mppx 0.6.29 → 0.6.31

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.
@@ -486,7 +486,7 @@ describe.runIf(isLocalnet)('session', () => {
486
486
  },
487
487
  request: makeRequest(),
488
488
  }),
489
- ).rejects.toThrow('voucher amount is below settled on-chain amount')
489
+ ).rejects.toThrow('voucher amount is at or below settled on-chain amount')
490
490
  })
491
491
 
492
492
  test('zero signer fallback uses payer', async () => {
@@ -698,27 +698,128 @@ describe.runIf(isLocalnet)('session', () => {
698
698
  ).rejects.toThrow(InvalidSignatureError)
699
699
  })
700
700
 
701
- test('rejects non-increasing voucher replay', async () => {
701
+ test('accepts lower voucher replay idempotently', async () => {
702
702
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
703
703
  const server = createServer()
704
704
  await openServerChannel(server, channelId, serializedTransaction)
705
705
 
706
+ const before = await store.getChannel(channelId)
707
+
708
+ // A non-advancing voucher (below highest, above settled) is accepted
709
+ // idempotently with the current highest amount, per the session spec.
710
+ const receipt = (await server.verify({
711
+ credential: {
712
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
713
+ payload: {
714
+ action: 'voucher' as const,
715
+ channelId,
716
+ cumulativeAmount: '500000',
717
+ signature: await signTestVoucher(channelId, 500000n),
718
+ },
719
+ },
720
+ request: makeRequest(),
721
+ })) as SessionReceipt
722
+
723
+ expect(receipt.status).toBe('success')
724
+ expect(receipt.acceptedCumulative).toBe('1000000')
725
+
726
+ // Channel state is unchanged - no advance, no additional charge.
727
+ const after = await store.getChannel(channelId)
728
+ expect(after).toEqual(before)
729
+ })
730
+
731
+ test('accepts lower replay after a higher voucher has advanced the channel', async () => {
732
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
733
+ const server = createServer()
734
+ await openServerChannel(server, channelId, serializedTransaction)
735
+
736
+ // Advance the channel from the opening 1000000 to 5000000.
737
+ await server.verify({
738
+ credential: {
739
+ challenge: makeChallenge({ id: 'challenge-advance', channelId }),
740
+ payload: {
741
+ action: 'voucher' as const,
742
+ channelId,
743
+ cumulativeAmount: '5000000',
744
+ signature: await signTestVoucher(channelId, 5000000n),
745
+ },
746
+ },
747
+ request: makeRequest(),
748
+ })
749
+
750
+ const before = await store.getChannel(channelId)
751
+
752
+ // A lower (but above-settled) voucher returns the current highest
753
+ // without rewinding state.
754
+ const receipt = (await server.verify({
755
+ credential: {
756
+ challenge: makeChallenge({ id: 'challenge-lower', channelId }),
757
+ payload: {
758
+ action: 'voucher' as const,
759
+ channelId,
760
+ cumulativeAmount: '3000000',
761
+ signature: await signTestVoucher(channelId, 3000000n),
762
+ },
763
+ },
764
+ request: makeRequest(),
765
+ })) as SessionReceipt
766
+
767
+ expect(receipt.status).toBe('success')
768
+ expect(receipt.acceptedCumulative).toBe('5000000')
769
+
770
+ const after = await store.getChannel(channelId)
771
+ expect(after).toEqual(before)
772
+ })
773
+
774
+ test('accepts lower voucher replay even when below minVoucherDelta', async () => {
775
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
776
+ // Non-advancing replay must short-circuit before the delta check.
777
+ const server = createServer({ minVoucherDelta: '2' })
778
+ await openServerChannel(server, channelId, serializedTransaction)
779
+
780
+ const before = await store.getChannel(channelId)
781
+
782
+ const receipt = (await server.verify({
783
+ credential: {
784
+ challenge: makeChallenge({ id: 'challenge-lower-delta', channelId }),
785
+ payload: {
786
+ action: 'voucher' as const,
787
+ channelId,
788
+ cumulativeAmount: '500000',
789
+ signature: await signTestVoucher(channelId, 500000n),
790
+ },
791
+ },
792
+ request: makeRequest(),
793
+ })) as SessionReceipt
794
+
795
+ expect(receipt.status).toBe('success')
796
+ expect(receipt.acceptedCumulative).toBe('1000000')
797
+
798
+ const after = await store.getChannel(channelId)
799
+ expect(after).toEqual(before)
800
+ })
801
+
802
+ test('rejects lower voucher replay signed by the wrong signer', async () => {
803
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
804
+ const server = createServer()
805
+ await openServerChannel(server, channelId, serializedTransaction)
806
+
807
+ // A valid signature from a different account must NOT be treated as an
808
+ // idempotent replay.
706
809
  await expect(
707
810
  server.verify({
708
811
  credential: {
709
- challenge: makeChallenge({ id: 'challenge-2', channelId }),
812
+ challenge: makeChallenge({ id: 'challenge-wrong-signer', channelId }),
710
813
  payload: {
711
814
  action: 'voucher' as const,
712
815
  channelId,
713
816
  cumulativeAmount: '500000',
714
- signature: await signTestVoucher(channelId, 500000n),
817
+ signature: await signTestVoucher(channelId, 500000n, accounts[3]),
715
818
  },
716
819
  },
717
820
  request: makeRequest(),
718
821
  }),
719
- ).rejects.toThrow(
720
- 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
721
- )
822
+ ).rejects.toThrow(InvalidSignatureError)
722
823
  })
723
824
 
724
825
  test('rejects replay of settled voucher', async () => {
@@ -743,7 +844,7 @@ describe.runIf(isLocalnet)('session', () => {
743
844
  },
744
845
  request: makeRequest(),
745
846
  }),
746
- ).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
847
+ ).rejects.toThrow('voucher cumulativeAmount is at or below on-chain settled amount')
747
848
  })
748
849
 
749
850
  test('rejects voucher exceeding deposit', async () => {
@@ -820,17 +921,15 @@ describe.runIf(isLocalnet)('session', () => {
820
921
  payload: {
821
922
  action: 'voucher' as const,
822
923
  channelId,
823
- // Attacker submits cumulativeAmount=500000, which is <= highestVoucherAmount (1000000)
824
- // but > settled (0). Rejected by non-increasing cumulative amount check before signature validation.
924
+ // Forged signature for a non-advancing amount (500000 <= highest
925
+ // 1000000, > settled 0). Rejected by signature verification.
825
926
  cumulativeAmount: '500000',
826
927
  signature: `0x${'ab'.repeat(65)}` as Hex,
827
928
  },
828
929
  },
829
930
  request: makeRequest(),
830
931
  }),
831
- ).rejects.toThrow(
832
- 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
833
- )
932
+ ).rejects.toThrow('invalid voucher signature')
834
933
  })
835
934
 
836
935
  test('rejects forged voucher with valid amount but invalid signature', async () => {
@@ -967,7 +1066,7 @@ describe.runIf(isLocalnet)('session', () => {
967
1066
  },
968
1067
  request: makeRequest(),
969
1068
  }),
970
- ).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
1069
+ ).rejects.toThrow('voucher cumulativeAmount is at or below on-chain settled amount')
971
1070
  })
972
1071
 
973
1072
  test('rejects leaked voucher used in open action with mismatched channel', async () => {
@@ -2510,7 +2609,7 @@ describe.runIf(isLocalnet)('session', () => {
2510
2609
  },
2511
2610
  request: makeRequest(),
2512
2611
  }),
2513
- ).rejects.toThrow('voucher cumulativeAmount is below on-chain settled amount')
2612
+ ).rejects.toThrow('voucher cumulativeAmount is at or below on-chain settled amount')
2514
2613
  })
2515
2614
 
2516
2615
  test('close after recovery respects on-chain settled as minimum', async () => {
@@ -4385,7 +4484,7 @@ describe.runIf(isLocalnet)('session', () => {
4385
4484
  if (result.status === 402) return result.challenge
4386
4485
 
4387
4486
  if (action === 'voucher') {
4388
- return new Response(null, { status: 200 })
4487
+ return result.withReceipt()
4389
4488
  }
4390
4489
 
4391
4490
  if (request.headers.get('Accept')?.includes('text/event-stream')) {
@@ -4463,7 +4562,7 @@ describe.runIf(isLocalnet)('session', () => {
4463
4562
  if (result.status === 402) return result.challenge
4464
4563
 
4465
4564
  if (action === 'voucher') {
4466
- return new Response(null, { status: 200 })
4565
+ return result.withReceipt()
4467
4566
  }
4468
4567
 
4469
4568
  if (request.headers.get('Accept')?.includes('text/event-stream')) {
@@ -4631,7 +4730,7 @@ describe.runIf(isLocalnet)('session', () => {
4631
4730
  if (result.status === 402) return result.challenge
4632
4731
 
4633
4732
  if (action === 'voucher') {
4634
- return new Response(null, { status: 200 })
4733
+ return result.withReceipt()
4635
4734
  }
4636
4735
 
4637
4736
  if (request.headers.get('Accept')?.includes('text/event-stream')) {
@@ -285,7 +285,8 @@ export function session<const parameters extends session.Parameters>(
285
285
  if (
286
286
  envelope &&
287
287
  isSessionContentRequest(envelope.capturedRequest) &&
288
- (payload.action === 'open' || payload.action === 'voucher')
288
+ (payload.action === 'open' ||
289
+ (payload.action === 'voucher' && !isSseNegotiationRequest(envelope.capturedRequest)))
289
290
  ) {
290
291
  const charged = await charge(
291
292
  store,
@@ -315,17 +316,23 @@ export function session<const parameters extends session.Parameters>(
315
316
  // updates; billable requests fall through to the application handler.
316
317
  respond({ credential, envelope, input }) {
317
318
  const { payload } = credential as Credential.Credential<SessionCredentialPayload>
319
+ const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input)
318
320
 
319
321
  if (payload.action === 'close') return new Response(null, { status: 204 })
320
322
  if (payload.action === 'topUp') return new Response(null, { status: 204 })
323
+ if (parameters.sse && payload.action === 'voucher' && isSseNegotiationRequest(request))
324
+ return new Response(null, { status: 204 })
321
325
 
322
- const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input)
323
326
  if (isSessionContentRequest(request)) return undefined
324
327
  return new Response(null, { status: 204 })
325
328
  },
326
329
  })
327
330
  }
328
331
 
332
+ function isSseNegotiationRequest(input: Pick<Method.CapturedRequest, 'headers'>): boolean {
333
+ return input.headers.get('Accept')?.includes('text/event-stream') ?? false
334
+ }
335
+
329
336
  export declare namespace session {
330
337
  type Defaults = LooseOmit<
331
338
  Method.RequestDefaults<typeof Methods.session>,
@@ -553,7 +560,7 @@ async function verifyAndAcceptVoucher(parameters: {
553
560
 
554
561
  if (voucher.cumulativeAmount <= onChain.settled) {
555
562
  throw new VerificationFailedError({
556
- reason: 'voucher cumulativeAmount is below on-chain settled amount',
563
+ reason: 'voucher cumulativeAmount is at or below on-chain settled amount',
557
564
  })
558
565
  }
559
566
 
@@ -561,12 +568,6 @@ async function verifyAndAcceptVoucher(parameters: {
561
568
  throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
562
569
  }
563
570
 
564
- if (voucher.cumulativeAmount < channel.highestVoucherAmount) {
565
- throw new VerificationFailedError({
566
- reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
567
- })
568
- }
569
-
570
571
  const isValid = await verifyVoucher(
571
572
  channel.escrowContract,
572
573
  channel.chainId,
@@ -578,9 +579,11 @@ async function verifyAndAcceptVoucher(parameters: {
578
579
  throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
579
580
  }
580
581
 
581
- // Idempotent replay: equal cumulative voucher is accepted without
582
- // advancing channel state or charging additional value.
583
- if (voucher.cumulativeAmount === channel.highestVoucherAmount) {
582
+ // Idempotent replay: a non-advancing voucher (at or below the highest
583
+ // accepted amount, but above the on-chain settled amount checked above)
584
+ // returns 200 OK with the current highest amount without advancing state,
585
+ // per the session spec's idempotency requirement.
586
+ if (voucher.cumulativeAmount <= channel.highestVoucherAmount) {
584
587
  return createSessionReceipt({
585
588
  challengeId: challenge.id,
586
589
  channelId,
@@ -660,7 +663,7 @@ async function handleOpen(
660
663
 
661
664
  if (voucher.cumulativeAmount <= onChain.settled) {
662
665
  throw new VerificationFailedError({
663
- reason: 'voucher cumulativeAmount is below on-chain settled amount',
666
+ reason: 'voucher cumulativeAmount is at or below on-chain settled amount',
664
667
  })
665
668
  }
666
669
 
@@ -701,7 +704,7 @@ async function handleOpen(
701
704
  if (existing) {
702
705
  if (voucher.cumulativeAmount <= existing.settledOnChain) {
703
706
  throw new VerificationFailedError({
704
- reason: 'voucher amount is below settled on-chain amount',
707
+ reason: 'voucher amount is at or below settled on-chain amount',
705
708
  })
706
709
  }
707
710
 
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "accounts": "0.10.2",
6
+ "accounts": "0.14.6",
7
7
  "mppx": "workspace:*",
8
8
  "viem": "2.50.4"
9
9
  }