Rust Fund

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

contribute() accepts contributions to a fund with no active deadline after withdrawal

Root + Impact

Description

contribute() adds SOL to a fund. It should reject contributions once the campaign is over (goal met, funds withdrawn, or deadline passed). The problem is that when deadline == 0 (never set), the deadline check is skipped entirely, so contribute() always succeeds — even after the creator has already called withdraw(). New contributions flow into a fund that has already been emptied, with no way to prevent it and unclear recoverability.

pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
// @> if deadline == 0, this check is skipped -> always accepts
if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineReached.into());
}
// ... transfer SOL, fund.amount_raised += amount ...
Ok(())
}

Risk

Likelihood:

  • Occurs on any fund where the deadline was never set (deadline == 0), which is the default state after fund_create.

Impact:

  • Contributions are accepted even after the creator withdrew, so new funds enter a fund with corrupted accounting (see amount_raised bug), and there is no clean state marking the campaign as closed.

Proof of Concept

  1. Creator creates a fund (deadline defaults to 0, never set).

  2. Alice contributes 5 SOL.

  3. Creator calls withdraw and takes the funds.

  4. Bob contributes 3 SOL — the call succeeds because deadline == 0 skips the
    deadline check entirely, even though the campaign was effectively closed.

  5. Bob's funds enter a fund with already-corrupted accounting and no clean way out.

it("contribute succeeds even after the creator withdrew (deadline never set)", async () => {
// Alice contributes
await program.methods.contribute(new BN(5 * LAMPORTS_PER_SOL))
.accounts({ fund, contributor: alice.publicKey, /* ... */ })
.signers([alice]).rpc();
// Creator withdraws
await program.methods.withdraw()
.accounts({ fund, creator: creator.publicKey, /* ... */ })
.signers([creator]).rpc();
// Bob contributes AFTER withdrawal — should be rejected, but succeeds
await program.methods.contribute(new BN(3 * LAMPORTS_PER_SOL))
.accounts({ fund, contributor: bob.publicKey, /* ... */ })
.signers([bob]).rpc();
const fundAcc = await program.account.fund.fetch(fund);
// Bob's contribution was accepted into an already-withdrawn fund
assert.isTrue(fundAcc.amountRaised.toNumber() >= 3 * LAMPORTS_PER_SOL);
});

Recommended Mitigation

Note: with deadline == 0 (the default), the deadline check in contribute is skipped, so contributions are accepted regardless of the fund's real lifecycle state. Running requires the Anchor test setup; the missing check is evident from the source.

+ // Add a `closed` / `withdrawn` flag to the Fund struct, set it in withdraw(),
+ // and reject contributions when closed:
pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
+ require!(!fund.withdrawn, ErrorCode::FundClosed);
if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineReached.into());
}
// ...
}
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!