mppx 0.6.12 → 0.6.14

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 (65) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  3. package/dist/cli/plugins/tempo.js +24 -1
  4. package/dist/cli/plugins/tempo.js.map +1 -1
  5. package/dist/middlewares/express.d.ts.map +1 -1
  6. package/dist/middlewares/express.js +22 -0
  7. package/dist/middlewares/express.js.map +1 -1
  8. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  9. package/dist/tempo/client/SessionManager.js +26 -1
  10. package/dist/tempo/client/SessionManager.js.map +1 -1
  11. package/dist/tempo/internal/fee-payer.d.ts +11 -1
  12. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  13. package/dist/tempo/internal/fee-payer.js +71 -6
  14. package/dist/tempo/internal/fee-payer.js.map +1 -1
  15. package/dist/tempo/server/Charge.d.ts.map +1 -1
  16. package/dist/tempo/server/Charge.js +53 -10
  17. package/dist/tempo/server/Charge.js.map +1 -1
  18. package/dist/tempo/server/Session.d.ts.map +1 -1
  19. package/dist/tempo/server/Session.js +80 -29
  20. package/dist/tempo/server/Session.js.map +1 -1
  21. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  22. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  25. package/dist/tempo/server/internal/request-body.d.ts +1 -1
  26. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  27. package/dist/tempo/server/internal/request-body.js +3 -0
  28. package/dist/tempo/server/internal/request-body.js.map +1 -1
  29. package/dist/tempo/server/internal/transport.d.ts +7 -0
  30. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  31. package/dist/tempo/server/internal/transport.js +16 -0
  32. package/dist/tempo/server/internal/transport.js.map +1 -1
  33. package/dist/tempo/session/Chain.d.ts +1 -0
  34. package/dist/tempo/session/Chain.d.ts.map +1 -1
  35. package/dist/tempo/session/Chain.js +28 -11
  36. package/dist/tempo/session/Chain.js.map +1 -1
  37. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  38. package/dist/tempo/session/ChannelStore.js +6 -0
  39. package/dist/tempo/session/ChannelStore.js.map +1 -1
  40. package/dist/tempo/session/Sse.d.ts +1 -0
  41. package/dist/tempo/session/Sse.d.ts.map +1 -1
  42. package/dist/tempo/session/Sse.js +34 -12
  43. package/dist/tempo/session/Sse.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/plugins/tempo.ts +28 -1
  46. package/src/middlewares/express.test.ts +27 -0
  47. package/src/middlewares/express.ts +24 -0
  48. package/src/tempo/client/SessionManager.ts +26 -1
  49. package/src/tempo/internal/fee-payer.test.ts +139 -0
  50. package/src/tempo/internal/fee-payer.ts +85 -6
  51. package/src/tempo/server/Charge.test.ts +119 -0
  52. package/src/tempo/server/Charge.ts +70 -10
  53. package/src/tempo/server/Session.test.ts +327 -0
  54. package/src/tempo/server/Session.ts +91 -39
  55. package/src/tempo/server/internal/html.gen.ts +1 -1
  56. package/src/tempo/server/internal/request-body.test.ts +26 -0
  57. package/src/tempo/server/internal/request-body.ts +4 -1
  58. package/src/tempo/server/internal/transport.test.ts +28 -2
  59. package/src/tempo/server/internal/transport.ts +23 -0
  60. package/src/tempo/session/Chain.test.ts +140 -1
  61. package/src/tempo/session/Chain.ts +34 -10
  62. package/src/tempo/session/ChannelStore.test.ts +21 -0
  63. package/src/tempo/session/ChannelStore.ts +6 -0
  64. package/src/tempo/session/Sse.test.ts +52 -0
  65. package/src/tempo/session/Sse.ts +22 -2
@@ -138,9 +138,10 @@ export function charge<const parameters extends charge.Parameters>(
138
138
  throw new Error(`Client not configured with chainId ${chainId}.`)
139
139
 
140
140
  const resolvedFeePayer = (() => {
141
+ if (request.feePayer === false) return credential ? false : undefined
141
142
  const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer
142
- const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl)
143
- if (credential) return account
143
+ const requested = account ?? feePayer ?? feePayerUrl
144
+ if (credential) return account ?? (feePayerUrl ? true : undefined)
144
145
  if (requested) return true
145
146
  return undefined
146
147
  })()
@@ -167,12 +168,17 @@ export function charge<const parameters extends charge.Parameters>(
167
168
  const client = await getClient({ chainId })
168
169
 
169
170
  const { amount, methodDetails } = resolvedRequest
171
+ const requestAllowsFeePayer =
172
+ request.feePayer !== false &&
173
+ (request.feePayer === undefined ||
174
+ request.feePayer === true ||
175
+ typeof request.feePayer === 'object')
170
176
  const feePayerAccount =
171
- typeof request.feePayer === 'object'
172
- ? request.feePayer
173
- : methodDetails?.feePayer === true
174
- ? feePayer
175
- : undefined
177
+ methodDetails?.feePayer === true && requestAllowsFeePayer
178
+ ? typeof request.feePayer === 'object'
179
+ ? request.feePayer
180
+ : feePayer
181
+ : undefined
176
182
  const expires = challenge.expires
177
183
  const supportedModes = methodDetails?.supportedModes as
178
184
  | readonly Methods.ChargeMode[]
@@ -292,6 +298,7 @@ export function charge<const parameters extends charge.Parameters>(
292
298
  }
293
299
 
294
300
  let releaseReservation = true
301
+ let sponsoredSenderReservation: { chainId: number; sender: `0x${string}` } | undefined
295
302
 
296
303
  try {
297
304
  if (!FeePayer.isTempoTransaction(serializedTransaction))
@@ -309,7 +316,10 @@ export function charge<const parameters extends charge.Parameters>(
309
316
  to?: `0x${string}` | undefined
310
317
  }[]
311
318
  const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
312
- const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
319
+ const isFeePayerTx =
320
+ methodDetails?.feePayer === true &&
321
+ requestAllowsFeePayer &&
322
+ !!(feePayerAccount || feePayerUrl)
313
323
  const matchedCalls = assertTransferCalls(calls, {
314
324
  currency,
315
325
  exactCount: isFeePayerTx,
@@ -321,8 +331,28 @@ export function charge<const parameters extends charge.Parameters>(
321
331
  realm: challenge.realm,
322
332
  })
323
333
 
324
- if (isFeePayerTx)
325
- FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
334
+ if (isFeePayerTx) {
335
+ const reservationChainId = chainId ?? client.chain!.id
336
+ if (
337
+ !(await markSponsoredSenderInFlight(store, {
338
+ chainId: reservationChainId,
339
+ sender: transaction.from as `0x${string}`,
340
+ }))
341
+ ) {
342
+ throw new VerificationFailedError({
343
+ reason: 'Sponsored transaction from this sender is already in flight',
344
+ })
345
+ }
346
+ sponsoredSenderReservation = {
347
+ chainId: reservationChainId,
348
+ sender: transaction.from as `0x${string}`,
349
+ }
350
+ FeePayer.validateCalls(
351
+ transaction.calls,
352
+ { amount, currency, recipient },
353
+ { currency, expectedTransfers: transfers },
354
+ )
355
+ }
326
356
 
327
357
  const expectedFeeToken = defaults.currency[chainId as keyof typeof defaults.currency]
328
358
  const resolvedFeeToken = transaction.feeToken ?? expectedFeeToken
@@ -413,6 +443,9 @@ export function charge<const parameters extends charge.Parameters>(
413
443
  } catch (error) {
414
444
  if (releaseReservation) await releaseHashUse(store, hash)
415
445
  throw error
446
+ } finally {
447
+ if (sponsoredSenderReservation)
448
+ await releaseSponsoredSenderInFlight(store, sponsoredSenderReservation)
416
449
  }
417
450
  }
418
451
 
@@ -698,6 +731,14 @@ function getProofStoreKey(challengeId: string): `mppx:charge:${string}` {
698
731
  return `mppx:charge:proof:${challengeId}`
699
732
  }
700
733
 
734
+ /** @internal */
735
+ function getSponsoredSenderStoreKey(parameters: {
736
+ chainId: number
737
+ sender: `0x${string}`
738
+ }): `mppx:charge:${string}` {
739
+ return `mppx:charge:sponsor:${parameters.chainId}:${parameters.sender.toLowerCase()}`
740
+ }
741
+
701
742
  async function markHashUsed(
702
743
  store: Store.AtomicStore<charge.StoreItemMap>,
703
744
  hash: `0x${string}`,
@@ -716,6 +757,25 @@ async function releaseHashUse(
716
757
  await store.delete(getHashStoreKey(hash))
717
758
  }
718
759
 
760
+ /** @internal */
761
+ async function markSponsoredSenderInFlight(
762
+ store: Store.AtomicStore<charge.StoreItemMap>,
763
+ parameters: { chainId: number; sender: `0x${string}` },
764
+ ): Promise<boolean> {
765
+ return store.update(getSponsoredSenderStoreKey(parameters), (current) => {
766
+ if (current !== null) return { op: 'noop', result: false }
767
+ return { op: 'set', value: Date.now(), result: true }
768
+ })
769
+ }
770
+
771
+ /** @internal */
772
+ async function releaseSponsoredSenderInFlight(
773
+ store: Store.AtomicStore<charge.StoreItemMap>,
774
+ parameters: { chainId: number; sender: `0x${string}` },
775
+ ): Promise<void> {
776
+ await store.delete(getSponsoredSenderStoreKey(parameters))
777
+ }
778
+
719
779
  /** @internal */
720
780
  async function markProofUsed(
721
781
  store: Store.AtomicStore<charge.StoreItemMap>,
@@ -11,6 +11,7 @@ import { Base64, Secp256k1 } from 'ox'
11
11
  import {
12
12
  type Address,
13
13
  createClient,
14
+ custom,
14
15
  type Hex,
15
16
  parseSignature,
16
17
  serializeCompactSignature,
@@ -258,6 +259,54 @@ describe.runIf(isLocalnet)('session', () => {
258
259
  ).rejects.toThrow('invalid voucher signature')
259
260
  })
260
261
 
262
+ test('rejects sponsored open with invalid voucher before broadcasting', async () => {
263
+ const rpcMethods: string[] = []
264
+ const interceptingClient = createClient({
265
+ account: recipientAccount,
266
+ chain,
267
+ transport: custom({
268
+ async request(args: any) {
269
+ rpcMethods.push(args.method)
270
+ return client.transport.request(args)
271
+ },
272
+ }),
273
+ })
274
+
275
+ const salt = nextSalt()
276
+ const { channelId, serializedTransaction } = await signOpenChannel({
277
+ escrow: escrowContract,
278
+ payer,
279
+ payee: recipient,
280
+ token: currency,
281
+ deposit: 10000000n,
282
+ salt,
283
+ feePayer: true,
284
+ })
285
+ const server = createServer({
286
+ feePayer: recipientAccount,
287
+ getClient: () => interceptingClient,
288
+ })
289
+
290
+ await expect(
291
+ server.verify({
292
+ credential: {
293
+ challenge: makeChallenge({ channelId }),
294
+ payload: {
295
+ action: 'open' as const,
296
+ type: 'transaction' as const,
297
+ channelId,
298
+ transaction: serializedTransaction,
299
+ cumulativeAmount: '1000000',
300
+ signature: `0x${'ab'.repeat(65)}` as Hex,
301
+ },
302
+ },
303
+ request: makeRequest({ feePayer: true }),
304
+ }),
305
+ ).rejects.toThrow('invalid voucher signature')
306
+
307
+ expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
308
+ })
309
+
261
310
  test('reopen existing channel with higher voucher updates state', async () => {
262
311
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
263
312
  const server = createServer()
@@ -2083,6 +2132,10 @@ describe.runIf(isLocalnet)('session', () => {
2083
2132
  ).rejects.toThrow(
2084
2133
  `Cannot close channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`,
2085
2134
  )
2135
+
2136
+ const persisted = await store.getChannel(channelId)
2137
+ expect(persisted?.closeRequestedAt).toBe(0n)
2138
+ expect(persisted?.finalized).toBe(false)
2086
2139
  })
2087
2140
 
2088
2141
  test('sessionManager.close surfaces problem details from HTTP close failures', async () => {
@@ -2646,6 +2699,77 @@ describe.runIf(isLocalnet)('session', () => {
2646
2699
  expect(channel?.highestVoucherAmount).toBe(5000000n)
2647
2700
  expect(channel?.spent).toBe(0n)
2648
2701
  })
2702
+
2703
+ test('close marks the channel pending before on-chain close so concurrent charges fail', async () => {
2704
+ const baseStore = Store.memory()
2705
+ let pendingChargeError: unknown
2706
+ let probedPendingClose = false
2707
+
2708
+ const probingStore = Store.from<Store.AtomicStore>({
2709
+ get: (key) => baseStore.get(key),
2710
+ put: (key, value) => baseStore.put(key, value),
2711
+ delete: (key) => baseStore.delete(key),
2712
+ async update(key, fn) {
2713
+ const result = await baseStore.update(key, fn)
2714
+ const current = await baseStore.get(key)
2715
+ if (
2716
+ current &&
2717
+ typeof current === 'object' &&
2718
+ 'closeRequestedAt' in current &&
2719
+ (current as ChannelStore.State).closeRequestedAt !== 0n &&
2720
+ !(current as ChannelStore.State).finalized &&
2721
+ !probedPendingClose
2722
+ ) {
2723
+ probedPendingClose = true
2724
+ try {
2725
+ await charge(ChannelStore.fromStore(probingStore), channelId, 1000000n)
2726
+ } catch (error) {
2727
+ pendingChargeError = error
2728
+ }
2729
+ }
2730
+ return result
2731
+ },
2732
+ })
2733
+
2734
+ const probedStore = ChannelStore.fromStore(baseStore)
2735
+ const server = createServerWithStore(probingStore)
2736
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2737
+
2738
+ await server.verify({
2739
+ credential: {
2740
+ challenge: makeChallenge({ id: 'open-before-pending-close', channelId }),
2741
+ payload: {
2742
+ action: 'open' as const,
2743
+ type: 'transaction' as const,
2744
+ channelId,
2745
+ transaction: serializedTransaction,
2746
+ cumulativeAmount: '3000000',
2747
+ signature: await signTestVoucher(channelId, 3000000n),
2748
+ },
2749
+ },
2750
+ request: makeRequest(),
2751
+ })
2752
+
2753
+ await charge(probedStore, channelId, 1000000n)
2754
+
2755
+ const receipt = await server.verify({
2756
+ credential: {
2757
+ challenge: makeChallenge({ id: 'pending-close', channelId }),
2758
+ payload: {
2759
+ action: 'close' as const,
2760
+ channelId,
2761
+ cumulativeAmount: '3000000',
2762
+ signature: await signTestVoucher(channelId, 3000000n),
2763
+ },
2764
+ },
2765
+ request: makeRequest(),
2766
+ })
2767
+
2768
+ expect(receipt.status).toBe('success')
2769
+ expect(probedPendingClose).toBe(true)
2770
+ expect(pendingChargeError).toBeInstanceOf(ChannelClosedError)
2771
+ expect((pendingChargeError as Error).message).toContain('pending close request')
2772
+ })
2649
2773
  })
2650
2774
 
2651
2775
  describe('fault tolerance', () => {
@@ -3220,6 +3344,47 @@ describe.runIf(isLocalnet)('session', () => {
3220
3344
  expect(persisted?.finalized).toBe(true)
3221
3345
  })
3222
3346
 
3347
+ test('sessionManager rejects receipts that exceed the locally signed voucher', async () => {
3348
+ const handler = createHandler()
3349
+ const route = handler.session({
3350
+ amount: '1',
3351
+ decimals: 6,
3352
+ unitType: 'token',
3353
+ })
3354
+
3355
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3356
+ const request = new Request(input, init)
3357
+ const result = await route(request)
3358
+ if (result.status === 402) return result.challenge
3359
+
3360
+ const response = result.withReceipt(new Response('ok'))
3361
+ const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
3362
+ return new Response(response.body, {
3363
+ status: response.status,
3364
+ statusText: response.statusText,
3365
+ headers: {
3366
+ 'Payment-Receipt': serializeSessionReceipt({
3367
+ ...receipt,
3368
+ acceptedCumulative: '3000000',
3369
+ spent: '3000000',
3370
+ }),
3371
+ },
3372
+ })
3373
+ }
3374
+
3375
+ const manager = sessionManager({
3376
+ account: payer,
3377
+ client,
3378
+ escrowContract,
3379
+ fetch,
3380
+ maxDeposit: '3',
3381
+ })
3382
+
3383
+ await expect(manager.fetch('https://api.example.com/resource')).rejects.toThrow(
3384
+ 'receipt accepted cumulative exceeds local voucher state',
3385
+ )
3386
+ })
3387
+
3223
3388
  test('does not return Payment-Receipt on verification errors', async () => {
3224
3389
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3225
3390
  const handler = createHandler()
@@ -3469,6 +3634,79 @@ describe.runIf(isLocalnet)('session', () => {
3469
3634
  expect(replay.headers.get('Payment-Receipt')).toBeNull()
3470
3635
  })
3471
3636
 
3637
+ test('default HTTP bodyless POST query flow charges instead of treating voucher as management', async () => {
3638
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3639
+ const signature = await signTestVoucher(channelId, 1000000n)
3640
+ const handler = createHandler()
3641
+ const route = handler.session({
3642
+ amount: '1',
3643
+ decimals: 6,
3644
+ unitType: 'token',
3645
+ })
3646
+
3647
+ const first = await route(
3648
+ new Request('https://api.example.com/search?q=paid', {
3649
+ method: 'POST',
3650
+ }),
3651
+ )
3652
+ expect(first.status).toBe(402)
3653
+ if (first.status !== 402) throw new Error('expected challenge')
3654
+
3655
+ const open = await route(
3656
+ new Request('https://api.example.com/search?q=paid', {
3657
+ method: 'POST',
3658
+ headers: {
3659
+ Authorization: Credential.serialize({
3660
+ challenge: Challenge.fromResponse(first.challenge),
3661
+ payload: {
3662
+ action: 'open',
3663
+ type: 'transaction',
3664
+ channelId,
3665
+ transaction: serializedTransaction,
3666
+ cumulativeAmount: '1000000',
3667
+ signature,
3668
+ },
3669
+ }),
3670
+ },
3671
+ }),
3672
+ )
3673
+ expect(open.status).toBe(200)
3674
+ if (open.status !== 200) throw new Error('expected paid response')
3675
+ const paid = open.withReceipt(new Response('query-result'))
3676
+ expect(await paid.text()).toBe('query-result')
3677
+ const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
3678
+ expect(receipt.spent).toBe('1000000')
3679
+ expect(receipt.units).toBe(1)
3680
+
3681
+ const replayChallenge = await route(
3682
+ new Request('https://api.example.com/search?q=paid', {
3683
+ method: 'POST',
3684
+ }),
3685
+ )
3686
+ expect(replayChallenge.status).toBe(402)
3687
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3688
+
3689
+ const replay = await route(
3690
+ new Request('https://api.example.com/search?q=paid', {
3691
+ method: 'POST',
3692
+ headers: {
3693
+ Authorization: Credential.serialize({
3694
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3695
+ payload: {
3696
+ action: 'voucher',
3697
+ channelId,
3698
+ cumulativeAmount: '1000000',
3699
+ signature,
3700
+ },
3701
+ }),
3702
+ },
3703
+ }),
3704
+ )
3705
+ expect(replay.status).toBe(402)
3706
+ if (replay.status !== 402) throw new Error('expected challenge')
3707
+ expect(replay.challenge.headers.get('Payment-Receipt')).toBeNull()
3708
+ })
3709
+
3472
3710
  test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
3473
3711
  const handler = createHandler()
3474
3712
  const route = handler.session({
@@ -4245,6 +4483,89 @@ describe.runIf(isLocalnet)('session', () => {
4245
4483
  expect(persisted?.finalized).toBe(true)
4246
4484
  })
4247
4485
 
4486
+ test('plain-response SSE fallback precharges before running concurrent paid handlers', async () => {
4487
+ const backingStore = Store.memory()
4488
+ const route = Mppx_server.create({
4489
+ methods: [
4490
+ tempo_server.session({
4491
+ store: backingStore,
4492
+ getClient: () => client,
4493
+ account: recipientAccount,
4494
+ currency,
4495
+ escrowContract,
4496
+ chainId: chain.id,
4497
+ sse: true,
4498
+ }),
4499
+ ],
4500
+ realm: 'api.example.com',
4501
+ secretKey: 'secret',
4502
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
4503
+
4504
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
4505
+
4506
+ const openChallenge = await route(new Request('https://api.example.com/fallback'))
4507
+ expect(openChallenge.status).toBe(402)
4508
+ if (openChallenge.status !== 402) throw new Error('expected challenge')
4509
+
4510
+ const openResult = await route(
4511
+ new Request('https://api.example.com/fallback', {
4512
+ method: 'POST',
4513
+ headers: {
4514
+ Authorization: Credential.serialize({
4515
+ challenge: Challenge.fromResponse(openChallenge.challenge),
4516
+ payload: {
4517
+ action: 'open',
4518
+ type: 'transaction',
4519
+ channelId,
4520
+ transaction: serializedTransaction,
4521
+ cumulativeAmount: '1000000',
4522
+ signature: await signTestVoucher(channelId, 1000000n),
4523
+ },
4524
+ }),
4525
+ },
4526
+ }),
4527
+ )
4528
+ expect(openResult.status).toBe(200)
4529
+ if (openResult.status !== 200) throw new Error('expected open response')
4530
+ expect(openResult.withReceipt().status).toBe(204)
4531
+
4532
+ const replayChallenge = await route(new Request('https://api.example.com/fallback'))
4533
+ expect(replayChallenge.status).toBe(402)
4534
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
4535
+
4536
+ const authorization = Credential.serialize({
4537
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
4538
+ payload: {
4539
+ action: 'voucher',
4540
+ channelId,
4541
+ cumulativeAmount: '1000000',
4542
+ signature: await signTestVoucher(channelId, 1000000n),
4543
+ },
4544
+ })
4545
+
4546
+ let handlerRuns = 0
4547
+ const serve = async () => {
4548
+ const result = await route(
4549
+ new Request('https://api.example.com/fallback', {
4550
+ headers: { Authorization: authorization },
4551
+ }),
4552
+ )
4553
+ if (result.status === 402) return result.challenge
4554
+ handlerRuns++
4555
+ return result.withReceipt(new Response('paid-fallback'))
4556
+ }
4557
+
4558
+ const [first, second] = await Promise.all([serve(), serve()])
4559
+ const statuses = [first.status, second.status].sort()
4560
+
4561
+ expect(statuses).toEqual([200, 402])
4562
+ expect(handlerRuns).toBe(1)
4563
+
4564
+ const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId)
4565
+ expect(persisted?.spent).toBe(1000000n)
4566
+ expect(persisted?.units).toBe(1)
4567
+ })
4568
+
4248
4569
  test('handles repeated exhaustion/resume cycles within one stream', async () => {
4249
4570
  const backingStore = Store.memory()
4250
4571
  const routeHandler = Mppx_server.create({
@@ -5437,6 +5758,12 @@ describe('session request and verify guardrails', () => {
5437
5758
  request: makeRequest({ feePayer: false }),
5438
5759
  } as never)
5439
5760
  expect(normalized.feePayer).toBeUndefined()
5761
+
5762
+ const verificationRequest = await server.request!({
5763
+ credential: { challenge: {}, payload: {} } as never,
5764
+ request: makeRequest({ feePayer: false }),
5765
+ } as never)
5766
+ expect(verificationRequest.feePayer).toBe(false)
5440
5767
  })
5441
5768
 
5442
5769
  test('request leaves escrowContract undefined when chain has no configured default', async () => {