RustFund

First Flight #36
Beginner FriendlyRust
100 EXP
View results
Submission Details
Severity: low
Valid

Stuck Rent Exemption Lamports in Fund and Contribution Accounts

Summary

The "rustfund" contract has a design flaw where rent exemption lamports, paid during the creation of Fund and Contribution accounts, remain stuck in these accounts after their purpose is fulfilled. There is no mechanism to close these accounts or reclaim the remaining SOL, leaving small amounts (approximately 0.00089 SOL per account, based on Solana’s rent model) inaccessible. While the financial impact is minor, this inefficiency results in a permanent loss of resources, which is suboptimal for a production-ready contract and could accumulate costs in a widely used system.


Vulnerability Details

The vulnerability stems from the lack of account closure functionality in the contract. In Solana, accounts created with the init instruction (e.g., Fund and Contribution accounts) require a minimum balance to be rent-exempt, paid by the creator or contributor. However, once the fund is withdrawn or contributions are refunded, these accounts persist with their rent exemption lamports, and no function exists to reclaim them.

Root Cause

The root cause is the absence of a "close account" instruction for Fund and Contribution accounts after their lifecycle ends. The contract initializes these accounts with specific space allocations but does not provide a way to deallocate them. Below are the relevant snippets:

Fund Account Creation in FundCreate

#[derive(Accounts)]
#[instruction(name: String)]
pub struct FundCreate<'info> {
#[account(init, payer = creator, space = 8 + Fund::INIT_SPACE, seeds = [name.as_bytes(), creator.key().as_ref()], bump)]
pub fund: Account<'info, Fund>,
#[account(mut)]
pub creator: Signer<'info>,
pub system_program: Program<'info, System>,
}
  • The init attribute creates the fund account, with the creator paying the rent exemption (e.g., ~0.00089 SOL for a minimal account, more with Fund::INIT_SPACE).

  • After FundWithdraw transfers fund.amount_raised, the rent exemption lamports remain in the account.

Contribution Account Creation in FundContribute

#[derive(Accounts)]
pub struct FundContribute<'info> {
#[account(mut)]
pub fund: Account<'info, Fund>,
#[account(mut)]
pub contributor: Signer<'info>,
#[account(
init_if_needed,
payer = contributor,
space = 8 + Contribution::INIT_SPACE,
seeds = [fund.key().as_ref(), contributor.key().as_ref()],
bump
)]
pub contribution: Account<'info, Contribution>,
pub system_program: Program<'info, System>,
}
  • The init_if_needed attribute creates the contribution account if it doesn’t exist, with the contributor paying the rent exemption.

  • After FundRefund resets contribution.amount to 0, the account persists with its rent exemption balance.

Withdrawal in FundWithdraw

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(())
}
  • This transfers only fund.amount_raised, leaving the rent exemption lamports (initial balance minus amount_raised) in the fund account.

In Solana, accounts must maintain a minimum balance to avoid rent collection, and without a closure mechanism, these lamports are effectively locked.


Impact

The impact of this vulnerability is relatively low but notable:

  • Resource Loss: Each Fund and Contribution account retains its rent exemption lamports (e.g., ~0.00089 SOL for a small account, potentially more depending on INIT_SPACE). For a single campaign, this is negligible, but in a system with many funds or contributors, the cumulative loss could become significant.

Tools Used

  • Manual Code Review

Recommendations

To mitigate this vulnerability, the following steps are recommended:

  1. Add a CloseFund Function:
    Implement a new function to close the Fund account after withdrawal, transferring remaining lamports to the creator. Here’s an example:

    #[derive(Accounts)]
    pub struct CloseFund<'info> {
    #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump, has_one = creator, close = creator)]
    pub fund: Account<'info, Fund>,
    #[account(mut)]
    pub creator: Signer<'info>,
    pub system_program: Program<'info, System>,
    }
    pub fn close_fund(ctx: Context<CloseFund>) -> Result<()> {
    // Optional: Add checks (e.g., amount_raised == 0) if withdrawal must occur first
    Ok(())
    }
    • The close = creator attribute transfers the remaining lamports to the creator and deallocates the fund account.

    • Call this after FundWithdraw to reclaim the rent exemption.

  2. Add a CloseContribution Function:
    Implement a function to close the Contribution account after a refund, transferring remaining lamports to the contributor:

    #[derive(Accounts)]
    pub struct CloseContribution<'info> {
    #[account(mut)]
    pub fund: Account<'info, Fund>,
    #[account(
    mut,
    seeds = [fund.key().as_ref(), contributor.key().as_ref()],
    bump,
    has_one = contributor,
    has_one = fund,
    close = contributor
    )]
    pub contribution: Account<'info, Contribution>,
    #[account(mut)]
    pub contributor: Signer<'info>,
    pub system_program: Program<'info, System>,
    }
    pub fn close_contribution(ctx: Context<CloseContribution>) -> Result<()> {
    if ctx.accounts.contribution.amount != 0 {
    return Err(ErrorCode::ContributionNotRefunded.into());
    }
    Ok(())
    }
    • The close = contributor attribute reclaims the lamports.

    • The check ensures the contribution is refunded first.

Updates

Appeal created

bube Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Accounts are not closed after withdraw and refund

Support

FAQs

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