Rust Fund

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

Refund does not decrease fund amount_raised

Refund does not decrease fund amount_raised

Description

When a contributor is refunded, the fund's total raised amount should be reduced by the refunded amount. Otherwise, the campaign accounting continues to count funds that have already left the fund account.

The refund instruction transfers contribution.amount lamports from the fund account to the contributor and then resets contribution.amount to zero. However, it never decrements fund.amount_raised. This leaves the global fund accounting overstated after refunds.

In the current codebase, this issue is partially masked by the separate contribution-accounting bug: contribution.amount is never increased during contribute, so the reachable refund path transfers zero lamports. Even so, the refund handler itself is missing the accounting update required to keep fund.amount_raised synchronized with actual fund balances.

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());
}
**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;
@> // fund.amount_raised is never decremented by amount
Ok(())
}

Risk

Likelihood:

  • This occurs on every refund path because the handler never updates fund.amount_raised.

  • Once contribution accounting is fixed so that contribution.amount can be non-zero, every successful refund will leave global fund accounting overstated.

Impact:

  • Refunded funds can still be counted as raised by the campaign.

  • Stale amount_raised can break later withdrawal logic by making the program attempt to withdraw lamports that are no longer held by the fund account.

Proof of Concept

const airdropSolAmount = 5;
const airdrop = async (
pubkey: anchor.web3.PublicKey,
sol = airdropSolAmount
) => {
const sig = await provider.connection.requestAirdrop(
pubkey,
sol * anchor.web3.LAMPORTS_PER_SOL
);
const latest = await provider.connection.getLatestBlockhash();
await provider.connection.confirmTransaction(
{
signature: sig,
blockhash: latest.blockhash,
lastValidBlockHeight: latest.lastValidBlockHeight,
},
"confirmed"
);
};
it("refund leaves amountRaised unchanged", async () => {
const testCreator = provider.wallet;
const testContributor = anchor.web3.Keypair.generate();
const testFundName = `refund-accounting-${Date.now()}`;
const testContribution = new anchor.BN(anchor.web3.LAMPORTS_PER_SOL / 2);
await airdrop(testContributor.publicKey);
const [testFundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(testFundName), testCreator.publicKey.toBuffer()],
program.programId
);
const [testContributionPDA] = await PublicKey.findProgramAddress(
[testFundPDA.toBuffer(), testContributor.publicKey.toBuffer()],
program.programId
);
await program.methods
.fundCreate(testFundName, description, goal)
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
await program.methods
.contribute(testContribution)
.accountsPartial({
fund: testFundPDA,
contributor: testContributor.publicKey,
contribution: testContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([testContributor])
.rpc();
const fundAccountBeforeRefund =
await program.account.fund.fetch(testFundPDA);
await program.methods
.refund()
.accountsPartial({
fund: testFundPDA,
contribution: testContributionPDA,
contributor: testContributor.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([testContributor])
.rpc();
const fundAccountAfterRefund =
await program.account.fund.fetch(testFundPDA);
// In the current code this refund transfers 0 because contribution.amount
// is never recorded. The handler still shows a separate accounting issue:
// it never decrements fund.amountRaised.
expect(fundAccountBeforeRefund.amountRaised.toNumber()).to.eq(
fundAccountAfterRefund.amountRaised.toNumber()
);
});

The test shows that fund.amountRaised remains unchanged across the refund path. In the current implementation the refund amount is zero because of the separate missing contribution accounting bug, but the refund handler still lacks the required fund.amount_raised -= amount update.

Recommended Mitigation

Decrement fund.amount_raised by the refunded amount before resetting the contributor's amount. Use checked arithmetic to prevent underflow.

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
**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.fund.amount_raised = ctx.accounts
+ .fund
+ .amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.contribution.amount = 0;
Ok(())
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 23 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution

# \[H-02] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution ## Description The `refund` function does not update `fund.amount_raised`, causing an inconsistency between the fund's actual balance and the recorded raised amount. As a result, when the fund creator tries to withdraw funds, the transaction may fail due to insufficient balance, effectively locking funds in the contract. ## Vulnerability Details The issue arises in the `refund` function, which transfers funds back to the contributor but does not update the `amount_raised` field: ```rust 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()); } 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)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; Ok(()) } ``` The issue becomes evident when the fund creator attempts to withdraw using the following function: ```rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let amount = ctx.accounts.fund.amount_raised; 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.creator.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.creator.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ``` Since `amount_raised` is never updated when a refund occurs, the creator will attempt to withdraw more than what actually exists in the fund, causing an insufficient funds error and failing the transaction. ## Impact - If any contributor requests a refund, the total balance in the fund decreases. However, `fund.amount_raised` remains unchanged, leading to an overestimated available balance. - When the fund creator calls `withdraw`, they attempt to transfer `fund.amount_raised`, which no longer matches the actual available balance. - This results in a failed transaction, effectively locking funds in the contract since the withdraw function will always fail if refunds have been processed. ## Proof of Concept This issue is not currently caught by tests because the `contribute` function itself has a bug (not updating `contribution.amount`), preventing the refund function from executing properly. Once the contribute function is fixed, the issue will be clearly visible in test cases. ## Recommendations The `refund` function must update `fund.amount_raised` to ensure the contract state reflects the actual balance after refunds. ### Fixed Code: ```diff 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()); } 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)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; + // Fix: Decrease the fund's recorded amount_raised + let fund = &mut ctx.accounts.fund; + fund.amount_raised = fund.amount_raised.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!