Appearance
Solana Bank (the off-chain cage)
The service is
apps/solana-bank(@numero/solana-bank); game-server reaches it via the CHAIN-selectedBANK_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
| Concern | EVM bank | Solana 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 signing | EIP-712 signTypedData | ed25519 message the program introspects (Ed25519 native program + instructions sysvar) |
| Deadlines | block timestamp | the chain Clock (read via chainDeadline(), never wall time) |
| Deposit auth | one ERC-2612 permit signature | two-step: the user signs an SPL approve to the program delegate, then the cage submits deposit |
| Wallet | linked EVM wallet | bring-your-own Solana wallet (Phantom) linked via Privy SIWS (embedded wallets deferred) |
| Deployment record | per-deploy contract addresses in the DB | fixed program IDs + deterministic PDAs (just cluster/RPC) |
Route map (EVM ↔ Solana) — built per phase
| Route | Solana on-chain leg | Phase |
|---|---|---|
POST /api/tables | core::init_table | P2 |
| faucet | token::faucet (admin-gated) | P2 |
POST /api/deposit | SPL approve (user) → core::deposit (cage) | P2 |
POST /api/withdraw | core::withdraw | P2 |
/settlement/settle | ed25519 receipt → core::settle | P2 |
| rake collect | core::collect_rake | P2 |
/escape/:tableId, forced→custody | custody::move_to_custody (core→custody seam) | P3 |
/api/custody/claim | ed25519 receipt → custody::redeem | P3 |
/admin/custody/{dispute,release} | custody::set_disputed / dual-sig release_disputed | P3 |
| betting market / deposit / claim / freeze / withdraw | betting::{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/tablesDB row, and the auth-server Solana-linkage endpoint are Phase 2b — they couple the cage to Postgres (thedeposit_intents.userIdFK →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.
| Route | On-chain | Notes |
|---|---|---|
POST /escape/:tableId | move_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/dispute | set_disputed | cage 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.
| Route | On-chain | Notes |
|---|---|---|
POST /api/betting/market | create_market | cage-gated; one pool per market |
POST /api/betting/deposit | betting::deposit | bettor 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/settle | freeze → 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/withdraw | betting::withdraw | cage 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/tablesDB row, the auth-server Solana-linkage endpoint, and the custody conservation reconciler. All couple the cage to Postgres (thedeposit_intents.userIdFK →users) and need a migratednumero_test+ seeded UUID users in the harness.