mppx 0.5.13 → 0.5.16

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 (83) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/Method.d.ts +5 -2
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  6. package/dist/mcp-sdk/server/Transport.js +8 -2
  7. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  8. package/dist/server/Mppx.d.ts.map +1 -1
  9. package/dist/server/Mppx.js +17 -10
  10. package/dist/server/Mppx.js.map +1 -1
  11. package/dist/server/Request.js +5 -1
  12. package/dist/server/Request.js.map +1 -1
  13. package/dist/server/Transport.d.ts.map +1 -1
  14. package/dist/server/Transport.js +4 -0
  15. package/dist/server/Transport.js.map +1 -1
  16. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  17. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/stripe/server/internal/html.gen.js +1 -1
  19. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +4 -2
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  24. package/dist/tempo/client/SessionManager.js +20 -10
  25. package/dist/tempo/client/SessionManager.js.map +1 -1
  26. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  27. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  28. package/dist/tempo/internal/fee-payer.js +99 -23
  29. package/dist/tempo/internal/fee-payer.js.map +1 -1
  30. package/dist/tempo/server/Charge.d.ts.map +1 -1
  31. package/dist/tempo/server/Charge.js +6 -0
  32. package/dist/tempo/server/Charge.js.map +1 -1
  33. package/dist/tempo/server/Session.d.ts +4 -0
  34. package/dist/tempo/server/Session.d.ts.map +1 -1
  35. package/dist/tempo/server/Session.js +79 -48
  36. package/dist/tempo/server/Session.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/dist/tempo/server/internal/transport.d.ts +0 -7
  42. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  43. package/dist/tempo/server/internal/transport.js +84 -13
  44. package/dist/tempo/server/internal/transport.js.map +1 -1
  45. package/dist/tempo/session/Chain.d.ts +5 -0
  46. package/dist/tempo/session/Chain.d.ts.map +1 -1
  47. package/dist/tempo/session/Chain.js +202 -63
  48. package/dist/tempo/session/Chain.js.map +1 -1
  49. package/dist/tempo/session/ChannelStore.d.ts +1 -0
  50. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  51. package/dist/tempo/session/ChannelStore.js +38 -15
  52. package/dist/tempo/session/ChannelStore.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/Method.ts +5 -2
  55. package/src/internal/changeset.test.ts +106 -0
  56. package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
  57. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  58. package/src/mcp-sdk/server/Transport.ts +10 -2
  59. package/src/proxy/Proxy.test.ts +149 -1
  60. package/src/server/Mppx.test.ts +120 -0
  61. package/src/server/Mppx.ts +27 -11
  62. package/src/server/Request.test.ts +46 -1
  63. package/src/server/Request.ts +6 -1
  64. package/src/server/Transport.test.ts +2 -0
  65. package/src/server/Transport.ts +4 -0
  66. package/src/stripe/server/internal/html.gen.ts +1 -1
  67. package/src/tempo/Methods.test.ts +13 -0
  68. package/src/tempo/Methods.ts +23 -16
  69. package/src/tempo/client/SessionManager.ts +32 -9
  70. package/src/tempo/internal/fee-payer.test.ts +88 -16
  71. package/src/tempo/internal/fee-payer.ts +118 -23
  72. package/src/tempo/server/Charge.test.ts +73 -0
  73. package/src/tempo/server/Charge.ts +6 -0
  74. package/src/tempo/server/Session.test.ts +934 -47
  75. package/src/tempo/server/Session.ts +100 -52
  76. package/src/tempo/server/internal/html.gen.ts +1 -1
  77. package/src/tempo/server/internal/transport.test.ts +321 -10
  78. package/src/tempo/server/internal/transport.ts +101 -14
  79. package/src/tempo/session/Chain.test.ts +225 -2
  80. package/src/tempo/session/Chain.ts +250 -65
  81. package/src/tempo/session/ChannelStore.test.ts +23 -0
  82. package/src/tempo/session/ChannelStore.ts +46 -13
  83. package/src/viem/Client.test.ts +52 -1
@@ -2,7 +2,11 @@ import * as node_http from 'node:http'
2
2
 
3
3
  import type { z } from 'mppx'
4
4
  import { Challenge, Credential } from 'mppx'
5
- import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
5
+ import {
6
+ Mppx as Mppx_server,
7
+ Transport as ServerTransport,
8
+ tempo as tempo_server,
9
+ } from 'mppx/server'
6
10
  import { Base64 } from 'ox'
7
11
  import {
8
12
  type Address,
@@ -45,6 +49,7 @@ import {
45
49
  } from '../internal/defaults.js'
46
50
  import type * as Methods from '../Methods.js'
47
51
  import * as ChannelStore from '../session/ChannelStore.js'
52
+ import { deserializeSessionReceipt } from '../session/Receipt.js'
48
53
  import type { SessionReceipt } from '../session/Types.js'
49
54
  import { signVoucher } from '../session/Voucher.js'
50
55
  import * as TempoWs from '../session/Ws.js'
@@ -149,6 +154,40 @@ describe.runIf(isLocalnet)('session', () => {
149
154
  expect(ch!.highestVoucherAmount).toBe(1000000n)
150
155
  })
151
156
 
157
+ test('fee-payer policy override is enforced for sponsored open', async () => {
158
+ const salt = nextSalt()
159
+ const { channelId, serializedTransaction } = await signOpenChannel({
160
+ escrow: escrowContract,
161
+ payer,
162
+ payee: recipient,
163
+ token: currency,
164
+ deposit: 10000000n,
165
+ salt,
166
+ feePayer: true,
167
+ })
168
+ const server = createServer({
169
+ feePayer: recipientAccount,
170
+ feePayerPolicy: { maxGas: 1n },
171
+ })
172
+
173
+ await expect(
174
+ server.verify({
175
+ credential: {
176
+ challenge: makeChallenge({ channelId }),
177
+ payload: {
178
+ action: 'open' as const,
179
+ type: 'transaction' as const,
180
+ channelId,
181
+ transaction: serializedTransaction,
182
+ cumulativeAmount: '1000000',
183
+ signature: await signTestVoucher(channelId, 1000000n),
184
+ },
185
+ },
186
+ request: makeRequest({ feePayer: true }),
187
+ }),
188
+ ).rejects.toThrow('gas exceeds sponsor policy')
189
+ })
190
+
152
191
  test('rejects open when payee mismatch', async () => {
153
192
  const wrongPayee = accounts[3].address
154
193
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n, {
@@ -299,6 +338,66 @@ describe.runIf(isLocalnet)('session', () => {
299
338
  expect(ch!.highestVoucherAmount).toBe(1000000n)
300
339
  })
301
340
 
341
+ test('reopen with a case-variant channelId does not reset available balance', async () => {
342
+ let open:
343
+ | {
344
+ channelId: Hex
345
+ serializedTransaction: Hex
346
+ }
347
+ | undefined
348
+ for (let i = 0; i < 10; i++) {
349
+ const candidate = await createSignedOpenTransaction(10000000n)
350
+ if (/[a-f]/.test(candidate.channelId)) {
351
+ open = candidate
352
+ break
353
+ }
354
+ }
355
+ if (!open) throw new Error('failed to generate channelId with alphabetic hex characters')
356
+
357
+ const { channelId, serializedTransaction } = open
358
+ const caseVariantChannelId = channelId.replace(/[a-f]/, (character) =>
359
+ character.toUpperCase(),
360
+ ) as Hex
361
+ const server = createServer()
362
+
363
+ await server.verify({
364
+ credential: {
365
+ challenge: makeChallenge({ id: 'open-1', channelId }),
366
+ payload: {
367
+ action: 'open' as const,
368
+ type: 'transaction' as const,
369
+ channelId,
370
+ transaction: serializedTransaction,
371
+ cumulativeAmount: '5000000',
372
+ signature: await signTestVoucher(channelId, 5000000n),
373
+ },
374
+ },
375
+ request: makeRequest(),
376
+ })
377
+
378
+ await charge(store, channelId, 4000000n)
379
+
380
+ const reopenReceipt = (await server.verify({
381
+ credential: {
382
+ challenge: makeChallenge({ id: 'open-2', channelId: caseVariantChannelId }),
383
+ payload: {
384
+ action: 'open' as const,
385
+ type: 'transaction' as const,
386
+ channelId: caseVariantChannelId,
387
+ transaction: serializedTransaction,
388
+ cumulativeAmount: '5000000',
389
+ signature: await signTestVoucher(caseVariantChannelId, 5000000n),
390
+ },
391
+ },
392
+ request: makeRequest(),
393
+ })) as SessionReceipt
394
+
395
+ expect(reopenReceipt.spent).toBe('4000000')
396
+ await expect(charge(store, caseVariantChannelId, 2000000n)).rejects.toThrow(
397
+ 'requested 2000000, available 1000000',
398
+ )
399
+ })
400
+
302
401
  test('rejects voucher below settledOnChain', async () => {
303
402
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
304
403
  const server = createServer()
@@ -1036,6 +1135,40 @@ describe.runIf(isLocalnet)('session', () => {
1036
1135
  expect(ch!.deposit).toBe(20000000n)
1037
1136
  })
1038
1137
 
1138
+ test('fee-payer policy override is enforced for sponsored topUp', async () => {
1139
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1140
+ const server = createServer({
1141
+ feePayer: recipientAccount,
1142
+ feePayerPolicy: { maxGas: 1n },
1143
+ })
1144
+ await openServerChannel(server, channelId, serializedTransaction)
1145
+
1146
+ const { serializedTransaction: topUpTx } = await signTopUpChannel({
1147
+ escrow: escrowContract,
1148
+ payer,
1149
+ channelId,
1150
+ token: currency,
1151
+ amount: 10000000n,
1152
+ feePayer: true,
1153
+ })
1154
+
1155
+ await expect(
1156
+ server.verify({
1157
+ credential: {
1158
+ challenge: makeChallenge({ id: 'challenge-topup-policy', channelId }),
1159
+ payload: {
1160
+ action: 'topUp' as const,
1161
+ type: 'transaction' as const,
1162
+ channelId,
1163
+ transaction: topUpTx,
1164
+ additionalDeposit: '10000000',
1165
+ },
1166
+ },
1167
+ request: makeRequest({ feePayer: true }),
1168
+ }),
1169
+ ).rejects.toThrow('gas exceeds sponsor policy')
1170
+ })
1171
+
1039
1172
  test('topUp receipt preserves spent and units from prior charges', async () => {
1040
1173
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1041
1174
  const server = createServer()
@@ -2512,7 +2645,7 @@ describe.runIf(isLocalnet)('session', () => {
2512
2645
  })
2513
2646
 
2514
2647
  describe('protocol compatibility', () => {
2515
- test('HEAD voucher management request falls through to content handler', () => {
2648
+ test('HEAD voucher request is gated as a non-billable management request', () => {
2516
2649
  const server = createServer()
2517
2650
  const response = server.respond!({
2518
2651
  credential: {
@@ -2521,14 +2654,137 @@ describe.runIf(isLocalnet)('session', () => {
2521
2654
  }),
2522
2655
  payload: { action: 'voucher' },
2523
2656
  },
2657
+ envelope: {
2658
+ capturedRequest: {
2659
+ hasBody: false,
2660
+ headers: new Headers(),
2661
+ method: 'HEAD',
2662
+ url: new URL('https://api.example.com/resource'),
2663
+ },
2664
+ },
2524
2665
  input: new Request('https://api.example.com/resource', {
2525
2666
  method: 'HEAD',
2526
2667
  }),
2527
2668
  } as never)
2528
2669
 
2670
+ expect(response).toBeInstanceOf(Response)
2671
+ expect((response as Response).status).toBe(204)
2672
+ })
2673
+
2674
+ test('captured request classification wins over the raw input method', () => {
2675
+ const server = createServer()
2676
+ const response = server.respond!({
2677
+ credential: {
2678
+ challenge: makeChallenge({
2679
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
2680
+ }),
2681
+ payload: { action: 'voucher' },
2682
+ },
2683
+ envelope: {
2684
+ capturedRequest: {
2685
+ hasBody: false,
2686
+ headers: new Headers(),
2687
+ method: 'POST',
2688
+ url: new URL('mcp://request/tools%2Fcall'),
2689
+ },
2690
+ },
2691
+ input: new Request('https://api.example.com/resource', {
2692
+ method: 'GET',
2693
+ }),
2694
+ } as never)
2695
+
2696
+ expect(response).toBeInstanceOf(Response)
2697
+ expect((response as Response).status).toBe(204)
2698
+ })
2699
+
2700
+ test('captured request body metadata wins over a bodyless raw input', () => {
2701
+ const server = createServer()
2702
+ const response = server.respond!({
2703
+ credential: {
2704
+ challenge: makeChallenge({
2705
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
2706
+ }),
2707
+ payload: { action: 'voucher' },
2708
+ },
2709
+ envelope: {
2710
+ capturedRequest: {
2711
+ hasBody: true,
2712
+ headers: new Headers(),
2713
+ method: 'POST',
2714
+ url: new URL('mcp://request/tools%2Fcall'),
2715
+ },
2716
+ },
2717
+ input: new Request('https://api.example.com/resource', {
2718
+ method: 'POST',
2719
+ }),
2720
+ } as never)
2721
+
2529
2722
  expect(response).toBeUndefined()
2530
2723
  })
2531
2724
 
2725
+ test('bills repeated MCP voucher replays using the captured request snapshot', async () => {
2726
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
2727
+ const server = createServer()
2728
+ const mcpCapturedRequest = await ServerTransport.mcp().captureRequest?.({
2729
+ id: 1,
2730
+ jsonrpc: '2.0',
2731
+ method: 'tools/call',
2732
+ params: {},
2733
+ })
2734
+ if (!mcpCapturedRequest) throw new Error('missing MCP captured request')
2735
+
2736
+ const openChallenge = makeChallenge({ id: 'mcp-open', channelId })
2737
+ const openCredential = {
2738
+ challenge: openChallenge,
2739
+ payload: {
2740
+ action: 'open' as const,
2741
+ type: 'transaction' as const,
2742
+ channelId,
2743
+ transaction: serializedTransaction,
2744
+ cumulativeAmount: '2000000',
2745
+ signature: await signTestVoucher(channelId, 2000000n),
2746
+ },
2747
+ }
2748
+ const openReceipt = (await server.verify({
2749
+ credential: openCredential,
2750
+ envelope: {
2751
+ capturedRequest: mcpCapturedRequest,
2752
+ challenge: openChallenge,
2753
+ credential: openCredential,
2754
+ },
2755
+ request: makeRequest({ amount: '1' }),
2756
+ } as never)) as SessionReceipt
2757
+ expect(openReceipt.spent).toBe('1000000')
2758
+ expect(openReceipt.units).toBe(1)
2759
+
2760
+ const replayChallenge = makeChallenge({ id: 'mcp-replay', channelId })
2761
+ const replayCredential = {
2762
+ challenge: replayChallenge,
2763
+ payload: {
2764
+ action: 'voucher' as const,
2765
+ channelId,
2766
+ cumulativeAmount: '2000000',
2767
+ signature: await signTestVoucher(channelId, 2000000n),
2768
+ },
2769
+ }
2770
+ const replayReceipt = (await server.verify({
2771
+ credential: replayCredential,
2772
+ envelope: {
2773
+ capturedRequest: mcpCapturedRequest,
2774
+ challenge: replayChallenge,
2775
+ credential: replayCredential,
2776
+ },
2777
+ request: makeRequest({ amount: '1' }),
2778
+ } as never)) as SessionReceipt
2779
+
2780
+ expect(replayReceipt.spent).toBe('2000000')
2781
+ expect(replayReceipt.units).toBe(2)
2782
+
2783
+ const channel = await store.getChannel(channelId)
2784
+ expect(channel?.spent).toBe(2000000n)
2785
+ expect(channel?.units).toBe(2)
2786
+ })
2787
+
2532
2788
  test('ignores unknown challenge and credential fields for forward compatibility', async () => {
2533
2789
  const challenge = Challenge.from({
2534
2790
  id: 'forward-compat',
@@ -2583,6 +2839,99 @@ describe.runIf(isLocalnet)('session', () => {
2583
2839
  expect(second.status).toBe(200)
2584
2840
  })
2585
2841
 
2842
+ test('plain HTTP session charges content requests and rejects same-voucher replay', async () => {
2843
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2844
+ const handler = createHandler()
2845
+ const route = handler.session({
2846
+ amount: '1',
2847
+ decimals: 6,
2848
+ unitType: 'token',
2849
+ })
2850
+
2851
+ const first = await route(new Request('https://api.example.com/resource'))
2852
+ if (first.status !== 402) throw new Error('expected challenge')
2853
+ const issuedChallenge = Challenge.fromResponse(first.challenge)
2854
+
2855
+ const authorization = Credential.serialize({
2856
+ challenge: issuedChallenge,
2857
+ payload: {
2858
+ action: 'open',
2859
+ type: 'transaction',
2860
+ channelId,
2861
+ transaction: serializedTransaction,
2862
+ cumulativeAmount: '1000000',
2863
+ signature: await signTestVoucher(channelId, 1000000n),
2864
+ },
2865
+ })
2866
+
2867
+ const second = await route(
2868
+ new Request('https://api.example.com/resource', {
2869
+ headers: { Authorization: authorization },
2870
+ }),
2871
+ )
2872
+ if (second.status !== 200) throw new Error('expected paid response')
2873
+
2874
+ const paidResponse = second.withReceipt(new Response('ok'))
2875
+ const receipt = deserializeSessionReceipt(paidResponse.headers.get('Payment-Receipt')!)
2876
+ expect(receipt.spent).toBe('1000000')
2877
+ expect(receipt.units).toBe(1)
2878
+
2879
+ const persisted = await store.getChannel(channelId)
2880
+ expect(persisted?.spent).toBe(1000000n)
2881
+ expect(persisted?.units).toBe(1)
2882
+
2883
+ const replay = await route(
2884
+ new Request('https://api.example.com/resource', {
2885
+ headers: { Authorization: authorization },
2886
+ }),
2887
+ )
2888
+ expect(replay.status).toBe(402)
2889
+ })
2890
+
2891
+ test('sessionManager fetch/close tracks spent from plain HTTP receipts', async () => {
2892
+ const handler = createHandler()
2893
+ const route = handler.session({
2894
+ amount: '1',
2895
+ decimals: 6,
2896
+ unitType: 'token',
2897
+ })
2898
+
2899
+ let contentRequests = 0
2900
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
2901
+ const request = new Request(input, init)
2902
+ const result = await route(request)
2903
+ if (result.status === 402) return result.challenge
2904
+ if (request.method === 'GET') contentRequests++
2905
+ return result.withReceipt(new Response('ok'))
2906
+ }
2907
+
2908
+ const manager = sessionManager({
2909
+ account: payer,
2910
+ client,
2911
+ escrowContract,
2912
+ fetch,
2913
+ maxDeposit: '3',
2914
+ })
2915
+
2916
+ const first = await manager.fetch('https://api.example.com/resource')
2917
+ expect(first.status).toBe(200)
2918
+ expect(first.receipt?.spent).toBe('1000000')
2919
+ expect(first.receipt?.units).toBe(1)
2920
+
2921
+ const second = await manager.fetch('https://api.example.com/resource')
2922
+ expect(second.status).toBe(200)
2923
+ expect(second.receipt?.spent).toBe('2000000')
2924
+ expect(second.receipt?.units).toBe(2)
2925
+
2926
+ const closeReceipt = await manager.close()
2927
+ expect(closeReceipt?.status).toBe('success')
2928
+ expect(closeReceipt?.spent).toBe('2000000')
2929
+ expect(contentRequests).toBe(2)
2930
+
2931
+ const persisted = await store.getChannel(manager.channelId!)
2932
+ expect(persisted?.finalized).toBe(true)
2933
+ })
2934
+
2586
2935
  test('does not return Payment-Receipt on verification errors', async () => {
2587
2936
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2588
2937
  const handler = createHandler()
@@ -2619,59 +2968,272 @@ describe.runIf(isLocalnet)('session', () => {
2619
2968
  expect(second.challenge.headers.get('Payment-Receipt')).toBeNull()
2620
2969
  })
2621
2970
 
2622
- test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
2971
+ test('default HTTP GET flow does not serve a replayed voucher without advancing accounting', async () => {
2972
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2973
+ const signature = await signTestVoucher(channelId, 1000000n)
2623
2974
  const handler = createHandler()
2624
2975
  const route = handler.session({
2625
- amount: '0.000000000000000001',
2626
- suggestedDeposit: '0.000000000000000002',
2627
- minVoucherDelta: '0.000000000000000001',
2628
- decimals: 18,
2976
+ amount: '1',
2977
+ decimals: 6,
2629
2978
  unitType: 'token',
2630
2979
  })
2631
2980
 
2632
- const result = await route(new Request('https://api.example.com/resource'))
2633
- expect(result.status).toBe(402)
2634
- if (result.status !== 402) throw new Error('expected challenge')
2635
-
2636
- const challenge = Challenge.fromResponse(result.challenge)
2637
- const request = challenge.request as {
2638
- amount: string
2639
- suggestedDeposit: string
2640
- methodDetails: { minVoucherDelta: string }
2981
+ const serve = async (request: Request) => {
2982
+ const result = await route(request)
2983
+ if (result.status === 402) return result.challenge
2984
+ return result.withReceipt(new Response('paid-content'))
2641
2985
  }
2642
- expect(request.amount).toBe('1')
2643
- expect(request.suggestedDeposit).toBe('2')
2644
- expect(request.methodDetails.minVoucherDelta).toBe('1')
2645
- })
2646
- })
2647
2986
 
2648
- describe('structured errors', () => {
2649
- test('ChannelNotFoundError on unknown channel', async () => {
2650
- const { channelId } = await createSignedOpenTransaction(10000000n)
2651
- const server = createServer()
2987
+ const issueChallenge = () => route(new Request('https://api.example.com/resource'))
2652
2988
 
2653
- try {
2654
- await server.verify({
2655
- credential: {
2656
- challenge: makeChallenge({ channelId }),
2657
- payload: {
2658
- action: 'voucher' as const,
2659
- channelId,
2660
- cumulativeAmount: '1000000',
2661
- signature: await signTestVoucher(channelId, 1000000n),
2662
- },
2663
- },
2664
- request: makeRequest(),
2665
- })
2666
- expect.unreachable()
2667
- } catch (e) {
2668
- expect(e).toBeInstanceOf(ChannelNotFoundError)
2669
- expect((e as ChannelNotFoundError).status).toBe(410)
2670
- }
2671
- })
2989
+ const first = await issueChallenge()
2990
+ expect(first.status).toBe(402)
2991
+ if (first.status !== 402) throw new Error('expected challenge')
2672
2992
 
2673
- test('InvalidSignatureError has status 402', async () => {
2674
- const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2993
+ const open = await serve(
2994
+ new Request('https://api.example.com/resource', {
2995
+ headers: {
2996
+ Authorization: Credential.serialize({
2997
+ challenge: Challenge.fromResponse(first.challenge),
2998
+ payload: {
2999
+ action: 'open',
3000
+ type: 'transaction',
3001
+ channelId,
3002
+ transaction: serializedTransaction,
3003
+ cumulativeAmount: '1000000',
3004
+ signature,
3005
+ },
3006
+ }),
3007
+ },
3008
+ }),
3009
+ )
3010
+ expect(open.status).toBe(200)
3011
+ expect(await open.text()).toBe('paid-content')
3012
+ const openReceipt = deserializeSessionReceipt(open.headers.get('Payment-Receipt') as string)
3013
+ expect(openReceipt.acceptedCumulative).toBe('1000000')
3014
+ expect(openReceipt.spent).toBe('1000000')
3015
+ expect(openReceipt.units).toBe(1)
3016
+
3017
+ const replayChallenge = await issueChallenge()
3018
+ expect(replayChallenge.status).toBe(402)
3019
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3020
+
3021
+ const replay = await serve(
3022
+ new Request('https://api.example.com/resource', {
3023
+ headers: {
3024
+ Authorization: Credential.serialize({
3025
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3026
+ payload: {
3027
+ action: 'voucher',
3028
+ channelId,
3029
+ cumulativeAmount: '1000000',
3030
+ signature,
3031
+ },
3032
+ }),
3033
+ },
3034
+ }),
3035
+ )
3036
+ expect(replay.status).toBe(402)
3037
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
3038
+ })
3039
+
3040
+ test('default HTTP POST content flow does not serve a replayed voucher without advancing accounting', async () => {
3041
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3042
+ const signature = await signTestVoucher(channelId, 1000000n)
3043
+ const handler = createHandler()
3044
+ const route = handler.session({
3045
+ amount: '1',
3046
+ decimals: 6,
3047
+ unitType: 'token',
3048
+ })
3049
+
3050
+ const serve = async (request: Request) => {
3051
+ const result = await route(request)
3052
+ if (result.status === 402) return result.challenge
3053
+ return result.withReceipt(new Response('paid-content'))
3054
+ }
3055
+
3056
+ const makeRequest = (authorization?: string) =>
3057
+ new Request('https://api.example.com/resource', {
3058
+ method: 'POST',
3059
+ body: '{}',
3060
+ headers: {
3061
+ 'content-length': '2',
3062
+ 'content-type': 'application/json',
3063
+ ...(authorization ? { Authorization: authorization } : {}),
3064
+ },
3065
+ })
3066
+
3067
+ const first = await route(makeRequest())
3068
+ expect(first.status).toBe(402)
3069
+ if (first.status !== 402) throw new Error('expected challenge')
3070
+
3071
+ const open = await serve(
3072
+ makeRequest(
3073
+ Credential.serialize({
3074
+ challenge: Challenge.fromResponse(first.challenge),
3075
+ payload: {
3076
+ action: 'open',
3077
+ type: 'transaction',
3078
+ channelId,
3079
+ transaction: serializedTransaction,
3080
+ cumulativeAmount: '1000000',
3081
+ signature,
3082
+ },
3083
+ }),
3084
+ ),
3085
+ )
3086
+ expect(open.status).toBe(200)
3087
+ expect(await open.text()).toBe('paid-content')
3088
+ const openReceipt = deserializeSessionReceipt(open.headers.get('Payment-Receipt') as string)
3089
+ expect(openReceipt.acceptedCumulative).toBe('1000000')
3090
+ expect(openReceipt.spent).toBe('1000000')
3091
+ expect(openReceipt.units).toBe(1)
3092
+
3093
+ const replayChallenge = await route(makeRequest())
3094
+ expect(replayChallenge.status).toBe(402)
3095
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3096
+
3097
+ const replay = await serve(
3098
+ makeRequest(
3099
+ Credential.serialize({
3100
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3101
+ payload: {
3102
+ action: 'voucher',
3103
+ channelId,
3104
+ cumulativeAmount: '1000000',
3105
+ signature,
3106
+ },
3107
+ }),
3108
+ ),
3109
+ )
3110
+ expect(replay.status).toBe(402)
3111
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
3112
+ })
3113
+
3114
+ test('default HTTP POST content flow with body and no body headers does not serve a replayed voucher', async () => {
3115
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3116
+ const signature = await signTestVoucher(channelId, 1000000n)
3117
+ const handler = createHandler()
3118
+ const route = handler.session({
3119
+ amount: '1',
3120
+ decimals: 6,
3121
+ unitType: 'token',
3122
+ })
3123
+
3124
+ const serve = async (request: Request) => {
3125
+ const result = await route(request)
3126
+ if (result.status === 402) return result.challenge
3127
+ return result.withReceipt(new Response('paid-content'))
3128
+ }
3129
+
3130
+ const makeRequest = (authorization?: string) =>
3131
+ new Request('https://api.example.com/resource', {
3132
+ method: 'POST',
3133
+ body: '{}',
3134
+ ...(authorization ? { headers: { Authorization: authorization } } : {}),
3135
+ })
3136
+
3137
+ const first = await route(makeRequest())
3138
+ expect(first.status).toBe(402)
3139
+ if (first.status !== 402) throw new Error('expected challenge')
3140
+
3141
+ const open = await serve(
3142
+ makeRequest(
3143
+ Credential.serialize({
3144
+ challenge: Challenge.fromResponse(first.challenge),
3145
+ payload: {
3146
+ action: 'open',
3147
+ type: 'transaction',
3148
+ channelId,
3149
+ transaction: serializedTransaction,
3150
+ cumulativeAmount: '1000000',
3151
+ signature,
3152
+ },
3153
+ }),
3154
+ ),
3155
+ )
3156
+ expect(open.status).toBe(200)
3157
+ expect(await open.text()).toBe('paid-content')
3158
+ const openReceipt = deserializeSessionReceipt(open.headers.get('Payment-Receipt') as string)
3159
+ expect(openReceipt.acceptedCumulative).toBe('1000000')
3160
+ expect(openReceipt.spent).toBe('1000000')
3161
+ expect(openReceipt.units).toBe(1)
3162
+
3163
+ const replayChallenge = await route(makeRequest())
3164
+ expect(replayChallenge.status).toBe(402)
3165
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3166
+
3167
+ const replay = await serve(
3168
+ makeRequest(
3169
+ Credential.serialize({
3170
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3171
+ payload: {
3172
+ action: 'voucher',
3173
+ channelId,
3174
+ cumulativeAmount: '1000000',
3175
+ signature,
3176
+ },
3177
+ }),
3178
+ ),
3179
+ )
3180
+ expect(replay.status).toBe(402)
3181
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
3182
+ })
3183
+
3184
+ test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
3185
+ const handler = createHandler()
3186
+ const route = handler.session({
3187
+ amount: '0.000000000000000001',
3188
+ suggestedDeposit: '0.000000000000000002',
3189
+ minVoucherDelta: '0.000000000000000001',
3190
+ decimals: 18,
3191
+ unitType: 'token',
3192
+ })
3193
+
3194
+ const result = await route(new Request('https://api.example.com/resource'))
3195
+ expect(result.status).toBe(402)
3196
+ if (result.status !== 402) throw new Error('expected challenge')
3197
+
3198
+ const challenge = Challenge.fromResponse(result.challenge)
3199
+ const request = challenge.request as {
3200
+ amount: string
3201
+ suggestedDeposit: string
3202
+ methodDetails: { minVoucherDelta: string }
3203
+ }
3204
+ expect(request.amount).toBe('1')
3205
+ expect(request.suggestedDeposit).toBe('2')
3206
+ expect(request.methodDetails.minVoucherDelta).toBe('1')
3207
+ })
3208
+ })
3209
+
3210
+ describe('structured errors', () => {
3211
+ test('ChannelNotFoundError on unknown channel', async () => {
3212
+ const { channelId } = await createSignedOpenTransaction(10000000n)
3213
+ const server = createServer()
3214
+
3215
+ try {
3216
+ await server.verify({
3217
+ credential: {
3218
+ challenge: makeChallenge({ channelId }),
3219
+ payload: {
3220
+ action: 'voucher' as const,
3221
+ channelId,
3222
+ cumulativeAmount: '1000000',
3223
+ signature: await signTestVoucher(channelId, 1000000n),
3224
+ },
3225
+ },
3226
+ request: makeRequest(),
3227
+ })
3228
+ expect.unreachable()
3229
+ } catch (e) {
3230
+ expect(e).toBeInstanceOf(ChannelNotFoundError)
3231
+ expect((e as ChannelNotFoundError).status).toBe(410)
3232
+ }
3233
+ })
3234
+
3235
+ test('InvalidSignatureError has status 402', async () => {
3236
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2675
3237
  const server = createServer()
2676
3238
 
2677
3239
  try {
@@ -2835,6 +3397,23 @@ describe.runIf(isLocalnet)('session', () => {
2835
3397
  expect(result).toBeUndefined()
2836
3398
  })
2837
3399
 
3400
+ test('returns undefined for open POST with body and no body headers (content request)', () => {
3401
+ const server = createServer()
3402
+ const result = server.respond!({
3403
+ credential: {
3404
+ challenge: makeChallenge({
3405
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
3406
+ }),
3407
+ payload: { action: 'open' },
3408
+ },
3409
+ input: new Request('http://localhost', {
3410
+ method: 'POST',
3411
+ body: '{}',
3412
+ }),
3413
+ } as any)
3414
+ expect(result).toBeUndefined()
3415
+ })
3416
+
2838
3417
  test('returns 204 for GET with topUp action', () => {
2839
3418
  const server = createServer()
2840
3419
  const result = server.respond!({
@@ -2884,6 +3463,23 @@ describe.runIf(isLocalnet)('session', () => {
2884
3463
  expect(result).toBeUndefined()
2885
3464
  })
2886
3465
 
3466
+ test('returns undefined for voucher POST with body and no body headers (content request)', () => {
3467
+ const server = createServer()
3468
+ const result = server.respond!({
3469
+ credential: {
3470
+ challenge: makeChallenge({
3471
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
3472
+ }),
3473
+ payload: { action: 'voucher' },
3474
+ },
3475
+ input: new Request('http://localhost', {
3476
+ method: 'POST',
3477
+ body: '{}',
3478
+ }),
3479
+ } as any)
3480
+ expect(result).toBeUndefined()
3481
+ })
3482
+
2887
3483
  test('returns 204 for voucher POST with content-length: 0', () => {
2888
3484
  const server = createServer()
2889
3485
  const result = server.respond!({
@@ -2903,6 +3499,187 @@ describe.runIf(isLocalnet)('session', () => {
2903
3499
  })
2904
3500
  })
2905
3501
 
3502
+ describe('HTTP session manager', () => {
3503
+ test('tracks spent from HTTP error receipts and closes at that amount', async () => {
3504
+ const backingStore = Store.memory()
3505
+ const routeHandler = Mppx_server.create({
3506
+ methods: [
3507
+ tempo_server.session({
3508
+ store: backingStore,
3509
+ getClient: () => client,
3510
+ account: recipientAccount,
3511
+ currency,
3512
+ escrowContract,
3513
+ chainId: chain.id,
3514
+ }),
3515
+ ],
3516
+ realm: 'api.example.com',
3517
+ secretKey: 'secret',
3518
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3519
+
3520
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3521
+ const request = new Request(input, init)
3522
+ const result = await routeHandler(request)
3523
+ if (result.status === 402) return result.challenge
3524
+ return result.withReceipt(new Response('upstream failed', { status: 500 }))
3525
+ }
3526
+
3527
+ const manager = sessionManager({
3528
+ account: payer,
3529
+ client,
3530
+ escrowContract,
3531
+ fetch,
3532
+ maxDeposit: '2',
3533
+ })
3534
+
3535
+ const response = await manager.fetch('https://api.example.com/resource')
3536
+ expect(response.status).toBe(500)
3537
+ expect(response.receipt?.spent).toBe('1000000')
3538
+ expect(response.receipt?.units).toBe(1)
3539
+
3540
+ const closeReceipt = await manager.close()
3541
+ expect(closeReceipt?.status).toBe('success')
3542
+ expect(closeReceipt?.spent).toBe('1000000')
3543
+ })
3544
+
3545
+ test('tracks spent from null-body HTTP content receipts and closes at that amount', async () => {
3546
+ const backingStore = Store.memory()
3547
+ const routeHandler = Mppx_server.create({
3548
+ methods: [
3549
+ tempo_server.session({
3550
+ store: backingStore,
3551
+ getClient: () => client,
3552
+ account: recipientAccount,
3553
+ currency,
3554
+ escrowContract,
3555
+ chainId: chain.id,
3556
+ }),
3557
+ ],
3558
+ realm: 'api.example.com',
3559
+ secretKey: 'secret',
3560
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3561
+
3562
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3563
+ const request = new Request(input, init)
3564
+ const result = await routeHandler(request)
3565
+ if (result.status === 402) return result.challenge
3566
+ return result.withReceipt(new Response(null, { status: 204 }))
3567
+ }
3568
+
3569
+ const manager = sessionManager({
3570
+ account: payer,
3571
+ client,
3572
+ escrowContract,
3573
+ fetch,
3574
+ maxDeposit: '2',
3575
+ })
3576
+
3577
+ const response = await manager.fetch('https://api.example.com/resource')
3578
+ expect(response.status).toBe(204)
3579
+ expect(await response.text()).toBe('')
3580
+ expect(response.receipt?.spent).toBe('1000000')
3581
+ expect(response.receipt?.units).toBe(1)
3582
+
3583
+ const closeReceipt = await manager.close()
3584
+ expect(closeReceipt?.status).toBe('success')
3585
+ expect(closeReceipt?.spent).toBe('1000000')
3586
+ })
3587
+
3588
+ test('throws when fallback close returns a non-ok response', async () => {
3589
+ const backingStore = Store.memory()
3590
+ const routeHandler = Mppx_server.create({
3591
+ methods: [
3592
+ tempo_server.session({
3593
+ store: backingStore,
3594
+ getClient: () => client,
3595
+ account: recipientAccount,
3596
+ currency,
3597
+ escrowContract,
3598
+ chainId: chain.id,
3599
+ }),
3600
+ ],
3601
+ realm: 'api.example.com',
3602
+ secretKey: 'secret',
3603
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3604
+
3605
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3606
+ const request = new Request(input, init)
3607
+ const action = request.headers.has('Authorization')
3608
+ ? Credential.fromRequest<any>(request).payload?.action
3609
+ : undefined
3610
+ const result = await routeHandler(request)
3611
+ if (result.status === 402) return result.challenge
3612
+ if (action === 'close') {
3613
+ return new Response('close failed', {
3614
+ status: 500,
3615
+ headers: { 'WWW-Authenticate': 'Payment error="close_failed"' },
3616
+ })
3617
+ }
3618
+ return result.withReceipt(new Response('ok'))
3619
+ }
3620
+
3621
+ const manager = sessionManager({
3622
+ account: payer,
3623
+ client,
3624
+ escrowContract,
3625
+ fetch,
3626
+ maxDeposit: '2',
3627
+ })
3628
+
3629
+ const response = await manager.fetch('https://api.example.com/resource')
3630
+ expect(response.status).toBe(200)
3631
+ expect(response.receipt?.spent).toBe('1000000')
3632
+
3633
+ await expect(manager.close()).rejects.toThrow(
3634
+ 'Close request failed with status 500: close failed [WWW-Authenticate: Payment error="close_failed"]',
3635
+ )
3636
+ })
3637
+
3638
+ test('sse transport charges plain HTTP fallback responses and closes at receipt.spent', async () => {
3639
+ const backingStore = Store.memory()
3640
+ const routeHandler = Mppx_server.create({
3641
+ methods: [
3642
+ tempo_server.session({
3643
+ store: backingStore,
3644
+ getClient: () => client,
3645
+ account: recipientAccount,
3646
+ currency,
3647
+ escrowContract,
3648
+ chainId: chain.id,
3649
+ sse: true,
3650
+ }),
3651
+ ],
3652
+ realm: 'api.example.com',
3653
+ secretKey: 'secret',
3654
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3655
+
3656
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3657
+ const request = new Request(input, init)
3658
+ const result = await routeHandler(request)
3659
+ if (result.status === 402) return result.challenge
3660
+ return result.withReceipt(new Response('ok'))
3661
+ }
3662
+
3663
+ const manager = sessionManager({
3664
+ account: payer,
3665
+ client,
3666
+ escrowContract,
3667
+ fetch,
3668
+ maxDeposit: '2',
3669
+ })
3670
+
3671
+ const response = await manager.fetch('https://api.example.com/resource')
3672
+ expect(response.status).toBe(200)
3673
+ expect(await response.text()).toBe('ok')
3674
+ expect(response.receipt?.spent).toBe('1000000')
3675
+ expect(response.receipt?.units).toBe(1)
3676
+
3677
+ const closeReceipt = await manager.close()
3678
+ expect(closeReceipt?.status).toBe('success')
3679
+ expect(closeReceipt?.spent).toBe('1000000')
3680
+ })
3681
+ })
3682
+
2906
3683
  describe('SSE', () => {
2907
3684
  test('behavior: withReceipt accepts async generator and returns Response', async () => {
2908
3685
  const handler = Mppx_server.create({
@@ -3053,6 +3830,91 @@ describe.runIf(isLocalnet)('session', () => {
3053
3830
  expect(persisted?.finalized).toBe(true)
3054
3831
  })
3055
3832
 
3833
+ test('unitType=request auto-metered SSE responses charge once across the stream', async () => {
3834
+ const backingStore = Store.memory()
3835
+ const routeHandler = Mppx_server.create({
3836
+ methods: [
3837
+ tempo_server.session({
3838
+ store: backingStore,
3839
+ getClient: () => client,
3840
+ account: recipientAccount,
3841
+ currency,
3842
+ escrowContract,
3843
+ chainId: chain.id,
3844
+ sse: true,
3845
+ }),
3846
+ ],
3847
+ realm: 'api.example.com',
3848
+ secretKey: 'secret',
3849
+ }).session({ amount: '1', decimals: 6, unitType: 'request' })
3850
+
3851
+ let voucherPosts = 0
3852
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3853
+ const request = new Request(input, init)
3854
+ let action: 'open' | 'topUp' | 'voucher' | 'close' | undefined
3855
+
3856
+ if (request.method === 'POST' && request.headers.has('Authorization')) {
3857
+ try {
3858
+ const credential = Credential.fromRequest<any>(request)
3859
+ action = credential.payload?.action
3860
+ if (action === 'voucher') voucherPosts++
3861
+ } catch {}
3862
+ }
3863
+
3864
+ const result = await routeHandler(request)
3865
+ if (result.status === 402) return result.challenge
3866
+
3867
+ if (action === 'voucher') {
3868
+ return new Response(null, { status: 200 })
3869
+ }
3870
+
3871
+ if (request.headers.get('Accept')?.includes('text/event-stream')) {
3872
+ const encoder = new TextEncoder()
3873
+ const upstream = new Response(
3874
+ new ReadableStream({
3875
+ start(controller) {
3876
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-1\n\n'))
3877
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-2\n\n'))
3878
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-3\n\n'))
3879
+ controller.close()
3880
+ },
3881
+ }),
3882
+ { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
3883
+ )
3884
+ return result.withReceipt(upstream)
3885
+ }
3886
+
3887
+ return result.withReceipt(new Response('ok'))
3888
+ }
3889
+
3890
+ const manager = sessionManager({
3891
+ account: payer,
3892
+ client,
3893
+ escrowContract,
3894
+ fetch,
3895
+ maxDeposit: '1',
3896
+ })
3897
+
3898
+ const chunks: string[] = []
3899
+ const stream = await manager.sse('https://api.example.com/stream')
3900
+ for await (const chunk of stream) chunks.push(chunk)
3901
+
3902
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
3903
+ expect(voucherPosts).toBe(0)
3904
+
3905
+ const closeReceipt = await manager.close()
3906
+ expect(closeReceipt?.status).toBe('success')
3907
+ expect(closeReceipt?.spent).toBe('1000000')
3908
+
3909
+ const channelId = manager.channelId
3910
+ expect(channelId).toBeTruthy()
3911
+
3912
+ const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId!)
3913
+ expect(persisted?.spent).toBe(1000000n)
3914
+ expect(persisted?.units).toBe(1)
3915
+ expect(persisted?.finalized).toBe(true)
3916
+ })
3917
+
3056
3918
  test('handles repeated exhaustion/resume cycles within one stream', async () => {
3057
3919
  const backingStore = Store.memory()
3058
3920
  const routeHandler = Mppx_server.create({
@@ -4488,6 +5350,30 @@ describe('session default currency resolution', () => {
4488
5350
  const challenge = Challenge.fromResponse(result.challenge)
4489
5351
  expect(challenge.request.currency).toBe('0xcustom')
4490
5352
  })
5353
+
5354
+ test('handler.session throws for zero-amount routes', () => {
5355
+ const handler = Mppx_server.create({
5356
+ methods: [
5357
+ tempo_server.session({
5358
+ store: Store.memory(),
5359
+ getClient: () => mockClient,
5360
+ account: mockAccount,
5361
+ escrowContract: '0x0000000000000000000000000000000000000002',
5362
+ chainId: 4217,
5363
+ }),
5364
+ ],
5365
+ realm: 'api.example.com',
5366
+ secretKey: 'secret',
5367
+ })
5368
+
5369
+ expect(() =>
5370
+ (handler.session as Function)({
5371
+ amount: '0',
5372
+ decimals: 6,
5373
+ unitType: 'token',
5374
+ }),
5375
+ ).toThrow('Session amount must be greater than 0')
5376
+ })
4491
5377
  })
4492
5378
 
4493
5379
  function nextSalt(): Hex {
@@ -4514,7 +5400,7 @@ function makeChallenge(opts: { id?: string; channelId: Hex }) {
4514
5400
  } as Challenge.Challenge<z.output<typeof Methods.session.schema.request>, 'session', 'tempo'>
4515
5401
  }
4516
5402
 
4517
- function makeRequest() {
5403
+ function makeRequest(overrides: Partial<Record<string, unknown>> = {}) {
4518
5404
  return {
4519
5405
  amount: '1000000',
4520
5406
  unitType: 'token',
@@ -4523,6 +5409,7 @@ function makeRequest() {
4523
5409
  recipient: recipient as string,
4524
5410
  escrowContract: escrowContract as string,
4525
5411
  chainId: chain.id,
5412
+ ...overrides,
4526
5413
  }
4527
5414
  }
4528
5415