Rust Fund

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

Refund does not update amount_raised, breaking fund accounting invariants

Root + Impact

Description

Normal Behavior

When a contributor successfully receives a refund, the campaign’s total raised amount (amount_raised) should decrease accordingly to accurately reflect the remaining funds held by the campaign.

This ensures:

  • correct fund accounting

  • safe withdrawals

  • consistent on-chain state

Issue

The refund instruction transfers SOL back to the contributor and resets contribution.amount, but never updates fund.amount_raised.

As a result, the fund’s internal accounting no longer reflects the actual lamports held by the fund account.

// Root cause in the codebase with @> markers
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
...
**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)?;
@> ctx.accounts.contribution.amount = 0;
Ok(())
}

No corresponding reduction is applied to fund.amount_raised.

Risk

Likelihood:

  • Reason 1: This occurs every time refund is successfully executed.

  • Reason 2: There is no compensating logic elsewhere that adjusts amount_raised.

Impact:

  • Impact 1: amount_raised can overstate the actual funds held by the campaign.

  • Impact 2: The creator may later withdraw more SOL than remains in the fund account, causing failed withdrawals or inconsistent protocol behavior.

Proof of Concept

1. A contributor deposits 10 SOL.
2. `fund.amount_raised` becomes 10.
3. The campaign fails and the contributor calls `refund`.
4. 10 SOL is transferred back to the contributor.
5. `fund.amount_raised` remains 10 despite the fund holding 0 SOL.
6. The creator later calls `withdraw`, relying on an incorrect `amount_raised` value.

Explanation:

Because the fund’s accounting variable is not updated during refunds, the protocol’s internal state diverges from the actual lamport balance, violating core accounting invariants.

Recommended Mitigation

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let amount = ctx.accounts.contribution.amount;
...
**fund.to_account_info().try_borrow_mut_lamports()? =
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)?;
+ fund.amount_raised = fund
+ .amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0;
Ok(())
}

Explanation:

Updating amount_raised during refunds ensures the campaign’s recorded total always matches the actual funds held, preventing downstream logic from relying on stale or incorrect state.

Updates

Lead Judging Commences

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