Rust Fund

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

Rustfund — Failed-Campaign Refunds Pay 0 Because contribute Never Credits Contribution.amount

Description

rustfund::contribute never writes the deposited amount onto the contributor’s Contribution account. The only assignment in the contribute path is the first-touch initializer that stores 0. SOL is transferred Bob → Fund and Fund.amount_raised is incremented, but Contribution.amount stays 0 for the life of the deposit.

When the campaign later fails (deadline != 0 && now >= deadline && amount_raised < goal), refund is reachable and succeeds. It reads amount = Contribution.amount (always 0), moves 0 lamports back to the contributor, and writes 0 again. The call returns Ok. The Fund still holds rent-exempt-min + A and amount_raised is still A, while the residual claim set {C | C.fund == F.key() && C.amount > 0} is empty.

This is a complete accounting/reserve desync: on-chain SOL is in the Fund, the book-entry that would authorize returning it is never created, and the contributor-facing exit pays nothing. No privileged attacker is required. Any contributor who deposited A > 0 on a campaign that then fails (or whose creator rewrites the deadline into the past) loses 100% of principal through a “successful” 0-lamport refund.

Deep Dive

The bug is a single missing store. In production contribute (programs/rustfund/src/lib.rs:25-51) the Contribution account is initialized with amount = 0 and is never updated after the transfer:

// programs/rustfund/src/lib.rs — contribute (L25-51)
// L37: first-touch initializer — the only write to Contribution.amount in this path
contribution.amount = 0;
// L41-48: system_program::transfer moves A lamports Bob → Fund
system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.contributor.to_account_info(),
to: ctx.accounts.fund.to_account_info(),
},
),
amount,
)?;
// L50: Fund book-entry updated; Contribution.amount is never incremented
ctx.accounts.fund.amount_raised += amount;
// missing: contribution.amount += amount;

An exhaustive search of the crate shows only two writes to Contribution.amount:

  1. contribute L37 — stores 0 on first touch.

  2. refund L85 — stores 0 after a (zero) payout.

There is no contribution.amount += amount after the transfer, and no other instruction mutates the field.

refund (programs/rustfund/src/lib.rs:66-87) trusts that field as the sole payout size:

let amount = ctx.accounts.contribution.amount; // L68 — always 0 after contribute
// L69-71: only Clock vs Fund.deadline; no check that Contribution.amount > 0
require!(
clock.unix_timestamp >= ctx.accounts.fund.deadline,
ErrorCode::DeadlineNotReached
);
// L73-81: lamport move of `amount` (0) Fund → contributor
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? += amount;
ctx.accounts.contribution.amount = 0; // L85

Deadline gates do not save the path:

  • contribute rejects only DeadlineReached (L29-31): Clock.unix_timestamp vs Fund.deadline. It does not require dealine_set and does not require a nonzero Contribution.amount.

  • refund rejects only DeadlineNotReached (L69-71) on the same clock comparison.

  • set_deadline (L55-62) writes Fund.deadline but never sets dealine_set = true, so that flag cannot block refund.

  • set_deadline does not require a future timestamp, so the creator can force the failed state immediately after collecting A.

Invariant break (INV-009): a failed-campaign self-exit must pay the deposited amount and leave only rent-exempt lamports in F. After the successful refund:

| Account / field | Expected (failed exit) | Actual |
|---|---|---|
| Contribution.amount | 0 after paying A | 0 after paying 0 |
| Bob lamports | +A | +0 |
| Fund extra lamports | 0 (rent-exempt only) | A |
| Fund.amount_raised | 0 (or reduced by A) | A |
| Residual claims {C.amount > 0} | empty, consistent with empty vault | empty, inconsistent with vault holding A |

withdraw is authorized by INV-009 only on success(F). Production withdraw (L90-105) has no success check (a separate spec hole that would let the creator take A), but that is not a legitimate failed-campaign recovery for contributors. The contributor-facing exit does not return A.

No hidden admin or upgrade path in this program can rewrite Contribution.amount. Rent-exempt minimum is independent of A. Native SOL only; default config.

Exploitation

No privileged role is required. The creator can optionally accelerate failure by setting a past deadline; the same 0-payout refund occurs if time simply elapses.

Preconditions: default program, native SOL. Alice is the campaign creator. Bob is any contributor.

Sequence

  1. Alice calls rustfund::fund_create(name, description, goal=G).

  • Fund PDA initialized with amount_raised = 0, deadline = 0, dealine_set = false.

  • Fund lamports = rent-exempt minimum.

  1. Alice calls rustfund::set_deadline(deadline=D) with D > Clock.unix_timestamp (e.g. now + 60).

  • Fund.deadline = D.

  • dealine_set stays false.

  1. Bob calls rustfund::contribute(amount=A) with 0 < A < G against Fund PDA seeds [name, Alice] and Contribution PDA seeds [fund.key(), Bob].

  • L37: first-touch Contribution.amount = 0.

  • L41-48: transfer A lamports Bob → Fund.

  • L50: Fund.amount_raised += A.

  • Contribution.amount remains 0.

  1. Wait until Clock.unix_timestamp >= D, or Alice calls set_deadline with a past unix timestamp (set_deadline does not require a future D).

  • Operational failed state: deadline != 0 && now >= deadline && amount_raised = A < goal = G.

  1. Bob calls rustfund::refund() with the same Contribution PDA.

  • L68: amount = Contribution.amount = 0.

  • DeadlineNotReached does not fire.

  • Fund lamports -= 0; Bob lamports += 0.

  • Contribution.amount written to 0.

  • Instruction returns Ok.

Concrete payload

  • G = 1_000_000_000

  • A = 500_000_000

  • Alice: fund_create(name="campaign", description="desc", goal=1_000_000_000)

  • Alice: set_deadline(D) with D > now

  • Bob: contribute(amount=500_000_000)

  • Advance clock to unix_timestamp >= D (or Alice set_deadline(past_ts))

  • Bob: refund()

Post-state (measured on a line-faithful accounting model of L25-51 and L66-87)

  • Contribution.amount == 0

  • Bob credit == 0

  • Fund extra lamports == A (500_000_000 above rent-exempt min)

  • Fund.amount_raised == 500_000_000

  • Residual claims empty

The deposited SOL is stranded in the Fund. Contributors have no remaining instruction that will pay them A.

Impact

Critical — permanent loss of 100% of contributor principal on every failed campaign.

  • Every successful contribute(A) leaves the only refundable book-entry at 0.

  • Failed-campaign refund is the intended self-exit. It succeeds and pays 0, so users believe they have been refunded while A remains in the Fund.

  • After refund, there is no residual claim (Contribution.amount == 0) that could recover the stranded lamports. The money is not attributed to any contributor.

  • A creator can force this state at any time after collecting deposits by calling set_deadline with a past timestamp.

  • Even without malice, any campaign that misses its goal produces the same total loss for every contributor.

  • INV-009 is broken: failed-campaign exit does not return deposits and does not leave the Fund at rent-exempt only.

  • Creator withdraw is not a valid recovery under the failed-campaign spec; if used, it would let the creator take stranded A rather than return it.

Scope is default config, native SOL, no special privileges, 100% of principal per affected contribution. Confidence is high (source-level proof: the only two writes to Contribution.amount both store 0).

Recommendation

Credit the contribution before or immediately after the SOL transfer, and keep Fund book-entry, vault lamports, and per-contributor claims in lockstep.

  1. **Record the deposit on Contribution.** After a successful transfer in contribute:

```rust
ctx.accounts.contribution.contributor = ctx.accounts.contributor.key();
ctx.accounts.contribution.fund = ctx.accounts.fund.key();
ctx.accounts.contribution.amount += amount; // or = amount on first touch
ctx.accounts.fund.amount_raised += amount;
```
Do not leave the initializer’s 0 as the live balance.

  1. **Decrement Fund.amount_raised on refund** by the same amount paid out, and assert Fund lamports never drop below rent-exempt minimum.

  2. Refuse a zero-amount refund (require!(amount > 0, ...)) so a never-credited account cannot “succeed” as a no-op exit.

  3. Harden deadline semantics: set dealine_set = true in set_deadline; require a future timestamp on first set (and define whether the creator may rewrite it); gate refund on the operational failed state (dealine_set && now >= deadline && amount_raised < goal), not clock alone.

  4. **Gate withdraw on campaign success** (amount_raised >= goal and deadline rules per INV-009) so stranded failed-campaign SOL cannot be taken by the creator.

  5. Add an invariant test: after contribute(A) then failed-state refund(), Bob’s lamports increase by A, Fund extra lamports are 0, amount_raised is reduced by A, and Contribution.amount == 0 only after paying A.

Updates

Lead Judging Commences

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