@atumlabs/payment-gateway-client 2.2.0 → 3.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/README.md CHANGED
@@ -1,19 +1,22 @@
1
1
  # Payment Gateway Client
2
2
 
3
3
  TypeScript client for Atum Payment Gateway API with CLI support.
4
+ Full documentation: [docs.atum.xyz](https://docs.atum.xyz).
4
5
 
5
- > **Proprietary software - not open source.**
6
+ > Copyright 2026 Atum Labs, Inc.
6
7
  >
7
- > Copyright (c) 2026 Atum Labs, Inc. All rights reserved. Access to and use of `@atumlabs/payment-gateway-client` are limited to entities expressly authorized by Atum and are governed by the Atum Payment Gateway Client Private Repository License Agreement in the LICENSE file included with this package. Do not redistribute, publish, mirror, sublicense, or provide this package to any unauthorized person. By accessing, installing, copying, or using the package, you agree to the license terms.
8
+ > Licensed under the Apache License, Version 2.0. See the `LICENSE` file
9
+ > distributed with this package.
8
10
 
9
- > NOTE: Payments from EVM chains require prior ERC-20 approval on the source token for the Permit2 contract (0x000000000022D473030F116dDEE9F6B43aC78BA3 on virtually all EVM chains).
10
- >
11
- > (And the same applies for Tron/TRC-20, with a Permit2 address of TJhMXTHQHeQyMD7TcKQFqAePNgG4b31H9m on mainnet or TPUqJPASUn1zLvbLBgRZ5pBYrx7WSe5ahp on Shasta).
11
+ > NOTE: Before your first payment from an EVM or Tron chain, the source token must be approved
12
+ > for the Permit2 contract. Without it a payment is built, signed and accepted by the gateway,
13
+ > and then reverts on-chain at settlement. It is a one-off transaction per wallet and token —
14
+ > see [Approving the source token](#approving-the-source-token) for `ensureSourceApproval`,
15
+ > which does it for you.
12
16
 
13
17
  ## Installation
14
18
 
15
- `@atumlabs/payment-gateway-client` is a private npm package. Once your npm account
16
- has been granted access, install it with npm.
19
+ `@atumlabs/payment-gateway-client` is on the public npm registry.
17
20
 
18
21
  To use the SDK in a project:
19
22
 
@@ -30,13 +33,40 @@ npm install -g @atumlabs/payment-gateway-client
30
33
  A project-local install also provides the commands, under `node_modules/.bin`, so
31
34
  `npx send-payment ...` works without a global install.
32
35
 
36
+ ## Supported corridors
37
+
38
+ A payment moves value from a source asset to a destination asset. The two can be on
39
+ different chains, and in different chain families.
40
+
41
+ Assets are named with [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) identifiers:
42
+
43
+ | Chain family | Network | Example asset identifier |
44
+ | --- | --- | --- |
45
+ | EVM | Base Sepolia | `eip155:84532/erc20:0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
46
+ | Solana | Devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU` |
47
+ | Tron | Shasta | `tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs` |
48
+
49
+ Every row above is a testnet, so nothing here settles real value if it is copied by mistake.
50
+ The mainnet identifier for the same asset is a different string on every chain — take it from
51
+ [supported assets](https://docs.atum.xyz/get-started/reference/supported-assets), never by
52
+ editing one of these.
53
+
54
+ For the identifier of each supported token and network, see
55
+ [supported assets](https://docs.atum.xyz/get-started/reference/supported-assets) and
56
+ [supported networks](https://docs.atum.xyz/get-started/reference/supported-networks).
57
+
58
+ Copy identifiers exactly: base58 values, such as Solana chain references, token mints
59
+ and account addresses, are case-sensitive. The escrow and verifier addresses for a
60
+ chain are fetched by `preparePaymentRequest`, so you never supply them yourself.
61
+
33
62
  ## CLI Usage
34
63
 
35
- The package provides four commands: `send-payment`, `request-quote`,
64
+ The package provides five commands: `send-payment`, `approve-permit2`, `request-quote`,
36
65
  `quote-status`, and `payment-status`. After a global install:
37
66
 
38
67
  ```bash
39
68
  send-payment [options] <sender> <amount> <sourceAsset> <receiver> <destinationAsset>
69
+ send-payment --submit <file> [options]
40
70
  ```
41
71
 
42
72
  ### Arguments
@@ -53,6 +83,8 @@ send-payment [options] <sender> <amount> <sourceAsset> <receiver> <destinationAs
53
83
  - `--gateway <url>` - Payment gateway URL (default: testnet)
54
84
  - `--request-id <id>` - Idempotency key; seeds the on-chain deposit nonce. Re-run with the same id to re-attempt one payment. Omitted, an id is generated and printed, which makes every bare re-run a separate payment (see [Idempotency and retries](#idempotency-and-retries))
55
85
  - `--request-id-prefix <prefix>` - Prefix for the auto-generated request id (default: `pmt-gw-cli`)
86
+ - `--prepare-only` - Build and sign the request, print it to stdout, and stop without submitting. For inspecting an authorization, or for review before paying; send it later with `--submit` (see [Preparing a payment for review](#preparing-a-payment-for-review)). Cannot be combined with `--wait`
87
+ - `--submit <file>` - Send a request built earlier by `--prepare-only`, instead of building one. Signs nothing and needs no private key; `-` reads stdin. Takes no corridor arguments
56
88
  - `--wait` - After submitting, keep checking until the payment settles or fails instead of reporting it as still pending
57
89
  - `--wait-seconds <n>` - How long `--wait` keeps checking (default: `60`). A convenience bound only; giving up does not affect the payment
58
90
  - `-h, --help` - Display help
@@ -66,7 +98,7 @@ runs the next step only for a settled payment:
66
98
  | ---- | --------------------------------------------------------------------------- |
67
99
  | `0` | `completed` — settled |
68
100
  | `2` | `pending` — accepted, no result yet |
69
- | `3` | `failed` / `cancelled` — terminal; a fresh attempt needs a new `--request-id` |
101
+ | `3` | `failed` — terminal; a fresh attempt needs a new `--request-id` |
70
102
  | `1` | no outcome obtained (rejected, gateway unreachable, bad arguments) |
71
103
 
72
104
  On `2`, follow the payment with **`payment-status <payment_id>`** — the id is in the
@@ -84,15 +116,24 @@ printed on failure for exactly this.
84
116
  `payment-status` is a lookup, so its exit code reports whether the lookup worked
85
117
  (`0`) or not (`1`), never the payment's outcome — read `status` for that.
86
118
 
87
- ### CAIP-19 Asset Format Examples
119
+ **The table describes a submitted payment, so it does not apply to `--prepare-only`.**
120
+ There, `0` means the request was built and signed — nothing was submitted and no
121
+ payment exists. Do not chain a payment-conditional step off a `--prepare-only` run.
122
+
123
+ ### Asset format
88
124
 
89
- - **EVM ERC20**: `eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831`
90
- - **Tron TRC20**: `tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs`
125
+ `sourceAsset` and `destinationAsset` are CAIP-19 identifiers — see
126
+ [Supported corridors](#supported-corridors).
91
127
 
92
128
  ### Signing provider
93
129
 
94
130
  Choose how the sender authorization is signed with `--provider` (default: `raw`).
95
131
 
132
+ Whichever provider you use, pass `pinnedAddress` to `createSenderSigner` with the payment's
133
+ source account. The SDK checks that the signing key resolves to that address before anything
134
+ is signed, so the wrong key fails immediately instead of producing a signature the gateway
135
+ rejects. This works on every chain and on both providers.
136
+
96
137
  #### Private key (`--provider raw`, default)
97
138
 
98
139
  Provide your private key via environment variable:
@@ -126,7 +167,7 @@ does not pull in Turnkey or `@solana/web3.js`.
126
167
  The command-line tools also read an optional `.env` file from the directory you
127
168
  run them in, so credentials and settings do not have to be retyped each time:
128
169
 
129
- ```
170
+ ```bash
130
171
  PRIVATE_KEY=0x...
131
172
  GATEWAY_URL=https://payment-gw.production-testnet.atum.xyz
132
173
  SOLANA_RPC_URL=
@@ -149,6 +190,10 @@ environment.
149
190
  - **PaymentRequest JSON** → `stderr` (for debugging/logging)
150
191
  - **PaymentResponse JSON** → `stdout` (for parsing/automation)
151
192
 
193
+ Under `--prepare-only` there is no response, and the signed request is the result, so
194
+ it goes to `stdout` instead and the `stderr` copy is dropped. That keeps
195
+ `send-payment --prepare-only ... | jq` working.
196
+
152
197
  ### Example
153
198
 
154
199
  ```bash
@@ -160,6 +205,103 @@ PRIVATE_KEY=0xabc... send-payment \
160
205
  tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs
161
206
  ```
162
207
 
208
+ ### Preparing a payment for review
209
+
210
+ `send-payment` builds, signs and submits in one step. To have the payment approved
211
+ before it is sent, split it in two: `--prepare-only` stops after signing and prints
212
+ the request instead, so you can see exactly what you are about to authorize — the
213
+ document is not a description of the payment, it *is* the payment. `--submit` sends
214
+ one that was prepared earlier.
215
+
216
+ ```bash
217
+ # 1. build and sign, submit nothing
218
+ send-payment --prepare-only --quote-deadline-seconds 30 \
219
+ 0xF0D0... 100000 \
220
+ eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831 \
221
+ 0xMerchant... \
222
+ eip155:42220/erc20:0xcebA9300f2b948710d2653dD7B07f33A8B32118C \
223
+ > request.json
224
+
225
+ # 2. inspect it, have it reviewed
226
+
227
+ # 3. send it
228
+ send-payment --submit request.json --wait
229
+ ```
230
+
231
+ Or piped, with no file on disk:
232
+
233
+ ```bash
234
+ send-payment --prepare-only <args...> | send-payment --submit -
235
+ ```
236
+
237
+ Four things to know:
238
+
239
+ - **`--prepare-only` still contacts the gateway.** The corridor addresses come from
240
+ it, so this is not an offline signing mode.
241
+ - **Widen the quote window if you intend to submit later — but only by tens of seconds.**
242
+ A prepared document is submittable only while its quote window is open, and the default
243
+ is 10 seconds. That window is also the auction: the gateway waits it out before choosing
244
+ a settler, and settler quotes expire on their own, so an over-long window ends in
245
+ `QUOTES_EXPIRED`. That failure is terminal, and a fresh attempt needs a new request id.
246
+ Give the reviewer enough time and no more.
247
+ - **`--submit` signs nothing and needs no private key** — the authorization is already
248
+ in the file. It also cannot repair one: a request whose signature does not match its
249
+ contents is rejected, and the fix is to prepare a new one.
250
+ - **Treat the prepared file like a signed cheque.** It is a signed authorization, not a
251
+ draft: anyone who has it can send it, because `--submit` needs no key and the signature
252
+ is already inside. Keep it where you would keep a credential, and delete it once the
253
+ payment is sent or the request is abandoned.
254
+
255
+ `--submit` takes no corridor arguments, since the request already carries them, and it
256
+ rejects the options that only shape a request being built (`--request-id`,
257
+ `--quote-deadline-seconds`, `--provider` and the rest) rather than accepting and ignoring
258
+ them. It reports with the same exit codes and streams as an ordinary run.
259
+
260
+ Re-preparing with the same `--request-id` targets the **same payment** — the deposit nonce
261
+ is derived from the id, so it cannot pay twice — but it does not reproduce the same bytes.
262
+ The deadlines are stamped from the clock on each run and the signature covers them, so two
263
+ prepared documents for one payment always differ in `quote_deadline`,
264
+ `fulfillment_deadline` and the signature.
265
+
266
+ ### Approving the source token from the CLI
267
+
268
+ `approve-permit2` grants the one-off approval described in
269
+ [Approving the source token](#approving-the-source-token). Run it once per wallet and token,
270
+ before the first `send-payment` from that token:
271
+
272
+ ```bash
273
+ approve-permit2 <sender> <sourceAsset> --rpc-url <url>
274
+ ```
275
+
276
+ ```bash
277
+ # is one needed at all? reads the allowance, sends nothing, costs no gas
278
+ approve-permit2 0xF0D0... eip155:421614/erc20:0xaf88... --rpc-url https://... --check
279
+
280
+ # grant it
281
+ approve-permit2 0xF0D0... eip155:421614/erc20:0xaf88... --rpc-url https://...
282
+ ```
283
+
284
+ This is the only command in the package that broadcasts a transaction and spends gas.
285
+
286
+ - `--rpc-url <url>` - RPC endpoint for the source chain. Also read from `RPC_URL`.
287
+ - `--check` - report whether an approval is needed, then exit. Needs no private key.
288
+ - `--amount <units>` - approve only this many smallest units instead of an unlimited approval.
289
+ - `--spender <address>` - approve a contract other than the canonical Permit2.
290
+
291
+ The private key comes from `PRIVATE_KEY` or an interactive prompt, never a flag — a flag would
292
+ put it in your shell history and in the process list. It is only requested once the allowance has
293
+ been read and an approval is genuinely due, so an already-approved wallet never has to produce
294
+ one, and it is checked against `<sender>` before anything is sent. The result JSON goes to stdout.
295
+
296
+ EVM and Tron. Solana needs no approval at all, and the command says so rather than doing nothing.
297
+
298
+ Tron additionally needs the `tronweb` package, which is an **optional** peer dependency so that
299
+ EVM-only installs do not carry it. Install it alongside the CLI when you pay from Tron:
300
+
301
+ ```bash
302
+ npm install -g tronweb
303
+ ```
304
+
163
305
  ### Removing the command-line tools
164
306
 
165
307
  ```bash
@@ -176,7 +318,6 @@ import {
176
318
  createSenderSigner,
177
319
  signPaymentRequest,
178
320
  getErrorResponse,
179
- type PaymentResponse,
180
321
  ApiError,
181
322
  } from '@atumlabs/payment-gateway-client';
182
323
 
@@ -196,6 +337,27 @@ environment, in order of precedence:
196
337
 
197
338
  Mainnet access is granted by Atum; use the mainnet gateway URL provided to you.
198
339
 
340
+ ### Timeouts
341
+
342
+ Requests are bounded so an unresponsive gateway fails rather than hanging. There
343
+ are two budgets, because some endpoints are held open by the gateway on purpose
344
+ while it collects quotes or settles a payment:
345
+
346
+ | Budget | Default | Applies to |
347
+ | --- | --- | --- |
348
+ | `timeoutMs` | 30s | requests the gateway answers as soon as it can |
349
+ | `syncWaitTimeoutMs` | 120s | `POST /v1/payments`, `POST /v1/payments/awards`, and any `wait: true` call |
350
+
351
+ ```typescript
352
+ const client = new PaymentGatewayClient({ timeoutMs: 10_000, syncWaitTimeoutMs: 60_000 });
353
+ ```
354
+
355
+ Exceeding either raises a `GatewayTimeoutError` (see Error Handling). Raise
356
+ `syncWaitTimeoutMs` if your gateway is configured with a larger
357
+ `payment_sync_wait_seconds` than the 30s default, since it must clear that cap.
358
+ For a single request, an interceptor that sets `timeout` takes precedence over
359
+ both.
360
+
199
361
  ### Example: Tron to Celo Sepolia Payment
200
362
 
201
363
  ```typescript
@@ -236,7 +398,7 @@ async function sendPayment() {
236
398
  // Submit the payment
237
399
  const response = await client.payments.submitPayment({
238
400
  requestBody: paymentRequest
239
- }) as PaymentResponse;
401
+ });
240
402
 
241
403
  // A 2xx means the payment was accepted, NOT that it settled. Branch on `status`:
242
404
  // cross-chain settlement can outrun the gateway's synchronous window, so an accepted
@@ -255,7 +417,6 @@ async function sendPayment() {
255
417
  console.log('Destination TX:', response.fulfillment_confirmation?.destination_tx_hash);
256
418
  break;
257
419
  case 'failed':
258
- case 'cancelled':
259
420
  // Terminal, and this request_id stays bound to it. A fresh attempt needs a NEW id.
260
421
  console.error('Failed:', response.error?.code, response.error?.message);
261
422
  break;
@@ -263,7 +424,7 @@ async function sendPayment() {
263
424
  // Still settling. Do not release goods, and do not pay again under a new id.
264
425
  // Follow it with getPaymentStatus(payment_id); re-submitting this same request
265
426
  // also returns the current state, but it re-sends the payment to do so.
266
- console.log('Still settling — follow it with getPaymentStatus.');
427
+ console.log('Still settling — see "Tracking a payment" below.');
267
428
  }
268
429
 
269
430
  return response;
@@ -279,16 +440,187 @@ async function sendPayment() {
279
440
  }
280
441
  ```
281
442
 
443
+ ### Tracking a payment
444
+
445
+ A 2xx from `submitPayment` means the payment was accepted, not that it settled. Read
446
+ `status` on the response, and follow the payment while it is still `pending`.
447
+
448
+ **Store your `request_id` before you call `submitPayment`, not after.** It is the only
449
+ handle that survives a crash between sending the request and receiving the response, and
450
+ it is what you need to find the payment again.
451
+
452
+ | Status | Meaning |
453
+ | --- | --- |
454
+ | `pending` | Accepted, not yet settled |
455
+ | `finalizing` | Paid on chain, collecting confirmations. Not final |
456
+ | `completed` | Delivered |
457
+ | `failed` | Did not settle |
458
+
459
+ `completed` and `failed` are final. A payment that reaches one of them keeps
460
+ its `request_id` for good, so re-submitting that id returns the same finished payment
461
+ rather than trying again — a genuine new attempt needs a **new** `request_id`. Any status
462
+ this client does not recognise is treated as still in flight. `isTerminalStatus` is exported
463
+ so you can ask that question without keeping your own copy of the list: a value added to the
464
+ contract then cannot silently read as finished in your code.
465
+
466
+ `getPaymentStatus` returns the payment's state at that moment:
467
+
468
+ ```typescript
469
+ import { isTerminalStatus } from '@atumlabs/payment-gateway-client';
470
+
471
+ const snapshot = await client.payments.getPaymentStatus({ paymentId });
472
+
473
+ if (!isTerminalStatus(snapshot.status)) {
474
+ // Still in flight. Do not release goods.
475
+ }
476
+
477
+ if (snapshot.error) {
478
+ // Present when the payment failed. The chain, transaction hash and revert reason are
479
+ // under `blockchain_context`, not on the error itself.
480
+ console.error(
481
+ snapshot.error.code,
482
+ snapshot.error.message,
483
+ snapshot.error.blockchain_context?.transaction_hash ?? '',
484
+ );
485
+ }
486
+ ```
487
+
488
+ #### Waiting for an outcome
489
+
490
+ `getPaymentStatus` answers once and does not wait. To follow a payment to its end, use
491
+ `waitForTerminalStatus`, which is the same helper behind the `send-payment --wait` flag:
492
+
493
+ ```typescript
494
+ import { waitForTerminalStatus } from '@atumlabs/payment-gateway-client';
495
+
496
+ const outcome = await waitForTerminalStatus({
497
+ fetchStatus: () => client.payments.getPaymentStatus({ paymentId }),
498
+ budgetMs: 60_000,
499
+ onError: (error) => console.warn('status check failed, still waiting:', error),
500
+ });
501
+
502
+ if (outcome.timedOut) {
503
+ // Still in flight when the budget ran out. Not a failure — check again later.
504
+ } else {
505
+ console.log(outcome.snapshot?.status);
506
+ }
507
+ ```
508
+
509
+ A failed status check does not end the wait: a network blip while a payment is settling is
510
+ the worst moment to stop looking, so failures are counted in `outcome.errorCount`, reported
511
+ through `onError`, and polling continues.
512
+
513
+ `budgetMs` defaults to `DEFAULT_WAIT_BUDGET_MS` (one minute) and is in **milliseconds**.
514
+
515
+ Checks are `DEFAULT_POLL_INTERVAL_MS` apart
516
+ (2 seconds) by default; pass `intervalMs` to widen it, and if you are following many
517
+ payments at once, stagger their start times so their polls do not align.
518
+
519
+ Pass a `signal` to stop a wait early, for a process draining on shutdown or a request that
520
+ has been cancelled. The outcome then reports `aborted`, and still carries the last status it
521
+ read. `timedOut` is true whenever the wait ended without a terminal status, so branching on
522
+ it stays correct either way; read `aborted` when you need to tell the two apart.
523
+
524
+ ```typescript
525
+ const controller = new AbortController();
526
+ process.once('SIGTERM', () => controller.abort());
527
+
528
+ const outcome = await waitForTerminalStatus({
529
+ fetchStatus: () => client.payments.getPaymentStatus({ paymentId }),
530
+ signal: controller.signal,
531
+ });
532
+
533
+ if (outcome.aborted) {
534
+ // Shutting down. The payment is unaffected; pick it up again on the next start.
535
+ }
536
+ ```
537
+
538
+ Running out of budget says nothing about the payment: `timedOut` means you stopped
539
+ watching, not that anything failed. Check again later rather than paying again — see
540
+ [Idempotency and retries](#idempotency-and-retries).
541
+
542
+ #### Finding a payment again
543
+
544
+ If a submit call threw, you may hold no `payment_id` — but the payment may still have been
545
+ created. `resolvePayment` turns the `request_id` you stored back into one. It throws an
546
+ `ApiError` with status `404` when the gateway has no payment recorded for that id:
547
+
548
+ ```typescript
549
+ import { isApiError } from '@atumlabs/payment-gateway-client';
550
+
551
+ try {
552
+ const { payment_id } = await client.payments.resolvePayment({ requestId: 'order-4711' });
553
+ const snapshot = await client.payments.getPaymentStatus({ paymentId: payment_id });
554
+ console.log(snapshot.status);
555
+ } catch (error) {
556
+ if (isApiError(error) && error.status === 404) {
557
+ // No payment recorded for that request_id yet.
558
+ } else {
559
+ throw error;
560
+ }
561
+ }
562
+ ```
563
+
564
+ A `404` is not proof that nothing happened. A submit that timed out may still be in flight,
565
+ and the gateway records the payment when it lands. **Never start a fresh payment under a new
566
+ `request_id` to recover** — that is a second payment, with real money on both. Re-submit the
567
+ **same** `request_id` instead: it returns the payment if one was created, and creates it if
568
+ not.
569
+
570
+ #### Step-by-step detail
571
+
572
+ `getPaymentTimeline` returns the payment's individual steps, each with its own status and,
573
+ for on-chain steps, a chain id and transaction hash. Use it to show progress, or to
574
+ investigate a payment that did not settle:
575
+
576
+ ```typescript
577
+ const timeline = await client.payments.getPaymentTimeline({ paymentId });
578
+
579
+ for (const step of timeline.steps) {
580
+ console.log(step.status, step.label, step.transaction_hash ?? '');
581
+ }
582
+
583
+ if (timeline.error) {
584
+ // Why it failed, and where: for an on-chain failure this carries the chain id, the
585
+ // transaction hash and the revert reason.
586
+ console.error(
587
+ timeline.error.code,
588
+ timeline.error.message,
589
+ timeline.error.blockchain_context?.revert_reason ?? '',
590
+ );
591
+ }
592
+ ```
593
+
594
+ A payment reads the same on every endpoint: `getPayment`, `getPaymentStatus` and
595
+ `getPaymentTimeline` all report the one `PaymentStatus`. `finalizing` means the
596
+ transaction paying your recipient is on chain and accruing confirmations — it is not
597
+ final, and it is not a reason to treat the payment as delivered.
598
+
599
+ ### Solana signing
600
+
601
+ Solana signs with Ed25519, which does not let a verifier recover the signer's key from
602
+ the signature, so the key travels with the message as `payload.delegate_signer`.
603
+ `signPaymentRequest` and the command-line tools set it for you.
604
+
605
+ The key you sign with must be the payment's source account, which is what `pinnedAddress`
606
+ checks — see [Signing provider](#signing-provider).
607
+
282
608
  ### Error Handling
283
609
 
284
610
  The gateway returns a typed, public error body — `ErrorResponse`
285
- (`{ code, message, request_id?, docs_url?, domain? }`). Use `getErrorResponse`
286
- to narrow a caught error to it; it returns `undefined` for anything that is not
287
- a gateway error with that shape. For transport-level detail (HTTP status, URL),
288
- the thrown value is still an `ApiError`.
611
+ (`{ code, message, request_id?, payment_id?, docs_url?, domain? }`). Use
612
+ `getErrorResponse` to narrow a caught error to it; it returns `undefined` for
613
+ anything that is not a gateway error with that shape. `payment_id` is present only
614
+ when the error is about a specific payment, so it is absent on the validation and
615
+ authentication failures that happen before a payment exists. For transport-level
616
+ detail (HTTP status, URL), the thrown value is still an `ApiError`.
617
+
618
+ Input validation happens before any request, so those failures are a plain `Error` carrying the
619
+ message and no `ErrorResponse` — `getErrorResponse` returns `undefined` for them. Read
620
+ `error.message`.
289
621
 
290
622
  ```typescript
291
- import { getErrorResponse, ApiError } from '@atumlabs/payment-gateway-client';
623
+ import { getErrorResponse, isGatewayTimeoutError, ApiError } from '@atumlabs/payment-gateway-client';
292
624
 
293
625
  try {
294
626
  await client.payments.submitPayment({ requestBody: paymentRequest });
@@ -299,6 +631,10 @@ try {
299
631
  console.error('Message:', gwError.message); // safe, human-readable text
300
632
  console.error('Request ID:', gwError.request_id); // quote this to support
301
633
  console.error('Docs:', gwError.docs_url);
634
+ } else if (isGatewayTimeoutError(error)) {
635
+ // The gateway never answered within the client's budget. There is no
636
+ // response and no status, so this is not an ApiError.
637
+ console.error(error.message, error.timeoutMs);
302
638
  } else if (error instanceof ApiError) {
303
639
  // Transport-level failure with no typed body (e.g. a proxy/gateway error).
304
640
  console.error('HTTP', error.status, error.statusText, error.url);
@@ -313,9 +649,12 @@ may still have been accepted. Treat it as unknown rather than as a failure:
313
649
 
314
650
  - If you already hold the `payment_id` from an earlier attempt, read
315
651
  `getPaymentStatus` — the cheapest way to find out what happened.
316
- - If you do not, re-submit the same `request_id`. It resolves to the payment if one
317
- was created and creates it if not, which is the only recovery that works without
318
- an id.
652
+ - If you do not, call `resolvePayment` with the same `request_id` to get the
653
+ `payment_id`, then read its status. A `404` there means the gateway has no payment
654
+ recorded for that id *yet* — see [Finding a payment again](#finding-a-payment-again).
655
+ - Re-submitting the same `request_id` also works and needs no lookup: it resolves to
656
+ the payment if one was created, and creates it if not. Never recover by starting a
657
+ payment under a **new** `request_id`: that is a second payment, not a retry.
319
658
 
320
659
  One code is worth handling by name:
321
660
 
@@ -330,42 +669,204 @@ if (gwError?.code === 'IDEMPOTENCY_TERMS_MISMATCH') {
330
669
 
331
670
  ### Asset Identifiers
332
671
 
333
- Assets are identified using the [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) format:
334
- - Format: `{chain_id}/{asset_namespace}:{asset_reference}`
335
- - Tron example: `tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs`
336
- - EVM example: `eip155:11142220/erc20:0x456a3D042C0DbD3db53D5489e98dFb038553B0d0`
672
+ Assets are identified using the [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) format,
673
+ `{chain_id}/{asset_namespace}:{asset_reference}` — see
674
+ [Supported corridors](#supported-corridors) for an example per chain family.
337
675
 
338
- ## Idempotency and retries
676
+ Identifiers are validated before a request is built, so a malformed one is reported with the value
677
+ and what to correct rather than surfacing from a chain library. The rules: both namespaces are
678
+ canonicalized to lowercase, an `eip155` chain id is decimal, an EVM token address is 20-byte hex
679
+ and, when mixed-case, must carry a valid [EIP-55](https://eips.ethereum.org/EIPS/eip-55) checksum,
680
+ and a native asset (`slip44`) has no contract to deposit so it is refused. `parseAssetIdentifier`
681
+ and `assetChainId` are exported if you want the same parsing and canonicalization in your own code.
682
+
683
+ The checksum rule applies to the destination as well as the source, which is stricter than the
684
+ gateway, and it is typo protection rather than a settlement guard. For a destination address you
685
+ cannot edit, the all-lowercase form is the same twenty bytes and is accepted. Do not reach for that
686
+ on the source: settler configuration matches the source asset identifier byte for byte, so
687
+ lowercasing it moves the failure to settlement instead of removing it.
688
+
689
+ ## Approving the source token
690
+
691
+ On EVM and Tron the escrow moves your token through **Permit2**, so the token contract has to be
692
+ told that Permit2 may move it. That is a one-off transaction per `(wallet, token)` — every payment
693
+ afterwards is just an offline signature and costs nothing.
694
+
695
+ Skipping it produces the worst kind of failure: the request is built correctly, signed correctly
696
+ and accepted by the gateway, and then settlement reverts on-chain minutes later.
697
+
698
+ `ensureSourceApproval` reads the current allowance and sends an approval only if it falls short:
699
+
700
+ ```ts
701
+ import { ensureSourceApproval, needsSourceApproval } from '@atumlabs/payment-gateway-client';
702
+ import { Wallet, JsonRpcProvider } from 'ethers';
703
+
704
+ const wallet = new Wallet(process.env.PRIVATE_KEY!, new JsonRpcProvider(RPC_URL));
705
+
706
+ const result = await ensureSourceApproval({
707
+ network: 'eip155:11142220',
708
+ token: '0x456a3D042C0DbD3db53D5489e98dFb038553B0d0',
709
+ owner: wallet.address,
710
+ signer: wallet,
711
+ });
712
+
713
+ if (!result.alreadySufficient) {
714
+ console.log(`approved in ${result.txHash}`);
715
+ }
716
+ ```
717
+
718
+ You supply the signer, so the SDK never picks an RPC endpoint for you — everything else in this
719
+ package is offline, and this is the one call that broadcasts a transaction and spends gas.
720
+
721
+ It waits for the approval to be mined before returning, so `txHash` means confirmed rather than
722
+ merely sent. That wait is bounded — one minute by default, `confirmation.timeoutMs` to change it —
723
+ and a timeout is reported as *unconfirmed*, not failed, because the transaction may still land.
724
+ Pass `onSubmitted` to receive the hash the moment it is broadcast, so a slow approval can still be
725
+ looked up:
726
+
727
+ ```ts
728
+ await ensureSourceApproval({
729
+ network, token, owner, signer,
730
+ onSubmitted: (txHash) => console.log(`watching ${txHash}`),
731
+ confirmation: { timeoutMs: 120_000 },
732
+ });
733
+ ```
734
+
735
+ `isUnconfirmed(error)` tells the two apart. It matters because the responses are opposite: a
736
+ failed approval needs another one, an unconfirmed approval needs a look at the transaction and
737
+ nothing else. Sending a second one on top of the first only pays for an allowance you are already
738
+ getting.
739
+
740
+ ```ts
741
+ try {
742
+ await ensureSourceApproval({ network, token, owner, signer });
743
+ } catch (error) {
744
+ if (isUnconfirmed(error)) {
745
+ // Broadcast, not yet seen to confirm. Check the hash from onSubmitted; do not re-send.
746
+ } else {
747
+ throw error;
748
+ }
749
+ }
750
+ ```
751
+
752
+ On EVM, some tokens — mainnet USDT is the one you are most likely to meet — refuse to move an
753
+ allowance straight from one non-zero value to another. If your wallet already holds a partial
754
+ allowance on such a token, the approval is reset to zero first, automatically, and the result
755
+ carries a `resetTxHash` alongside the usual `txHash`. The check costs nothing and sends nothing,
756
+ and a wallet with no allowance skips it entirely, so an ordinary approval still takes exactly one
757
+ transaction.
758
+
759
+ Tron does not do this. An ordinary TRC-20 accepts the change in place, so guessing otherwise would
760
+ burn an extra transaction at Tron's fee limit; if a Tron token ever does refuse, the error tells
761
+ you to approve zero first and retry.
339
762
 
340
- Every payment carries a `request_id`. Pass a stable value and reuse the exact
341
- same one on every retry (a timeout, a network blip, a restart) and you end up
342
- with one payment, not two — and you get the same answer each time.
763
+ Paying from Tron takes a TronWeb instance in place of the ethers signer. The Permit2 address for
764
+ the network is resolved for you:
343
765
 
344
- - Using this SDK, `request_id` is **required** on the payment path
345
- (`preparePaymentRequest`), and it is the only thing you manage: the SDK derives
346
- the on-chain deposit nonce deterministically from it, so retries line up on
347
- their own. Reuse the same `request_id` to retry safely.
348
- - On the CLI, `--request-id` is optional; when omitted an id is generated and
766
+ ```ts
767
+ await ensureSourceApproval({ network: 'tron:shasta', token, owner, tronWeb });
768
+ ```
769
+
770
+ Solana needs no approval at all — the signed deposit authorizes the transfer itself — so the call
771
+ reports `alreadySufficient` without touching the chain.
772
+
773
+ ### Checking without spending gas
774
+
775
+ `needsSourceApproval` takes the same arguments and answers the same question, read-only. Use it to
776
+ warn a payer that a transaction is coming before asking them to sign:
777
+
778
+ ```ts
779
+ if (await needsSourceApproval({ network: 'eip155:11142220', token, owner, signer })) {
780
+ // a one-off approval transaction is due first
781
+ }
782
+ ```
783
+
784
+ ### Bounded approvals
785
+
786
+ The approval sent is unlimited by default, which is why later payments need no further
787
+ transaction. Pass `approvalAmount` to bound the approval this sends instead.
788
+
789
+ On its own the amount is treated as its own requirement, so an allowance still holding the full
790
+ bound is left alone rather than re-approved — the right answer for a one-off approval. Across
791
+ repeated payments, pair it with `requiredAllowance`, as below: Permit2 **decrements** the
792
+ allowance on every payment, so a bound measured against itself stops covering itself the moment
793
+ the first charge lands, and every later call would send another approval. Either way a bounded
794
+ approval is consumed as it is spent and eventually has to be granted again:
795
+
796
+ ```ts
797
+ await ensureSourceApproval({
798
+ network: 'eip155:11142220',
799
+ token,
800
+ owner,
801
+ signer,
802
+ requiredAllowance: 1_000_000n, // what this payment needs
803
+ approvalAmount: 5_000_000n, // approve only this much, not unlimited
804
+ });
805
+ ```
806
+
807
+ These helpers raise an allowance to what a payment needs; they never lower one. A wallet already
808
+ holding more than `approvalAmount` is left exactly as it is and reports `alreadySufficient`
809
+ without sending anything. Reducing or revoking an allowance is a separate operation, and not one
810
+ these perform.
811
+
812
+ Permit2 contract addresses, if you would rather approve by hand:
813
+
814
+ | Chain | Address |
815
+ | --- | --- |
816
+ | EVM (virtually all chains) | `0x000000000022D473030F116dDEE9F6B43aC78BA3` |
817
+ | Tron mainnet | `TJhMXTHQHeQyMD7TcKQFqAePNgG4b31H9m` |
818
+ | Tron Shasta | `TPUqJPASUn1zLvbLBgRZ5pBYrx7WSe5ahp` |
819
+
820
+ ## Idempotency and retries
821
+
822
+ Every write request carries a `request_id`. Pass a stable value and reuse the
823
+ exact same one on every retry (a timeout, a network blip, a restart) and you end
824
+ up with one payment, not two — and you get the same answer each time. One contract
825
+ covers `submitPayment` and `createQuoteRequest` alike.
826
+
827
+ - Using this SDK, `request_id` is **required** on both builders
828
+ (`preparePaymentRequest`, `prepareQuoteRequest`) and neither invents one — an id
829
+ the library chose is an id you cannot reuse on a retry. On the payment path it is
830
+ the only thing you manage: the SDK derives the on-chain deposit nonce
831
+ deterministically from it, so retries line up on their own.
832
+ - On the CLIs, `--request-id` is optional; when omitted an id is generated and
349
833
  printed, so you can re-run with `--request-id <id>` to re-attempt the same
350
- payment. A bare re-run, with no `--request-id`, is a **separate** payment.
351
- - A genuinely new payment needs a **new** `request_id`. Reusing one with different
834
+ payment or recover the same batch. A bare re-run, with no `--request-id`, is a
835
+ **separate** payment or batch.
836
+ - Re-sending an id returns what it created the first time — the original payment,
837
+ or the original quote-request batch — and creates nothing new.
838
+ - A genuinely new request needs a **new** `request_id`. Reusing one with different
352
839
  economics (a corrected amount, a different token) is rejected with `409`
353
- `IDEMPOTENCY_TERMS_MISMATCH` — one id identifies one payment.
840
+ `IDEMPOTENCY_TERMS_MISMATCH` — one id identifies one payment, or one batch.
354
841
  - Deadlines and the signature are exempt, so a re-submission of the original
355
- bytes is fine even though its deadlines have expired by then.
356
- - `idempotent_replay` on the response tells you whether you were handed a payment
842
+ bytes is fine even though its deadlines have expired by then. This is what makes
843
+ a lost `createQuoteRequest` response recoverable: re-send it and you get your
844
+ `quote_request_id` back.
845
+ - `idempotent_replay` on the response tells you whether you were handed something
357
846
  that already existed.
358
847
  - `pending` is not success. It means accepted and still settling: re-submit the
359
848
  same `request_id` to collect the result. A `failed` payment is terminal and
360
849
  keeps its id, so recovering from one needs a new id.
361
850
 
362
851
  See [docs/idempotency.md](docs/idempotency.md) for the full model: the two
363
- guarantees, when the `409` applies, how to read the outcome, and the per-chain
364
- differences (EVM/Tron vs Solana).
852
+ guarantees and which endpoints each covers, when the `409` applies, how to read
853
+ the outcome, and the per-chain differences (EVM/Tron vs Solana).
854
+
855
+ ## Signing
856
+
857
+ A PaymentRequest carries three unrelated signatures: `signPaymentRequest` authorizes the
858
+ on-chain transfer, `signDeclaration` attests the document, and an identity envelope carries the
859
+ originator. None substitutes for another.
860
+
861
+ Both signing surfaces take a **signer**, never a private key, so a key held in a KMS, an HSM or
862
+ a custodial provider works without leaving its boundary; `signerFromEthersWallet` covers a local
863
+ key. See [docs/signing.md](docs/signing.md) for which signature to use when, what the proof does
864
+ and does not cover, and the two details a custodial signer must get right.
365
865
 
366
866
  ## What is in the package
367
867
 
368
868
  - `dist/` — compiled JavaScript and TypeScript declarations, ready to use
369
- - `dist/cli/` — the four command-line tools
869
+ - `dist/cli/` — the five command-line tools
370
870
  - `docs/` — reference documentation, including [idempotency and retries](docs/idempotency.md)
871
+ and [signing](docs/signing.md)
371
872
  - `LICENSE`, `THIRD-PARTY-NOTICES.txt` — license terms and third-party attribution