Rust Fund

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

No instruction closes Contribution or Fund accounts, so each contributor's Contribution rent and each creator's Fund rent stay locked in program-owned accounts with no way to reclaim them

Root + Impact

Description

  • On Solana, an account that a program creates for a user is normally closed when it is no longer needed. Its rent-exempt deposit then goes back to whoever paid it. Here that means the contributor's Contribution record after a refund, and the creator's Fund account once the campaign is settled.

  • Each contributor pays the rent for an 80-byte Contribution PDA (init_if_needed, payer = contributor, L125-L131). Each creator pays the rent for the 5,273-byte Fund PDA (init, payer = creator, L111). No account in the program has a close constraint, and there is no close instruction.

    • refund() only sets contribution.amount = 0 (L85).

    • withdraw() moves only amount_raised (L91) and leaves the rent behind.

    Both accounts are owned by the program, so nothing else can debit them. The Contribution record does stay reusable: a later contribution to the same fund reuses it without charging rent again. Only the rent cannot be reclaimed.

// programs/rustfund/src/lib.rs L84-L85 (@> L85): refund(): zeroes the record instead of closing it
// Reset contribution amount after refund
@> ctx.accounts.contribution.amount = 0;
​
// programs/rustfund/src/lib.rs L144-L155 (@> L153): FundRefund: no close constraint on the Contribution account
#[derive(Accounts)]
pub struct FundRefund<'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
)]
pub contribution: Account<'info, Contribution>,
​
// programs/rustfund/src/lib.rs L161-L164 (@> L163): FundWithdraw: no close constraint on the Fund account
#[derive(Accounts)]
pub struct FundWithdraw<'info> {
@> #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump,has_one = creator)]
pub fund: Account<'info, Fund>,

Risk

Likelihood:

  • It occurs whenever a user backs or creates a campaign. The contributor pays the Contribution rent on their first contribution to a fund, and the creator pays the Fund rent in fund_create. Both deposits are left behind when the campaign ends, whether it ends in refunds or in a withdrawal.

  • A user has no instruction to call later to recover them. The PoC shows Anchor rejecting close, close_contribution, close_fund and close_account with InstructionFallbackNotFound (101).

Impact:

  • Each contributor permanently loses 1,447,680 lamports (about 0.00145 SOL) per fund they back. Each creator permanently loses 37,590,960 lamports (about 0.0376 SOL) per campaign. The amounts are small and are the users' own deposits, so the impact is Low.

  • refund() leaves a zeroed Contribution account on-chain instead of cleaning up. The Fund rent can come back only in one degenerate case: if amount_raised at the first withdrawal equals the Fund rent exactly, a second withdraw() re-subtracts the stale total and drains the account to 0. With one lamport more or less, it fails (boundary test).

Proof of Concept

Test 1 follows one contributor and one creator.

  • It checks the rent figures against the Rent sysvar: Contribution 80 bytes = 1,447,680 lamports; Fund 5,273 bytes = 37,590,960 lamports.

  • fund_create debits the creator exactly the Fund rent. contribute(1 SOL) debits Alice exactly 1 SOL plus the Contribution rent.

  • After refund(), the Contribution account still exists with its rent and amount = 0. Alice receives 0 because of the separate contribution.amount bug. The test asserts that the rent is not returned, whatever the recorded amount.

  • A second 0.5 SOL contribution costs Alice exactly 0.5 SOL, so the record is reused.

  • withdraw() pays the creator exactly amount_raised and leaves the Fund holding exactly its rent.

  • Every recovery attempt fails:

    • a second refund() moves nothing;

    • a second withdraw() fails with InsufficientFunds;

    • close, close_contribution, close_fund and close_account fail with InstructionFallbackNotFound (101).

  • Both deposits remain in the program-owned accounts.

Test 2 sets the limits of the Fund case:

  • With amount_raised equal to the Fund rent, a second withdraw() drains the Fund to 0 and returns the rent.

  • With one lamport less, it fails with InsufficientFundsForRent.

  • With one lamport more, it fails with InsufficientFunds.

// PoC RF-08: no instruction closes Contribution or Fund accounts, so the rent
// deposits paid by contributors and creators stay locked in program-owned PDAs.
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
system_instruction,
transaction::{Transaction, TransactionError},
};
​
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
const CONTRIBUTION_SPACE: usize = 8 + 72; // 80
const FUND_SPACE: usize = 8 + 5265; // 5273
const CONTRIBUTION_RENT: u64 = 1_447_680; // (128 + 80) * 6960
const FUND_RENT: u64 = 37_590_960; // (128 + 5273) * 6960
​
fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let accounts = Box::leak(Box::new(accounts.to_vec()));
rustfund::entry(program_id, accounts, data)
}
​
async fn send(
ctx: &mut ProgramTestContext,
ixs: &[Instruction],
extra_signers: &[&Keypair],
) -> std::result::Result<(), BanksClientError> {
let bh = ctx.get_new_latest_blockhash().await.unwrap();
let mut signers: Vec<&Keypair> = vec![&ctx.payer];
signers.extend_from_slice(extra_signers);
// ctx.payer pays every tx fee, so user balances move only by program effects.
let tx = Transaction::new_signed_with_payer(ixs, Some(&ctx.payer.pubkey()), &signers, bh);
ctx.banks_client.process_transaction(tx).await
}
​
async fn lamports(ctx: &mut ProgramTestContext, key: &Pubkey) -> u64 {
ctx.banks_client.get_account(*key).await.unwrap().map(|a| a.lamports).unwrap_or(0)
}
​
async fn fund_state(ctx: &mut ProgramTestContext, key: &Pubkey) -> rustfund::Fund {
let acc = ctx.banks_client.get_account(*key).await.unwrap().unwrap();
rustfund::Fund::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
async fn contribution_state(ctx: &mut ProgramTestContext, key: &Pubkey) -> rustfund::Contribution {
let acc = ctx.banks_client.get_account(*key).await.unwrap().unwrap();
rustfund::Contribution::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
fn fund_pda(name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], &rustfund::ID).0
}
​
fn contrib_pda(fund: &Pubkey, contributor: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], &rustfund::ID).0
}
​
fn ix_create(fund: Pubkey, creator: Pubkey, name: &str) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundCreate {
fund,
creator,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::FundCreate {
name: name.to_string(),
description: "desc".to_string(),
goal: 100 * LAMPORTS_PER_SOL,
}
.data(),
}
}
​
fn ix_contribute(fund: Pubkey, contributor: Pubkey, amount: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundContribute {
fund,
contributor,
contribution: contrib_pda(&fund, &contributor),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount }.data(),
}
}
​
fn ix_refund(fund: Pubkey, contributor: Pubkey) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundRefund {
fund,
contribution: contrib_pda(&fund, &contributor),
contributor,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Refund {}.data(),
}
}
​
fn ix_withdraw(fund: Pubkey, creator: Pubkey) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundWithdraw {
fund,
creator,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Withdraw {}.data(),
}
}
​
fn discriminator(name: &str) -> Vec<u8> {
anchor_lang::solana_program::hash::hash(format!("global:{}", name).as_bytes()).to_bytes()[..8].to_vec()
}
​
async fn new_funded_user(ctx: &mut ProgramTestContext, sol: u64) -> Keypair {
let kp = Keypair::new();
let ix = system_instruction::transfer(&ctx.payer.pubkey(), &kp.pubkey(), sol * LAMPORTS_PER_SOL);
send(ctx, &[ix], &[]).await.unwrap();
kp
}
​
#[tokio::test]
async fn rf08_contribution_and_fund_rent_locked_with_no_close_path() {
let pt = ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction));
let mut ctx = pt.start_with_context().await;
​
// Rent figures used by the finding, checked against the live Rent sysvar.
let rent = ctx.banks_client.get_rent().await.unwrap();
assert_eq!(rustfund::Contribution::INIT_SPACE + 8, CONTRIBUTION_SPACE);
assert_eq!(rustfund::Fund::INIT_SPACE + 8, FUND_SPACE);
assert_eq!(rent.minimum_balance(CONTRIBUTION_SPACE), CONTRIBUTION_RENT);
assert_eq!(rent.minimum_balance(FUND_SPACE), FUND_RENT);
println!("rent: Contribution({} B) = {} lamports, Fund({} B) = {} lamports",
CONTRIBUTION_SPACE, CONTRIBUTION_RENT, FUND_SPACE, FUND_RENT);
​
let creator = new_funded_user(&mut ctx, 5).await;
let alice = new_funded_user(&mut ctx, 20).await;
let name = "proj";
let fund = fund_pda(name, &creator.pubkey());
let contrib = contrib_pda(&fund, &alice.pubkey());
​
// 1. Creator creates the fund and pays exactly the Fund rent (deadline stays 0,
// so contribute/refund are always open).
let creator_0 = lamports(&mut ctx, &creator.pubkey()).await;
send(&mut ctx, &[ix_create(fund, creator.pubkey(), name)], &[&creator]).await.unwrap();
let creator_1 = lamports(&mut ctx, &creator.pubkey()).await;
let fund_acc = ctx.banks_client.get_account(fund).await.unwrap().unwrap();
assert_eq!(creator_0 - creator_1, FUND_RENT, "creator pays the Fund rent");
assert_eq!(fund_acc.lamports, FUND_RENT);
assert_eq!(fund_acc.data.len(), FUND_SPACE);
assert_eq!(fund_acc.owner, rustfund::ID);
println!("fund_create: creator paid {} lamports of Fund rent", creator_0 - creator_1);
​
// 2. Alice contributes 1 SOL and pays exactly 1 SOL + Contribution rent.
let alice_0 = lamports(&mut ctx, &alice.pubkey()).await;
send(&mut ctx, &[ix_contribute(fund, alice.pubkey(), LAMPORTS_PER_SOL)], &[&alice]).await.unwrap();
let alice_1 = lamports(&mut ctx, &alice.pubkey()).await;
let c_acc = ctx.banks_client.get_account(contrib).await.unwrap().unwrap();
assert_eq!(alice_0 - alice_1, LAMPORTS_PER_SOL + CONTRIBUTION_RENT);
assert_eq!(c_acc.lamports, CONTRIBUTION_RENT);
assert_eq!(c_acc.data.len(), CONTRIBUTION_SPACE);
assert_eq!(c_acc.owner, rustfund::ID, "only rustfund can ever debit this account");
println!("contribute(1 SOL): alice paid {} lamports = 1 SOL + {} rent", alice_0 - alice_1, CONTRIBUTION_RENT);
​
// 3. Alice refunds. refund() pays out contribution.amount and zeroes it, but the
// Contribution account stays open with its rent. (The recorded amount is 0 here
// because of a separate bug, RF-01; it does not affect the rent.)
let recorded = contribution_state(&mut ctx, &contrib).await.amount;
send(&mut ctx, &[ix_refund(fund, alice.pubkey())], &[&alice]).await.unwrap();
let alice_2 = lamports(&mut ctx, &alice.pubkey()).await;
let c_acc = ctx.banks_client.get_account(contrib).await.unwrap()
.expect("Contribution account still exists after refund");
let c = contribution_state(&mut ctx, &contrib).await;
assert_eq!(alice_2, alice_1 + recorded, "refund returns at most the recorded amount, never the rent");
assert_eq!(c_acc.lamports, CONTRIBUTION_RENT, "rent stays in the Contribution PDA");
assert_eq!(c_acc.owner, rustfund::ID);
assert_eq!(c.amount, 0);
assert_eq!(c.contributor, alice.pubkey());
println!("refund: alice received {} lamports; Contribution still open, lamports = {}, amount = {}",
alice_2 - alice_1, c_acc.lamports, c.amount);
​
// 4. The zeroed PDA is reusable: a second contribution charges no new rent.
// (Limb 3's "never-reusable" is inaccurate; the rent is only unreclaimable.)
send(&mut ctx, &[ix_contribute(fund, alice.pubkey(), LAMPORTS_PER_SOL / 2)], &[&alice]).await.unwrap();
let alice_3 = lamports(&mut ctx, &alice.pubkey()).await;
assert_eq!(alice_2 - alice_3, LAMPORTS_PER_SOL / 2, "re-contribution reuses the PDA, no second rent");
assert_eq!(lamports(&mut ctx, &contrib).await, CONTRIBUTION_RENT);
​
// 5. Creator withdraws: gets amount_raised only; the Fund account keeps its rent.
let raised = fund_state(&mut ctx, &fund).await.amount_raised;
assert_eq!(raised, LAMPORTS_PER_SOL + LAMPORTS_PER_SOL / 2);
send(&mut ctx, &[ix_withdraw(fund, creator.pubkey())], &[&creator]).await.unwrap();
let creator_2 = lamports(&mut ctx, &creator.pubkey()).await;
assert_eq!(creator_2 - creator_1, raised);
let fund_acc = ctx.banks_client.get_account(fund).await.unwrap()
.expect("Fund account still exists after withdraw");
assert_eq!(fund_acc.lamports, FUND_RENT, "Fund rent left behind after withdraw");
println!("withdraw: creator received {} lamports (amount_raised); Fund still open with {} lamports",
creator_2 - creator_1, fund_acc.lamports);
​
// 6. Every recovery route the program exposes fails to return the rent.
// 6a. A second refund succeeds but moves nothing; the Contribution still holds rent.
send(&mut ctx, &[ix_refund(fund, alice.pubkey())], &[&alice]).await.unwrap();
assert_eq!(lamports(&mut ctx, &alice.pubkey()).await, alice_3);
assert_eq!(lamports(&mut ctx, &contrib).await, CONTRIBUTION_RENT);
​
// 6b. A second withdraw re-subtracts the stale amount_raised (1.5 SOL) from a
// balance of FUND_RENT and fails; the rent stays.
let err = send(&mut ctx, &[ix_withdraw(fund, creator.pubkey())], &[&creator]).await.unwrap_err();
println!("second withdraw error: {:?}", err);
assert_eq!(err.unwrap(), TransactionError::InstructionError(0, InstructionError::InsufficientFunds));
assert_eq!(lamports(&mut ctx, &fund).await, FUND_RENT);
​
// 6c. No close-style instruction exists: Anchor rejects any such discriminator
// with InstructionFallbackNotFound (101).
for name in ["close", "close_contribution", "close_fund", "close_account"] {
let ix = Instruction {
program_id: rustfund::ID,
accounts: vec![
AccountMeta::new(contrib, false),
AccountMeta::new(fund, false),
AccountMeta::new(alice.pubkey(), true),
],
data: discriminator(name),
};
let err = send(&mut ctx, &[ix], &[&alice]).await.unwrap_err();
assert_eq!(err.unwrap(), TransactionError::InstructionError(0, InstructionError::Custom(101)),
"no `{}` instruction exists", name);
println!("ix `{}` -> InstructionFallbackNotFound (101)", name);
}
​
// Final state: both deposits remain locked in program-owned accounts.
assert_eq!(lamports(&mut ctx, &contrib).await, CONTRIBUTION_RENT);
assert_eq!(lamports(&mut ctx, &fund).await, FUND_RENT);
println!("LOCKED: alice's Contribution rent = {} lamports, creator's Fund rent = {} lamports",
CONTRIBUTION_RENT, FUND_RENT);
}
​
// Boundary of the Fund limb: the only way a creator gets the Fund rent back is
// through the stale-amount_raised double-withdraw bug, and only when amount_raised
// at the first withdraw equals FUND_RENT exactly. Off by one lamport either way, it fails.
#[tokio::test]
async fn rf08_boundary_fund_rent_recoverable_only_via_double_withdraw_at_exact_rent() {
let pt = ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction));
let mut ctx = pt.start_with_context().await;
let creator = new_funded_user(&mut ctx, 10).await;
​
for (name, raised) in [("exact", FUND_RENT), ("minus1", FUND_RENT - 1), ("plus1", FUND_RENT + 1)] {
let fund = fund_pda(name, &creator.pubkey());
send(&mut ctx, &[ix_create(fund, creator.pubkey(), name)], &[&creator]).await.unwrap();
send(&mut ctx, &[ix_contribute(fund, creator.pubkey(), raised)], &[&creator]).await.unwrap();
send(&mut ctx, &[ix_withdraw(fund, creator.pubkey())], &[&creator]).await.unwrap();
assert_eq!(lamports(&mut ctx, &fund).await, FUND_RENT);
let before = lamports(&mut ctx, &creator.pubkey()).await;
let res = send(&mut ctx, &[ix_withdraw(fund, creator.pubkey())], &[&creator]).await;
let after = lamports(&mut ctx, &creator.pubkey()).await;
match name {
"exact" => {
assert!(res.is_ok());
assert_eq!(after - before, FUND_RENT, "creator recovers the Fund rent");
assert!(ctx.banks_client.get_account(fund).await.unwrap().is_none(), "Fund drained to 0");
println!("[{}] second withdraw OK: creator recovered {} lamports, Fund account gone", name, after - before);
}
"minus1" => {
let e = res.unwrap_err().unwrap();
println!("[{}] second withdraw error: {:?}", name, e);
assert!(matches!(e, TransactionError::InsufficientFundsForRent { .. }));
assert_eq!(lamports(&mut ctx, &fund).await, FUND_RENT);
}
_ => {
let e = res.unwrap_err().unwrap();
println!("[{}] second withdraw error: {:?}", name, e);
assert_eq!(e, TransactionError::InstructionError(0, InstructionError::InsufficientFunds));
assert_eq!(lamports(&mut ctx, &fund).await, FUND_RENT);
}
}
// In every case the creator's own Contribution PDA keeps its rent.
assert_eq!(lamports(&mut ctx, &contrib_pda(&fund, &creator.pubkey())).await, CONTRIBUTION_RENT);
}
}

Run from the repository root. Everything before the cd line is a one-time setup; save the file above as programs/rustfund/tests/poc_RF-08.rs before running the last line:

cat >> programs/rustfund/Cargo.toml <<'TOML'
​
[dev-dependencies]
solana-program-test = "~1.18.26"
solana-sdk = "~1.18.26"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
TOML
mkdir -p programs/rustfund/tests
cd programs/rustfund && cargo test --test poc_RF-08 -- --nocapture

Result: test result: ok. 2 passed; 0 failed. Key log lines:

rent: Contribution(80 B) = 1447680 lamports, Fund(5273 B) = 37590960 lamports
refund: alice received 0 lamports; Contribution still open, lamports = 1447680, amount = 0
withdraw: creator received 1500000000 lamports (amount_raised); Fund still open with 37590960 lamports
ix `close` -> InstructionFallbackNotFound (101)
LOCKED: alice's Contribution rent = 1447680 lamports, creator's Fund rent = 37590960 lamports
[exact] second withdraw OK: creator recovered 37590960 lamports, Fund account gone
[minus1] second withdraw error: InsufficientFundsForRent { account_index: 2 }
[plus1] second withdraw error: InstructionError(0, InsufficientFunds)

Recommended Mitigation

Close the Contribution record to the contributor when it is refunded, and close the Fund to the creator when it is paid out. Apply the second change together with the withdraw gating fix, so that a Fund is closed only after a successful campaign. A failed campaign's Fund also needs a creator-only close instruction that can run once all contributions have been refunded.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -151,5 +151,6 @@
bump,
has_one=contributor,
- has_one=fund
+ has_one=fund,
+ close = contributor
)]
pub contribution: Account<'info, Contribution>,
@@ -162,3 +163,3 @@
pub struct FundWithdraw<'info> {
- #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump,has_one = creator)]
+ #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump,has_one = creator, close = creator)]
pub fund: Account<'info, Fund>,
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!