Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

No terminal campaign state or close path, goal = 0 accepted, creator self-contribution allowed, and deadline boundary off-by-one

Root + Impact

Description

  • A crowdfunding lifecycle settles campaigns into a terminal state, validates the goal as a positive target, and reclaims rent from fully-settled accounts.

  • None of these exist: no CampaignStatus terminal state, no close instruction, no goal > 0 validation, no creator-exclusion in contribute(), and the two time guards use opposite strict comparisons — at deadline == now both contribute() and refund() pass simultaneously.

fund.goal = goal; // @> no require!(goal > 0) — 0 accepted
fund.deadline = 0; // @> no CampaignStatus field anywhere
// contribute(): no check that contributor != fund.creator @> self-donation allowed
// contribute() rejects at deadline < now; refund() at deadline > now
// @> at deadline == now both are allowed

Risk

Likelihood:

  • Reason 1: The rent lock applies to 100% of campaigns ever created — deterministic, compounding dust per campaign.

  • Reason 2: goal = 0 and self-donation are trivial parameter choices available to any creator.

Impact:

  • Impact 1: SOL permanently locked as rent (~0.038 SOL per fund PDA for 5281 bytes + ~0.001 SOL per contribution PDA) — an unreclaimable protocol-level value leak.

  • Impact 2: goal = 0 plus self-contributions game any success gating (current or future) and let creators fake campaign momentum.

Proof of Concept

Three single calls — goal = 0 accepted, creator self-contributes, and no close instruction exists for drained PDAs:

await program.methods.fundCreate("Free", "g", new BN(0)) // goal = 0 accepted
.accounts({ fund: fundPda, creator: attacker, systemProgram: SystemProgram.programId })
.signers([attacker]).rpc();
​
await program.methods.contribute(new BN(5 * LAMPORTS_PER_SOL)) // creator == contributor
.accounts({ fund: fundPda, contributor: attacker, contribution: attackerContributionPda, systemProgram: SystemProgram.programId })
.signers([attacker]).rpc();
// No close instruction exists: drained fund & contribution PDAs keep paying rent forever.

Expected result: all three succeed — the zero-goal campaign exists, the self-contribution is recorded, and the drained PDAs remain rent-locked with no exit.

Recommended Mitigation

Introduce an explicit lifecycle: validate the goal at creation, track a terminal state per campaign, and add a close instruction that returns rent only after settlement — plus exclude the creator from contributing and settle one boundary semantics for the deadline second.

+ require!(goal > 0, ErrorCode::InvalidGoal);
fund.goal = goal;
+ fund.status = CampaignStatus::Active; // new enum { Active, Withdrawn, Refunded }
​
+ require!(ctx.accounts.contributor.key() != fund.creator.key(),
+ ErrorCode::CreatorSelfContribution); // in contribute()
​
+ // new close instruction — #[account(mut, close = creator)] on fund,
+ // allowed only in Withdrawn/Refunded terminal states
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!