Try   HackMD
tags: solana

Create Locker program in Solana blockchain by Anchor

In this article, I am trying to dig into anchor feature more deeply by adpoting a simple and widely used concept in blockchain field called Locker (or vesting)

It will cover:

  1. Two types of PDA in Anchor
    • Derivation PDA
    • Creation PDA
  2. Access control
  3. CPI (Anchor Cross Program Invokation wrapper)
  4. Anchor customizable Error

Prerequisites

# update anchor
cargo install --git https://github.com/project-serum/anchor --tag v0.24.2 anchor-cli --locked
anchor-cli 0.24.2

# Check rust version
macbookpro4eric@Erics-MacBook-Pro ~ % rustc --version                 
rustc 1.61.0 (fe5b13d68 2022-05-18)

Set up project

  1. Init anchor project
anchor init project64_anchor_solana_locker
  1. Create new worksapce for locker-manager
anchor new locker-manager

Program overview

Source: Solmeet#8

Program functionality

The locker program served with 3 types of key functions

  1. One-off function
  2. Admin only function
  3. User operation function

One-off function: Init Locker Program state

This insruction will only used once since it is used to create a top level program related PDA state.

Accounts to pass in

  1. Pass in authority account
  2. Pass in a not yet initalized keypair for anchor to create a PDA as locker-manager (so-call creation PDA type)
  3. Passing in system program which will be called by the #2 to initalize PDA
#[derive(Accounts)]
#[instruction(locker_manager_nonce: u64)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,
    #[account(
        init,
        seeds = [b"locker-manager".as_ref(), &locker_manager_nonce.to_le_bytes()],
        bump,
        payer = authority,
        space = 8 + 32
    )]
    pub locker_manager_info: Box<Account<'info, LockerManagerInfo>>,
    pub system_program: Program<'info, System>,
}

Creation PDA's constraint macro #[account(constraints)]

  1. init,
  2. seeds = [b"locker-manager".as_ref(), &locker_manager_nonce.to_le_bytes()],
  3. bump,
  4. payer = authority,
  5. space = 8 + 32

Anchor introduce the handy feature to let us init a account inside the program so that we do not need to do it in front end. To do so, we must use "init" constraint to let anchor know we are going to init a PDA.

#2 ~ #4 are stright forward, those are the base information for any PDA.

As been known, any memory allocation of rust struct needs to be defined in compile time. Meaning we need to define the max size of the account info. it is why we need to define the #5 space.
In this case, the first 8 bytes is the descrimiator of this instruction.
The following 32 byte is the LockerManagerInfo state which we defined as below:

#[account]
pub struct LockerManagerInfo {
    pub authority: Pubkey,
}

Then in the program (processor) section, what we need to do is to set set the signer as authority of created Locker manager PDA


    pub fn initialize(ctx: Context<Initialize>, _locker_manager_nonce: u64) -> Result<()> {
        ctx.accounts.locker_manager_info.authority = *ctx.accounts.authority.key;
        Ok(())
    }

In this section, we create a new Locker Manager PDA and set its authoriy as signer(payer).

What if we want to change the authority to others. In this case, we need to implement a new instruction called Auth.

Admin function: Reset authority of Locker manager

Only the current admin can set the new admin of the locker manager

Accounts to pass in

#[derive(Accounts)]
#[instruction(locker_manager_nonce: u64)]
pub struct Auth<'info> {
    pub authority: Signer<'info>,
    #[account(
        mut,
        seeds = [b"locker-manager".as_ref(), &locker_manager_nonce.to_le_bytes()],
        bump
    )]
    pub locker_manager_info: Box<Account<'info, LockerManagerInfo>>,
}

Derivation PDA's constraint macro #[account(constraints)]
init,
2. seeds = [b"locker-manager".as_ref(), &locker_manager_nonce.to_le_bytes()],
3. bump,
4. payer = authority,
5. space = 8 + 32
6. mut

Unlike PDA creation, this PDA does not have any storage so that we do not need to initiate it with space (neither pay rent so that payer is not needed.)

Comparsion between creation and derivation

Source: Solmeet#8

Usage of creation
The PDA needs to store data and it needs to be initiated by the program
Usage of derivation

  1. The PDA DOES NOT need to store data so that it neithor need to be inited by the program
  2. The PDA needs to store data but it has already been inited before. (This is the case in this instruction)

User operation function #1: vesting money in locker

User can vest their fund in the locker. In this case, the progrm will create a new locker for that user

Accounts to be passed in

#[derive(Accounts)]
pub struct CreateLocker<'info> {
    #[account(zero)]
    pub locker: Box<Account<'info, Locker>>,
    #[account(mut)]
    pub vault: Account<'info, TokenAccount>,
    /// CHECK: This is not dangerous because we don't read or write from this account
    #[account(mut)]
    pub depositor: AccountInfo<'info>,
    pub depositor_authority: Signer<'info>,
    // Misc.
    pub token_program: Program<'info, Token>,
    pub rent: Sysvar<'info, Rent>,
    pub clock: Sysvar<'info, Clock>,
}
impl<'info> CreateLocker<'info> {
    pub fn transfer_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        CpiContext::new(
            self.token_program.to_account_info(), 
            Transfer {
                from: self.depositor.clone(),
                to: self.vault.to_account_info(),
                authority: self.depositor_authority.to_account_info()
            }
        )
    }
}

Zero constraint
A new keypair will be passed into this transaction as Locker account. This program will then white all the locker info into the keypair account inside the processer. As it is the very first time the keypair is passed in, the discriminator has not been added into the info. To let anchor not to check the discrimnation, we need to pass in the constraint "zero".

pub fn create_locker(
        ctx: Context<CreateLocker>,
        beneficiary: Pubkey,
        deposit_amount: u64,
        nonce: u8,
        start_ts: i64,
        end_ts: i64,
        period_count: u64,
        reward_kepper: Option<RewardKeeper>,
    ) -> Result<()> {
        let locker = &mut ctx.accounts.locker;
        locker.beneficiary = beneficiary;
        locker.mint = ctx.accounts.vault.mint;
        locker.vault = *ctx.accounts.vault.to_account_info().key;
        locker.period_count = period_count;
        locker.start_balance = deposit_amount;
        locker.end_ts = end_ts;
        locker.start_ts = start_ts;
        locker.created_ts = ctx.accounts.clock.unix_timestamp;
        locker.current_balance = deposit_amount;
        locker.whitelist_owned = 0;
        locker.grantor = *ctx.accounts.depositor_authority.key;
        locker.nonce = nonce;
        locker.reward_keeper = reward_kepper;

        transfer(ctx.accounts.transfer_ctx(), deposit_amount)?;

        Ok(())
    }

Implement test

Before making the last user operation function "withdraw", let's add the test typescript to check if everything working fine.

# Check the program id
anchor keys list

Copy and past the program id into

// program/locker-manager/src/lib.rs
...
declare_id!("<The program id>");
...
// ./Anchor.toml
...
locker_manager = "<The program id>"
...
// ./tests/locker-manager.ts
...
const LOCKER_MANAGER_PROGRAM_ID = new anchor.web3.PublicKey(
  "<The program id>"
);
...

Then we can start the test by using anchor test

import * as anchor from "@project-serum/anchor";
import NodeWallet from "@project-serum/anchor/dist/cjs/nodewallet";
import { IDL as lockerManagerIDL } from "../target/types/locker_manager";
import { createMintAndVault, createTokenAccountInstrs } from "./utils";
import {
  PublicKey,
  SystemProgram,
  Keypair,
  Commitment,
  Connection,
} from "@solana/web3.js";
import * as splToken from "@solana/spl-token";
import assert from "assert";

const LOCKER_MANAGER_PROGRAM_ID = new anchor.web3.PublicKey(
  "8n68CTRnohbAtZBrPQYE3gFAcB4ZgXi1zoydvriEF96P"
);

describe("Locker Manager", async () => {
  const commitment: Commitment = "confirmed";
  // const connection = new Connection("https://rpc-mainnet-fork.dappio.xyz", {
  //   commitment,
  //   wsEndpoint: "wss://rpc-mainnet-fork.dappio.xyz/ws",
  // });
  function sleepMillisecond(millisecond: number) {
    return new Promise((resolve) => setTimeout(resolve, millisecond));
  }
  const connection = new Connection("http://localhost:8899", {
    commitment,
    wsEndpoint: "ws://localhost:8900/",
  });
  const options = anchor.AnchorProvider.defaultOptions();
  const wallet = NodeWallet.local();
  const provider = new anchor.AnchorProvider(connection, wallet, options);

  anchor.setProvider(provider);
  const lockerManager = new anchor.Program(
    lockerManagerIDL,
    LOCKER_MANAGER_PROGRAM_ID,
    provider
  );

  // const WHITELIST_SIZE = 10;

  let lockerManagerInfoAddress = null as PublicKey;
  let _lockerManagerBump = null as number;
  const lockerManagerNonce = new anchor.BN(
    Math.floor(Math.random() * 100000000)
  );

  let mint = null;
  let god = null;

  it("Sets up initial test state", async () => {
    const [_mint, _god] = await createMintAndVault(
      provider as anchor.AnchorProvider,
      new anchor.BN(1000000)
    );
    mint = _mint;
    god = _god;
  });

  it("Is initialized!", async () => {
    [lockerManagerInfoAddress, _lockerManagerBump] =
      await anchor.web3.PublicKey.findProgramAddress(
        [
          Buffer.from(anchor.utils.bytes.utf8.encode("locker-manager")),
          lockerManagerNonce.toBuffer("le", 8),
        ],
        lockerManager.programId
      );

    await lockerManager.methods
      .initialize(lockerManagerNonce)
      .accounts({
        authority: provider.wallet.publicKey,
        lockerManagerInfo: lockerManagerInfoAddress,
        systemProgram: SystemProgram.programId,
      })
      .rpc();

    await sleepMillisecond(1000); // Need to wait 1 sec to let method finish? not sure why

    const lockerManagerInfo =
      await lockerManager.account.lockerManagerInfo.fetch(
        lockerManagerInfoAddress
      );

    assert.ok(lockerManagerInfo.authority.equals(provider.wallet.publicKey));
  });

  it("Sets a new authority", async () => {
    const newAuthority = Keypair.generate();
    await lockerManager.methods
      .setAuthority(lockerManagerNonce, newAuthority.publicKey)
      .accounts({
        authority: provider.wallet.publicKey,
        lockerManagerInfo: lockerManagerInfoAddress,
      })
      .rpc();

    await sleepMillisecond(1000); // Need to wait 1 sec to let method finish? not sure why

    let lockerManagerInfo = await lockerManager.account.lockerManagerInfo.fetch(
      lockerManagerInfoAddress
    );
    assert.ok(lockerManagerInfo.authority.equals(newAuthority.publicKey));
  });

  const locker = Keypair.generate();
  let lockerAccount = null;
  let lockerVaultAuthority = null as PublicKey;

  it("Creates a locker account", async () => {
    const slot = await connection.getSlot();
    const blocktime = await connection.getBlockTime(slot);
    const startTs = new anchor.BN(blocktime);
    const endTs = new anchor.BN(startTs.toNumber() + 5);
    const periodCount = new anchor.BN(2);
    const beneficiary = provider.wallet.publicKey;
    const depositAmount = new anchor.BN(100);

    const vault = Keypair.generate();
    let [_lockerVaultAuthority, lockerVaultNonce] =
      await anchor.web3.PublicKey.findProgramAddress(
        [locker.publicKey.toBuffer()],
        lockerManager.programId
      );
    lockerVaultAuthority = _lockerVaultAuthority;

    const sig = await lockerManager.methods
      .createLocker(
        beneficiary,
        depositAmount,
        lockerVaultNonce,
        startTs,
        endTs,
        periodCount
        // null // Lock reward keeper is None.
      )
      .accounts({
        locker: locker.publicKey,
        vault: vault.publicKey,
        depositor: god,
        depositorAuthority: provider.wallet.publicKey,
        tokenProgram: splToken.TOKEN_PROGRAM_ID,
        rent: anchor.web3.SYSVAR_RENT_PUBKEY,
        clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
      })
      .signers([locker, vault])
      .preInstructions([
        await lockerManager.account.locker.createInstruction(locker),
        ...(await createTokenAccountInstrs(
          provider,
          vault.publicKey,
          mint,
          lockerVaultAuthority
        )),
      ])
      .rpc();
    await sleepMillisecond(1000);
    lockerAccount = await lockerManager.account.locker.fetch(locker.publicKey);

    assert.ok(lockerAccount.beneficiary.equals(provider.wallet.publicKey));
    assert.ok(lockerAccount.mint.equals(mint));
    assert.ok(lockerAccount.grantor.equals(provider.wallet.publicKey));
    assert.ok(lockerAccount.currentBalance.eq(depositAmount));
    assert.ok(lockerAccount.startBalance.eq(depositAmount));
    assert.ok(lockerAccount.whitelistOwned.eq(new anchor.BN(0)));
    assert.equal(lockerAccount.nonce, lockerVaultNonce);
    assert.ok(lockerAccount.createdTs.gt(new anchor.BN(0)));
    assert.ok(lockerAccount.startTs.eq(startTs));
    assert.ok(lockerAccount.endTs.eq(endTs));
    // assert.ok(lockerAccount.rewardKeeper === null);
  });
});

User operation function #2: withdraw money from locker

The withraw function is more complicated as we need to implement following few features:

  1. withdraw from vault
  2. calcuate the date and amount which can be unlocked

Withdraw

The accounts to be passed in
#[derive(Accounts)]
pub struct Withdraw<'info> {
    #[account(mut, has_one = beneficiary, has_one = vault)]
    locker: Box<Account<'info, Locker>>,
    beneficiary: Signer<'info>,
    #[account(mut)]
    vault: Account<'info, TokenAccount>,
    /// CHECK: This is not dangerous because we don't read or write from this account
    #[account(seeds = [locker.to_account_info().key.as_ref()], bump = locker.nonce)]
    locker_vault_authority: AccountInfo<'info>,
    #[account(mut)]
    token: Account<'info, TokenAccount>,
    token_program: Program<'info, Token>,
    clock: Sysvar<'info, Clock>,
}

impl<'info> Withdraw<'info> {
    pub fn transfer_ctx(&self) -> CpiContext<'_, '_, '_, 'info, Transfer<'info>> {
        CpiContext::new(
            self.token_program.to_account_info(),
            Transfer { 
                from: self.vault.to_account_info(), 
                to: self.token.to_account_info(), 
                authority: self.locker_vault_authority.to_account_info(),
            }
        )
    }
}
Processor
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    // Transfer funds out.
    let seeds = &[
        ctx.accounts.locker.to_account_info().key.as_ref(),
        &[ctx.accounts.locker.nonce],
    ];
    let signer = &[&seeds[..]];
    // let cpi_ctx = CpiContext::from(&*ctx.accounts).with_signer(signer);
    // transfer(cpi_ctx, amount)?;
    let cpi_ctx = ctx.accounts.transfer_ctx().with_signer(signer);
    transfer(cpi_ctx, amount)?;

    // Bookeeping.
    let locker = &mut ctx.accounts.locker;
    locker.current_balance -= amount;

    Ok(())
}

Test


  it("Withdraws from the locker account", async () => {
    const token = await createTokenAccount(
      provider,
      mint,
      provider.wallet.publicKey
    );

    await lockerManager.methods
      .withdraw(new anchor.BN(100))
      .accounts({
        locker: locker.publicKey,
        beneficiary: provider.wallet.publicKey,
        token,
        vault: lockerAccount.vault,
        lockerVaultAuthority,
        tokenProgram: splToken.TOKEN_PROGRAM_ID,
        clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
      })
      .rpc();
    await sleepMillisecond(1000);
    lockerAccount = await lockerManager.account.locker.fetch(locker.publicKey);
    assert.ok(lockerAccount.currentBalance.eq(new anchor.BN(0)));

    const vaultAccount = await splToken.getAccount(
      provider.connection,
      lockerAccount.vault
    );
    assert.ok(
      new anchor.BN(vaultAccount.amount.toString()).eq(new anchor.BN(0))
    );

    const tokenAccount = await splToken.getAccount(provider.connection, token);
    assert.ok(
      new anchor.BN(tokenAccount.amount.toString()).eq(new anchor.BN(100))
    );
  });

Now we are able to withdraw the token from locker vault.
But there are still thing to do since it does not make sense that the locker allows to withdraw anytime without limitation.

So we will need to implement calcuator function for the locker.

To be continue