Rust Fund

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

Refund Allowed When Goal Is Met, Enabling Double-Spend

Root + Impact

Description

Normal behavior:
According to the contract specification, refunds should only be allowed when the deadline is reached AND the funding goal
was not met. This ensures contributors receive their money back only if campaigns fail, while creators can claim funds
from successful campaigns.

The specific issue:
The refund() function validates that the deadline has been reached but does not verify that the goal was NOT met. This
missing check allows contributors to request refunds even from successful campaigns where amount_raised >= goal, creating
a double-spend vulnerability where both the creator (via withdraw()) and contributors (via refund()) can claim the same
funds.

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());
}
// @> MISSING: Check if goal was NOT met
// @> Should verify: amount_raised < goal
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.contributor.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0;
Ok(())
}

Risk

Likelihood:

  • Contributors naturally call refund() once the deadline is reached, expecting to recover their money if the goal wasn't
    met — the missing goal check is invisible to normal usage patterns, so this bug is triggered automatically on every
    successful campaign

  • The vulnerability activates on any campaign that meets its goal and then reaches the deadline, affecting all
    contributors without requiring special conditions or attacker sophistication

Impact:

  • Creates a double-spend vulnerability where both creator (calling withdraw()) and contributors (calling refund()) claim
    the same SOL from the fund account, draining it and causing one party to lose money

  • Violates the contract's core promise that "contributors can get refunds if deadlines are reached and goals aren't met,"
    breaking the refund mechanism for successful campaigns

  • System state becomes dependent on transaction ordering — if creator withdraws first, refund calls fail with
    InsufficientFunds; if refund succeeds first, creator's withdrawal fails, leaving the campaign in an inconsistent state

Proof of Concept

This test creates a campaign with a 1000 SOL goal, immediately contributes exactly 1000 SOL (goal is met), then waits for
the deadline. The contributor then calls refund() which should fail because the goal was achieved. Instead, the refund
succeeds, proving that the missing goal check allows double-spending on successful campaigns.

it("refund is allowed even when goal is met", async () => {
const creator = provider.wallet;
const goal = new anchor.BN(1000e9); // 1000 SOL
const contribution = new anchor.BN(1000e9); // Exactly 1000 SOL (goal met!)
const [fundPDA] = await findFundPDA("F04Fund", creator.publicKey);
const [contributionPDA] = await findContributionPDA(fundPDA, creator.publicKey);
// Create fund
await program.methods.fundCreate("F04Fund", "Test", goal)
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram })
.rpc();
// Set deadline to now
const deadline = new anchor.BN(Math.floor(Date.now() / 1000));
await program.methods.setDeadline(deadline)
.accounts({ fund: fundPDA, creator: creator.publicKey })
.rpc();
// Contribute exactly 1000 SOL (GOAL IS MET)
await program.methods.contribute(contribution)
.accounts({
fund: fundPDA,
contributor: creator.publicKey,
contribution: contributionPDA,
systemProgram,
})
.rpc();
let fundData = await program.account.fund.fetch(fundPDA);
console.log("Goal: ", fundData.goal.toString());
console.log("Raised: ", fundData.amountRaised.toString());
console.log("Goal met?", fundData.amountRaised.gte(fundData.goal) ? "YES ✓" : "NO ❌");
// Wait for deadline
await new Promise(r => setTimeout(r, 1000));
// Try to refund (should FAIL because goal was met)
try {
await program.methods.refund()
.accounts({
fund: fundPDA,
contribution: contributionPDA,
contributor: creator.publicKey,
systemProgram,
})
.rpc();
console.log("REFUND SUCCEEDED (should have failed!)");
expect(true).to.be.false; // Should not reach here
} catch (err) {
console.log("Correctly blocked");
}
});

Recommended Mitigation

The refund function must verify both conditions before processing: (1) the deadline has passed, AND (2) the goal was not
met. Without the goal check, the contract cannot distinguish between failed campaigns (where refunds are appropriate) and
successful ones (where funds belong to the creator). This single missing validation enables the entire double-spend
vulnerability.

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let fund = ctx.accounts.fund;
let amount = ctx.accounts.contribution.amount;
if fund.deadline != 0 && fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineNotReached.into());
}
// REQUIRED: Verify goal was NOT met (only refund failed campaigns)
+ require!(
+ fund.amount_raised < fund.goal,
+ ErrorCode::GoalMet
+ );
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.contributor.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0;
Ok(())
}
Also add the error variant to the ErrorCode enum:
#[error_code]
pub enum ErrorCode {
// ... existing errors ...
#[msg("Goal was met, refunds not allowed")]
GoalMet,
}
Updates

Lead Judging Commences

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