Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

No close/sweep path: rent + injected lamports can be permanently trapped in PDAs

Root + Impact

Description

  • Normal behavior: protocol-owned PDAs should have a close/cleanup path, and unexpected lamports should be recoverable or safely accounted for.

  • Actual behavior: there is no close/sweep instruction; withdraw/refund only move tracked counters, not “excess lamports”, so lamports sent via plain system transfers can remain stuck forever.

// programs/rustfund/src/lib.rs
// @ withdraw only transfers tracked `amount_raised`
let amount = ctx.accounts.fund.amount_raised; // @ programs/rustfund/src/lib.rs:91

Risk

Likelihood:

  • Occurs whenever anyone system-transfers lamports to a Fund PDA (griefing or mistake).

  • Occurs permanently because no instruction closes Fund/Contribution accounts to reclaim rent.

Impact:

  • Permanent value lock inside program-owned accounts (cannot be recovered without an upgrade).

  • Long-term state bloat and unusable trapped SOL.

Proof of Concept

use anchor_lang::{prelude::Pubkey, AccountDeserialize, InstructionData, ToAccountMetas};
use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult};
use solana_program_test::{processor, BanksClientError, ProgramTest, ProgramTestContext};
use solana_sdk::{
signature::{Keypair, Signer},
system_instruction, system_program,
transaction::Transaction,
};
fn process_rustfund_instruction<'a, 'b, 'c, 'd>(
program_id: &'a Pubkey,
accounts: &'b [AccountInfo<'c>],
instruction_data: &'d [u8],
) -> ProgramResult {
let accounts: &'c [AccountInfo<'c>] = unsafe { std::mem::transmute(accounts) };
rustfund::entry(program_id, accounts, instruction_data)
}
fn derive_fund_pda(program_id: &Pubkey, name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], program_id).0
}
fn derive_contribution_pda(program_id: &Pubkey, fund: &Pubkey, contributor: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], program_id).0
}
async fn send_tx(
ctx: &mut ProgramTestContext,
instructions: Vec<solana_sdk::instruction::Instruction>,
extra_signers: Vec<&Keypair>,
) -> Result<(), BanksClientError> {
let recent_blockhash = ctx.banks_client.get_latest_blockhash().await.unwrap();
let mut signers = vec![&ctx.payer];
signers.extend(extra_signers);
let tx = Transaction::new_signed_with_payer(
&instructions,
Some(&ctx.payer.pubkey()),
&signers,
recent_blockhash,
);
ctx.banks_client.process_transaction(tx).await
}
async fn get_lamports(ctx: &mut ProgramTestContext, address: Pubkey) -> u64 {
ctx.banks_client
.get_account(address)
.await
.unwrap()
.map(|a| a.lamports)
.unwrap_or(0)
}
async fn fetch_fund(ctx: &mut ProgramTestContext, fund: Pubkey) -> rustfund::Fund {
let account = ctx
.banks_client
.get_account(fund)
.await
.unwrap()
.expect("fund account should exist");
rustfund::Fund::try_deserialize(&mut account.data.as_ref()).unwrap()
}
#[tokio::test]
async fn poc_finding3_injected_lamports_stuck() {
let program_id = rustfund::id();
let mut pt = ProgramTest::new("rustfund", program_id, processor!(process_rustfund_instruction));
let mut ctx = pt.start_with_context().await;
let creator = ctx.payer.pubkey();
let contributor = Keypair::new();
// Fund contributor (rent + donation + injection).
send_tx(
&mut ctx,
vec![system_instruction::transfer(&creator, &contributor.pubkey(), 2_000_000_000)],
vec![],
)
.await
.unwrap();
let name = "inject-stuck";
let description = "lamport injection PoC";
let goal = 1u64;
let contribute_amount = 1_000_000u64;
let injected = 42_424u64;
let fund = derive_fund_pda(&program_id, name, &creator);
let contribution = derive_contribution_pda(&program_id, &fund, &contributor.pubkey());
// Create fund.
let ix_create = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundCreate {
fund,
creator,
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::FundCreate {
name: name.to_string(),
description: description.to_string(),
goal,
}
.data(),
};
send_tx(&mut ctx, vec![ix_create], vec![]).await.unwrap();
let rent_baseline = get_lamports(&mut ctx, fund).await;
// Contribute via program accounting.
let ix_contribute = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundContribute {
fund,
contributor: contributor.pubkey(),
contribution,
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute {
amount: contribute_amount,
}
.data(),
};
send_tx(&mut ctx, vec![ix_contribute], vec![&contributor])
.await
.unwrap();
// Inject lamports directly (not via `contribute`).
send_tx(
&mut ctx,
vec![system_instruction::transfer(&contributor.pubkey(), &fund, injected)],
vec![&contributor],
)
.await
.unwrap();
// Program accounting ignores injected lamports.
let fund_state_after_inject = fetch_fund(&mut ctx, fund).await;
assert_eq!(fund_state_after_inject.amount_raised, contribute_amount);
// Withdraw moves only tracked `amount_raised`, not injected lamports.
let ix_withdraw = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundWithdraw {
fund,
creator,
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Withdraw {}.data(),
};
send_tx(&mut ctx, vec![ix_withdraw.clone()], vec![]).await.unwrap();
let fund_after_withdraw = get_lamports(&mut ctx, fund).await;
println!(
"fund balance after withdraw: {} (expected rent {} + injected {})",
fund_after_withdraw, rent_baseline, injected
);
assert_eq!(fund_after_withdraw, rent_baseline + injected);
// With no sweep/close path, a second withdraw cannot recover the injected lamports.
let err = send_tx(&mut ctx, vec![ix_withdraw], vec![]).await.unwrap_err();
println!("second withdraw error: {err:?}");
}

Recommended Mitigation

(And/or add a carefully-designed sweep for unexpected lamports above escrow + enforce terminal states.)

+ #[account(close = receiver)]
+ pub fund: Account<'info, Fund>
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!