@atumlabs/x402-atum-escrow
v0.3.1
Published
The atum-escrow x402 scheme as installable mechanisms: client (payer), server (resource-server 402 builder), facilitator (keyless relay to Atum), and a payment-identifier-aware fetch wrapper.
Readme
Copyright 2026 Atum Labs, Inc.
Licensed under the Apache License, Version 2.0. See the
LICENSEfile distributed with this package.The public x402 protocol and third-party x402 packages remain subject to their own terms. This license covers Atum's implementation only.
@atumlabs/x402-atum-escrow
The atum-escrow x402 scheme, as installable mechanisms. Drop it into a standard
x402 client or resource server and the pair becomes "Atum-aware": the payer spends
any source asset/chain Atum supports, and the merchant receives its chosen asset on
its chosen chain, for an exact amount — Atum converts between them.
Full documentation: docs.atum.xyz.
atum-escrow rides standard x402 v2 envelopes. The generic parts (catching the 402,
parsing accepts[], selecting a requirement, retrying) are handled by the stock x402
HTTP wrappers; this package supplies only the scheme-specific pieces: how the 402 is
built (server) and how the PAYMENT-SIGNATURE payload is constructed and signed
(client).
Install
pnpm add @atumlabs/x402-atum-escrowPeer stack: @x402/core, ethers v6 (EVM/Tron signing). Solana signing uses
tweetnacl/bs58, bundled with the package.
Roles
Each role is a separate subpath, mirroring the stock scheme packages:
| Subpath | Role | Registers onto |
| ---------------------------------------- | ------------------------------------------ | -------------------- |
| @atumlabs/x402-atum-escrow/server | resource server (merchant): builds the 402 | x402ResourceServer |
| @atumlabs/x402-atum-escrow/client | payer: builds + signs the payment | x402Client |
| @atumlabs/x402-atum-escrow/facilitator | facilitator: keyless relay to Atum | x402Facilitator |
Payer (client) Merchant (server) Facilitator
| GET /resource | |
| ---------------------------> | |
| 402 + accepts[] (sources), | |
| asking for a purchase id | |
| <--------------------------- | |
| pick a source option, name | |
| the purchase, sign deposit | |
| GET /resource | |
| (PAYMENT-SIGNATURE) | |
| ---------------------------> | /verify then /settle |
| | -------------------------> |
| | settled (+ receipt) |
| | <------------------------- |
| 200 + resource | |
| <--------------------------- | |Naming the purchase is what makes re-attempting it resolve to the original payment instead of a second charge — see Idempotency.
Server (merchant)
Register the scheme onto an x402ResourceServer with your corridor config, then
build the route's accepts[] from the same config so there is a single source of
truth for which source chains you offer.
import { x402ResourceServer } from "@x402/core/server";
import { paymentMiddleware } from "@x402/express";
import {
registerAtumEscrowScheme,
type AtumEscrowServerConfig,
} from "@atumlabs/x402-atum-escrow/server";
const config: AtumEscrowServerConfig = {
// Where you receive: chain, token, and address.
destination: {
network: "eip155:42161",
asset: "0x…USDC",
address: "0x…merchant",
},
// Exact amount you receive, in atomic units of the destination token.
fulfillmentAmount: "10000000",
// Markup over fulfillmentAmount to derive each source cap, in basis points (300 = 3%).
markupBps: 300,
fulfillmentProxy: "0x…proxy",
reserver: "0x…reserver",
releaser: "0x…releaser",
quoteDeadlineSeconds: 20,
fulfillmentDeadlineSeconds: 300,
// The source chains you accept, keyed by CAIP-2 network. A single instance can serve
// EVM, Tron, and Solana sources at once; each signs its native deposit authorization.
sources: {
// EVM (Permit2 / EIP-712 deposit).
"eip155:8453": { asset: "0x…baseUSDC", escrow: "0x…baseEscrow" },
// Tron (TIP-712 deposit; base58 addresses).
"tron:mainnet": { asset: "T…usdt", escrow: "T…tronEscrow" },
// Solana (Ed25519 deposit; base58 addresses + the escrow signature-domain).
"solana:mainnet": {
asset: "…mint",
escrow: "…solEscrow",
svmSignatureClusterId: "mainnet",
svmSignatureDomainVersion: 1,
},
},
};
const server = registerAtumEscrowScheme(
new x402ResourceServer(facilitatorClient),
config
);
// One accepts[] entry per source option, from the same config.
const accepts = Object.entries(config.sources).map(([network, source]) => ({
scheme: "atum-escrow",
network,
payTo: source.escrow, // funds lock in escrow, not with the merchant
price: "$0.10", // required by the route type, but ignored: the amount is fulfillmentAmount + markup
maxTimeoutSeconds: 60,
}));
app.use(paymentMiddleware({ "GET /paid": { accepts } }, server));registerAtumEscrowScheme registers under the eip155:* wildcard by default; pass an
explicit network list as the third argument to pin it. A malformed config throws at
registration (fail-loud at startup).
Which chains and assets can you accept?
The sources map above uses placeholders. The real values come from our documentation: every
supported token is listed with its identifier under
supported assets, and the chain ids
under supported networks. Assets
are named with CAIP-19 identifiers, for example
eip155:84532/erc20:0x036CbD53842c5426634e7929541eC2318f3dCF7e for USDC on Base Sepolia.
Copy identifiers exactly: base58 values, such as Solana token mints and Tron addresses, are case-sensitive, and re-casing one produces a different valid value rather than a different spelling of the same one.
Client (payer)
Register the scheme onto an x402Client with a signer, then wrap fetch. Build the client once
and reuse it; each call says which purchase it is paying for.
import { ethers } from "ethers";
import { x402Client } from "@x402/core/client";
import { registerAtumEscrowScheme } from "@atumlabs/x402-atum-escrow/client";
import { wrapFetchWithAtumPayment } from "@atumlabs/x402-atum-escrow/fetch";
const client = new x402Client();
registerAtumEscrowScheme(client, { signer: new ethers.Wallet(PRIVATE_KEY) });
const pay = wrapFetchWithAtumPayment(fetch, client);
// Required. One value per purchase — see Idempotency below.
const res = await pay("https://merchant.example/paid", {}, { paymentIdentifier: orderId });The client reads the selected 402 option's extra.atum (destination, deadlines,
escrow/proxy/role addresses), builds the Atum payment request, signs the source-side
escrow-deposit authorization, and emits the PAYMENT-SIGNATURE. The signature is
chain-specific: EVM and Tron sign a Permit2/TIP-712 typed-data deposit with an ethers
signer; Solana signs an Ed25519 deposit with a SolanaSigner. It registers under
eip155:* by default; pass networks to fund from Solana/Tron or to restrict the
source chains.
For a Solana source, pass a SolanaSigner — a { publicKey, sign } capability (a local
tweetnacl keypair or a remote signer), so key material never reaches the mechanism:
import {
registerAtumEscrowScheme,
type SolanaSigner,
} from "@atumlabs/x402-atum-escrow/client";
const solanaSigner: SolanaSigner = {
publicKey: "…base58",
async sign(message) {
/* Ed25519-sign `message`, return the 64-byte detached signature */
},
};
registerAtumEscrowScheme(client, { signer: solanaSigner, networks: ["solana:mainnet"] });Precondition: source authorization state
The payer must have the source-side authorization already in place, or the escrow deposit reverts at settlement — arranging it is the payer/app's responsibility (this mechanism assumes it and does not check on-chain):
- EVM / Tron: an
approve(Permit2)allowance covering the spend. - Solana: an escrow delegate (created out-of-band) authorizing the deposit.
Do this before signing. A deposit that reverts on-chain is a terminal settlement failure, and a terminal failure belongs to the purchase identifier that caused it — so re-attempting that purchase then needs a new identifier (see Settlement outcomes). Getting the allowance right up front keeps the identifier usable.
For an EVM or Tron source, ensureSourceApproval is offered as an optional convenience — use it if
you would rather not write the ERC-20 calls yourself:
import { Wallet, JsonRpcProvider } from "ethers";
import { ensureSourceApproval } from "@atumlabs/x402-atum-escrow/client";
const wallet = new Wallet(PRIVATE_KEY, new JsonRpcProvider(SOURCE_RPC_URL));
await ensureSourceApproval({
network: "eip155:8453",
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
owner: wallet.address,
signer: wallet,
// Pass the amount this charge needs (the 402 option's `amount`), so a leftover smaller
// allowance isn't mistaken for enough.
requiredAllowance: 1_000_000n,
});It reads the current allowance and sends an approval only if it falls short. The approval it sends is unlimited, so later charges on the same token need no further transaction — and it stays sufficient as payments consume it, because Permit2 decrements the allowance each time. On Solana there is no allowance to grant, so the call does nothing.
Paying from Tron takes a TronWeb instance in place of the ethers signer; the Permit2 address for the network is resolved for you:
await ensureSourceApproval({ network: "tron:mainnet", token, owner, tronWeb });The approval waits to be mined before returning, bounded by confirmation.timeoutMs (one minute
by default); a timeout is reported as unconfirmed rather than failed, since the transaction may
still land. onSubmitted hands you the hash the moment it is broadcast.
isUnconfirmed(error) tells the two apart. It matters because the responses are opposite: a
failed approval needs another one, an unconfirmed approval needs a look at the transaction and
nothing else. Sending a second one on top of the first only pays for an allowance you are already
getting.
try {
await ensureSourceApproval({ network, token, owner, signer });
} catch (error) {
if (isUnconfirmed(error)) {
// Broadcast, not yet seen to confirm. Check the hash from onSubmitted; do not re-send.
} else {
throw error;
}
}On EVM, tokens that refuse to overwrite a non-zero allowance — mainnet USDT most notably — are
reset to zero first, automatically and only where the token demands it; the result then carries a
resetTxHash too. Tron does not do this, because an ordinary TRC-20 accepts the change in place.
To bound the approval that gets sent, pass approvalAmount. On its own the amount is treated as
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. Either way a
bounded approval is consumed as it is spent and eventually has to be granted again.
These helpers raise an allowance to what a payment needs; they never lower one. A wallet already
holding more than approvalAmount is left exactly as it is and reports alreadySufficient
without sending anything. Reducing or revoking an allowance is a separate operation, and not one
these perform.
needsSourceApproval(params) takes the same arguments and answers the same question without
sending anything, so it is safe to call as a preflight before asking a payer to sign:
if (await needsSourceApproval({ network, token, owner, signer })) {
// a one-off approval transaction is due first
}Idempotency
A retry must not become a second payment. HTTP requests get retried — by an agent, by a proxy, by a user pressing the button again — and each retry re-fetches the resource and gets a fresh 402, so nothing about the rebuilt payment is byte-identical to the first attempt. The scheme gives the payment a stable identity instead, and the Atum gateway de-duplicates on it.
That identity comes from you. You name the purchase being paid for, and the client derives the
payment's request_id from that name:
pay(url, {}, { paymentIdentifier: "order-4711" })
↓ travels in the standard x402 `payment-identifier` extension
↓ client derives request_id from (identifier, source account)
↓ gateway de-duplicates on request_id
re-attempt under the same identifier → the ORIGINAL payment, not a second chargeThe carrier is the x402 specification's own
payment-identifier
extension, so nothing about this is specific to Atum: the resource server declares that it needs
an identifier, and the payer names the purchase.
The contract
| Rule | Why | | -------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Same identifier for every attempt at one purchase | This is what makes the re-attempt safe. A new identifier is a new payment. | | Different identifier for a different purchase | Reusing one across two genuine purchases de-duplicates the second onto the first — the payer receives twice, the merchant is paid once. | | Nothing can detect a reused identifier | Same as every idempotency-key API. The identifier IS the identity; correctness is the caller's. |
The third rule is the one with teeth, so it is worth being concrete about the failure and the defence. If a payer reuses one identifier across two genuine purchases, the network cannot tell them apart: the second resolves onto the first payment and only one payment is made.
A merchant can close that on its own side, and should. Both purchases resolve to the same
payment, so both are served the same receipt — the same payment_id and the same
destination_tx_hash. A merchant that keys fulfilment on the receipt rather than on the request
therefore fulfils once and recognises the second as already-fulfilled, instead of shipping twice
against a single payment. Treat receipt-keyed fulfilment as a merchant responsibility: the scheme
guarantees you will not be paid twice for one identifier, and this is how you avoid delivering
twice for one.
A natural per-purchase reference — an order id, a cart id, an invoice number — is the right value. A UUID minted once per purchase and reused across that purchase's attempts works equally well. What does not work is a value derived from the payment terms (two identical orders would collide) or one minted fresh per attempt (every re-attempt becomes a new payment).
Any non-empty string is accepted. The extension itself permits 16-128 characters of letters,
digits, hyphen and underscore, and a reference outside that — order/0001, or simply one that is
too short — is mapped onto a conforming identifier by digest. The mapping is stable, so the same
reference always yields the same payment identity; a reference that already conforms travels
unchanged, so pass a conforming one if you want the wire value to match your own id.
Both sides
Payer — pass paymentIdentifier on each call. Omitting it is not "opting out of idempotency":
the client refuses to sign, naming what to supply. There is deliberately no generated
fallback — one would look like an idempotency key while letting every re-attempt double-charge.
Note that the wrapped fetch stays assignable to typeof fetch, so a library that accepts a
fetch implementation can call it as fetch(url, init) and drop the third argument. Such a call
is refused rather than paid unsafely.
Payer, driving the client directly — if you fetch the 402 yourself and call
createPaymentPayload, there is no per-call channel to carry the identifier, so name the purchase
on the 402 first:
import { withPaymentIdentifier } from "@atumlabs/x402-atum-escrow/client";
// paymentRequired: the 402 you decoded yourself
const payload = await client.createPaymentPayload(
withPaymentIdentifier(paymentRequired, orderId),
);Same function the wrapped fetch uses, so the two paths enforce the same rules: it refuses when
neither you nor the resource server named the purchase, and refuses when you and the resource server
name it differently (a payer may not overwrite a declared identifier). Writing the extension by hand
instead is what to avoid — that is how a payment ends up carrying no identity at all.
Merchant — nothing to write. The server mechanism declares the extension automatically, so a payer knows an identifier is required before it builds anything.
A merchant that already tracks the purchase server-side may name it itself instead, by declaring the identifier on the route:
app.use(
paymentMiddleware(
{
"GET /paid": {
accepts,
extensions: {
"payment-identifier": { info: { required: true, id: "order_4711_a1b2c3d4" } },
},
},
},
server
)
);The scheme never overwrites an identifier the resource server declared — a payer may add to a
declaration but not replace it — and a payer that supplies a different one gets an error rather
than a payment made under an identity it did not choose. Note that a route declaration is static;
naming a purchase per request means declaring it dynamically, which x402 supports through an
extension's own enrichDeclaration hook.
Scheme rules
Two behaviours are part of the atum-escrow scheme rather than of any one implementation of it. A
third-party client or server built against this scheme has to match them, so they are stated
normatively here.
1. A payment with no purchase identifier is refused, not guessed at. The client will not build a
payment when neither the caller nor the resource server has named the purchase, and it does not
generate an identifier of its own. A generated one would look like an idempotency key while letting
every re-attempt become a second payment — the precise failure the identifier exists to prevent. The
resource server declares payment-identifier with required: true on every 402, so a payer that
knows nothing else about this scheme still learns that an identifier is mandatory and what shape it
takes.
2. That refusal surfaces as 402, not 400. A missing identifier is not a malformed request; it
is a payment that cannot yet be made safely, and the remedy is for the payer to attempt again with
the purchase named. So the resource server answers 402 with the requirement re-declared, and a
payer should treat it as "attempt again, naming the purchase" rather than as an error to report and
abandon.
Note for implementers: the HTTP status is chosen by the x402 framework, not by the scheme — in
@x402/corethe mapping is fixed (permit2_allowance_requiredbecomes412, everything else402). A scheme therefore cannot select its own status, which is why this is stated as a rule about what a payer should expect rather than as something the scheme enforces.
Settlement outcomes
x402 models settlement as a boolean, so everything short of settled arrives as success: false on
a 402. That single flag covers three situations that call for three different responses, and
errorReason is what separates them:
| errorReason | What happened | What the payer should do |
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------- |
| (none — success: true) | Settled | Nothing; the resource is served |
| settlement_pending | Accepted, still settling | Re-attempt under the SAME identifier — it resolves to this payment |
| settlement_failed | Terminal failure | Start a new purchase under a NEW identifier — this one can never settle |
| a gateway code | Refused; nothing charged | Fix the request and pay the same purchase again |
Three of the four settlement outcomes carry the payment id, so a merchant can reconcile without parsing a message — the refusal is the one exception, because nothing was charged and there is no payment to name:
{
"success": true,
"extensions": {
"atum": { "paymentId": "pay_…", "state": "completed" }
}
}{
"success": false,
"errorReason": "settlement_pending",
"extensions": {
"atum": { "paymentId": "pay_…", "state": "pending", "statusUrl": "https://…" }
}
}Pending: retry, don't poll
A pending payment is real and may already be moving funds. Do not release the goods, and do not pay again under a new identifier.
Re-attempting the same purchase — the same paymentIdentifier — is how you collect the result. The
gateway resolves the re-attempt onto the same payment and replays its receipt once it has one, so
re-attempting costs nothing. statusUrl is there for out-of-band reconciliation (dashboards, support, a ledger job);
it is not a step the payment flow has to take, and this client does not poll it.
Note that a pending attempt is not served: the resource is withheld until settlement completes, because goods must not be released against an unfinished payment.
Failed: a retry cannot recover it
settlement_failed is the opposite instruction. That purchase's identifier now resolves to a dead
payment forever, so re-attempting it returns the same failure indefinitely. Recovering means
treating it as a new purchase, under a new identifier.
This is why the two are distinguished rather than collapsed into one failure: reading pending as failed abandons a payment that was about to succeed (and invites a second charge under a fresh identifier), while reading failed as pending strands the payer in a retry loop that can never finish.
Reusing a key for different terms
The gateway refuses a key that comes back with different economics — a different source,
destination, or fulfillmentAmount — instead of silently resolving it onto the first payment. It
arrives as a refusal carrying the gateway's code:
{ "success": false, "errorReason": "IDEMPOTENCY_TERMS_MISMATCH", "errorMessage": "…" }Nothing was charged and nothing is in flight, so this is a request to fix rather than a payment to wait on — the fix is always a distinct key per purchase. Deadlines are deliberately not part of the comparison: every retry re-signs with fresh deadlines, so including them would make the mismatch fire on every legitimate retry.
A transport or server failure is deliberately not reported as a refusal. When the outcome is unknown the response is a 5xx, because calling it a definite "did not settle" would invite a second payment for a purchase that may have gone through.
How pricing works
The merchant pins an exact fulfillmentAmount (what it receives) and adds a static
markup to derive each source option's cap (amount). The payer signs a
max_source_amount no greater than that cap; the merchant always receives exactly
fulfillmentAmount, and the headroom covers settlement margin and gas. A payer may
sign a lower cap, which only lowers its own escrow lock and risks the auction not
clearing — it can never reduce the merchant's receive side.
Status
The client, server, and facilitator roles are all shipped, across EVM, Solana, and Tron sources. The current profile emits the source-side authorization only; the holder-signed identity binding (a Verifiable Presentation over the request) required for full x402 conformance is added in a later revision.
