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.
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)
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
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.
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
| File | What it holds | You touch it? |
|---|---|---|
| src/lib.rs | Thin routing — each #[program] fn calls into instructions/ | Yes, signature only |
| src/state/vault_state.rs | The VaultState account struct | Yes |
| src/instructions/initialize.rs | Creates the PDAs, seeds the vault with rent | Yes |
| src/instructions/withdraw.rs | Signed CPI transfer out of the vault PDA | Yes |
| src/error.rs | One placeholder CustomError | Yes |
| src/instructions/deposit.rs | Transfer in | No |
| src/instructions/close.rs | Drain and close | No |
| src/constants.rs | PDA seeds | No |
| tests/common/mod.rs | LiteSVM harness and instruction builders | Yes |
| tests/test_withdraw.rs | Existing withdraw tests | Yes |
| tests/test_initialize.rs | Existing init tests, reads raw account bytes | Yes, call sites |
| tests/test_deposit.rs | Existing deposit tests | Yes, 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.
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.
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.
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:
-
initialize_vaultininstructions/initialize.rstakes a newmax_withdraw: u64parameter and passes it intoset_inner. -
The
initializewrapper insrc/lib.rstakes 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.
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_withdraw —
max_withdraw, on its own line rather than
max_withdraw: max_withdraw.
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.
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.
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
- The check runs before the CPI transfer, not after.
amount == max_withdrawis allowed. It is a limit, not a strict bound.- Use
require!with your custom error, not a barepanic!orassert!.
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.
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.
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_ixtakes amax_withdraw: u64and passes it intoInitialize { 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.
cargo test and work the error list top
to bottom.
The three tests
The challenge names them, and the middle one is the point:
- A valid withdrawal — under the cap, succeeds.
- Exactly at the limit —
amount == max_withdraw, succeeds. - 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
- Fresh
setup_svm(), freshKeypair,fundit. initialize_vaultwith a cap you choose — pick a round number you can reason about, like2 * ONE_SOL.- Deposit enough that the vault can actually cover the withdrawal. A rejection because the vault was empty proves nothing about your cap.
- 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.
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.
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
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:
| Field | Value |
|---|---|
| base repository | ASCorreia/solana-fall-vault |
| base branch | main |
| head repository | <you>/solana-fall-vault |
| compare branch | feat/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.
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
#[account]
#[derive(InitSpace)]
pub struct VaultState {
pub vault_bump: u8, // The bump seed for the vault account PDA
pub bump: u8, // The bump seed for the VaultState PDA itself
pub max_withdraw: u64, // Per-transaction withdrawal cap, in lamports
}
Solution Checkpoint 3 — initialize.rs and lib.rs
// instructions/initialize.rs
pub fn initialize_vault(ctx: Context<Initialize>, max_withdraw: u64) -> Result<()> {
// ... unchanged rent transfer ...
ctx.accounts.vault_state.set_inner(VaultState {
vault_bump: ctx.bumps.vault,
bump: ctx.bumps.vault_state,
max_withdraw,
});
Ok(())
}
// lib.rs
pub fn initialize(ctx: Context<Initialize>, max_withdraw: u64) -> Result<()> {
initialize::initialize_vault(ctx, max_withdraw)
}
Solution Checkpoint 4 — error.rs
#[error_code]
pub enum ErrorCode {
#[msg("Custom error message")]
CustomError,
#[msg("Withdrawal amount exceeds the per-transaction limit")]
ExceedsMaxWithdraw,
}
Solution Checkpoint 5 — withdraw.rs
use crate::{error::ErrorCode, VAULT_SEED, VAULT_STATE_SEED, VaultState};
pub fn withdraw_lamports(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
require!(
amount <= ctx.accounts.vault_state.max_withdraw,
ErrorCode::ExceedsMaxWithdraw
);
msg!("Withdrawing lamports from vault");
// ... unchanged CPI transfer ...
}
<=, so a withdrawal of exactly the cap is allowed.
Solution Checkpoint 6 — tests/common/mod.rs
pub fn build_initialize_ix(payer: &Pubkey, max_withdraw: u64) -> Instruction {
let (vault_state, _) = vault_state_pda(payer);
let (vault, _) = vault_pda(payer);
Instruction::new_with_bytes(
lamports_vault::id(),
&lamports_vault::instruction::Initialize { max_withdraw }.data(),
lamports_vault::accounts::Initialize {
user: *payer,
vault_state,
vault,
system_program: system_program::ID,
}
.to_account_metas(None),
)
}
pub fn initialize_vault(svm: &mut LiteSVM, payer: &Keypair, max_withdraw: u64) {
let ix = build_initialize_ix(&payer.pubkey(), max_withdraw);
send(svm, payer, &[ix], &[]).expect("initialize should succeed");
}
Then update the eleven call sites — pass 100 * ONE_SOL in tests that are not about the cap.
Solution Checkpoint 7 — the three tests
#[test]
fn withdraw_under_the_limit_succeeds() {
let mut svm = setup_svm();
let user = Keypair::new();
fund(&mut svm, &user.pubkey(), 10 * ONE_SOL);
let max = 2 * ONE_SOL;
initialize_vault(&mut svm, &user, max);
send(&mut svm, &user, &[build_deposit_ix(&user.pubkey(), 5 * ONE_SOL)], &[])
.expect("deposit should succeed");
send(&mut svm, &user, &[build_withdraw_ix(&user.pubkey(), max - 1)], &[])
.expect("withdrawing under the limit should succeed");
}
#[test]
fn withdraw_exactly_at_the_limit_succeeds() {
let mut svm = setup_svm();
let user = Keypair::new();
fund(&mut svm, &user.pubkey(), 10 * ONE_SOL);
let max = 2 * ONE_SOL;
initialize_vault(&mut svm, &user, max);
send(&mut svm, &user, &[build_deposit_ix(&user.pubkey(), 5 * ONE_SOL)], &[])
.expect("deposit should succeed");
// The boundary is inclusive: `amount == max_withdraw` must be allowed.
send(&mut svm, &user, &[build_withdraw_ix(&user.pubkey(), max)], &[])
.expect("withdrawing exactly the limit should succeed");
}
#[test]
fn withdraw_over_the_limit_fails() {
let mut svm = setup_svm();
let user = Keypair::new();
fund(&mut svm, &user.pubkey(), 10 * ONE_SOL);
let max = 2 * ONE_SOL;
initialize_vault(&mut svm, &user, max);
// Deposit far more than we try to withdraw, so a rejection can ONLY be the cap.
send(&mut svm, &user, &[build_deposit_ix(&user.pubkey(), 5 * ONE_SOL)], &[])
.expect("deposit should succeed");
let res = send(&mut svm, &user, &[build_withdraw_ix(&user.pubkey(), max + 1)], &[]);
assert!(res.is_err(), "one lamport over the limit must be rejected");
}
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