Rust Fund

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

The Fund PDA is seeded with the raw campaign name and Solana limits seeds to 32 bytes, so fund_create() fails for every name of 33-200 bytes although the account reserves 200 bytes for the name

Root + Impact

Description

  • The README lets creators launch campaigns "with custom names, descriptions, and funding goals". The Fund account reserves #[max_len(200)] bytes for the name, so names of up to 200 bytes are meant to work.

  • FundCreate derives the Fund PDA from the raw name (L111), and FundWithdraw re-derives it the same way (L163). Solana caps every PDA seed at MAX_SEED_LEN = 32 bytes (solana-program 1.18.26, pubkey.rs). Anchor 0.30.1's init code calls Pubkey::find_program_address, which cannot derive an address from a longer seed. So fund_create fails for every name of 33 to 200 bytes, and a client cannot even compute the address to pass. Non-ASCII names hit the limit sooner: 11 CJK characters are 33 bytes in UTF-8. Every Fund still pays rent for the 168 name bytes that can never be used.

// programs/rustfund/src/lib.rs L108-L112 (@> L111): FundCreate: the raw name is a PDA seed
#[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>,
​
// programs/rustfund/src/lib.rs L161-L163 (@> L163): FundWithdraw: re-derives the PDA from the same seed
#[derive(Accounts)]
pub struct FundWithdraw<'info> {
@> #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump,has_one = creator)]
​
// programs/rustfund/src/lib.rs L179-L183 (@> L182): Fund: 200 bytes are reserved for the name
#[account]
#[derive(InitSpace)]
pub struct Fund {
@> #[max_len(200)]
pub name: String,

Risk

Likelihood:

  • Any creator who chooses a descriptive title longer than 32 bytes hits it, for example the 43-byte "Community Solar Panels for Riverside School".

  • Creators who write names in non-Latin scripts hit it at 11 characters, because each CJK character takes 3 bytes.

Impact:

  • fund_create reverts for every name of 33 to 200 bytes. The advertised custom names are therefore limited to 32 bytes, and the creator has to shorten the name. No funds are at risk and the failure is atomic.

  • Every Fund pays rent for 168 name bytes that can never hold data: 1,169,280 lamports per fund.

Proof of Concept

The test first sets up its baselines and a control.

  • A 32-byte ASCII name creates a fund, and so does a 10-character (30-byte) CJK name.

  • A short name sent with a wrong address fails cleanly with ConstraintSeeds (2006).

It then tries four names:

  • 33 bytes, one byte more than the baseline;

  • the 43-byte example above;

  • 11 CJK characters (33 bytes);

  • 200 bytes, the declared maximum.

For each name:

  • the client-side create_program_address returns MaxSeedLengthExceeded;

  • try_find_program_address returns None;

  • fund_create fails inside Anchor's PDA derivation and creates no account.

Finally, the test computes the rent paid for the 168 unusable name bytes.

A note on the native test: the program runs natively in-process, so Anchor's find_program_address takes the off-chain code path. It panics with "Unable to find a viable program address bump seed" (solana-program-1.18.26/src/pubkey.rs:482). solana-program-test 1.18 hangs when a native program panics. The test's process_instruction wrapper therefore catches the panic, records the message and returns Custom(0x0BAD5EED); that code is a test artefact. On-chain, the same derivation goes through the sol_try_find_program_address syscall, which returns BadSeeds(MaxSeedLengthExceeded) and aborts the program (solana-bpf-loader-program-1.18.26/src/syscalls/mod.rs:689-690). The transaction fails either way, with a different error code. This on-chain path was read in the pinned source, not executed.

// PoC RF-07: the Fund PDA is seeded with the raw campaign name, and Solana caps every
// PDA seed at MAX_SEED_LEN = 32 bytes. Any name of 33..=200 bytes (the declared max_len)
// can never be used to create a campaign.
//
// Native (in-process) execution note: rustfund is compiled for the host here, so Anchor's
// init codegen `Pubkey::find_program_address` takes the off-chain branch, gets
// Err(MaxSeedLengthExceeded) from create_program_address, returns None and PANICS
// ("Unable to find a viable program address bump seed"). solana-program-test 1.18 hangs
// when a native processor panics, so the wrapper below catches the unwind, records the
// panic message, and fails the instruction with a sentinel error. On-chain the same call
// goes through sol_try_find_program_address, which aborts the VM with
// SyscallError::BadSeeds(MaxSeedLengthExceeded) -- the transaction fails either way.
​
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::solana_program::pubkey::{PubkeyError, MAX_SEED_LEN};
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
system_instruction,
transaction::{Transaction, TransactionError},
};
use std::sync::Mutex;
​
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
const PANIC_SENTINEL: u32 = 0x0BAD_5EED;
const ANCHOR_CONSTRAINT_SEEDS: u32 = 2006;
​
static LAST_PANIC: Mutex<Option<String>> = Mutex::new(None);
​
fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let accounts = Box::leak(Box::new(accounts.to_vec()));
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
rustfund::entry(program_id, accounts, data)
}));
match res {
Ok(r) => r,
Err(payload) => {
let msg = if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else {
"<non-string panic payload>".to_string()
};
*LAST_PANIC.lock().unwrap() = Some(msg);
Err(ProgramError::Custom(PANIC_SENTINEL))
}
}
}
​
fn take_panic() -> Option<String> {
LAST_PANIC.lock().unwrap().take()
}
​
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);
tokio::time::timeout(
std::time::Duration::from_secs(60),
ctx.banks_client.process_transaction(tx),
)
.await
.expect("transaction hung (uncaught native panic?)")
}
​
fn create_ix(fund: Pubkey, creator: &Pubkey, name: &str) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundCreate {
fund,
creator: *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 custom_code(err: &BanksClientError) -> Option<u32> {
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(
0,
InstructionError::Custom(c),
)) => Some(*c),
BanksClientError::SimulationError {
err: TransactionError::InstructionError(0, InstructionError::Custom(c)),
..
} => Some(*c),
_ => None,
}
}
​
/// Tries to create a campaign whose name is too long to be a PDA seed. The client cannot
/// derive the PDA (that is part of the finding), so it passes an arbitrary address; the
/// program fails inside Anchor's seed derivation before the address is even compared.
async fn assert_long_name_rejected(ctx: &mut ProgramTestContext, creator: &Keypair, name: &str) {
assert!(name.len() > MAX_SEED_LEN);
assert!(name.len() <= 200, "still within the declared #[max_len(200)]");
​
// Client side: derivation is impossible.
let creator_key = creator.pubkey();
let seeds: [&[u8]; 3] = [name.as_bytes(), creator_key.as_ref(), &[255u8]];
assert_eq!(
Pubkey::create_program_address(&seeds, &rustfund::ID),
Err(PubkeyError::MaxSeedLengthExceeded)
);
assert!(Pubkey::try_find_program_address(&[name.as_bytes(), creator.pubkey().as_ref()], &rustfund::ID)
.is_none());
​
// On chain (native): fund_create fails inside the PDA derivation.
take_panic();
let any_addr = Pubkey::new_unique();
let err = send(ctx, &[create_ix(any_addr, &creator.pubkey(), name)], &[creator])
.await
.expect_err("fund_create with a >32-byte name must fail");
let panic_msg = take_panic();
println!(
" name ({} bytes, {} chars) -> fund_create FAILED: {:?}; program panic = {:?}",
name.len(),
name.chars().count(),
err,
panic_msg
);
assert_eq!(custom_code(&err), Some(PANIC_SENTINEL), "failure must be the seed-derivation abort");
assert_eq!(
panic_msg.as_deref(),
Some("Unable to find a viable program address bump seed"),
"the abort is Anchor's find_program_address on the raw name seed"
);
// Nothing was created at the address passed in.
assert!(ctx.banks_client.get_account(any_addr).await.unwrap().is_none());
}
​
#[tokio::test]
async fn rf07_names_over_32_bytes_cannot_create_a_fund() {
let pt = ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction));
let mut ctx = pt.start_with_context().await;
​
let creator = Keypair::new();
let payer_key = ctx.payer.pubkey();
send(
&mut ctx,
&[system_instruction::transfer(&payer_key, &creator.pubkey(), 10 * LAMPORTS_PER_SOL)],
&[],
)
.await
.unwrap();
​
println!("MAX_SEED_LEN = {}; Fund::INIT_SPACE = {}", MAX_SEED_LEN, rustfund::Fund::INIT_SPACE);
​
// ---------- Baseline 1: a 32-byte ASCII name works end to end. ----------
let name32 = "Community Solar Riverside School"; // 32 bytes
assert_eq!(name32.len(), 32);
let (pda32, _) =
Pubkey::find_program_address(&[name32.as_bytes(), creator.pubkey().as_ref()], &rustfund::ID);
send(&mut ctx, &[create_ix(pda32, &creator.pubkey(), name32)], &[&creator])
.await
.expect("32-byte name must succeed");
let acc32 = ctx.banks_client.get_account(pda32).await.unwrap().unwrap();
let fund32 = rustfund::Fund::try_deserialize(&mut acc32.data.as_slice()).unwrap();
assert_eq!(fund32.name, name32);
println!("BASELINE: 32-byte name {:?} -> fund created at {}", name32, pda32);
​
// ---------- Baseline 2: 10 CJK characters (30 bytes) work. ----------
let cjk10 = "社区太阳能板河畔学校"; // 10 chars x 3 bytes = 30 bytes
assert_eq!((cjk10.chars().count(), cjk10.len()), (10, 30));
let (pda_cjk10, _) =
Pubkey::find_program_address(&[cjk10.as_bytes(), creator.pubkey().as_ref()], &rustfund::ID);
send(&mut ctx, &[create_ix(pda_cjk10, &creator.pubkey(), cjk10)], &[&creator])
.await
.expect("30-byte CJK name must succeed");
println!("BASELINE: 10-char CJK name (30 bytes) -> fund created at {}", pda_cjk10);
​
// ---------- Control: a short name with a WRONG address is a normal Anchor error. ----------
// Shows that the long-name failure below is not just "wrong address passed".
take_panic();
let err = send(
&mut ctx,
&[create_ix(Pubkey::new_unique(), &creator.pubkey(), "Control name")],
&[&creator],
)
.await
.expect_err("wrong address must fail");
assert_eq!(custom_code(&err), Some(ANCHOR_CONSTRAINT_SEEDS));
assert!(take_panic().is_none(), "short-name failure is a clean ConstraintSeeds, no abort");
println!("CONTROL: short name + wrong address -> ConstraintSeeds (2006), no abort");
​
// ---------- The break: every name of 33..=200 bytes is rejected. ----------
println!("BREAK:");
// 33 bytes: one byte past the baseline.
let name33 = "Community Solar Riverside Schools";
assert_eq!(name33.len(), 33);
assert_long_name_rejected(&mut ctx, &creator, name33).await;
// The README-style example from the finding (43 bytes).
assert_long_name_rejected(&mut ctx, &creator, "Community Solar Panels for Riverside School").await;
// 11 CJK characters = 33 bytes.
let cjk11 = "社区太阳能板河畔学校的";
assert_eq!((cjk11.chars().count(), cjk11.len()), (11, 33));
assert_long_name_rejected(&mut ctx, &creator, cjk11).await;
// The declared maximum, 200 bytes.
let name200 = "x".repeat(200);
assert_long_name_rejected(&mut ctx, &creator, &name200).await;
​
// ---------- Rent paid for name bytes that can never be used. ----------
let rent = ctx.banks_client.get_rent().await.unwrap();
let space = 8 + rustfund::Fund::INIT_SPACE;
assert_eq!(acc32.data.len(), space);
let reserved_name_bytes = 4 + 200;
let usable_name_bytes = 4 + MAX_SEED_LEN;
let unusable = reserved_name_bytes - usable_name_bytes;
assert_eq!(unusable, 168);
let wasted = rent.minimum_balance(space) - rent.minimum_balance(space - unusable);
assert!(acc32.lamports >= rent.minimum_balance(space));
println!(
"RENT: fund account = {} bytes, lamports = {}; {} name bytes can never be used -> {} lamports of rent per fund for unusable space",
space, acc32.lamports, unusable, wasted
);
assert!(wasted > 0);
}

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

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

BASELINE: 32-byte name "Community Solar Riverside School" -> fund created at 7iwjsPwVimJwqDwoxfpW8Vv6FR2yQgnLhYwnhUwNvNBU
CONTROL: short name + wrong address -> ConstraintSeeds (2006), no abort
name (33 bytes, 33 chars) -> fund_create FAILED: TransactionError(InstructionError(0, Custom(195911405))); program panic = Some("Unable to find a viable program address bump seed")
name (43 bytes, 43 chars) -> fund_create FAILED: TransactionError(InstructionError(0, Custom(195911405))); program panic = Some("Unable to find a viable program address bump seed")
name (33 bytes, 11 chars) -> fund_create FAILED: TransactionError(InstructionError(0, Custom(195911405))); program panic = Some("Unable to find a viable program address bump seed")
name (200 bytes, 200 chars) -> fund_create FAILED: TransactionError(InstructionError(0, Custom(195911405))); program panic = Some("Unable to find a viable program address bump seed")
RENT: fund account = 5273 bytes, lamports = 37590960; 168 name bytes can never be used -> 1169280 lamports of rent per fund for unusable space

Recommended Mitigation

Seed the PDA with a fixed-size hash of the name, so any name up to max_len works. Clients then derive the address from hash(name). Alternatively, reject names longer than 32 bytes and reduce max_len to 32, so the limit is explicit and no rent is paid for unusable bytes.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -110,3 +110,3 @@
pub struct FundCreate<'info> {
- #[account(init, payer = creator, space = 8 + Fund::INIT_SPACE,seeds = [name.as_bytes(),creator.key().as_ref()], bump)]
+ #[account(init, payer = creator, space = 8 + Fund::INIT_SPACE, seeds = [anchor_lang::solana_program::hash::hash(name.as_bytes()).as_ref(), creator.key().as_ref()], bump)]
pub fund: Account<'info, Fund>,
@@ -162,3 +162,3 @@
pub struct FundWithdraw<'info> {
- #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump,has_one = creator)]
+ #[account(mut, seeds = [anchor_lang::solana_program::hash::hash(fund.name.as_bytes()).as_ref(), creator.key().as_ref()], bump, has_one = creator)]
pub fund: Account<'info, Fund>,
Updates

Lead Judging Commences

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