mppx 0.8.5 → 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 (134) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/Receipt.d.ts +20 -2
  3. package/dist/Receipt.d.ts.map +1 -1
  4. package/dist/Receipt.js +19 -12
  5. package/dist/Receipt.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +6 -4
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/internal.js +2 -2
  10. package/dist/cli/internal.js.map +1 -1
  11. package/dist/cli/plugins/evm.d.ts +2 -0
  12. package/dist/cli/plugins/evm.d.ts.map +1 -0
  13. package/dist/cli/plugins/evm.js +51 -0
  14. package/dist/cli/plugins/evm.js.map +1 -0
  15. package/dist/cli/plugins/index.d.ts +1 -0
  16. package/dist/cli/plugins/index.d.ts.map +1 -1
  17. package/dist/cli/plugins/index.js +1 -0
  18. package/dist/cli/plugins/index.js.map +1 -1
  19. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  20. package/dist/cli/plugins/tempo.js +4 -2
  21. package/dist/cli/plugins/tempo.js.map +1 -1
  22. package/dist/cli/validate/challenge.d.ts +2 -0
  23. package/dist/cli/validate/challenge.d.ts.map +1 -1
  24. package/dist/cli/validate/challenge.js +184 -78
  25. package/dist/cli/validate/challenge.js.map +1 -1
  26. package/dist/cli/validate/discovery.d.ts +1 -0
  27. package/dist/cli/validate/discovery.d.ts.map +1 -1
  28. package/dist/cli/validate/discovery.js +99 -35
  29. package/dist/cli/validate/discovery.js.map +1 -1
  30. package/dist/cli/validate/helpers.d.ts +14 -1
  31. package/dist/cli/validate/helpers.d.ts.map +1 -1
  32. package/dist/cli/validate/helpers.js +0 -13
  33. package/dist/cli/validate/helpers.js.map +1 -1
  34. package/dist/cli/validate/index.d.ts +1 -0
  35. package/dist/cli/validate/index.d.ts.map +1 -1
  36. package/dist/cli/validate/index.js +98 -151
  37. package/dist/cli/validate/index.js.map +1 -1
  38. package/dist/cli/validate/payment.d.ts +1 -0
  39. package/dist/cli/validate/payment.d.ts.map +1 -1
  40. package/dist/cli/validate/payment.js +215 -144
  41. package/dist/cli/validate/payment.js.map +1 -1
  42. package/dist/client/internal/Fetch.d.ts.map +1 -1
  43. package/dist/client/internal/Fetch.js +6 -1
  44. package/dist/client/internal/Fetch.js.map +1 -1
  45. package/dist/evm/Chains.d.ts +4 -0
  46. package/dist/evm/Chains.d.ts.map +1 -1
  47. package/dist/evm/Chains.js +4 -0
  48. package/dist/evm/Chains.js.map +1 -1
  49. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  50. package/dist/internal/AcceptPayment.js +7 -1
  51. package/dist/internal/AcceptPayment.js.map +1 -1
  52. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  53. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.js +1 -1
  55. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  56. package/dist/tempo/client/Charge.d.ts.map +1 -1
  57. package/dist/tempo/client/Charge.js +6 -2
  58. package/dist/tempo/client/Charge.js.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  60. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  61. package/dist/tempo/server/internal/html.gen.js +1 -1
  62. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  63. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  64. package/dist/tempo/server/internal/request-body.js +2 -0
  65. package/dist/tempo/server/internal/request-body.js.map +1 -1
  66. package/dist/tempo/session/precompile/Chain.d.ts +26 -2
  67. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  68. package/dist/tempo/session/precompile/Chain.js +38 -5
  69. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  70. package/dist/tempo/session/server/ChannelStore.js +4 -4
  71. package/dist/tempo/session/server/ChannelStore.js.map +1 -1
  72. package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
  73. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  74. package/dist/tempo/session/server/CredentialVerification.js +21 -1
  75. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  76. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  77. package/dist/tempo/session/server/Session.js +1 -0
  78. package/dist/tempo/session/server/Session.js.map +1 -1
  79. package/dist/validation/core.d.ts +79 -0
  80. package/dist/validation/core.d.ts.map +1 -0
  81. package/dist/validation/core.js +256 -0
  82. package/dist/validation/core.js.map +1 -0
  83. package/dist/validation/index.d.ts +3 -0
  84. package/dist/validation/index.d.ts.map +1 -0
  85. package/dist/validation/index.js +2 -0
  86. package/dist/validation/index.js.map +1 -0
  87. package/dist/x402/Assets.d.ts +9 -0
  88. package/dist/x402/Assets.d.ts.map +1 -1
  89. package/dist/x402/Assets.js +38 -0
  90. package/dist/x402/Assets.js.map +1 -1
  91. package/package.json +9 -4
  92. package/src/Receipt.test.ts +53 -0
  93. package/src/Receipt.ts +26 -13
  94. package/src/cli/cli.test.ts +41 -5
  95. package/src/cli/cli.ts +7 -4
  96. package/src/cli/internal.ts +2 -2
  97. package/src/cli/plugins/evm.ts +55 -0
  98. package/src/cli/plugins/index.ts +1 -0
  99. package/src/cli/plugins/tempo.ts +6 -2
  100. package/src/cli/validate/challenge.ts +334 -121
  101. package/src/cli/validate/discovery.test.ts +307 -0
  102. package/src/cli/validate/discovery.ts +104 -38
  103. package/src/cli/validate/helpers.ts +15 -13
  104. package/src/cli/validate/index.ts +117 -240
  105. package/src/cli/validate/payment.ts +261 -178
  106. package/src/cli/validate.test.ts +143 -3
  107. package/src/client/internal/Fetch.test.ts +186 -2
  108. package/src/client/internal/Fetch.ts +5 -1
  109. package/src/evm/Chains.ts +6 -0
  110. package/src/evm/PublicInterface.test-d.ts +7 -0
  111. package/src/internal/AcceptPayment.test.ts +45 -0
  112. package/src/internal/AcceptPayment.ts +11 -1
  113. package/src/stripe/server/internal/html/package.json +1 -1
  114. package/src/stripe/server/internal/html.gen.ts +1 -1
  115. package/src/tempo/client/Charge.test.ts +61 -0
  116. package/src/tempo/client/Charge.ts +6 -1
  117. package/src/tempo/server/internal/html/package.json +1 -1
  118. package/src/tempo/server/internal/html.gen.ts +1 -1
  119. package/src/tempo/server/internal/request-body.test.ts +9 -0
  120. package/src/tempo/server/internal/request-body.ts +2 -0
  121. package/src/tempo/session/precompile/Chain.test.ts +121 -2
  122. package/src/tempo/session/precompile/Chain.ts +56 -6
  123. package/src/tempo/session/server/ChannelStore.test.ts +17 -0
  124. package/src/tempo/session/server/ChannelStore.ts +4 -4
  125. package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
  126. package/src/tempo/session/server/CredentialVerification.ts +30 -0
  127. package/src/tempo/session/server/Session.integration.test.ts +5 -0
  128. package/src/tempo/session/server/Session.test.ts +119 -53
  129. package/src/tempo/session/server/Session.ts +1 -0
  130. package/src/validation/core.ts +348 -0
  131. package/src/validation/index.ts +11 -0
  132. package/src/x402/Assets.test.ts +42 -0
  133. package/src/x402/Assets.ts +40 -0
  134. 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
@@ -51,6 +51,7 @@ function createMockClient(
51
51
  channel?: { descriptor: Channel.ChannelDescriptor; state: Chain.ChannelState } | undefined
52
52
  receipt?: Record<string, unknown> | null | undefined
53
53
  rpcMethods?: string[] | undefined
54
+ onRequest?: ((method: string, params: unknown) => void) | undefined
54
55
  } = {},
55
56
  ) {
56
57
  return createClient({
@@ -59,6 +60,7 @@ function createMockClient(
59
60
  transport: custom({
60
61
  async request(args) {
61
62
  parameters.rpcMethods?.push(args.method)
63
+ parameters.onRequest?.(args.method, args.params)
62
64
  if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
63
65
  if (args.method === 'eth_sendRawTransaction') return txHash
64
66
  if (args.method === 'eth_sendRawTransactionSync') return parameters.receipt ?? receipt([])
@@ -91,7 +93,7 @@ function createMockClient(
91
93
  })
92
94
  }
93
95
 
94
- function receipt(logs: readonly Record<string, unknown>[]) {
96
+ function receipt(logs: readonly Record<string, unknown>[], transactionHash: Hex = txHash) {
95
97
  return {
96
98
  blockHash: `0x${'01'.repeat(32)}`,
97
99
  blockNumber: '0x1',
@@ -104,7 +106,7 @@ function receipt(logs: readonly Record<string, unknown>[]) {
104
106
  logsBloom: `0x${'00'.repeat(256)}`,
105
107
  status: '0x1',
106
108
  to: tip20ChannelEscrow,
107
- transactionHash: txHash,
109
+ transactionHash,
108
110
  transactionIndex: '0x0',
109
111
  type: '0x76',
110
112
  }
@@ -253,6 +255,41 @@ function expectedExpiringNonceHash(serializedTransaction: `0x${string}`) {
253
255
  )
254
256
  }
255
257
 
258
+ describe('precompile receipt wait', () => {
259
+ test('does not run replacement detection while waiting for Tempo receipts', async () => {
260
+ const requestedHash = `0x${'12'.repeat(32)}` as Hex
261
+ const rpcMethods: string[] = []
262
+ let receiptReads = 0
263
+ let blockNumber = 0n
264
+ const client = createClient({
265
+ chain: { id: chainId } as never,
266
+ pollingInterval: 1,
267
+ transport: custom({
268
+ async request(args) {
269
+ rpcMethods.push(args.method)
270
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
271
+ if (args.method === 'eth_blockNumber') {
272
+ blockNumber += 1n
273
+ return `0x${blockNumber.toString(16)}`
274
+ }
275
+ if (args.method === 'eth_getTransactionReceipt') {
276
+ receiptReads += 1
277
+ if (receiptReads === 1) return null
278
+ return receipt([], requestedHash)
279
+ }
280
+ throw new Error(`unexpected rpc request: ${args.method}`)
281
+ },
282
+ }),
283
+ })
284
+
285
+ const result = await Chain.waitForSuccessfulReceipt(client, requestedHash)
286
+
287
+ expect(result.transactionHash).toBe(requestedHash)
288
+ expect(rpcMethods).not.toContain('eth_getTransactionByHash')
289
+ expect(rpcMethods).not.toContain('eth_getBlockByNumber')
290
+ })
291
+ })
292
+
256
293
  describe('precompile open calldata parsing', () => {
257
294
  test('parseOpenCall accepts TIP-1034 open calldata', () => {
258
295
  const data = encodeFunctionData({
@@ -641,6 +678,44 @@ describe('precompile broadcastOpenTransaction', () => {
641
678
  })
642
679
  })
643
680
 
681
+ test('pins the read-back to the block that included the open transaction', async () => {
682
+ const serializedTransaction = await createOpenTransaction()
683
+ const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
684
+ const expectedDescriptor = { ...descriptor, expiringNonceHash }
685
+ const channelId = Channel.computeId({
686
+ ...expectedDescriptor,
687
+ chainId,
688
+ escrow: tip20ChannelEscrow,
689
+ })
690
+ const state = { settled: 0n, deposit, closeRequestedAt: 0 }
691
+ // The open tx is mined in block 0x1 (see `receipt`). The read-back must be
692
+ // pinned to that block, not `latest`, so a lagging RPC replica cannot
693
+ // answer with stale/empty state.
694
+ const ethCallBlockTags: unknown[] = []
695
+
696
+ await Chain.broadcastOpenTransaction({
697
+ chainId,
698
+ client: createMockClient({
699
+ channel: { descriptor: expectedDescriptor, state },
700
+ receipt: receipt([openedLog({ channelId, expiringNonceHash })]),
701
+ onRequest: (method, params) => {
702
+ if (method === 'eth_call') ethCallBlockTags.push((params as unknown[])[1])
703
+ },
704
+ }),
705
+ escrowContract: tip20ChannelEscrow,
706
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
707
+ expectedChannelId: channelId,
708
+ expectedCurrency: descriptor.token,
709
+ expectedExpiringNonceHash: expiringNonceHash,
710
+ expectedOperator: descriptor.operator,
711
+ expectedPayee: descriptor.payee,
712
+ expectedPayer: descriptor.payer,
713
+ serializedTransaction,
714
+ })
715
+
716
+ expect(ethCallBlockTags).toEqual(['0x1'])
717
+ })
718
+
644
719
  test('rejects ChannelOpened receipt deposit mismatches', async () => {
645
720
  const serializedTransaction = await createOpenTransaction()
646
721
  const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
@@ -1256,3 +1331,47 @@ describe('Chain.assertPrecompileFeePayerPolicy', () => {
1256
1331
  }
1257
1332
  })
1258
1333
  })
1334
+
1335
+ describe('Chain.readbackWithRetry', () => {
1336
+ test('returns immediately when the read succeeds on the first attempt', async () => {
1337
+ let calls = 0
1338
+ const result = await Chain.readbackWithRetry(
1339
+ async () => {
1340
+ calls++
1341
+ return 'ok'
1342
+ },
1343
+ { delayMs: 0 },
1344
+ )
1345
+ expect(result).toBe('ok')
1346
+ expect(calls).toBe(1)
1347
+ })
1348
+
1349
+ test('retries a lagging read until it resolves', async () => {
1350
+ let calls = 0
1351
+ const result = await Chain.readbackWithRetry(
1352
+ async () => {
1353
+ calls++
1354
+ if (calls < 3) throw new Error('header not found')
1355
+ return 'ok'
1356
+ },
1357
+ { retries: 5, delayMs: 0 },
1358
+ )
1359
+ expect(result).toBe('ok')
1360
+ expect(calls).toBe(3)
1361
+ })
1362
+
1363
+ test('rethrows the last error after exhausting retries', async () => {
1364
+ let calls = 0
1365
+ await expect(
1366
+ Chain.readbackWithRetry(
1367
+ async () => {
1368
+ calls++
1369
+ throw new Error(`attempt ${calls}`)
1370
+ },
1371
+ { retries: 2, delayMs: 0 },
1372
+ ),
1373
+ ).rejects.toThrow('attempt 3')
1374
+ // 1 initial attempt + 2 retries.
1375
+ expect(calls).toBe(3)
1376
+ })
1377
+ })
@@ -293,12 +293,14 @@ export async function getChannel(
293
293
  client: Client,
294
294
  descriptor: ChannelDescriptor,
295
295
  escrow: Address = tip20ChannelEscrow,
296
+ blockNumber?: bigint,
296
297
  ): Promise<Channel> {
297
298
  const channel = await readContract(client, {
298
299
  address: escrow,
299
300
  abi: escrowAbi,
300
301
  functionName: 'getChannel',
301
302
  args: [descriptorTuple(descriptor)],
303
+ ...(blockNumber !== undefined ? { blockNumber } : {}),
302
304
  })
303
305
  return {
304
306
  descriptor: channel.descriptor,
@@ -313,12 +315,14 @@ export async function getChannelState(
313
315
  client: Client,
314
316
  channelId: Hex,
315
317
  escrow: Address = tip20ChannelEscrow,
318
+ blockNumber?: bigint,
316
319
  ): Promise<ChannelState> {
317
320
  const state = await readContract(client, {
318
321
  address: escrow,
319
322
  abi: escrowAbi,
320
323
  functionName: 'getChannelState',
321
324
  args: [channelId],
325
+ ...(blockNumber !== undefined ? { blockNumber } : {}),
322
326
  })
323
327
  return stateFromTuple(state)
324
328
  }
@@ -340,6 +344,47 @@ export async function getChannelStatesBatch(
340
344
  return states.map(stateFromTuple)
341
345
  }
342
346
 
347
+ /** Tuning for {@link readbackWithRetry}. */
348
+ export type ReadbackRetryOptions = {
349
+ /** Additional attempts after the first, before giving up. @default 5 */
350
+ retries?: number | undefined
351
+ /** Delay between attempts, in milliseconds. @default 250 */
352
+ delayMs?: number | undefined
353
+ }
354
+
355
+ /**
356
+ * Retry an on-chain readback that is pinned to the block containing a just-sent
357
+ * transaction.
358
+ *
359
+ * The escrow state read is served by a load-balanced RPC whose replicas can lag
360
+ * behind the block that produced the transaction receipt. Callers pin the read
361
+ * to that block number (via the `blockNumber` arg on {@link getChannel} /
362
+ * {@link getChannelState}) so a node that has imported the block returns
363
+ * authoritative state; replicas that have not yet imported it throw (e.g.
364
+ * "header not found"), so we retry with a short backoff until one catches up.
365
+ *
366
+ * This closes the read-after-write race behind
367
+ * `on-chain channel state does not match open receipt` — without it, a stale
368
+ * `latest` read on a lagging replica returns an empty channel and fails
369
+ * verification even though the open transaction succeeded.
370
+ */
371
+ export async function readbackWithRetry<T>(
372
+ read: () => Promise<T>,
373
+ options: ReadbackRetryOptions = {},
374
+ ): Promise<T> {
375
+ const { retries = 5, delayMs = 250 } = options
376
+ let lastError: unknown
377
+ for (let attempt = 0; attempt <= retries; attempt++) {
378
+ try {
379
+ return await read()
380
+ } catch (error) {
381
+ lastError = error
382
+ if (attempt < retries) await new Promise((resolve) => setTimeout(resolve, delayMs))
383
+ }
384
+ }
385
+ throw lastError
386
+ }
387
+
343
388
  /** Options accepted by low-level TIP-1034 on-chain management helpers. */
344
389
  export type ChannelTransactionOptions = {
345
390
  /** Account used to send the transaction when the viem client has no default account. */
@@ -586,7 +631,7 @@ export async function sendTransaction(client: TransactionClient, transaction: He
586
631
 
587
632
  /** Wait for a receipt and reject reverted precompile transactions. */
588
633
  export async function waitForSuccessfulReceipt(client: TransactionClient, hash: Hex) {
589
- const receipt = await waitForTransactionReceipt(client, { hash })
634
+ const receipt = await waitForTransactionReceipt(client, { hash, checkReplacement: false })
590
635
  if (receipt.status !== 'success')
591
636
  throw new VerificationFailedError({ reason: 'precompile transaction reverted' })
592
637
  return receipt
@@ -808,7 +853,9 @@ export async function broadcastOpenTransaction(
808
853
  expectedChannelId: parameters.expectedChannelId,
809
854
  openDeposit: open.deposit,
810
855
  })
811
- const chainChannel = await getChannel(parameters.client, descriptor, parameters.escrowContract)
856
+ const chainChannel = await readbackWithRetry(() =>
857
+ getChannel(parameters.client, descriptor, parameters.escrowContract, receipt.blockNumber),
858
+ )
812
859
  const state = chainChannel.state
813
860
  validateOpenReadbackState({ emittedDeposit: opened.deposit, state })
814
861
  return {
@@ -896,10 +943,13 @@ export async function broadcastTopUpTransaction(
896
943
  emittedChannelId: toppedUp.channelId,
897
944
  expectedChannelId: parameters.expectedChannelId,
898
945
  })
899
- const state = await getChannelState(
900
- parameters.client,
901
- toppedUp.channelId,
902
- parameters.escrowContract,
946
+ const state = await readbackWithRetry(() =>
947
+ getChannelState(
948
+ parameters.client,
949
+ toppedUp.channelId,
950
+ parameters.escrowContract,
951
+ receipt.blockNumber,
952
+ ),
903
953
  )
904
954
  validateTopUpReadbackState({ newDeposit: toppedUp.newDeposit, state })
905
955
  return { txHash: receipt.transactionHash, newDeposit: toppedUp.newDeposit, state }
@@ -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
  })