mppx 0.5.14 → 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.
- package/CHANGELOG.md +15 -0
- package/dist/Method.d.ts +5 -2
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +8 -2
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +17 -10
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Request.js +5 -1
- package/dist/server/Request.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +4 -0
- package/dist/server/Transport.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +4 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +20 -10
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +92 -21
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +43 -20
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +0 -7
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +84 -13
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/Method.ts +5 -2
- package/src/internal/changeset.test.ts +106 -0
- package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
- package/src/mcp-sdk/server/Transport.test.ts +1 -0
- package/src/mcp-sdk/server/Transport.ts +10 -2
- package/src/proxy/Proxy.test.ts +149 -1
- package/src/server/Mppx.test.ts +120 -0
- package/src/server/Mppx.ts +27 -11
- package/src/server/Request.test.ts +46 -1
- package/src/server/Request.ts +6 -1
- package/src/server/Transport.test.ts +2 -0
- package/src/server/Transport.ts +4 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +13 -0
- package/src/tempo/Methods.ts +23 -16
- package/src/tempo/client/SessionManager.ts +32 -9
- package/src/tempo/internal/fee-payer.test.ts +40 -4
- package/src/tempo/internal/fee-payer.ts +105 -21
- package/src/tempo/server/Session.test.ts +760 -2
- package/src/tempo/server/Session.ts +59 -17
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +321 -10
- package/src/tempo/server/internal/transport.ts +101 -14
- 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 {
|
|
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'
|
|
@@ -2640,7 +2645,7 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2640
2645
|
})
|
|
2641
2646
|
|
|
2642
2647
|
describe('protocol compatibility', () => {
|
|
2643
|
-
test('HEAD voucher
|
|
2648
|
+
test('HEAD voucher request is gated as a non-billable management request', () => {
|
|
2644
2649
|
const server = createServer()
|
|
2645
2650
|
const response = server.respond!({
|
|
2646
2651
|
credential: {
|
|
@@ -2649,14 +2654,137 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2649
2654
|
}),
|
|
2650
2655
|
payload: { action: 'voucher' },
|
|
2651
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
|
+
},
|
|
2652
2665
|
input: new Request('https://api.example.com/resource', {
|
|
2653
2666
|
method: 'HEAD',
|
|
2654
2667
|
}),
|
|
2655
2668
|
} as never)
|
|
2656
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
|
+
|
|
2657
2722
|
expect(response).toBeUndefined()
|
|
2658
2723
|
})
|
|
2659
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
|
+
|
|
2660
2788
|
test('ignores unknown challenge and credential fields for forward compatibility', async () => {
|
|
2661
2789
|
const challenge = Challenge.from({
|
|
2662
2790
|
id: 'forward-compat',
|
|
@@ -2711,6 +2839,99 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2711
2839
|
expect(second.status).toBe(200)
|
|
2712
2840
|
})
|
|
2713
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
|
+
|
|
2714
2935
|
test('does not return Payment-Receipt on verification errors', async () => {
|
|
2715
2936
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2716
2937
|
const handler = createHandler()
|
|
@@ -2747,6 +2968,219 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2747
2968
|
expect(second.challenge.headers.get('Payment-Receipt')).toBeNull()
|
|
2748
2969
|
})
|
|
2749
2970
|
|
|
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)
|
|
2974
|
+
const handler = createHandler()
|
|
2975
|
+
const route = handler.session({
|
|
2976
|
+
amount: '1',
|
|
2977
|
+
decimals: 6,
|
|
2978
|
+
unitType: 'token',
|
|
2979
|
+
})
|
|
2980
|
+
|
|
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'))
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
const issueChallenge = () => route(new Request('https://api.example.com/resource'))
|
|
2988
|
+
|
|
2989
|
+
const first = await issueChallenge()
|
|
2990
|
+
expect(first.status).toBe(402)
|
|
2991
|
+
if (first.status !== 402) throw new Error('expected challenge')
|
|
2992
|
+
|
|
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
|
+
|
|
2750
3184
|
test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
|
|
2751
3185
|
const handler = createHandler()
|
|
2752
3186
|
const route = handler.session({
|
|
@@ -2963,6 +3397,23 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2963
3397
|
expect(result).toBeUndefined()
|
|
2964
3398
|
})
|
|
2965
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
|
+
|
|
2966
3417
|
test('returns 204 for GET with topUp action', () => {
|
|
2967
3418
|
const server = createServer()
|
|
2968
3419
|
const result = server.respond!({
|
|
@@ -3012,6 +3463,23 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3012
3463
|
expect(result).toBeUndefined()
|
|
3013
3464
|
})
|
|
3014
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
|
+
|
|
3015
3483
|
test('returns 204 for voucher POST with content-length: 0', () => {
|
|
3016
3484
|
const server = createServer()
|
|
3017
3485
|
const result = server.respond!({
|
|
@@ -3031,6 +3499,187 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3031
3499
|
})
|
|
3032
3500
|
})
|
|
3033
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
|
+
|
|
3034
3683
|
describe('SSE', () => {
|
|
3035
3684
|
test('behavior: withReceipt accepts async generator and returns Response', async () => {
|
|
3036
3685
|
const handler = Mppx_server.create({
|
|
@@ -3181,6 +3830,91 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3181
3830
|
expect(persisted?.finalized).toBe(true)
|
|
3182
3831
|
})
|
|
3183
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
|
+
|
|
3184
3918
|
test('handles repeated exhaustion/resume cycles within one stream', async () => {
|
|
3185
3919
|
const backingStore = Store.memory()
|
|
3186
3920
|
const routeHandler = Mppx_server.create({
|
|
@@ -4616,6 +5350,30 @@ describe('session default currency resolution', () => {
|
|
|
4616
5350
|
const challenge = Challenge.fromResponse(result.challenge)
|
|
4617
5351
|
expect(challenge.request.currency).toBe('0xcustom')
|
|
4618
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
|
+
})
|
|
4619
5377
|
})
|
|
4620
5378
|
|
|
4621
5379
|
function nextSalt(): Hex {
|