mppx 0.8.13 → 0.8.15
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 +37 -0
- package/dist/Errors.d.ts +10 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +11 -2
- package/dist/Errors.js.map +1 -1
- package/dist/Method.d.ts +105 -3
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +102 -2
- package/dist/Method.js.map +1 -1
- package/dist/cli/validate/payment.d.ts.map +1 -1
- package/dist/cli/validate/payment.js +18 -4
- package/dist/cli/validate/payment.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +98 -5
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodChallenge.d.ts +19 -0
- package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
- package/dist/client/internal/MethodChallenge.js +15 -0
- package/dist/client/internal/MethodChallenge.js.map +1 -0
- package/dist/client/internal/MethodResponse.d.ts +15 -0
- package/dist/client/internal/MethodResponse.d.ts.map +1 -1
- package/dist/client/internal/MethodResponse.js +10 -0
- package/dist/client/internal/MethodResponse.js.map +1 -1
- package/dist/server/Mppx.d.ts +23 -4
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +91 -82
- package/dist/server/Mppx.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/client/Methods.d.ts +1 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +7 -2
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.js +2 -1
- package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +46 -77
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +289 -209
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +12 -1
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Relay.d.ts +57 -0
- package/dist/tempo/server/Relay.d.ts.map +1 -0
- package/dist/tempo/server/Relay.js +179 -0
- package/dist/tempo/server/Relay.js.map +1 -0
- package/dist/tempo/server/SponsorBudget.d.ts +59 -0
- package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
- package/dist/tempo/server/SponsorBudget.js +150 -0
- package/dist/tempo/server/SponsorBudget.js.map +1 -0
- 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/session/client/ChannelOps.d.ts +7 -1
- package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/session/client/ChannelOps.js +9 -4
- package/dist/tempo/session/client/ChannelOps.js.map +1 -1
- package/dist/tempo/session/client/CredentialState.d.ts +5 -1
- package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
- package/dist/tempo/session/client/CredentialState.js +37 -19
- package/dist/tempo/session/client/CredentialState.js.map +1 -1
- package/dist/tempo/session/client/Runtime.d.ts +8 -0
- package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
- package/dist/tempo/session/client/Runtime.js +17 -0
- package/dist/tempo/session/client/Runtime.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts +15 -5
- package/dist/tempo/session/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +216 -45
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +8 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +114 -44
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +16 -6
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +84 -37
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +96 -8
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/precompile/Channel.d.ts +2 -0
- package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Channel.js +4 -0
- package/dist/tempo/session/precompile/Channel.js.map +1 -1
- package/dist/tempo/session/server/RequestState.js +3 -1
- package/dist/tempo/session/server/RequestState.js.map +1 -1
- package/dist/tempo/session/server/Ws.d.ts.map +1 -1
- package/dist/tempo/session/server/Ws.js +3 -0
- package/dist/tempo/session/server/Ws.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.js +14 -3
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
- package/dist/viem/Client.d.ts.map +1 -1
- package/dist/viem/Client.js +6 -1
- package/dist/viem/Client.js.map +1 -1
- package/package.json +2 -2
- package/src/Errors.test.ts +23 -0
- package/src/Errors.ts +21 -2
- package/src/Method.test.ts +102 -1
- package/src/Method.ts +241 -5
- package/src/cli/validate/payment.ts +18 -4
- package/src/cli/validate.test.ts +16 -0
- package/src/client/Mppx.test-d.ts +3 -3
- package/src/client/internal/Fetch.test.ts +77 -0
- package/src/client/internal/Fetch.ts +122 -11
- package/src/client/internal/MethodChallenge.ts +35 -0
- package/src/client/internal/MethodResponse.ts +28 -0
- package/src/server/Mppx.test-d.ts +2 -0
- package/src/server/Mppx.test.ts +205 -0
- package/src/server/Mppx.ts +167 -103
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/PublicExports.test-d.ts +20 -0
- package/src/tempo/internal/fee-payer.ts +7 -3
- package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
- package/src/tempo/legacy/client/ChannelOps.ts +2 -1
- package/src/tempo/server/Charge.test.ts +337 -63
- package/src/tempo/server/Charge.ts +443 -267
- package/src/tempo/server/Methods.ts +11 -0
- package/src/tempo/server/Relay.test.ts +677 -0
- package/src/tempo/server/Relay.ts +297 -0
- package/src/tempo/server/SponsorBudget.test.ts +154 -0
- package/src/tempo/server/SponsorBudget.ts +219 -0
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/README.md +7 -0
- package/src/tempo/session/client/ChannelOps.test.ts +17 -2
- package/src/tempo/session/client/ChannelOps.ts +12 -4
- package/src/tempo/session/client/CredentialState.ts +61 -20
- package/src/tempo/session/client/Runtime.test.ts +12 -0
- package/src/tempo/session/client/Runtime.ts +22 -0
- package/src/tempo/session/client/Session.test.ts +506 -7
- package/src/tempo/session/client/Session.ts +279 -54
- package/src/tempo/session/client/SessionManager.test.ts +318 -13
- package/src/tempo/session/client/SessionManager.ts +132 -42
- package/src/tempo/session/client/Transports.test.ts +54 -1
- package/src/tempo/session/client/Transports.ts +137 -57
- package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
- package/src/tempo/session/precompile/Chain.test.ts +197 -10
- package/src/tempo/session/precompile/Chain.ts +102 -8
- package/src/tempo/session/precompile/Channel.test.ts +9 -0
- package/src/tempo/session/precompile/Channel.ts +5 -0
- package/src/tempo/session/server/RequestState.test.ts +8 -1
- package/src/tempo/session/server/RequestState.ts +2 -2
- package/src/tempo/session/server/Session.test.ts +66 -4
- package/src/tempo/session/server/Ws.ts +3 -0
- package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
- package/src/tempo/subscription/KeyAuthorization.ts +14 -3
- package/src/viem/Client.test.ts +18 -1
- package/src/viem/Client.ts +7 -1
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { type Address, createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem'
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
3
|
import { Account as TempoAccount, Secp256k1, Transaction } from 'viem/tempo'
|
|
4
|
-
import { describe, expect, test } from 'vp/test'
|
|
4
|
+
import { describe, expect, test, vi } from 'vp/test'
|
|
5
5
|
|
|
6
6
|
import { serialize as serializeChallenge, type Challenge } from '../../../Challenge.js'
|
|
7
7
|
import * as Fetch from '../../../client/internal/Fetch.js'
|
|
8
|
+
import * as MethodChallenge from '../../../client/internal/MethodChallenge.js'
|
|
8
9
|
import * as Constants from '../../../Constants.js'
|
|
9
10
|
import * as Credential from '../../../Credential.js'
|
|
10
11
|
import * as z from '../../../zod.js'
|
|
12
|
+
import * as AutoSwap from '../../internal/auto-swap.js'
|
|
11
13
|
import * as Methods from '../../Methods.js'
|
|
12
14
|
import * as Channel from '../precompile/Channel.js'
|
|
13
15
|
import { escrowAbi } from '../precompile/escrow.abi.js'
|
|
14
16
|
import { tip20ChannelEscrow } from '../precompile/Protocol.js'
|
|
15
17
|
import * as Types from '../precompile/Protocol.js'
|
|
16
18
|
import * as Voucher from '../precompile/Voucher.js'
|
|
17
|
-
import { createChannelStore } from './ChannelStore.js'
|
|
19
|
+
import { channelKey, createChannelStore } from './ChannelStore.js'
|
|
18
20
|
import { session } from './Session.js'
|
|
19
21
|
|
|
20
22
|
const account = privateKeyToAccount(
|
|
@@ -58,6 +60,13 @@ type SessionChallenge = Challenge<
|
|
|
58
60
|
typeof Methods.session.name
|
|
59
61
|
>
|
|
60
62
|
|
|
63
|
+
const defaultChannelKey = channelKey({
|
|
64
|
+
chainId,
|
|
65
|
+
escrow: tip20ChannelEscrow,
|
|
66
|
+
payee: descriptor.payee,
|
|
67
|
+
token: descriptor.token,
|
|
68
|
+
})
|
|
69
|
+
|
|
61
70
|
function makeChallenge(overrides: Partial<SessionChallenge['request']> = {}): SessionChallenge {
|
|
62
71
|
const request: SessionChallenge['request'] = {
|
|
63
72
|
amount: '100',
|
|
@@ -76,6 +85,83 @@ function makeChallenge(overrides: Partial<SessionChallenge['request']> = {}): Se
|
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
function makeSessionChallenge(
|
|
89
|
+
overrides: Partial<SessionChallenge['request']> = {},
|
|
90
|
+
): SessionChallenge {
|
|
91
|
+
return makeChallenge({
|
|
92
|
+
...overrides,
|
|
93
|
+
methodDetails: {
|
|
94
|
+
chainId,
|
|
95
|
+
escrowContract: tip20ChannelEscrow,
|
|
96
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
97
|
+
...overrides.methodDetails,
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function paymentRequired(challenge: SessionChallenge, headers?: HeadersInit): Response {
|
|
103
|
+
const responseHeaders = new Headers(headers)
|
|
104
|
+
responseHeaders.set(Constants.Headers.wwwAuthenticate, serializeChallenge(challenge))
|
|
105
|
+
return new Response(null, {
|
|
106
|
+
headers: responseHeaders,
|
|
107
|
+
status: 402,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function withSnapshot(
|
|
112
|
+
payload: Types.SessionCredentialPayload,
|
|
113
|
+
acceptedCumulative?: string,
|
|
114
|
+
): SessionChallenge {
|
|
115
|
+
if (payload.action !== 'open') throw new Error('expected open credential')
|
|
116
|
+
return {
|
|
117
|
+
...makeSessionChallenge({
|
|
118
|
+
methodDetails: {
|
|
119
|
+
chainId,
|
|
120
|
+
escrowContract: tip20ChannelEscrow,
|
|
121
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
122
|
+
sessionSnapshot: {
|
|
123
|
+
acceptedCumulative: acceptedCumulative ?? payload.cumulativeAmount,
|
|
124
|
+
chainId,
|
|
125
|
+
channelId: payload.channelId,
|
|
126
|
+
deposit: '1000',
|
|
127
|
+
descriptor: payload.descriptor,
|
|
128
|
+
escrow: tip20ChannelEscrow,
|
|
129
|
+
requiredCumulative: '200',
|
|
130
|
+
settled: '0',
|
|
131
|
+
spent: payload.cumulativeAmount,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
id: 'replacement-id',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function makeSessionMethod(
|
|
140
|
+
channelStore = createChannelStore(),
|
|
141
|
+
parameters: Partial<session.Parameters> = {},
|
|
142
|
+
) {
|
|
143
|
+
return session({
|
|
144
|
+
account,
|
|
145
|
+
channelStore,
|
|
146
|
+
decimals: 0,
|
|
147
|
+
getClient: () => client,
|
|
148
|
+
maxDeposit: '1000',
|
|
149
|
+
...parameters,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function makeSessionFetch(
|
|
154
|
+
fetch: typeof globalThis.fetch,
|
|
155
|
+
channelStore = createChannelStore(),
|
|
156
|
+
maxPaymentRetries?: number,
|
|
157
|
+
) {
|
|
158
|
+
return Fetch.from({
|
|
159
|
+
fetch,
|
|
160
|
+
maxPaymentRetries,
|
|
161
|
+
methods: [makeSessionMethod(channelStore)],
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
79
165
|
function deserialize(credential: string) {
|
|
80
166
|
return Credential.deserialize(credential).payload as Types.SessionCredentialPayload
|
|
81
167
|
}
|
|
@@ -99,6 +185,14 @@ function openDeposit(payload: Types.SessionCredentialPayload): bigint {
|
|
|
99
185
|
return openArgs(payload)[3]
|
|
100
186
|
}
|
|
101
187
|
|
|
188
|
+
function transactionCalls(payload: Types.SessionCredentialPayload) {
|
|
189
|
+
if (payload.action !== 'open' && payload.action !== 'topUp')
|
|
190
|
+
throw new Error('expected transaction payload')
|
|
191
|
+
const transaction = Transaction.deserialize(payload.transaction)
|
|
192
|
+
if (!('calls' in transaction)) throw new Error('expected tempo calls')
|
|
193
|
+
return transaction.calls as readonly { to?: Address; data?: `0x${string}` }[]
|
|
194
|
+
}
|
|
195
|
+
|
|
102
196
|
describe('precompile client session', () => {
|
|
103
197
|
test('handles only TIP-1034 session challenges', () => {
|
|
104
198
|
const method = session({ account, getClient: () => client })
|
|
@@ -205,7 +299,341 @@ describe('precompile client session', () => {
|
|
|
205
299
|
expect(await response.text()).toBe(
|
|
206
300
|
`${Types.formatMessageEvent('first')}${Types.formatMessageEvent('second')}`,
|
|
207
301
|
)
|
|
208
|
-
expect(actions).toEqual(['open', '
|
|
302
|
+
expect(actions).toEqual(['open', 'topUp', 'voucher', 'voucher'])
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('tops up before signing plain HTTP vouchers through Fetch.from', async () => {
|
|
306
|
+
const challenge = makeChallenge({
|
|
307
|
+
suggestedDeposit: '100',
|
|
308
|
+
methodDetails: {
|
|
309
|
+
chainId,
|
|
310
|
+
escrowContract: tip20ChannelEscrow,
|
|
311
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
315
|
+
const paidRequests: {
|
|
316
|
+
body: BodyInit | null
|
|
317
|
+
header: string | null
|
|
318
|
+
method: string | undefined
|
|
319
|
+
}[] = []
|
|
320
|
+
const vouchers: string[] = []
|
|
321
|
+
let opened: Extract<Types.SessionCredentialPayload, { action: 'open' }> | undefined
|
|
322
|
+
const paymentRequired = () =>
|
|
323
|
+
new Response(null, {
|
|
324
|
+
status: 402,
|
|
325
|
+
headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
|
|
326
|
+
})
|
|
327
|
+
const rawFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
328
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
329
|
+
if (!authorization) return paymentRequired()
|
|
330
|
+
|
|
331
|
+
const payload = deserialize(authorization)
|
|
332
|
+
actions.push(payload.action)
|
|
333
|
+
if (payload.action === 'open') {
|
|
334
|
+
opened = payload
|
|
335
|
+
return new Response('opened')
|
|
336
|
+
}
|
|
337
|
+
if (payload.action === 'topUp') return new Response(null, { status: 204 })
|
|
338
|
+
if (payload.action !== 'voucher' || !opened) throw new Error('expected open voucher')
|
|
339
|
+
vouchers.push(authorization)
|
|
340
|
+
if (vouchers.length === 1) return paymentRequired()
|
|
341
|
+
|
|
342
|
+
paidRequests.push({
|
|
343
|
+
body: init?.body ?? null,
|
|
344
|
+
header: new Headers(init?.headers).get('x-test'),
|
|
345
|
+
method: init?.method,
|
|
346
|
+
})
|
|
347
|
+
return new Response('paid')
|
|
348
|
+
}
|
|
349
|
+
const fetch = Fetch.from({
|
|
350
|
+
fetch: rawFetch,
|
|
351
|
+
methods: [
|
|
352
|
+
session({
|
|
353
|
+
account,
|
|
354
|
+
decimals: 0,
|
|
355
|
+
getClient: () => client,
|
|
356
|
+
maxDeposit: '300',
|
|
357
|
+
}),
|
|
358
|
+
],
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
expect(await (await fetch('https://example.com/resource')).text()).toBe('opened')
|
|
362
|
+
const response = await fetch('https://example.com/resource', {
|
|
363
|
+
body: 'request-body',
|
|
364
|
+
headers: { 'x-test': 'preserved' },
|
|
365
|
+
method: 'POST',
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
expect(await response.text()).toBe('paid')
|
|
369
|
+
expect(actions).toEqual(['open', 'topUp', 'voucher', 'voucher'])
|
|
370
|
+
expect(new Set(vouchers).size).toBe(1)
|
|
371
|
+
expect(paidRequests).toEqual([{ body: 'request-body', header: 'preserved', method: 'POST' }])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test('retries an open rejected by a replacement challenge', async () => {
|
|
375
|
+
const challenge = makeSessionChallenge({ suggestedDeposit: '1000' })
|
|
376
|
+
const replacement = { ...challenge, id: 'replacement-id' }
|
|
377
|
+
const channelStore = createChannelStore()
|
|
378
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
379
|
+
let paidRequests = 0
|
|
380
|
+
const fetch = makeSessionFetch(async (_input, init) => {
|
|
381
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
382
|
+
if (!authorization) return paymentRequired(challenge)
|
|
383
|
+
actions.push(deserialize(authorization).action)
|
|
384
|
+
if (++paidRequests === 1) return paymentRequired(replacement)
|
|
385
|
+
return new Response('rejected', { status: 500 })
|
|
386
|
+
}, channelStore)
|
|
387
|
+
|
|
388
|
+
expect((await fetch('https://example.com/resource')).status).toBe(500)
|
|
389
|
+
expect(actions).toEqual(['open', 'open'])
|
|
390
|
+
expect(await channelStore.get(defaultChannelKey)).toBeUndefined()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('retries an open after the paid request throws', async () => {
|
|
394
|
+
const challenge = makeSessionChallenge({ suggestedDeposit: '1000' })
|
|
395
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
396
|
+
const fetch = makeSessionFetch(async (_input, init) => {
|
|
397
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
398
|
+
if (!authorization) return paymentRequired(challenge)
|
|
399
|
+
actions.push(deserialize(authorization).action)
|
|
400
|
+
throw new Error('paid request failed')
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
await expect(fetch('https://example.com/resource')).rejects.toThrow('paid request failed')
|
|
404
|
+
await expect(fetch('https://example.com/resource')).rejects.toThrow('paid request failed')
|
|
405
|
+
expect(actions).toEqual(['open', 'open'])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test('keeps an open acknowledged by a payment receipt', async () => {
|
|
409
|
+
const challenge = makeSessionChallenge({ suggestedDeposit: '1000' })
|
|
410
|
+
const channelStore = createChannelStore()
|
|
411
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
412
|
+
const credentials: string[] = []
|
|
413
|
+
let paidRequests = 0
|
|
414
|
+
const fetch = makeSessionFetch(async (_input, init) => {
|
|
415
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
416
|
+
if (!authorization) return paymentRequired(challenge)
|
|
417
|
+
const payload = deserialize(authorization)
|
|
418
|
+
actions.push(payload.action)
|
|
419
|
+
credentials.push(authorization)
|
|
420
|
+
if (++paidRequests === 1) return paymentRequired(challenge)
|
|
421
|
+
if (paidRequests > 2) return new Response('rejected', { status: 500 })
|
|
422
|
+
return paymentRequired(challenge, {
|
|
423
|
+
[Constants.Headers.paymentReceipt]: Types.serializeSessionReceipt(
|
|
424
|
+
Types.createSessionReceipt({
|
|
425
|
+
acceptedCumulative: 100n,
|
|
426
|
+
challengeId: challenge.id,
|
|
427
|
+
channelId: payload.channelId,
|
|
428
|
+
spent: 100n,
|
|
429
|
+
}),
|
|
430
|
+
),
|
|
431
|
+
})
|
|
432
|
+
}, channelStore)
|
|
433
|
+
|
|
434
|
+
expect((await fetch('https://example.com/resource')).status).toBe(500)
|
|
435
|
+
expect(actions).toEqual(['open', 'open', 'voucher'])
|
|
436
|
+
expect(credentials[0]).toBe(credentials[1])
|
|
437
|
+
expect(await channelStore.get(defaultChannelKey)).toMatchObject({ opened: true })
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test.each([
|
|
441
|
+
[
|
|
442
|
+
'keeps an open acknowledged by the final session snapshot',
|
|
443
|
+
undefined,
|
|
444
|
+
['open', 'voucher'],
|
|
445
|
+
true,
|
|
446
|
+
],
|
|
447
|
+
[
|
|
448
|
+
'fails closed on malformed acknowledgement snapshots without leaking the open lock',
|
|
449
|
+
'not-a-number',
|
|
450
|
+
['open', 'open'],
|
|
451
|
+
undefined,
|
|
452
|
+
],
|
|
453
|
+
])('%s', async (_name, acceptedCumulative, expectedActions, expectedOpened) => {
|
|
454
|
+
const challenge = makeSessionChallenge({ suggestedDeposit: '1000' })
|
|
455
|
+
const channelStore = createChannelStore()
|
|
456
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
457
|
+
let paidRequests = 0
|
|
458
|
+
const fetch = makeSessionFetch(
|
|
459
|
+
async (_input, init) => {
|
|
460
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
461
|
+
if (!authorization) return paymentRequired(challenge)
|
|
462
|
+
const payload = deserialize(authorization)
|
|
463
|
+
actions.push(payload.action)
|
|
464
|
+
if (++paidRequests > 1) return new Response('rejected', { status: 500 })
|
|
465
|
+
return paymentRequired(withSnapshot(payload, acceptedCumulative))
|
|
466
|
+
},
|
|
467
|
+
channelStore,
|
|
468
|
+
1,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
expect((await fetch('https://example.com/resource')).status).toBe(402)
|
|
472
|
+
expect((await fetch('https://example.com/resource')).status).toBe(500)
|
|
473
|
+
expect(actions).toEqual(expectedActions)
|
|
474
|
+
expect((await channelStore.get(defaultChannelKey))?.opened).toBe(expectedOpened)
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
test('keeps an acknowledged open when challenge ordering throws', async () => {
|
|
478
|
+
const challenge = makeSessionChallenge({ suggestedDeposit: '1000' })
|
|
479
|
+
const channelStore = createChannelStore()
|
|
480
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
481
|
+
let ordered = 0
|
|
482
|
+
let paidRequests = 0
|
|
483
|
+
const fetch = Fetch.from({
|
|
484
|
+
fetch: async (_input, init) => {
|
|
485
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
486
|
+
if (!authorization) return paymentRequired(challenge)
|
|
487
|
+
const payload = deserialize(authorization)
|
|
488
|
+
actions.push(payload.action)
|
|
489
|
+
if (++paidRequests > 1) return new Response('paid')
|
|
490
|
+
return paymentRequired(withSnapshot(payload))
|
|
491
|
+
},
|
|
492
|
+
methods: [makeSessionMethod(channelStore)],
|
|
493
|
+
orderChallenges(candidates) {
|
|
494
|
+
if (++ordered === 2) throw new Error('ordering failed')
|
|
495
|
+
return candidates
|
|
496
|
+
},
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
await expect(fetch('https://example.com/resource')).rejects.toThrow('ordering failed')
|
|
500
|
+
expect(await (await fetch('https://example.com/resource')).text()).toBe('paid')
|
|
501
|
+
expect(actions).toEqual(['open', 'voucher'])
|
|
502
|
+
expect(await channelStore.get(defaultChannelKey)).toMatchObject({ opened: true })
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test('serializes concurrent opens against the committed channel', async () => {
|
|
506
|
+
const challenge = makeSessionChallenge()
|
|
507
|
+
const channelStore = createChannelStore()
|
|
508
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
509
|
+
const vouchers: bigint[] = []
|
|
510
|
+
const firstGate = Promise.withResolvers<void>()
|
|
511
|
+
const firstRequest = Promise.withResolvers<void>()
|
|
512
|
+
const waitersReady = Promise.withResolvers<void>()
|
|
513
|
+
let credentialPlans = 0
|
|
514
|
+
const fetch = Fetch.from({
|
|
515
|
+
fetch: async (input, init) => {
|
|
516
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
517
|
+
if (!authorization) return paymentRequired(challenge)
|
|
518
|
+
const payload = deserialize(authorization)
|
|
519
|
+
actions.push(payload.action)
|
|
520
|
+
if (payload.action === 'topUp') return new Response(null, { status: 204 })
|
|
521
|
+
if (payload.action === 'voucher') vouchers.push(BigInt(payload.cumulativeAmount))
|
|
522
|
+
if (input.toString().endsWith('/first')) {
|
|
523
|
+
firstRequest.resolve()
|
|
524
|
+
await firstGate.promise
|
|
525
|
+
return new Response('paid')
|
|
526
|
+
}
|
|
527
|
+
return new Response('rejected', { status: 500 })
|
|
528
|
+
},
|
|
529
|
+
methods: [
|
|
530
|
+
makeSessionMethod(channelStore, {
|
|
531
|
+
resolveAccount() {
|
|
532
|
+
if (++credentialPlans === 3) waitersReady.resolve()
|
|
533
|
+
return account
|
|
534
|
+
},
|
|
535
|
+
}),
|
|
536
|
+
],
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
const first = fetch('https://example.com/first')
|
|
540
|
+
await firstRequest.promise
|
|
541
|
+
const second = fetch('https://example.com/second')
|
|
542
|
+
const third = fetch('https://example.com/third')
|
|
543
|
+
await waitersReady.promise
|
|
544
|
+
firstGate.resolve()
|
|
545
|
+
|
|
546
|
+
expect((await first).status).toBe(200)
|
|
547
|
+
expect((await second).status).toBe(500)
|
|
548
|
+
expect((await third).status).toBe(500)
|
|
549
|
+
expect(actions).toEqual(['open', 'topUp', 'voucher', 'topUp', 'voucher'])
|
|
550
|
+
expect(vouchers).toEqual([200n, 300n])
|
|
551
|
+
expect(await channelStore.get(defaultChannelKey)).toMatchObject({ opened: true })
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
test('replaces a stored channel owned by another account', async () => {
|
|
555
|
+
const channelStore = createChannelStore()
|
|
556
|
+
const foreign = '0x0000000000000000000000000000000000000004' as Address
|
|
557
|
+
await channelStore.set({
|
|
558
|
+
chainId,
|
|
559
|
+
channelId: `0x${'44'.repeat(32)}`,
|
|
560
|
+
cumulativeAmount: 100n,
|
|
561
|
+
deposit: 100n,
|
|
562
|
+
descriptor: { ...descriptor, authorizedSigner: foreign, payer: foreign },
|
|
563
|
+
escrow: tip20ChannelEscrow,
|
|
564
|
+
opened: true,
|
|
565
|
+
})
|
|
566
|
+
const actions: Types.SessionCredentialPayload['action'][] = []
|
|
567
|
+
const challenge = makeSessionChallenge({ suggestedDeposit: '1000' })
|
|
568
|
+
const fetch = makeSessionFetch(async (_input, init) => {
|
|
569
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
570
|
+
if (!authorization) return paymentRequired(challenge)
|
|
571
|
+
actions.push(deserialize(authorization).action)
|
|
572
|
+
return new Response('paid')
|
|
573
|
+
}, channelStore)
|
|
574
|
+
|
|
575
|
+
expect(await (await fetch('https://example.com/resource')).text()).toBe('paid')
|
|
576
|
+
expect(await (await fetch('https://example.com/resource')).text()).toBe('paid')
|
|
577
|
+
expect(actions).toEqual(['open', 'voucher'])
|
|
578
|
+
expect(await channelStore.get(defaultChannelKey)).toMatchObject({
|
|
579
|
+
descriptor: { authorizedSigner: account.address, payer: account.address },
|
|
580
|
+
opened: true,
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('uses a matching server snapshot deposit when checking headroom', async () => {
|
|
585
|
+
const channelStore = createChannelStore()
|
|
586
|
+
const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
|
|
587
|
+
await channelStore.set({
|
|
588
|
+
chainId,
|
|
589
|
+
channelId,
|
|
590
|
+
cumulativeAmount: 100n,
|
|
591
|
+
deposit: 100n,
|
|
592
|
+
descriptor,
|
|
593
|
+
escrow: tip20ChannelEscrow,
|
|
594
|
+
opened: true,
|
|
595
|
+
})
|
|
596
|
+
const challenge = makeChallenge({
|
|
597
|
+
methodDetails: {
|
|
598
|
+
chainId,
|
|
599
|
+
escrowContract: tip20ChannelEscrow,
|
|
600
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
601
|
+
sessionSnapshot: {
|
|
602
|
+
acceptedCumulative: '100',
|
|
603
|
+
chainId,
|
|
604
|
+
channelId,
|
|
605
|
+
deposit: '200',
|
|
606
|
+
descriptor,
|
|
607
|
+
escrow: tip20ChannelEscrow,
|
|
608
|
+
requiredCumulative: '200',
|
|
609
|
+
settled: '0',
|
|
610
|
+
spent: '100',
|
|
611
|
+
units: 1,
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
})
|
|
615
|
+
let updatedDeposit = 0n
|
|
616
|
+
const method = session({
|
|
617
|
+
account,
|
|
618
|
+
channelStore,
|
|
619
|
+
decimals: 0,
|
|
620
|
+
getClient: () => client,
|
|
621
|
+
onChannelUpdate: (entry) => {
|
|
622
|
+
updatedDeposit = entry.deposit
|
|
623
|
+
},
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
await expect(
|
|
627
|
+
MethodChallenge.handle(method, {
|
|
628
|
+
challenge,
|
|
629
|
+
context: {},
|
|
630
|
+
fetch: async () => {
|
|
631
|
+
throw new Error('unexpected top-up')
|
|
632
|
+
},
|
|
633
|
+
input: 'https://example.com/resource',
|
|
634
|
+
}),
|
|
635
|
+
).resolves.toBeUndefined()
|
|
636
|
+
expect(updatedDeposit).toBe(200n)
|
|
209
637
|
})
|
|
210
638
|
|
|
211
639
|
test('opens for the current amount without client deposit configuration', async () => {
|
|
@@ -218,6 +646,72 @@ describe('precompile client session', () => {
|
|
|
218
646
|
expect(openDeposit(payload)).toBe(100n)
|
|
219
647
|
})
|
|
220
648
|
|
|
649
|
+
test('atomically auto-swaps before opening a session channel', async () => {
|
|
650
|
+
const sourceToken = '0x0000000000000000000000000000000000000007' as Address
|
|
651
|
+
const swapCalls = [
|
|
652
|
+
{ to: sourceToken, data: '0x1234' as const },
|
|
653
|
+
{ to: '0x0000000000000000000000000000000000000008' as Address, data: '0x5678' as const },
|
|
654
|
+
]
|
|
655
|
+
const findCalls = vi.spyOn(AutoSwap, 'findCalls').mockResolvedValue(swapCalls)
|
|
656
|
+
try {
|
|
657
|
+
const method = session({
|
|
658
|
+
account,
|
|
659
|
+
autoSwap: { slippage: 2, tokenIn: [sourceToken] },
|
|
660
|
+
getClient: () => client,
|
|
661
|
+
})
|
|
662
|
+
const payload = deserialize(
|
|
663
|
+
await method.createCredential({ challenge: makeChallenge(), context: {} }),
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
expect(findCalls).toHaveBeenCalledWith(expect.any(Object), {
|
|
667
|
+
account: account.address,
|
|
668
|
+
amountOut: 100n,
|
|
669
|
+
slippage: 2,
|
|
670
|
+
tokenIn: expect.arrayContaining([sourceToken]),
|
|
671
|
+
tokenOut: descriptor.token,
|
|
672
|
+
})
|
|
673
|
+
const calls = transactionCalls(payload)
|
|
674
|
+
expect(calls.slice(0, 2)).toEqual(swapCalls)
|
|
675
|
+
expect(calls[2]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
|
|
676
|
+
expect(decodeFunctionData({ abi: escrowAbi, data: calls[2]!.data! }).functionName).toBe(
|
|
677
|
+
'open',
|
|
678
|
+
)
|
|
679
|
+
} finally {
|
|
680
|
+
findCalls.mockRestore()
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('atomically auto-swaps before topping up a session channel', async () => {
|
|
685
|
+
const sourceToken = '0x0000000000000000000000000000000000000007' as Address
|
|
686
|
+
const swapCalls = [{ to: sourceToken, data: '0x1234' as const }]
|
|
687
|
+
const findCalls = vi.spyOn(AutoSwap, 'findCalls').mockResolvedValue(swapCalls)
|
|
688
|
+
try {
|
|
689
|
+
const method = session({ account, autoSwap: true, getClient: () => client })
|
|
690
|
+
const payload = deserialize(
|
|
691
|
+
await method.createCredential({
|
|
692
|
+
challenge: makeChallenge(),
|
|
693
|
+
context: { action: 'topUp', additionalDepositRaw: '250', descriptor },
|
|
694
|
+
}),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
expect(findCalls).toHaveBeenCalledWith(expect.any(Object), {
|
|
698
|
+
account: account.address,
|
|
699
|
+
amountOut: 250n,
|
|
700
|
+
slippage: 1,
|
|
701
|
+
tokenIn: AutoSwap.defaultCurrencies,
|
|
702
|
+
tokenOut: descriptor.token,
|
|
703
|
+
})
|
|
704
|
+
const calls = transactionCalls(payload)
|
|
705
|
+
expect(calls[0]).toEqual(swapCalls[0])
|
|
706
|
+
expect(calls[1]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
|
|
707
|
+
expect(decodeFunctionData({ abi: escrowAbi, data: calls[1]!.data! }).functionName).toBe(
|
|
708
|
+
'topUp',
|
|
709
|
+
)
|
|
710
|
+
} finally {
|
|
711
|
+
findCalls.mockRestore()
|
|
712
|
+
}
|
|
713
|
+
})
|
|
714
|
+
|
|
221
715
|
test('uses client chain ID when the challenge omits chain ID', async () => {
|
|
222
716
|
const method = session({ account, getClient: () => client })
|
|
223
717
|
const credential = deserializeCredential(
|
|
@@ -286,12 +780,17 @@ describe('precompile client session', () => {
|
|
|
286
780
|
expect(openDeposit(payload)).toBe(100n)
|
|
287
781
|
})
|
|
288
782
|
|
|
289
|
-
test('rejects open
|
|
783
|
+
test('rejects open above maxDeposit without leaking the Fetch lock', async () => {
|
|
290
784
|
const method = session({ account, decimals: 0, maxDeposit: '50', getClient: () => client })
|
|
785
|
+
const fetch = Fetch.from({
|
|
786
|
+
fetch: async () => paymentRequired(makeSessionChallenge()),
|
|
787
|
+
methods: [method],
|
|
788
|
+
})
|
|
291
789
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
790
|
+
for (let attempt = 0; attempt < 2; attempt++)
|
|
791
|
+
await expect(fetch('https://example.com/resource')).rejects.toThrow(
|
|
792
|
+
'requested voucher amount 100 exceeds local maxDeposit 50',
|
|
793
|
+
)
|
|
295
794
|
})
|
|
296
795
|
|
|
297
796
|
test('rejects explicit opening deposits below the request amount', async () => {
|