Rust Fund

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

refund does not verify the campaign failed: it omits the goal-not-met check and its deadline guard is skipped entirely when deadline == 0, allowing refunds outside the intended window

Root + Impact

Description

  • A contributor should be able to refund only when the campaign has actually failed, that is, only after the deadline has been reached with the goal not met.

    refund checks only the deadline, and even that check is bypassable. It never compares amount_raised against goal, so a successful campaign is still refundable. And because the deadline guard is written as deadline != 0 && deadline > now, when deadline == 0 (the default after fund_create, and the normal state because M-1 leaves the flag unset) the first conjunct is false, the && short-circuits, and the guard never triggers, so refund is callable at any time.

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
@> if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > now {
return Err(ErrorCode::DeadlineNotReached.into());
}
@> // no `require!(amount_raised < goal)`: a successful campaign is still refundable
@> // when deadline == 0 the `!= 0` conjunct short-circuits, so the guard is skipped entirely
// ... pays out `amount` and resets contribution.amount ...
}

Risk

Likelihood:

  • When no deadline has been set (the default deadline == 0 after fund_create), the guard is skipped and refund proceeds at any moment, including immediately after contributing.

  • When a campaign has met its goal and the deadline passes, refund is still accepted because the goal is never compared against amount_raised.

Impact:

  • Refund is reachable outside the intended "deadline reached and goal not met" window, both on no-deadline funds and on successful campaigns, which breaks the state machine the protocol relies on.

  • Once per-contributor amounts are tracked (the H-2 fix), this same unsound authorization becomes a direct drain: contributors could pull funds from a successful campaign or instantly refund, racing the creator's withdrawal. In the current code the payout is 0 due to H-2, so the value loss is latent, but the authorization logic is wrong.

Proof of Concept

On a fund where no deadline was ever set, refund is accepted even though no deadline has been reached and the goal was never checked (it does not revert with DeadlineNotReached).

it("refund is accepted with no deadline set and without checking the goal", async () => {
// fund created, NO setDeadline call -> fund.deadline == 0
await program.methods.contribute(new anchor.BN(LAMPORTS_PER_SOL))
.accounts({ fund: fundPDA, contributor: contributor.publicKey, contribution: contributionPDA, systemProgram: SystemProgram.programId })
.signers([contributor]).rpc();
// Deadline not reached and goal not checked, yet refund does NOT revert:
await program.methods.refund()
.accounts({ fund: fundPDA, contribution: contributionPDA, contributor: contributor.publicKey, systemProgram: SystemProgram.programId })
.signers([contributor]).rpc(); // succeeds (would drain if contribution.amount were tracked)
});

Recommended Mitigation

Restrict refund to genuinely failed campaigns. Requiring that a deadline was set and has passed closes the deadline == 0 short-circuit bypass, and requiring amount_raised < goal ensures refunds are only available when the campaign did not succeed, so contributors cannot pull funds the creator is entitled to and cannot refund before the campaign has actually ended.

Add DeadlineNotSet and GoalMet variants to ErrorCode. This relies on the M-1 fix (dealine_set actually being set) to be fully effective.

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();
+ require!(ctx.accounts.fund.dealine_set, ErrorCode::DeadlineNotSet);
+ require!(now >= ctx.accounts.fund.deadline, ErrorCode::DeadlineNotReached);
+ require!(ctx.accounts.fund.amount_raised < ctx.accounts.fund.goal, ErrorCode::GoalMet);
// ...
Updates

Lead Judging Commences

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