Rust Fund

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

refund() decrements amount_raised permanently, allowing partial refunds to push a successful campaign below goal and lock the creator out of withdraw()

Root + Impact

Description

  • refund() at line 85 sets ctx.accounts.contribution.amount = 0 (zeroing the contribution record) but never decrements fund.amount_raised. This means the protocol's accounting for whether a campaign reached its goal is stale after any refund. When a creator calls withdraw(), it reads fund.amount_raised (line 91) — but that value no longer reflects actual lamports held by the PDA after refunds have occurred, and there is no mechanism to re-evaluate goal attainment after the fact.

  • The practical outcome: a campaign that legitimately reached its goal can have amount_raised remain at or above goal while the PDA's actual lamport balance has been reduced by refunds. If the protocol were later fixed to add a goal check to withdraw() (the recommended fix for H-01), the combination of H-01's fix and this inconsistency would permanently lock the creator out of their funds. Even in the current codebase, a creator who withdraws after partial refunds will attempt to transfer amount_raised lamports but the PDA may hold fewer, causing the checked_sub at line 95 to return ProgramError::InsufficientFunds and revert — locking the creator's withdrawal permanently with no recovery path.

// lib.rs lines 66–88 — refund does NOT update amount_raised
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// ... deadline check ...
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount) // PDA lamports decrease
.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; // contribution zeroed
// fund.amount_raised is NEVER decremented here
Ok(())
}
// lib.rs lines 90–105 — withdraw reads stale amount_raised
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised; // stale after refunds
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount) // panics if PDA holds < amount_raised
.ok_or(ProgramError::InsufficientFunds)?;

Risk

Likelihood:

  • Only requires one contributor to have successfully called refund() after a campaign reaches its goal, which is a normal user action.

  • The state divergence between fund.amount_raised and the PDA's actual lamport balance is permanent — there is no admin reset or recovery instruction in the program.

  • The triggering condition (someone refunds after a goal is reached) becomes likely in any campaign that hits its goal and then has its deadline expire, since the current refund logic does not prevent goal-met refunds (see H-04).

Impact:

  • The creator cannot withdraw their raised funds. The lamports remain locked in the PDA forever because withdraw() will always attempt to transfer the stale amount_raised value, which exceeds the PDA's actual balance.

  • There is no close instruction or emergency drain. The SOL is permanently stuck — neither the creator nor any admin can retrieve it.

  • Funds remaining in the PDA after partial refunds (the non-refunded contributors' SOL) are permanently locked along with the creator's legitimate withdrawal.

Proof of Concept

grep -n "amount_raised\|contribution.amount\|fund.amount_raised" programs/rustfund/src/lib.rs
19: fund.amount_raised = 0;
50: fund.amount_raised += amount;
85: ctx.accounts.contribution.amount = 0;
91: let amount = ctx.accounts.fund.amount_raised;
189: pub amount_raised: u64,

Line 50 increments fund.amount_raised on every contribution, and line 85 zeroes contribution.amount on refund — but no line in refund() decrements fund.amount_raised. Line 91 reads the stale amount_raised as the withdrawal amount. After any refund, these two quantities diverge and the PDA's actual lamport balance is less than what withdraw() will attempt to move, causing a permanent InsufficientFunds revert on every subsequent creator withdrawal attempt.

Recommended Mitigation

ctx.accounts.contribution.amount = 0;
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
Ok(())

Decrement fund.amount_raised by the refunded amount inside refund(). This keeps amount_raised synchronized with the PDA's actual lamport balance, ensuring withdraw() never attempts to transfer more than the fund holds.

Updates

Lead Judging Commences

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