Solana Summer · Assignment 01 · Vault Challenge

Cap what a vault can pay out in one transaction.

The vault works. It has no limits. Add a per-transaction withdrawal ceiling — stored in state, set at initialize, enforced on withdraw — then prove it with tests and submit the whole thing as a pull request.

Level
Beginner–Intermediate
Time
2–4 hours
Stack
Anchor 1.1.2 · LiteSVM
Tests
Rust, not TS
Submit
Pull request

What you are building

lamports-vault already works. Someone can create one, put SOL in, and take SOL out. The catch: right now they can take everything out in a single go.

Your job is to add a spending limit. Each vault gets a max_withdraw amount, set when it is created, and any withdrawal bigger than that gets turned away.

The change itself is small — one new field, one check, and a clear error message. Most of your time will go into updating the tests so they pass again. That is normal, and it is a big part of what changing someone else's code actually looks like.

00
Setup

Fork & set up

You do not have write access to Andre's repo, and you should not want it. The standard open-source flow is: fork 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/ASCorreia/solana-fall-vault and click Fork (top right). That creates github.com/<you>/solana-fall-vault — a full copy under your account that you own completely.

2 · Clone your fork, not the original

git clone https://github.com/<you>/solana-fall-vault.git
cd solana-fall-vault

3 · Add the original as upstream

Your clone knows about your fork (origin) and nothing else. Adding the original as a second remote lets you pull in changes the maintainer makes while you work.

git remote add upstream https://github.com/ASCorreia/solana-fall-vault.git
git remote -v

# origin    https://github.com/<you>/solana-fall-vault.git   (fetch/push)
# upstream  https://github.com/ASCorreia/solana-fall-vault.git (fetch/push)
The convention origin is always yours — the one you push to. upstream is always theirs — the one you pull from. Getting these backwards is the most common first-fork mistake, and it usually shows up as a permission error on push.

4 · Branch before you touch anything

git switch -c feat/max-withdraw

Never do the work on main. Two concrete reasons: your main stays a clean mirror of upstream so you can sync it at any time, and a PR opened from main is painful to update or reuse for a second contribution.

5 · Build and run the existing tests

anchor build
cargo test
Order matters, and the failure is not obvious The test harness loads the compiled program with include_bytes!("../../../../target/deploy/lamports_vault.so"). If you run cargo test before anchor build, you get a compile error about a missing file, not a test failure. Build first, every time.

All existing tests should pass before you change a line. If they do not, fix your environment first — you cannot tell your breakage from theirs otherwise.

01
Orientation

Find the work

Here is a map of the repo for you, give it a read and check back when you are struggling on finding where you need to make changes

FileWhat it holdsYou touch it?
src/lib.rsThin routing — each #[program] fn calls into instructions/Yes, signature only
src/state/vault_state.rsThe VaultState account structYes
src/instructions/initialize.rsCreates the PDAs, seeds the vault with rentYes
src/instructions/withdraw.rsSigned CPI transfer out of the vault PDAYes
src/error.rsOne placeholder CustomErrorYes
src/instructions/deposit.rsTransfer inNo
src/instructions/close.rsDrain and closeNo
src/constants.rsPDA seedsNo
tests/common/mod.rsLiteSVM harness and instruction buildersYes
tests/test_withdraw.rsExisting withdraw testsYes
tests/test_initialize.rsExisting init tests, reads raw account bytesYes, call sites
tests/test_deposit.rsExisting deposit testsYes, call sites

Read these two before continuing

initialize.rs — see how set_inner writes the whole struct at once. Adding a field means this call stops compiling until you supply it, which is Rust doing you a favor.

withdraw.rs — see how the vault PDA signs for itself via signer_seeds. Your check goes in before that transfer ever happens.

02
State

Add the field

What this does. Gives every vault somewhere to remember its own limit. The cap belongs in state, not in a constant — different vaults should be able to have different ceilings.

Add max_withdraw: u64 to VaultState.

You do not need to touch space = in initialize.rs. The struct derives InitSpace, and VaultState::INIT_SPACE recalculates itself. That is the entire reason that derive exists.

Put it AFTER the two bumps. This one bites.

test_initialize.rs reads the account's raw bytes and asserts data[8] == vault_bump and data[9] == bump. Anchor's discriminator takes bytes 0–8, and Borsh writes fields in declaration order with no padding — so the first struct field lands at byte 8.

03
initialize

Set it at init

What this does. Lets the caller choose the cap when they create the vault, and writes it into state.

Two edits, and they must agree:

  1. initialize_vault in instructions/initialize.rs takes a new max_withdraw: u64 parameter and passes it into set_inner.
  2. The initialize wrapper in src/lib.rs takes the same parameter and forwards it.

Miss the second and nothing compiles — the wrapper calls a function whose signature no longer matches. That is the routing layer earning its keep.

Why an argument rather than a constant A hardcoded ceiling would be simpler and worse. Taking it as an instruction argument means the cap is a property of each vault, chosen by whoever creates it. It also means the instruction's wire format changes — which is what checkpoint 6 is about.
Hint set_inner wants every field

set_inner takes a complete VaultState. Once the struct has three fields, you supply three. Rust names the missing one for you, so let the compiler drive.

Shorthand works if your parameter is named max_withdrawmax_withdraw, on its own line rather than max_withdraw: max_withdraw.

04
error.rs

A real error

What this does. Gives the failure a name and a message a caller can actually act on.

error.rs currently holds one placeholder variant with the message "Custom error message". Add a real one — something like ExceedsMaxWithdraw with a message that says what went wrong.

Why not just return a generic error Anchor assigns each variant a stable numeric code and surfaces the message in transaction logs. A client can branch on the specific code to show "over your limit" instead of "transaction failed". Vague error messages are how users end up filing support tickets that say "it didn't work".

Leave the placeholder CustomError where it is. Deleting it would shift every error code after it, and it is not yours to remove in this PR.

05
withdraw

Enforce the cap

What this does. Rejects any withdrawal larger than the vault's limit, before any lamports move.

In withdraw_lamports, compare amount against ctx.accounts.vault_state.max_withdraw and bail with your new error if it is larger.

Requirements

  1. The check runs before the CPI transfer, not after.
  2. amount == max_withdraw is allowed. It is a limit, not a strict bound.
  3. Use require! with your custom error, not a bare panic! or assert!.
Hint Importing the error type

withdraw.rs does not import ErrorCode yet. Extend the existing use crate::{...} line rather than adding a second one — the file already pulls in VAULT_SEED and friends the same way.

Hint Off-by-one on the boundary

require!(amount <= max, ...) and require!(amount < max, ...) differ on exactly one input, and the challenge asks you to test that input specifically. "Exceeds the limit" means strictly greater than. A withdrawal of exactly the cap must succeed.

Check yourself anchor build succeeds and the program logic is done. The remaining checkpoints are about the tests — which currently do not compile, and that is expected.
06
tests/common

Repair the harness

Changing an instruction's arguments changes its serialized data, so every test that builds an initialize instruction is now wrong. The compiler will list them; here is the whole set up front.

In tests/common/mod.rs

  • build_initialize_ix takes a max_withdraw: u64 and passes it into Initialize { max_withdraw }.
  • initialize_vault (the convenience wrapper) takes it too and forwards it.

For the tests that are not about the limit, pass something generous — 100 * ONE_SOL — so the cap never interferes with what they were written to check. A deposit test failing because of a withdrawal ceiling would be a bad test.

Hint Where to fix tests

Read and understand build_initialize_ix and initialize_vault in tests/common/mod.rs - What must change in initialization now that we've added a max withdraw? Afterwards Look through test_deposit.rs, test_initialize.rs, and test_withdraw.rs for any test that is calling the updated functions and fix according to your updates.

This is the actual lesson of the checkpoint Four lines of program logic just forced edits across four files and eleven call sites. That ratio is normal when you change a shared interface, and it is why "just add a parameter" is never as small as it sounds. Let the compiler enumerate the damage rather than hunting by hand — run cargo test and work the error list top to bottom.
07
tests/test_withdraw.rs

The three tests

The challenge names them, and the middle one is the point:

  1. A valid withdrawal — under the cap, succeeds.
  2. Exactly at the limitamount == max_withdraw, succeeds.
  3. Over the limit — one lamport more, fails.

Two and three are a boundary pair. Together they pin down exactly where the line sits, and they are the only tests that can tell <= from <. A suite with only "works" and "obviously too much" passes for both implementations, one of which is wrong.

Shape of each test

  1. Fresh setup_svm(), fresh Keypair, fund it.
  2. initialize_vault with a cap you choose — pick a round number you can reason about, like 2 * ONE_SOL.
  3. Deposit enough that the vault can actually cover the withdrawal. A rejection because the vault was empty proves nothing about your cap.
  4. Attempt the withdrawal and assert on the result.
Hint Asserting success vs failure

send(...) returns a Result. Existing tests use .expect("...should succeed") for the happy path and assert!(res.is_err(), "...") for rejection. Follow the file's style rather than inventing your own.

Hint Making the over-limit test honest

Use max_withdraw + 1, not some large round number. One lamport over is the smallest input that must fail, so it cannot pass for an unrelated reason — insufficient funds, a bad PDA, an arithmetic overflow somewhere else.

Stronger still: assert the failure logs mention your error. A test that accepts any error will keep passing after you break the vault in a completely different way.

Check yourself anchor build && cargo test — every pre-existing test still passes, plus your three. If an old test broke, you changed behavior you were not asked to change. Go back to checkpoint 2 and check your field order.
08
Submit

Submit Via PR

A pull request is a request, addressed to a person. Everything below is about making that person's job easy.

1 · Check what you are about to commit

git status
git diff
This repo's .gitignore only covers /target It does not ignore node_modules/ or .anchor/. If you ran yarn, you are one git add . away from a pull request containing thousands of files. Read git status before staging, every time — and prefer naming your files explicitly over git add ..

2 · Commit in logical pieces

git add programs/lamports-vault/src
git commit -m "Add per-transaction withdrawal limit to vault state"

git add programs/lamports-vault/tests
git commit -m "Test withdrawal limit at, under, and over the cap"

Imperative mood — "Add", not "Added" or "Adding". It reads as an instruction to the codebase, which is the convention git itself uses. Two focused commits beat one final changes, and beat nine commits called fix.

3 · Sync with upstream before you push

git fetch upstream
git rebase upstream/main

If the maintainer changed anything while you worked, this replays your commits on top of their latest. Doing it now means resolving conflicts on your own time instead of being asked to in review.

4 · Push to your fork

git push -u origin feat/max-withdraw

To origin — your fork. You have no write access to upstream, and a push there will be rejected.

5 · Open the pull request

GitHub shows a "Compare & pull request" banner on your fork right after the push. Check the direction carefully before submitting:

FieldValue
base repositoryASCorreia/solana-fall-vault
base branchmain
head repository<you>/solana-fall-vault
compare branchfeat/max-withdraw

6 · Write a description worth reading

## What
Adds a per-transaction withdrawal limit to the lamports vault.

## Why
The vault currently allows draining the full balance in a single
withdraw. A per-transaction cap bounds the damage from a leaked key
or a buggy client.

## How
- `max_withdraw: u64` appended to `VaultState` (appended, not
  prepended, so the byte offsets `test_initialize` asserts on stay valid)
- set from a new `initialize` argument
- `withdraw` rejects `amount > max_withdraw` with `ExceedsMaxWithdraw`

## Testing
`anchor build && cargo test` — all existing tests pass, plus three new
ones covering under, exactly at, and one lamport over the cap.

Explain the why and any non-obvious decision. The field-order note is exactly the kind of thing that earns trust: it shows you understood a consequence a reviewer would otherwise have to catch.

7 · After you submit

  • Leave "Allow edits by maintainers" checked. It lets them push a small fix instead of asking you for one.
  • Respond to review by pushing more commits to the same branch — the PR updates itself. Do not open a second PR.
  • Do not force-push once review has started unless asked; it invalidates the comments people have already left.
  • Reply to every comment, even just to say you have done it. Silence reads as disagreement.
S
Reference

Solutions

Let the compiler walk you through it first. Most of this challenge is Rust telling you precisely what is broken, and reading a solution skips the part where you learn to read those messages.

Solution Checkpoint 2 — state/vault_state.rs
Solution Checkpoint 3 — initialize.rs and lib.rs
Solution Checkpoint 4 — error.rs
Solution Checkpoint 5 — withdraw.rs
Solution Checkpoint 6 — tests/common/mod.rs
Solution Checkpoint 7 — the three tests
?
When it breaks

Troubleshooting

Error couldn't read .../target/deploy/lamports_vault.so

The test harness embeds the compiled program at compile time with include_bytes!, 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, before cargo test.

Error vault_bump mismatch, in a test you never touched

You put max_withdraw before the bumps in VaultState. test_initialize.rs reads data[8] and data[9] directly, and your new u64 now occupies those bytes. Move the field to the end of the struct. Checkpoint 2 has the byte-level comparison.

Error this function takes 3 arguments but 2 were supplied

Expected — you changed a shared helper and the call sites have not caught up. Checkpoint 6 lists all eleven. Work the compiler's list from the top; each fix usually reveals the next.

Error Your tests pass but the limit does not work

Most likely you rebuilt nothing. cargo test uses whatever .so is on disk, so testing after editing .rs files but before anchor build tests your previous program. Use anchor build && cargo test as one command and the problem disappears.

Error Permission denied (403) when pushing

You are pushing to upstream instead of origin. Check with git remote -v: origin must be <you>/solana-fall-vault. If you cloned the original repo by mistake, fix it without re-cloning:

git remote set-url origin https://github.com/<you>/solana-fall-vault.git
git remote add upstream https://github.com/ASCorreia/solana-fall-vault.git
Error Your PR shows hundreds of changed files

You committed node_modules/ or another build directory — the repo's .gitignore only covers /target. Remove them from git while keeping them on disk, then amend:

git rm -r --cached node_modules .anchor
echo "node_modules/" >> .gitignore
echo ".anchor/" >> .gitignore
git add .gitignore
git commit --amend --no-edit
git push --force-with-lease

Force-pushing is acceptable here because the PR has not been reviewed yet. Once someone has commented, ask before rewriting history.

Error Rust version or toolchain complaints

rust-toolchain.toml pins 1.98.0, and rustup installs it automatically on first build. Do not override it with +stable — a different compiler is a difference between your results and everyone else's.

This project uses Anchor 1.1.2. If you have seen 0.31 tutorials, note that CpiContext::new takes a Pubkey here rather than an AccountInfo — the existing code is correct, do not "fix" it.

Solana Summer · lamports-vault · program 9AvGuh5C8cYcYU7RwwWQU9iDFqWpjQ9MnGZ8cfbkJPLc · submit by pull request