Rust Fund

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

[L-01] Refund Does Not Decrement amount_raised

[L-01] Refund Does Not Decrement amount_raised

Description

  • When a refund is processed, the lamports are transferred out of the fund account and contribution.amount is zeroed. The amount_raised field should be decremented to reflect the reduced fund balance.

  • The refund logic never decrements fund.amount_raised. The accounting state becomes inconsistent with the actual fund balance.

@> // refund() transfers lamports out but never decrements fund.amount_raised
ctx.accounts.contribution.amount = 0;

Risk

Likelihood: High

  • Every refund execution leaves amount_raised stale.

Impact: Low

  • amount_raised overstates the actual fund balance. If withdraw() checks this value (per H-03 fix), the creator would attempt to withdraw more than available, causing InsufficientFunds.

Severity: Low

Proof of Concept

A contributor deposits 5 SOL, then after the deadline passes, calls refund(). The lamports leave the fund account, but amount_raised remains unchanged — the accounting state diverges from the actual balance.

it("Proves refund does not decrement amount_raised", async () => {
await program.methods.fundCreate("poc-l001", "test", goal).rpc();
await program.methods.setDeadline(shortDeadline).rpc();
await program.methods.contribute(new anchor.BN(5 * LAMPORTS_PER_SOL)).rpc();
let fund = await program.account.fund.fetch(fundPDA);
const amountBefore = fund.amountRaised.toNumber();
expect(amountBefore).to.equal(5 * LAMPORTS_PER_SOL);
// Wait for deadline, then refund
await sleep(6000);
await program.methods.refund().rpc();
// amount_raised is STILL 5 SOL even though lamports were transferred out
fund = await program.account.fund.fetch(fundPDA);
expect(fund.amountRaised.toNumber()).to.equal(amountBefore); // <-- NOT DECREMENTED
// Actual fund balance is now lower than amount_raised claims
const fundBalance = await provider.connection.getBalance(fundPDA);
console.log(`amount_raised: ${fund.amountRaised} vs actual balance: ${fundBalance}`);
});
## Recommended Mitigation
Subtract the refunded amount from `amount_raised` using checked arithmetic. This restores the invariant that `amount_raised` always reflects the actual SOL held by the fund PDA. Without this fix, the H-03 mitigation (which adds a goal check on `amount_raised`) would attempt to withdraw more lamports than the fund holds post-refund, causing a runtime panic.
```diff
ctx.accounts.contribution.amount = 0;
+ fund.amount_raised = fund.amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 3 days 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!