Blockster Bankroll — security audit.
An independent, line-by-line review of the Anchor program that backs Blockster's coin flip game and dual-vault liquidity pools. 22 instructions, 7 account structs, 32 error codes, roughly 1,200 lines of Rust.
Executive summary
This report details an independent security review of the Blockster Bankroll Anchor program, the on-chain smart contract that custodies SOL and BUX deposits, tracks bettor positions, and executes settlement for the Coin Flip game on Solana. Review was conducted at commit 1d4985d on the feat/solana-migration branch.
The program implements a commit-reveal randomness scheme, a two-vault LP pool with standard AMM-style share math, a registry of parametric games bounded by safety caps, and a two-tier referral programme. Arithmetic uses u64 with u128 intermediates and consistently employs checked_* variants. PDAs are derived with canonical seeds and bumps are stored to avoid recomputation. SPL Token and System Program CPIs use signer seeds correctly.
Two Medium findings stand out. M-01 notes that the on-chain program does not recompute win/loss from the revealed seed — it accepts the won and payout parameters from the settler at face value. Combined with the fact that the settler is a single keypair rather than a multi-signer setup, this concentrates outcome authority off-chain. M-02 flags that the program-internal authority field is not rotatable on-chain, so an authority-key compromise requires full program redeployment via the upgrade authority.
The six Low findings and eight Informational findings are mostly hardening opportunities: per-bet vs aggregate liability controls, slippage parameters on deposits, dead fields, misleading error names, and explicit documentation of where trust still resides. None are exploitable against user funds as currently configured.
The program is suitable for devnet operation as-is and is suitable for mainnet operation with the operational controls described in M-01 and M-02 (multisig on authority, documented settler rotation, published outcome-verification tooling). LPs evaluating the protocol should read M-01 carefully and form their own view on the settler trust assumption before depositing meaningful capital.
Scope
- Crate
- contracts/blockster-bankroll/programs/blockster-bankroll
- lib.rs
- Program entrypoints (22 instruction handlers)
- instructions/
- 16 instruction files covering init, LP, bet lifecycle, referrals, admin
- state/
- 7 account structs: GameRegistry, SolVaultState, BuxVaultState, PlayerState, BetOrder, ReferralState, GameEntry (embedded)
- math.rs
- LP price, LP mint/burn, max-bet, max-payout, referral reward calculators
- errors.rs
- 32 error codes
- events.rs
- 14 emitted events
- CPIs
- System Program transfers, SPL Token mint/burn/transfer, Metaplex Token Metadata creation
- Settler service
- contracts/blockster-settler (TypeScript) — reviewed informally for CPIs but not audited independently
- Phoenix backend
- Off-chain game server, Mnesia cache, LiveView UI
- Deployment process
- CI/CD, keypair custody, key rotation operations
- Airdrop program
- wxiuLBuqxem5... — separate crate not reviewed here
- Legacy EVM contracts
- contracts/legacy-evm preserved for archive only
- Economic design
- House edge calibration, game theory, expected LP returns
Methodology
Review was conducted as a full manual read of all Rust source files with cross-reference to the Anchor IDL. For each instruction we enumerated:
- Signer set: which accounts are
Signer<T>, and what constraints bind them. - Account validation: seed derivations,
has_onerelationships, ownership checks. - Pre-conditions: every
require!and constraint, mapped to error codes. - State transitions: what fields are read and written, whether mutations can be reverted if downstream logic fails.
- CPI safety: whether invoked_signed seeds match the PDA holding authority, and whether token programs are the ones expected.
- Arithmetic: overflow, underflow, division-by-zero, signed-vs-unsigned, precision loss in truncation.
We built the following mental threat model and probed each instruction against it:
- A malicious player attempting to withdraw more than their share, settle bets they didn't place, or manipulate outcomes.
- A malicious settler attempting to drain the vault via selective settlements, reuse commitments, or pay themselves.
- A malicious authority attempting to set exploitative config (huge multipliers, 100% fees) before a legitimate bet settles.
- A front-runner attempting to sandwich deposits or withdrawals.
- An uninvolved party attempting to block or poison settlement by interacting with the PDAs.
Where a threat was successfully defended, we recorded it under Verified properties. Where a threat revealed an exposure, we recorded it as a finding.
Findings summary
| ID | Title | Severity | Status |
|---|---|---|---|
| M-01 | Outcome (won, payout) determined off-chain; settler has unilateral control | Medium | Acknowledged |
| M-02 | authority field is not rotatable on-chain | Medium | Acknowledged |
| L-01 | No aggregate cap on total_liability as a fraction of vault balance | Low | Acknowledged |
| L-02 | Settler keypair is a single point of failure for rent recovery across placement rotations | Low | By design |
| L-03 | Game multipliers can be rewritten with no slippage protection for in-flight bet prep | Low | Acknowledged |
| L-04 | fee_bps is stored and validated but never applied in settlement | Low | Acknowledged |
| L-05 | InvalidServerSeed error reused for has_one = player mismatches | Low | Acknowledged |
| L-06 | Settler can self-DoS by spamming submit_commitment for arbitrary wallets | Low | By design |
| I-01 | has_active_order field in PlayerState is dead code | Informational | Acknowledged |
| I-02 | MINIMUM_LIQUIDITY is a flat 10,000 regardless of token decimals | Informational | By design |
| I-03 | No slippage parameter on deposit/withdraw instructions | Informational | Acknowledged |
| I-04 | submit_commitment allows arbitrarily large future nonces | Informational | By design |
| I-05 | Referral rewards are paid from vault (LP cost), not from a separate fee bucket | Informational | By design |
| I-06 | Pause does not block settlement — intentional but should be explicit to LPs | Informational | By design |
| I-07 | UnauthorizedSettler error is raised for authority-role mismatches in pause/update_config | Informational | Acknowledged |
| I-08 | Events lack bet_order pubkey, requiring indexers to derive it | Informational | Acknowledged |
Medium findings
Outcome (won, payout) determined off-chain; settler has unilateral control
The settle_bet instruction takes four caller-supplied arguments: nonce, server_seed, won, and payout. The program verifies three things:
SHA256(server_seed) == bet_order.commitment_hash- the bet has not expired
payout ≤ bet_order.max_payout
Critically, the program does not verify that the submitted won and payout are consistent with the revealed server seed and the bet parameters. The settler is trusted to derive them honestly off-chain.
Because the settler is a single Solana keypair (game_registry.settler) rather than a multisig, compromise of that key grants an attacker unilateral authority over outcomes. The per-bet guard payout ≤ max_payout limits exfiltration per bet to at most 2× the wager, but systematic abuse (e.g. declaring every bet a win) could drain the vault over many bets.
Scenario 1: Settler key compromise. Attacker can route losing bets to themselves as wins (at up to 2× amount) and redirect payouts to a controlled wallet via referral misdirection. Bounded per-bet but unbounded in aggregate.
Scenario 2: Settler-player collusion. Operator can collude with a specific wallet, marking every bet from that wallet as a win at max_payout. Because the off-chain RNG is private, this is undetectable to external observers absent the losing verification that M-01's remediation would enable.
Scenario 3: Buggy settler. A bug in the off-chain outcome derivation (e.g. an off-by-one in byte extraction) would systematically misreport outcomes, with no on-chain tripwire.
Scope. Affects LP solvency in the long run. Individual players are not at risk — if anything, a buggy/malicious settler has incentive to overpay, not underpay, since clients can prove underpayment off-chain and reputational damage is a credible deterrent.
Primary remediation (v2). Move outcome derivation on-chain. Store the bet's difficulty and predictions (max 5 bytes as a packed u8) in BetOrder. On settle, the program should:
- Recompute the client seed from
SHA256(player_pubkey ++ amount ++ vault_type ++ difficulty ++ predictions). - Compute
combined = SHA256(server_seed ++ client_seed ++ nonce). - Derive flips from the first
Nbytes, compute win per mode. - Compute payout as
amount × multiplier / 10000if won, else 0. - Reject the settlement if the caller's submitted
won/payoutdisagree with the derived values. (Or drop those arguments entirely and have the program compute them authoritatively.)
This eliminates the settler's outcome authority without changing the user experience. The settler remains responsible for revealing the seed and paying the compute; the chain becomes responsible for determining the result.
Interim remediation. Migrate the settler keypair to a Squads-style multisig, publish the outcome-derivation algorithm in documentation (done: see Provably Fair page), and provide a first-class verification tool so anyone can audit a posteriori.
authority field is not rotatable on-chain
The authority field of GameRegistry is written exactly once — at initialize_registry — and there is no instruction to update it. The authority can rotate the settler via update_config, but cannot rotate itself.
If the authority key is compromised, the only path to rotation is redeploying the program under a new authority via the off-chain upgrade authority (a separate key). That requires (a) the upgrade authority being held by someone other than the attacker, and (b) operational readiness to re-initialize PDAs in a way that's compatible with existing state.
A compromised authority can:
- Register a new game with arbitrary multipliers (e.g., 100× with unchecked
max_bet_bpsup to 5%), converting LP capital to house losses one bet at a time. - Rotate the settler to an attacker-controlled address and collect all rent / gain arbitrary outcome control per M-01.
- Pause the program and leave it paused, denying users withdrawals (settlements still work, but deposits/withdrawals do not).
Caps on per-game max_bet_bps (500 = 5%) and fee_bps (1000 = 10%) bound the rate of exfiltration. The minimum multiplier of 100 (1×) prevents immediate zero-payout games, though nothing prevents the authority from registering a 1.01× game that is unprofitable for LPs over time.
Preferred (v2). Add a two-step transfer_authority instruction with:
propose_authority(new: Pubkey)— writespending_authorityandpropose_timestamp.accept_authority()— callable only bypending_authority, and only after a timelock of (say) 24h. Clearspending_authorityand overwritesauthority.
The timelock gives observers time to react if a proposed transfer looks suspicious. The two-step prevents accidental loss of authority to a key that doesn't exist.
Interim. Hold the authority key in a 2-of-3 Squads multisig, document the key custody clearly, and publish an incident-response runbook for upgrade-authority-assisted rotation.
Low findings
No aggregate cap on total_liability as a fraction of vault balance
Each bet is bounded in isolation by potential_profit ≤ net_balance, where net_balance = vault − rent − total_liability. As total_liability grows, net_balance shrinks, which tightens subsequent bets — but there is no hard upper bound on what fraction of the vault can be booked as liability at once.
In an extreme scenario with many simultaneous bets placed under max_bet_bps = 5% and the 31.68× difficulty, total_liability could asymptotically approach the vault balance, leaving little or no available liquidity for withdrawals until bets settle.
Not a solvency risk: the vault can always cover any single bet's max_payout because that was the placement invariant. It is a liquidity risk: withdrawals would revert with InsufficientLiquidity until pending bets resolve. In practice bets settle in seconds so queueing is rare, but a coordinated wave of bets could transiently lock LP capital.
Introduce a system-level constant (e.g. MAX_LIABILITY_BPS = 5000) and enforce total_liability ≤ vault_balance × MAX_LIABILITY_BPS / 10000 at placement. New bets revert with a new LiabilityCapExceeded error rather than BetTooLarge.
Settler keypair is a single point of failure for rent recovery across placement rotations
Each BetOrder stores the rent_payer at placement time (enforced to equal the current settler). On settle_bet / reclaim_expired, the account closes and rent returns to that stored rent_payer via has_one.
If the settler is rotated via update_config while bets are pending, those pending bets still reference the old settler. The old key must remain accessible to:
- Sign
settle_bet(settler is the signer). - Receive the rent rebate on BetOrder close (both settle and reclaim).
If the old key is destroyed (e.g. compromise-forced rotation), the in-flight bets can only be resolved via reclaim_expired (player-signed), and the rent will still flow to the old settler's pubkey — which may now be attacker-controlled.
Operational inconvenience rather than a fund-loss risk for users. The rent amount per BetOrder is small (around 2,000,000 lamports / 0.002 SOL). Total at-risk rent at any moment is bounded by unsettled_count × rent_per_bet, typically under 0.1 SOL.
Document the rotation runbook: before rotating settler, wait for all pending bets to settle or expire. For compromise-forced rotation, accept the transient rent loss. Alternatively, relax the has_one = rent_payer constraint to allow either the stored rent_payer or the current settler to receive rent.
Game multipliers can be rewritten with no slippage protection for in-flight bet prep
update_config allows the authority to rewrite new_game_multipliers: [u16; 9] at any time. Each multiplier must be >=100 if non-zero but is otherwise unbounded.
Placed bets are unaffected (their max_payout is fixed at placement). New bets use the new multipliers at placement time. If the UI quotes a bet at multiplier X, the user signs, and the authority rewrites to Y in between, the bet actually placed uses Y with no warning to the user.
A user can end up betting on less-favourable terms than they agreed to. Impact is small because the signing window is typically 1–2 seconds; coordinated exploitation would require the authority to front-run user signatures. Most adversarial in a compromised-authority scenario (see M-02).
Add an optional expected_multiplier_bps: Option<u64> argument to place_bet_sol/place_bet_bux. When Some, the program verifies it matches the current multiplier for the requested difficulty and reverts otherwise. Have the UI populate it from the live quote.
Alternatively, add a timelock to multiplier changes: writes take effect N seconds after they are proposed, giving clients time to re-quote.
fee_bps is stored and validated but never applied in settlement
GameEntry.fee_bps is set by register_game (capped at 1000 bps) and writable via update_config, but is never read by settle_bet or any other instruction. The field is effectively dead.
Confusing for auditors and integrators reading the code — implies a protocol fee that doesn't exist. No security impact.
Either wire it in (deduct payout × fee_bps / 10000 from payouts and accumulate in a protocol_fee_accrued field, plus a withdrawal instruction) or remove the field in the next account-layout upgrade.
InvalidServerSeed error reused for has_one = player mismatches
In settle_bet.rs:77 and reclaim_expired.rs:60, 69, the has_one = player constraint carries the error InvalidServerSeed. A player-mismatch at settlement will surface to clients as "invalid server seed", which is misleading.
Debug and monitoring ergonomics. No security impact.
Add an InvalidPlayer error code and use it for player-mismatch constraints. InvalidServerSeed should remain exclusive to the SHA-256 check in the handler.
Settler can self-DoS by spamming submit_commitment for arbitrary wallets
submit_commitment uses init_if_needed to create the player's PlayerState PDA, with payer = settler. Nothing stops the settler from calling submit_commitment for randomly generated pubkeys, allocating a PlayerState (~326 bytes, ~2.5 million lamports rent) for each. This is self-harm — the settler is spending its own SOL — but can drain a misconfigured fee-payer budget quickly.
If the settler's SOL budget is exhausted, legitimate submit_commitment and place_bet_* calls (which require the settler as rent_payer) will fail, effectively pausing the game. The settler's private key is the only thing that can trigger this so it's a self-inflicted wound, not an external attack surface.
Monitor the settler balance and alert below a threshold. Consider moving PlayerState rent to the player (paid at set_referrer or first place_bet) — this pushes rent cost onto the real user who benefits from the state rather than onto the settler.
Informational findings
has_active_order field in PlayerState is dead code
PlayerState.has_active_order: bool is declared and initialised to false in submit_commitment.rs:50 and set_referrer.rs:58, but never read or toggled. Appears to be vestigial from a design where concurrent bets per player were forbidden. Remove or repurpose.
MINIMUM_LIQUIDITY is a flat 10,000 regardless of token decimals
Both SOL (9 decimals) and BUX (9 decimals) use MINIMUM_LIQUIDITY = 10_000, which is 0.00001 of either token. Negligible at real deposit sizes. If a future vault is added with 6 decimals, 10,000 base units would represent 0.01 units — still small but proportionally ~1000× larger. Consider scaling MINIMUM_LIQUIDITY by 10^(9 - decimals) when registering new vaults.
No slippage parameter on deposit/withdraw instructions
Depositors cannot specify min_lp_out; withdrawers cannot specify min_underlying_out. The program uses the LP price computed from live vault state, so a bet settling in the same transaction or block could shift the effective balance and mint the LP at a different rate than the UI quoted. In practice drift is tiny, but MEV-style sandwiching isn't theoretically prevented.
submit_commitment allows arbitrarily large future nonces
The require!(nonce >= player_state.nonce, ...) check allows the settler to set pending_nonce to any value greater than or equal to the current. This is intentional (allows pre-commitment batching) but a buggy client could leave the player stuck at a huge nonce with a stale commitment. Consider capping the gap at a small number (say 10) unless an explicit batch flag is passed.
Referral rewards are paid from vault (LP cost), not from a separate fee bucket
On loss, referral rewards of up to 1.5% combined (tier-1 + tier-2) are deducted from the same vault that backs LP positions — reducing LPs' share of the house edge. There is no protocol-side accrual or separate treasury. This is a design choice, not a bug, but LPs should understand they are funding the referral programme.
Pause does not block settlement — intentional but should be explicit to LPs
The paused flag gates deposits, withdrawals, bet placement, commitment submission, and referrer-setting, but not settle_bet or reclaim_expired. This is correct: pausing must not trap pending bets. But it means that in an incident response scenario where the settler key is believed compromised, pause alone does not stop the settler from continuing to settle bets at attacker-chosen outcomes. Consider adding a separate deep_pause or settle_pause flag for that scenario.
UnauthorizedSettler error is raised for authority-role mismatches in pause/update_config
pause.rs:10 and update_config.rs:11 both raise UnauthorizedSettler when the signer doesn't match game_registry.authority. The error message says "Unauthorized settler" but the role being checked is actually authority. Add a distinct UnauthorizedAuthority error code.
Events lack bet_order pubkey, requiring indexers to derive it
BetPlaced, BetSettled, and BetReclaimed emit player and nonce, which is sufficient to derive the BetOrder PDA, but not the PDA address directly. Including it would spare indexers the derivation and make logs more self-contained.
Verified properties
The following properties were verified to hold across all relevant code paths. They are listed here to scope what was tested and to document protections that are in place.
- Commitment binds seed
- settle_bet verifies SHA256(server_seed) == bet_order.commitment_hash, using anchor_lang::solana_program::hash. No path bypasses this check.
- Commitment binds bet
- place_bet_* copies pending_commitment into bet_order.commitment_hash at placement, clears player_state.pending_commitment, and increments player_state.nonce. Rebinding requires a fresh submit_commitment.
- Reveal-only-once
- BetOrder closes on settle. The PDA cannot be re-initialised at the same nonce because the nonce is seed material.
- Overflow/underflow
- All multi-value arithmetic uses checked_add, checked_sub, checked_mul, checked_div on u128 intermediates where necessary. Errors with MathOverflow.
- Division by zero
- calculate_underlying_for_lp and calculate_lp_price require lp_supply > 0 before dividing. calculate_max_bet_for_difficulty short-circuits to 0 when multiplier_bps == 0 (prevents ÷0).
- Precision
- LP math truncates consistently. Off-chain Elixir code uses trunc/1 to match.
- Signed fields
- house_profit and net_pnl_* use i64; checked_add and checked_sub guard overflow in both directions.
- PDA seeds are canonical
- All PDAs use the seeds documented in state/ doc comments. Bumps are stored in GameRegistry for vault/mint PDAs and in account bodies for player/bet.
- has_one relationships
- BetOrder.has_one = player and has_one = rent_payer correctly bind settlement and reclaim accounts. Hostile accounts cannot be substituted.
- Vault authority
- SOL vault signs via [b"sol_vault", bump] in CPIs. BUX token account signs via [b"bux_token_account", bump]. LP mint authorities use dedicated seeds. All CPIs are invoke_signed with the correct seeds.
- Settler gating
- submit_commitment, settle_bet gated on settler.key() == game_registry.settler. update_config, pause, register_game, create_lp_metadata gated on authority.key() == game_registry.authority. Player-facing instructions gated on player signature where appropriate.
- Deposit donation defence
- First deposit burns MINIMUM_LIQUIDITY to prevent first-depositor-frontrun attacks on LP price.
- Withdraw cannot exceed available
- calculate_underlying_for_lp return is checked against available_balance = vault − rent − total_liability.
- Rent-exempt preservation
- SOL vault withdrawal math subtracts rent-exempt minimum before computing available_balance, so the vault never loses rent exemption.
- Empty pool handling
- calculate_lp_price returns LP_PRICE_PRECISION (1.0) when lp_supply == 0, avoiding division by zero on first deposit.
- Pre-placement validation
- amount >= min_bet, amount <= per-difficulty max, potential_profit <= net_balance. All enforced at placement with reverts.
- Nonce replay protection
- BetOrder PDA seed includes the nonce; PlayerState.nonce increments monotonically; mismatched nonce reverts with NonceMismatch or fails PDA init.
- Settle expiry asymmetry
- settle_bet requires elapsed <= bet_timeout; reclaim_expired requires elapsed > bet_timeout. Strict inequality avoids edge-case overlap at the timeout boundary.
- Referral failure safety
- Transfer failures in referral payout emit ReferralRewardFailed; the main settle_bet does not revert. Payouts to the player are never blocked by referral issues.
- Caps at config time
- update_config validates new_game_max_bet_bps <= MAX_BET_BPS (500), new_game_fee_bps <= MAX_FEE_BPS (1000), new_referral_bps <= MAX_SPLIT_BPS (500), tier1+tier2 <= MAX_SPLIT_BPS (500), new_bet_timeout >= MIN_BET_TIMEOUT (60).
- Multiplier floor
- Non-zero multipliers in update_config must be >= 100 (matches on-chain MULTIPLIER_SCALE of 100, i.e., >= 1×).
- Game registry bounded
- MAX_GAMES = 10; register_game rejects when full (GameRegistryFull).
- Self-referral blocked
- set_referrer rejects player == referrer (SelfReferral). Tier-2 loops (tier-2 == player) are zeroed out.
Out-of-scope items worth testing separately
- Settler service code (TypeScript). The off-chain settler holds the private key that can settle every bet. Its correctness and security are as important as the on-chain program's — probably more so in the context of M-01. Recommend an independent review of
contracts/blockster-settler/src/services/bankroll-service.tswith focus on: HMAC auth robustness, keypair custody, outcome derivation correctness (byte-to-flip algorithm must match the on-chain expectation in v2), rate limiting, and replay protection. - Deployment keys. Program upgrade authority and
authoritykeypair custody. For mainnet we recommend 2-of-3 or 3-of-5 Squads multisig for both. - Phoenix LiveView UI. Not financially sensitive in the same way but could show wrong balances or allow bets that would revert. Minor impact.
- Economic model. Whether the ~1% house edge is sufficient to compensate LPs for the tail risk of the 31.68× multiplier, given expected bet-size distribution.
- Airdrop program. Separate crate, not touched by this audit.
Auditor notes
The codebase reads like an Anchor program written by a developer who has shipped Anchor programs before. Variable naming is consistent, PDA seeds are documented inline, account sizing is explicit with comments, and _reserved padding is everywhere it belongs. The commit-reveal implementation is textbook. The LP math is FateSwap-style and well-tested by the #[cfg(test)] module in math.rs.
The two Medium findings are not surprises — they are design trade-offs the team has already reasoned through. M-01 is a known quantity in casino-on-chain designs: moving outcome logic fully on-chain is expensive in compute units and requires storing prediction data per bet. The current approach gambles (pun intended) that the settler remains honest and operationally robust. Post-M-01 remediation, the program would be genuinely trustless. M-02 is a rotation issue that any production deployment would need to solve via off-chain key-custody hygiene regardless of what the program does.
The Low and Informational findings are ergonomic improvements. None of them exposes current user deposits to risk.
For LP participants evaluating the pool: understand that your risk profile is a function of (a) the program's correctness — reviewed here and clean — and (b) the off-chain settler's correctness and honesty — reviewable but not eliminated without M-01's remediation. Size your deposits accordingly.