RustFund

First Flight #36
Beginner FriendlyRust
100 EXP
View results
Submission Details
Severity: high
Invalid

The `amount_raised` may not reflect the actual SOL balance if funds are sent directly leading to loss of fund

Summary

The amount_raised field in the Fund account, intended to track the total SOL contributed to a crowdfunding campaign, can become inconsistent with the actual SOL balance of the fund account if SOL is sent directly to the fund’s address outside the FundContribute function. This is possible because Solana allows native SOL transfers to any account’s public key (a Program Derived Address, or PDA, in this case) via standard transactions, bypassing the contract’s logic. Since amount_raised is only updated within FundContribute, direct transfers are not accounted for, leading to discrepancies that affect withdrawal and refund operations. This design flaw could result in inaccurate accounting, unexpected behavior, or loss of funds, making it a notable issue.


Vulnerability Details

The vulnerability arises because the contract assumes all SOL contributions flow through the FundContribute function, which updates amount_raised. However, in Solana, any user or program can send SOL directly to the fund’s PDA (derived from [name.as_bytes(), creator.key().as_ref()]) without invoking the contract, and the contract does not reconcile these external deposits with amount_raised.

Root Cause

The root cause is the contract’s reliance on amount_raised as a manually updated counter rather than using the fund account’s actual lamports balance (fund.to_account_info().lamports()). Here’s the relevant code:

Contribution Logic in FundContribute

pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let contribution = &mut ctx.accounts.contribution;
// ... (deadline check)
// Transfer SOL from contributor to fund account
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.contributor.to_account_info(),
to: fund.to_account_info(),
},
);
system_program::transfer(cpi_context, amount)?;
fund.amount_raised += amount;
Ok(())
}
  • fund.amount_raised is incremented by the amount transferred via the contract.

  • Direct SOL transfers to fund.to_account_info().key() (the PDA) are not captured, as they don’t trigger this function.

Withdrawal Logic in FundWithdraw

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised; // Uses tracked amount, not balance
**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(())
}
  • Withdrawal uses fund.amount_raised, not the actual balance, potentially leaving untracked SOL behind or failing if amount_raised exceeds the balance.

Solana’s Account Model

  • The fund account is a PDA, owned by the program, and its public key is derivable from the seeds. Anyone can send SOL to it using a native transfer (e.g., via solana transfer CLI or a wallet).

  • The actual balance (lamports) increases with direct transfers, but amount_raised remains unchanged unless FundContribute is called.

Scenario Demonstrating the Issue

  1. A fund is created with goal = 100 SOL and amount_raised = 0.

  2. A user sends 50 SOL directly to the fund’s PDA via a native transfer.

  3. fund.to_account_info().lamports() reflects ~50 SOL (plus rent exemption), but fund.amount_raised remains 0.

  4. A contributor uses FundContribute to send 30 SOL, updating amount_raised to 30 SOL.

  5. The creator calls FundWithdraw:

    • amount = fund.amount_raised = 30 SOL, withdrawing only 30 SOL.

    • 20 SOL (50 - 30) remains in the account, untracked and inaccessible unless manually withdrawn later.

This discrepancy shows amount_raised does not reflect the true balance, leading to potential confusion or loss.


Impact

The impact of this issue is moderate but depends on usage and intent:

  • Inaccurate Accounting: amount_raised underreports the true funds available, misleading contributors and the creator about the campaign’s progress toward the goal.

  • Loss of Fund: In FundWithdraw, only amount_raised is transferred, leaving any directly deposited SOL in the account. Without a closure mechanism, this SOL is stuck unless manually handled.

Tools Used

The analysis was conducted using:

  • Manual Code Review

Recommendations

To address this issue, the contract should reconcile amount_raised with the actual balance or redesign its fund-tracking approach.

Use Actual Balance in FundWithdraw

Modify FundWithdraw to use the fund’s lamports balance instead of amount_raised, ensuring all SOL (including direct transfers) is withdrawable:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund_balance = ctx.accounts.fund.to_account_info().lamports();
let rent_exemption = Rent::get()?.minimum_balance(8 + Fund::INIT_SPACE); // Calculate rent exemption
let amount = fund_balance.checked_sub(rent_exemption).ok_or(ProgramError::InsufficientFunds)?; // Withdraw all but rent
**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)?;
// Optional: Reset amount_raised for consistency
ctx.accounts.fund.amount_raised = 0;
Ok(())
}
Updates

Lead Judging Commences

bube Lead Judge
3 months ago

Appeal created

bube Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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