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
@@ -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,
@@ -609,7 +623,55 @@ describe('Session', () => {
609
623
  expect(s.channelId).not.toBe(storedChannelId)
610
624
  })
611
625
 
612
- test('rolls back an optimistic open when a replacement challenge does not reference it', async () => {
626
+ test('keeps a persisted channel after its committed top-up when the paid retry fails', async () => {
627
+ const seeded = channelEntry({ cumulativeAmount: 10_000_000n, deposit: 10_000_000n })
628
+ const { store, delete: remove, map } = makeChannelStore([seeded])
629
+ const postedPayloads: SessionCredentialPayload[] = []
630
+ let probes = 0
631
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
632
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
633
+ const payload = authorization
634
+ ? Credential.deserialize<SessionCredentialPayload>(authorization).payload
635
+ : undefined
636
+ if (!payload) {
637
+ probes++
638
+ if (probes > 1) throw new Error('unexpected fresh-channel retry')
639
+ return Promise.resolve(make402Response())
640
+ }
641
+
642
+ postedPayloads.push(payload)
643
+ if (payload.action === 'topUp') return Promise.resolve(new Response(null, { status: 204 }))
644
+ if (payload.action === 'voucher')
645
+ return Promise.resolve(new Response('upstream failed', { status: 500 }))
646
+ throw new Error(`unexpected ${payload.action} credential`)
647
+ })
648
+ const s = sessionManager({
649
+ account,
650
+ client,
651
+ fetch: mockFetch as typeof globalThis.fetch,
652
+ maxDeposit: '20',
653
+ channelStore: store,
654
+ })
655
+
656
+ const response = await s.fetch('https://api.example.com/data')
657
+
658
+ expect(response.status).toBe(500)
659
+ expect(postedPayloads.map((payload) => payload.action)).toEqual(['topUp', 'voucher'])
660
+ expect(remove).not.toHaveBeenCalled()
661
+ expect(map.get(entryKey(seeded))).toMatchObject({
662
+ channelId: storedChannelId,
663
+ cumulativeAmount: 10_000_000n,
664
+ deposit: 11_000_000n,
665
+ })
666
+ expect(s.channelId).toBe(storedChannelId)
667
+ expect(s.state).toMatchObject({
668
+ status: 'active',
669
+ channelId: storedChannelId,
670
+ deposit: '11000000',
671
+ })
672
+ })
673
+
674
+ test('rolls back optimistic opens across replacement challenges', async () => {
613
675
  const { store, delete: remove } = makeChannelStore()
614
676
  const postedPayloads: SessionCredentialPayload[] = []
615
677
  const challenge = (feePayer: boolean) =>
@@ -621,6 +683,8 @@ describe('Session', () => {
621
683
  feePayer,
622
684
  },
623
685
  })
686
+ const original = challenge(true)
687
+ const replacement = challenge(false)
624
688
  let openCount = 0
625
689
  const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
626
690
  const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
@@ -629,10 +693,11 @@ describe('Session', () => {
629
693
  : undefined
630
694
  if (payload) postedPayloads.push(payload)
631
695
 
632
- if (!payload) return Promise.resolve(make402Response(challenge(true)))
696
+ if (!payload) return Promise.resolve(make402Response(original))
633
697
  if (payload.action === 'open') {
634
698
  openCount++
635
- 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))
636
701
  return Promise.resolve(makeOkResponse())
637
702
  }
638
703
  return Promise.resolve(new Response('unexpected voucher', { status: 500 }))
@@ -648,12 +713,33 @@ describe('Session', () => {
648
713
  const response = await s.fetch('https://api.example.com/data')
649
714
 
650
715
  expect(response.status).toBe(200)
651
- // A replacement challenge with no snapshot means the server did not
652
- // acknowledge the optimistic open, so the next credential must open again.
653
- expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'open'])
654
- expect(postedPayloads[0]?.channelId).not.toBe(postedPayloads[1]?.channelId)
655
- expect(remove).toHaveBeenCalledOnce()
656
- 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')
657
743
  })
658
744
 
659
745
  test('does not bootstrap when disabled', async () => {
@@ -753,10 +839,11 @@ describe('Session', () => {
753
839
  await restarted.fetch('https://api.example.com/data')
754
840
 
755
841
  // The first request carries no hint header; after the 402 the restarted
756
- // manager resumes the persisted channel from the entry index with a
757
- // voucher rather than opening a new one.
842
+ // manager tops up and resumes the full persisted channel rather than
843
+ // opening a new one.
758
844
  expect(new Headers(resumeFetch.mock.calls[0]?.[1]?.headers).get('Payment-Session')).toBeNull()
759
- expect(posted[0]).toMatchObject({ action: 'voucher', channelId })
845
+ expect(posted.map((payload) => payload.action)).toEqual(['topUp', 'voucher'])
846
+ expect(posted[1]).toMatchObject({ channelId })
760
847
  })
761
848
 
762
849
  test('persists opened channels and deletes closed channels when supported', async () => {
@@ -940,6 +1027,60 @@ describe('Session', () => {
940
1027
  })
941
1028
  })
942
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
+
943
1084
  test('preemptively top-ups before signing an HTTP voucher that exceeds deposit', async () => {
944
1085
  const postedPayloads: SessionCredentialPayload[] = []
945
1086
  let challengeCount = 0
@@ -987,6 +1128,7 @@ describe('Session', () => {
987
1128
  client,
988
1129
  fetch: mockFetch as typeof globalThis.fetch,
989
1130
  maxDeposit: '10',
1131
+ topUpAmount: '5',
990
1132
  })
991
1133
 
992
1134
  await s.fetch('https://api.example.com/data')
@@ -994,14 +1136,132 @@ describe('Session', () => {
994
1136
 
995
1137
  expect(response.status).toBe(200)
996
1138
  expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'topUp', 'voucher'])
1139
+ const topUp = postedPayloads[1]
1140
+ if (topUp?.action !== 'topUp') throw new Error('expected top-up payload')
1141
+ expect(topUp.additionalDeposit).toBe('5000000')
997
1142
  expect(s.state).toMatchObject({
998
1143
  status: 'active',
999
1144
  acceptedCumulative: '2000000',
1000
- deposit: '2000000',
1145
+ deposit: '6000000',
1001
1146
  spent: '2000000',
1002
1147
  units: 2,
1003
1148
  })
1004
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
+ })
1005
1265
  })
1006
1266
 
1007
1267
  describe('.topUp()', () => {
@@ -1398,6 +1658,51 @@ describe('Session', () => {
1398
1658
  })
1399
1659
  })
1400
1660
 
1661
+ describe('.ws()', () => {
1662
+ test('tops up a full persisted channel before constructing the socket', async () => {
1663
+ const events: string[] = []
1664
+ const { map, set, store } = makeChannelStore([
1665
+ channelEntry({ cumulativeAmount: 1_000_000n, deposit: 1_000_000n }),
1666
+ ])
1667
+ set.mockImplementation((entry) => {
1668
+ map.set(entryKey(entry), entry)
1669
+ events.push(`set:${entry.deposit}:${entry.cumulativeAmount}`)
1670
+ })
1671
+ const fetch = vi.fn(async (_input, init?: RequestInit) => {
1672
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
1673
+ if (!authorization) {
1674
+ events.push('probe')
1675
+ return make402Response(makeChallenge({ suggestedDeposit: '1000000' }))
1676
+ }
1677
+ const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
1678
+ if (payload.action !== 'topUp') throw new Error(`unexpected ${payload.action} fetch`)
1679
+ events.push('topUp')
1680
+ return new Response(null, { status: 204 })
1681
+ })
1682
+ function WebSocket() {
1683
+ events.push('socket')
1684
+ throw new Error('stop after preparation')
1685
+ }
1686
+ const s = sessionManager({
1687
+ account,
1688
+ client,
1689
+ fetch: fetch as typeof globalThis.fetch,
1690
+ maxDeposit: '3',
1691
+ channelStore: store,
1692
+ webSocket: WebSocket as never,
1693
+ })
1694
+
1695
+ await expect(s.ws('wss://api.example.com/socket')).rejects.toThrow('stop after preparation')
1696
+ expect(events).toEqual([
1697
+ 'probe',
1698
+ 'topUp',
1699
+ 'set:2000000:1000000',
1700
+ 'set:2000000:2000000',
1701
+ 'socket',
1702
+ ])
1703
+ })
1704
+ })
1705
+
1401
1706
  describe('.close()', () => {
1402
1707
  test('is no-op when not opened', async () => {
1403
1708
  const mockFetch = vi.fn()