Stake
Your starting point
The staking program’s full source, a standard Anchor program with no tests, at
examples/staking/. It CPIs into mpl-core, so that program’s .so is committed
alongside. The built fixtures are committed too, so a fresh clone runs this
chapter’s test without building anything:
git clone -b feat/buildable-ix https://github.com/cds-rs/anchor-litesvm
cd anchor-litesvm
cargo test -p anchor-litesvm --test book_stake
examples/staking/ the program source (no tests)
crates/anchor-litesvm/tests/fixtures/staking.so the built program
crates/anchor-litesvm/tests/fixtures/mpl_core.so the mpl-core CPI callee
crates/anchor-litesvm/tests/book_stake.rs this chapter's test
Changed the program? Rebuild the fixture with cd examples/staking && anchor build.
The staking program lets a holder stake an mpl-core NFT into a collection
and earn rewards. create_collection and mint_asset set up the NFT side
of things; initialize opens a config PDA on the collection with a
rewards rate and a freeze period, in days; stake freezes an asset in place
and records when; unstake, once that freeze period elapses, unfreezes it
again and mints the rewards.
This is the deepest CPI tree in the book: mpl-core assets are only mutable
through CPIs into the mpl-core program itself, so nearly everything stake
and unstake do to the NFT shows up as a nested frame rather than as a
direct account write in staking’s own frame.
The accounts
Three PDAs hang off the collection and the config, and the asset lives inside mpl-core; that shape is what makes the CPI tree deep.
flowchart TD
Owner["(1) Owner (staker)"]
Collection["(2) Collection<br/>mpl-core collection"]
Asset["(3) Asset<br/>mpl-core NFT"]
Config["(4) Config PDA<br/>seeds: config + collection<br/>rewards_bps, freeze_period"]
UA["(5) update_authority PDA<br/>seeds: update_authority + collection"]
Mint["(6) rewards_mint PDA<br/>seeds: rewards_mint + config"]
Ata["(7) user_rewards_ata<br/>ATA of owner + rewards_mint"]
Owner -->|"stakes / unstakes"| Asset
Collection -->|contains| Asset
Collection -->|seeds| Config
Collection -->|seeds| UA
Config -->|seeds| Mint
UA -->|"mpl-core authority for"| Asset
Mint -->|"unstake mints to"| Ata
The owner (1) stakes an asset (3), an mpl-core NFT that belongs to a collection
(2). Two PDAs are seeded off that collection: config (4) holds the staking
terms (the rewards rate and the freeze period), and update_authority (5) is a
PDA the program controls, set as the collection’s mpl-core update authority
when create_collection runs. That authority is what lets staking sign the
plugin adds and updates that freeze and unfreeze the asset, which is what the
deep CPI tree below is made of. rewards_mint (6) is seeded off config, and
unstake mints from it into the staker’s ATA (7).
staking depends on mpl-core, whose crate is pinned to anchor 0.31, so it
builds under its own 0.31 toolchain rather than in this anchor 1.0 workspace.
That version gap looks like it should block the typed client; it does not.
staking’s IDL is spec 0.1.0, the same format anchor 1.0 emits. The one
snag is a name clash: the IDL embeds mpl-core’s Key enum, which collides
with anchor_lang’s Key trait once declare_program! glob-imports both,
and current rustc rejects the ambiguous glob. make fixtures runs the
framework’s sanitize pass (anchor_litesvm::sanitize_idl) over
idls/staking.json, which namespaces Key to StakingKey. With that, the
typed client generates like vault’s and escrow’s, and this chapter drives
staking the same way they drive their programs: a bundle and typed args per
instruction, no hand-built bytes.
The typed client
declare_program!(staking) generates the typed client from the sanitized
IDL, and bundles_from_idl!(staking) generates an account bundle per
instruction. So a stake call is a StakeBundle plus its (empty) args:
#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
anchor_lang::declare_program!(staking);
anchor_litesvm::bundles_from_idl!(staking);
fn stake_bundle(admin: &Keypair, asset: &Keypair, collection: &Keypair) -> StakeBundle {
StakeBundle {
owner: admin.pubkey(),
asset: asset.pubkey(),
collection: collection.pubkey(),
}
}
}
StakeBundle carries only the three accounts that vary per call: the owner
and the two mpl-core assets. config and the update-authority PDA are both
seeded off collection, so the bundle derives them from the IDL’s seeds; you
never spell out the account list or the discriminator, and there is no
positional slot to get wrong.
Two-program boot
#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
/// Deploys both vendored programs and names the staking custom errors. The
/// framework has no errors-from-IDL helper yet, so `register_program_errors`
/// supplies the mapping (codes are declaration order from 6000, per
/// `error.rs`); that is what makes a failing leaf read as
/// `FreezePeriodNotElapsed` instead of `custom program error: 0x1775`.
fn boot() -> anchor_litesvm::AnchorContext {
let mut ctx = AnchorLiteSVM::build_with_programs(&[
(staking::ID, "staking", &common::fixture_bytes("staking")),
(MPL_CORE_ID, "mpl_core", &common::fixture_bytes("mpl_core")),
]);
ctx.register_program_errors(
staking::ID,
&[
(6000, "InvalidOwner"),
(6001, "InvalidUpdateAuthority"),
(6002, "AlreadyStaked"),
(6003, "AssetNotStaked"),
(6004, "InvalidTimestamp"),
(6005, "FreezePeriodNotElapsed"),
(6006, "InvalidRewardsBps"),
(6007, "NothingToClaim"),
],
);
ctx
}
}
build_with_programs deploys the staking program alongside mpl_core:
staking CPIs into it for every NFT operation, so both programs need to be
live on the SVM for any of this to run.
The IDL carries staking’s error names, but the framework has no helper to
source them yet, so register_program_errors supplies the mapping, read
straight off staking’s own error.rs. That’s what turns a failing leaf into
FreezePeriodNotElapsed instead of the far less readable custom program error: 0x1775.
Happy path
#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
let mut ctx = boot();
let admin = ctx.cast_actor("Alice");
let (collection, asset) = setup(&mut ctx, &admin);
let result = ctx
.tx(&[&admin])
.build(
stake_bundle(&admin, &asset, &collection),
staking::client::args::Stake {},
)
.send_ok();
}
setup runs the first three calls through their own bundles: create_collection
mints a fresh mpl-core collection asset (the container stake attaches NFTs
to), initialize opens the config PDA on it with a 500bps rewards rate and
a 7-day freeze period, and mint_asset mints an NFT into the collection. Then
stake freezes it in place.
result.tree_string() renders the last of those four calls, stake:
── staking::Stake ──────────────────────────────────────────
Transaction signers=[Alice]
└── staking::Stake [1] ✓ 48001cu signer=Alice
├── mpl_core::AddPlugin [2] ✓ 15421cu
│ ├── System [3] ✓ (no cu)
│ └── System [3] ✓ (no cu)
└── mpl_core::AddPlugin [2] ✓ 11838cu
└── System [3] ✓ (no cu)
Compute Units (this run): 48001
Fee: 5000 lamports
Legend (3):
Alice = A5qBARFnwRspViGBJ3882LjApyRt8nqYhciAH65YckU
staking = GoZYUCqeKxN2TXNcAnSm8aGfWSpqzBgSqackvDzzFAMg
mpl_core = CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d
staking::Stake CPIs into mpl_core::AddPlugin twice, once per plugin it
attaches: first the Attributes plugin, which records staked and
staked_at as data on the asset, then the FreezeDelegate plugin, which is
what actually freezes the asset in place. Both effects show up as nested
frames rather than as writes inside staking’s own frame, because that’s
the only way staking is allowed to touch someone else’s mpl-core asset.
Each AddPlugin call, in turn, touches System to resize the asset
account: attaching a plugin grows the account’s stored data, and the extra
rent that growth requires gets funded through a System transfer CPI.
Freeze lock
A staker who tries to unstake before the freeze period elapses gets turned
away. unstake reads the current clock, works out how many days have
passed since stake recorded staked_at, and requires that count to reach
initialize’s 7-day freeze period before it will touch the asset at all:
#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
// Only 1 of the 7 freeze-period days has elapsed.
ctx.svm.advance_days(1);
let ix = ctx.program().build_ix(
unstake_bundle(&admin, &asset, &collection),
staking::client::args::Unstake {},
);
let result = ctx.send_err_named(ix, &[&admin], "FreezePeriodNotElapsed");
}
── staking::Unstake ────────────────────────────────────────
Transaction signers=[Alice]
└── staking::Unstake [1] ✗ 49676cu signer=Alice
├── AssociatedToken [2] ✓ 16416cu
│ ├── Token [3] ✓ 183cu
│ ├── System [3] ✓ (no cu)
│ ├── Token [3] ✓ 38cu
│ └── Token [3] ✓ 235cu
└── Error: FreezePeriodNotElapsed
Error: InstructionError(0, Custom(6005))
Compute Units (this run): 49676
Fee: 5000 lamports
Legend (2):
Alice = A5qBARFnwRspViGBJ3882LjApyRt8nqYhciAH65YckU
staking = GoZYUCqeKxN2TXNcAnSm8aGfWSpqzBgSqackvDzzFAMg
The AssociatedToken frame still runs and succeeds: unstake creates the
staker’s rewards ATA before it ever checks the freeze period, the same
create-first, guard-second ordering the escrow chapter’s expiry check
showed. Then the ✗ FreezePeriodNotElapsed leaf stops the transaction,
with 6 of the 7 freeze-period days still owed.
Give unstake the days it’s owed, and the very same call succeeds:
#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_stake.rs
// 8 of the 7 freeze-period days have elapsed.
ctx.svm.advance_days(8);
let result = ctx
.tx(&[&admin])
.build(
unstake_bundle(&admin, &asset, &collection),
staking::client::args::Unstake {},
)
.send_ok();
}
── staking::Unstake ────────────────────────────────────────
Transaction signers=[Alice]
└── staking::Unstake [1] ✓ 87846cu signer=Alice
├── AssociatedToken [2] ✓ 16416cu
│ ├── Token [3] ✓ 183cu
│ ├── System [3] ✓ (no cu)
│ ├── Token [3] ✓ 38cu
│ └── Token [3] ✓ 235cu
├── mpl_core::UpdatePlugin [2] ✓ 18305cu
│ └── System [3] ✓ (no cu)
├── mpl_core::UpdatePlugin [2] ✓ 11548cu
└── Token [2] ✓ 161cu
Compute Units (this run): 87846
Fee: 5000 lamports
Legend (3):
Alice = A5qBARFnwRspViGBJ3882LjApyRt8nqYhciAH65YckU
staking = GoZYUCqeKxN2TXNcAnSm8aGfWSpqzBgSqackvDzzFAMg
mpl_core = CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d
Past the freeze period, unstake runs to completion. The first
mpl_core::UpdatePlugin call resets the Attributes plugin’s staked /
staked_at values, undoing what stake’s first AddPlugin call recorded.
The second UpdatePlugin call sets FreezeDelegate.frozen back to false,
unfreezing the asset. The final Token call is the payoff: it mints the
staking rewards to the staker’s ATA, at the rewards rate initialize set
back at the start of the chapter.
The full test is crates/anchor-litesvm/tests/book_stake.rs.