Appearance
Custody Layer
Status: shipped in eight phases (a final hardening pass closed three fund-safety gaps — see below). Nine decisions in total, with eight fund-safety invariants (E-CUST-1..8).
What it is
A table disposition (escape, idle_kick) no longer pays players directly. It records everything and hands the pool to custody — a FREE/DISPUTED holding state on the diamond — and distribution becomes a separate, calmer mode. This closes three problems the player-funds lifecycle audit surfaced: escape paid players with no history anchor, coupled a table's conclusion to N transfers all succeeding, and had no answer for contested funds — plus the lost-keys risk (funds stranded behind a depositing wallet the player no longer controls).
How it works
CustodyFacet is a new facet on the existing EIP-2535 diamond, sharing pool storage via LibCustody. Because it shares storage, a table→custody hand-off is an internal ledger move (pool → custody claim), not a token transfer — atomic and transfer-failure-proof (no per-player safeTransfer can wedge the table's conclusion).
Claims are keyed by bytes16 userId (not wallet), so any currently-linked wallet can redeem — the lost-keys fix.
Dispositions (D4)
- escape (
escapeToCustody): the ENTIRE pool → per-user FREE claims; the table closes (E-CUST-7). Used for frozen tables + the idle reaper's funded-abandoned path. - idle_kick (
moveToCustody): ONE player's stack → a FREE claim; a partial pool debit; every other seat untouched and the table stays open. Used for sit-out timeout, hosted-agent auto-stand-up, stale-seat reconciler.
The off-chain routing for both lives in admin-server (the EVM bank/cage, now called evm-bank); game-server stays chain-unaware. resolveForcedWithdrawDestination returns kind:'custody' (its designed swap went live), so the forced-withdraw path routes to custody.
The FREE claim (D3) is the verbatim table-withdrawal
POST /api/custody/claim (JWT) runs the identical trust model as a table cash-out: JWT identity → wallet-linkage check (any linked wallet, E-CUST-5) → server-signed EIP-712 CustodyClaimReceipt → redeem via the tx-queue → durable withdrawal_intents row (source:'custody') → terminal position-chain custody_claim event. To the player it is indistinguishable from standing up and withdrawing. Deadlines derive from chain time, never Date.now() (E-CUST-6).
DISPUTED is a hard contract-state hold (D3, D7)
setDisputed marks an entry unredeemable by the FREE path (the claim route + the contract both gate on state). Release requires releaseDisputed with BOTH the server signature AND a designated arbiter signature — no single actor releases a hold (multi-sig-shaped; swap the single arbiter for governance later). The release memorializes identically to a FREE redeem, so no dispute leaves a dangling chain.
SnG (D6)
For tournament tables, the hand-off amount is chip_balance (CTN equity, real money), NEVER the engine's tournament-chip stack — the two unit systems are never conflated.
Conservation (D9)
A 5-minute reconciler is the system-wide silent-leakage guard (E-CUST-8). It reconciles against the diamond's actual chip-token balance (ground truth):
sum(deposited) == diamond.balanceOf(chipToken) + sum(paid-out)
+ sum(rake-collected)Custody-held funds are inside diamond.balanceOf (custody is an internal ledger move). A later hardening pass found that the original sum(tablePoolBalance) model was broken by rake: collectRakeFor drains the separate accumulatedRake counter via safeTransfer without debiting tablePoolBalance, so the pool sum overstated the diamond's real holdings by the collected rake and read a conserving system as a leak. Reconciling against balanceOf + an explicit rake-collected term (sum of confirmed rake_reconciliations.total_rake) is robust. On-demand via GET /admin/custody/conservation. See the conservation-alarm runbook.
Release-signature freshness
A CustodyReleaseReceipt is bound to the entry's current disputeNonce, which increments on each transition INTO DISPUTED. So an arbiter signature from a prior dispute epoch (entry un-disputed then re-disputed) fails verification — a stale arbiter approval can't be replayed on a re-disputed entry.
Redeem reconciliation
If markCustodyRedeemed fails after the on-chain redeem confirms, a 60-second reconciler detects the entry (getCustodyEntry().redeemedAt > 0 but DB redeemed_at IS NULL, found via the still-submitted source:'custody' intent) and finishes the durable write — the entry isn't stuck claimable and conservation stops overcounting custody.
Fund-safety invariants
| ID | Guards |
|---|---|
| E-CUST-1 | Hand-off conserves, single pool debit, atomic, idempotent, 0-balance skipped, SnG uses CTN equity |
| E-CUST-2 | A claim can't exceed the entry; redeemed exactly once |
| E-CUST-3 | DISPUTED is a contract-state gate; released only by dual-sig |
| E-CUST-4 | Every claim/release carries the finalHash + terminates the chain |
| E-CUST-5 | FREE redeem reuses the table-withdrawal trust (any linked wallet) |
| E-CUST-6 | Deadlines derive from chain time, never wall clock |
| E-CUST-7 | escape (full-close) vs idle_kick (partial, stays-open) are distinct |
| E-CUST-8 | System conservation: deposited == diamond.balanceOf + paid-out + rake-collected |
Each has greppable Enforces E-CUST-N enforcement comments and a custody.* span carrying its canary attributes.