mppx 0.8.14 → 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 (80) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/validate/payment.d.ts.map +1 -1
  3. package/dist/cli/validate/payment.js +18 -4
  4. package/dist/cli/validate/payment.js.map +1 -1
  5. package/dist/client/internal/Fetch.d.ts.map +1 -1
  6. package/dist/client/internal/Fetch.js +93 -10
  7. package/dist/client/internal/Fetch.js.map +1 -1
  8. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  9. package/dist/client/internal/MethodChallenge.d.ts.map +1 -1
  10. package/dist/client/internal/MethodChallenge.js +4 -0
  11. package/dist/client/internal/MethodChallenge.js.map +1 -1
  12. package/dist/client/internal/MethodResponse.d.ts +15 -0
  13. package/dist/client/internal/MethodResponse.d.ts.map +1 -1
  14. package/dist/client/internal/MethodResponse.js +10 -0
  15. package/dist/client/internal/MethodResponse.js.map +1 -1
  16. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  17. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/stripe/server/internal/html.gen.js +1 -1
  19. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/server/Charge.d.ts +9 -1
  21. package/dist/tempo/server/Charge.d.ts.map +1 -1
  22. package/dist/tempo/server/Charge.js.map +1 -1
  23. package/dist/tempo/server/Relay.d.ts +13 -4
  24. package/dist/tempo/server/Relay.d.ts.map +1 -1
  25. package/dist/tempo/server/Relay.js +4 -2
  26. package/dist/tempo/server/Relay.js.map +1 -1
  27. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -1
  28. package/dist/tempo/server/SponsorBudget.js +7 -1
  29. package/dist/tempo/server/SponsorBudget.js.map +1 -1
  30. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  31. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  32. package/dist/tempo/server/internal/html.gen.js +1 -1
  33. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  34. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  35. package/dist/tempo/session/client/Session.js +128 -14
  36. package/dist/tempo/session/client/Session.js.map +1 -1
  37. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  38. package/dist/tempo/session/client/SessionManager.js +62 -20
  39. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  40. package/dist/tempo/session/client/Transports.d.ts +9 -1
  41. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  42. package/dist/tempo/session/client/Transports.js +36 -13
  43. package/dist/tempo/session/client/Transports.js.map +1 -1
  44. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  45. package/dist/tempo/session/server/Ws.js +3 -0
  46. package/dist/tempo/session/server/Ws.js.map +1 -1
  47. package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
  48. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  49. package/dist/tempo/subscription/KeyAuthorization.js +14 -3
  50. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  51. package/dist/viem/Client.d.ts.map +1 -1
  52. package/dist/viem/Client.js +6 -1
  53. package/dist/viem/Client.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/cli/validate/payment.ts +18 -4
  56. package/src/cli/validate.test.ts +16 -0
  57. package/src/client/internal/Fetch.test.ts +77 -0
  58. package/src/client/internal/Fetch.ts +113 -11
  59. package/src/client/internal/MethodChallenge.ts +5 -0
  60. package/src/client/internal/MethodResponse.ts +28 -0
  61. package/src/stripe/server/internal/html.gen.ts +1 -1
  62. package/src/tempo/server/Charge.ts +9 -1
  63. package/src/tempo/server/Relay.test.ts +156 -2
  64. package/src/tempo/server/Relay.ts +13 -4
  65. package/src/tempo/server/SponsorBudget.test.ts +29 -0
  66. package/src/tempo/server/SponsorBudget.ts +7 -1
  67. package/src/tempo/server/internal/html.gen.ts +1 -1
  68. package/src/tempo/session/README.md +7 -0
  69. package/src/tempo/session/client/Session.test.ts +299 -33
  70. package/src/tempo/session/client/Session.ts +152 -16
  71. package/src/tempo/session/client/SessionManager.test.ts +216 -9
  72. package/src/tempo/session/client/SessionManager.ts +63 -17
  73. package/src/tempo/session/client/Transports.test.ts +22 -0
  74. package/src/tempo/session/client/Transports.ts +58 -22
  75. package/src/tempo/session/server/Session.test.ts +66 -4
  76. package/src/tempo/session/server/Ws.ts +3 -0
  77. package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
  78. package/src/tempo/subscription/KeyAuthorization.ts +14 -3
  79. package/src/viem/Client.test.ts +18 -1
  80. package/src/viem/Client.ts +7 -1
@@ -83,6 +83,13 @@ sequenceDiagram
83
83
  route handler. `open` and `voucher` used on non-billable requests are also
84
84
  management updates.
85
85
 
86
+ When `Fetch.from` creates an automatic `open`, it keeps the channel entry
87
+ provisional until the server accepts the response or acknowledges the open with
88
+ a matching receipt or session snapshot. Rejected or unacknowledged opens are
89
+ discarded so the next request retries `open`; concurrent automatic opens for the
90
+ same payment scope are serialized. Direct credential creation and
91
+ `SessionManager` retain their existing lifecycle ownership.
92
+
86
93
  The current HTTP contract is deliberately pre-handler: voucher acceptance and
87
94
  default request charging occur before the handler runs. A handler failure can
88
95
  therefore follow an accepted voucher and recorded `spent`. Sessions does not
@@ -16,7 +16,7 @@ import { escrowAbi } from '../precompile/escrow.abi.js'
16
16
  import { tip20ChannelEscrow } from '../precompile/Protocol.js'
17
17
  import * as Types from '../precompile/Protocol.js'
18
18
  import * as Voucher from '../precompile/Voucher.js'
19
- import { createChannelStore } from './ChannelStore.js'
19
+ import { channelKey, createChannelStore } from './ChannelStore.js'
20
20
  import { session } from './Session.js'
21
21
 
22
22
  const account = privateKeyToAccount(
@@ -60,6 +60,13 @@ type SessionChallenge = Challenge<
60
60
  typeof Methods.session.name
61
61
  >
62
62
 
63
+ const defaultChannelKey = channelKey({
64
+ chainId,
65
+ escrow: tip20ChannelEscrow,
66
+ payee: descriptor.payee,
67
+ token: descriptor.token,
68
+ })
69
+
63
70
  function makeChallenge(overrides: Partial<SessionChallenge['request']> = {}): SessionChallenge {
64
71
  const request: SessionChallenge['request'] = {
65
72
  amount: '100',
@@ -78,6 +85,83 @@ function makeChallenge(overrides: Partial<SessionChallenge['request']> = {}): Se
78
85
  }
79
86
  }
80
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
+
81
165
  function deserialize(credential: string) {
82
166
  return Credential.deserialize(credential).payload as Types.SessionCredentialPayload
83
167
  }
@@ -233,14 +317,16 @@ describe('precompile client session', () => {
233
317
  header: string | null
234
318
  method: string | undefined
235
319
  }[] = []
320
+ const vouchers: string[] = []
236
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
+ })
237
327
  const rawFetch: typeof globalThis.fetch = async (_input, init) => {
238
328
  const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
239
- if (!authorization)
240
- return new Response(null, {
241
- status: 402,
242
- headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
243
- })
329
+ if (!authorization) return paymentRequired()
244
330
 
245
331
  const payload = deserialize(authorization)
246
332
  actions.push(payload.action)
@@ -250,6 +336,8 @@ describe('precompile client session', () => {
250
336
  }
251
337
  if (payload.action === 'topUp') return new Response(null, { status: 204 })
252
338
  if (payload.action !== 'voucher' || !opened) throw new Error('expected open voucher')
339
+ vouchers.push(authorization)
340
+ if (vouchers.length === 1) return paymentRequired()
253
341
 
254
342
  paidRequests.push({
255
343
  body: init?.body ?? null,
@@ -278,11 +366,192 @@ describe('precompile client session', () => {
278
366
  })
279
367
 
280
368
  expect(await response.text()).toBe('paid')
281
- expect(actions).toEqual(['open', 'topUp', 'voucher'])
369
+ expect(actions).toEqual(['open', 'topUp', 'voucher', 'voucher'])
370
+ expect(new Set(vouchers).size).toBe(1)
282
371
  expect(paidRequests).toEqual([{ body: 'request-body', header: 'preserved', method: 'POST' }])
283
372
  })
284
373
 
285
- test('does not top up a stored channel owned by another account', async () => {
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 () => {
286
555
  const channelStore = createChannelStore()
287
556
  const foreign = '0x0000000000000000000000000000000000000004' as Address
288
557
  await channelStore.set({
@@ -295,29 +564,21 @@ describe('precompile client session', () => {
295
564
  opened: true,
296
565
  })
297
566
  const actions: Types.SessionCredentialPayload['action'][] = []
298
- const challenge = makeChallenge({
299
- methodDetails: {
300
- chainId,
301
- escrowContract: tip20ChannelEscrow,
302
- sessionProtocol: Constants.SessionProtocols.v2,
303
- },
304
- })
305
- const fetch = Fetch.from({
306
- fetch: async (_input, init) => {
307
- const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
308
- if (!authorization)
309
- return new Response(null, {
310
- status: 402,
311
- headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
312
- })
313
- actions.push(deserialize(authorization).action)
314
- return new Response('paid')
315
- },
316
- methods: [session({ account, channelStore, decimals: 0, getClient: () => client })],
317
- })
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)
318
574
 
319
575
  expect(await (await fetch('https://example.com/resource')).text()).toBe('paid')
320
- expect(actions).toEqual(['open'])
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
+ })
321
582
  })
322
583
 
323
584
  test('uses a matching server snapshot deposit when checking headroom', async () => {
@@ -519,12 +780,17 @@ describe('precompile client session', () => {
519
780
  expect(openDeposit(payload)).toBe(100n)
520
781
  })
521
782
 
522
- test('rejects open when maxDeposit is below the request amount', async () => {
783
+ test('rejects open above maxDeposit without leaking the Fetch lock', async () => {
523
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
+ })
524
789
 
525
- await expect(
526
- method.createCredential({ challenge: makeChallenge(), context: {} }),
527
- ).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
+ )
528
794
  })
529
795
 
530
796
  test('rejects explicit opening deposits below the request amount', async () => {
@@ -3,6 +3,7 @@ import { tempo as tempo_chain } from 'viem/chains'
3
3
 
4
4
  import * as MethodChallenge from '../../../client/internal/MethodChallenge.js'
5
5
  import * as MethodResponse from '../../../client/internal/MethodResponse.js'
6
+ import * as Constants from '../../../Constants.js'
6
7
  import * as Credential from '../../../Credential.js'
7
8
  import * as Method from '../../../Method.js'
8
9
  import * as Account from '../../../viem/Account.js'
@@ -16,6 +17,7 @@ import * as defaults from '../../internal/defaults.js'
16
17
  import * as Methods from '../../Methods.js'
17
18
  import * as Channel from '../precompile/Channel.js'
18
19
  import {
20
+ deserializeSessionReceipt,
19
21
  isEventStream,
20
22
  readSessionChallengeAmount,
21
23
  requireSessionCredentialContext,
@@ -35,6 +37,7 @@ import {
35
37
  } from './CredentialState.js'
36
38
  import { assertWithinMaxDeposit, resolveAutomaticTopUp } from './Runtime.js'
37
39
  import {
40
+ getSessionSnapshot,
38
41
  handleSseNeedVoucher,
39
42
  isTip1034SessionChallenge,
40
43
  postTopUp,
@@ -45,6 +48,67 @@ import {
45
48
 
46
49
  export { sessionContextSchema, type SessionContext } from './CredentialState.js'
47
50
 
51
+ /** Tail promise for each payment scope, used to serialize automatic opens per store. */
52
+ const channelTails = new WeakMap<ChannelStore, Map<string, Promise<void>>>()
53
+
54
+ /** Serializes automatic opens for one payment scope across methods sharing a store. */
55
+ async function lockChannel(store: ChannelStore, key: string) {
56
+ const tails = channelTails.get(store) ?? new Map<string, Promise<void>>()
57
+ channelTails.set(store, tails)
58
+ const previous = tails.get(key)
59
+ let unlock!: () => void
60
+ const current = new Promise<void>((resolve) => {
61
+ unlock = resolve
62
+ })
63
+ tails.set(key, current)
64
+ await previous
65
+ return () => {
66
+ unlock()
67
+ if (tails.get(key) === current) tails.delete(key)
68
+ }
69
+ }
70
+
71
+ function matchesOpen(
72
+ opened: ChannelEntry,
73
+ acknowledgement: { acceptedCumulative: string; channelId: string } | null | undefined,
74
+ ): boolean {
75
+ try {
76
+ return (
77
+ acknowledgement?.channelId.toLowerCase() === opened.channelId.toLowerCase() &&
78
+ BigInt(acknowledgement.acceptedCumulative) === opened.cumulativeAmount
79
+ )
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Returns whether the server acknowledged an open.
87
+ *
88
+ * Receipts must match the originating challenge, channel, and cumulative amount.
89
+ * Replacement challenges may acknowledge the same channel and amount through a
90
+ * session snapshot. Malformed acknowledgements are ignored.
91
+ */
92
+ function acknowledgesOpen(
93
+ outcome: MethodResponse.AttemptOutcome,
94
+ challenge: TempoSessionChallenge,
95
+ opened: ChannelEntry,
96
+ ): boolean {
97
+ try {
98
+ const receiptHeader = outcome.response?.headers.get(Constants.Headers.paymentReceipt)
99
+ if (receiptHeader) {
100
+ const receipt = deserializeSessionReceipt(receiptHeader)
101
+ if (receipt.challengeId === challenge.id && matchesOpen(opened, receipt)) return true
102
+ }
103
+ } catch {}
104
+ return (
105
+ outcome.challenges?.some((candidate) => {
106
+ if (!isTip1034SessionChallenge(candidate)) return false
107
+ return matchesOpen(opened, getSessionSnapshot(candidate))
108
+ }) ?? false
109
+ )
110
+ }
111
+
48
112
  /**
49
113
  * Creates the low-level TIP-1034 session payment method for use with `Mppx.create()`.
50
114
  *
@@ -100,30 +164,102 @@ export function session(parameters: session.Parameters = {}) {
100
164
  )
101
165
  }
102
166
 
167
+ const resolveCredentialPlan = async (
168
+ resolved: ChallengeContext,
169
+ context: CredentialContext | undefined,
170
+ entry: ChannelEntry | undefined,
171
+ ) =>
172
+ planCredential({
173
+ account: await resolveCredentialAccount(resolved, context, entry),
174
+ entry,
175
+ context,
176
+ decimals,
177
+ maxDeposit,
178
+ resolved,
179
+ })
180
+
103
181
  const method = Method.toClient(Methods.session, {
104
182
  canHandleChallenge: ({ challenge }) => isTip1034SessionChallenge(challenge),
105
183
  context: sessionContextSchema,
106
- async createCredential({ challenge, context }) {
184
+ async createCredential(parameters) {
185
+ const { challenge, context } = parameters
107
186
  const resolved = await resolveChallengeContext({
108
187
  challenge,
109
188
  escrowOverride,
110
189
  getClient,
111
190
  })
112
- const entry = await store.get(resolved.key)
113
- const account = await resolveCredentialAccount(resolved, context, entry)
114
- const payload = await executeCredentialPlan(
115
- planCredential({
116
- account,
117
- entry,
118
- context,
119
- decimals,
120
- maxDeposit,
121
- resolved,
122
- }),
123
- sink,
124
- AutoSwap.resolve(context?.autoSwap ?? autoSwapParameter, AutoSwap.defaultCurrencies),
125
- )
126
- return serializeCredential(challenge, payload, resolved.chainId, account)
191
+ const attempt = MethodResponse.getAttempt(parameters)
192
+ let release = () => {}
193
+ try {
194
+ let entry = await store.get(resolved.key)
195
+ let plan = await resolveCredentialPlan(resolved, context, entry)
196
+ if (attempt && plan.type === 'open') {
197
+ release = await lockChannel(store, resolved.key)
198
+ const nextEntry = await store.get(resolved.key)
199
+ if (nextEntry?.channelId !== entry?.channelId) {
200
+ entry = nextEntry
201
+ if (entry?.opened) {
202
+ await attempt.prepare()
203
+ entry = await store.get(resolved.key)
204
+ }
205
+ plan = await resolveCredentialPlan(resolved, context, entry)
206
+ }
207
+ }
208
+ let pendingOpen: ChannelEntry | undefined
209
+ // Defer opens only when low-level Fetch owns the response lifecycle.
210
+ // SessionManager unregisters this hook and keeps its existing transaction boundary.
211
+ const credentialSink =
212
+ attempt && plan.type === 'open'
213
+ ? {
214
+ store: {
215
+ get: (key: string) => store.get(key),
216
+ async set(next: ChannelEntry) {
217
+ pendingOpen = next
218
+ },
219
+ delete: (key: string) => store.delete(key),
220
+ },
221
+ notifyUpdate() {},
222
+ }
223
+ : sink
224
+ const payload = await executeCredentialPlan(
225
+ plan,
226
+ credentialSink,
227
+ AutoSwap.resolve(context?.autoSwap ?? autoSwapParameter, AutoSwap.defaultCurrencies),
228
+ )
229
+ const credential = await serializeCredential(
230
+ challenge,
231
+ payload,
232
+ resolved.chainId,
233
+ plan.account,
234
+ )
235
+ if (attempt && pendingOpen) {
236
+ const opened = pendingOpen
237
+ attempt.settle = async (outcome) => {
238
+ const accepted =
239
+ outcome.status === 'accepted' || acknowledgesOpen(outcome, challenge, opened)
240
+ if (!accepted && outcome.status === 'pending') return false
241
+ try {
242
+ if (accepted) {
243
+ const current = await store.get(resolved.key)
244
+ if (
245
+ !current?.opened ||
246
+ current.channelId.toLowerCase() !== opened.channelId.toLowerCase()
247
+ ) {
248
+ await store.set(opened)
249
+ sink.notifyUpdate(opened)
250
+ }
251
+ }
252
+ return true
253
+ } finally {
254
+ release()
255
+ }
256
+ }
257
+ } else release()
258
+ return credential
259
+ } catch (error) {
260
+ release()
261
+ throw error
262
+ }
127
263
  },
128
264
  })
129
265