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
| 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 |
Make instruction here runs in about 30k CU.
The files you will touch
Search the code for TODO (challenge). That comment in
lib.rs marks where Take and Cancel plug in.
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.
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.
- 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
Tools this repo expects
| 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 |
Crates this repo pins
| 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- | 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 |
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
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
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.
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. 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 yourTakeandCancelcalls 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 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. Use it throughDerefMutexactly like a&mut Escrow.
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.
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); }
}
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.
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.
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.
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:
- 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.
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,
});
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.
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
| # | Account | W | S | Notes |
|---|---|---|---|---|
| 0 | taker | ✓ | ✓ | Pays fees; funds its 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 creating | |
| 7 | taker_ata_b | ✓ | ATA(taker, mint B), source of B. Must exist with enough balance | |
| 8 | maker_ata_b | ✓ | ATA(maker, mint B), destination for B. May need creating | |
| 9 | system_program | |||
| 10 | token_program | |||
| 11 | associated_token_program |
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
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
- 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.
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.
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
| # | Account | W | S | 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 |
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
- Destructure. Check
maker.is_signer(). - Load escrow state with
Escrow::load_mut, verify program ownership, verifyescrow.maker() == maker.address()andescrow.mint_a() == mint_a.address(). Copy outbump, drop the guard. - 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 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.
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.
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.
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
- 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 |
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
| 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 |
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.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.
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::CloseAccountpinocchio_associated_token_account::instructions::CreateIdempotentlamports(),set_lamports(u64),close(),resize(usize),owned_by(&Address)are all onAccountView, from thesolana-account-viewcrate thatpinocchiore-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 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) |
Pinocchio escrow assignment · build before you test · fork of decentra1ized/solana-fall-pescrow