mppx 0.8.6 → 0.8.7

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 (104) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cli/internal.js +2 -2
  3. package/dist/cli/internal.js.map +1 -1
  4. package/dist/cli/plugins/evm.d.ts +2 -0
  5. package/dist/cli/plugins/evm.d.ts.map +1 -0
  6. package/dist/cli/plugins/evm.js +51 -0
  7. package/dist/cli/plugins/evm.js.map +1 -0
  8. package/dist/cli/plugins/index.d.ts +1 -0
  9. package/dist/cli/plugins/index.d.ts.map +1 -1
  10. package/dist/cli/plugins/index.js +1 -0
  11. package/dist/cli/plugins/index.js.map +1 -1
  12. package/dist/cli/validate/challenge.d.ts +2 -0
  13. package/dist/cli/validate/challenge.d.ts.map +1 -1
  14. package/dist/cli/validate/challenge.js +184 -78
  15. package/dist/cli/validate/challenge.js.map +1 -1
  16. package/dist/cli/validate/discovery.d.ts +1 -0
  17. package/dist/cli/validate/discovery.d.ts.map +1 -1
  18. package/dist/cli/validate/discovery.js +99 -35
  19. package/dist/cli/validate/discovery.js.map +1 -1
  20. package/dist/cli/validate/helpers.d.ts +14 -1
  21. package/dist/cli/validate/helpers.d.ts.map +1 -1
  22. package/dist/cli/validate/helpers.js +0 -13
  23. package/dist/cli/validate/helpers.js.map +1 -1
  24. package/dist/cli/validate/index.d.ts +1 -0
  25. package/dist/cli/validate/index.d.ts.map +1 -1
  26. package/dist/cli/validate/index.js +98 -151
  27. package/dist/cli/validate/index.js.map +1 -1
  28. package/dist/cli/validate/payment.d.ts +1 -0
  29. package/dist/cli/validate/payment.d.ts.map +1 -1
  30. package/dist/cli/validate/payment.js +215 -144
  31. package/dist/cli/validate/payment.js.map +1 -1
  32. package/dist/evm/Chains.d.ts +4 -0
  33. package/dist/evm/Chains.d.ts.map +1 -1
  34. package/dist/evm/Chains.js +4 -0
  35. package/dist/evm/Chains.js.map +1 -1
  36. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  37. package/dist/internal/AcceptPayment.js +7 -1
  38. package/dist/internal/AcceptPayment.js.map +1 -1
  39. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  40. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  41. package/dist/stripe/server/internal/html.gen.js +1 -1
  42. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  43. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  44. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  45. package/dist/tempo/server/internal/html.gen.js +1 -1
  46. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  47. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  48. package/dist/tempo/server/internal/request-body.js +2 -0
  49. package/dist/tempo/server/internal/request-body.js.map +1 -1
  50. package/dist/tempo/session/server/ChannelStore.js +4 -4
  51. package/dist/tempo/session/server/ChannelStore.js.map +1 -1
  52. package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
  53. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  54. package/dist/tempo/session/server/CredentialVerification.js +21 -1
  55. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  56. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  57. package/dist/tempo/session/server/Session.js +1 -0
  58. package/dist/tempo/session/server/Session.js.map +1 -1
  59. package/dist/validation/core.d.ts +79 -0
  60. package/dist/validation/core.d.ts.map +1 -0
  61. package/dist/validation/core.js +256 -0
  62. package/dist/validation/core.js.map +1 -0
  63. package/dist/validation/index.d.ts +3 -0
  64. package/dist/validation/index.d.ts.map +1 -0
  65. package/dist/validation/index.js +2 -0
  66. package/dist/validation/index.js.map +1 -0
  67. package/dist/x402/Assets.d.ts +9 -0
  68. package/dist/x402/Assets.d.ts.map +1 -1
  69. package/dist/x402/Assets.js +38 -0
  70. package/dist/x402/Assets.js.map +1 -1
  71. package/package.json +9 -4
  72. package/src/cli/internal.ts +2 -2
  73. package/src/cli/plugins/evm.ts +55 -0
  74. package/src/cli/plugins/index.ts +1 -0
  75. package/src/cli/validate/challenge.ts +334 -121
  76. package/src/cli/validate/discovery.test.ts +307 -0
  77. package/src/cli/validate/discovery.ts +104 -38
  78. package/src/cli/validate/helpers.ts +15 -13
  79. package/src/cli/validate/index.ts +117 -240
  80. package/src/cli/validate/payment.ts +261 -178
  81. package/src/cli/validate.test.ts +143 -3
  82. package/src/client/internal/Fetch.test.ts +142 -2
  83. package/src/evm/Chains.ts +6 -0
  84. package/src/evm/PublicInterface.test-d.ts +7 -0
  85. package/src/internal/AcceptPayment.test.ts +45 -0
  86. package/src/internal/AcceptPayment.ts +11 -1
  87. package/src/stripe/server/internal/html/package.json +1 -1
  88. package/src/stripe/server/internal/html.gen.ts +1 -1
  89. package/src/tempo/server/internal/html/package.json +1 -1
  90. package/src/tempo/server/internal/html.gen.ts +1 -1
  91. package/src/tempo/server/internal/request-body.test.ts +9 -0
  92. package/src/tempo/server/internal/request-body.ts +2 -0
  93. package/src/tempo/session/server/ChannelStore.test.ts +17 -0
  94. package/src/tempo/session/server/ChannelStore.ts +4 -4
  95. package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
  96. package/src/tempo/session/server/CredentialVerification.ts +30 -0
  97. package/src/tempo/session/server/Session.integration.test.ts +5 -0
  98. package/src/tempo/session/server/Session.test.ts +119 -53
  99. package/src/tempo/session/server/Session.ts +1 -0
  100. package/src/validation/core.ts +348 -0
  101. package/src/validation/index.ts +11 -0
  102. package/src/x402/Assets.test.ts +42 -0
  103. package/src/x402/Assets.ts +40 -0
  104. package/src/x402/Exact.localnet.test.ts +475 -0
@@ -27,6 +27,15 @@ describe('request-body', () => {
27
27
  ).toBe(true)
28
28
  })
29
29
 
30
+ test('returns false for empty body streams with content-length zero', () => {
31
+ expect(
32
+ hasCapturedRequestBody({
33
+ hasBody: true,
34
+ headers: new Headers({ 'content-length': '0' }),
35
+ }),
36
+ ).toBe(false)
37
+ })
38
+
30
39
  test('returns true when content-length is non-zero', () => {
31
40
  expect(
32
41
  hasCapturedRequestBody({
@@ -19,6 +19,8 @@ export function captureRequestBodyProbe(input: Request): RequestBodyProbe {
19
19
  export function hasCapturedRequestBody(
20
20
  input: Pick<RequestBodyProbe, 'headers' | 'hasBody'>,
21
21
  ): boolean {
22
+ if (input.headers.get('content-length') === '0' && !input.headers.has('transfer-encoding'))
23
+ return false
22
24
  if (hasBodyFramingHeaders(input)) return true
23
25
  if (input.hasBody === true) return true
24
26
  return false
@@ -559,6 +559,23 @@ describe('ChannelStore voucher acceptance', () => {
559
559
  expect((await store.getChannel(stateUpdateChannelId))?.highestVoucherAmount).toBe(50n)
560
560
  })
561
561
 
562
+ test('rejects an already accepted voucher when it has settled on-chain', async () => {
563
+ vi.spyOn(Voucher, 'verifyVoucher').mockReturnValue(true)
564
+ const stored = voucherAcceptanceChannel({ highestVoucherAmount: 50n })
565
+
566
+ await expect(
567
+ ChannelStore.verifyAndAcceptVoucher({
568
+ challenge: voucherAcceptanceChallenge,
569
+ channel: stored,
570
+ channelState: { deposit: 100n, settled: 50n, closeRequestedAt: 0 },
571
+ methodDetails: { chainId: 4217, escrowContract: voucherAcceptanceEscrow },
572
+ minVoucherDelta: 5n,
573
+ store: memoryChannelStore(stored),
574
+ voucher: voucherAcceptanceVoucher(50n),
575
+ }),
576
+ ).rejects.toThrow(VerificationFailedError)
577
+ })
578
+
562
579
  test('rejects vouchers beyond deposit or below minimum delta', async () => {
563
580
  vi.spyOn(Voucher, 'verifyVoucher').mockReturnValue(true)
564
581
  const stored = voucherAcceptanceChannel({ highestVoucherAmount: 50n })
@@ -542,6 +542,10 @@ export async function verifyAndAcceptVoucher(
542
542
  )
543
543
  if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
544
544
 
545
+ if (voucher.cumulativeAmount <= channelState.settled)
546
+ throw new VerificationFailedError({
547
+ reason: 'voucher cumulativeAmount is below on-chain settled amount',
548
+ })
545
549
  if (voucher.cumulativeAmount === channel.highestVoucherAmount)
546
550
  return createSessionReceipt({
547
551
  challengeId: challenge.id,
@@ -550,10 +554,6 @@ export async function verifyAndAcceptVoucher(
550
554
  spent: channel.spent,
551
555
  units: channel.units,
552
556
  })
553
- if (voucher.cumulativeAmount <= channelState.settled)
554
- throw new VerificationFailedError({
555
- reason: 'voucher cumulativeAmount is below on-chain settled amount',
556
- })
557
557
  const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
558
558
  if (delta < minVoucherDelta)
559
559
  throw new DeltaTooSmallError({
@@ -1,13 +1,19 @@
1
1
  import type { Address, Hex } from 'viem'
2
- import { describe, expect, test } from 'vp/test'
2
+ import { afterEach, describe, expect, test, vi } from 'vp/test'
3
3
 
4
+ import * as Challenge from '../../../Challenge.js'
5
+ import * as Store from '../../../Store.js'
6
+ import { chainId as chainIds } from '../../internal/defaults.js'
4
7
  import * as Channel from '../precompile/Channel.js'
8
+ import * as Voucher from '../precompile/Voucher.js'
9
+ import * as ChannelStore from './ChannelStore.js'
5
10
  import {
6
11
  assertOpenCredentialCoversRequest,
7
12
  requireSessionCredentialAction,
8
13
  requireSessionCredentialPayload,
9
14
  requireSessionCredentialPayloadHeader,
10
15
  validateChannelDescriptor,
16
+ verifyCredentialPayload,
11
17
  } from './CredentialVerification.js'
12
18
 
13
19
  describe('SessionCredentialGuards', () => {
@@ -24,6 +30,10 @@ describe('SessionCredentialGuards', () => {
24
30
  const signature = `0x${'ab'.repeat(65)}` as Hex
25
31
  const transaction = `0x${'cd'.repeat(32)}` as Hex
26
32
 
33
+ afterEach(() => {
34
+ vi.restoreAllMocks()
35
+ })
36
+
27
37
  describe('SessionCredentialGuards', () => {
28
38
  test('reads valid action discriminators', () => {
29
39
  expect(requireSessionCredentialAction({ action: 'open' })).toBe('open')
@@ -179,4 +189,111 @@ describe('SessionCredentialGuards', () => {
179
189
  ).toThrow(case_.expected)
180
190
  })
181
191
  })
192
+
193
+ describe('verifyCredentialPayload', () => {
194
+ const escrow = '0x4D50500000000000000000000000000000000000' as Address
195
+ const testChainId = chainIds.testnet
196
+ const computedChannelId = Channel.computeId({ ...descriptor, chainId: testChainId, escrow })
197
+ const challenge = Challenge.from({
198
+ id: 'credential-source-test',
199
+ realm: 'example.test',
200
+ method: 'tempo',
201
+ intent: 'session',
202
+ request: {
203
+ amount: '1',
204
+ currency: descriptor.token,
205
+ recipient: descriptor.payee,
206
+ unitType: 'request',
207
+ },
208
+ })
209
+
210
+ function channelStore(): ChannelStore.ChannelStore {
211
+ return ChannelStore.fromStore(Store.memory())
212
+ }
213
+
214
+ async function seedChannel(store: ChannelStore.ChannelStore) {
215
+ await store.updateChannel(computedChannelId, () => ({
216
+ backend: 'precompile',
217
+ authorizedSigner: descriptor.authorizedSigner,
218
+ chainId: testChainId,
219
+ channelId: computedChannelId,
220
+ closeRequestedAt: 0n,
221
+ createdAt: '2026-01-01T00:00:00.000Z',
222
+ deposit: 100n,
223
+ descriptor,
224
+ escrowContract: escrow,
225
+ expiringNonceHash: descriptor.expiringNonceHash,
226
+ finalized: false,
227
+ highestVoucher: null,
228
+ highestVoucherAmount: 50n,
229
+ operator: descriptor.operator,
230
+ payee: descriptor.payee,
231
+ payer: descriptor.payer,
232
+ salt: descriptor.salt,
233
+ settledOnChain: 0n,
234
+ spent: 0n,
235
+ token: descriptor.token,
236
+ units: 0,
237
+ }))
238
+ }
239
+
240
+ test.each([
241
+ { label: 'payer', sourceAddress: descriptor.payer },
242
+ { label: 'authorized signer', sourceAddress: descriptor.authorizedSigner },
243
+ ])('accepts voucher credentials from the channel $label', async ({ sourceAddress }) => {
244
+ const store = channelStore()
245
+ await seedChannel(store)
246
+ const verifyVoucher = vi.spyOn(Voucher, 'verifyVoucher').mockResolvedValue(true)
247
+
248
+ await expect(
249
+ verifyCredentialPayload({
250
+ challenge,
251
+ channelStateTtl: 60_000,
252
+ chainId: testChainId,
253
+ client: {} as never,
254
+ credentialSource: `did:pkh:eip155:${testChainId}:${sourceAddress}`,
255
+ escrow,
256
+ lastOnChainVerified: new Map([[computedChannelId, Date.now()]]),
257
+ minVoucherDelta: 1n,
258
+ payload: {
259
+ action: 'voucher',
260
+ channelId: computedChannelId,
261
+ cumulativeAmount: '60',
262
+ descriptor,
263
+ signature,
264
+ },
265
+ store,
266
+ }),
267
+ ).resolves.toMatchObject({ acceptedCumulative: '60' })
268
+ expect(verifyVoucher).toHaveBeenCalledOnce()
269
+ })
270
+
271
+ test('rejects voucher credentials from a different source', async () => {
272
+ const store = channelStore()
273
+ await seedChannel(store)
274
+ const verifyVoucher = vi.spyOn(Voucher, 'verifyVoucher').mockResolvedValue(true)
275
+
276
+ await expect(
277
+ verifyCredentialPayload({
278
+ challenge,
279
+ channelStateTtl: 60_000,
280
+ chainId: testChainId,
281
+ client: {} as never,
282
+ credentialSource: `did:pkh:eip155:${testChainId}:0x0000000000000000000000000000000000000099`,
283
+ escrow,
284
+ lastOnChainVerified: new Map([[computedChannelId, Date.now()]]),
285
+ minVoucherDelta: 1n,
286
+ payload: {
287
+ action: 'voucher',
288
+ channelId: computedChannelId,
289
+ cumulativeAmount: '60',
290
+ descriptor,
291
+ signature,
292
+ },
293
+ store,
294
+ }),
295
+ ).rejects.toThrow('credential source does not match channel payer or authorized signer')
296
+ expect(verifyVoucher).not.toHaveBeenCalled()
297
+ })
298
+ })
182
299
  })
@@ -126,6 +126,32 @@ export function assertOpenCredentialCoversRequest(parameters: {
126
126
  throw new VerificationFailedError({ reason: 'voucher amount is less than request amount' })
127
127
  }
128
128
 
129
+ /** Verifies that the credential source is authorized to spend from the channel. */
130
+ export function assertCredentialSourceCanSpend(parameters: {
131
+ chainId: number
132
+ channel: Pick<ChannelStore.State, 'authorizedSigner' | 'payer'>
133
+ source?: string | undefined
134
+ }): void {
135
+ const sourceAddress = readCredentialSourceAddress(parameters.source, parameters.chainId)
136
+ if (
137
+ isAddressEqual(sourceAddress, parameters.channel.payer) ||
138
+ isAddressEqual(sourceAddress, parameters.channel.authorizedSigner)
139
+ )
140
+ return
141
+ throw new VerificationFailedError({
142
+ reason: 'credential source does not match channel payer or authorized signer',
143
+ })
144
+ }
145
+
146
+ function readCredentialSourceAddress(source: string | undefined, chainId: number): Address {
147
+ const prefix = `did:pkh:eip155:${chainId}:`
148
+ if (!source?.startsWith(prefix))
149
+ throw new VerificationFailedError({ reason: 'credential source does not match channel' })
150
+ const address = source.slice(prefix.length)
151
+ if (isAddress(address, { strict: false })) return address
152
+ throw new VerificationFailedError({ reason: 'invalid credential source' })
153
+ }
154
+
129
155
  const sessionCredentialActions = [
130
156
  'open',
131
157
  'topUp',
@@ -302,6 +328,8 @@ export type VerifyCredentialPayloadParameters = {
302
328
  chainId: number
303
329
  /** viem client used for precompile reads and transaction broadcasts. */
304
330
  client: Chain.TransactionClient
331
+ /** Optional payer identifier from the HTTP credential source field. */
332
+ credentialSource?: string | undefined
305
333
  /** TIP20EscrowChannel precompile address for this session method. */
306
334
  escrow: Address
307
335
  /** Operator address advertised in the HMAC-bound challenge details. */
@@ -531,6 +559,7 @@ async function handleVoucherCredential(
531
559
  store,
532
560
  client,
533
561
  challenge,
562
+ credentialSource,
534
563
  payload,
535
564
  chainId,
536
565
  escrow,
@@ -564,6 +593,7 @@ async function handleVoucherCredential(
564
593
  store,
565
594
  validateDescriptor: true,
566
595
  })
596
+ assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource })
567
597
  if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
568
598
  const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl
569
599
  const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined
@@ -84,6 +84,10 @@ function sessionRequest(channelId: Hex.Hex, amount = '100') {
84
84
  }
85
85
  }
86
86
 
87
+ function sourceFor(account = payer): string {
88
+ return `did:pkh:eip155:${chain.id}:${account.address}`
89
+ }
90
+
87
91
  describe.runIf(isPrecompileTestnet)('precompile server session chain integration', () => {
88
92
  test('broadcasts and verifies a real precompile open credential', async () => {
89
93
  const rawStore = Store.memory()
@@ -247,6 +251,7 @@ describe.runIf(isPrecompileTestnet)('precompile server session chain integration
247
251
  request: sessionRequest(channelId),
248
252
  } as never,
249
253
  payload,
254
+ source: sourceFor(),
250
255
  },
251
256
  request: sessionRequest(channelId) as never,
252
257
  })
@@ -1,5 +1,7 @@
1
1
  import * as node_http from 'node:http'
2
2
 
3
+ import { serve } from '@hono/node-server'
4
+ import { Hono } from 'hono'
3
5
  import { Challenge, Constants, Credential } from 'mppx'
4
6
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
5
7
  import {
@@ -235,6 +237,18 @@ function verifyRequestWithFeePayer(channelId: Hex, feePayer: typeof payer): Veri
235
237
  } as unknown as VerifyRequest
236
238
  }
237
239
 
240
+ function sourceFor(account = payer): string {
241
+ return `did:pkh:eip155:${chainId}:${account.address}`
242
+ }
243
+
244
+ function voucherCredential(payload: SessionCredentialPayload, channelId: Hex) {
245
+ return {
246
+ challenge: makeChallenge(channelId),
247
+ payload,
248
+ source: sourceFor(),
249
+ }
250
+ }
251
+
238
252
  let saltCounter = 0
239
253
 
240
254
  async function createOpenPayload(
@@ -1434,10 +1448,7 @@ describe('precompile server session unit guardrails', () => {
1434
1448
  )
1435
1449
 
1436
1450
  const receipt = (await method.verify({
1437
- credential: {
1438
- challenge: makeChallenge(openPayload.channelId),
1439
- payload: voucher,
1440
- },
1451
+ credential: voucherCredential(voucher, openPayload.channelId),
1441
1452
  request: verifyRequest(openPayload.channelId),
1442
1453
  })) as SessionReceipt
1443
1454
 
@@ -1473,10 +1484,7 @@ describe('precompile server session unit guardrails', () => {
1473
1484
  })
1474
1485
 
1475
1486
  const receipt = (await method.verify({
1476
- credential: {
1477
- challenge: makeChallenge(openPayload.channelId),
1478
- payload: voucher,
1479
- },
1487
+ credential: voucherCredential(voucher, openPayload.channelId),
1480
1488
  request: verifyRequest(openPayload.channelId),
1481
1489
  })) as SessionReceipt
1482
1490
 
@@ -1506,10 +1514,7 @@ describe('precompile server session unit guardrails', () => {
1506
1514
 
1507
1515
  await expect(
1508
1516
  method.verify({
1509
- credential: {
1510
- challenge: makeChallenge(openPayload.channelId),
1511
- payload: voucher,
1512
- },
1517
+ credential: voucherCredential(voucher, openPayload.channelId),
1513
1518
  request: verifyRequest(openPayload.channelId),
1514
1519
  }),
1515
1520
  ).rejects.toThrow(
@@ -1534,16 +1539,13 @@ describe('precompile server session unit guardrails', () => {
1534
1539
 
1535
1540
  await expect(
1536
1541
  method.verify({
1537
- credential: {
1538
- challenge: makeChallenge(openPayload.channelId),
1539
- payload: voucher,
1540
- },
1542
+ credential: voucherCredential(voucher, openPayload.channelId),
1541
1543
  request: verifyRequest(openPayload.channelId),
1542
1544
  }),
1543
1545
  ).rejects.toThrow(/voucher delta 150 below minimum 200/)
1544
1546
  })
1545
1547
 
1546
- test('accepts idempotent precompile voucher replay after on-chain settlement catches up', async () => {
1548
+ test('rejects idempotent precompile voucher replay after on-chain settlement catches up', async () => {
1547
1549
  const rawStore = Store.memory()
1548
1550
  const store = channelStore(rawStore)
1549
1551
  const openPayload = await createOpenPayload({ initialAmount: 100n })
@@ -1572,17 +1574,12 @@ describe('precompile server session unit guardrails', () => {
1572
1574
  createStateClient(payer, { settled: 500n, deposit: 1_000n, closeRequestedAt: 0 }),
1573
1575
  })
1574
1576
 
1575
- const receipt = (await method.verify({
1576
- credential: {
1577
- challenge: makeChallenge(openPayload.channelId),
1578
- payload: voucher,
1579
- },
1580
- request: verifyRequest(openPayload.channelId),
1581
- })) as SessionReceipt
1582
-
1583
- expect(receipt.acceptedCumulative).toBe('500')
1584
- expect(receipt.spent).toBe('500')
1585
- expect(receipt.units).toBe(10)
1577
+ await expect(
1578
+ method.verify({
1579
+ credential: voucherCredential(voucher, openPayload.channelId),
1580
+ request: verifyRequest(openPayload.channelId),
1581
+ }),
1582
+ ).rejects.toThrow(/below on-chain settled amount/)
1586
1583
  })
1587
1584
 
1588
1585
  test('rejects stale or hijacked precompile voucher signatures', async () => {
@@ -1599,16 +1596,16 @@ describe('precompile server session unit guardrails', () => {
1599
1596
 
1600
1597
  await expect(
1601
1598
  method.verify({
1602
- credential: {
1603
- challenge: makeChallenge(openPayload.channelId),
1604
- payload: {
1599
+ credential: voucherCredential(
1600
+ {
1605
1601
  action: 'voucher',
1606
1602
  channelId: openPayload.channelId,
1607
1603
  cumulativeAmount: '250',
1608
1604
  descriptor: openPayload.descriptor,
1609
1605
  signature,
1610
1606
  },
1611
- },
1607
+ openPayload.channelId,
1608
+ ),
1612
1609
  request: verifyRequest(openPayload.channelId),
1613
1610
  }),
1614
1611
  ).rejects.toThrow(/invalid voucher signature/)
@@ -1628,10 +1625,7 @@ describe('precompile server session unit guardrails', () => {
1628
1625
 
1629
1626
  await expect(
1630
1627
  method.verify({
1631
- credential: {
1632
- challenge: makeChallenge(openPayload.channelId),
1633
- payload: voucher,
1634
- },
1628
+ credential: voucherCredential(voucher, openPayload.channelId),
1635
1629
  request: verifyRequest(openPayload.channelId),
1636
1630
  }),
1637
1631
  ).rejects.toThrow(/exceeds.*deposit|insufficient channel deposit/)
@@ -1664,10 +1658,7 @@ describe('precompile server session unit guardrails', () => {
1664
1658
 
1665
1659
  await expect(
1666
1660
  method.verify({
1667
- credential: {
1668
- challenge: makeChallenge(openPayload.channelId),
1669
- payload: voucher,
1670
- },
1661
+ credential: voucherCredential(voucher, openPayload.channelId),
1671
1662
  request: verifyRequest(openPayload.channelId),
1672
1663
  }),
1673
1664
  ).rejects.toThrow(/pending close request/)
@@ -1699,10 +1690,7 @@ describe('precompile server session unit guardrails', () => {
1699
1690
 
1700
1691
  await expect(
1701
1692
  method.verify({
1702
- credential: {
1703
- challenge: makeChallenge(openPayload.channelId),
1704
- payload: voucher,
1705
- },
1693
+ credential: voucherCredential(voucher, openPayload.channelId),
1706
1694
  request: verifyRequest(openPayload.channelId),
1707
1695
  }),
1708
1696
  ).rejects.toThrow(/deposit is zero|channel deposit is zero|not found/)
@@ -1853,6 +1841,7 @@ describe('precompile server session unit guardrails', () => {
1853
1841
  Authorization: Credential.serialize({
1854
1842
  challenge: Challenge.fromResponse(first.challenge),
1855
1843
  payload: voucher,
1844
+ source: sourceFor(),
1856
1845
  }),
1857
1846
  },
1858
1847
  }),
@@ -1874,6 +1863,7 @@ describe('precompile server session unit guardrails', () => {
1874
1863
  Authorization: Credential.serialize({
1875
1864
  challenge: Challenge.fromResponse(replayChallenge.challenge),
1876
1865
  payload: voucher,
1866
+ source: sourceFor(),
1877
1867
  }),
1878
1868
  },
1879
1869
  }),
@@ -1915,6 +1905,7 @@ describe('precompile server session unit guardrails', () => {
1915
1905
  Credential.serialize({
1916
1906
  challenge: Challenge.fromResponse(first.challenge),
1917
1907
  payload: voucher,
1908
+ source: sourceFor(),
1918
1909
  }),
1919
1910
  ),
1920
1911
  )
@@ -1933,6 +1924,7 @@ describe('precompile server session unit guardrails', () => {
1933
1924
  Credential.serialize({
1934
1925
  challenge: Challenge.fromResponse(replayChallenge.challenge),
1935
1926
  payload: voucher,
1927
+ source: sourceFor(),
1936
1928
  }),
1937
1929
  ),
1938
1930
  )
@@ -1963,6 +1955,7 @@ describe('precompile server session unit guardrails', () => {
1963
1955
  descriptor: openPayload.descriptor,
1964
1956
  signature: (await createOpenPayload({ account: wrongPayer })).signature,
1965
1957
  },
1958
+ source: sourceFor(),
1966
1959
  }),
1967
1960
  },
1968
1961
  }),
@@ -2125,6 +2118,7 @@ describe('precompile server session unit guardrails', () => {
2125
2118
  let voucherPosts = 0
2126
2119
  const amount = options.amount ?? '1'
2127
2120
  const maxDeposit = options.maxDeposit ?? 3n
2121
+ let deposit = maxDeposit
2128
2122
  const unitType = options.unitType ?? 'token'
2129
2123
  const route = Mppx_server.create({
2130
2124
  methods: [
@@ -2151,12 +2145,19 @@ describe('precompile server session unit guardrails', () => {
2151
2145
  receipt: transactionReceipt([
2152
2146
  closedLog(payload.channelId, BigInt(payload.cumulativeAmount), 0n),
2153
2147
  ]),
2154
- state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
2148
+ state: { settled: 0n, deposit, closeRequestedAt: 0 },
2149
+ })
2150
+ }
2151
+ if (payload?.action === 'topUp') {
2152
+ deposit += BigInt(payload.additionalDeposit)
2153
+ return createServerClient([], payer, payload.channelId, {
2154
+ receipt: transactionReceipt([topUpLog(payload, deposit)]),
2155
+ state: { settled: 0n, deposit, closeRequestedAt: 0 },
2155
2156
  })
2156
2157
  }
2157
2158
  return createStateClient(payer, {
2158
2159
  settled: 0n,
2159
- deposit: maxDeposit,
2160
+ deposit,
2160
2161
  closeRequestedAt: 0,
2161
2162
  })
2162
2163
  },
@@ -2166,8 +2167,7 @@ describe('precompile server session unit guardrails', () => {
2166
2167
  secretKey: 'test-secret-key-test-secret-key-32',
2167
2168
  }).session({ amount, decimals: 0, suggestedDeposit: maxDeposit.toString(), unitType })
2168
2169
 
2169
- const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
2170
- const request = new Request(input, init)
2170
+ const handle = async (request: Request) => {
2171
2171
  currentPayload = undefined
2172
2172
  if (request.headers.has('Authorization')) {
2173
2173
  try {
@@ -2178,7 +2178,7 @@ describe('precompile server session unit guardrails', () => {
2178
2178
 
2179
2179
  const result = await route(request)
2180
2180
  if (result.status === 402) return result.challenge
2181
- if (currentPayload?.action === 'voucher') return new Response(null, { status: 200 })
2181
+ if (request.method === 'POST') return result.withReceipt()
2182
2182
 
2183
2183
  if (request.headers.get('Accept')?.includes('text/event-stream')) {
2184
2184
  if (unitType === 'request') {
@@ -2211,8 +2211,12 @@ describe('precompile server session unit guardrails', () => {
2211
2211
  return result.withReceipt(new Response('ok'))
2212
2212
  }
2213
2213
 
2214
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) =>
2215
+ handle(new Request(input, init))
2216
+
2214
2217
  return {
2215
2218
  fetch,
2219
+ handle,
2216
2220
  rawStore,
2217
2221
  get voucherPosts() {
2218
2222
  return voucherPosts
@@ -2247,6 +2251,71 @@ describe('precompile server session unit guardrails', () => {
2247
2251
  expect(persisted?.finalized).toBe(true)
2248
2252
  })
2249
2253
 
2254
+ test('handles empty management POST streams through a Node adapter', async () => {
2255
+ const harness = createManagedSseFetch({ maxDeposit: 1n })
2256
+ const managementPosts: Array<{
2257
+ action: SessionCredentialPayload['action']
2258
+ contentLength: string | null
2259
+ hasBody: boolean
2260
+ status: number
2261
+ }> = []
2262
+ const app = new Hono()
2263
+ app.all('*', async (c) => {
2264
+ const request = c.req.raw
2265
+ const payload = request.headers.has('Authorization')
2266
+ ? Credential.fromRequest<SessionCredentialPayload>(request).payload
2267
+ : undefined
2268
+ const response = await harness.handle(request)
2269
+ if (request.method === 'POST' && payload)
2270
+ managementPosts.push({
2271
+ action: payload.action,
2272
+ contentLength: request.headers.get('content-length'),
2273
+ hasBody: request.body !== null,
2274
+ status: response.status,
2275
+ })
2276
+ return response
2277
+ })
2278
+ const server = serve({ fetch: app.fetch, port: 0 })
2279
+ await new Promise<void>((resolve) => server.once('listening', resolve))
2280
+ const address = server.address()
2281
+ if (!address || typeof address === 'string') throw new Error('expected TCP address')
2282
+
2283
+ try {
2284
+ const fetch: typeof globalThis.fetch = async (input, init) => {
2285
+ const response = await globalThis.fetch(input, init)
2286
+ return new Response(response.body, response)
2287
+ }
2288
+ const manager = precompileSessionManager({
2289
+ account: payer,
2290
+ client: createSigningClient(),
2291
+ decimals: 0,
2292
+ fetch,
2293
+ maxDeposit: '3',
2294
+ })
2295
+
2296
+ const chunks: string[] = []
2297
+ const stream = await manager.sse(`http://localhost:${address.port}/stream`)
2298
+ for await (const chunk of stream) chunks.push(chunk)
2299
+
2300
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
2301
+ const closeReceipt = await manager.close()
2302
+ expect(closeReceipt?.spent).toBe('3')
2303
+ expect(managementPosts).toEqual(
2304
+ expect.arrayContaining([
2305
+ { action: 'topUp', contentLength: '0', hasBody: true, status: 204 },
2306
+ { action: 'voucher', contentLength: '0', hasBody: true, status: 204 },
2307
+ { action: 'close', contentLength: '0', hasBody: true, status: 204 },
2308
+ ]),
2309
+ )
2310
+ expect(managementPosts.every(({ status }) => status === 204)).toBe(true)
2311
+
2312
+ const persisted = await channelStore(harness.rawStore).getChannel(manager.channelId!)
2313
+ expect(persisted?.finalized).toBe(true)
2314
+ } finally {
2315
+ server.close()
2316
+ }
2317
+ })
2318
+
2250
2319
  test('unitType=request auto-metered SSE responses charge once across the stream', async () => {
2251
2320
  const harness = createManagedSseFetch({ maxDeposit: 1n, unitType: 'request' })
2252
2321
  const manager = precompileSessionManager({
@@ -2848,10 +2917,7 @@ describe('precompile server session unit guardrails', () => {
2848
2917
  })
2849
2918
 
2850
2919
  const receipt = await method.verify({
2851
- credential: {
2852
- challenge: makeChallenge(openPayload.channelId),
2853
- payload: lowerVoucher,
2854
- },
2920
+ credential: voucherCredential(lowerVoucher, openPayload.channelId),
2855
2921
  request: verifyRequest(openPayload.channelId),
2856
2922
  })
2857
2923
 
@@ -413,6 +413,7 @@ export function session<const parameters extends session.Parameters>(
413
413
  channelStateTtl,
414
414
  chainId: context.chainId,
415
415
  client: context.client,
416
+ credentialSource: credential.source,
416
417
  escrow: context.escrow,
417
418
  expectedOperator: context.methodDetails.operator,
418
419
  feePayer: context.feePayer,