mppx 0.8.13 → 0.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/Errors.d.ts +10 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +11 -2
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/Method.d.ts +105 -3
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +102 -2
  9. package/dist/Method.js.map +1 -1
  10. package/dist/cli/validate/payment.d.ts.map +1 -1
  11. package/dist/cli/validate/payment.js +18 -4
  12. package/dist/cli/validate/payment.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts.map +1 -1
  14. package/dist/client/internal/Fetch.js +98 -5
  15. package/dist/client/internal/Fetch.js.map +1 -1
  16. package/dist/client/internal/MethodChallenge.d.ts +19 -0
  17. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  18. package/dist/client/internal/MethodChallenge.js +15 -0
  19. package/dist/client/internal/MethodChallenge.js.map +1 -0
  20. package/dist/client/internal/MethodResponse.d.ts +15 -0
  21. package/dist/client/internal/MethodResponse.d.ts.map +1 -1
  22. package/dist/client/internal/MethodResponse.js +10 -0
  23. package/dist/client/internal/MethodResponse.js.map +1 -1
  24. package/dist/server/Mppx.d.ts +23 -4
  25. package/dist/server/Mppx.d.ts.map +1 -1
  26. package/dist/server/Mppx.js +91 -82
  27. package/dist/server/Mppx.js.map +1 -1
  28. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  29. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/stripe/server/internal/html.gen.js +1 -1
  31. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  32. package/dist/tempo/client/Methods.d.ts +1 -0
  33. package/dist/tempo/client/Methods.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +7 -2
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  39. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  40. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  41. package/dist/tempo/server/Charge.d.ts +46 -77
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +289 -209
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/Methods.d.ts +12 -1
  46. package/dist/tempo/server/Methods.d.ts.map +1 -1
  47. package/dist/tempo/server/Methods.js +4 -0
  48. package/dist/tempo/server/Methods.js.map +1 -1
  49. package/dist/tempo/server/Relay.d.ts +57 -0
  50. package/dist/tempo/server/Relay.d.ts.map +1 -0
  51. package/dist/tempo/server/Relay.js +179 -0
  52. package/dist/tempo/server/Relay.js.map +1 -0
  53. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  54. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  55. package/dist/tempo/server/SponsorBudget.js +150 -0
  56. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  57. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  58. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  61. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  62. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/session/client/ChannelOps.js +9 -4
  64. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  66. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  67. package/dist/tempo/session/client/CredentialState.js +37 -19
  68. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  69. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  70. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  71. package/dist/tempo/session/client/Runtime.js +17 -0
  72. package/dist/tempo/session/client/Runtime.js.map +1 -1
  73. package/dist/tempo/session/client/Session.d.ts +15 -5
  74. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/session/client/Session.js +216 -45
  76. package/dist/tempo/session/client/Session.js.map +1 -1
  77. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  78. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  79. package/dist/tempo/session/client/SessionManager.js +114 -44
  80. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  81. package/dist/tempo/session/client/Transports.d.ts +16 -6
  82. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  83. package/dist/tempo/session/client/Transports.js +84 -37
  84. package/dist/tempo/session/client/Transports.js.map +1 -1
  85. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  86. package/dist/tempo/session/precompile/Chain.js +96 -8
  87. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  88. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  89. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  90. package/dist/tempo/session/precompile/Channel.js +4 -0
  91. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  92. package/dist/tempo/session/server/RequestState.js +3 -1
  93. package/dist/tempo/session/server/RequestState.js.map +1 -1
  94. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  95. package/dist/tempo/session/server/Ws.js +3 -0
  96. package/dist/tempo/session/server/Ws.js.map +1 -1
  97. package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
  98. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  99. package/dist/tempo/subscription/KeyAuthorization.js +14 -3
  100. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  101. package/dist/viem/Client.d.ts.map +1 -1
  102. package/dist/viem/Client.js +6 -1
  103. package/dist/viem/Client.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/Errors.test.ts +23 -0
  106. package/src/Errors.ts +21 -2
  107. package/src/Method.test.ts +102 -1
  108. package/src/Method.ts +241 -5
  109. package/src/cli/validate/payment.ts +18 -4
  110. package/src/cli/validate.test.ts +16 -0
  111. package/src/client/Mppx.test-d.ts +3 -3
  112. package/src/client/internal/Fetch.test.ts +77 -0
  113. package/src/client/internal/Fetch.ts +122 -11
  114. package/src/client/internal/MethodChallenge.ts +35 -0
  115. package/src/client/internal/MethodResponse.ts +28 -0
  116. package/src/server/Mppx.test-d.ts +2 -0
  117. package/src/server/Mppx.test.ts +205 -0
  118. package/src/server/Mppx.ts +167 -103
  119. package/src/stripe/server/internal/html.gen.ts +1 -1
  120. package/src/tempo/PublicExports.test-d.ts +20 -0
  121. package/src/tempo/internal/fee-payer.ts +7 -3
  122. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  123. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  124. package/src/tempo/server/Charge.test.ts +337 -63
  125. package/src/tempo/server/Charge.ts +443 -267
  126. package/src/tempo/server/Methods.ts +11 -0
  127. package/src/tempo/server/Relay.test.ts +677 -0
  128. package/src/tempo/server/Relay.ts +297 -0
  129. package/src/tempo/server/SponsorBudget.test.ts +154 -0
  130. package/src/tempo/server/SponsorBudget.ts +219 -0
  131. package/src/tempo/server/internal/html.gen.ts +1 -1
  132. package/src/tempo/session/README.md +7 -0
  133. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  134. package/src/tempo/session/client/ChannelOps.ts +12 -4
  135. package/src/tempo/session/client/CredentialState.ts +61 -20
  136. package/src/tempo/session/client/Runtime.test.ts +12 -0
  137. package/src/tempo/session/client/Runtime.ts +22 -0
  138. package/src/tempo/session/client/Session.test.ts +506 -7
  139. package/src/tempo/session/client/Session.ts +279 -54
  140. package/src/tempo/session/client/SessionManager.test.ts +318 -13
  141. package/src/tempo/session/client/SessionManager.ts +132 -42
  142. package/src/tempo/session/client/Transports.test.ts +54 -1
  143. package/src/tempo/session/client/Transports.ts +137 -57
  144. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  145. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  146. package/src/tempo/session/precompile/Chain.ts +102 -8
  147. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  148. package/src/tempo/session/precompile/Channel.ts +5 -0
  149. package/src/tempo/session/server/RequestState.test.ts +8 -1
  150. package/src/tempo/session/server/RequestState.ts +2 -2
  151. package/src/tempo/session/server/Session.test.ts +66 -4
  152. package/src/tempo/session/server/Ws.ts +3 -0
  153. package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
  154. package/src/tempo/subscription/KeyAuthorization.ts +14 -3
  155. package/src/viem/Client.test.ts +18 -1
  156. package/src/viem/Client.ts +7 -1
@@ -1,5 +1,5 @@
1
1
  import type { Account, Address, Client, Hex } from 'viem'
2
- import { encodeFunctionData, isAddressEqual, parseEventLogs } from 'viem'
2
+ import { decodeFunctionData, encodeFunctionData, isAddressEqual, parseEventLogs } from 'viem'
3
3
  import {
4
4
  call,
5
5
  prepareTransactionRequest,
@@ -10,7 +10,7 @@ import {
10
10
  signTransaction,
11
11
  waitForTransactionReceipt,
12
12
  } from 'viem/actions'
13
- import { Transaction } from 'viem/tempo'
13
+ import { Abis, Addresses, Transaction } from 'viem/tempo'
14
14
 
15
15
  import { BadRequestError, VerificationFailedError } from '../../../Errors.js'
16
16
  import * as FeePayer from '../../internal/fee-payer.js'
@@ -401,6 +401,7 @@ export type ChannelTransactionOptions = {
401
401
 
402
402
  type ParsedPrecompileCredentialTransaction = {
403
403
  call: Transaction.TransactionTempo['calls'][number] & { data: Hex; to: Address }
404
+ prefixCalls: readonly Transaction.TransactionTempo['calls'][number][]
404
405
  transaction: ReturnType<(typeof Transaction)['deserialize']>
405
406
  }
406
407
 
@@ -418,11 +419,11 @@ function parsePrecompileCredentialTransaction(parameters: {
418
419
  serializedTransaction as Transaction.TransactionSerializedTempo,
419
420
  )
420
421
  const calls = transaction.calls
421
- if (calls.length !== 1)
422
+ if (calls.length !== 1 && calls.length !== 3)
422
423
  throw new VerificationFailedError({
423
- reason: `TIP-1034 ${label} transaction must contain exactly one call`,
424
+ reason: `TIP-1034 ${label} transaction must contain one management call, optionally preceded by an auto-swap`,
424
425
  })
425
- const call = calls[0]!
426
+ const call = calls[calls.length - 1]!
426
427
  if (!call.to || !isAddressEqual(call.to, escrowContract))
427
428
  throw new VerificationFailedError({
428
429
  reason: `TIP-1034 ${label} transaction targets the wrong address`,
@@ -431,7 +432,88 @@ function parsePrecompileCredentialTransaction(parameters: {
431
432
  throw new VerificationFailedError({
432
433
  reason: `TIP-1034 ${label} transaction is missing calldata`,
433
434
  })
434
- return { transaction, call: { ...call, data: call.data, to: call.to } }
435
+ return {
436
+ transaction,
437
+ call: { ...call, data: call.data, to: call.to },
438
+ prefixCalls: calls.slice(0, -1),
439
+ }
440
+ }
441
+
442
+ function validateAutoSwapPrefix(parameters: {
443
+ amountOut: bigint
444
+ currency: Address
445
+ label: 'open' | 'topUp'
446
+ prefixCalls: readonly Transaction.TransactionTempo['calls'][number][]
447
+ }) {
448
+ const { amountOut, currency, label, prefixCalls } = parameters
449
+ if (prefixCalls.length === 0) return
450
+
451
+ const fail = (reason: string): never => {
452
+ throw new VerificationFailedError({ reason: `TIP-1034 ${label} auto-swap ${reason}` })
453
+ }
454
+ if (prefixCalls.length !== 2) fail('must contain exactly approve and swap calls')
455
+
456
+ const approveCall = prefixCalls[0]!
457
+ const swapCall = prefixCalls[1]!
458
+ const approveTo = approveCall.to
459
+ const approveData = approveCall.data
460
+ const swapTo = swapCall.to
461
+ const swapData = swapCall.data
462
+ if (!approveTo || !approveData || !swapTo || !swapData)
463
+ fail('call is missing a target or calldata')
464
+ const checkedApproveTo = approveTo as Address
465
+ const checkedApproveData = approveData as Hex
466
+ const checkedSwapTo = swapTo as Address
467
+ const checkedSwapData = swapData as Hex
468
+ if ((approveCall.value ?? 0n) !== 0n || (swapCall.value ?? 0n) !== 0n)
469
+ fail('calls must not transfer native value')
470
+ if (!isAddressEqual(checkedSwapTo, Addresses.stablecoinDex)) fail('targets the wrong DEX')
471
+
472
+ const approve = (() => {
473
+ try {
474
+ return decodeFunctionData({ abi: Abis.tip20, data: checkedApproveData })
475
+ } catch {
476
+ return fail('approval calldata is invalid')
477
+ }
478
+ })()
479
+ const swap = (() => {
480
+ try {
481
+ return decodeFunctionData({ abi: Abis.stablecoinDex, data: checkedSwapData })
482
+ } catch {
483
+ return fail('swap calldata is invalid')
484
+ }
485
+ })()
486
+ if (approve.functionName !== 'approve' || swap.functionName !== 'swapExactAmountOut')
487
+ fail('must contain approve followed by swapExactAmountOut')
488
+
489
+ const [spender, approvedAmount] = approve.args as readonly [Address, bigint]
490
+ const [tokenIn, tokenOut, swapAmountOut, maxAmountIn] = swap.args as readonly [
491
+ Address,
492
+ Address,
493
+ bigint,
494
+ bigint,
495
+ ]
496
+ if (!isAddressEqual(checkedApproveTo, tokenIn)) fail('approval token does not match swap input')
497
+ if (!isAddressEqual(spender, Addresses.stablecoinDex)) fail('approval spender is not the DEX')
498
+ if (approvedAmount !== maxAmountIn) fail('approval amount does not match swap maximum input')
499
+ if (!isAddressEqual(tokenOut, currency)) fail('output token does not match channel currency')
500
+ if (swapAmountOut !== amountOut) fail('output amount does not match channel deposit')
501
+ if (isAddressEqual(tokenIn, tokenOut)) fail('input and output tokens must differ')
502
+
503
+ const canonicalApprove = encodeFunctionData({
504
+ abi: Abis.tip20,
505
+ functionName: 'approve',
506
+ args: [spender, approvedAmount],
507
+ })
508
+ const canonicalSwap = encodeFunctionData({
509
+ abi: Abis.stablecoinDex,
510
+ functionName: 'swapExactAmountOut',
511
+ args: [tokenIn, tokenOut, swapAmountOut, maxAmountIn],
512
+ })
513
+ if (checkedApproveData.toLowerCase() !== canonicalApprove.toLowerCase())
514
+ fail('approval calldata is not canonical')
515
+ if (checkedSwapData.toLowerCase() !== canonicalSwap.toLowerCase())
516
+ fail('swap calldata is not canonical')
435
517
  }
436
518
 
437
519
  async function simulateTempoTransaction(client: Client, request: unknown) {
@@ -796,7 +878,7 @@ export type BroadcastOpenTransactionParameters = {
796
878
  export async function broadcastOpenTransaction(
797
879
  parameters: BroadcastOpenTransactionParameters,
798
880
  ): Promise<BroadcastOpenTransactionResult> {
799
- const { transaction, call } = parsePrecompileCredentialTransaction({
881
+ const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({
800
882
  escrowContract: parameters.escrowContract,
801
883
  feePayer: parameters.feePayer,
802
884
  label: 'open',
@@ -812,6 +894,12 @@ export async function broadcastOpenTransaction(
812
894
  authorizedSigner: parameters.expectedAuthorizedSigner,
813
895
  },
814
896
  })
897
+ validateAutoSwapPrefix({
898
+ amountOut: open.deposit,
899
+ currency: parameters.expectedCurrency,
900
+ label: 'open',
901
+ prefixCalls,
902
+ })
815
903
  const descriptor = ChannelOps.descriptorFromOpen({
816
904
  chainId: parameters.chainId,
817
905
  escrow: parameters.escrowContract,
@@ -920,7 +1008,7 @@ export type BroadcastTopUpTransactionParameters = {
920
1008
  export async function broadcastTopUpTransaction(
921
1009
  parameters: BroadcastTopUpTransactionParameters,
922
1010
  ): Promise<BroadcastTopUpTransactionResult> {
923
- const { transaction, call } = parsePrecompileCredentialTransaction({
1011
+ const { transaction, call, prefixCalls } = parsePrecompileCredentialTransaction({
924
1012
  escrowContract: parameters.escrowContract,
925
1013
  feePayer: parameters.feePayer,
926
1014
  label: 'topUp',
@@ -933,6 +1021,12 @@ export async function broadcastTopUpTransaction(
933
1021
  additionalDeposit: parameters.additionalDeposit,
934
1022
  },
935
1023
  })
1024
+ validateAutoSwapPrefix({
1025
+ amountOut: parameters.additionalDeposit,
1026
+ currency: parameters.expectedCurrency,
1027
+ label: 'topUp',
1028
+ prefixCalls,
1029
+ })
936
1030
  const receipt = await sendCredentialTransaction({
937
1031
  challengeExpires: parameters.challengeExpires,
938
1032
  chainId: parameters.chainId,
@@ -27,6 +27,15 @@ const transaction = {
27
27
  } as const satisfies Channel.ExpiringNonceTransaction
28
28
 
29
29
  describe('precompile Channel.computeId', () => {
30
+ test('uses the payer when the descriptor delegates to the zero address', () => {
31
+ expect(
32
+ Channel.resolveAuthorizedSigner({
33
+ ...descriptor,
34
+ authorizedSigner: '0x0000000000000000000000000000000000000000',
35
+ }),
36
+ ).toBe(descriptor.payer)
37
+ })
38
+
30
39
  test('returns deterministic 32-byte hash for fixed inputs', () => {
31
40
  const id = Channel.computeId({ ...descriptor, chainId })
32
41
  expect(Channel.computeId({ ...descriptor, chainId })).toBe(id)
@@ -9,6 +9,11 @@ import type { ChannelDescriptor } from './Protocol.js'
9
9
  /** Re-export of the TIP-1034 channel descriptor shape. */
10
10
  export type { ChannelDescriptor } from './Protocol.js'
11
11
 
12
+ /** Resolves the descriptor's effective voucher signer; zero delegates to the payer. */
13
+ export function resolveAuthorizedSigner(descriptor: ChannelDescriptor): Address {
14
+ return BigInt(descriptor.authorizedSigner) === 0n ? descriptor.payer : descriptor.authorizedSigner
15
+ }
16
+
12
17
  /** Tempo transaction shape used to derive the TIP-1034 `expiringNonceHash`. */
13
18
  export type ExpiringNonceTransaction = (
14
19
  | z_TransactionSerializableTempo
@@ -544,10 +544,17 @@ describe('SessionSnapshotHints', () => {
544
544
  }
545
545
  })
546
546
 
547
- test('omits hints for missing, non-precompile, or finalized channels', async () => {
547
+ test('omits hints for missing, non-precompile, closing, or finalized channels', async () => {
548
548
  await expect(
549
549
  resolveSessionSnapshot({ amount: 1n, channelId, store: store(null) }),
550
550
  ).resolves.toBe(undefined)
551
+ await expect(
552
+ resolveSessionSnapshot({
553
+ amount: 1n,
554
+ channelId,
555
+ store: store(channel({ closeRequestedAt: 1n })),
556
+ }),
557
+ ).resolves.toBe(undefined)
551
558
  await expect(
552
559
  resolveSessionSnapshot({
553
560
  amount: 1n,
@@ -143,6 +143,7 @@ export async function resolveSessionSnapshot(
143
143
  const channel = await store.getChannel(ChannelStore.normalizeChannelId(channelId))
144
144
  if (!channel || !ChannelStore.isPrecompileState(channel)) return undefined
145
145
  if (channel.finalized) return undefined
146
+ if (channel.closeRequestedAt !== 0n) return undefined
146
147
  if (!channel.highestVoucher) return undefined
147
148
  if (channel.highestVoucher.cumulativeAmount !== channel.highestVoucherAmount) return undefined
148
149
  if (expected && !matchesSnapshotPaymentFields(channel, expected)) return undefined
@@ -151,8 +152,7 @@ export async function resolveSessionSnapshot(
151
152
  acceptedCumulative: channel.highestVoucherAmount.toString(),
152
153
  chainId: channel.chainId,
153
154
  channelId: channel.channelId,
154
- closeRequestedAt:
155
- channel.closeRequestedAt === 0n ? undefined : channel.closeRequestedAt.toString(),
155
+ closeRequestedAt: undefined,
156
156
  deposit: channel.deposit.toString(),
157
157
  descriptor: channel.descriptor,
158
158
  escrow: channel.escrowContract,
@@ -2421,12 +2421,15 @@ describe('precompile server session unit guardrails', () => {
2421
2421
  })
2422
2422
 
2423
2423
  describe('WebSocket parity', () => {
2424
- async function createManagedWsHarness(options: { maxDeposit?: bigint } = {}) {
2424
+ async function createManagedWsHarness(
2425
+ options: { challengeTtlMs?: number; maxDeposit?: bigint } = {},
2426
+ ) {
2425
2427
  const rawStore = Store.memory()
2428
+ let challengeRequests = 0
2426
2429
  let currentPayload: SessionCredentialPayload | undefined
2427
2430
  let voucherPosts = 0
2428
2431
  const maxDeposit = options.maxDeposit ?? 3n
2429
- const routeHandler = Mppx_server.create({
2432
+ const payment = Mppx_server.create({
2430
2433
  methods: [
2431
2434
  tempo_server.session({
2432
2435
  amount: '1',
@@ -2463,7 +2466,7 @@ describe('precompile server session unit guardrails', () => {
2463
2466
  ],
2464
2467
  realm: 'api.example.com',
2465
2468
  secretKey: 'test-secret-key-test-secret-key-32',
2466
- }).session({ amount: '1', decimals: 0, suggestedDeposit: maxDeposit.toString() })
2469
+ })
2467
2470
 
2468
2471
  const route = async (request: Request) => {
2469
2472
  currentPayload = undefined
@@ -2472,7 +2475,15 @@ describe('precompile server session unit guardrails', () => {
2472
2475
  currentPayload = Credential.fromRequest<SessionCredentialPayload>(request).payload
2473
2476
  if (currentPayload.action === 'voucher') voucherPosts++
2474
2477
  } catch {}
2475
- }
2478
+ } else challengeRequests++
2479
+ const routeHandler = payment.session({
2480
+ amount: '1',
2481
+ decimals: 0,
2482
+ suggestedDeposit: maxDeposit.toString(),
2483
+ ...(options.challengeTtlMs === undefined
2484
+ ? {}
2485
+ : { expires: new Date(Date.now() + options.challengeTtlMs) }),
2486
+ })
2476
2487
  return routeHandler(request)
2477
2488
  }
2478
2489
 
@@ -2510,6 +2521,9 @@ describe('precompile server session unit guardrails', () => {
2510
2521
  get port() {
2511
2522
  return port
2512
2523
  },
2524
+ get challengeRequests() {
2525
+ return challengeRequests
2526
+ },
2513
2527
  get voucherPosts() {
2514
2528
  return voucherPosts
2515
2529
  },
@@ -2520,6 +2534,54 @@ describe('precompile server session unit guardrails', () => {
2520
2534
  }
2521
2535
  }
2522
2536
 
2537
+ test('refreshes an expired challenge before sending a later websocket voucher', async () => {
2538
+ const harness = await createManagedWsHarness({ challengeTtlMs: 2_000, maxDeposit: 2n })
2539
+ harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
2540
+ void TempoWs.serve({
2541
+ socket,
2542
+ store: harness.rawStore,
2543
+ url: `${harness.server.url}/ws`,
2544
+ route: harness.route,
2545
+ generate: async function* (stream: TempoWs.SessionController) {
2546
+ await stream.charge()
2547
+ yield 'chunk-1'
2548
+ await new Promise((resolve) => setTimeout(resolve, 2_200))
2549
+ await stream.charge()
2550
+ yield 'chunk-2'
2551
+ },
2552
+ })
2553
+ })
2554
+
2555
+ try {
2556
+ const manager = precompileSessionManager({
2557
+ account: payer,
2558
+ client: createSigningClient(),
2559
+ decimals: 0,
2560
+ fetch: globalThis.fetch,
2561
+ maxDeposit: '2',
2562
+ webSocket: WebSocket as never,
2563
+ })
2564
+ const ws = await manager.ws(`ws://localhost:${harness.port}/ws`)
2565
+ const chunks: string[] = []
2566
+
2567
+ await new Promise<void>((resolve, reject) => {
2568
+ ws.addEventListener('message', (event) => {
2569
+ if (typeof event.data === 'string') chunks.push(event.data)
2570
+ })
2571
+ ws.addEventListener('close', () => resolve(), { once: true })
2572
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
2573
+ once: true,
2574
+ })
2575
+ })
2576
+
2577
+ expect(chunks).toEqual(['chunk-1', 'chunk-2'])
2578
+ expect(harness.challengeRequests).toBeGreaterThan(1)
2579
+ expect(harness.voucherPosts).toBe(1)
2580
+ } finally {
2581
+ harness.close()
2582
+ }
2583
+ })
2584
+
2523
2585
  test('open -> stream -> need-voucher -> resume -> close', async () => {
2524
2586
  const harness = await createManagedWsHarness({ maxDeposit: 3n })
2525
2587
  harness.wsServer.on('connection', (socket: import('ws').WebSocket) => {
@@ -220,6 +220,9 @@ export async function serve(options: serve.Options): Promise<void> {
220
220
  }
221
221
 
222
222
  const { receipt } = authorizationResult
223
+ if (payload.action === 'voucher' && runtime.streamContext) {
224
+ runtime.streamContext.challengeId = credential.challenge.id
225
+ }
223
226
  await send(socket, formatReceiptMessage(receipt))
224
227
 
225
228
  if (payload.action === 'close') {
@@ -1,4 +1,5 @@
1
- import { KeyAuthorization } from 'ox/tempo'
1
+ import { Rlp } from 'ox'
2
+ import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'
2
3
  import { privateKeyToAccount } from 'viem/accounts'
3
4
  import { describe, expect, test } from 'vp/test'
4
5
 
@@ -69,6 +70,25 @@ async function createPayload(request = parseRequest()) {
69
70
  } as const
70
71
  }
71
72
 
73
+ function serializeCompositeSignature(
74
+ type: 'keychain' | 'multisig',
75
+ inner: SignatureEnvelope.Primitive,
76
+ ) {
77
+ return SignatureEnvelope.serialize(
78
+ type === 'keychain'
79
+ ? {
80
+ inner,
81
+ type,
82
+ userAddress: rootAccount.address,
83
+ }
84
+ : {
85
+ account: rootAccount.address,
86
+ signatures: [inner],
87
+ type,
88
+ },
89
+ )
90
+ }
91
+
72
92
  describe('tempo subscription key authorization', () => {
73
93
  test('signs and verifies a scoped key authorization', async () => {
74
94
  const request = parseRequest()
@@ -87,6 +107,55 @@ describe('tempo subscription key authorization', () => {
87
107
  )
88
108
  })
89
109
 
110
+ test.each(['keychain', 'multisig'] as const)(
111
+ 'rejects %s authorization signatures',
112
+ async (type) => {
113
+ const request = parseRequest()
114
+
115
+ await expect(
116
+ signSubscriptionKeyAuthorization({
117
+ accessKey,
118
+ account: {
119
+ async sign({ hash }) {
120
+ const inner = SignatureEnvelope.from(await rootAccount.sign({ hash }))
121
+ if (inner.type === 'keychain' || inner.type === 'multisig') {
122
+ throw new Error('expected primitive signature')
123
+ }
124
+ return serializeCompositeSignature(type, inner)
125
+ },
126
+ },
127
+ chainId: 4217,
128
+ request,
129
+ }),
130
+ ).rejects.toThrow('keyAuthorization must use a primitive signature')
131
+ },
132
+ )
133
+
134
+ test.each(['keychain', 'multisig'] as const)(
135
+ 'rejects serialized %s authorization signatures',
136
+ async (type) => {
137
+ const request = parseRequest()
138
+ const payload = await createPayload(request)
139
+ const authorization = KeyAuthorization.deserialize(payload.signature)
140
+ const [authorizationTuple] = KeyAuthorization.toTuple(authorization)
141
+
142
+ expect(() =>
143
+ verifySubscriptionKeyAuthorization({
144
+ accessKey,
145
+ chainId: 4217,
146
+ payload: {
147
+ ...payload,
148
+ signature: Rlp.fromHex([
149
+ authorizationTuple,
150
+ serializeCompositeSignature(type, authorization.signature!),
151
+ ]),
152
+ },
153
+ request,
154
+ }),
155
+ ).toThrow('keyAuthorization must use a primitive signature')
156
+ },
157
+ )
158
+
90
159
  test('builds wallet allowed calls from the subscription request', () => {
91
160
  const request = parseRequest()
92
161
 
@@ -168,8 +168,14 @@ export async function signSubscriptionKeyAuthorization(parameters: {
168
168
  const signature = await account.sign({
169
169
  hash: KeyAuthorization.getSignPayload(authorization),
170
170
  })
171
+ const signatureEnvelope = SignatureEnvelope.from(signature)
172
+ if (signatureEnvelope.type === 'keychain' || signatureEnvelope.type === 'multisig') {
173
+ throw new VerificationFailedError({
174
+ reason: 'keyAuthorization must use a primitive signature',
175
+ })
176
+ }
171
177
  return KeyAuthorization.from(authorization, {
172
- signature: SignatureEnvelope.from(signature),
178
+ signature,
173
179
  })
174
180
  }
175
181
 
@@ -238,14 +244,19 @@ function createUnsignedAuthorization(parameters: {
238
244
  function deserializeAuthorization(signature: `0x${string}`) {
239
245
  try {
240
246
  return KeyAuthorization.deserialize(signature)
241
- } catch {
247
+ } catch (error) {
248
+ if (error instanceof KeyAuthorization.InvalidSignatureTypeError) {
249
+ throw new VerificationFailedError({
250
+ reason: 'keyAuthorization must use a primitive signature',
251
+ })
252
+ }
242
253
  throw new VerificationFailedError({ reason: 'invalid keyAuthorization payload' })
243
254
  }
244
255
  }
245
256
 
246
257
  function getPrimitiveSignature(authorization: Authorization) {
247
258
  const signature = authorization.signature
248
- if (!signature || signature.type === 'keychain') {
259
+ if (!signature) {
249
260
  throw new VerificationFailedError({
250
261
  reason: 'keyAuthorization must use a primitive signature',
251
262
  })
@@ -2,7 +2,7 @@ import { createClient, custom, defineChain, type Hex } from 'viem'
2
2
  import { privateKeyToAccount } from 'viem/accounts'
3
3
  import { signTransaction } from 'viem/actions'
4
4
  import { Account as TempoAccount, Transaction } from 'viem/tempo'
5
- import { tempoLocalnet } from 'viem/tempo/chains'
5
+ import { tempo as tempoMainnetChain, tempoLocalnet, tempoModerato } from 'viem/tempo/chains'
6
6
  import { afterEach, describe, expect, test, vi } from 'vp/test'
7
7
 
8
8
  import * as Client from './Client.js'
@@ -48,6 +48,23 @@ describe('getResolver', () => {
48
48
  expect(client.chain?.name).toBe('test')
49
49
  })
50
50
 
51
+ test('behavior: resolves the correct named Tempo chain even when the default chain differs', async () => {
52
+ const getClient = Client.getResolver({
53
+ // Default chain is mainnet, but the resolved chain ID below is testnet.
54
+ chain: tempoMainnetChain as never,
55
+ rpcUrl: {
56
+ [tempoMainnetChain.id]: 'https://rpc.example.com',
57
+ [tempoModerato.id]: 'https://rpc2.example.com',
58
+ },
59
+ })
60
+
61
+ const client = await getClient({ chainId: tempoModerato.id })
62
+
63
+ expect(client.chain?.id).toBe(tempoModerato.id)
64
+ expect(client.chain?.name).toBe(tempoModerato.name)
65
+ expect(client.chain?.name).not.toBe(tempoMainnetChain.name)
66
+ })
67
+
51
68
  test('error: throws when no rpcUrl provided', () => {
52
69
  const getClient = Client.getResolver({})
53
70
 
@@ -1,8 +1,14 @@
1
1
  import { type Chain, type Client, createClient, createTransport, custom, http } from 'viem'
2
2
  import { withFeePayer } from 'viem/tempo'
3
+ import { tempo as tempoMainnetChain, tempoModerato } from 'viem/tempo/chains'
3
4
 
4
5
  import type { MaybePromise } from '../internal/types.js'
5
6
 
7
+ const knownTempoChains: Record<number, Chain> = {
8
+ [tempoMainnetChain.id]: tempoMainnetChain,
9
+ [tempoModerato.id]: tempoModerato,
10
+ }
11
+
6
12
  export function getResolver(
7
13
  parameters: getResolver.Parameters & {
8
14
  /** Default chain to use if not provided. */
@@ -73,7 +79,7 @@ export function getResolver(
73
79
  if (!url) throw new Error(`No \`rpcUrl\` configured for \`chainId\` (${resolvedChainId}).`)
74
80
  const transport = feePayerUrl ? withFeePayer(http(url), http(feePayerUrl)) : http(url)
75
81
  return createClient({
76
- chain: { ...chain, id: resolvedChainId } as never,
82
+ chain: (knownTempoChains[resolvedChainId] ?? { ...chain, id: resolvedChainId }) as never,
77
83
  transport,
78
84
  })
79
85
  }