Skip to Content
GuidesSandbox safety

Sandbox safety

This is the standalone safety reference for TxNod’s sandbox surface. It covers per-chain format safety, the seven-layer defense, three concrete failure scenarios with the exact error message text an integrator sees, and the recommended CI assertion.

For the project-creation flow, read Sandbox projects. For the agent-driven testing loop, read Agent-driven testing.

Per-chain truth table

For 4 of the 15 supported assets, the chain itself rejects a sandbox→mainnet send because the address format differs between mainnet and testnet. For the remaining 11 assets — every EVM-family chain plus the TRON family — the address format is identical between mainnet and testnet, so format protection is impossible and safety relies on the structural layers below.

ChainMainnet formatSandbox/testnet formatMainnet wallet rejects send?Mechanism
Bitcoinbc1q... (bech32 mainnet HRP) / 1... / 3...tb1q... (bech32 testnet HRP) / m... / n... / 2...YesBech32 HRP byte differs; legacy address version byte differs
Ethereum (ETH + ERC-20 USDT/USDC)0x... 20-byte hex0x... 20-byte hexNoIdentical address format; safety via chain id at signing time
Polygon PoS (POL + Polygon USDT/USDC)0x... 20-byte hex0x... 20-byte hexNoIdentical address format; safety via chain id
BNB Smart Chain (BNB + BEP-20 USDT/USDC)0x... 20-byte hex0x... 20-byte hexNoIdentical address format; safety via chain id
TRON (TRX + TRC-20 USDT)T... (base58check, 0x41 leading byte)T... (base58check, 0x41 leading byte — testnet uses same byte)NoIdentical address format; testnet differentiation is endpoint-only
Cardanoaddr1... (mainnet NetworkId)addr_test1... (testnet NetworkId)YesAddress-level NetworkId byte differs
TON (Toncoin)EQ... / UQ... (mainnet workchain prefix)kQ... / 0Q... (testnet workchain prefix)YesAddress prefix byte encodes workchain + bounceable bit
TON (jetton USDT-TON)EQ... / UQ... mainnetkQ... / 0Q... testnetYesSame workchain-prefix mechanism

Coverage line: 4 of 15 assets format-safe (BTC, ADA, TON, USDT-TON); 11 assets — every EVM-family + TRON family — cannot be format-protected.

Layered defenses

The seven layers below catch the 11 non-format-safe assets and double-cover the 4 format-safe ones. Each layer is independent — bypassing one does not bypass the others.

Layer 1 — API-secret prefix

Every sandbox secret begins with sk_sandbox_. Production secrets do not. Anyone — human or agent — can grep their .env to verify they match the deployed NODE_ENV:

if (process.env.TXNOD_API_SECRET?.startsWith('sk_sandbox_')) { console.log('[boot] running with sandbox API secret'); } else { console.log('[boot] running with production API secret'); } export {};

Layer 2 — SDK constructor hard-fail

When the SDK detects NODE_ENV === 'production' (or the explicit environment: 'production' constructor option) AND the configured secret starts with sk_sandbox_, instantiation throws TxnodSandboxKeyInProductionError:

import { TxnodClient, TxnodSandboxKeyInProductionError } from '@txnod/sdk'; try { const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); } catch (err) { if (err instanceof TxnodSandboxKeyInProductionError) { throw new Error('refusing to boot with sandbox secret in production'); } throw err; }

The override (iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true) exists for legitimate staging-replica setups. It does not suppress the non-suppressible console.error on every constructor invocation, and every outbound API request carries an X-Txnod-Client-Environment: production header so server-side telemetry surfaces the misuse.

Layer 3 — SDK xpub-prefix guard

When TXNOD_<chain>_XPUB configured for a production project starts with a testnet prefix (tpub, vpub, upub), the SDK throws TxnodSandboxXpubInProductionError at boot:

import { TxnodClient, TxnodSandboxXpubInProductionError } from '@txnod/sdk'; try { const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); // createInvoice() triggers the xpub-prefix verification when TXNOD_<chain>_XPUB is set await client.createInvoice({ external_id: 'order-1', coin: 'btc', amount_usd: 5, }); } catch (err) { if (err instanceof TxnodSandboxXpubInProductionError) { throw new Error('refusing to verify against testnet xpub in production'); } throw err; }

EVM-family xpub prefixes are valid on both mainnet and testnet, so this layer only catches BTC-family testnet prefixes. The other 11 assets are caught by layer 4 and layer 5.

Layer 4 — Server cross-mode rejection

A sandbox API secret targeting a production project (or a production secret targeting a sandbox project) is rejected at the server with error_code: sandbox_key_against_production_project or production_key_against_sandbox_project. The SDK throws typed errors:

import { TxnodClient, TxnodSandboxKeyAgainstProductionProjectError, TxnodProductionKeyAgainstSandboxProjectError, } from '@txnod/sdk'; declare const client: TxnodClient; declare const productionProjectId: string; try { await client.sandbox.reset(productionProjectId); } catch (err) { if (err instanceof TxnodSandboxKeyAgainstProductionProjectError) { throw new Error('refusing: sandbox key cannot target production project'); } if (err instanceof TxnodProductionKeyAgainstSandboxProjectError) { throw new Error('refusing: production key cannot target sandbox project'); } throw err; }

The cross-mode check fires only on the four explicit-projectId endpoints ({projectId}/clock/advance, {projectId}/reset, DELETE {projectId}, {projectId}/wallets) and on every project-scoped MCP tool. The 10 invoice-scoped simulate endpoints have an implicit project_id (derived from the API key), so the cross-mode check is tautological there and does not fire.

Layer 5 — Webhook envelope mode

Every outbound webhook carries mode: 'production' | 'testnet' | 'sandbox'. Integrators assert this in their handler so a sandbox event cannot leak into production (and so a testnet event is never confused with a production one):

import { verifyWebhookSignature } from '@txnod/sdk'; export async function POST(request: Request): Promise<Response> { const rawBody = await request.text(); const event = verifyWebhookSignature( request.headers, rawBody, process.env.TXNOD_WEBHOOK_SECRET!, ); if (event.mode === 'sandbox' && process.env.NODE_ENV === 'production') { throw new Error('refusing sandbox event in production'); } // ...handler logic... return Response.json({ ok: true }); }

When event.mode === 'sandbox', see Sandbox safety for the recommended assertion.

Layer 6 — Documentation / CI assertion

The recommended CI assertion (below) is reproduced byte-exact across the SDK-bundled 05-sandbox.md, this guide, and every example app’s tests/setup.ts. For the env-mismatch threat model (sandbox secret deployed against production NODE_ENV, or vice versa) it’s the load-bearing line of defense: it catches the misconfiguration at test boot rather than at deploy time, even if every earlier layer is bypassed.

Layer 7 — MCP tool cross-tenant guard

Every one of the 14 sandbox MCP tools (sandbox_simulate_*, sandbox_clock_advance, sandbox_reset, sandbox_delete, sandbox_list_wallets) calls assertSandboxProjectKind(projectId, { ownerUserId }) at the top of its handler. The helper runs a single SELECT against the project row and asserts four invariants in one shot:

  1. The project row exists.
  2. It is not soft-deleted (deleted_at IS NULL).
  3. Its kind is 'sandbox' (not 'production').
  4. Its owner_user_id matches the calling PAT’s ownerUserId.

Any miss across all four collapses into a single McpNotFoundError('sandbox project', projectId) so the calling agent cannot oracle-probe the existence of a production project, another operator’s sandbox project, or distinguish “wrong kind” from “wrong owner” by error-message diff. For the cross-tenant threat model this is the load-bearing predicate: PATs minted with the default “all my projects” allowlist mode have projectAllowlist === null, which makes the secondary assertProjectAllowed check a no-op for those tokens. (PATs explicitly scoped to a project allowlist do hit assertProjectAllowed — that’s a real second guard whose miss surfaces as a distinct McpAllowlistError rather than collapsing into not-found.)

The same helper backs the sandbox:simulate-scoped MCP tools surfaced via the @txnod/mcp-stdio bridge to Claude Desktop / Claude Code / Cursor — the cross-tenant guarantee holds end-to-end from the agent’s prompt down to the row-level assertion. Operator A’s PAT cannot probe operator B’s sandbox project IDs; the protocol surface is structurally indistinguishable from “project never existed”.

What happens if…

A sandbox API secret is deployed with NODE_ENV=production

TxnodClient instantiation throws TxnodSandboxKeyInProductionError at boot. The exact error message:

TxnodSandboxKeyInProductionError: Sandbox API secret (sk_sandbox_*) detected with NODE_ENV=production. Set environment: 'non-production' or use a production API secret. To override (staging-replica only), pass iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true.

Recovery: replace the deployed TXNOD_API_SECRET with the production project’s secret, OR set NODE_ENV=development (or another non-production value), OR wire the override constructor option (only for legitimate staging-replica setups). The recommended CI assertion catches this at test boot rather than at deploy time.

A production API secret is used to drive a sandbox project’s reset()

The server returns HTTP 400 with error_code: production_key_against_sandbox_project. The SDK throws TxnodProductionKeyAgainstSandboxProjectError. The exact error message:

TxnodProductionKeyAgainstSandboxProjectError: API key is for a production project but target project is a sandbox project. Cross-mode operations are not permitted.

Recovery: use the sandbox PAT (with sandbox:simulate scope) for sandbox project operations, not the production API secret. The four cross-mode-checked endpoints are {projectId}/clock/advance, {projectId}/reset, DELETE {projectId}, and {projectId}/wallets — invoice-scoped simulate endpoints don’t fire this check because the project_id is implicit in the API key.

A handler doesn’t branch on event.mode and a sandbox event arrives in production

The handler processes the sandbox event as if it were a real payment — fulfilling an order, sending an email, marking inventory shipped. The handler “succeeds” with no error, but the financial side-effect is wrong because no real funds moved. Layer 5 of the defense exists specifically to prevent this; the recommended assertion at the top of every handler:

import type { WebhookEvent } from '@txnod/sdk'; declare const event: WebhookEvent; if (event.mode === 'sandbox' && process.env.NODE_ENV === 'production') { throw new Error('refusing to process sandbox event in production'); }

The exact error message your handler should throw is the literal text above so ops can grep logs for the specific phrase. Recovery: confirm TXNOD_WEBHOOK_SECRET is the production project’s secret (not the sandbox project’s) — a misrouted webhook means the wrong secret is signing the inbound envelope. The two-secret routing helper shown in Agent-driven testing is the canonical handler shape that surfaces this misconfiguration as a defense-in-depth log line.

Add this once at the top of your test setup so a misconfiguration breaks CI loudly:

// tests/setup.ts function assertSafeMode(): void { const env = process.env.NODE_ENV ?? 'unknown'; const secret = process.env.TXNOD_API_SECRET ?? ''; const isSandbox = secret.startsWith('sk_sandbox_'); if (env === 'production' && isSandbox) { throw new Error( '[ci] Sandbox API secret cannot run with NODE_ENV=production', ); } if (env !== 'production' && !isSandbox) { throw new Error( '[ci] Production API secret cannot run outside production', ); } } assertSafeMode(); export {};

This block is reproduced byte-exact across the SDK-bundled 05-sandbox.md, this guide, and the canonical example app’s tests/setup.ts. An agent or human integrator can grep for the canonical text to verify they’re aligned.