thirdweb 5.121.2 → 5.121.3

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 (71) hide show
  1. package/dist/cjs/react/core/hooks/x402/useFetchWithPaymentCore.js +1 -0
  2. package/dist/cjs/react/core/hooks/x402/useFetchWithPaymentCore.js.map +1 -1
  3. package/dist/cjs/react/native/hooks/x402/useFetchWithPayment.js +1 -1
  4. package/dist/cjs/react/web/hooks/x402/useFetchWithPayment.js +2 -2
  5. package/dist/cjs/react/web/hooks/x402/useFetchWithPayment.js.map +1 -1
  6. package/dist/cjs/react/web/ui/x402/PaymentErrorModal.js +4 -4
  7. package/dist/cjs/react/web/ui/x402/PaymentErrorModal.js.map +1 -1
  8. package/dist/cjs/version.js +1 -1
  9. package/dist/cjs/x402/encode.js +41 -2
  10. package/dist/cjs/x402/encode.js.map +1 -1
  11. package/dist/cjs/x402/fetchWithPayment.js +56 -33
  12. package/dist/cjs/x402/fetchWithPayment.js.map +1 -1
  13. package/dist/cjs/x402/schemas.js +134 -2
  14. package/dist/cjs/x402/schemas.js.map +1 -1
  15. package/dist/cjs/x402/sign.js +56 -15
  16. package/dist/cjs/x402/sign.js.map +1 -1
  17. package/dist/esm/react/core/hooks/x402/useFetchWithPaymentCore.js +2 -1
  18. package/dist/esm/react/core/hooks/x402/useFetchWithPaymentCore.js.map +1 -1
  19. package/dist/esm/react/native/hooks/x402/useFetchWithPayment.js +1 -1
  20. package/dist/esm/react/web/hooks/x402/useFetchWithPayment.js +2 -2
  21. package/dist/esm/react/web/hooks/x402/useFetchWithPayment.js.map +1 -1
  22. package/dist/esm/react/web/ui/x402/PaymentErrorModal.js +5 -5
  23. package/dist/esm/react/web/ui/x402/PaymentErrorModal.js.map +1 -1
  24. package/dist/esm/version.js +1 -1
  25. package/dist/esm/x402/encode.js +40 -2
  26. package/dist/esm/x402/encode.js.map +1 -1
  27. package/dist/esm/x402/fetchWithPayment.js +57 -35
  28. package/dist/esm/x402/fetchWithPayment.js.map +1 -1
  29. package/dist/esm/x402/schemas.js +129 -1
  30. package/dist/esm/x402/schemas.js.map +1 -1
  31. package/dist/esm/x402/sign.js +57 -16
  32. package/dist/esm/x402/sign.js.map +1 -1
  33. package/dist/scripts/bridge-widget.js +2 -2
  34. package/dist/types/react/core/hooks/x402/useFetchWithPaymentCore.d.ts +1 -0
  35. package/dist/types/react/core/hooks/x402/useFetchWithPaymentCore.d.ts.map +1 -1
  36. package/dist/types/react/native/hooks/x402/useFetchWithPayment.d.ts +1 -1
  37. package/dist/types/react/web/hooks/x402/useFetchWithPayment.d.ts +1 -1
  38. package/dist/types/react/web/hooks/x402/useFetchWithPayment.d.ts.map +1 -1
  39. package/dist/types/react/web/ui/x402/PaymentErrorModal.d.ts +1 -0
  40. package/dist/types/react/web/ui/x402/PaymentErrorModal.d.ts.map +1 -1
  41. package/dist/types/version.d.ts +1 -1
  42. package/dist/types/x402/encode.d.ts +7 -0
  43. package/dist/types/x402/encode.d.ts.map +1 -1
  44. package/dist/types/x402/fetchWithPayment.d.ts +10 -1
  45. package/dist/types/x402/fetchWithPayment.d.ts.map +1 -1
  46. package/dist/types/x402/schemas.d.ts +60 -2
  47. package/dist/types/x402/schemas.d.ts.map +1 -1
  48. package/dist/types/x402/sign.d.ts +13 -1
  49. package/dist/types/x402/sign.d.ts.map +1 -1
  50. package/package.json +3 -3
  51. package/src/contract/actions/resolve-abi.test.ts +3 -5
  52. package/src/extensions/airdrop/write/airdropERC721WithSignature.test.ts +4 -5
  53. package/src/extensions/erc1155/drop1155.test.ts +1 -1
  54. package/src/extensions/erc1155/write/sigMint1155.test.ts +189 -187
  55. package/src/extensions/erc20/drop20.test.ts +1 -1
  56. package/src/extensions/erc721/write/sigMint721.test.ts +4 -6
  57. package/src/extensions/modules/ClaimableERC1155/claimableERC1155.test.ts +180 -178
  58. package/src/extensions/modules/ClaimableERC20/claimableERC20.test.ts +1 -1
  59. package/src/extensions/modules/ClaimableERC721/claimableERC721.test.ts +1 -1
  60. package/src/extensions/prebuilts/process-ref-deployments.test.ts +1 -1
  61. package/src/react/core/hooks/x402/useFetchWithPaymentCore.ts +6 -1
  62. package/src/react/native/hooks/x402/useFetchWithPayment.ts +1 -1
  63. package/src/react/web/hooks/x402/useFetchWithPayment.tsx +3 -1
  64. package/src/react/web/ui/x402/PaymentErrorModal.tsx +9 -4
  65. package/src/version.ts +1 -1
  66. package/src/x402/encode.ts +46 -2
  67. package/src/x402/fetchWithPayment.test.ts +1173 -104
  68. package/src/x402/fetchWithPayment.ts +72 -54
  69. package/src/x402/schemas.test.ts +123 -0
  70. package/src/x402/schemas.ts +199 -6
  71. package/src/x402/sign.ts +86 -10
@@ -1,13 +1,39 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { allowance } from "../extensions/erc20/__generated__/IERC20/read/allowance.js";
3
+ import { nonces } from "../extensions/erc20/__generated__/IERC20Permit/read/nonces.js";
4
+ import { getAddress } from "../utils/address.js";
5
+ import type { AsyncStorage } from "../utils/storage/AsyncStorage.js";
6
+ import { webLocalStorage } from "../utils/storage/webStorage.js";
2
7
  import { safeBase64Decode, safeBase64Encode } from "./encode.js";
3
8
  import { wrapFetchWithPayment } from "./fetchWithPayment.js";
4
9
  import { getPaymentRequestHeader } from "./headers.js";
10
+ import {
11
+ getPermitSignatureFromCache,
12
+ savePermitSignatureToCache,
13
+ } from "./permitSignatureStorage.js";
5
14
 
6
- // Mock the createPaymentHeader function
7
- vi.mock("./sign.js", () => ({
8
- createPaymentHeader: vi.fn().mockResolvedValue("mock-payment-header"),
15
+ vi.mock("./common.js", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("./common.js")>();
17
+ return {
18
+ ...actual,
19
+ getSupportedSignatureType: vi.fn(
20
+ async (args: { eip712Extras?: { primaryType?: string } }) =>
21
+ args.eip712Extras?.primaryType ?? "TransferWithAuthorization",
22
+ ),
23
+ };
24
+ });
25
+
26
+ vi.mock("../extensions/erc20/__generated__/IERC20/read/allowance.js", () => ({
27
+ allowance: vi.fn(),
9
28
  }));
10
29
 
30
+ vi.mock(
31
+ "../extensions/erc20/__generated__/IERC20Permit/read/nonces.js",
32
+ () => ({
33
+ nonces: vi.fn(),
34
+ }),
35
+ );
36
+
11
37
  // Mock webLocalStorage
12
38
  vi.mock("../utils/storage/webStorage.js", () => ({
13
39
  webLocalStorage: {
@@ -17,6 +43,34 @@ vi.mock("../utils/storage/webStorage.js", () => ({
17
43
  },
18
44
  }));
19
45
 
46
+ const SIGNATURE = `0x${"11".repeat(65)}`;
47
+ const CACHED_SIGNATURE = `0x${"22".repeat(65)}`;
48
+ const OWNER = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd";
49
+ const PAY_TO = "0x1234567890123456789012345678901234567890";
50
+ const OTHER_ADDRESS = "0x9999999999999999999999999999999999999999";
51
+ const ASSET = "0x0000000000000000000000000000000000000001";
52
+
53
+ function base64Utf8(data: unknown): string {
54
+ return Buffer.from(JSON.stringify(data), "utf-8").toString("base64");
55
+ }
56
+
57
+ function paymentRequiredResponse(
58
+ data: unknown,
59
+ mode: "header" | "body",
60
+ ): Response {
61
+ if (mode === "header") {
62
+ return new Response(null, {
63
+ status: 402,
64
+ headers: { "payment-required": base64Utf8(data) },
65
+ });
66
+ }
67
+ return new Response(JSON.stringify(data), { status: 402 });
68
+ }
69
+
70
+ function successResponse(): Response {
71
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
72
+ }
73
+
20
74
  describe("wrapFetchWithPayment", () => {
21
75
  const mockPaymentRequirements = {
22
76
  scheme: "exact",
@@ -25,9 +79,9 @@ describe("wrapFetchWithPayment", () => {
25
79
  resource: "https://api.example.com/resource",
26
80
  description: "Test payment",
27
81
  mimeType: "application/json",
28
- payTo: "0x1234567890123456789012345678901234567890",
82
+ payTo: PAY_TO,
29
83
  maxTimeoutSeconds: 300,
30
- asset: "0x0000000000000000000000000000000000000001",
84
+ asset: ASSET,
31
85
  extra: {
32
86
  name: "Test Token",
33
87
  version: "1",
@@ -40,12 +94,31 @@ describe("wrapFetchWithPayment", () => {
40
94
  error: undefined,
41
95
  };
42
96
 
97
+ const canonicalV2Requirement = {
98
+ scheme: "exact",
99
+ network: "eip155:1",
100
+ amount: "12345",
101
+ payTo: PAY_TO,
102
+ maxTimeoutSeconds: 300,
103
+ asset: ASSET,
104
+ extra: {
105
+ name: "Test Token",
106
+ version: "1",
107
+ },
108
+ };
109
+
110
+ const canonicalV2Resource = {
111
+ url: "https://api.example.com/resource",
112
+ description: "Test payment",
113
+ mimeType: "application/json",
114
+ };
115
+
43
116
  const mockClient = {
44
117
  clientId: "test-client-id",
45
118
  } as Parameters<typeof wrapFetchWithPayment>[1];
46
119
 
47
120
  const mockAccount = {
48
- address: "0x1234567890123456789012345678901234567890",
121
+ address: OWNER,
49
122
  signTypedData: vi.fn(),
50
123
  };
51
124
 
@@ -55,8 +128,36 @@ describe("wrapFetchWithPayment", () => {
55
128
  switchChain: vi.fn(),
56
129
  } as unknown as Parameters<typeof wrapFetchWithPayment>[2];
57
130
 
131
+ function sentPayment(
132
+ mockFetch: ReturnType<typeof vi.fn>,
133
+ x402Version: number,
134
+ ) {
135
+ const init = mockFetch.mock.calls[1]?.[1] as RequestInit;
136
+ const headers = init.headers as Record<string, string>;
137
+ const header = headers[getPaymentRequestHeader(x402Version)];
138
+ expect(header).toBeDefined();
139
+ return JSON.parse(
140
+ Buffer.from(header as string, "base64").toString("utf-8"),
141
+ );
142
+ }
143
+
144
+ function signedValues() {
145
+ return mockAccount.signTypedData.mock.calls.map(
146
+ (call) => (call[0] as { message: { value: bigint } }).message.value,
147
+ );
148
+ }
149
+
150
+ function mockFetchWith(...responses: Response[]) {
151
+ const mockFetch = vi.fn();
152
+ for (const response of responses) {
153
+ mockFetch.mockResolvedValueOnce(response);
154
+ }
155
+ return mockFetch;
156
+ }
157
+
58
158
  beforeEach(() => {
59
159
  vi.clearAllMocks();
160
+ mockAccount.signTypedData.mockResolvedValue(SIGNATURE);
60
161
  });
61
162
 
62
163
  it("should pass through non-402 responses unchanged", async () => {
@@ -77,29 +178,11 @@ describe("wrapFetchWithPayment", () => {
77
178
  });
78
179
 
79
180
  it("should parse payment requirements from payment-required header when present", async () => {
80
- const encodedPaymentInfo = safeBase64Encode(
81
- JSON.stringify(mock402ResponseData),
181
+ const mockFetch = mockFetchWith(
182
+ paymentRequiredResponse(mock402ResponseData, "header"),
183
+ successResponse(),
82
184
  );
83
185
 
84
- const mock402Response = new Response(null, {
85
- status: 402,
86
- headers: {
87
- "payment-required": encodedPaymentInfo,
88
- },
89
- });
90
-
91
- const mockSuccessResponse = new Response(
92
- JSON.stringify({ success: true }),
93
- {
94
- status: 200,
95
- },
96
- );
97
-
98
- const mockFetch = vi
99
- .fn()
100
- .mockResolvedValueOnce(mock402Response)
101
- .mockResolvedValueOnce(mockSuccessResponse);
102
-
103
186
  const wrappedFetch = wrapFetchWithPayment(
104
187
  mockFetch,
105
188
  mockClient,
@@ -118,22 +201,11 @@ describe("wrapFetchWithPayment", () => {
118
201
  });
119
202
 
120
203
  it("should parse payment requirements from JSON body when payment-required header is absent", async () => {
121
- const mock402Response = new Response(JSON.stringify(mock402ResponseData), {
122
- status: 402,
123
- });
124
-
125
- const mockSuccessResponse = new Response(
126
- JSON.stringify({ success: true }),
127
- {
128
- status: 200,
129
- },
204
+ const mockFetch = mockFetchWith(
205
+ paymentRequiredResponse(mock402ResponseData, "body"),
206
+ successResponse(),
130
207
  );
131
208
 
132
- const mockFetch = vi
133
- .fn()
134
- .mockResolvedValueOnce(mock402Response)
135
- .mockResolvedValueOnce(mockSuccessResponse);
136
-
137
209
  const wrappedFetch = wrapFetchWithPayment(
138
210
  mockFetch,
139
211
  mockClient,
@@ -178,17 +250,7 @@ describe("wrapFetchWithPayment", () => {
178
250
  },
179
251
  });
180
252
 
181
- const mockSuccessResponse = new Response(
182
- JSON.stringify({ success: true }),
183
- {
184
- status: 200,
185
- },
186
- );
187
-
188
- const mockFetch = vi
189
- .fn()
190
- .mockResolvedValueOnce(mock402Response)
191
- .mockResolvedValueOnce(mockSuccessResponse);
253
+ const mockFetch = mockFetchWith(mock402Response, successResponse());
192
254
 
193
255
  // Use maxValue to verify which payment requirements are used
194
256
  // If header is used (500000), it should pass
@@ -206,47 +268,7 @@ describe("wrapFetchWithPayment", () => {
206
268
 
207
269
  // Should succeed because header value (500000) is under maxValue (1000000)
208
270
  expect(response.status).toBe(200);
209
- });
210
-
211
- it("should parse payment requirements from payment-required header", async () => {
212
- const encodedPaymentInfo = safeBase64Encode(
213
- JSON.stringify(mock402ResponseData),
214
- );
215
-
216
- const mock402Response = new Response(null, {
217
- status: 402,
218
- headers: {
219
- "payment-required": encodedPaymentInfo,
220
- },
221
- });
222
-
223
- const mockSuccessResponse = new Response(
224
- JSON.stringify({ success: true }),
225
- {
226
- status: 200,
227
- },
228
- );
229
-
230
- const mockFetch = vi
231
- .fn()
232
- .mockResolvedValueOnce(mock402Response)
233
- .mockResolvedValueOnce(mockSuccessResponse);
234
-
235
- const wrappedFetch = wrapFetchWithPayment(
236
- mockFetch,
237
- mockClient,
238
- mockWallet,
239
- );
240
- const response = await wrappedFetch("https://api.example.com/resource");
241
-
242
- expect(response.status).toBe(200);
243
- expect(mockFetch).toHaveBeenCalledTimes(2);
244
-
245
- // Verify the second call includes the payment header for the version
246
- const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit;
247
- expect(secondCallInit.headers).toHaveProperty(
248
- getPaymentRequestHeader(mock402ResponseData.x402Version),
249
- );
271
+ expect(signedValues()).toEqual([500000n]);
250
272
  });
251
273
 
252
274
  it("should correctly decode a raw base64 encoded payment-required header", async () => {
@@ -275,17 +297,7 @@ describe("wrapFetchWithPayment", () => {
275
297
  },
276
298
  });
277
299
 
278
- const mockSuccessResponse = new Response(
279
- JSON.stringify({ success: true }),
280
- {
281
- status: 200,
282
- },
283
- );
284
-
285
- const mockFetch = vi
286
- .fn()
287
- .mockResolvedValueOnce(mock402Response)
288
- .mockResolvedValueOnce(mockSuccessResponse);
300
+ const mockFetch = mockFetchWith(mock402Response, successResponse());
289
301
 
290
302
  // Use a wallet on Base (chain 8453) to match the payment requirements
291
303
  const baseWallet = {
@@ -310,4 +322,1061 @@ describe("wrapFetchWithPayment", () => {
310
322
  getPaymentRequestHeader(parsed.x402Version),
311
323
  );
312
324
  });
325
+
326
+ it("does not sign when a payment was already attempted for the request", async () => {
327
+ const mockFetch = mockFetchWith(
328
+ paymentRequiredResponse(mock402ResponseData, "header"),
329
+ );
330
+
331
+ const wrappedFetch = wrapFetchWithPayment(
332
+ mockFetch,
333
+ mockClient,
334
+ mockWallet,
335
+ );
336
+
337
+ await expect(
338
+ wrappedFetch("https://api.example.com/resource", {
339
+ __is402Retry: true,
340
+ } as RequestInit),
341
+ ).rejects.toThrow("Payment already attempted");
342
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
343
+ expect(mockFetch).toHaveBeenCalledTimes(1);
344
+ });
345
+
346
+ describe("x402 v2 payment requirements", () => {
347
+ it("pays a canonical v2 header requirement that only specifies amount", async () => {
348
+ const paymentRequired = {
349
+ x402Version: 2,
350
+ resource: canonicalV2Resource,
351
+ accepts: [canonicalV2Requirement],
352
+ };
353
+ const mockFetch = mockFetchWith(
354
+ paymentRequiredResponse(paymentRequired, "header"),
355
+ successResponse(),
356
+ );
357
+
358
+ const wrappedFetch = wrapFetchWithPayment(
359
+ mockFetch,
360
+ mockClient,
361
+ mockWallet,
362
+ { maxValue: 12345n },
363
+ );
364
+ const response = await wrappedFetch("https://api.example.com/resource");
365
+
366
+ expect(response.status).toBe(200);
367
+ expect(signedValues()).toEqual([12345n]);
368
+
369
+ const typedData = mockAccount.signTypedData.mock.calls[0]?.[0];
370
+ expect(typedData.primaryType).toBe("TransferWithAuthorization");
371
+ expect(typedData.domain.chainId).toBe(1);
372
+ expect(typedData.domain.verifyingContract).toBe(getAddress(ASSET));
373
+ expect(typedData.message.from).toBe(getAddress(OWNER));
374
+ expect(typedData.message.to).toBe(getAddress(PAY_TO));
375
+
376
+ const init = mockFetch.mock.calls[1]?.[1] as RequestInit;
377
+ expect(init.headers).toHaveProperty("PAYMENT-SIGNATURE");
378
+ expect(init.headers).not.toHaveProperty("X-PAYMENT");
379
+
380
+ const payment = sentPayment(mockFetch, 2);
381
+ expect(payment.x402Version).toBe(2);
382
+ expect(payment.payload.authorization.value).toBe("12345");
383
+ expect(payment.payload.signature).toBe(SIGNATURE);
384
+ });
385
+
386
+ it("includes the raw accepted requirement and top-level scheme/network in the v2 payload", async () => {
387
+ const paymentRequired = {
388
+ x402Version: 2,
389
+ resource: canonicalV2Resource,
390
+ accepts: [canonicalV2Requirement],
391
+ };
392
+ const mockFetch = mockFetchWith(
393
+ paymentRequiredResponse(paymentRequired, "header"),
394
+ successResponse(),
395
+ );
396
+
397
+ const wrappedFetch = wrapFetchWithPayment(
398
+ mockFetch,
399
+ mockClient,
400
+ mockWallet,
401
+ );
402
+ await wrappedFetch("https://api.example.com/resource");
403
+
404
+ const payment = sentPayment(mockFetch, 2);
405
+ expect(payment.scheme).toBe("exact");
406
+ expect(payment.network).toBe("eip155:1");
407
+ expect(payment.accepted).toEqual(canonicalV2Requirement);
408
+ expect(payment.accepted).not.toHaveProperty("maxAmountRequired");
409
+ expect(payment.resource).toEqual(canonicalV2Resource);
410
+ });
411
+
412
+ it("pays a canonical v2 JSON body requirement", async () => {
413
+ const paymentRequired = {
414
+ x402Version: 2,
415
+ accepts: [canonicalV2Requirement],
416
+ };
417
+ const mockFetch = mockFetchWith(
418
+ paymentRequiredResponse(paymentRequired, "body"),
419
+ successResponse(),
420
+ );
421
+
422
+ const wrappedFetch = wrapFetchWithPayment(
423
+ mockFetch,
424
+ mockClient,
425
+ mockWallet,
426
+ );
427
+ const response = await wrappedFetch("https://api.example.com/paid");
428
+
429
+ expect(response.status).toBe(200);
430
+ expect(signedValues()).toEqual([12345n]);
431
+
432
+ const payment = sentPayment(mockFetch, 2);
433
+ expect(payment.payload.authorization.value).toBe("12345");
434
+ expect(payment.accepted).toEqual(canonicalV2Requirement);
435
+ expect(payment.resource.url).toBe("https://api.example.com/paid");
436
+ });
437
+
438
+ it.each(["header", "body"] as const)(
439
+ "pays a v2 %s requirement with non-Latin-1 text",
440
+ async (mode) => {
441
+ const requirement = {
442
+ ...canonicalV2Requirement,
443
+ description: "Premium — content ✓",
444
+ };
445
+ const resource = {
446
+ ...canonicalV2Resource,
447
+ description: "Données 日本語",
448
+ };
449
+ const paymentRequired = {
450
+ x402Version: 2,
451
+ resource,
452
+ accepts: [requirement],
453
+ };
454
+ const mockFetch = mockFetchWith(
455
+ paymentRequiredResponse(paymentRequired, mode),
456
+ successResponse(),
457
+ );
458
+
459
+ const wrappedFetch = wrapFetchWithPayment(
460
+ mockFetch,
461
+ mockClient,
462
+ mockWallet,
463
+ );
464
+ const response = await wrappedFetch("https://api.example.com/resource");
465
+
466
+ expect(response.status).toBe(200);
467
+ const payment = sentPayment(mockFetch, 2);
468
+ expect(payment.accepted).toEqual(requirement);
469
+ expect(payment.resource).toEqual(resource);
470
+ expect(signedValues()).toEqual([12345n]);
471
+ },
472
+ );
473
+
474
+ it("pays a v2 envelope with v1-shaped requirements", async () => {
475
+ const paymentRequired = {
476
+ x402Version: 2,
477
+ error: "Payment required",
478
+ resource: { url: "https://api.example.com/resource" },
479
+ accepts: [mockPaymentRequirements],
480
+ };
481
+ const mockFetch = mockFetchWith(
482
+ paymentRequiredResponse(paymentRequired, "header"),
483
+ successResponse(),
484
+ );
485
+
486
+ const wrappedFetch = wrapFetchWithPayment(
487
+ mockFetch,
488
+ mockClient,
489
+ mockWallet,
490
+ );
491
+ const response = await wrappedFetch("https://api.example.com/resource");
492
+
493
+ expect(response.status).toBe(200);
494
+ expect(signedValues()).toEqual([1000000n]);
495
+
496
+ const payment = sentPayment(mockFetch, 2);
497
+ expect(payment.payload.authorization.value).toBe("1000000");
498
+ expect(payment.accepted).toEqual(mockPaymentRequirements);
499
+ expect(payment.resource).toEqual({
500
+ url: "https://api.example.com/resource",
501
+ });
502
+ });
503
+
504
+ it("pays a v1 body requirement with the v1 header and payload", async () => {
505
+ const paymentRequired = {
506
+ x402Version: 1,
507
+ accepts: [mockPaymentRequirements],
508
+ };
509
+ const mockFetch = mockFetchWith(
510
+ paymentRequiredResponse(paymentRequired, "body"),
511
+ successResponse(),
512
+ );
513
+
514
+ const wrappedFetch = wrapFetchWithPayment(
515
+ mockFetch,
516
+ mockClient,
517
+ mockWallet,
518
+ );
519
+ const response = await wrappedFetch("https://api.example.com/resource");
520
+
521
+ expect(response.status).toBe(200);
522
+ const init = mockFetch.mock.calls[1]?.[1] as RequestInit;
523
+ expect(init.headers).toHaveProperty("X-PAYMENT");
524
+ expect(init.headers).not.toHaveProperty("PAYMENT-SIGNATURE");
525
+
526
+ const payment = sentPayment(mockFetch, 1);
527
+ expect(Object.keys(payment).sort()).toEqual(
528
+ ["network", "payload", "scheme", "x402Version"].sort(),
529
+ );
530
+ expect(payment.x402Version).toBe(1);
531
+ expect(payment.payload.authorization.value).toBe("1000000");
532
+ expect(signedValues()).toEqual([1000000n]);
533
+ });
534
+
535
+ it("defaults to x402 v2 when the response has no x402Version", async () => {
536
+ const mockFetch = mockFetchWith(
537
+ paymentRequiredResponse(
538
+ { resource: canonicalV2Resource, accepts: [canonicalV2Requirement] },
539
+ "header",
540
+ ),
541
+ successResponse(),
542
+ );
543
+
544
+ const wrappedFetch = wrapFetchWithPayment(
545
+ mockFetch,
546
+ mockClient,
547
+ mockWallet,
548
+ );
549
+ await wrappedFetch("https://api.example.com/resource");
550
+
551
+ const init = mockFetch.mock.calls[1]?.[1] as RequestInit;
552
+ expect(init.headers).toHaveProperty("PAYMENT-SIGNATURE");
553
+ const payment = sentPayment(mockFetch, 2);
554
+ expect(payment.x402Version).toBe(2);
555
+ expect(payment.accepted).toEqual(canonicalV2Requirement);
556
+ });
557
+
558
+ describe.each(["header", "body"] as const)("%s", (mode) => {
559
+ it.each([
560
+ [1, { amount: "1", maxAmountRequired: "1000000000" }],
561
+ [1, { amount: "1000000000", maxAmountRequired: "1" }],
562
+ [2, { amount: "1", maxAmountRequired: "1000000000" }],
563
+ [2, { amount: "1000000000", maxAmountRequired: "1" }],
564
+ ])(
565
+ "rejects before signing when amounts differ (x402Version %i, %j)",
566
+ async (x402Version, amounts) => {
567
+ const paymentRequired = {
568
+ x402Version,
569
+ accepts: [{ ...mockPaymentRequirements, ...amounts }],
570
+ };
571
+ const mockFetch = mockFetchWith(
572
+ paymentRequiredResponse(paymentRequired, mode),
573
+ successResponse(),
574
+ );
575
+
576
+ const wrappedFetch = wrapFetchWithPayment(
577
+ mockFetch,
578
+ mockClient,
579
+ mockWallet,
580
+ );
581
+
582
+ await expect(
583
+ wrappedFetch("https://api.example.com/resource"),
584
+ ).rejects.toThrow("do not match");
585
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
586
+ expect(mockFetch).toHaveBeenCalledTimes(1);
587
+ },
588
+ );
589
+ });
590
+
591
+ it("rejects before signing when amounts differ with maxValue set", async () => {
592
+ const paymentRequired = {
593
+ x402Version: 2,
594
+ resource: canonicalV2Resource,
595
+ accepts: [
596
+ {
597
+ ...canonicalV2Requirement,
598
+ amount: "1",
599
+ maxAmountRequired: "1000000000",
600
+ },
601
+ ],
602
+ };
603
+ const mockFetch = mockFetchWith(
604
+ paymentRequiredResponse(paymentRequired, "header"),
605
+ successResponse(),
606
+ );
607
+
608
+ const wrappedFetch = wrapFetchWithPayment(
609
+ mockFetch,
610
+ mockClient,
611
+ mockWallet,
612
+ { maxValue: 10n },
613
+ );
614
+
615
+ await expect(
616
+ wrappedFetch("https://api.example.com/resource"),
617
+ ).rejects.toThrow("do not match");
618
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
619
+ expect(mockFetch).toHaveBeenCalledTimes(1);
620
+ });
621
+
622
+ it("rejects when a requirement that would not be selected has mismatched amounts", async () => {
623
+ const paymentRequired = {
624
+ x402Version: 2,
625
+ resource: canonicalV2Resource,
626
+ accepts: [
627
+ canonicalV2Requirement,
628
+ {
629
+ ...canonicalV2Requirement,
630
+ network: "eip155:8453",
631
+ amount: "1",
632
+ maxAmountRequired: "1000000000",
633
+ },
634
+ ],
635
+ };
636
+ const mockFetch = mockFetchWith(
637
+ paymentRequiredResponse(paymentRequired, "header"),
638
+ successResponse(),
639
+ );
640
+
641
+ const wrappedFetch = wrapFetchWithPayment(
642
+ mockFetch,
643
+ mockClient,
644
+ mockWallet,
645
+ );
646
+
647
+ await expect(
648
+ wrappedFetch("https://api.example.com/resource"),
649
+ ).rejects.toThrow("do not match");
650
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
651
+ expect(mockFetch).toHaveBeenCalledTimes(1);
652
+ });
653
+
654
+ it("accepts amount and maxAmountRequired when they are equal", async () => {
655
+ const paymentRequired = {
656
+ x402Version: 2,
657
+ resource: canonicalV2Resource,
658
+ accepts: [{ ...canonicalV2Requirement, maxAmountRequired: "12345" }],
659
+ };
660
+ const mockFetch = mockFetchWith(
661
+ paymentRequiredResponse(paymentRequired, "header"),
662
+ successResponse(),
663
+ );
664
+
665
+ const wrappedFetch = wrapFetchWithPayment(
666
+ mockFetch,
667
+ mockClient,
668
+ mockWallet,
669
+ );
670
+ const response = await wrappedFetch("https://api.example.com/resource");
671
+
672
+ expect(response.status).toBe(200);
673
+ expect(signedValues()).toEqual([12345n]);
674
+ });
675
+
676
+ it("signs the canonical form of a zero-padded amount", async () => {
677
+ const requirement = {
678
+ ...canonicalV2Requirement,
679
+ amount: "0100",
680
+ maxAmountRequired: "100",
681
+ };
682
+ const mockFetch = mockFetchWith(
683
+ paymentRequiredResponse(
684
+ {
685
+ x402Version: 2,
686
+ resource: canonicalV2Resource,
687
+ accepts: [requirement],
688
+ },
689
+ "header",
690
+ ),
691
+ successResponse(),
692
+ );
693
+
694
+ const wrappedFetch = wrapFetchWithPayment(
695
+ mockFetch,
696
+ mockClient,
697
+ mockWallet,
698
+ { maxValue: 100n },
699
+ );
700
+ await wrappedFetch("https://api.example.com/resource");
701
+
702
+ expect(signedValues()).toEqual([100n]);
703
+ const payment = sentPayment(mockFetch, 2);
704
+ expect(payment.payload.authorization.value).toBe("100");
705
+ expect(payment.accepted).toEqual(requirement);
706
+ });
707
+
708
+ it("pays the maximum uint256 amount", async () => {
709
+ const max = (2n ** 256n - 1n).toString();
710
+ const mockFetch = mockFetchWith(
711
+ paymentRequiredResponse(
712
+ {
713
+ x402Version: 2,
714
+ resource: canonicalV2Resource,
715
+ accepts: [{ ...canonicalV2Requirement, amount: max }],
716
+ },
717
+ "header",
718
+ ),
719
+ successResponse(),
720
+ );
721
+
722
+ const wrappedFetch = wrapFetchWithPayment(
723
+ mockFetch,
724
+ mockClient,
725
+ mockWallet,
726
+ );
727
+ await wrappedFetch("https://api.example.com/resource");
728
+
729
+ expect(signedValues()).toEqual([2n ** 256n - 1n]);
730
+ expect(sentPayment(mockFetch, 2).payload.authorization.value).toBe(max);
731
+ });
732
+
733
+ it.each([
734
+ ["canonical v2", { x402Version: 2, accepts: [canonicalV2Requirement] }],
735
+ ["v1-shaped", mock402ResponseData],
736
+ ])(
737
+ "blocks a %s payment when maxValue is 0n",
738
+ async (_name, paymentRequired) => {
739
+ const mockFetch = mockFetchWith(
740
+ paymentRequiredResponse(paymentRequired, "header"),
741
+ successResponse(),
742
+ );
743
+
744
+ const wrappedFetch = wrapFetchWithPayment(
745
+ mockFetch,
746
+ mockClient,
747
+ mockWallet,
748
+ { maxValue: 0n },
749
+ );
750
+
751
+ await expect(
752
+ wrappedFetch("https://api.example.com/resource"),
753
+ ).rejects.toThrow("Payment amount exceeds maximum allowed");
754
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
755
+ expect(mockFetch).toHaveBeenCalledTimes(1);
756
+ },
757
+ );
758
+
759
+ it("blocks a canonical v2 amount above maxValue", async () => {
760
+ const paymentRequired = {
761
+ x402Version: 2,
762
+ resource: canonicalV2Resource,
763
+ accepts: [canonicalV2Requirement],
764
+ };
765
+ const mockFetch = mockFetchWith(
766
+ paymentRequiredResponse(paymentRequired, "header"),
767
+ );
768
+
769
+ const wrappedFetch = wrapFetchWithPayment(
770
+ mockFetch,
771
+ mockClient,
772
+ mockWallet,
773
+ { maxValue: 12344n },
774
+ );
775
+
776
+ await expect(
777
+ wrappedFetch("https://api.example.com/resource"),
778
+ ).rejects.toThrow("Payment amount exceeds maximum allowed");
779
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
780
+ });
781
+
782
+ it.each([
783
+ ["amount", { amount: "1e3" }],
784
+ ["maxAmountRequired", { amount: undefined, maxAmountRequired: "1e3" }],
785
+ ["amount", { amount: "-1" }],
786
+ ["amount", { amount: "1.5" }],
787
+ ["amount", { amount: "" }],
788
+ ["amount", { amount: 1000 }],
789
+ ["amount", { amount: (2n ** 256n).toString() }],
790
+ ["amount", { amount: "0x10" }],
791
+ ["maxAmountRequired", { amount: undefined, maxAmountRequired: "0x10" }],
792
+ ["amount", { amount: " 100" }],
793
+ ["amount", { amount: "100\n" }],
794
+ ["amount", { amount: "+100" }],
795
+ ["amount", { amount: "1".repeat(79) }],
796
+ ["maxAmountRequired", { maxAmountRequired: "" }],
797
+ ])("rejects a malformed %s (%j)", async (_field, override) => {
798
+ const paymentRequired = {
799
+ x402Version: 2,
800
+ resource: canonicalV2Resource,
801
+ accepts: [{ ...canonicalV2Requirement, ...override }],
802
+ };
803
+ const mockFetch = mockFetchWith(
804
+ paymentRequiredResponse(paymentRequired, "header"),
805
+ );
806
+
807
+ const wrappedFetch = wrapFetchWithPayment(
808
+ mockFetch,
809
+ mockClient,
810
+ mockWallet,
811
+ );
812
+
813
+ await expect(
814
+ wrappedFetch("https://api.example.com/resource"),
815
+ ).rejects.toThrow("Invalid payment requirements");
816
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
817
+ expect(mockFetch).toHaveBeenCalledTimes(1);
818
+ });
819
+
820
+ it("rejects a requirement with no amount", async () => {
821
+ const { amount: _amount, ...withoutAmount } = canonicalV2Requirement;
822
+ const paymentRequired = {
823
+ x402Version: 2,
824
+ resource: canonicalV2Resource,
825
+ accepts: [withoutAmount],
826
+ };
827
+ const mockFetch = mockFetchWith(
828
+ paymentRequiredResponse(paymentRequired, "header"),
829
+ );
830
+
831
+ const wrappedFetch = wrapFetchWithPayment(
832
+ mockFetch,
833
+ mockClient,
834
+ mockWallet,
835
+ );
836
+
837
+ await expect(
838
+ wrappedFetch("https://api.example.com/resource"),
839
+ ).rejects.toThrow("missing amount or maxAmountRequired");
840
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
841
+ });
842
+
843
+ describe("resource resolution", () => {
844
+ it("keeps the requirement resource and echoes the top-level resource", async () => {
845
+ const requirement = {
846
+ ...mockPaymentRequirements,
847
+ resource: "https://api.example.com/a",
848
+ };
849
+ const topLevelResource = { url: "https://api.example.com/b" };
850
+ const mockFetch = mockFetchWith(
851
+ paymentRequiredResponse(
852
+ {
853
+ x402Version: 2,
854
+ resource: topLevelResource,
855
+ accepts: [requirement],
856
+ },
857
+ "header",
858
+ ),
859
+ successResponse(),
860
+ );
861
+ const selector = vi.fn(
862
+ (requirements: Array<{ resource: string }>) => requirements[0],
863
+ );
864
+
865
+ const wrappedFetch = wrapFetchWithPayment(
866
+ mockFetch,
867
+ mockClient,
868
+ mockWallet,
869
+ { paymentRequirementsSelector: selector as never },
870
+ );
871
+ await wrappedFetch("https://api.example.com/c");
872
+
873
+ expect(selector.mock.calls[0]?.[0]?.[0]?.resource).toBe(
874
+ "https://api.example.com/a",
875
+ );
876
+ const payment = sentPayment(mockFetch, 2);
877
+ expect(payment.resource).toEqual(topLevelResource);
878
+ expect(payment.accepted).toEqual(requirement);
879
+ });
880
+
881
+ it.each([
882
+ [
883
+ "a Request",
884
+ () => new Request("https://api.example.com/request"),
885
+ "https://api.example.com/request",
886
+ ],
887
+ [
888
+ "a URL",
889
+ () => new URL("https://api.example.com/url"),
890
+ "https://api.example.com/url",
891
+ ],
892
+ ])(
893
+ "falls back to the URL of %s",
894
+ async (_name, createInput, expectedUrl) => {
895
+ const mockFetch = mockFetchWith(
896
+ paymentRequiredResponse(
897
+ { x402Version: 2, accepts: [canonicalV2Requirement] },
898
+ "body",
899
+ ),
900
+ successResponse(),
901
+ );
902
+
903
+ const wrappedFetch = wrapFetchWithPayment(
904
+ mockFetch,
905
+ mockClient,
906
+ mockWallet,
907
+ );
908
+ await wrappedFetch(createInput() as unknown as RequestInfo);
909
+
910
+ expect(sentPayment(mockFetch, 2).resource.url).toBe(expectedUrl);
911
+ },
912
+ );
913
+
914
+ it("rejects before signing when no resource URL can be resolved", async () => {
915
+ const mockFetch = mockFetchWith(
916
+ paymentRequiredResponse(
917
+ { x402Version: 2, accepts: [canonicalV2Requirement] },
918
+ "body",
919
+ ),
920
+ successResponse(),
921
+ );
922
+
923
+ const wrappedFetch = wrapFetchWithPayment(
924
+ mockFetch,
925
+ mockClient,
926
+ mockWallet,
927
+ );
928
+
929
+ await expect(wrappedFetch("/paid")).rejects.toThrow();
930
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
931
+ expect(mockFetch).toHaveBeenCalledTimes(1);
932
+ });
933
+ });
934
+
935
+ describe("custom selector", () => {
936
+ const paymentRequired = {
937
+ x402Version: 2,
938
+ resource: canonicalV2Resource,
939
+ accepts: [canonicalV2Requirement],
940
+ };
941
+
942
+ it("validates requirements returned by a custom selector", async () => {
943
+ const mockFetch = mockFetchWith(
944
+ paymentRequiredResponse(paymentRequired, "header"),
945
+ );
946
+
947
+ const wrappedFetch = wrapFetchWithPayment(
948
+ mockFetch,
949
+ mockClient,
950
+ mockWallet,
951
+ {
952
+ paymentRequirementsSelector: (requirements) => {
953
+ const [first] = requirements;
954
+ return first ? { ...first, maxAmountRequired: "1e3" } : undefined;
955
+ },
956
+ },
957
+ );
958
+
959
+ await expect(
960
+ wrappedFetch("https://api.example.com/resource"),
961
+ ).rejects.toThrow("Invalid payment requirements");
962
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
963
+ });
964
+
965
+ it("validates a parsed requirement modified in place by a custom selector", async () => {
966
+ const mockFetch = mockFetchWith(
967
+ paymentRequiredResponse(paymentRequired, "header"),
968
+ );
969
+
970
+ const wrappedFetch = wrapFetchWithPayment(
971
+ mockFetch,
972
+ mockClient,
973
+ mockWallet,
974
+ {
975
+ paymentRequirementsSelector: (requirements) => {
976
+ const [first] = requirements;
977
+ if (first) {
978
+ first.maxAmountRequired = "0x10";
979
+ }
980
+ return first;
981
+ },
982
+ },
983
+ );
984
+
985
+ await expect(
986
+ wrappedFetch("https://api.example.com/resource"),
987
+ ).rejects.toThrow("Invalid payment requirements");
988
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
989
+ });
990
+
991
+ it("caps the amount of a copy returned by a custom selector", async () => {
992
+ const mockFetch = mockFetchWith(
993
+ paymentRequiredResponse(paymentRequired, "header"),
994
+ );
995
+
996
+ const wrappedFetch = wrapFetchWithPayment(
997
+ mockFetch,
998
+ mockClient,
999
+ mockWallet,
1000
+ {
1001
+ maxValue: 15000n,
1002
+ paymentRequirementsSelector: (requirements) => {
1003
+ const [first] = requirements;
1004
+ return first
1005
+ ? { ...first, maxAmountRequired: "20000" }
1006
+ : undefined;
1007
+ },
1008
+ },
1009
+ );
1010
+
1011
+ await expect(
1012
+ wrappedFetch("https://api.example.com/resource"),
1013
+ ).rejects.toThrow("Payment amount exceeds maximum allowed");
1014
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
1015
+ });
1016
+
1017
+ it("signs and echoes a copy returned by a custom selector", async () => {
1018
+ const mockFetch = mockFetchWith(
1019
+ paymentRequiredResponse(paymentRequired, "header"),
1020
+ successResponse(),
1021
+ );
1022
+
1023
+ const wrappedFetch = wrapFetchWithPayment(
1024
+ mockFetch,
1025
+ mockClient,
1026
+ mockWallet,
1027
+ {
1028
+ paymentRequirementsSelector: (requirements) => {
1029
+ const [first] = requirements;
1030
+ return first
1031
+ ? { ...first, maxAmountRequired: "20000" }
1032
+ : undefined;
1033
+ },
1034
+ },
1035
+ );
1036
+ await wrappedFetch("https://api.example.com/resource");
1037
+
1038
+ expect(signedValues()).toEqual([20000n]);
1039
+ const payment = sentPayment(mockFetch, 2);
1040
+ expect(payment.payload.authorization.value).toBe("20000");
1041
+ expect(payment.accepted.maxAmountRequired).toBe("20000");
1042
+ });
1043
+
1044
+ it("exposes the normalised amount as maxAmountRequired to selectors", async () => {
1045
+ const mockFetch = mockFetchWith(
1046
+ paymentRequiredResponse(paymentRequired, "header"),
1047
+ successResponse(),
1048
+ );
1049
+ const selector = vi.fn(
1050
+ (requirements: Array<{ maxAmountRequired: string }>) =>
1051
+ requirements[0],
1052
+ );
1053
+
1054
+ const wrappedFetch = wrapFetchWithPayment(
1055
+ mockFetch,
1056
+ mockClient,
1057
+ mockWallet,
1058
+ {
1059
+ paymentRequirementsSelector: selector as never,
1060
+ },
1061
+ );
1062
+ await wrappedFetch("https://api.example.com/resource");
1063
+
1064
+ expect(selector.mock.calls[0]?.[0]).toEqual([
1065
+ expect.objectContaining({
1066
+ maxAmountRequired: "12345",
1067
+ resource: canonicalV2Resource.url,
1068
+ description: "",
1069
+ mimeType: "",
1070
+ }),
1071
+ ]);
1072
+ expect(signedValues()).toEqual([12345n]);
1073
+ expect(sentPayment(mockFetch, 2).accepted).toEqual(
1074
+ canonicalV2Requirement,
1075
+ );
1076
+ });
1077
+ });
1078
+ });
1079
+
1080
+ describe("upto permit cache", () => {
1081
+ const uptoRequirement = {
1082
+ ...canonicalV2Requirement,
1083
+ scheme: "upto",
1084
+ amount: "1000",
1085
+ extra: {
1086
+ name: "Test Token",
1087
+ version: "1",
1088
+ primaryType: "Permit",
1089
+ },
1090
+ };
1091
+
1092
+ const cacheParams = {
1093
+ chainId: 1,
1094
+ asset: ASSET,
1095
+ owner: OWNER,
1096
+ spender: PAY_TO,
1097
+ };
1098
+
1099
+ function createMemoryStorage(): AsyncStorage {
1100
+ const store = new Map<string, string>();
1101
+ return {
1102
+ getItem: async (key) => store.get(key) ?? null,
1103
+ setItem: async (key, value) => {
1104
+ store.set(key, value);
1105
+ },
1106
+ removeItem: async (key) => {
1107
+ store.delete(key);
1108
+ },
1109
+ };
1110
+ }
1111
+
1112
+ async function seedCachedPermit(
1113
+ storage: AsyncStorage,
1114
+ value: string,
1115
+ overrides: {
1116
+ deadlineOffset?: number;
1117
+ from?: string;
1118
+ to?: string;
1119
+ envelope?: Record<string, unknown>;
1120
+ } = {},
1121
+ ) {
1122
+ const deadline = String(
1123
+ Math.floor(Date.now() / 1000) + (overrides.deadlineOffset ?? 3600),
1124
+ );
1125
+ await savePermitSignatureToCache(
1126
+ storage,
1127
+ cacheParams,
1128
+ {
1129
+ x402Version: 1,
1130
+ scheme: "upto",
1131
+ network: "eip155:1",
1132
+ ...overrides.envelope,
1133
+ payload: {
1134
+ signature: CACHED_SIGNATURE,
1135
+ authorization: {
1136
+ from: overrides.from ?? OWNER,
1137
+ to: overrides.to ?? PAY_TO,
1138
+ value,
1139
+ validAfter: "0",
1140
+ validBefore: deadline,
1141
+ nonce: `0x${"00".repeat(32)}`,
1142
+ },
1143
+ },
1144
+ } as Parameters<typeof savePermitSignatureToCache>[2],
1145
+ deadline,
1146
+ value,
1147
+ );
1148
+ }
1149
+
1150
+ beforeEach(() => {
1151
+ vi.mocked(allowance).mockResolvedValue(10n ** 18n);
1152
+ vi.mocked(nonces).mockResolvedValue(0n);
1153
+ });
1154
+
1155
+ it("reuses a cached permit that covers the amount, in the current envelope", async () => {
1156
+ const storage = createMemoryStorage();
1157
+ await seedCachedPermit(storage, "5000");
1158
+
1159
+ const paymentRequired = {
1160
+ x402Version: 2,
1161
+ resource: canonicalV2Resource,
1162
+ accepts: [uptoRequirement],
1163
+ };
1164
+ const mockFetch = mockFetchWith(
1165
+ paymentRequiredResponse(paymentRequired, "header"),
1166
+ successResponse(),
1167
+ );
1168
+
1169
+ const wrappedFetch = wrapFetchWithPayment(
1170
+ mockFetch,
1171
+ mockClient,
1172
+ mockWallet,
1173
+ { storage },
1174
+ );
1175
+ const response = await wrappedFetch("https://api.example.com/resource");
1176
+
1177
+ expect(response.status).toBe(200);
1178
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
1179
+
1180
+ const payment = sentPayment(mockFetch, 2);
1181
+ expect(payment.x402Version).toBe(2);
1182
+ expect(payment.scheme).toBe("upto");
1183
+ expect(payment.network).toBe("eip155:1");
1184
+ expect(payment.accepted).toEqual(uptoRequirement);
1185
+ expect(payment.resource).toEqual(canonicalV2Resource);
1186
+ expect(payment.payload.signature).toBe(CACHED_SIGNATURE);
1187
+ });
1188
+
1189
+ it("signs a new permit when the cached permit is below the amount", async () => {
1190
+ const storage = createMemoryStorage();
1191
+ await seedCachedPermit(storage, "999");
1192
+
1193
+ const paymentRequired = {
1194
+ x402Version: 2,
1195
+ resource: canonicalV2Resource,
1196
+ accepts: [uptoRequirement],
1197
+ };
1198
+ const mockFetch = mockFetchWith(
1199
+ paymentRequiredResponse(paymentRequired, "header"),
1200
+ successResponse(),
1201
+ );
1202
+
1203
+ const wrappedFetch = wrapFetchWithPayment(
1204
+ mockFetch,
1205
+ mockClient,
1206
+ mockWallet,
1207
+ { storage },
1208
+ );
1209
+ const response = await wrappedFetch("https://api.example.com/resource");
1210
+
1211
+ expect(response.status).toBe(200);
1212
+ expect(signedValues()).toEqual([1000n]);
1213
+
1214
+ const payment = sentPayment(mockFetch, 2);
1215
+ expect(payment.payload.signature).toBe(SIGNATURE);
1216
+ expect(payment.payload.authorization.value).toBe("1000");
1217
+ expect(payment.accepted).toEqual(uptoRequirement);
1218
+
1219
+ const cached = await getPermitSignatureFromCache(storage, cacheParams);
1220
+ expect(cached?.maxAmount).toBe("1000");
1221
+ expect(cached?.payload.x402Version).toBe(2);
1222
+ expect(cached?.payload.payload).toMatchObject({
1223
+ signature: SIGNATURE,
1224
+ authorization: { value: "1000" },
1225
+ });
1226
+ });
1227
+
1228
+ it.each([
1229
+ { name: "a cached value equal to the amount", reused: true },
1230
+ {
1231
+ name: "a minAmountRequired covered by the allowance",
1232
+ allowance: 600n,
1233
+ minAmountRequired: "500",
1234
+ reused: true,
1235
+ },
1236
+ {
1237
+ name: "a cached value above maxValue",
1238
+ maxValue: 1000n,
1239
+ cachedValue: "5000",
1240
+ reused: false,
1241
+ },
1242
+ { name: "an expired deadline", deadlineOffset: -60, reused: false },
1243
+ { name: "an allowance below the amount", allowance: 999n, reused: false },
1244
+ {
1245
+ name: "a minAmountRequired of zero",
1246
+ allowance: 0n,
1247
+ minAmountRequired: "0",
1248
+ reused: false,
1249
+ },
1250
+ { name: "a malformed cached value", cachedValue: "1e3", reused: false },
1251
+ {
1252
+ name: "a permit for another spender",
1253
+ to: OTHER_ADDRESS,
1254
+ reused: false,
1255
+ },
1256
+ {
1257
+ name: "a permit from another owner",
1258
+ from: OTHER_ADDRESS,
1259
+ reused: false,
1260
+ },
1261
+ ])("handles $name", async (testCase) => {
1262
+ const storage = createMemoryStorage();
1263
+ await seedCachedPermit(storage, testCase.cachedValue ?? "1000", {
1264
+ deadlineOffset: testCase.deadlineOffset,
1265
+ from: testCase.from,
1266
+ to: testCase.to,
1267
+ });
1268
+ if (testCase.allowance !== undefined) {
1269
+ vi.mocked(allowance).mockResolvedValue(testCase.allowance);
1270
+ }
1271
+
1272
+ const requirement = testCase.minAmountRequired
1273
+ ? {
1274
+ ...uptoRequirement,
1275
+ extra: {
1276
+ ...uptoRequirement.extra,
1277
+ minAmountRequired: testCase.minAmountRequired,
1278
+ },
1279
+ }
1280
+ : uptoRequirement;
1281
+ const mockFetch = mockFetchWith(
1282
+ paymentRequiredResponse(
1283
+ {
1284
+ x402Version: 2,
1285
+ resource: canonicalV2Resource,
1286
+ accepts: [requirement],
1287
+ },
1288
+ "header",
1289
+ ),
1290
+ successResponse(),
1291
+ );
1292
+
1293
+ const wrappedFetch = wrapFetchWithPayment(
1294
+ mockFetch,
1295
+ mockClient,
1296
+ mockWallet,
1297
+ { storage, maxValue: testCase.maxValue },
1298
+ );
1299
+ await wrappedFetch("https://api.example.com/resource");
1300
+
1301
+ const payment = sentPayment(mockFetch, 2);
1302
+ if (testCase.reused) {
1303
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
1304
+ expect(payment.payload.signature).toBe(CACHED_SIGNATURE);
1305
+ } else {
1306
+ expect(signedValues()).toEqual([1000n]);
1307
+ expect(payment.payload.signature).toBe(SIGNATURE);
1308
+ expect(payment.payload.authorization.value).toBe("1000");
1309
+ }
1310
+ });
1311
+
1312
+ it("reuses a v2 cached permit in a v1 envelope for a v1 requirement", async () => {
1313
+ const storage = createMemoryStorage();
1314
+ await seedCachedPermit(storage, "5000", {
1315
+ envelope: {
1316
+ x402Version: 2,
1317
+ accepted: uptoRequirement,
1318
+ resource: canonicalV2Resource,
1319
+ },
1320
+ });
1321
+
1322
+ const v1Requirement = {
1323
+ ...mockPaymentRequirements,
1324
+ scheme: "upto",
1325
+ maxAmountRequired: "1000",
1326
+ extra: uptoRequirement.extra,
1327
+ };
1328
+ const mockFetch = mockFetchWith(
1329
+ paymentRequiredResponse(
1330
+ { x402Version: 1, accepts: [v1Requirement] },
1331
+ "body",
1332
+ ),
1333
+ successResponse(),
1334
+ );
1335
+
1336
+ const wrappedFetch = wrapFetchWithPayment(
1337
+ mockFetch,
1338
+ mockClient,
1339
+ mockWallet,
1340
+ { storage },
1341
+ );
1342
+ await wrappedFetch("https://api.example.com/resource");
1343
+
1344
+ expect(mockAccount.signTypedData).not.toHaveBeenCalled();
1345
+ const payment = sentPayment(mockFetch, 1);
1346
+ expect(Object.keys(payment).sort()).toEqual(
1347
+ ["network", "payload", "scheme", "x402Version"].sort(),
1348
+ );
1349
+ expect(payment.x402Version).toBe(1);
1350
+ expect(payment.payload.signature).toBe(CACHED_SIGNATURE);
1351
+ });
1352
+
1353
+ it("writes and clears the permit with the default storage when payment is rejected", async () => {
1354
+ const paymentRequired = {
1355
+ x402Version: 2,
1356
+ resource: canonicalV2Resource,
1357
+ accepts: [uptoRequirement],
1358
+ };
1359
+ const mockFetch = mockFetchWith(
1360
+ paymentRequiredResponse(paymentRequired, "header"),
1361
+ paymentRequiredResponse(paymentRequired, "header"),
1362
+ );
1363
+
1364
+ const wrappedFetch = wrapFetchWithPayment(
1365
+ mockFetch,
1366
+ mockClient,
1367
+ mockWallet,
1368
+ );
1369
+ const response = await wrappedFetch("https://api.example.com/resource");
1370
+
1371
+ expect(response.status).toBe(402);
1372
+ const expectedKey = `x402:permit:1:${ASSET.toLowerCase()}:${OWNER.toLowerCase()}:${PAY_TO.toLowerCase()}`;
1373
+ expect(vi.mocked(webLocalStorage.setItem).mock.calls[0]?.[0]).toBe(
1374
+ expectedKey,
1375
+ );
1376
+ expect(vi.mocked(webLocalStorage.removeItem)).toHaveBeenCalledTimes(1);
1377
+ expect(vi.mocked(webLocalStorage.removeItem).mock.calls[0]?.[0]).toBe(
1378
+ expectedKey,
1379
+ );
1380
+ });
1381
+ });
313
1382
  });