Solana Fall School · Assignment

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.

Start
Fork the repo
Pinocchio
0.11.2
Tests
Rust · LiteSVM
Challenges
3

What an escrow is

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
InstructionWho signsWhat happensStatus
MakemakerCreate escrow PDA + vault, move amount_to_give of A into the vaultImplemented
TaketakerTaker pays amount_to_receive of B to maker, receives all A, accounts closeYour job
CancelmakerMaker gets A back, accounts closeYour job
Why Pinocchio, not Anchor 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.

The files you will touch

lib.rsEntrypoint and instruction dispatch. Add two match arms.
instructions/mod.rsThe instruction enum. Register your new modules.
instructions/make.rsFinished. Read it first, copy its patterns. Checkpoint 3.
instructions/take.rsDoes not exist yet. Challenge 1.
instructions/cancel.rsDoes not exist yet. Challenge 2.
state/escrow.rsThe zero-copy account layout. Read only, you will call its getters.
tests/mod.rsLiteSVM 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, not TokenAccount.
  • Token CPI structs carry a multisig_signers field.

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.

00
Setup

Fork the repo

When you finish this: your own copy of the assignment on GitHub and on your machine, with the Solana build tools installed.

  1. Open github.com/decentra1ized/solana-fall-pescrow and click Fork, top right.
  2. 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

Tools this repo expects

ToolVersionHow to get it
Ruststablerustup update stable
cargo build-sbfAgave 3.xComes 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

Crates this repo pins

CrateVersionWhat it gives you
pinocchio0.11.2AccountView, entrypoint!, CPI helpers, sysvars
pinocchio-system0.6.1Typed CPI to the System Program: CreateAccount, …
pinocchio-token0.6.0Typed CPI to SPL Token: Transfer, CloseAccount, …
pinocchio-associated-token-account0.4.0Typed CPI to the ATA program
pinocchio-pubkey0.3.0derive_address, now a published crate rather than a git dependency
litesvm, litesvm-token0.9.1The in-process SVM and its mint / ATA helpers
solana-rent3.1.0Dev-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-validator to 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 push
01
Starting point

Build and test

When 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 -- --nocapture

You 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.so into LiteSVM. Skip cargo build-sbf and the tests run your old program, not your latest change.

cargo test alone 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 features = ["no-entrypoint"], 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.

02
Read before you write

How it works

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.

1 · lib.rs, the entrypoint

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. Your take.rs and cancel.rs handlers 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.

2 · instructions/mod.rs, the enum

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.

3 · state/escrow.rs, zero-copy state

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

    /// Borrow the account data as a typed, mutable `Escrow` view.
    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);
        }
        // SAFETY: #[repr(C)], alignment 1, and the length check above make the cast sound.
        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 Escrow with a pointer cast. Writing to the struct writes straight into the account.
  • The u64s are stored as [u8; 8] on purpose. A real u64 field 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() and escrow.bump heavily in Take and Cancel.
  • load_mut returns a RefMut<Escrow>, not a bare &mut Escrow. RefMut::map re-types the guard from [u8] to Escrow without releasing it, so the account's borrow flag stays set for as long as the view lives. Use it through DerefMut exactly like a &mut Escrow.
A borrow you must remember to drop

load_mut calls try_borrow_mut() on the account and hands you the guard. While that RefMut<Escrow> is alive, any CPI that touches the same account fails with AccountBorrowFailed, and close() 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.

An accessor that dropped the guard and returned a bare &mut Escrow would silently switch that protection off: the runtime would think the account is free while you still hold a pointer into it. That is why the return type is what it is.

03
Read before you write

Make, line by line

When you finish this: you know the seven moves in make.rs. Take and Cancel reuse five of them.

1 · Unpack accounts by position

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.

2 · Validate the maker's token account

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_view borrows the account's data. If that borrow is still alive when we later CPI with maker_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.

3 · Parse instruction data

Layout after the discriminator byte: [amount_to_receive: u64 LE][amount_to_give: u64 LE]. No bump — the program derives it 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.

4 · Derive the canonical bump, and verify the PDA

Anchor's seeds = [b"escrow", maker.key().as_ref()], bump constraint. Make derives the canonical bump on-chain and stores it in the escrow in 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);
}

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.

Why not let the client send the bump?

With seeds ["escrow", maker] every valid bump gives a different address. A client-chosen bump would let one maker open several "the" escrows, and any client that looks the escrow up with find_program_address would only ever see the canonical one. Deriving it on-chain closes that hole.

The cost is a loop over candidate bumps — a few thousand CU, once per escrow. Take and Cancel avoid the loop by re-deriving from the stored bump with derive_address, which is a single hash. If you ever need several escrows per maker, add an explicit seed such as an escrow_id rather than reusing the bump for it.

5 · Create the escrow account, signed by the PDA

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 Signer is the whole trick invoke_signed is 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 this Signer in Take and Cancel, except the bump comes from escrow_state.bump instead of find_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 Rent struct 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.

6 · Create the vault

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()?;

7 · Deposit

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.

How the test drives it

setup() creates a LiteSVM, overrides the Rent sysvar, airdrops a payer, and loads target/deploy/escrow.so. Then test_make_instruction:

  1. Creates two mints, A and B, with 6 decimals.
  2. Creates the maker's ATA for A and mints 1,000 A into it.
  3. Derives the escrow PDA with Pubkey::find_program_address(&[b"escrow", maker], &PROGRAM_ID) and the vault with get_associated_token_address(&escrow, &mint_a).
  4. Builds the instruction data [0u8, amount_to_receive LE, amount_to_give LE] and the 9 AccountMetas in the order from step 1. The test still calls find_program_address to know the escrow address, and to check the stored bump afterwards, but it does not send the bump.
  5. Sends the transaction and prints CU usage.
  6. 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.

04
Challenge 1 · hard

Implement Take

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

#AccountWSNotes
0taker✓✓Pays fees; funds its own ATA for A if missing
1maker✓Receives rent refunds. Must equal escrow.maker()
2mint_aMust equal escrow.mint_a()
3mint_bMust equal escrow.mint_b()
4escrow_account✓PDA, owned by this program, will be closed
5vault✓ATA(escrow PDA, mint A), will be closed
6taker_ata_a✓ATA(taker, mint A), destination for A. May need creating
7taker_ata_b✓ATA(taker, mint B), source of B. Must exist with enough balance
8maker_ata_b✓ATA(maker, mint B), destination for B. May need creating
9system_program
10token_program
11associated_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.

1 · Destructure and check the signer

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().

2 · Load the escrow state, and trust it only after checking the owner

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.

3 · Cross-check the passed accounts against the state

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 here

4 · Re-derive the PDA

derive_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 with no loop. It is what proves the escrow belongs to this maker with this bump.

Do not call find_program_address here. Make already paid for the search and wrote the answer into state; repeating it burns CU on every Take.

5 · Validate the vault

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.

6 · Make sure the destination ATAs exist

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,
    token_program,
    system_program,
}.invoke()?;
// and again for maker_ata_b with wallet: maker, mint: mint_b
From 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 Hint. Try it from the cheat sheet and from make.rs first; open the hint when you are stuck, not before.

7 · CPI #1, taker pays maker

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()?;   // plain invoke: the taker signed the transaction

8 · Build the PDA signer, then CPI #2, vault pays taker

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()])?;

Keep the signer alive: step 10 needs it again. That is why the code clones it rather than moving it.

9 · CPI #3, close the vault

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.

10 · Close the escrow account, by hand

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 — that 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
// 9 · the vault is an SPL token account, so the token program closes it
pinocchio_token::instructions::CloseAccount {
    account: vault,
    destination: maker,
    authority: escrow_account,
    multisig_signers: &[] as &[&AccountView],
}.invoke_signed(&[signer.clone()])?;

// 10 · the escrow is ours, so we close it ourselves
maker.set_lamports(maker.lamports() + escrow_account.lamports());
escrow_account.set_lamports(0);
escrow_account.close()?;

11 · Wire it up

  1. In instructions/mod.rs: pub mod take; pub use take::*;
  2. In lib.rs, replace the _ => arm with a real one for EscrowInstructions::Take.
  3. cargo build-sbf. It must compile before you touch the tests.
You are done when cargo build-sbf succeeds and the account order comment at the top of take.rs matches the table above. The tests come in challenge 3.
05
Challenge 2 · shorter

Implement Cancel

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

#AccountWSNotes
0maker✓✓Must sign. Must equal escrow.maker()
1mint_aMust equal escrow.mint_a()
2escrow_account✓PDA, will be closed
3vault✓Will be closed
4maker_ata_a✓Destination for the returned A
5token_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_signer proves who sent the transaction; those two prove it is the right escrow for that signer.

The steps

  1. Destructure. Check maker.is_signer().
  2. Load escrow state with Escrow::load_mut, verify program ownership, verify escrow.maker() == maker.address() and escrow.mint_a() == mint_a.address(). Copy out bump, drop the guard.
  3. Re-derive and check the PDA.
  4. Validate the vault: owner = escrow PDA, mint = mint A. Read its balance, drop the borrow. Validate maker_ata_a: owner = maker, mint = mint A.
  5. Build the PDA signer.
  6. Transfer { from: vault, to: maker_ata_a, authority: escrow_account, multisig_signers: &[] as &[&AccountView], amount: vault_amount }.invoke_signed(...)
  7. CloseAccount { account: vault, destination: maker, authority: escrow_account, multisig_signers: &[] as &[&AccountView] }.invoke_signed(...)
  8. Close the escrow account by hand, same as Take step 10.
  9. Wire up cancel.rs in mod.rs and lib.rs. Build.
Hint Steps 4 to 8 look familiar

They are nearly identical to Take steps 5 and 8 to 10. Feel free to factor the "drain vault, close vault, close escrow" sequence into a shared helper that takes the destination token account and the Signer.

If you do, keep the helper in instructions/mod.rs or a new instructions/shared.rs, and make both Take and Cancel call it. One place to fix, not two.

You are done when cargo build-sbf succeeds with both new match arms in lib.rs, and there is no _ => arm silently swallowing MakeV2. Return an explicit error for it.
06
Challenge 3 · the proof

Prove it with tests

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.

1 · Factor the Make setup into a helper

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_sysvar the way setup() does, or every test you write from here fails at the Make step with InsufficientFundsForRent. Reuse setup() rather than building a fresh LiteSVM by hand.

2 · test_take_instruction

  1. Run the Make helper.
  2. Create a second keypair, taker. Airdrop it SOL. Create taker_ata_b and mint 100 B into it.
  3. Derive taker_ata_a and maker_ata_b with get_associated_token_address. Do not create them. Your program does that with CreateIdempotent.
  4. Build the instruction: data vec![1u8], the twelve AccountMetas in the order from challenge 1, signed by the taker.
  5. Send it, print compute_units_consumed.

Then assert:

Read backExpect
taker_ata_a.amount500 A
maker_ata_b.amount100 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 balanceWent up by roughly the rent of both closed accounts

3 · test_cancel_instruction

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.

4 · Two negative tests

TestExpectWhat it proves
Take by a taker who has only 50 BTransaction failsThe token program rejects the underfunded transfer, and nothing else moved
Cancel signed by a different keypairTransaction failsThe 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 returns Err. Then read the vault back and confirm the 500 A are still there.
Hint Asserting a failed transaction in LiteSVM

svm.send_transaction(tx) returns a Result. For the negative tests:

let result = svm.send_transaction(transaction);
assert!(result.is_err(), "a stranger must not be able to cancel");

// then prove nothing moved
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, amount_to_give);
Hint Take on a mismatched maker should fail too

A third negative test worth writing: send Take with a maker account that is not the one stored in the escrow. It must fail on your cross-check in step 3 of challenge 1, before any tokens move.

5 · Definition of done

  • src/instructions/take.rs implemented and wired into mod.rs and lib.rs.
  • src/instructions/cancel.rs implemented and wired.
  • cargo build-sbf succeeds with no new unsafe. The only one in the codebase is the pointer cast in Escrow::load_mut; you should not need another.
  • cargo test runs 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.
?
When it breaks

Troubleshooting

Error Failed to read program SO file … Run `cargo build-sbf` first

You ran cargo test before building. Run cargo build-sbf first.

Error My change does nothing in the tests

The tests are loading the old .so. Run cargo build-sbf again, then cargo test.

Error InsufficientFundsForRent in a test

Your test's setup() is missing the svm.set_sysvar Rent override. Pinocchio 0.11 reads the folded SIMD-0194 rate, LiteSVM 0.9 ships the legacy pair, and the program ends up requesting half the required lamports. See checkpoint 3.

Error 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 guard drop before you invoke anything.

Error 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 all need &mut AccountView.

Destructure accounts directly, the way Make does, so every binding is already mutable, and give any helper that mutates an account a &mut AccountView parameter.

Error missing field `multisig_signers`

Every pinocchio-token 0.6 instruction struct has it. Pass &[] as &[&AccountView] when you are not using a multisig; the cast is needed so the generic parameter can be inferred from an empty slice.

Error cannot find type `TokenAccount` in `pinocchio_token::state`

It was renamed. Use pinocchio_token::state::Account, and pass the account view directly — from_account_view(maker_ata), not from_account_view(&maker_ata).

Error Cross-program invocation with unauthorized signer

Your Seeds do not reproduce the PDA. Check, in order:

  • Is the bump the one stored in state, escrow.bump?
  • Is the address the maker's, not the taker's?
  • Is the seed literal exactly b"escrow"?
Error invalid account data for instruction, from the Token program

You are probably passing an account that is not initialised yet, usually a forgotten CreateIdempotent, or the from and to mints do not match.

Error InvalidInstructionData when I send Take

The _ => arm in lib.rs is still catching it. Add the EscrowInstructions::Take arm, rebuild, retest.

Error NotEnoughAccountKeys

The test passes fewer accounts than the destructuring pattern expects. Count the AccountMetas against the table for that instruction. Take needs twelve.

Error sum of account balances before and after instruction do not match

You called escrow_account.close() without moving its lamports to the maker first. Do the set_lamports pair, then close().

Error duplicate symbol: entrypoint

An SPL program crate in [dev-dependencies] is missing features = ["no-entrypoint"]. Add it.

Error Do I need an unsafe block to check the owner?

No. Older Pinocchio made AccountView::owner() an unsafe fn, and tutorials from that era wrap the check in unsafe { }. In 0.11 you write account.owned_by(&crate::ID), or account.owner() == &crate::ID, with no unsafe at all.

owned_by reads better as a guard clause and is what make.rs uses. The only unsafe in this codebase is the pointer cast inside Escrow::load_mut.

Ask Do Take and Cancel need a bump in their instruction data?

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.

Ask Why does load_mut return a RefMut and not &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. Rust would still stop you from reusing that one binding, but nothing would catch the same account arriving twice in the accounts list. Use it through DerefMut like a &mut Escrow, copy primitives out, and let it drop before any CPI.

Error InvalidSeeds from Make, on an escrow that does not exist yet

The escrow_account your client passed is not the canonical PDA for that maker. Make derives the canonical bump itself, so the address it expects is exactly find_program_address(&[b"escrow", maker], &PROGRAM_ID).0. Derive the AccountMeta the same way in your test.

Error Where is CloseAccount / CreateIdempotent / set_lamports?
  • pinocchio_token::instructions::CloseAccount
  • pinocchio_associated_token_account::instructions::CreateIdempotent
  • lamports(), set_lamports(u64), close(), resize(usize), owned_by(&Address) are all on AccountView, from the solana-account-view crate that pinocchio re-exports.

If your crate version lacks one, check the docs.rs page for the version pinned in Cargo.toml.

Pinocchio cheat sheet

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 pubkeyaccount.address()
Check who owns an accountaccount.owned_by(&crate::ID) or account.owner() == &crate::ID
Check an account signedaccount.is_signer()
Read SPL token account fieldspinocchio_token::state::Account::from_account_view(acc)? → .owner(), .mint(), .amount()
Find the canonical bump, init onlyAddress::find_program_address(&[seed1, seed2], &crate::ID) → (Address, u8)
Derive a PDA, bump knownpinocchio_pubkey::derive_address(&[seed1, seed2, &[bump]], None, &crate::ID.to_bytes())
Load escrow stateEscrow::load_mut(acc)? → RefMut<Escrow>; drop it before any CPI
Sign a CPI as a PDAlet s = [Seed::from(..), ..]; let signer = Signer::from(&s); ix.invoke_signed(&[signer])
Create an accountpinocchio_system::instructions::CreateAccount { from, to, lamports, space, owner }
Create an ATApinocchio_associated_token_account::instructions::Create { .. } / CreateIdempotent { .. }
Transfer SPL tokenspinocchio_token::instructions::Transfer { from, to, authority, multisig_signers, amount }
Close an SPL token accountpinocchio_token::instructions::CloseAccount { account, destination, authority, multisig_signers }
Say "no multisig"multisig_signers: &[] as &[&AccountView]
Rent-exempt minimumRent::get()?.try_minimum_balance(space)? → (128 + space) * lamports_per_byte
Read / move lamportsaccount.lamports(), account.set_lamports(n)
Close a program-owned accountmove lamports out, then account.close()?
Log somethingpinocchio_log::log!("value: {}", x)

Pinocchio escrow assignment · build before you test · fork of decentra1ized/solana-fall-pescrow