Appearance
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/depositdirectly (JWT bearer auth, CORS allowlisted). - Admin-server ecrecovers the permit signer against the claimed
signerAddress, validates the linkage via auth-serverGET /api/internal/user-wallets/:userId, submitsregisterAndDepositvia the tx queue, and then calls game-serverPOST /internal/credit-chipson confirmation. - WS
sit_downis seat-only:{ type: 'sit_down', seatIndex }. Any legacy field triggers asit_down_shape_changederror. - 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:
| Grep | Pattern | Expected |
|---|---|---|
| 1 | literal wallet / Wallet | 0 matches in core modules |
| 2 | : Hex / : Address identifier types | 0 matches |
| 3 | non-hash-primitive viem imports | 0 matches in core modules |
| 4 | @numero/contracts imports | 0 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/depositwith 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.