Description
withdraw() (L90–L105) sizes the creator's payout from a state counter instead of from the SOL actually spendable in the fund account:
pub fn withdraw(ctx: Context
let amount = ctx.accounts.fund.amount_raised; // L91 — the accounting total, not the spendable balance
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?; // L96 — fails as soon as amount_raised > spendable balance
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?; // L101 — credits the creator the full accounting total
Ok(())
}
The fund PDA is created with #[account(init, payer = creator, space = 8 + Fund::INIT_SPACE, ...)] (L111). Anchor's init enforces rent exemption and transfers rent.minimum_balance(space) from the payer into the account; for this struct that reserve is a real, permanently locked part of the balance:
Fund { name: #[max_len(200)] String, description: #[max_len(5000)] String,
goal: u64, deadline: u64, creator: Pubkey, amount_raised: u64, dealine_set: bool }
INIT_SPACE = (4+200) + (4+5000) + 8 + 8 + 32 + 8 + 1 = 5265 bytes
account space = 8 (discriminator) + 5265 = 5273 bytes
rent-exempt minimum = 3480 * (5273 + 128) * 2 lamports ≈ 0.0376 SOL
amount_raised only ever tracks contributions (= 0 at L19, += amount at L50), so the rent reserve is never part of it — but it is part of lamports(). Two consequences follow from that single mismatch:
The first withdrawal pays less than the accounting total. checked_sub(amount_raised) succeeds only because the reserve is still sitting in the account, and the creator is credited amount_raised while the fund's balance drops to exactly the reserve. The SOL that funded the campaign is not what moves out; the reserve is left behind as an unmovable remainder.
After any partial refund the fund is bricked. refund() (L73–L85) reduces the fund balance but never decrements amount_raised (its only state write is contribution.amount = 0). From that point amount_raised > spendable balance, so withdraw() reverts with InsufficientFunds — and because the fund PDA is never closed (close = does not appear anywhere in the file), nobody can recover those lamports either.
The repo's own test suite confirms the state it produces, without treating it as a bug: tests/rustfund.ts L240–L288 ("Cannot withdraw after withdrawing for the first time") asserts assert.ok(fund.amountRaised.toNumber() > fundPDABalance) — i.e. the accounting total permanently exceeding the on-chain balance is the end state of the happy path, and the follow-up test "Check can withdraw after refund" (L290–L338) only ever reaches the InsufficientFunds revert.
Risk
Contributors' SOL becomes unreachable. A fund whose campaign was partially refunded can never be withdrawn from: withdraw() reverts forever, and no instruction in the program can move the remaining lamports (there is no close, no sweep, no owner-side withdrawal of the reserve). The value is stranded in a PDA that nobody owns.
Creators systematically under-collect. Every successful withdrawal leaves ~0.0376 SOL of rent reserve behind — the creator is credited amount_raised but the campaign's spendable SOL is not what they receive; on small campaigns this is a large fraction of the raise (a 1 SOL goal loses ~3.8% to an amount that goes to no one).
The accounting total is not trustworthy for any downstream decision. amount_raised diverges from lamports() after the first refund or withdrawal, and it is the only figure the program reads when paying out — so every later payout decision is made against a number that no longer describes the money available.
Impact is high: user funds become permanently unreachable and the creator's payout is wrong in every campaign. Likelihood is high: it needs no adversarial action at all — one contribute + one withdraw (the repo's own test sequence) reaches it, and any refund makes it permanent.
Proof of Concept
Overview. withdraw() sizes the payout from fund.amount_raised, while the fund account also holds a rent reserve that amount_raised never counts and that no instruction can ever release; the first withdrawal is therefore short by exactly that reserve, and after any refund amount_raised > spendable balance makes every later withdraw() revert forever.
Actors.
Attacker / trigger: the creator, calling withdraw() in a normal campaign flow — no special privileges or timing needed.
Victim 1: contributors who refunded part of a campaign and then find the fund can never be withdrawn from (their SOL sits in the PDA with no reachable path).
Victim 2: the creator, who is credited amount_raised while ~0.0376 SOL of rent reserve stays locked in the account.
Protocol: Fund account created at L111 (init + rent exemption), paid out at L90–L105.
Anchor-level reproduction (exact arithmetic, run with rustc): audit_repro.rs in the repo root reproduces the two numeric facts that withdraw() and refund() operate on — the rent reserve of a 5273-byte fund account, and the resulting divergence between amount_raised and the spendable balance:
// Contribution / Fund account sizes come straight from the structs at L173 and L181.
let space: u64 = 8 + 72; // contribution account: 80 bytes
let required_lamports: u64 = 3480 * (space + 128) * 2; // Anchor's rent-exempt check (L1056 of anchor syn constraints)
let fund_space: u64 = 8 + 4 + 200 + 4 + 5000 + 8 + 8 + 32 + 8 + 1; // 5273 bytes, from Fund::INIT_SPACE
let fund_rent_reserve: u64 = 3480 * (fund_space + 128) * 2; // the reserve init() moved in at L111
// The repo's own test parameters: goal = 1 SOL, contribution = 0.5 SOL (tests/rustfund.ts L19-L20).
let contribution_amount: u64 = 500_000_000;
// refund(): subtracts amount from the fund but never returns the reserve (L73-L85)
let fund_balance_before = fund_rent_reserve + contribution_amount;
let fund_balance_after = fund_balance_before.checked_sub(contribution_amount).unwrap();
// withdraw(): let amount = fund.amount_raised then checked_sub (L91, L96) — this is the failing arithmetic:
let mut amount_raised = contribution_amount; // never decremented by refund()
let solana_balance = fund_balance_after;
let withdrawal_attempt = solana_balance.checked_sub(amount_raised); // None -> ProgramError::InsufficientFunds
println!("fund account space : {fund_space} bytes");
println!("rent reserve locked in the PDA: {fund_rent_reserve} lamports");
println!("after refund: amount_raised={amount_raised} but spendable balance={solana_balance}");
println!("withdraw() attempt : {withdrawal_attempt:?}");
un and output:
$ rustc audit_repro.rs -o audit_repro && ./audit_repro
fund account space : 5273 bytes
rent reserve locked in the PDA: 37590960 lamports
after refund: amount_raised=500000000 but spendable balance=37590960
withdraw() attempt : None
None is precisely what checked_sub(amount) returns at L96, which Anchor surfaces as the InsufficientFunds error the repo's own test expects.
Anchor integration test that exercises the instruction directly (tests/rustfund.ts, the sequence the repo already ships):
// 1. fund_create(goal = 1 SOL) -> init() transfers rent.minimum_balance(5273) from the creator into the fund PDA
await program.methods.fundCreate(fundName, description, new anchor.BN(1_000_000_000))... // L36-L52
// 2. contribute 0.5 SOL -> fund.amount_raised = 500_000_000 while lamports() = reserve + 0.5 SOL
await program.methods.contribute(new anchor.BN(500_000_000))... // L69-L90
// 3. withdraw() -> succeeds only because the reserve is still in the account; creator credited 0.5 SOL,
// fund PDA left holding exactly the 37_590_960 lamports of reserve
await program.methods.withdraw()... // L219-L238
// 4. a second withdraw() (or a withdraw after any refund) can no longer pay amount_raised:
await assert.rejects(() => program.methods.withdraw().rpc(), /InsufficientFunds/); // L264-L276
const fundPDABalance = await provider.connection.getBalance(fundPDA);
const fund = await program.account.fund.fetch(fundPDA);
assert.ok(fund.amountRaised.toNumber() > fundPDABalance); // L286 — accounting total permanently exceeds the balance
anchor test (Anchor 0.30.1 + local solana-test-validator) is the runner for that file; anchor build first, then anchor test. The last assertion is the program's own test suite recording the divergence as an expected outcome — it demonstrates the state, and nothing in lib.rs can ever bring those two numbers back together.
Recommended Mitigation
Pay out from what is actually spendable, not from the counter. Compute the reserve at payout time and subtract it:
pub fn withdraw(ctx: Context
let fund = &mut ctx.accounts.fund;
let rent = Rent::get()?;
let reserve = rent.minimum_balance(std::mem::size_of::
let spendable = fund
.to_account_info()
.lamports()
.checked_sub(reserve)
.ok_or(ProgramError::InsufficientFunds)?; // never touch the rent reserve
let amount = fund.amount_raised.min(spendable); // pay what exists, not what was counted
**fund.to_account_info().try_borrow_mut_lamports()? =
fund.to_account_info().lamports().checked_sub(amount).ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.lamports().checked_add(amount).ok_or(ErrorCode::CalculationOverflow)?;
fund.amount_raised = 0; // keep the counter in step with the balance
Ok(())
}
Keep the two representations of the same money consistent. refund() must decrement the total it paid out, otherwise every later payout decision is made against a stale number:
// inside refund(), next to the existing contribution.amount = 0 at L85
ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
.checked_sub(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
Release the reserve when the fund is finished. Add a closing path so the rent reserve returns to a real owner instead of being stranded in a PDA:
#[account(mut, close = creator)] // on FundWithdraw's fund field (L163)
pub fund: Account<'info, Fund>,
Closing after the final withdrawal removes the account and returns its remaining lamports to the creator; combined with fix 1 it makes "the campaign is over" a state that actually frees the money.
Test file reference
tests/rustfund.ts (the repo's own suite: Withdraws funds, Cannot withdraw after withdrawing for the first time, Check can withdraw after refund) and audit_repro.rs (added at the repo root; run with rustc audit_repro.rs -o audit_repro && ./audit_repro). Tool: Anchor 0.30.1 / anchor test + rustc.
# \[H-02] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution ## Description The `refund` function does not update `fund.amount_raised`, causing an inconsistency between the fund's actual balance and the recorded raised amount. As a result, when the fund creator tries to withdraw funds, the transaction may fail due to insufficient balance, effectively locking funds in the contract. ## Vulnerability Details The issue arises in the `refund` function, which transfers funds back to the contributor but does not update the `amount_raised` field: ```rust pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.contributor.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; Ok(()) } ``` The issue becomes evident when the fund creator attempts to withdraw using the following function: ```rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let amount = ctx.accounts.fund.amount_raised; ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.creator.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ``` Since `amount_raised` is never updated when a refund occurs, the creator will attempt to withdraw more than what actually exists in the fund, causing an insufficient funds error and failing the transaction. ## Impact - If any contributor requests a refund, the total balance in the fund decreases. However, `fund.amount_raised` remains unchanged, leading to an overestimated available balance. - When the fund creator calls `withdraw`, they attempt to transfer `fund.amount_raised`, which no longer matches the actual available balance. - This results in a failed transaction, effectively locking funds in the contract since the withdraw function will always fail if refunds have been processed. ## Proof of Concept This issue is not currently caught by tests because the `contribute` function itself has a bug (not updating `contribution.amount`), preventing the refund function from executing properly. Once the contribute function is fixed, the issue will be clearly visible in test cases. ## Recommendations The `refund` function must update `fund.amount_raised` to ensure the contract state reflects the actual balance after refunds. ### Fixed Code: ```diff pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.contributor.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; + // Fix: Decrease the fund's recorded amount_raised + let fund = &mut ctx.accounts.fund; + fund.amount_raised = fund.amount_raised.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.