Appearance
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:
entryFeeis the total a player pays;rakeFeeis the house's take per entry. Prize pool =(entryFee − rakeFee) × seats— computed bytournamentPrizePool()inapps/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.computePayoutAmountsdistributes with basis-point floors + dust-to-first-position so payouts + fee == entries, exactly (E-RAKE-1). Both cages deriverakeFee = 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 topoker.hand_playersand 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_markerPDA seeded by the chain head makes each collection once-only on-chain (initfails 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'sHOUSE_DESTINATION_SOLANAenv var (Doppler-managed per environment — the Solana counterpart of EVM'sRAKE_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 × seatsthrough 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 unconfirmedpayout_intents(zero once the all-confirmed gate passes) — nottable_playersequity, 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_playersrow as the tournament concludes (payout confirmed → that rowgone/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:
- 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_feekinds — a stuck fee has no other retrigger once all payouts confirmed). - Collect unclaimed house value — oldest table first, closed tables included (vaults are never deleted).
- 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 ERRORsolana_admin.rake.conservationspan withrake.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
| Setting | Where | Meaning |
|---|---|---|
tournamentFeeRate | game_settings (global) | Fee derivation rate when rakeFee omitted at table-create (default 0.1) |
HOUSE_DESTINATION_SOLANA | env (Doppler, per environment) | The house wallet collections pay to — fail-loud when unset; its TSKPK ATA must exist |
rakeFee | tables.settings (sng) | Per-entry house take; explicit 0 = feeless tournament |
rakePercent / rakeCap / rakeMinPot | tables.settings (cash) | Per-hand engine rake policy |
Invariants (E-RAKE-1..4)
- Prize payouts + house fee == total entries, exactly.
- Collections bounded by the live player claim (cage: cash →
vault − Σ table_players balances; tournament fees →vault − Σ unconfirmed payouts) AND the vault (program). - House value claims exactly once (claim-first row + on-chain marker; re-dispatch reconciles).
- No table concludes with unclaimed house value stranded (close-guard/conservation sweep).