Skip to content

Testing on Solana (the Solana test profile)

The docker test stack has two chain profiles. The EVM profile (default) runs a hardhat chain container + evm-bank. The Solana profile runs the beta's real chain — the 4 Anchor programs (numero_{token,core,custody,betting}) on a solana-test-validator + solana-bank (the cage) — so the Solana fund paths (deposit / withdraw / settle / betting / the chip-claim-board playSKPK grant) run end-to-end against real programs, not stubs.

Program IDs (post devnet-solana-deploy): numero-custody and numero-betting were rotated to fresh IDs (EXHhL2QU… / BfGbFyiT…) because their old configs+vaults were permanently tSKPK-bound singletons; numero-token and numero-core kept theirs. The test/e2e/solana-cage harness, Anchor.toml, scripts/{init-devnet,standup-local-validator}.ts, and the on-chain tests all carry the fresh IDs — the same IDs used on devnet + (later) mainnet. See The Solana Beta Chain.

Dual-economy testing (real-play-money-split)

The cage e2e harness (test/e2e/solana-cage/) and the local standup (pnpm solana:localnet) stand up both economies since real-play-money-split: init_mints + init_playskpk (beta), an external "canonical USDC" test mint (deterministic 0x77 keypair, init-authority-held — standing in for Circle's devnet faucet), init_real_backing binding it, and one custody init per economy (the default custody binds the DEPLOY chip; beta also inits the REAL custody at GiKdXReu…).

Two build artifacts are required before any cage-e2e run:

bash
cd onchain && anchor build --no-idl && bash scripts/build-real-custody.sh

(numero_custody_real.so is the byte-identical real-economy custody under its fresh declare_id — the harness refuses to start without it.)

Funding helpers changed with the backing: fundAndWrap now mints external USDC → wraps (the SKUSDC faucet has no path into the chip); TestUser.realTskpk is the REAL-chip ATA (equal to tskpk on tskpk-profile runs). The dual-economy rows live in test/e2e/solana-cage/real-split.test.ts (run with CHIP_TOKEN=playskpk); skpk_enabled rows are written AT THE RUN's environment shard (h.environment) — never 'all' (shared dev DB).

Why the validator runs on the host (not a container)

EVM's hardhat chain runs in a container. Solana's does not — a deliberate, platform-driven exception:

  • The only Anza validator image (anzaxyz/agave) is linux/amd64-only. On Apple Silicon it runs under emulation, and the emulated solana-test-validator fails to become RPC-ready in 120s+ (a native one boots in ~5s). A consensus node under emulation is too slow and flaky for a test stack people run repeatedly.
  • Anza does not reliably publish native aarch64-linux releases.
  • The host already has a native arm64 validator, and the dev flow (pnpm solana:localnet) + the test/e2e/solana-cage harness already run it there.

So the Solana profile runs the validator on the host and points the containerized cage + game-server at host.docker.internal:8899. Everything else (the cage, game-server, auth-server, postgres) stays containerized, exactly like the EVM profile.

Trade-off: the host validator is a singleton on 127.0.0.1:8899, so the Solana profile is one-stack-at-a-time (not per-worktree-isolated like the EVM hardhat container) until the validator's --rpc-port is parameterized by TEST_PREFIX (a planned follow-up). The EVM profile stays fully per-worktree. Because it's a shared singleton, test:up:solana refuses to start if another worktree's solana-bank is already running (both would silently share + corrupt the one chain) — bring the other down first, or SOLANA_SHARE_OK=1 to override.

The EVM → Solana mapping

EVM profileSolana profile
hardhat container (local chain)host solana-test-validator (native arm64, 4 programs loaded)
chain-deploy one-shot (deploy diamond → DB)the same host step — pnpm solana:localnet inits playSKPK/custody/betting + writes the cage keypairs
evm-bank + game-server CHAIN=evmsolana-bank (CHIP_TOKEN=playskpk) + game-server CHAIN=solana

Chain + deploy: pnpm solana:localnet

scripts/standup-local-validator.ts (pnpm solana:localnet) is the Solana analog of EVM's chain container and its deploy one-shot in one host step. With CHIP_TOKEN=playskpk (beta mode) it:

  1. Spawns solana-test-validator --reset --quiet with all 4 programs loaded (--bpf-program <id> <so> from onchain/target/deploy/).
  2. Inits playSKPK (grant authority = the cage) + custody (cage/arbiter/admin) + betting — the real-money init_mints/wrap path is absent.
  3. Writes the fixed-seed authority keypairs (cage / faucet / init) to .solana-localnet/ (gitignored), which the cage mounts.
  4. Stays alive (Ctrl-C to stop).

Prerequisite: build the programs once — export PATH="$HOME/.local/share/solana/install/active_release/bin:$HOME/.cargo/bin:$PATH" && cd onchain && anchor build --no-idl → the 4 .so (gitignored build artifacts).

Import-time env baseline: the standup imports the cage's chain/ builders, and solana-bank/src/env.ts asserts its full env contract at module load — so the standup must run with the cage's required vars present (they're not dereferenced; mirrors test/e2e/solana-cage/_env-placeholders.ts). The test:up:solana switch provides these.

The cage: solana-bank (test fragment)

apps/solana-bank/docker-compose.test.yml runs the cage in the stack, mirroring evm-bank's fragment with three Solana-specific twists:

  • profiles: ["solana"] — opt-in. The default pnpm test:up (EVM) skips it entirely; only --profile solana brings it up, so the EVM suites are unaffected.
  • extra_hosts: ["host.docker.internal:host-gateway"] — so the container reaches the host validator at host.docker.internal:8899.
  • volumes: [./.solana-localnet:/app/.solana-localnet:ro] — the cage mounts the authority keypairs the standup wrote.

apps/solana-bank/.env.test (committed, non-secret) points the cage at the host validator and matches the shared stack values (SERVICE_KEY, DATABASE_URL, AUTH_*, GAME_SERVER_INTERNAL_URL) from evm-bank/.env.test:

CHIP_TOKEN=playskpk
SOLANA_RPC_URL=http://host.docker.internal:8899
CAGE_KEYPAIR_PATH=/app/.solana-localnet/cage.json      # + faucet.json, init.json
NODE_ENV=test                                          # REQUIRED with ENABLE_TEST_AUTH

Gotcha: ENABLE_TEST_AUTH=true requires NODE_ENV (development|test) — the fail-closed test-auth gate. Without it the cage crash-loops at boot (assertTestAuthSafety). Every test-profile service that enables test auth must set NODE_ENV=test.

A booted cage answers a service-key grant end-to-end against the real chain:

POST /api/grant  { recipient, amount: 500, claimId }   x-service-key
→ { ok: true, idempotent: false, txHash: "…" }         # minted 500 playSKPK
# retry with the same claimId:
→ { ok: true, idempotent: true,  txHash: null }         # no double-mint (on-chain grant_marker)

Running the Solana profile

bash
# 1. Start the host validator (separate terminal — stays alive, Ctrl-C to stop):
CHIP_TOKEN=playskpk pnpm solana:localnet

# 2. Bring up the stack on the Solana profile:
pnpm test:up:solana        # = TEST_CHAIN=solana bash scripts/test-stack.sh up
pnpm test:down:solana

TEST_CHAIN=solana makes scripts/test-stack.sh:

  • layer in docker-compose.test.solana.yml (game-server environment: { CHAIN: solana, SOLANA_BANK_INTERNAL_URL: http://solana-bank:3668 }, which wins over the CHAIN=evm env_file) + --profile solana (brings up solana-bank),
  • fail fast if the host validator isn't ready — it checks readiness + ownership, not just liveness: the validator must answer and have numero_core loaded (a bare solana-test-validator with no programs is rejected, so you don't discover it at the first grant), and no other worktree's solana-bank may be running (see the shared-singleton note above),
  • otherwise behave exactly like pnpm test:up (per-worktree name, port allocation — but see the one-stack-at-a-time caveat above).

The default pnpm test:up is unchangedTEST_CHAIN defaults to evm, so the EVM stack is byte-for-byte what it was (game-server CHAIN=evm, no solana-bank).

One CHAIN per game-server: a single game-server serves one chain, so the EVM and Solana profiles can't share a game-server — they're separate profiles selected by TEST_CHAIN. (The beta is Solana-only; CHAIN=evm is the legacy path.)

First consumer

The Solana profile is proven ready (validator + cage + game-server on CHAIN=solana, all healthy, with a real cage grant end-to-end). The first game-server-orchestrated fund-path proof is chip-claim-board Phase 3 (the playSKPK claim grant: game-server → cage → chain), which runs its A2/A6/A8/A11/A15 on this profile.