@atumlabs/mppx-atum-escrow 0.2.2 → 0.4.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,55 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-09-16
11
+
12
+ ### Changed
13
+ - Clarified that the approval helpers raise an allowance and never lower one: a wallet already holding more than `approvalAmount` is left as it is and reports `alreadySufficient`. The wording said `approvalAmount` caps what the escrow can ever move, which is only true when no larger allowance already exists. Reducing or revoking an allowance is a separate operation these do not perform.
14
+ - `ensureSourceApproval` refuses to send when the client that would sign is not the `owner` whose allowance it read. An approval only ever applies to the account that sends it, so a mismatched signer approved that account instead, returned a transaction hash that read as success, and left the owner exactly as unable to pay as before — a silent failure that surfaced only as a settlement revert. Read-only `needsSourceApproval` is unaffected: asking whether a payer is ready without holding their keys is a legitimate preflight, and a client that cannot report which account it signs as is not treated as a mismatch.
15
+ - Tron approvals wait for confirmation before returning, so the hash they report means mined rather than broadcast — a payer that deposited immediately afterwards would otherwise race their own approval. The EVM path already waited; what is new there is a bound on how long it will wait (`confirmation.timeoutMs`, one minute by default) instead of waiting forever, and an `onSubmitted` callback that hands you the hash the moment it is broadcast so a stuck approval can still be looked up.
16
+ - On EVM, `ensureSourceApproval` resets an allowance to zero before setting a new one, but only where the token demands it. Some tokens — mainnet USDT most notably — refuse to move an allowance straight from one non-zero value to another, so a payer holding any leftover allowance could not be approved at all and saw a bare on-chain revert. The check is a simulated call that costs nothing and sends nothing, and a wallet with no allowance skips it entirely, so an ordinary approval still takes exactly one transaction. Tron does not do this: an ordinary TRC-20 accepts the change in place, and guessing otherwise would burn an extra transaction at Tron's fee limit — if a Tron token ever does refuse, the error names the manual remedy.
17
+ - The approval helpers are now one implementation shared by this package, the x402 scheme and the payment-gateway client, so the three cannot drift apart on what counts as a sufficient allowance. The exported names and their behaviour are unchanged.
18
+ - `ChargeRequest` declares the Solana `issuedAt` on its `extra` itself, instead of inheriting it
19
+ from the shared x402 request type. That type dropped the field — an x402 resource server rebuilds
20
+ its payment requirements on the paid request and compares them against what the payer echoed, and
21
+ a clock reading cannot survive that comparison. MPP has no such rebuild, so a merchant-stamped
22
+ issue time stays valid here and `buildChargeRequest` still emits it exactly as before. Type-only:
23
+ the emitted JavaScript is byte-identical, so nothing changes at runtime and no code needs editing
24
+ on upgrade.
25
+ - A destination identifier whose CAIP namespaces are not lowercase, such as `SOLANA:` or `SPL:`, is now accepted on Solana. Previously that chain compared the whole identifier byte for byte and refused it. CAIP pins both namespaces lowercase, so this only forgives a spelling the schema already treats as equivalent.
26
+
27
+ ### Added
28
+ - **`isUnconfirmed`**, for telling a broadcast-but-unseen approval apart from one that failed. The wait for a confirmation is bounded, and reaching that bound is not a failure: the transaction may confirm a moment later. The two need opposite responses — a failure needs another approval, an unconfirmed one needs the transaction checked and nothing else — and a caller that treats them alike pays for an allowance they are already getting. Previously the distinction existed only as an undocumented property on the error.
29
+ - **`needsSourceApproval`**, the read-only counterpart to `ensureSourceApproval`. It takes the same arguments and answers the same question, but only reads the allowance: no transaction, no gas. Use it to tell a payer that a one-off approval is due before asking them to sign, instead of letting them find out when one is broadcast.
30
+ - **`ensureSourceApproval` now works on Tron.** It previously documented Tron support it did not have — the implementation reached the chain through ethers, which cannot talk to Tron, so a Tron source failed either for want of a `spender` or somewhere inside ethers. Pass a TronWeb instance as `tronWeb` in place of the ethers `signer`, and the Permit2 address for the network is resolved for you rather than demanded as `spender`. `tronweb` is not a dependency of this package; the caller constructs the instance, exactly as they already construct the ethers signer.
31
+ - **`approvalAmount`**, for granting a bounded approval instead of an unlimited one, capping what the escrow can ever move. On its own the amount is its own requirement, so an allowance still holding the full bound is left alone rather than re-approved — the right answer for a one-off approval. Across repeated payments, pair it with `requiredAllowance`: a bound measured against itself stops covering itself the moment the first charge is decremented from it, and every later call would send another approval. The trade-off to weigh is that Permit2 decrements the allowance on every payment, so a bounded approval is consumed and eventually has to be granted again. Asking to approve less than an explicit `requiredAllowance` is refused outright, since that approval could never satisfy the charge it was granted for.
32
+ - `AtumEscrowServer`, the type `registerServer` returns, for naming the method where it is built in one place and registered in another. Its `verify()` now resolves with an `AtumEscrowReceipt`, so `fulfillmentConfirmation` is available directly on the result: any cast you added to reach it can be removed. Casts already in place continue to compile, so this needs no change on upgrade.
33
+
34
+ ### Fixed
35
+ - **`ensureSourceApproval` no longer sends a fresh approval before every payment after the first.** Permit2 decrements the payer's allowance on each transfer it makes, so an allowance granted as unlimited stops being exactly `MaxUint256` the moment the first payment settles. Sufficiency was measured against that exact value, so from the second payment onward the check never short-circuited again and every charge was preceded by another approval transaction. The payments were correct; the payer was buying an allowance they already held. Sufficiency is now measured against half of `MaxUint256`, which separates an unlimited approval that has been partly spent from one a payer bounded deliberately — crossing that floor would take more token units than any supply contains. Nothing changes for a caller passing `requiredAllowance`: that comparison was already right, and only the unlimited case was wrong.
36
+ - A payer whose `signer` option carries a blank `pinnedAddress` falls back to the payer's own account. Blank covers an empty string, which is how an unset variable arrives, and a whitespace only value, which is how one read from a file or a padded shell variable arrives. It previously passed the value straight through, which the payment-gateway client now refuses instead of reading as "no pin", and the two blank forms took different paths.
37
+ - `verify()` compares the payer's destination asset identifier against the merchant's advertised one part by part, instead of lowercasing the whole string for every chain except Solana. A Tron `trc20:` reference is base58, where capitalisation is part of the value, so the old fold treated two distinct tokens as the same one on a field the deposit signature does not cover. The token reference and a Solana chain reference are now compared the way the neighbouring address comparison already documented, and only the two CAIP namespaces fold.
38
+
39
+ ## [0.3.0] - 2026-07-30
40
+
41
+ ### Added
42
+ - `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`.
43
+ - `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.
44
+ - `PaymentSubmitResult.status`, so a submitter can pass the gateway's lifecycle state through. An absent status is read as pending.
45
+
46
+ ### Fixed
47
+ - 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.
48
+ - 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.
49
+
50
+ ### Changed
51
+ - **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.
52
+ - **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.
53
+ - 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.
54
+ - **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.
55
+ - 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.
56
+
8
57
  ## [0.2.2] - 2026-07-31
9
58
 
10
59
  ### Changed
package/README.md CHANGED
@@ -6,6 +6,7 @@
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).
9
+ Full documentation: [docs.atum.xyz](https://docs.atum.xyz).
9
10
 
10
11
  - The **payer** funds the payment in any supported source asset and chain (e.g. USDC on Base,
11
12
  USDT on Tron, or USDC on Solana).
@@ -48,12 +49,15 @@ Payer (client) Merchant (server) Atum Payment Gateway
48
49
 
49
50
  1. The merchant answers a request with a `402` **challenge** describing what it receives
50
51
  (destination asset, chain, exact amount) and the source option a payer may fund from. You build
51
- this challenge from a **corridor** with `buildChargeRequest`.
52
+ this challenge from a **corridor** with `buildChargeChallenge`.
52
53
  2. The payer's `registerClient` reads the challenge, builds an Atum payment request, signs the
53
54
  source-chain deposit authorization for that chain, and returns it as an MPP **credential**.
54
55
  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.
56
+ every term against the challenge) and **submits** it to the Atum Payment Gateway. Once the
57
+ payment settles it returns a **receipt** carrying the settlement confirmation. A payment that
58
+ settles more slowly than the gateway's synchronous window reports as *pending*, and the payer's
59
+ retry of the same purchase collects the result — see
60
+ [Settlement outcomes](#settlement-outcomes).
57
61
 
58
62
  The source-chain authorization is built and signed through the Atum
59
63
  [`@atumlabs/payment-gateway-client`](https://www.npmjs.com/package/@atumlabs/payment-gateway-client),
@@ -78,9 +82,6 @@ The merchant examples below also use
78
82
  [`@atumlabs/payment-gateway-client`](https://www.npmjs.com/package/@atumlabs/payment-gateway-client)
79
83
  for the gateway connection and corridor defaults.
80
84
 
81
- > The snippets below are illustrative. For complete, runnable, end-to-end integrations
82
- > (payer + merchant), see the examples repository: **https://github.com/Atum-Labs/examples**.
83
-
84
85
  ## Key concepts
85
86
 
86
87
  | Term | What it means |
@@ -92,11 +93,24 @@ for the gateway connection and corridor defaults.
92
93
  | **Escrow** | The source-chain contract the payer's funds lock into until the merchant is paid. |
93
94
  | **Deadlines** | Two budgets in seconds: `quoteDeadlineSeconds` (how long the auction runs) and `fulfillmentDeadlineSeconds` (how long settlement may take). Required order: `now < quote < fulfillment`. |
94
95
  | **`PaymentSubmitter`** | A small adapter *you* provide that hands a signed payment request to the Atum Payment Gateway and returns the result. |
96
+ | **`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
97
  | **Receipt** | What `verify()` returns on success: the MPP receipt plus the full settlement confirmation. |
96
98
 
97
99
  All addresses and amounts are strings; all amounts are **atomic units** (never floats). Addresses
98
100
  are in each chain's native form — `0x…` hex for EVM, base58 for Tron and Solana.
99
101
 
102
+ ### Which chains and assets can a corridor use?
103
+
104
+ Atum supports many corridors. The identifier for every supported token is listed under
105
+ [supported assets](https://docs.atum.xyz/get-started/reference/supported-assets), and the chain ids
106
+ under [supported networks](https://docs.atum.xyz/get-started/reference/supported-networks). Assets
107
+ are named with [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) identifiers, for example
108
+ `eip155:84532/erc20:0x036CbD53842c5426634e7929541eC2318f3dCF7e` for USDC on Base Sepolia.
109
+
110
+ Copy identifiers exactly: base58 values, such as Solana token mints and account addresses, are
111
+ case-sensitive. The escrow and role addresses for each chain are filled in by
112
+ `corridorFromDefaults`, so you never paste those by hand.
113
+
100
114
  ## Quick start — merchant (server)
101
115
 
102
116
  ```ts
@@ -104,7 +118,7 @@ import { Mppx } from "mppx/server";
104
118
  import { PaymentGatewayClient } from "@atumlabs/payment-gateway-client";
105
119
  import {
106
120
  registerServer,
107
- buildChargeRequest,
121
+ buildChargeChallenge,
108
122
  corridorFromDefaults,
109
123
  type AtumEscrowCorridor,
110
124
  type PaymentSubmitter,
@@ -141,11 +155,14 @@ const corridor: AtumEscrowCorridor = await corridorFromDefaults(gateway, {
141
155
  // You can also build a corridor by hand if you already hold the addresses — see AtumEscrowCorridor.
142
156
 
143
157
  // 2. Provide the gateway adapter. The gateway response maps 1:1 onto what the method needs.
158
+ // Pass `status` through: it is what distinguishes a payment still settling from one that
159
+ // failed. Submit and return — do not poll for completion (see "Settlement outcomes").
144
160
  const submitter: PaymentSubmitter = {
145
161
  async submit(request) {
146
162
  const res = await gateway.payments.submitPayment({ requestBody: request });
147
163
  return {
148
164
  payment_id: res.payment_id,
165
+ status: res.status,
149
166
  fulfillment_confirmation: res.fulfillment_confirmation,
150
167
  };
151
168
  },
@@ -160,20 +177,27 @@ const mppx = Mppx.create({
160
177
  methods: [method],
161
178
  });
162
179
 
163
- // 4. Guard a route. buildChargeRequest turns your corridor + a chosen source + the price into
180
+ // 4. Guard a route. buildChargeChallenge turns your corridor + a chosen source + the price into
164
181
  // the challenge. Set the price per charge, so one corridor serves any amount.
165
182
  app.get("/paid-resource", async (req) => {
166
- const request = buildChargeRequest(
183
+ const { request, meta } = buildChargeChallenge(
167
184
  corridor,
168
185
  { network: "eip155:8453", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }, // offer Base USDC
169
186
  "10000000", // receive exactly 10 USDC
187
+ // Identifies the purchase, NOT the attempt: the same value every time this order is
188
+ // re-offered or retried, a different one for a new order. See "Retries and idempotency".
189
+ { intentId: orderIdFor(req) },
170
190
  );
171
- const result = await mppx.compose(["atum-escrow/charge", request])(req);
191
+ const result = await mppx.compose(["atum-escrow/charge", { ...request, meta }])(req);
172
192
  if (result.status === 402) return result.challenge; // not paid yet — ask for payment
173
193
  return result.withReceipt(new Response("here is your resource")); // paid & settled
174
194
  });
175
195
  ```
176
196
 
197
+ > Before production: `mppx.fetch`/a single `verify()` call is enough for this quickstart, but not
198
+ > for a corridor whose settlement can genuinely take minutes — see
199
+ > [Settlement outcomes](#settlement-outcomes) for handling that without polling.
200
+
177
201
  ## Quick start — payer (client)
178
202
 
179
203
  ```ts
@@ -182,6 +206,7 @@ import { registerClient } from "@atumlabs/mppx-atum-escrow/client";
182
206
 
183
207
  // The signer is chain-agnostic: pass private-key options (bound to the challenge's source chain
184
208
  // automatically) or a ready-made SenderSigner. `account` is your source-chain address.
209
+ // Use a TESTNET-ONLY wallet for PAYER_PRIVATE_KEY while trying this out — never one holding real funds.
185
210
  const method = registerClient({
186
211
  signer: { privateKey: process.env.PAYER_PRIVATE_KEY! },
187
212
  account: process.env.PAYER_ADDRESS!,
@@ -194,6 +219,10 @@ const res = await mppx.fetch("https://api.example.com/paid-resource");
194
219
  const resource = await res.text();
195
220
  ```
196
221
 
222
+ > Before production: `mppx.fetch` retries only a few times with no delay, which isn't enough for a
223
+ > corridor whose settlement can genuinely take minutes — see
224
+ > [Settlement outcomes](#settlement-outcomes) for the retry pattern that handles it.
225
+
197
226
  ### Approving the source token
198
227
 
199
228
  On EVM and Tron, the payer must approve the token-transfer contract (Permit2) to move the source
@@ -212,11 +241,66 @@ await ensureSourceApproval({
212
241
  owner: wallet.address,
213
242
  signer: wallet,
214
243
  // Pass the source cap this charge needs (from the challenge). A leftover smaller allowance
215
- // then won't be mistaken for enough. Omit it to ensure an unlimited approval instead.
244
+ // then won't be mistaken for enough.
216
245
  requiredAllowance: BigInt(challenge.request.source.amount),
217
246
  });
218
247
  ```
219
248
 
249
+ Paying from Tron takes a TronWeb instance instead of an ethers signer; everything else is the
250
+ same, and the Permit2 address for the network is resolved for you:
251
+
252
+ ```ts
253
+ await ensureSourceApproval({ network: "tron:mainnet", token, owner, tronWeb });
254
+ ```
255
+
256
+ The approval waits to be mined before returning, bounded by `confirmation.timeoutMs` (one minute
257
+ by default); a timeout is reported as unconfirmed rather than failed, since the transaction may
258
+ still land. `onSubmitted` hands you the hash the moment it is broadcast.
259
+
260
+ `isUnconfirmed(error)` tells the two apart. It matters because the responses are opposite: a
261
+ failed approval needs another one, an unconfirmed approval needs a look at the transaction and
262
+ nothing else. Sending a second one on top of the first only pays for an allowance you are already
263
+ getting.
264
+
265
+ ```ts
266
+ try {
267
+ await ensureSourceApproval({ network, token, owner, signer });
268
+ } catch (error) {
269
+ if (isUnconfirmed(error)) {
270
+ // Broadcast, not yet seen to confirm. Check the hash from onSubmitted; do not re-send.
271
+ } else {
272
+ throw error;
273
+ }
274
+ }
275
+ ```
276
+
277
+ On EVM, tokens that refuse to overwrite a non-zero allowance — mainnet USDT most notably — are
278
+ reset to zero first, automatically and only where the token demands it; the result then carries a
279
+ `resetTxHash` too. Tron does not do this, because an ordinary TRC-20 accepts the change in place.
280
+
281
+ The approval sent is unlimited, so later charges on the same token need no further transaction.
282
+ To bound the approval that gets sent, pass `approvalAmount`. On its own the amount is treated as
283
+ its own requirement, so an allowance still holding the full bound is left alone rather than
284
+ re-approved — the right answer for a one-off approval. Across repeated payments, pair it with
285
+ `requiredAllowance`: Permit2 decrements the allowance on every payment, so a bound measured
286
+ against itself stops covering itself the moment the first charge lands, and every later call
287
+ would send another approval. Either way a bounded approval is consumed as it is spent and
288
+ eventually has to be granted again.
289
+
290
+ These helpers raise an allowance to what a payment needs; they never lower one. A wallet already
291
+ holding more than `approvalAmount` is left exactly as it is and reports `alreadySufficient`
292
+ without sending anything. Reducing or revoking an allowance is a separate operation, and not one
293
+ these perform.
294
+
295
+ `needsSourceApproval(params)` takes the same arguments and answers the same question without
296
+ sending anything, so it is safe to call before every charge:
297
+
298
+ ```ts
299
+ if (await needsSourceApproval({ network, token, owner, signer })) {
300
+ // tell the payer a one-off approval transaction is coming, before asking them to sign
301
+ }
302
+ ```
303
+
220
304
  ## What the merchant `verify()` guarantees
221
305
 
222
306
  `verify()` fails fast (no chain I/O) before submitting, and throws if any check fails. The Atum
@@ -255,44 +339,187 @@ On success you get an `AtumEscrowReceipt` — the MPP receipt plus the full sett
255
339
  }
256
340
  ```
257
341
 
342
+ `verify()` resolves with this type, so `fulfillmentConfirmation` is available straight off the
343
+ result. `AtumEscrowReceipt` is exported, so you can name it in your own function signatures.
344
+
258
345
  ## Retries and idempotency
259
346
 
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.
347
+ A retry must never become a second charge, and two different purchases must never collapse into
348
+ one. Both come down to a single value: the **per-purchase identifier** the merchant stamps into the
349
+ challenge.
350
+
351
+ ```ts
352
+ const { request, meta } = buildChargeChallenge(corridor, source, amount, {
353
+ intentId: order.id, // ← identifies the purchase
354
+ })
355
+ ```
356
+
357
+ The payer derives the payment's `request_id` from `intentId`, and the gateway de-duplicates on that
358
+ `request_id`. So the identifier's lifetime *is* the definition of "the same payment":
359
+
360
+ - **one value per purchase** — a value shared by two purchases makes the second resolve onto the
361
+ first and go unpaid (see the merchant-side defence below);
362
+ - **the same value on every retry** of that purchase, including every re-issued `402` for it — a
363
+ value that changes per attempt makes each retry a fresh charge;
364
+ - **never derived from the terms** — two orders for the same item have identical terms and must
365
+ still be two payments.
366
+
367
+ Use the merchant's own order, invoice, or cart id. Notably it is *not* the challenge id: that is an
368
+ HMAC over the challenge's own contents, so it moves whenever the expiry or the quote moves, neither
369
+ of which tracks the purchase.
370
+
371
+ A challenge carrying no identifier is **refused** by the payer's client rather than paid. There is
372
+ no fallback, deliberately: a per-attempt value would look like an idempotency key while letting
373
+ every retry be charged again.
374
+
375
+ On top of that, EVM and Tron sources get an on-chain backstop — the deposit nonce is derived from
376
+ the same identifier, and the escrow reverts a second deposit reusing it. Solana's replay nonce only
377
+ guards a short window, so there the gateway's `request_id` de-duplication is the durable protection.
378
+
379
+ ### The merchant-side defence, and why you want it
380
+
381
+ Nothing can detect an identifier reused across two genuine purchases — the identifier *is* the
382
+ identity, so the second purchase resolves onto the first payment and only one payment is made. As the
383
+ merchant, you can close that on your own side.
384
+
385
+ Both purchases resolve to the **same payment**, so `verify()` hands you the **same receipt** for
386
+ both — the same `paymentId` and the same destination transaction. Key fulfilment on the receipt
387
+ rather than on the incoming request, and the second purchase is recognised as already-fulfilled
388
+ instead of being shipped a second time against a single payment.
389
+
390
+ Treat this as your responsibility rather than the network's: `atum-escrow` guarantees you will not be
391
+ paid twice for one identifier, and receipt-keyed fulfilment is how you avoid *delivering* twice for
392
+ one.
393
+
394
+ ### Reusing an identifier with different terms
395
+
396
+ The gateway rejects a purchase identifier that comes back with different economics — who pays, who
397
+ receives, in which assets, or for how much — instead of silently resolving it to the first payment.
398
+ Surface it as a `PaymentRejectedError` from your submitter and the payer sees the cause; the fix is
399
+ always a distinct identifier per purchase.
400
+
401
+ ## Settlement outcomes
402
+
403
+ A payment that settles returns a receipt. A payment that does not settle within the gateway's
404
+ synchronous window is **not** a failure, and the two are distinguished because they call for
405
+ opposite responses:
406
+
407
+ | `verify()` outcome | Meaning | What the payer should do |
408
+ | --- | --- | --- |
409
+ | receipt | settled | nothing — the resource is served |
410
+ | `SettlementPendingError` | accepted, still settling | re-attempt the **same** purchase; it resolves to this payment and returns its result |
411
+ | `SettlementFailedError` | terminal failure | start a **new** purchase under a new identifier — this one can never settle |
412
+ | `PaymentRejectedError` | refused, nothing charged | fix the request and pay the same purchase again |
413
+
414
+ The pending and failed rows call for **opposite** actions, and the reason is worth stating: a
415
+ terminal failure keeps its identifier. The gateway does not release it, so re-attempting that
416
+ purchase under the same `intentId` resolves to the same dead payment for good — which is exactly what
417
+ you want for a pending payment and exactly what you must not do after a terminal one. Recovering from
418
+ a terminal failure therefore means minting a **new** `intentId`; it is a new purchase as far as the
419
+ network is concerned. Getting these two the wrong way round is how a payer either charges twice or
420
+ waits forever.
421
+
422
+ ### What re-attempting a purchase means
423
+
424
+ Run the purchase again: request a fresh challenge, sign it again, and keep the **same `intentId`**.
425
+
426
+ That split — rebuild the authorization, reuse the identifier — is the whole contract, and each half
427
+ matters for a different reason. The `intentId` must not change because the payment's identity derives
428
+ from it; change it and the re-attempt is a second charge rather than a retry. Everything time-bound
429
+ must change because `quote_deadline` and `fulfillment_deadline` are **absolute timestamps**, fixed at
430
+ the moment the challenge was built.
431
+
432
+ So a credential you have already signed cannot simply be presented a second time. Once its quote
433
+ window has closed, `verify()` refuses it and says so — and on Solana it is worse than a policy
434
+ refusal, because the deposit authorization carries its own on-chain replay window that expires along
435
+ with the deadlines.
436
+
437
+ A payment-enabled `fetch` handles the rebuild-and-resubmit mechanics for you, since every attempt
438
+ fetches a new challenge — but its own retry loop is a fixed, short number of attempts with no delay
439
+ between them (3, by default; configurable via `maxPaymentRetries`, but that only changes how many
440
+ times it hammers the endpoint, not how long it waits). That is enough when settlement finishes almost
441
+ immediately, but not for a corridor that can genuinely take minutes: raising `maxPaymentRetries`
442
+ turns "retry a few times" into "retry rapidly, still for no longer," which is not the same thing as
443
+ waiting for settlement. For that, drive the retry yourself, with a real interval between attempts.
444
+
445
+ The example below is a *payer* driving a *merchant* over real HTTP — two separate processes, not two
446
+ functions called back to back — since that is the case `mppx.fetch` does not cover and this package
447
+ provides no shortcut for. `method.createCredential` (payer) and the merchant's own endpoint are on
448
+ opposite ends of the request; nothing here runs the merchant's `verify()` in the payer's process.
449
+
450
+ ```ts
451
+ import { Transport } from "mppx/client";
452
+ import type { AtumEscrowChallenge } from "@atumlabs/mppx-atum-escrow/client";
453
+
454
+ // `method` is the client from "Quick start — payer" above. The merchant is what keeps `intentId`
455
+ // stable across attempts (it must derive the same value from the order on every request); the
456
+ // payer never supplies or sees it directly, only the challenge that already carries it.
457
+ const transport = Transport.http();
458
+
459
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
460
+ // 1. Ask for the resource. The merchant answers 402 with a fresh challenge.
461
+ const firstResponse = await fetch(url);
462
+ if (!(await transport.isPaymentRequired(firstResponse))) return firstResponse; // free, or already paid
463
+
464
+ // 2. Build and sign a payment for THIS challenge. The challenge arrives off the wire, so confirm
465
+ // it is this scheme's before signing against it — a bare cast would sign whatever the
466
+ // response contained. Each attempt re-signs — a full round trip for a Turnkey/KMS signer — so
467
+ // pick an interval below with that cost in mind, not one copied from a lightweight status poller.
468
+ const offered = await transport.getChallenge(firstResponse);
469
+ if (offered.method !== "atum-escrow" || offered.intent !== "charge") {
470
+ throw new Error(`unexpected challenge ${offered.method}.${offered.intent}`);
471
+ }
472
+ const challenge = offered as AtumEscrowChallenge;
473
+ const credential = await method.createCredential({ challenge });
474
+
475
+ // 3. Resubmit with the credential attached. `response.ok` (200) is the only success case — a
476
+ // non-402 failure (e.g. PaymentRejectedError, a 400) is neither settled nor a fresh challenge
477
+ // to retry, so check for it explicitly rather than falling into the pending/failed handling
478
+ // below, which assumes a 402.
479
+ const response = await fetch(url, transport.setCredential({}, credential, { challenge }));
480
+ if (response.ok) return response; // paid & settled
481
+ if (!(await transport.isPaymentRequired(response))) {
482
+ const problem = await response.json();
483
+ throw new Error(problem.detail ?? `request failed with ${response.status}`);
484
+ }
485
+
486
+ // Pending and failed are BOTH a 402 — the wire-visible discriminator is the problem-details
487
+ // `type`, which the mppx framework's error classes fix per outcome (see isSettlementPending /
488
+ // isSettlementFailed below for the merchant-side equivalent check). `paymentId` is its own
489
+ // field too, not just interpolated into `detail`'s prose, so the payer can log/reconcile it
490
+ // without parsing a sentence.
491
+ const problem = await response.json();
492
+ if (problem.type !== "https://paymentauth.org/problems/payment-action-required") {
493
+ throw new Error(problem.detail); // terminal — a new purchase needs a new intentId
494
+ }
495
+ // `payment-action-required` covers two different causes: a payment genuinely still settling
496
+ // (paymentId present — it reached the gateway) and this attempt's authorization having gone
497
+ // stale before it could be submitted (paymentId absent — nothing reached the gateway yet). Log
498
+ // accordingly rather than always saying "still settling", which is only true of the first.
499
+ console.log(
500
+ `attempt ${attempt}: ${problem.paymentId ? `still settling (payment ${problem.paymentId})` : "authorization expired before submission, retrying with a fresh one"}`,
501
+ );
502
+ await sleep(interval);
503
+ }
504
+ throw new Error(`purchase never settled after ${maxAttempts} attempts`);
505
+ ```
506
+
507
+ Both settlement errors carry the gateway's `paymentId` for reconciliation — as its own field on
508
+ `toProblemDetails()`, not only interpolated into `detail`'s prose — and both extend the framework's
509
+ error types, so a merchant that does not distinguish them still gets the standard `402` +
510
+ problem-details response. A same-process merchant discriminates with `isSettlementPending` /
511
+ `isSettlementFailed` / `isPaymentRejected` on the thrown error object; a payer, receiving only the
512
+ wire response, discriminates on `type` as shown above.
513
+
514
+ Because the payer's retry is what collects a pending result, a `PaymentSubmitter` should submit and
515
+ return what the gateway said — **not** poll for completion. Polling holds the merchant's request
516
+ open for the whole settlement window and hides the pending state the retry depends on.
288
517
 
289
518
  ## Scope
290
519
 
291
520
  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`.
521
+ merchant-configured destination chain. One source option is offered per challenge; a corridor may
522
+ list several and offer a different one per `402`.
296
523
 
297
524
  > **Solana deadline constraint:** a Solana deposit authorization expires within the escrow's fixed
298
525
  > on-chain replay window (~2 minutes), independent of the corridor budget. So a corridor that
@@ -306,27 +533,41 @@ Import everything from the root, or from the role-specific entry points (which e
306
533
  each side needs):
307
534
 
308
535
  - `@atumlabs/mppx-atum-escrow` — everything below
309
- - `@atumlabs/mppx-atum-escrow/client` — `registerClient`, `ensureSourceApproval` + payer types
310
- - `@atumlabs/mppx-atum-escrow/server` — `registerServer`, `buildChargeRequest`,
311
- `validateCorridor`, `corridorFromDefaults` + merchant types
536
+ - `@atumlabs/mppx-atum-escrow/client` — `registerClient`, `ensureSourceApproval`, `needsSourceApproval` + payer types
537
+ - `@atumlabs/mppx-atum-escrow/server` — `registerServer`, `buildChargeChallenge`,
538
+ `buildChargeRequest`, `validateCorridor`, `corridorFromDefaults`, the settlement errors +
539
+ merchant types
312
540
 
313
541
  | Export | Description |
314
542
  | --- | --- |
315
543
  | `registerClient(config)` | Payer-side method. `config`: `{ signer, account, now?, solanaClockReader? }`. |
316
- | `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`). |
544
+ | `registerServer(config)` | Merchant-side method. `config`: `{ submitter, now? }`. Returns an `AtumEscrowServer`, whose `verify()` resolves with an `AtumEscrowReceipt`. |
545
+ | `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 }`. |
546
+ | `buildChargeRequest(corridor, select, fulfillmentAmount, options?)` | The payment terms alone, without the identifier. For supplying challenge metadata by hand. |
547
+ | `validateCorridor(corridor)` | Validates a corridor's shape and per-source addresses (run automatically by both builders). |
319
548
  | `corridorFromDefaults(defaults, params)` | Builds a corridor by fetching escrow/role/proxy addresses from the gateway `/defaults`. |
320
- | `ensureSourceApproval(params)` | Ensures the payer has approved Permit2 to move the source token (EVM/Tron; no-op on Solana). |
549
+ | `ensureSourceApproval(params)` | Ensures the payer has approved Permit2 to move the source token. `signer` for EVM, `tronWeb` for Tron; no-op on Solana. |
550
+ | `needsSourceApproval(params)` | Whether `ensureSourceApproval` would send a transaction. Read-only, spends no gas. |
551
+ | `SettlementPendingError`, `SettlementFailedError`, `PaymentRejectedError` | The non-receipt outcomes of `verify()`. See [Settlement outcomes](#settlement-outcomes). |
552
+ | `isSettlementPending`, `isSettlementFailed`, `isPaymentRejected` | Guards for the above. |
321
553
  | `atumEscrowChargeMethod` | The base `mppx` method (advanced/custom wiring). |
322
554
  | `ChargeRequestSchema`, `CredentialPayloadSchema` | The `zod` wire schemas. |
323
555
  | `METHOD_NAME` (`"atum-escrow"`), `INTENT` (`"charge"`) | The method/intent identifiers. |
556
+ | `INTENT_ID_META_KEY` | The challenge-metadata key carrying the per-purchase identifier. |
324
557
 
325
558
  Key types: `AtumEscrowCorridor`, `AtumEscrowSource`, `SenderSigner`, `SenderSignerOptions`,
326
- `PaymentSubmitter`, `AtumEscrowClientConfig`, `AtumEscrowServerConfig`, `ChainDefaultsSource`,
327
- `EnsureApprovalResult`, `AtumEscrowChallenge`, `AtumEscrowCredential`, `AtumEscrowReceipt`,
328
- `ChargeRequest`, `PaymentRequest`, `FulfillmentConfirmation`.
559
+ `PaymentSubmitter`, `PaymentSubmitResult`, `PaymentSettlementStatus`, `AtumEscrowClientConfig`,
560
+ `AtumEscrowServer`, `AtumEscrowServerConfig`, `ChainDefaultsSource`, `EnsureApprovalResult`,
561
+ `AtumEscrowChallenge`, `AtumEscrowCredential`, `AtumEscrowReceipt`, `ChargeChallenge`, `ChargeRequest`,
562
+ `SettlementErrorDetails`, `PaymentRequest`, `FulfillmentConfirmation`.
329
563
 
330
564
  > **EVM implementation note:** on EVM the deposit authorization is a Permit2
331
565
  > `PermitWitnessTransferFrom` signature; Tron uses the equivalent TIP-712 typed data, and Solana
332
566
  > uses an ed25519-signed deposit. The public API is the same across all three.
567
+
568
+ ## Further reading
569
+
570
+ - [Accepting and making MPP payments with Atum](https://docs.atum.xyz/payment-protocols/mpp/overview)
571
+ - [Supported assets](https://docs.atum.xyz/get-started/reference/supported-assets) and [supported networks](https://docs.atum.xyz/get-started/reference/supported-networks)
572
+ - [MPP](https://mpp.dev)
573
+ - [Atum documentation](https://docs.atum.xyz)
@@ -2,6 +2,13 @@ THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
2
2
  ==============================================================================
3
3
  @atumlabs/mppx-atum-escrow
4
4
 
5
+ @atumlabs/mppx-atum-escrow is proprietary software, governed by the license
6
+ agreement in the package-root LICENSE file. THIS file is not that license. This
7
+ package includes the third-party software identified below; the licenses and
8
+ notices reproduced here apply only to those third-party components. They do not
9
+ modify the proprietary license governing this package or any Atum-authored code,
10
+ and they do not make it open source.
11
+
5
12
  The published artifact of this package (the compiled code under dist/) statically
6
13
  bundles the third-party open-source components listed below; their source is
7
14
  redistributed within this package, so their license and copyright notices are