Skip to content

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 meaningless handHash values.
  • 0 hands pre-2026-04-15 (the legacy fallback fired sporadically when ohhBuilder was 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_allin was 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.