Summary
The refund
function contains serious flaws that can lead to incorrect refunds, fund loss, and contract bricking. It fails to check if the fundraising goal was met before issuing refunds, incorrectly manipulates account balances, and does not prevent multiple refunds per user.
Vulnerability Details
1️⃣ Incorrect Refund Condition (Does Not Check If Goal Was Met).
The function only checks if the deadline has passed before issuing a refund:
if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {return Err(ErrorCode::DeadlineNotReached.into());}
However, it does not check if the funding goal was met.
If the goal was met, refunds should not be issued.
If the goal was not met, contributors should be refunded.
Impact
Potential loss of funds → If the goal was reached, refunds should not be processed.
Violation of crowdfunding logic → Refunds should depend on whether the goal was met, not just the deadline.
PoC Exploit:
await program.methods
.refund()
.accounts({ fund: fundPDA, contributor: user.publicKey })
.signers([user])
.rpc();
Recommendations
Modify refund to only allow refunds if the goal was NOT met:
if fund.amount_raised >= fund.goal {
return Err(ErrorCode::GoalMetNoRefund.into());
}
Vulnerability Details
2️⃣ Unsafe Manual Lamport Manipulation (Fund & Contributor Balance Updates)
The function directly modifies lamport balances:
**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)?;
This approach is unsafe because:
Bypasses Solana’s runtime security checks.
Can lead to account state corruption.
If an error occurs mid-transaction, funds can be lost.
Impact
Contract bricking risk if an invalid balance update occurs.
Loss of funds due to direct manipuation of balances.
No proper transfer execution via invoke_signed().
Recommendations
Replace direct lamport updates with invoke_signed for secure fund transfers:
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.fund.to_account_info().key,
&ctx.accounts.contributor.to_account_info().key,
amount,
);
invoke_signed(
&transfer_instruction,
&[
ctx.accounts.fund.to_account_info(),
ctx.accounts.contributor.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[fund.key().as_ref(), contributor.key().as_ref(), &[ctx.bumps.contribution]]],
)?;
Vulnerability Details
3️⃣ No Protection Against Multiple Refunds (Double Spending Issue)
The function does not mark contributions as refunded before transferring funds:
ctx.accounts.contribution.amount = 0;
Since the refund is issued before setting amount = 0, an attacker can call refund() multiple times before the transaction finalizes, draining the entire fund.
Impact:
PoC Exploit:
await Promise.all([
program.methods.refund().accounts({ fund: fundPDA, contributor: user.publicKey }).signers([user]).rpc(),
program.methods.refund().accounts({ fund: fundPDA, contributor: user.publicKey }).signers([user]).rpc(),
]);
Recommendations
Mark the contribution as refunded before processing the refund:
let refund_amount = contribution.amount;
contribution.amount = 0;
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.fund.to_account_info().key,
&ctx.accounts.contributor.to_account_info().key,
refund_amount,
);
invoke_signed(
&transfer_instruction,
&[
ctx.accounts.fund.to_account_info(),
ctx.accounts.contributor.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[fund.key().as_ref(), contributor.key().as_ref(), &[ctx.bumps.contribution]]],
)?;
Final Comprehensive Fixed Code
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let contribution = &mut ctx.accounts.contribution;
let contributor = &mut ctx.accounts.contributor;
if fund.amount_raised >= fund.goal {
return Err(ErrorCode::GoalMetNoRefund.into());
}
let current_time = Clock::get()
.map_err(|_| ErrorCode::CalculationOverflow)?
.unix_timestamp
.try_into()
.map_err(|_| ErrorCode::CalculationOverflow)?;
if fund.deadline != 0 && fund.deadline > current_time {
return Err(ErrorCode::DeadlineNotReached.into());
}
let refund_amount = contribution.amount;
contribution.amount = 0;
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.fund.to_account_info().key,
&ctx.accounts.contributor.to_account_info().key,
refund_amount,
);
invoke_signed(
&transfer_instruction,
&[
ctx.accounts.fund.to_account_info(),
ctx.accounts.contributor.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[fund.key().as_ref(), contributor.key().as_ref(), &[ctx.bumps.contribution]]],
)?;
Ok(())
}