Rust Fund

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

contribute() never records the amount in the contribution account, so refund() reads a zero balance and always returns 0 lamports to the contributor

Description
The Contribution account is the only record of how much a contributor is entitled to get back, and it is written exactly once — with zero:

rust

if contribution.contributor == Pubkey::default() { // L34 — first contribution for this (fund, contributor) PDA
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
contribution.amount = 0; // L37 — the recorded amount is hard-coded to zero
}

// Transfer SOL from contributor to fund account
system_program::transfer(cpi_context, amount)?; // L48 — the real SOL moves …
fund.amount_raised += amount; // L50 — … and is recorded on the fund only
contribution.amount is never assigned amount and never accumulated. The only other write to it is the reset in refund() (L85). So after any number of contributions, every contribution account holds amount = 0.

refund() then reads that field as the payout size:

rust

let amount = ctx.accounts.contribution.amount; // L68 — always 0
...
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports().checked_sub(amount)...; // subtracts 0
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.contributor.to_account_info().lamports().checked_add(amount)...; // credits 0
ctx.accounts.contribution.amount = 0; // L85 — resetting 0 to 0
The refund path therefore executes successfully and moves nothing: the contributor's balance is unchanged, the fund's balance is unchanged, and the contribution account is "reset" from 0 to 0. Because Contribution is created with init_if_needed (L126) and guarded by has_one = contributor / has_one = fund (L152–L153), the account exists for anyone who contributed — so refund() does not revert, it silently succeeds as a no-op. The README's advertised feature ("Refund Mechanism: Contributors can get refunds if deadlines are reached and goals aren't met") is unreachable in practice.

Note also that the fund balance itself cannot be attributed to individual contributors at all: contributions go straight into the fund PDA via system_program::transfer (L41–L48), so the per-contribution record is the only source a refund can use — and it is empty.

Risk
The advertised refund mechanism does not pay anything. A contributor whose campaign passed its deadline gets a transaction that succeeds, costs them the fee for calling it, and returns 0 lamports. There is no revert to signal failure, so wallets and front-ends will report a successful refund that transferred nothing.
Contributors lose their SOL permanently in a failed campaign. The only path back from the fund account to a contributor is refund(), and it pays contribution.amount. With that field pinned at 0, every contribution to a campaign that never withdrew is unrecoverable — while the creator can still drain the whole pool via withdraw() (see Finding 1/4).
The program has no per-contribution accounting at all. Even a fixed refund formula cannot be built on top of this state: there is no record of who contributed how much, so partial refunds and pro-rata distribution are impossible without re-creating the field.
Impact is high (contributors' funds become unrecoverable; the protocol's advertised guarantee is void) and likelihood is high: it happens on the first contribution of every campaign, with no special ordering or attacker involved.

Proof of Concept
Overview. contribution.amount is initialised to 0 at L37 and never updated, while refund() (L68) uses that field as the payout amount — so the refund path always transfers 0 lamports.

Actors.

Attacker / trigger: any contributor calling contribute() once (the ordinary flow).
Victim: every contributor to a campaign whose deadline passed; they paid real SOL and can only ever be refunded 0.
Protocol: Contribution account (L125–L132, init_if_needed, seeds [fund, contributor]) read by refund() at L68.
Anchor integration test (tests/rustfund.ts, the sequence already present in the repo, with the assertion that demonstrates the bug):

ts

// contribute 0.5 SOL (L69-L90) … then read the contribution account back:
const contributionAccount = await program.account.contribution.fetch(contributionPDA);
console.log("recorded amount:", contributionAccount.amount.toNumber()); // -> 0, after a 0.5 SOL contribution

// the repo's own commented-out test already recorded this exact behaviour (tests/rustfund.ts L92-L134):
assert.ok(contributionAccountAfter.amount.toNumber() - contributionAccAmountBefore.toNumber() == 0); // no state update
assert.ok(userBalanceAfterRefund - userBalanceBeforeRefund < contribution.toNumber()); // refund paid less than the contribution

// after deadline passes, refund() succeeds but moves nothing:
await program.methods.refund().accounts({ fund: fundPDA, contribution: contributionPDA, contributor: ... }).rpc();
const after = await provider.connection.getBalance(contributor.publicKey); // unchanged: amount was 0
Run with anchor build && anchor test. The printed value of contributionAccount.amount is 0 while the fund's lamports increased by 500,000,000 — the two facts that make refund() a no-op.

Anchor-level arithmetic reproduction (audit_repro.rs, run with rustc) showing what refund() computes:

rust

// refund(): let amount = ctx.accounts.contribution.amount; // L68 — the only value ever stored there is 0
let contribution_amount_recorded: u64 = 0; // L37 hard-codes it, no later write exists except the reset at L85
let paid_in: u64 = 500_000_000; // what the contributor actually sent via system_program::transfer (L48)

let credited_back = contribution_amount_recorded; // checked_add(amount) at L78-L81
println!("contributor paid {paid_in} lamports, refund returns {credited_back} lamports");
代码

$ rustc audit_repro.rs -o audit_repro && ./audit_repro
contributor paid 500000000 lamports, refund returns 0 lamports
Risk — why likelihood is high
One contribute() call is enough: the branch at L34 runs for every new contributor, and there is no code path anywhere in the file that writes a non-zero value to contribution.amount. (A grep of the whole program shows exactly two writes to it: L37 = 0 and L85 = 0.)

Recommended Mitigation

  1. Record and accumulate the contribution.

rust

if contribution.contributor == Pubkey::default() {
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
contribution.amount = 0;
}

// after the transfer succeeds, keep the per-contributor total in step with the money that moved:
contribution.amount = contribution.amount.checked_add(amount).ok_or(ErrorCode::CalculationOverflow)?;
fund.amount_raised = fund.amount_raised.checked_add(amount).ok_or(ErrorCode::CalculationOverflow)?;
2. Guard against zero-value contributions so an empty record can never be mistaken for a paid one.

rust

if amount == 0 { return Err(ErrorCode::ZeroContribution.into()); } // add to ErrorCode
3. Make the refund explicit about what it is paying, and keep both totals consistent.

rust

let amount = ctx.accounts.contribution.amount;
if amount == 0 { return Err(ErrorCode::NothingToRefund.into()); } // never silently succeed with a 0-lamport "refund"
ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?; // mirrors Finding 1 fix 2
Test file reference
tests/rustfund.ts (Contributes to fund, and the repo's commented-out Contributes to fund but does not update contribution state and cannot refund) + audit_repro.rs. Tool: Anchor 0.30.1 / anchor test + rustc.

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!