Appearance
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, andcomputePayoutAmountsdistributes 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/tableson an sng without an explicitrakeFeederivesfloor(entryFee × tournamentFeeRate / (1 + rate))from the global game-setting (default 10%) — a fee-less $22 tournament now recordsrakeFee: 2and advertises a $40 pool. Thesolana_admin.tables.createspan carriestable.entry_fee/table.rake_fee. - Explicit
rakeFee: 0still 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 landsrakeFee: 2in 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 overtable_id ‖ amount ‖ head ‖ destination ‖ deadline(the settle pattern — destination bound in the message, the F1 lesson); arake_markerPDA seeded by the chain head makes each collection once-only on-chain (initfails 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.tsruns the claim-first discipline (the EVM reconcile shape): walk the table's unclaimedplayer_id='house'rows in deterministic order → write therake_reconciliationschain link (keccak256(prev ‖ tableIdHash ‖ unixMs ‖ handHash ‖ rake)— byte-format-identical to EVM, so rake is re-derivable from hand history end-to-end) → receipt-verifiedcollect_rake→ mark complete. A marker revert reconciles (never re-pays); a stuck claim resumes with the same head; theconservationHeadroomgate refuses any claim beyondvault − Σ 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 collectsrakeFee × seats(kind='tournament_fee'), exactly once across re-dispatches. Destination is theHOUSE_DESTINATION_SOLANAenv 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
/rakeis chain-aware — the cage grew EVM-parity reads (/settlement/unreconciled-rake,/reconciliation-history, apending-rakestub) taggedchain: '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 ERRORsolana_admin.rake.conservationspan 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 (
anchorsuite 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_ENVIRONMENTshard so sweeps can never touch live tables in the shared dev DB, and the harness cage gets its own run-scopedHOUSE_DESTINATION_SOLANAhouse 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=solanaadmin 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.