system/opsec-audit-pre-prod-2026-05-19.md
Date: 2026-05-19
Auditor: Claude (6 parallel investigation agents, code-only review)
Scope: turf-vault (Anchor), turf-monster (Rails), studio-engine + solana-studio gems, operational envelope
Excluded: Live prod state, DDoS/CDN layer, KYC/AML, settled decisions in feedback_audit_2026_05_17_decisions.md
Cross-references: audit RFP, Squads migration runbook, ecosystem audit 2026-05-17, credentials, house burn-down
Post-audit status (2026-05-23): Original verdict was "NO-GO for mainnet" with 15 mainnet-blocking + 18 should-have findings. Execution is now substantially advanced:
- Pre-mainnet hard prerequisites — 25 of 26 ✅ shipped (see § 8). Only OPSEC-025 (external Anchor audit) remains. The Heroku env vars listed in § 8 are all set;
bin/audit-env-checkpasses.- Should-have HIGH-severity items — 17 of 18 ✅ shipped. Only OPSEC-028 (mint_entry_token Rails alert) remains, deferred as a post-launch alerting task.
- OPSEC-051–089 backlog — operational tracking only; non-blocking.
- Post-audit additions (§ 8) — incident-driven findings appended after 2026-05-19. OPSEC-090 (2026-06-13): non-prod process can transact on live mainnet when all
SOLANA_*vars are consistently mainnet — open, dev-safety guard not yet built.Effective verdict today: GO-pending-external-audit. The current limiter is OPSEC-025 (the third-party Anchor audit, $20–60k + 4–8 weeks), not the application stack. The original "Go / No-Go" section below is preserved verbatim for traceability.
The stack is impressively coherent for solo-operator scale — multisig wiring is real, IDL pinning code exists, the audit posture is documented. But there are 15 mainnet-blocking findings that span every layer: the Anchor program has two direct vault-drain paths, the Rails app has at least one one-shot money exfil endpoint (/wallet/deposit?amount=), webhook signature verification fails open on missing config, account-merge primitives allow session hijack via OAuth or wallet-link collision, and operational prerequisites (Squads upgrade authority, external audit) remain documented-but-not-done.
The Anchor program has the cleanest layer despite being smallest — most of its issues are bounded by 2-of-3 cosign. The Rails layer is the largest attack surface and the weakest — accepts client-supplied tx signatures, amounts, and seed counts as authoritative state. The gems layer has the highest blast radius — bugs there affect every app simultaneously.
WalletsController#deposit accepts arbitrary params[:amount] and dispatches admin USDC transfer to the requester. On mainnet this is a single authenticated POST away from draining the operator wallet. Likely the most exploitable thing in the stack.~/.config/solana/id.json. Squads migration is a documented strict prereq. Without it, every machine holding that file is an arbitrary-code-deployment surface over the live treasury.link_solana and OAuth callbacks funnel collisions into merge_users!, which performs min(survivor.id, absorbed.id) then set_app_session(survivor). A logged-in attacker who triggers a collision (wallet or Google email) becomes the victim's session.MOONPAY_WEBHOOK_KEY is blank. One missed Heroku config var = unlimited USDC minted to attacker-controlled wallets.settle_contest permits duplicate-entry double payouts. Compromised admin signer + a fatigued cosigner approving a long settlement vec containing the attacker's (wallet, entry_num) twice = direct vault drain. 2-of-3 cosign is the only gate, and the settlement payload is too dense to audit visually.| Severity | Count | Posture |
|---|---|---|
| CRITICAL | 17 | Mainnet-blocking |
| HIGH | 28 | Fix before mainnet open (rolling launch acceptable for some) |
| MEDIUM | 22 | Track + fix within 90 days post-launch |
| LOW | 17 | Hygiene; ship as backlog |
The system is six fixes away from being launchable to a capped-TVL mainnet smoke phase, and ~20 fixes from a full unconstrained launch — but the foundational SOPs (rate limiting, semantic TX verification, fail-closed defaults) are missing across the entire Rails surface and need to be retrofitted before card funds and on-chain funds meet user-supplied input on a public address.
WalletsController#deposit accepts arbitrary amount, transfers from adminapp/controllers/wallets_controller.rb:23-44, calling Solana::Vault#fund_user at app/services/solana/vault.rb:312-321/wallet/deposit?amount=999999. The action takes params[:amount].to_f, converts to lamports, and invokes vault.fund_user(current_user, amount) which signs as admin to transfer USDC out of the operator's ATA into the user's ATA. There is no devnet guard on this action (Solana::Config.devnet? check is absent here) and no amount cap.raise "Disabled in production" if Rails.env.production?. The legitimate top-up paths are Stripe checkout + MoonPay; the in-app deposit is a dev affordance that does not belong on a money-handling production app.grep -rn 'def deposit' app/controllers/wallets_controller.rb. Confirm route definition at config/routes.rb resource :wallet do post :deposit end.Dx8u…GaCT = 4AQMNwhyZtsaCLx3Dv9G5a2rXaJ6M221FYQw6sommRWz (single keypair, ~/.config/solana/id.json).~/.config/solana/id.json can ship arbitrary program code over the live vault. RCE on the operator's laptop, a stolen backup, an accidentally-committed key, a Heroku ps:exec session = total treasury compromise. The 2-of-3 cosign on settlement/withdraw is bypassed because new program logic can do anything.docs/agents/system/squads-upgrade-authority-migration.md. Rehearse on devnet first. Documented as strict prereq.solana program show <program_id> should show Authority: <Squad vault PDA>.settle_contest allows duplicate-entry double payoutsprograms/turf_vault/src/instructions/settle_contest.rs:56-102settlements: Vec<Settlement> with no dedup on (wallet, entry_num). Deserialize→mutate→serialize per iteration means the second pass for a repeated entry reads the first pass's write and credits again. Compromised admin signer + a cosigner approving a 50-entry payload that has the attacker twice = direct drain on next withdraw. Cosign fatigue on long settlements is the load-bearing trust assumption.BTreeSet<(Pubkey, u32)> during the loop; reject duplicates with a dedicated error. Additionally, require contest_entry.status == Active before mutating (prevents settle-then-resettle of the same entry).enter_contest_with_token burns user tokens without user signatureprograms/turf_vault/src/instructions/enter_contest_with_token.rs:13-67wallet account is UncheckedAccount. Only payer (any vault signer, 1-of-3) signs. Token owner does NOT sign. A compromised Alex Bot key iterates getProgramAccounts for EntryTokenAccount{consumed: false}, then calls enter_contest_with_token to burn each user's token into low-prize/already-lost contests. Combined with OPSEC-003, the same key can enter the attacker's wallet into a stuffed contest and settle. Server-subsidized prize pools (intentional v1 gap) mean each burned token destroys ~$19 of user value AND reroutes vault subsidy to the attacker.user: Signer<'info> (mirror enter_contest_direct_with_token), OR introduce per-token spend-intent signatures the admin submits on behalf of the user.app/controllers/accounts_controller.rb:50-73 (link_solana) + app/controllers/omniauth_callbacks_controller.rb:11-22 + app/models/concerns/user_mergeable.rb:6-12 (merge_users!)link_solana and the logged-in OAuth callback funnel collisions (existing && existing.id != current_user.id) into merge_users!, which sets survivor = min(survivor.id, absorbed.id) then set_app_session(survivor). A fresh-account attacker (new high id) who triggers a collision with a victim (old low id) results in the merged survivor being the victim, and the session being switched into the victim. Full account takeover. For OAuth: same primitive plus User.from_omniauth finds-by-email without checking email_verified, so a Google account with a forged/unverified email matching a wallet-only Turf Monster user takes over that wallet.auth.info.email_verified == true for OAuth-driven email lookups. (c) Make the signed wallet message embed User-ID: <current_user.id> and verify post-Ed25519 to bind the link to the active session.app/controllers/webhooks/moonpay_controller.rb:30-39return true if webhook_key.blank? — if MOONPAY_WEBHOOK_KEY is unset on Heroku (one missed env var), the webhook accepts unsigned POSTs from any internet source. Attacker forges transaction_completed events for arbitrary walletAddress and quoteCurrencyAmount, triggering MoonpayDepositJob to fund those wallets with USDC from the treasury. Combined with OPSEC-022 (no DB unique index for external payment IDs), N forged events with N distinct IDs all pass.return false if webhook_key.blank? && !Rails.env.development?. Boot-time assertion in config/initializers/moonpay.rb raising if MOONPAY_WEBHOOK_KEY blank under Rails.env.production?.update_level trusts client-supplied seeds_totalapp/controllers/accounts_controller.rb:99-108PATCH /account/update_level seeds_total=99999999 directly persists current_user.level. The level drives the "Free Entry Earned 🎟️" UI badge (per memory, a marketing vector). Attacker pumps level → screenshots → social-engineers operator into manual mint at /admin/free_entries. Even without ops engagement, the level value pollutes leaderboards and any future tier-reward logic.Solana::Vault.new.sync_balance(current_user.solana_address)[:seeds].app/services/stripe_checkout_validator.rb:87-95, app/controllers/webhooks/stripe_controller.rb:81-87kind != "tokens" (deposit flow), amount_matches? returns true unconditionally. The handler then uses session.metadata["amount_cents"].to_i to drive StripeDepositJob. The session's actual amount_total (the only authoritative paid amount) is never compared. Combined with OPSEC-006-class signature bypass or a future server bug that sets mismatched metadata, $1 paid → $500 credited. Even absent those, any path that constructs a Checkout session with attacker-influenced metadata becomes a money-printing bug.amount_matches?, always compare session.amount_total == session.metadata["amount_cents"].to_i. Drive StripeDepositJob off session.amount_total (the authoritative figure), not metadata.app/jobs/token_purchase_job.rb:11-46for_session.exists? returns true after the StripePurchase row is created at the top of the job. If vault.mint_entry_token succeeds for tokens 1-2 of a 3-pack and fails on 3 (RPC timeout, slot lag), the job raises. Sidekiq retries — but the early-return at line 11 sees the row exists and returns immediately. mint_tx_signatures is only persisted at the end of the loop, so the retry has no resume point. User paid $49 and got 1-2 tokens; no audit trail of which were minted.tx_signatures incrementally inside the loop (per-mint save). On retry, skip mints whose EntryTokenAccount PDA already exists on-chain (the Anchor init constraint is the source of truth). Alternatively split into N single-mint jobs each with key stripe:#{session_id}:#{i}.verify_solana_transaction! lacks semantic verificationapp/controllers/contests_controller.rb:638-654 (and callers at :83, :143, :370-407)meta.err is nil. It does not check (a) program ID matches Turf Vault, (b) the instruction is the expected one (e.g., enter_contest_direct, settle_contest, create_contest), (c) the signer is current_user.web3_solana_address, (d) the PDAs referenced match the server-derived expected PDAs, (e) the tx_signature hasn't already been consumed by another DB row. Attacker submits ANY successful past TX signature (e.g., a $0.01 SOL transfer) as tx_signature to "confirm" their cart entry without paying. The DB flips status; the prize pool is short their entry fee.meta.transaction.message.accountKeys and instructions. Assert program ID matches Solana::Config::PROGRAM_ID. Decode the instruction discriminator and assert it matches the expected operation. Assert a writable account at the expected PDA position. For confirm_onchain_entry, re-derive entry_pda server-side and compare. For pending_transactions#confirm, assert one of the signer keys matches the claimed cosigner_address AND that it's in the multisig set.PendingTransactions#confirm trusts client tx_signature for settlementapp/controllers/admin/pending_transactions_controller.rb:14-39params[:tx_signature] and params[:cosigner_address] straight to DB and flips target.update!(onchain_settled: true) for contests. No on-chain re-fetch, no instruction validation, no signer check. A rogue admin (or attacker holding admin session via OPSEC-005) flips onchain_settled for a $1881 large contest by POSTing any string — subsequent admin payout clicks proceed against a contest that was never actually settled on-chain.verify_solana_transaction! from OPSEC-010. Specifically assert: settle_contest instruction, target contest PDA in accounts, cosigner pubkey appears in TX signers AND is in MULTISIG_SIGNERS.Solana::Config::PROGRAM_ID fallback is the orphaned program IDapp/services/solana/config.rb:37Hy8GmJWPMdt6bx3VG4BLFnpNX9TBwkPt87W6bkHgr2J, the orphan with no upgrade authority in our possession. If SOLANA_PROGRAM_ID is unset/misnamed on Heroku mainnet, the app silently talks to a non-existent address — and if anyone deploys a program at that address on mainnet, we hand them our users' TXs. Adjacent risk: rake error messages at solana.rake:373 still print the stale ID, misleading incident response.SOLANA_PROGRAM_ID required at boot in production (raise KeyError if missing). Add a boot-time assertion that the value matches a sealed mainnet allowlist post-Squads-migration.force_close_vault rake has no network guardlib/tasks/solana.rake:52 (and init_vault, migrate_user_account adjacent)bin/rails solana:init_vault FORCE_CLOSE=true calls vault.force_close_vault with no Solana::Config.devnet? or Rails.env.production? check (other destructive tasks like faucet, mint, airdrop have this guard). A typo on a production console destroys the live vault. The on-chain 2-of-3 multisig is the only defense — and the bot signer is automatic, the human signer might cosign reflexively.raise "force_close disabled outside devnet" if Solana::Config.mainnet? at the top of each destructive rake task. Add CONFIRM_PROD=yes requirement for any prod-destructive op.EXPECTED_IDL_HASH fails open when blank in productionapp/services/solana/config.rb:70-90, config/initializers/solana_idl_verification.rb:11-18verify_idl! returns nil silently when EXPECTED_IDL_HASH.blank? or idl_hash.nil?. Combined with SKIP_IDL_VERIFICATION=true escape hatch, a malicious deploy unsets both and silently boots against a drifted/tampered program. The post-audit checklist already flags this env var as still TODO — current production boots with verification disabled.EXPECTED_IDL_HASH blank OR if IDL_PATH missing. Require SKIP_REASON to be set + a Sentry alert raised when SKIP_IDL_VERIFICATION=true.SECRET_KEY_BASE permanently locks managed-wallet encryptionapp/services/solana/keypair.rb:30Rails.application.credentials.secret_key_base[0, 32]. secret_key_base is a 128-char hex string; [0, 32] returns 32 hex characters — effective 128 bits of entropy (silent downgrade from advertised 256). Worse: no key derivation function, no version tag, no rotation path. If RAILS_MASTER_KEY is ever rotated, every encrypted_web2_solana_private_key in the DB becomes undecryptable and every managed-wallet user loses access to their funds. A routine credentials rotation becomes a wallet-destruction event.ActiveSupport::KeyGenerator.new(secret_key_base).generate_key("turf-monster wallet encryption v1", 32) for full 256-bit material. (b) Introduce a separate MANAGED_WALLET_ENCRYPTION_KEY env var that is rotation-isolated from SECRET_KEY_BASE, with a documented rotation procedure that re-encrypts existing rows. (c) Cold-backup RAILS_MASTER_KEY to paper/safe before mainnet./sso_login mutates session — CSRF + cross-app takeover via shared cookiestudio-engine/app/controllers/sessions_controller.rb:19, studio-engine/lib/studio.rb:119set_app_session(user) based on session[:sso_email]. CSRF doesn't cover GETs; browsers prefetch GETs; <img src=…> triggers GETs. The _studio_session cookie spans *.mcritchie.studio — an XSS anywhere in any subdomain (current OR future satellite) can write session[:sso_email], and a subsequent /sso_login visit from any source auto-creates the user and starts a session. authenticate_sso_user! auto-provisions if missing, so no prior account needed.Origin/Referer check binding the request to the hub domain. Better: hub mints a single-use signed token, satellite consumes it via POST with token verification.Solana::Transaction#serialize doesn't verify signer countsolana-studio/lib/solana/transaction.rb:80-93num_required_signatures based on accounts marked is_signer: true. If @signers.length < num_required_signatures, gem produces a malformed payload silently — RPC rejects, but no client-side detection. serialize_partial is worse: writes "\x00" * 64 for missing slots (line 127), so a partially-signed TX missing a required signer can still be broadcast by a wrapper that doesn't merge sigs. On a vault drain operation, a caller's bug becomes a silent failure or worse a signed TX with hopefully-no-effect that wastes admin SOL.serialize if @signers.length != num_required_signatures. Same check in serialize_partial after counting both @signers and @_additional_signers. Assert each signer's public_key appears as is_signer: true in account_keys.Solana::AuthVerifier has no domain bindingsolana-studio/lib/solana/auth_verifier.rb:70Nonce: <stored_value> in the message. Doesn't enforce the message references the host app, domain, or action. If turf-monster issues nonce ABC123 and any third-party dApp coaxes the same user to sign a message containing the same nonce (timing/leak/social), that signature passes turf-monster login verify. Standard SIWS (Sign-In With Solana) format binds host, statement, version, chain-id, issued-at, etc. — this gem doesn't.expected_host: param to verify!. Assert message starts with a canonical SIWS-style prefix: "#{host} wants you to sign in...\nDomain: #{host}\n". Reject otherwise.app/controllers/inline_sessions_controller.rb:1-16skip_before_action :require_authentication, no rate-limiting, no lockout, no CAPTCHA. Scripted credential-stuffing trivially. Returns user info on success — perfect enumeration target. Same root cause: no rack-attack anywhere in the stack.rack-attack. Throttle per-IP and per-email on /login, /sessions/inline, /auth/solana/nonce, /auth/solana/verify, /tokens/stripe_checkout, /wallet/airdrop, /faucet, /webhooks/*. Default 5/min per IP, 20/min per email.Solana::Config.devnet?app/controllers/faucet_controller.rb:12-48, app/controllers/wallets_controller.rb:133-162, app/controllers/admin_controller.rb:20-36, app/controllers/tokens_controller.rb:75-94, app/controllers/users_controller.rb:4-19Solana::Config.devnet?, which reads SOLANA_NETWORK env (default "devnet"). One env-var slip → infinite money. UsersController#add_funds has NO devnet guard at all — admin-only, but combined with OPSEC-005 takeover paths, a compromised admin account drains the bot. FaucetController#claim per-call cap is $500 but no per-day or per-user cap.raise "Disabled on mainnet" if Rails.env.production? to every faucet/airdrop/mint/add_funds action. Disable the routes entirely behind unless Rails.env.production? in config/routes.rb. Add a SOLANA_NETWORK == 'mainnet-beta' boot assertion that prints which devnet-only actions are disabled.app/services/solana/keypair.rb:7-13 (class-level @admin ||=), app/models/user.rb:142-145 (decrypt on every call, returned to caller)Solana::Keypair is memoized for dyno lifetime. Managed-user keypairs are decrypted on each call and held until GC. Solana::Keypair has no overridden inspect/to_s — Sentry default include_local_variables=false is current, but flipping it to true (common debugging move) would ship the 64-byte secret offsite on any exception with a keypair-typed local. awesome_print is in the bundle. Heroku ps:exec enables debugger attach if anyone turns it on.Solana::Keypair#inspect and #to_s to redact (<Keypair pubkey=#{addr[0..7]}…>). Override marshal_dump. Pin Sentry include_local_variables = false and before_send scrubber that drops frame vars named *keypair*, *private*, *secret*. Confirm Heroku ps:exec is disabled in production.app/jobs/stripe_deposit_job.rb:6, app/jobs/moonpay_deposit_job.rb:6, db/schema.rb:340-347TransactionLog.exists?(metadata: {…_id: …}). No unique index on metadata keys. Sidekiq's at-least-once delivery + concurrent workers means TOCTOU between exists? and record! — double-fund. StripePurchase has a unique index for token purchases, but the deposit path uses TransactionLog only.stripe_session_id text column + unique partial index to transaction_logs. Same for moonpay_tx_id. Let DB UNIQUE catch the race.Season account is unconstrained across all four enter_contest variantsprograms/turf_vault/src/instructions/enter_contest_direct.rs:77, enter_contest_direct_with_token.rs:67, enter_contest.rs:51, enter_contest_with_token.rs:65season: Account<'info, Season> has no seeds constraint and Contest doesn't store season_id. Caller passes any Season. For enter_contest_direct (user-permissionless per OPSEC-024), users always pick the max-seed-schedule season. Levels accumulate fast, future tier-reward features become drains.contest.season_id: u32 field, set at create_contest. Constrain season with seeds = [b"season", contest.season_id.to_le_bytes().as_ref()].enter_contest_direct has no admin/signer gatingprograms/turf_vault/src/instructions/enter_contest_direct.rs:27-31enter_contest, no vault_state.is_signer(&payer.key()) check. Anyone can be payer. Combined with OPSEC-023, users self-serve entries with any season and any entry_num, racing to claim slot 0 (highest seed reward) before Rails allocates centrally. Funds-safe (user signs their own USDC transfer) but breaks Rails' assumption that direct-entry is operator-mediated.is_signer(&payer.key()) to gate via operator, or accept user-permissionless intentionally and make entry_num deterministic (derive from contest.current_entries at instruction time).create_contest payout sum uses unchecked arithmeticprograms/turf_vault/src/instructions/create_contest.rs:57payout_amounts.iter().sum::<u64>() wraps silently. Attacker creator constructs payout_amounts = [u64::MAX, 1] with prizes=0, sum overflows to 0, equality check passes, no USDC transferred but contest stores attacker-controlled payout array. Direct on-chain settle bounds by entry_fees + prizes (small) so program-level theft is bounded — but if Rails reads payout_amounts for UI display or off-chain settlement math, attacker controls the display.try_fold(0u64, |acc, x| acc.checked_add(*x)).ok_or(Overflow)?.force_close_vault is replayable indefinitely on the new vaultprograms/turf_vault/src/instructions/force_close_vault.rs:30-73migration_complete flag. The instruction validates 2-of-3 against bytes at offsets data[8..104], which on the new vault is still the signers array. Compromised admin + phished cosigner can drain VaultState lamports and zero data at any time. Vault USDC accounts persist (PDA-owned) but become orphaned because no vault_state for seeds. Effective program DoS until re-init + migration of every UserAccount + every Contest.VaultState.migration_locked: bool set true post-migration, refuse force_close when locked. Or check that the data layout's first 8 bytes indicate old schema before proceeding.update_signers can lock out the multisigprograms/turf_vault/src/instructions/update_signers.rs:21-31new_threshold in 1..=3 and rejects duplicates, but doesn't require the current admin/cosigner to appear in new_signers. Two compromised signers rotate to three attacker addresses; legitimate parties locked out. Or a fat-finger Phantom paste during rotation bricks the multisig.require!(new_signers.contains(&admin.key()) || new_signers.contains(&cosigner.key())). Better: 2-step rotation with a 7-day timelock.mint_entry_token 1-of-3 admin authority + server-subsidized prize pool = vault drain primitiveprograms/turf_vault/src/instructions/mint_entry_token.rs:16-48EntryTokenAccounts to attacker wallets. Consuming each via enter_contest_with_token enters server-subsidized contests at zero attacker cost. Attacker majority-stuffs a 100-entry contest with 80 free entries, wins prizes ($1500 per contest per CLAUDE.md memory), repeats. Iterated: $75K/week burn until detection. Detection is off-chain (Rails monitors mint events without matching Stripe source_ref).mint_entry_token. Or add daily mint rate-limit PDA. Or require on-chain Stripe-session-hash commitment with 24h challenge window. As short-term mitigation: add Rails alert on any mint without a corresponding StripePurchase row.mint_entry_token source_ref not validated, enables compromised-admin trail forgeryprograms/turf_vault/src/instructions/mint_entry_token.rs:55-71source: u8 accepts any value; source_ref: [u8; 64] is opaque. No collision check. Compromised admin mints duplicates sharing legitimate source_ref values — detection-evasion (the on-chain trail looks legit).source ∈ {0,1,2}. Add a MintLedger PDA seeded by (source, source_ref_hash) with init to enforce one-mint-per-external-ref.payout_entry race double-pay (and adjacent race patterns)app/controllers/contests_controller.rb:162-183 (payout_entry), app/controllers/transaction_logs_controller.rb:25-45 (approve), app/controllers/admin/free_entries_controller.rb:13-24 (mint), app/controllers/contests_controller.rb:229-314 (enter token-funded path)payout_entry: $1000 first-place paid twice = real $1000 loss. For approve: same on withdrawals. For mint: double-mints free tokens. For enter token-funded: admin SOL rent for a doomed second TX.record.with_lock { ... }. Or atomic claim: Entry.where(id:, payout_tx_signature: nil).update_all(payout_tx_signature: 'claiming-by-' + Process.pid) and check affected_rows == 1. The locking pattern needs to become a controller convention.WalletsController#withdraw doesn't validate balance at request timeapp/controllers/wallets_controller.rb:110-131amount_dollars = params[:amount].to_f, persisted to TransactionLog without comparing to user's on-chain USDC balance. Admin reviewing the queue may approve a withdrawal larger than the user has, and approve calls vault.withdraw(txn.user.solana_keypair, amount_lamports) directly. If the source is the bot wallet (e.g. for managed users), bot drains itself; if it's the user's ATA, TX fails on-chain.config/initializers/stripe.rb, app/controllers/webhooks/stripe_controller.rb:11-21STRIPE_WEBHOOK_SECRET causes Stripe::Webhook.construct_event(payload, sig, nil) to raise ArgumentError. Controller only rescues JSON::ParserError and Stripe::SignatureVerificationError — ArgumentError 500s. Stripe retries 3x and gives up. Real customers pay → never get tokens → chargeback wave.stripe.rb, raise in production if either STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET is blank. Add Rails.application.config.x.stripe_webhook_enabled flag. Additional defense: in production refuse to boot if STRIPE_SECRET_KEY doesn't start with sk_live_.app/controllers/webhooks/stripe_controller.rb:23-44checkout.session.completed. If production env has a misconfigured STRIPE_SECRET_KEY=sk_test_… (the current .env per audit memory), test-mode events re-fetch under a test key and pass — minting real mainnet tokens for test-mode payments. The Stripe key correctness is silently load-bearing.return head :ok if Rails.env.production? && !event.livemode. Also enforce sk_live_ prefix at boot.dev_mint route + admin gating brittleapp/controllers/tokens_controller.rb:75-94, 103-106, config/routes.rb:147current_user&.admin? && Solana::Config.devnet?. Any bug in the filter chain, any accidental skip_before_action on a child, any env-var flip leaves a free-mint endpoint exposed. No TransactionLog row created for dev mints — no audit trail.post "tokens/dev_mint", ... unless Rails.env.production?. Always log dev mints to TransactionLog regardless of environment.app/controllers/webhooks/moonpay_controller.rb:46-48data["quoteCurrencyAmount"] (the fiat amount per MoonPay docs) where it should use baseCurrencyAmount (the crypto amount). So users get credited in USD as if it were USDC — misattribution at best, massive over-credit at worst depending on exchange rate. Also no server-side order pre-registration: nothing persists what the user agreed to buy before redirecting to MoonPay, so there's no record to validate against.GET /v1/transactions/{id}, use the authoritative cryptoAmount field. Persist a MoonpayPurchase order row when initiating redirect with an idempotency token; look up by id in the webhook.app/controllers/webhooks/stripe_controller.rb:25-44checkout.session.completed handled. charge.refunded, charge.dispute.created, charge.dispute.funds_withdrawn, payment_intent.payment_failed all silently ignored. Attacker buys 3-pack with stolen card → 3 tokens mint → uses to enter contest → ~10-60 days later issuer disputes → Stripe debits $49 + $15 dispute fee. Operator eats every loss. The refund_status column on StripePurchase is dead — nothing writes to it.charge.dispute.created → flag users.payment_risk_flag = true, block further token purchases, alert ops via RECONCILER_ALERT_WEBHOOK. Handle charge.refunded → mark StripePurchase.refund_status = "refunded", attempt on-chain token revocation if unspent.OutboundRequestLogger captures signed TX bytes verbatimapp/services/outbound_request_logger.rb:10-19, app/services/solana/client_logger.rb:10-37SENSITIVE_KEYS whitelist is generic; doesn't redact Solana RPC payloads. Every sendTransaction RPC writes base64-encoded signed TX bytes to outbound_requests DB table. Pre-broadcast signed TXs (partially-signed admin TXs awaiting cosign) include admin signatures — replayable inside the ~2-minute blockhash window. mint_entry_token source_ref writes Stripe session ids verbatim. DB dump → adversary can correlate users, replay never-confirmed TXs, harvest payment metadata.sendTransaction/sendRawTransaction/simulateTransaction. Add explicit redaction for the first param of these methods. Store only post-broadcast TX signature, never the signed payload.filter_parameter_logging is incompleteconfig/initializers/filter_parameter_logging.rb:7[:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn]. _key catches encrypted_web2_solana_private_key via partial match. Missing: :signature, :serialized_tx, :tx, :private_key, :mnemonic, :recovery_phrase, :webhook_signature. Heroku log drain captures params[:serialized_tx] on admin pending-transaction endpoints, params[:signature] from Phantom auth, customer_email from Stripe webhook (via TokensLogger.dump, which goes to Rails.logger and bypasses filter_parameters entirely). 7-day retention on Heroku Papertrail/Logentries makes this a meaningful PII exposure.:signature, :serialized_tx, :tx, :private_key, :mnemonic, :recovery_phrase, :webhook_signature, :nonce to filter. Wrap TokensLogger.dump (and Solana::ClientLogger) in explicit field redaction. Confirm customer_email is stripped from [tokens] lines.SOLANA_NETWORK / PROGRAM_ID / RPC_URL cross-validationapp/services/solana/config.rb (all three constants), no verify_network_alignment! stepSOLANA_RPC_URL is hijacked via Heroku config access (no second-factor on Heroku config writes), all signed TXs go to attacker RPC pre-broadcast — observability blind spot at minimum.getGenesisHash on the configured RPC, compare against pinned mainnet/devnet hash. Refuse to boot on mismatch. Allowlist SOLANA_RPC_URL to known providers (Helius, QuickNode, Triton, Solana Foundation endpoints). Default SOLANA_NETWORK to nil, raise if missing in production.config/environments/production.rb:75 (queue_adapter = :async), config/initializers/sidekiq_cron.rb, Procfilequeue_adapter = :async runs jobs in web dyno threads, not Sidekiq. sidekiq_cron.rb only loads under Sidekiq.server? (worker dyno). If worker isn't scaled and adapter isn't fixed, the reconciler cron (15-min interval, the only DB↔chain divergence detector) never fires. TokenPurchaseJob runs in web thread, potentially blocking request response.config.active_job.queue_adapter = :sidekiq in production. Confirm worker dyno scaled ≥ 1. Verify cron jobs in /admin/jobs after deploy.*.mcritchie.studio cookie + cross-app session — single XSS = total takeoverconfig/initializers/session_store.rb (per CLAUDE.md, domain .mcritchie.studio)SECRET_KEY_BASE (required for SSO). One XSS on any subdomain (current or future satellite) reads the session cookie and acts as that user on every McRitchie app — including triggering wallet ops on turf-monster.secure: true, httponly: true, samesite: :lax). Add Content-Security-Policy headers (currently absent). Long-term: replace the shared-cookie SSO with per-app encrypted token handoff.current_user legacy session[:user_id] fallback enables cross-app fixationstudio-engine/app/controllers/concerns/studio/error_handling.rb:23-28session[Studio.session_key] is empty BUT session[:user_id] is present, the engine looks up by that ID and calls set_app_session(user). Combined with shared subdomain cookie, an XSS that writes session[:user_id] anywhere becomes login-as-anyone everywhere. The Devise-era migration window is over.Transaction.serialize_partial instance-variable signer state non-thread-safesolana-studio/lib/solana/transaction.rb:108-133@_additional_signers ||= [] + additional_signers.each { ... << pk } — instance ivar accumulates across calls. ensure resets only on normal exit. If a Transaction builder is memoized or shared across requests/threads (which the turf-monster Vault doesn't currently do, but the API invites), two parallel partial-sign flows leak signers between transactions: wrong fee-payer, wrong message bytes signed.serialize_partial to accept additional_signers as a local arg, drop the ivar. Document Transaction as single-use-and-discard.solana_sessions#verify auto-creates a user — Sybil farm via SOL drainapp/controllers/solana_sessions_controller.rb:15-44, app/models/user.rb after_createafter_create :generate_managed_wallet! enqueues EnsureAtaJob — admin SOL rent per new user. Attacker scripts the loop, exhausts admin SOL.change_password doesn't invalidate other sessionsapp/controllers/accounts_controller.rb:110-126session_token column, bump on password change + sensitive ops, include in session lookup.app/controllers/accounts_controller.rb:130-132account_params permits :email. No old-address notification, no password re-prompt. Combined with OPSEC-005 OAuth merge primitive, an attacker who briefly holds a session can change email to one they control then re-take the account via Google OAuth later.enter_contest_direct_with_token similar gating gapprograms/turf_vault/src/instructions/enter_contest_direct_with_token.rs:14-21is_signer constraint on vault_state. payer is just any signer (pays rent), user signs. Combined with OPSEC-023, user picks max-seed season. The on-chain instruction itself doesn't lose money but the inflated seeds drive level / future tier-reward features.payer = user to disable the "admin facilitates" framing for this direct variant.programs/turf_vault/src/instructions/settle_contest.rs:36-106settlements: Vec<Settlement> flips status to Settled with no error. Missed entries stuck Active forever. close_contest allows close on Settled Contest regardless of per-entry status.require!(settlements.len() == contest.current_entries).set_inviter no rate-limit, no first-touch enforcementapp/controllers/accounts_controller.rb:84-97current_user.invited_by_id from public slug. If referral rewards ever launch, this is the Sybil farm: create N accounts, point each at the attacker./r/:slug. Reject if current_user.created_at < 5.minutes.ago (signup-time decision only).Admin::SeasonsController#set_current doesn't validate on-chain existenceapp/controllers/admin/seasons_controller.rb:11-46vault.list_seasons before persisting.| ID | File:line | Description |
|---|---|---|
| OPSEC-051 | programs/turf_vault/src/instructions/force_close_vault.rs:22-27, migrate_user_account.rs:26-30 |
Runtime find_program_address (no stored bump) is OK for canonical-bump verification but inconsistent with codebase pattern. Minor. (VAULT-007) |
| OPSEC-052 | turf-vault/CLAUDE.md, multiple references |
Stale orphan program ID 7Hy8…r2J everywhere — declare_id!() is correct (Dx8u…GaCT) but docs mislead incident response. (VAULT-008) |
| OPSEC-053 | programs/turf_vault/src/instructions/initialize.rs:51 |
Admin not enforced at signers[0] though CLAUDE.md implies. Defensive position naming nit. (VAULT-013) |
| OPSEC-054 | programs/turf_vault/src/instructions/close_contest.rs:1-28 |
Closing a Contest doesn't sweep residual vault USDC — accounting becomes inferred off-chain. (VAULT-014) |
| OPSEC-055 | app/controllers/contests_controller.rb:483-493 |
fill admin action hard-codes seeded test users; on mainnet wastes real USDC per click. (CTRL-018) |
| OPSEC-056 | app/controllers/contests_controller.rb:452-532 |
simulate_game/jump/reset admin actions live in prod routes — re-triggers payouts if invoked on settled contests. (CTRL-019) |
| OPSEC-057 | app/controllers/webhooks/stripe_controller.rb:62-64 |
Validator-rejected events return 200 OK with no Sentry capture; bug = silent loss. (CTRL-020, WEBHOOK-016) |
| OPSEC-058 | app/services/solana/reconciler.rb:14-23 |
RPC failure during sync_balance treated as "missing account" → mass-alert spam. (SVC-014) |
| OPSEC-059 | app/controllers/contests_controller.rb:233-263 |
Managed-wallet token consumption requires only session auth (no wallet signature) — session hijack burns paid tokens. (SVC-016) |
| OPSEC-060 | lib/tasks/solana.rake:100-106 |
generate_keypair puts encrypted-key output to stdout (Heroku log drain). Use stderr + tty? gate. (SVC-018) |
| OPSEC-061 | app/controllers/webhooks/moonpay_controller.rb:50-53 |
User attribution by walletAddress is spoofable. Tie to server-side order record. (WEBHOOK-011) |
| OPSEC-062 | app/services/stripe_checkout_validator.rb:75-78 |
Only rescues Stripe::InvalidRequestError; transient APIConnectionError/AuthenticationError 500s with no retry. (WEBHOOK-012) |
| OPSEC-063 | app/controllers/wallets_controller.rb:47 |
Stripe deposit amount bounds enforced only at request time, not webhook time. (WEBHOOK-013) |
| OPSEC-064 | app/controllers/tokens_controller.rb:23-49 |
Promotion codes not explicitly disabled. Future regression risk. (WEBHOOK-015) |
| OPSEC-065 | studio-engine/lib/studio/s3.rb:19-21,44-48 |
s3_bucket_prefix interpolation no char validation. Misconfig → bad-host URLs. (GEM-007) |
| OPSEC-066 | studio-engine/app/models/error_log.rb:21,33-41 |
ErrorLog.capture! fans to Sentry without scrubbing exception.message. Document or wrap. (GEM-008) |
| OPSEC-067 | solana-studio/lib/solana/keypair.rb:29-32 |
Keypair.from_json_file(path) does plain File.read — document trust-source-only, optionally Pathname-guard. (GEM-009) |
| OPSEC-068 | studio-engine/docs/GOOGLE_AUTH_SETUP.md:50 |
Docs recommend OmniAuth.config.allowed_request_methods = [:post, :get]. GET defeats CSRF protection. Should be [:post]. (GEM-012) |
| OPSEC-069 | solana-studio/lib/solana/spl_token.rb:61-77 |
Uses legacy Transfer (discriminator 3) not TransferChecked (12). Add transfer_checked_instruction builder. (GEM-018) |
| OPSEC-070 | solana-studio/lib/solana/client.rb:61-79 |
send_and_confirm poll with no exp-backoff; under RPC rate-limit, false timeout error. (GEM-011) |
| OPSEC-071 | Gemfile + CI |
No bundler-audit in CI. Adds free CVE coverage. (OPS-Q2) |
| OPSEC-072 | RPC provider | SOLANA_RPC_URL defaults to public devnet endpoint — rate-limited under real load. Mainnet needs paid provider. (OPS-Q6) |
Sentry.init does not pin include_local_variables = false explicitly (default is false in current SDK, but worth pinning + adding a before_send scrubber). [config/initializers/sentry.rb, SVC-024]Solana::Client send-transaction retry behavior is opaque; verify it doesn't re-broadcast with a fresh blockhash (would enable double-submit). [SVC-025]Solana::Config::MULTISIG_SIGNERS hardcodes production signer pubkeys in source. Not secret but rotation = redeploy. [app/services/solana/config.rb:15-17, SVC-023]Solana::Config::ADMIN_KEYPAIR_PATH is dead config. Remove. [config.rb:12, SVC-011]StripePurchase.name_slug includes 16 chars of session_id in URL slug. Use random hex. [app/models/stripe_purchase.rb:48-50, WEBHOOK-018]Stripe.api_key = ENV["..."] happens at boot with no production nil-check beyond a warning. (Closely related to OPSEC-032.) [config/initializers/stripe.rb, WEBHOOK-019]StripePurchase.refund_status column exists but is dead code (no writes). Misleading. [WEBHOOK-021]Vault.mint_entry_token source_ref stores full Stripe session ID on-chain. Use truncated HMAC. [app/jobs/token_purchase_job.rb:42, WEBHOOK-024]AuthVerifier.verify! error messages leak input byte-length, useful for fingerprinting. Reduce to generic. [solana-studio/lib/solana/auth_verifier.rb:61,64, GEM-013]display_balance swallows all errors → "$0". UX-misleading. [app/controllers/application_controller.rb:50-66, CTRL-026]Solana::Config::PROGRAM_ID literal is stale (orphan) for runtime fallback. Cross-references OPSEC-012 but documented separately as a cosmetic+latent risk. [SVC-003, OPS-013]tokens/processing?session_id=… echoes session ID via Alpine view; confirm template escapes. [WEBHOOK-014]wallet#show?deposit=success URL flag should not drive any state assertion in views. [app/controllers/wallets_controller.rb, CTRL-012]create_user_account allows anyone to pay rent for someone else's PDA — admin spending griefing surface (negligible). [VAULT-019]migrate_user_account writes wallet from data into struct via raw bytes; PDA constraint already enforces match. Defensive nit. [VAULT-007 detail]Studio.welcome_message flash interpolation — confirm _flash.html.erb Alpine handler uses x-text not x-html. [GEM-010]ErrorLog show view shows full backtrace + DB primary keys; if admin? is ever subdivided into "viewer admin", revisit. [GEM-014]Tracking against the Squads migration runbook, house-burn-down protocols, and audit findings above.
WalletsController#deposit deleted or production-disabledsettle_contest dedup fix shippedenter_contest_with_token requires user signeremail_verified == trueupdate_level route deleted, level recomputed server-sideamount_total == metadata.amount_centsverify_solana_transaction! validates program + instruction + signer + PDAPendingTransactions#confirm uses the hardened verifierSOLANA_PROGRAM_ID required at boot; orphan fallback removedforce_close_vault, init_vault, migrate_user_account rake tasks gated on Rails.env.production? + CONFIRM_PROD=yesEXPECTED_IDL_HASH required in production; fail-closedKeyGenerator with documented MANAGED_WALLET_ENCRYPTION_KEY rotation path; RAILS_MASTER_KEY in cold storage/sso_login POST-only + CSRF tokenTransaction#serialize raises on signer count mismatchAuthVerifier.verify! enforces canonical host-bound message prefixrack-attack installed with throttles on auth/webhook/payment endpointsSolana::Keypair#inspect/to_s redacted; Sentry include_local_variables = false pinned + scrubbertransaction_logs(stripe_session_id) and transaction_logs(moonpay_tx_id)charge.dispute.created + charge.refunded handlers wiredqueue_adapter = :sidekiq in production + worker dyno scaledsecure: true, httponly: true, samesite: :laxsession[:user_id] migration block deletedaudit-post-execution-checklist)SOLANA_PROGRAM_ID (mainnet program ID, post-Squads-migration)SOLANA_NETWORK=mainnet-betaSOLANA_RPC_URL (paid provider)EXPECTED_IDL_HASH (post-bin/rails solana:idl_hash)SENTRY_DSNRECONCILER_ALERT_WEBHOOKSTRIPE_SECRET_KEY (live, sk_live_ prefix)STRIPE_WEBHOOK_SECRET (live)MOONPAY_WEBHOOK_KEY (live)MOONPAY_API_KEY, MOONPAY_SECRET_KEY (live)contest.season_id + PDA seeds)enter_contest_direct gating decision (admin-gated OR deterministic entry_num)create_contest payout sum checked_addforce_close_vault migration lockupdate_signers lockout protectionmint_entry_token short-term mitigation (Rails alert on mint-without-purchase)sk_live_ prefix check:signature, :serialized_tx, :private_key, :mnemonic, :recovery_phrasegetGenesisHash cross-validation at bootTransaction.serialize_partial ivar refactorEnsureAtaJob until first contest interactionopsec-medium / opsec-lowThree rough waves. Wave 1 unblocks the next mainnet planning conversation. Wave 2 is what ships before a single real-money user touches the system. Wave 3 is concurrent with Wave 2 but on independent long-lead timelines.
These are the highest-impact, lowest-effort fixes. They should land before continuing any other launch-prep work.
WalletsController#deposit. One-line guard or full removal. <30 min.update_level route + recompute server-side. 30 min.Rails.env.production? guards to all destructive rake tasks. 30 min.EXPECTED_IDL_HASH in production. 15 min.filter_parameter_logging. 15 min.Solana::Keypair#inspect and #to_s. 30 min.:sidekiq; confirm worker. 30 min + ops check.session[:user_id] migration. 15 min.EnsureAtaJob deferral. 1-2 hr.Wave 1 covers ~12 critical findings with cumulative effort under one workday for a focused operator.
Engineering work that requires careful design + testing. Parallelizable across the four layers.
Anchor (turf-vault), in a single audited release:
- OPSEC-003 settle_contest dedup
- OPSEC-004 enter_contest_with_token user signer requirement
- OPSEC-023 + OPSEC-047 Season binding
- OPSEC-024 enter_contest_direct signer decision
- OPSEC-025 create_contest checked sum
- OPSEC-026 force_close_vault migration lock
- OPSEC-027 update_signers lockout protection
- OPSEC-029 mint_entry_token source_ref validation + MintLedger PDA
- OPSEC-048 Settlement completeness require
Rails controllers:
- OPSEC-005 Account merge primitive hardening (link_solana + OAuth)
- OPSEC-010 verify_solana_transaction! semantic verification
- OPSEC-011 PendingTransactions#confirm using hardened verifier
- OPSEC-019 rack-attack installed
- OPSEC-030 Row-locking convention applied across payout_entry, approve, free_entries#mint, token-funded enter
- OPSEC-031 Withdraw balance validation
- OPSEC-032 Stripe webhook secret boot assertion + sk_live_ check
- OPSEC-033 Controller-level livemode gate
- OPSEC-034 dev_mint route-level disable
- OPSEC-045, OPSEC-046 Session/email change hardening
Webhooks / payments:
- OPSEC-008 Stripe deposit metadata validation
- OPSEC-009 TokenPurchaseJob incremental persistence
- OPSEC-022 DB unique indexes on external payment IDs
- OPSEC-035 MoonPay authoritative re-fetch
- OPSEC-036 Chargeback / dispute / refund handlers
Gems:
- OPSEC-016 /sso_login POST + CSRF
- OPSEC-017 Transaction#serialize signer count check
- OPSEC-018 AuthVerifier host binding
- OPSEC-043 Transaction#serialize_partial ivar refactor
Ops + config:
- OPSEC-015 KDF + rotation path for managed wallet encryption
- OPSEC-037 OutboundRequestLogger Solana RPC redaction
- OPSEC-039 getGenesisHash cross-validation
- OPSEC-041 CSP headers + cookie attr verification
- Heroku env var checklist (SENTRY_DSN, RECONCILER_ALERT_WEBHOOK, IDL hash, all payment provider keys)
mint_entry_token + daily rate-limit PDA. ~1 week. Stretches into post-launch but should land before token volume scales.The Squads runbook already specifies the phased rollout (smoke → capped → uncapped). Adopt unchanged. Adjust caps based on this audit's residual risk:
Findings discovered after the 2026-05-19 audit, appended to keep a single canonical OPSEC tracker. Numbering continues from OPSEC-089. Each entry records the triggering incident.
turf-monster/app/services/solana/config.rb (cluster / mint / program / network selection — NETWORK, mainnet?, USDC_MINT, PROGRAM_ID), turf-monster/app/services/solana/vault.rb (all tx builders + broadcast: build_create_contest, build_enter_contest, cosign_and_broadcast_entry, mint_entry_token, create_contest_server_funded, and the private build_tx / build_partial_signed / build_partial_unsigned / build_tx_unsigned chokepoints). Adjacent to the OPSEC-039 boot guard at turf-monster/config/initializers/solana_network_alignment.rb.localhost:3100) while Phantom was pointed at mainnet. At that moment the local app was also fully configured for mainnet — SOLANA_PROGRAM_ID=DaFv… (live mainnet program), mainnet Helius RPC, mainnet USDC mint EPjFW…, and the mainnet admin/payer key 8K81. A real CreateContest executed on mainnet and moved 45 real USDC into a mainnet prize pool. Recovered separately via cancel_contest.solana_network_alignment.rb) only blocks mixed clusters — e.g. a mainnet program ID with a devnet RPC. Here all three vars were consistently mainnet, so getGenesisHash matched the declared mainnet-beta and the check passed. OPSEC-039 validates internal consistency, not "a dev/local process has any business touching mainnet at all." That second question was never asked. This is the pattern-#10 ("Devnet-only-via-config, no defense in depth") failure mode inverted: there is no Rails.env-layered gate forcing non-prod processes off mainnet..env points at the live cluster — a stale .env, a copy-pasted prod config var, a worktree that inherited the wrong SOLANA_* set — can sign and broadcast real-money instructions (create_contest, enter_contest, mint_entry_token, settlement cosigns) against the live program with the live admin key, from a laptop, with zero confirmation prompt. The funds move is silent and indistinguishable from prod traffic in the outbound-request log.Rails.env.production? is false — local/dev/test, i.e. not the prod Heroku dyno), the app must HARD-REFUSE to (a) boot and (b) build or submit any transaction when the resolved cluster is mainnet-beta, UNLESS an explicit opt-in ALLOW_LOCAL_MAINNET=true is set. Default = block. Mirror the OPSEC-039 escape-hatch ergonomics (SOLANA_SKIP_NETWORK_CHECK) so the bypass is deliberate, env-var-gated, and loud in logs when active.Recommended approach (do NOT implement yet — ticket only):
Solana::Config — add Solana::Config.local_mainnet_blocked? (true when !Rails.env.production? && mainnet? && ENV["ALLOW_LOCAL_MAINNET"] != "true") plus a raise_if_local_mainnet!(context) helper that builds the loud error message. Centralizing the decision in Config keeps the boot guard and the runtime guard in lockstep — same rule, one source of truth (avoids the OPSEC-075-class "logic duplicated, drifts" smell).solana_network_alignment.rb, e.g. config/initializers/solana_local_mainnet_guard.rb) — fail the process at startup, mirroring OPSEC-039. This catches the common case (dev server / console / Sidekiq booting against a mainnet .env) before any traffic. Run inside config.after_initialize so Solana::Config is loaded, same as OPSEC-039.Solana::Config.raise_if_local_mainnet! from the lowest common transaction-build/broadcast helpers in vault.rb (the private build_tx* / build_partial_* methods, and/or cosign_and_broadcast_entry). This closes the gap where a long-running dev process started on devnet and the operator swapped .env to mainnet mid-session without restarting (Sidekiq snapshots .env at boot — see the bin/tm restart note in turf-monster CLAUDE.md), and it defends against any future code path that bypasses the boot guard.Rails.env.production? is the reliable, already-load-bearing signal: the prod Heroku dynos run RAILS_ENV=production; local/dev/test never do. Do not infer "prod" from the presence of mainnet env vars (that's the exact thing this guard exists to catch — circular). Prefer this over a DYNO/hostname sniff.ALLOW_LOCAL_MAINNET=true, exactly as OPSEC-039 tests set SOLANA_SKIP_NETWORK_CHECK. Default test NETWORK is devnet, so the common path is unaffected.RAILS_ENV = development
SOLANA_NETWORK = mainnet-beta
SOLANA_PROGRAM_ID = DaFv…
SOLANA_USDC_MINT = EPjFW… (real USDC)
A dev/test process is about to sign real-money instructions with the live admin key.
This is almost certainly a stale or copy-pasted .env.
To proceed anyway (you are SURE you want local→mainnet): ALLOW_LOCAL_MAINNET=true
```
Verification (post-fix): (a) RAILS_ENV=development SOLANA_NETWORK=mainnet-beta bin/rails runner 'true' must raise at boot; (b) the same with ALLOW_LOCAL_MAINNET=true must boot and log the bypass loudly; (c) a unit test asserting Solana::Config.local_mainnet_blocked? truth table across the Rails.env × NETWORK × ALLOW_LOCAL_MAINNET matrix; (d) a vault test asserting build_create_contest (or the shared build_tx helper) raises under dev+mainnet-no-optin. Cross-reference OPSEC-039 (consistency check) and OPSEC-013/OPSEC-020 (the Rails.env.production?-layered destructive-op gates this generalizes).
These cross-cut the findings and should drive code review going forward.
tx_signature, seeds_total, amount, entry_pda, cosigner_address, wallet_address, or any other on-chain identifier from the client without independent re-derivation is a money-loss bug waiting to happen. The params_token HMAC pattern in Phantom contest creation is the right model — extend it everywhere.rack-attack is a one-day investment that closes a wide attack surface.outbound_requests table (signed TX bytes), Rails log drain (filter_parameter_logging gaps + bypassing via Rails.logger.info), Sentry (no before_send scrubber, unfiltered exception messages). Sensitive data should never reach any of them.SOLANA_NETWORK, SOLANA_PROGRAM_ID, SOLANA_RPC_URL are independent env vars. Any mismatch = silent misroute. Boot-time getGenesisHash validation collapses this.Solana::Config.devnet? or admin gating, never both layered with Rails.env.production?. One config slip = real money.This audit was performed via six parallel investigation agents spanning the Anchor program, Rails controllers, Rails service layer, webhooks/payments, shared gems, and operational envelope. Each agent had ~1500 words of output; this document consolidates and dedupes. Total raw findings ≈ 140; consolidated unique findings = 89 (OPSEC-001 through OPSEC-089). Post-audit incident-driven findings are appended in § 8 (OPSEC-090+) and continue the numbering.
Re-audit cadence: Re-run quarterly or whenever a new payment processor / new on-chain instruction / new auth method ships. The current audit reflects the state of the code on 2026-05-19.
Next document expected after audit: A separate session triages findings into PR-sized work items and sequences them against the Squads migration + external audit timeline. Do not start fixing from this document directly — triage first.
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.