system/prelaunch-audit-2026-05-24-jasper.md
Independent fresh pass. Not relying on the 2026-05-19 OPSEC audit.
I re-derived findings from current code in /Users/alex/projects/turf-vault and /Users/alex/projects/solana-studio. Two CRITICAL findings block mainnet launch.
CRITICAL-1 — migrate_user_account (instructions/migrate_user_account.rs:27-155) accepts the target wallet as an UncheckedAccount with no Signer requirement and no binding between the PDA-seed wallet arg and the on-chain user_account.wallet field. Any 1-of-3 admin (Heroku-resident bot included) can call it. With v0.15 fully rolled out it's a no-op, but any future schema bump re-opens the path — and the v0.13→v0.14 branch zero-fills username, so a buggy or hostile admin call can wipe identity. Fix: require wallet Signer or 2-of-3; bind wallet to the stored field; refuse unknown legacy sizes.
CRITICAL-2 — set_username (instructions/set_username.rs) enforces nothing — no uniqueness, no length, no character set, no rate-limit, no reserved-name protection. A direct program caller bypassing Rails can claim "alex_mcritchie", "admin", or homoglyphs. Anyone treating on-chain username as authoritative is exposed. Fix: at minimum reserved-prefix list; ideally a UsernameRegistry PDA + 1/24h rate-limit.
HIGH-1: Contest accounts lack seeds = [b"contest", contest_id] constraint across all entry/settle instructions — a substituted Contest account is accepted as long as discriminator matches.
HIGH-2: Token-funded entries do not increment entry_fees; with prizes=0 settle pays $0 — no on-chain backing of operator subsidy.
MEDIUMs: force_close_vault missing discriminator check (defense-in-depth); create_user_account permissionless with caller-chosen username; Borsh integer/string decoders don't bounds-check truncated buffers.
LOWs: mint_entry_token not pause-gated; enter_contest_direct_with_token payer unconstrained; raw-byte .unwrap() panic surface; homoglyph usernames.
Off-chain client is well-hardened (OPSEC-017/018/043 all in place, TLS strict, allocation cap, constant-time nonce, prefix host binding). No critical client-side findings. Single-trust-domain Squads keys remain an organizational blocker for Steffon's rollout.
migrate_user_account lets any 1-of-3 admin rewrite any UserAccount, no wallet consent/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/migrate_user_account.rs:27-155pub wallet: UncheckedAccount<'info> with no Signer requirement, no has_one, no constraint binding it to user_account.wallet. Line 93 — branching v0.13 vs v0.14 purely by data.len() >= 113. Line 98-101 — the v0.13 branch silently sets username = [0u8; 32]. The handler reads fields, resizes (line 107), then writes them back. The discriminator check at line 81 is good, but PDA seeds + wallet field are not cross-checked.Alex Bot key (single trust domain on Heroku) is compromised, attacker iterates every UserAccount and "migrates" — paths exist where username gets zeroed, and any bug in field-offset arithmetic clobbers balance / total_won.INIT_SPACE. Every v0.15 account becomes "stale". Attacker with admin key calls migrate_user_account per wallet. If a future bump moves a field, the field-by-field read at lines 84-101 deserializes from wrong offsets, writes wrong values, persists. Account-owner has no notification, no consent.rust
constraint = user_account.try_borrow_data()?.get(8..40)
.map(|bytes| Pubkey::try_from(bytes).ok() == Some(wallet.key())).unwrap_or(false)
@ VaultError::Unauthorized
Plus require either wallet: Signer<'info> OR 2-of-3 (parallel to settle/force_close). Plus require!(current_len == 81 || current_len == 113, VaultError::InvalidAccountData) to refuse unknown legacy sizes.set_username has zero on-chain validation/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/set_username.rs:1-34constraint = user_account.wallet == wallet.key(). The 32-byte username is written verbatim. No uniqueness, no length floor, no character set, no reserved-prefix list, no rate-limit.create_user_account directly (permissionless — see MEDIUM-2) with username = "alex_mcritchie". Operator impersonation persists on-chain.set_username calls (~5000 lamports each on devnet) — no on-chain throttle."аlex" (Cyrillic) is on-chain different from "alex" (Latin) but renders identically.require!(!starts_with(b"admin"), ...), plus "system", "turf", "vault", brand names. Plus an is_printable_ascii guard rejecting bytes outside 0x20..0x7E (or a strict UTF-8 normalize).UsernameRegistry PDA at seeds = [b"username", username_bytes], init in set_username, close prior PDA on overwrite. Solana-native uniqueness, race-free.last_username_set_at: i64 to UserAccount on next layout bump; enforce 1-change-per-24h. Cost: 8 bytes, 1 instruction tweak.enter_contest.rs:42-46, enter_contest_direct.rs:47-52, enter_contest_with_token.rs:47-52, enter_contest_direct_with_token.rs:42-46, settle_contest.rs:45-49.Contest slot. There is no seeds = [b"contest", contest.contest_id.as_ref()], bump = contest.bump constraint. So any Contest account can be supplied; its stored contest_id then drives ContestEntry PDA seeds.enter_contest_direct: User signs a TX they think targets Contest A ($10 fee). A compromised browser extension or compromised Rails endpoint swaps the contest account for Contest B ($1 fee, also Open) before submission. User signs, $1 transferred, entry recorded in B. On-chain is internally consistent but the user got what they didn't intend.settle_contest: Two cosigners are presented "settle Contest X". Squad TX details show the account list, but a careless cosigner approves a TX where the Contest account is Y. Same auth, wrong target. Defense in depth says program should pin.rust
seeds = [b"contest", contest.contest_id.as_ref()],
bump = contest.bump,
Anchor re-derives the PDA from the account's own stored contest_id + bump and rejects mismatches.entry_fees, settle cap becomes prizesenter_contest_with_token.rs:101-103, enter_contest_direct_with_token.rs:94-96.current_entries but NOT entry_fees. settle_contest.rs:64-68 caps total payouts at contest.entry_fees + contest.prizes. So a contest where 100% of entries are token-funded and prizes == 0 can pay $0 total at settle, even if Rails promised the winner real money.prizes=0. Settle assigns ranks but payout cannot exceed 0+0=0 — the cap will reject any non-zero settlement. Users with "winning" tokens get nothing on-chain. Rails promise to the user is broken with zero on-chain recourse.entry_fees by contest.entry_fee (mirrors paid entries) — but then who funds the USDC? Operator must pre-deposit; add a "subsidy" debit at settle.mint_entry_token unless a backing reserve exists (track operator-paid SOL/USDC reserve per contest).force_close_vault doesn't verify the discriminator before reading signer bytes/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/force_close_vault.rs:43-78data[..8] == VaultState::DISCRIMINATOR. migrate_user_account.rs:81 does this correctly — the inconsistency is a defense-in-depth gap.require!(&data[..8] == VaultState::DISCRIMINATOR, VaultError::Unauthorized); after the length check at line 49. Zero-cost.create_user_account is permissionless, caller picks username/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/create_user_account.rs:14-54pub payer: Signer<'info> with no constraint on who payer is. Anyone can pay rent for anyone's UserAccount and pre-set the username. The wallet owner can later overwrite via set_username (CRITICAL-2 caveats apply), but the first-touch window is wide open.username = "you_suck". User logs in, sees the slur, has to sign a username change. Combine with CRITICAL-2 (no reserved-name list) to set "admin" on legitimate user wallets. Also: rent-grief — attacker spams create_user_account for thousands of random wallets, paying their own SOL but indirectly congesting the operator's TX-processing pipeline.wallet: Signer<'info> or a vault signer as payer. The current operator-funded onboarding flow uses Alex Bot as payer for managed wallets, and Phantom signs for their own wallet — both satisfy the tighter constraint./Users/alex/projects/solana-studio/lib/solana/borsh.rb:63-91bytes.byteslice(offset, n) returns nil past EOF or a short slice if running off the end. decode_u8/u16/u32/u64 then call .unpack1 — nil.unpack1 returns nil; "a".byteslice(0,4).unpack1("V") on a 1-byte string returns garbage. decode_string at line 89 does .to_s on a possibly-nil slice then returns offset + length (the requested offset, not bytes consumed). Downstream decodes are silently misaligned.getAccountInfo. Rails reconciler misreads balance as a fabricated value. Direct fund-drain not possible (on-chain state unchanged), but operator books diverge from reality.ruby
def read_exact!(bytes, offset, n, kind)
slice = bytes.byteslice(offset, n)
raise "Borsh #{kind}: truncated at offset #{offset}, need #{n}" unless slice && slice.bytesize == n
slice
end
Apply uniformly to all decode_* functions.mint_entry_token not pause-gated/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/mint_entry_token.rs:24-25mint_paused flag, independently flippable 2-of-3. Or gate mint_entry_token on pause too and accept the operational tradeoff.enter_contest_direct_with_token payer is unconstrained/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/enter_contest_direct_with_token.rs:21-22, 35-39enter_contest_direct.rs:40-45 (OPSEC-024 gates payer to vault signer), this path leaves payer open. Bounded by user-signer-required + valid token, so not directly exploitable, but inconsistent with the sibling..unwrap() on raw-byte slice conversion in migrate_user_account/Users/alex/projects/turf-vault/programs/turf_vault/src/instructions/migrate_user_account.rs:86-90u64::from_le_bytes(data[40..48].try_into().unwrap()) panics on slice-length mismatch. Currently safe because of the data.len() >= 81 guard at line 75, but a future refactor that loosens the bound converts runtime check into a panic-abort.? instead of .unwrap().set_username.rs:28-34, create_user_account.rs:44[u8; 32] stored, no normalization. Cyrillic а displays identically to Latin a.0x20-0x7E./Users/alex/projects/solana-studio/lib/solana/borsh.rb:63-81/Users/alex/projects/solana-studio/lib/solana/auth_verifier.rb:85-87message.start_with?("#{expected_host} ") is currently strict-enough (trailing space delimiter), but a future message format that prepends "https://" or appends ":443" would break the check open or silently fail-closed.withdraw.rs:62-122): rolling 24h, correctly resets via saturating_sub; first-call init when daily_window_start=0 works because now - 0 >= 86_400.settle_contest.rs:75-80, 127): seen Vec + entry.status == Active closes double-payout vectors.settle_contest.rs:64-68): correct.create_contest.rs:80-83): correctly fixed with try_fold + checked_add.enter_contest_with_token.rs:31: correct Signer requirement.update_signers.rs:43-48: correct.force_close_vault.rs:55-61: correct.solana-studio/lib/solana/transaction.rb:88-91, 116-119, 130-135: correct.solana-studio/lib/solana/client.rb:144-180: explicit VERIFY_PEER + TLS 1.2 min, http rejected unless localhost.borsh.rb:8): 10MB on length-prefixed decodes.auth_verifier.rb:11-23, 100-102).VaultState/UserAccount/Contest/ContestEntry/EntryTokenAccount/Season — no cross-entity collision risk.transaction.rb:19-21: SHA256("global:<name>")[0,8] matches Anchor.remaining_accounts PDA verification (settle_contest.rs:92-108): recomputes both PDAs from wallet + entry_num, rejects substitution.EXPECTED_IDL_HASH in turf-monster. The wire-format discriminator naming is SHA256("global:<name>")[0..7] which fails loudly on rename. As long as the post-Squads-deploy re-pin-from-BUILT-IDL protocol is followed (OPSEC-014), this is fine.Block launch (must fix):
1. Lock down migrate_user_account (CRITICAL-1)
2. Add minimal on-chain validation to set_username — reserved-prefix list + rate-limit (CRITICAL-2)
Strongly recommend before launch:
3. PDA-seed-pin every Contest constraint (HIGH-1)
4. Decide policy on token-entry backing (HIGH-2)
5. Tighten create_user_account permissionlessness (MEDIUM-2)
6. Borsh decoder bounds checks (MEDIUM-3)
Post-launch acceptable:
7. Discriminator check in force_close_vault (MEDIUM-1)
8. Mint pause separate flag (LOW-1)
9. Username normalization (LOW-4)
Organizational (Steffon):
10. Move Mason's Squads key out of shared 1Password before mainnet.
We emailed a one-tap sign-in link to . It expires shortly and can only be used once.
No email? Check spam, or close this and try again.