Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Severity: medium
Valid

Refund never decrements amount_raised — withdraw blocked after any refund

Title: Refund never decrements amount_raised — withdraw blocked after any refund
Impact: Medium. After any contributor refunds, the creator can never withdraw remaining funds.
Likelihood: High. Any successful refund triggers the stale state — no recovery path.
Reference Files: programs/rustfund/src/lib.rs:66-88,90-105

Description

The refund() instruction transfers SOL from the fund PDA back to the contributor but never decrements fund.amount_raised. After a refund, amount_raised reports the pre-refund value while the actual lamport balance has decreased. When withdraw() reads amount_raised and attempts checked_sub, the subtraction fails because the fund no longer holds enough lamports — permanently blocking all future withdrawals.

// refund(): transfers SOL, amount_raised NOT decremented
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount).ok_or(ProgramError::InsufficientFunds)?;
// MISSING: fund.amount_raised -= amount;
// withdraw(): reads stale amount_raised → FAILS
let amount = ctx.accounts.fund.amount_raised; // includes refunded SOL

The two state variables permanently diverge after the first refund with no reconciliation mechanism.

Risk

Impact: Medium. After any single refund, the creator can never call withdraw() successfully. All remaining legitimate contributions are locked in the fund PDA forever.
Likelihood: High. The first refund permanently corrupts the state. No reset function or reconciliation path exists.
With 3 contributors at 10 SOL each and 1 refunding: amount_raised stays at 30 while the fund holds 20 SOL. Creator's withdraw() for 30 from 20 SOL reverts — 20 SOL locked forever.

Proof of Concept

it("Withdraw blocked after any refund", async () => {
await program.methods.contribute(new anchor.BN(5_000_000_000))
.accounts({ fund: fundPDA, contributor: contributor.publicKey, contribution: c, systemProgram: SystemProgram.programId }).rpc();
const raisedBefore = (await program.account.fund.fetch(fundPDA)).amountRaised;
await new Promise(r => setTimeout(r, 15000));
await program.methods.refund()
.accounts({ fund: fundPDA, contribution: c, contributor: contributor.publicKey, systemProgram: SystemProgram.programId }).rpc();
assert((await program.account.fund.fetch(fundPDA)).amountRaised.eq(raisedBefore)); // STALE
try {
await program.methods.withdraw()
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: SystemProgram.programId }).rpc();
assert.fail("Should revert");
} catch (e) { assert(e.toString().includes("InsufficientFunds")); }
});

The PoC proves amount_raised stays unchanged and withdraw() reverts permanently after any refund.

Recommended Mitigation

fund.amount_raised = fund.amount_raised.checked_sub(amount)
.ok_or(ErrorCode::CalculationOverflow)?;

Decrement amount_raised by the refunded amount to keep accounting state consistent with the lamport balance.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 23 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution

# \[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(()) } ```

Support

FAQs

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

Give us feedback!