Solana Summer · Escrow Challenge

Make an offer the maker cannot instantly retract.

The escrow lets a maker cancel the moment anyone tries to take their offer. You will stamp each escrow with its creation time, hold cancellation shut for five minutes, and write the tests that prove both sides of that line.

Level
Intermediate
Time
3–5 hours
Stack
Anchor 1.0 · SPL Token
Tests
Rust · LiteSVM
Submit
Pull request

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.

The fix, in one sentence A minimum lifetime makes the offer credible. If the maker cannot cancel for five minutes, then for five minutes the offer genuinely means what it says — and a taker can act on it without racing.

What you will be working with

Anchor 1.0The framework that turns Rust into a Solana program.
SPL TokenReal token accounts and transfers, not just lamports.
LiteSVMRuns the program locally so tests are fast. Tests are Rust — cargo test.
ClockSolana's on-chain wall clock. New to you, and the heart of this challenge.

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.

00
Setup

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)
The convention 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
Order matters, and the error will not say so The test harness embeds the compiled program with 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.

01
Orientation

Read the escrow

Read README.md first — it walks through every account in every instruction. Then come back for the map.

Three instructions

InstructionWho calls itWhat moves
makemakerCreates the escrow, moves token A into the vault
takeanyoneTaker's token B → maker, vault's token A → taker, both at once
cancelmaker onlyVault'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

FileHoldsYou touch it?
src/state/escrow.rsThe Escrow account structYes
src/instructions/make.rsCreates the escrow, funds the vaultYes
src/instructions/cancel.rsReturns tokens, closes everythingYes
src/error.rsOne placeholder errorYes
src/constants.rsOne unused constantYes
src/instructions/take.rsThe swapNo — read it for context
src/lib.rsThree one-line wrappersNo — signatures do not change
tests/test_make.rsThe only existing testRead it; you will copy its setup
Notice what is missing There is no 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.

02
State

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.

Where to put it Append it after 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.
03
make

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.

This timestamp is agreed, not measured 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.

04
error.rs · constants.rs

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
Why this is not fussiness A named constant tells a reader what 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.
05
cancel

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

  1. The check runs first — before any tokens move.
  2. Exactly at the boundary is allowed. Five minutes have passed means now >= created_at + 300.
  3. Use require! with your custom error.
  4. Add the delay with checked_add, not +.
Why checked_add on a timestamp In release builds Rust wraps on overflow instead of panicking. A garbage 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.

06
tests

Travel in time

You need two tests, and they are the whole point of the assignment:

  1. Too early — cancel right after make, expect rejection.
  2. 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);
No new dependency needed 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}");
Check yourself anchor build && cargo testtest_make still passes, plus your new ones.
07
Submit

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
This repo's .gitignore only covers /target It does not ignore 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

FieldValue
base repositorydecentra1ized/solana-fall-t22-escrow
base branchmain
head repository<you>/solana-fall-t22-escrow
compare branchfeat/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.
?
When it breaks

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 move unix_timestamp. Use get_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 Cancel struct — 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