Build an NFT Stake Program Using Anchor in Solana. (With rewards)

Siddhant KhareSiddhant Khare
11 min read

Welcome back, reader! Today we are going to build an NFT staking program in Anchor which will also provide rewards to the user. There are quite a few instructions here, so it's going to be a long article.

Key concepts

Before we move on to the meaty stuff, let's first understand some basic concepts that we need for this project.

1. PDA ( Program Derived Address )

What Is A Program Derived Address (PDA)?, 54% OFF

Now if you're familiar with some WEB3 concepts, you'd probably know that we need Accounts to store anything on the Blockchain. Accounts are keypair entities that are used to store data, tokens, and sign transactions. They are a key pair of
a. Secret Key
b. Public Key
and someone owns the keypair as one account. Now similarly, PDAs are Accounts that are owned by the program, but the catch here is that they don't have a secret key.

This is because they lie outside the ED25519 Curve(The curve containing Accounts with secret keys)

What is a Program Derived Address (PDA)?

They are deterministic, meaning one can derive them by using two things:
a. Seeds (These can be a string like: "token-authority" or even a number which is then converted to a Buffer of bytes)
b. Bump (A number ranging from 255 to 0, Anchor will use the canonical bump if not specified)

to learn more about PDAs, read the official docs.

2. CPI ( Cross Program Invocation )

Cross Program Invocation

Now it might be the case that we may require an instruction that is currently unavailable in our program. With the help of a CPI call, we can access that instruction if it exists in the program that we are making a CPI call to.

Similiar to how we create API calls, we define the accounts that we are passing and the program account info from where we are calling the instruction.

to learn more about CPIs, read the official docs.

Let's start building the program

Before we move on to the program, Here are the tools that we will need to build this program.
- solana-program: 1.18.17
- anchor version: 0.29.0
- solana-cli: 1.18.17

To install Rust: https://www.rust-lang.org/tools/install

To install Anchor: https://www.anchor-lang.com/docs/installation

To install Solana: https://docs.solanalabs.com/cli/install

After you make sure you have the required tools with the appropriate versions, Open your code editor.

1. Project Set-up

In your current directory create an anchor project by using the command -

anchor init nft_stake_program

In the cargo.toml file inside the /programs directory, add these dependencies -

[dependencies]
anchor-lang = { version = "0.29.0", features = ["init-if-needed"] }
anchor-spl = "0.29.0"
solana-program = "1.18.17"

Open your terminal and type this to start a local Solana blockchain -

solana-test-validator

Let it run and open a new terminal window and in there type this. We do this to create a new key pair that we will use to sign our transactions.

solana-keygen new -o keypair.json

It will ask you to pass a phrase and type it twice to confirm the wallet creation.
Next up, configure this wallet as your current local Solana wallet. To do this, type -

solana config set --keypair <PATH_TO_NEW_KEYPAIR.json>
// For eg: solana config set --keypair "/home/Desktop/nft_stake_program/keypair.json"

Next up, fund it with SOL, and don't worry since it's a local Solana blockchain, you can airdrop 100 or even more SOL at one request.

solana airdrop 100

Then inside the root project folder, initialize an npm repository

npm init

In the package.json add these dependencies and perform npm install

"dependencies": {
        "@coral-xyz/anchor": "^0.29.0",
        "@solana/spl-token": "^0.3.11",
        "@solana/web3.js": "^1.94.0"
    },
    "devDependencies": {
        "chai": "^4.3.4",
        "mocha": "^9.0.3",
        "ts-mocha": "^10.0.0",
        "ts-node": "10.9.2",
        "@types/bn.js": "^5.1.0",
        "@types/chai": "^4.3.0",
        "@types/mocha": "^9.0.0",
        "typescript": "^4.3.5",
        "prettier": "^2.6.2"
    }

2. Time to write some Accounts.

Inside the programs/nft_stake_program folder, you'll find the lib.rs file where we are going to write our program. Add all these modules from the anchor_spl and solana_program crates.

use anchor_lang::prelude::*;
use anchor_spl::{
    associated_token::AssociatedToken,
    token::{
        Mint, 
        Token, 
        MintTo, 
        mint_to,
        TokenAccount, 
        SetAuthority, 
        set_authority,
        spl_token::instruction::AuthorityType,
        FreezeAccount, 
        freeze_account, 
        ThawAccount, 
        thaw_account,
        Transfer,
        transfer}
    };
use solana_program::clock::Clock;
use std::cmp::min;

Now let's define the Account Instructions that we will need. I've implemented all the instructions and creation of accounts as separately as possible, to get the exact idea of what is happening.

#[derive(Accounts)]
#[instruction(decimals: u8)]
pub struct CreateFTMint<'info> {
    #[account(
        init, 
        mint::authority = token_authority,
        mint::decimals = decimals,
        seeds = ["token-mint".as_bytes()],
        bump,
        payer = payer)]
    pub token_mint: Account<'info, Mint>,

    #[account(seeds = ["token-authority".as_bytes()], bump)]
    /// CHECK: This is the mint_authority
    pub token_authority: AccountInfo<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}

This struct represents the instruction to create a fungible token mint, for the tokens that we are going to use
token_mint: Represents the actual mint of the fungible tokens.
token_authority: Represents the PDA in control of the token mint.

Similarly, like the above instruction as boiler-plate to create another struct for the NFT mint too - I know you can do it! make sure to include an nft_mint_authority which is in charge of the nft-mint.

Now since you have both FTMint and NFTMint ready, let's create a vault to store the reward tokens.

#[derive(Accounts)]
pub struct CreateVault<'info> {
    #[account(mut, seeds = ["token-mint".as_bytes()], bump)]
    pub token_mint: Account<'info, Mint>,
    #[account(seeds = ["vault-authority".as_bytes()], bump)]
    /// CHECK: This is the vault authority
    pub vault_authority: AccountInfo<'info>,
    #[account(
        init,
        token:: mint = token_mint,
        token:: authority = vault_authority,
        payer = payer
    )]    
    pub vault_token_account: Account<'info, TokenAccount>,
    #[account(mut)]
    pub payer: Signer<'info>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}

Keep in mind this, /// CHECK: This is the vault authority is not supposed to be a comment. It is one of the most important lines in our code. Since we have used AccountInfo<'info> to define our authorities, we have to add manual checks to ensure the program doesn't panic and think it's safe.

Similarly, just like the above instruction, create two token accounts, both for the user but one should be to store the FT tokens and one for the NFT token.

Now write the instruction to airdrop the Ft tokens to the vault_token_account including the CPI context that we will pass while creating a cpi_call to the mint_to instruction from the token module in the anchor_spl crate.

#[derive(Accounts)]
pub struct Airdrop<'info> {
    #[account(mut, seeds = ["token-mint".as_bytes()], bump)]
    pub token_mint: Account<'info, Mint>,

    #[account(mut, seeds = ["token-authority".as_bytes()], bump)]
    /// CHECK: This is the mint authority
    pub token_authority: AccountInfo<'info>,

    #[account(mut)]
    pub payer: Signer<'info>,

    #[account(mut)]    
    pub vault_token_account: Account<'info, TokenAccount>,

    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}

impl <'info> Airdrop <'info> {
    pub fn mint_to_ctx(&self) -> CpiContext<'_, '_, '_, 'info, MintTo<'info>> {
        let cpi_program = self.token_program.to_account_info();
        let cpi_accounts = MintTo {
            mint: self.token_mint.to_account_info(),
            to: self.vault_token_account.to_account_info(),
            authority: self.token_authority.to_account_info()
        };

        CpiContext::new(cpi_program, cpi_accounts)
    }
}

Similar to above, using it as a boilerplate, create an instruction to mint an NFT to users_nft_token_account that you created previously.

Next up, we have to change the NFT mint's authority to None, to make sure it won't be minted again,

#[derive(Accounts)]
pub struct ChangeAuth <'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
    /// CHECK: This is the nft-mint authority
    #[account(mut, seeds = ["nfttoken-authority".as_bytes()], bump)]
    pub nft_mint_authority: AccountInfo<'info>,
    #[account(mut, seeds = ["nft-mint".as_bytes()], bump)]
    pub nft_mint: Account<'info, Mint>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}

impl <'info> ChangeAuth <'info> {
    pub fn change_nftauth(&self) -> CpiContext<'_, '_, '_, 'info, SetAuthority<'info>> {
        let cpi_program = self.token_program.to_account_info();
        let cpi_accounts = SetAuthority {
            account_or_mint: self.nft_mint.to_account_info(),
            current_authority: self.nft_mint_authority.to_account_info()
        };
        CpiContext::new(cpi_program, cpi_accounts)
    }
}

Now we change the freezeAuthority of the NFT mint instead. We are going to assign the freezeAuthority to the vault_authority PDA that we derived while creating the vault. Also, create an NFTinfo account in the process that stores the slot that is of u64.

#[derive(Accounts)]

pub struct StakeNFT<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(mut, seeds = ["nft-mint".as_bytes()], bump)]
    pub nft_mint: Account<'info, Mint>,
    #[account(mut, seeds = ["nfttoken-authority".as_bytes()], bump)]
    /// CHECK: This is the mint authority
    pub nft_mint_authority: AccountInfo<'info>,
    #[account(mut, seeds = ["vault-authority".as_bytes()], bump)]
    /// CHECK: This is the vault authority
    pub vault_authority: AccountInfo<'info>,
    #[account(
        init_if_needed,
        seeds = ["nft-info".as_bytes()],
        bump,
        payer = user,
        space = 8 + 8
    )]
    pub nft_info: Account<'info, NftStakeInfo>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}  

impl <'info> StakeNFT <'info> {
    pub fn change_nftfreezeauth(&self) -> CpiContext<'_, '_, '_, 'info, SetAuthority<'info>> {
        let cpi_program = self.token_program.to_account_info();
        let cpi_accounts = SetAuthority {
            account_or_mint: self.nft_mint.to_account_info(),
            current_authority: self.nft_mint_authority.to_account_info()
        };
        CpiContext::new(cpi_program, cpi_accounts)
    }
}

#[account]
pub struct NftStakeInfo {
    pub slot: u64
}

As soon as the freezeAuthority is implemented, it means the user is now staking their NFT, so we freeze the user's NFT token account by passing in the vault_authority PDA as the freezeAuthority for the NFT Mint.

#[derive(Accounts)]
pub struct FreezeUser<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(mut)]
    pub user_nft_account: Account<'info, TokenAccount>,
    #[account(mut, seeds = ["nft-mint".as_bytes()], bump)]
    pub nft_mint: Account<'info, Mint>,
    #[account(mut, seeds = ["vault-authority".as_bytes()], bump)]
    /// CHECK: This is the vault authority
    pub vault_authority: AccountInfo<'info>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}
impl <'info> FreezeUser <'info> {
    pub fn freeze_user(&self) -> CpiContext<'_, '_, '_, 'info, FreezeAccount<'info>> {
        let cpi_program = self.token_program.to_account_info();
        let cpi_accounts = FreezeAccount {
            account: self.user_nft_account.to_account_info(),
            mint: self.nft_mint.to_account_info(),
            authority: self.vault_authority.to_account_info()
        };
        CpiContext::new(cpi_program, cpi_accounts)
    }
}

That's it, the Staking process is done. Now if the user wants to unstake, we simply have to do 2 things :

1. Thaw user's NFT token account using the ThawAccount instruction from the token module
2. Change the freeze_authority of the NFT mint back to nft_mint_authority PDA.
The process is almost the same, So I'll leave the rest to you!

One final Account struct that we need for the final Instruction is to Disburse rewards.

#[derive(Accounts)]
pub struct DisburseRewards<'info> {
    #[account(
        mut,
        seeds = ["nft-info".as_bytes()],
        bump
    )]
    pub nft_info: Account<'info, NftStakeInfo>,
    #[account(mut)]    
    pub vault_token_account: Account<'info, TokenAccount>,
    #[account(seeds = ["vault-authority".as_bytes()], bump)]
    /// CHECK: This is the vault authority
    pub vault_authority: AccountInfo<'info>,
    #[account(mut)]    
    pub user_token_account: Account<'info, TokenAccount>,
    pub payer: Signer<'info>,
    pub rent: Sysvar<'info, Rent>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>
}
impl <'info> DisburseRewards <'info> {
    pub fn disburse_rewards(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        let cpi_program = self.token_program.to_account_info();
        let cpi_accounts = Transfer {
            authority: self.vault_authority.to_account_info(),
            from: self.vault_token_account.to_account_info(),
            to: self.user_token_account.to_account_info()
        };
        CpiContext::new(cpi_program, cpi_accounts)
    }
}

3. Implementing instructions

The initial instructions involving

  1. Create FT Mint

  2. Create NFT Mint

  3. Create a vault token account

  4. Create the user's token account

  5. Create the user's NFT token account

Have 0 additional data that we have to pass apart from the decimals required in the starting FT mint instruction

 pub fn create_ft_mint(ctx: Context<CreateFTMint>, decimals: u8) -> Result<()> {
        msg!("FT Mint created successfully!");
        msg!("FT mint address: {}", ctx.accounts.token_mint.key());
        msg!("The decimals entered: {}", decimals);
        Ok(())
    }

So I'd leave the remaining 4 to you.

Now for airdropping(minting) new FT tokens, we have to type this instruction -

pub fn airdrop(ctx: Context<Airdrop>, amount: u64) -> Result<()> {
        let token_bump = ctx.bumps.token_authority;
        let token_seeds = &["token-authority".as_bytes(), &[token_bump]];
        let signer = &[&token_seeds[..]];

        msg!("Airdropping {} tokens...", amount);
        let mint_to_ctx = ctx.accounts.mint_to_ctx().with_signer(signer);
        let _ = mint_to(mint_to_ctx, amount);

        msg!("Airdrop Complete!");
        Ok(())
    }

The instruction to Mint the NFT to the user's NFT token account would be exactly the same except the amount should be 1.

Next, we change the mint authority of the NFT to none.

pub fn change_auth(ctx: Context<ChangeAuth>) -> Result<()> {

        let token_bump = ctx.bumps.nft_mint_authority;
        let token_seeds = &["nfttoken-authority".as_bytes(), &[token_bump]];
        let signer = &[&token_seeds[..]];
        msg!("Changing Authority of NFT Mint..");
        let change_nftauth = ctx.accounts.change_nftauth().with_signer(signer);
        let _ = set_authority(change_nftauth, AuthorityType::MintTokens, None)?;

        msg!("Authority changed sucessfully!");
        Ok(())
    }

with the AuthorityType:: we imported from the anchor_spl crate it would be easier for you to add the freeze authority. But there's something you need to be aware of here. You have to get the Clock() to time the slot we are staking the NFT.

pub fn stake(ctx: Context<StakeNFT>) -> Result<()> {

        // Here the cpi instruction will be set up like above

        let _ = set_authority(change_nftfreezeauth, AuthorityType::FreezeAccount, Some(ctx.accounts.vault_authority.key()));

        let clock = Clock::get()?;
        ctx.accounts.nft_info.slot = clock.slot;

        msg!("Authority changed sucessfully!");
        Ok(())
    }

Now using the above instructions, Analyze the pattern for Setting up the Context, Making the CPI call, and creating instructions for

  1. freeze_user(): Freeze the user's NFT token account

  2. thaw_user(): Thaw the frozen user's NFT token account

  3. change_auth_back(): Change the freeze authority from vault_authority PDA to nft_mint_authority PDA.

Finally, I'm going to let you get as creative as possible for the final instruction disburse_rewards().

It is completely up to you, to use the algorithm you want for calculating rewards. But I'll help you set it up.

pub fn disburse_rewards(ctx: Context<DisburseRewards>) -> Result<()> {

        // CPI Instruction set-up here

        // This is how you get the slot details
        let clock = Clock::get()?;
        let slots = clock.slot - ctx.accounts.nft_info.slot;

        // Get creative in calculating the reward

        // Finally transfer the FT tokens from the Vault to User
        let _ = transfer(give_rewards, reward);
        msg!("Rewards Disbursed!..");
        Ok(())

    }

That is all the code we need for the Smart Contract. You can create test cases for the program using ts-mocha and the auto-generated typescript test case file you will find in the /tests folder in the project.

4. Running the Program

Make sure the local Solana blockchain is still running in the terminal, If a lot of slots have been passed, I suggest you restart the validator by passing in the argument
--reset, while you run it again.

Let's build the program using anchor build and then deploy it.

anchor build
anchor deploy

Now since you are testing the contract locally while passing your anchor test command, you also have to pass this argument -

anchor test --skip-local-validator

Done. The program will be up and running if you implement all the test cases properly.

Do upvote this guide if you learned something new and make sure to follow the blog.

Happy Coding!

0
Subscribe to my newsletter

Read articles from Siddhant Khare directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Siddhant Khare
Siddhant Khare

A C.S. grad doing CS stuff!