@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 +49 -0
- package/README.md +294 -53
- package/THIRD-PARTY-NOTICES.txt +7 -0
- package/dist/{chunk-IPZJXELQ.js → chunk-KRSFEITH.js} +3594 -469
- package/dist/{chunk-4P34CLTO.js → chunk-OEEC5P3E.js} +1323 -252
- package/dist/{chunk-NEM3HEZW.js → chunk-TPLD2L6B.js} +141 -13
- package/dist/client.d.ts +192 -52
- package/dist/client.js +11 -4
- package/dist/index.d.ts +3 -77
- package/dist/index.js +26 -5
- package/dist/{internal-DzEGXm14.d.ts → internal-CJEu9yUF.d.ts} +131 -24
- package/dist/server.d.ts +255 -50
- package/dist/server.js +18 -2
- package/package.json +10 -8
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 `
|
|
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
|
|
56
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
293
|
-
|
|
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`, `
|
|
311
|
-
`validateCorridor`, `corridorFromDefaults
|
|
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
|
-
| `
|
|
318
|
-
| `
|
|
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
|
|
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`, `
|
|
327
|
-
`
|
|
328
|
-
`
|
|
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)
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -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
|