Solana Fall School · Assignment · Native Rust on Solana with Pinocchio 0.11.2 · Tests in Rust with LiteSVM · 3 challenges
Finish the escrow, without a framework.
This repo is a native Rust escrow written with Pinocchio. Make works and is tested. Take and Cancel are missing. Your job is to build them, and prove they work, in three challenges.
| Checkpoint | Section | When you finish this |
|---|---|---|
| 00 | Setup: Fork the repo | Your own copy, with the build tools installed |
| 01 | Starting point: Build and test | The shipped Make test passes on the untouched code |
| 02 | Read before you write: How it works | You can explain dispatch and zero-copy state |
| 03 | Read before you write: Make, line by line | You know the seven moves in make.rs |
| 04 | Challenge 1 · hard: Implement Take | take.rs compiles and is wired in |
| 05 | Challenge 2 · shorter: Implement Cancel | cancel.rs compiles, MakeV2 explicitly rejected |
| 06 | Challenge 3 · the proof: Prove it with tests | Five tests pass, the stranger's Cancel fails |
| ? | When it breaks: Troubleshooting | Look up the error |
| Pinocchio cheat sheet | API reference for the pinned versions |
The same material is available as an interactive guide with progress tracking at pinocchio-escrow-guide-3-day1.vercel.app.
Alice has token A and wants token B. Bob has B and wants A. Neither wants to send first. The program is the neutral third party: it holds Alice's A in a vault only the program can move, remembers the terms in an escrow account, and releases the tokens when Bob pays, or hands them back if Alice changes her mind.
MAKE TAKE
Alice ──500 A──▶ [ Vault ] ──500 A──▶ Bob
(maker) ▲ │
│ escrow PDA │
│ records the deal │
Alice ◀─────────────────── 100 B ───────┘
(taker)
CANCEL
Alice ◀──500 A── [ Vault ] only the maker, only before a Take
| Instruction | Who signs | What happens | Status |
|---|---|---|---|
Make |
maker | Create escrow PDA + vault, move amount_to_give of A into the vault |
Implemented |
Take |
taker | Taker pays amount_to_receive of B to maker, receives all A, accounts close |
Your job |
Cancel |
maker | Maker gets A back, accounts close | Your job |
If you have only written Anchor programs, this is where you see what the framework was doing for you: manual account validation, manual PDA derivation, zero-copy state, and raw CPIs. The payoff shows up in compute: the Make instruction here runs in about 30k CU.
| File | What it is |
|---|---|
lib.rs |
Entrypoint and instruction dispatch. Add two match arms. |
instructions/mod.rs |
The instruction enum. Register your new modules. |
instructions/make.rs |
Finished. Read it first, copy its patterns. Checkpoint 03. |
instructions/take.rs |
Does not exist yet. Challenge 1. |
instructions/cancel.rs |
Does not exist yet. Challenge 2. |
state/escrow.rs |
The zero-copy account layout. Read only, you will call its getters. |
tests/mod.rs |
LiteSVM tests. One exists; you add four. Challenge 3. |
Search the code for TODO (challenge). That comment in lib.rs marks where Take and Cancel plug in.
This repo is on Pinocchio 0.11. Older tutorials will not compile.
The 0.11 line changed signatures that almost every escrow tutorial online still shows the old way. Three to know before you start:
- Accounts arrive as
&mut [AccountView], not&[AccountView].- The token account state type is
pinocchio_token::state::Account, notTokenAccount.- Token CPI structs carry a
multisig_signersfield.If a snippet from elsewhere does not compile, check its version before you change your own code. The cheat sheet at the end of this page is written for the versions pinned in this repo.
When you finish this: your own copy of the assignment on GitHub and on your machine, with the Solana build tools installed.
- Open github.com/decentra1ized/solana-fall-pescrow and click Fork, top right.
- Clone your fork, not the original. Put your GitHub username in the link.
git clone https://github.com/YOUR-USERNAME/solana-fall-pescrow.git
cd solana-fall-pescrow| Tool | Version | How to get it |
|---|---|---|
| Rust | stable | rustup update stable |
cargo build-sbf |
Agave 3.x | Comes with the Agave installer. Or cargo install solana-cargo-build-sbf and let it fetch platform-tools on first run |
| Anchor | Not used. This is native Rust | |
| Node, Yarn | Not used. Tests are in Rust |
| Crate | Version | What it gives you |
|---|---|---|
pinocchio |
0.11.2 | AccountView, entrypoint!, CPI helpers, sysvars |
pinocchio-system |
0.6.1 | Typed CPI to the System Program: CreateAccount, … |
pinocchio-token |
0.6.0 | Typed CPI to SPL Token: Transfer, CloseAccount, … |
pinocchio-associated-token-account |
0.4.0 | Typed CPI to the ATA program |
pinocchio-pubkey |
0.3.0 | derive_address, now a published crate rather than a git dependency |
litesvm, litesvm-token |
0.9.1 | The in-process SVM and its mint / ATA helpers |
solana-rent |
3.1.0 | Dev-only. The Rent type the tests use to override the sysvar |
No validator either. The tests run in LiteSVM, an in-process Solana VM. There is no
solana-test-validatorto start, and nothing to deploy. Build the.so, run the tests, done.
Push after each challenge, so your progress is saved:
git add -A && git commit -m "challenge 1: take" && git pushWhen you finish this: the shipped Make test passes on the untouched code. Prove this before changing anything.
# 1. Compile the on-chain program to target/deploy/escrow.so
cargo build-sbf
# 2. Run the LiteSVM tests (they load the .so from step 1)
cargo test -- --nocaptureYou should see one passing test and its compute usage:
Make transaction successful
CUs Consumed: ~30000
test tests::tests::test_make_instruction ... ok
Always build before you test. The tests do not compile your program. They read the finished
target/deploy/escrow.sointo LiteSVM. Skipcargo build-sbfand the tests run your old program, not your latest change.
cargo testalone compiles the crate for your laptop's CPU, which is what the test binary needs. It does not produce a.so.
Why the SPL dev-dependencies have no-entrypoint
Without it the SPL crate's own program entrypoint gets linked into the test binary and collides with the one Pinocchio generates (duplicate symbol: entrypoint). Keep that feature on any SPL program crate you add later.
When you finish this: you can explain how an instruction reaches its handler, and how the escrow account stores its data without any serialization library.
entrypoint!(process_instruction);
declare_id!("4ibrEMW5F6hKnkW4jVedswYv6H6VtwPN6ar6dvXDN1nT");
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
instruction_data: &[u8],
) -> ProgramResult {
if program_id != &ID {
return Err(ProgramError::IncorrectProgramId);
}
// First byte = which instruction. The rest = that instruction's payload.
let (discriminator, data) = instruction_data
.split_first()
.ok_or(ProgramError::InvalidInstructionData)?;
match EscrowInstructions::try_from(discriminator)? {
EscrowInstructions::Make => instructions::process_make_instruction(accounts, data)?,
// TODO (challenge): EscrowInstructions::Take and EscrowInstructions::Cancel
_ => return Err(ProgramError::InvalidInstructionData), // ← Take / Cancel land here today
}
Ok(())
}entrypoint!is Pinocchio's macro. It parses the runtime input into&mut [AccountView]without the allocations and copies of the standard SDK path. Combined with explicit validation and compact instruction data, that can significantly reduce compute.- The slice is mutable because in 0.11 everything that changes an account in place (
set_lamports,close,try_borrow_mut) takes&mut self. Yourtake.rsandcancel.rshandlers must take&mut [AccountView]too. - There is no Anchor-style 8-byte discriminator. We use one byte, so the instruction data stays smaller.
- The
_ =>arm is where your Take and Cancel calls will go.
pub enum EscrowInstructions {
Make = 0,
Take = 1,
Cancel = 2,
MakeV2 = 3,
}
impl TryFrom<&u8> for EscrowInstructions { /* 0 → Make, 1 → Take, 2 → Cancel, 3 → MakeV2, _ → error */ }Take and Cancel already have their discriminators reserved, 1 and 2. Your test code will put that byte first in instruction_data.
This is the most "Pinocchio" part of the codebase. There is no Borsh, no serialize() or deserialize(). The struct is the bytes.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Escrow {
maker: [u8; 32],
mint_a: [u8; 32],
mint_b: [u8; 32],
amount_to_receive: [u8; 8], // stored as raw LE bytes, not u64
amount_to_give: [u8; 8], // ↑ keeps align_of::<Escrow>() == 1
pub bump: u8,
}
impl Escrow {
pub const LEN: usize = core::mem::size_of::<Self>(); // 113. Let the compiler count, never hand-write the sum
/// Borrow the account data as a typed, mutable Escrow view. The RefMut keeps the
/// account's borrow flag set until you drop it.
pub fn load_mut(account: &mut AccountView) -> Result<RefMut<'_, Self>, ProgramError> {
let data = account.try_borrow_mut()?;
if data.len() != Escrow::LEN { return Err(ProgramError::InvalidAccountData); }
if (data.as_ptr() as usize) % core::mem::align_of::<Self>() != 0 {
return Err(ProgramError::InvalidAccountData);
}
Ok(RefMut::map(data, |bytes| unsafe { &mut *(bytes.as_mut_ptr() as *mut Self) }))
}
pub fn amount_to_give(&self) -> u64 { u64::from_le_bytes(self.amount_to_give) }
pub fn set_amount_to_give(&mut self, amount: u64) { self.amount_to_give = amount.to_le_bytes(); }
// ... same pattern for every field
}#[repr(C)]pins the field order and layout so we can reinterpret the account's byte buffer as&mut Escrowwith a pointer cast. Writing to the struct writes straight into the account.- The
u64s are stored as[u8; 8]on purpose. A realu64field would force 8-byte alignment, and account data buffers are not guaranteed to be aligned. Byte arrays have alignment 1, so the cast is always sound. - The getters and setters do the little-endian conversion on the way in and out. You will use
escrow.maker(),escrow.mint_b(),escrow.amount_to_receive()andescrow.bumpheavily in Take and Cancel. load_mutreturns aRefMut<Escrow>, not a bare&mut Escrow.RefMut::mapre-types the guard from[u8]toEscrowwithout releasing it, so the account's borrow flag stays set for as long as the view lives. A version that returned&mut Escrowand let the guard drop inside the function would silently switch that protection off; Rust would still stop you from reusing the same binding, but nothing would catch the same account arriving twice in the accounts list.
A borrow you must remember to drop.
load_mutcallstry_borrow_mut()on the account and hands you the guard. While thatRefMut<Escrow>is alive, any CPI that touches the same account fails withAccountBorrowFailed, andclose()refuses too. Read what you need into locals, then let the guard go out of scope before you invoke anything. This will bite you in Take.
When you finish this: you know the seven moves in make.rs. Take and Cancel reuse five of them.
No names, no #[account] attributes. The client must pass them in exactly this order.
let [
maker, // 0 signer, pays for everything
mint_a, // 1 the token being deposited
mint_b, // 2 the token the maker wants back
escrow_account, // 3 PDA ["escrow", maker], to be created
maker_ata, // 4 maker's token account for mint A (source)
escrow_ata, // 5 vault: ATA(owner = escrow PDA, mint A), to be created
system_program, // 6
token_program, // 7
_associated_token_program @ .. // 8
] = accounts else {
return Err(ProgramError::NotEnoughAccountKeys);
};Because accounts is &mut [AccountView], each binding here is a &mut AccountView. CPI structs want &AccountView, and Rust reborrows automatically, so you pass them as-is.
Anchor's token::authority = maker, token::mint = mint_a constraints, done by hand:
{
let maker_ata_state = pinocchio_token::state::Account::from_account_view(maker_ata)?;
if maker_ata_state.owner() != maker.address() { return Err(ProgramError::IllegalOwner); }
if maker_ata_state.mint() != mint_a.address() { return Err(ProgramError::InvalidAccountData); }
}The braces matter.
from_account_viewborrows the account's data. If that borrow is still alive when we later CPI withmaker_ata, the runtime refuses with a borrow error. Scoping it releases the borrow early. You will use this block pattern at least four times in Take.
Layout after the discriminator byte: [amount_to_receive: u64 LE][amount_to_give: u64 LE]. No bump: the program derives that itself in step 4.
const MAKE_DATA_LEN: usize = 16; // 8 + 8
if data.len() < MAKE_DATA_LEN {
return Err(ProgramError::InvalidInstructionData);
}
let amount_to_receive = u64::from_le_bytes(data[0..8].try_into().unwrap());
let amount_to_give = u64::from_le_bytes(data[8..16].try_into().unwrap());
if !maker.is_signer() {
return Err(ProgramError::MissingRequiredSignature);
}from_le_bytes on a slice is the idiomatic way to read an integer out of instruction data: no alignment concerns, no unsafe, and the length check up front means a short payload fails cleanly instead of reading out of bounds.
Anchor's seeds = [b"escrow", maker.key().as_ref()], bump constraint. Make derives the canonical bump on-chain with find_program_address and stores it in the escrow (step 5). It never accepts a bump from the client.
let (escrow_account_pda, bump) =
Address::find_program_address(&[b"escrow", maker.address().as_ref()], &crate::ID);
if escrow_account_pda != *escrow_account.address() {
return Err(ProgramError::InvalidSeeds);
}Why not let the client send the bump? With seeds
["escrow", maker]every valid bump gives a different address, so a client-chosen bump would let one maker open several "the" escrows, and any client that looks the escrow up withfind_program_addresswould only ever see the canonical one. Deriving the bump once at init closes that hole. It loops over candidate bumps (a few thousand CU in the worst case, once per escrow); Take and Cancel avoid the loop by re-deriving from the stored bump withderive_address, which is a single hash. If you ever need several escrows per maker, add an explicit seed such as anescrow_idrather than reusing the bump for it.
Return an error rather than assert_eq!. A panic surfaces to the client as a generic "program failed to complete", while InvalidSeeds says exactly what went wrong.
init in Anchor terms.
let bump_bytes = [bump];
let seed = [Seed::from(b"escrow"), Seed::from(maker.address().as_array()), Seed::from(&bump_bytes)];
let seeds = Signer::from(&seed);
// Refuse to overwrite an escrow that already exists for this maker.
if escrow_account.owned_by(&crate::ID) {
return Err(ProgramError::AccountAlreadyInitialized);
}
CreateAccount {
from: maker,
to: escrow_account,
lamports: Rent::get()?.try_minimum_balance(Escrow::LEN)?,
space: Escrow::LEN as u64,
owner: &crate::ID,
}.invoke_signed(&[seeds.clone()])?;
// Scoped so the RefMut guard on the escrow data is released before the CPIs below.
{
let mut escrow_state = Escrow::load_mut(escrow_account)?;
escrow_state.set_maker(maker.address());
escrow_state.set_mint_a(mint_a.address());
escrow_state.set_mint_b(mint_b.address());
escrow_state.set_amount_to_receive(amount_to_receive);
escrow_state.set_amount_to_give(amount_to_give);
escrow_state.bump = bump;
}Note the shape: an early return on the "already exists" case, then the happy path straight down the function. Guard clauses read better than nesting the whole instruction inside an if, and AccountAlreadyInitialized tells the client more than IllegalOwner would.
This
Signeris the whole trick.invoke_signedis how a program "signs" as a PDA: it proves to the runtime that it knows the seeds that produce that address. You will build exactly thisSignerin Take and Cancel, except the bump comes fromescrow_state.bumpinstead offind_program_address.
How much rent the account needs.
Rent::get()?.try_minimum_balance(len)returns(128 + len) * lamports_per_byte. Pinocchio 0.11 follows SIMD-0194, which folded the old 2.0-year exemption threshold into the rate: one integer multiply, no floating point, 8 CU instead of ~256.Its
Rentstruct is 8 bytes. It reads only the rate and ignores the sysvar's remaining fields. That detail is what makes the test setup below need one extra line.
An ATA whose wallet is the escrow PDA. Because the PDA is the owner, only this program, via invoke_signed, can ever move tokens out of it.
pinocchio_associated_token_account::instructions::Create {
funding_account: maker,
account: escrow_ata,
wallet: escrow_account,
mint: mint_a,
token_program,
system_program,
}.invoke()?;A plain SPL Token transfer, signed by the maker who signed the transaction. So invoke(), not invoke_signed().
pinocchio_token::instructions::Transfer {
from: maker_ata,
to: escrow_ata,
authority: maker,
multisig_signers: &[] as &[&AccountView], // no multisig here; the type still has to be spelled out
amount: amount_to_give,
}.invoke()?;multisig_signers is new in pinocchio-token 0.6 and there is no Default to lean on, so every Transfer and CloseAccount you write needs it. The as &[&AccountView] cast is there so the generic parameter can be inferred from an empty slice.
Done. Three CPIs, about 30k CU total.
setup() creates a LiteSVM, overrides the Rent sysvar, airdrops a payer, and loads target/deploy/escrow.so. Then test_make_instruction:
- Creates two mints, A and B, with 6 decimals.
- Creates the maker's ATA for A and mints 1,000 A into it.
- Derives the escrow PDA with
Pubkey::find_program_address(&[b"escrow", maker], &PROGRAM_ID)and the vault withget_associated_token_address(&escrow, &mint_a). - Builds the instruction data
[0u8, amount_to_receive LE, amount_to_give LE]and the 9AccountMetas in the order from step 1. The test still callsfind_program_addressto know the escrow address and to check the stored bump afterwards, but it does not send the bump. - Sends the transaction and prints CU usage.
- Reads the accounts back and asserts the vault holds 500 A, the maker's ATA dropped to 500 A, and the escrow's 113 bytes contain the maker, both mints, both amounts and the bump.
Step 6 is the part people skip. A transaction that succeeds is not the same as a transaction that did the right thing. Your Take and Cancel tests must read state back the same way.
The one line in setup() you must not delete:
let mut svm = LiteSVM::new();
// LiteSVM 0.9 still ships the pre-SIMD-0194 Rent sysvar. Match the live cluster.
#[allow(deprecated)]
svm.set_sysvar(&solana_rent::Rent {
lamports_per_byte_year: 6960,
exemption_threshold: 1.0,
burn_percent: 50,
});Why, and what breaks without it
Mainnet, testnet and devnet have activated SIMD-0194, so the live sysvar carries the folded rate: 6960 lamports per byte with a threshold of 1.0. LiteSVM 0.9.1 still initialises the sysvar with the legacy pair, 3480 per byte-year and a 2.0 threshold. The same product, split differently.
Pinocchio 0.11 reads only the rate and multiplies once. Against the legacy sysvar it therefore asks the System Program for exactly half the lamports the runtime requires, and Make fails with InsufficientFundsForRent. Overriding the sysvar makes both sides agree on 6960. Carry that line into every helper you factor out for Take and Cancel.
Goal: Bob sees Alice's escrow, 500 A for 100 B, and calls Take. In one atomic transaction, 100 B moves from Bob to Alice, 500 A moves from the vault to Bob, and the vault and escrow accounts are closed with their rent refunded to Alice.
Why it matters: without Take, the escrow is a one-way deposit box. This is the instruction that makes it a trade.
Instruction data: just the discriminator, 1. Everything else is already in the escrow account.
Accounts, suggested order:
| # | Account | Writable | Signer | Notes |
|---|---|---|---|---|
| 0 | taker |
✅ | ✅ | Pays fees; creates own ATA for A if missing |
| 1 | maker |
✅ | Receives rent refunds. Must equal escrow.maker() |
|
| 2 | mint_a |
Must equal escrow.mint_a() |
||
| 3 | mint_b |
Must equal escrow.mint_b() |
||
| 4 | escrow_account |
✅ | PDA, owned by this program, will be closed | |
| 5 | vault |
✅ | ATA(escrow PDA, mint A), will be closed | |
| 6 | taker_ata_a |
✅ | ATA(taker, mint A), destination for A. May need to be created | |
| 7 | taker_ata_b |
✅ | ATA(taker, mint B), source of B. Must exist and have enough balance | |
| 8 | maker_ata_b |
✅ | ATA(maker, mint B), destination for B. May need to be created | |
| 9 | system_program |
|||
| 10 | token_program |
|||
| 11 | associated_token_program |
Account order is the API. There is no IDL. Write the account order in a comment at the top of
take.rs, and keep the test in sync with it.
Your handler takes accounts: &mut [AccountView], same as process_make_instruction. Destructure with the same let [ ... ] = accounts else { ... } pattern, with the twelve accounts above, so every binding is already a &mut AccountView. Then check taker.is_signer().
Escrow::load_mut(escrow_account)? gives you a RefMut<Escrow>. Before trusting anything in it, verify escrow_account.owned_by(&crate::ID). Otherwise anyone could pass a fake account with a fake maker.
maker.address() must equal escrow.maker(), and the two mints must match escrow.mint_a() and escrow.mint_b(). Copy amount_to_receive and bump into locals now, then end the block so the borrow drops. You are about to CPI with escrow_account as a signer, and a live borrow will fail.
let (amount_to_receive, bump) = {
let escrow = Escrow::load_mut(escrow_account)?;
if escrow.maker() != *maker.address() { return Err(ProgramError::InvalidAccountData); }
if escrow.mint_a() != *mint_a.address() { return Err(ProgramError::InvalidAccountData); }
if escrow.mint_b() != *mint_b.address() { return Err(ProgramError::InvalidAccountData); }
(escrow.amount_to_receive(), escrow.bump)
}; // ← RefMut guard dropped herederive_address(&[b"escrow", maker.address().as_ref(), &[bump]], None, &crate::ID.to_bytes()) (from pinocchio_pubkey) must equal escrow_account.address(). The bump is the canonical one Make stored, so this is a single hash, no loop. It is what proves the escrow belongs to this maker with this bump.
Load it with pinocchio_token::state::Account::from_account_view, check owner() == escrow_account.address() and mint() == mint_a.address(), read amount() into a local. That is how much A the taker will receive. Drop the borrow.
taker_ata_a and maker_ata_b may not have been created yet. Use pinocchio_associated_token_account::instructions::CreateIdempotent, which is safe to call if they already exist, with taker as the funding account. Then validate taker_ata_b the same way Make validated maker_ata: owner = taker, mint = mint_b.
Hint: CreateIdempotent has the same shape as Create
pinocchio_associated_token_account::instructions::CreateIdempotent {
funding_account: taker,
account: taker_ata_a,
wallet: taker,
mint: mint_a,
system_program,
token_program,
}.invoke()?;
// and again for maker_ata_b with wallet: maker, mint: mint_bFrom here on, write it before you open the hint. The remaining steps give you the requirement, not the code. Each one has the finished call behind a collapsed hint. Try it from the cheat sheet and from
make.rsfirst; open the hint when you are stuck, not before.
Transfer amount_to_receive of mint B from taker_ata_b to maker_ata_b. The authority is the taker, who signed the transaction, so this is a plain invoke().
Hint: the transfer call
pinocchio_token::instructions::Transfer {
from: taker_ata_b,
to: maker_ata_b,
authority: taker,
multisig_signers: &[] as &[&AccountView],
amount: amount_to_receive,
}.invoke()?;The vault's authority is the escrow PDA, not any wallet, so this transfer must be invoke_signed with a Signer built from the same three seeds Make used: b"escrow", the maker's address, and the bump. The bump comes from state; you never recompute it with find_program_address here.
Move vault_amount, the balance you read in step 5, not amount_to_give from state, out of the vault and into taker_ata_a.
Hint: signing as the PDA
let bump_bytes = [bump];
let seed = [Seed::from(b"escrow"), Seed::from(maker.address().as_array()), Seed::from(&bump_bytes)];
let signer = Signer::from(&seed);
pinocchio_token::instructions::Transfer {
from: vault,
to: taker_ata_a,
authority: escrow_account,
multisig_signers: &[] as &[&AccountView],
amount: vault_amount,
}.invoke_signed(&[signer.clone()])?;An empty token account still holds rent. Close it with the token program's CloseAccount, again signed by the PDA, and send the lamports to the maker, who paid for it.
The escrow is owned by this program, so there is no CPI for it. Two moves, in this order: add its lamports to the maker's balance and zero its own, then call close(). Skip the lamport move and the runtime rejects the instruction as unbalanced.
AccountView::close zeroes the account's data length, lamports and owner in one go. Both set_lamports and close take &mut self, which is why the accounts slice is mutable. close fails with AccountBorrowFailed if you still hold a borrow on the escrow data, which is another reason to copy the fields out in step 3.
Hint: closing both accounts
pinocchio_token::instructions::CloseAccount {
account: vault,
destination: maker,
authority: escrow_account,
multisig_signers: &[] as &[&AccountView],
}.invoke_signed(&[signer.clone()])?;
maker.set_lamports(maker.lamports() + escrow_account.lamports());
escrow_account.set_lamports(0);
escrow_account.close()?;- In
instructions/mod.rs:pub mod take; pub use take::*; - In
lib.rs, replace the_ =>arm with a real one forEscrowInstructions::Take. cargo build-sbf. It must compile before you touch the tests.
You are done when
cargo build-sbfsucceeds and the account order comment at the top oftake.rsmatches the table above. The tests come in Challenge 3.
Goal: Alice changes her mind before anyone takes the deal. She calls Cancel. The 500 A go back to her, the vault and escrow are closed, and rent is refunded to her.
Why it matters: without Cancel, a deposit nobody takes is locked forever. With a wrong Cancel, anyone can drain any escrow. This is the shortest instruction and the easiest to get dangerously wrong.
Instruction data: just the discriminator, 2.
Accounts, suggested order:
| # | Account | Writable | Signer | Notes |
|---|---|---|---|---|
| 0 | maker |
✅ | ✅ | Must sign. Must equal escrow.maker() |
| 1 | mint_a |
Must equal escrow.mint_a() |
||
| 2 | escrow_account |
✅ | PDA, will be closed | |
| 3 | vault |
✅ | Will be closed | |
| 4 | maker_ata_a |
✅ | Destination for the returned A | |
| 5 | token_program |
maker.is_signer()is the critical authorization check. Forget it, and anyone can drain any escrow back to its maker, which is annoying. Forget the stored-maker check too, and they can drain it to themselves, which is catastrophic.The stored maker and the PDA re-derivation still matter.
is_signerproves who sent the transaction; those two prove it is the right escrow for that signer.
- Destructure. Check
maker.is_signer(). - Load escrow state, verify program ownership, verify
escrow.maker() == maker.address()andescrow.mint_a() == mint_a.address(). Copy outbump, drop the borrow. - Re-derive and check the PDA.
- Validate the vault: owner = escrow PDA, mint = mint A. Read its balance, drop the borrow. Validate
maker_ata_a: owner = maker, mint = mint A. - Build the PDA signer.
Transfer { from: vault, to: maker_ata_a, authority: escrow_account, multisig_signers: &[] as &[&AccountView], amount: vault_amount }.invoke_signed(...)CloseAccount { account: vault, destination: maker, authority: escrow_account, multisig_signers: &[] as &[&AccountView] }.invoke_signed(...)- Close the escrow account by hand, same as Take step 10.
- Wire up
cancel.rsinmod.rsandlib.rs. Build.
Hint: steps 4 to 8 look familiar
They are the tail end of Take. Consider extracting "drain vault to a destination, close vault, close escrow" into a helper in src/instructions/shared.rs that takes the destination ATA and the PDA Signer. Both instructions then become validation plus one call. Any helper that mutates an account (set_lamports, close) must take &mut AccountView.
You are done when
cargo build-sbfsucceeds with both new match arms inlib.rs, and there is no_ =>arm silently swallowingMakeV2. Return an explicit error for it.
Goal: four new LiteSVM tests. Two happy paths that read state back, and two negative tests that must fail for the right reason.
Why it matters: a Take that "works" but leaves the escrow open, or a Cancel that a stranger can call, both pass a test that only checks the transaction succeeded.
Every test starts the same way: two mints, a funded maker ATA, a Make transaction. Pull that out of test_make_instruction into a function that returns what the next steps need: the svm, the maker, both mints, the escrow PDA and bump, and the vault address.
Keep the Rent override. Your helper must still call
svm.set_sysvarthe waysetup()does, or every test you write from here fails at the Make step withInsufficientFundsForRent. Reusesetup()rather than building a fresh LiteSVM by hand.
- Run the Make helper.
- Create a second keypair,
taker. Airdrop it SOL. Createtaker_ata_band mint 100 B into it. - Derive
taker_ata_aandmaker_ata_bwithget_associated_token_address. Do not create them. Your program does that withCreateIdempotent. - Build the instruction: data
vec![1u8], the twelveAccountMetas in the order from Challenge 1, signed by the taker. - Send it, print
compute_units_consumed.
Then assert:
| Read back | Expect |
|---|---|
taker_ata_a.amount |
500 A |
maker_ata_b.amount |
100 B |
svm.get_account(&vault) |
None, or 0 lamports and system owner |
svm.get_account(&escrow) |
None, or 0 lamports and system owner |
| maker SOL balance | Went up by roughly the rent of both closed accounts |
After Make, send Cancel signed by the maker: data vec![2u8], the six accounts from Challenge 2. Assert the maker's ATA is back to 1,000 A and both PDA accounts are gone.
| Test | Expect | What it proves |
|---|---|---|
| Take by a taker who has only 50 B | Transaction fails | The token program rejects the underfunded transfer, and nothing else moved |
| Cancel signed by a different keypair | Transaction fails | The maker check and the signer check both hold |
If the stranger's Cancel passes, you have a bug. This is the one test that matters most in the whole assignment. Do not
unwrap()the send here; assert it returnsErr. Then read the vault back and confirm the 500 A are still there.
Hint: asserting a failed transaction in LiteSVM
let result = svm.send_transaction(tx);
assert!(result.is_err(), "a stranger must not be able to cancel someone else's escrow");
let vault_acc = svm.get_account(&vault).unwrap();
let vault_state = spl_token_2022::state::Account::unpack(&vault_acc.data).unwrap();
assert_eq!(vault_state.amount, 500_000_000); // A never leftHint: Take on a mismatched maker should fail too
Optional fifth negative test: send a Take where the maker account does not match escrow.maker(). The cross-check in Take step 3 must reject it before any token moves. Same assertion pattern as above.
-
src/instructions/take.rsimplemented and wired intomod.rsandlib.rs. -
src/instructions/cancel.rsimplemented and wired. -
cargo build-sbfsucceeds with no newunsafe. The only one in the codebase is the pointer cast inEscrow::load_mut; you should not need another. -
cargo testruns Make → Take, Make → Cancel, and at least the two negative tests above, all green. - Every test prints
compute_units_consumed, like the Make test does, and you have compared Take and Cancel against Make's ~30k. There is no fixed budget to hit, but a number far above Make's is worth a look at how many CPIs you are making.
You are done when five tests pass, the stranger's Cancel fails, and you can say what Take and Cancel cost next to Make. Push it.
Failed to read program SO file … Run `cargo build-sbf` first
The tests load target/deploy/escrow.so and it is not there. Run cargo build-sbf, then cargo test.
My change does nothing in the tests
You edited the program but did not rebuild the .so. cargo test compiles the crate for your CPU, not for SBF. Run cargo build-sbf again; the tests pick up the new binary.
InsufficientFundsForRent in a test
Your test setup lost the Rent sysvar override. Pinocchio 0.11 computes rent the SIMD-0194 way and LiteSVM 0.9.1 seeds the legacy sysvar, so the program asks for half the required lamports. Keep the svm.set_sysvar(...) line from setup() in every helper. See "How the test drives it" in checkpoint 03.
Account borrow failed / AccountBorrowFailed
A Ref/RefMut on the account's data (from Account::from_account_view or Escrow::load_mut) is still alive when you CPI or call close(). Wrap the read in { }, copy primitives out, and let the borrow drop before you invoke anything.
cannot borrow as mutable, or types differ in mutability
In 0.11 the accounts slice is &mut [AccountView], and try_borrow_mut, set_lamports and close need &mut AccountView. Destructure accounts directly, as Make does, so every binding is already mutable, and give any helper that mutates an account a &mut AccountView parameter. Your handler signature must be accounts: &mut [AccountView].
missing field `multisig_signers`
Every pinocchio-token 0.6 instruction struct has it and there is no Default. Pass multisig_signers: &[] as &[&AccountView] when you are not using a multisig; the cast lets the generic parameter be inferred from an empty slice.
cannot find type `TokenAccount` in `pinocchio_token::state`
You copied from an older tutorial. In pinocchio-token 0.6 the type is pinocchio_token::state::Account.
Cross-program invocation with unauthorized signer
Your Seeds do not reproduce the PDA. Check three things: the bump is the one stored in state (Make wrote the canonical one there), the address is the maker's (not the taker's), and the literal is exactly b"escrow".
invalid account data for instruction, from the Token program
You are passing an account that is not initialised yet (forgot CreateIdempotent for taker_ata_a or maker_ata_b), or the from and to accounts have different mints.
InvalidInstructionData when I send Take
The _ => arm in lib.rs is still catching discriminator 1. Add the real match arm for EscrowInstructions::Take, then cargo build-sbf.
NotEnoughAccountKeys
Your test passes fewer accounts than the handler destructures. Count against the table: Take needs 12, Cancel needs 6.
sum of account balances before and after instruction do not match
You called close() on the escrow (or let the vault close) without moving its lamports first. Do the set_lamports pair, then close(). Lamports can never disappear inside an instruction; they have to land somewhere.
duplicate symbol: entrypoint
An SPL program crate in [dev-dependencies] is missing features = ["no-entrypoint"], so its entrypoint collides with the one Pinocchio generates. Add the feature to the crate you just added.
Should my Take test send the bump?
No. Make derives and stores the canonical bump; Take and Cancel read it back with escrow.bump. Instruction data for both is the single discriminator byte. Your test still needs find_program_address to know the escrow address for the AccountMeta, but the returned bump is not sent anywhere.
Why does Escrow::load_mut return a RefMut instead of &mut Escrow?
Because the RefMut is what keeps the account's borrow flag set. RefMut::map re-types the guard from [u8] to Escrow without releasing it. If the accessor dropped the guard and handed back a bare &mut Escrow, the runtime would think the account is free while you still hold a pointer into it. Use it through DerefMut exactly like a &mut Escrow (let mut e = Escrow::load_mut(acc)?; e.set_maker(..)), copy primitives out, and let it drop before any CPI.
Do I need an unsafe block to check the owner?
No. In 0.11 AccountView::owner() is a safe fn, and owned_by(&crate::ID) is the shortest way to write the check. The only unsafe in this codebase is the pointer cast inside Escrow::load_mut.
Where is CloseAccount / CreateIdempotent / set_lamports?
pinocchio_token::instructions::CloseAccount, pinocchio_associated_token_account::instructions::CreateIdempotent, and set_lamports, lamports, close, owned_by are methods on AccountView (from the solana-account-view crate that pinocchio re-exports). If your IDE cannot find one, check that Cargo.toml still pins the versions in the table at the top.
Quick reference for the APIs used in this repo: pinocchio = 0.11.2, pinocchio-token = 0.6.0.
| I want to… | Use |
|---|---|
| Get an account's pubkey | account.address() |
| Check who owns an account | account.owned_by(&crate::ID) or account.owner() == &crate::ID |
| Check an account signed | account.is_signer() |
| Read SPL token account fields | pinocchio_token::state::Account::from_account_view(acc)? → .owner(), .mint(), .amount() |
| Find the canonical bump (init only) | Address::find_program_address(&[seed1, seed2], &crate::ID) → (Address, u8) |
| Derive a PDA (bump known) | pinocchio_pubkey::derive_address(&[seed1, seed2, &[bump]], None, &crate::ID.to_bytes()) |
| Load escrow state | Escrow::load_mut(acc)? → RefMut<Escrow>; drop it before any CPI |
| Sign a CPI as a PDA | let s = [Seed::from(..), ..]; let signer = Signer::from(&s); ix.invoke_signed(&[signer]) |
| Create an account | pinocchio_system::instructions::CreateAccount { from, to, lamports, space, owner } |
| Create an ATA | pinocchio_associated_token_account::instructions::Create { .. } / CreateIdempotent { .. } |
| Transfer SPL tokens | pinocchio_token::instructions::Transfer { from, to, authority, multisig_signers, amount } |
| Close an SPL token account | pinocchio_token::instructions::CloseAccount { account, destination, authority, multisig_signers } |
| Say "no multisig" | multisig_signers: &[] as &[&AccountView] |
| Rent-exempt minimum | Rent::get()?.try_minimum_balance(space)? → (128 + space) * lamports_per_byte |
| Read / move lamports | account.lamports(), account.set_lamports(n) |
| Close a program-owned account | move lamports out, then account.close()? |
| Log something | pinocchio_log::log!("value: {}", x) |
Account order is the API. There is no IDL. Document the account order for every instruction in a comment at the top of its file, and keep the test in sync.