@atumlabs/payment-gateway-client 2.1.0 → 2.2.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 CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.2.0] - 2026-08-04
9
+
10
+ > **Read this before upgrading if you script `send-payment`.** Its exit codes now
11
+ > distinguish a settled payment from one still settling and one that failed, where every
12
+ > accepted submission previously exited `0`. See the first entry under Changed.
13
+ >
14
+ > One typed-API change: three fields on `PaymentResponse` are no longer optional. This makes
15
+ > reading a response simpler, not harder — see Changed.
16
+
17
+ ### Added
18
+ - `send-payment --wait` keeps checking a submitted payment until it settles or fails, instead of reporting it as still pending. `--wait-seconds <n>` sets how long (default 60). With `--wait` the JSON on stdout is printed once the wait ends and reports the payment as of the last check — so it carries the settled `status` and the `fulfillment_confirmation`, rather than the `pending` the submit returned. Without `--wait`, output is unchanged. The bound is a client-side convenience: giving up does not affect the payment, and re-submitting the same `request_id` or reading the status endpoint picks up wherever it got to. A settlement that stalls never becomes terminal on its own, so no bound can promise a terminal answer.
19
+ - `send-payment` and `payment-status` now print a one-line outcome summary to stderr saying what the payment's state means for a re-attempt: a pending payment is collected by re-submitting the **same** `request_id`, while a terminally failed one keeps its id and needs a **new** one. The JSON on stdout is unchanged.
20
+ - Documented in `docs/idempotency.md` and the README: what the `409` compares, that deadlines and the signature are exempt from it, `idempotent_replay`, how to read `status`, and that a direct caller may either poll the status endpoint or re-submit.
21
+
22
+ ### Changed
23
+ - **Behaviour change — `send-payment` exit codes.** Previously any accepted submission exited `0`. Now `0` means `completed`, `2` means `pending`, and `3` means `failed`/`cancelled`; `1` continues to mean the payment could not be submitted. Previously a still-settling payment and a terminally failed one both exited `0`, so `send-payment ... && <next step>` ran the next step for a payment that had not settled — including when re-submitting a `request_id` whose payment had already failed. Scripts that treat `0` as "submitted" should treat `0` and `2` as "submitted"; scripts that mean "settled" now get that from `0` alone. `payment-status` is unaffected: as a lookup, its exit code reports whether the lookup worked.
24
+ - `--request-id`'s help text now states the consequence of omitting it: an id is generated, which makes every bare re-run a separate payment rather than a retry.
25
+ - **`PaymentResponse.payment_id`, `.status` and `.idempotent_replay` are now required** in the typed API, matching what the gateway has always sent. They were typed optional, so TypeScript reported them as possibly `undefined` and reading `idempotent_replay` meant writing `=== true` to satisfy the compiler for a value that is always present. Existing code keeps compiling — a narrowed type only removes checks — unless it constructs a `PaymentResponse` itself, as a test fixture might, in which case all three now have to be supplied.
26
+
27
+
28
+
29
+ ### Fixed
30
+ - Corrected the idempotency documentation. The 2.1.0 note below said that reusing a `request_id` with different terms returns the original payment and silently drops the new terms. The gateway rejects it with `409` `IDEMPOTENCY_TERMS_MISMATCH`, so an integrator following the old text would neither expect nor handle that error.
31
+ - `IDEMPOTENCY_TERMS_MISMATCH`'s message described the compared terms as "source, destination, or fulfillment_amount", which had become incomplete — `max_source_amount` is compared too. It now names the categories ("accounts, assets, or amounts") rather than enumerating fields, so it stays accurate as the payment schema grows. The docs describe the comparison the same way, and point at the error's own `message` for which category differed.
32
+
8
33
  ## [2.1.0] - 2026-07-31
9
34
 
10
35
  ### Added
package/README.md CHANGED
@@ -51,10 +51,39 @@ send-payment [options] <sender> <amount> <sourceAsset> <receiver> <destinationAs
51
51
 
52
52
  - `-V, --version` - Output the version number
53
53
  - `--gateway <url>` - Payment gateway URL (default: testnet)
54
- - `--request-id <id>` - Idempotency key; seeds the on-chain deposit nonce. Reuse the same id to retry a payment safely. Omitted: a one-shot id is generated and printed (see [Idempotency and retries](#idempotency-and-retries))
54
+ - `--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
55
  - `--request-id-prefix <prefix>` - Prefix for the auto-generated request id (default: `pmt-gw-cli`)
56
+ - `--wait` - After submitting, keep checking until the payment settles or fails instead of reporting it as still pending
57
+ - `--wait-seconds <n>` - How long `--wait` keeps checking (default: `60`). A convenience bound only; giving up does not affect the payment
56
58
  - `-h, --help` - Display help
57
59
 
60
+ ### Exit codes
61
+
62
+ `send-payment` reports the payment's ending, so `send-payment ... && <next step>`
63
+ runs the next step only for a settled payment:
64
+
65
+ | Code | Meaning |
66
+ | ---- | --------------------------------------------------------------------------- |
67
+ | `0` | `completed` — settled |
68
+ | `2` | `pending` — accepted, no result yet |
69
+ | `3` | `failed` / `cancelled` — terminal; a fresh attempt needs a new `--request-id` |
70
+ | `1` | no outcome obtained (rejected, gateway unreachable, bad arguments) |
71
+
72
+ On `2`, follow the payment with **`payment-status <payment_id>`** — the id is in the
73
+ output — or use `--wait` to have `send-payment` do it for you. Re-running
74
+ `send-payment` with the same `--request-id` also returns the current state, but it
75
+ rebuilds and re-sends the payment to do so, which is more work than a status
76
+ lookup.
77
+
78
+ `1` is the case that reverses that order. It means the outcome is **unknown**, not
79
+ that nothing happened: a request that timed out may still have been accepted, and
80
+ you may not have a `payment_id` to look up. Re-run with the same `--request-id`,
81
+ which resolves to the payment if one was created and creates it if not. The id is
82
+ printed on failure for exactly this.
83
+
84
+ `payment-status` is a lookup, so its exit code reports whether the lookup worked
85
+ (`0`) or not (`1`), never the payment's outcome — read `status` for that.
86
+
58
87
  ### CAIP-19 Asset Format Examples
59
88
 
60
89
  - **EVM ERC20**: `eip155:42161/erc20:0xaf88d065e77c8cC2239327C5EDb3A432268e5831`
@@ -209,11 +238,34 @@ async function sendPayment() {
209
238
  requestBody: paymentRequest
210
239
  }) as PaymentResponse;
211
240
 
212
- console.log('Payment successful!');
241
+ // A 2xx means the payment was accepted, NOT that it settled. Branch on `status`:
242
+ // cross-chain settlement can outrun the gateway's synchronous window, so an accepted
243
+ // payment may still have no result yet. See "Idempotency and retries" below.
213
244
  console.log('Payment ID:', response.payment_id);
214
- console.log('Source TX:', response.fulfillment_confirmation?.source_tx_hash);
215
- console.log('Destination TX:', response.fulfillment_confirmation?.destination_tx_hash);
216
-
245
+ if (response.idempotent_replay) {
246
+ // This request_id had been used before, so this is the payment your earlier attempt
247
+ // created. Nothing additional was charged.
248
+ console.log('Handed back an existing payment');
249
+ }
250
+
251
+ switch (response.status) {
252
+ case 'completed':
253
+ console.log('Settled.');
254
+ console.log('Source TX:', response.fulfillment_confirmation?.source_tx_hash);
255
+ console.log('Destination TX:', response.fulfillment_confirmation?.destination_tx_hash);
256
+ break;
257
+ case 'failed':
258
+ case 'cancelled':
259
+ // Terminal, and this request_id stays bound to it. A fresh attempt needs a NEW id.
260
+ console.error('Failed:', response.error?.code, response.error?.message);
261
+ break;
262
+ default:
263
+ // Still settling. Do not release goods, and do not pay again under a new id.
264
+ // Follow it with getPaymentStatus(payment_id); re-submitting this same request
265
+ // also returns the current state, but it re-sends the payment to do so.
266
+ console.log('Still settling — follow it with getPaymentStatus.');
267
+ }
268
+
217
269
  return response;
218
270
  } catch (error) {
219
271
  const gwError = getErrorResponse(error);
@@ -256,6 +308,26 @@ try {
256
308
  }
257
309
  ```
258
310
 
311
+ A thrown error does **not** mean no payment was created — a request that timed out
312
+ may still have been accepted. Treat it as unknown rather than as a failure:
313
+
314
+ - If you already hold the `payment_id` from an earlier attempt, read
315
+ `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.
319
+
320
+ One code is worth handling by name:
321
+
322
+ ```typescript
323
+ const gwError = getErrorResponse(error);
324
+ if (gwError?.code === 'IDEMPOTENCY_TERMS_MISMATCH') {
325
+ // 409 — this request_id already identifies a payment with different economics.
326
+ // Re-submitting cannot amend that payment; a genuinely different one needs a new
327
+ // request_id. See "Idempotency and retries".
328
+ }
329
+ ```
330
+
259
331
  ### Asset Identifiers
260
332
 
261
333
  Assets are identified using the [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) format:
@@ -273,15 +345,23 @@ with one payment, not two — and you get the same answer each time.
273
345
  (`preparePaymentRequest`), and it is the only thing you manage: the SDK derives
274
346
  the on-chain deposit nonce deterministically from it, so retries line up on
275
347
  their own. Reuse the same `request_id` to retry safely.
276
- - On the CLI, `--request-id` is optional; when omitted a one-shot id is generated
277
- and printed, so you can re-run with `--request-id <id>` to retry the same
278
- payment.
348
+ - On the CLI, `--request-id` is optional; when omitted an id is generated and
349
+ 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.
279
351
  - A genuinely new payment needs a **new** `request_id`. Reusing one with different
280
- terms (a corrected amount, a different token) returns the original payment
281
- rather than an error, so the new terms are silently dropped.
352
+ economics (a corrected amount, a different token) is rejected with `409`
353
+ `IDEMPOTENCY_TERMS_MISMATCH` — one id identifies one payment.
354
+ - 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
357
+ that already existed.
358
+ - `pending` is not success. It means accepted and still settling: re-submit the
359
+ same `request_id` to collect the result. A `failed` payment is terminal and
360
+ keeps its id, so recovering from one needs a new id.
282
361
 
283
362
  See [docs/idempotency.md](docs/idempotency.md) for the full model: the two
284
- guarantees and the per-chain differences (EVM/Tron vs Solana).
363
+ guarantees, when the `409` applies, how to read the outcome, and the per-chain
364
+ differences (EVM/Tron vs Solana).
285
365
 
286
366
  ## What is in the package
287
367
 
@@ -249,12 +249,14 @@ var require_utils = __commonJS({
249
249
  "../schemas/declarations/PaymentRequest/v1/bindings/typescript/sender-auth/dist/utils.js"(exports2) {
250
250
  "use strict";
251
251
  Object.defineProperty(exports2, "__esModule", { value: true });
252
- exports2.SOLANA_REPLAY_SLOTS = exports2.PERMIT_NONCE_DOMAIN = exports2.REPLAY_HORIZON_SECS = void 0;
252
+ exports2.REQUEST_ID_DOMAIN = exports2.SOLANA_REPLAY_SLOTS = exports2.PERMIT_NONCE_DOMAIN = exports2.REPLAY_HORIZON_SECS = void 0;
253
253
  exports2.resolvePermitDeadline = resolvePermitDeadline;
254
254
  exports2.generatePermitNonce = generatePermitNonce;
255
255
  exports2.resolvePermitNonce = resolvePermitNonce;
256
+ exports2.canonicalDepositor = canonicalDepositor;
256
257
  exports2.deterministicPermitNonce = deterministicPermitNonce2;
257
258
  exports2.deterministicSolanaNonce = deterministicSolanaNonce2;
259
+ exports2.deterministicRequestId = deterministicRequestId;
258
260
  exports2.sameAddress = sameAddress;
259
261
  exports2.assertKeyMatchesSender = assertKeyMatchesSender2;
260
262
  exports2.assertToken = assertToken;
@@ -299,6 +301,20 @@ var require_utils = __commonJS({
299
301
  const bucket = (seed >> 8n) % (1n << SOLANA_BUCKET_BITS);
300
302
  return bucket << 32n | slot;
301
303
  }
304
+ exports2.REQUEST_ID_DOMAIN = "atum-request:v1:";
305
+ function deterministicRequestId(params) {
306
+ if (!params.anchor) {
307
+ throw new Error("sender_auth: a per-intent anchor is required to derive a deterministic request_id (a random one would silently break idempotency and let a retry double-charge)");
308
+ }
309
+ if (!params.account) {
310
+ throw new Error("sender_auth: account is required to derive a deterministic request_id");
311
+ }
312
+ if (!/^[A-Za-z0-9_-]*$/.test(params.prefix)) {
313
+ throw new Error(`sender_auth: request_id prefix must contain only letters, digits, '_' or '-' (got ${JSON.stringify(params.prefix)})`);
314
+ }
315
+ const digest = (0, ethers_1.keccak256)((0, ethers_1.toUtf8Bytes)(`${exports2.REQUEST_ID_DOMAIN}${params.anchor}:${canonicalDepositor(params.account)}`));
316
+ return `${params.prefix}${digest.slice(2, 26)}`;
317
+ }
302
318
  function sameAddress(a, b) {
303
319
  if (a.startsWith("0x") && b.startsWith("0x")) {
304
320
  return a.toLowerCase() === b.toLowerCase();
@@ -890,7 +906,7 @@ var require_evm = __commonJS({
890
906
  exports2.evmAdapter = {
891
907
  async buildAuthMessage({ sourceAsset, maxSourceAmount, sourceDefaults, deadline, nonce }) {
892
908
  if (nonce === void 0) {
893
- throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. the challenge id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
909
+ throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. an order id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
894
910
  }
895
911
  if (deadline === void 0) {
896
912
  throw new Error("sender_auth: buildAuthMessage requires an explicit permit deadline (absolute Unix seconds) \u2014 the low-level builder does not default it; derive it from the payment's fulfillment/settlement deadline in the calling layer (pgc/mppx)");
@@ -918,6 +934,10 @@ var require_evm = __commonJS({
918
934
  throw new Error("EVM adapter sign(): signed_messages[i].message_prehash is required");
919
935
  }
920
936
  const prehash = JSON.parse(signedMessage.message_prehash);
937
+ const prehashDigest = (0, evm_escrow_encoding_1.hashPermitWitnessTransferFrom)(prehash);
938
+ if (prehashDigest.toLowerCase() !== signedMessage.message.toLowerCase()) {
939
+ throw new Error(`EVM adapter sign(): message_prehash implies digest ${prehashDigest} but message is ${signedMessage.message}`);
940
+ }
921
941
  const wallet = new ethers_1.Wallet(privateKey);
922
942
  return wallet.signTypedData(prehash.domain, prehash.types, prehash.message);
923
943
  },
@@ -1004,7 +1024,7 @@ var require_tron = __commonJS({
1004
1024
  exports2.tronAdapter = {
1005
1025
  async buildAuthMessage({ sourceAsset, maxSourceAmount, sourceDefaults, deadline, nonce }) {
1006
1026
  if (nonce === void 0) {
1007
- throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. the challenge id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
1027
+ throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. an order id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
1008
1028
  }
1009
1029
  if (deadline === void 0) {
1010
1030
  throw new Error("sender_auth: buildAuthMessage requires an explicit permit deadline (absolute Unix seconds) \u2014 the low-level builder does not default it; derive it from the payment's fulfillment/settlement deadline in the calling layer (pgc/mppx)");
@@ -1027,9 +1047,19 @@ var require_tron = __commonJS({
1027
1047
  const messageHash = (0, tvm_escrow_encoding_1.hashTvmPermitWitnessTransferFrom)(messagePrehash);
1028
1048
  return { messageScheme: "EVM_PERMIT2", messageHash, messagePrehash: JSON.stringify(messagePrehash) };
1029
1049
  },
1050
+ // Signs the TIP-712 typed data, not the bare digest, so a signer can inspect the fields it is
1051
+ // authorizing (Turnkey policies read `eth.eip_712.message[...]`).
1030
1052
  async sign(signedMessage, privateKey) {
1053
+ if (!signedMessage.message_prehash) {
1054
+ throw new Error("Tron adapter sign(): signed_messages[i].message_prehash is required");
1055
+ }
1056
+ const prehash = JSON.parse(signedMessage.message_prehash);
1057
+ const prehashDigest = (0, tvm_escrow_encoding_1.hashTvmPermitWitnessTransferFrom)(prehash);
1058
+ if (prehashDigest.toLowerCase() !== signedMessage.message.toLowerCase()) {
1059
+ throw new Error(`Tron adapter sign(): message_prehash implies digest ${prehashDigest} but message is ${signedMessage.message}`);
1060
+ }
1031
1061
  const wallet = new ethers_1.Wallet(privateKey.startsWith("0x") ? privateKey : "0x" + privateKey);
1032
- return wallet.signingKey.sign(signedMessage.message).serialized;
1062
+ return wallet.signTypedData(prehash.domain, prehash.types, prehash.message);
1033
1063
  },
1034
1064
  recoverSigner(signed, source) {
1035
1065
  let recovered;
@@ -2867,8 +2897,26 @@ var PaymentsService = class {
2867
2897
  * 2. Find optimal settlement routes across chains/assets
2868
2898
  * 3. Secure funds in escrow
2869
2899
  * 4. Coordinate with settlement agents for execution
2870
- * 5. Return a real-time result if processing completes within 30 seconds,
2871
- * otherwise return a payment ID for status polling
2900
+ * 5. Return a real-time result if settlement completes inside the synchronous window,
2901
+ * otherwise return a payment ID and status for polling
2902
+ *
2903
+ * **Idempotency.** `request_id` is the idempotency key, scoped to the source account
2904
+ * and originator. Submitting the same `request_id` again returns the ORIGINAL payment
2905
+ * instead of charging a second time, so a client whose request timed out or failed
2906
+ * in transit can safely re-submit.
2907
+ *
2908
+ * Because a re-submission must be admissible on its own, some fields legitimately
2909
+ * differ between attempts and are excluded from the comparison: `quote_deadline` and
2910
+ * `fulfillment_deadline` (both must be in the future, so a later attempt has to move
2911
+ * them), and the `sender_auth` signature they are signed into.
2912
+ *
2913
+ * What must NOT differ is the payment's economics — source account and asset,
2914
+ * destination accounts and assets, `fulfillment_amount`, and `max_source_amount` (the
2915
+ * payer's own risk cap; lowering it on a retry describes a different payment, not the
2916
+ * same one with less headroom). Re-using one `request_id` for a genuinely different
2917
+ * payment is a client bug that would otherwise be silently collapsed onto the first
2918
+ * payment, so it is rejected with `409` and `IDEMPOTENCY_TERMS_MISMATCH`. Use a
2919
+ * distinct `request_id` per payment.
2872
2920
  *
2873
2921
  * @param data The data for the request.
2874
2922
  * @param data.requestBody
@@ -2885,6 +2933,11 @@ var PaymentsService = class {
2885
2933
  400: "Invalid request",
2886
2934
  401: "Signature or sender authorization could not be verified",
2887
2935
  403: "Payment declined during counterparty authorization",
2936
+ 409: `The \`request_id\` has already been used for a payment with different economics
2937
+ (\`IDEMPOTENCY_TERMS_MISMATCH\`). Re-submitting one \`request_id\` is how a retry is
2938
+ made safe, so it must identify one payment; use a distinct \`request_id\` per
2939
+ payment.
2940
+ `,
2888
2941
  413: "Payment request too large to process",
2889
2942
  500: "Internal server error",
2890
2943
  503: "A dependency is temporarily unavailable"
@@ -3704,7 +3757,7 @@ var PaymentGatewayClient2 = class extends PaymentGatewayClient {
3704
3757
  };
3705
3758
 
3706
3759
  // src/version.ts
3707
- var PACKAGE_VERSION = true ? "2.1.0" : "0.0.0-dev";
3760
+ var PACKAGE_VERSION = true ? "2.2.0" : "0.0.0-dev";
3708
3761
 
3709
3762
  // src/cli/load-env.ts
3710
3763
  var import_dotenv = require("dotenv");
@@ -3712,6 +3765,34 @@ function loadCliEnv() {
3712
3765
  (0, import_dotenv.config)({ override: false, quiet: true });
3713
3766
  }
3714
3767
 
3768
+ // src/cli/payment-outcome.ts
3769
+ var EXIT_COMPLETED = 0;
3770
+ var EXIT_PENDING = 2;
3771
+ var EXIT_TERMINAL_FAILURE = 3;
3772
+ function describePaymentOutcome(input) {
3773
+ const { status, paymentId, requestId, idempotentReplay, errorCode } = input;
3774
+ const payment = paymentId ? `payment ${paymentId}` : "the payment";
3775
+ const replay = idempotentReplay ? " This is the payment your earlier attempt created \u2014 nothing additional was charged." : "";
3776
+ if (status === "completed") {
3777
+ return {
3778
+ exitCode: EXIT_COMPLETED,
3779
+ summary: `completed: ${payment} settled.${replay}`
3780
+ };
3781
+ }
3782
+ if (status === "failed" || status === "cancelled") {
3783
+ const reason = errorCode ? ` (${errorCode})` : "";
3784
+ return {
3785
+ exitCode: EXIT_TERMINAL_FAILURE,
3786
+ summary: `${status}: ${payment} ended without settling${reason}. This is final \u2014 re-submitting ${requestId ? `request id ${requestId}` : "the same request id"} returns this same failed payment, so a fresh attempt needs a NEW request id.`
3787
+ };
3788
+ }
3789
+ const resubmit = requestId ? `request id ${requestId}` : "the same request id";
3790
+ return {
3791
+ exitCode: EXIT_PENDING,
3792
+ summary: `pending: ${payment} was accepted and is still settling.${replay} To collect the result, keep checking its status, or re-submit ${resubmit}. Do not start a new payment under a different request id \u2014 that would pay twice.`
3793
+ };
3794
+ }
3795
+
3715
3796
  // src/cli/payment-status.ts
3716
3797
  loadCliEnv();
3717
3798
  var program = new import_commander.Command();
@@ -3722,11 +3803,22 @@ Example:
3722
3803
  Status values: pending | completed | failed | cancelled
3723
3804
 
3724
3805
  Output:
3725
- Response JSON -> stdout (for parsing/automation, e.g. | jq)`).action(async (paymentId, options) => {
3806
+ Response JSON -> stdout (for parsing/automation, e.g. | jq)
3807
+ What it means -> stderr
3808
+
3809
+ Exit code reports whether the lookup worked (0) or not (1), not the payment's
3810
+ outcome \u2014 read \`status\` for that, or use send-payment's exit code.`).action(async (paymentId, options) => {
3726
3811
  try {
3727
3812
  const client = new PaymentGatewayClient2({ BASE: options.gateway });
3728
3813
  const response = await client.payments.getPaymentStatus({ paymentId });
3729
3814
  console.log(JSON.stringify(response, null, 2));
3815
+ console.error(
3816
+ describePaymentOutcome({
3817
+ status: response.status,
3818
+ paymentId: response.payment_id ?? paymentId,
3819
+ errorCode: response.error?.code
3820
+ }).summary
3821
+ );
3730
3822
  process.exit(0);
3731
3823
  } catch (error) {
3732
3824
  if (error instanceof ApiError && error.body) {
@@ -249,12 +249,14 @@ var require_utils = __commonJS({
249
249
  "../schemas/declarations/PaymentRequest/v1/bindings/typescript/sender-auth/dist/utils.js"(exports2) {
250
250
  "use strict";
251
251
  Object.defineProperty(exports2, "__esModule", { value: true });
252
- exports2.SOLANA_REPLAY_SLOTS = exports2.PERMIT_NONCE_DOMAIN = exports2.REPLAY_HORIZON_SECS = void 0;
252
+ exports2.REQUEST_ID_DOMAIN = exports2.SOLANA_REPLAY_SLOTS = exports2.PERMIT_NONCE_DOMAIN = exports2.REPLAY_HORIZON_SECS = void 0;
253
253
  exports2.resolvePermitDeadline = resolvePermitDeadline;
254
254
  exports2.generatePermitNonce = generatePermitNonce;
255
255
  exports2.resolvePermitNonce = resolvePermitNonce;
256
+ exports2.canonicalDepositor = canonicalDepositor;
256
257
  exports2.deterministicPermitNonce = deterministicPermitNonce2;
257
258
  exports2.deterministicSolanaNonce = deterministicSolanaNonce2;
259
+ exports2.deterministicRequestId = deterministicRequestId;
258
260
  exports2.sameAddress = sameAddress;
259
261
  exports2.assertKeyMatchesSender = assertKeyMatchesSender2;
260
262
  exports2.assertToken = assertToken;
@@ -299,6 +301,20 @@ var require_utils = __commonJS({
299
301
  const bucket = (seed >> 8n) % (1n << SOLANA_BUCKET_BITS);
300
302
  return bucket << 32n | slot;
301
303
  }
304
+ exports2.REQUEST_ID_DOMAIN = "atum-request:v1:";
305
+ function deterministicRequestId(params) {
306
+ if (!params.anchor) {
307
+ throw new Error("sender_auth: a per-intent anchor is required to derive a deterministic request_id (a random one would silently break idempotency and let a retry double-charge)");
308
+ }
309
+ if (!params.account) {
310
+ throw new Error("sender_auth: account is required to derive a deterministic request_id");
311
+ }
312
+ if (!/^[A-Za-z0-9_-]*$/.test(params.prefix)) {
313
+ throw new Error(`sender_auth: request_id prefix must contain only letters, digits, '_' or '-' (got ${JSON.stringify(params.prefix)})`);
314
+ }
315
+ const digest = (0, ethers_1.keccak256)((0, ethers_1.toUtf8Bytes)(`${exports2.REQUEST_ID_DOMAIN}${params.anchor}:${canonicalDepositor(params.account)}`));
316
+ return `${params.prefix}${digest.slice(2, 26)}`;
317
+ }
302
318
  function sameAddress(a, b) {
303
319
  if (a.startsWith("0x") && b.startsWith("0x")) {
304
320
  return a.toLowerCase() === b.toLowerCase();
@@ -890,7 +906,7 @@ var require_evm = __commonJS({
890
906
  exports2.evmAdapter = {
891
907
  async buildAuthMessage({ sourceAsset, maxSourceAmount, sourceDefaults, deadline, nonce }) {
892
908
  if (nonce === void 0) {
893
- throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. the challenge id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
909
+ throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. an order id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
894
910
  }
895
911
  if (deadline === void 0) {
896
912
  throw new Error("sender_auth: buildAuthMessage requires an explicit permit deadline (absolute Unix seconds) \u2014 the low-level builder does not default it; derive it from the payment's fulfillment/settlement deadline in the calling layer (pgc/mppx)");
@@ -918,6 +934,10 @@ var require_evm = __commonJS({
918
934
  throw new Error("EVM adapter sign(): signed_messages[i].message_prehash is required");
919
935
  }
920
936
  const prehash = JSON.parse(signedMessage.message_prehash);
937
+ const prehashDigest = (0, evm_escrow_encoding_1.hashPermitWitnessTransferFrom)(prehash);
938
+ if (prehashDigest.toLowerCase() !== signedMessage.message.toLowerCase()) {
939
+ throw new Error(`EVM adapter sign(): message_prehash implies digest ${prehashDigest} but message is ${signedMessage.message}`);
940
+ }
921
941
  const wallet = new ethers_1.Wallet(privateKey);
922
942
  return wallet.signTypedData(prehash.domain, prehash.types, prehash.message);
923
943
  },
@@ -1004,7 +1024,7 @@ var require_tron = __commonJS({
1004
1024
  exports2.tronAdapter = {
1005
1025
  async buildAuthMessage({ sourceAsset, maxSourceAmount, sourceDefaults, deadline, nonce }) {
1006
1026
  if (nonce === void 0) {
1007
- throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. the challenge id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
1027
+ throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. an order id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
1008
1028
  }
1009
1029
  if (deadline === void 0) {
1010
1030
  throw new Error("sender_auth: buildAuthMessage requires an explicit permit deadline (absolute Unix seconds) \u2014 the low-level builder does not default it; derive it from the payment's fulfillment/settlement deadline in the calling layer (pgc/mppx)");
@@ -1027,9 +1047,19 @@ var require_tron = __commonJS({
1027
1047
  const messageHash = (0, tvm_escrow_encoding_1.hashTvmPermitWitnessTransferFrom)(messagePrehash);
1028
1048
  return { messageScheme: "EVM_PERMIT2", messageHash, messagePrehash: JSON.stringify(messagePrehash) };
1029
1049
  },
1050
+ // Signs the TIP-712 typed data, not the bare digest, so a signer can inspect the fields it is
1051
+ // authorizing (Turnkey policies read `eth.eip_712.message[...]`).
1030
1052
  async sign(signedMessage, privateKey) {
1053
+ if (!signedMessage.message_prehash) {
1054
+ throw new Error("Tron adapter sign(): signed_messages[i].message_prehash is required");
1055
+ }
1056
+ const prehash = JSON.parse(signedMessage.message_prehash);
1057
+ const prehashDigest = (0, tvm_escrow_encoding_1.hashTvmPermitWitnessTransferFrom)(prehash);
1058
+ if (prehashDigest.toLowerCase() !== signedMessage.message.toLowerCase()) {
1059
+ throw new Error(`Tron adapter sign(): message_prehash implies digest ${prehashDigest} but message is ${signedMessage.message}`);
1060
+ }
1031
1061
  const wallet = new ethers_1.Wallet(privateKey.startsWith("0x") ? privateKey : "0x" + privateKey);
1032
- return wallet.signingKey.sign(signedMessage.message).serialized;
1062
+ return wallet.signTypedData(prehash.domain, prehash.types, prehash.message);
1033
1063
  },
1034
1064
  recoverSigner(signed, source) {
1035
1065
  let recovered;
@@ -2867,8 +2897,26 @@ var PaymentsService = class {
2867
2897
  * 2. Find optimal settlement routes across chains/assets
2868
2898
  * 3. Secure funds in escrow
2869
2899
  * 4. Coordinate with settlement agents for execution
2870
- * 5. Return a real-time result if processing completes within 30 seconds,
2871
- * otherwise return a payment ID for status polling
2900
+ * 5. Return a real-time result if settlement completes inside the synchronous window,
2901
+ * otherwise return a payment ID and status for polling
2902
+ *
2903
+ * **Idempotency.** `request_id` is the idempotency key, scoped to the source account
2904
+ * and originator. Submitting the same `request_id` again returns the ORIGINAL payment
2905
+ * instead of charging a second time, so a client whose request timed out or failed
2906
+ * in transit can safely re-submit.
2907
+ *
2908
+ * Because a re-submission must be admissible on its own, some fields legitimately
2909
+ * differ between attempts and are excluded from the comparison: `quote_deadline` and
2910
+ * `fulfillment_deadline` (both must be in the future, so a later attempt has to move
2911
+ * them), and the `sender_auth` signature they are signed into.
2912
+ *
2913
+ * What must NOT differ is the payment's economics — source account and asset,
2914
+ * destination accounts and assets, `fulfillment_amount`, and `max_source_amount` (the
2915
+ * payer's own risk cap; lowering it on a retry describes a different payment, not the
2916
+ * same one with less headroom). Re-using one `request_id` for a genuinely different
2917
+ * payment is a client bug that would otherwise be silently collapsed onto the first
2918
+ * payment, so it is rejected with `409` and `IDEMPOTENCY_TERMS_MISMATCH`. Use a
2919
+ * distinct `request_id` per payment.
2872
2920
  *
2873
2921
  * @param data The data for the request.
2874
2922
  * @param data.requestBody
@@ -2885,6 +2933,11 @@ var PaymentsService = class {
2885
2933
  400: "Invalid request",
2886
2934
  401: "Signature or sender authorization could not be verified",
2887
2935
  403: "Payment declined during counterparty authorization",
2936
+ 409: `The \`request_id\` has already been used for a payment with different economics
2937
+ (\`IDEMPOTENCY_TERMS_MISMATCH\`). Re-submitting one \`request_id\` is how a retry is
2938
+ made safe, so it must identify one payment; use a distinct \`request_id\` per
2939
+ payment.
2940
+ `,
2888
2941
  413: "Payment request too large to process",
2889
2942
  500: "Internal server error",
2890
2943
  503: "A dependency is temporarily unavailable"
@@ -3704,7 +3757,7 @@ var PaymentGatewayClient2 = class extends PaymentGatewayClient {
3704
3757
  };
3705
3758
 
3706
3759
  // src/version.ts
3707
- var PACKAGE_VERSION = true ? "2.1.0" : "0.0.0-dev";
3760
+ var PACKAGE_VERSION = true ? "2.2.0" : "0.0.0-dev";
3708
3761
 
3709
3762
  // src/cli/load-env.ts
3710
3763
  var import_dotenv = require("dotenv");
@@ -249,12 +249,14 @@ var require_utils = __commonJS({
249
249
  "../schemas/declarations/PaymentRequest/v1/bindings/typescript/sender-auth/dist/utils.js"(exports2) {
250
250
  "use strict";
251
251
  Object.defineProperty(exports2, "__esModule", { value: true });
252
- exports2.SOLANA_REPLAY_SLOTS = exports2.PERMIT_NONCE_DOMAIN = exports2.REPLAY_HORIZON_SECS = void 0;
252
+ exports2.REQUEST_ID_DOMAIN = exports2.SOLANA_REPLAY_SLOTS = exports2.PERMIT_NONCE_DOMAIN = exports2.REPLAY_HORIZON_SECS = void 0;
253
253
  exports2.resolvePermitDeadline = resolvePermitDeadline;
254
254
  exports2.generatePermitNonce = generatePermitNonce;
255
255
  exports2.resolvePermitNonce = resolvePermitNonce;
256
+ exports2.canonicalDepositor = canonicalDepositor;
256
257
  exports2.deterministicPermitNonce = deterministicPermitNonce2;
257
258
  exports2.deterministicSolanaNonce = deterministicSolanaNonce2;
259
+ exports2.deterministicRequestId = deterministicRequestId;
258
260
  exports2.sameAddress = sameAddress;
259
261
  exports2.assertKeyMatchesSender = assertKeyMatchesSender2;
260
262
  exports2.assertToken = assertToken;
@@ -299,6 +301,20 @@ var require_utils = __commonJS({
299
301
  const bucket = (seed >> 8n) % (1n << SOLANA_BUCKET_BITS);
300
302
  return bucket << 32n | slot;
301
303
  }
304
+ exports2.REQUEST_ID_DOMAIN = "atum-request:v1:";
305
+ function deterministicRequestId(params) {
306
+ if (!params.anchor) {
307
+ throw new Error("sender_auth: a per-intent anchor is required to derive a deterministic request_id (a random one would silently break idempotency and let a retry double-charge)");
308
+ }
309
+ if (!params.account) {
310
+ throw new Error("sender_auth: account is required to derive a deterministic request_id");
311
+ }
312
+ if (!/^[A-Za-z0-9_-]*$/.test(params.prefix)) {
313
+ throw new Error(`sender_auth: request_id prefix must contain only letters, digits, '_' or '-' (got ${JSON.stringify(params.prefix)})`);
314
+ }
315
+ const digest = (0, ethers_1.keccak256)((0, ethers_1.toUtf8Bytes)(`${exports2.REQUEST_ID_DOMAIN}${params.anchor}:${canonicalDepositor(params.account)}`));
316
+ return `${params.prefix}${digest.slice(2, 26)}`;
317
+ }
302
318
  function sameAddress(a, b) {
303
319
  if (a.startsWith("0x") && b.startsWith("0x")) {
304
320
  return a.toLowerCase() === b.toLowerCase();
@@ -890,7 +906,7 @@ var require_evm = __commonJS({
890
906
  exports2.evmAdapter = {
891
907
  async buildAuthMessage({ sourceAsset, maxSourceAmount, sourceDefaults, deadline, nonce }) {
892
908
  if (nonce === void 0) {
893
- throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. the challenge id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
909
+ throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. an order id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
894
910
  }
895
911
  if (deadline === void 0) {
896
912
  throw new Error("sender_auth: buildAuthMessage requires an explicit permit deadline (absolute Unix seconds) \u2014 the low-level builder does not default it; derive it from the payment's fulfillment/settlement deadline in the calling layer (pgc/mppx)");
@@ -918,6 +934,10 @@ var require_evm = __commonJS({
918
934
  throw new Error("EVM adapter sign(): signed_messages[i].message_prehash is required");
919
935
  }
920
936
  const prehash = JSON.parse(signedMessage.message_prehash);
937
+ const prehashDigest = (0, evm_escrow_encoding_1.hashPermitWitnessTransferFrom)(prehash);
938
+ if (prehashDigest.toLowerCase() !== signedMessage.message.toLowerCase()) {
939
+ throw new Error(`EVM adapter sign(): message_prehash implies digest ${prehashDigest} but message is ${signedMessage.message}`);
940
+ }
921
941
  const wallet = new ethers_1.Wallet(privateKey);
922
942
  return wallet.signTypedData(prehash.domain, prehash.types, prehash.message);
923
943
  },
@@ -1004,7 +1024,7 @@ var require_tron = __commonJS({
1004
1024
  exports2.tronAdapter = {
1005
1025
  async buildAuthMessage({ sourceAsset, maxSourceAmount, sourceDefaults, deadline, nonce }) {
1006
1026
  if (nonce === void 0) {
1007
- throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. the challenge id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
1027
+ throw new Error("sender_auth: buildAuthMessage requires an explicit nonce \u2014 derive it deterministically from a stable request id (e.g. an order id) so retries reproduce the same permit; pass generatePermitNonce() only for genuinely one-shot builds");
1008
1028
  }
1009
1029
  if (deadline === void 0) {
1010
1030
  throw new Error("sender_auth: buildAuthMessage requires an explicit permit deadline (absolute Unix seconds) \u2014 the low-level builder does not default it; derive it from the payment's fulfillment/settlement deadline in the calling layer (pgc/mppx)");
@@ -1027,9 +1047,19 @@ var require_tron = __commonJS({
1027
1047
  const messageHash = (0, tvm_escrow_encoding_1.hashTvmPermitWitnessTransferFrom)(messagePrehash);
1028
1048
  return { messageScheme: "EVM_PERMIT2", messageHash, messagePrehash: JSON.stringify(messagePrehash) };
1029
1049
  },
1050
+ // Signs the TIP-712 typed data, not the bare digest, so a signer can inspect the fields it is
1051
+ // authorizing (Turnkey policies read `eth.eip_712.message[...]`).
1030
1052
  async sign(signedMessage, privateKey) {
1053
+ if (!signedMessage.message_prehash) {
1054
+ throw new Error("Tron adapter sign(): signed_messages[i].message_prehash is required");
1055
+ }
1056
+ const prehash = JSON.parse(signedMessage.message_prehash);
1057
+ const prehashDigest = (0, tvm_escrow_encoding_1.hashTvmPermitWitnessTransferFrom)(prehash);
1058
+ if (prehashDigest.toLowerCase() !== signedMessage.message.toLowerCase()) {
1059
+ throw new Error(`Tron adapter sign(): message_prehash implies digest ${prehashDigest} but message is ${signedMessage.message}`);
1060
+ }
1031
1061
  const wallet = new ethers_1.Wallet(privateKey.startsWith("0x") ? privateKey : "0x" + privateKey);
1032
- return wallet.signingKey.sign(signedMessage.message).serialized;
1062
+ return wallet.signTypedData(prehash.domain, prehash.types, prehash.message);
1033
1063
  },
1034
1064
  recoverSigner(signed, source) {
1035
1065
  let recovered;
@@ -2867,8 +2897,26 @@ var PaymentsService = class {
2867
2897
  * 2. Find optimal settlement routes across chains/assets
2868
2898
  * 3. Secure funds in escrow
2869
2899
  * 4. Coordinate with settlement agents for execution
2870
- * 5. Return a real-time result if processing completes within 30 seconds,
2871
- * otherwise return a payment ID for status polling
2900
+ * 5. Return a real-time result if settlement completes inside the synchronous window,
2901
+ * otherwise return a payment ID and status for polling
2902
+ *
2903
+ * **Idempotency.** `request_id` is the idempotency key, scoped to the source account
2904
+ * and originator. Submitting the same `request_id` again returns the ORIGINAL payment
2905
+ * instead of charging a second time, so a client whose request timed out or failed
2906
+ * in transit can safely re-submit.
2907
+ *
2908
+ * Because a re-submission must be admissible on its own, some fields legitimately
2909
+ * differ between attempts and are excluded from the comparison: `quote_deadline` and
2910
+ * `fulfillment_deadline` (both must be in the future, so a later attempt has to move
2911
+ * them), and the `sender_auth` signature they are signed into.
2912
+ *
2913
+ * What must NOT differ is the payment's economics — source account and asset,
2914
+ * destination accounts and assets, `fulfillment_amount`, and `max_source_amount` (the
2915
+ * payer's own risk cap; lowering it on a retry describes a different payment, not the
2916
+ * same one with less headroom). Re-using one `request_id` for a genuinely different
2917
+ * payment is a client bug that would otherwise be silently collapsed onto the first
2918
+ * payment, so it is rejected with `409` and `IDEMPOTENCY_TERMS_MISMATCH`. Use a
2919
+ * distinct `request_id` per payment.
2872
2920
  *
2873
2921
  * @param data The data for the request.
2874
2922
  * @param data.requestBody
@@ -2885,6 +2933,11 @@ var PaymentsService = class {
2885
2933
  400: "Invalid request",
2886
2934
  401: "Signature or sender authorization could not be verified",
2887
2935
  403: "Payment declined during counterparty authorization",
2936
+ 409: `The \`request_id\` has already been used for a payment with different economics
2937
+ (\`IDEMPOTENCY_TERMS_MISMATCH\`). Re-submitting one \`request_id\` is how a retry is
2938
+ made safe, so it must identify one payment; use a distinct \`request_id\` per
2939
+ payment.
2940
+ `,
2888
2941
  413: "Payment request too large to process",
2889
2942
  500: "Internal server error",
2890
2943
  503: "A dependency is temporarily unavailable"
@@ -3714,7 +3767,7 @@ function parseSeconds(optionName) {
3714
3767
  }
3715
3768
 
3716
3769
  // src/version.ts
3717
- var PACKAGE_VERSION = true ? "2.1.0" : "0.0.0-dev";
3770
+ var PACKAGE_VERSION = true ? "2.2.0" : "0.0.0-dev";
3718
3771
 
3719
3772
  // src/cli/load-env.ts
3720
3773
  var import_dotenv = require("dotenv");