@atumlabs/payment-gateway-client 2.1.0 → 3.0.0
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/CHANGELOG.md +114 -0
- package/README.md +604 -40
- package/THIRD-PARTY-NOTICES.txt +7 -0
- package/dist/cli/approve-permit2.d.ts +1 -0
- package/dist/cli/approve-permit2.js +7100 -0
- package/dist/cli/payment-status.js +2311 -234
- package/dist/cli/quote-status.js +2271 -233
- package/dist/cli/request-quote.js +2299 -240
- package/dist/cli/send-payment.js +3131 -410
- package/dist/index.d.ts +1392 -194
- package/dist/index.js +3264 -414
- package/docs/idempotency.md +155 -19
- package/package.json +24 -9
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
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
6
|
> **Proprietary software - not open source.**
|
|
6
7
|
>
|
|
7
8
|
> 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
9
|
|
|
9
|
-
> NOTE:
|
|
10
|
-
>
|
|
11
|
-
>
|
|
10
|
+
> NOTE: Before your first payment from an EVM or Tron chain, the source token must be approved
|
|
11
|
+
> for the Permit2 contract. Without it a payment is built, signed and accepted by the gateway,
|
|
12
|
+
> and then reverts on-chain at settlement. It is a one-off transaction per wallet and token —
|
|
13
|
+
> see [Approving the source token](#approving-the-source-token) for `ensureSourceApproval`,
|
|
14
|
+
> which does it for you.
|
|
12
15
|
|
|
13
16
|
## Installation
|
|
14
17
|
|
|
@@ -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
|
|
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
|
|
@@ -51,19 +81,59 @@ send-payment [options] <sender> <amount> <sourceAsset> <receiver> <destinationAs
|
|
|
51
81
|
|
|
52
82
|
- `-V, --version` - Output the version number
|
|
53
83
|
- `--gateway <url>` - Payment gateway URL (default: testnet)
|
|
54
|
-
- `--request-id <id>` - Idempotency key; seeds the on-chain deposit nonce.
|
|
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
|
|
88
|
+
- `--wait` - After submitting, keep checking until the payment settles or fails instead of reporting it as still pending
|
|
89
|
+
- `--wait-seconds <n>` - How long `--wait` keeps checking (default: `60`). A convenience bound only; giving up does not affect the payment
|
|
56
90
|
- `-h, --help` - Display help
|
|
57
91
|
|
|
58
|
-
###
|
|
92
|
+
### Exit codes
|
|
93
|
+
|
|
94
|
+
`send-payment` reports the payment's ending, so `send-payment ... && <next step>`
|
|
95
|
+
runs the next step only for a settled payment:
|
|
96
|
+
|
|
97
|
+
| Code | Meaning |
|
|
98
|
+
| ---- | --------------------------------------------------------------------------- |
|
|
99
|
+
| `0` | `completed` — settled |
|
|
100
|
+
| `2` | `pending` — accepted, no result yet |
|
|
101
|
+
| `3` | `failed` / `cancelled` — terminal; a fresh attempt needs a new `--request-id` |
|
|
102
|
+
| `1` | no outcome obtained (rejected, gateway unreachable, bad arguments) |
|
|
103
|
+
|
|
104
|
+
On `2`, follow the payment with **`payment-status <payment_id>`** — the id is in the
|
|
105
|
+
output — or use `--wait` to have `send-payment` do it for you. Re-running
|
|
106
|
+
`send-payment` with the same `--request-id` also returns the current state, but it
|
|
107
|
+
rebuilds and re-sends the payment to do so, which is more work than a status
|
|
108
|
+
lookup.
|
|
109
|
+
|
|
110
|
+
`1` is the case that reverses that order. It means the outcome is **unknown**, not
|
|
111
|
+
that nothing happened: a request that timed out may still have been accepted, and
|
|
112
|
+
you may not have a `payment_id` to look up. Re-run with the same `--request-id`,
|
|
113
|
+
which resolves to the payment if one was created and creates it if not. The id is
|
|
114
|
+
printed on failure for exactly this.
|
|
59
115
|
|
|
60
|
-
-
|
|
61
|
-
|
|
116
|
+
`payment-status` is a lookup, so its exit code reports whether the lookup worked
|
|
117
|
+
(`0`) or not (`1`), never the payment's outcome — read `status` for that.
|
|
118
|
+
|
|
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
|
|
124
|
+
|
|
125
|
+
`sourceAsset` and `destinationAsset` are CAIP-19 identifiers — see
|
|
126
|
+
[Supported corridors](#supported-corridors).
|
|
62
127
|
|
|
63
128
|
### Signing provider
|
|
64
129
|
|
|
65
130
|
Choose how the sender authorization is signed with `--provider` (default: `raw`).
|
|
66
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
|
+
|
|
67
137
|
#### Private key (`--provider raw`, default)
|
|
68
138
|
|
|
69
139
|
Provide your private key via environment variable:
|
|
@@ -97,7 +167,7 @@ does not pull in Turnkey or `@solana/web3.js`.
|
|
|
97
167
|
The command-line tools also read an optional `.env` file from the directory you
|
|
98
168
|
run them in, so credentials and settings do not have to be retyped each time:
|
|
99
169
|
|
|
100
|
-
```
|
|
170
|
+
```bash
|
|
101
171
|
PRIVATE_KEY=0x...
|
|
102
172
|
GATEWAY_URL=https://payment-gw.production-testnet.atum.xyz
|
|
103
173
|
SOLANA_RPC_URL=
|
|
@@ -120,6 +190,10 @@ environment.
|
|
|
120
190
|
- **PaymentRequest JSON** → `stderr` (for debugging/logging)
|
|
121
191
|
- **PaymentResponse JSON** → `stdout` (for parsing/automation)
|
|
122
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
|
+
|
|
123
197
|
### Example
|
|
124
198
|
|
|
125
199
|
```bash
|
|
@@ -131,6 +205,103 @@ PRIVATE_KEY=0xabc... send-payment \
|
|
|
131
205
|
tron:shasta/trc20:TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs
|
|
132
206
|
```
|
|
133
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
|
+
|
|
134
305
|
### Removing the command-line tools
|
|
135
306
|
|
|
136
307
|
```bash
|
|
@@ -147,7 +318,6 @@ import {
|
|
|
147
318
|
createSenderSigner,
|
|
148
319
|
signPaymentRequest,
|
|
149
320
|
getErrorResponse,
|
|
150
|
-
type PaymentResponse,
|
|
151
321
|
ApiError,
|
|
152
322
|
} from '@atumlabs/payment-gateway-client';
|
|
153
323
|
|
|
@@ -167,6 +337,27 @@ environment, in order of precedence:
|
|
|
167
337
|
|
|
168
338
|
Mainnet access is granted by Atum; use the mainnet gateway URL provided to you.
|
|
169
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
|
+
|
|
170
361
|
### Example: Tron to Celo Sepolia Payment
|
|
171
362
|
|
|
172
363
|
```typescript
|
|
@@ -207,13 +398,36 @@ async function sendPayment() {
|
|
|
207
398
|
// Submit the payment
|
|
208
399
|
const response = await client.payments.submitPayment({
|
|
209
400
|
requestBody: paymentRequest
|
|
210
|
-
})
|
|
401
|
+
});
|
|
211
402
|
|
|
212
|
-
|
|
403
|
+
// A 2xx means the payment was accepted, NOT that it settled. Branch on `status`:
|
|
404
|
+
// cross-chain settlement can outrun the gateway's synchronous window, so an accepted
|
|
405
|
+
// payment may still have no result yet. See "Idempotency and retries" below.
|
|
213
406
|
console.log('Payment ID:', response.payment_id);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
407
|
+
if (response.idempotent_replay) {
|
|
408
|
+
// This request_id had been used before, so this is the payment your earlier attempt
|
|
409
|
+
// created. Nothing additional was charged.
|
|
410
|
+
console.log('Handed back an existing payment');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
switch (response.status) {
|
|
414
|
+
case 'completed':
|
|
415
|
+
console.log('Settled.');
|
|
416
|
+
console.log('Source TX:', response.fulfillment_confirmation?.source_tx_hash);
|
|
417
|
+
console.log('Destination TX:', response.fulfillment_confirmation?.destination_tx_hash);
|
|
418
|
+
break;
|
|
419
|
+
case 'failed':
|
|
420
|
+
case 'cancelled':
|
|
421
|
+
// Terminal, and this request_id stays bound to it. A fresh attempt needs a NEW id.
|
|
422
|
+
console.error('Failed:', response.error?.code, response.error?.message);
|
|
423
|
+
break;
|
|
424
|
+
default:
|
|
425
|
+
// Still settling. Do not release goods, and do not pay again under a new id.
|
|
426
|
+
// Follow it with getPaymentStatus(payment_id); re-submitting this same request
|
|
427
|
+
// also returns the current state, but it re-sends the payment to do so.
|
|
428
|
+
console.log('Still settling — see "Tracking a payment" below.');
|
|
429
|
+
}
|
|
430
|
+
|
|
217
431
|
return response;
|
|
218
432
|
} catch (error) {
|
|
219
433
|
const gwError = getErrorResponse(error);
|
|
@@ -227,16 +441,181 @@ async function sendPayment() {
|
|
|
227
441
|
}
|
|
228
442
|
```
|
|
229
443
|
|
|
444
|
+
### Tracking a payment
|
|
445
|
+
|
|
446
|
+
A 2xx from `submitPayment` means the payment was accepted, not that it settled. Read
|
|
447
|
+
`status` on the response, and follow the payment while it is still `pending`.
|
|
448
|
+
|
|
449
|
+
**Store your `request_id` before you call `submitPayment`, not after.** It is the only
|
|
450
|
+
handle that survives a crash between sending the request and receiving the response, and
|
|
451
|
+
it is what you need to find the payment again.
|
|
452
|
+
|
|
453
|
+
| Status | Meaning |
|
|
454
|
+
| --- | --- |
|
|
455
|
+
| `pending` | Accepted, not yet settled |
|
|
456
|
+
| `completed` | Delivered |
|
|
457
|
+
| `failed` | Did not settle |
|
|
458
|
+
| `cancelled` | Cancelled before completion |
|
|
459
|
+
|
|
460
|
+
`completed`, `failed` and `cancelled` are final. A payment that reaches one of them keeps
|
|
461
|
+
its `request_id` for good, so re-submitting that id returns the same finished payment
|
|
462
|
+
rather than trying again — a genuine new attempt needs a **new** `request_id`. Any status
|
|
463
|
+
this client does not recognise is treated as still in flight. `isTerminalStatus` is exported
|
|
464
|
+
so you can ask that question without keeping your own copy of the list: a value added to the
|
|
465
|
+
contract then cannot silently read as finished in your code.
|
|
466
|
+
|
|
467
|
+
`getPaymentStatus` returns the payment's state at that moment:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
import { isTerminalStatus } from '@atumlabs/payment-gateway-client';
|
|
471
|
+
|
|
472
|
+
const snapshot = await client.payments.getPaymentStatus({ paymentId });
|
|
473
|
+
|
|
474
|
+
if (!isTerminalStatus(snapshot.status)) {
|
|
475
|
+
// Still in flight. Do not release goods.
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (snapshot.error) {
|
|
479
|
+
// Present when the payment failed. The chain, transaction hash and revert reason are
|
|
480
|
+
// under `blockchain_context`, not on the error itself.
|
|
481
|
+
console.error(
|
|
482
|
+
snapshot.error.code,
|
|
483
|
+
snapshot.error.message,
|
|
484
|
+
snapshot.error.blockchain_context?.tx_hash ?? '',
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
#### Waiting for an outcome
|
|
490
|
+
|
|
491
|
+
`getPaymentStatus` answers once and does not wait. To follow a payment to its end, use
|
|
492
|
+
`waitForTerminalStatus`, which is the same helper behind the `send-payment --wait` flag:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
import { waitForTerminalStatus } from '@atumlabs/payment-gateway-client';
|
|
496
|
+
|
|
497
|
+
const outcome = await waitForTerminalStatus({
|
|
498
|
+
fetchStatus: () => client.payments.getPaymentStatus({ paymentId }),
|
|
499
|
+
budgetMs: 60_000,
|
|
500
|
+
onError: (error) => console.warn('status check failed, still waiting:', error),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
if (outcome.timedOut) {
|
|
504
|
+
// Still in flight when the budget ran out. Not a failure — check again later.
|
|
505
|
+
} else {
|
|
506
|
+
console.log(outcome.snapshot?.status);
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
A failed status check does not end the wait: a network blip while a payment is settling is
|
|
511
|
+
the worst moment to stop looking, so failures are counted in `outcome.errorCount`, reported
|
|
512
|
+
through `onError`, and polling continues.
|
|
513
|
+
|
|
514
|
+
`budgetMs` defaults to `DEFAULT_WAIT_BUDGET_MS` (one minute) and is in **milliseconds**.
|
|
515
|
+
|
|
516
|
+
Checks are `DEFAULT_POLL_INTERVAL_MS` apart
|
|
517
|
+
(2 seconds) by default; pass `intervalMs` to widen it, and if you are following many
|
|
518
|
+
payments at once, stagger their start times so their polls do not align.
|
|
519
|
+
|
|
520
|
+
Pass a `signal` to stop a wait early, for a process draining on shutdown or a request that
|
|
521
|
+
has been cancelled. The outcome then reports `aborted`, and still carries the last status it
|
|
522
|
+
read. `timedOut` is true whenever the wait ended without a terminal status, so branching on
|
|
523
|
+
it stays correct either way; read `aborted` when you need to tell the two apart.
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
const controller = new AbortController();
|
|
527
|
+
process.once('SIGTERM', () => controller.abort());
|
|
528
|
+
|
|
529
|
+
const outcome = await waitForTerminalStatus({
|
|
530
|
+
fetchStatus: () => client.payments.getPaymentStatus({ paymentId }),
|
|
531
|
+
signal: controller.signal,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
if (outcome.aborted) {
|
|
535
|
+
// Shutting down. The payment is unaffected; pick it up again on the next start.
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Running out of budget says nothing about the payment: `timedOut` means you stopped
|
|
540
|
+
watching, not that anything failed. Check again later rather than paying again — see
|
|
541
|
+
[Idempotency and retries](#idempotency-and-retries).
|
|
542
|
+
|
|
543
|
+
#### Finding a payment again
|
|
544
|
+
|
|
545
|
+
If a submit call threw, you may hold no `payment_id` — but the payment may still have been
|
|
546
|
+
created. `resolvePayment` turns the `request_id` you stored back into one. It throws an
|
|
547
|
+
`ApiError` with status `404` when the gateway has no payment recorded for that id:
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
import { isApiError } from '@atumlabs/payment-gateway-client';
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const { payment_id } = await client.payments.resolvePayment({ requestId: 'order-4711' });
|
|
554
|
+
const snapshot = await client.payments.getPaymentStatus({ paymentId: payment_id });
|
|
555
|
+
console.log(snapshot.status);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
if (isApiError(error) && error.status === 404) {
|
|
558
|
+
// No payment recorded for that request_id yet.
|
|
559
|
+
} else {
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
A `404` is not proof that nothing happened. A submit that timed out may still be in flight,
|
|
566
|
+
and the gateway records the payment when it lands. **Never start a fresh payment under a new
|
|
567
|
+
`request_id` to recover** — that is a second payment, with real money on both. Re-submit the
|
|
568
|
+
**same** `request_id` instead: it returns the payment if one was created, and creates it if
|
|
569
|
+
not.
|
|
570
|
+
|
|
571
|
+
#### Step-by-step detail
|
|
572
|
+
|
|
573
|
+
`getPaymentTimeline` returns the payment's individual steps, each with its own status and,
|
|
574
|
+
for on-chain steps, a chain id and transaction hash. Use it to show progress, or to
|
|
575
|
+
investigate a payment that did not settle:
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
const timeline = await client.payments.getPaymentTimeline({ paymentId });
|
|
579
|
+
|
|
580
|
+
for (const step of timeline.steps) {
|
|
581
|
+
console.log(step.status, step.label, step.tx_hash ?? '');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (timeline.error) {
|
|
585
|
+
// Why it failed, and where: for an on-chain failure this carries the chain id, the
|
|
586
|
+
// transaction hash and the revert reason.
|
|
587
|
+
console.error(timeline.error.code, timeline.error.message, timeline.error.revert_reason ?? '');
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
The timeline reports `settling` while on-chain steps are running, for a payment that
|
|
592
|
+
`getPaymentStatus` still reports as `pending`.
|
|
593
|
+
|
|
594
|
+
### Solana signing
|
|
595
|
+
|
|
596
|
+
Solana signs with Ed25519, which does not let a verifier recover the signer's key from
|
|
597
|
+
the signature, so the key travels with the message as `payload.delegate_signer`.
|
|
598
|
+
`signPaymentRequest` and the command-line tools set it for you.
|
|
599
|
+
|
|
600
|
+
The key you sign with must be the payment's source account, which is what `pinnedAddress`
|
|
601
|
+
checks — see [Signing provider](#signing-provider).
|
|
602
|
+
|
|
230
603
|
### Error Handling
|
|
231
604
|
|
|
232
605
|
The gateway returns a typed, public error body — `ErrorResponse`
|
|
233
|
-
(`{ code, message, request_id?, docs_url?, domain? }`). Use
|
|
234
|
-
to narrow a caught error to it; it returns `undefined` for
|
|
235
|
-
a gateway error with that shape.
|
|
236
|
-
the
|
|
606
|
+
(`{ code, message, request_id?, payment_id?, docs_url?, domain? }`). Use
|
|
607
|
+
`getErrorResponse` to narrow a caught error to it; it returns `undefined` for
|
|
608
|
+
anything that is not a gateway error with that shape. `payment_id` is present only
|
|
609
|
+
when the error is about a specific payment, so it is absent on the validation and
|
|
610
|
+
authentication failures that happen before a payment exists. For transport-level
|
|
611
|
+
detail (HTTP status, URL), the thrown value is still an `ApiError`.
|
|
612
|
+
|
|
613
|
+
Input validation happens before any request, so those failures are a plain `Error` carrying the
|
|
614
|
+
message and no `ErrorResponse` — `getErrorResponse` returns `undefined` for them. Read
|
|
615
|
+
`error.message`.
|
|
237
616
|
|
|
238
617
|
```typescript
|
|
239
|
-
import { getErrorResponse, ApiError } from '@atumlabs/payment-gateway-client';
|
|
618
|
+
import { getErrorResponse, isGatewayTimeoutError, ApiError } from '@atumlabs/payment-gateway-client';
|
|
240
619
|
|
|
241
620
|
try {
|
|
242
621
|
await client.payments.submitPayment({ requestBody: paymentRequest });
|
|
@@ -247,6 +626,10 @@ try {
|
|
|
247
626
|
console.error('Message:', gwError.message); // safe, human-readable text
|
|
248
627
|
console.error('Request ID:', gwError.request_id); // quote this to support
|
|
249
628
|
console.error('Docs:', gwError.docs_url);
|
|
629
|
+
} else if (isGatewayTimeoutError(error)) {
|
|
630
|
+
// The gateway never answered within the client's budget. There is no
|
|
631
|
+
// response and no status, so this is not an ApiError.
|
|
632
|
+
console.error(error.message, error.timeoutMs);
|
|
250
633
|
} else if (error instanceof ApiError) {
|
|
251
634
|
// Transport-level failure with no typed body (e.g. a proxy/gateway error).
|
|
252
635
|
console.error('HTTP', error.status, error.statusText, error.url);
|
|
@@ -256,36 +639,217 @@ try {
|
|
|
256
639
|
}
|
|
257
640
|
```
|
|
258
641
|
|
|
642
|
+
A thrown error does **not** mean no payment was created — a request that timed out
|
|
643
|
+
may still have been accepted. Treat it as unknown rather than as a failure:
|
|
644
|
+
|
|
645
|
+
- If you already hold the `payment_id` from an earlier attempt, read
|
|
646
|
+
`getPaymentStatus` — the cheapest way to find out what happened.
|
|
647
|
+
- If you do not, call `resolvePayment` with the same `request_id` to get the
|
|
648
|
+
`payment_id`, then read its status. A `404` there means the gateway has no payment
|
|
649
|
+
recorded for that id *yet* — see [Finding a payment again](#finding-a-payment-again).
|
|
650
|
+
- Re-submitting the same `request_id` also works and needs no lookup: it resolves to
|
|
651
|
+
the payment if one was created, and creates it if not. Never recover by starting a
|
|
652
|
+
payment under a **new** `request_id`: that is a second payment, not a retry.
|
|
653
|
+
|
|
654
|
+
One code is worth handling by name:
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
const gwError = getErrorResponse(error);
|
|
658
|
+
if (gwError?.code === 'IDEMPOTENCY_TERMS_MISMATCH') {
|
|
659
|
+
// 409 — this request_id already identifies a payment with different economics.
|
|
660
|
+
// Re-submitting cannot amend that payment; a genuinely different one needs a new
|
|
661
|
+
// request_id. See "Idempotency and retries".
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
259
665
|
### Asset Identifiers
|
|
260
666
|
|
|
261
|
-
Assets are identified using the [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) format
|
|
262
|
-
|
|
263
|
-
-
|
|
264
|
-
|
|
667
|
+
Assets are identified using the [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) format,
|
|
668
|
+
`{chain_id}/{asset_namespace}:{asset_reference}` — see
|
|
669
|
+
[Supported corridors](#supported-corridors) for an example per chain family.
|
|
670
|
+
|
|
671
|
+
Identifiers are validated before a request is built, so a malformed one is reported with the value
|
|
672
|
+
and what to correct rather than surfacing from a chain library. The rules: both namespaces are
|
|
673
|
+
canonicalized to lowercase, an `eip155` chain id is decimal, an EVM token address is 20-byte hex
|
|
674
|
+
and, when mixed-case, must carry a valid [EIP-55](https://eips.ethereum.org/EIPS/eip-55) checksum,
|
|
675
|
+
and a native asset (`slip44`) has no contract to deposit so it is refused. `parseAssetIdentifier`
|
|
676
|
+
and `assetChainId` are exported if you want the same parsing and canonicalization in your own code.
|
|
677
|
+
|
|
678
|
+
The checksum rule applies to the destination as well as the source, which is stricter than the
|
|
679
|
+
gateway, and it is typo protection rather than a settlement guard. For a destination address you
|
|
680
|
+
cannot edit, the all-lowercase form is the same twenty bytes and is accepted. Do not reach for that
|
|
681
|
+
on the source: settler configuration matches the source asset identifier byte for byte, so
|
|
682
|
+
lowercasing it moves the failure to settlement instead of removing it.
|
|
683
|
+
|
|
684
|
+
## Approving the source token
|
|
685
|
+
|
|
686
|
+
On EVM and Tron the escrow moves your token through **Permit2**, so the token contract has to be
|
|
687
|
+
told that Permit2 may move it. That is a one-off transaction per `(wallet, token)` — every payment
|
|
688
|
+
afterwards is just an offline signature and costs nothing.
|
|
689
|
+
|
|
690
|
+
Skipping it produces the worst kind of failure: the request is built correctly, signed correctly
|
|
691
|
+
and accepted by the gateway, and then settlement reverts on-chain minutes later.
|
|
692
|
+
|
|
693
|
+
`ensureSourceApproval` reads the current allowance and sends an approval only if it falls short:
|
|
694
|
+
|
|
695
|
+
```ts
|
|
696
|
+
import { ensureSourceApproval, needsSourceApproval } from '@atumlabs/payment-gateway-client';
|
|
697
|
+
import { Wallet, JsonRpcProvider } from 'ethers';
|
|
698
|
+
|
|
699
|
+
const wallet = new Wallet(process.env.PRIVATE_KEY!, new JsonRpcProvider(RPC_URL));
|
|
700
|
+
|
|
701
|
+
const result = await ensureSourceApproval({
|
|
702
|
+
network: 'eip155:11142220',
|
|
703
|
+
token: '0x456a3D042C0DbD3db53D5489e98dFb038553B0d0',
|
|
704
|
+
owner: wallet.address,
|
|
705
|
+
signer: wallet,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
if (!result.alreadySufficient) {
|
|
709
|
+
console.log(`approved in ${result.txHash}`);
|
|
710
|
+
}
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
You supply the signer, so the SDK never picks an RPC endpoint for you — everything else in this
|
|
714
|
+
package is offline, and this is the one call that broadcasts a transaction and spends gas.
|
|
715
|
+
|
|
716
|
+
It waits for the approval to be mined before returning, so `txHash` means confirmed rather than
|
|
717
|
+
merely sent. That wait is bounded — one minute by default, `confirmation.timeoutMs` to change it —
|
|
718
|
+
and a timeout is reported as *unconfirmed*, not failed, because the transaction may still land.
|
|
719
|
+
Pass `onSubmitted` to receive the hash the moment it is broadcast, so a slow approval can still be
|
|
720
|
+
looked up:
|
|
721
|
+
|
|
722
|
+
```ts
|
|
723
|
+
await ensureSourceApproval({
|
|
724
|
+
network, token, owner, signer,
|
|
725
|
+
onSubmitted: (txHash) => console.log(`watching ${txHash}`),
|
|
726
|
+
confirmation: { timeoutMs: 120_000 },
|
|
727
|
+
});
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
`isUnconfirmed(error)` tells the two apart. It matters because the responses are opposite: a
|
|
731
|
+
failed approval needs another one, an unconfirmed approval needs a look at the transaction and
|
|
732
|
+
nothing else. Sending a second one on top of the first only pays for an allowance you are already
|
|
733
|
+
getting.
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
try {
|
|
737
|
+
await ensureSourceApproval({ network, token, owner, signer });
|
|
738
|
+
} catch (error) {
|
|
739
|
+
if (isUnconfirmed(error)) {
|
|
740
|
+
// Broadcast, not yet seen to confirm. Check the hash from onSubmitted; do not re-send.
|
|
741
|
+
} else {
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
On EVM, some tokens — mainnet USDT is the one you are most likely to meet — refuse to move an
|
|
748
|
+
allowance straight from one non-zero value to another. If your wallet already holds a partial
|
|
749
|
+
allowance on such a token, the approval is reset to zero first, automatically, and the result
|
|
750
|
+
carries a `resetTxHash` alongside the usual `txHash`. The check costs nothing and sends nothing,
|
|
751
|
+
and a wallet with no allowance skips it entirely, so an ordinary approval still takes exactly one
|
|
752
|
+
transaction.
|
|
753
|
+
|
|
754
|
+
Tron does not do this. An ordinary TRC-20 accepts the change in place, so guessing otherwise would
|
|
755
|
+
burn an extra transaction at Tron's fee limit; if a Tron token ever does refuse, the error tells
|
|
756
|
+
you to approve zero first and retry.
|
|
757
|
+
|
|
758
|
+
Paying from Tron takes a TronWeb instance in place of the ethers signer. The Permit2 address for
|
|
759
|
+
the network is resolved for you:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
await ensureSourceApproval({ network: 'tron:shasta', token, owner, tronWeb });
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Solana needs no approval at all — the signed deposit authorizes the transfer itself — so the call
|
|
766
|
+
reports `alreadySufficient` without touching the chain.
|
|
767
|
+
|
|
768
|
+
### Checking without spending gas
|
|
769
|
+
|
|
770
|
+
`needsSourceApproval` takes the same arguments and answers the same question, read-only. Use it to
|
|
771
|
+
warn a payer that a transaction is coming before asking them to sign:
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
if (await needsSourceApproval({ network: 'eip155:11142220', token, owner, signer })) {
|
|
775
|
+
// a one-off approval transaction is due first
|
|
776
|
+
}
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
### Bounded approvals
|
|
780
|
+
|
|
781
|
+
The approval sent is unlimited by default, which is why later payments need no further
|
|
782
|
+
transaction. Pass `approvalAmount` to bound the approval this sends instead.
|
|
783
|
+
|
|
784
|
+
On its own the amount is treated as its own requirement, so an allowance still holding the full
|
|
785
|
+
bound is left alone rather than re-approved — the right answer for a one-off approval. Across
|
|
786
|
+
repeated payments, pair it with `requiredAllowance`, as below: Permit2 **decrements** the
|
|
787
|
+
allowance on every payment, so a bound measured against itself stops covering itself the moment
|
|
788
|
+
the first charge lands, and every later call would send another approval. Either way a bounded
|
|
789
|
+
approval is consumed as it is spent and eventually has to be granted again:
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
await ensureSourceApproval({
|
|
793
|
+
network: 'eip155:11142220',
|
|
794
|
+
token,
|
|
795
|
+
owner,
|
|
796
|
+
signer,
|
|
797
|
+
requiredAllowance: 1_000_000n, // what this payment needs
|
|
798
|
+
approvalAmount: 5_000_000n, // approve only this much, not unlimited
|
|
799
|
+
});
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
These helpers raise an allowance to what a payment needs; they never lower one. A wallet already
|
|
803
|
+
holding more than `approvalAmount` is left exactly as it is and reports `alreadySufficient`
|
|
804
|
+
without sending anything. Reducing or revoking an allowance is a separate operation, and not one
|
|
805
|
+
these perform.
|
|
806
|
+
|
|
807
|
+
Permit2 contract addresses, if you would rather approve by hand:
|
|
808
|
+
|
|
809
|
+
| Chain | Address |
|
|
810
|
+
| --- | --- |
|
|
811
|
+
| EVM (virtually all chains) | `0x000000000022D473030F116dDEE9F6B43aC78BA3` |
|
|
812
|
+
| Tron mainnet | `TJhMXTHQHeQyMD7TcKQFqAePNgG4b31H9m` |
|
|
813
|
+
| Tron Shasta | `TPUqJPASUn1zLvbLBgRZ5pBYrx7WSe5ahp` |
|
|
265
814
|
|
|
266
815
|
## Idempotency and retries
|
|
267
816
|
|
|
268
|
-
Every
|
|
269
|
-
same one on every retry (a timeout, a network blip, a restart) and you end
|
|
270
|
-
with one payment, not two — and you get the same answer each time.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
817
|
+
Every write request carries a `request_id`. Pass a stable value and reuse the
|
|
818
|
+
exact same one on every retry (a timeout, a network blip, a restart) and you end
|
|
819
|
+
up with one payment, not two — and you get the same answer each time. One contract
|
|
820
|
+
covers `submitPayment` and `createQuoteRequest` alike.
|
|
821
|
+
|
|
822
|
+
- Using this SDK, `request_id` is **required** on both builders
|
|
823
|
+
(`preparePaymentRequest`, `prepareQuoteRequest`) and neither invents one — an id
|
|
824
|
+
the library chose is an id you cannot reuse on a retry. On the payment path it is
|
|
825
|
+
the only thing you manage: the SDK derives the on-chain deposit nonce
|
|
826
|
+
deterministically from it, so retries line up on their own.
|
|
827
|
+
- On the CLIs, `--request-id` is optional; when omitted an id is generated and
|
|
828
|
+
printed, so you can re-run with `--request-id <id>` to re-attempt the same
|
|
829
|
+
payment or recover the same batch. A bare re-run, with no `--request-id`, is a
|
|
830
|
+
**separate** payment or batch.
|
|
831
|
+
- Re-sending an id returns what it created the first time — the original payment,
|
|
832
|
+
or the original quote-request batch — and creates nothing new.
|
|
833
|
+
- A genuinely new request needs a **new** `request_id`. Reusing one with different
|
|
834
|
+
economics (a corrected amount, a different token) is rejected with `409`
|
|
835
|
+
`IDEMPOTENCY_TERMS_MISMATCH` — one id identifies one payment, or one batch.
|
|
836
|
+
- Deadlines and the signature are exempt, so a re-submission of the original
|
|
837
|
+
bytes is fine even though its deadlines have expired by then. This is what makes
|
|
838
|
+
a lost `createQuoteRequest` response recoverable: re-send it and you get your
|
|
839
|
+
`quote_request_id` back.
|
|
840
|
+
- `idempotent_replay` on the response tells you whether you were handed something
|
|
841
|
+
that already existed.
|
|
842
|
+
- `pending` is not success. It means accepted and still settling: re-submit the
|
|
843
|
+
same `request_id` to collect the result. A `failed` payment is terminal and
|
|
844
|
+
keeps its id, so recovering from one needs a new id.
|
|
282
845
|
|
|
283
846
|
See [docs/idempotency.md](docs/idempotency.md) for the full model: the two
|
|
284
|
-
guarantees and the
|
|
847
|
+
guarantees and which endpoints each covers, when the `409` applies, how to read
|
|
848
|
+
the outcome, and the per-chain differences (EVM/Tron vs Solana).
|
|
285
849
|
|
|
286
850
|
## What is in the package
|
|
287
851
|
|
|
288
852
|
- `dist/` — compiled JavaScript and TypeScript declarations, ready to use
|
|
289
|
-
- `dist/cli/` — the
|
|
853
|
+
- `dist/cli/` — the five command-line tools
|
|
290
854
|
- `docs/` — reference documentation, including [idempotency and retries](docs/idempotency.md)
|
|
291
855
|
- `LICENSE`, `THIRD-PARTY-NOTICES.txt` — license terms and third-party attribution
|