Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

`withdraw` has no deadline or goal checks — the creator can drain every contribution at any time

Root + Impact

Description

Normal behavior: Per the protocol, the creator may withdraw only after the campaign succeeds — the goal must be met by the deadline ("Creators can withdraw funds once their campaign succeeds"; contributors get refunds when the deadline is reached and the goal is not met). withdraw must therefore enforce: a deadline was set, the deadline has passed, and amount_raised >= goal.

The issue: withdraw performs none of those checks. It reads fund.amount_raised and moves that many lamports from the campaign vault to the creator's wallet unconditionally. A creator can call it immediately after the first contribution — with the deadline far in the future and the goal unmet — and drain the entire vault. After that, contributors have nothing left to refund: the vault is empty, so the refund path can only fail (InsufficientFunds) or, with the H01 accounting bug, silently return 0. There is also no terminal state: withdraw neither marks the campaign withdrawn nor zeroes amount_raised, so the function is callable again and the vault/ledger can further diverge.

Root cause in programs/rustfund/src/lib.rs:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> no checks anywhere:
// @> - no requirement that a deadline was set / has been reached
// @> - no requirement that amount_raised >= goal (campaign succeeded)
// @> - no state flip marking the campaign as withdrawn
**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(())
}

The only constraint on FundWithdraw is that the signer owns the campaign (has_one = creator); nothing gates when the payout may happen.

Risk

Likelihood:

  • Reason 1 — The capability is unconditional: every campaign, from its first contribution onward, lets the creator pull the whole amount_raised. No state or time condition has to be arranged.

  • Reason 2 — The failure case is the protocol's core promise: a creator whose campaign is still live (deadline not reached, goal not met) — or a failed campaign — can take all funds, and the refund mechanism no longer has any SOL to return.

Impact:

  • Impact 1 — Total loss of contributors' funds on any campaign whose creator withdraws early: the vault is emptied before the success condition is ever evaluated.

  • Impact 2 — The refund guarantee is meaningless in practice — refunds only exist while the vault still holds the deposits, and withdraw can always be called first.

Proof of Concept

Anchor/TS against the shipped program (deterministic from the code):

// Creator sets a fund with goal 1 SOL and a deadline 1 week in the future.
await program.methods.fundCreate("camp", "", new anchor.BN(1_000_000_000))
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: SYSTEM }).rpc();
await program.methods.setDeadline(new anchor.BN(now + 604_800))
.accounts({ fund: fundPDA, creator: creator.publicKey }).rpc();
// A contributor deposits 0.5 SOL — campaign is LIVE, goal NOT met.
await program.methods.contribute(new anchor.BN(500_000_000))
.accounts({ fund: fundPDA, contributor: contributor.publicKey, contribution: contributionPDA, systemProgram: SYSTEM })
.signers([contributor]).rpc();
// Creator immediately withdraws everything — no error:
await program.methods.withdraw()
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: SYSTEM }).rpc();
// Vault is now empty; contributor's refund (later) finds 0 lamports to return.

Recommended Mitigation

Gate the payout on the success condition and make the terminal state explicit:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ // success condition: deadline set, reached, and goal met
+ let fund_state = &ctx.accounts.fund;
+ require!(fund_state.deadline != 0, ErrorCode::DeadlineNotSet);
+ require!(
+ (fund_state.deadline as i64) < Clock::get()?.unix_timestamp,
+ ErrorCode::DeadlineNotReached
+ );
+ require!(
+ fund_state.amount_raised >= fund_state.goal,
+ ErrorCode::GoalNotReached
+ );
+
let amount = ctx.accounts.fund.amount_raised;
...
+ // mark withdrawn so the payout cannot repeat
+ ctx.accounts.fund.amount_raised = 0;
Ok(())
}

(Add the referenced DeadlineNotSet / GoalNotReached variants to ErrorCode.)

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!