Skip to content

Rake & fees — the house is paid, auditable, and visible

2026-07-03 — a "$20 + $2" heads-up tournament paid the winner $44. The prize-pool math computed entryFee × seats at every orchestrator site and never subtracted rakeFee — the house's cut was paid to the winners, on both chains, since SnG tournaments shipped. On Solana it was doubly broken: the cage never derived a rakeFee at table-create at all, so no fee was even recorded. (The UI, ironically, was honest the whole time — it already rendered the fee-adjusted pool; the payout just didn't match the advertisement.)

What changed (Phase 1 — the fee split)

  • tournament-pool.ts (game-server) is the single fee-split source of truth: tournamentPrizePool = (entryFee − rakeFee) × seats, tournamentHouseFee = rakeFee × seats, and computePayoutAmounts distributes the pool with basis-point floors plus dust-to-first-position — so Σ payouts + house fee == entryFee × seats, exactly, for any prize structure (E-RAKE-1; previously floor dust was silently lost). All three orchestrator pool sites (payouts, per-rank prize view, bonding liquidity-target default) ride it, and the advertised rank prize now derives from the same distribution that pays — they can never diverge.
  • The Solana cage derives the fee like EVM: POST /api/tables on an sng without an explicit rakeFee derives floor(entryFee × tournamentFeeRate / (1 + rate)) from the global game-setting (default 10%) — a fee-less $22 tournament now records rakeFee: 2 and advertises a $40 pool. The solana_admin.tables.create span carries table.entry_fee / table.rake_fee.
  • Explicit rakeFee: 0 still pays the full pool — the anchor behavior is pinned by test.

Verification (Phase 1)

  • Unit (game-server, 5/5): fee-split conservation property over structures × fee cases, including the 33.33/33.33/33.34 dust case and the exact "$22 pays $40 / house keeps $4" repro.
  • RTL (poker, 15/15): the registration screen's "20 + 2 tSKPK" split + fee-adjusted "40 tSKPK" pool (and never "44").
  • Cage e2e (rake-T2): a fee-less $22 sng created through the cage lands rakeFee: 2 in its settings row.
  • Test-profile integration (test/poker/sng-fee-split.test.ts): heads-up force-end pays a single position-1 payout of exactly 40 with the fee (and 44 with explicit fee 0) through the real orchestrator payout path.

Phases 2–4 (same day): the house actually collects

  • Phase 2 — program enforcement (devnet): numero-core::collect_rake(table_id, amount, rake_chain_head, deadline) replaces the accrue-then-drain counter. The cage signs an ed25519 receipt over table_id ‖ amount ‖ head ‖ destination ‖ deadline (the settle pattern — destination bound in the message, the F1 lesson); a rake_marker PDA seeded by the chain head makes each collection once-only on-chain (init fails on replay); the transfer is vault-capped. Wrong-signer / tampered-message / missing-verify-ix / expired-deadline / replayed-head all REFUSED with no funds moved (T14 suite; onchain 22/22).
  • Phase 3 — cage collection flows: rake-collect.ts runs the claim-first discipline (the EVM reconcile shape): walk the table's unclaimed player_id='house' rows in deterministic order → write the rake_reconciliations chain link (keccak256(prev ‖ tableIdHash ‖ unixMs ‖ handHash ‖ rake) — byte-format-identical to EVM, so rake is re-derivable from hand history end-to-end) → receipt-verified collect_rake → mark complete. A marker revert reconciles (never re-pays); a stuck claim resumes with the same head; the conservationHeadroom gate refuses any claim beyond vault − Σ player balances (E-RAKE-2 — player money is structurally uncollectable as rake). Tournament fees ride the same engine: when the last payout of an SnG confirms, the cage collects rakeFee × seats (kind='tournament_fee'), exactly once across re-dispatches. Destination is the HOUSE_DESTINATION_SOLANA env var (originally a game-setting; moved to env same-day — a per-deploy fact belongs in the deploy's env) — fail-loud when unset.
  • Phase 4 — surfaces + the conservation sweep: admin /rake is chain-aware — the cage grew EVM-parity reads (/settlement/unreconciled-rake, /reconciliation-history, a pending-rake stub) tagged chain: 'solana', and the page switches on the tag: per-table Collect buttons (the { tableId } dialect), the EVM accumulated-pool section hidden (Solana pays direct from the vault), history rows showing table + rake/fee kind. RakeTray + per-hand history rake at the poker table are locked in by RTL. And the E-RAKE-4 conservation sweep closes the last hole: a boot-started, TABLE_ENVIRONMENT-scoped sweep auto-collects unclaimed house value through the same claim-first engine — closed tables included (vaults are never deleted, so post-close collection works) — and raises an ERROR solana_admin.rake.conservation span naming each uncollectable table (destination unset, conservation divergence, stuck claim). No table concludes with the house's money silently stranded.

Phase 5 (same day): hardening — the pipeline was fund-safe but liveness-dead

A hardening pass found five ways the house didn't get paid while every no-steal invariant held, all fixed + regression-locked: the admin Collect button's {tableId}-only request was 400ing (route demanded destination); the fee-at-close was conservation_refused on every production tournament (the gate counted table_players CTN equity, which the payouts themselves distribute — the tournament_fee gate now measures UNCONFIRMED payout_intents, and game-server settles registrant rows as the tournament concludes); one transient collect failure wedged the whole global chain forever (claim-first marks rows, so a stuck claim was invisible to the unclaimed scan while 409-blocking every other table — the sweep now RESUMES stuck links first, both kinds); claim creation was raceable (per-table pg_advisory_xact_lock + guarded row-marking now serialize it); and vault-less EVM tables sharing the environment made the E-RAKE-4 alarm permanently red (now classified no_solana_vault, WARN).

Verification

  • On-chain: 22/22 (anchor suite incl. the T14 rejection matrix).
  • Cage e2e: 53/53 — claim-first collect / chain linkage / collect-twice-pays-once (T7/T8/T9), conservation refusal (T10), tournament fee exactly-once at close (rake-T6), the closed-table sweep (rake-T13), and the five falsification rows (T15–T19). The harness runs in a run-unique TABLE_ENVIRONMENT shard so sweeps can never touch live tables in the shared dev DB, and the harness cage gets its own run-scoped HOUSE_DESTINATION_SOLANA house wallet (no shared-DB settings row to clobber).
  • Test-profile integration 3/3 (the $22-pays-$40 repro, the fee-0 anchor, and T20: a closed tournament settles every registrant row).
  • Poker units 550/550 (incl. new RakeTray/HandHistory rake RTL); game-server units green; telemetry contracts green.
  • Live smoke: the local cage serves the chain-aware reads against real dev-DB data; the in-browser Collect smoke lands with the first CHAIN=solana admin deploy (the request shape is regression-locked by T15).

Operator note: set the house destination in each environment's cage configuration (and create its TSKPK ATA once) — collections fail loud (503 house_destination_unset) until it exists.

See the decision page and the Rake & Fees guide.