@metalntwx/metal-pay 0.3.0 → 0.3.2

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.
@@ -1,7 +1,8 @@
1
- import { BaseError, ContractFunctionRevertedError, concatHex, createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, FeeCapTooHighError, FeeCapTooLowError, http, InsufficientFundsError, IntrinsicGasTooHighError, IntrinsicGasTooLowError, keccak256, NonceMaxValueError, NonceTooHighError, parseEventLogs, sha256, stringToHex, TipAboveFeeCapError, TransactionTypeNotSupportedError, } from "viem";
1
+ import { BaseError, ContractFunctionRevertedError, concatHex, createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, FeeCapTooHighError, FeeCapTooLowError, InsufficientFundsError, IntrinsicGasTooHighError, IntrinsicGasTooLowError, keccak256, NonceMaxValueError, NonceTooHighError, parseEventLogs, sha256, stringToHex, TipAboveFeeCapError, TransactionTypeNotSupportedError, } from "viem";
2
2
  import { getNodeError } from "viem/utils";
3
3
  import { clearingAbi } from "./clearing-abi.js";
4
4
  import { clearingUint64, clearingUnits } from "./clearing-codec.js";
5
+ import { formatUsdz, rpcTransport, USDZ } from "./networks.js";
5
6
  export function clearingLogProof(proof) {
6
7
  return {
7
8
  ...proof,
@@ -44,19 +45,24 @@ export async function clearingClose(signer, deployment, balance, destination, de
44
45
  * counts `uint64` units of `Clearing.unit()` token base units each, a
45
46
  * millionth of the token (its base unit at six decimals or fewer). */
46
47
  export const CLEARING_MAX_AMOUNT = 2n ** 64n - 1n;
48
+ /** A clearing service that gave no answer, or a 5xx. */
49
+ class ServiceUnreachable extends Error {
50
+ }
47
51
  async function serviceGet(serviceUrl, path, fetcher) {
48
52
  const response = await fetcher(new URL(path, serviceUrl), {
49
53
  signal: AbortSignal.timeout(30_000),
54
+ }).catch((error) => {
55
+ throw new ServiceUnreachable(`clearing service gave no answer for ${path} (${error instanceof Error ? error.message : String(error)})`, { cause: error });
50
56
  });
51
57
  if (!response.ok)
52
- throw new Error(`clearing service returned ${response.status} for ${path}`);
58
+ throw new (response.status >= 500 ? ServiceUnreachable : Error)(`clearing service returned ${response.status} for ${path}`);
53
59
  return (await response.json());
54
60
  }
55
61
  /** What `account` can still spend in the open epoch, in the token's base
56
62
  * units: the operator's estimate from the balance and deposits it has
57
63
  * counted and the payments it has acknowledged. */
58
64
  export async function clearingSpendable(opts) {
59
- const pub = createPublicClient({ transport: http(opts.rpcUrl) });
65
+ const pub = createPublicClient({ transport: rpcTransport(opts.rpcUrl) });
60
66
  const [{ amount }, unit] = await Promise.all([
61
67
  serviceGet(opts.serviceUrl, `/spendable/${opts.account}`, opts.fetch ?? globalThis.fetch),
62
68
  pub.readContract({ address: opts.settlement, abi: clearingAbi, functionName: "unit" }),
@@ -85,7 +91,7 @@ const REFUSALS = [
85
91
  /** Gas and fees are set here: on Metal, a fee-carrying `eth_estimateGas`
86
92
  * from an account with no native balance fails. */
87
93
  function chainClients({ rpcUrl, chainId, from, timeoutMs = 60_000, pollMs = 500 }) {
88
- const transport = http(rpcUrl);
94
+ const transport = rpcTransport(rpcUrl);
89
95
  const pub = createPublicClient({ transport });
90
96
  const wallet = createWalletClient({ account: from, transport });
91
97
  // The dry run first, under the gas the transaction is sent with, so a
@@ -143,6 +149,12 @@ function succeeded(functionName, receipt) {
143
149
  throw new Error(`${functionName} reverted: ${receipt.transactionHash}`);
144
150
  return receipt.transactionHash;
145
151
  }
152
+ /** A withdrawal's queue or claim refused, by its dry run or as a mined
153
+ * revert (`tx`), because an epoch finalized after the service proved it:
154
+ * the contract checks a proof against its finalized state alone. */
155
+ function finalizedUnder(functionName, tx, cause) {
156
+ return new Error(`${tx ? `${functionName} ${tx} reverted` : `${functionName} was refused`} because an epoch finalized under it, so the service's proof is for the state before; nothing was lost${tx ? " but its gas" : ""}: make the same call again after the next epoch`, cause === undefined ? {} : { cause });
157
+ }
146
158
  /** Put `amount` token base units into clearing custody under `account`, a
147
159
  * clearing key; it must be a whole number of the contract's clearing
148
160
  * units. `from` pays: an approval for the amount unless one that covers it
@@ -150,8 +162,10 @@ function succeeded(functionName, receipt) {
150
162
  * registrations, so the deposit waits for that window and is sent again
151
163
  * if the window closes under it, up to `timeoutMs` (default 60 s). It
152
164
  * refuses at once a contract that holds another token, and a deposit past
153
- * what the contract holds in all. A deposit whose outcome is unknown is not
154
- * sent again: the error names it. */
165
+ * what the contract holds in all. A deposit the token refuses because
166
+ * `from` holds less fails naming what `from` holds and what the deposit
167
+ * needs. A deposit whose outcome is unknown is not sent again: the error
168
+ * names it. */
155
169
  export async function depositToClearing(opts) {
156
170
  if (opts.amount <= 0n)
157
171
  throw new Error("deposit amount must be positive");
@@ -194,6 +208,14 @@ export async function depositToClearing(opts) {
194
208
  // A full contract reverts the same way, and `assertRoom` tells them apart.
195
209
  const windowClosed = (error) => error instanceof BaseError &&
196
210
  error.walk((e) => e instanceof ContractFunctionRevertedError && e.data?.errorName === "InvalidBoundary") !== null;
211
+ // The token refuses a transfer past what `from` holds with the reason
212
+ // "balance"; what it holds is read after the refusal.
213
+ const shortOf = async (error) => {
214
+ if (!(error instanceof BaseError && error.walk((e) => e instanceof ContractFunctionRevertedError && e.reason === "balance")))
215
+ return undefined;
216
+ const held = await pub.readContract({ address: opts.asset, abi: erc20Abi, functionName: "balanceOf", args: [opts.from.address] });
217
+ return `${opts.from.address} holds ${formatUsdz(held)} ${USDZ.symbol} and the deposit needs ${formatUsdz(opts.amount)} ${USDZ.symbol}; fund it or deposit less`;
218
+ };
197
219
  for (;;) {
198
220
  // A deposit that reverts deposited nothing, and the next dry run says
199
221
  // why: if the window closed under it, the next round waits for the next
@@ -205,12 +227,16 @@ export async function depositToClearing(opts) {
205
227
  const why = await pub
206
228
  .simulateContract({ ...deposit, account: opts.from.address, gas: depositGas })
207
229
  .then(() => undefined, (error) => error);
208
- if (!windowClosed(why))
209
- throw new Error(`deposit ${receipt.transactionHash} reverted${why instanceof BaseError ? `: ${why.shortMessage}` : ", and its dry run now passes"}`);
230
+ if (!windowClosed(why)) {
231
+ const short = await shortOf(why);
232
+ throw new Error(`deposit ${receipt.transactionHash} reverted${short ? `: ${short}` : why instanceof BaseError ? `: ${why.shortMessage}` : ", and its dry run now passes"}`);
233
+ }
210
234
  }
211
235
  catch (error) {
212
- if (!windowClosed(error))
213
- throw error;
236
+ if (!windowClosed(error)) {
237
+ const short = await shortOf(error);
238
+ throw short ? new Error(`the deposit was refused, and nothing was deposited: ${short}`, { cause: error }) : error;
239
+ }
214
240
  }
215
241
  await assertRoom();
216
242
  if (Date.now() >= deadline)
@@ -224,6 +250,7 @@ const accountKey = (account) => sha256(concatHex([stringToHex("_COMMONWARE_CLEAR
224
250
  * `serviceUrls` that proves it. */
225
251
  async function lookupPayout(serviceUrls, fetcher, epoch, account, destination) {
226
252
  const failures = [];
253
+ const unreachable = [];
227
254
  for (const serviceUrl of serviceUrls) {
228
255
  try {
229
256
  const payout = await serviceGet(serviceUrl, `/payout/${epoch}/${account}`, fetcher);
@@ -235,10 +262,14 @@ async function lookupPayout(serviceUrls, fetcher, epoch, account, destination) {
235
262
  return payout;
236
263
  }
237
264
  catch (error) {
265
+ if (error instanceof ServiceUnreachable)
266
+ unreachable.push(error);
238
267
  failures.push(`${serviceUrl}: ${error instanceof Error ? error.message : String(error)}`);
239
268
  }
240
269
  }
241
- throw new Error(`no clearing service named proves the payout of this account's withdrawal in epoch ${epoch}; a validator proves it once it has retained that epoch (${failures.join("; ")})`);
270
+ throw new Error(`no clearing service named proves the payout of this account's withdrawal in epoch ${epoch}; a validator proves it once it has retained that epoch (${failures.join("; ")})`,
271
+ // None of them answered.
272
+ unreachable.length === serviceUrls.length ? { cause: unreachable[0] } : {});
242
273
  }
243
274
  /** Claims `payout` to `destination` unless it is 0 or claimed already. The
244
275
  * claim is pushed to `claimed` as it lands. */
@@ -250,7 +281,14 @@ async function claimPayout({ pub, send, settlement, unit }, payout, destination,
250
281
  const consumed = await pub.readContract({ ...contract, functionName: "claimedPayouts", args: [position >> 8n] });
251
282
  if (consumed & (1n << (position & 255n)))
252
283
  return;
253
- const receipt = await send({ ...contract, functionName: "claim", args: [destination, BigInt(payout.amount), clearingLogProof(payout.proof)] }, 1000000n);
284
+ // The proof is for the payouts log the service read as finalized; once a
285
+ // later epoch finalizes, the contract's log is longer and refuses it.
286
+ const moved = async () => (await pub.readContract({ ...contract, functionName: "finalizedPayouts" }))[1] !== BigInt(payout.proof.leaves);
287
+ const receipt = await send({ ...contract, functionName: "claim", args: [destination, BigInt(payout.amount), clearingLogProof(payout.proof)] }, 1000000n).catch(async (error) => {
288
+ throw revertOf(error) === "InvalidProof" && (await moved()) ? finalizedUnder("claim", undefined, error) : error;
289
+ });
290
+ if (receipt.status !== "success" && (await moved()))
291
+ throw finalizedUnder("claim", receipt.transactionHash);
254
292
  const tx = succeeded("claim", receipt);
255
293
  const deferred = parseEventLogs({ abi: clearingAbi, eventName: "PayoutDeferred", logs: receipt.logs }).length > 0;
256
294
  claimed.push({ amount: BigInt(payout.amount) * unit, tx, deferred });
@@ -260,7 +298,9 @@ async function claimPayout({ pub, send, settlement, unit }, payout, destination,
260
298
  * withdrawal of whatever finalized balance remains, to `destination` (`from` itself
261
299
  * unless given). A payer's unused deposit and a seller's earnings leave the
262
300
  * same way. Call again after the queued withdrawal's epoch finalizes. A
263
- * failure after a claim names the claims made before it. */
301
+ * failure after a claim names the claims made before it. A queue or claim
302
+ * refused because an epoch finalized under its proof moved nothing: the
303
+ * error says to call again after the next epoch. */
264
304
  export async function withdrawFromClearing(opts) {
265
305
  const { pub, send } = chainClients(opts);
266
306
  const fetcher = opts.fetch ?? globalThis.fetch;
@@ -276,18 +316,33 @@ export async function withdrawFromClearing(opts) {
276
316
  ]);
277
317
  const [encodedHash, epochPlusOne, , , queuedTo] = status;
278
318
  if (encodedHash !== `0x${"00".repeat(32)}` && (epochPlusOne === 0n || epochPlusOne > finalized)) {
279
- if (epochPlusOne > 0n)
280
- return { status: "waiting", epoch: epochPlusOne - 1n };
281
- // Not yet registered: the next registration carries it.
282
319
  const [registered, admitted] = await Promise.all([
283
320
  pub.readContract({ ...contract, functionName: "registered" }),
284
321
  pub.readContract({ ...contract, functionName: "admittedCount" }),
285
322
  ]);
323
+ // A service that has missed a deadline may never finalize the epoch.
324
+ for (const call of faultCalls(admitted, opts.signer.publicKey)) {
325
+ const missed = await pub.simulateContract({ ...contract, ...call, account: opts.from.address }).then(() => true, (error) => {
326
+ if (revertOf(error) !== "InvalidDeadline")
327
+ throw error;
328
+ return false;
329
+ });
330
+ if (missed)
331
+ throw new Error(`the clearing service has missed a deadline (${call.functionName} would fault the deployment), so the withdrawal may never finalize: take the account's funds out with recoverFromClearing`);
332
+ }
333
+ if (epochPlusOne > 0n)
334
+ return { status: "waiting", epoch: epochPlusOne - 1n };
335
+ // Not yet registered: the next registration carries it.
286
336
  return { status: "waiting", epoch: registered ? admitted + 1n : admitted };
287
337
  }
288
338
  const claimed = [];
289
339
  try {
290
- return await claimThenQueue();
340
+ return await claimThenQueue().catch((error) => {
341
+ const gone = error instanceof ServiceUnreachable ? error : error instanceof Error && error.cause instanceof ServiceUnreachable ? error.cause : undefined;
342
+ if (!gone)
343
+ throw error;
344
+ throw new Error(`the clearing service could not be reached (${gone.message}): call withdrawFromClearing again once it answers; if it has stopped, pass a validator's URL from the facilitator's /supported extra.validatorUrls as serviceUrl`, { cause: error });
345
+ });
291
346
  }
292
347
  catch (error) {
293
348
  if (claimed.length === 0)
@@ -298,11 +353,14 @@ export async function withdrawFromClearing(opts) {
298
353
  async function claimThenQueue() {
299
354
  if (epochPlusOne > 0n)
300
355
  await claimPayout({ pub, send, settlement: opts.settlement, unit }, await lookupPayout([opts.serviceUrl], fetcher, epochPlusOne - 1n, opts.signer.publicKey, queuedTo), queuedTo, claimed);
356
+ const unqueued = () => (claimed.length > 0 ? { status: "sent", claimed, queued: null } : { status: "empty" });
301
357
  // The service proves balances only against a finalized epoch.
302
358
  if (finalized === 0n)
303
- return { status: "sent", claimed, queued: null };
359
+ return unqueued();
304
360
  const balance = await clearingBalance(opts.serviceUrl, opts.signer.publicKey, fetcher);
305
- if (BigInt(balance.amount) === 0n || !balance.opening)
361
+ if (BigInt(balance.amount) === 0n)
362
+ return unqueued();
363
+ if (!balance.opening)
306
364
  return { status: "sent", claimed, queued: null };
307
365
  const [deployment, policy, block] = await Promise.all([
308
366
  pub.readContract({ ...contract, functionName: "deployment" }),
@@ -313,14 +371,23 @@ export async function withdrawFromClearing(opts) {
313
371
  // The balance is proven against the finalized state: an epoch that
314
372
  // finalizes before the withdrawal lands moves it, and the contract
315
373
  // refuses the withdrawal with `InvalidBoundary`.
316
- const receipt = await send({ ...contract, functionName: "queueWithdrawal", args: [close.withdrawal, close.opening] }, 2000000n).catch((error) => {
317
- if (revertOf(error) !== "InvalidBoundary")
318
- throw error;
319
- throw new Error("the withdrawal was refused because an epoch finalized under it: call withdrawFromClearing again", { cause: error });
374
+ const moved = async () => (await pub.readContract({ ...contract, functionName: "finalizedState" })).toLowerCase() !== close.withdrawal.state.toLowerCase();
375
+ const receipt = await send({ ...contract, functionName: "queueWithdrawal", args: [close.withdrawal, close.opening] }, 2000000n).catch(async (error) => {
376
+ throw revertOf(error) === "InvalidBoundary" && (await moved()) ? finalizedUnder("queueWithdrawal", undefined, error) : error;
320
377
  });
378
+ if (receipt.status !== "success" && (await moved()))
379
+ throw finalizedUnder("queueWithdrawal", receipt.transactionHash);
321
380
  return { status: "sent", claimed, queued: { tx: succeeded("queueWithdrawal", receipt) } };
322
381
  }
323
382
  }
383
+ /** The calls that fault a deployment whose service has missed a deadline:
384
+ * to admit the epoch it registered, `account`'s queued withdrawal, or its
385
+ * deposit. Each reverts `InvalidDeadline` until its deadline passes. */
386
+ const faultCalls = (admitted, account) => [
387
+ { functionName: "faultRegistration", args: [] },
388
+ { functionName: "faultWithdrawal", args: [account] },
389
+ { functionName: "faultDeposit", args: [admitted, account] },
390
+ ];
324
391
  const revertOf = (error) => {
325
392
  if (!(error instanceof BaseError))
326
393
  return undefined;
@@ -371,12 +438,7 @@ export async function recoverFromClearing(opts) {
371
438
  let faulted = null;
372
439
  if ((await pub.readContract({ ...contract, functionName: "fault" })) === 0) {
373
440
  const admitted = await pub.readContract({ ...contract, functionName: "admittedCount" });
374
- const faults = [
375
- { functionName: "faultRegistration", args: [] },
376
- { functionName: "faultWithdrawal", args: [account] },
377
- { functionName: "faultDeposit", args: [admitted, account] },
378
- ];
379
- for (const call of faults) {
441
+ for (const call of faultCalls(admitted, account)) {
380
442
  try {
381
443
  faulted = succeeded(call.functionName, await send({ ...contract, ...call }, 200000n));
382
444
  break;
@@ -26,4 +26,13 @@ export declare function verifyClearingPayment(paid: ClearingPaid, requirements:
26
26
  id: string;
27
27
  status: "preconfirmed";
28
28
  }>;
29
- export declare function registerClearingClient(client: x402Client, network: `eip155:${number}`, payer: Pick<ReturnType<typeof createClearingClient>, "pay" | "payable" | "pendingInvoice">): x402Client;
29
+ /** Pay by clearing through `client` on `network`. A request with an offer on
30
+ * `network` but none the wallet can pay fails, unless `exactFallback` is
31
+ * true: then it is paid by another offer of the request on `network`,
32
+ * `exact` from its EVM account, and fails when it has none. A request with
33
+ * no offer on `network` is left to the
34
+ * client's other registrations. A payment the clearing service does not take
35
+ * throws a `ClearingRefusal`. */
36
+ export declare function registerClearingClient(client: x402Client, network: `eip155:${number}`, payer: Pick<ReturnType<typeof createClearingClient>, "pay" | "unpayable" | "pendingInvoice">, { exactFallback }?: {
37
+ exactFallback?: boolean;
38
+ }): x402Client;
@@ -1,6 +1,7 @@
1
- import { createPublicClient, http, sha256, stringToHex } from "viem";
1
+ import { createPublicClient, sha256, stringToHex } from "viem";
2
2
  import { clearingAbi } from "./clearing-abi.js";
3
3
  import { CLEARING_SCHEME, clearingUnits, decodeSend, invoiceMessage, verifyClearingSignature, verifyReceipt, outgoingRoot, } from "./clearing-codec.js";
4
+ import { rpcTransport } from "./networks.js";
4
5
  /** A resource-bound invoice namespace; the payer appends a unique payment id. */
5
6
  export function clearingRequirements(seller, amount, resource) {
6
7
  if (amount <= 0n)
@@ -80,7 +81,7 @@ export async function verifyClearingPayment(paid, requirements, config) {
80
81
  if (!clearingPaymentOf({ payload: paid }))
81
82
  throw new Error("invalid clearing payment");
82
83
  const send = decodeSend(paid.receipt.body);
83
- const client = createPublicClient({ transport: http(config.rpcUrl) });
84
+ const client = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
84
85
  const [context, fault, asset, chainId, unit] = await Promise.all([
85
86
  client.readContract({
86
87
  address: config.settlement,
@@ -169,14 +170,32 @@ export async function verifyClearingPayment(paid, requirements, config) {
169
170
  status: "preconfirmed",
170
171
  };
171
172
  }
172
- export function registerClearingClient(client, network, payer) {
173
+ /** Pay by clearing through `client` on `network`. A request with an offer on
174
+ * `network` but none the wallet can pay fails, unless `exactFallback` is
175
+ * true: then it is paid by another offer of the request on `network`,
176
+ * `exact` from its EVM account, and fails when it has none. A request with
177
+ * no offer on `network` is left to the
178
+ * client's other registrations. A payment the clearing service does not take
179
+ * throws a `ClearingRefusal`. */
180
+ export function registerClearingClient(client, network, payer, { exactFallback = false } = {}) {
181
+ // The URL each offer was asked for, kept with a payment whose outcome is
182
+ // unknown so a refusal names the request to send again.
183
+ const urls = new WeakMap();
173
184
  client.register(network, {
174
185
  scheme: CLEARING_SCHEME,
186
+ schemeHooks: {
187
+ async onBeforePaymentCreation({ paymentRequired, selectedRequirements }) {
188
+ urls.set(selectedRequirements, paymentRequired.resource.url);
189
+ },
190
+ },
175
191
  async createPaymentPayload(x402Version, requirements) {
176
192
  const pending = await payer.pendingInvoice();
177
- const suffix = globalThis.crypto.randomUUID();
178
- const invoice = clearingInvoice(requirements, pending?.id ?? `${requirements.extra?.invoicePrefix}:${suffix}`);
179
- const paid = await payer.pay(invoice);
193
+ // A payment held for another URL is the wallet's to refuse, naming it.
194
+ const prefix = `${requirements.extra?.invoicePrefix}:`;
195
+ const invoice = clearingInvoice(requirements, pending?.id.startsWith(prefix)
196
+ ? pending.id
197
+ : prefix + globalThis.crypto.randomUUID());
198
+ const paid = await payer.pay(invoice, urls.get(requirements));
180
199
  return {
181
200
  x402Version,
182
201
  payload: paid,
@@ -187,17 +206,44 @@ export function registerClearingClient(client, network, payer) {
187
206
  // its policies, and a clearing offer is `upfront`: so a payer set up for
188
207
  // clearing keeps the clearing offers its wallet can pay, when there are any.
189
208
  client.registerPolicy((_, offers) => {
190
- const clearing = offers.filter((offer) => {
191
- if (offer.scheme !== CLEARING_SCHEME || offer.network !== network)
192
- return false;
209
+ if (!offers.some((offer) => offer.network === network))
210
+ return offers;
211
+ const offered = offers.filter((offer) => offer.scheme === CLEARING_SCHEME && offer.network === network);
212
+ // Why the wallet does not pay `offer`, or undefined when it does.
213
+ const unpaid = (offer) => {
214
+ if (!/^[0-9]+$/.test(offer.amount) || BigInt(offer.amount) === 0n)
215
+ return { reason: `offer is priced at "${offer.amount}", not a positive whole number of base units`, aboveMaxAmount: false };
216
+ let invoice;
193
217
  try {
194
- return payer.payable(clearingInvoice(offer, `${offer.extra?.invoicePrefix}:`));
218
+ invoice = clearingInvoice(offer, `${offer.extra?.invoicePrefix}:`);
195
219
  }
196
220
  catch {
197
- return false;
221
+ return { reason: "offer is malformed (invalid clearing requirement)", aboveMaxAmount: false };
198
222
  }
199
- });
200
- return clearing.length > 0 ? clearing : offers;
223
+ return payer.unpayable(invoice);
224
+ };
225
+ const reasons = offered.map(unpaid);
226
+ const clearing = offered.filter((_, i) => reasons[i] === undefined);
227
+ if (clearing.length > 0)
228
+ return clearing;
229
+ if (exactFallback) {
230
+ // The offers this wallet just refused stay out: the stock selector can
231
+ // choose them again, since a clearing offer with no extra.paymentFlow
232
+ // is otherwise indistinguishable from one it can pay. The fallback is
233
+ // for this request's offers on `network`; with none left, it fails.
234
+ if (offers.some((offer) => offer.network === network && !offered.includes(offer)))
235
+ return offers.filter((offer) => !offered.includes(offer));
236
+ }
237
+ const why = offered.length > 0
238
+ ? `this clearing wallet pays none of the clearing offers here (${[...new Set(reasons.map((r) => r?.reason))].join("; ")})`
239
+ : `this request offers no clearing payment on ${network}`;
240
+ // An offer above the wallet's maxAmount is for its own deployment: the
241
+ // limit or the seller is what to change, not the scheme.
242
+ throw new Error(reasons.some((r) => r?.aboveMaxAmount)
243
+ ? `${why}; nothing was paid: raise the wallet's maxAmount or pay another seller`
244
+ : exactFallback
245
+ ? `${why}, and this request offers no other way to pay it; nothing was paid`
246
+ : `${why}, and paying by exact is off; nothing was paid: pass exactFallback: true to registerClearingClient to pay such a request by exact`);
201
247
  });
202
248
  return client;
203
249
  }
package/dist/client.d.ts CHANGED
@@ -31,7 +31,8 @@ export interface MetalPaymentClientConfig {
31
31
  fetch?: typeof globalThis.fetch;
32
32
  /**
33
33
  * The most one payment may cost, in `*USDZ` base units; `DEFAULT_MAX_PAYMENT`
34
- * by default. An offer above it is refused before anything is signed.
34
+ * by default. An offer above it is refused before anything is signed. A
35
+ * clearing payment is capped by its wallet's own `maxAmount` instead.
35
36
  */
36
37
  maxAmount?: bigint;
37
38
  }
@@ -47,7 +48,11 @@ export declare function allowAsset(client: x402Client, network: Network, asset:
47
48
  */
48
49
  export declare function keepPaymentOnOrigin(fetch: typeof globalThis.fetch): typeof globalThis.fetch;
49
50
  export interface MetalPaymentClient {
50
- /** `fetch` that answers a 402 by paying and retrying, once. */
51
+ /**
52
+ * `fetch` that answers a 402 by paying and retrying, once. When the
53
+ * clearing service does not take a payment, the error's `cause` is the
54
+ * `ClearingRefusal`.
55
+ */
51
56
  fetchWithPayment: ReturnType<typeof wrapFetchWithPayment>;
52
57
  client: x402Client;
53
58
  network: MetalNetwork;
package/dist/client.js CHANGED
@@ -1,6 +1,8 @@
1
- import { x402Client } from "@x402/core/client";
1
+ import { x402Client, x402HTTPClient } from "@x402/core/client";
2
2
  import { wrapFetchWithPayment } from "@x402/fetch";
3
3
  import { ExactEvmScheme } from "@x402/evm/exact/client";
4
+ import { ClearingRefusal } from "./clearing-client.js";
5
+ import { CLEARING_SCHEME } from "./clearing-codec.js";
4
6
  import { METAL_NETWORKS, USDZ, formatUsdz, usdzBaseUnits, resolveNetwork, } from "./networks.js";
5
7
  /**
6
8
  * Register Metal's devnet and testnet with an `@x402/core` client for the
@@ -81,17 +83,48 @@ export function createMetalPaymentClient({ account, network, fetch = globalThis.
81
83
  // The hooks are the guard: the client's own spend controls would refuse
82
84
  // first, naming nothing of the offer, so they stand aside on this client.
83
85
  client.setSpendControls({ allowedAssets: true });
84
- // @x402/fetch signs whatever amount an offer asks; this hook is the payer's spending guard.
85
- client.onBeforePaymentCreation(async ({ selectedRequirements: { amount } }) => {
86
- if (BigInt(amount) <= maxAmount)
86
+ // @x402/fetch signs whatever amount an offer asks; this hook is the payer's
87
+ // spending guard. A clearing wallet guards its own payments.
88
+ client.onBeforePaymentCreation(async ({ selectedRequirements: { scheme, amount } }) => {
89
+ if (scheme === CLEARING_SCHEME || BigInt(amount) <= maxAmount)
87
90
  return;
88
91
  return {
89
92
  abort: true,
90
93
  reason: `offer of ${formatUsdz(amount)} ${USDZ.symbol} is above the ${formatUsdz(maxAmount)} ${USDZ.symbol} limit per payment`,
91
94
  };
92
95
  });
96
+ const paid = keepPaymentOnOrigin(fetch);
93
97
  return {
94
- fetchWithPayment: wrapFetchWithPayment(keepPaymentOnOrigin(fetch), client),
98
+ // @x402/fetch rethrows a failure to pay as a new Error with the message
99
+ // alone; each request keeps its clearing refusal to give as the cause.
100
+ fetchWithPayment: async (input, init) => {
101
+ let refused;
102
+ // A clearing payment records the URL this request went to, as the
103
+ // fetch resolved it, for a refusal to name: behind a proxy, the
104
+ // seller's own name for the resource can differ from it. The seller
105
+ // receives its own name back.
106
+ let url;
107
+ const sending = (request, options) => {
108
+ url ??= request instanceof Request ? request.url : String(request);
109
+ return paid(request, options);
110
+ };
111
+ const paying = new x402HTTPClient(client);
112
+ paying.createPaymentPayload = (paymentRequired) => client
113
+ .createPaymentPayload({ ...paymentRequired, resource: { ...paymentRequired.resource, url: url ?? paymentRequired.resource.url } })
114
+ .then((payload) => ({ ...payload, resource: paymentRequired.resource }), (error) => {
115
+ if (error instanceof ClearingRefusal)
116
+ refused = error;
117
+ throw error;
118
+ });
119
+ try {
120
+ return await wrapFetchWithPayment(sending, paying)(input, init);
121
+ }
122
+ catch (error) {
123
+ if (!refused)
124
+ throw error;
125
+ throw new Error(`Failed to create payment payload: ${refused.message}`, { cause: refused });
126
+ }
127
+ },
95
128
  client,
96
129
  network: resolved,
97
130
  };
@@ -70,6 +70,9 @@ export declare const metalAccountAbi: readonly [{
70
70
  }, {
71
71
  readonly type: "uint256";
72
72
  readonly name: "nonce";
73
+ }, {
74
+ readonly type: "uint64";
75
+ readonly name: "validUntil";
73
76
  }, {
74
77
  readonly type: "bytes";
75
78
  readonly name: "signature";
@@ -84,7 +87,9 @@ export declare function assertMetalAccount(client: PublicClient, account: Addres
84
87
  /** The gas an executeSigned adds to the call it carries, by the quorum signature's signers. */
85
88
  export declare function quorumGas(signature: Hex): bigint;
86
89
  /** Self-calls approved by the account's existing ERC-7913 quorum, in one batch
87
- * that runs whole or not at all. The signing callback may collect mixed keys,
88
- * weights and passkeys; it returns the normal abi.encode(bytes[] signers,
89
- * bytes[] signatures) account signature. */
90
- export declare function authorizeMandateCall(client: PublicClient, factory: Address, call: MandateCall | readonly MandateCall[], signQuorum: (digest: Hex) => Promise<Hex>): Promise<MandateCall>;
90
+ * that runs whole or not at all. The account refuses the signed batch once the
91
+ * chain's block timestamp passes `validUntil` (unix seconds): pick a deadline
92
+ * just long enough to collect the signatures and submit. The signing callback
93
+ * may collect mixed keys, weights and passkeys; it returns the normal
94
+ * abi.encode(bytes[] signers, bytes[] signatures) account signature. */
95
+ export declare function authorizeMandateCall(client: PublicClient, factory: Address, call: MandateCall | readonly MandateCall[], validUntil: bigint, signQuorum: (digest: Hex) => Promise<Hex>): Promise<MandateCall>;
@@ -6,7 +6,7 @@ export const metalAccountFactoryAbi = parseAbi([
6
6
  ]);
7
7
  export const metalAccountAbi = parseAbi([
8
8
  "function nonces(address owner, uint192 key) view returns (uint256)",
9
- "function executeSigned(bytes32 mode, bytes executionData, uint256 nonce, bytes signature)",
9
+ "function executeSigned(bytes32 mode, bytes executionData, uint256 nonce, uint64 validUntil, bytes signature)",
10
10
  ]);
11
11
  export const ACCOUNT_BATCH_MODE = "0x0100000000000000000000000000000000000000000000000000000000000000";
12
12
  /** Exact immutable ERC-1167 clone emitted by MetalAccountFactory. */
@@ -56,10 +56,12 @@ export function quorumGas(signature) {
56
56
  return gas;
57
57
  }
58
58
  /** Self-calls approved by the account's existing ERC-7913 quorum, in one batch
59
- * that runs whole or not at all. The signing callback may collect mixed keys,
60
- * weights and passkeys; it returns the normal abi.encode(bytes[] signers,
61
- * bytes[] signatures) account signature. */
62
- export async function authorizeMandateCall(client, factory, call, signQuorum) {
59
+ * that runs whole or not at all. The account refuses the signed batch once the
60
+ * chain's block timestamp passes `validUntil` (unix seconds): pick a deadline
61
+ * just long enough to collect the signatures and submit. The signing callback
62
+ * may collect mixed keys, weights and passkeys; it returns the normal
63
+ * abi.encode(bytes[] signers, bytes[] signatures) account signature. */
64
+ export async function authorizeMandateCall(client, factory, call, validUntil, signQuorum) {
63
65
  const chainId = client.chain?.id;
64
66
  if (chainId === undefined)
65
67
  throw new Error("the RPC client names no chain");
@@ -71,9 +73,11 @@ export async function authorizeMandateCall(client, factory, call, signQuorum) {
71
73
  if (calls.some((c) => !isAddressEqual(c.to, account)))
72
74
  throw new Error("every call in one batch goes to the same account");
73
75
  await assertMetalAccount(client, account, factory);
74
- // A fresh 192-bit nonce lane per call: two administration calls in flight,
75
- // or one beside the owner's own operations on lane 0, never claim one nonce.
76
- const lane = hexToBigInt(bytesToHex(crypto.getRandomValues(new Uint8Array(24))));
76
+ // A fresh nonce lane per call: two administration calls in flight, or one
77
+ // beside the owner's own operations on lane 0, never claim one nonce. A
78
+ // quorum's lane keeps the key's top bit clear; set, it is a lane for
79
+ // signatures below the threshold, which the account refuses for the quorum.
80
+ const lane = hexToBigInt(bytesToHex(crypto.getRandomValues(new Uint8Array(24)))) >> 1n;
77
81
  const sequence = await client.readContract({ address: account, abi: metalAccountAbi, functionName: "nonces", args: [account, lane] });
78
82
  const nonce = (lane << 64n) | sequence;
79
83
  const executionData = encodeAbiParameters(parseAbiParameters("(address target, uint256 value, bytes callData)[]"), [calls.map((c) => ({ target: account, value: 0n, callData: c.data }))]);
@@ -81,11 +85,11 @@ export async function authorizeMandateCall(client, factory, call, signQuorum) {
81
85
  // it could have the quorum sign a batch of its own choosing.
82
86
  const digest = hashTypedData({
83
87
  domain: { name: "MetalAccount", version: "1", chainId, verifyingContract: account },
84
- types: { Execute: [{ name: "mode", type: "bytes32" }, { name: "executionData", type: "bytes" }, { name: "nonce", type: "uint256" }] },
88
+ types: { Execute: [{ name: "mode", type: "bytes32" }, { name: "executionData", type: "bytes" }, { name: "nonce", type: "uint256" }, { name: "validUntil", type: "uint64" }] },
85
89
  primaryType: "Execute",
86
- message: { mode: ACCOUNT_BATCH_MODE, executionData, nonce },
90
+ message: { mode: ACCOUNT_BATCH_MODE, executionData, nonce, validUntil },
87
91
  });
88
92
  const signature = await signQuorum(digest);
89
93
  const gas = calls.reduce((sum, c) => sum + c.gas, 0n) + quorumGas(signature);
90
- return { ...first, gas, data: encodeFunctionData({ abi: metalAccountAbi, functionName: "executeSigned", args: [ACCOUNT_BATCH_MODE, executionData, nonce, signature] }) };
94
+ return { ...first, gas, data: encodeFunctionData({ abi: metalAccountAbi, functionName: "executeSigned", args: [ACCOUNT_BATCH_MODE, executionData, nonce, validUntil, signature] }) };
91
95
  }
@@ -1,8 +1,8 @@
1
1
  // The seller's side of a signed order: a check, before the facilitator is
2
2
  // asked to verify, that a payment under a mandate carries the agent's signed
3
3
  // offer for this very offer, under the agent key the payer's account binds.
4
- import { createPublicClient, getAddress, http, isAddress, isHex, isAddressEqual } from "viem";
5
- import { accountFactoryFor, metalNetwork } from "../networks.js";
4
+ import { createPublicClient, getAddress, isAddress, isHex, isAddressEqual } from "viem";
5
+ import { accountFactoryFor, metalNetwork, rpcTransport } from "../networks.js";
6
6
  import { assertMetalAccount } from "./account.js";
7
7
  import { referencePrefix } from "./binding.js";
8
8
  import { decodeMandateBlob } from "./l3a.js";
@@ -20,7 +20,7 @@ import { asksSignedOffer, mandateRefOf, signedOfferOf } from "./extension.js";
20
20
  * route that does not ask is left to the facilitator.
21
21
  */
22
22
  export function requireSignedOffer(server, config) {
23
- const publicClient = "publicClient" in config ? config.publicClient : createPublicClient({ transport: http(config.rpcUrl) });
23
+ const publicClient = "publicClient" in config ? config.publicClient : createPublicClient({ transport: rpcTransport(config.rpcUrl) });
24
24
  const refuse = (reason) => ({ abort: true, reason });
25
25
  // x402 treats a hook that throws as one that passed, so a check that cannot
26
26
  // finish refuses the payment instead. The refusal reaches the payer, so it