Skip to content

Chain of Custody Phase 4 — Zero Wallet Awareness in Game-Server

Date: 2026-04-17 Status: Implemented

Decision

Game-server has zero wallet awareness. Every signer address, every on-chain call, every permit-recovery step lives on admin-server (the money-only bank, now called evm-bank). Game-server exchanges only { userId, roomCode, seatIndex, amount, idempotencyKey } with admin-server and never sees a signer address.

This is the structural follow-through of the game-admin-alignment casino model: the cage holds the money, the table holds the chips, and now that boundary is enforced by static grep invariants in CI-style checks.

What Changed

Deposit and withdraw flows moved to UI ↔ admin-server direct (Option B)

Previously, the UI sent a permit through game-server (either via the sit_down WS message or a forwarded HTTP call), which in turn forwarded it to admin-server. That chain of custody put game-server in the permit-handling path even though game-server never needed signer data.

Now:

  • UI signs an ERC-2612 permit locally and POSTs to admin-server /api/deposit directly (JWT bearer auth, CORS allowlisted).
  • Admin-server ecrecovers the permit signer against the claimed signerAddress, validates the linkage via auth-server GET /api/internal/user-wallets/:userId, submits registerAndDeposit via the tx queue, and then calls game-server POST /internal/credit-chips on confirmation.
  • WS sit_down is seat-only: { type: 'sit_down', seatIndex }. Any legacy field triggers a sit_down_shape_changed error.
  • Stand-up is release-only: game-server broadcasts stood_up, and the UI hits admin-server /api/withdraw.
  • Withdrawal has two auth modes: voluntary (JWT + destinationWallet linkage check) and forced (service-key, bypasses linkage — used for sit-out timeout, escape, table-close).

Position-chain hash reshaped to userId bytes

The off-chain hash chain that anchors the on-chain settlement receipt used to encode the 20-byte deposit wallet. Now it encodes the 16-byte UUID of public.users.id. The contract's historyHash is opaque bytes32 — the algorithm change is invisible to it. Deposits from any wallet linked to the same user append to the same chain.

deposit:    keccak256(prevHash, 'deposit',  userId(bytes16), tableId, amount)
hand:       keccak256(prevHash, handHash)
withdrawal: keccak256(prevHash, 'withdraw', userId(bytes16), amount)
merge:      keccak256(prevHash, 'merge',    ...sortedSourceHashes)

JWT no longer carries walletAddress

The JWT payload issued by auth-server drops the walletAddress claim. Clients that need signer data fetch it via GET /api/wallets; they never read it off the JWT.

Schema columns dropped

Migration 0030_magenta_marrow.sql drops wallet_address from room_players, game_sessions, poker.hand_players, and wallet from position_events. Migration 0031_nebulous_garia.sql relaxes hosted_agents.owner_wallet and player_sessions.wallet to nullable (they retain legacy rows; game-server no longer writes to them).

Structural Invariants

Four greps in apps/game-server/src/ enforce the doctrine:

GrepPatternExpected
1literal wallet / Wallet0 matches in core modules
2: Hex / : Address identifier types0 matches
3non-hash-primitive viem imports0 matches in core modules
4@numero/contracts imports0 matches

Scope boundary: "core modules" excludes games/dealer/** (legacy variant, deferred to a dealer-adaptation follow-up), shared/reconciler/fold.ts (on-chain event watcher that architecturally belongs on admin-server, tracked under custody-layer), and __tests__/**. The hash primitives keccak256, encodePacked, toBytes, toHex, and hexToBytes are pure crypto utilities with no chain awareness and are allowed; all other viem exports are banned.

A new game-type author should run these four greps before opening a PR — see adding-a-game-type for the shell commands.

Deposit Sequence

Retrying the same idempotencyKey is a no-op — the admin-server reconciler re-submits orphaned deposit_intents rows whose credited_at is NULL on a 30-second interval, so a transient game-server outage during the callback doesn't orphan funds.

Tradeoffs

  • Hosted-agent / clawbot-runner adapters are out of scope. Agents still register via the legacy Ed25519 agentToken REST flow. A follow-up plan will move them onto the same admin-server /api/deposit + JWT path the UI uses.
  • Dealer (Numero card game) variant still has wallet-aware state. Out of scope for this plan; tracked as a dealer-adaptation follow-up.
  • Fold reconciler watches on-chain events from game-server. Architecturally should live on admin-server; tracked under the custody-layer follow-up.
  • Three extra HTTP hops per deposit (UI → admin, admin → auth-server linkage check, admin → game-server credit-chips). Each is a single in-cluster round-trip.

Follow-ups

  • custody-layer — off-pool locked/unlocked custody for emergency holds, forced panic withdrawal without the deposit wallet, and cheating freezes.
  • dealer-adaptation — port the dealer room onto the same deposit/withdraw architecture.
  • hosted-agent / clawbot-runner adaptation — move agent funding onto admin-server /api/deposit with a service-minted JWT.

Amendments

2026-04-20 — atomic-write pattern generalized to a boundary contract

The four-step atomic-write pattern this ADR established for the credit-chips path (ledger.deposit + tableBalance.recordDeposit + ledger.allocate/addToSeat + tableBalance.allocateChips, all synchronous, all in one call) now generalizes beyond credit-chips into a project-wide persistence-boundary contract on BaseTable.

The contract is two-sided: the framework owns BaseTable.persistAtBoundary(context) — a sync, fail-loud method that opens a Drizzle transaction, writes the universal table_players.chip_balance checkpoint, and invokes an abstract persistGameBoundary(tx, context) hook. Each game type owns (a) when to call it — its "lock-in moment" (poker = hand-end, arena = round-end + game-end, future turn-based games could be per-turn) — and (b) its replay schema (per-boundary JSONB in a game-specific {game}_{boundary}s.replay column).

Poker hand-end writes were migrated onto the contract shortly after (landed 2026-04-20); the Arena game type followed.

The generalization preserves the chain-of-custody-4 invariant that chip-state writes are atomic at the moment they cross the "ephemeral → durable" threshold. What changes is the scope: chain-of-custody-4 enforced atomicity at credit-chips; durable-table-state enforces it at every game-type lock-in moment.

Full ADR

The full ADR is retained in the project's internal planning archive.