solana
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:
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)
anchor init project64_anchor_solana_locker
anchor new locker-manager
Source: Solmeet#8
The locker program served with 3 types of key functions
This insruction will only used once since it is used to create a top level program related PDA state.
#[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)]
- init,
- seeds = [b"locker-manager".as_ref(), &locker_manager_nonce.to_le_bytes()],
- bump,
- payer = authority,
- 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.
Only the current admin can set the new admin of the locker manager
#[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#8Usage of creation
The PDA needs to store data and it needs to be initiated by the program
Usage of derivation
- The PDA DOES NOT need to store data so that it neithor need to be inited by the program
- The PDA needs to store data but it has already been inited before. (This is the case in this instruction)
User can vest their fund in the locker. In this case, the progrm will create a new locker for that user
#[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(())
}
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);
});
});
The withraw function is more complicated as we need to implement following few features:
#[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(),
}
)
}
}
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(())
}
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 …