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.
Files changed (156) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/Errors.d.ts +10 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +11 -2
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/Method.d.ts +105 -3
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +102 -2
  9. package/dist/Method.js.map +1 -1
  10. package/dist/cli/validate/payment.d.ts.map +1 -1
  11. package/dist/cli/validate/payment.js +18 -4
  12. package/dist/cli/validate/payment.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts.map +1 -1
  14. package/dist/client/internal/Fetch.js +98 -5
  15. package/dist/client/internal/Fetch.js.map +1 -1
  16. package/dist/client/internal/MethodChallenge.d.ts +19 -0
  17. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  18. package/dist/client/internal/MethodChallenge.js +15 -0
  19. package/dist/client/internal/MethodChallenge.js.map +1 -0
  20. package/dist/client/internal/MethodResponse.d.ts +15 -0
  21. package/dist/client/internal/MethodResponse.d.ts.map +1 -1
  22. package/dist/client/internal/MethodResponse.js +10 -0
  23. package/dist/client/internal/MethodResponse.js.map +1 -1
  24. package/dist/server/Mppx.d.ts +23 -4
  25. package/dist/server/Mppx.d.ts.map +1 -1
  26. package/dist/server/Mppx.js +91 -82
  27. package/dist/server/Mppx.js.map +1 -1
  28. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  29. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/stripe/server/internal/html.gen.js +1 -1
  31. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  32. package/dist/tempo/client/Methods.d.ts +1 -0
  33. package/dist/tempo/client/Methods.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +7 -2
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  39. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  40. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  41. package/dist/tempo/server/Charge.d.ts +46 -77
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +289 -209
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/Methods.d.ts +12 -1
  46. package/dist/tempo/server/Methods.d.ts.map +1 -1
  47. package/dist/tempo/server/Methods.js +4 -0
  48. package/dist/tempo/server/Methods.js.map +1 -1
  49. package/dist/tempo/server/Relay.d.ts +57 -0
  50. package/dist/tempo/server/Relay.d.ts.map +1 -0
  51. package/dist/tempo/server/Relay.js +179 -0
  52. package/dist/tempo/server/Relay.js.map +1 -0
  53. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  54. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  55. package/dist/tempo/server/SponsorBudget.js +150 -0
  56. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  57. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  58. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  61. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  62. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/session/client/ChannelOps.js +9 -4
  64. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  66. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  67. package/dist/tempo/session/client/CredentialState.js +37 -19
  68. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  69. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  70. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  71. package/dist/tempo/session/client/Runtime.js +17 -0
  72. package/dist/tempo/session/client/Runtime.js.map +1 -1
  73. package/dist/tempo/session/client/Session.d.ts +15 -5
  74. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/session/client/Session.js +216 -45
  76. package/dist/tempo/session/client/Session.js.map +1 -1
  77. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  78. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  79. package/dist/tempo/session/client/SessionManager.js +114 -44
  80. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  81. package/dist/tempo/session/client/Transports.d.ts +16 -6
  82. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  83. package/dist/tempo/session/client/Transports.js +84 -37
  84. package/dist/tempo/session/client/Transports.js.map +1 -1
  85. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  86. package/dist/tempo/session/precompile/Chain.js +96 -8
  87. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  88. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  89. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  90. package/dist/tempo/session/precompile/Channel.js +4 -0
  91. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  92. package/dist/tempo/session/server/RequestState.js +3 -1
  93. package/dist/tempo/session/server/RequestState.js.map +1 -1
  94. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  95. package/dist/tempo/session/server/Ws.js +3 -0
  96. package/dist/tempo/session/server/Ws.js.map +1 -1
  97. package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
  98. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  99. package/dist/tempo/subscription/KeyAuthorization.js +14 -3
  100. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  101. package/dist/viem/Client.d.ts.map +1 -1
  102. package/dist/viem/Client.js +6 -1
  103. package/dist/viem/Client.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/Errors.test.ts +23 -0
  106. package/src/Errors.ts +21 -2
  107. package/src/Method.test.ts +102 -1
  108. package/src/Method.ts +241 -5
  109. package/src/cli/validate/payment.ts +18 -4
  110. package/src/cli/validate.test.ts +16 -0
  111. package/src/client/Mppx.test-d.ts +3 -3
  112. package/src/client/internal/Fetch.test.ts +77 -0
  113. package/src/client/internal/Fetch.ts +122 -11
  114. package/src/client/internal/MethodChallenge.ts +35 -0
  115. package/src/client/internal/MethodResponse.ts +28 -0
  116. package/src/server/Mppx.test-d.ts +2 -0
  117. package/src/server/Mppx.test.ts +205 -0
  118. package/src/server/Mppx.ts +167 -103
  119. package/src/stripe/server/internal/html.gen.ts +1 -1
  120. package/src/tempo/PublicExports.test-d.ts +20 -0
  121. package/src/tempo/internal/fee-payer.ts +7 -3
  122. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  123. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  124. package/src/tempo/server/Charge.test.ts +337 -63
  125. package/src/tempo/server/Charge.ts +443 -267
  126. package/src/tempo/server/Methods.ts +11 -0
  127. package/src/tempo/server/Relay.test.ts +677 -0
  128. package/src/tempo/server/Relay.ts +297 -0
  129. package/src/tempo/server/SponsorBudget.test.ts +154 -0
  130. package/src/tempo/server/SponsorBudget.ts +219 -0
  131. package/src/tempo/server/internal/html.gen.ts +1 -1
  132. package/src/tempo/session/README.md +7 -0
  133. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  134. package/src/tempo/session/client/ChannelOps.ts +12 -4
  135. package/src/tempo/session/client/CredentialState.ts +61 -20
  136. package/src/tempo/session/client/Runtime.test.ts +12 -0
  137. package/src/tempo/session/client/Runtime.ts +22 -0
  138. package/src/tempo/session/client/Session.test.ts +506 -7
  139. package/src/tempo/session/client/Session.ts +279 -54
  140. package/src/tempo/session/client/SessionManager.test.ts +318 -13
  141. package/src/tempo/session/client/SessionManager.ts +132 -42
  142. package/src/tempo/session/client/Transports.test.ts +54 -1
  143. package/src/tempo/session/client/Transports.ts +137 -57
  144. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  145. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  146. package/src/tempo/session/precompile/Chain.ts +102 -8
  147. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  148. package/src/tempo/session/precompile/Channel.ts +5 -0
  149. package/src/tempo/session/server/RequestState.test.ts +8 -1
  150. package/src/tempo/session/server/RequestState.ts +2 -2
  151. package/src/tempo/session/server/Session.test.ts +66 -4
  152. package/src/tempo/session/server/Ws.ts +3 -0
  153. package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
  154. package/src/tempo/subscription/KeyAuthorization.ts +14 -3
  155. package/src/viem/Client.test.ts +18 -1
  156. 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', 'voucher', 'topUp', 'voucher'])
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 when maxDeposit is below the request amount', async () => {
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
- await expect(
293
- method.createCredential({ challenge: makeChallenge(), context: {} }),
294
- ).rejects.toThrow('requested voucher amount 100 exceeds local maxDeposit 50')
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 () => {