What an escrow is
Two people want to trade tokens, and neither wants to send first. An escrow solves that by letting a program hold the goods instead of a person.
The maker says "I will give 100 of token A for 50 of token B" and deposits their 100 A into a vault the program controls. Anyone can then be the taker: they send 50 B to the maker and receive the 100 A, both movements in a single transaction that either fully happens or fully does not. If nobody takes the offer, the maker cancels and gets their tokens back.
Nobody ever has to trust the other side. That's what "trustless" actually means in practice — not that everyone is honest, but that dishonesty has nowhere to happen.
Why the offer needs a time lock
Right now the maker can cancel at any instant, including the instant someone tries to take the offer. That is a real problem, and it is worth understanding before you write a line of code.
An offer that can be withdrawn at zero notice is a free option. The maker keeps every bit of the upside — if the market moves their way, someone takes the offer and they are happy — while paying nothing for the downside, because the moment the market moves against them they simply cancel. In traditional finance you pay a premium for that privilege. Here it is free, and the cost lands on takers.
It also wastes takers' money directly. A taker submits a transaction, the maker's cancel lands first, and the taker's transaction fails after they have already paid the fee.
What you will be working with
cargo test.Every step has hints you can open when you want them. This repo also has a real README — read it before checkpoint 1; it explains every account in the program.
Fork & set up
You cannot push to the original repo, and you should not want to. The standard open-source flow is: copy it into your own account, work on a branch there, then ask the maintainer to pull your branch in.
1 · Fork on GitHub
Open github.com/decentra1ized/solana-fall-t22-escrow and click
Fork (top right). You now own
github.com/<you>/solana-fall-t22-escrow.
2 · Clone your fork — not the original
git clone https://github.com/<you>/solana-fall-t22-escrow.git
cd solana-fall-t22-escrow
3 · Add the original as upstream
git remote add upstream https://github.com/decentra1ized/solana-fall-t22-escrow.git
git remote -v
# origin https://github.com/<you>/solana-fall-t22-escrow.git (yours — you push here)
# upstream https://github.com/decentra1ized/solana-fall-t22-escrow.git (theirs — you pull from here)
origin is always yours. upstream is always theirs. Getting
these backwards shows up later as a permission error on push, and it confuses
everybody the first time.
4 · Branch before you touch anything
git switch -c feat/escrow-timelock
Never work on main. Your main stays a clean mirror of
upstream so you can sync it whenever you like, and a pull request opened from
main is awkward to update or reuse later.
5 · Build, then test
anchor build
cargo test
include_bytes!("../../../target/deploy/escrow.so"). Run
cargo test before anchor build and you get a
compile error about a missing file, not a test failure. Build first —
every time, after every change to the program.
One test exists (test_make) and it should pass before you change
anything. If it does not, fix your environment first; you cannot tell your breakage
from theirs otherwise.
Read the escrow
Read README.md first — it walks through every account in every
instruction. Then come back for the map.
Three instructions
| Instruction | Who calls it | What moves |
|---|---|---|
| make | maker | Creates the escrow, moves token A into the vault |
| take | anyone | Taker's token B → maker, vault's token A → taker, both at once |
| cancel | maker only | Vault's token A back to the maker, escrow closed |
Two accounts hold the deal
escrow — a PDA derived from
["escrow", maker, seed]. It stores the terms: who, which mints, how
much of each, and the bump. The seed is a u16 the maker
picks, so one person can run several offers at once.
vault_a — a token account whose authority is the escrow PDA. This is where the maker's token A actually sits. Because a PDA owns it, no human can move those tokens; only the program can, and only by the rules written in it.
The files
| File | Holds | You touch it? |
|---|---|---|
| src/state/escrow.rs | The Escrow account struct | Yes |
| src/instructions/make.rs | Creates the escrow, funds the vault | Yes |
| src/instructions/cancel.rs | Returns tokens, closes everything | Yes |
| src/error.rs | One placeholder error | Yes |
| src/constants.rs | One unused constant | Yes |
| src/instructions/take.rs | The swap | No — read it for context |
| src/lib.rs | Three one-line wrappers | No — signatures do not change |
| tests/test_make.rs | The only existing test | Read it; you will copy its setup |
tests/common/mod.rs and no test for cancel at
all. The mint and token-account helpers live as plain functions at the top of
test_make.rs. In checkpoint 6 you will lift them into your own test
file — that is normal, and it is worth reading them closely now.
Unlike the last assignment, lib.rs stays untouched here. You are adding
a field and a check, not changing any instruction's arguments — so nothing ripples
outward. Fewer files, more thinking.
Remember the time
What this does. Gives each escrow somewhere to record when it was
born, so cancel can work out how old it is.
Add a created_at: i64 field to the Escrow struct in
src/state/escrow.rs.
Why i64 and not u64
Solana's unix_timestamp is a signed 64-bit integer — seconds since
1970, where negative means before 1970. You will never see a negative one here, but
matching the type the runtime gives you avoids a cast, and casts are where sign bugs
are born. Use the type the source uses.
You do not need to change space = 8 + Escrow::INIT_SPACE in
make.rs. The struct derives InitSpace, which recomputes
the size for you. That derive is doing real work — 8 more bytes of rent, calculated
automatically.
bump. It happens not to matter here — the existing test
deserializes by field name, so it tolerates reordering — but appending is the habit
you want. Plenty of code out there reads accounts at fixed byte offsets, and
reordering a struct silently breaks every one of those readers.
Stamp it in make
What this does. Records the current time into the escrow at the moment it is created.
In make.rs, the handler calls set_inner with a fully
populated Escrow. Add your new field there, filled from Solana's clock.
Where does a program get the time?
Not from the operating system — a program has no OS. Solana exposes a
sysvar called Clock, a special account the runtime keeps
updated with the current slot, epoch, and a unix_timestamp agreed by
validators.
let now = Clock::get()?.unix_timestamp;
Clock is already in scope through anchor_lang::prelude::*,
which make.rs imports. Nothing to add.
unix_timestamp is derived from validator votes, so it can drift by a
handful of seconds from your wristwatch and it is not perfectly monotonic. Fine for
"five minutes have passed." Not fine for anything needing sub-second precision, and
worth remembering the first time you are tempted to build a countdown on it.
Hint set_inner wants every field
set_inner takes a complete Escrow. Once the struct has
a new field, the compiler will refuse to build until you supply it, and it will
name the one you missed. Let it drive.
The handler ends by returning transfer_checked(...) directly — no
semicolon. Leave that alone; add your field inside the set_inner
block above it.
A real error
What this does. Names the failure, so a caller sees "the time lock has not elapsed" instead of "transaction failed."
error.rs has one placeholder variant with the message
"Custom error message". Add a real one — something like
TimeLockActive, with a message that tells the user what happened.
Leave the placeholder where it is. Anchor numbers error variants in declaration order, so deleting one shifts the codes of everything after it — and it is not yours to remove in this pull request.
Put the five minutes in constants.rs
constants.rs currently holds one unused SEED. Add your
delay there rather than writing 300 inline in the handler:
#[constant]
pub const CANCEL_DELAY_SECONDS: i64 = 300; // 5 minutes
300 means, and it puts the
number in one place when the answer to "can we make it ten minutes?" arrives. The
#[constant] attribute also exports it into the IDL, so a frontend can
read the same value instead of hardcoding its own copy and drifting out of sync.
Hold the door shut
What this does. Refuses to cancel until five minutes have passed since the escrow was made.
In cancel.rs's handler, read the clock, work out when the escrow
unlocks, and reject anything earlier.
Requirements
- The check runs first — before any tokens move.
- Exactly at the boundary is allowed. Five minutes have passed means
now >= created_at + 300. - Use
require!with your custom error. - Add the delay with
checked_add, not+.
created_at near i64::MAX plus 300 would wrap to a huge
negative number, making now >= unlock_at trivially true — the lock
opens instead of holding. It cannot happen with a real clock, but "cannot happen"
is how audit findings start. Reach for checked arithmetic whenever a number came
from stored state.
Hint Importing your error and constant
cancel.rs currently imports only use crate::Escrow;.
Extend that one line rather than adding more — the constant and the error both
live in the crate root via the pub use lines in lib.rs.
Hint The boundary, precisely
>= and > differ on exactly one second, and
checkpoint 6 asks you to test that second. "Five minutes have passed" includes
the moment five minutes have passed, so at exactly created_at + 300
the cancel must succeed.
Travel in time
You need two tests, and they are the whole point of the assignment:
- Too early — cancel right after make, expect rejection.
- Late enough — advance the clock past five minutes, expect success.
A third is worth adding if you have the appetite: cancel at exactly
created_at + 300, which must succeed. That one pins the boundary, and
it is the only test that can tell >= from >.
Waiting five real minutes is not the answer
LiteSVM lets you set the clock directly. The instinct is to reach for
warp_to_slot — and it will not work:
// Moves the slot. Leaves unix_timestamp exactly where it was.
svm.warp_to_slot(1_000_000);
Verified: after warping a fresh LiteSVM to slot 1,000,000,
unix_timestamp was still 0. Slots and wall time are
separate fields, and only one of them is what your program reads.
What does work:
use anchor_lang::prelude::Clock;
let mut clock = svm.get_sysvar::<Clock>();
clock.unix_timestamp += 301;
svm.set_sysvar(&clock);
anchor_lang::prelude::Clock is the same type LiteSVM wants, so
get_sysvar and set_sysvar accept it directly. You do not
need to add solana-clock to Cargo.toml.
Two things about a fresh LiteSVM
A new LiteSVM starts at unix_timestamp = 0. So the
created_at your make stores will be 0, and
you are advancing from zero, not from today's date. Print it once if that feels
strange — seeing it is faster than reasoning about it.
Also: setting the sysvar changes the clock from that point on. Make the
escrow first, then move the clock, then cancel. Moving it before
make just changes what gets stamped in.
Getting the setup you need
Create tests/test_cancel.rs and copy setup_mint and
setup_token_account from test_make.rs. Yes, copying is
allowed — Rust compiles each file in tests/ as its own crate, so they
genuinely cannot see each other's helpers without a shared mod.
Hint The accounts cancel needs
Fewer than make: maker, escrow,
mint_a, maker_ata_a, vault_a,
token_program. No system_program, no
mint_b — read the Cancel accounts struct and match it
exactly.
vault_a is the associated token account of the escrow PDA,
so derive it with
get_associated_token_address(&escrow_pda, &mint_a_pk), the
same way test_make does.
Hint Making the rejection test honest
assert!(res.is_err()) passes if the transaction failed for
any reason — a missing account, a wrong mint, a typo in your seeds. That
test would keep passing even if you deleted your time lock entirely.
Prove the failure is yours by checking the logs mention your error:
let err = res.unwrap_err();
let logs = err.meta.logs.join("\n");
assert!(logs.contains("TimeLockActive"), "expected the time lock error, got: {logs}");
anchor build && cargo test — test_make still
passes, plus your new ones.
Open the PR
A pull request is a request, addressed to a person. Everything here is about making that person's job easy.
1 · Look before you stage
git status
git diff
node_modules/ or
.anchor/, and the repo has a package.json and a
yarn.lock. If you ran yarn, you are one
git add . away from a pull request with thousands of files. Name your
files explicitly instead.
2 · Two commits, not one
git add programs/escrow/src/state programs/escrow/src/instructions/make.rs \
programs/escrow/src/error.rs programs/escrow/src/constants.rs \
programs/escrow/src/instructions/cancel.rs
git commit -m "Add five minute time lock before escrow cancellation"
git add programs/escrow/tests
git commit -m "Test cancellation before and after the time lock"
Imperative mood — "Add", not "Added". The feature and the tests are two separate ideas, and a reviewer should be able to read them separately.
3 · Sync before you push
git fetch upstream
git rebase upstream/main
This replays your commits on top of whatever the maintainer has done since. Resolve conflicts now, on your own time, rather than being asked to mid-review.
4 · Push to your fork
git push -u origin feat/escrow-timelock
5 · Open it in the right direction
| Field | Value |
|---|---|
| base repository | decentra1ized/solana-fall-t22-escrow |
| base branch | main |
| head repository | <you>/solana-fall-t22-escrow |
| compare branch | feat/escrow-timelock |
6 · Write a description worth reading
## What
Escrows can no longer be cancelled for 5 minutes after creation.
## Why
An offer that can be withdrawn instantly is a free option for the maker,
and it lets them cancel in front of a taker who has already paid a fee.
A minimum lifetime makes the offer credible.
## How
- `created_at: i64` appended to `Escrow`, stamped from `Clock` in `make`
- `cancel` rejects with `TimeLockActive` until `created_at + CANCEL_DELAY_SECONDS`
- delay lives in `constants.rs` and is exported to the IDL
## Testing
`anchor build && cargo test` — `test_make` still passes, plus new tests
for cancelling too early, exactly at the boundary, and after the lock.
7 · After you submit
- Leave "Allow edits by maintainers" checked, so they can push a small fix rather than asking you for one.
- Respond to review by pushing more commits to the same branch — the PR updates itself. Never open a second one.
- Do not force-push once review has started unless asked; it invalidates comments people already left.
- Reply to every comment, even just to say you have done it. Silence reads as disagreement.
Troubleshooting
Error couldn't read .../target/deploy/escrow.so
The harness embeds the compiled program at compile time, so a missing
.so is a build error rather than a test failure. Run
anchor build first, and again after every change to the program.
anchor build && cargo test as one command saves you this.
Error Cross-program invocation with unauthorized signer
The signer seeds do not derive the account you are trying to sign for. Print
both and compare: the seeds you pass must match the account's derivation
exactly, in order, including the bump last. For the escrow PDA that is
[b"escrow", maker, seed.to_le_bytes(), bump] — all four, and
leaving one out gives you an unrelated address rather than an error that says
so.
Error Your "after the lock" test still fails
Work through these in order:
- Did you use
warp_to_slot? It does not moveunix_timestamp. Useget_sysvar/set_sysvar. - Did you move the clock before calling
make? Then you only changed what got stamped in. Make first, then move. - Is the failure a signer error rather than your assertion? Then the accounts you built the instruction with do not match the
Cancelstruct — re-read it and compare field by field.
Error Your "too early" test passes even with the check deleted
You are asserting is_err() and nothing more, so any failure counts
— a wrong account, a bad mint, a typo. Assert on the logs containing your error
name instead. A test that cannot fail for the right reason is not testing
anything.
Error created_at is 0 in my tests
Correct and expected. A fresh LiteSVM starts at
unix_timestamp = 0, so an escrow made immediately records
0. You are advancing from zero, not from today. On devnet or
mainnet it would be a real timestamp.
Error feature `edition2024` is required
Your Rust is older than the dependency tree wants.
rustup update stable, and let
rust-toolchain.toml pin 1.89.0 — do not override it
with +stable, since a different compiler is a difference between
your results and everyone else's.
Error No such file or directory (os error 2)
Anchor failed to launch a binary and will not say which one. Run:
which rustc cargo solana cargo-build-sbf avm agave-install
Whichever comes back empty is your answer. Install Rust through rustup rather than Homebrew — a brew Rust lands where Anchor does not look.
Error Permission denied (403) when pushing
You are pushing to upstream instead of origin. Check
with git remote -v. If you cloned the original repo by mistake, fix
it in place:
git remote set-url origin https://github.com/<you>/solana-fall-t22-escrow.git
git remote add upstream https://github.com/decentra1ized/solana-fall-t22-escrow.git
Solana Summer · escrow · program 8hVo1qi4VPNuieLP9NFpuUcDA9CLT8aMooo9exCunTQF · submit by pull request