@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
|
@@ -3,12 +3,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { paymentMiddleware } from "@x402/hono";
|
|
5
5
|
import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
|
|
6
|
-
import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentSignatureHeader } from "@x402/core/http";
|
|
6
|
+
import { decodePaymentRequiredHeader, decodePaymentResponseHeader, decodePaymentSignatureHeader, encodePaymentSignatureHeader } from "@x402/core/http";
|
|
7
7
|
import type { PaymentPayload, PaymentRequirements } from "@x402/core/types";
|
|
8
8
|
import { sha256, stringToHex } from "viem";
|
|
9
9
|
import { privateKeyToAccount } from "viem/accounts";
|
|
10
10
|
import { createClearingSigner, encodeSend, invoiceMessage, outgoingRoot, type ClearingInvoice } from "./clearing-codec.js";
|
|
11
|
-
import type
|
|
11
|
+
import { ClearingRefusal, type ClearingPaid } from "./clearing-client.js";
|
|
12
12
|
import { ClearingServerScheme } from "./clearing-server.js";
|
|
13
13
|
import { clearingPaymentMismatch, clearingPaymentOf, registerClearingClient } from "./clearing-x402.js";
|
|
14
14
|
import { createMetalPaymentClient } from "./client.js";
|
|
@@ -82,8 +82,8 @@ const fakePayer = {
|
|
|
82
82
|
async pendingInvoice() {
|
|
83
83
|
return null;
|
|
84
84
|
},
|
|
85
|
-
|
|
86
|
-
return
|
|
85
|
+
unpayable() {
|
|
86
|
+
return undefined;
|
|
87
87
|
},
|
|
88
88
|
async pay(invoice: ClearingInvoice): Promise<ClearingPaid> {
|
|
89
89
|
const entries = [{ recipient: invoice.recipient, cumulative: invoice.amount, count: "1" }];
|
|
@@ -260,16 +260,78 @@ describe("ClearingServerScheme under the stock middleware", () => {
|
|
|
260
260
|
expect(served).toBe(1);
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
it("pays with clearing when offered clearing and exact, in either order
|
|
263
|
+
it("pays with clearing when offered clearing and exact, in either order", async () => {
|
|
264
264
|
const accepts = await challenge("http://seller.local/chunk");
|
|
265
|
-
const chosen = async (
|
|
265
|
+
const chosen = async (offers: PaymentRequirements[]) => {
|
|
266
266
|
const { client } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet" });
|
|
267
|
-
registerClearingClient(client, NETWORK,
|
|
267
|
+
registerClearingClient(client, NETWORK, fakePayer);
|
|
268
268
|
return (await client.createPaymentPayload({ x402Version: 2, resource: { url: "http://seller.local/chunk" }, accepts: offers })).accepted.scheme;
|
|
269
269
|
};
|
|
270
|
-
expect(await chosen(
|
|
271
|
-
expect(await chosen(
|
|
272
|
-
|
|
270
|
+
expect(await chosen(accepts)).toBe("bajillion");
|
|
271
|
+
expect(await chosen([...accepts].reverse())).toBe("bajillion");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/** The Metal client over the seller, and the schemes of the payments it sent. */
|
|
275
|
+
const paying = (options: { maxAmount?: bigint } = {}) => {
|
|
276
|
+
const sent: string[] = [];
|
|
277
|
+
const { client, fetchWithPayment } = createMetalPaymentClient({
|
|
278
|
+
account: privateKeyToAccount(`0x${"45".repeat(32)}`),
|
|
279
|
+
network: "devnet",
|
|
280
|
+
...options,
|
|
281
|
+
fetch: async (input, init) => {
|
|
282
|
+
const request = new Request(input as RequestInfo, init);
|
|
283
|
+
const header = request.headers.get("payment-signature");
|
|
284
|
+
if (header) sent.push(decodePaymentSignatureHeader(header).accepted.scheme);
|
|
285
|
+
return app.request(request);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
return { client, fetchWithPayment, sent };
|
|
289
|
+
};
|
|
290
|
+
const unpayable = { ...fakePayer, unpayable: () => ({ reason: "offer is for deployment 0x1, not this wallet's 0x2", aboveMaxAmount: false }) };
|
|
291
|
+
|
|
292
|
+
it("pays a request it cannot pay by clearing by exact only with exactFallback: unset, the request fails and sends no payment", async () => {
|
|
293
|
+
const accepts = await challenge("http://seller.local/chunk");
|
|
294
|
+
const refused = paying();
|
|
295
|
+
registerClearingClient(refused.client, NETWORK, unpayable);
|
|
296
|
+
await expect(refused.fetchWithPayment("http://seller.local/chunk")).rejects.toThrow(
|
|
297
|
+
/pays none of the clearing offers here \(offer is for deployment 0x1, not this wallet's 0x2\), and paying by exact is off; nothing was paid: pass exactFallback: true/,
|
|
298
|
+
);
|
|
299
|
+
await expect(
|
|
300
|
+
refused.client.createPaymentPayload({ x402Version: 2, resource: { url: "http://seller.local/chunk" }, accepts: [accepts[1]!] }),
|
|
301
|
+
).rejects.toThrow(/offers no clearing payment on eip155:1337, and paying by exact is off/);
|
|
302
|
+
expect(refused.sent).toEqual([]);
|
|
303
|
+
const fallback = paying();
|
|
304
|
+
registerClearingClient(fallback.client, NETWORK, unpayable, { exactFallback: true });
|
|
305
|
+
await fallback.fetchWithPayment("http://seller.local/chunk");
|
|
306
|
+
expect(fallback.sent).toEqual(["exact"]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("caps exact payments, not clearing ones, by the payment client's maxAmount", async () => {
|
|
310
|
+
// k12-visual's outer limit of 0, set to block the exact fallback.
|
|
311
|
+
const clearing = paying({ maxAmount: 0n });
|
|
312
|
+
registerClearingClient(clearing.client, NETWORK, fakePayer);
|
|
313
|
+
expect((await clearing.fetchWithPayment("http://seller.local/chunk")).status).toBe(200);
|
|
314
|
+
expect(clearing.sent).toEqual(["bajillion"]);
|
|
315
|
+
const exact = paying({ maxAmount: 0n });
|
|
316
|
+
registerClearingClient(exact.client, NETWORK, unpayable, { exactFallback: true });
|
|
317
|
+
await expect(exact.fetchWithPayment("http://seller.local/chunk")).rejects.toThrow(/offer of 0.01 \*USDZ is above the 0 \*USDZ limit per payment/);
|
|
318
|
+
expect(exact.sent).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("fails a request whose clearing payment is refused with the refusal as its error's cause, sending no payment", async () => {
|
|
322
|
+
const refusal = new ClearingRefusal("between_epochs", { action: "retry_after_ms", ms: 1000 }, "clearing is between epochs");
|
|
323
|
+
const refused = paying();
|
|
324
|
+
registerClearingClient(refused.client, NETWORK, {
|
|
325
|
+
...fakePayer,
|
|
326
|
+
async pay() {
|
|
327
|
+
throw refusal;
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
const error = await refused.fetchWithPayment("http://seller.local/chunk").catch((e: unknown) => e);
|
|
331
|
+
expect(error).toBeInstanceOf(Error);
|
|
332
|
+
expect((error as Error).message).toBe("Failed to create payment payload: clearing is between epochs");
|
|
333
|
+
expect((error as Error).cause).toBe(refusal);
|
|
334
|
+
expect(refused.sent).toEqual([]);
|
|
273
335
|
});
|
|
274
336
|
});
|
|
275
337
|
|
package/src/clearing-x402.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PaymentRequirements, PaymentPayload } from "@x402/core/types";
|
|
2
2
|
import type { x402Client } from "@x402/core/client";
|
|
3
|
-
import { createPublicClient,
|
|
3
|
+
import { createPublicClient, sha256, stringToHex, type Hex } from "viem";
|
|
4
4
|
import { clearingAbi } from "./clearing-abi.js";
|
|
5
5
|
import {
|
|
6
6
|
CLEARING_SCHEME,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ClearingInvoice,
|
|
14
14
|
} from "./clearing-codec.js";
|
|
15
15
|
import type { ClearingPaid, createClearingClient } from "./clearing-client.js";
|
|
16
|
+
import { rpcTransport } from "./networks.js";
|
|
16
17
|
|
|
17
18
|
export interface ClearingSeller {
|
|
18
19
|
network: `eip155:${number}`;
|
|
@@ -129,7 +130,7 @@ export async function verifyClearingPayment(
|
|
|
129
130
|
)
|
|
130
131
|
throw new Error("invalid clearing payment");
|
|
131
132
|
const send = decodeSend(paid.receipt.body);
|
|
132
|
-
const client = createPublicClient({ transport:
|
|
133
|
+
const client = createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
133
134
|
const [context, fault, asset, chainId, unit] = await Promise.all([
|
|
134
135
|
client.readContract({
|
|
135
136
|
address: config.settlement,
|
|
@@ -237,24 +238,43 @@ export async function verifyClearingPayment(
|
|
|
237
238
|
status: "preconfirmed" as const,
|
|
238
239
|
};
|
|
239
240
|
}
|
|
241
|
+
/** Pay by clearing through `client` on `network`. A request with an offer on
|
|
242
|
+
* `network` but none the wallet can pay fails, unless `exactFallback` is
|
|
243
|
+
* true: then it is paid by another offer of the request on `network`,
|
|
244
|
+
* `exact` from its EVM account, and fails when it has none. A request with
|
|
245
|
+
* no offer on `network` is left to the
|
|
246
|
+
* client's other registrations. A payment the clearing service does not take
|
|
247
|
+
* throws a `ClearingRefusal`. */
|
|
240
248
|
export function registerClearingClient(
|
|
241
249
|
client: x402Client,
|
|
242
250
|
network: `eip155:${number}`,
|
|
243
251
|
payer: Pick<
|
|
244
252
|
ReturnType<typeof createClearingClient>,
|
|
245
|
-
"pay" | "
|
|
253
|
+
"pay" | "unpayable" | "pendingInvoice"
|
|
246
254
|
>,
|
|
255
|
+
{ exactFallback = false }: { exactFallback?: boolean } = {},
|
|
247
256
|
) {
|
|
257
|
+
// The URL each offer was asked for, kept with a payment whose outcome is
|
|
258
|
+
// unknown so a refusal names the request to send again.
|
|
259
|
+
const urls = new WeakMap<PaymentRequirements, string>();
|
|
248
260
|
client.register(network, {
|
|
249
261
|
scheme: CLEARING_SCHEME,
|
|
262
|
+
schemeHooks: {
|
|
263
|
+
async onBeforePaymentCreation({ paymentRequired, selectedRequirements }) {
|
|
264
|
+
urls.set(selectedRequirements, paymentRequired.resource.url);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
250
267
|
async createPaymentPayload(x402Version, requirements) {
|
|
251
268
|
const pending = await payer.pendingInvoice();
|
|
252
|
-
|
|
269
|
+
// A payment held for another URL is the wallet's to refuse, naming it.
|
|
270
|
+
const prefix = `${requirements.extra?.invoicePrefix}:`;
|
|
253
271
|
const invoice = clearingInvoice(
|
|
254
272
|
requirements,
|
|
255
|
-
pending?.id
|
|
273
|
+
pending?.id.startsWith(prefix)
|
|
274
|
+
? pending.id
|
|
275
|
+
: prefix + globalThis.crypto.randomUUID(),
|
|
256
276
|
);
|
|
257
|
-
const paid = await payer.pay(invoice);
|
|
277
|
+
const paid = await payer.pay(invoice, urls.get(requirements));
|
|
258
278
|
return {
|
|
259
279
|
x402Version,
|
|
260
280
|
payload: paid as unknown as Record<string, unknown>,
|
|
@@ -265,15 +285,44 @@ export function registerClearingClient(
|
|
|
265
285
|
// its policies, and a clearing offer is `upfront`: so a payer set up for
|
|
266
286
|
// clearing keeps the clearing offers its wallet can pay, when there are any.
|
|
267
287
|
client.registerPolicy((_, offers) => {
|
|
268
|
-
|
|
269
|
-
|
|
288
|
+
if (!offers.some((offer) => offer.network === network)) return offers;
|
|
289
|
+
const offered = offers.filter((offer) => offer.scheme === CLEARING_SCHEME && offer.network === network);
|
|
290
|
+
// Why the wallet does not pay `offer`, or undefined when it does.
|
|
291
|
+
const unpaid = (offer: PaymentRequirements) => {
|
|
292
|
+
if (!/^[0-9]+$/.test(offer.amount) || BigInt(offer.amount) === 0n)
|
|
293
|
+
return { reason: `offer is priced at "${offer.amount}", not a positive whole number of base units`, aboveMaxAmount: false };
|
|
294
|
+
let invoice: ClearingInvoice;
|
|
270
295
|
try {
|
|
271
|
-
|
|
296
|
+
invoice = clearingInvoice(offer, `${offer.extra?.invoicePrefix}:`);
|
|
272
297
|
} catch {
|
|
273
|
-
return false;
|
|
298
|
+
return { reason: "offer is malformed (invalid clearing requirement)", aboveMaxAmount: false };
|
|
274
299
|
}
|
|
275
|
-
|
|
276
|
-
|
|
300
|
+
return payer.unpayable(invoice);
|
|
301
|
+
};
|
|
302
|
+
const reasons = offered.map(unpaid);
|
|
303
|
+
const clearing = offered.filter((_, i) => reasons[i] === undefined);
|
|
304
|
+
if (clearing.length > 0) return clearing;
|
|
305
|
+
if (exactFallback) {
|
|
306
|
+
// The offers this wallet just refused stay out: the stock selector can
|
|
307
|
+
// choose them again, since a clearing offer with no extra.paymentFlow
|
|
308
|
+
// is otherwise indistinguishable from one it can pay. The fallback is
|
|
309
|
+
// for this request's offers on `network`; with none left, it fails.
|
|
310
|
+
if (offers.some((offer) => offer.network === network && !offered.includes(offer)))
|
|
311
|
+
return offers.filter((offer) => !offered.includes(offer));
|
|
312
|
+
}
|
|
313
|
+
const why =
|
|
314
|
+
offered.length > 0
|
|
315
|
+
? `this clearing wallet pays none of the clearing offers here (${[...new Set(reasons.map((r) => r?.reason))].join("; ")})`
|
|
316
|
+
: `this request offers no clearing payment on ${network}`;
|
|
317
|
+
// An offer above the wallet's maxAmount is for its own deployment: the
|
|
318
|
+
// limit or the seller is what to change, not the scheme.
|
|
319
|
+
throw new Error(
|
|
320
|
+
reasons.some((r) => r?.aboveMaxAmount)
|
|
321
|
+
? `${why}; nothing was paid: raise the wallet's maxAmount or pay another seller`
|
|
322
|
+
: exactFallback
|
|
323
|
+
? `${why}, and this request offers no other way to pay it; nothing was paid`
|
|
324
|
+
: `${why}, and paying by exact is off; nothing was paid: pass exactFallback: true to registerClearingClient to pay such a request by exact`,
|
|
325
|
+
);
|
|
277
326
|
});
|
|
278
327
|
return client;
|
|
279
328
|
}
|
package/src/client.test.ts
CHANGED
|
@@ -318,6 +318,14 @@ describe("usdz amounts", () => {
|
|
|
318
318
|
expect(formatUsdz("2000000000000000000")).toBe("2");
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
it("formats a negative amount, as clearingSpendable can read, with one leading sign", () => {
|
|
322
|
+
expect(formatUsdz(-100000000000000000n)).toBe("-0.1");
|
|
323
|
+
expect(formatUsdz(-1500000000000000000n)).toBe("-1.5");
|
|
324
|
+
expect(formatUsdz(-1n)).toBe("-0.000000000000000001");
|
|
325
|
+
expect(formatUsdz("-2000000000000000000")).toBe("-2");
|
|
326
|
+
expect(formatUsdz(0n)).toBe("0");
|
|
327
|
+
});
|
|
328
|
+
|
|
321
329
|
it("rejects malformed and over-precise amounts", () => {
|
|
322
330
|
expect(() => usdzBaseUnits("1e3")).toThrow();
|
|
323
331
|
expect(() => usdzBaseUnits("0." + "1".repeat(19))).toThrow(/decimals/);
|
package/src/client.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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 type { ClientEvmSigner } from "@x402/evm";
|
|
4
4
|
import type { Network } from "@x402/core/types";
|
|
5
5
|
import { ExactEvmScheme } from "@x402/evm/exact/client";
|
|
6
|
+
import { ClearingRefusal } from "./clearing-client.js";
|
|
7
|
+
import { CLEARING_SCHEME } from "./clearing-codec.js";
|
|
6
8
|
import {
|
|
7
9
|
METAL_NETWORKS,
|
|
8
10
|
USDZ,
|
|
@@ -76,7 +78,8 @@ export interface MetalPaymentClientConfig {
|
|
|
76
78
|
fetch?: typeof globalThis.fetch;
|
|
77
79
|
/**
|
|
78
80
|
* The most one payment may cost, in `*USDZ` base units; `DEFAULT_MAX_PAYMENT`
|
|
79
|
-
* by default. An offer above it is refused before anything is signed.
|
|
81
|
+
* by default. An offer above it is refused before anything is signed. A
|
|
82
|
+
* clearing payment is capped by its wallet's own `maxAmount` instead.
|
|
80
83
|
*/
|
|
81
84
|
maxAmount?: bigint;
|
|
82
85
|
}
|
|
@@ -117,7 +120,11 @@ export function keepPaymentOnOrigin(fetch: typeof globalThis.fetch): typeof glob
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
export interface MetalPaymentClient {
|
|
120
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* `fetch` that answers a 402 by paying and retrying, once. When the
|
|
125
|
+
* clearing service does not take a payment, the error's `cause` is the
|
|
126
|
+
* `ClearingRefusal`.
|
|
127
|
+
*/
|
|
121
128
|
fetchWithPayment: ReturnType<typeof wrapFetchWithPayment>;
|
|
122
129
|
client: x402Client;
|
|
123
130
|
network: MetalNetwork;
|
|
@@ -138,18 +145,50 @@ export function createMetalPaymentClient({
|
|
|
138
145
|
// The hooks are the guard: the client's own spend controls would refuse
|
|
139
146
|
// first, naming nothing of the offer, so they stand aside on this client.
|
|
140
147
|
client.setSpendControls({ allowedAssets: true });
|
|
141
|
-
// @x402/fetch signs whatever amount an offer asks; this hook is the payer's
|
|
148
|
+
// @x402/fetch signs whatever amount an offer asks; this hook is the payer's
|
|
149
|
+
// spending guard. A clearing wallet guards its own payments.
|
|
142
150
|
client.onBeforePaymentCreation(
|
|
143
|
-
async ({ selectedRequirements: { amount } }) => {
|
|
144
|
-
if (BigInt(amount) <= maxAmount) return;
|
|
151
|
+
async ({ selectedRequirements: { scheme, amount } }) => {
|
|
152
|
+
if (scheme === CLEARING_SCHEME || BigInt(amount) <= maxAmount) return;
|
|
145
153
|
return {
|
|
146
154
|
abort: true,
|
|
147
155
|
reason: `offer of ${formatUsdz(amount)} ${USDZ.symbol} is above the ${formatUsdz(maxAmount)} ${USDZ.symbol} limit per payment`,
|
|
148
156
|
};
|
|
149
157
|
},
|
|
150
158
|
);
|
|
159
|
+
const paid = keepPaymentOnOrigin(fetch);
|
|
151
160
|
return {
|
|
152
|
-
|
|
161
|
+
// @x402/fetch rethrows a failure to pay as a new Error with the message
|
|
162
|
+
// alone; each request keeps its clearing refusal to give as the cause.
|
|
163
|
+
fetchWithPayment: async (input, init) => {
|
|
164
|
+
let refused: ClearingRefusal | undefined;
|
|
165
|
+
// A clearing payment records the URL this request went to, as the
|
|
166
|
+
// fetch resolved it, for a refusal to name: behind a proxy, the
|
|
167
|
+
// seller's own name for the resource can differ from it. The seller
|
|
168
|
+
// receives its own name back.
|
|
169
|
+
let url: string | undefined;
|
|
170
|
+
const sending: typeof globalThis.fetch = (request, options) => {
|
|
171
|
+
url ??= request instanceof Request ? request.url : String(request);
|
|
172
|
+
return paid(request, options);
|
|
173
|
+
};
|
|
174
|
+
const paying = new x402HTTPClient(client);
|
|
175
|
+
paying.createPaymentPayload = (paymentRequired) =>
|
|
176
|
+
client
|
|
177
|
+
.createPaymentPayload({ ...paymentRequired, resource: { ...paymentRequired.resource, url: url ?? paymentRequired.resource.url } })
|
|
178
|
+
.then(
|
|
179
|
+
(payload) => ({ ...payload, resource: paymentRequired.resource }),
|
|
180
|
+
(error: unknown) => {
|
|
181
|
+
if (error instanceof ClearingRefusal) refused = error;
|
|
182
|
+
throw error;
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
try {
|
|
186
|
+
return await wrapFetchWithPayment(sending, paying)(input, init);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!refused) throw error;
|
|
189
|
+
throw new Error(`Failed to create payment payload: ${refused.message}`, { cause: refused });
|
|
190
|
+
}
|
|
191
|
+
},
|
|
153
192
|
client,
|
|
154
193
|
network: resolved,
|
|
155
194
|
};
|
package/src/mandates/account.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const metalAccountFactoryAbi = parseAbi([
|
|
|
8
8
|
]);
|
|
9
9
|
export const metalAccountAbi = parseAbi([
|
|
10
10
|
"function nonces(address owner, uint192 key) view returns (uint256)",
|
|
11
|
-
"function executeSigned(bytes32 mode, bytes executionData, uint256 nonce, bytes signature)",
|
|
11
|
+
"function executeSigned(bytes32 mode, bytes executionData, uint256 nonce, uint64 validUntil, bytes signature)",
|
|
12
12
|
]);
|
|
13
13
|
export const ACCOUNT_BATCH_MODE = "0x0100000000000000000000000000000000000000000000000000000000000000";
|
|
14
14
|
|
|
@@ -59,10 +59,12 @@ export function quorumGas(signature: Hex): bigint {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** Self-calls approved by the account's existing ERC-7913 quorum, in one batch
|
|
62
|
-
* that runs whole or not at all. The
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
62
|
+
* that runs whole or not at all. The account refuses the signed batch once the
|
|
63
|
+
* chain's block timestamp passes `validUntil` (unix seconds): pick a deadline
|
|
64
|
+
* just long enough to collect the signatures and submit. The signing callback
|
|
65
|
+
* may collect mixed keys, weights and passkeys; it returns the normal
|
|
66
|
+
* abi.encode(bytes[] signers, bytes[] signatures) account signature. */
|
|
67
|
+
export async function authorizeMandateCall(client: PublicClient, factory: Address, call: MandateCall | readonly MandateCall[], validUntil: bigint, signQuorum: (digest: Hex) => Promise<Hex>): Promise<MandateCall> {
|
|
66
68
|
const chainId = client.chain?.id;
|
|
67
69
|
if (chainId === undefined) throw new Error("the RPC client names no chain");
|
|
68
70
|
const calls = Array.isArray(call) ? (call as readonly MandateCall[]) : [call as MandateCall];
|
|
@@ -71,9 +73,11 @@ export async function authorizeMandateCall(client: PublicClient, factory: Addres
|
|
|
71
73
|
const account = first.to;
|
|
72
74
|
if (calls.some((c) => !isAddressEqual(c.to, account))) 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: PublicClient, factory: Addres
|
|
|
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/src/mandates/seller.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// offer for this very offer, under the agent key the payer's account binds.
|
|
4
4
|
|
|
5
5
|
import type { x402ResourceServer } from "@x402/core/server";
|
|
6
|
-
import { type Address, type Hex, type PublicClient, createPublicClient, getAddress,
|
|
7
|
-
import { accountFactoryFor, metalNetwork } from "../networks.js";
|
|
6
|
+
import { type Address, type Hex, type PublicClient, createPublicClient, getAddress, isAddress, isHex, isAddressEqual } from "viem";
|
|
7
|
+
import { accountFactoryFor, metalNetwork, rpcTransport } from "../networks.js";
|
|
8
8
|
import { assertMetalAccount } from "./account.js";
|
|
9
9
|
import { referencePrefix } from "./binding.js";
|
|
10
10
|
import { decodeMandateBlob } from "./l3a.js";
|
|
@@ -32,7 +32,7 @@ export type RequireSignedOfferConfig = { accountFactory?: Address } & ({ publicC
|
|
|
32
32
|
* route that does not ask is left to the facilitator.
|
|
33
33
|
*/
|
|
34
34
|
export function requireSignedOffer(server: x402ResourceServer, config: RequireSignedOfferConfig): x402ResourceServer {
|
|
35
|
-
const publicClient = "publicClient" in config ? config.publicClient : createPublicClient({ transport:
|
|
35
|
+
const publicClient = "publicClient" in config ? config.publicClient : createPublicClient({ transport: rpcTransport(config.rpcUrl) });
|
|
36
36
|
const refuse = (reason: string) => ({ abort: true as const, reason });
|
|
37
37
|
// x402 treats a hook that throws as one that passed, so a check that cannot
|
|
38
38
|
// finish refuses the payment instead. The refusal reaches the payer, so it
|