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;
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? -= amount;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? += amount;
Ok(())
}
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
**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(())
}
#[account]
pub struct Fund {
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);
await program.methods.fundCreate("F05Fund", "Test", goal)
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram })
.rpc();
const deadline = new anchor.BN(Math.floor(Date.now() / 1000));
await program.methods.setDeadline(deadline)
.accounts({ fund: fundPDA, creator: creator.publicKey })
.rpc();
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));
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");
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,
}