Rust Fund

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

amount_raised desynchronizes from real lamports in refund/withdraw — rent-exempt reserve drained, Fund account purged

Root + Impact

Description

  • Normal behavior: `fund.amount_raised` should always equal the real SOL balance of the `fund` PDA (minus rent), so withdrawals and refunds never exceed or fall short of available funds.

  • Specific issue: `refund` subtracts lamports and sets `contribution.amount = 0` but never decrements `fund.amount_raised`. `withdraw` reads `fund.amount_raised` and transfers it but never resets it to 0. The internal counter drifts from the real balance. `withdraw` can also be re-called after new contributions and pull more than collected since the last withdraw. Worse, nothing protects the rent-exempt minimum: if lamports drop below it, the runtime purges the `Fund` account and all state is lost.

// Root cause in the codebase with @> marks to highlight the relevant section
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// @> fund.amount_raised never decremented here
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ...;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? = ...;
ctx.accounts.contribution.amount = 0;
Ok(())
}
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> fund.amount_raised never reset to 0 after transfer
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ...;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ...;
Ok(())
}

Risk

Likelihood:

  • Reason 1: After any refund, `amount_raised` stays high while real balance drops, so the next `withdraw` over-draws.

  • Reason 2: A `withdraw` that ignores the rent reserve can push the PDA below rent-exemption, purging it.

Impact:

  • Impact 1: Over-withdrawal drains the rent reserve and/or other contributors' funds.

  • Impact 2: PDA purge destroys campaign state and any residual funds irrecoverably.

Proof of Concept

This test proves the desync drains the rent reserve and purges the Fund account. We create a fund, contribute the goal amount, set deadline in the past, refund (which does not decrement amount_raised), then withdraw the full amount_raised. The PDA drops below rent-exemption and is purged by the runtime.


it("H-5: amount_raised desync drains rent reserve", async () => {
await program.methods.fundCreate(fundName, description, goal).accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId }).rpc();
await program.methods.contribute(goal).accounts({ fund: fundPDA, contributor: contributor.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId }).signers([contributor]).rpc();
await program.methods.setDeadline(new anchor.BN(Math.floor(Date.now() / 1000) - 10)).accounts({ fund: fundPDA, creator: creator.publicKey }).rpc();
await program.methods.refund().accounts({ fund: fundPDA, contribution: contributionPDA, contributor: contributor.publicKey, systemProgram: anchor.web3.SystemProgram.programId }).signers([contributor]).rpc();
await program.methods.withdraw().accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId }).rpc();
const info = await provider.connection.getAccountInfo(fundPDA);
expect(info).to.be.null;
});

Recommended Mitigation

Update state before any transfer (checks-effects-interactions) and protect the rent-exempt reserve so the Fund account is never purged.


pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
// ...
fund.amount_raised = fund.amount_raised
.checked_sub(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0;
// ... lamports logic ...
}
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &ctx.accounts.fund;
let amount = fund.amount_raised;
+ let rent = Rent::get()?.minimum_balance(fund.to_account_info().data_len());
+ require!(
+ fund.to_account_info().lamports().checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)? >= rent,
+ ErrorCode::InsufficientFunds
+ );
fund.amount_raised = 0;
// ... transfer ...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!