Appearance
Poker OHH Integration — Hash Chain Anchored to Canonical Hand History
Date: 2026-04-29 → 2026-04-30 Status: Implemented
Decision
Every poker hand's handHash binds to the canonical JSON of its Open Hand History (OHH) document — keccak256(toBytes(toCanonicalJson(replay))) — not to a random per-hand identifier plus pot/rake totals.
This is the load-bearing fairness primitive for the Numero poker on-chain settlement model. The chain hashes per-position are committed in the EIP-712 settlement receipt at withdrawal time. A player must be able to fetch their on-chain receipt, replay their hand history off-chain from poker.hands.replay, and confirm the recomputed sessionHash matches what was committed. That round-trip is only meaningful if the per-hand contribution to the chain (hand_hash) actually binds to gameplay.
What Changed
handHash source (the load-bearing fix)
Previously, apps/game-server/src/games/poker/ledger.ts:writeHandEnd computed:
ts
const handHash = keccak256(
encodePacked(['string', 'uint256', 'uint256'], [handId, BigInt(potTotal), BigInt(result.rake)])
);Where handId was a random UUID per hand. The chain hashes resulting from this were internally consistent (each event's hash was a correct keccak256(prev, hand_hash)) but bound to nothing about the deal — an adversarial server could fabricate any sequence of cards / actions / pots and the chain would still verify.
Now:
ts
const handHash = ohhBuilder.computeHash();
const replay = ohhBuilder.build();ohhBuilder.computeHash() is keccak256(toBytes(toCanonicalJson(this.build()))) — the canonical hash of the OHH document itself. Same value writes into both poker.hands.handHash and position_events.hand_hash. Off-chain re-derivation reads poker.hands.replay, recomputes keccak256(toBytes(toCanonicalJson(replay))), and confirms it matches.
No fallback. If ohhBuilder is null at this point, writeHandEnd throws loud rather than restore the regression. Pre-fix garbage hash is dead code.
OhhBuilder restored verbatim from 8b5de20a^
The 218-line OhhBuilder accumulator was deleted during poker-consolidation Wave 1 (2026-04-27) and never ported to the canonical flow.ts. The fix was a literal git show 8b5de20a^:apps/game-server/src/games/poker/ohh-builder.ts > apps/game-server/src/games/poker/ohh-builder.ts. Single import drift — legacy PlayerInfo was reshaped during BaseTable generalization — handled with a 3-field local OhhBuilderPlayerInfo interface inside ohh-builder.ts itself.
Engine-truth chip flow capture
flow.ts handleAction originally passed client-supplied action.amount directly to ohhBuilder.addAction. The harness DSL (and most clients) sends {type: 'call'} with no amount field — engines fill in the call amount from forced-bet state. Result: every Call action in OHH recorded amount: 0.
Fix: engine.executeAction now captures chip flow BETWEEN table.actionTaken (chip-commit) and autoAdvance (showdown distribution), exposing it as result.chipFlow = { chipsContributed, isAllIn }. This is necessary because autoAdvance may run all the way to showdown and distribute pots, restoring the WINNER's stack to non-zero — reading seat.stack() after executeAction returns would give a post-distribution snapshot for all-in winners, making isAllIn falsely read false.
Per-pot rake threading
flow.ts handleHandEnd now passes room.table.lastHandRake().rakePerPot as the 4th argument to ohhBuilder.setPots. Without it, OhhBuilder defaults all rake to pots[0] even when side pots have their own rake — a silent footgun for multi-pot hands.
Live broadcast
hand_result WebSocket payload now carries an ohh: OhhData field (the inner envelope, not double-wrapped). Sourced from boundary.payload.handRow.replay.ohh since room.ohhBuilder has been reset to null by the time doFinalizeHandEnd runs. UI can render hand history live without a follow-up fetch.
/me/hands API contract
Endpoint at apps/game-server/src/index.ts:644 was returning hand.ohh = {ohh: OhhData} (double-enveloped). Cleaned to return inner OhhData directly so consumers can write hand.ohh.players instead of hand.ohh.ohh.players. No UI consumers existed in tree; safe contract change.
Test harness verify.ts re-derivation
The harness's verifyHashes was a tautology before this plan: it re-derived keccak256(prev, hand_hash) where hand_hash was read straight off the position_events row. Internally consistent, externally meaningless. Modified scripts/test-harness/verify.ts to look up the corresponding poker.hands.replay and recompute keccak256(toBytes(toCanonicalJson(replay))) for each chain event — the recomputed hash must match the stored value, or verifyHashes reports a divergence.
This is the canonical pattern for any future hash-binding test: re-derive from inputs, never trust the stored value.
Backfill / Disclosure
- 108 dev-DB hands post-2026-04-15 had
replay = '{}'and meaninglesshandHashvalues. - 0 hands pre-2026-04-15 (the legacy fallback fired sporadically when
ohhBuilderwas null in pre-Wave-1 code, but never actually fired in practice). - All 108 are pre-launch test/dev hands. No real-user impact, no retroactive remediation needed.
Why It Took A Second Pass
The original 5-phase plan landed clean: the full test trajectory green, verifyHashes calls in scenarios 01/08/11/16 went from tautological-green to real-green, full poker suite passing.
Then a falsification pass ran. Three more bugs surfaced — all in the integration (Phase 2 wiring), none in the restored OhhBuilder:
- Call action amount was 0 instead of the real chip flow.
is_allinwas false for call-all-ins (first-iteration fix using seat-delta-after-executeAction was wrong; needed engine-internal chipFlow capture).- Multi-pot rake was bunched onto
pots[0].
The chain still bound correctly to the OHH document — but the content of the document was incomplete. The value of the second pass: the author of a plan is the last person to notice the gaps in their own thinking.
References
- Predecessor: OHH Hand History Standard (the original hand-history ADR).
- Cross-cutting: an earlier poker consolidation deleted
OhhBuilder; this plan restored it.