Skip to content

Rake & Fees

How the house gets paid — the fee split, the ledger, the collection flow, and the audit chain. Solana-first; the shared pool math applies to both chains. See the rake-and-fees decision for the full rationale.

The fee split

  • Tournaments: entryFee is the total a player pays; rakeFee is the house's take per entry. Prize pool = (entryFee − rakeFee) × seats — computed by tournamentPrizePool() in apps/game-server/src/games/poker/tournament-pool.ts, the single source of truth used by every payout site, the rank-prize view, and the bonding-target default. computePayoutAmounts distributes with basis-point floors + dust-to-first-position so payouts + fee == entries, exactly (E-RAKE-1). Both cages derive rakeFee = floor(entryFee × tournamentFeeRate / (1 + rate)) (global game-setting, default 10%) when the creator omits it.
  • Cash: the engine takes rake natively per hand (rakePercent/rakeCap table settings); each hand writes a player_id='house' row to poker.hand_players and the players' stacks are debited. That row set IS the house's ledger.

House-as-player: the collection model

The house is an ordinary account at the ledger level. Money leaves a table vault as rake only through numero-core's collect_rake(table_id, amount, rake_chain_head, deadline):

  • the cage signs an ed25519 receipt over (table_id ‖ amount ‖ rake_chain_head ‖ destination ‖ deadline) — the program verifies it via the Ed25519 native program (the settle pattern); the destination is bound in the message;
  • a rake_marker PDA seeded by the chain head makes each collection once-only on-chain (init fails on replay — the cage reconciles, never re-pays);
  • the transfer is vault-capped, and the cage refuses to even claim beyond vault − Σ player balances (E-RAKE-2 — player money is structurally uncollectable as rake).

The collection flow (both revenue kinds)

  • Cash: POST /settlement/reconcile-rake { tableId, destination? } (service-key). Destination defaults to the cage's HOUSE_DESTINATION_SOLANA env var (Doppler-managed per environment — the Solana counterpart of EVM's RAKE_COLLECTOR_ADDRESS; fail-loud when unset); the body override exists for operators/tests. The destination's TSKPK ATA must exist (created once per environment). A stuck claim (link without a collect tx) resumes with the same head — the marker guarantees once-only; a stuck claim on a different table returns 409 with recovery guidance (the EVM discipline).
  • Tournaments: when the last payout for an SnG confirms, the cage collects rakeFee × seats through the same engine (kind='tournament_fee', one claim row per table, zero handHash — the link still binds tableId + amount). Fire-and-forget from the payout path; a failed collection leaves a resumable claim. The fee's conservation gate measures unconfirmed payout_intents (zero once the all-confirmed gate passes) — not table_players equity, which the payouts themselves distribute.
  • Claim serialization: the whole claim decision (read → chain-link insert → row marks) runs under a per-table pg_advisory_xact_lock, so the sweep, the admin Collect button, and the fee leg can never fork the ledger; row-marking touches only still-unclaimed rows and asserts the count.
  • Registrant settlement precondition: game-server settles each registrant's table_players row as the tournament concludes (payout confirmed → that row gone/0; settling → closed → every remaining row). A closed tournament carries no phantom chip equity — which is what keeps the conservation gates honest.

The conservation sweep (E-RAKE-4)

A boot-armed, TABLE_ENVIRONMENT-scoped sweep (RAKE_CONSERVATION_SWEEP_MS, default 5 min; arms with the reconciler interval) keeps the pipeline live without operator attention:

  1. Resume stuck claims first — a claim whose on-chain leg failed has already marked its rows (claim-first), making it invisible to the unclaimed scan while it 409-blocks every other table; the sweep re-drives it through the same collect (cash and tournament_fee kinds — a stuck fee has no other retrigger once all payouts confirmed).
  2. Collect unclaimed house value — oldest table first, closed tables included (vaults are never deleted).
  3. Classify, then alarm — tables with no Solana vault (EVM tables sharing the environment) log as no_solana_vault (WARN); everything genuinely uncollectable (destination unset, conservation divergence) raises the ERROR solana_admin.rake.conservation span with rake.sweep.refused_tables.

The audit chain

Every collection is a link in rake_reconciliations: keccak256(prev ‖ keccak256(tableId) ‖ unixMs ‖ handHash ‖ rake). Cash links bind the collected hands' handHashes — and each hand's OHH document (which the handHash covers) records the rake per pot — so rake is re-derivable from hand history end-to-end: player's on-chain settlement anchor → OHH → per-hand rake → collection links → on-chain RakeCollected events carrying the head.

Configuration

SettingWhereMeaning
tournamentFeeRategame_settings (global)Fee derivation rate when rakeFee omitted at table-create (default 0.1)
HOUSE_DESTINATION_SOLANAenv (Doppler, per environment)The house wallet collections pay to — fail-loud when unset; its TSKPK ATA must exist
rakeFeetables.settings (sng)Per-entry house take; explicit 0 = feeless tournament
rakePercent / rakeCap / rakeMinPottables.settings (cash)Per-hand engine rake policy

Invariants (E-RAKE-1..4)

  1. Prize payouts + house fee == total entries, exactly.
  2. Collections bounded by the live player claim (cage: cash → vault − Σ table_players balances; tournament fees → vault − Σ unconfirmed payouts) AND the vault (program).
  3. House value claims exactly once (claim-first row + on-chain marker; re-dispatch reconciles).
  4. No table concludes with unclaimed house value stranded (close-guard/conservation sweep).