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.
| Chain | Mainnet format | Sandbox/testnet format | Mainnet wallet rejects send? | Mechanism |
|---|---|---|---|---|
| Bitcoin | bc1q... (bech32 mainnet HRP) / 1... / 3... | tb1q... (bech32 testnet HRP) / m... / n... / 2... | Yes | Bech32 HRP byte differs; legacy address version byte differs |
| Ethereum (ETH + ERC-20 USDT/USDC) | 0x... 20-byte hex | 0x... 20-byte hex | No | Identical address format; safety via chain id at signing time |
| Polygon PoS (POL + Polygon USDT/USDC) | 0x... 20-byte hex | 0x... 20-byte hex | No | Identical address format; safety via chain id |
| BNB Smart Chain (BNB + BEP-20 USDT/USDC) | 0x... 20-byte hex | 0x... 20-byte hex | No | Identical 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) | No | Identical address format; testnet differentiation is endpoint-only |
| Cardano | addr1... (mainnet NetworkId) | addr_test1... (testnet NetworkId) | Yes | Address-level NetworkId byte differs |
| TON (Toncoin) | EQ... / UQ... (mainnet workchain prefix) | kQ... / 0Q... (testnet workchain prefix) | Yes | Address prefix byte encodes workchain + bounceable bit |
| TON (jetton USDT-TON) | EQ... / UQ... mainnet | kQ... / 0Q... testnet | Yes | Same 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:
- The project row exists.
- It is not soft-deleted (
deleted_at IS NULL). - Its
kindis'sandbox'(not'production'). - Its
owner_user_idmatches the calling PAT’sownerUserId.
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.
Recommended CI assertion
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.