Skip to content

Solana Bank (the off-chain cage)

The service is apps/solana-bank (@numero/solana-bank); game-server reaches it via the CHAIN-selected BANK_URL.

Status: in progress. Phase 1 (scaffold) is built — the service, the chain-client, the ed25519 receipt-signer, and the out-of-process e2e harness. The fund routes land per phase (P2 core loop → P3 custody → P4 betting).

solana-bank is the Solana counterpart of the EVM bank — the "cage" that gates + submits on-chain fund operations and signs the receipts the programs verify. It mirrors the EVM bank's HTTP interface byte-for-byte on the request/response surface, so game-server stays chain-unaware: you select the chain by pointing BANK_URL at the EVM cage or this one. The on-chain leg of every route targets the Solana programs instead of the EVM diamond.

How it differs from the EVM cage

ConcernEVM bankSolana cage
Submission@numero/tx-queue → Thirdweb Engine (nonce pool)@solana/web3.js blockhash → confirm → retry-on-expiry (no Engine — Solana has no sequential nonces)
Receipt signingEIP-712 signTypedDataed25519 message the program introspects (Ed25519 native program + instructions sysvar)
Deadlinesblock timestampthe chain Clock (read via chainDeadline(), never wall time)
Deposit authone ERC-2612 permit signaturetwo-step: the user signs an SPL approve to the program delegate, then the cage submits deposit
Walletlinked EVM walletbring-your-own Solana wallet (Phantom) linked via Privy SIWS (embedded wallets deferred)
Deployment recordper-deploy contract addresses in the DBfixed program IDs + deterministic PDAs (just cluster/RPC)

Route map (EVM ↔ Solana) — built per phase

RouteSolana on-chain legPhase
POST /api/tablescore::init_tableP2
faucettoken::faucet (admin-gated)P2
POST /api/depositSPL approve (user) → core::deposit (cage)P2
POST /api/withdrawcore::withdrawP2
/settlement/settleed25519 receipt → core::settleP2
rake collectcore::collect_rakeP2
/escape/:tableId, forced→custodycustody::move_to_custody (core→custody seam)P3
/api/custody/claimed25519 receipt → custody::redeemP3
/admin/custody/{dispute,release}custody::set_disputed / dual-sig release_disputedP3
betting market / deposit / claim / freeze / withdrawbetting::{create_market,deposit,claim,release_market_to_custody_vault,withdraw}P4

Submission + signing model

The chain-client (src/chain/) is the translation layer: instructions.ts builds every program instruction (lifted from the tested on-chain harness), submit.ts sends with blockhash-expiry retry, and receipt-signer.ts builds the ed25519 receipt messages (byte-matching the programs) + the verify instruction. Routes compose [ed25519VerifyIx, programIx] for the signature-gated paths (settle / custody redeem + release / betting claim) and submit cage-signed transactions for the gated paths (deposit / withdraw / escape / etc.). Durable intents + reconcilers (reused from @numero/db) make a dropped on-chain leg recoverable exactly once.

Core loop (built — Phase 2)

Faucet, deposit, withdraw, settle, and rake are live and proven end-to-end through the cage's HTTP surface against the four programs on a local validator (the out-of-process e2e harness). Each route is service-key authed and emits a solana_admin.* span.

Two-step BYO-wallet deposit

Settle (ed25519 receipt)

chips → base-units (× 10⁶) crosses at the contract boundary (lib/chips.ts); submission is blockhash → confirm → retry (@solana/web3.js, no Engine). Durability (durable deposit_intents

  • reconciler — exactly-once on a dropped credit-notify), the /api/tables DB row, and the auth-server Solana-linkage endpoint are Phase 2b — they couple the cage to Postgres (the deposit_intents.userId FK → users) and are tracked separately; the core loop above is proven without them.

Custody (built — Phase 3)

Table dispositions hand funds to per-user custody claims instead of paying wallets directly; a claim is redeemed later (by any linked wallet — the lost-keys property) via the verbatim table-withdrawal path. Each flow is proven on-chain through the cage (no Postgres). Claims are keyed by a cage-assigned entry_id so table escapes and (future) betting freezes share one redeemable namespace.

RouteOn-chainNotes
POST /escape/:tableIdmove_to_custody per player (core→custody CPI seam)snapshot from game-server; debits the table vault; memorializes the Position head as finalHash; closes the table
POST /api/custody/claim[ed25519_verify, redeem]destination-linkage check; entry-capped; redeemed-once; refuses a DISPUTED entry
POST /admin/custody/disputeset_disputedcage acts as the custody admin; bumps dispute_nonce
POST /admin/custody/release[ed25519_verify(cage), ed25519_verify(arbiter), release_disputed]dual-sig: cage co-signs + the arbiter's out-of-band detached ed25519 (bound to the on-chain arbiter) — neither alone releases

Durable claim-intents + the conservation reconciler (/admin/custody/conservation) are Phase 3b — the same Postgres pass as the core-loop intents.

Betting (built — Phase 4)

Prediction markets are in development and currently disabled. The betting-server stays the chain-unaware off-chain brain; the cage owns every on-chain leg. Proven on-chain through the cage (no Postgres). Escapes and freezes share one custody entry_id namespace.

RouteOn-chainNotes
POST /api/betting/marketcreate_marketcage-gated; one pool per market
POST /api/betting/depositbetting::depositbettor pre-approves the betting delegate; pool = vault balance
POST /api/betting/claim[ed25519_verify, claim]per-(market,user) claimId; pool-capped + single-use (claim_marker PDA)
POST /api/betting/settlefreeze → move_market_to_custody (per bettor)winner → off-chain record; freeze → FREE custody claims (betting→custody seam), redeemed via the custody path
POST /api/betting/withdrawbetting::withdrawcage reclaims UNBET funds (pool-capped)

Durable betting intents + reconcilers are Phase 3b (the shared Postgres pass).

What's left

  • Phase 5 — point a game-server at the cage via BANK_URL (drop-in smoke, T6) + the response-shape parity sweep against the EVM bank schemas (T18).
  • Phase 2b/3b (one Postgres pass) — durable deposit_intents/withdrawal_intents/claim/betting intents + reconcilers (exactly-once, T7), the /api/tables DB row, the auth-server Solana-linkage endpoint, and the custody conservation reconciler. All couple the cage to Postgres (the deposit_intents.userId FK → users) and need a migrated numero_test + seeded UUID users in the harness.