mppx 0.8.12 → 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 (193) hide show
  1. package/CHANGELOG.md +18 -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/node.d.ts +29 -0
  39. package/dist/client/node.d.ts.map +1 -0
  40. package/dist/client/node.js +177 -0
  41. package/dist/client/node.js.map +1 -0
  42. package/dist/internal/types.d.ts +24 -0
  43. package/dist/internal/types.d.ts.map +1 -1
  44. package/dist/stripe/server/Charge.d.ts +8 -4
  45. package/dist/stripe/server/Charge.d.ts.map +1 -1
  46. package/dist/stripe/server/Charge.js.map +1 -1
  47. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  48. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/stripe/server/internal/html.gen.js +1 -1
  50. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/client/Methods.d.ts +1 -1
  52. package/dist/tempo/internal/types.d.ts +10 -1
  53. package/dist/tempo/internal/types.d.ts.map +1 -1
  54. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  55. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  56. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  57. package/dist/tempo/legacy/server/Session.js.map +1 -1
  58. package/dist/tempo/server/Charge.d.ts +3 -2
  59. package/dist/tempo/server/Charge.d.ts.map +1 -1
  60. package/dist/tempo/server/Charge.js.map +1 -1
  61. package/dist/tempo/server/Methods.d.ts +2 -23
  62. package/dist/tempo/server/Methods.d.ts.map +1 -1
  63. package/dist/tempo/server/Methods.js +2 -2
  64. package/dist/tempo/server/Methods.js.map +1 -1
  65. package/dist/tempo/server/Subscription.d.ts +3 -2
  66. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  67. package/dist/tempo/server/Subscription.js.map +1 -1
  68. package/dist/tempo/server/index.d.ts +1 -1
  69. package/dist/tempo/server/index.d.ts.map +1 -1
  70. package/dist/tempo/server/index.js.map +1 -1
  71. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  72. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  73. package/dist/tempo/server/internal/html.gen.js +1 -1
  74. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  75. package/dist/tempo/session/Snapshot.d.ts +9 -0
  76. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  77. package/dist/tempo/session/Snapshot.js +6 -0
  78. package/dist/tempo/session/Snapshot.js.map +1 -1
  79. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  80. package/dist/tempo/session/client/ChannelOps.js +11 -2
  81. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  82. package/dist/tempo/session/client/CredentialState.d.ts +21 -1
  83. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  84. package/dist/tempo/session/client/CredentialState.js +66 -1
  85. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  86. package/dist/tempo/session/client/Session.d.ts +1 -1
  87. package/dist/tempo/session/client/SessionManager.d.ts +2 -2
  88. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  89. package/dist/tempo/session/client/SessionManager.js +128 -30
  90. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  91. package/dist/tempo/session/client/Transports.d.ts +11 -2
  92. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  93. package/dist/tempo/session/client/Transports.js +33 -9
  94. package/dist/tempo/session/client/Transports.js.map +1 -1
  95. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  96. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  97. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  98. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  99. package/dist/tempo/session/precompile/Chain.js +13 -4
  100. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  101. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  102. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  103. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  104. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  105. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  106. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  107. package/dist/tempo/session/server/MeteredStream.js +5 -4
  108. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  109. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  110. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  111. package/dist/tempo/session/server/RequestState.js +10 -4
  112. package/dist/tempo/session/server/RequestState.js.map +1 -1
  113. package/dist/tempo/session/server/Session.d.ts +21 -6
  114. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  115. package/dist/tempo/session/server/Session.js +4 -1
  116. package/dist/tempo/session/server/Session.js.map +1 -1
  117. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  118. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  119. package/dist/tempo/session/server/Settlement.js +20 -0
  120. package/dist/tempo/session/server/Settlement.js.map +1 -1
  121. package/dist/tempo/session/server/Ws.d.ts +2 -0
  122. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  123. package/dist/tempo/session/server/Ws.js.map +1 -1
  124. package/dist/tempo/session/server/index.d.ts +1 -1
  125. package/dist/tempo/session/server/index.d.ts.map +1 -1
  126. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  127. package/dist/validation/core.d.ts.map +1 -1
  128. package/dist/validation/core.js +6 -2
  129. package/dist/validation/core.js.map +1 -1
  130. package/dist/viem/Client.d.ts.map +1 -1
  131. package/dist/viem/Client.js +24 -17
  132. package/dist/viem/Client.js.map +1 -1
  133. package/package.json +6 -1
  134. package/src/cli/account.ts +45 -10
  135. package/src/cli/cli.test.ts +165 -70
  136. package/src/cli/cli.ts +67 -2
  137. package/src/cli/mcp.test.ts +11 -0
  138. package/src/cli/sessions/Manager.test.ts +249 -0
  139. package/src/cli/sessions/Manager.ts +93 -0
  140. package/src/cli/sessions/commands.ts +444 -0
  141. package/src/cli/sessions/request.test.ts +51 -0
  142. package/src/cli/sessions/request.ts +353 -0
  143. package/src/cli/sessions/store.test.ts +581 -0
  144. package/src/cli/sessions/store.ts +940 -0
  145. package/src/cli/utils.test.ts +23 -0
  146. package/src/cli/utils.ts +10 -0
  147. package/src/cli/validate/index.ts +12 -14
  148. package/src/cli/validate/messages.ts +7 -0
  149. package/src/cli/validate.test.ts +38 -5
  150. package/src/client/node.test.ts +115 -0
  151. package/src/client/node.ts +247 -0
  152. package/src/internal/types.test-d.ts +21 -0
  153. package/src/internal/types.ts +33 -0
  154. package/src/server/Mppx.test-d.ts +42 -0
  155. package/src/stripe/Methods.test.ts +10 -0
  156. package/src/stripe/server/Charge.test-d.ts +66 -0
  157. package/src/stripe/server/Charge.ts +8 -5
  158. package/src/stripe/server/internal/html.gen.ts +1 -1
  159. package/src/tempo/internal/types.ts +13 -2
  160. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  161. package/src/tempo/legacy/server/Session.ts +6 -5
  162. package/src/tempo/server/Charge.ts +6 -4
  163. package/src/tempo/server/Methods.ts +5 -4
  164. package/src/tempo/server/Subscription.ts +6 -4
  165. package/src/tempo/server/index.ts +5 -1
  166. package/src/tempo/server/internal/html.gen.ts +1 -1
  167. package/src/tempo/session/README.md +201 -0
  168. package/src/tempo/session/Snapshot.ts +21 -0
  169. package/src/tempo/session/client/ChannelOps.test.ts +40 -0
  170. package/src/tempo/session/client/ChannelOps.ts +12 -2
  171. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  172. package/src/tempo/session/client/CredentialState.ts +103 -1
  173. package/src/tempo/session/client/Session.test.ts +3 -3
  174. package/src/tempo/session/client/SessionManager.test.ts +137 -4
  175. package/src/tempo/session/client/SessionManager.ts +139 -33
  176. package/src/tempo/session/client/Transports.test.ts +150 -8
  177. package/src/tempo/session/client/Transports.ts +48 -10
  178. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  179. package/src/tempo/session/precompile/Chain.test.ts +26 -0
  180. package/src/tempo/session/precompile/Chain.ts +17 -4
  181. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  182. package/src/tempo/session/server/MeteredStream.ts +12 -5
  183. package/src/tempo/session/server/RequestState.test.ts +39 -3
  184. package/src/tempo/session/server/RequestState.ts +13 -6
  185. package/src/tempo/session/server/Session.test.ts +461 -0
  186. package/src/tempo/session/server/Session.ts +31 -8
  187. package/src/tempo/session/server/Settlement.ts +45 -0
  188. package/src/tempo/session/server/Ws.test.ts +113 -0
  189. package/src/tempo/session/server/Ws.ts +2 -0
  190. package/src/tempo/session/server/index.ts +2 -0
  191. package/src/validation/core.ts +7 -1
  192. package/src/viem/Client.test.ts +44 -1
  193. 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,
@@ -112,9 +114,15 @@ describe('HttpManagement', () => {
112
114
  })
113
115
  }
114
116
 
115
- function receiptHeader(acceptedCumulative: bigint, spent: bigint) {
117
+ function receiptHeader(acceptedCumulative: bigint, spent: bigint, challengeId = 'challenge-1') {
116
118
  return serializeSessionReceipt(
117
- createSessionReceipt({ acceptedCumulative, challengeId: 'challenge-1', channelId, spent }),
119
+ createSessionReceipt({
120
+ acceptedCumulative,
121
+ challengeId,
122
+ channelId,
123
+ spent,
124
+ txHash: `0x${'aa'.repeat(32)}`,
125
+ }),
118
126
  )
119
127
  }
120
128
 
@@ -139,9 +147,12 @@ describe('HttpManagement', () => {
139
147
  )
140
148
  })
141
149
 
142
- test('strips resource query state from management URLs', () => {
150
+ test('preserves query state and strips fragments from management URLs', () => {
143
151
  expect(managementInput('https://example.test/resource?cursor=abc').toString()).toBe(
144
- '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',
145
156
  )
146
157
  })
147
158
 
@@ -185,7 +196,7 @@ describe('HttpManagement', () => {
185
196
  return 'top-up-credential'
186
197
  })
187
198
  const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
188
- expect(input.toString()).toBe('https://example.test/resource')
199
+ expect(input.toString()).toBe('https://example.test/resource?cursor=abc')
189
200
  expect(init?.method).toBe('POST')
190
201
  expect(authorizationHeader(init)).toBe('top-up-credential')
191
202
  return new Response(null, {
@@ -301,16 +312,20 @@ describe('HttpManagement', () => {
301
312
  const entry = channel()
302
313
  const retryChallenge = { ...challenge(), id: 'challenge-2' } as TempoSessionChallenge
303
314
  const setChallenge = vi.fn()
304
- 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!)
305
319
  return `close-${challenge_.id}`
306
320
  })
321
+ const resolveSignedCloseAmount = vi.fn(() => '6')
307
322
  const fetch = vi
308
323
  .fn()
309
324
  .mockResolvedValueOnce(response402(retryChallenge))
310
325
  .mockResolvedValueOnce(
311
326
  new Response(null, {
312
327
  status: 200,
313
- headers: { [Constants.Headers.paymentReceipt]: receiptHeader(5n, 5n) },
328
+ headers: { [Constants.Headers.paymentReceipt]: receiptHeader(6n, 6n, 'challenge-2') },
314
329
  }),
315
330
  )
316
331
 
@@ -318,18 +333,71 @@ describe('HttpManagement', () => {
318
333
  createSessionCredential,
319
334
  fetch,
320
335
  lastUrl: 'https://example.test/resource',
336
+ resolveSignedCloseAmount,
321
337
  signedCloseAmount: '5',
322
338
  setChallenge,
323
339
  target: { challenge: challenge(), channel: entry, channelId },
324
340
  })
325
341
 
326
- expect(receipt?.spent).toBe('5')
342
+ expect(receipt?.spent).toBe('6')
327
343
  expect(setChallenge).toHaveBeenCalledWith(retryChallenge)
344
+ expect(resolveSignedCloseAmount).toHaveBeenCalledOnce()
345
+ expect(resolveSignedCloseAmount).toHaveBeenCalledWith(retryChallenge)
346
+ expect(closeAmounts).toEqual(['5', '6'])
328
347
  expect(createSessionCredential).toHaveBeenCalledTimes(2)
329
348
  expect(createSessionCredential.mock.calls[1]?.[0]).toMatchObject({ id: retryChallenge.id })
330
349
  expect(authorizationHeader(fetch.mock.calls[1]?.[1])).toBe('close-challenge-2')
331
350
  })
332
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
+
333
401
  test('closeHttpSession includes problem detail and challenge header on failure', async () => {
334
402
  await expect(
335
403
  closeHttpSession({
@@ -747,6 +815,80 @@ describe('SseDriver', () => {
747
815
  await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce())
748
816
  },
749
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
+ })
750
892
  })
751
893
 
752
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. */
@@ -784,18 +812,28 @@ export async function openSseSession(
784
812
  if (!isEventStream(response) && driver.getChannel()?.opened)
785
813
  response = await driver.doFetch(input, sseInit)
786
814
 
787
- 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> {
788
825
  if (!isEventStream(response)) throw new Error('SSE response is not an event stream.')
789
826
  if (!response.body) throw new Error('Response has no body.')
827
+ const challenge = driver.getChallenge()
790
828
 
791
829
  return iterateSseMessages({
792
830
  onNeedVoucher: (event) => handleSseNeedVoucher({ challenge, driver, input }, event),
793
831
  onReceipt(receipt) {
794
832
  driver.acceptReceipt(receipt)
795
- onReceipt?.(receipt)
833
+ options?.onReceipt?.(receipt)
796
834
  },
797
835
  response,
798
- signal,
836
+ signal: options?.signal,
799
837
  })
800
838
  }
801
839
 
@@ -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({
@@ -1001,8 +1001,11 @@ async function sendPrecompileTransaction(
1001
1001
  label: string,
1002
1002
  options?: ChannelTransactionOptions,
1003
1003
  ): Promise<Hex> {
1004
- if (options?.feePayer) {
1005
- const account = options.account ?? client.account
1004
+ const account = options?.account ?? client.account
1005
+ const selfSponsored =
1006
+ account && options?.feePayer && isAddressEqual(account.address, options.feePayer.address)
1007
+
1008
+ if (options?.feePayer && !selfSponsored) {
1006
1009
  if (!account) throw new Error(`Cannot ${label} precompile channel: no account available.`)
1007
1010
  const feeToken =
1008
1011
  options.feeToken ??
@@ -1033,10 +1036,20 @@ async function sendPrecompileTransaction(
1033
1036
  return receipt.transactionHash
1034
1037
  }
1035
1038
 
1039
+ const feeToken =
1040
+ options?.feeToken ??
1041
+ (selfSponsored
1042
+ ? await resolveFeeToken({
1043
+ account: account.address,
1044
+ candidateTokens: options?.candidateFeeTokens,
1045
+ client,
1046
+ })
1047
+ : undefined)
1048
+
1036
1049
  return sendPrecompileContractCall(client, {
1037
- account: options?.account,
1050
+ account,
1038
1051
  to,
1039
1052
  data,
1040
- feeToken: options?.feeToken,
1053
+ feeToken,
1041
1054
  })
1042
1055
  }
@@ -30,7 +30,7 @@ import {
30
30
  import * as Voucher from '../precompile/Voucher.js'
31
31
  import * as ChannelStore from './ChannelStore.js'
32
32
  import { getChallengePaymentFields } from './RequestState.js'
33
- import { assertSettlementSender, getClientAccount } from './Settlement.js'
33
+ import { assertSettlementSender, getClientAccount, type OnSessionSettlement } from './Settlement.js'
34
34
 
35
35
  /** Returns the effective voucher signer for a TIP-1034 descriptor. */
36
36
  export function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address {
@@ -344,6 +344,8 @@ export type VerifyCredentialPayloadParameters = {
344
344
  lastOnChainVerified: Map<Hex, number>
345
345
  /** Minimum allowed voucher delta in raw units. */
346
346
  minVoucherDelta: bigint
347
+ /** Callback invoked after an on-chain settlement or close transaction is confirmed. */
348
+ onSessionSettlement?: OnSessionSettlement | undefined
347
349
  /** Discriminated session credential payload to verify. */
348
350
  payload: SessionCredentialPayload
349
351
  /** Server-side channel store. */
@@ -747,6 +749,21 @@ async function handleCloseCredential(
747
749
  signature: payload.signature,
748
750
  }),
749
751
  )
752
+ if (parameters.onSessionSettlement && txHash) {
753
+ try {
754
+ await parameters.onSessionSettlement(
755
+ Object.freeze({
756
+ txHash,
757
+ channelId,
758
+ trigger: 'close' as const,
759
+ amount: settledToPayee,
760
+ delta: settledToPayee - state.settled,
761
+ }),
762
+ )
763
+ } catch {
764
+ // Errors are isolated — observers cannot break the settlement flow.
765
+ }
766
+ }
750
767
  return createSessionReceipt({
751
768
  challengeId: challenge.id,
752
769
  channelId,
@@ -12,8 +12,14 @@ export type SessionController = {
12
12
  * The reservation blocks until sufficient voucher headroom exists, but the
13
13
  * charge is only committed once a chunk is actually emitted. If the stream
14
14
  * ends or aborts before that emission, the reservation is dropped.
15
+ *
16
+ * Pass an explicit raw-unit `amount` for request-aware or otherwise dynamic
17
+ * pricing. When omitted, the session challenge's configured tick cost is
18
+ * used.
15
19
  */
16
- charge(): Promise<void>
20
+ charge(amount?: bigint): Promise<void>
21
+ /** Aborted when the client closes or requests the final session receipt. */
22
+ signal: AbortSignal
17
23
  }
18
24
 
19
25
  /** Async stream source accepted by paid session transports. */
@@ -49,7 +55,7 @@ export async function* meterIterable(options: MeteredStreamOptions): AsyncGenera
49
55
  let reservedAmount = 0n
50
56
  let reservedUnits = 0
51
57
 
52
- const charge = async () => {
58
+ const charge = async (amount = options.tickCost) => {
53
59
  if (prepaidUnits > 0) {
54
60
  prepaidUnits -= 1
55
61
  return
@@ -58,19 +64,20 @@ export async function* meterIterable(options: MeteredStreamOptions): AsyncGenera
58
64
  await reserveChargeOrWait({
59
65
  store: options.store,
60
66
  channelId: options.channelId,
61
- amount: options.tickCost,
67
+ amount,
62
68
  reservedAmount,
63
69
  emit: options.emitNeedVoucher,
64
70
  formatNeedVoucher: options.formatNeedVoucher,
65
71
  pollIntervalMs: options.pollIntervalMs,
66
72
  signal: options.signal,
67
73
  })
68
- reservedAmount += options.tickCost
74
+ reservedAmount += amount
69
75
  reservedUnits += 1
70
76
  }
71
77
 
78
+ const signal = options.signal ?? new AbortController().signal
72
79
  const iterable =
73
- typeof options.generate === 'function' ? options.generate({ charge }) : options.generate
80
+ typeof options.generate === 'function' ? options.generate({ charge, signal }) : options.generate
74
81
 
75
82
  for await (const value of iterable) {
76
83
  if (options.signal?.aborted) break
@@ -316,7 +316,11 @@ describe('SessionSnapshotHints', () => {
316
316
  escrowContract,
317
317
  expiringNonceHash: descriptor.expiringNonceHash,
318
318
  finalized: false,
319
- highestVoucher: null,
319
+ highestVoucher: {
320
+ channelId,
321
+ cumulativeAmount: 500n,
322
+ signature: `0x${'44'.repeat(64)}`,
323
+ },
320
324
  highestVoucherAmount: 500n,
321
325
  operator: descriptor.operator,
322
326
  payee: descriptor.payee,
@@ -451,6 +455,11 @@ describe('SessionSnapshotHints', () => {
451
455
  deposit: '1000',
452
456
  descriptor,
453
457
  escrow: escrowContract,
458
+ highestVoucher: {
459
+ channelId,
460
+ cumulativeAmount: '500',
461
+ signature: `0x${'44'.repeat(64)}`,
462
+ },
454
463
  requiredCumulative: '350',
455
464
  settled: '100',
456
465
  spent: '300',
@@ -458,7 +467,7 @@ describe('SessionSnapshotHints', () => {
458
467
  })
459
468
  })
460
469
 
461
- test('raises accepted cumulative when next request exceeds current authorization', async () => {
470
+ test('keeps accepted cumulative at the signed voucher when the next request needs more', async () => {
462
471
  const snapshot = await resolveSessionSnapshot({
463
472
  amount: 300n,
464
473
  channelId,
@@ -466,7 +475,34 @@ describe('SessionSnapshotHints', () => {
466
475
  })
467
476
 
468
477
  expect(snapshot?.requiredCumulative).toBe('600')
469
- expect(snapshot?.acceptedCumulative).toBe('600')
478
+ expect(snapshot?.acceptedCumulative).toBe('500')
479
+ expect(snapshot?.highestVoucher?.cumulativeAmount).toBe('500')
480
+ })
481
+
482
+ test('omits reusable channel hints without a matching signed highest voucher', async () => {
483
+ await expect(
484
+ resolveSessionSnapshot({
485
+ amount: 50n,
486
+ channelId,
487
+ store: store(channel({ highestVoucher: null })),
488
+ }),
489
+ ).resolves.toBeUndefined()
490
+
491
+ await expect(
492
+ resolveSessionSnapshot({
493
+ amount: 50n,
494
+ channelId,
495
+ store: store(
496
+ channel({
497
+ highestVoucher: {
498
+ channelId,
499
+ cumulativeAmount: 499n,
500
+ signature: `0x${'44'.repeat(64)}`,
501
+ },
502
+ }),
503
+ ),
504
+ }),
505
+ ).resolves.toBeUndefined()
470
506
  })
471
507
 
472
508
  test('omits reusable channel hints when payment fields do not match', async () => {
@@ -80,7 +80,11 @@ export type ResolveSessionChannelIdParameters = {
80
80
  store: ChannelStore.ChannelStore
81
81
  }
82
82
 
83
- /** Application hook for mapping request identity to an existing session channel. */
83
+ /**
84
+ * Application-owned lookup from authenticated identity and payment scope to an existing session
85
+ * channel. MPPx does not derive a storage key or scan the channel store; the returned channel ID is
86
+ * loaded and independently validated before its snapshot is advertised.
87
+ */
84
88
  export type ResolveSessionChannelId = (
85
89
  parameters: ResolveSessionChannelIdParameters,
86
90
  ) => Promise<string | null | undefined> | string | null | undefined
@@ -139,14 +143,12 @@ export async function resolveSessionSnapshot(
139
143
  const channel = await store.getChannel(ChannelStore.normalizeChannelId(channelId))
140
144
  if (!channel || !ChannelStore.isPrecompileState(channel)) return undefined
141
145
  if (channel.finalized) return undefined
146
+ if (!channel.highestVoucher) return undefined
147
+ if (channel.highestVoucher.cumulativeAmount !== channel.highestVoucherAmount) return undefined
142
148
  if (expected && !matchesSnapshotPaymentFields(channel, expected)) return undefined
143
149
  const requiredCumulative = channel.spent + amount
144
- const acceptedCumulative =
145
- channel.highestVoucherAmount > requiredCumulative
146
- ? channel.highestVoucherAmount
147
- : requiredCumulative
148
150
  return {
149
- acceptedCumulative: acceptedCumulative.toString(),
151
+ acceptedCumulative: channel.highestVoucherAmount.toString(),
150
152
  chainId: channel.chainId,
151
153
  channelId: channel.channelId,
152
154
  closeRequestedAt:
@@ -154,6 +156,11 @@ export async function resolveSessionSnapshot(
154
156
  deposit: channel.deposit.toString(),
155
157
  descriptor: channel.descriptor,
156
158
  escrow: channel.escrowContract,
159
+ highestVoucher: {
160
+ channelId: channel.highestVoucher.channelId,
161
+ cumulativeAmount: channel.highestVoucher.cumulativeAmount.toString(),
162
+ signature: channel.highestVoucher.signature,
163
+ },
157
164
  requiredCumulative: requiredCumulative.toString(),
158
165
  settled: channel.settledOnChain.toString(),
159
166
  spent: channel.spent.toString(),