Rust Fund

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

contribute() never records contribution.amount, so refund() always pays 0 lamports and contributors to a failed campaign can never get their SOL back

Root + Impact

Description

  • Each contributor has a Contribution PDA (seeds [fund, contributor]) that records how much SOL they have put into a fund. refund() pays that recorded amount back from the fund's lamports. The README promises: "Contributors can get refunds if deadlines are reached and goals aren't met".

  • contribute() writes contribution.amount only once, setting it to 0 when the record is first created (L37). It then transfers amount lamports into the fund (L48) and adds amount to fund.amount_raised (L50), but never to contribution.amount. On a later contribution, init_if_needed reloads the existing record, the initialisation branch is skipped and the field stays 0. refund() pays out that field (L68), so every refund moves 0 lamports, writes 0 back (L85) and returns Ok. Nothing signals the loss. The only instruction that can move the deposited SOL out of the fund is withdraw(), which pays it to the creator.

// programs/rustfund/src/lib.rs L33-L38 (@> L37): contribute(): the only write to contribution.amount is inside the first-use branch
// Initialize or update contribution record
if contribution.contributor == Pubkey::default() {
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
@> contribution.amount = 0;
}
​
// programs/rustfund/src/lib.rs L48-L51 (@> L50): contribute(): the deposit is added to the fund total only
system_program::transfer(cpi_context, amount)?;
@> fund.amount_raised += amount;
Ok(())
​
// programs/rustfund/src/lib.rs L66-L68 (@> L68): refund(): pays the recorded amount, which is always 0
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
@> let amount = ctx.accounts.contribution.amount;

Risk

Likelihood:

  • It happens on every refund, for every contributor of every campaign, in normal honest use. No attacker, timing or special input is needed.

  • It happens in exactly the case the README promises refunds for: a contributor calls refund() after the deadline of a campaign that missed its goal.

Impact:

  • Contributors can never recover their SOL through the program. refund() succeeds, moves 0 lamports and leaves contribution.amount at 0, so neither the transaction nor the client reports a problem.

  • The deposits stay in the fund PDA, and the only way out is withdraw() to the creator. In the PoC, Alice puts 11 SOL into a campaign that fails. Her refund pays 0, and the creator then withdraws all 11 SOL.

Proof of Concept

The test creates a campaign with a 100 SOL goal and a 7-day deadline. Alice contributes 10 SOL and then 1 SOL; the second call reuses her Contribution record through init_if_needed. After each call, fund.amount_raised is correct (10 SOL, then 11 SOL) and the record names Alice and the fund, but contribution.amount is 0. The fund's lamports did rise by exactly 11 SOL. A refund before the deadline fails with DeadlineNotReached (6002), so the time guard works. After the deadline, with the goal missed, refund() returns Ok, but neither Alice's balance nor the fund's balance changes. The creator then calls withdraw() on the failed campaign and receives the full 11 SOL.

// PoC RF-01: contribute() never records contribution.amount, so refund() always pays 0.
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
system_instruction,
transaction::{Transaction, TransactionError},
};
​
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
​
fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let accounts = Box::leak(Box::new(accounts.to_vec()));
rustfund::entry(program_id, accounts, data)
}
​
async fn send(
ctx: &mut ProgramTestContext,
ixs: &[Instruction],
extra_signers: &[&Keypair],
) -> std::result::Result<(), BanksClientError> {
let bh = ctx.get_new_latest_blockhash().await.unwrap();
let mut signers: Vec<&Keypair> = vec![&ctx.payer];
signers.extend_from_slice(extra_signers);
let tx = Transaction::new_signed_with_payer(ixs, Some(&ctx.payer.pubkey()), &signers, bh);
ctx.banks_client.process_transaction(tx).await
}
​
async fn lamports(ctx: &mut ProgramTestContext, key: &Pubkey) -> u64 {
ctx.banks_client.get_account(*key).await.unwrap().map(|a| a.lamports).unwrap_or(0)
}
​
async fn fund_state(ctx: &mut ProgramTestContext, key: &Pubkey) -> rustfund::Fund {
let acc = ctx.banks_client.get_account(*key).await.unwrap().unwrap();
rustfund::Fund::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
async fn contribution_state(ctx: &mut ProgramTestContext, key: &Pubkey) -> rustfund::Contribution {
let acc = ctx.banks_client.get_account(*key).await.unwrap().unwrap();
rustfund::Contribution::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
#[tokio::test]
async fn rf01_contribution_amount_never_recorded_refund_pays_zero() {
let pt = ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction));
let mut ctx = pt.start_with_context().await;
​
let creator = Keypair::new();
let alice = Keypair::new();
// Fund creator and Alice from the payer (payer pays all tx fees in this test,
// so Alice's and the creator's balances move only by program effects).
let fund_ixs = [
system_instruction::transfer(&ctx.payer.pubkey(), &creator.pubkey(), 5 * LAMPORTS_PER_SOL),
system_instruction::transfer(&ctx.payer.pubkey(), &alice.pubkey(), 20 * LAMPORTS_PER_SOL),
];
send(&mut ctx, &fund_ixs, &[]).await.unwrap();
​
// 1. Creator creates campaign "proj" with goal = 100 SOL.
let name = "proj".to_string();
let goal = 100 * LAMPORTS_PER_SOL;
let (fund_pda, _) =
Pubkey::find_program_address(&[name.as_bytes(), creator.pubkey().as_ref()], &rustfund::ID);
let ix = Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundCreate {
fund: fund_pda,
creator: creator.pubkey(),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::FundCreate { name: name.clone(), description: "desc".to_string(), goal }.data(),
};
send(&mut ctx, &[ix], &[&creator]).await.unwrap();
​
// 2. Creator sets deadline = now + 7 days.
let clock: Clock = ctx.banks_client.get_sysvar::<Clock>().await.unwrap();
let deadline: u64 = clock.unix_timestamp as u64 + 7 * 24 * 3600;
let ix = Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundSetDeadline { fund: fund_pda, creator: creator.pubkey() }
.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline }.data(),
};
send(&mut ctx, &[ix], &[&creator]).await.unwrap();
​
// 3. Alice contributes 10 SOL, then 1 SOL more (repeat contribution via init_if_needed).
let (contrib_pda, _) = Pubkey::find_program_address(
&[fund_pda.as_ref(), alice.pubkey().as_ref()],
&rustfund::ID,
);
let fund_lamports_before = lamports(&mut ctx, &fund_pda).await;
let alice_start = lamports(&mut ctx, &alice.pubkey()).await;
let mut total_contributed = 0u64;
for amt in [10 * LAMPORTS_PER_SOL, 1 * LAMPORTS_PER_SOL] {
let ix = Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundContribute {
fund: fund_pda,
contributor: alice.pubkey(),
contribution: contrib_pda,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount: amt }.data(),
};
send(&mut ctx, &[ix], &[&alice]).await.unwrap();
total_contributed += amt;
let f = fund_state(&mut ctx, &fund_pda).await;
let c = contribution_state(&mut ctx, &contrib_pda).await;
println!(
"after contribute({} lamports): fund.amount_raised = {}, contribution.amount = {}, contribution.contributor = {}",
amt, f.amount_raised, c.amount, c.contributor
);
// Baseline: the fund-level ledger IS updated correctly...
assert_eq!(f.amount_raised, total_contributed);
// ...and the record was initialised for Alice...
assert_eq!(c.contributor, alice.pubkey());
assert_eq!(c.fund, fund_pda);
// BUG: ...but the per-contributor ledger that refund() reads stays 0.
assert_eq!(c.amount, 0, "contribution.amount should have been recorded but is not");
}
let fund_lamports_after_contrib = lamports(&mut ctx, &fund_pda).await;
let alice_after_contrib = lamports(&mut ctx, &alice.pubkey()).await;
println!(
"fund PDA lamports: {} -> {} (+{}); Alice lamports: {} -> {} (-{}, incl. Contribution PDA rent)",
fund_lamports_before,
fund_lamports_after_contrib,
fund_lamports_after_contrib - fund_lamports_before,
alice_start,
alice_after_contrib,
alice_start - alice_after_contrib
);
// The SOL really moved into the fund.
assert_eq!(fund_lamports_after_contrib - fund_lamports_before, total_contributed);
​
let refund_ix = Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundRefund {
fund: fund_pda,
contribution: contrib_pda,
contributor: alice.pubkey(),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Refund {}.data(),
};
​
// Baseline: refund before the deadline is correctly rejected (DeadlineNotReached = 6002),
// so the guard works and a later Ok is a genuine "refund accepted".
let err = send(&mut ctx, &[refund_ix.clone()], &[&alice]).await.unwrap_err();
println!("refund before deadline -> {:?}", err);
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::Custom(code))) => {
assert_eq!(code, 6002)
}
other => panic!("unexpected error: {:?}", other),
}
​
// 4. Deadline passes with goal unmet (11 SOL < 100 SOL) -> README refund case.
let mut clock: Clock = ctx.banks_client.get_sysvar::<Clock>().await.unwrap();
clock.unix_timestamp = deadline as i64 + 1;
ctx.set_sysvar(&clock);
let f = fund_state(&mut ctx, &fund_pda).await;
assert!(f.amount_raised < f.goal, "goal must be unmet for the refund case");
​
// 5. Alice calls refund(): it SUCCEEDS but pays 0.
let fund_before_refund = lamports(&mut ctx, &fund_pda).await;
let alice_before_refund = lamports(&mut ctx, &alice.pubkey()).await;
let res = send(&mut ctx, &[refund_ix], &[&alice]).await;
println!("refund after deadline -> {:?}", res);
assert!(res.is_ok(), "refund() returns Ok, so no error signals the loss");
let fund_after_refund = lamports(&mut ctx, &fund_pda).await;
let alice_after_refund = lamports(&mut ctx, &alice.pubkey()).await;
let c = contribution_state(&mut ctx, &contrib_pda).await;
println!(
"refund: expected payout = {} lamports, actual Alice delta = {}, fund delta = {}, contribution.amount = {}",
total_contributed,
alice_after_refund as i128 - alice_before_refund as i128,
fund_after_refund as i128 - fund_before_refund as i128,
c.amount
);
assert_eq!(alice_after_refund, alice_before_refund, "Alice received 0 lamports from refund");
assert_eq!(fund_after_refund, fund_before_refund, "fund kept all of Alice's 11 SOL");
assert_eq!(c.amount, 0);
​
// 6. The only exit for the lamports is withdraw() -> the creator, even though the goal failed.
let creator_before = lamports(&mut ctx, &creator.pubkey()).await;
let ix = Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundWithdraw {
fund: fund_pda,
creator: creator.pubkey(),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Withdraw {}.data(),
};
send(&mut ctx, &[ix], &[&creator]).await.unwrap();
let creator_after = lamports(&mut ctx, &creator.pubkey()).await;
let fund_final = lamports(&mut ctx, &fund_pda).await;
println!(
"withdraw on failed campaign: creator +{} lamports; fund PDA lamports {} -> {}",
creator_after - creator_before,
fund_after_refund,
fund_final
);
assert_eq!(creator_after - creator_before, total_contributed, "creator took Alice's unrefundable SOL");
assert_eq!(fund_final, fund_lamports_before, "fund back to rent-only; Alice's 11 SOL is gone");
println!("RF-01 CONFIRMED: Alice contributed {} lamports, refund paid 0, creator withdrew {}", total_contributed, creator_after - creator_before);
}

Run from the repository root. Everything before the cd line is a one-time setup; save the file above as programs/rustfund/tests/poc_RF-01.rs before running the last line:

cat >> programs/rustfund/Cargo.toml <<'TOML'
​
[dev-dependencies]
solana-program-test = "~1.18.26"
solana-sdk = "~1.18.26"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
TOML
mkdir -p programs/rustfund/tests
cd programs/rustfund && cargo test --test poc_RF-01 -- --nocapture

Result: test result: ok. 1 passed; 0 failed. Key log lines:

after contribute(1000000000 lamports): fund.amount_raised = 11000000000, contribution.amount = 0, contribution.contributor = DLoNMyaQi5L6VmAZFK9HQ8SS4aXeYftRqmqTr5SSxHcu
refund before deadline -> TransactionError(InstructionError(0, Custom(6002)))
refund after deadline -> Ok(())
refund: expected payout = 11000000000 lamports, actual Alice delta = 0, fund delta = 0, contribution.amount = 0
withdraw on failed campaign: creator +11000000000 lamports; fund PDA lamports 11037590960 -> 37590960
RF-01 CONFIRMED: Alice contributed 11000000000 lamports, refund paid 0, creator withdrew 11000000000

Recommended Mitigation

Record each deposit in the contributor's record. Once refunds pay real amounts, refund() must also reduce fund.amount_raised. Otherwise the fund total stays overstated after a refund, and the creator's withdraw() of the remaining balance reverts.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -48,5 +48,6 @@
system_program::transfer(cpi_context, amount)?;
fund.amount_raised += amount;
+ contribution.amount = contribution.amount.checked_add(amount).ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
}
@@ -83,5 +84,6 @@
// Reset contribution amount after refund
ctx.accounts.contribution.amount = 0;
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours 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!