Skip to content

Verify: wallet reads bypass the primary validator

Live since 2026-06-20 after the incident in CLAUDE.md "Wallet read paths — never hit the primary validator".

scan-rpc.testnet.sanect.com is a public CNAME (Cloudflare, DNS-only) pointing at the snapshot-publisher Railway service (archive node, PRUNING=nothing). The staking-app uses it for all heavy read-only RPC.

What should hit which RPC

MethodRPCWhy
eth_sendRawTransactionrpc.testnet.sanect.comTx broadcast needs a validator
personal_sign, eth_signTypedData_v4(no RPC — local MetaMask)Pure client signing
eth_getBalance (user's own SNCT)rpc.testnet.sanect.comMetaMask polls this on its own — out of our control
eth_getBalance (ShieldedPool TVL)scan-rpc.testnet.sanect.comOur code routes it via getReadProvider()
eth_call (pool view fns: commitmentCount, verifier)scan-rpc.testnet.sanect.comSame
eth_getLogs, eth_getBlockByNumber (memo scan fallback)scan-rpc.testnet.sanect.comSame
Memo indexer HTTP /api/shielded/memosscan.testnet.sanect.com (explorer-backend)Normal path; not RPC

How to verify after a staking-app deploy

  1. Open https://app.testnet.sanect.com in a fresh browser profile
  2. DevTools → Network tab → filter Fetch/XHR
  3. Connect MetaMask, click 🛡 ShieldedBalance
  4. Watch the table — within 30 seconds you should see:
    • Multiple rows to scan-rpc.testnet.sanect.com (TVL poll fires immediately, then every 30s)
    • One or two rows to scan.testnet.sanect.com/api/shielded/... (indexer status + memos)
    • Zero rows to rpc.testnet.sanect.com from the panel itself
  5. Click Make Private and approve a small shield amount
  6. Now you should see one eth_sendRawTransaction row to rpc.testnet.sanect.com — that's expected, it's the tx broadcast

If step 4 still shows rpc.testnet.sanect.com taking the read traffic, the env didn't bake into the build:

bash
# In the Railway build logs for the staking-app deploy, look for:
ENV NEXT_PUBLIC_SCAN_RPC_URL=https://scan-rpc.testnet.sanect.com
# Or grep the built bundle:
docker run --rm sanect-staking sh -c 'grep -r scan-rpc /app/.next/static | head -1'

If the env is not in the bundle, re-check the Railway build args (NOT just variables — must be added as Build Arg in the service settings so it's available at npm run build time, when the NEXT_PUBLIC_* envs get inlined).

Diagnosing the "red 200" pattern

If /api/shielded/indexer/status shows 200 OK but the row is red, it means the JS fetch threw AFTER headers arrived — usually AbortSignal.timeout firing mid-body on a cold Cloudflare connection. This was the silent trigger for the 2026-06-20 OOM (it flipped useIndexer=false, RPC fallback then scanned from block 0 and OOM-killed the primary).

PR #290 increased the timeout 5s → 12s and added a retry with 600 ms backoff. If you still see red 200s after deploy:

  1. Cloudflare cold-start latency: the explorer-backend's first request after idle can take 6–8 s. Hit https://scan.testnet.sanect.com/api/shielded/indexer/status directly in a browser tab first to warm the connection, then reload the staking app.
  2. Real backend slowness: curl -w "%{time_total}\n" -o /dev/null https://scan.testnet.sanect.com/api/shielded/indexer/status — should be < 1 s under load. If it's not, check the explorer-backend's Postgres query for the status endpoint.

When to update this doc

  • ShieldedPool address changes → update NEXT_PUBLIC_POOL_DEPLOY_BLOCK in Railway and bump CLAUDE.md
  • Archive node moved to a new host → update the Cloudflare CNAME for scan-rpc.testnet.sanect.com
  • Mainnet launch → same pattern applies (scan-rpc.sanect.com against the mainnet archive node)