Rust Fund

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

No campaign outcome is ever enforced — `refund` and `withdraw` are not mutually exclusive

Root + Impact

Description

Normal behavior: Crowdfunding has exactly two terminal outcomes. If the goal is met by the deadline the campaign succeeds and only the creator withdraws; if the deadline passes with the goal unmet it fails and only contributors refund. The two flows must be mutually exclusive and each gated on its own outcome.

The issue: No function ever records or enforces an outcome. refund checks only that the deadline has passed — it never verifies the goal was not met, so after a successful campaign (goal met at deadline) contributors can still call refund and drain the vault the creator is entitled to. Conversely withdraw (see H02) never verifies success. Neither function flips any state, so after the deadline both paths are open simultaneously and can be executed in any order — refund after success, withdraw after failure, withdraw twice, refund after the vault was withdrawn. The program has no succeeded/failed/withdrawn concept at all.

Root cause in programs/rustfund/src/lib.rs:

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// only time is checked:
if ctx.accounts.fund.deadline != 0
&& ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap()
{
return Err(ErrorCode::DeadlineNotReached.into());
}
// @> NO check that the goal was NOT met -> refund is legal after a SUCCESSFUL campaign
// ...
}
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> NO success/deadline checks (see H02) and no terminal-state flip anywhere
// ...
}

Risk

Likelihood:

  • Reason 1 — After any campaign's deadline, both entry points are callable; the outcome (goal met or not) is never consulted by either function.

  • Reason 2 — Front-running or simple ordering is all that is needed: on a successful campaign a contributor's refund can race the creator's withdrawal, and on a failed one the creator can withdraw before contributors refund.

Impact:

  • Impact 1 — On a successful campaign, a contributor can refund after the goal was met, taking SOL the creator has earned — the success payout is not protected.

  • Impact 2 — The two user powers promised in the spec are never made exclusive, so no state of the protocol is ever final and consistent; every terminal outcome is racy.

Proof of Concept

Anchor/TS against the shipped program (deterministic from the code):

// Fund: goal = 1 SOL, deadline = now + 1h. Contributor deposits 1.5 SOL (goal met).
await program.methods.contribute(new anchor.BN(1_500_000_000))
.accounts({ fund: fundPDA, contribution: contributionPDA, contributor: c.publicKey, systemProgram: SYSTEM })
.signers([c]).rpc();
// Wait past deadline. Campaign SUCCEEDED (amount_raised 1.5 >= goal 1).
// Yet refund() succeeds — nothing checks the goal outcome:
await program.methods.refund()
.accounts({ fund: fundPDA, contribution: contributionPDA, contributor: c.publicKey, systemProgram: SYSTEM })
.signers([c]).rpc();
// => creator's successful-campaign payout is drained by the refund path (given H01 fixed,
// today the drain is masked by contribution.amount never being credited).

Recommended Mitigation

Introduce an explicit terminal state and gate each flow on it:

pub struct Fund {
...
pub amount_raised: u64,
pub dealine_set: bool,
+ pub status: CampaignStatus, // Active | Succeeded | Failed
}
pub fn refund(...) {
// only the failure path may refund:
+ require!(goal_not_met_and_deadline_passed(...), ErrorCode::CampaignNotFailed);
...
+ fund.status = CampaignStatus::Failed; // terminal
}
pub fn withdraw(...) {
+ require!(fund.status == CampaignStatus::Active && deadline_passed && amount_raised >= goal, ...);
...
+ fund.status = CampaignStatus::Succeeded; // terminal
}

(Add the referenced CampaignNotFailed variant — or an equivalent guard — to ErrorCode.)

Updates

Lead Judging Commences

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