SOLSTACKAcademy

Builder· Lesson 2 of 8· 5 min

Accounts, constraints and state in code

The Token Locker's real account structs, line by line: how init, seeds, has_one and init_if_needed turn security rules into declarations, how state is laid out in bytes, and how the program signs for its vault.

Solstack's Token Locker is an Anchor program of about 700 lines. Most of the security lives in two structs: what create_lock requires and what release requires. Read those and the instruction bodies become short.

Declaring what an instruction needs

onchain/token-locker · CreateLockRust
#[derive(Accounts)]
#[instruction(params: CreateLockParams)]
pub struct CreateLock<'info> {
    #[account(mut)]
    pub creator: Signer<'info>,

    /// CHECK: any address can receive a lock — a wallet or a multisig vault PDA.
    pub recipient: UncheckedAccount<'info>,

    #[account(constraint = mint.to_account_info().owner == &token_program.key() @ LockError::WrongTokenProgram)]
    pub mint: InterfaceAccount<'info, Mint>,

    #[account(
        mut,
        constraint = creator_token_account.mint == mint.key() @ LockError::MintMismatch,
        constraint = creator_token_account.owner == creator.key() @ LockError::NotTokenOwner,
    )]
    pub creator_token_account: InterfaceAccount<'info, TokenAccount>,

    #[account(
        init,
        payer = creator,
        space = Lock::SPACE,
        seeds = [LOCK_SEED, mint.key().as_ref(), creator.key().as_ref(), &params.lock_id.to_le_bytes()],
        bump,
    )]
    pub lock: Account<'info, Lock>,

    #[account(
        init,
        payer = creator,
        associated_token::mint = mint,
        associated_token::authority = lock,
        associated_token::token_program = token_program,
    )]
    pub vault: InterfaceAccount<'info, TokenAccount>,

    /// Created now, at the creator's expense, so a release crank never has to
    /// fund the recipient's account.
    #[account(
        init_if_needed,
        payer = creator,
        associated_token::mint = mint,
        associated_token::authority = recipient,
        associated_token::token_program = token_program,
    )]
    pub recipient_token_account: InterfaceAccount<'info, TokenAccount>,

    pub token_program: Interface<'info, TokenInterface>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}
Signer / #[account(mut)]The creator must sign and will be debited: the lock's rent, the vault's rent, the recipient's account if new.
UncheckedAccount + /// CHECK:An account the program deliberately does not validate. Anchor refuses to compile without the comment saying why.
InterfaceAccount<Mint>A mint under either token program. The constraint ties it to the token_program passed in, so a Token-2022 mint cannot be paired with the classic program.
init, payer, space, seeds, bumpCreate the lock at a PDA of (mint, creator, lock_id). The same creator can lock the same mint many times; a different program cannot make this address.
associated_token::authority = lockThe vault is the lock PDA's own associated token account. No key exists that can sign for it.
init_if_neededCreate the recipient's token account unless it exists. Safe here because an existing ATA has exactly one valid layout; dangerous on your own state types — see Program security.
Interface<TokenInterface>Accepts the Token or Token-2022 program and nothing else. The CPI target cannot be substituted.

State and its bytes

the Lock accountRust
#[account]
pub struct Lock {
    pub creator: Pubkey,
    pub recipient: Pubkey,
    pub mint: Pubkey,
    pub vault: Pubkey,
    pub lock_id: u64,
    pub total_amount: u64,
    pub released_amount: u64,
    pub cliff_amount: u64,
    pub cliff_ts: i64,
    pub end_ts: i64,
    pub period: i64,
    pub created_ts: i64,
    pub cancelable: bool,
    pub transferable: bool,
    pub bump: u8,
    pub name: [u8; 32],
}

impl Lock {
    pub const SPACE: usize = 8 + 32 * 4 + 8 * 8 + 1 + 1 + 1 + 32;

    pub fn vested_at(&self, now: i64) -> u64 {
        vested_amount(self.total_amount, self.cliff_amount, self.cliff_ts, self.end_ts, self.period, now)
    }
}

Space is spelled out here rather than derived so the client can hard-code the same layout for memcmp filters. #[derive(InitSpace)] computes it for you when you don't need that; either way the 8-byte discriminator comes first and is not part of your struct.

Re-checking on every later instruction

onchain/token-locker · ReleaseRust
#[derive(Accounts)]
pub struct Release<'info> {
    /// Whoever cranks the release; pays the network fee.
    #[account(mut)]
    pub payer: Signer<'info>,

    #[account(
        mut,
        seeds = [LOCK_SEED, lock.mint.as_ref(), lock.creator.as_ref(), &lock.lock_id.to_le_bytes()],
        bump = lock.bump,
        has_one = mint @ LockError::MintMismatch,
        has_one = vault @ LockError::VaultMismatch,
        has_one = recipient @ LockError::NotRecipient,
        has_one = creator @ LockError::NotCreator,
    )]
    pub lock: Account<'info, Lock>,

    #[account(mut, constraint = mint.to_account_info().owner == &token_program.key() @ LockError::WrongTokenProgram)]
    pub mint: InterfaceAccount<'info, Mint>,
    #[account(mut)]
    pub vault: InterfaceAccount<'info, TokenAccount>,

    /// CHECK: matched against the lock by `has_one`.
    pub recipient: UncheckedAccount<'info>,
    #[account(
        init_if_needed,
        payer = payer,
        associated_token::mint = mint,
        associated_token::authority = recipient,
        associated_token::token_program = token_program,
    )]
    pub recipient_token_account: InterfaceAccount<'info, TokenAccount>,

    /// CHECK: matched against the lock by `has_one`; gets the rent back when the lock completes.
    #[account(mut)]
    pub creator: UncheckedAccount<'info>,

    pub token_program: Interface<'info, TokenInterface>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}

Notice what Release does not require: a signature from anyone in particular. Anyone can crank it, because the four has_one lines pin every other account to the values stored in the lock — the caller cannot point the release at a different vault or recipient. bump = lock.bump re-derives the PDA with the stored bump instead of searching for it, which costs no compute and rejects any non-canonical address.

Signing for the vault

inside release()Rust
let mint_key = lock.mint;
let creator_key = lock.creator;
let lock_id = lock.lock_id.to_le_bytes();
let bump = [lock.bump];
let seeds: &[&[u8]] = &[LOCK_SEED, mint_key.as_ref(), creator_key.as_ref(), &lock_id, &bump];
let signer = &[seeds];

token_interface::transfer_checked(
    CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        TransferChecked {
            from: ctx.accounts.vault.to_account_info(),
            mint: ctx.accounts.mint.to_account_info(),
            to: ctx.accounts.recipient_token_account.to_account_info(),
            authority: ctx.accounts.lock.to_account_info(),
        },
        signer,
    ),
    amount,
    ctx.accounts.mint.decimals,
)?;

Errors and events

Rust
#[error_code]
pub enum LockError {
    #[msg("Amount must be greater than zero")]
    ZeroAmount,          // 6000 · 0x1770
    #[msg("End must be in the future")]
    EndInPast,           // 6001
    // ...
    #[msg("Nothing has vested yet")]
    NothingToRelease,    // 6005 · 0x1775
}

#[event]
pub struct TokensReleased {
    pub lock: Pubkey,
    pub recipient: Pubkey,
    pub mint: Pubkey,
    pub amount: u64,
    pub released_total: u64,
    pub completed: bool,
}

// in the handler:
emit!(TokensReleased { lock: lock.key(), recipient: lock.recipient, mint: lock.mint, amount, released_total, completed });

require! turns a failed condition into one of these errors; the explorer shows the message and the number. emit! writes the event to the transaction log as base64 Program data:, which the client decodes with the IDL — the crank in worker/locker.js watches for TokensReleased to know a delivery landed.

Solana Explorer's IDL tab showing per-account mutable, signer and PDA flags
  1. #[account(mut)] on a Signer shows as Mutable + Signer. The IDL carries every constraint an explorer can display.
  2. An account with seeds in its constraints is flagged PDA and its derivation is published, so clients resolve it automatically.
How the constraints you write appear to everyone else, captured 19 Sep 2026 · explorer.solana.com

What to remember

  • An Accounts struct is the security model. Each attribute is a check that runs before the handler.
  • init + seeds + bump creates state at a PDA; has_one on later instructions pins every related account to it.
  • UncheckedAccount is allowed only with a CHECK comment, and only when another constraint covers it.
  • State is Borsh in field order behind an 8-byte discriminator. Know the offsets for filters.
  • new_with_signer with the stored seeds is how a program moves tokens out of its own vault.

Try it

0 of 32 lessons done