@metalntwx/metal-pay 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +72 -33
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +49 -159
  5. package/dist/clearing-client.js +191 -564
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +191 -0
  9. package/dist/clearing-custody.js +490 -0
  10. package/dist/clearing-server.d.ts +30 -49
  11. package/dist/clearing-server.js +87 -67
  12. package/dist/clearing-x402.d.ts +29 -0
  13. package/dist/clearing-x402.js +203 -0
  14. package/dist/client.d.ts +7 -13
  15. package/dist/client.js +16 -18
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/binding.d.ts +4 -27
  19. package/dist/mandates/binding.js +5 -9
  20. package/dist/mandates/checkout.js +5 -5
  21. package/dist/mandates/client.js +4 -3
  22. package/dist/mandates/l3a.d.ts +2 -2
  23. package/dist/mandates/l3a.js +5 -4
  24. package/dist/mandates/seller.d.ts +1 -1
  25. package/dist/mandates/seller.js +1 -1
  26. package/dist/mandates/verify.d.ts +4 -6
  27. package/dist/mandates/verify.js +42 -66
  28. package/dist/mandates/vi/credential.js +4 -4
  29. package/dist/node.d.ts +11 -0
  30. package/dist/node.js +97 -0
  31. package/package.json +18 -11
  32. package/src/clearing-abi.ts +2361 -0
  33. package/src/clearing-client.ts +303 -0
  34. package/src/clearing-codec.test.ts +57 -0
  35. package/src/clearing-codec.ts +251 -0
  36. package/src/clearing-custody.test.ts +534 -0
  37. package/src/clearing-custody.ts +665 -0
  38. package/src/clearing-fixture.json +37 -0
  39. package/src/clearing-server.test.ts +388 -0
  40. package/src/clearing-server.ts +123 -0
  41. package/src/clearing-x402.test.ts +181 -0
  42. package/src/clearing-x402.ts +279 -0
  43. package/src/client.test.ts +325 -0
  44. package/src/client.ts +156 -0
  45. package/src/index.ts +33 -0
  46. package/src/mandates/account.ts +91 -0
  47. package/src/mandates/binding.ts +280 -0
  48. package/src/mandates/chain.ts +68 -0
  49. package/src/mandates/checkout.ts +368 -0
  50. package/src/mandates/client.ts +159 -0
  51. package/src/mandates/extension.ts +48 -0
  52. package/src/mandates/index.ts +121 -0
  53. package/src/mandates/ingest.ts +42 -0
  54. package/src/mandates/jcs.ts +16 -0
  55. package/src/mandates/l3a.ts +216 -0
  56. package/src/mandates/seller.ts +91 -0
  57. package/src/mandates/verify.ts +532 -0
  58. package/src/mandates/vi/credential.ts +477 -0
  59. package/src/mandates/vi/issue.ts +118 -0
  60. package/src/mandates/vi/sd-jwt.ts +189 -0
  61. package/src/networks.test.ts +16 -0
  62. package/src/networks.ts +152 -0
  63. package/src/node.test.ts +24 -0
  64. package/src/node.ts +96 -0
  65. package/src/server.test.ts +223 -0
  66. package/src/server.ts +77 -0
  67. package/dist/clearing.d.ts +0 -204
  68. package/dist/clearing.js +0 -151
  69. package/dist/file-storage.d.ts +0 -39
  70. package/dist/file-storage.js +0 -195
@@ -0,0 +1,325 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { x402Client } from "@x402/core/client";
3
+ import type { PaymentRequired, SchemeNetworkClient } from "@x402/core/types";
4
+ import { getAddress, verifyTypedData } from "viem";
5
+ import { privateKeyToAccount } from "viem/accounts";
6
+ import { buildTransferAuthorizationTypedData } from "@metalntwx/metal-ts/erc3009";
7
+ import {
8
+ DEFAULT_MAX_PAYMENT,
9
+ createMetalPaymentClient,
10
+ registerMetalNetworks,
11
+ } from "./client.js";
12
+ import { METAL_NETWORKS, USDZ, formatUsdz, usdzBaseUnits } from "./networks.js";
13
+
14
+ const payer = privateKeyToAccount(
15
+ "0x963db81757846cd99b9b9a49274b334da9ad1384f2b41cae53730986da94d039",
16
+ );
17
+ const PAY_TO = "0x000000000000000000000000000000000000b22b";
18
+
19
+ const OTHER_TOKEN = "0x000000000000000000000000000000000000c0de";
20
+
21
+ function offer(
22
+ network: string,
23
+ amount = usdzBaseUnits("0.01"),
24
+ token: { asset: string; extra: { name: string; version: string } } = {
25
+ asset: USDZ.address,
26
+ extra: { name: USDZ.name, version: USDZ.version },
27
+ },
28
+ ): PaymentRequired {
29
+ return {
30
+ x402Version: 2,
31
+ resource: {
32
+ url: "http://seller.local/quote",
33
+ description: "a quote",
34
+ mimeType: "application/json",
35
+ },
36
+ accepts: [
37
+ {
38
+ scheme: "exact",
39
+ network: network as `${string}:${string}`,
40
+ asset: token.asset,
41
+ amount,
42
+ payTo: PAY_TO,
43
+ maxTimeoutSeconds: 60,
44
+ extra: token.extra,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+
50
+ describe("registerMetalNetworks", () => {
51
+ // A stock client pays only assets in its own table; *USDZ is not in it.
52
+ const allowing = () => new x402Client().setSpendControls({ allowedAssets: true });
53
+
54
+ it("leaves the spend controls the payer set on its client", async () => {
55
+ const client = registerMetalNetworks(
56
+ new x402Client().setSpendControls({
57
+ allowedAssets: [{ network: "eip155:1337", asset: USDZ.address, maxAmountPerPayment: "5" }],
58
+ }),
59
+ { signer: payer },
60
+ );
61
+ await expect(client.createPaymentPayload(offer("eip155:1337"))).rejects.toThrow(/maxAmountPerPayment/);
62
+ });
63
+
64
+ it("signs an eip155:1337 offer as the ERC-3009 authorization MIP20 verifies", async () => {
65
+ const client = registerMetalNetworks(allowing(), { signer: payer });
66
+ const payload = await client.createPaymentPayload(offer("eip155:1337"));
67
+ expect(payload.accepted.network).toBe("eip155:1337");
68
+ const { authorization, signature } = payload.payload as {
69
+ authorization: {
70
+ from: `0x${string}`;
71
+ to: `0x${string}`;
72
+ value: string;
73
+ validAfter: string;
74
+ validBefore: string;
75
+ nonce: `0x${string}`;
76
+ };
77
+ signature: `0x${string}`;
78
+ };
79
+ expect(getAddress(authorization.from)).toBe(payer.address);
80
+ expect(getAddress(authorization.to)).toBe(getAddress(PAY_TO));
81
+ expect(authorization.value).toBe(usdzBaseUnits("0.01"));
82
+
83
+ const typedData = buildTransferAuthorizationTypedData(
84
+ { tokenName: USDZ.name, chainId: 1337n, verifyingContract: USDZ.address },
85
+ {
86
+ from: authorization.from,
87
+ to: authorization.to,
88
+ value: BigInt(authorization.value),
89
+ validAfter: BigInt(authorization.validAfter),
90
+ validBefore: BigInt(authorization.validBefore),
91
+ nonce: authorization.nonce,
92
+ },
93
+ );
94
+ const ok = await verifyTypedData({
95
+ address: payer.address,
96
+ domain: typedData.domain,
97
+ types: {
98
+ TransferWithAuthorization: typedData.types.TransferWithAuthorization,
99
+ },
100
+ primaryType: "TransferWithAuthorization",
101
+ message: typedData.message,
102
+ signature,
103
+ });
104
+ expect(ok).toBe(true);
105
+ });
106
+
107
+ it("registers testnet too", async () => {
108
+ const client = registerMetalNetworks(allowing(), { signer: payer });
109
+ const payload = await client.createPaymentPayload(offer("eip155:63825"));
110
+ expect(payload.accepted.network).toBe(METAL_NETWORKS.testnet.network);
111
+ });
112
+
113
+ it("refuses an offer in any token but *USDZ before anything is signed", async () => {
114
+ const signTypedData = vi.fn(payer.signTypedData);
115
+ const client = registerMetalNetworks(allowing(), {
116
+ signer: { address: payer.address, signTypedData },
117
+ });
118
+ const other = {
119
+ asset: OTHER_TOKEN,
120
+ extra: { name: "Other USD", version: "1" },
121
+ };
122
+ await expect(
123
+ client.createPaymentPayload(offer("eip155:1337", "1", other)),
124
+ ).rejects.toThrow(
125
+ `Payment creation aborted: offer is in ${OTHER_TOKEN}, not *USDZ (${USDZ.address})`,
126
+ );
127
+ expect(signTypedData).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("refuses a *USDZ offer under any signing domain but USDZ's before anything is signed", async () => {
131
+ const signTypedData = vi.fn(payer.signTypedData);
132
+ const client = registerMetalNetworks(allowing(), {
133
+ signer: { address: payer.address, signTypedData },
134
+ });
135
+ const renamed = {
136
+ asset: USDZ.address,
137
+ extra: { name: "Other USD", version: "2" },
138
+ };
139
+ await expect(
140
+ client.createPaymentPayload(offer("eip155:1337", "1", renamed)),
141
+ ).rejects.toThrow(
142
+ 'Payment creation aborted: offer signs for the EIP-712 domain "Other USD" version "2", not "USD Zero" version "1"',
143
+ );
144
+ expect(signTypedData).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('refuses a testnet offer signing for the pre-rename "Metal USD" domain before anything is signed', async () => {
148
+ const signTypedData = vi.fn(payer.signTypedData);
149
+ const client = registerMetalNetworks(allowing(), {
150
+ signer: { address: payer.address, signTypedData },
151
+ });
152
+ const legacy = {
153
+ asset: USDZ.address,
154
+ extra: { name: "Metal USD", version: "1" },
155
+ };
156
+ await expect(
157
+ client.createPaymentPayload(offer("eip155:63825", "1", legacy)),
158
+ ).rejects.toThrow(
159
+ 'offer signs for the EIP-712 domain "Metal USD" version "1", not "USD Zero" version "1"',
160
+ );
161
+ expect(signTypedData).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("leaves offers in other schemes, such as a custom scheme, to the scheme registered for them", async () => {
165
+ const custom: SchemeNetworkClient = {
166
+ scheme: "custom-payment",
167
+ createPaymentPayload: async (x402Version) => ({
168
+ x402Version,
169
+ payload: { debit: "signed" },
170
+ }),
171
+ };
172
+ const client = registerMetalNetworks(allowing(), {
173
+ signer: payer,
174
+ }).register("eip155:1337", custom);
175
+ const customDomain = {
176
+ asset: USDZ.address,
177
+ extra: { name: "CustomPayment", version: "1" },
178
+ };
179
+ const debit = offer("eip155:1337", "1", customDomain);
180
+ debit.accepts[0] = { ...debit.accepts[0]!, scheme: "custom-payment" };
181
+ const payload = await client.createPaymentPayload(debit);
182
+ expect(payload.payload).toEqual({ debit: "signed" });
183
+ await expect(
184
+ client.createPaymentPayload(offer("eip155:1337", "1", customDomain)),
185
+ ).rejects.toThrow(
186
+ 'offer signs for the EIP-712 domain "CustomPayment" version "1"',
187
+ );
188
+ });
189
+
190
+ it("accepts the *USDZ address in any letter case", async () => {
191
+ const client = registerMetalNetworks(allowing(), { signer: payer });
192
+ const lower = {
193
+ asset: USDZ.address.toLowerCase(),
194
+ extra: { name: USDZ.name, version: USDZ.version },
195
+ };
196
+ const payload = await client.createPaymentPayload(
197
+ offer("eip155:1337", "1", lower),
198
+ );
199
+ expect(payload.accepted.asset).toBe(lower.asset);
200
+ });
201
+ });
202
+
203
+ describe("createMetalPaymentClient", () => {
204
+ it("pays only on the network it was created for", async () => {
205
+ const { client, network } = createMetalPaymentClient({
206
+ account: payer,
207
+ network: "devnet",
208
+ });
209
+ expect(network.chainId).toBe(1337);
210
+ await expect(
211
+ client.createPaymentPayload(offer("eip155:63825")),
212
+ ).rejects.toThrow();
213
+ const payload = await client.createPaymentPayload(offer("eip155:1337"));
214
+ expect(payload.accepted.network).toBe("eip155:1337");
215
+ });
216
+
217
+ it("refuses an offer above the limit per payment before anything is signed", async () => {
218
+ const signTypedData = vi.fn(payer.signTypedData);
219
+ const { client } = createMetalPaymentClient({
220
+ account: { address: payer.address, signTypedData },
221
+ network: "devnet",
222
+ });
223
+ await expect(
224
+ client.createPaymentPayload(offer("eip155:1337", usdzBaseUnits("2"))),
225
+ ).rejects.toThrow(
226
+ "Payment creation aborted: offer of 2 *USDZ is above the 1 *USDZ limit per payment",
227
+ );
228
+ expect(signTypedData).not.toHaveBeenCalled();
229
+ });
230
+
231
+ it("pays an offer equal to the limit", async () => {
232
+ const signTypedData = vi.fn(payer.signTypedData);
233
+ const { client } = createMetalPaymentClient({
234
+ account: { address: payer.address, signTypedData },
235
+ network: "devnet",
236
+ });
237
+ const payload = await client.createPaymentPayload(
238
+ offer("eip155:1337", usdzBaseUnits("1")),
239
+ );
240
+ expect(payload.accepted.amount).toBe(usdzBaseUnits("1"));
241
+ expect(signTypedData).toHaveBeenCalledOnce();
242
+ });
243
+
244
+ it("applies a custom maxAmount", async () => {
245
+ const strict = createMetalPaymentClient({
246
+ account: payer,
247
+ network: "devnet",
248
+ maxAmount: BigInt(usdzBaseUnits("0.005")),
249
+ });
250
+ await expect(
251
+ strict.client.createPaymentPayload(offer("eip155:1337")),
252
+ ).rejects.toThrow(
253
+ "offer of 0.01 *USDZ is above the 0.005 *USDZ limit per payment",
254
+ );
255
+ const generous = createMetalPaymentClient({
256
+ account: payer,
257
+ network: "devnet",
258
+ maxAmount: BigInt(usdzBaseUnits("5")),
259
+ });
260
+ const payload = await generous.client.createPaymentPayload(
261
+ offer("eip155:1337", usdzBaseUnits("2")),
262
+ );
263
+ expect(payload.accepted.amount).toBe(usdzBaseUnits("2"));
264
+ });
265
+
266
+ it("refuses an offer in another token before checking its amount", async () => {
267
+ const { client } = createMetalPaymentClient({
268
+ account: payer,
269
+ network: "devnet",
270
+ });
271
+ const other = {
272
+ asset: OTHER_TOKEN,
273
+ extra: { name: "Other USD", version: "1" },
274
+ };
275
+ await expect(
276
+ client.createPaymentPayload(
277
+ offer("eip155:1337", usdzBaseUnits("2"), other),
278
+ ),
279
+ ).rejects.toThrow(`offer is in ${OTHER_TOKEN}, not *USDZ`);
280
+ });
281
+
282
+ it("limits each payment to 1 *USDZ by default", () => {
283
+ expect(DEFAULT_MAX_PAYMENT).toBe(10n ** 18n);
284
+ });
285
+
286
+ it("refuses an unknown network name", () => {
287
+ expect(() =>
288
+ createMetalPaymentClient({ account: payer, network: "mainnet" as never }),
289
+ ).toThrow(/unknown Metal network/);
290
+ });
291
+
292
+ it("keeps its own refusals by standing the client's spend controls aside", () => {
293
+ const prototype = x402Client.prototype;
294
+ const original = prototype.setSpendControls;
295
+ const setSpendControls = vi.fn(function (this: x402Client) {
296
+ return this;
297
+ });
298
+ prototype.setSpendControls = setSpendControls as typeof original;
299
+ try {
300
+ createMetalPaymentClient({
301
+ account: payer,
302
+ network: "testnet",
303
+ maxAmount: BigInt(usdzBaseUnits("0.05")),
304
+ });
305
+ } finally {
306
+ prototype.setSpendControls = original;
307
+ }
308
+ expect(setSpendControls).toHaveBeenLastCalledWith({ allowedAssets: true });
309
+ });
310
+ });
311
+
312
+ describe("usdz amounts", () => {
313
+ it("converts decimals to 18-digit base units and back", () => {
314
+ expect(usdzBaseUnits("0.01")).toBe("10000000000000000");
315
+ expect(usdzBaseUnits("$1.50")).toBe("1500000000000000000");
316
+ expect(usdzBaseUnits(2)).toBe("2000000000000000000");
317
+ expect(formatUsdz(10000000000000000n)).toBe("0.01");
318
+ expect(formatUsdz("2000000000000000000")).toBe("2");
319
+ });
320
+
321
+ it("rejects malformed and over-precise amounts", () => {
322
+ expect(() => usdzBaseUnits("1e3")).toThrow();
323
+ expect(() => usdzBaseUnits("0." + "1".repeat(19))).toThrow(/decimals/);
324
+ });
325
+ });
package/src/client.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { x402Client } from "@x402/core/client";
2
+ import { wrapFetchWithPayment } from "@x402/fetch";
3
+ import type { ClientEvmSigner } from "@x402/evm";
4
+ import type { Network } from "@x402/core/types";
5
+ import { ExactEvmScheme } from "@x402/evm/exact/client";
6
+ import {
7
+ METAL_NETWORKS,
8
+ USDZ,
9
+ formatUsdz,
10
+ usdzBaseUnits,
11
+ resolveNetwork,
12
+ type MetalNetwork,
13
+ type MetalNetworkName,
14
+ } from "./networks.js";
15
+
16
+ /**
17
+ * What signs a payment: a viem local account (`privateKeyToAccount`) or
18
+ * anything with an address and EIP-712 `signTypedData`.
19
+ */
20
+ export type MetalPaymentSigner = Pick<
21
+ ClientEvmSigner,
22
+ "address" | "signTypedData"
23
+ >;
24
+
25
+ export interface RegisterMetalNetworksConfig {
26
+ signer: MetalPaymentSigner;
27
+ /** Which Metal networks to accept offers on; both by default. */
28
+ networks?: readonly (MetalNetworkName | MetalNetwork)[];
29
+ }
30
+
31
+ /**
32
+ * Register Metal's devnet and testnet with an `@x402/core` client for the
33
+ * `exact` scheme. The client signs an `exact` offer on `eip155:1337` or
34
+ * `eip155:63825` only when it is in `*USDZ` under USDZ's EIP-712 domain, and
35
+ * refuses any other before signing. Offers in other schemes are left to
36
+ * the schemes explicitly registered for them.
37
+ */
38
+ export function registerMetalNetworks(
39
+ client: x402Client,
40
+ { signer, networks }: RegisterMetalNetworksConfig,
41
+ ): x402Client {
42
+ const scheme = new ExactEvmScheme(signer);
43
+ const registered = (networks ?? Object.values(METAL_NETWORKS)).map(
44
+ (entry) => resolveNetwork(entry).network,
45
+ );
46
+ for (const network of registered) client.register(network, scheme);
47
+ // ExactEvmScheme signs for whatever token an offer names, and that token redeems the signature without the facilitator.
48
+ client.onBeforePaymentCreation(
49
+ async ({ selectedRequirements: { scheme, network, asset, extra } }) => {
50
+ if (scheme !== "exact" || !registered.includes(network)) return;
51
+ if (asset.toLowerCase() !== USDZ.address.toLowerCase()) {
52
+ return {
53
+ abort: true,
54
+ reason: `offer is in ${asset}, not ${USDZ.symbol} (${USDZ.address})`,
55
+ };
56
+ }
57
+ if (extra?.name !== USDZ.name || extra?.version !== USDZ.version) {
58
+ return {
59
+ abort: true,
60
+ reason: `offer signs for the EIP-712 domain "${extra?.name}" version "${extra?.version}", not "${USDZ.name}" version "${USDZ.version}"`,
61
+ };
62
+ }
63
+ },
64
+ );
65
+ return client;
66
+ }
67
+
68
+ /** The most a payment client pays for one offer unless told otherwise: 1 `*USDZ`, in base units. */
69
+ export const DEFAULT_MAX_PAYMENT = BigInt(usdzBaseUnits("1"));
70
+
71
+ export interface MetalPaymentClientConfig {
72
+ account: MetalPaymentSigner;
73
+ /** The one network this client pays on; offers for any other are refused. */
74
+ network: MetalNetworkName | MetalNetwork;
75
+ /** The fetch to wrap; `globalThis.fetch` by default. */
76
+ fetch?: typeof globalThis.fetch;
77
+ /**
78
+ * 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.
80
+ */
81
+ maxAmount?: bigint;
82
+ }
83
+
84
+ /**
85
+ * The stock client pays only assets in its own table unless its spend
86
+ * controls list others. This lists `asset` on `network`.
87
+ */
88
+ export function allowAsset(client: x402Client, network: Network, asset: string): void {
89
+ client.setSpendControls({ allowedAssets: [{ network, asset }] });
90
+ }
91
+
92
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
93
+
94
+ /**
95
+ * `fetch` that follows the redirects of a request carrying a payment only
96
+ * within the origin it was made for, and answers a redirect to any other
97
+ * origin as it is, so no other origin receives the payment.
98
+ */
99
+ export function keepPaymentOnOrigin(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
100
+ return async (input, init) => {
101
+ const request = new Request(input, init);
102
+ if (!request.headers.has("PAYMENT-SIGNATURE") && !request.headers.has("X-PAYMENT")) return fetch(request);
103
+ const body = request.body === null ? undefined : await request.arrayBuffer();
104
+ let url = new URL(request.url);
105
+ let method = request.method;
106
+ for (let hops = 0; ; hops++) {
107
+ const sendsBody = method !== "GET" && method !== "HEAD";
108
+ const response = await fetch(new Request(url, { method, headers: request.headers, ...(sendsBody && body !== undefined && { body }), signal: request.signal, redirect: "manual" }));
109
+ const location = response.headers.get("location");
110
+ if (!REDIRECTS.has(response.status) || location === null || hops === 20) return response;
111
+ const next = new URL(location, url);
112
+ if (next.origin !== url.origin) return response;
113
+ if (response.status === 303 || (response.status <= 302 && method === "POST")) method = "GET";
114
+ url = next;
115
+ }
116
+ };
117
+ }
118
+
119
+ export interface MetalPaymentClient {
120
+ /** `fetch` that answers a 402 by paying and retrying, once. */
121
+ fetchWithPayment: ReturnType<typeof wrapFetchWithPayment>;
122
+ client: x402Client;
123
+ network: MetalNetwork;
124
+ }
125
+
126
+ /** One call to pay: `const { fetchWithPayment } = createMetalPaymentClient({ account, network: "devnet" })`. */
127
+ export function createMetalPaymentClient({
128
+ account,
129
+ network,
130
+ fetch = globalThis.fetch,
131
+ maxAmount = DEFAULT_MAX_PAYMENT,
132
+ }: MetalPaymentClientConfig): MetalPaymentClient {
133
+ const resolved = resolveNetwork(network);
134
+ const client = registerMetalNetworks(new x402Client(), {
135
+ signer: account,
136
+ networks: [resolved],
137
+ });
138
+ // The hooks are the guard: the client's own spend controls would refuse
139
+ // first, naming nothing of the offer, so they stand aside on this client.
140
+ client.setSpendControls({ allowedAssets: true });
141
+ // @x402/fetch signs whatever amount an offer asks; this hook is the payer's spending guard.
142
+ client.onBeforePaymentCreation(
143
+ async ({ selectedRequirements: { amount } }) => {
144
+ if (BigInt(amount) <= maxAmount) return;
145
+ return {
146
+ abort: true,
147
+ reason: `offer of ${formatUsdz(amount)} ${USDZ.symbol} is above the ${formatUsdz(maxAmount)} ${USDZ.symbol} limit per payment`,
148
+ };
149
+ },
150
+ );
151
+ return {
152
+ fetchWithPayment: wrapFetchWithPayment(keepPaymentOnOrigin(fetch), client),
153
+ client,
154
+ network: resolved,
155
+ };
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ export {
2
+ METAL_NETWORKS,
3
+ USDZ,
4
+ formatUsdz,
5
+ metalNetwork,
6
+ usdzBaseUnits,
7
+ accountFactoryFor,
8
+ rpcUrlFor,
9
+ type DollarToken,
10
+ type MetalNetwork,
11
+ type MetalNetworkName,
12
+ } from "./networks.js";
13
+ export {
14
+ DEFAULT_MAX_PAYMENT,
15
+ createMetalPaymentClient,
16
+ registerMetalNetworks,
17
+ type MetalPaymentClient,
18
+ type MetalPaymentClientConfig,
19
+ type MetalPaymentSigner,
20
+ type RegisterMetalNetworksConfig,
21
+ } from "./client.js";
22
+ export {
23
+ metalUsd,
24
+ registerMetalScheme,
25
+ sellerToken,
26
+ type RegisterMetalSchemeConfig,
27
+ } from "./server.js";
28
+ export * from "./clearing-codec.js";
29
+ export { clearingAbi } from "./clearing-abi.js";
30
+ export * from "./clearing-client.js";
31
+ export * from "./clearing-x402.js";
32
+ export * from "./clearing-server.js";
33
+ export * from "./clearing-custody.js";
@@ -0,0 +1,91 @@
1
+ import { type Address, type Hex, type PublicClient, concat, decodeAbiParameters, encodeAbiParameters, encodeFunctionData, bytesToHex, hashTypedData, hexToBigInt, isAddressEqual, parseAbi, parseAbiParameters, size } from "viem";
2
+ import type { MandateCall } from "./binding.js";
3
+
4
+ export const metalAccountFactoryAbi = parseAbi([
5
+ "function implementation() view returns (address)",
6
+ "function createAccount(bytes[] signers, uint64[] weights, uint64 threshold) returns (address)",
7
+ "function predictAccount(bytes[] signers, uint64[] weights, uint64 threshold) view returns (address)",
8
+ ]);
9
+ export const metalAccountAbi = parseAbi([
10
+ "function nonces(address owner, uint192 key) view returns (uint256)",
11
+ "function executeSigned(bytes32 mode, bytes executionData, uint256 nonce, bytes signature)",
12
+ ]);
13
+ export const ACCOUNT_BATCH_MODE = "0x0100000000000000000000000000000000000000000000000000000000000000";
14
+
15
+ /** Exact immutable ERC-1167 clone emitted by MetalAccountFactory. */
16
+ export function metalAccountCode(implementation: Address): Hex {
17
+ return concat(["0x363d3d373d3d3d363d73", implementation, "0x5af43d82803e903d91602b57fd5bf3"]).toLowerCase() as Hex;
18
+ }
19
+
20
+ /** The factory is a caller-selected trust anchor, never inferred from payer-controlled data. */
21
+ export async function assertMetalAccount(client: PublicClient, account: Address, factory: Address, blockNumber?: bigint): Promise<void> {
22
+ const at = blockNumber === undefined ? {} : { blockNumber };
23
+ const implementation = await client.readContract({ address: factory, abi: metalAccountFactoryAbi, functionName: "implementation", ...at });
24
+ const code = await client.getCode({ address: account, ...at });
25
+ const when = blockNumber === undefined ? "" : ` at block ${blockNumber}`;
26
+ if (code === undefined || code === "0x") throw new Error(`${account} has no code${when}: no account is deployed there`);
27
+ if (code.toLowerCase() !== metalAccountCode(implementation)) throw new Error(`${account} is not a MetalAccount clone of the trusted factory ${factory}`);
28
+ }
29
+
30
+ /** The account's own execution, with a quorum of keys. */
31
+ const EXECUTION_GAS = 500_000n;
32
+ /**
33
+ * What one signature beyond an EOA's 65-byte ECDSA costs the account to
34
+ * verify, per started 512 bytes of it: a P-256 key or a passkey runs the
35
+ * P-256 precompile (441,600 gas on Metal), and a nested account's signature
36
+ * may carry several. A 1-of-1 passkey account's executeSigned of a token
37
+ * transfer measured 620,302 gas.
38
+ */
39
+ const SIGNATURE_GAS = 700_000n;
40
+
41
+ /** The gas an executeSigned adds to the call it carries, by the quorum signature's signers. */
42
+ export function quorumGas(signature: Hex): bigint {
43
+ const perStarted512 = (bytes: bigint) => SIGNATURE_GAS * ((bytes + 511n) / 512n || 1n);
44
+ let signers: readonly Hex[];
45
+ let signatures: readonly Hex[];
46
+ try {
47
+ [signers, signatures] = decodeAbiParameters(parseAbiParameters("bytes[], bytes[]"), signature);
48
+ } catch {
49
+ // Not the (signers, signatures) form the account reads: size it by its length.
50
+ return EXECUTION_GAS + perStarted512(BigInt(size(signature)));
51
+ }
52
+ let gas = EXECUTION_GAS;
53
+ signers.forEach((signer, i) => {
54
+ const bytes = BigInt(size(signatures[i] ?? "0x"));
55
+ if (size(signer) === 20 && bytes === 65n) return;
56
+ gas += perStarted512(bytes);
57
+ });
58
+ return gas;
59
+ }
60
+
61
+ /** Self-calls approved by the account's existing ERC-7913 quorum, in one batch
62
+ * that runs whole or not at all. The signing callback may collect mixed keys,
63
+ * weights and passkeys; it returns the normal abi.encode(bytes[] signers,
64
+ * bytes[] signatures) account signature. */
65
+ export async function authorizeMandateCall(client: PublicClient, factory: Address, call: MandateCall | readonly MandateCall[], signQuorum: (digest: Hex) => Promise<Hex>): Promise<MandateCall> {
66
+ const chainId = client.chain?.id;
67
+ if (chainId === undefined) throw new Error("the RPC client names no chain");
68
+ const calls = Array.isArray(call) ? (call as readonly MandateCall[]) : [call as MandateCall];
69
+ const [first] = calls;
70
+ if (first === undefined) throw new Error("no call to authorize");
71
+ const account = first.to;
72
+ if (calls.some((c) => !isAddressEqual(c.to, account))) throw new Error("every call in one batch goes to the same account");
73
+ 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))));
77
+ const sequence = await client.readContract({ address: account, abi: metalAccountAbi, functionName: "nonces", args: [account, lane] });
78
+ const nonce = (lane << 64n) | sequence;
79
+ const executionData = encodeAbiParameters(parseAbiParameters("(address target, uint256 value, bytes callData)[]"), [calls.map((c) => ({ target: account, value: 0n, callData: c.data }))]);
80
+ // The account's executeDigest, computed here: an RPC endpoint that answered
81
+ // it could have the quorum sign a batch of its own choosing.
82
+ const digest = hashTypedData({
83
+ domain: { name: "MetalAccount", version: "1", chainId, verifyingContract: account },
84
+ types: { Execute: [{ name: "mode", type: "bytes32" }, { name: "executionData", type: "bytes" }, { name: "nonce", type: "uint256" }] },
85
+ primaryType: "Execute",
86
+ message: { mode: ACCOUNT_BATCH_MODE, executionData, nonce },
87
+ });
88
+ const signature = await signQuorum(digest);
89
+ 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] }) };
91
+ }