mppx 0.8.11 → 0.8.13

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 (218) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -1
  3. package/dist/cli/account.d.ts +88 -0
  4. package/dist/cli/account.d.ts.map +1 -1
  5. package/dist/cli/account.js +39 -9
  6. package/dist/cli/account.js.map +1 -1
  7. package/dist/cli/cli.d.ts +2 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +59 -2
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/sessions/Manager.d.ts +31 -0
  12. package/dist/cli/sessions/Manager.d.ts.map +1 -0
  13. package/dist/cli/sessions/Manager.js +56 -0
  14. package/dist/cli/sessions/Manager.js.map +1 -0
  15. package/dist/cli/sessions/commands.d.ts +36 -0
  16. package/dist/cli/sessions/commands.d.ts.map +1 -0
  17. package/dist/cli/sessions/commands.js +407 -0
  18. package/dist/cli/sessions/commands.js.map +1 -0
  19. package/dist/cli/sessions/request.d.ts +35 -0
  20. package/dist/cli/sessions/request.d.ts.map +1 -0
  21. package/dist/cli/sessions/request.js +271 -0
  22. package/dist/cli/sessions/request.js.map +1 -0
  23. package/dist/cli/sessions/store.d.ts +134 -0
  24. package/dist/cli/sessions/store.d.ts.map +1 -0
  25. package/dist/cli/sessions/store.js +723 -0
  26. package/dist/cli/sessions/store.js.map +1 -0
  27. package/dist/cli/utils.d.ts +2 -0
  28. package/dist/cli/utils.d.ts.map +1 -1
  29. package/dist/cli/utils.js +12 -0
  30. package/dist/cli/utils.js.map +1 -1
  31. package/dist/cli/validate/index.d.ts.map +1 -1
  32. package/dist/cli/validate/index.js +12 -6
  33. package/dist/cli/validate/index.js.map +1 -1
  34. package/dist/cli/validate/messages.d.ts +2 -0
  35. package/dist/cli/validate/messages.d.ts.map +1 -0
  36. package/dist/cli/validate/messages.js +8 -0
  37. package/dist/cli/validate/messages.js.map +1 -0
  38. package/dist/client/internal/Fetch.d.ts.map +1 -1
  39. package/dist/client/internal/Fetch.js +35 -6
  40. package/dist/client/internal/Fetch.js.map +1 -1
  41. package/dist/client/internal/MethodResponse.d.ts +23 -0
  42. package/dist/client/internal/MethodResponse.d.ts.map +1 -0
  43. package/dist/client/internal/MethodResponse.js +15 -0
  44. package/dist/client/internal/MethodResponse.js.map +1 -0
  45. package/dist/client/node.d.ts +29 -0
  46. package/dist/client/node.d.ts.map +1 -0
  47. package/dist/client/node.js +177 -0
  48. package/dist/client/node.js.map +1 -0
  49. package/dist/internal/types.d.ts +24 -0
  50. package/dist/internal/types.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.d.ts +8 -4
  52. package/dist/stripe/server/Charge.d.ts.map +1 -1
  53. package/dist/stripe/server/Charge.js.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  55. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  56. package/dist/stripe/server/internal/html.gen.js +1 -1
  57. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  58. package/dist/tempo/client/Methods.d.ts +1 -1
  59. package/dist/tempo/internal/types.d.ts +10 -1
  60. package/dist/tempo/internal/types.d.ts.map +1 -1
  61. package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
  62. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/legacy/client/ChannelOps.js +4 -0
  64. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  66. package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
  67. package/dist/tempo/legacy/client/Session.js +7 -0
  68. package/dist/tempo/legacy/client/Session.js.map +1 -1
  69. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  70. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  71. package/dist/tempo/legacy/server/Session.js.map +1 -1
  72. package/dist/tempo/server/Charge.d.ts +3 -2
  73. package/dist/tempo/server/Charge.d.ts.map +1 -1
  74. package/dist/tempo/server/Charge.js.map +1 -1
  75. package/dist/tempo/server/Methods.d.ts +2 -23
  76. package/dist/tempo/server/Methods.d.ts.map +1 -1
  77. package/dist/tempo/server/Methods.js +2 -2
  78. package/dist/tempo/server/Methods.js.map +1 -1
  79. package/dist/tempo/server/Subscription.d.ts +3 -2
  80. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  81. package/dist/tempo/server/Subscription.js.map +1 -1
  82. package/dist/tempo/server/index.d.ts +1 -1
  83. package/dist/tempo/server/index.d.ts.map +1 -1
  84. package/dist/tempo/server/index.js.map +1 -1
  85. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  86. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  87. package/dist/tempo/server/internal/html.gen.js +1 -1
  88. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  89. package/dist/tempo/session/Snapshot.d.ts +9 -0
  90. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  91. package/dist/tempo/session/Snapshot.js +6 -0
  92. package/dist/tempo/session/Snapshot.js.map +1 -1
  93. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  94. package/dist/tempo/session/client/ChannelOps.js +11 -2
  95. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  96. package/dist/tempo/session/client/CredentialState.d.ts +21 -1
  97. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  98. package/dist/tempo/session/client/CredentialState.js +66 -1
  99. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  100. package/dist/tempo/session/client/Session.d.ts +1 -1
  101. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  102. package/dist/tempo/session/client/Session.js +53 -1
  103. package/dist/tempo/session/client/Session.js.map +1 -1
  104. package/dist/tempo/session/client/SessionManager.d.ts +2 -2
  105. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  106. package/dist/tempo/session/client/SessionManager.js +130 -30
  107. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  108. package/dist/tempo/session/client/Transports.d.ts +21 -2
  109. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  110. package/dist/tempo/session/client/Transports.js +129 -18
  111. package/dist/tempo/session/client/Transports.js.map +1 -1
  112. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  113. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  114. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  115. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  116. package/dist/tempo/session/precompile/Chain.js +13 -4
  117. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  118. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  119. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  120. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  121. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  122. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  123. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  124. package/dist/tempo/session/server/MeteredStream.js +5 -4
  125. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  126. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  127. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  128. package/dist/tempo/session/server/RequestState.js +10 -4
  129. package/dist/tempo/session/server/RequestState.js.map +1 -1
  130. package/dist/tempo/session/server/Session.d.ts +21 -6
  131. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  132. package/dist/tempo/session/server/Session.js +4 -1
  133. package/dist/tempo/session/server/Session.js.map +1 -1
  134. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  135. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  136. package/dist/tempo/session/server/Settlement.js +20 -0
  137. package/dist/tempo/session/server/Settlement.js.map +1 -1
  138. package/dist/tempo/session/server/Ws.d.ts +2 -0
  139. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  140. package/dist/tempo/session/server/Ws.js.map +1 -1
  141. package/dist/tempo/session/server/index.d.ts +1 -1
  142. package/dist/tempo/session/server/index.d.ts.map +1 -1
  143. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  144. package/dist/validation/core.d.ts.map +1 -1
  145. package/dist/validation/core.js +6 -2
  146. package/dist/validation/core.js.map +1 -1
  147. package/dist/viem/Client.d.ts.map +1 -1
  148. package/dist/viem/Client.js +24 -17
  149. package/dist/viem/Client.js.map +1 -1
  150. package/package.json +6 -1
  151. package/src/cli/account.ts +45 -10
  152. package/src/cli/cli.test.ts +165 -70
  153. package/src/cli/cli.ts +67 -2
  154. package/src/cli/mcp.test.ts +11 -0
  155. package/src/cli/sessions/Manager.test.ts +249 -0
  156. package/src/cli/sessions/Manager.ts +93 -0
  157. package/src/cli/sessions/commands.ts +444 -0
  158. package/src/cli/sessions/request.test.ts +51 -0
  159. package/src/cli/sessions/request.ts +353 -0
  160. package/src/cli/sessions/store.test.ts +581 -0
  161. package/src/cli/sessions/store.ts +940 -0
  162. package/src/cli/utils.test.ts +23 -0
  163. package/src/cli/utils.ts +10 -0
  164. package/src/cli/validate/index.ts +12 -14
  165. package/src/cli/validate/messages.ts +7 -0
  166. package/src/cli/validate.test.ts +38 -5
  167. package/src/client/internal/Fetch.test.ts +29 -0
  168. package/src/client/internal/Fetch.ts +51 -6
  169. package/src/client/internal/MethodResponse.ts +39 -0
  170. package/src/client/node.test.ts +115 -0
  171. package/src/client/node.ts +247 -0
  172. package/src/internal/types.test-d.ts +21 -0
  173. package/src/internal/types.ts +33 -0
  174. package/src/server/Mppx.test-d.ts +42 -0
  175. package/src/stripe/Methods.test.ts +10 -0
  176. package/src/stripe/server/Charge.test-d.ts +66 -0
  177. package/src/stripe/server/Charge.ts +8 -5
  178. package/src/stripe/server/internal/html.gen.ts +1 -1
  179. package/src/tempo/internal/types.ts +13 -2
  180. package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
  181. package/src/tempo/legacy/client/ChannelOps.ts +7 -0
  182. package/src/tempo/legacy/client/Session.test.ts +102 -1
  183. package/src/tempo/legacy/client/Session.ts +10 -0
  184. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  185. package/src/tempo/legacy/server/Session.ts +6 -5
  186. package/src/tempo/server/Charge.ts +6 -4
  187. package/src/tempo/server/Methods.ts +5 -4
  188. package/src/tempo/server/Subscription.ts +6 -4
  189. package/src/tempo/server/index.ts +5 -1
  190. package/src/tempo/server/internal/html.gen.ts +1 -1
  191. package/src/tempo/session/README.md +201 -0
  192. package/src/tempo/session/Snapshot.ts +21 -0
  193. package/src/tempo/session/client/ChannelOps.test.ts +40 -0
  194. package/src/tempo/session/client/ChannelOps.ts +12 -2
  195. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  196. package/src/tempo/session/client/CredentialState.ts +103 -1
  197. package/src/tempo/session/client/Session.test.ts +86 -4
  198. package/src/tempo/session/client/Session.ts +73 -1
  199. package/src/tempo/session/client/SessionManager.test.ts +137 -4
  200. package/src/tempo/session/client/SessionManager.ts +141 -33
  201. package/src/tempo/session/client/Transports.test.ts +225 -8
  202. package/src/tempo/session/client/Transports.ts +156 -21
  203. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  204. package/src/tempo/session/precompile/Chain.test.ts +26 -0
  205. package/src/tempo/session/precompile/Chain.ts +17 -4
  206. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  207. package/src/tempo/session/server/MeteredStream.ts +12 -5
  208. package/src/tempo/session/server/RequestState.test.ts +39 -3
  209. package/src/tempo/session/server/RequestState.ts +13 -6
  210. package/src/tempo/session/server/Session.test.ts +461 -0
  211. package/src/tempo/session/server/Session.ts +31 -8
  212. package/src/tempo/session/server/Settlement.ts +45 -0
  213. package/src/tempo/session/server/Ws.test.ts +113 -0
  214. package/src/tempo/session/server/Ws.ts +2 -0
  215. package/src/tempo/session/server/index.ts +2 -0
  216. package/src/validation/core.ts +7 -1
  217. package/src/viem/Client.test.ts +44 -1
  218. package/src/viem/Client.ts +31 -17
@@ -9,6 +9,7 @@ import type { ChannelEntry } from '../client/ChannelOps.js'
9
9
  import type { SessionContext } from '../client/CredentialState.js'
10
10
  import {
11
11
  createSessionReceipt,
12
+ formatNeedVoucherEvent,
12
13
  serializeSessionReceipt,
13
14
  tip20ChannelEscrow,
14
15
  type ChannelDescriptor,
@@ -18,6 +19,7 @@ import { initialState, type SessionState } from './Runtime.js'
18
19
  import {
19
20
  applyTopUpResult,
20
21
  closeHttpSession,
22
+ consumeSseSessionResponse,
21
23
  createActiveSocketSession,
22
24
  driveSseResponse,
23
25
  isExpectedSocketReceipt,
@@ -32,6 +34,7 @@ import {
32
34
  validateSocketCloseReadyReceipt,
33
35
  validateSocketPaymentReceipt,
34
36
  webSocketProbeUrl,
37
+ wrapSseResponse,
35
38
  type TempoSessionChallenge,
36
39
  type TopUpRequirement,
37
40
  } from './Transports.js'
@@ -111,9 +114,15 @@ describe('HttpManagement', () => {
111
114
  })
112
115
  }
113
116
 
114
- function receiptHeader(acceptedCumulative: bigint, spent: bigint) {
117
+ function receiptHeader(acceptedCumulative: bigint, spent: bigint, challengeId = 'challenge-1') {
115
118
  return serializeSessionReceipt(
116
- createSessionReceipt({ acceptedCumulative, challengeId: 'challenge-1', channelId, spent }),
119
+ createSessionReceipt({
120
+ acceptedCumulative,
121
+ challengeId,
122
+ channelId,
123
+ spent,
124
+ txHash: `0x${'aa'.repeat(32)}`,
125
+ }),
117
126
  )
118
127
  }
119
128
 
@@ -138,9 +147,12 @@ describe('HttpManagement', () => {
138
147
  )
139
148
  })
140
149
 
141
- test('strips resource query state from management URLs', () => {
150
+ test('preserves query state and strips fragments from management URLs', () => {
142
151
  expect(managementInput('https://example.test/resource?cursor=abc').toString()).toBe(
143
- 'https://example.test/resource',
152
+ 'https://example.test/resource?cursor=abc',
153
+ )
154
+ expect(managementInput('https://example.test/resource?cursor=abc#result').toString()).toBe(
155
+ 'https://example.test/resource?cursor=abc',
144
156
  )
145
157
  })
146
158
 
@@ -184,7 +196,7 @@ describe('HttpManagement', () => {
184
196
  return 'top-up-credential'
185
197
  })
186
198
  const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
187
- expect(input.toString()).toBe('https://example.test/resource')
199
+ expect(input.toString()).toBe('https://example.test/resource?cursor=abc')
188
200
  expect(init?.method).toBe('POST')
189
201
  expect(authorizationHeader(init)).toBe('top-up-credential')
190
202
  return new Response(null, {
@@ -300,16 +312,20 @@ describe('HttpManagement', () => {
300
312
  const entry = channel()
301
313
  const retryChallenge = { ...challenge(), id: 'challenge-2' } as TempoSessionChallenge
302
314
  const setChallenge = vi.fn()
303
- const createSessionCredential = vi.fn(async (challenge_) => {
315
+ const closeAmounts: string[] = []
316
+ const createSessionCredential = vi.fn(async (challenge_, context: SessionContext) => {
317
+ if (context.action !== 'close') throw new Error('expected close context')
318
+ closeAmounts.push(context.cumulativeAmountRaw!)
304
319
  return `close-${challenge_.id}`
305
320
  })
321
+ const resolveSignedCloseAmount = vi.fn(() => '6')
306
322
  const fetch = vi
307
323
  .fn()
308
324
  .mockResolvedValueOnce(response402(retryChallenge))
309
325
  .mockResolvedValueOnce(
310
326
  new Response(null, {
311
327
  status: 200,
312
- headers: { [Constants.Headers.paymentReceipt]: receiptHeader(5n, 5n) },
328
+ headers: { [Constants.Headers.paymentReceipt]: receiptHeader(6n, 6n, 'challenge-2') },
313
329
  }),
314
330
  )
315
331
 
@@ -317,18 +333,71 @@ describe('HttpManagement', () => {
317
333
  createSessionCredential,
318
334
  fetch,
319
335
  lastUrl: 'https://example.test/resource',
336
+ resolveSignedCloseAmount,
320
337
  signedCloseAmount: '5',
321
338
  setChallenge,
322
339
  target: { challenge: challenge(), channel: entry, channelId },
323
340
  })
324
341
 
325
- expect(receipt?.spent).toBe('5')
342
+ expect(receipt?.spent).toBe('6')
326
343
  expect(setChallenge).toHaveBeenCalledWith(retryChallenge)
344
+ expect(resolveSignedCloseAmount).toHaveBeenCalledOnce()
345
+ expect(resolveSignedCloseAmount).toHaveBeenCalledWith(retryChallenge)
346
+ expect(closeAmounts).toEqual(['5', '6'])
327
347
  expect(createSessionCredential).toHaveBeenCalledTimes(2)
328
348
  expect(createSessionCredential.mock.calls[1]?.[0]).toMatchObject({ id: retryChallenge.id })
329
349
  expect(authorizationHeader(fetch.mock.calls[1]?.[1])).toBe('close-challenge-2')
330
350
  })
331
351
 
352
+ test('closeHttpSession rejects a receipt without settlement proof', async () => {
353
+ const receipt = serializeSessionReceipt(
354
+ createSessionReceipt({
355
+ acceptedCumulative: 5n,
356
+ challengeId: 'challenge-1',
357
+ channelId,
358
+ spent: 5n,
359
+ }),
360
+ )
361
+ await expect(
362
+ closeHttpSession({
363
+ createSessionCredential: async () => 'close-credential',
364
+ fetch: async () =>
365
+ new Response(null, {
366
+ headers: { [Constants.Headers.paymentReceipt]: receipt },
367
+ }),
368
+ lastUrl: 'https://example.test/resource',
369
+ signedCloseAmount: '5',
370
+ target: { challenge: challenge(), channel: channel(), channelId },
371
+ }),
372
+ ).rejects.toThrow('Session close response included a mismatched payment receipt.')
373
+ })
374
+
375
+ test('closeHttpSession rejects an invalid settlement transaction hash', async () => {
376
+ for (const txHash of ['0x1234', `0x${'gg'.repeat(32)}`]) {
377
+ const receipt = serializeSessionReceipt(
378
+ createSessionReceipt({
379
+ acceptedCumulative: 5n,
380
+ challengeId: 'challenge-1',
381
+ channelId,
382
+ spent: 5n,
383
+ txHash: txHash as Hex.Hex,
384
+ }),
385
+ )
386
+ await expect(
387
+ closeHttpSession({
388
+ createSessionCredential: async () => 'close-credential',
389
+ fetch: async () =>
390
+ new Response(null, {
391
+ headers: { [Constants.Headers.paymentReceipt]: receipt },
392
+ }),
393
+ lastUrl: 'https://example.test/resource',
394
+ signedCloseAmount: '5',
395
+ target: { challenge: challenge(), channel: channel(), channelId },
396
+ }),
397
+ ).rejects.toThrow('Session close response included a mismatched payment receipt.')
398
+ }
399
+ })
400
+
332
401
  test('closeHttpSession includes problem detail and challenge header on failure', async () => {
333
402
  await expect(
334
403
  closeHttpSession({
@@ -672,6 +741,154 @@ describe('SseDriver', () => {
672
741
  ])
673
742
  expect(acceptReceipt).toHaveBeenCalledWith(receipt)
674
743
  })
744
+
745
+ test('wraps standard CRLF streams and preserves response metadata', async () => {
746
+ const receipt = createSessionReceipt({
747
+ acceptedCumulative: 2n,
748
+ challengeId: 'challenge-1',
749
+ channelId: `0x${'01'.repeat(32)}`,
750
+ spent: 2n,
751
+ })
752
+ const encoder = new TextEncoder()
753
+ const source = new Response(
754
+ new ReadableStream({
755
+ start(controller) {
756
+ controller.enqueue(encoder.encode(': keepalive\r\n\r'))
757
+ controller.enqueue(
758
+ encoder.encode(
759
+ [
760
+ '\nevent: custom\r\ndata: hello\r\n\r\n',
761
+ `event: payment-receipt\r\ndata: ${JSON.stringify(receipt)}\r\n\r\n`,
762
+ ].join(''),
763
+ ),
764
+ )
765
+ controller.close()
766
+ },
767
+ }),
768
+ {
769
+ headers: { 'content-length': '100', 'content-type': 'text/event-stream' },
770
+ status: 201,
771
+ statusText: 'Created',
772
+ },
773
+ )
774
+ Object.defineProperty(source, 'url', { value: 'https://example.com/stream' })
775
+
776
+ const response = wrapSseResponse({
777
+ async onNeedVoucher() {},
778
+ onReceipt() {},
779
+ response: source,
780
+ })
781
+
782
+ expect(response.status).toBe(201)
783
+ expect(response.statusText).toBe('Created')
784
+ expect(response.url).toBe('https://example.com/stream')
785
+ expect(response.headers.get('content-type')).toBe('text/event-stream')
786
+ expect(response.headers.has('content-length')).toBe(false)
787
+ expect(await response.text()).toBe(': keepalive\r\n\r\nevent: custom\r\ndata: hello\r\n\r\n')
788
+ })
789
+
790
+ test.each(['consumer', 'signal'] as const)(
791
+ 'cancels the source stream from %s',
792
+ async (source) => {
793
+ const abortController = new AbortController()
794
+ const cancel = vi.fn()
795
+ const sourceResponse = new Response(
796
+ new ReadableStream({
797
+ cancel,
798
+ start(controller) {
799
+ controller.enqueue(new TextEncoder().encode('data: first\n\n'))
800
+ },
801
+ }),
802
+ )
803
+ const response = wrapSseResponse({
804
+ async onNeedVoucher() {},
805
+ onReceipt() {},
806
+ response: sourceResponse,
807
+ signal: abortController.signal,
808
+ })
809
+
810
+ const reader = response.body!.getReader()
811
+ await reader.read()
812
+ if (source === 'signal') abortController.abort()
813
+ else await reader.cancel()
814
+
815
+ await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce())
816
+ },
817
+ )
818
+
819
+ test('keeps voucher events bound to the challenge that opened the stream', async () => {
820
+ const channelId = `0x${'01'.repeat(32)}` as Hex.Hex
821
+ const token = '0x20c0000000000000000000000000000000000001' as Address
822
+ const payee = '0x0000000000000000000000000000000000000002' as Address
823
+ const descriptor: ChannelDescriptor = {
824
+ authorizedSigner: '0x0000000000000000000000000000000000000001',
825
+ expiringNonceHash: `0x${'03'.repeat(32)}` as Hex.Hex,
826
+ operator: '0x0000000000000000000000000000000000000000',
827
+ payee,
828
+ payer: '0x0000000000000000000000000000000000000001',
829
+ salt: `0x${'02'.repeat(32)}` as Hex.Hex,
830
+ token,
831
+ }
832
+ const makeChallenge = (id: string) =>
833
+ Challenge.from({
834
+ id,
835
+ intent: Constants.Intents.session,
836
+ method: Constants.Methods.tempo,
837
+ realm: 'example.test',
838
+ request: {
839
+ amount: '1',
840
+ currency: token,
841
+ methodDetails: { chainId: 4217, escrowContract: tip20ChannelEscrow },
842
+ recipient: payee,
843
+ unitType: 'request',
844
+ },
845
+ }) as TempoSessionChallenge
846
+ const streamChallenge = makeChallenge('challenge-1')
847
+ let currentChallenge = streamChallenge
848
+ const createSessionCredential = vi.fn(async () => 'Payment credential')
849
+ const channel: ChannelEntry = {
850
+ chainId: 4217,
851
+ channelId,
852
+ cumulativeAmount: 1n,
853
+ deposit: 10n,
854
+ descriptor,
855
+ escrow: tip20ChannelEscrow,
856
+ opened: true,
857
+ }
858
+ const response = new Response(
859
+ formatNeedVoucherEvent({
860
+ acceptedCumulative: '1',
861
+ channelId,
862
+ deposit: '10',
863
+ requiredCumulative: '2',
864
+ }),
865
+ { headers: { 'Content-Type': 'text/event-stream' } },
866
+ )
867
+ const stream = consumeSseSessionResponse('https://example.test/stream', response, undefined, {
868
+ acceptReceipt() {},
869
+ assertVoucherWithinLocalLimit() {},
870
+ createSessionCredential,
871
+ async doFetch() {
872
+ throw new Error('unexpected resource fetch')
873
+ },
874
+ fetch: vi.fn(async () => new Response(null, { status: 204 })),
875
+ getChallenge: () => currentChallenge,
876
+ getChannel: () => channel,
877
+ managementInput: (input) => input,
878
+ async topUpIfNeeded() {},
879
+ })
880
+
881
+ currentChallenge = makeChallenge('challenge-2')
882
+ const frames: string[] = []
883
+ for await (const frame of stream) frames.push(frame)
884
+
885
+ expect(frames).toEqual([])
886
+ expect(createSessionCredential).toHaveBeenCalledOnce()
887
+ expect(createSessionCredential).toHaveBeenCalledWith(
888
+ streamChallenge,
889
+ expect.objectContaining({ action: 'voucher', cumulativeAmountRaw: '2' }),
890
+ )
891
+ })
675
892
  })
676
893
 
677
894
  describe('WsDriver', () => {
@@ -1,4 +1,4 @@
1
- import type { Hex } from 'ox'
1
+ import { Hex } from 'ox'
2
2
 
3
3
  import * as Challenge from '../../../Challenge.js'
4
4
  import * as Fetch from '../../../client/internal/Fetch.js'
@@ -502,6 +502,8 @@ export type CloseHttpSessionParameters = {
502
502
  fetch: typeof globalThis.fetch
503
503
  /** Last paid resource URL; used as the management endpoint base. */
504
504
  lastUrl: RequestInfo | URL | null
505
+ /** Resolves the close amount again when the server refreshes the challenge. */
506
+ resolveSignedCloseAmount?: ((challenge: TempoSessionChallenge) => string) | undefined
505
507
  /** Final cumulative amount the client is willing to sign. */
506
508
  signedCloseAmount: string
507
509
  /** Stores a fresh close challenge when the first close credential expired. */
@@ -544,7 +546,7 @@ export function requestInitWithAuthorization(
544
546
  }
545
547
  }
546
548
 
547
- /** Returns the URL used for out-of-band management POSTs, stripping resource query state. */
549
+ /** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
548
550
  export function managementInput(input: RequestInfo | URL): RequestInfo | URL {
549
551
  try {
550
552
  const base =
@@ -555,7 +557,7 @@ export function managementInput(input: RequestInfo | URL): RequestInfo | URL {
555
557
  : input instanceof URL
556
558
  ? new URL(input)
557
559
  : new URL(String(input), base)
558
- url.search = ''
560
+ url.hash = ''
559
561
  return url
560
562
  } catch {
561
563
  return input
@@ -682,12 +684,18 @@ export async function closeHttpSession(
682
684
  )
683
685
  }
684
686
 
685
- const postClose = async (challenge: TempoSessionChallenge) => {
687
+ let closeChallenge = parameters.target.challenge
688
+ let signedCloseAmount = parameters.signedCloseAmount
689
+ const postClose = async (challenge: TempoSessionChallenge, refreshed = false) => {
690
+ closeChallenge = challenge
691
+ signedCloseAmount =
692
+ (refreshed ? parameters.resolveSignedCloseAmount?.(challenge) : undefined) ??
693
+ parameters.signedCloseAmount
686
694
  const credential = await parameters.createSessionCredential(challenge, {
687
695
  action: 'close',
688
696
  channelId: parameters.target.channelId,
689
697
  descriptor: parameters.target.channel.descriptor,
690
- cumulativeAmountRaw: parameters.signedCloseAmount,
698
+ cumulativeAmountRaw: signedCloseAmount,
691
699
  })
692
700
  return parameters.fetch(parameters.lastUrl!, {
693
701
  method: 'POST',
@@ -700,7 +708,7 @@ export async function closeHttpSession(
700
708
  const challenge = Challenge.fromResponseList(response).find(isTempoSessionChallenge)
701
709
  if (challenge) {
702
710
  parameters.setChallenge?.(challenge)
703
- response = await postClose(challenge)
711
+ response = await postClose(challenge, true)
704
712
  }
705
713
  }
706
714
  if (!response.ok) {
@@ -712,7 +720,21 @@ export async function closeHttpSession(
712
720
  }
713
721
 
714
722
  const receiptHeader = response.headers.get(Constants.Headers.paymentReceipt)
715
- return receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined
723
+ if (!receiptHeader) return undefined
724
+ const receipt = deserializeSessionReceipt(receiptHeader)
725
+ if (
726
+ !receipt.txHash ||
727
+ !Hex.validate(receipt.txHash, { strict: true }) ||
728
+ Hex.size(receipt.txHash) !== 32 ||
729
+ !isExpectedCloseReceipt({
730
+ challengeId: closeChallenge.id,
731
+ channelId: parameters.target.channelId,
732
+ expectedCloseAmount: signedCloseAmount,
733
+ receipt,
734
+ })
735
+ )
736
+ throw new Error('Session close response included a mismatched payment receipt.')
737
+ return receipt
716
738
  }
717
739
 
718
740
  /** Options accepted by the auto-driving SSE session flow. */
@@ -723,6 +745,12 @@ export type SseDriverOptions = RequestInit & {
723
745
  signal?: AbortSignal | undefined
724
746
  }
725
747
 
748
+ /** @internal Options used when consuming an already-paid SSE response. */
749
+ export type SseResponseOptions = {
750
+ onReceipt?: ((receipt: SessionReceipt) => void) | undefined
751
+ signal?: AbortSignal | undefined
752
+ }
753
+
726
754
  /** Dependencies the SSE driver needs from `SessionManager`. */
727
755
  export type OpenSseSessionParameters = {
728
756
  /** Creates a session credential for the selected challenge/context. */
@@ -748,6 +776,17 @@ export type OpenSseSessionParameters = {
748
776
  topUpIfNeeded(parameters: TopUpRequirement): Promise<void>
749
777
  }
750
778
 
779
+ /** Session payment operations shared with fetch-backed streams. */
780
+ export type SsePaymentDriver = Pick<
781
+ OpenSseSessionParameters,
782
+ | 'assertVoucherWithinLocalLimit'
783
+ | 'createSessionCredential'
784
+ | 'fetch'
785
+ | 'getChannel'
786
+ | 'managementInput'
787
+ | 'topUpIfNeeded'
788
+ >
789
+
751
790
  /**
752
791
  * Opens an auto-driving paid SSE stream.
753
792
  *
@@ -773,18 +812,28 @@ export async function openSseSession(
773
812
  if (!isEventStream(response) && driver.getChannel()?.opened)
774
813
  response = await driver.doFetch(input, sseInit)
775
814
 
776
- const challenge = driver.getChallenge()
815
+ return consumeSseSessionResponse(input, response, { onReceipt, signal }, driver)
816
+ }
817
+
818
+ /** @internal Consumes an already-paid SSE response with the session transport driver. */
819
+ export function consumeSseSessionResponse(
820
+ input: RequestInfo | URL,
821
+ response: Response,
822
+ options: SseResponseOptions | undefined,
823
+ driver: OpenSseSessionParameters,
824
+ ): AsyncIterable<string> {
777
825
  if (!isEventStream(response)) throw new Error('SSE response is not an event stream.')
778
826
  if (!response.body) throw new Error('Response has no body.')
827
+ const challenge = driver.getChallenge()
779
828
 
780
829
  return iterateSseMessages({
781
830
  onNeedVoucher: (event) => handleSseNeedVoucher({ challenge, driver, input }, event),
782
831
  onReceipt(receipt) {
783
832
  driver.acceptReceipt(receipt)
784
- onReceipt?.(receipt)
833
+ options?.onReceipt?.(receipt)
785
834
  },
786
835
  response,
787
- signal,
836
+ signal: options?.signal,
788
837
  })
789
838
  }
790
839
 
@@ -822,22 +871,32 @@ export async function* driveSseResponse(
822
871
  const reader = parameters.response.body.getReader()
823
872
  const decoder = new TextDecoder()
824
873
  let buffer = ''
874
+ let complete = false
875
+ const cancel = () => {
876
+ void reader.cancel(parameters.signal?.reason).catch(() => undefined)
877
+ }
878
+ parameters.signal?.addEventListener('abort', cancel, { once: true })
879
+ if (parameters.signal?.aborted) cancel()
825
880
 
826
881
  try {
827
882
  while (true) {
828
883
  if (parameters.signal?.aborted) break
829
884
 
830
885
  const { done, value } = await reader.read()
831
- if (done) break
832
-
833
- buffer += decoder.decode(value, { stream: true })
834
- const parts = buffer.split('\n\n')
835
- buffer = parts.pop()!
836
-
837
- for (const part of parts) {
886
+ if (done) {
887
+ complete = true
888
+ buffer += decoder.decode()
889
+ } else buffer += decoder.decode(value, { stream: true })
890
+
891
+ while (true) {
892
+ const separator = findSseSeparator(buffer, done)
893
+ if (!separator) break
894
+ const end = separator.index + separator.length
895
+ const part = buffer.slice(0, separator.index)
896
+ const raw = buffer.slice(0, end)
897
+ buffer = buffer.slice(end)
838
898
  if (!part.trim()) continue
839
- const event = parseEvent(part)
840
- const raw = `${part}\n\n`
899
+ const event = parseEvent(part.replace(/\r\n|\r/g, '\n'))
841
900
  if (!event) {
842
901
  yield { raw }
843
902
  continue
@@ -855,16 +914,92 @@ export async function* driveSseResponse(
855
914
  break
856
915
  }
857
916
  }
917
+ if (done) break
858
918
  }
859
919
  } finally {
920
+ parameters.signal?.removeEventListener('abort', cancel)
921
+ if (!complete) await reader.cancel().catch(() => undefined)
860
922
  reader.releaseLock()
861
923
  }
862
924
  }
863
925
 
864
- async function handleSseNeedVoucher(
926
+ function findSseSeparator(
927
+ value: string,
928
+ complete: boolean,
929
+ ): { index: number; length: number } | undefined {
930
+ for (let index = 0; index < value.length; index++) {
931
+ const first = lineEndingLength(value, index, complete)
932
+ if (!first) continue
933
+ const second = lineEndingLength(value, index + first, complete)
934
+ if (second) return { index, length: first + second }
935
+ index += first - 1
936
+ }
937
+ return undefined
938
+ }
939
+
940
+ function lineEndingLength(value: string, index: number, complete: boolean): number {
941
+ if (value[index] === '\n') return 1
942
+ if (value[index] !== '\r') return 0
943
+ if (value[index + 1] === '\n') return 2
944
+ if (index + 1 === value.length && !complete) return 0
945
+ return 1
946
+ }
947
+
948
+ /** Wraps an SSE response while handling session payment frames in-band. */
949
+ export function wrapSseResponse(parameters: DriveSseResponseParameters): Response {
950
+ const abortController = new AbortController()
951
+ const abort = () => abortController.abort(parameters.signal?.reason)
952
+ parameters.signal?.addEventListener('abort', abort, { once: true })
953
+ if (parameters.signal?.aborted) abort()
954
+
955
+ const iterator = driveSseResponse({ ...parameters, signal: abortController.signal })
956
+ const encoder = new TextEncoder()
957
+ const headers = new Headers(parameters.response.headers)
958
+ headers.delete('content-length')
959
+ const cleanup = () => parameters.signal?.removeEventListener('abort', abort)
960
+
961
+ const response = new Response(
962
+ new ReadableStream({
963
+ async pull(controller) {
964
+ try {
965
+ const frame = await iterator.next()
966
+ if (frame.done) {
967
+ cleanup()
968
+ return controller.close()
969
+ }
970
+ controller.enqueue(encoder.encode(frame.value.raw))
971
+ } catch (error) {
972
+ cleanup()
973
+ throw error
974
+ }
975
+ },
976
+ async cancel() {
977
+ cleanup()
978
+ abortController.abort()
979
+ await iterator.return(undefined)
980
+ },
981
+ }),
982
+ {
983
+ headers,
984
+ status: parameters.response.status,
985
+ statusText: parameters.response.statusText,
986
+ },
987
+ )
988
+
989
+ for (const property of ['redirected', 'type', 'url'] as const)
990
+ Object.defineProperty(response, property, {
991
+ configurable: true,
992
+ get: () => parameters.response[property],
993
+ })
994
+
995
+ return response
996
+ }
997
+
998
+ /** Handles an in-band voucher request using the shared SSE payment driver. */
999
+ export async function handleSseNeedVoucher(
865
1000
  parameters: {
866
1001
  challenge: TempoSessionChallenge | null
867
- driver: OpenSseSessionParameters
1002
+ driver: SsePaymentDriver
868
1003
  input: RequestInfo | URL
869
1004
  },
870
1005
  event: NeedVoucherEvent,
@@ -0,0 +1,36 @@
1
+ import type { ChannelEntry } from '../ChannelOps.js'
2
+ import type { PaymentResponse, SessionManager } from '../SessionManager.js'
3
+ import type { SseResponseOptions, TempoSessionChallenge } from '../Transports.js'
4
+
5
+ type RehydrateParameters = {
6
+ channel: ChannelEntry
7
+ challenge: TempoSessionChallenge
8
+ input: RequestInfo | URL
9
+ spent: bigint
10
+ }
11
+
12
+ type SessionManagerInternals = {
13
+ consumeSseResponse(
14
+ input: RequestInfo | URL,
15
+ response: PaymentResponse,
16
+ options?: SseResponseOptions | undefined,
17
+ ): AsyncIterable<string>
18
+ rehydrate(parameters: RehydrateParameters): void
19
+ }
20
+
21
+ const internals = new WeakMap<SessionManager, SessionManagerInternals>()
22
+
23
+ /** @internal Registers private transport and recovery hooks for a session manager. */
24
+ export function registerSessionManagerInternals(
25
+ manager: SessionManager,
26
+ value: SessionManagerInternals,
27
+ ): void {
28
+ internals.set(manager, value)
29
+ }
30
+
31
+ /** @internal Returns private transport and recovery hooks for a session manager. */
32
+ export function getSessionManagerInternals(manager: SessionManager): SessionManagerInternals {
33
+ const value = internals.get(manager)
34
+ if (!value) throw new Error('Session manager internals are unavailable.')
35
+ return value
36
+ }
@@ -62,6 +62,7 @@ function createMockClient(
62
62
  parameters.rpcMethods?.push(args.method)
63
63
  parameters.onRequest?.(args.method, args.params)
64
64
  if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
65
+ if (args.method === 'eth_sendTransaction') return txHash
65
66
  if (args.method === 'eth_sendRawTransaction') return txHash
66
67
  if (args.method === 'eth_sendRawTransactionSync') return parameters.receipt ?? receipt([])
67
68
  if (args.method === 'eth_getTransactionReceipt') return parameters.receipt ?? null
@@ -290,6 +291,31 @@ describe('precompile receipt wait', () => {
290
291
  })
291
292
  })
292
293
 
294
+ describe('precompile server transactions', () => {
295
+ test('does not add a fee-payer signature when the sender and fee payer are the same account', async () => {
296
+ const rpcMethods: string[] = []
297
+ const client = createMockClient({ rpcMethods })
298
+ const sender = { address: feePayer.address, type: 'json-rpc' } as const
299
+
300
+ await Chain.closeOnChain(
301
+ client,
302
+ descriptor,
303
+ 1n,
304
+ 1n,
305
+ `0x${'11'.repeat(65)}`,
306
+ tip20ChannelEscrow,
307
+ {
308
+ account: sender,
309
+ candidateFeeTokens: [descriptor.token],
310
+ feePayer,
311
+ },
312
+ )
313
+
314
+ expect(rpcMethods).toContain('eth_sendTransaction')
315
+ expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
316
+ })
317
+ })
318
+
293
319
  describe('precompile open calldata parsing', () => {
294
320
  test('parseOpenCall accepts TIP-1034 open calldata', () => {
295
321
  const data = encodeFunctionData({