@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.
- package/AGENTS.md +29 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/_shared/index.d.ts +68 -0
- package/dist/client-sandbox.d.ts +396 -0
- package/dist/client-sandbox.d.ts.map +1 -0
- package/dist/client-sandbox.js +448 -0
- package/dist/client-sandbox.js.map +1 -0
- package/dist/client.d.ts +429 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +588 -0
- package/dist/client.js.map +1 -0
- package/dist/env.d.ts +29 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +44 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +1887 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2107 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/internals/error-ctor-map.d.ts +11 -0
- package/dist/internals/error-ctor-map.d.ts.map +1 -0
- package/dist/internals/error-ctor-map.js +75 -0
- package/dist/internals/error-ctor-map.js.map +1 -0
- package/dist/internals/fetch-with-retry.d.ts +34 -0
- package/dist/internals/fetch-with-retry.d.ts.map +1 -0
- package/dist/internals/fetch-with-retry.js +233 -0
- package/dist/internals/fetch-with-retry.js.map +1 -0
- package/dist/internals/hmac.d.ts +2 -0
- package/dist/internals/hmac.d.ts.map +1 -0
- package/dist/internals/hmac.js +10 -0
- package/dist/internals/hmac.js.map +1 -0
- package/dist/internals/logger.d.ts +9 -0
- package/dist/internals/logger.d.ts.map +1 -0
- package/dist/internals/logger.js +16 -0
- package/dist/internals/logger.js.map +1 -0
- package/dist/internals/parse-problem-details.d.ts +3 -0
- package/dist/internals/parse-problem-details.d.ts.map +1 -0
- package/dist/internals/parse-problem-details.js +76 -0
- package/dist/internals/parse-problem-details.js.map +1 -0
- package/dist/internals/synthetic-details.d.ts +12 -0
- package/dist/internals/synthetic-details.d.ts.map +1 -0
- package/dist/internals/synthetic-details.js +19 -0
- package/dist/internals/synthetic-details.js.map +1 -0
- package/dist/verify/chains/bsc.d.ts +17 -0
- package/dist/verify/chains/bsc.d.ts.map +1 -0
- package/dist/verify/chains/bsc.js +15 -0
- package/dist/verify/chains/bsc.js.map +1 -0
- package/dist/verify/chains/btc.d.ts +22 -0
- package/dist/verify/chains/btc.d.ts.map +1 -0
- package/dist/verify/chains/btc.js +55 -0
- package/dist/verify/chains/btc.js.map +1 -0
- package/dist/verify/chains/cardano.d.ts +73 -0
- package/dist/verify/chains/cardano.d.ts.map +1 -0
- package/dist/verify/chains/cardano.js +175 -0
- package/dist/verify/chains/cardano.js.map +1 -0
- package/dist/verify/chains/evm.d.ts +21 -0
- package/dist/verify/chains/evm.d.ts.map +1 -0
- package/dist/verify/chains/evm.js +46 -0
- package/dist/verify/chains/evm.js.map +1 -0
- package/dist/verify/chains/polygon.d.ts +17 -0
- package/dist/verify/chains/polygon.d.ts.map +1 -0
- package/dist/verify/chains/polygon.js +15 -0
- package/dist/verify/chains/polygon.js.map +1 -0
- package/dist/verify/chains/secp256k1-bip32.d.ts +20 -0
- package/dist/verify/chains/secp256k1-bip32.d.ts.map +1 -0
- package/dist/verify/chains/secp256k1-bip32.js +88 -0
- package/dist/verify/chains/secp256k1-bip32.js.map +1 -0
- package/dist/verify/chains/ton-cell.d.ts +179 -0
- package/dist/verify/chains/ton-cell.d.ts.map +1 -0
- package/dist/verify/chains/ton-cell.js +614 -0
- package/dist/verify/chains/ton-cell.js.map +1 -0
- package/dist/verify/chains/ton.d.ts +84 -0
- package/dist/verify/chains/ton.d.ts.map +1 -0
- package/dist/verify/chains/ton.js +131 -0
- package/dist/verify/chains/ton.js.map +1 -0
- package/dist/verify/chains/tron.d.ts +21 -0
- package/dist/verify/chains/tron.d.ts.map +1 -0
- package/dist/verify/chains/tron.js +42 -0
- package/dist/verify/chains/tron.js.map +1 -0
- package/dist/verify/config.d.ts +41 -0
- package/dist/verify/config.d.ts.map +1 -0
- package/dist/verify/config.js +120 -0
- package/dist/verify/config.js.map +1 -0
- package/dist/verify/errors.d.ts +56 -0
- package/dist/verify/errors.d.ts.map +1 -0
- package/dist/verify/errors.js +58 -0
- package/dist/verify/errors.js.map +1 -0
- package/dist/verify/index.d.ts +119 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +166 -0
- package/dist/verify/index.js.map +1 -0
- package/dist/verify/xpub-safety.d.ts +33 -0
- package/dist/verify/xpub-safety.d.ts.map +1 -0
- package/dist/verify/xpub-safety.js +54 -0
- package/dist/verify/xpub-safety.js.map +1 -0
- package/dist/verify-webhook-signature.d.ts +30 -0
- package/dist/verify-webhook-signature.d.ts.map +1 -0
- package/dist/verify-webhook-signature.js +84 -0
- package/dist/verify-webhook-signature.js.map +1 -0
- package/docs/00-getting-started.md +135 -0
- package/docs/01-authentication.md +114 -0
- package/docs/02-invoices.md +216 -0
- package/docs/03-rates-and-quotes.md +82 -0
- package/docs/04-webhooks.md +126 -0
- package/docs/05-errors.md +199 -0
- package/docs/05-sandbox.md +159 -0
- package/docs/06-idempotency.md +132 -0
- package/docs/examples/express-webhook-receiver.md +97 -0
- package/docs/examples/nextjs-route-handler.md +206 -0
- package/docs/examples/sandbox-vitest-suite.md +263 -0
- package/docs/index.md +66 -0
- package/docs/reference/client.md +392 -0
- package/docs/reference/errors.md +161 -0
- package/docs/reference/types.md +400 -0
- package/package.json +53 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Next.js 16 App Router — create invoice + verify webhook"
|
|
3
|
+
description: "Two complete route handlers: POST /api/checkout to create an invoice, POST /api/txnod-webhook to verify and handle events."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Next.js 16 App Router example
|
|
8
|
+
|
|
9
|
+
Two files. Both run on the Node.js runtime (the SDK uses `node:crypto`). Do not set `runtime = 'edge'` on these routes.
|
|
10
|
+
|
|
11
|
+
Assumed env vars (see [`../00-getting-started.md`](../00-getting-started.md)):
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
TXNOD_PROJECT_ID=01JXXXXXXXXXXXXXXXXXXXXXXX
|
|
15
|
+
TXNOD_API_SECRET=...
|
|
16
|
+
TXNOD_WEBHOOK_SECRET=... # same value as TXNOD_API_SECRET
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Shared client — `src/lib/txnod.ts`
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { TxnodClient } from '@txnod/sdk';
|
|
23
|
+
|
|
24
|
+
export const txnod = new TxnodClient({
|
|
25
|
+
projectId: process.env.TXNOD_PROJECT_ID!,
|
|
26
|
+
apiSecret: process.env.TXNOD_API_SECRET!,
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Create invoice — `src/app/api/checkout/route.ts`
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import {
|
|
35
|
+
TxnodCoinNotEnabledError,
|
|
36
|
+
TxnodError,
|
|
37
|
+
TxnodExternalIdConflictError,
|
|
38
|
+
TxnodPoolExhaustedError,
|
|
39
|
+
TxnodRateLimitError,
|
|
40
|
+
TxnodValidationError,
|
|
41
|
+
} from '@txnod/sdk';
|
|
42
|
+
import { txnod } from '@/lib/txnod';
|
|
43
|
+
|
|
44
|
+
export const runtime = 'nodejs';
|
|
45
|
+
|
|
46
|
+
export async function POST(request: Request): Promise<Response> {
|
|
47
|
+
const body = (await request.json()) as {
|
|
48
|
+
order_id: string;
|
|
49
|
+
amount_usd: number;
|
|
50
|
+
coin: 'btc' | 'eth' | 'usdt_erc20' | 'usdc_erc20' | 'trx' | 'usdt_trc20' | 'ada' | 'pol' | 'usdt_polygon' | 'usdc_polygon' | 'bnb' | 'usdt_bep20' | 'usdc_bep20' | 'ton' | 'usdt_ton';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const invoice = await txnod.createInvoice({
|
|
55
|
+
external_id: body.order_id ?? randomUUID(),
|
|
56
|
+
coin: body.coin,
|
|
57
|
+
amount_usd: body.amount_usd,
|
|
58
|
+
callback_url: new URL('/api/txnod-webhook', request.url).toString(),
|
|
59
|
+
metadata: { source: 'checkout' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return Response.json({
|
|
63
|
+
invoice_id: invoice.id,
|
|
64
|
+
pay_to: invoice.address,
|
|
65
|
+
amount_crypto: invoice.amount_crypto,
|
|
66
|
+
amount_crypto_units: invoice.amount_crypto_units,
|
|
67
|
+
payment_uri: invoice.payment_uri,
|
|
68
|
+
expires_at_iso: invoice.expires_at_iso,
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof TxnodExternalIdConflictError) {
|
|
72
|
+
// Idempotent: fetch the existing invoice and return it.
|
|
73
|
+
const page = await txnod.searchInvoices({
|
|
74
|
+
external_id: body.order_id,
|
|
75
|
+
limit: 1,
|
|
76
|
+
});
|
|
77
|
+
const existing = page.items[0];
|
|
78
|
+
if (existing !== undefined) {
|
|
79
|
+
return Response.json({
|
|
80
|
+
invoice_id: existing.id,
|
|
81
|
+
pay_to: existing.address,
|
|
82
|
+
amount_crypto: existing.amount_crypto,
|
|
83
|
+
amount_crypto_units: existing.amount_crypto_units,
|
|
84
|
+
payment_uri: existing.payment_uri,
|
|
85
|
+
expires_at_iso: existing.expires_at_iso,
|
|
86
|
+
reused: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (err instanceof TxnodValidationError) {
|
|
92
|
+
return Response.json(
|
|
93
|
+
{ error: 'validation_error', errors: err.raw.errors, request_id: err.request_id },
|
|
94
|
+
{ status: 422 },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (err instanceof TxnodCoinNotEnabledError) {
|
|
99
|
+
const rates = await txnod.getRates({});
|
|
100
|
+
return Response.json(
|
|
101
|
+
{
|
|
102
|
+
error: 'coin_not_enabled',
|
|
103
|
+
enabled_coins: Object.keys(rates.rates),
|
|
104
|
+
request_id: err.request_id,
|
|
105
|
+
},
|
|
106
|
+
{ status: 422 },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (err instanceof TxnodRateLimitError) {
|
|
111
|
+
return Response.json(
|
|
112
|
+
{ error: 'rate_limited', retry_after_seconds: err.retry_after_seconds },
|
|
113
|
+
{ status: 429 },
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (err instanceof TxnodPoolExhaustedError) {
|
|
118
|
+
return Response.json(
|
|
119
|
+
{ error: 'pool_exhausted', retry_after_seconds: err.retry_after_seconds },
|
|
120
|
+
{ status: 503 },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (err instanceof TxnodError) {
|
|
125
|
+
return Response.json(
|
|
126
|
+
{ error: err.error_code, request_id: err.request_id },
|
|
127
|
+
{ status: err.status },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Verify webhook — `src/app/api/txnod-webhook/route.ts`
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import {
|
|
139
|
+
TxnodHmacError,
|
|
140
|
+
TxnodSignatureFormatError,
|
|
141
|
+
TxnodTimestampError,
|
|
142
|
+
verifyWebhookSignature,
|
|
143
|
+
} from '@txnod/sdk';
|
|
144
|
+
import { recordEventOnce, fulfilOrder, reverseOrder } from '@/lib/orders';
|
|
145
|
+
|
|
146
|
+
export const runtime = 'nodejs';
|
|
147
|
+
|
|
148
|
+
export async function POST(request: Request): Promise<Response> {
|
|
149
|
+
const rawBody = await request.text(); // exact bytes, do not pre-parse
|
|
150
|
+
|
|
151
|
+
let event;
|
|
152
|
+
try {
|
|
153
|
+
event = verifyWebhookSignature(
|
|
154
|
+
request.headers,
|
|
155
|
+
rawBody,
|
|
156
|
+
process.env.TXNOD_WEBHOOK_SECRET!,
|
|
157
|
+
);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
if (err instanceof TxnodSignatureFormatError) return new Response('bad header', { status: 401 });
|
|
160
|
+
if (err instanceof TxnodHmacError) return new Response('bad sig', { status: 401 });
|
|
161
|
+
if (err instanceof TxnodTimestampError) {
|
|
162
|
+
console.warn('txnod webhook clock skew seconds:', err.skew_seconds);
|
|
163
|
+
return new Response('stale', { status: 401 });
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Idempotency: insert-if-not-exists keyed on event_id.
|
|
169
|
+
const isFirstTime = await recordEventOnce(event.event_id, event.event_type);
|
|
170
|
+
if (!isFirstTime) return Response.json({ ok: true, duplicate: true });
|
|
171
|
+
|
|
172
|
+
switch (event.event_type) {
|
|
173
|
+
case 'invoice.paid':
|
|
174
|
+
case 'invoice.overpaid':
|
|
175
|
+
// event.data.invoice_id is `string` here — no cast needed.
|
|
176
|
+
await fulfilOrder(event.data.invoice_id);
|
|
177
|
+
break;
|
|
178
|
+
case 'invoice.reverted':
|
|
179
|
+
await reverseOrder(event.data.invoice_id);
|
|
180
|
+
break;
|
|
181
|
+
case 'invoice.expired':
|
|
182
|
+
case 'invoice.expired_paid_late':
|
|
183
|
+
// Handle per your business rules.
|
|
184
|
+
break;
|
|
185
|
+
case 'invoice.detected':
|
|
186
|
+
case 'invoice.partial':
|
|
187
|
+
// Optional UI signal — do not fulfil yet.
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Response.json({ ok: true });
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Notes
|
|
196
|
+
|
|
197
|
+
- **Do not use the edge runtime** — the SDK relies on `node:crypto`.
|
|
198
|
+
- **Do not wrap the webhook route in JSON parsing middleware** — HMAC verification requires the exact request bytes.
|
|
199
|
+
- **Fulfilment policy.** The example fulfils on `invoice.paid`. If your policy demands finalization-depth confirmations before release, gate `fulfilOrder` on `event.data.confirmations` against your threshold (see [`../06-idempotency.md`](../06-idempotency.md)).
|
|
200
|
+
- **Reversal policy.** `reverseOrder` must be safe to run even if the order had not been fulfilled — a `reverted` event can arrive before the paid event has been fully processed in edge cases.
|
|
201
|
+
|
|
202
|
+
## Related
|
|
203
|
+
|
|
204
|
+
- [`../00-getting-started.md`](../00-getting-started.md) — env var setup
|
|
205
|
+
- [`../06-idempotency.md`](../06-idempotency.md) — why the `recordEventOnce` pattern
|
|
206
|
+
- [`express-webhook-receiver.md`](./express-webhook-receiver.md) — equivalent for Express
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Sandbox: end-to-end Vitest suite"
|
|
3
|
+
description: "Vitest fixture that exercises every sandbox simulate-* method and asserts the integrator's webhook handler updates state correctly."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Sandbox: end-to-end Vitest suite
|
|
8
|
+
|
|
9
|
+
This example walks every webhook event type the SDK can produce: `invoice.detected`, `invoice.paid`, `invoice.overpaid`, `invoice.partial`, `invoice.expired`, `invoice.expired_paid_late`, `invoice.reverted`. Drop it into your project as `tests/sandbox.test.ts`. The handler under test (`processWebhook`) is a plausible stand-in for your idempotent webhook receiver — replace its body with your real one.
|
|
10
|
+
|
|
11
|
+
## How the harness wires up
|
|
12
|
+
|
|
13
|
+
There is **no `signature_hex` / `payload_raw` field on `client.listWebhookEvents()`** — that endpoint is a forensic delivery-log only. Re-verifying a webhook means parsing the envelope your callback URL actually received: the dispatcher's HTTP request, with `X-Txnod-Signature` and the raw body bytes. The harness below stands up a tiny `node:http` capture-server bound to a free local port; the request handler reads the raw body off the request stream and runs `verifyWebhookSignature(req.headers, rawBody, secret)` to parse + verify the typed `WebhookEvent`. Each `simulate*` call is followed by a polling loop that drains the captured queue keyed by `invoiceId` until the expected event arrives.
|
|
14
|
+
|
|
15
|
+
The order map is keyed by `invoice.id` (a real wire field on `WebhookEventData.invoice_id`) joined to a side-table that captures `external_id` at `createInvoice` time — `event.data.external_id` does **not** exist on the wire shape, and reading it would silently coerce to `undefined`.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { createServer, type Server } from 'node:http';
|
|
20
|
+
import {
|
|
21
|
+
TxnodClient,
|
|
22
|
+
verifyWebhookSignature,
|
|
23
|
+
type WebhookEvent,
|
|
24
|
+
} from '@txnod/sdk';
|
|
25
|
+
|
|
26
|
+
interface OrderState {
|
|
27
|
+
status: 'pending' | 'detected' | 'paid' | 'overpaid' | 'partial' | 'expired' | 'reverted';
|
|
28
|
+
events: WebhookEvent['event_type'][];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// invoice.id (wire field) -> external_id (partner-side, never on the wire).
|
|
32
|
+
// Captured at createInvoice time; consulted in processWebhook to key the
|
|
33
|
+
// order map by your business identifier.
|
|
34
|
+
const invoiceToExternalId = new Map<string, string>();
|
|
35
|
+
const orders = new Map<string, OrderState>();
|
|
36
|
+
|
|
37
|
+
function processWebhook(event: WebhookEvent): void {
|
|
38
|
+
const externalId = invoiceToExternalId.get(event.data.invoice_id);
|
|
39
|
+
if (externalId === undefined) return; // unknown invoice (shouldn't happen in the suite below)
|
|
40
|
+
const state: OrderState = orders.get(externalId) ?? {
|
|
41
|
+
status: 'pending',
|
|
42
|
+
events: [],
|
|
43
|
+
};
|
|
44
|
+
if (state.events.includes(event.event_type)) return; // idempotent dedup on event_id is the wire contract; this is a coarser dedup for the example
|
|
45
|
+
state.events.push(event.event_type);
|
|
46
|
+
switch (event.event_type) {
|
|
47
|
+
case 'invoice.detected':
|
|
48
|
+
state.status = 'detected';
|
|
49
|
+
break;
|
|
50
|
+
case 'invoice.paid':
|
|
51
|
+
state.status = 'paid';
|
|
52
|
+
break;
|
|
53
|
+
case 'invoice.overpaid':
|
|
54
|
+
state.status = 'overpaid';
|
|
55
|
+
break;
|
|
56
|
+
case 'invoice.partial':
|
|
57
|
+
state.status = 'partial';
|
|
58
|
+
break;
|
|
59
|
+
case 'invoice.expired':
|
|
60
|
+
case 'invoice.expired_paid_late':
|
|
61
|
+
// Most integrations treat both terminal expiry events the same way; if
|
|
62
|
+
// you accept late payments and want to credit them as paid-late,
|
|
63
|
+
// branch on event_type before this switch.
|
|
64
|
+
state.status = 'expired';
|
|
65
|
+
break;
|
|
66
|
+
case 'invoice.reverted':
|
|
67
|
+
state.status = 'reverted';
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
orders.set(externalId, state);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SECRET = process.env.TXNOD_WEBHOOK_SECRET!;
|
|
74
|
+
const PROJECT_ID = process.env.TXNOD_PROJECT_ID!;
|
|
75
|
+
|
|
76
|
+
const client = new TxnodClient({
|
|
77
|
+
projectId: PROJECT_ID,
|
|
78
|
+
apiSecret: process.env.TXNOD_API_SECRET!,
|
|
79
|
+
environment: 'non-production',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Per-invoice queue of typed events the local capture-server has verified.
|
|
83
|
+
const capturedByInvoice = new Map<string, WebhookEvent[]>();
|
|
84
|
+
|
|
85
|
+
let receiver: Server;
|
|
86
|
+
let receiverUrl: string;
|
|
87
|
+
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
receiver = createServer((req, res) => {
|
|
90
|
+
if (req.method !== 'POST') {
|
|
91
|
+
res.statusCode = 405;
|
|
92
|
+
res.end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const chunks: Buffer[] = [];
|
|
96
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
97
|
+
req.on('end', () => {
|
|
98
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
99
|
+
try {
|
|
100
|
+
const event = verifyWebhookSignature(req.headers, rawBody, SECRET);
|
|
101
|
+
const queue = capturedByInvoice.get(event.data.invoice_id) ?? [];
|
|
102
|
+
queue.push(event);
|
|
103
|
+
capturedByInvoice.set(event.data.invoice_id, queue);
|
|
104
|
+
res.statusCode = 200;
|
|
105
|
+
} catch {
|
|
106
|
+
// HMAC mismatch — the dispatcher will retry; surface 401 so retries fire
|
|
107
|
+
// and the test can assert on captured-event counts.
|
|
108
|
+
res.statusCode = 401;
|
|
109
|
+
}
|
|
110
|
+
res.end();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
await new Promise<void>((resolve) => receiver.listen(0, '127.0.0.1', resolve));
|
|
114
|
+
const addr = receiver.address();
|
|
115
|
+
if (addr === null || typeof addr === 'string') {
|
|
116
|
+
throw new Error('receiver bound to unexpected address');
|
|
117
|
+
}
|
|
118
|
+
receiverUrl = `http://127.0.0.1:${addr.port}/`;
|
|
119
|
+
await client.sandbox.reset(PROJECT_ID);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterAll(async () => {
|
|
123
|
+
await client.sandbox.reset(PROJECT_ID);
|
|
124
|
+
await new Promise<void>((resolve, reject) =>
|
|
125
|
+
receiver.close((err) => (err === undefined ? resolve() : reject(err))),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Per-test isolation runs in-memory only — clearing the captured-event queue,
|
|
130
|
+
// the side-table, and the order map. We intentionally do NOT call
|
|
131
|
+
// `client.sandbox.reset(PROJECT_ID)` between tests: the dispatcher worker is
|
|
132
|
+
// async-decoupled from the simulate-* call, so a mid-test reset can race
|
|
133
|
+
// late-arriving webhooks (and surface flakes via deliveries that key on a
|
|
134
|
+
// now-orphaned invoice_id). The single `beforeAll`/`afterAll` reset bookends
|
|
135
|
+
// the whole suite — that's the shape this example is meant to teach.
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
capturedByInvoice.clear();
|
|
138
|
+
invoiceToExternalId.clear();
|
|
139
|
+
orders.clear();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
async function createInvoice(args: {
|
|
143
|
+
externalId: string;
|
|
144
|
+
amountUsd: number;
|
|
145
|
+
coin: 'btc' | 'eth' | 'usdt_trc20';
|
|
146
|
+
}): Promise<string> {
|
|
147
|
+
const invoice = await client.createInvoice({
|
|
148
|
+
amount_usd: args.amountUsd,
|
|
149
|
+
coin: args.coin,
|
|
150
|
+
external_id: args.externalId,
|
|
151
|
+
callback_url: receiverUrl,
|
|
152
|
+
});
|
|
153
|
+
invoiceToExternalId.set(invoice.id, args.externalId);
|
|
154
|
+
return invoice.id;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function waitForEvents(
|
|
158
|
+
invoiceId: string,
|
|
159
|
+
expected: number,
|
|
160
|
+
timeoutMs = 5000,
|
|
161
|
+
): Promise<WebhookEvent[]> {
|
|
162
|
+
const deadline = Date.now() + timeoutMs;
|
|
163
|
+
while (Date.now() < deadline) {
|
|
164
|
+
const queue = capturedByInvoice.get(invoiceId) ?? [];
|
|
165
|
+
if (queue.length >= expected) return [...queue];
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
}
|
|
168
|
+
return capturedByInvoice.get(invoiceId) ?? [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('Sandbox webhook coverage', () => {
|
|
172
|
+
it('detected → paid emits invoice.detected then invoice.paid with mode=sandbox', async () => {
|
|
173
|
+
const invoiceId = await createInvoice({ externalId: 'order-paid', amountUsd: 5, coin: 'btc' });
|
|
174
|
+
await client.sandbox.simulateDetect(invoiceId, { seed: 'paid-seed' });
|
|
175
|
+
await client.sandbox.simulatePaid(invoiceId);
|
|
176
|
+
const events = await waitForEvents(invoiceId, 2);
|
|
177
|
+
for (const e of events) processWebhook(e);
|
|
178
|
+
expect(orders.get('order-paid')?.status).toBe('paid');
|
|
179
|
+
expect(events.every((e) => e.mode === 'sandbox')).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('detected → overpaid emits invoice.overpaid', async () => {
|
|
183
|
+
const invoiceId = await createInvoice({ externalId: 'order-overpaid', amountUsd: 5, coin: 'eth' });
|
|
184
|
+
await client.sandbox.simulateDetect(invoiceId);
|
|
185
|
+
await client.sandbox.simulateOverpaid(invoiceId, { multiplier: 1.5 });
|
|
186
|
+
const events = await waitForEvents(invoiceId, 2);
|
|
187
|
+
for (const e of events) processWebhook(e);
|
|
188
|
+
expect(orders.get('order-overpaid')?.status).toBe('overpaid');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('detected → partial emits invoice.partial and invoice stays open', async () => {
|
|
192
|
+
const invoiceId = await createInvoice({ externalId: 'order-partial', amountUsd: 5, coin: 'usdt_trc20' });
|
|
193
|
+
await client.sandbox.simulateDetect(invoiceId);
|
|
194
|
+
await client.sandbox.simulatePartial(invoiceId, { fraction: 0.5 });
|
|
195
|
+
const events = await waitForEvents(invoiceId, 2);
|
|
196
|
+
for (const e of events) processWebhook(e);
|
|
197
|
+
expect(orders.get('order-partial')?.status).toBe('partial');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('pending → expired emits invoice.expired', async () => {
|
|
201
|
+
const invoiceId = await createInvoice({ externalId: 'order-expired', amountUsd: 5, coin: 'btc' });
|
|
202
|
+
await client.sandbox.simulateExpire(invoiceId);
|
|
203
|
+
const events = await waitForEvents(invoiceId, 1);
|
|
204
|
+
for (const e of events) processWebhook(e);
|
|
205
|
+
expect(orders.get('order-expired')?.status).toBe('expired');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('expired → expired_paid_late emits invoice.expired_paid_late', async () => {
|
|
209
|
+
const invoiceId = await createInvoice({ externalId: 'order-late', amountUsd: 5, coin: 'btc' });
|
|
210
|
+
await client.sandbox.simulateExpire(invoiceId);
|
|
211
|
+
await client.sandbox.simulateLatePayment(invoiceId);
|
|
212
|
+
const events = await waitForEvents(invoiceId, 2);
|
|
213
|
+
for (const e of events) processWebhook(e);
|
|
214
|
+
expect(orders.get('order-late')?.status).toBe('expired');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('paid → reverted → paid (reorg + reconfirm) emits invoice.reverted then a fresh invoice.paid', async () => {
|
|
218
|
+
const invoiceId = await createInvoice({ externalId: 'order-reorg', amountUsd: 5, coin: 'btc' });
|
|
219
|
+
await client.sandbox.simulateDetect(invoiceId);
|
|
220
|
+
await client.sandbox.simulatePaid(invoiceId);
|
|
221
|
+
await client.sandbox.simulateReorg(invoiceId);
|
|
222
|
+
await client.sandbox.simulateReconfirm(invoiceId);
|
|
223
|
+
const events = await waitForEvents(invoiceId, 4);
|
|
224
|
+
for (const e of events) processWebhook(e);
|
|
225
|
+
const types = events.map((e) => e.event_type);
|
|
226
|
+
expect(types).toContain('invoice.reverted');
|
|
227
|
+
expect(types.filter((t) => t === 'invoice.paid').length).toBeGreaterThanOrEqual(2);
|
|
228
|
+
expect(orders.get('order-reorg')?.status).toBe('paid');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('duplicate delivery re-fires the same event_id (idempotency exercise)', async () => {
|
|
232
|
+
const invoiceId = await createInvoice({ externalId: 'order-dup', amountUsd: 5, coin: 'btc' });
|
|
233
|
+
await client.sandbox.simulateDetect(invoiceId);
|
|
234
|
+
await client.sandbox.simulatePaid(invoiceId);
|
|
235
|
+
const before = await waitForEvents(invoiceId, 2);
|
|
236
|
+
await client.sandbox.simulateDuplicateDelivery(invoiceId);
|
|
237
|
+
const after = await waitForEvents(invoiceId, 3);
|
|
238
|
+
const beforeIds = new Set(before.map((e) => e.event_id));
|
|
239
|
+
const afterIds = new Set(after.map((e) => e.event_id));
|
|
240
|
+
// Same event_id reappeared — your idempotency dedup must absorb it.
|
|
241
|
+
expect([...afterIds].some((id) => beforeIds.has(id))).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('clock_advance bumps confirmations and finalises detected invoices', async () => {
|
|
245
|
+
const invoiceId = await createInvoice({ externalId: 'order-clock', amountUsd: 5, coin: 'btc' });
|
|
246
|
+
await client.sandbox.simulateDetect(invoiceId);
|
|
247
|
+
const r = await client.sandbox.clockAdvance(PROJECT_ID, {
|
|
248
|
+
chain: 'btc',
|
|
249
|
+
blocks: 6,
|
|
250
|
+
});
|
|
251
|
+
expect(r.advanced).toBeGreaterThanOrEqual(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Reading the example
|
|
257
|
+
|
|
258
|
+
- **Capture-server harness** — `node:http` server bound to a free local port; the dispatcher's `POST` arrives with the standard `X-Txnod-Signature` header, the raw body is read off the request stream, and `verifyWebhookSignature(req.headers, rawBody, SECRET)` parses + verifies in one call. The verified `WebhookEvent` is queued per-`invoice_id`. No SDK listing call is involved — that endpoint is a forensic-only surface and does not return signing material or raw bodies.
|
|
259
|
+
- **Order map key** — the wire shape carries `event.data.invoice_id` (a real field on `WebhookEventData`); it does **not** carry your `external_id`. The harness records `invoice.id → external_id` at `createInvoice` time so `processWebhook` can look up your business identifier without a server round-trip.
|
|
260
|
+
- **Per-event idempotency check** — the `processWebhook` stand-in dedups on `(externalId, event_type)` for clarity. **Your real handler must dedup on `event_id`** (same `event_id` arrives on every retry of the same delivery; manual `resendWebhookEvent` mints a fresh `event_id` with `original_event_id` for lineage).
|
|
261
|
+
- **`event.mode === 'sandbox'`** — every webhook envelope carries the `mode` discriminator (`'production' | 'testnet' | 'sandbox'`). Your handler should assert this matches its runtime expectations (see [`05-sandbox.md`](../05-sandbox.md#recommended-ci-assertion)).
|
|
262
|
+
- **`reset()` between runs** — `client.sandbox.reset(projectId)` soft-purges the project's data tail (invoices, transactions, outbox events, address pool) while preserving the project shell, xpubs, bindings, and api_keys. Use it as `beforeAll`/`afterAll`/`afterEach` so tests start from a clean slate.
|
|
263
|
+
- **`simulateDuplicateDelivery`** — the most under-exercised correctness path in real integrations. The same `event_id` arrives twice; the integrator's idempotency layer is the device under test.
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "@txnod/sdk documentation"
|
|
3
|
+
description: "Index and 30-second overview of the agent-targeted docs bundled inside @txnod/sdk."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @txnod/sdk documentation
|
|
8
|
+
|
|
9
|
+
Fully-typed REST client and webhook verifier for [TxNod](https://txnod.com) — a non-custodial crypto payment gateway for BTC, ETH (+ ERC-20 USDT/USDC), TRON (+ TRC-20 USDT), Cardano, Polygon PoS (+ POL and Polygon USDT/USDC), BNB Smart Chain (+ BNB and BEP-20 USDT/USDC), and TON (+ jetton USDT). Node ≥ 20, zero cryptocurrency runtime dependencies.
|
|
10
|
+
|
|
11
|
+
## 30-second overview
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { TxnodClient, verifyWebhookSignature } from '@txnod/sdk';
|
|
15
|
+
|
|
16
|
+
const client = new TxnodClient({
|
|
17
|
+
projectId: process.env.TXNOD_PROJECT_ID!,
|
|
18
|
+
apiSecret: process.env.TXNOD_API_SECRET!,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 1. Create an invoice
|
|
22
|
+
const invoice = await client.createInvoice({
|
|
23
|
+
amount_usd: 9.99,
|
|
24
|
+
coin: 'usdt_trc20',
|
|
25
|
+
external_id: 'order-42',
|
|
26
|
+
callback_url: 'https://your-site.com/api/txnod-webhook',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 2. Show `invoice.address`, `invoice.amount_crypto`, `invoice.payment_uri` to the user
|
|
30
|
+
|
|
31
|
+
// 3. Verify inbound webhooks against TXNOD_WEBHOOK_SECRET (= TXNOD_API_SECRET)
|
|
32
|
+
const event = verifyWebhookSignature(req.headers, rawBody, secret);
|
|
33
|
+
if (event.event_type === 'invoice.paid') { /* ... */ }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Table of contents
|
|
37
|
+
|
|
38
|
+
### Guides (read in order on first integration)
|
|
39
|
+
|
|
40
|
+
- [`00-getting-started.md`](./00-getting-started.md) — install, env vars, first invoice, first verified webhook
|
|
41
|
+
- [`01-authentication.md`](./01-authentication.md) — HMAC scheme, headers, clock skew, key rotation
|
|
42
|
+
- [`02-invoices.md`](./02-invoices.md) — create, fetch, search, cancel; status lifecycle
|
|
43
|
+
- [`03-rates-and-quotes.md`](./03-rates-and-quotes.md) — `getRates` and `quoteAmount`, indicative vs binding
|
|
44
|
+
- [`04-webhooks.md`](./04-webhooks.md) — event types, `verifyWebhookSignature`, retries, resend
|
|
45
|
+
- [`05-errors.md`](./05-errors.md) — every typed error class and the correct branch to write
|
|
46
|
+
- [`05-sandbox.md`](./05-sandbox.md) — `client.sandbox.*`, environment-detection guards, layered defenses, per-chain testnet truth table
|
|
47
|
+
- [`06-idempotency.md`](./06-idempotency.md) — `external_id`, `event_id`, reorgs, reverts
|
|
48
|
+
|
|
49
|
+
### Reference
|
|
50
|
+
|
|
51
|
+
- [`reference/client.md`](./reference/client.md) — every `TxnodClient` method: signature, behaviour, example
|
|
52
|
+
- [`reference/errors.md`](./reference/errors.md) — `error_code` → class table
|
|
53
|
+
- [`reference/types.md`](./reference/types.md) — pointers to Zod schemas that define request/response shapes
|
|
54
|
+
|
|
55
|
+
### Examples
|
|
56
|
+
|
|
57
|
+
- [`examples/nextjs-route-handler.md`](./examples/nextjs-route-handler.md) — Next.js 16 App Router
|
|
58
|
+
- [`examples/express-webhook-receiver.md`](./examples/express-webhook-receiver.md) — Express receiver with raw body
|
|
59
|
+
- [`examples/sandbox-vitest-suite.md`](./examples/sandbox-vitest-suite.md) — end-to-end Vitest suite covering all 7 webhook event types via `client.sandbox.*`
|
|
60
|
+
|
|
61
|
+
## Non-negotiable invariants
|
|
62
|
+
|
|
63
|
+
1. **Non-custodial.** The SDK never handles private keys or mnemonics. `apiSecret` is an HMAC secret.
|
|
64
|
+
2. **Verify webhooks before parsing.** Use [`verifyWebhookSignature`](./04-webhooks.md) — it HMACs, clock-checks, and parses for you.
|
|
65
|
+
3. **Idempotency on four layers.** See [`06-idempotency.md`](./06-idempotency.md).
|
|
66
|
+
4. **`paid` is not terminal until finalization.** Reorgs can revert it to `reverted`. Treat disbursal/fulfilment triggers accordingly.
|