Appearance
Solana Programs (on-chain layer)
The Solana on-chain layer — the port of the EVM diamond's live surface (smart-contracts.md) to Anchor programs, at complete parity, as several separate programs behind the same cage interface. Built incrementally: P1 tokens (this page's first section) → P2 settlement/core → P3 custody → P4 betting. Workspace:
solana-migration/onchain/(Anchor 0.31.1 + Agave 4.0,--no-idl).
Token layer — numero-token (Phase 1, built)
Mirrors the EVM two-token economy (SKPKUSDC + TSKPK):
- SKUSDC — the test USDC-equivalent stablecoin SPL mint (6-dec). An admin-gated
faucetmints it (the grant-demo on-ramp). Mint authority = the program'smint_authPDA. - TSKPK — the chip SPL mint (6-dec), a 1:1 USDC-backed WETH-style wrapper over SKUSDC:
wrap(amount)— pullsamountSKUSDC from the user into the reserve PDA, mintsamountTSKPK to the user.unwrap(amount)— burnsamountTSKPK from the user, returnsamountSKUSDC from the reserve.- Fully backed (every TSKPK is 1 SKUSDC in the reserve); no fees / rebasing.
The table vault (Phase 2) holds TSKPK. Demo on-ramp: faucet SKUSDC → wrap to TSKPK → deposit → play → cash out (unwrap back to SKUSDC).
Accounts (PDAs)
| PDA | Seed | Role |
|---|---|---|
skusdc_mint | [b"skusdc"] | the SKUSDC mint (authority = mint_auth) |
tskpk_mint | [b"tskpk"] | the TSKPK mint (authority = mint_auth) |
reserve | [b"reserve"] | SKUSDC token account backing TSKPK (authority = mint_auth) |
mint_auth | [b"mint_auth"] | the single PDA that owns the mints + reserve and signs all CPIs |
TokenConfig | [b"config"] | stores the faucet_authority (the admin allowed to mint via the faucet) |
Instructions
| Instruction | Auth | Does | Event |
|---|---|---|---|
init_mints(faucet_authority) | payer | one-time: create the two mints + reserve + TokenConfig | — |
faucet(amount) | admin signs | mint SKUSDC to a recipient token account | FaucetMinted |
set_faucet_authority(new) | admin signs | rotate the faucet authority | FaucetAuthorityUpdated |
wrap(amount) | user signs | SKUSDC user → reserve, mint TSKPK 1:1 | Wrapped |
unwrap(amount) | user signs | burn TSKPK, SKUSDC reserve → user | Unwrapped |
The faucet gate (mirrors the EVM server-submitted faucet)
The faucet is admin-gated: only the configured faucet_authority (a signer, checked against TokenConfig) may mint — an unauthorized caller is rejected (NotFaucetAuthority). This is the on-chain half of "control how often people get test funds": the eligibility policy (invite codes, social-media rewards, per-wallet / rate limits during the pre-go-live strategy) lives off-chain in the cage, exactly like the EVM bank faucet — the chain just enforces that the admin authorized the mint. set_faucet_authority lets ops rotate / revoke the faucet key without redeploying.
Observability is via Anchor events (emit!) — the on-chain surface the cage + indexers consume, mirroring the EVM token events.
Verified
T1 (A1–A3) + A1b pass on solana-test-validator: the admin faucet credits SKUSDC and a non-admin faucet is rejected; wrap debits SKUSDC and mints TSKPK 1:1 with the reserve holding the backing; unwrap reverses it exactly (raw-web3.js test, no IDL).
Settlement/core — numero-core (Phase 2, built)
The grant-critical milestone — mirrors the EVM TableFacet + SettlementFacet live core, productionizing the proven spike (spike/receipt-spike/, 9/9). Verified T2–T7 (10/10 with T1) on solana-test-validator.
Accounts (per-table PDAs)
| PDA | Seed | Role |
|---|---|---|
Table | [b"table", table_id] | config (cage, tskpk_mint), accumulated_rake, total_deposited; the vault authority |
vault | [b"vault", table_id] | the TSKPK token account holding the pooled chips (authority = Table) |
Position | [b"position", table_id, user] | the keccak current_hash (the per-user position chain) |
delegate | [b"delegate"] | the program's SPL delegate — the user approves it on their TSKPK ATA |
Instructions
| Instruction | Auth | Does | Event |
|---|---|---|---|
init_table(table_id, cage) | payer | create the Table + TSKPK vault | — |
deposit(table_id, amount, hand_hash) | cage signs (the gate) | delegated transfer user → vault; append keccak(prev ‖ user ‖ table_id ‖ amount ‖ hand_hash) | Deposited |
settle(table_id, amount_out, rake, history_hash, deadline) | cage ed25519 receipt | introspect the Ed25519 verify-ix (signer == cage, message == receipt); Clock deadline; bind history-hash; pay out from vault; accrue rake; advance chain | Settled |
withdraw(table_id, amount) | cage signs | pay out from the vault | Withdrawn |
collect_rake(table_id) | cage signs | drain accrued rake from the vault to the house | RakeCollected |
The cage gate (mirrors EVM depositFor/withdrawFor)
deposit/withdraw require the cage as a signer — only cage-validated, cage-submitted txs land (the cage checks buy-in limits + wallet-linkage off-chain first). The user's authorization is an SPL approve/delegate (the ERC-2612-permit-equivalent): the user pre-approves the delegate PDA on their TSKPK ATA, and the program does the delegated transfer. The cage never holds the user's keys.
The settlement receipt (no ecrecover)
Solana has no ecrecover, so settle verifies the cage's signature via the Ed25519 native program + instructions-sysvar introspection (the spike's proven pattern): the tx carries an Ed25519 verify-ix; the program reads the instructions sysvar, finds it, and binds signer == the cage AND message == the receipt it reconstructs (table_id ‖ recipient ‖ amount_out ‖ rake ‖ history_hash ‖ deadline). A valid signature over a different message, or by a different signer, cannot authorize the payout. The four fund-safety rejections (wrong-signer / tampered-message / missing-verify-ix / expired-deadline) are tested.
Destination binding (F1). Because settle is signature-gated (anyone may submit a valid receipt), the payout destination is bound to the receipt identity: the recipient token account must be owned by recipient_owner (token::authority = recipient_owner). Without this a captured receipt could be re-submitted with a swapped destination to redirect the payout. The other receipt-gated payouts (custody redeem/release_disputed, betting claim) bind the destination token-account pubkey directly in the signed message. Symmetrically, the cage-gated deposit binds its source account (token::authority = user) so a position credit can't be mis-attributed.
Conservation
No payout (settle / withdraw / collect_rake) can exceed the vault balance — the Solana analog of the EVM debitPool underflow revert.
Initialization is deploy-gated (F2)
Every program's init_* instruction (init_mints, init_table, init_custody, init_betting) requires a hardcoded INIT_AUTHORITY as a signer — only the deploy authority can initialize, which blocks an attacker from front-running the deploy to seize the config authorities (faucet authority, custody signer/arbiter/admin, betting signer/cage) or squat table ids. The constant is currently the test deploy key; set it to the production deploy key before mainnet (a documented one-line swap per program). The custody init additionally requires signer != arbiter so the dispute dual-sig can never collapse to a single key.
Custody — numero-custody (Phase 3, built)
Mirrors the EVM CustodyFacet + LibCustody. A table disposition (escape / idle-kick) hands a player's stack to custody as a FREE per-user claim; funds leave custody only on a signed redeem or a dual-sig release_disputed. Claims are keyed by user_id (16 bytes, public.users.id) so ANY currently-linked wallet redeems — the lost-keys property. Verified T8–T10 (13/13 with T1–T7) on solana-test-validator.
Accounts (PDAs)
| PDA | Seed | Role |
|---|---|---|
CustodyConfig | [b"config"] | the cage signer (claim/release receipts), the arbiter (dispute co-sign), the admin (dispute), tskpk_mint; the custody-vault authority |
custody_vault | [b"custody_vault"] | one shared TSKPK token account holding all escrowed claims (authority = CustodyConfig) |
CustodyEntry | [b"entry", table_id, user_id] | one claim: amount, final_hash, origin, disputed, redeemed, dispute_nonce |
Instructions
| Instruction | Auth | Does | Event |
|---|---|---|---|
init_custody(signer, arbiter, admin) | payer | create CustodyConfig + custody_vault | — |
move_to_custody(table_id, user_id, amount, final_hash, origin) | cage signs | CPI core release_to_custody_vault (table-vault → custody-vault), then write a FREE claim | CustodyEntryCreated |
redeem(table_id, user_id, amount, deadline) | cage ed25519 receipt | introspect the Ed25519 verify-ix (signer == cage, message == table_id ‖ user_id ‖ destination ‖ amount ‖ deadline); entry-capped; redeemed-once; conservation; Clock deadline; pay out | CustodyRedeemed |
set_disputed(table_id, user_id, disputed) | admin signs | flip FREE ⇄ DISPUTED; bump dispute_nonce on each transition into DISPUTED | CustodyDisputed |
release_disputed(table_id, user_id, amount, deadline) | cage + arbiter ed25519 | two sibling verify-ixs (cage at offset 2, arbiter at offset 1) over a dispute_nonce-bound message; pay out a DISPUTED entry | CustodyReleased |
Escape vs idle-kick is the origin tag + the off-chain follow-up: escape = the cage calls move_to_custody once per player (origin = ESCAPE) then closes the table; idle-kick = a single call (origin = IDLE_KICK) and the table stays open (only that player's stack is debited, every other seat untouched). Conservation is enforced inside the core seam (it refuses to move more than the table vault holds).
The escape CPI seam (core → custody)
The only core↔custody coupling. Custody's move_to_custody does the token move + the claim-write as one atomic instruction, so there is no "drained vault, no claim" window even from a buggy cage:
The cage signs the outer tx; its signature propagates through invoke into the core seam, which re-checks cage == table.cage and the conservation cap and signs the transfer as the Table PDA.
The dual-sig release (dispute)
A DISPUTED entry is unredeemable through redeem (E-CUST-3). release_disputed requires BOTH the cage and the arbiter ed25519 signatures over a release message bound to the entry's current dispute_nonce — neither alone releases, and a stale arbiter signature from a prior dispute epoch fails after an un-dispute → re-dispute (the nonce bump). Mirrors the EVM releaseDisputed + its stale-signature fix.
Conservation + the escape parity note
No redeem / release_disputed payout can exceed the custody vault. Parity note: unlike the EVM diamond's commingled internal ledger (where escape moves no ERC-20), Solana custody owns its own vault, so escape physically moves TSKPK table-vault → custody-vault — the funds never leave the protocol until a claim. The EVM "no token move at escape" sub-clause of A17 is a storage detail that doesn't translate; the meaningful invariants (redeemable claims, debit conservation, redeemed-once, dispute dual-sig) hold identically.
Betting — numero-betting (Phase 4, built)
On-chain money for prediction markets on SnG tournaments (mirrors the EVM BettingFacet + LibBetting). Prediction markets are in development and currently disabled. One pool per per-tournament market; per-bettor shares live off-chain — the chain holds only the pool total + a single-use claim guard. Verified T11–T12 + the admin-withdraw test (16/16 with T1–T10) on solana-test-validator.
Accounts (PDAs)
| PDA | Seed | Role |
|---|---|---|
BettingConfig | [b"config"] | the signer (claim-receipt key, = the custody signer), the cage (gate), tskpk_mint |
Market | [b"market", market_id] | table_id provenance, cage, state (None/Open/Settling/Settled/Escaped); the vault authority |
market_vault | [b"market_vault", market_id] | the TSKPK pool (authority = Market) — its balance IS the pool |
delegate | [b"delegate"] | the program's SPL delegate — the bettor approves it on their TSKPK ATA |
ClaimMarker | [b"claim", market_id, claim_id] | per-claim single-use guard (init fails on replay) |
Instructions
| Instruction | Auth | Does | Event |
|---|---|---|---|
init_betting(signer, cage) | payer | create BettingConfig | — |
create_market(market_id, table_id) | cage signs | create the Market (Open) + market vault | MarketCreated |
deposit(market_id, amount) | cage signs (the gate) | delegated transfer bettor → market vault | BetDeposited |
claim(market_id, claim_id, amount, deadline) | betting ed25519 receipt | introspect the verify-ix (signer == betting signer, message == market_id ‖ claim_id ‖ dest ‖ amount ‖ deadline); pool-capped; single-use (ClaimMarker init); chain-time deadline; pay out | BetClaimed |
withdraw(market_id, amount) | cage signs | reclaim UNBET funds from the pool to a recipient (the cash-game-table model) | BetWithdrawn |
release_market_to_custody_vault(market_id, amount) | cage signs | the freeze → custody seam: market vault → custody vault | MarketEscaped |
set_market_state(market_id, state) | cage signs | lifecycle bookkeeping (Open → Settling → Settled/Escaped) | MarketStateChanged |
Claim, deposit, conservation
deposit mirrors the core/poker deposit: cage-gated, the bettor's authorization is an SPL approve/delegate (no bettor gas). claim mirrors the settle receipt — a betting-server ed25519 signature, verified by introspecting the sibling Ed25519 verify-ix (signer + message binding), and the pool is the hard cap (a market can never pay out more than it holds, whatever the receipt says — the Solana analog of the EVM debitPool underflow). The claim_id marker PDA makes each payout single-use (a replay re-init fails).
Withdraw — reclaiming unbet funds
These markets are entry-only: once staked, funds back outcome shares and are untouchable until settlement (no secondary market — conservation depends on the pool staying whole). But funds deposited and not yet staked are reclaimable at any point via withdraw, a cage-gated, pool-capped admin withdrawal exactly like a cash-game table's numero-core::withdraw. The cage authorizes the amount (it knows each bettor's unbet balance off-chain); on-chain conservation is the pool cap. This is a reclaim of uncommitted deposits, not a position cash-out — the entry-only model is unchanged.
Freeze → custody (the betting → custody seam)
A frozen market hands its pool to per-bettor FREE custody claims, redeemed through the verbatim custody path (like a table escape). The seam lives on the custody side: numero-custody.move_market_to_custody CPIs numero-betting.release_market_to_custody_vault (market vault → custody vault) and writes the FREE entry — one atomic instruction. Custody entries are keyed by a cage-assigned unique entry_id (the Solana analog of EVM's global nextEntryId), so a table escape and a market freeze for the same numeric id never collide; a frozen-market bettor then redeems via numero-custody.redeem exactly like an escaped player.
The on-chain layer is now at complete parity with the live EVM core across all four programs (tokens · settlement/core · custody · betting). The off-chain solana-bank cage consumes this instruction interface next; devnet deployment of the four .so is the one deferred operational step.
Local Solana chain (dev loop)
A persistent local validator for developing + smoking the Solana path (the EVM side has Arb/Base Sepolia; this is the Solana equivalent for local dev):
sh
# The Solana toolchain isn't on the default PATH — prepend it first:
export PATH="$HOME/.local/share/solana/install/active_release/bin:$PATH"
pnpm solana:localnet # solana-migration/scripts/standup-local-validator.tssolana:localnet spawns solana-test-validator (--reset, stays up — Ctrl-C to stop) with the four programs loaded at their fixed declare_id IDs, then airdrops + runs init_mints / init_custody / init_betting (the same recipe the e2e harness uses): init authority seed 0x55, cage 0x11, arbiter 0x22, faucet 0x44, admin = cage. On standup it writes the cage keypairs to .solana-localnet/ (gitignored) and prints the env to point the cage at it:
SOLANA_RPC_URL=http://127.0.0.1:8899
CAGE_KEYPAIR_PATH=…/.solana-localnet/cage.json
FAUCET_AUTHORITY_KEYPAIR_PATH=…/.solana-localnet/faucet.json
INIT_AUTHORITY_KEYPAIR_PATH=…/.solana-localnet/init.jsonRun the cage with those env values (+ a DATABASE_URL + AUTH_JWKS_URL for the browser JWT path) and it talks to this chain. The program IDs are identical on localnet / devnet / mainnet (fixed declare_id!), so only SOLANA_RPC_URL + the authority keypairs change between clusters.
Devnet / mainnet deployment of the four
.so+ the go-live runbook (incl. the pre-mainnetINIT_AUTHORITYswap off the seed-0x55test key) are the remaining steps.