@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.
- package/README.md +41 -14
- package/dist/clearing-client.d.ts +51 -4
- package/dist/clearing-client.js +277 -45
- package/dist/clearing-custody.d.ts +17 -6
- package/dist/clearing-custody.js +91 -29
- package/dist/clearing-x402.d.ts +10 -1
- package/dist/clearing-x402.js +59 -13
- package/dist/client.d.ts +7 -2
- package/dist/client.js +38 -5
- package/dist/mandates/account.d.ts +9 -4
- package/dist/mandates/account.js +15 -11
- package/dist/mandates/seller.js +3 -3
- package/dist/mandates/verify.js +107 -18
- package/dist/networks.d.ts +9 -1
- package/dist/networks.js +105 -4
- package/package.json +1 -1
- package/src/clearing-client.test.ts +850 -0
- package/src/clearing-client.ts +366 -57
- package/src/clearing-custody.test.ts +240 -11
- package/src/clearing-custody.ts +106 -28
- package/src/clearing-server.test.ts +72 -10
- package/src/clearing-x402.test.ts +1 -1
- package/src/clearing-x402.ts +61 -12
- package/src/client.test.ts +8 -0
- package/src/client.ts +46 -7
- package/src/mandates/account.ts +15 -11
- package/src/mandates/seller.ts +3 -3
- package/src/mandates/verify.ts +113 -20
- package/src/networks.test.ts +163 -2
- package/src/networks.ts +95 -5
package/dist/clearing-custody.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { BaseError, ContractFunctionRevertedError, concatHex, createPublicClient, createWalletClient, encodeFunctionData, erc20Abi, FeeCapTooHighError, FeeCapTooLowError,
|
|
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:
|
|
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 =
|
|
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
|
|
154
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
359
|
+
return unqueued();
|
|
304
360
|
const balance = await clearingBalance(opts.serviceUrl, opts.signer.publicKey, fetcher);
|
|
305
|
-
if (BigInt(balance.amount) === 0n
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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;
|
package/dist/clearing-x402.d.ts
CHANGED
|
@@ -26,4 +26,13 @@ export declare function verifyClearingPayment(paid: ClearingPaid, requirements:
|
|
|
26
26
|
id: string;
|
|
27
27
|
status: "preconfirmed";
|
|
28
28
|
}>;
|
|
29
|
-
|
|
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;
|
package/dist/clearing-x402.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createPublicClient,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
const
|
|
179
|
-
const
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
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>;
|
package/dist/mandates/account.js
CHANGED
|
@@ -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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
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
|
|
75
|
-
//
|
|
76
|
-
|
|
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
|
}
|
package/dist/mandates/seller.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|