Rust Fund

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

Refund returns 0 because Contribution.amount is never recorded

Root + Impact

Description

  • Normal behavior: contribute records each contributor’s deposited lamports so refund can return them after the deadline.

  • Actual behavior: contribute initializes contribution.amount = 0 and never increments it; refund transfers contribution.amount, so refunds always send 0.


// programs/rustfund/src/lib.rs
// @ contribute never records the deposit
contribution.amount = 0; // @ programs/rustfund/src/lib.rs:37
// @ refund uses stored amount (always 0)
let amount = ctx.accounts.contribution.amount; // @ programs/rustfund/src/lib.rs:68

Risk

Likelihood:

  • Occurs whenever a user contributes and later calls refund after the deadline; the stored amount remains 0.

  • Occurs for every contributor because no instruction ever increments Contribution.amount.

Impact:

  • Contributors cannot recover deposits via refund; funds remain locked in the Fund PDA (or can be taken by creator via withdraw).

  • Breaks core protocol integrity and user expectations.

Proof of Concept

Explanation

  • This PoC runs the real program logic locally using solana-program-test (no mocks).

  • It creates a fund, contributes 1_000_000 lamports, sets the deadline in the past, calls refund, and asserts the contributor receives 0 lamports and Contribution.amount stays 0.

Run (local/private)

  • CARGO_REGISTRIES_CRATES_IO_PROTOCOL=git cargo test -p rustfund --test poc_finding1_refund_zero --offline -- --nocapture

use anchor_lang::{prelude::Pubkey, AccountDeserialize, InstructionData, ToAccountMetas};
use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult};
use solana_program_test::{processor, BanksClientError, ProgramTest, ProgramTestContext};
use solana_sdk::{
clock::Clock,
signature::{Keypair, Signer},
system_instruction, system_program,
transaction::Transaction,
};
fn process_rustfund_instruction<'a, 'b, 'c, 'd>(
program_id: &'a Pubkey,
accounts: &'b [AccountInfo<'c>],
instruction_data: &'d [u8],
) -> ProgramResult {
// Adapter for `solana-program-test` vs Anchor entrypoint lifetimes.
let accounts: &'c [AccountInfo<'c>] = unsafe { std::mem::transmute(accounts) };
rustfund::entry(program_id, accounts, instruction_data)
}
fn derive_fund_pda(program_id: &Pubkey, name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], program_id).0
}
fn derive_contribution_pda(program_id: &Pubkey, fund: &Pubkey, contributor: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], program_id).0
}
async fn send_tx(
ctx: &mut ProgramTestContext,
instructions: Vec<solana_sdk::instruction::Instruction>,
extra_signers: Vec<&Keypair>,
) -> Result<(), BanksClientError> {
let recent_blockhash = ctx.banks_client.get_latest_blockhash().await.unwrap();
let mut signers = vec![&ctx.payer];
signers.extend(extra_signers);
let tx = Transaction::new_signed_with_payer(
&instructions,
Some(&ctx.payer.pubkey()),
&signers,
recent_blockhash,
);
ctx.banks_client.process_transaction(tx).await
}
async fn get_lamports(ctx: &mut ProgramTestContext, address: Pubkey) -> u64 {
ctx.banks_client
.get_account(address)
.await
.unwrap()
.map(|a| a.lamports)
.unwrap_or(0)
}
async fn get_clock(ctx: &mut ProgramTestContext) -> Clock {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap()
}
async fn fetch_contribution(ctx: &mut ProgramTestContext, contribution: Pubkey) -> rustfund::Contribution {
let account = ctx
.banks_client
.get_account(contribution)
.await
.unwrap()
.expect("contribution account should exist");
rustfund::Contribution::try_deserialize(&mut account.data.as_ref()).unwrap()
}
#[tokio::test]
async fn poc_finding1_refund_returns_zero() {
let program_id = rustfund::id();
let mut pt = ProgramTest::new("rustfund", program_id, processor!(process_rustfund_instruction));
let mut ctx = pt.start_with_context().await;
let creator = ctx.payer.pubkey();
let contributor = Keypair::new();
// Fund contributor (rent + donation).
send_tx(
&mut ctx,
vec![system_instruction::transfer(&creator, &contributor.pubkey(), 2_000_000_000)],
vec![],
)
.await
.unwrap();
let name = "refund-test";
let description = "refund accounting PoC";
let goal = 10_000_000_000u64;
let amount = 1_000_000u64;
let fund = derive_fund_pda(&program_id, name, &creator);
let contribution = derive_contribution_pda(&program_id, &fund, &contributor.pubkey());
// Create fund.
let ix_create = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundCreate {
fund,
creator,
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::FundCreate {
name: name.to_string(),
description: description.to_string(),
goal,
}
.data(),
};
send_tx(&mut ctx, vec![ix_create], vec![]).await.unwrap();
// Contribute `amount`.
let fund_before = get_lamports(&mut ctx, fund).await;
let ix_contribute = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundContribute {
fund,
contributor: contributor.pubkey(),
contribution,
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount }.data(),
};
send_tx(&mut ctx, vec![ix_contribute], vec![&contributor])
.await
.unwrap();
let fund_after = get_lamports(&mut ctx, fund).await;
assert_eq!(fund_after - fund_before, amount);
// BUG: contribution.amount was never recorded.
let contrib_state = fetch_contribution(&mut ctx, contribution).await;
assert_eq!(contrib_state.amount, 0);
// Set deadline in the past (refund allowed).
let clock = get_clock(&mut ctx).await;
let past_deadline = clock.unix_timestamp.saturating_sub(10).max(1) as u64;
let ix_set_deadline = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline: past_deadline }.data(),
};
send_tx(&mut ctx, vec![ix_set_deadline], vec![]).await.unwrap();
// Refund.
let contributor_before = get_lamports(&mut ctx, contributor.pubkey()).await;
let fund_before_refund = get_lamports(&mut ctx, fund).await;
let ix_refund = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundRefund {
fund,
contribution,
contributor: contributor.pubkey(),
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Refund {}.data(),
};
send_tx(&mut ctx, vec![ix_refund], vec![&contributor])
.await
.unwrap();
let contributor_after = get_lamports(&mut ctx, contributor.pubkey()).await;
let fund_after_refund = get_lamports(&mut ctx, fund).await;
// BUG: refund delta is 0.
println!(
"refund delta: contributor={} fund={}",
contributor_after as i128 - contributor_before as i128,
fund_after_refund as i128 - fund_before_refund as i128
);
assert_eq!(contributor_after, contributor_before);
assert_eq!(fund_after_refund, fund_before_refund);
}

Recommended Mitigation

Explanation

  • Record deposits by incrementing Contribution.amount during contribute, and continue to refund using that tracked amount.

  • Use checked arithmetic to avoid overflow/panics, and consider closing the Contribution PDA after refund to recover rent.

Suggested patch (conceptual)

// contribute
- if contribution.contributor == Pubkey::default() { ... contribution.amount = 0; }
+ if contribution.contributor == Pubkey::default() { ... contribution.amount = 0; }
+ contribution.amount = contribution
+ .amount
+ .checked_add(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-03] Permanent Loss of Contributor Funds: Missing Update to contribution.amount in the contribute() rustfund Contract

## Description The `rustfund` contract contains a logical error in the `contribute()` function that prevents `contribution.amount` from updating after a user makes a donation. Even though the code increments `fund.amount_raised`, the individual contributor’s record is never updated. As a result, the refund mechanism relies on a zeroed `contribution.amount`, preventing contributors from recovering the correct amount of funds. This issue disrupts the expected crowdfunding flow, undermines the integrity of individual contributions, and ultimately breaks the refund logic for users who should be entitled to their donated lamports if a project does not reach its goal. ## Vulnerability Details The `rustfund` contract fails to update the `contribution.amount` field in the `contribute()` function. While `fund.amount_raised` reflects the total lamports contributed, individual contributors’ amounts remain at zero, effectively breaking the logic for refunds. This oversight compromises the contract’s guarantee that users can retrieve their funds if the project does not succeed or if they become eligible for a refund. In its current state, once a user initiates a valid contribution, there is no proper record of their deposit aside from the aggregated fund total. Any subsequent `refund()` call will use the uninitialized `contribution.amount` (which remains zero), meaning contributors are unable to recover their deposits. Although this issue does not inherently enable an external attacker to steal funds directly, it causes loss of user funds through an incomplete or misleading refund process. ## Impact This logic flaw undermines the contract’s refund mechanism, potentially causing permanent loss of contributed funds. Contributors are led to believe they can retrieve their deposits if the crowdfunding goal is not met or the deadline passes; however, because `contribution.amount` never reflects the actual amount contributed, no valid refund can occur. This defect results in a direct financial impact for users who cannot recover their funds, and it diminishes trust in the contract’s overall integrity. ## Likelihood Explanation This vulnerability manifests whenever contributors interact with the `contribute()` and `refund()` functions in a real-world scenario. Because the missing code update is consistent across all calls, **every** contribution will fail to correctly record the contributor’s amount. Consequently, any refund operation will lead to the same zero-amount issue. This makes the flaw highly likely to occur and reliably reproducible for every user who attempts to donate and then request a refund. ## Proof of Concept The logical error lies in the `contribute()` function, where the `amount` is transferred to the `fund` and `fund.amount_raised` is incremented, yet `contribution.amount` remains unchanged. As a result, if `refund()` is called later, the contributed funds are not reimbursed because `contribution.amount` remains at zero. ### Code Analysis - [lib.rs -](https://github.com/CodeHawks-Contests/2025-03-rustfund/blob/main/programs/rustfund/src/lib.rs#L34-L52) [`contribute`](https://github.com/CodeHawks-Contests/2025-03-rustfund/blob/main/programs/rustfund/src/lib.rs#L34-L51) Below is an abridged version of the `contribute()` function focusing on the relevant sections: ```Rust pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> { // ... Preliminary code ... // Initialize or update contribution record if contribution.contributor == Pubkey::default() { contribution.contributor = ctx.accounts.contributor.key(); contribution.fund = fund.key(); contribution.amount = 0; } // (!) The amount is transferred but 'contribution.amount' is never updated 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(()) } ``` After `system_program::transfer(...)`, the update to `contribution.amount` is missing. The required line should be: ```rust contribution.amount = contribution.amount.checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; ``` ### Explanation Since `contribution.amount` never increments during a contribution, the contract correctly records the transferred amount in `fund.amount_raised` but fails to mirror that amount in the contribution account. Consequently, `refund()` relies on a `contribution.amount` that remains zero, preventing users from retrieving their funds. ### Vulnerable Scenario 1. Alice creates a new fund using `fund_create()`. 2. Alice contributes 0.5 SOL via `contribute()`. Internally, `fund.amount_raised` increments, but `contribution.amount` remains at 0. 3. The fund’s deadline passes, and `refund()` is called. 4. The `refund()` function attempts to return the amount stored in `contribution.amount`, which is 0, so Alice does not get her 0.5 SOL back. ### Test and Result This test aims to verify that when a user contributes a specific amount to the fund, both `contribution.amount` and `fund.amountRaised` are updated accordingly. After invoking the `contribute()` method and fetching the relevant on-chain accounts, the test checks if the recorded amounts match the expected value. In the provided output, `contribution.amount` remains at zero instead of reflecting the correct 500000000 lamports, confirming that the code to increment this field is missing or not executed, resulting in the failing assertion. - Add the following test to `tests/rustfund.ts` after of the function test Contributes to fund ```TypeScript it("Contributes to fund", async () => {}); it("should update the contribution amount when a user contributes", async () => { // Derive the PDA for the contribution account [contributionPDA, contributionBump] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), provider.wallet.publicKey.toBuffer()], program.programId ); // Invoke the 'contribute' function to transfer the specified amount await program.methods .contribute(contribution) .accounts({ fund: fundPDA, contributor: provider.wallet.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // Fetch the updated 'fund' and 'contribution' accounts to validate changes const fundAccount = await program.account.fund.fetch(fundPDA); const contributionAccount = await program.account.contribution.fetch( contributionPDA ); // Confirm that 'contribution.amount' correctly reflects the contributed amount expect(contributionAccount.amount.toNumber()).to.equal( contribution.toNumber(), "The contribution.amount was not correctly updated" ); // Verify that 'fund.amountRaised' also matches the newly contributed amount expect(fundAccount.amountRaised.toNumber()).to.equal( contribution.toNumber(), "The fund.amountRaised was not correctly updated" ); }); it("Refunds contribution", async () => {}); ``` ```bash 1) rustfund should update the contribution amount when a user contributes: The contribution.amount was not correctly updated + expected - actual -0 +500000000 ``` ### Confirmation This flaw is confirmed by observing that `contribution.amount` never increases after a contribution. Its persistent zero value leads to `refund()` failing to return the appropriate funds. A safe and effective fix is to update `contribution.amount` within `contribute()`, for example by using `checked_add` to avoid overflow. ## Recommendations Include a line to increment the `contribution.amount` within the `contribute()` function, ensuring it tracks each user's donation amount. Use a safe addition operation to prevent overflow: ```rust contribution.amount = contribution.amount.checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; ``` This change ensures the refund mechanism properly returns the correct amount to contributors.

Support

FAQs

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

Give us feedback!