Skip to content

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 CustodyClaimReceiptredeem 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

IDGuards
E-CUST-1Hand-off conserves, single pool debit, atomic, idempotent, 0-balance skipped, SnG uses CTN equity
E-CUST-2A claim can't exceed the entry; redeemed exactly once
E-CUST-3DISPUTED is a contract-state gate; released only by dual-sig
E-CUST-4Every claim/release carries the finalHash + terminates the chain
E-CUST-5FREE redeem reuses the table-withdrawal trust (any linked wallet)
E-CUST-6Deadlines derive from chain time, never wall clock
E-CUST-7escape (full-close) vs idle_kick (partial, stays-open) are distinct
E-CUST-8System 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.