Published on

Lottery Tutorial, Your Seventh Seahorse Solana Program

Authors

Welcome back. Let's write some more Seahorse! This tutorial follows on from the previous tic-tac-toe example example. Start here if you're an absolute beginner. Today we're going to build a lottery program. Multiple users can enter the lottery, but only one of them will be chosen as the winner who will receive a prize pool of tokens. In this tutorial we'll practice creating token mints and token accounts using the SPL-token program in Seahorse. This tutorial will assume you're using Solana Playground, but can also be done locally if you chose.

As usual we'll start with the accounts that our program will create. Firstly the administrator of the lottery LotteryAdmin and then for the user entering the lottery User. The lottery admin account will store the admin_address which is the address of the owner of the admin account. The winner_address which is initially empty but later will store the address of the lottery winner. The user_count field which indicates the current number of users who have entered the lottery. Finally the winning_user which in a true application would be chosen randomly but for the sake of simplification and focus with this tutorial we will pick a number ourselves. The User account is very simple, it holds 2 fields, user_address and balance.

lottery.py
class LotteryAdmin(Account):
  admin_address: Pubkey
  winner_address: Pubkey
  user_count: u64
  winning_user: u64 # chosen by you 

class User(Account):
  user_address: Pubkey
  balance: u64

The first instruction will be to initialize a LotteryAdmin account. This instruction will take 3 arguments, owner which is the public key of the signer, the one creating the admin account. An empty instance of the LotteryAdmin and finally winner_random_num which should be an integer chosen freely by you. To make testing easy I suggest using 2, which dictates that the second account to enter the lottery will be the winner.

We then initialize the empty instance of the lottery admin with the seeds of 'admin' and the owner's public key. We set the admin_address to the owner's public key and the user_count to zero. Our random number is assigned to the winnig_user field, with the winner_address left unassigned as we don't know who the winner will be yet.

lottery.py
@instruction
def init_admin(owner: Signer, admin: Empty[LotteryAdmin], winner_random_num: u64):
  
  admin = admin.init(
    payer = owner, 
    seeds = ['admin',owner]
  )

  admin.admin_address = owner.key()
  admin.user_count = 0
  admin.winning_user = winner_random_num
  # winner_address not set

Token Mints & Token Accounts

Now we need to create a token for our prize. We could do this on the command line using the Solanan CLI, however here we're going to go through the steps of token creation and issuance through a program. The first step when creating a token is to create a token mint account. This account acts as a central repository that stores all the key metadata for the token.

Our instruction init_token_mint takes 3 arguments, the Signer, an empty instance of a TokenMint account and our newly created LotteryAdmin. This is our first time using TokenMint in our Seahorse programs. Seahorse provides built-in support for the SPL token program which you can read more about in the docs here.

Within the instruction firstly we assert that the signer is the owner of the lottery admin account. Then we initialize the token mint account with the seeds of 'token mint' and the signer's private key. Decimals refers to the number of decimals for the new token, here we've set this to 4, so 1 token would be displayed as 1.0000, and the smallest possible amount of the new token would be 0.0001. For context, SOL has 9 decimals, commonly used stablecoin USDC has 6 decimals, for simplicity's sake we've gone with 4. Finally we set an authority for the token mint account to the signer.

lottery.py
@instruction
def init_token_mint(signer: Signer, new_token_mint: Empty[TokenMint], admin: LotteryAdmin):
  
  assert(signer.key() == admin.admin_address), "Only Admin authorized to call this function"  
  
  new_token_mint.init(
    payer = signer,
    seeds = ['token-mint', signer],
    decimals = 4,
    authority = signer
  )

After creating the token mint, our next step will be to create a token account for the lottery admin. This token account after it's been initialized will store all the prize tokens. The instruction to initialize the token account takes 3 arguments, the Signer, an empty instance of a TokenAccount and the token mint account that we created in our previous instruction. To initialize the token account within the instruction we need to provide 4 fields, the payer, the seeds, the mint and the authority.

lottery.py
@instruction
def init_admin_token_account(signer: Signer, admin_token_acc: Empty[TokenAccount], mint: TokenMint):
    
  admin_token_acc.init(
    payer = signer, 
    seeds = ['admin-token-acc', signer], 
    mint = mint, 
    authority = signer
  )

Now that the lottery admin has a token account we are ready to mint them some tokens. We can easily do this with the following instruction providing the relevant accounts and using the mint.mint() method. This method takes 3 arguments, the authority which is the signer, to which is the token account that will receive the tokens and amount, which is the amount of tokens to be minted. In this case 10k tokens.

lottery.py
@instruction
def mint_tokens_to_admin(signer: Signer, mint: TokenMint, recipient: TokenAccount, admin:LotteryAdmin):

  assert(signer.key() == admin.admin_address), "Only Admin authorized to call this function"
  
  mint.mint(
    authority = signer,
    to = recipient,
    amount = 10_000
  )

User accounts

Okay, so now that our lottery admin is ready, they have a token account filled with prize tokens ready to be claimed, it's time for us to shift focus to the user. Initializing a user account is similar to initializing the lottery admin. The instruction accepts an empty instance of the User account and we initialize this account with some seeds.

lottery.py
@instruction
def init_user(owner: Signer, user: Empty[User]):
  
  user = user.init(
    payer=owner,
    seeds = ['user',owner]
  )
  
  user.user_address = owner.key()

Now our user is ready to enter the lottery. They can do this through the user_enters_lottery instruction. This instruction does 2 things, firstly it initializes a token account for the user, secondly it increments the user_count field on the lottery admin account and runs a check to see if the current count matches the winning_user field. If it does match, then the winner_address field is set to the user's address. As we set our winning user field to 2 previously this means the second user to enter the lottery will win. If it was set to 4 then the fourth user to enter would win. Each user can only enter the lottery once with a single token account because trying to initialize a token account for a second time with seeds that have already been used will result in an error message.

lottery.py
@instruction
def user_enters_lottery(signer: Signer, user: User, admin: LotteryAdmin, user_token: Empty[TokenAccount], mint: TokenMint):  
  
  user_token.init(
    payer = signer, 
    seeds = ['Token', signer],
    mint = mint, 
    authority = signer
  )   

  admin.user_count += 1
  
  if(admin.user_count == admin.winning_user):
    admin.winner_address = user.user_address

Our final instruction facilitates everyone's favorite part of a lottery -- collecting our winnings! This is done through our check_winner instruction which can be called by the admin authority to disperse the winnings to the winner. If the provided user address matches the winner address then 9k tokens are transferred to the user's token account. So effectively 90% of the tokens go to the winner and 10% are kept by the program. This transaction is facilitated by the transfer method which is used to send tokens between token accounts.

lottery.py
@instruction
def check_winner(signer: Signer, user: User, admin: LotteryAdmin, user_token: TokenAccount, admin_token: TokenAccount):
  
  assert(signer.key() == admin.admin_address), "Only Admin authorized to call this function"
                   
  if(user.user_address == admin.winner_address):
    
    print("Congrats you've won the lottery!")

    # 90% distributed to winner, 10% retained by admin
    admin_token.transfer( 
        authority = signer, 
        to = user_token, 
        amount = 9_000)

    user.balance += 9_000
  
  else:
    print("Sorry, you did not win. Try again next time.")

Final code

Our full program should now look something like this below.

lottery.py

from seahorse.prelude import *

declare_id('')


class LotteryAdmin(Account):
  admin_address: Pubkey
  winner_address: Pubkey
  user_count: u64
  winning_user: u64 # chosen by you 

class User(Account):
  user_address: Pubkey
  balance: u64
  

# Initialise the LotteryAdmin account
@instruction
def init_admin(owner: Signer, admin: Empty[LotteryAdmin], winner_random_num: u64):
  
  admin = admin.init(
    payer = owner, 
    seeds = ['admin',owner]
  )

  admin.admin_address = owner.key()
  admin.user_count = 0
  admin.winning_user = winner_random_num
  # winner_address not set


# Initialise the token mint for prize token
@instruction
def init_token_mint(signer: Signer, new_token_mint: Empty[TokenMint], admin: LotteryAdmin):
  
  assert(signer.key() == admin.admin_address), "Only Admin authorized to call this function"  
  
  new_token_mint.init(
    payer = signer,
    seeds = ['token-mint', signer],
    decimals = 4,
    authority = signer
  )


# Initialize LotteryAdmin's prize token account
@instruction
def init_admin_token_account(signer: Signer, admin_token_acc: Empty[TokenAccount], mint: TokenMint):
    
  admin_token_acc.init(
    payer = signer, 
    seeds = ['admin-token-acc', signer], 
    mint = mint, 
    authority = signer
  )


# Mint prize tokens to Admin's account
@instruction
def mint_tokens_to_admin(signer: Signer, mint: TokenMint, recipient: TokenAccount, admin:LotteryAdmin):

  assert(signer.key() == admin.admin_address), "Only Admin authorized to call this function"
  
  mint.mint(
    authority = signer,
    to = recipient,
    amount = 10_000
  )


# Initialize User's account 
@instruction
def init_user(owner: Signer, user: Empty[User]):
  
  user = user.init(
    payer=owner,
    seeds = ['user',owner]
  )
  
  user.user_address = owner.key()


# User enters lottery by generating a prize token account
@instruction
def user_enters_lottery(signer: Signer, user: User, admin: LotteryAdmin, user_token: Empty[TokenAccount], mint: TokenMint):  
  
  user_token.init(
    payer = signer, 
    seeds = ['Token', signer],
    mint = mint, 
    authority = signer
  )   

  admin.user_count += 1
  
  if(admin.user_count == admin.winning_user):
    admin.winner_address = user.user_address
    

# Check to see if user has won
@instruction
def check_winner(signer: Signer, user: User, admin: LotteryAdmin, user_token: TokenAccount, admin_token: TokenAccount):
  
  assert(signer.key() == admin.admin_address), "Only Admin authorized to call this function"
                   
  if(user.user_address == admin.winner_address):
    
    print("Congrats you've won the lottery!")

    # 90% distributed to winner, 10% retained by admin
    admin_token.transfer( 
        authority = signer, 
        to = user_token, 
        amount = 9_000)

    user.balance += 9_000
  
  else:
    print("Sorry, you did not win. Try again next time.")

Testing

Build and deploy the program the same way as we've done for all of our previous Solana Playground programs. Once the program is on dev-net you can begin testing using the test tab. Execute the instructions in order. I suggest storing the new account addresses into a notepad text file as they are generated, then copy and paste them over as needed. It will speed up the testing process and avoid confusion.

LotteryAdmin: 
Token mint:
LotteryAdmin token account: 
User account:
User token account:

We can also do some automated testing of the program with a script. Copy and paste the code below into the seahorse.test.ts file within the tests folder found on the explorer tab. I recommend rebuilding and redeploying the program with admin.winning_user = 1 before running these tests because they are all run from a single user account.

seahorse.test.ts

// No imports needed: web3, anchor, pg and more are globally available

describe("Tests", () => {
  // const newAccountKp = new web3.Keypair();
  const tokenProgram = new web3.PublicKey(
    "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
  );

  const winner_random_num = 1;

  const [lottoAdmin] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("admin"), pg.wallet.publicKey.toBuffer()],
    pg.program.programId
  );

  const [tokenMint] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("token-mint"), pg.wallet.publicKey.toBuffer()],
    pg.program.programId
  );

  const [adminTokenAccount] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("admin-token-acc"), pg.wallet.publicKey.toBuffer()],
    pg.program.programId
  );

  const [user] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("user"), pg.wallet.publicKey.toBuffer()],
    pg.program.programId
  );

  const [userTokenAccount] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("Token"), pg.wallet.publicKey.toBuffer()],
    pg.program.programId
  );

  it("initAdmin", async () => {
    const tx = await pg.program.methods
      .initAdmin(new anchor.BN(winner_random_num))
      .accounts({
        owner: pg.wallet.publicKey,
        admin: lottoAdmin,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(`New admin created at ${lottoAdmin.toString()}`);
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });

  it("initTokenMint", async () => {
    const tx = await pg.program.methods
      .initTokenMint()
      .accounts({
        signer: pg.wallet.publicKey,
        newTokenMint: tokenMint,
        admin: lottoAdmin,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(`New token mint account created at ${tokenMint.toString()}`);
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });

  it("initAdminTokenAccount", async () => {
    let tx = await pg.program.methods
      .initAdminTokenAccount()
      .accounts({
        signer: pg.wallet.publicKey,
        adminTokenAcc: adminTokenAccount,
        mint: tokenMint,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(
      `Admin token account created at ${adminTokenAccount.toString()}`
    );
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });

  it("mintTokensToAdmin", async () => {
    let tx = await pg.program.methods
      .mintTokensToAdmin()
      .accounts({
        signer: pg.wallet.publicKey,
        mint: tokenMint,
        recipient: adminTokenAccount,
        admin: lottoAdmin,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(`Tokens minted to Admin at ${adminTokenAccount.toString()}`);
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });

  it("initUser", async () => {
    let tx = await pg.program.methods
      .initUser()
      .accounts({
        owner: pg.wallet.publicKey,
        user: user,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(`User account initalized at ${user.toString()}`);
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });

  it("userEntersLottery", async () => {
    let tx = await pg.program.methods
      .userEntersLottery()
      .accounts({
        signer: pg.wallet.publicKey,
        user: user,
        admin: lottoAdmin,
        userToken: userTokenAccount,
        mint: tokenMint,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(
      `User token account initalized at ${userTokenAccount.toString()}`
    );
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });

  it("checkWinner", async () => {
    let tx = await pg.program.methods
      .checkWinner()
      .accounts({
        signer: pg.wallet.publicKey,
        user: user,
        admin: lottoAdmin,
        userToken: userTokenAccount,
        adminToken: adminTokenAccount,
      })
      .rpc();

    await pg.connection.confirmTransaction(tx);
    console.log(`winner transaction ${tx}`);
    console.log(`Use 'solana confirm -v ${tx}' to see the logs`);
  });
});

Okay, that's it for this tutorial, our first to use the token program together with Seahorse. Congratulations if you followed along. We're making progress.