Rust Fund

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

Missing campaign lifecycle enforcement (goal unused; no terminal states; withdraw ungated by goal/deadline

Root + Impact

Description

  • Normal behavior: goal/deadline should define a terminal success/failure state that gates withdraw vs refund, and stops contributions post-settlement.

  • Actual behavior: goal is never referenced after initialization; there is no status field; withdraw performs a transfer with no goal/deadline checks, and contribute remains callable with no “settled” guard.

// programs/rustfund/src/lib.rs
fund.goal = goal; // @ programs/rustfund/src/lib.rs:16 (never enforced elsewhere)
// @ withdraw has no goal/deadline gating
pub fn withdraw(...) -> Result<()> { ... } // @ programs/rustfund/src/lib.rs:90

Risk

Likelihood:

  • Occurs whenever creators withdraw outside the intended lifecycle (the program never enforces one).

  • Occurs whenever users keep contributing after a withdraw; the program never transitions to a terminal state.Impact:

  • “Crowdfunding” semantics collapse into an ungoverned escrow-like account with misleading fields.

  • Composes with Finding 2/1 to create permanent stuck-funds and DoS states.

Proof of Concept

  • Code-path PoC: withdraw contains no checks against fund.goal or fund.deadline; its success depends only on lamports vs amount_raised.

  • Behavioral evidence: TestWithdrawPoisonPill (bug_report.rs (line 301)) performs withdraw without any lifecycle transition being set.

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::{
clock::Clock,
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 get_clock(ctx: &mut ProgramTestContext) -> Clock {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap()
}
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_finding5_withdraw_is_not_gated_by_goal_or_deadline() {
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).
send_tx(
&mut ctx,
vec![system_instruction::transfer(&creator, &contributor.pubkey(), 2_000_000_000)],
vec![],
)
.await
.unwrap();
let name = "ungated-withdraw";
let description = "withdraw ignores goal/deadline PoC";
let goal = 10_000_000_000u64; // intentionally unreachable in PoC
let amount = 1_000_000u64;
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;
// Set deadline far in the future.
let clock = get_clock(&mut ctx).await;
let future_deadline = clock.unix_timestamp.saturating_add(3600).max(1) as u64;
let ix_set_deadline = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline {
deadline: future_deadline,
}
.data(),
};
send_tx(&mut ctx, vec![ix_set_deadline], vec![]).await.unwrap();
// Contribute `amount` (<< goal).
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 }.data(),
};
send_tx(&mut ctx, vec![ix_contribute], vec![&contributor])
.await
.unwrap();
// Confirm the campaign is neither "goal reached" nor "deadline reached".
let fund_state = fetch_fund(&mut ctx, fund).await;
let now = get_clock(&mut ctx).await.unix_timestamp as u64;
assert!(fund_state.amount_raised < fund_state.goal);
assert!(now < fund_state.deadline);
// Withdraw STILL succeeds (no goal/deadline gating).
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], vec![]).await.unwrap();
let fund_after_withdraw = get_lamports(&mut ctx, fund).await;
println!(
"fund balance after withdraw: {} (rent baseline {})",
fund_after_withdraw, rent_baseline
);
assert_eq!(fund_after_withdraw, rent_baseline);
}

Recommended Mitigation

+ pub status: FundStatus
+ require!(status == Open, ...)
+ transition(Open -> Succeeded/Failed -> Withdrawn/Closed)
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!