@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.
@@ -0,0 +1,850 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import { x402Client } from "@x402/core/client";
4
+ import { encodePaymentRequiredHeader } from "@x402/core/http";
5
+ import type { PaymentRequirements } from "@x402/core/types";
6
+ import { decodeFunctionData, encodeFunctionResult, hexToBytes, toHex, type Hex } from "viem";
7
+ import { privateKeyToAccount } from "viem/accounts";
8
+ import { clearingAbi } from "./clearing-abi.js";
9
+ import { p256 } from "@noble/curves/p256";
10
+ import { CLEARING_REFERENCE, clearingSignatureDigest, createClearingSigner, encodeSend } from "./clearing-codec.js";
11
+ import { ClearingRefusal, createClearingClient, type ClearingClientState, type ClearingPaid, type ClearingPayment } from "./clearing-client.js";
12
+ import { clearingInvoice, clearingRequirements, registerClearingClient } from "./clearing-x402.js";
13
+ import { createMetalPaymentClient } from "./client.js";
14
+
15
+ const NETWORK = "eip155:1337";
16
+ const SETTLEMENT = `0x${"22".repeat(20)}` as const;
17
+ const ASSET = `0x${"33".repeat(20)}` as const;
18
+ const ANCHOR = `0x${"aa".repeat(32)}` as const;
19
+ const ZERO32 = `0x${"00".repeat(32)}` as const;
20
+ const SERVICE_URL = "http://clearing.test";
21
+ /** The contract's clearing unit: an 18-decimal token's, a millionth of it. */
22
+ const UNIT = 10n ** 12n;
23
+ const payer = createClearingSigner(`0x${"12".repeat(32)}`);
24
+ const seller = createClearingSigner(`0x${"23".repeat(32)}`);
25
+ const operator = createClearingSigner(`0x${"34".repeat(32)}`);
26
+ const CHUNK = "http://seller.test/chunk/1";
27
+ const OTHER = "http://seller.test/chunk/2";
28
+
29
+ /** The Clearing contract's epoch count, the service's epoch 1 admitted, its fault, none, the chain's id, and whether the contract is there, unless set otherwise. */
30
+ const chain = { admitted: 1n, fault: 0, id: 1337, deployed: true };
31
+ const head = { root: ZERO32, operations: 0n, floor: 0n };
32
+ const views: Record<string, () => unknown> = {
33
+ registered: () => true,
34
+ fault: () => chain.fault,
35
+ admittedCount: () => chain.admitted,
36
+ unit: () => UNIT,
37
+ operatorKey: () => operator.publicKey,
38
+ contextOf: () => ({
39
+ anchor: ANCHOR,
40
+ epoch: 1n,
41
+ operatorKey: operator.publicKey,
42
+ deployment: ZERO32,
43
+ deposits: ZERO32,
44
+ withdrawals: ZERO32,
45
+ liability: 0n,
46
+ admissionDeadline: 100n,
47
+ challengeDeadline: 200n,
48
+ limits: { states: 0n, rows: 0n, withdrawals: 0n, accountEntries: 0n, totalEntries: 0n, paymentTotal: 0n, depositTotal: 0n, withdrawalTotal: 0n },
49
+ committee: ZERO32,
50
+ predecessor: ZERO32,
51
+ activity: head,
52
+ payouts: head,
53
+ activityFloor: 0n,
54
+ payoutFloor: 0n,
55
+ }),
56
+ };
57
+ const rpc = createServer((req, res) => {
58
+ let body = "";
59
+ req.on("data", (c) => (body += c));
60
+ req.on("end", () => {
61
+ const { id, method, params } = JSON.parse(body);
62
+ const ok = (result: unknown) => res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
63
+ res.setHeader("content-type", "application/json");
64
+ if (method === "eth_chainId") return ok(`0x${chain.id.toString(16)}`);
65
+ if (method === "eth_getBlockByNumber")
66
+ return ok({
67
+ number: "0xa", hash: ZERO32, parentHash: ZERO32, timestamp: "0x1", baseFeePerGas: "0x7", gasLimit: "0x1", gasUsed: "0x0",
68
+ transactions: [], logsBloom: `0x${"00".repeat(256)}`, miner: `0x${"00".repeat(20)}`, extraData: "0x", difficulty: "0x0",
69
+ nonce: "0x0000000000000000", sha3Uncles: ZERO32, stateRoot: ZERO32, receiptsRoot: ZERO32, transactionsRoot: ZERO32, size: "0x1", uncles: [],
70
+ });
71
+ if (!chain.deployed) return ok("0x");
72
+ const { functionName } = decodeFunctionData({ abi: clearingAbi, data: params[0].data ?? params[0].input });
73
+ ok(encodeFunctionResult({ abi: clearingAbi, functionName, result: views[functionName]!() } as never));
74
+ });
75
+ });
76
+ let rpcUrl: string;
77
+ beforeAll(async () => {
78
+ rpcUrl = await listen(rpc);
79
+ });
80
+ afterAll(() => new Promise<void>((resolve) => rpc.close(() => resolve())));
81
+
82
+ /** The service's answers, as the builders saw them. */
83
+ const open = (epoch = "1") =>
84
+ Response.json({ reference: CLEARING_REFERENCE, network: NETWORK, settlement: SETTLEMENT, asset: ASSET, operator: operator.publicKey, epoch, anchor: ANCHOR });
85
+ const betweenEpochs = () => Response.json({ error: "clearing request unavailable" }, { status: 409 });
86
+ const rejected = () => Response.json({ error: "clearing request unavailable", rejected: true }, { status: 409 });
87
+ /** The service's estimate of what the account can spend, in clearing units. */
88
+ const spendable = (amount: bigint) => () => Response.json({ amount: amount.toString() });
89
+ const busy = () => Response.json({ error: "busy" }, { status: 503 });
90
+ const lost = (): Response => {
91
+ throw new TypeError("fetch failed");
92
+ };
93
+ const accepted = (payment: ClearingPayment) =>
94
+ Response.json({
95
+ invoice: payment.invoice,
96
+ receipt: { body: payment.body, payerSignature: payment.payerSignature, operatorSignature: operator.sign("_COMMONWARE_CLEARING_VECTOR_ACK", payment.body) },
97
+ invoiceSignature: payment.invoiceSignature,
98
+ entries: payment.entries,
99
+ });
100
+
101
+ /** A wallet over a clearing service that answers `/context`, `/spendable`, `/receipt` and each `POST /payments` in turn, `open`, nothing spendable, no receipt known (404) and `accepted` once its list runs out. */
102
+ function wallet(
103
+ answers: { context?: (() => Response)[]; spendable?: (() => Response)[]; receipt?: (() => Response)[]; payments?: ((payment: ClearingPayment) => Response)[] } = {},
104
+ maxAmount = 1000n * UNIT,
105
+ ) {
106
+ let state: ClearingClientState | null = null;
107
+ const posted: ClearingPayment[] = [];
108
+ const asked: string[] = [];
109
+ const client = createClearingClient({
110
+ serviceUrl: SERVICE_URL,
111
+ rpcUrl,
112
+ network: NETWORK,
113
+ settlement: SETTLEMENT,
114
+ asset: ASSET,
115
+ signer: payer,
116
+ maxAmount,
117
+ store: {
118
+ async load() {
119
+ return structuredClone(state);
120
+ },
121
+ async save(value) {
122
+ state = structuredClone(value);
123
+ },
124
+ },
125
+ fetch: async (input, init) => {
126
+ const path = new URL(String(input)).pathname;
127
+ asked.push(new URL(String(input)).pathname + new URL(String(input)).search);
128
+ if (path === "/context") return (answers.context?.shift() ?? open)();
129
+ if (path === `/spendable/${payer.publicKey}`) return (answers.spendable?.shift() ?? spendable(0n))();
130
+ if (path.startsWith(`/receipt/${payer.publicKey}/`)) return (answers.receipt?.shift() ?? (() => new Response(null, { status: 404 })))();
131
+ if (path === "/payments") {
132
+ const payment = JSON.parse(String(init?.body)) as ClearingPayment;
133
+ posted.push(payment);
134
+ return (answers.payments?.shift() ?? accepted)(payment);
135
+ }
136
+ return new Response(null, { status: 404 });
137
+ },
138
+ });
139
+ return { client, posted, asked, state: () => state };
140
+ }
141
+ const offer = (url: string, amount = 5n * UNIT) =>
142
+ clearingRequirements({ network: NETWORK, asset: ASSET, settlement: SETTLEMENT, recipient: seller.publicKey, serviceUrl: SERVICE_URL }, amount, url);
143
+ const invoiceFor = (url: string, amount = 5n * UNIT) => clearingInvoice(offer(url, amount), `${offer(url, amount).extra!.invoicePrefix}:${url.at(-1)}`);
144
+ const refusal = (paying: Promise<unknown>) =>
145
+ paying.then(
146
+ () => expect.unreachable("the payment went through"),
147
+ (error: unknown) => {
148
+ expect(error).toBeInstanceOf(ClearingRefusal);
149
+ return error as ClearingRefusal;
150
+ },
151
+ );
152
+
153
+ describe("clearing refusals", () => {
154
+ it("between epochs: /context answers 409 `clearing request unavailable`; nothing is held and a later request pays", async () => {
155
+ const { client, posted, state } = wallet({ context: [betweenEpochs] });
156
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
157
+ expect(error.reason).toBe("between_epochs");
158
+ // The service registers an epoch 2 s after the last one's admission, then a block includes it.
159
+ expect(error.retry).toEqual({ action: "retry_after_ms", ms: 3000 });
160
+ expect(error.message).toMatch(/between epochs/);
161
+ expect(error.message).toMatch(/a few seconds/);
162
+ expect(error.message).toMatch(/every epoch/);
163
+ expect(error.message).toMatch(/after 3000 ms, and again after each further between_epochs refusal/);
164
+ // A validator's URL as serviceUrl, or a service that is down, answers the same for good.
165
+ expect(error.message).toMatch(/if it lasts longer than an epoch, check that serviceUrl is the facilitator's extra\.serviceUrl \(a validator takes no payments\), or the service is down/);
166
+ expect(error.message).toMatch(/nothing was paid/);
167
+ expect(posted).toHaveLength(0);
168
+ expect(state()?.pending).toBeUndefined();
169
+ await client.pay(invoiceFor(OTHER), OTHER);
170
+ });
171
+
172
+ it("between epochs: the contract has not admitted the epoch the service opened", async () => {
173
+ chain.admitted = 0n;
174
+ try {
175
+ const { client, posted } = wallet();
176
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
177
+ expect(error).toMatchObject({ reason: "between_epochs", retry: { action: "retry_after_ms" } });
178
+ expect(posted).toHaveLength(0);
179
+ } finally {
180
+ chain.admitted = 1n;
181
+ }
182
+ });
183
+
184
+ it("between epochs: a payment the service refuses for good because its epoch closed as it was sent", async () => {
185
+ const { client, state } = wallet({ context: [open, betweenEpochs], payments: [rejected] });
186
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
187
+ expect(error).toMatchObject({ reason: "between_epochs", retry: { action: "retry_after_ms", ms: 3000 } });
188
+ expect(state()?.pending).toBeUndefined();
189
+ });
190
+
191
+ it("between epochs: a refused payment whose epoch the service has moved past", async () => {
192
+ const { client } = wallet({ context: [open, () => open("2")], payments: [rejected] });
193
+ expect((await refusal(client.pay(invoiceFor(CHUNK), CHUNK))).reason).toBe("between_epochs");
194
+ });
195
+
196
+ it("a faulted deployment: /context answers 409 `clearing request unavailable` for good, and the refusal names recoverFromClearing", async () => {
197
+ const faulted = (error: Error) => {
198
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
199
+ expect(error.message).toMatch(/faulted/);
200
+ expect(error.message).toMatch(/nothing was paid/);
201
+ expect(error.message).toMatch(/recoverFromClearing/);
202
+ };
203
+ const failed = (paying: Promise<unknown>) => paying.then(() => expect.unreachable("the payment went through"), (e: Error) => e);
204
+ // A payment held from before the fault, sent again: the service rejects it, and the re-read refuses.
205
+ const held = wallet({ context: [open, betweenEpochs], payments: [lost, rejected] });
206
+ await refusal(held.client.pay(invoiceFor(CHUNK), CHUNK));
207
+ chain.fault = 1;
208
+ try {
209
+ faulted(await failed(held.client.pay(invoiceFor(CHUNK), CHUNK)));
210
+ expect(held.state()?.pending).toBeUndefined();
211
+ // A new payment: /context refuses first, or the service has yet to see the fault.
212
+ for (const answers of [{ context: [betweenEpochs] }, {}]) {
213
+ const { client, posted } = wallet(answers);
214
+ faulted(await failed(client.pay(invoiceFor(CHUNK), CHUNK)));
215
+ expect(posted).toHaveLength(0);
216
+ }
217
+ } finally {
218
+ chain.fault = 0;
219
+ }
220
+ });
221
+
222
+ it("insufficient deposit: the service refuses the payment for good while its epoch is open and the account cannot spend its price", async () => {
223
+ const { client, state } = wallet({ payments: [rejected], spendable: [spendable(4n)] });
224
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
225
+ expect(error.reason).toBe("insufficient_deposit");
226
+ expect(error.retry).toEqual({ action: "deposit" });
227
+ expect(error.message).toMatch(/deposit does not cover/);
228
+ expect(error.message).toMatch(/nothing was paid/);
229
+ expect(error.message).toMatch(/depositToClearing/);
230
+ expect(state()?.pending).toBeUndefined();
231
+ });
232
+
233
+ it("a refusal the deposit does not explain: the account can spend the price, so the wallet does not say to deposit", async () => {
234
+ const { client, state } = wallet({ payments: [rejected], spendable: [spendable(5n)] });
235
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
236
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
237
+ expect(error.message).toMatch(/deposit covers it/);
238
+ expect(error.message).toMatch(/nothing was paid/);
239
+ expect(error.message).not.toMatch(/deposit more/);
240
+ expect((error.cause as Error).message).toBe("clearing payment rejected before acknowledgment");
241
+ expect(state()?.pending).toBeUndefined();
242
+ });
243
+
244
+ it("a refusal the wallet cannot explain: the /context re-read answers 503, which is not between epochs", async () => {
245
+ const { client, state } = wallet({ context: [open, busy], payments: [rejected] });
246
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
247
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
248
+ expect(error.message).toMatch(/503/);
249
+ expect(error.message).toMatch(/nothing was paid/);
250
+ expect((error.cause as Error).message).toBe("clearing payment rejected before acknowledgment");
251
+ expect(state()?.pending).toBeUndefined();
252
+ });
253
+
254
+ it("a wrong chain: the RPC serves another chain while the contract there looks between epochs, and the error names the chain, not a gap", async () => {
255
+ Object.assign(chain, { id: 1, admitted: 0n });
256
+ try {
257
+ const { client, posted } = wallet();
258
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
259
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
260
+ expect(error.message).toMatch(/serves eip155:1, not eip155:1337/);
261
+ expect(posted).toHaveLength(0);
262
+ } finally {
263
+ Object.assign(chain, { id: 1337, admitted: 1n });
264
+ }
265
+ });
266
+
267
+ it("a wrong chain with no Clearing contract at the address: the error names the chain, not the missing contract", async () => {
268
+ Object.assign(chain, { id: 1, deployed: false });
269
+ try {
270
+ for (const answers of [{}, { context: [betweenEpochs] }]) {
271
+ const error = await wallet(answers).client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
272
+ expect(error.message).toMatch(/serves eip155:1, not eip155:1337/);
273
+ }
274
+ } finally {
275
+ Object.assign(chain, { id: 1337, deployed: true });
276
+ }
277
+ });
278
+
279
+ it("a held payment sent again to a service that has yet to see the fault: its rejection names recoverFromClearing, not a deposit or a resend", async () => {
280
+ for (const answers of [{ spendable: [spendable(0n)] }, { spendable: [spendable(5n)] }, { context: [open, () => open("2")] }]) {
281
+ const { client } = wallet({ ...answers, payments: [lost, rejected] });
282
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
283
+ chain.fault = 1;
284
+ try {
285
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
286
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
287
+ expect(error.message).toMatch(/faulted.*nothing was paid.*recoverFromClearing/);
288
+ } finally {
289
+ chain.fault = 0;
290
+ }
291
+ }
292
+ });
293
+
294
+ it("a held payment whose deployment faulted while the service is gone: the error names recoverFromClearing, not another resend", async () => {
295
+ const { client } = wallet({ payments: [lost, lost] });
296
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
297
+ chain.fault = 1;
298
+ try {
299
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
300
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
301
+ expect(error.message).toMatch(/faulted.*recoverFromClearing/);
302
+ // The hold outlives the deployment: a replacement takes a new wallet.
303
+ expect(error.message).toMatch(/keeps it held, so a replacement deployment takes a new wallet/);
304
+ expect(error.message).not.toMatch(/nothing was paid/);
305
+ } finally {
306
+ chain.fault = 0;
307
+ }
308
+ });
309
+
310
+ it("a first /context that answers 503 names it, says nothing was paid, and says to send the request again", async () => {
311
+ const { client, posted } = wallet({ context: [busy] });
312
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
313
+ expect(error.message).toMatch(/503/);
314
+ expect(error.message).toMatch(/nothing was paid/);
315
+ expect(error.message).toMatch(/send the request again/);
316
+ expect((error.cause as Error).message).toBe("clearing service returned 503");
317
+ expect(posted).toHaveLength(0);
318
+ });
319
+
320
+ it("an acknowledgment that does not verify, on every resend, holds the payment as answer_lost and says the service answered, not that the answer was lost", async () => {
321
+ const forged = (payment: ClearingPayment) =>
322
+ Response.json({
323
+ invoice: payment.invoice,
324
+ // Signed by a key that is not the operator's.
325
+ receipt: { body: payment.body, payerSignature: payment.payerSignature, operatorSignature: seller.sign("_COMMONWARE_CLEARING_VECTOR_ACK", payment.body) },
326
+ invoiceSignature: payment.invoiceSignature,
327
+ entries: payment.entries,
328
+ });
329
+ const { client, posted, state } = wallet({ payments: [forged, forged] });
330
+ for (let send = 0; send < 2; send++) {
331
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
332
+ expect(error).toMatchObject({ reason: "answer_lost", retry: { action: "repeat_same_request", url: CHUNK, ms: 3000 } });
333
+ expect(error.message).toMatch(/the clearing service answered/);
334
+ expect(error.message).toMatch(/does not verify against this payment and the deployment's operator key/);
335
+ expect(error.message).toMatch(/serviceUrl may belong to another deployment/);
336
+ expect(error.message).toMatch(/facilitator's \/supported/);
337
+ expect(error.message).toMatch(/until it is sent once to the right service/);
338
+ expect(error.message).not.toMatch(/lost|no receipt/);
339
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
340
+ }
341
+ // Sent to a service that acknowledges it, it is the same payment, paid once.
342
+ await client.pay(invoiceFor(CHUNK), CHUNK);
343
+ expect(posted[2]).toEqual(posted[0]);
344
+ expect(state()?.pending).toBeUndefined();
345
+ });
346
+
347
+ it("answer lost: `fetch failed` on POST /payments holds the payment, and the same request sends it again, paid once", async () => {
348
+ const { client, posted, state } = wallet({ payments: [lost] });
349
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
350
+ expect(error.reason).toBe("answer_lost");
351
+ expect(error.retry).toEqual({ action: "repeat_same_request", url: CHUNK, amount: (5n * UNIT).toString(), ms: 3000 });
352
+ expect(error.message).toMatch(/fetch failed/);
353
+ expect(error.message).toMatch(/after 3000 ms/);
354
+ expect(error.message).toMatch(/outcome is unknown/);
355
+ expect(error.message).toMatch(/send the same request again/);
356
+ expect(error.message).toContain("pass the wallet's pendingInvoice() to its pay");
357
+ expect(error.message).toContain(CHUNK);
358
+ expect(error.message).toMatch(/if it is still held after an epoch, check that serviceUrl is the facilitator's extra\.serviceUrl \(a validator takes no payments\)/);
359
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
360
+ await client.pay(invoiceFor(CHUNK), CHUNK);
361
+ expect(posted).toHaveLength(2);
362
+ expect(posted[1]).toEqual(posted[0]);
363
+ expect(state()?.pending).toBeUndefined();
364
+ });
365
+
366
+ it("a POST /payments refusal with no rejected flag, 409 or 503, holds the payment and resends it byte-identical", async () => {
367
+ for (const refused of [betweenEpochs, busy]) {
368
+ const { client, posted, state } = wallet({ payments: [refused, accepted] });
369
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
370
+ expect(error.reason).toBe("answer_lost");
371
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
372
+ expect(posted).toHaveLength(1);
373
+ await client.pay(invoiceFor(CHUNK), CHUNK);
374
+ expect(posted).toHaveLength(2);
375
+ expect(posted[1]).toEqual(posted[0]);
376
+ expect(state()?.pending).toBeUndefined();
377
+ }
378
+ });
379
+
380
+ it("a held payment the service keeps refusing is released once its epoch has closed with no receipt, and not before", async () => {
381
+ // A 400 the service answers for the same bytes every time, as it does for a payment it cannot parse.
382
+ const unparseable = () => Response.json({ error: "malformed payment" }, { status: 400 });
383
+ const none = () => Response.json(null);
384
+ const { client, posted, state } = wallet({ payments: [unparseable, unparseable, unparseable], receipt: [none, none] });
385
+ expect((await refusal(client.pay(invoiceFor(CHUNK), CHUNK))).reason).toBe("answer_lost");
386
+ // Sent again while its epoch is open, the service has no receipt and may yet take it: it stays held.
387
+ expect((await refusal(client.pay(invoiceFor(CHUNK), CHUNK))).reason).toBe("answer_lost");
388
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
389
+ chain.admitted = 2n;
390
+ try {
391
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
392
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
393
+ expect(error.message).toMatch(/the clearing service has no record of this payment, 5000000000000 base units for http:\/\/seller\.test\/chunk\/1, and the epoch it was signed for has closed or is no longer the live one/);
394
+ expect(error.message).toMatch(/released; nothing was paid and nothing is held: send the request again/);
395
+ expect(state()?.pending).toBeUndefined();
396
+ expect(posted).toHaveLength(3);
397
+ } finally {
398
+ chain.admitted = 1n;
399
+ }
400
+ });
401
+
402
+ it("releases a held payment whose epoch is no longer the service's live one, even when an epoch numbered like it is open, and starts its vector over", async () => {
403
+ const unparseable = () => Response.json({ error: "malformed payment" }, { status: 400 });
404
+ // A chain reset: the service's epoch 1 carries another anchor than the payment was signed under.
405
+ const reset = () =>
406
+ Response.json({ reference: CLEARING_REFERENCE, network: NETWORK, settlement: SETTLEMENT, asset: ASSET, operator: operator.publicKey, epoch: "1", anchor: `0x${"bb".repeat(32)}` });
407
+ const { client, state } = wallet({ context: [open, open, reset], payments: [accepted, unparseable, unparseable], receipt: [() => Response.json(null)] });
408
+ await client.pay(invoiceFor(OTHER), OTHER);
409
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
410
+ expect(state()?.sequence).toBe("1");
411
+ expect(state()?.entries).toHaveLength(1);
412
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
413
+ expect(error.message).toMatch(/the epoch it was signed for has closed or is no longer the live one/);
414
+ expect(state()?.pending).toBeUndefined();
415
+ expect(state()).toMatchObject({ entries: [], sequence: "0" });
416
+ expect(state()?.epoch).toBeUndefined();
417
+ });
418
+
419
+ it("releases a held payment once the service has moved past its epoch, without the chain having admitted it", async () => {
420
+ const unparseable = () => Response.json({ error: "malformed payment" }, { status: 400 });
421
+ const later = () => open("2");
422
+ const { client, state } = wallet({ context: [open, later], payments: [unparseable, unparseable], receipt: [() => Response.json(null)] });
423
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
424
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
425
+ expect(error.message).toMatch(/is released/);
426
+ expect(state()?.pending).toBeUndefined();
427
+ });
428
+
429
+ it("does not release a held payment on a null receipt from a service that is not this wallet's deployment", async () => {
430
+ const unparseable = () => Response.json({ error: "malformed payment" }, { status: 400 });
431
+ const other = () =>
432
+ Response.json({ reference: CLEARING_REFERENCE, network: NETWORK, settlement: ASSET, asset: ASSET, operator: operator.publicKey, epoch: "1", anchor: ANCHOR });
433
+ const { client, state } = wallet({ context: [open, other], payments: [unparseable, unparseable], receipt: [() => Response.json(null)] });
434
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
435
+ chain.admitted = 2n;
436
+ try {
437
+ expect((await refusal(client.pay(invoiceFor(CHUNK), CHUNK))).reason).toBe("answer_lost");
438
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
439
+ } finally {
440
+ chain.admitted = 1n;
441
+ }
442
+ });
443
+
444
+ it("reads the service's epoch and the chain before it reads the receipt, and asks for the receipt of the payment's own epoch", async () => {
445
+ const unparseable = () => Response.json({ error: "malformed payment" }, { status: 400 });
446
+ const { client, asked } = wallet({ payments: [unparseable, unparseable], receipt: [() => Response.json(null)] });
447
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
448
+ asked.length = 0;
449
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
450
+ const receipt = asked.findIndex((path) => path.startsWith("/receipt/"));
451
+ expect(asked.indexOf("/context")).toBeGreaterThanOrEqual(0);
452
+ expect(asked.indexOf("/context")).toBeLessThan(receipt);
453
+ expect(asked[receipt]).toMatch(/\?epoch=1$/);
454
+ });
455
+
456
+ it("does not settle a sent-again payment from a verifying receipt for another payment: another body, another payer signature or another invoice", async () => {
457
+ const send = "_COMMONWARE_CLEARING_VECTOR_SEND";
458
+ const ack = "_COMMONWARE_CLEARING_VECTOR_ACK";
459
+ // Receipts every check but the comparison with the held payment accepts: the payer and the operator both signed them.
460
+ const receipts = [
461
+ // Another body: the payer's next payment in the epoch.
462
+ (paid: ClearingPayment) => {
463
+ const body = encodeSend({ anchor: ANCHOR, epoch: 1n, payer: payer.publicKey, sequence: 9n, debit: 1n, vector: ZERO32 });
464
+ return { invoice: paid.invoice, body, payerSignature: payer.sign(send, body) };
465
+ },
466
+ // The same body under another valid payer signature.
467
+ (paid: ClearingPayment) => {
468
+ const other = toHex(
469
+ p256.sign(hexToBytes(clearingSignatureDigest(send, paid.body)), hexToBytes(`0x${"12".repeat(32)}`), { lowS: true, prehash: false, extraEntropy: true }).toCompactRawBytes(),
470
+ );
471
+ expect(other).not.toBe(paid.payerSignature);
472
+ return { invoice: paid.invoice, body: paid.body, payerSignature: other };
473
+ },
474
+ // Another invoice under the same body.
475
+ (paid: ClearingPayment) => ({ invoice: { ...paid.invoice, amount: (BigInt(paid.invoice.amount) + UNIT).toString() }, body: paid.body, payerSignature: paid.payerSignature }),
476
+ ];
477
+ for (const receipt of receipts) {
478
+ const w: ReturnType<typeof wallet> = wallet({
479
+ payments: [lost, busy],
480
+ receipt: [
481
+ () => {
482
+ const original = w.posted[0]!;
483
+ const { invoice, body, payerSignature } = receipt(original);
484
+ return Response.json({ invoice, receipt: { body, payerSignature, operatorSignature: operator.sign(ack, body) }, invoiceSignature: original.invoiceSignature, entries: original.entries });
485
+ },
486
+ ],
487
+ });
488
+ await refusal(w.client.pay(invoiceFor(CHUNK), CHUNK));
489
+ const error = await refusal(w.client.pay(invoiceFor(CHUNK), CHUNK));
490
+ expect(error.message).toMatch(/does not verify/);
491
+ expect(w.posted).toHaveLength(2);
492
+ expect(w.state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
493
+ }
494
+ });
495
+
496
+ it("holds a payment whose epoch has closed when the service cannot say whether it has a receipt", async () => {
497
+ const unparseable = () => Response.json({ error: "malformed payment" }, { status: 400 });
498
+ const { client, state } = wallet({ payments: [unparseable, unparseable], receipt: [busy] });
499
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
500
+ chain.admitted = 2n;
501
+ try {
502
+ expect((await refusal(client.pay(invoiceFor(CHUNK), CHUNK))).reason).toBe("answer_lost");
503
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
504
+ } finally {
505
+ chain.admitted = 1n;
506
+ }
507
+ });
508
+
509
+ it("settles a sent-again payment from the receipt the service retained, paid once", async () => {
510
+ const w: ReturnType<typeof wallet> = wallet({ payments: [lost, busy], receipt: [() => accepted(w.posted[0]!)] });
511
+ await refusal(w.client.pay(invoiceFor(CHUNK), CHUNK));
512
+ const paid = await w.client.pay(invoiceFor(CHUNK), CHUNK);
513
+ expect(paid.invoice).toEqual(invoiceFor(CHUNK));
514
+ expect(w.posted).toHaveLength(2);
515
+ expect(w.state()?.pending).toBeUndefined();
516
+ expect(w.state()?.payments[invoiceFor(CHUNK).id]).toBeDefined();
517
+ });
518
+
519
+ it("does not settle a sent-again payment from a receipt the operator did not sign", async () => {
520
+ const forged = () =>
521
+ Response.json({
522
+ invoice: invoiceFor(CHUNK),
523
+ receipt: { body: w.posted[0]!.body, payerSignature: w.posted[0]!.payerSignature, operatorSignature: seller.sign("_COMMONWARE_CLEARING_VECTOR_ACK", w.posted[0]!.body) },
524
+ invoiceSignature: w.posted[0]!.invoiceSignature,
525
+ entries: w.posted[0]!.entries,
526
+ });
527
+ const w: ReturnType<typeof wallet> = wallet({ payments: [lost, busy], receipt: [forged] });
528
+ await refusal(w.client.pay(invoiceFor(CHUNK), CHUNK));
529
+ const error = await refusal(w.client.pay(invoiceFor(CHUNK), CHUNK));
530
+ expect(error.message).toMatch(/does not verify/);
531
+ expect(w.state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
532
+ });
533
+
534
+ it("takes the most recipients one payment's vector holds, and refuses a 1025th before signing it, as the service's parser would refuse the vector", async () => {
535
+ // A wallet that has paid `count` recipients this epoch, the first of them the seller.
536
+ const crowded = (count: number) => {
537
+ let saved: ClearingClientState | null = {
538
+ epoch: "1",
539
+ sequence: "0",
540
+ payments: {},
541
+ entries: Array.from({ length: count }, (_, i) => ({
542
+ recipient: i === 0 ? seller.publicKey : createClearingSigner(`0x${(i + 1).toString(16).padStart(64, "0")}`).publicKey,
543
+ cumulative: "1",
544
+ count: "1",
545
+ })),
546
+ };
547
+ const posted: ClearingPayment[] = [];
548
+ const client = createClearingClient({
549
+ serviceUrl: SERVICE_URL,
550
+ rpcUrl,
551
+ network: NETWORK,
552
+ settlement: SETTLEMENT,
553
+ asset: ASSET,
554
+ signer: payer,
555
+ maxAmount: 1000n * UNIT,
556
+ store: { load: async () => structuredClone(saved), save: async (value) => void (saved = structuredClone(value)) },
557
+ fetch: async (input, init) => {
558
+ if (new URL(String(input)).pathname === "/context") return open();
559
+ const payment = JSON.parse(String(init?.body)) as ClearingPayment;
560
+ posted.push(payment);
561
+ return accepted(payment);
562
+ },
563
+ });
564
+ const toNew = () => {
565
+ const requirements = clearingRequirements({ network: NETWORK, asset: ASSET, settlement: SETTLEMENT, recipient: createClearingSigner(`0x${"77".repeat(32)}`).publicKey, serviceUrl: SERVICE_URL }, 5n * UNIT, OTHER);
566
+ return clearingInvoice(requirements, `${requirements.extra!.invoicePrefix}:new`);
567
+ };
568
+ return { client, posted, saved: () => saved, toNew };
569
+ };
570
+ // 1023 recipients and a new one is the 1024th; 1024 and one of them again stays 1024.
571
+ const room = crowded(1023);
572
+ await room.client.pay(room.toNew(), OTHER);
573
+ expect(room.posted).toHaveLength(1);
574
+ const repeat = crowded(1024);
575
+ await repeat.client.pay(invoiceFor(CHUNK), CHUNK);
576
+ expect(repeat.posted).toHaveLength(1);
577
+ // 1024 and a new one is the 1025th.
578
+ const full = crowded(1024);
579
+ const error = await full.client.pay(full.toNew(), OTHER).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
580
+ expect(error.message).toMatch(/has paid 1024 recipients in epoch 1, the most one payment's vector holds; nothing was paid and nothing is held: send the request again after the next epoch/);
581
+ expect(full.posted).toHaveLength(0);
582
+ expect(full.saved()?.pending).toBeUndefined();
583
+ });
584
+
585
+ it("names the hold when the held payment is refused because the wallet's maxAmount was lowered", async () => {
586
+ let saved: ClearingClientState | null = null;
587
+ const store = { load: async () => structuredClone(saved), save: async (value: ClearingClientState) => void (saved = structuredClone(value)) };
588
+ const over = (maxAmount: bigint) =>
589
+ createClearingClient({
590
+ serviceUrl: SERVICE_URL,
591
+ rpcUrl,
592
+ network: NETWORK,
593
+ settlement: SETTLEMENT,
594
+ asset: ASSET,
595
+ signer: payer,
596
+ maxAmount,
597
+ store,
598
+ fetch: async (input) => (new URL(String(input)).pathname === "/context" ? open() : lost()),
599
+ });
600
+ await refusal(over(1000n * UNIT).pay(invoiceFor(CHUNK), CHUNK));
601
+ const error = await over(1n * UNIT).pay((await over(1n * UNIT).pendingInvoice())!).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
602
+ expect(error.message).toMatch(/is above this wallet's maxAmount.*the wallet holds this payment and pays nothing else until it goes through: raise the wallet's maxAmount to send it/);
603
+ });
604
+
605
+ it("a payment held for another deployment can never go through here: the error names a new wallet and recovery on the old deployment", async () => {
606
+ let saved: ClearingClientState | null = null;
607
+ const store = { load: async () => structuredClone(saved), save: async (value: ClearingClientState) => void (saved = structuredClone(value)) };
608
+ const over = (settlement: Hex, answers: ((payment: ClearingPayment) => Response)[]) =>
609
+ createClearingClient({
610
+ serviceUrl: SERVICE_URL,
611
+ rpcUrl,
612
+ network: NETWORK,
613
+ settlement,
614
+ asset: ASSET,
615
+ signer: payer,
616
+ maxAmount: 1000n * UNIT,
617
+ store,
618
+ fetch: async (input) => {
619
+ const path = new URL(String(input)).pathname;
620
+ if (path === "/context") return Response.json({ reference: CLEARING_REFERENCE, network: NETWORK, settlement, asset: ASSET, operator: operator.publicKey, epoch: "1", anchor: ANCHOR });
621
+ return answers.shift()!({} as ClearingPayment);
622
+ },
623
+ });
624
+ await refusal(over(SETTLEMENT, [lost]).pay(invoiceFor(CHUNK), CHUNK));
625
+ const REPLACEMENT = `0x${"44".repeat(20)}` as const;
626
+ const replacement = over(REPLACEMENT, []);
627
+ const held = (await replacement.pendingInvoice())!;
628
+ const requirements = clearingRequirements({ network: NETWORK, asset: ASSET, settlement: REPLACEMENT, recipient: seller.publicKey, serviceUrl: SERVICE_URL }, 5n * UNIT, OTHER);
629
+ const fresh = clearingInvoice(requirements, `${requirements.extra!.invoicePrefix}:2`);
630
+ // The held payment itself, and a new one for the replacement deployment.
631
+ for (const attempt of [() => replacement.pay(held), () => replacement.pay(fresh, OTHER)]) {
632
+ const error = await attempt().then(() => expect.unreachable("the payment went through"), (e: Error) => e);
633
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
634
+ expect(error.message).toMatch(/holds a payment for deployment 0x2222222222222222222222222222222222222222, which this wallet cannot send to 0x4444444444444444444444444444444444444444/);
635
+ expect(error.message).toMatch(/withdrawFromClearing, or recoverFromClearing once it has faulted/);
636
+ expect(error.message).toMatch(/new wallet/);
637
+ expect(error.message).not.toMatch(/pendingInvoice\(\)/);
638
+ }
639
+ });
640
+
641
+ it("a /context that answers 404 or 403 is a service URL that is not the clearing service, not a request to send again", async () => {
642
+ for (const status of [401, 403, 404]) {
643
+ const { client, posted } = wallet({ context: [() => new Response(null, { status })] });
644
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
645
+ expect(error.message).toMatch(new RegExp(`${status} for /context`));
646
+ expect(error.message).toMatch(/serviceUrl is not the clearing service/);
647
+ expect(error.message).toMatch(/nothing was paid and nothing is held/);
648
+ expect(error.message).not.toMatch(/send the request again/);
649
+ expect(posted).toHaveLength(0);
650
+ }
651
+ });
652
+
653
+ it("a failed balance read while the epoch is open is not between epochs", async () => {
654
+ const { client, state } = wallet({ context: [open, open], payments: [rejected], spendable: [betweenEpochs] });
655
+ const error = await client.pay(invoiceFor(CHUNK), CHUNK).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
656
+ expect(error).not.toBeInstanceOf(ClearingRefusal);
657
+ expect(error.message).toMatch(/reading why failed/);
658
+ expect(state()?.pending).toBeUndefined();
659
+ });
660
+
661
+ it("a 200 that is not a receipt holds the payment, as an acknowledgment that does not verify", async () => {
662
+ for (const answer of [() => Response.json({}), () => Response.json(null), () => Response.json({ receipt: {} })]) {
663
+ const { client, state } = wallet({ payments: [answer] });
664
+ const error = await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
665
+ expect(error.reason).toBe("answer_lost");
666
+ expect(error.message).toMatch(/does not verify/);
667
+ expect(state()?.pending?.invoice).toEqual(invoiceFor(CHUNK));
668
+ }
669
+ });
670
+
671
+ it("payment held: another request while one is held names the held request's URL and price, and sends nothing", async () => {
672
+ const { client, posted } = wallet({ payments: [lost] });
673
+ await refusal(client.pay(invoiceFor(CHUNK), CHUNK));
674
+ const error = await refusal(client.pay(invoiceFor(OTHER, 7n * UNIT), OTHER));
675
+ expect(error.reason).toBe("payment_held");
676
+ expect(error.retry).toEqual({ action: "repeat_same_request", url: CHUNK, amount: (5n * UNIT).toString(), ms: 3000 });
677
+ expect(error.message).toContain(CHUNK);
678
+ expect(error.message).toMatch(/send that request again after 3000 ms/);
679
+ expect(posted).toHaveLength(1);
680
+ // The same URL at another price is another payment, held off the same way.
681
+ const repriced = await refusal(client.pay(invoiceFor(CHUNK, 6n * UNIT), CHUNK));
682
+ expect(repriced.reason).toBe("payment_held");
683
+ // The way out when the URL no longer offers that price: the held payment itself, sent once more.
684
+ expect(repriced.message).toContain("pass the wallet's pendingInvoice() to its pay");
685
+ await client.pay((await client.pendingInvoice())!);
686
+ expect(posted).toHaveLength(2);
687
+ expect(posted[1]).toEqual(posted[0]);
688
+ expect(await client.pendingInvoice()).toBeNull();
689
+ });
690
+ });
691
+
692
+ describe("registerClearingClient", () => {
693
+ it("refuses a request for another URL while a payment is held, naming the held one, then pays the held one once", async () => {
694
+ const { client: clearing, posted } = wallet({ payments: [lost] });
695
+ const client = registerClearingClient(new x402Client().setSpendControls({ allowedAssets: true }), NETWORK, clearing);
696
+ const required = (url: string) => ({ x402Version: 2, resource: { url }, accepts: [offer(url)] });
697
+ const lostAnswer = await refusal(client.createPaymentPayload(required(CHUNK)));
698
+ expect(lostAnswer).toMatchObject({ reason: "answer_lost", retry: { action: "repeat_same_request", url: CHUNK } });
699
+ // The builders' `invalid clearing requirement`.
700
+ const held = await refusal(client.createPaymentPayload(required(OTHER)));
701
+ expect(held).toMatchObject({ reason: "payment_held", retry: { action: "repeat_same_request", url: CHUNK, amount: (5n * UNIT).toString() } });
702
+ await client.createPaymentPayload(required(CHUNK));
703
+ expect(posted).toHaveLength(2);
704
+ expect(posted[1]).toEqual(posted[0]);
705
+ });
706
+
707
+ it("names the URL the payer fetched, not the seller's name for it, behind a TLS-terminating proxy", async () => {
708
+ // The seller's listener sees plain http, so its offer names http://.
709
+ const declared = "http://seller.test/chunk/1";
710
+ const fetched = "https://seller.test/chunk/1";
711
+ const sent: string[] = [];
712
+ const seller = async (input: RequestInfo | URL, init?: RequestInit) => {
713
+ const signature = new Request(input, init).headers.get("PAYMENT-SIGNATURE");
714
+ if (signature) return sent.push(signature), new Response("chunk");
715
+ return new Response(null, {
716
+ status: 402,
717
+ headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader({ x402Version: 2, resource: { url: declared }, accepts: [offer(declared)] }) },
718
+ });
719
+ };
720
+ const { client: clearing } = wallet({ payments: [lost] });
721
+ const { client, fetchWithPayment } = createMetalPaymentClient({ account: privateKeyToAccount(`0x${"45".repeat(32)}`), network: "devnet", fetch: seller });
722
+ registerClearingClient(client, NETWORK, clearing);
723
+ const error = await fetchWithPayment(fetched).then(() => expect.unreachable("the payment went through"), (e: Error) => e);
724
+ expect(error.cause).toBeInstanceOf(ClearingRefusal);
725
+ expect((error.cause as ClearingRefusal).retry).toMatchObject({ action: "repeat_same_request", url: fetched });
726
+ expect(error.message).toContain(fetched);
727
+ // The payment the seller receives names the resource as the seller did.
728
+ expect((await fetchWithPayment(fetched)).status).toBe(200);
729
+ expect(JSON.parse(atob(sent[0]!)).resource).toEqual({ url: declared });
730
+ });
731
+
732
+ const TESTNET = "eip155:63825";
733
+ const BASE = "eip155:8453";
734
+ /** A clearing wallet that pays any offer on `network`. */
735
+ const payingOn = (network: string) => ({
736
+ pendingInvoice: async () => null,
737
+ unpayable: (invoice: { network: string }) => (invoice.network === network ? undefined : { reason: "offer is for another network", aboveMaxAmount: false }),
738
+ pay: async (invoice: unknown) => ({ invoice }) as ClearingPaid,
739
+ });
740
+ const on = (network: string, scheme = "bajillion"): PaymentRequirements => ({ ...offer(CHUNK), network: network as `${string}:${string}`, scheme });
741
+ const exact = { scheme: "exact", createPaymentPayload: async (x402Version: number) => ({ x402Version, payload: {} }) };
742
+ const chosen = async (client: x402Client, accepts: PaymentRequirements[]) =>
743
+ (await client.createPaymentPayload({ x402Version: 2, resource: { url: CHUNK }, accepts })).accepted;
744
+
745
+ it("two registrations on one client: each pays its own network's requests and leaves the other's untouched", async () => {
746
+ const client = new x402Client().setSpendControls({ allowedAssets: true });
747
+ registerClearingClient(client, NETWORK, payingOn(NETWORK));
748
+ registerClearingClient(client, TESTNET, payingOn(TESTNET));
749
+ expect((await chosen(client, [on(NETWORK)])).network).toBe(NETWORK);
750
+ expect((await chosen(client, [on(TESTNET)])).network).toBe(TESTNET);
751
+ expect([NETWORK, TESTNET]).toContain((await chosen(client, [on(NETWORK), on(TESTNET)])).network);
752
+ });
753
+
754
+ it("a request with no offer on the registration's network goes to the client's other schemes; one on it with no clearing offer still fails", async () => {
755
+ const client = new x402Client().setSpendControls({ allowedAssets: true });
756
+ client.register(BASE, exact);
757
+ client.register(NETWORK, exact);
758
+ registerClearingClient(client, NETWORK, payingOn(NETWORK));
759
+ expect(await chosen(client, [on(BASE, "exact")])).toMatchObject({ network: BASE, scheme: "exact" });
760
+ await expect(chosen(client, [on(NETWORK, "exact")])).rejects.toThrow(/offers no clearing payment on eip155:1337/);
761
+ });
762
+
763
+ it("names what is wrong with a clearing offer it cannot pay: its price, its form, or its deployment and limit", async () => {
764
+ const client = registerClearingClient(new x402Client().setSpendControls({ allowedAssets: true }), NETWORK, wallet().client);
765
+ const refused = (accepts: PaymentRequirements[]) => chosen(client, accepts).then(() => expect.unreachable("the offer was paid"), (e: Error) => e.message);
766
+ expect(await refused([{ ...on(NETWORK), amount: "0" }])).toMatch(/\(offer is priced at "0", not a positive whole number of base units\), and paying by exact is off/);
767
+ expect(await refused([{ ...on(NETWORK), amount: "1.5" }])).toMatch(/\(offer is priced at "1.5", not a positive whole number of base units\)/);
768
+ // A leading zero passes /^[0-9]+$/ and BigInt(); the service's canonical() rejects it as malformed.
769
+ expect(await refused([{ ...on(NETWORK), amount: "05000000000000" }])).toMatch(/\(offer of 05000000000000 base units is not a positive whole number in canonical form\)/);
770
+ expect(await refused([{ ...on(NETWORK), payTo: "0x1234" }])).toMatch(/\(offer is malformed \(invalid clearing requirement\)\)/);
771
+ expect(await refused([{ ...on(NETWORK), extra: { ...on(NETWORK).extra, settlement: ASSET } }])).toContain(
772
+ `(offer is for deployment ${ASSET}, not this wallet's ${SETTLEMENT}), and paying by exact is off; nothing was paid: pass exactFallback: true`,
773
+ );
774
+ expect(await refused([{ ...on(NETWORK), asset: SETTLEMENT }])).toContain(`(offer is for asset ${SETTLEMENT}, not this wallet's ${ASSET})`);
775
+ });
776
+
777
+ it("refuses a leading-zero amount before signing, so nothing is sent for the service to refuse as malformed", async () => {
778
+ const { client: clearing, posted } = wallet();
779
+ const client = registerClearingClient(new x402Client().setSpendControls({ allowedAssets: true }), NETWORK, clearing);
780
+ const leadingZero = { ...on(NETWORK), amount: "05000000000000" };
781
+ await expect(chosen(client, [leadingZero])).rejects.toThrow(/not a positive whole number in canonical form/);
782
+ expect(posted).toHaveLength(0);
783
+ });
784
+
785
+ it("names a clearing offer above the wallet's maxAmount with both amounts, and says to raise it or pay another seller, not to pay by exact", async () => {
786
+ // k12-backend's probe: a 0.50 *USDZ offer to a wallet whose maxAmount is 0.30 *USDZ.
787
+ const client = registerClearingClient(new x402Client().setSpendControls({ allowedAssets: true }), NETWORK, wallet({}, 300_000n * UNIT).client);
788
+ const refused = (accepts: PaymentRequirements[]) => chosen(client, accepts).then(() => expect.unreachable("the offer was paid"), (e: Error) => e.message);
789
+ const over = { ...on(NETWORK), amount: String(500_000n * UNIT) };
790
+ expect(await refused([over])).toBe(
791
+ "this clearing wallet pays none of the clearing offers here (offer 0.5 *USDZ is above this wallet's maxAmount 0.3 *USDZ); nothing was paid: raise the wallet's maxAmount or pay another seller",
792
+ );
793
+ // Beside an offer for another deployment, the one for the wallet's own decides the advice.
794
+ const elsewhere = { ...on(NETWORK), extra: { ...on(NETWORK).extra, settlement: ASSET } };
795
+ const both = await refused([elsewhere, over]);
796
+ expect(both).toContain(`(offer is for deployment ${ASSET}, not this wallet's ${SETTLEMENT}; offer 0.5 *USDZ is above this wallet's maxAmount 0.3 *USDZ)`);
797
+ expect(both).not.toContain("exactFallback");
798
+ expect(await refused([elsewhere])).toContain("pass exactFallback: true");
799
+ });
800
+
801
+ it("falls back to exact when the clearing offer it refuses is listed first and carries no paymentFlow", async () => {
802
+ // A seller's own 402, built with the exported clearingRequirements() rather than ClearingServerScheme,
803
+ // lists clearing before exact; the stock selector would otherwise pick the clearing offer again.
804
+ const neverPays = {
805
+ pendingInvoice: async () => null,
806
+ unpayable: () => ({ reason: "offer is for another deployment, not this wallet's", aboveMaxAmount: false }),
807
+ pay: async () => expect.unreachable("a refused clearing offer was signed"),
808
+ };
809
+ const client = new x402Client().setSpendControls({ allowedAssets: true });
810
+ client.register(NETWORK, exact);
811
+ registerClearingClient(client, NETWORK, neverPays, { exactFallback: true });
812
+ expect(await chosen(client, [on(NETWORK), on(NETWORK, "exact")])).toMatchObject({ network: NETWORK, scheme: "exact" });
813
+ });
814
+
815
+ it("with exactFallback, an exact offer on another network is not a way to pay a request whose offers on this network are all refused clearing offers", async () => {
816
+ const neverPays = {
817
+ pendingInvoice: async () => null,
818
+ unpayable: () => ({ reason: "offer is for another deployment, not this wallet's", aboveMaxAmount: false }),
819
+ pay: async () => expect.unreachable("a refused clearing offer was signed"),
820
+ };
821
+ const client = new x402Client().setSpendControls({ allowedAssets: true });
822
+ client.register(NETWORK, exact);
823
+ client.register(BASE, exact);
824
+ registerClearingClient(client, NETWORK, neverPays, { exactFallback: true });
825
+ await expect(chosen(client, [on(NETWORK), on(BASE, "exact")])).rejects.toThrow(/this request offers no other way to pay it; nothing was paid/);
826
+ });
827
+
828
+ it("without exactFallback, still throws naming the refused clearing offers even when an exact one is also listed", async () => {
829
+ const neverPays = {
830
+ pendingInvoice: async () => null,
831
+ unpayable: () => ({ reason: "offer is for another deployment, not this wallet's", aboveMaxAmount: false }),
832
+ pay: async () => expect.unreachable("a refused clearing offer was signed"),
833
+ };
834
+ const client = new x402Client().setSpendControls({ allowedAssets: true });
835
+ client.register(NETWORK, exact);
836
+ registerClearingClient(client, NETWORK, neverPays);
837
+ await expect(chosen(client, [on(NETWORK), on(NETWORK, "exact")])).rejects.toThrow(/paying by exact is off/);
838
+ });
839
+ });
840
+
841
+ function listen(server: Server): Promise<string> {
842
+ return new Promise((resolve, reject) => {
843
+ server.once("error", reject);
844
+ server.listen(0, "127.0.0.1", () => {
845
+ const address = server.address();
846
+ if (!address || typeof address === "string") return reject(new Error("no port"));
847
+ resolve(`http://127.0.0.1:${address.port}`);
848
+ });
849
+ });
850
+ }