Appearance
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:
| Area | Live entry points | Caller |
|---|---|---|
| Tokens | SKPKUSDC.mint; TSKPK.deposit/depositWithPermit/withdraw (1:1 USDC-backed wrapper) | evm-bank / Engine |
| Deposit | TableFacet.registerAndDeposit (permit, admin-only) | evm-bank (25 callers) |
| Settle | SettlementFacet.settle (EIP-712 game-signer receipt) | evm-bank |
| Withdraw | SettlementFacet.withdrawFor (server-signer only) | evm-bank (25 callers) |
| Rake | WithdrawalFacet.settleHouseRake + collectRakeFor | evm-bank |
| Table lifecycle | TableFacet.registerTable / closeTable | evm-bank |
| Custody (Phase 2) | CustodyFacet.escapeToCustody / moveToCustody / redeem / setDisputed / releaseDisputed | evm-bank |
| Betting (Phase 2) | BettingFacet.createMarket / deposit / claim / escapeMarketToCustody | evm-bank |
| Admin/config | AdminFacet: registerGame, pause, addAdminSigner, addSupportedToken, setServerSigner, setRakeCollector, setGameSigner | owner / admin |
Deployed but apparently NOT used (no callers found in apps/, scripts/, packages/):
| Contract / function | Status | Evidence |
|---|---|---|
FairnessFacet (commitSeed, revealSeed, startTimeoutClaim, executeTimeoutClaim) | unused — commit-reveal seed + timeout-claim machinery | 0 app callers |
TransferFacet.transfer | inert — operates on the deprecated per-player balances mapping | deprecated path |
PokerFacet / NumeroFacet (depositToPoker/depositToNumero, per-game deposits) | deprecated — superseded by the game-agnostic registerAndDeposit → SettlementFacet.settle | 1 / 0 callers |
AdminFacet.emergencyWithdraw | dead — reads the deprecated per-player balances (never written by the pooled path) | 0 callers |
AdminFacet.tableEscape | superseded — 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 + registerAndDeposit | legacy |
LibTable.balances (per-player mapping) + creditBalance/debitBalance/getBalance | deprecated — the pooled model uses LibTable.pools | by 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).
NumeroCardRoomDiamondis a proxy; facets are added viadiamondCut(owner-only) and share storage via fixedkeccak256-keyed slots defined in theLib*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 naivesum(pools)overstates true balance by collected-but-unpaid rake — a known gotcha).
Tokens (the two-token economy)
SKPKUSDC— "Mock USDC (Skillspoker)", symbolSKPK-USDC, 6 decimals. The testnet stand-in for real USDC and the reserve/backing asset.mint(to, amount)isMINTER_ROLE-gated (the Engine wallet; faucet rate-limiting is off-chain). ERC-2612 permit. This is the on-ramp asset.TSKPK— "Test Skillspoker Chip", symboltSKPK, 6 decimals. A WETH9-style 1:1 wrapper whoseunderlyingis 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 withunderlying = 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), creditsLibTable.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 tomsg.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) — recordsclosedTables+ accumulatestotalRake.- Views:
verifyReceipt,getNonce,getSettledPnL,domainSeparator.
WithdrawalFacet — rake
settleHouseRake(receipt, signature)— game-signer receipt naming the rake collector; bumps theaccumulatedRake[gameId][token]counter (no payout).historyHashcommits to the ordered rake chain.collectRakeFor(gameId, token, destination)(game-signer) /collectRake(gameId, token)(rake collector) — drainaccumulatedRaketo the collector. Paid from the diamond's raw token balance, notpools.
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 acustodySignerEIP-712CustodyClaimReceipt; 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 currentdisputeNonceto 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 acustodySignerBettingClaimReceipt; single-useclaimId; 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, withnonce > storedreplay protection (SettlementFacet + WithdrawalFacet). historyHashis 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 againstcustodySigner/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
balancesmapping,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 sharedmint_authPDA. Created byinit_playskpk(grant_authority)(INIT_AUTHORITY-gated), independent ofinit_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_authoritystored inPlaySkpkConfig, rotatable viaset_grant_authority) and idempotent perclaim_idvia agrant_markerPDA seeded by[b"grant_marker", claim_id](initfails on replay — the same once-only guard asrake_marker). EmitsGranted { recipient, amount, claim_id }. This is the on-chain leg the chip-claim-board calls; theclaim_idthreads 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 ID — 9azX3QdAmwfqP4JEkvGVs5e4pnYxaGusyyg4XC8jkE93 (cargo feature real-economy swaps only declare_id; build via onchain/scripts/build-real-custody.sh → numero_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.