@txnod/sdk 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +29 -0
  2. package/CHANGELOG.md +22 -0
  3. package/LICENSE +21 -0
  4. package/README.md +434 -0
  5. package/dist/_shared/index.d.ts +68 -0
  6. package/dist/client-sandbox.d.ts +396 -0
  7. package/dist/client-sandbox.d.ts.map +1 -0
  8. package/dist/client-sandbox.js +448 -0
  9. package/dist/client-sandbox.js.map +1 -0
  10. package/dist/client.d.ts +429 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +588 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/env.d.ts +29 -0
  15. package/dist/env.d.ts.map +1 -0
  16. package/dist/env.js +44 -0
  17. package/dist/env.js.map +1 -0
  18. package/dist/errors.d.ts +1887 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +2107 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/index.d.ts +35 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +32 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/internals/error-ctor-map.d.ts +11 -0
  27. package/dist/internals/error-ctor-map.d.ts.map +1 -0
  28. package/dist/internals/error-ctor-map.js +75 -0
  29. package/dist/internals/error-ctor-map.js.map +1 -0
  30. package/dist/internals/fetch-with-retry.d.ts +34 -0
  31. package/dist/internals/fetch-with-retry.d.ts.map +1 -0
  32. package/dist/internals/fetch-with-retry.js +233 -0
  33. package/dist/internals/fetch-with-retry.js.map +1 -0
  34. package/dist/internals/hmac.d.ts +2 -0
  35. package/dist/internals/hmac.d.ts.map +1 -0
  36. package/dist/internals/hmac.js +10 -0
  37. package/dist/internals/hmac.js.map +1 -0
  38. package/dist/internals/logger.d.ts +9 -0
  39. package/dist/internals/logger.d.ts.map +1 -0
  40. package/dist/internals/logger.js +16 -0
  41. package/dist/internals/logger.js.map +1 -0
  42. package/dist/internals/parse-problem-details.d.ts +3 -0
  43. package/dist/internals/parse-problem-details.d.ts.map +1 -0
  44. package/dist/internals/parse-problem-details.js +76 -0
  45. package/dist/internals/parse-problem-details.js.map +1 -0
  46. package/dist/internals/synthetic-details.d.ts +12 -0
  47. package/dist/internals/synthetic-details.d.ts.map +1 -0
  48. package/dist/internals/synthetic-details.js +19 -0
  49. package/dist/internals/synthetic-details.js.map +1 -0
  50. package/dist/verify/chains/bsc.d.ts +17 -0
  51. package/dist/verify/chains/bsc.d.ts.map +1 -0
  52. package/dist/verify/chains/bsc.js +15 -0
  53. package/dist/verify/chains/bsc.js.map +1 -0
  54. package/dist/verify/chains/btc.d.ts +22 -0
  55. package/dist/verify/chains/btc.d.ts.map +1 -0
  56. package/dist/verify/chains/btc.js +55 -0
  57. package/dist/verify/chains/btc.js.map +1 -0
  58. package/dist/verify/chains/cardano.d.ts +73 -0
  59. package/dist/verify/chains/cardano.d.ts.map +1 -0
  60. package/dist/verify/chains/cardano.js +175 -0
  61. package/dist/verify/chains/cardano.js.map +1 -0
  62. package/dist/verify/chains/evm.d.ts +21 -0
  63. package/dist/verify/chains/evm.d.ts.map +1 -0
  64. package/dist/verify/chains/evm.js +46 -0
  65. package/dist/verify/chains/evm.js.map +1 -0
  66. package/dist/verify/chains/polygon.d.ts +17 -0
  67. package/dist/verify/chains/polygon.d.ts.map +1 -0
  68. package/dist/verify/chains/polygon.js +15 -0
  69. package/dist/verify/chains/polygon.js.map +1 -0
  70. package/dist/verify/chains/secp256k1-bip32.d.ts +20 -0
  71. package/dist/verify/chains/secp256k1-bip32.d.ts.map +1 -0
  72. package/dist/verify/chains/secp256k1-bip32.js +88 -0
  73. package/dist/verify/chains/secp256k1-bip32.js.map +1 -0
  74. package/dist/verify/chains/ton-cell.d.ts +179 -0
  75. package/dist/verify/chains/ton-cell.d.ts.map +1 -0
  76. package/dist/verify/chains/ton-cell.js +614 -0
  77. package/dist/verify/chains/ton-cell.js.map +1 -0
  78. package/dist/verify/chains/ton.d.ts +84 -0
  79. package/dist/verify/chains/ton.d.ts.map +1 -0
  80. package/dist/verify/chains/ton.js +131 -0
  81. package/dist/verify/chains/ton.js.map +1 -0
  82. package/dist/verify/chains/tron.d.ts +21 -0
  83. package/dist/verify/chains/tron.d.ts.map +1 -0
  84. package/dist/verify/chains/tron.js +42 -0
  85. package/dist/verify/chains/tron.js.map +1 -0
  86. package/dist/verify/config.d.ts +41 -0
  87. package/dist/verify/config.d.ts.map +1 -0
  88. package/dist/verify/config.js +120 -0
  89. package/dist/verify/config.js.map +1 -0
  90. package/dist/verify/errors.d.ts +56 -0
  91. package/dist/verify/errors.d.ts.map +1 -0
  92. package/dist/verify/errors.js +58 -0
  93. package/dist/verify/errors.js.map +1 -0
  94. package/dist/verify/index.d.ts +119 -0
  95. package/dist/verify/index.d.ts.map +1 -0
  96. package/dist/verify/index.js +166 -0
  97. package/dist/verify/index.js.map +1 -0
  98. package/dist/verify/xpub-safety.d.ts +33 -0
  99. package/dist/verify/xpub-safety.d.ts.map +1 -0
  100. package/dist/verify/xpub-safety.js +54 -0
  101. package/dist/verify/xpub-safety.js.map +1 -0
  102. package/dist/verify-webhook-signature.d.ts +30 -0
  103. package/dist/verify-webhook-signature.d.ts.map +1 -0
  104. package/dist/verify-webhook-signature.js +84 -0
  105. package/dist/verify-webhook-signature.js.map +1 -0
  106. package/docs/00-getting-started.md +135 -0
  107. package/docs/01-authentication.md +114 -0
  108. package/docs/02-invoices.md +216 -0
  109. package/docs/03-rates-and-quotes.md +82 -0
  110. package/docs/04-webhooks.md +126 -0
  111. package/docs/05-errors.md +199 -0
  112. package/docs/05-sandbox.md +159 -0
  113. package/docs/06-idempotency.md +132 -0
  114. package/docs/examples/express-webhook-receiver.md +97 -0
  115. package/docs/examples/nextjs-route-handler.md +206 -0
  116. package/docs/examples/sandbox-vitest-suite.md +263 -0
  117. package/docs/index.md +66 -0
  118. package/docs/reference/client.md +392 -0
  119. package/docs/reference/errors.md +161 -0
  120. package/docs/reference/types.md +400 -0
  121. package/package.json +53 -0
@@ -0,0 +1,199 @@
1
+ ---
2
+ title: "Error handling"
3
+ description: "Every typed TxnodError subclass, what raises it, and how to branch on it."
4
+ sdk_version: 1.0.0
5
+ ---
6
+
7
+ # Error handling
8
+
9
+ Every `TxnodClient` method throws a subclass of `TxnodError` on any non-2xx response. `verifyWebhookSignature` throws a `TxnodError` subclass on signature failure. All subclasses are named exports of `@txnod/sdk`.
10
+
11
+ ## Base shape
12
+
13
+ ```ts
14
+ class TxnodError extends Error {
15
+ readonly kind: string; // machine-friendly class discriminant, e.g. 'rate_limit_exceeded'
16
+ readonly error_code: ErrorCode; // the server's error_code from the RFC 7807 envelope
17
+ readonly status: number; // HTTP status
18
+ readonly request_id: string; // correlate with txnod server logs via this id
19
+ readonly raw: ProblemDetails; // full RFC 7807 envelope, may carry `errors`/`meta`
20
+ }
21
+ ```
22
+
23
+ - Always log `err.request_id` — it's the only reliable way to correlate with server-side logs when asking for support.
24
+ - `err.raw.errors` is populated on `TxnodValidationError` with Zod-shaped per-field issues.
25
+
26
+ ## Canonical branching pattern
27
+
28
+ ```ts
29
+ import {
30
+ TxnodClient,
31
+ TxnodError,
32
+ TxnodPoolExhaustedError,
33
+ TxnodRateLimitError,
34
+ TxnodValidationError,
35
+ TxnodAuthInvalidError,
36
+ } from '@txnod/sdk';
37
+
38
+ try {
39
+ await client.createInvoice({ /* ... */ });
40
+ } catch (err) {
41
+ if (err instanceof TxnodRateLimitError || err instanceof TxnodPoolExhaustedError) {
42
+ await sleep(err.retry_after_seconds * 1000);
43
+ // retry once
44
+ return;
45
+ }
46
+ if (err instanceof TxnodValidationError) {
47
+ console.warn('per-field validation:', err.raw.errors);
48
+ throw err; // not retryable — fix inputs
49
+ }
50
+ if (err instanceof TxnodAuthInvalidError) {
51
+ throw new Error('check TXNOD_PROJECT_ID / TXNOD_API_SECRET');
52
+ }
53
+ if (err instanceof TxnodError) {
54
+ console.error(err.error_code, err.request_id, err.status);
55
+ }
56
+ throw err;
57
+ }
58
+ ```
59
+
60
+ **Always order the `instanceof` checks most-specific → base.** `TxnodRateLimitError extends TxnodError`, so a bare `instanceof TxnodError` check first would swallow everything.
61
+
62
+ ## Error class catalogue
63
+
64
+ Grouped by what the operator should do. Full `error_code` → class lookup is in [`reference/errors.md`](./reference/errors.md).
65
+
66
+ ### Fatal — fix inputs / configuration
67
+
68
+ | Class | `error_code` | Meaning |
69
+ |---|---|---|
70
+ | `TxnodValidationError` | `validation_error` | Request body failed server Zod validation — inspect `err.raw.errors` |
71
+ | `TxnodInvalidCoinError` | `invalid_coin` | `coin` not in supported coins enum |
72
+ | `TxnodInvalidXpubFormatError` | `invalid_xpub_format` | xpub structurally invalid for chain |
73
+ | `TxnodInvalidWebhookUrlError` | `invalid_webhook_url` | `callback_url` is not a valid https URL |
74
+ | `TxnodCoinNotEnabledError` | `coin_not_enabled` | Coin supported but not enabled for this project — use `getRates()` to see enabled coins |
75
+ | `TxnodAmountOutOfRangeError` | `amount_out_of_range` | Amount below/above project-configured range |
76
+ | `TxnodXpubNotVerifiedError` | `xpub_not_verified` | xpub added to the project but ownership challenge not yet completed |
77
+ | `TxnodWalletNotBoundError` | `wallet_not_bound` | No verified wallet of matching kind bound for the chain on this project — operator must bind one before invoice creation succeeds |
78
+ | `TxnodWalletKindMismatchError` | `wallet_kind_mismatch` | Cross-kind binding rejected at the dashboard — production projects only accept production wallets, testnet projects only testnet wallets |
79
+ | `TxnodTronNoActivatedAddressesError` | `tron_no_activated_addresses_available` | TRON-only — operator's address pool has zero activated rows; `.walletId` carries the operator wallet id for deep-link UX. Operator-action-required, do not auto-retry |
80
+ | `TxnodInvoiceNotCancellableError` | `invoice_not_cancellable` | Cannot cancel an invoice past its cancellable state |
81
+ | `TxnodInvalidStateTransitionError` | `invalid_state_transition` | Requested op would produce an illegal invoice transition |
82
+
83
+ ### Fatal — auth / identity
84
+
85
+ | Class | `error_code` | Meaning |
86
+ |---|---|---|
87
+ | `TxnodAuthInvalidError` | `auth_invalid` | Headers missing/malformed, or project id unknown |
88
+ | `TxnodSignatureInvalidError` | `signature_invalid` | Computed HMAC mismatch on outbound API call |
89
+ | `TxnodSignatureReplayedError` | `signature_replayed` | Same `(timestamp, signature)` seen before — body cache hit. Fix the partner's signing pipeline (do not regenerate timestamps for retries) |
90
+ | `TxnodTimestampOutOfWindowError` | `timestamp_out_of_window` | `X-Timestamp` outside ±300 s — fix NTP on caller |
91
+ | `TxnodKeySuspendedError` | `key_suspended` | Key owner suspended by operator |
92
+ | `TxnodKeyRevokedError` | `key_revoked` | Key has been rotated/revoked — stop using |
93
+ | `TxnodProjectSuspendedError` | `project_suspended` | Target project suspended by operator |
94
+ | `TxnodSubscriptionExpiredError` | `subscription_expired` | HTTP 402 — operator's subscription is not `active`; writes are blocked, reads still work. Operator must renew via dashboard `/billing` before writes resume |
95
+ | `TxnodPermissionDeniedError` | `permission_denied` | Key lacks the capability for this route |
96
+
97
+ ### Fatal — not found
98
+
99
+ | Class | `error_code` | Meaning |
100
+ |---|---|---|
101
+ | `TxnodInvoiceNotFoundError` | `invoice_not_found` | Invoice id does not belong to this project |
102
+ | `TxnodProjectNotFoundError` | `project_not_found` | Project id unknown or not accessible |
103
+ | `TxnodWalletNotFoundError` | `wallet_not_found` | No wallet bound for `(chain, project)` |
104
+ | `TxnodOrphanNotFoundError` | `orphan_not_found` | Orphan payment with that tx hash doesn't exist |
105
+ | `TxnodEventNotFoundError` | `event_not_found` | Webhook event id unknown |
106
+
107
+ ### Idempotent — treat as success
108
+
109
+ | Class | `error_code` | What to do |
110
+ |---|---|---|
111
+ | `TxnodExternalIdConflictError` | `external_id_conflict` | Invoice with that `external_id` already exists; fetch via `searchInvoices({ external_id })` |
112
+ | `TxnodOrphanAlreadyAttributedError` | `orphan_already_attributed` | Orphan already bound to an invoice; treat as success |
113
+
114
+ ### Transient — retry
115
+
116
+ | Class | `error_code` | Notes |
117
+ |---|---|---|
118
+ | `TxnodRateLimitError` | `rate_limit_exceeded` | `.retry_after_seconds` holds the `Retry-After` value. The SDK already retries once internally; this is what you see after retries are exhausted |
119
+ | `TxnodPoolExhaustedError` | `pool_exhausted` | Pool hit hard cap (`poolSizePerChain[chain]`). `.retry_after_seconds` holds the server-computed `Retry-After` (soonest `locked_until` or project cooldown). Wait it out, or raise the cap if it fires under steady load |
120
+ | `TxnodWebhookCapacityExhaustedError` | `webhook_capacity_exhausted` | Webhook fleet for this chain has no spare slots. `.retry_after_seconds` is always `0` because a partner retry alone won't free capacity — treat as transient outage and queue the invoice for a later retry |
121
+ | `TxnodServerError` | `internal_error` | 5xx classified as uncategorized — retryable with backoff |
122
+
123
+ ### TON-chain runtime
124
+
125
+ Server-side TON failures that surface to partner code on `createInvoice` (or downstream during settlement). `ton_jetton_resolve_failed` is transient (provider hiccup); the others are operator-action-required.
126
+
127
+ | Class | `error_code` | Meaning |
128
+ |---|---|---|
129
+ | `TxnodTonOperatorWalletNotDeployedError` | `ton_operator_wallet_not_deployed` | TON operator wallet has not been deployed on-chain yet — operator must broadcast the wallet's `StateInit` (typical first send) before invoices can resolve. HTTP 422 |
130
+ | `TxnodTonInvalidWalletVersionError` | `ton_invalid_wallet_version` | Configured wallet version does not match the deployed one. Operator must reconcile `TXNOD_TON_WALLET_VERSION` with the actual deployed version (`v3R2` / `v4R2` / `v5`). HTTP 422 |
131
+ | `TxnodTonJettonResolveFailedError` | `ton_jetton_resolve_failed` | Toncenter v3 or TONAPI failed to resolve the jetton master/wallet pair (provider hiccup, typically transient). HTTP 503 — retry with backoff |
132
+ | `TxnodTonCommentParseFailedError` | `ton_comment_parse_failed` | Inbound TON tx carried a malformed `payment_token` comment that did not parse as the expected 8-hex memo. Indicates a mis-formed wallet send; operator review required. HTTP 422 |
133
+
134
+ ### TonConnect operator-onboarding
135
+
136
+ Raised by the operator's TonConnect signing flow during xpub onboarding — never on partner-facing methods. Exported so the dashboard's typed-error catch can branch correctly.
137
+
138
+ | Class | `error_code` | Meaning |
139
+ |---|---|---|
140
+ | `TxnodTonConnectPayloadExpiredError` | `tonconnect_payload_expired` | The challenge payload was signed past its TTL — operator must restart the connect flow |
141
+ | `TxnodTonConnectPayloadUnknownError` | `tonconnect_payload_unknown` | Challenge payload not in the server's recent-issued set — replay or stale browser tab |
142
+ | `TxnodTonConnectDomainMismatchError` | `tonconnect_domain_mismatch` | `proof.domain.value` does not match `TXNOD_TONCONNECT_DOMAIN` — manifest is being served from the wrong origin |
143
+ | `TxnodTonConnectTimestampSkewError` | `tonconnect_timestamp_skew` | Wallet timestamp outside the configured window — clock-skew on the wallet side |
144
+ | `TxnodTonConnectUnknownWalletVersionError` | `tonconnect_unknown_wallet_version` | Wallet returned a `walletStateInit` whose version the server does not recognise |
145
+ | `TxnodTonConnectSignatureInvalidError` | `tonconnect_signature_invalid` | Ed25519 signature verification failed |
146
+ | `TxnodTonConnectNetworkMismatchError` | `tonconnect_network_mismatch` | Wallet network (`-239` mainnet / `-3` testnet) does not match the server-configured TON network |
147
+
148
+ ### Sandbox lifecycle + simulation
149
+
150
+ Raised by the sandbox surface (`/api/v1/sandbox/*` REST + the 14 `client.sandbox.*` SDK methods + `sandbox:simulate` MCP scope). See [`05-sandbox.md`](./05-sandbox.md) for the full lifecycle.
151
+
152
+ | Class | `error_code` | Meaning |
153
+ |---|---|---|
154
+ | `TxnodSandboxProjectRequiredError` | `sandbox_project_required` | Sandbox endpoint hit against a `kind: 'production'` project — sandbox tooling is structurally unreachable from production projects (and vice versa). HTTP 403 |
155
+ | `TxnodSandboxPerOperatorCapReachedError` | `sandbox_per_operator_cap_reached` | Operator already owns the maximum number of sandbox projects (per-operator cap). Delete an existing sandbox via `client.sandbox.destroy(id)` before creating another. HTTP 422 |
156
+ | `TxnodSandboxKeyAgainstProductionProjectError` | `sandbox_key_against_production_project` | A sandbox API secret (prefix `sk_sandbox_`) was used to authenticate against a production project. Mismatch between key kind and project kind. HTTP 400 |
157
+ | `TxnodProductionKeyAgainstSandboxProjectError` | `production_key_against_sandbox_project` | A production API secret was used against a sandbox project. Same kind-mismatch as the previous, opposite direction. HTTP 400 |
158
+ | `TxnodSandboxProvisioningFailedError` | `sandbox_provisioning_failed` | Server failed to provision the sandbox project shell during `POST /sandbox`. Internal failure; retry. HTTP 500 |
159
+ | `TxnodSandboxInvoiceTransitionInvalidError` | `sandbox_invoice_transition_invalid` | A `simulate*` call was issued against an invoice in a state that does not allow the requested transition. `.message` carries the current and attempted transition. HTTP 422 |
160
+ | `TxnodSandboxInvoiceNotFoundError` | `sandbox_invoice_not_found` | Sandbox `simulate*` call referenced an invoice that does not belong to the targeted sandbox project. Collapsed across "wrong project" / "soft-deleted" / "non-existent" (oracle-safe). HTTP 404 |
161
+ | `TxnodSandboxInvoiceTerminalError` | `sandbox_invoice_terminal` | `simulateDuplicateDelivery` requires a prior terminal webhook on the invoice — this one has none yet. HTTP 422 |
162
+ | `TxnodSandboxRateLimitExceededError` | `sandbox_rate_limit_exceeded` | Sandbox per-project rate cap hit (e.g. `clockAdvance` ≤ 10/min). HTTP 429 — back off |
163
+ | `TxnodSandboxResetFailedError` | `sandbox_reset_failed` | `client.sandbox.reset(projectId)` failed mid-purge. Internal failure; retry. HTTP 500 |
164
+ | `TxnodSandboxDeleteFailedError` | `sandbox_delete_failed` | `client.sandbox.destroy(projectId)` failed mid-delete. Internal failure; retry. HTTP 500 |
165
+ | `TxnodSandboxActiveInvoiceCapReachedError` | `sandbox_active_invoice_cap_reached` | Sandbox project has too many concurrently-open invoices — finalise or expire some via `simulateExpire` / `simulatePaid` before creating more. HTTP 422 |
166
+
167
+ ### SDK-local synthesised (init-time guards)
168
+
169
+ Thrown by `TxnodClient` constructor and `parseXpubConfig` before any HTTP call leaves the process. They synthesise a `ProblemDetails` envelope so they share the `instanceof TxnodError` invariant — but they have no server-side `error_code`. Catch them around `new TxnodClient(...)` and the verify helpers.
170
+
171
+ | Class | `kind` | Meaning |
172
+ |---|---|---|
173
+ | `TxnodSandboxKeyInProductionError` | `sandbox_key_in_production` | A `sk_sandbox_` secret was passed into a `TxnodClient` constructed with `environment: 'production'` (or with `environment` omitted while `NODE_ENV === 'production'`). Carries `.signal` (`'env-mismatch'` / `'explicit-production'`), `.secretPrefix`, `.detectedEnvironment`. **Fail-closed at construction** |
174
+ | `TxnodEnvironmentUnknownError` | `environment_unknown` | A `sk_sandbox_` secret was used without an explicit `environment` AND `NODE_ENV` is unset/unknown — refuses to guess. Set `environment: 'non-production'` or `'production'` explicitly |
175
+ | `TxnodSandboxXpubInProductionError` | `sandbox_xpub_in_production` | A testnet xpub (e.g. `tpub`/`vpub` for BTC) was loaded for a chain whose env-detected mode is production. Carries `.chain`, `.xpubPrefix`, `.detectedEnvironment`. **Fail-closed at config parse** |
176
+
177
+ ### Dashboard-only (exported but not reachable from `TxnodClient`)
178
+
179
+ The partner HMAC surface never raises these — they appear only on internal Server Actions inside the operator dashboard. They are exported so the `error_code` ↔ class map stays exhaustive. Do not branch on them in partner code.
180
+
181
+ | Class | `error_code` | Meaning |
182
+ |---|---|---|
183
+ | `TxnodWalletNotOwnedError` | `wallet_not_owned` | Operator targeted a wallet they do not own — never crosses the partner API |
184
+ | `TxnodWalletHasActiveBindingsError` | `wallet_has_active_bindings` | Operator tried to archive a wallet still bound to one or more projects |
185
+
186
+ ### Webhook-local (thrown by `verifyWebhookSignature` only)
187
+
188
+ | Class | Kind | Meaning |
189
+ |---|---|---|
190
+ | `TxnodSignatureFormatError` | `signature_format` | `X-Txnod-Signature` absent or not `t=<int>,v1=<hex>` |
191
+ | `TxnodHmacError` | `hmac` | Computed HMAC does not match |
192
+ | `TxnodTimestampError` | `timestamp` | `|now - t| > 300` — `.skew_seconds` is the signed delta |
193
+ | `TxnodWebhookPayloadParseError` | `webhook_payload_parse` | HMAC passed but body is not valid JSON. Indicates a misbehaving server release; treat as 401-class (do not parse, do not act). `.cause` carries the underlying `SyntaxError` |
194
+
195
+ ## Related
196
+
197
+ - [`reference/errors.md`](./reference/errors.md) — `error_code` ↔ class lookup
198
+ - [`01-authentication.md`](./01-authentication.md) — why the auth-family errors happen
199
+ - [`04-webhooks.md`](./04-webhooks.md) — when the webhook-local trio fires
@@ -0,0 +1,159 @@
1
+ ---
2
+ title: "Sandbox"
3
+ description: "client.sandbox.* surface, environment-detection guards, layered defenses against routing real customer funds to sandbox addresses."
4
+ sdk_version: 1.0.0
5
+ ---
6
+
7
+ # Sandbox
8
+
9
+ The `@txnod/sdk` ships a sandbox surface (`client.sandbox.*` and 14 matching MCP tools) for deterministic integration testing. **Sandbox secrets and sandbox projects exist in the same Postgres database as production — there is no separate environment.** Discrimination happens via:
10
+
11
+ 1. The `sk_sandbox_` API-secret prefix.
12
+ 2. A `projects.kind` column whose value is `'sandbox'` or `'production'`.
13
+ 3. The webhook envelope's `mode: 'sandbox' | 'production'` discriminator.
14
+ 4. The SDK constructor's environment-detection guards (this document).
15
+
16
+ ## 30-second overview
17
+
18
+ ```ts
19
+ import { TxnodClient } from '@txnod/sdk';
20
+
21
+ const client = new TxnodClient({
22
+ projectId: process.env.TXNOD_PROJECT_ID!,
23
+ apiSecret: process.env.TXNOD_API_SECRET!, // sk_sandbox_...
24
+ environment: 'non-production', // explicit override; trusts NODE_ENV otherwise
25
+ });
26
+
27
+ // 1. Create an invoice as usual
28
+ const invoice = await client.createInvoice({
29
+ amount_usd: 9.99,
30
+ coin: 'usdt_trc20',
31
+ external_id: 'order-42',
32
+ });
33
+
34
+ // 2. Drive the state machine deterministically
35
+ await client.sandbox.simulateDetect(invoice.id, { seed: 'order-42' });
36
+ await client.sandbox.simulatePaid(invoice.id);
37
+
38
+ // 3. Your webhook handler receives invoice.detected then invoice.paid with mode='sandbox'
39
+
40
+ // 4. Reset between test runs
41
+ await client.sandbox.reset(process.env.TXNOD_PROJECT_ID!);
42
+ ```
43
+
44
+ ## Environment detection (`getSdkEnv`)
45
+
46
+ The SDK never trusts a single signal. `getSdkEnv()` resolves environment in this order:
47
+
48
+ 1. The constructor's `environment` option (`'production' | 'non-production'`).
49
+ 2. `process.env.TXNOD_ENVIRONMENT === 'production'` → `'production'`.
50
+ 3. `process.env.TXNOD_ENVIRONMENT === 'non-production'` → `'non-production'`.
51
+ 4. `process.env.NODE_ENV === 'production'` → `'production'`.
52
+ 5. `process.env.NODE_ENV === 'development' | 'test'` → `'non-production'`.
53
+ 6. Otherwise → `'unknown'`.
54
+
55
+ `getSdkEnv()` is the only environment-signal reader in the SDK — there is no second source of truth.
56
+
57
+ ## Layered defenses
58
+
59
+ | Layer | What it catches | When it fires |
60
+ |---|---|---|
61
+ | 1. SDK constructor | Sandbox secret in production-detected env | Hard-fail at boot via `TxnodSandboxKeyInProductionError` |
62
+ | 2. SDK constructor | Sandbox secret with no env signal | Hard-fail at boot via `TxnodEnvironmentUnknownError` |
63
+ | 3. SDK xpub-prefix guard | Testnet xpub (`tpub`/`vpub`/`upub`) in production | Hard-fail at boot via `TxnodSandboxXpubInProductionError` |
64
+ | 4. Server cross-mode guard | Sandbox secret targeting production project (or vice-versa) | API returns `sandbox_key_against_production_project` / `production_key_against_sandbox_project` |
65
+ | 5. Webhook envelope `mode` | Drift between intent and outcome | Integrator asserts `event.mode === 'sandbox'` in non-prod tests |
66
+
67
+ ## Per-chain testnet-prefix truth table
68
+
69
+ | Chain | Mainnet xpub prefixes | Testnet xpub prefixes the SDK guard catches | Network safety also enforced at |
70
+ |---|---|---|---|
71
+ | BTC | `xpub` (BIP44), `ypub` (BIP49), `zpub` (BIP84) | `tpub`, `upub`, `vpub` | Address checksum + bech32 hrp (`bc1` / `tb1`) |
72
+ | ETH | `xpub` | `tpub` | Per-chain id (mainnet=1, testnet has no per-address discriminator) |
73
+ | Polygon | `xpub` | `tpub` | Per-chain id (mainnet=137, amoy=80002) |
74
+ | BSC | `xpub` | `tpub` | Per-chain id (mainnet=56, testnet=97) |
75
+ | TRON | `xpub` | `tpub` | Address byte prefix (`T` mainnet, `2` testnet via base58check leading byte) |
76
+ | Cardano | `acct_xvk1...` (CIP-5 hrp; same for mainnet + testnet) | (no SDK-side discriminator) | Address-level NetworkId byte (server-side) |
77
+ | TON | `TXNOD_TON_PUBKEY` is a 32-byte ed25519 pubkey, never an extended key | (no xpub concept; guard is a no-op) | `TXNOD_TON_WORKCHAIN` + bounceable address discriminator |
78
+
79
+ **EVM family carve-out:** since `xpub`-prefixed extended keys are valid for both mainnet and testnet on EVM chains (network differentiation lives in the per-address checksum and the chain id), the SDK guard ONLY catches `tpub`/`vpub`/`upub`. An `xpub` configured for an EVM testnet falls through to layer 4 (server cross-mode) and layer 5 (envelope `mode`).
80
+
81
+ **Cardano carve-out:** CIP-5's hrp does not distinguish testnet at the account-pubkey level — the safety lives in the address-level NetworkId byte. The xpub-prefix guard is a no-op for ADA.
82
+
83
+ ## Constructor options
84
+
85
+ | Option | Type | Purpose |
86
+ |---|---|---|
87
+ | `environment` | `'production' \| 'non-production'` | Explicit override; precedence above env vars. Use for staging replicas where `NODE_ENV='production'` but the secret is sandbox. |
88
+ | `iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses` | `boolean` | Bypass the sandbox-secret-in-production hard-fail. Setting `true` does NOT suppress a non-suppressible `console.error` on every constructor invocation, and adds an `X-Txnod-Client-Environment: production` header to every API call (server-side telemetry surfaces these in the admin dashboard). |
89
+
90
+ The override option is deliberately verbose — it must remain awkward to type. Renaming it in any future SDK version requires a major-version bump and a deprecation cycle.
91
+
92
+ ## `client.sandbox.*` surface
93
+
94
+ All 14 methods sign with the same HMAC scheme as the rest of the SDK and throw the typed sandbox-* error classes per the SDK's typed-error contract.
95
+
96
+ | Method | HTTP | Returns | Throws |
97
+ |---|---|---|---|
98
+ | `simulateDetect(invoiceId, opts?)` | `POST /api/v1/sandbox/invoices/{invoiceId}/simulate-detect` | `{ event_id, status: 'detected' }` | `TxnodSandboxInvoiceTransitionInvalidError` (invoice not in `pending`), `TxnodSandboxInvoiceNotFoundError` |
99
+ | `simulatePaid(invoiceId, opts?)` | `POST .../simulate-paid` | `{ event_id, status: 'paid' }` | `TxnodSandboxInvoiceTransitionInvalidError` (invoice not in `detected`) |
100
+ | `simulateOverpaid(invoiceId, params)` | `POST .../simulate-overpaid` | `{ event_id, status: 'paid' }` | `TxnodSandboxInvoiceTransitionInvalidError` |
101
+ | `simulatePartial(invoiceId, params)` | `POST .../simulate-partial` | `{ event_id, status: 'detected' }` | `TxnodSandboxInvoiceTransitionInvalidError` |
102
+ | `simulateExpire(invoiceId)` | `POST .../simulate-expire` | `{ event_id, status: 'expired' }` | `TxnodSandboxInvoiceTransitionInvalidError` (invoice already terminal) |
103
+ | `simulateLatePayment(invoiceId, opts?)` | `POST .../simulate-late-payment` | `{ event_id, status: 'expired_paid_late' }` | `TxnodSandboxInvoiceTransitionInvalidError` (invoice not in `expired`) |
104
+ | `simulateReorg(invoiceId)` | `POST .../simulate-reorg` | `{ event_id, status: 'reverted' }` | `TxnodSandboxInvoiceTransitionInvalidError` (invoice not in `paid`/`overpaid`/`partial`) |
105
+ | `simulateReconfirm(invoiceId)` | `POST .../simulate-reconfirm` | `{ event_id, status: 'paid' }` | `TxnodSandboxInvoiceTransitionInvalidError` (invoice not in `reverted`) |
106
+ | `simulateDuplicateDelivery(invoiceId)` | `POST .../simulate-duplicate-delivery` | `{ event_id }` | `TxnodSandboxInvoiceTerminalError` (no terminal event yet) |
107
+ | `simulateEvent(invoiceId, eventInput)` | `POST .../simulate-event` | `{ event_id, status }` | `TxnodSandboxInvoiceTransitionInvalidError` (current status != `expectedCurrentStatus`) |
108
+ | `clockAdvance(projectId, params)` | `POST /api/v1/sandbox/{projectId}/clock/advance` | `{ advanced, remaining }` | `TxnodSandboxRateLimitExceededError` (>10/min/project) |
109
+ | `reset(projectId)` | `POST /api/v1/sandbox/{projectId}/reset` | `{ status: 'reset' }` | `TxnodSandboxResetFailedError` |
110
+ | `destroy(projectId)` | `DELETE /api/v1/sandbox/{projectId}` | `{ status: 'deleted' }` | `TxnodSandboxDeleteFailedError` |
111
+ | `listWallets(projectId)` | `GET /api/v1/sandbox/{projectId}/wallets` | `WalletsListResponse` | (read-only; no transition errors) |
112
+
113
+ The `client.sandbox` getter is lazy — bundlers tree-shake the entire namespace when it is never referenced from your application code.
114
+
115
+ ## Recommended CI assertion
116
+
117
+ Add this once at the top of your test setup so a misconfiguration breaks CI loudly:
118
+
119
+ ```ts
120
+ // tests/setup.ts
121
+ function assertSafeMode(): void {
122
+ const env = process.env.NODE_ENV ?? 'unknown';
123
+ const secret = process.env.TXNOD_API_SECRET ?? '';
124
+ const isSandbox = secret.startsWith('sk_sandbox_');
125
+ if (env === 'production' && isSandbox) {
126
+ throw new Error(
127
+ '[ci] Sandbox API secret cannot run with NODE_ENV=production',
128
+ );
129
+ }
130
+ if (env !== 'production' && !isSandbox) {
131
+ throw new Error(
132
+ '[ci] Production API secret cannot run outside production',
133
+ );
134
+ }
135
+ }
136
+ assertSafeMode();
137
+ ```
138
+
139
+ And on every webhook event your test handler asserts the `mode` matches the runtime:
140
+
141
+ ```ts
142
+ import { verifyWebhookSignature } from '@txnod/sdk';
143
+
144
+ export async function POST(request: Request): Promise<Response> {
145
+ const rawBody = await request.text();
146
+ const event = verifyWebhookSignature(
147
+ request.headers,
148
+ rawBody,
149
+ process.env.TXNOD_WEBHOOK_SECRET!,
150
+ );
151
+ if (process.env.NODE_ENV === 'production' && event.mode === 'sandbox') {
152
+ throw new Error('refusing to process sandbox event in production');
153
+ }
154
+ // ...
155
+ return Response.json({ ok: true });
156
+ }
157
+ ```
158
+
159
+ See [`./examples/sandbox-vitest-suite.md`](./examples/sandbox-vitest-suite.md) for an end-to-end Vitest harness covering all seven webhook event types.
@@ -0,0 +1,132 @@
1
+ ---
2
+ title: "Idempotency, reorgs, and reverts"
3
+ description: "Four idempotency layers you must respect: external_id on create, event_id on webhooks, tx-level uniqueness, reorg re-confirmation."
4
+ sdk_version: 1.0.0
5
+ ---
6
+
7
+ # Idempotency, reorgs, and reverts
8
+
9
+ TxNod is designed around four idempotency layers. Integrating correctly means respecting each one.
10
+
11
+ ## 1. Invoice creation — `(project_id, external_id)`
12
+
13
+ `createInvoice` is idempotent on the pair `(project_id, external_id)`. A second attempt with the same `external_id` raises `TxnodExternalIdConflictError` — **treat it as success** and fetch the existing invoice:
14
+
15
+ ```ts
16
+ import { TxnodClient, TxnodExternalIdConflictError } from '@txnod/sdk';
17
+
18
+ import type { Coin } from '@txnod/sdk';
19
+
20
+ async function createOrLoadInvoice(args: {
21
+ external_id: string;
22
+ coin: Coin;
23
+ amount_usd: number;
24
+ callback_url: string;
25
+ }) {
26
+ try {
27
+ return await client.createInvoice(args);
28
+ } catch (err) {
29
+ if (err instanceof TxnodExternalIdConflictError) {
30
+ const page = await client.searchInvoices({ external_id: args.external_id, limit: 1 });
31
+ return page.items[0]!;
32
+ }
33
+ throw err;
34
+ }
35
+ }
36
+ ```
37
+
38
+ **Your `external_id` must be stable for the thing you are charging for.** Use the order / invoice id from your own system — not a random UUID regenerated on retry.
39
+
40
+ ## 2. Webhook events — `event_id`
41
+
42
+ Every webhook event carries a stable `event_id` (ULID). Retries of the **same** event use the **same** `event_id`. You MUST de-duplicate on it.
43
+
44
+ ```sql
45
+ -- Minimal partner-side schema
46
+ CREATE TABLE txnod_webhook_events_processed (
47
+ event_id TEXT PRIMARY KEY,
48
+ event_type TEXT NOT NULL,
49
+ invoice_id TEXT,
50
+ processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
51
+ );
52
+ ```
53
+
54
+ ```ts
55
+ import { verifyWebhookSignature } from '@txnod/sdk';
56
+
57
+ export async function POST(request: Request): Promise<Response> {
58
+ const rawBody = await request.text();
59
+ const event = verifyWebhookSignature(request.headers, rawBody, secret);
60
+
61
+ const inserted = await db.insertIgnore('txnod_webhook_events_processed', {
62
+ event_id: event.event_id,
63
+ event_type: event.event_type,
64
+ invoice_id: event.data.invoice_id, // typed as `string` on every event_type
65
+ });
66
+ if (!inserted) {
67
+ // We have already processed this event. Ack and move on.
68
+ return Response.json({ ok: true });
69
+ }
70
+
71
+ // First time we see this event_id — safe to take side effects.
72
+ await applyBusinessEffect(event);
73
+ return Response.json({ ok: true });
74
+ }
75
+ ```
76
+
77
+ Do **not** key idempotency on `(invoice_id, event_type)` — resends and reorg re-confirmations both preserve `event_id` but can produce legitimate repeats of `(invoice_id, event_type)`.
78
+
79
+ ## 3. On-chain credit — `(tx_hash, to_address, tx_output_index)`
80
+
81
+ The TxNod server uses `UNIQUE (tx_hash, to_address, tx_output_index)` to ensure a single UTXO (or EVM log / Tron trigger / Cardano output) credits an invoice exactly once, even across provider retries and watcher restarts. You do not need to reimplement this — it is transparent to you — but understanding it explains why a multi-output transaction can surface multiple `invoice.detected` events (one per matching output).
82
+
83
+ ## 4. Reorg re-confirmation — `(invoice_id, terminal_status)`
84
+
85
+ When a chain reorg evicts a transaction that had already confirmed, the invoice moves from `paid`/`overpaid` → `reverted` and emits `invoice.reverted` **once**. If the same transaction later re-confirms on the new canonical chain, the invoice transitions back to `paid`/`overpaid` and emits a fresh `invoice.paid`/`invoice.overpaid` with a stable `event_id` derived from `(invoice_id, terminal_status)`.
86
+
87
+ Rules:
88
+
89
+ - **`paid` is not terminal.** Do not release digital goods or disburse funds on `invoice.paid` alone. Wait for `confirmations` ≥ your finalization threshold (configurable per project), or impose your own time-based wait.
90
+ - **If you act on `paid` and later receive `invoice.reverted`**, you must roll the action back. Plan the reversal path from day one.
91
+ - **Re-confirmations are idempotent.** If you already fulfilled on the first `paid`, don't fulfill again on the second.
92
+
93
+ ### Minimal finite-state pattern
94
+
95
+ ```ts
96
+ type OrderState = 'awaiting_payment' | 'paid' | 'fulfilled' | 'reverted';
97
+
98
+ async function handleEvent(event: WebhookEvent) {
99
+ const order = await loadOrderByInvoiceId(event.data.invoice_id);
100
+
101
+ switch (event.event_type) {
102
+ case 'invoice.paid':
103
+ case 'invoice.overpaid':
104
+ if (order.state === 'awaiting_payment') {
105
+ await setOrderState(order.id, 'paid');
106
+ await fulfilIfReady(order); // only when confirmations >= threshold
107
+ } else if (order.state === 'reverted') {
108
+ // Re-confirmation after an earlier revert.
109
+ await setOrderState(order.id, 'paid');
110
+ }
111
+ return;
112
+
113
+ case 'invoice.reverted':
114
+ if (order.state === 'fulfilled') {
115
+ await reverseFulfilment(order); // your compensation logic
116
+ }
117
+ await setOrderState(order.id, 'reverted');
118
+ return;
119
+
120
+ case 'invoice.expired':
121
+ case 'invoice.expired_paid_late':
122
+ // Terminal — see your own policy for late payments.
123
+ return;
124
+ }
125
+ }
126
+ ```
127
+
128
+ ## Related
129
+
130
+ - [`02-invoices.md`](./02-invoices.md#lifecycle) — full status table
131
+ - [`04-webhooks.md`](./04-webhooks.md) — event envelope and retry semantics
132
+ - [`05-errors.md`](./05-errors.md) — `TxnodExternalIdConflictError` and `TxnodOrphanAlreadyAttributedError`
@@ -0,0 +1,97 @@
1
+ ---
2
+ title: "Express webhook receiver"
3
+ description: "Express webhook endpoint using express.raw so verifyWebhookSignature sees the exact request bytes."
4
+ sdk_version: 1.0.0
5
+ ---
6
+
7
+ # Express webhook receiver
8
+
9
+ Express's default `express.json()` parses and re-serializes the body — this breaks HMAC verification. For the webhook route you must mount `express.raw({ type: 'application/json' })`.
10
+
11
+ ## Full example
12
+
13
+ ```ts
14
+ import express, { type Request, type Response } from 'express';
15
+ import {
16
+ TxnodHmacError,
17
+ TxnodSignatureFormatError,
18
+ TxnodTimestampError,
19
+ verifyWebhookSignature,
20
+ } from '@txnod/sdk';
21
+
22
+ const app = express();
23
+
24
+ // Webhook route — raw body. Register BEFORE any generic JSON middleware.
25
+ app.post(
26
+ '/api/txnod-webhook',
27
+ express.raw({ type: 'application/json' }),
28
+ async (req: Request, res: Response) => {
29
+ // req.body is a Buffer here because of express.raw.
30
+ const rawBody = (req.body as Buffer).toString('utf8');
31
+
32
+ try {
33
+ const event = verifyWebhookSignature(
34
+ req.headers as Record<string, string | string[]>,
35
+ rawBody,
36
+ process.env.TXNOD_WEBHOOK_SECRET!,
37
+ );
38
+
39
+ // Idempotency: insert-if-not-exists keyed on event.event_id
40
+ // (see ../06-idempotency.md for the schema).
41
+
42
+ switch (event.event_type) {
43
+ case 'invoice.paid':
44
+ case 'invoice.overpaid':
45
+ // fulfil
46
+ break;
47
+ case 'invoice.reverted':
48
+ // reverse fulfilment if already applied
49
+ break;
50
+ case 'invoice.expired':
51
+ case 'invoice.expired_paid_late':
52
+ case 'invoice.detected':
53
+ case 'invoice.partial':
54
+ break;
55
+ }
56
+
57
+ res.status(200).json({ ok: true });
58
+ } catch (err) {
59
+ if (err instanceof TxnodSignatureFormatError) {
60
+ res.status(401).send('bad header');
61
+ return;
62
+ }
63
+ if (err instanceof TxnodHmacError) {
64
+ res.status(401).send('bad sig');
65
+ return;
66
+ }
67
+ if (err instanceof TxnodTimestampError) {
68
+ console.warn('clock skew seconds:', err.skew_seconds);
69
+ res.status(401).send('stale');
70
+ return;
71
+ }
72
+ throw err;
73
+ }
74
+ },
75
+ );
76
+
77
+ // All other routes can use the JSON parser freely.
78
+ app.use(express.json());
79
+
80
+ // ...
81
+
82
+ app.listen(3000);
83
+ ```
84
+
85
+ ## Things that break silently
86
+
87
+ 1. **Mounting `express.json()` before the webhook route.** The webhook handler then sees `req.body` as a parsed object, not a `Buffer`, and `.toString()` reconstructs JSON that does not byte-match what TxNod signed. Fix: register `express.raw` on the webhook route specifically, before any global `express.json()`.
88
+
89
+ 2. **Reverse proxies that rewrite bodies.** If a proxy normalizes whitespace, re-indents JSON, or adds/removes a trailing newline, the HMAC will not match. Disable body rewriting for the webhook path.
90
+
91
+ 3. **Array-valued headers.** Node on Express exposes `req.headers` where some entries may be `string[]`. `verifyWebhookSignature` handles both shapes, but case-insensitive lookup assumes lowercase names — do not pass a framework-specific wrapper that renames headers.
92
+
93
+ ## Related
94
+
95
+ - [`../04-webhooks.md`](../04-webhooks.md) — signature scheme
96
+ - [`../06-idempotency.md`](../06-idempotency.md) — `event_id` de-duplication
97
+ - [`nextjs-route-handler.md`](./nextjs-route-handler.md) — equivalent for Next.js 16 App Router