Rust Fund

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

withdraw() transfers amount_raised instead of actual vault lamports — rent permanently stranded

Root + Impact

Description

The fund PDA holds lamports equal to (rent-exempt minimum) + (sum of contributions). fund.amount_raised tracks only contributions. When withdraw() computes lamports().checked_sub(amount_raised), the result leaves the rent-exempt balance behind in the PDA. The creator receives less than expected, the PDA persists indefinitely consuming storage, and if amount_raised is overstated due to S-04 the subtraction reverts entirely — blocking all legitimate creator withdrawals on otherwise successful campaigns.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised; // contributions only, not full balance
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
@> .checked_sub(amount) // rent lamports remain stranded in PDA
.ok_or(ProgramError::InsufficientFunds)?;
...
}

Risk

Likelihood:

  • Every successful withdrawal leaves rent lamports stranded in the fund PDA since the account is never closed — affects every single withdrawal

  • When compounded with S-04 (stale amount_raised), the checked_sub fails entirely, blocking all creator withdrawals

Impact:

  • Creator does not receive the full expected amount — rent lamports (typically ~0.002 SOL per KB) remain inaccessible in the PDA

  • The fund account persists on-chain indefinitely consuming storage, contributing to state bloat

  • If amount_raised is overstated (S-04), the withdrawal reverts and the creator cannot recover funds from an otherwise successful campaign

Proof of Concept

it('fund PDA retains rent lamports after full withdrawal', async () => {
await contributeAndWithdraw(2_000_000_000);
const remaining = await provider.connection.getBalance(fundPda);
const rentExempt = await provider.connection
.getMinimumBalanceForRentExemption(8 + 5209); // Fund account size
@>// remaining ≈ rentExempt — rent lamports never returned to creator
assert.approximately(remaining, rentExempt, 100, 'Rent stranded in PDA');
});

Recommended Mitigation

The cleanest fix is to close the fund account on withdrawal using Anchor's close constraint, which automatically transfers all remaining lamports including rent to the specified account:

#[derive(Accounts)]
pub struct FundWithdraw<'info> {
- #[account(mut, seeds = [...], bump, has_one = creator)]
+ #[account(mut, seeds = [...], bump, has_one = creator, close = creator)]
pub fund: Account<'info, Fund>,
#[account(mut)]
pub creator: Signer<'info>,
pub system_program: Program<'info, System>,
}
// With close = creator, the entire withdraw() body simplifies to:
+ // Anchor automatically transfers all lamports to creator and zero-fills the account
- let amount = ctx.accounts.fund.amount_raised;
- **ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ...
- **ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ...
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-04] Unclaimed rent from fund and contribution accounts leads to permanent SOL lockup

## Description neither the `withdraw()` nor `refund()` functions properly close their respective accounts after the funds have been transferred. This results in the rent amount (the SOL required to keep the account allocated on the Solana blockchain) remaining locked in these accounts indefinitely. ## Vulnerability Details In Solana, accounts must maintain a minimum balance to remain "rent-exempt", ensuring they aren't purged from the blockchain. When accounts are no longer needed, best practice is to close them and return this rent to the appropriate party (typically the account creator). The RustFund protocol currently lacks this account cleanup mechanism. - The withdrawal process in `withdraw()` transfers the raised funds from the fund account to the creator - Similarly, the refund process in `refund()` transfers the contribution amount back to the contributor and resets the contribution amount In both functions, the account data is updated, but the accounts themselves remain open, with their rent amount locked. After all funds have been withdrawn or refunded, these accounts serve no further purpose but continue to consume blockchain resources and lock up SOL. ### Proof of Concept 1. Alice creates a fund with a goal of 100 SOL, which requires 0.1 SOL as rent for the fund account. 2. The fund successfully raises 100 SOL from various contributors, each of whom also paid rent for their contribution accounts (approximately 0.05 SOL each). 3. Alice calls `withdraw()` to claim the 100 SOL raised funds, but the fund account itself is not closed. 4. The 0.1 SOL rent for the fund account remains locked in the account indefinitely. 5. Similarly, when contributors request refunds through the `refund()` function, their contribution accounts are updated but not closed. 6. The rent for all contribution accounts (e.g., 10 contributors × 0.05 SOL = 0.5 SOL) remains locked indefinitely. 7. Over time, as more funds are created and more contributions are made, the amount of permanently locked SOL grows. ## Impact - Economic Inefficiency, - Blockchain bloat, - Reduced user returns ## Recommendations Implement proper account closure in both the `withdraw()` and `refund()` functions.

Support

FAQs

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

Give us feedback!