@atumlabs/mppx-atum-escrow 0.1.1 → 0.3.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 ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-07-30
9
+
10
+ ### Added
11
+ - `buildChargeChallenge(corridor, select, fulfillmentAmount, { intentId })` — the merchant-side entry point for building a charge challenge. It pairs the payment terms with a per-purchase identifier (your order or invoice id), which is what makes a retry of that purchase resolve to the original payment instead of becoming a second charge. It travels in challenge metadata under `INTENT_ID_META_KEY`.
12
+ - `SettlementPendingError`, `SettlementFailedError`, and `PaymentRejectedError`, with `isSettlementPending` / `isSettlementFailed` / `isPaymentRejected` guards. A payment that has not settled is no longer reported as a single undifferentiated failure: pending means retrying the same purchase is safe and is how the result is collected, while a terminal failure means that purchase can never settle and recovery requires a new one. Both settlement errors carry the gateway's `paymentId` — as its own field on `toProblemDetails()` (and `SettlementFailedError` also carries `state`), not only interpolated into the message text, so a payer reading the wire response can reconcile the attempt without parsing a sentence.
13
+ - `PaymentSubmitResult.status`, so a submitter can pass the gateway's lifecycle state through. An absent status is read as pending.
14
+
15
+ ### Fixed
16
+ - The payer's `signer` option now accepts Turnkey provider config (`{ provider: "turnkey", ... }`) as documented by its type, instead of throwing and directing you to construct a `TurnkeySenderSigner` yourself and pass it as a ready-made signer. Both paths now behave the same way; the ready-made-signer path still works unchanged.
17
+ - A credential presented after its quote window has closed now fails with a typed `PaymentActionRequiredError` (the same type `SettlementPendingError` carries) instead of a plain `Error`. mppx's request-routing layer replaces any thrown value that is not a recognized payment error with a generic "Payment verification failed." before it reaches the wire, which was silently erasing the "re-attempt with a fresh challenge" guidance for every real HTTP payer — only a merchant calling `verify()` directly in-process ever saw it. The message is unchanged; only its wire-visible type is fixed, so a payer's retry loop can now correctly recognize this as the same "fetch a fresh challenge and continue" outcome as a pending settlement.
18
+
19
+ ### Changed
20
+ - **Breaking:** the payment's identity is now derived from the challenge's per-purchase identifier rather than from the challenge id. The challenge id is an HMAC over the challenge's own contents, so it moved whenever the expiry or the quote moved: under a rolling `expires` every retry derived a fresh identity and was charged as a new payment, and with no `expires` or metadata at all two distinct purchases with identical terms collapsed onto one payment, leaving the second unpaid. Merchants now stamp `intentId` (via `buildChargeChallenge`); a challenge without one is refused by the payer's client rather than paid unsafely.
21
+ - **If your integration sets `challenge.opaque` yourself** (the pre-0.3.0 way to carry your own correlation data), note that it silently overrides the `meta` this relies on for `intentId` — the mppx framework only serializes `meta` into `opaque` when nothing has already set `opaque`. Move your own data onto extra keys of the same `meta` object `buildChargeChallenge` returns instead. See the README's "Quick start — merchant" section.
22
+ - A `PaymentSubmitter` should submit and return the gateway's response rather than polling for completion — the payer's retry is what collects a pending result. Polling holds the merchant's request open for the whole settlement window and hides the pending state that makes the retry safe.
23
+ - **Re-attempting a purchase is now spelled out**, in `SettlementPendingError`'s own message and in the README: request a fresh challenge and sign it again, keeping the same `intentId`. "Retry the same purchase" was ambiguous, and the reading it invited does not work — `quote_deadline` and `fulfillment_deadline` are absolute timestamps fixed when the challenge is built, so a credential that has already been signed cannot be presented a second time once its quote window has closed. On Solana the deposit authorization additionally carries its own on-chain replay window, which expires with them.
24
+ - An authorization whose quote window has closed is now reported separately from deadlines that are in the wrong order. They were one message, and they need opposite responses: the first is fixed by re-running the purchase, the second is a malformed request that no passage of time will make valid. The stale case now names the remedy, including which field must not change.
25
+
26
+ ## [0.2.2] - 2026-07-31
27
+
28
+ ### Changed
29
+ - README: the proprietary-license notice now references the bundled `LICENSE` file by name instead of linking to it, so it no longer points at a repository that is not publicly shared.
30
+
31
+ ## [0.2.1] - 2026-07-27
32
+
33
+ ### Changed
34
+ - Building and signing the payer's deposit authorization now runs entirely through the shared `@atum-labs/payment-request-sender-auth` library (via the updated `@atum-labs/payment-gateway-client`): the deposit nonce is derived deterministically from the charge identity (retry-safe) and the EVM/Tron permit deadline tracks the settlement horizon. No public API changes; the idempotency anchor is byte-identical.
35
+ - Deposit-freshness verification (permit deadline) now uses the merchant's configured clock, consistent with the existing deadline-ordering and budget checks, instead of the wall clock.
36
+
37
+ ## [0.2.0] - 2026-07-24
38
+
39
+ ### Changed
40
+ - Verifying the payer's deposit authorization (`sender_auth`) now delegates to the shared `@atum-labs/payment-request-sender-auth` library instead of an embedded copy of the recover-and-verify logic, keeping the escrow method byte-compatible with the rest of the Atum payment stack.
41
+
42
+ ## [0.1.1] - 2026-07-22
43
+
44
+ ### Changed
45
+ - Packaging for standalone publication: externalized `@solana/web3.js` so the LGPL-licensed `rpc-websockets` (pulled in transitively) is no longer bundled into the published package. Added `THIRD-PARTY-NOTICES.txt` and license materials. No API changes.
46
+
47
+ ## [0.1.0] - 2026-07-21
48
+
49
+ ### Added
50
+ - Initial release: `mppx-atum-escrow`, Atum's cross-chain escrow payment method for the MPP (x402-style) payment flow — build, sign, and verify a source-chain deposit authorization across EVM, Tron, and Solana.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Proprietary software - not open source.**
4
4
  >
5
- > Copyright (c) 2026 Atum Labs, Inc. All rights reserved. Access to and use of `@atumlabs/mppx-atum-escrow` are limited to entities expressly authorized by Atum and are governed by the [Atum MPP Escrow SDK Proprietary License Agreement](./LICENSE). 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.
5
+ > Copyright (c) 2026 Atum Labs, Inc. All rights reserved. Access to and use of `@atumlabs/mppx-atum-escrow` are limited to entities expressly authorized by Atum and are governed by the Atum MPP Escrow SDK Proprietary 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.
6
6
 
7
7
  An [`mppx`](https://www.npmjs.com/package/mppx) payment method that lets a merchant accept
8
8
  **cross-chain stablecoin payments** through Atum, over the Machine Payments Protocol (MPP).
@@ -48,12 +48,15 @@ Payer (client) Merchant (server) Atum Payment Gateway
48
48
 
49
49
  1. The merchant answers a request with a `402` **challenge** describing what it receives
50
50
  (destination asset, chain, exact amount) and the source option a payer may fund from. You build
51
- this challenge from a **corridor** with `buildChargeRequest`.
51
+ this challenge from a **corridor** with `buildChargeChallenge`.
52
52
  2. The payer's `registerClient` reads the challenge, builds an Atum payment request, signs the
53
53
  source-chain deposit authorization for that chain, and returns it as an MPP **credential**.
54
54
  3. The merchant's `registerServer` **verifies** the credential (recovers the signature, matches
55
- every term against the challenge) and **submits** it to the Atum Payment Gateway, blocking until
56
- settlement completes. On success it returns a **receipt** carrying the settlement confirmation.
55
+ every term against the challenge) and **submits** it to the Atum Payment Gateway. Once the
56
+ payment settles it returns a **receipt** carrying the settlement confirmation. A payment that
57
+ settles more slowly than the gateway's synchronous window reports as *pending*, and the payer's
58
+ retry of the same purchase collects the result — see
59
+ [Settlement outcomes](#settlement-outcomes).
57
60
 
58
61
  The source-chain authorization is built and signed through the Atum
59
62
  [`@atumlabs/payment-gateway-client`](https://www.npmjs.com/package/@atumlabs/payment-gateway-client),
@@ -92,6 +95,7 @@ for the gateway connection and corridor defaults.
92
95
  | **Escrow** | The source-chain contract the payer's funds lock into until the merchant is paid. |
93
96
  | **Deadlines** | Two budgets in seconds: `quoteDeadlineSeconds` (how long the auction runs) and `fulfillmentDeadlineSeconds` (how long settlement may take). Required order: `now < quote < fulfillment`. |
94
97
  | **`PaymentSubmitter`** | A small adapter *you* provide that hands a signed payment request to the Atum Payment Gateway and returns the result. |
98
+ | **`intentId`** | Identifies the *purchase* (your order or invoice id): one value per purchase, reused on every retry of it. The payment's identity derives from it, so it is what makes a retry safe. |
95
99
  | **Receipt** | What `verify()` returns on success: the MPP receipt plus the full settlement confirmation. |
96
100
 
97
101
  All addresses and amounts are strings; all amounts are **atomic units** (never floats). Addresses
@@ -104,7 +108,7 @@ import { Mppx } from "mppx/server";
104
108
  import { PaymentGatewayClient } from "@atumlabs/payment-gateway-client";
105
109
  import {
106
110
  registerServer,
107
- buildChargeRequest,
111
+ buildChargeChallenge,
108
112
  corridorFromDefaults,
109
113
  type AtumEscrowCorridor,
110
114
  type PaymentSubmitter,
@@ -141,11 +145,14 @@ const corridor: AtumEscrowCorridor = await corridorFromDefaults(gateway, {
141
145
  // You can also build a corridor by hand if you already hold the addresses — see AtumEscrowCorridor.
142
146
 
143
147
  // 2. Provide the gateway adapter. The gateway response maps 1:1 onto what the method needs.
148
+ // Pass `status` through: it is what distinguishes a payment still settling from one that
149
+ // failed. Submit and return — do not poll for completion (see "Settlement outcomes").
144
150
  const submitter: PaymentSubmitter = {
145
151
  async submit(request) {
146
152
  const res = await gateway.payments.submitPayment({ requestBody: request });
147
153
  return {
148
154
  payment_id: res.payment_id,
155
+ status: res.status,
149
156
  fulfillment_confirmation: res.fulfillment_confirmation,
150
157
  };
151
158
  },
@@ -160,20 +167,27 @@ const mppx = Mppx.create({
160
167
  methods: [method],
161
168
  });
162
169
 
163
- // 4. Guard a route. buildChargeRequest turns your corridor + a chosen source + the price into
170
+ // 4. Guard a route. buildChargeChallenge turns your corridor + a chosen source + the price into
164
171
  // the challenge. Set the price per charge, so one corridor serves any amount.
165
172
  app.get("/paid-resource", async (req) => {
166
- const request = buildChargeRequest(
173
+ const { request, meta } = buildChargeChallenge(
167
174
  corridor,
168
175
  { network: "eip155:8453", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }, // offer Base USDC
169
176
  "10000000", // receive exactly 10 USDC
177
+ // Identifies the purchase, NOT the attempt: the same value every time this order is
178
+ // re-offered or retried, a different one for a new order. See "Retries and idempotency".
179
+ { intentId: orderIdFor(req) },
170
180
  );
171
- const result = await mppx.compose(["atum-escrow/charge", request])(req);
181
+ const result = await mppx.compose(["atum-escrow/charge", { ...request, meta }])(req);
172
182
  if (result.status === 402) return result.challenge; // not paid yet — ask for payment
173
183
  return result.withReceipt(new Response("here is your resource")); // paid & settled
174
184
  });
175
185
  ```
176
186
 
187
+ > Before production: `mppx.fetch`/a single `verify()` call is enough for this quickstart, but not
188
+ > for a corridor whose settlement can genuinely take minutes — see
189
+ > [Settlement outcomes](#settlement-outcomes) for handling that without polling.
190
+
177
191
  ## Quick start — payer (client)
178
192
 
179
193
  ```ts
@@ -182,6 +196,7 @@ import { registerClient } from "@atumlabs/mppx-atum-escrow/client";
182
196
 
183
197
  // The signer is chain-agnostic: pass private-key options (bound to the challenge's source chain
184
198
  // automatically) or a ready-made SenderSigner. `account` is your source-chain address.
199
+ // Use a TESTNET-ONLY wallet for PAYER_PRIVATE_KEY while trying this out — never one holding real funds.
185
200
  const method = registerClient({
186
201
  signer: { privateKey: process.env.PAYER_PRIVATE_KEY! },
187
202
  account: process.env.PAYER_ADDRESS!,
@@ -194,6 +209,10 @@ const res = await mppx.fetch("https://api.example.com/paid-resource");
194
209
  const resource = await res.text();
195
210
  ```
196
211
 
212
+ > Before production: `mppx.fetch` retries only a few times with no delay, which isn't enough for a
213
+ > corridor whose settlement can genuinely take minutes — see
214
+ > [Settlement outcomes](#settlement-outcomes) for the retry pattern that handles it.
215
+
197
216
  ### Approving the source token
198
217
 
199
218
  On EVM and Tron, the payer must approve the token-transfer contract (Permit2) to move the source
@@ -257,42 +276,182 @@ On success you get an `AtumEscrowReceipt` — the MPP receipt plus the full sett
257
276
 
258
277
  ## Retries and idempotency
259
278
 
260
- Two layers protect a retry, and they protect **different** things:
261
-
262
- 1. **Build once, resubmit identical bytes — returns the original result.** `mppx.fetch` builds one
263
- credential per payment and reuses it across its automatic retries, so the gateway sees identical
264
- content, deduplicates, and returns the *original* result (including the receipt). This is the
265
- only layer that gives you a graceful retry. Driving the flow manually, do the same — never
266
- rebuild for a retry.
267
- 2. **Deterministic nonce — charge-safe, but not retry-graceful.** For EVM and Tron sources the
268
- deposit nonce and `request_id` are derived from the challenge id, so even a rebuild for the
269
- *same* challenge reuses the same nonce; the escrow consumes a nonce on the first deposit and
270
- reverts any second, so **at most one payment ever settles on-chain**. This is only
271
- *charge-safety*: a rebuild's deadlines are wall-clock, so its content differs and the gateway
272
- does not recognize it as the original — use layer 1 to get the original receipt back. Solana
273
- sources use a replay-window nonce and rely on the gateway's content-keyed dedup (the request id,
274
- and thus the payment id, are still derived deterministically from the challenge id).
275
-
276
- Layer 2 holds only if the challenge id is **stable across retries of one payment intent and unique
277
- across distinct intents**, and the merchant controls that. With `Mppx.create({ secretKey })` the
278
- challenge id is an HMAC over the challenge contents — including `opaque` and `expires` — so:
279
-
280
- - set **`opaque` to a per-intent identifier** (e.g. your order or invoice id), reused if that same
281
- intent is retried; and
282
- - keep **`expires` stable or absent** per intent — a floating `now() + TTL` changes the id on every
283
- `402` and defeats the anchor.
284
-
285
- Anchoring on a per-intent `opaque` (rather than the payment *terms*) is deliberate: two legitimate
286
- identical purchases must get **different** nonces, which distinct order ids provide. Without a
287
- `secretKey`, or with a per-request-random challenge id, only layer 1 protects you — so build once.
279
+ A retry must never become a second charge, and two different purchases must never collapse into
280
+ one. Both come down to a single value: the **per-purchase identifier** the merchant stamps into the
281
+ challenge.
282
+
283
+ ```ts
284
+ const { request, meta } = buildChargeChallenge(corridor, source, amount, {
285
+ intentId: order.id, // ← identifies the purchase
286
+ })
287
+ ```
288
+
289
+ The payer derives the payment's `request_id` from `intentId`, and the gateway de-duplicates on that
290
+ `request_id`. So the identifier's lifetime *is* the definition of "the same payment":
291
+
292
+ - **one value per purchase** — a value shared by two purchases makes the second resolve onto the
293
+ first and go unpaid (see the merchant-side defence below);
294
+ - **the same value on every retry** of that purchase, including every re-issued `402` for it — a
295
+ value that changes per attempt makes each retry a fresh charge;
296
+ - **never derived from the terms** — two orders for the same item have identical terms and must
297
+ still be two payments.
298
+
299
+ Use the merchant's own order, invoice, or cart id. Notably it is *not* the challenge id: that is an
300
+ HMAC over the challenge's own contents, so it moves whenever the expiry or the quote moves, neither
301
+ of which tracks the purchase.
302
+
303
+ A challenge carrying no identifier is **refused** by the payer's client rather than paid. There is
304
+ no fallback, deliberately: a per-attempt value would look like an idempotency key while letting
305
+ every retry be charged again.
306
+
307
+ On top of that, EVM and Tron sources get an on-chain backstop — the deposit nonce is derived from
308
+ the same identifier, and the escrow reverts a second deposit reusing it. Solana's replay nonce only
309
+ guards a short window, so there the gateway's `request_id` de-duplication is the durable protection.
310
+
311
+ ### The merchant-side defence, and why you want it
312
+
313
+ Nothing can detect an identifier reused across two genuine purchases — the identifier *is* the
314
+ identity, so the second purchase resolves onto the first payment and only one payment is made. As the
315
+ merchant, you can close that on your own side.
316
+
317
+ Both purchases resolve to the **same payment**, so `verify()` hands you the **same receipt** for
318
+ both — the same `paymentId` and the same destination transaction. Key fulfilment on the receipt
319
+ rather than on the incoming request, and the second purchase is recognised as already-fulfilled
320
+ instead of being shipped a second time against a single payment.
321
+
322
+ Treat this as your responsibility rather than the network's: `atum-escrow` guarantees you will not be
323
+ paid twice for one identifier, and receipt-keyed fulfilment is how you avoid *delivering* twice for
324
+ one.
325
+
326
+ ### Reusing an identifier with different terms
327
+
328
+ The gateway rejects a purchase identifier that comes back with different economics — who pays, who
329
+ receives, in which assets, or for how much — instead of silently resolving it to the first payment.
330
+ Surface it as a `PaymentRejectedError` from your submitter and the payer sees the cause; the fix is
331
+ always a distinct identifier per purchase.
332
+
333
+ ## Settlement outcomes
334
+
335
+ A payment that settles returns a receipt. A payment that does not settle within the gateway's
336
+ synchronous window is **not** a failure, and the two are distinguished because they call for
337
+ opposite responses:
338
+
339
+ | `verify()` outcome | Meaning | What the payer should do |
340
+ | --- | --- | --- |
341
+ | receipt | settled | nothing — the resource is served |
342
+ | `SettlementPendingError` | accepted, still settling | re-attempt the **same** purchase; it resolves to this payment and returns its result |
343
+ | `SettlementFailedError` | terminal failure | start a **new** purchase under a new identifier — this one can never settle |
344
+ | `PaymentRejectedError` | refused, nothing charged | fix the request and pay the same purchase again |
345
+
346
+ The pending and failed rows call for **opposite** actions, and the reason is worth stating: a
347
+ terminal failure keeps its identifier. The gateway does not release it, so re-attempting that
348
+ purchase under the same `intentId` resolves to the same dead payment for good — which is exactly what
349
+ you want for a pending payment and exactly what you must not do after a terminal one. Recovering from
350
+ a terminal failure therefore means minting a **new** `intentId`; it is a new purchase as far as the
351
+ network is concerned. Getting these two the wrong way round is how a payer either charges twice or
352
+ waits forever.
353
+
354
+ ### What re-attempting a purchase means
355
+
356
+ Run the purchase again: request a fresh challenge, sign it again, and keep the **same `intentId`**.
357
+
358
+ That split — rebuild the authorization, reuse the identifier — is the whole contract, and each half
359
+ matters for a different reason. The `intentId` must not change because the payment's identity derives
360
+ from it; change it and the re-attempt is a second charge rather than a retry. Everything time-bound
361
+ must change because `quote_deadline` and `fulfillment_deadline` are **absolute timestamps**, fixed at
362
+ the moment the challenge was built.
363
+
364
+ So a credential you have already signed cannot simply be presented a second time. Once its quote
365
+ window has closed, `verify()` refuses it and says so — and on Solana it is worse than a policy
366
+ refusal, because the deposit authorization carries its own on-chain replay window that expires along
367
+ with the deadlines.
368
+
369
+ A payment-enabled `fetch` handles the rebuild-and-resubmit mechanics for you, since every attempt
370
+ fetches a new challenge — but its own retry loop is a fixed, short number of attempts with no delay
371
+ between them (3, by default; configurable via `maxPaymentRetries`, but that only changes how many
372
+ times it hammers the endpoint, not how long it waits). That is enough when settlement finishes almost
373
+ immediately, but not for a corridor that can genuinely take minutes: raising `maxPaymentRetries`
374
+ turns "retry a few times" into "retry rapidly, still for no longer," which is not the same thing as
375
+ waiting for settlement. For that, drive the retry yourself, with a real interval between attempts.
376
+
377
+ The example below is a *payer* driving a *merchant* over real HTTP — two separate processes, not two
378
+ functions called back to back — since that is the case `mppx.fetch` does not cover and this package
379
+ provides no shortcut for. `method.createCredential` (payer) and the merchant's own endpoint are on
380
+ opposite ends of the request; nothing here runs the merchant's `verify()` in the payer's process.
381
+
382
+ ```ts
383
+ import { Transport } from "mppx/client";
384
+ import type { AtumEscrowChallenge } from "@atumlabs/mppx-atum-escrow/client";
385
+
386
+ // `method` is the client from "Quick start — payer" above. The merchant is what keeps `intentId`
387
+ // stable across attempts (it must derive the same value from the order on every request); the
388
+ // payer never supplies or sees it directly, only the challenge that already carries it.
389
+ const transport = Transport.http();
390
+
391
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
392
+ // 1. Ask for the resource. The merchant answers 402 with a fresh challenge.
393
+ const firstResponse = await fetch(url);
394
+ if (!(await transport.isPaymentRequired(firstResponse))) return firstResponse; // free, or already paid
395
+
396
+ // 2. Build and sign a payment for THIS challenge. The challenge arrives off the wire, so confirm
397
+ // it is this scheme's before signing against it — a bare cast would sign whatever the
398
+ // response contained. Each attempt re-signs — a full round trip for a Turnkey/KMS signer — so
399
+ // pick an interval below with that cost in mind, not one copied from a lightweight status poller.
400
+ const offered = await transport.getChallenge(firstResponse);
401
+ if (offered.method !== "atum-escrow" || offered.intent !== "charge") {
402
+ throw new Error(`unexpected challenge ${offered.method}.${offered.intent}`);
403
+ }
404
+ const challenge = offered as AtumEscrowChallenge;
405
+ const credential = await method.createCredential({ challenge });
406
+
407
+ // 3. Resubmit with the credential attached. `response.ok` (200) is the only success case — a
408
+ // non-402 failure (e.g. PaymentRejectedError, a 400) is neither settled nor a fresh challenge
409
+ // to retry, so check for it explicitly rather than falling into the pending/failed handling
410
+ // below, which assumes a 402.
411
+ const response = await fetch(url, transport.setCredential({}, credential, { challenge }));
412
+ if (response.ok) return response; // paid & settled
413
+ if (!(await transport.isPaymentRequired(response))) {
414
+ const problem = await response.json();
415
+ throw new Error(problem.detail ?? `request failed with ${response.status}`);
416
+ }
417
+
418
+ // Pending and failed are BOTH a 402 — the wire-visible discriminator is the problem-details
419
+ // `type`, which the mppx framework's error classes fix per outcome (see isSettlementPending /
420
+ // isSettlementFailed below for the merchant-side equivalent check). `paymentId` is its own
421
+ // field too, not just interpolated into `detail`'s prose, so the payer can log/reconcile it
422
+ // without parsing a sentence.
423
+ const problem = await response.json();
424
+ if (problem.type !== "https://paymentauth.org/problems/payment-action-required") {
425
+ throw new Error(problem.detail); // terminal — a new purchase needs a new intentId
426
+ }
427
+ // `payment-action-required` covers two different causes: a payment genuinely still settling
428
+ // (paymentId present — it reached the gateway) and this attempt's authorization having gone
429
+ // stale before it could be submitted (paymentId absent — nothing reached the gateway yet). Log
430
+ // accordingly rather than always saying "still settling", which is only true of the first.
431
+ console.log(
432
+ `attempt ${attempt}: ${problem.paymentId ? `still settling (payment ${problem.paymentId})` : "authorization expired before submission, retrying with a fresh one"}`,
433
+ );
434
+ await sleep(interval);
435
+ }
436
+ throw new Error(`purchase never settled after ${maxAttempts} attempts`);
437
+ ```
438
+
439
+ Both settlement errors carry the gateway's `paymentId` for reconciliation — as its own field on
440
+ `toProblemDetails()`, not only interpolated into `detail`'s prose — and both extend the framework's
441
+ error types, so a merchant that does not distinguish them still gets the standard `402` +
442
+ problem-details response. A same-process merchant discriminates with `isSettlementPending` /
443
+ `isSettlementFailed` / `isPaymentRejected` on the thrown error object; a payer, receiving only the
444
+ wire response, discriminates on `type` as shown above.
445
+
446
+ Because the payer's retry is what collects a pending result, a `PaymentSubmitter` should submit and
447
+ return what the gateway said — **not** poll for completion. Polling holds the merchant's request
448
+ open for the whole settlement window and hides the pending state the retry depends on.
288
449
 
289
450
  ## Scope
290
451
 
291
452
  This version supports the **`charge`** intent with **EVM, Tron, and Solana** source chains and a
292
- merchant-configured destination chain, with **synchronous** settlement (the request is held open
293
- until settlement completes, so your client and server timeouts must exceed the corridor's deadline
294
- budgets). One source option is offered per challenge; a corridor may list several and offer a
295
- different one per `402`.
453
+ merchant-configured destination chain. One source option is offered per challenge; a corridor may
454
+ list several and offer a different one per `402`.
296
455
 
297
456
  > **Solana deadline constraint:** a Solana deposit authorization expires within the escrow's fixed
298
457
  > on-chain replay window (~2 minutes), independent of the corridor budget. So a corridor that
@@ -307,25 +466,31 @@ each side needs):
307
466
 
308
467
  - `@atumlabs/mppx-atum-escrow` — everything below
309
468
  - `@atumlabs/mppx-atum-escrow/client` — `registerClient`, `ensureSourceApproval` + payer types
310
- - `@atumlabs/mppx-atum-escrow/server` — `registerServer`, `buildChargeRequest`,
311
- `validateCorridor`, `corridorFromDefaults` + merchant types
469
+ - `@atumlabs/mppx-atum-escrow/server` — `registerServer`, `buildChargeChallenge`,
470
+ `buildChargeRequest`, `validateCorridor`, `corridorFromDefaults`, the settlement errors +
471
+ merchant types
312
472
 
313
473
  | Export | Description |
314
474
  | --- | --- |
315
475
  | `registerClient(config)` | Payer-side method. `config`: `{ signer, account, now?, solanaClockReader? }`. |
316
476
  | `registerServer(config)` | Merchant-side method. `config`: `{ submitter, now? }`. |
317
- | `buildChargeRequest(corridor, select, fulfillmentAmount)` | Builds the `charge` challenge request for one source option, at a per-charge price. Throws if `select` matches no configured source. |
318
- | `validateCorridor(corridor)` | Validates a corridor's shape and per-source addresses (run automatically by `buildChargeRequest`). |
477
+ | `buildChargeChallenge(corridor, select, fulfillmentAmount, { intentId, issuedAt? })` | Builds a `charge` challenge — the payment terms plus the per-purchase identifier that makes a retry safe. **Use this.** Returns `{ request, meta }`. |
478
+ | `buildChargeRequest(corridor, select, fulfillmentAmount, options?)` | The payment terms alone, without the identifier. For supplying challenge metadata by hand. |
479
+ | `validateCorridor(corridor)` | Validates a corridor's shape and per-source addresses (run automatically by both builders). |
319
480
  | `corridorFromDefaults(defaults, params)` | Builds a corridor by fetching escrow/role/proxy addresses from the gateway `/defaults`. |
320
481
  | `ensureSourceApproval(params)` | Ensures the payer has approved Permit2 to move the source token (EVM/Tron; no-op on Solana). |
482
+ | `SettlementPendingError`, `SettlementFailedError`, `PaymentRejectedError` | The non-receipt outcomes of `verify()`. See [Settlement outcomes](#settlement-outcomes). |
483
+ | `isSettlementPending`, `isSettlementFailed`, `isPaymentRejected` | Guards for the above. |
321
484
  | `atumEscrowChargeMethod` | The base `mppx` method (advanced/custom wiring). |
322
485
  | `ChargeRequestSchema`, `CredentialPayloadSchema` | The `zod` wire schemas. |
323
486
  | `METHOD_NAME` (`"atum-escrow"`), `INTENT` (`"charge"`) | The method/intent identifiers. |
487
+ | `INTENT_ID_META_KEY` | The challenge-metadata key carrying the per-purchase identifier. |
324
488
 
325
489
  Key types: `AtumEscrowCorridor`, `AtumEscrowSource`, `SenderSigner`, `SenderSignerOptions`,
326
- `PaymentSubmitter`, `AtumEscrowClientConfig`, `AtumEscrowServerConfig`, `ChainDefaultsSource`,
327
- `EnsureApprovalResult`, `AtumEscrowChallenge`, `AtumEscrowCredential`, `AtumEscrowReceipt`,
328
- `ChargeRequest`, `PaymentRequest`, `FulfillmentConfirmation`.
490
+ `PaymentSubmitter`, `PaymentSubmitResult`, `PaymentSettlementStatus`, `AtumEscrowClientConfig`,
491
+ `AtumEscrowServerConfig`, `ChainDefaultsSource`, `EnsureApprovalResult`, `AtumEscrowChallenge`,
492
+ `AtumEscrowCredential`, `AtumEscrowReceipt`, `ChargeChallenge`, `ChargeRequest`,
493
+ `SettlementErrorDetails`, `PaymentRequest`, `FulfillmentConfirmation`.
329
494
 
330
495
  > **EVM implementation note:** on EVM the deposit authorization is a Permit2
331
496
  > `PermitWitnessTransferFrom` signature; Tron uses the equivalent TIP-712 typed data, and Solana