Rust Fund

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

Race Condition Between Withdraw and Refund Enables Double-Spend

Root + Impact

Description

Normal behavior:
Once funds are claimed from a campaign (either via creator withdrawal or contributor refund), they should be removed
atomically with mutual exclusion to prevent double-spending from the same account.

The specific issue:
Both withdraw() and refund() independently transfer lamports from the same fund account without any mutual exclusion
mechanism or state flag to prevent concurrent access. This creates a race condition where the same SOL can be claimed
twice, or one function fails after the other partially succeeds, leaving the system in an inconsistent state.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> Drains fund account without state tracking
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? += amount;
Ok(())
// @> No flag prevents calling again
}
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// @> Also drains fund account from same source
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? += amount;
ctx.accounts.contribution.amount = 0;
Ok(())
// @> No coordination with withdraw()
}
#[account]
pub struct Fund {
// @> Missing: pub withdrawn: bool;
pub amount_raised: u64,
}

Risk

Likelihood:

Likelihood:

  • Multiple transactions are processed concurrently in the same block on Solana, making race conditions between withdraw()
    and refund() naturally occur when both are submitted near the deadline

  • Any campaign meeting the goal while contributors simultaneously request refunds triggers both functions attempting to
    drain the same account without coordination

Impact:

  • Same SOL is claimed twice (double-spend) if both functions execute before balance checks fail, or one party loses their
    funds entirely depending on transaction order

  • System becomes unpredictable: if withdraw() succeeds first, refund() fails with InsufficientFunds blocking contributors;
    if refund() succeeds first, creator's withdrawal fails, preventing them from claiming legitimate funds

Proof of Concept

This test creates a campaign meeting its goal, then calls withdraw() first to drain the fund. When refund() is
subsequently called, it fails because the fund is empty. This shows the race condition: depending on transaction ordering,
both functions attempt to claim the same account, with one failing while the other succeeds.

it("both withdraw and refund drain same account (race condition)", async () => {
const creator = provider.wallet;
const goal = new anchor.BN(1000e9);
const contribution = new anchor.BN(1000e9);
const [fundPDA] = await findFundPDA("F05Fund", creator.publicKey);
const [contributionPDA] = await findContributionPDA(fundPDA, creator.publicKey);
// Create fund
await program.methods.fundCreate("F05Fund", "Test", goal)
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram })
.rpc();
// Set deadline
const deadline = new anchor.BN(Math.floor(Date.now() / 1000));
await program.methods.setDeadline(deadline)
.accounts({ fund: fundPDA, creator: creator.publicKey })
.rpc();
// Contribute (goal met)
await program.methods.contribute(contribution)
.accounts({
fund: fundPDA,
contributor: creator.publicKey,
contribution: contributionPDA,
systemProgram,
})
.rpc();
const fundBalanceBefore = await provider.connection.getBalance(fundPDA);
console.log("Fund balance before:", fundBalanceBefore, "lamports (1000 SOL)");
await new Promise(r => setTimeout(r, 1000));
// Creator withdraws
console.log("Creator calls withdraw()...");
const creatorBefore = await provider.connection.getBalance(creator.publicKey);
await program.methods.withdraw()
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram })
.rpc();
const creatorAfter = await provider.connection.getBalance(creator.publicKey);
const fundAfter1 = await provider.connection.getBalance(fundPDA);
console.log("Creator received: ", (creatorAfter - creatorBefore) / 1e9, "SOL");
console.log("Fund balance after withdraw: ", fundAfter1, "lamports");
// Contributor tries to refund (should fail - fund is empty)
console.log("Contributor calls refund()...");
try {
await program.methods.refund()
.accounts({
fund: fundPDA,
contribution: contributionPDA,
contributor: creator.publicKey,
systemProgram,
})
.rpc();
console.log("REFUND ALSO SUCCEEDED - double-spend!");
} catch (err) {
console.log("Refund failed (fund already drained)");
console.log("Race condition: Both functions try to transfer from same account.");
}
});

Recommended Mitigation

The race condition exists because there is no state flag preventing both functions from simultaneously claiming the same
account. Adding a withdrawn boolean tracks whether funds have already been claimed, allowing both functions to safely
check before transferring and blocking the second caller with a clear error.

#[account]
#[derive(InitSpace)]
pub struct Fund {
#[max_len(200)]
pub name: String,
#[max_len(5000)]
pub description: String,
pub goal: u64,
pub deadline: u64,
pub creator: Pubkey,
pub amount_raised: u64,
pub deadline_set: bool,
+ pub withdrawn: bool, // Track if funds have been withdrawn
}
Add the check and state update to withdraw():
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let amount = fund.amount_raised;
+ // Prevent double-withdrawal
+ require!(!fund.withdrawn, ErrorCode::AlreadyWithdrawn);
**fund.to_account_info().try_borrow_mut_lamports()? =
fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
+ fund.withdrawn = true; // Mark as claimed
+ fund.amount_raised = 0; // Zero out to prevent double-counting
Ok(())
}
Add the check to refund():
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());
}
+ // Prevent refund if creator already withdrew
+ require!(!fund.withdrawn, ErrorCode::AlreadyWithdrawn);
**fund.to_account_info().try_borrow_mut_lamports()? =
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(())
}
Add the error variant:
#[error_code]
pub enum ErrorCode {
// ... existing errors ...
#[msg("Funds already withdrawn")]
AlreadyWithdrawn,
}
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!