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
@@ -113,6 +113,20 @@ function makeChannelStore(seed: readonly ChannelEntry[] = []) {
113
113
  return { store, set, delete: remove, map }
114
114
  }
115
115
 
116
+ function makeDurableChannelStore() {
117
+ const backend = new Map<string, string>()
118
+ const store = createJsonChannelStore({
119
+ get: (key) => backend.get(key),
120
+ set: (key, value) => {
121
+ backend.set(key, value)
122
+ },
123
+ delete: (key) => {
124
+ backend.delete(key)
125
+ },
126
+ })
127
+ return { backend, store }
128
+ }
129
+
116
130
  function makeChallenge(overrides: Record<string, unknown> = {}): Challenge.Challenge {
117
131
  return Challenge.from({
118
132
  id: challengeId,
@@ -657,7 +671,7 @@ describe('Session', () => {
657
671
  })
658
672
  })
659
673
 
660
- test('rolls back an optimistic open when a replacement challenge does not reference it', async () => {
674
+ test('rolls back optimistic opens across replacement challenges', async () => {
661
675
  const { store, delete: remove } = makeChannelStore()
662
676
  const postedPayloads: SessionCredentialPayload[] = []
663
677
  const challenge = (feePayer: boolean) =>
@@ -669,6 +683,8 @@ describe('Session', () => {
669
683
  feePayer,
670
684
  },
671
685
  })
686
+ const original = challenge(true)
687
+ const replacement = challenge(false)
672
688
  let openCount = 0
673
689
  const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
674
690
  const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
@@ -677,10 +693,11 @@ describe('Session', () => {
677
693
  : undefined
678
694
  if (payload) postedPayloads.push(payload)
679
695
 
680
- if (!payload) return Promise.resolve(make402Response(challenge(true)))
696
+ if (!payload) return Promise.resolve(make402Response(original))
681
697
  if (payload.action === 'open') {
682
698
  openCount++
683
- if (openCount === 1) return Promise.resolve(make402Response(challenge(false)))
699
+ if (openCount === 1) return Promise.resolve(make402Response(replacement))
700
+ if (openCount === 2) return Promise.resolve(make402Response(original))
684
701
  return Promise.resolve(makeOkResponse())
685
702
  }
686
703
  return Promise.resolve(new Response('unexpected voucher', { status: 500 }))
@@ -696,12 +713,33 @@ describe('Session', () => {
696
713
  const response = await s.fetch('https://api.example.com/data')
697
714
 
698
715
  expect(response.status).toBe(200)
699
- // A replacement challenge with no snapshot means the server did not
700
- // acknowledge the optimistic open, so the next credential must open again.
701
- expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'open'])
702
- expect(postedPayloads[0]?.channelId).not.toBe(postedPayloads[1]?.channelId)
703
- expect(remove).toHaveBeenCalledOnce()
704
- expect(s.channelId).toBe(postedPayloads[1]?.channelId)
716
+ expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'open', 'open'])
717
+ expect(new Set(postedPayloads.map((payload) => payload.channelId)).size).toBe(3)
718
+ expect(remove).toHaveBeenCalledTimes(2)
719
+ expect(s.channelId).toBe(postedPayloads[2]?.channelId)
720
+ })
721
+
722
+ test('reuses prepared voucher state when an identical challenge succeeds', async () => {
723
+ const vouchers: Extract<SessionCredentialPayload, { action: 'voucher' }>[] = []
724
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
725
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
726
+ if (!authorization) return Promise.resolve(make402Response())
727
+ const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
728
+ if (payload.action !== 'voucher') throw new Error('expected voucher')
729
+ vouchers.push(payload)
730
+ return Promise.resolve(vouchers.length === 1 ? make402Response() : makeOkResponse())
731
+ })
732
+ const s = sessionManager({
733
+ account,
734
+ client,
735
+ fetch: mockFetch as typeof globalThis.fetch,
736
+ channelStore: makeChannelStore([channelEntry()]).store,
737
+ })
738
+
739
+ expect((await s.fetch('https://api.example.com/data')).status).toBe(200)
740
+ expect(vouchers.map((voucher) => voucher.cumulativeAmount)).toEqual(['2000000', '2000000'])
741
+ expect(s.cumulative).toBe(2_000_000n)
742
+ expect(s.state.status).toBe('active')
705
743
  })
706
744
 
707
745
  test('does not bootstrap when disabled', async () => {
@@ -989,6 +1027,60 @@ describe('Session', () => {
989
1027
  })
990
1028
  })
991
1029
 
1030
+ test('preserves a snapshot-acknowledged open when its voucher fails', async () => {
1031
+ const { backend, store } = makeDurableChannelStore()
1032
+ const payloads: SessionCredentialPayload[] = []
1033
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
1034
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
1035
+ if (!authorization)
1036
+ return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '10000000' })))
1037
+ const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
1038
+ payloads.push(payload)
1039
+ if (payload.action !== 'open')
1040
+ return Promise.resolve(new Response('failed', { status: 500 }))
1041
+ return Promise.resolve(
1042
+ make402Response(
1043
+ makeChallenge({
1044
+ methodDetails: {
1045
+ escrowContract: tip20ChannelEscrow,
1046
+ chainId: 4217,
1047
+ sessionProtocol: Constants.SessionProtocols.v2,
1048
+ sessionSnapshot: {
1049
+ acceptedCumulative: '1000000',
1050
+ chainId: 4217,
1051
+ channelId: payload.channelId,
1052
+ deposit: '10000000',
1053
+ descriptor: payload.descriptor,
1054
+ escrow: tip20ChannelEscrow,
1055
+ requiredCumulative: '2000000',
1056
+ settled: '0',
1057
+ spent: '1000000',
1058
+ units: 1,
1059
+ },
1060
+ },
1061
+ }),
1062
+ ),
1063
+ )
1064
+ })
1065
+ const s = sessionManager({
1066
+ account,
1067
+ client,
1068
+ fetch: mockFetch as typeof globalThis.fetch,
1069
+ maxDeposit: '10',
1070
+ channelStore: store,
1071
+ })
1072
+
1073
+ expect((await s.fetch('https://api.example.com/data')).status).toBe(500)
1074
+ expect(payloads.map((payload) => payload.action)).toEqual(['open', 'voucher'])
1075
+ expect(s.channelId).toBe(payloads[0]?.channelId)
1076
+ expect(s.cumulative).toBe(1_000_000n)
1077
+ expect(JSON.parse([...backend.values()][0]!)).toMatchObject({
1078
+ channelId: payloads[0]?.channelId,
1079
+ cumulativeAmount: '1000000',
1080
+ opened: true,
1081
+ })
1082
+ })
1083
+
992
1084
  test('preemptively top-ups before signing an HTTP voucher that exceeds deposit', async () => {
993
1085
  const postedPayloads: SessionCredentialPayload[] = []
994
1086
  let challengeCount = 0
@@ -1055,6 +1147,121 @@ describe('Session', () => {
1055
1147
  units: 2,
1056
1148
  })
1057
1149
  })
1150
+
1151
+ test('preserves a successful automatic top-up when the paid retry fails', async () => {
1152
+ const { map, store } = makeChannelStore()
1153
+ const payloads: SessionCredentialPayload[] = []
1154
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
1155
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
1156
+ if (!authorization)
1157
+ return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '1000000' })))
1158
+ const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
1159
+ payloads.push(payload)
1160
+ if (payload.action === 'open')
1161
+ return Promise.resolve(
1162
+ new Response('ok', {
1163
+ headers: {
1164
+ [Constants.Headers.paymentReceipt]: serializeSessionReceipt(
1165
+ createSessionReceipt({
1166
+ acceptedCumulative: 1_000_000n,
1167
+ challengeId,
1168
+ channelId: payload.channelId,
1169
+ spent: 1_000_000n,
1170
+ units: 3,
1171
+ }),
1172
+ ),
1173
+ },
1174
+ }),
1175
+ )
1176
+ if (payload.action === 'topUp') return Promise.resolve(new Response(null, { status: 204 }))
1177
+ return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '1000000' })))
1178
+ })
1179
+ const s = sessionManager({
1180
+ account,
1181
+ client,
1182
+ fetch: mockFetch as typeof globalThis.fetch,
1183
+ maxDeposit: '3',
1184
+ channelStore: store,
1185
+ })
1186
+
1187
+ await s.fetch('https://api.example.com/data')
1188
+ expect((await s.fetch('https://api.example.com/data')).status).toBe(402)
1189
+
1190
+ expect(payloads.map((payload) => payload.action)).toEqual([
1191
+ 'open',
1192
+ 'topUp',
1193
+ 'voucher',
1194
+ 'voucher',
1195
+ 'voucher',
1196
+ ])
1197
+ expect(s.cumulative).toBe(1_000_000n)
1198
+ expect(s.state).toMatchObject({ status: 'active', deposit: '2000000', units: 3 })
1199
+ expect([...map.values()][0]).toMatchObject({
1200
+ cumulativeAmount: 1_000_000n,
1201
+ deposit: 2_000_000n,
1202
+ })
1203
+ })
1204
+
1205
+ test('preserves a topped-up resumed channel in durable storage after payment fails', async () => {
1206
+ const { store } = makeDurableChannelStore()
1207
+ const seeded = channelEntry({ cumulativeAmount: 1_000_000n, deposit: 1_000_000n })
1208
+ await store.set(seeded)
1209
+ const payloads: SessionCredentialPayload[] = []
1210
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
1211
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
1212
+ if (!authorization)
1213
+ return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '1000000' })))
1214
+ const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
1215
+ payloads.push(payload)
1216
+ return Promise.resolve(
1217
+ payload.action === 'topUp'
1218
+ ? new Response(null, { status: 204 })
1219
+ : new Response('failed', { status: 500 }),
1220
+ )
1221
+ })
1222
+ const s = sessionManager({
1223
+ account,
1224
+ client,
1225
+ fetch: mockFetch as typeof globalThis.fetch,
1226
+ maxDeposit: '3',
1227
+ channelStore: store,
1228
+ })
1229
+
1230
+ expect((await s.fetch('https://api.example.com/data')).status).toBe(500)
1231
+
1232
+ expect(payloads.map((payload) => payload.action)).toEqual(['topUp', 'voucher'])
1233
+ expect(s.cumulative).toBe(1_000_000n)
1234
+ expect(s.state).toMatchObject({ status: 'active', deposit: '2000000' })
1235
+ await expect(store.get(entryKey(seeded))).resolves.toMatchObject({
1236
+ cumulativeAmount: 1_000_000n,
1237
+ deposit: 2_000_000n,
1238
+ opened: true,
1239
+ })
1240
+ })
1241
+
1242
+ test.each([
1243
+ ['new', false],
1244
+ ['replacement', true],
1245
+ ] as const)('removes a failed %s durable channel', async (_kind, seeded) => {
1246
+ const { backend, store } = makeDurableChannelStore()
1247
+ if (seeded) await store.set(channelEntry())
1248
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
1249
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
1250
+ return Promise.resolve(
1251
+ authorization ? new Response('failed', { status: 500 }) : make402Response(),
1252
+ )
1253
+ })
1254
+ const s = sessionManager({
1255
+ account,
1256
+ client,
1257
+ fetch: mockFetch as typeof globalThis.fetch,
1258
+ channelStore: store,
1259
+ })
1260
+
1261
+ expect((await s.fetch('https://api.example.com/data')).status).toBe(500)
1262
+ expect(s.channelId).toBeUndefined()
1263
+ expect(backend.size).toBe(0)
1264
+ })
1058
1265
  })
1059
1266
 
1060
1267
  describe('.topUp()', () => {
@@ -58,6 +58,7 @@ import { applyTopUpResult, resolveManualTopUp, type TopUpRequirement } from './T
58
58
  import {
59
59
  openWebSocketSession,
60
60
  prepareWebSocketSession,
61
+ probeWebSocketSession,
61
62
  type WebSocketDriverOptions,
62
63
  } from './Transports.js'
63
64
 
@@ -243,11 +244,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
243
244
 
244
245
  /** Removes a failed channel from candidacy for the rest of this manager's life. */
245
246
  async function ignoreChannel(entry: ChannelEntry) {
247
+ const key = entryKey(entry)
246
248
  ignoredChannelIds.add(entry.channelId)
247
- await Promise.resolve(backing.delete(entryKey(entry))).catch(() => undefined)
249
+ channelUse?.seenExisting.delete(key)
250
+ if (channelUse?.resumed === entry) channelUse.resumed = undefined
251
+ await Promise.resolve(backing.delete(key)).catch(() => undefined)
248
252
  }
249
253
 
250
- async function rollbackCreatedChannelsForRetry() {
254
+ async function rollbackCreatedChannels() {
251
255
  const use = channelUse
252
256
  if (!use?.created.size) return
253
257
  for (const [key, entry] of use.created) {
@@ -255,19 +259,31 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
255
259
  await Promise.resolve(backing.delete(key)).catch(() => undefined)
256
260
  }
257
261
  use.created.clear()
258
- restoreRuntime(use.previous)
262
+ await restoreRuntime(use.committed ?? use.previous)
259
263
  }
260
264
 
261
- function referencesCreatedChannel(challenge: Challenge.Challenge): boolean {
265
+ /** Commits a newly created channel once an authoritative server snapshot references it. */
266
+ function commitReferencedChannel(challenge: Challenge.Challenge): boolean {
262
267
  const snapshot = Constants.getMethodDetail<{ channelId?: unknown }>(
263
268
  challenge.request.methodDetails,
264
269
  Constants.MethodDetailKeys.sessionSnapshot,
265
270
  )
266
271
  if (typeof snapshot?.channelId !== 'string') return false
267
272
  const use = channelUse
268
- if (!use?.created.size) return false
269
- for (const entry of use.created.values()) {
270
- if (entry.channelId.toLowerCase() === snapshot.channelId.toLowerCase()) return true
273
+ if (!use?.created.size || !runtime.channel) return false
274
+ for (const [key, entry] of use.created) {
275
+ if (
276
+ entry.channelId.toLowerCase() !== snapshot.channelId.toLowerCase() ||
277
+ runtime.channel.channelId.toLowerCase() !== snapshot.channelId.toLowerCase()
278
+ )
279
+ continue
280
+ use.created.delete(key)
281
+ use.committed = captureRuntimeStateSnapshot({
282
+ channel: runtime.channel,
283
+ spent: runtime.spent,
284
+ state: runtime.state,
285
+ })
286
+ return true
271
287
  }
272
288
  return false
273
289
  }
@@ -276,6 +292,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
276
292
  return dispatchSessionEvent(runtime, event)
277
293
  }
278
294
 
295
+ function activeUnits() {
296
+ const state =
297
+ runtime.state.status === 'active'
298
+ ? runtime.state
299
+ : (channelUse?.committed?.state ?? channelUse?.previous.state)
300
+ return state?.status === 'active' ? state.units : 0
301
+ }
302
+
279
303
  function commitDurableTopUp(entry: ChannelEntry) {
280
304
  const use = channelUse
281
305
  const baseline = use?.committed?.channel?.entry ?? use?.resumed
@@ -312,7 +336,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
312
336
  challengeId: runtime.lastChallenge.id,
313
337
  entry,
314
338
  spent: runtime.spent.toString(),
315
- units: 0,
339
+ units: activeUnits(),
316
340
  })
317
341
  }
318
342
  commitDurableTopUp(entry)
@@ -332,11 +356,15 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
332
356
  const use = channelUse
333
357
  const isRepeatedRetryChallenge =
334
358
  use && use.challengesReceived > 0 && challenge.id === runtime.lastChallenge?.id
335
- if (!referencesCreatedChannel(challenge)) {
336
- if (use?.created.size) await rollbackCreatedChannelsForRetry()
337
- else if (isRepeatedRetryChallenge) restoreRuntime(use.previous)
338
- }
359
+ const isSameRetryChallenge =
360
+ isRepeatedRetryChallenge &&
361
+ Challenge.serialize(challenge) === Challenge.serialize(runtime.lastChallenge!)
339
362
  if (use) use.challengesReceived++
363
+ if (isSameRetryChallenge) return undefined
364
+ if (!commitReferencedChannel(challenge)) {
365
+ if (use?.created.size) await rollbackCreatedChannels()
366
+ else if (isRepeatedRetryChallenge) await restoreRuntime(use.committed ?? use.previous)
367
+ }
340
368
  runtime.lastChallenge = challenge
341
369
  dispatch({ type: 'challengeReceived', challengeId: challenge.id })
342
370
  return undefined
@@ -558,7 +586,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
558
586
  challengeId: runtime.lastChallenge.id,
559
587
  entry: applied.channel,
560
588
  spent: runtime.spent.toString(),
561
- units: runtime.state.status === 'active' ? runtime.state.units : 0,
589
+ units: activeUnits(),
562
590
  })
563
591
  commitDurableTopUp(applied.channel)
564
592
  }
@@ -589,7 +617,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
589
617
  })
590
618
  }
591
619
 
592
- function restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint) {
620
+ async function restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint) {
593
621
  const restored = restoreCumulativeAuthorization({
594
622
  channel: runtime.channel,
595
623
  channelId,
@@ -606,14 +634,16 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
606
634
  spent: restored.spent,
607
635
  units: restored.units,
608
636
  })
637
+ await backing.set(runtime.channel)
609
638
  }
610
639
  }
611
640
 
612
- function restoreRuntime(snapshot: RuntimeSnapshot) {
641
+ async function restoreRuntime(snapshot: RuntimeSnapshot) {
613
642
  const restored = restoreRuntimeStateSnapshot(snapshot, runtime.channel)
614
643
  runtime.channel = restored.channel
615
644
  runtime.spent = restored.spent
616
645
  runtime.state = restored.state
646
+ if (runtime.channel?.opened) await backing.set(runtime.channel)
617
647
  }
618
648
 
619
649
  function toPaymentResponse(response: Response): PaymentResponse {
@@ -679,7 +709,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
679
709
  try {
680
710
  response = await wrappedFetch(input, effectiveInit)
681
711
  } catch (error) {
682
- restoreRuntime(use.committed ?? previous)
712
+ if (use.created.size) await rollbackCreatedChannels()
713
+ else await restoreRuntime(use.committed ?? previous)
683
714
  if (await retryWithoutResumed()) continue
684
715
  throw error
685
716
  }
@@ -706,7 +737,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
706
737
  }
707
738
  }
708
739
  if (!attemptedHttpManagement && !paymentResponse.ok && !paymentResponse.receipt) {
709
- restoreRuntime(use.committed ?? previous)
740
+ if (use.created.size) await rollbackCreatedChannels()
741
+ else await restoreRuntime(use.committed ?? previous)
710
742
  if (await retryWithoutResumed()) continue
711
743
  return paymentResponse
712
744
  }
@@ -838,6 +870,20 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
838
870
  setSocketSession(session) {
839
871
  runtime.socketSession = session
840
872
  },
873
+ async refreshChallenge() {
874
+ const refreshed = await probeWebSocketSession({
875
+ fetch: config.fetch,
876
+ input: httpUrl,
877
+ probeInit: requestInitWithSessionHint(
878
+ httpUrl,
879
+ signalInit,
880
+ runtime.channel?.opened ? runtime.channel.channelId : undefined,
881
+ ),
882
+ signal: init?.signal,
883
+ })
884
+ runtime.lastChallenge = refreshed.challenge
885
+ return refreshed.challenge
886
+ },
841
887
  assertVoucherWithinLocalLimit,
842
888
  acceptReceipt: updateSpentFromReceipt,
843
889
  rejectCloseReady: receipts.rejectCloseReady,
@@ -310,6 +310,28 @@ describe('HttpManagement', () => {
310
310
  expect(restoreCumulative).toHaveBeenCalledWith(channelId, 5n)
311
311
  })
312
312
 
313
+ test('retryHttpPaymentRequired restores cumulative authorization when retry throws', async () => {
314
+ const entry = channel({ cumulativeAmount: 5n })
315
+ const restoreCumulative = vi.fn()
316
+
317
+ await expect(
318
+ retryHttpPaymentRequired({
319
+ createSessionCredential: async () => 'voucher-credential',
320
+ fetch: async () => {
321
+ throw new Error('network failed')
322
+ },
323
+ getChannel: () => entry,
324
+ input: 'https://example.test/resource',
325
+ response: response402(challenge(snapshot())),
326
+ restoreCumulative,
327
+ setChallenge() {},
328
+ topUpIfNeeded: async () => {},
329
+ }),
330
+ ).rejects.toThrow('network failed')
331
+
332
+ expect(restoreCumulative).toHaveBeenCalledWith(channelId, 5n)
333
+ })
334
+
313
335
  test('closeHttpSession posts a close credential and parses the receipt', async () => {
314
336
  const entry = channel()
315
337
  const createSessionCredential = vi.fn(async (_challenge, context: SessionContext) => {
@@ -47,6 +47,8 @@ export const WebSocketReadyState = {
47
47
  /** Client-side close code used for payment protocol errors. */
48
48
  export const ClientWebSocketProtocolErrorCloseCode = 3008
49
49
 
50
+ const webSocketChallengeRefreshWindowMs = 5_000
51
+
50
52
  type EventType = 'close' | 'error' | 'message' | 'open'
51
53
  type ManagedEventMap = {
52
54
  close: { code: number; reason: string; type: 'close'; wasClean: boolean }
@@ -489,7 +491,7 @@ export type RetryHttpPaymentRequiredParameters = {
489
491
  /** Failed HTTP response that may contain a session challenge. */
490
492
  response: Response
491
493
  /** Restores local cumulative authorization if the voucher retry fails. */
492
- restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint): void
494
+ restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint): void | Promise<void>
493
495
  /** Stores the selected follow-up challenge on the manager. */
494
496
  setChallenge(challenge: TempoSessionChallenge): void
495
497
  /** Performs automatic top-up before the voucher retry when deposit is insufficient. */
@@ -692,18 +694,25 @@ export async function retryHttpPaymentRequired(
692
694
  currentChannel.cumulativeAmount > requiredCumulative
693
695
  ? currentChannel.cumulativeAmount
694
696
  : requiredCumulative
695
- const credential = await parameters.createSessionCredential(challenge, {
696
- action: 'voucher',
697
- channelId: snapshot.channelId,
698
- descriptor: currentChannel.descriptor,
699
- cumulativeAmountRaw: cumulativeAmount.toString(),
700
- })
701
- const retry = await parameters.fetch(
702
- parameters.input,
703
- requestInitWithAuthorization(parameters.input, parameters.init, credential),
704
- )
697
+ const restore = () => parameters.restoreCumulative(snapshot.channelId, cumulativeBeforeVoucher)
698
+ let retry: Response
699
+ try {
700
+ const credential = await parameters.createSessionCredential(challenge, {
701
+ action: 'voucher',
702
+ channelId: snapshot.channelId,
703
+ descriptor: currentChannel.descriptor,
704
+ cumulativeAmountRaw: cumulativeAmount.toString(),
705
+ })
706
+ retry = await parameters.fetch(
707
+ parameters.input,
708
+ requestInitWithAuthorization(parameters.input, parameters.init, credential),
709
+ )
710
+ } catch (error) {
711
+ await restore()
712
+ throw error
713
+ }
705
714
  if (!retry.ok && !retry.headers.get(Constants.Headers.paymentReceipt)) {
706
- parameters.restoreCumulative(snapshot.channelId, cumulativeBeforeVoucher)
715
+ await restore()
707
716
  }
708
717
  return retry
709
718
  }
@@ -1110,6 +1119,15 @@ export type PrepareWebSocketSessionParameters = {
1110
1119
  signal?: AbortSignal | undefined
1111
1120
  }
1112
1121
 
1122
+ /** Inputs for probing a paid WebSocket endpoint without creating a credential. */
1123
+ export type ProbeWebSocketSessionParameters = Omit<
1124
+ PrepareWebSocketSessionParameters,
1125
+ 'createSessionCredential'
1126
+ >
1127
+
1128
+ /** Result of resolving the current payment challenge for a WebSocket endpoint. */
1129
+ export type ProbedWebSocketSession = Omit<PreparedWebSocketSession, 'credential'>
1130
+
1113
1131
  /** Result of the HTTP probe and opening credential creation for a WebSocket session. */
1114
1132
  export type PreparedWebSocketSession = {
1115
1133
  /** Selected tempo/session challenge from the HTTP probe. */
@@ -1173,6 +1191,8 @@ export type OpenWebSocketSessionParameters = {
1173
1191
  getChannel(): ChannelEntry | null
1174
1192
  /** Stores the active socket state in the manager. */
1175
1193
  setSocketSession(session: ActiveSocketSession): void
1194
+ /** Fetches the route's current challenge after the active one expires. */
1195
+ refreshChallenge(): Promise<TempoSessionChallenge>
1176
1196
  /** Validates a cumulative amount against local client policy. */
1177
1197
  assertVoucherWithinLocalLimit(cumulativeAmount: bigint): void
1178
1198
  /** Applies an incoming receipt to manager state. */
@@ -1213,10 +1233,10 @@ export type ValidateSocketPaymentReceiptParameters = ExpectedSocketReceiptParame
1213
1233
  expectedCloseAmount: string | null
1214
1234
  }
1215
1235
 
1216
- /** Probes a WebSocket endpoint over HTTP and creates the opening credential. */
1217
- export async function prepareWebSocketSession(
1218
- parameters: PrepareWebSocketSessionParameters,
1219
- ): Promise<PreparedWebSocketSession> {
1236
+ /** Probes a WebSocket endpoint over HTTP and returns its current session challenge. */
1237
+ export async function probeWebSocketSession(
1238
+ parameters: ProbeWebSocketSessionParameters,
1239
+ ): Promise<ProbedWebSocketSession> {
1220
1240
  const wsUrl = new URL(parameters.input.toString())
1221
1241
  const httpUrl = webSocketProbeUrl(wsUrl)
1222
1242
  parameters.onProbeUrl?.(httpUrl)
@@ -1239,12 +1259,22 @@ export async function prepareWebSocketSession(
1239
1259
 
1240
1260
  return {
1241
1261
  challenge,
1242
- credential: await parameters.createSessionCredential(challenge, {}),
1243
1262
  httpUrl,
1244
1263
  wsUrl,
1245
1264
  }
1246
1265
  }
1247
1266
 
1267
+ /** Probes a WebSocket endpoint over HTTP and creates the opening credential. */
1268
+ export async function prepareWebSocketSession(
1269
+ parameters: PrepareWebSocketSessionParameters,
1270
+ ): Promise<PreparedWebSocketSession> {
1271
+ const probed = await probeWebSocketSession(parameters)
1272
+ return {
1273
+ ...probed,
1274
+ credential: await parameters.createSessionCredential(probed.challenge, {}),
1275
+ }
1276
+ }
1277
+
1248
1278
  /** Creates the initial runtime state for a paid WebSocket from its opening credential. */
1249
1279
  export function createActiveSocketSession(
1250
1280
  parameters: CreateActiveSocketSessionParameters,
@@ -1452,9 +1482,18 @@ async function handleNeedVoucher(
1452
1482
  event: NeedVoucherEvent,
1453
1483
  ) {
1454
1484
  try {
1485
+ let challenge = parameters.driver.challenge
1486
+ if (
1487
+ challenge.expires &&
1488
+ new Date(challenge.expires).getTime() <= Date.now() + webSocketChallengeRefreshWindowMs
1489
+ ) {
1490
+ challenge = await parameters.driver.refreshChallenge()
1491
+ parameters.driver.challenge = challenge
1492
+ parameters.socketState.challenge = challenge
1493
+ }
1455
1494
  const resolution = await resolveNeedVoucherContext({
1456
1495
  assertVoucherWithinLocalLimit: parameters.driver.assertVoucherWithinLocalLimit,
1457
- challenge: parameters.driver.challenge,
1496
+ challenge,
1458
1497
  event,
1459
1498
  expectedChannelId: parameters.socketState.channelId,
1460
1499
  getChannel: parameters.driver.getChannel,
@@ -1469,10 +1508,7 @@ async function handleNeedVoucher(
1469
1508
  )
1470
1509
  return
1471
1510
  }
1472
- const voucher = await parameters.driver.createSessionCredential(
1473
- parameters.driver.challenge,
1474
- resolution.context,
1475
- )
1511
+ const voucher = await parameters.driver.createSessionCredential(challenge, resolution.context)
1476
1512
  parameters.rawSocket.send(Ws.formatAuthorizationMessage(voucher))
1477
1513
  } catch (error) {
1478
1514
  parameters.failSocketFlow(