Rust Fund

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

`contribute` never records the contributed amount, so every `refund` returns 0 lamports and no contributor can ever recover their contribution

Contribution::amount is only ever assigned the literal 0, so the refund mechanism silently pays nothing

Description

  • The Contribution account exists to remember how much each contributor put into a campaign, so that refund can pay that amount back if the campaign fails. The README promises: "Contributors can get refunds if deadlines are reached and goals aren't met".

  • contribute transfers the caller's SOL into the fund and credits fund.amount_raised, but it never writes the amount into the contributor's own record. contribution.amount is initialised to 0 and left there. refund reads that field, so it always moves exactly zero lamports — and it returns Ok(()), so the contributor's wallet reports a successful refund while receiving nothing.

// src/lib.rs:25-52
pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let contribution = &mut ctx.accounts.contribution;
...
// Initialize or update contribution record
if contribution.contributor == Pubkey::default() {
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
@> contribution.amount = 0; // @> set to zero...
}
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)?; // the SOL really does leave the contributor
@> fund.amount_raised += amount; // @> only the FUND-side counter is credited
@> // @> MISSING: contribution.amount += amount; <- the contributor's receipt is never written
Ok(())
}

amount is credited to exactly one of the two ledgers. The comment on line 33 says "Initialize or update contribution record", but the block only ever initialises — there is no update path.

// src/lib.rs:66-88
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
@> let amount = ctx.accounts.contribution.amount; // @> always 0
...
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount) // 0
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.contributor.to_account_info().lamports()
.checked_add(amount) // 0
.ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0; // the only other write - also zero
@> Ok(()) // @> returns SUCCESS having moved nothing
}

An exhaustive search of the file confirms there is no other write: contribution.amount appears exactly three times — = 0 at line 37, read at line 68, = 0 at line 85. The field is write-once-zero for the entire lifetime of the account.

Risk

Likelihood:

  • This happens on every single contribution. There is no attacker, no race, no special configuration and no precondition — the contributor simply uses the program as documented.

  • Both refund gates are passable, so contributors do reach the broken payout: refund is reachable whenever deadline == 0 (the state every fund is created in, line 17) or whenever the deadline has passed. Nothing stops the call from executing.

  • The failure is silent. The instruction returns Ok(()), no error is surfaced, and the contributor's client reports a confirmed transaction. Nothing signals that zero lamports moved.

Impact:

  • Every contributor loses 100% of what they contributed, with no path back. The refund mechanism — one of the four features the project advertises — cannot return value under any circumstance.

  • To be precise about what this is: the lamports are not destroyed or frozen. They sit in the fund PDA and remain fully claimable — by the creator, through withdraw (line 91). So the accurate description is misappropriation from contributor to creator, not a burn. The program has no close, no rescue and no admin path, and refund is the only instruction that can ever pay a contributor, so a contributor whose campaign failed has no route to their own money while the creator's route stays open.

  • The contributor is left strictly poorer after "refunding": they pay the transaction fee and receive nothing back. They also forfeit the ~0.00145 SOL of rent they paid to create their own Contribution PDA, because refund does not close it.

  • Because refund also resets contribution.amount = 0 (line 85), even a later fix that starts recording amounts would not restore records already zeroed by a "successful" refund.

Proof of Concept

Alice contributes 2 SOL and Bob contributes 3 SOL. Alice's on-chain receipt reads 0. After the deadline she calls refund, the transaction confirms, and she ends up poorer by exactly the network fee.

Run against a validator with the program loaded:

cargo build-sbf
solana-test-validator --reset --bpf-program 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h target/deploy/rustfund.so
npm install @solana/web3.js && node poc.mjs
// poc.mjs - deliberately IDL-free: raw instructions, so nothing is hidden behind a client library
import {
Connection, Keypair, PublicKey, SystemProgram, Transaction,
TransactionInstruction, LAMPORTS_PER_SOL, sendAndConfirmTransaction,
} from "@solana/web3.js";
import { createHash } from "crypto";
const PROGRAM_ID = new PublicKey("6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h");
const conn = new Connection("http://127.0.0.1:8899", "confirmed");
const SYS = SystemProgram.programId;
const disc = (n) => createHash("sha256").update(`global:${n}`).digest().subarray(0, 8);
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
const str = (s) => { const d = Buffer.from(s); const l = Buffer.alloc(4); l.writeUInt32LE(d.length); return Buffer.concat([l, d]); };
const M = (pubkey, isSigner, isWritable) => ({ pubkey, isSigner, isWritable });
const sol = (l) => (l / LAMPORTS_PER_SOL).toFixed(9);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function air(kp) {
const sig = await conn.requestAirdrop(kp.publicKey, 50 * LAMPORTS_PER_SOL);
await conn.confirmTransaction({ signature: sig, ...(await conn.getLatestBlockhash()) }, "confirmed");
}
const send = (ixs, s) => sendAndConfirmTransaction(conn, new Transaction().add(...ixs), s, { commitment: "confirmed" });
async function readContribution(pda) {
const d = (await conn.getAccountInfo(pda, "confirmed")).data;
return { contributor: new PublicKey(d.subarray(8, 40)), fund: new PublicKey(d.subarray(40, 72)), amount: d.readBigUInt64LE(72) };
}
(async () => {
const creator = Keypair.generate(), alice = Keypair.generate(), bob = Keypair.generate();
for (const k of [creator, alice, bob]) await air(k);
const name = "poc-" + Math.floor(Date.now() / 1000);
const [fundPda] = PublicKey.findProgramAddressSync([Buffer.from(name), creator.publicKey.toBuffer()], PROGRAM_ID);
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(creator.publicKey, true, true), M(SYS, false, false)],
data: Buffer.concat([disc("fund_create"), str(name), str("campaign"), u64(100 * LAMPORTS_PER_SOL)]) })], [creator]);
const deadline = Math.floor(Date.now() / 1000) + 3;
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(creator.publicKey, true, true)],
data: Buffer.concat([disc("set_deadline"), u64(deadline)]) })], [creator]);
for (const [who, amt] of [[alice, 2], [bob, 3]]) {
const [cPda] = PublicKey.findProgramAddressSync([fundPda.toBuffer(), who.publicKey.toBuffer()], PROGRAM_ID);
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(who.publicKey, true, true), M(cPda, false, true), M(SYS, false, false)],
data: Buffer.concat([disc("contribute"), u64(amt * LAMPORTS_PER_SOL)]) })], [who]);
}
const [aliceContrib] = PublicKey.findProgramAddressSync([fundPda.toBuffer(), alice.publicKey.toBuffer()], PROGRAM_ID);
console.log("alice paid 2 SOL, her Contribution.amount =", (await readContribution(aliceContrib)).amount);
await sleep(4500); // let the deadline pass so refund is unquestionably open
const before = await conn.getBalance(alice.publicKey, "confirmed");
const fundBefore = (await conn.getAccountInfo(fundPda, "confirmed")).lamports;
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(aliceContrib, false, true), M(alice.publicKey, true, true), M(SYS, false, false)],
data: disc("refund") })], [alice]); // <- succeeds
const after = await conn.getBalance(alice.publicKey, "confirmed");
const fundAfter = (await conn.getAccountInfo(fundPda, "confirmed")).lamports;
console.log(`refund CONFIRMED. alice ${sol(before)} -> ${sol(after)} SOL (delta ${sol(after - before)})`);
console.log(`fund PDA ${sol(fundBefore)} -> ${sol(fundAfter)} SOL (unchanged: ${fundBefore === fundAfter})`);
})();

Output:

alice paid 2 SOL, her Contribution.amount = 0n
refund CONFIRMED. alice 47.998547320 -> 47.998542320 SOL (delta -0.000005000)
fund PDA 5.037590960 -> 5.037590960 SOL (unchanged: true)

Alice's balance goes down by the 5000-lamport fee. Not one lamport of her 2 SOL comes back, and the fund's balance does not move — yet the transaction is confirmed and no error is raised.

Note on the project's own test suite

tests/rustfund.ts contains a "Refunds contribution" case, and it passes. It cannot detect this bug because the file has no assertions at all — every check is a console.log. The refund test logs contributionAccount and moves on, so a refund of zero looks identical to a correct refund.

Recommended Mitigation

Credit the contributor's record in the same place the fund's counter is credited, and pay out against it.

if contribution.contributor == Pubkey::default() {
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
contribution.amount = 0;
}
let cpi_context = CpiContext::new(...);
system_program::transfer(cpi_context, amount)?;
- fund.amount_raised += amount;
+ fund.amount_raised = fund.amount_raised
+ .checked_add(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
+ contribution.amount = contribution.amount
+ .checked_add(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
Ok(())

refund must then also keep the fund-side counter consistent, or the two ledgers drift apart again:

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
+ if amount == 0 {
+ return Err(ErrorCode::ZeroContribution.into());
+ }
...
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0;
Updates

Lead Judging Commences

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