Skip to content

Smart Contract Architecture (current EVM)

Current-state reference of the on-chain layer as deployed on Base Sepolia: every contract, what it does, and — critically — what is actually live vs. deprecated/unused. It was also the input to the Solana port, where only the live surface gets ported. Function-level inventory verified against the Solidity in packages/contracts/contracts/; live/dead classification verified against the actual callers (the EVM bank owns all on-chain writes).

TL;DR — what's actually used

The system is an EIP-2535 Diamond (NumeroCardRoomDiamond) with 10 facets, but a large fraction of the surface is legacy. The live on-chain layer is:

AreaLive entry pointsCaller
TokensSKPKUSDC.mint; TSKPK.deposit/depositWithPermit/withdraw (1:1 USDC-backed wrapper)evm-bank / Engine
DepositTableFacet.registerAndDeposit (permit, admin-only)evm-bank (25 callers)
SettleSettlementFacet.settle (EIP-712 game-signer receipt)evm-bank
WithdrawSettlementFacet.withdrawFor (server-signer only)evm-bank (25 callers)
RakeWithdrawalFacet.settleHouseRake + collectRakeForevm-bank
Table lifecycleTableFacet.registerTable / closeTableevm-bank
Custody (Phase 2)CustodyFacet.escapeToCustody / moveToCustody / redeem / setDisputed / releaseDisputedevm-bank
Betting (Phase 2)BettingFacet.createMarket / deposit / claim / escapeMarketToCustodyevm-bank
Admin/configAdminFacet: registerGame, pause, addAdminSigner, addSupportedToken, setServerSigner, setRakeCollector, setGameSignerowner / admin

Deployed but apparently NOT used (no callers found in apps/, scripts/, packages/):

Contract / functionStatusEvidence
FairnessFacet (commitSeed, revealSeed, startTimeoutClaim, executeTimeoutClaim)unused — commit-reveal seed + timeout-claim machinery0 app callers
TransferFacet.transferinert — operates on the deprecated per-player balances mappingdeprecated path
PokerFacet / NumeroFacet (depositToPoker/depositToNumero, per-game deposits)deprecated — superseded by the game-agnostic registerAndDepositSettlementFacet.settle1 / 0 callers
AdminFacet.emergencyWithdrawdead — reads the deprecated per-player balances (never written by the pooled path)0 callers
AdminFacet.tableEscapesuperseded — escape now routes through CustodyFacet.escapeToCustody (escape→custody)custody-layer Phase 2
ChitinToken (CTN, zero-decimal chip + ERC-1363 transferAndCall deposit)gone — replaced by tSKPK + registerAndDepositlegacy
LibTable.balances (per-player mapping) + creditBalance/debitBalance/getBalancedeprecated — the pooled model uses LibTable.poolsby design

The takeaway for the migration: port the lean core (tokens + deposit + settle + withdraw + rake), plus custody + betting as separate Phase-2 programs. Do not port the game-specific deposit facets, TransferFacet, FairnessFacet, ChitinToken, or the per-player balances path.

Architecture

  • Diamond (EIP-2535). NumeroCardRoomDiamond is a proxy; facets are added via diamondCut (owner-only) and share storage via fixed keccak256-keyed slots defined in the Lib* libraries. The diamond is the single on-chain "cage."
  • Casino-model boundary. The chain holds only pooled balances per table/market and acts as the conservation guard; the off-chain evm-bank + game-server are sovereign over per-player accounting. game-server is chain-unaware; evm-bank submits every on-chain write.
  • Pooled balances + shared-storage coupling. The canonical pool is LibTable.pools[tableId][token]. The deposit facet credits it; three different facets debit it for the three exit dispositions — normal withdrawal (SettlementFacet.withdrawFor), escape-to-custody (CustodyFacet.escapeToCustody/moveToCustody), and the legacy direct escape (AdminFacet.tableEscape). These facets are coupled through that one shared mapping — the reason a diamond (shared storage) was used. Betting has a separate pool (LibBetting.pools[marketId][token]); rake is a separate counter (LibSettlement.accumulatedRake) paid from the diamond's raw token balance (so a naive sum(pools)overstates true balance by collected-but-unpaid rake — a known gotcha).

Tokens (the two-token economy)

  • SKPKUSDC — "Mock USDC (Skillspoker)", symbol SKPK-USDC, 6 decimals. The testnet stand-in for real USDC and the reserve/backing asset. mint(to, amount) is MINTER_ROLE-gated (the Engine wallet; faucet rate-limiting is off-chain). ERC-2612 permit. This is the on-ramp asset.
  • TSKPK — "Test Skillspoker Chip", symbol tSKPK, 6 decimals. A WETH9-style 1:1 wrapper whose underlying is SKPK-USDC: deposit(amount) pulls SKPK-USDC and mints tSKPK 1:1; depositWithPermit(...) does it in one tx via a permit on the underlying; withdraw(amount) burns tSKPK and returns SKPK-USDC. Fully USDC-backed, no fees/rebasing. The diamond's pool pulls tSKPK. Mainnet plan: redeploy the same wrapper with underlying = real USDC (a one-address change).

So the economy is: faucet SKPK-USDC → wrap into tSKPK → deposit tSKPK into the table pool → play → withdraw tSKPK → unwrap to SKPK-USDC. (This is exactly the SKUSDC→TSKPK model the Solana plan mirrors.)

Live facets

TableFacet — deposit entry points

  • registerAndDeposit(player, tableId, token, amount, deadline, v,r,s)the primary production deposit/sit-down, onlyAdmin. Idempotently registers the table, consumes the player's ERC-2612 permit, safeTransferFrom(player→diamond), credits LibTable.pools. Player pays no gas (signs only the permit); evm-bank submits.
  • depositFor / depositForDirect — deposit variants to an existing table (permit / pre-approved), onlyAdmin.
  • registerTable(tableId, token) / closeTable(tableId) (requires pool == 0) — table lifecycle, onlyAdmin.
  • Views: tablePoolBalance(tableId, token).

SettlementFacet — settle + withdraw

  • settle(receipt, signature) — applies a hand result; verifies an EIP-712 game-signer receipt (see below) with a monotonic nonce replay guard; updates PnL + rake counters. Auth = the signature.
  • withdrawFor(wallet, player, tableId, amount, historyHash)the production withdrawal, gated to msg.sender == serverSigner. debitPool + safeTransfer. (game-server owns the per-player split; the chain just releases pooled funds.)
  • settleAndWithdraw(receipt, signature, amount) — player-initiated variant (receipt.player == msg.sender).
  • closeTable(...) (game-signer) — records closedTables + accumulates totalRake.
  • Views: verifyReceipt, getNonce, getSettledPnL, domainSeparator.

WithdrawalFacet — rake

  • settleHouseRake(receipt, signature) — game-signer receipt naming the rake collector; bumps the accumulatedRake[gameId][token] counter (no payout). historyHash commits to the ordered rake chain.
  • collectRakeFor(gameId, token, destination) (game-signer) / collectRake(gameId, token) (rake collector) — drain accumulatedRake to the collector. Paid from the diamond's raw token balance, not pools.

CustodyFacet — Phase-2 (the custody layer)

Table dispositions hand the pool to per-user FREE custody claims as an internal ledger move (no transfer at hand-off); funds leave only via signed redemption. Keyed by bytes16 userId so any linked wallet redeems (the lost-keys fix).

  • escapeToCustody(tableId, userIds[], amounts[], finalHashes[], token, idempotencyKey) — full-pool escape → FREE entries, onlyAdmin. moveToCustody(...) — partial-pool idle-kick (table stays open).
  • redeem(entryId, destination, amount, deadline, serverSig) — FREE payout; verifies a custodySigner EIP-712 CustodyClaimReceipt; entry caps the amount; redeemed-once guard. Linkage checked off-chain.
  • setDisputed (admin) + releaseDisputed(... serverSig, arbiterSig) — DISPUTED holds released only by server + arbiter dual-sig (binds the current disputeNonce to stop stale-sig replay).

BettingFacet — Phase-2 (prediction markets, in development)

Prediction markets are in development and currently disabled; the facet is deployed but not live. One pool per market; deposits via permit (admin-submitted); payouts via server-signed claim capped at the pool (never overpays); freeze hands the pool to custody.

  • createMarket / deposit (permit, onlyAdmin) / claim(... sig) (verifies a custodySignerBettingClaimReceipt; single-use claimId; pool cap) / escapeMarketToCustody(...) (freeze → FREE custody entries, reuses LibCustody). Token = tSKPK.

AdminFacet — config + admin

pause/unpause, addAdminSigner, addSupportedToken, setServerSigner, setRakeCollector, registerGame/setGameSigner/setGameActive, plus the superseded tableEscape (direct pool→wallet) and the dead emergencyWithdraw. Owner/admin gated.

The EIP-712 settlement receipt

solidity
SettlementReceipt(
  address player, int256 cumulativePnL, uint256 cumulativeRake, uint256 nonce,
  bytes32 tableId, bytes32 gameId, address token, bytes32 historyHash
)

Domain: EIP712Domain(name="NumeroCardRoom", version="1", chainId, verifyingContract=diamond).

  • Signed by the per-game gameSigner (LibGame.games[gameId].gameSigner) off-chain.
  • Verified on-chain via ECDSA.recover(...) == gameSigner, with nonce > stored replay protection (SettlementFacet + WithdrawalFacet).
  • historyHash is the position-chain keccak head (sessionHash) — carried as the off-chain withdrawal/audit proof, not recomputed on-chain. (Custody/betting reuse the same domain separator with their own typehashes, verified against custodySigner / arbiter.)

Relevance to the Solana migration

  • Port (grant-critical solana-programs): the two tokens (SKUSDC ≙ SKPK-USDC, TSKPK ≙ tSKPK wrapper), registerAndDeposit (cage-gated deposit), settle (ed25519 receipt — the Solana analog of the EIP-712 game-signer receipt), withdrawFor, rake, table lifecycle, and the position hash-chain (historyHash).
  • Phase-2 separate programs: custody and betting (their own state/vaults; CPI seams to the core).
  • Do NOT port: PokerFacet/NumeroFacet (use the game-agnostic settle path), TransferFacet, FairnessFacet (unused commit-reveal/timeout), ChitinToken, the per-player balances mapping, emergencyWithdraw, tableEscape (superseded by escape→custody).

playSKPK — the play-money economy (beta)

The PMF beta runs on playSKPK, a perpetual play-money token — NOT the USDC-backed two-token model above. Added to numero-token:

  • playSKPK mint — 6-dec, no backing, seed b"playskpk", under the shared mint_auth PDA. Created by init_playskpk(grant_authority) (INIT_AUTHORITY-gated), independent of init_mints — the beta inits ONLY playSKPK (SKUSDC/TSKPK stay uninited).
  • grant(amount, claim_id) — the cage mints playSKPK directly (no faucet, no wrap). Grant-authority-gated (grant_authority stored in PlaySkpkConfig, rotatable via set_grant_authority) and idempotent per claim_id via a grant_marker PDA seeded by [b"grant_marker", claim_id] (init fails on replay — the same once-only guard as rake_marker). Emits Granted { recipient, amount, claim_id }. This is the on-chain leg the chip-claim-board calls; the claim_id threads game-server → cage → grant.
  • playSKPK has no redeem-to-value path (no wrap/unwrap) — it's play money.

The real chip's backing switched to EXTERNAL canonical USDC (real-play-money-split, 2026-07-19). wrap/unwrap no longer touch the self-minted SKUSDC: init_real_backing(usdc_mint) (INIT_AUTHORITY-gated, once-only) binds a configured external USDC mint (Circle devnet USDC on staging; mainnet USDC at launch) plus a real_reserve token account of that mint under mint_auth; wrap pulls the user's USDC into the reserve and mints TSKPK 1:1, unwrap burns and returns — with the backing mint address-bound to real_config.usdc_mint (substitution reverts, WrongBackingMint) and the unwrap destination bound to the burner (user_usdc.authority == user). SKUSDC + the faucet remain compiled for legacy validators but have no path into the chip. Tests: onchain/tests/real-backing.ts (T11 buy/cash-out + T12 adversarial conservation); the shared test funding preamble is tests/_external-usdc.ts (mint external USDC → wrap), replacing faucet→SKUSDC→wrap.

One custody deployment per ECONOMY (same plan): custody vaults bind one mint for life, so the real (tSKPK) economy runs a byte-identical numero-custody under a fresh program ID9azX3QdAmwfqP4JEkvGVs5e4pnYxaGusyyg4XC8jkE93 (cargo feature real-economy swaps only declare_id; build via onchain/scripts/build-real-custody.shnumero_custody_real.so; keypair at onchain/keys/numero_custody_real-keypair.json). The play deployment (EXHhL2QU…, playSKPK-bound) is untouched; the cage routes custody calls by the table's money-kind.

core/custody/betting are mint-generic and run on playSKPK by init/wiring (their chip-mint field is playskpk_mint on the play deployments).

The cage grant flow

solana-bank exposes POST /api/grant — the off-chain leg the chip-claim-board's game-server engine calls. It is service-key only (it mints money; a browser JWT must never reach it — the eligibility policy lives in the claim board, the cage just mints what it's told). Body { recipient (wallet), amount (chips), claimId (32-byte hex) }; the cage is the chip↔base-unit boundary (chipsToBaseUnits). The cage keypair is the grant authority (signs the mint); keys().feePayer pays fees + the marker/ATA rent. Idempotency is on-chain: the game-server-supplied claimId seeds the grant_marker PDA, so a replay reverts "already in use" and the route returns { ok, idempotent: true } (nothing minted) rather than an error — the same no-double-mint floor as the payout/rake markers.

The cage's chip mint is CHIP_TOKEN-parameterized (playskpk | tskpk, unset → tskpk, captured at module load = one mint per deploy) via a chipMint() resolver: the whole fund path (table/custody/betting vaults + every player chip ATA) resolves through it, while initMints/wrap stay tskpk (real-money-intrinsic). Beta = CHIP_TOKEN=playskpk: init_playskpk runs instead of init_mints, so the SKUSDC mint + faucet config never exist — the real-money faucet/wrap surface is structurally absent, not merely unused. Behavior-preserving when unset (tskpk). The deliberate retirement of this coexistence is the playskpk-full-migration plan.

Solana rake model

The house is an ordinary account at the ledger level: per-hand rake lives in the DB house rows (poker.hand_players, player_id='house') and every collection chains into rake_reconciliations (each link binds the collected handHashes — rake is re-derivable from hand history). On-chain, numero-core's rebuilt collect_rake(table_id, amount, rake_chain_head, deadline) releases vault funds only on a cage ed25519 receipt binding (table_id ‖ amount ‖ rake_chain_head ‖ destination ‖ deadline), and each rake-chain link collects exactly once via a rake_marker PDA seeded by the head (init fails on replay — the cage reconciles, never re-pays). The old accrued-rake counter is retired (settle no longer bumps it; the field remains in the Table layout as dead space to avoid an account migration). This is deliberately stronger than the EVM design, which verifies the accrual receipt but has no on-chain once-only guard on the drain.