mppx 0.5.13 → 0.5.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.
@@ -37,6 +37,7 @@ import * as Store from '../../Store.js'
37
37
  import * as Client from '../../viem/Client.js'
38
38
  import * as Account from '../internal/account.js'
39
39
  import * as defaults from '../internal/defaults.js'
40
+ import * as FeePayer from '../internal/fee-payer.js'
40
41
  import type * as types from '../internal/types.js'
41
42
  import * as Methods from '../Methods.js'
42
43
  import {
@@ -92,6 +93,7 @@ export function session<const parameters extends session.Parameters>(
92
93
  channelStateTtl = 5_000,
93
94
  currency = defaults.resolveCurrency(parameters),
94
95
  decimals = defaults.decimals,
96
+ feePayerPolicy,
95
97
  store: rawStore = Store.memory(),
96
98
  suggestedDeposit,
97
99
  unitType,
@@ -203,9 +205,10 @@ export function session<const parameters extends session.Parameters>(
203
205
  payload,
204
206
  methodDetails,
205
207
  resolvedFeePayer,
208
+ feePayerPolicy,
206
209
  waitForConfirmation,
207
210
  )
208
- lastOnChainVerified.set(payload.channelId, Date.now())
211
+ lastOnChainVerified.set(sessionReceipt.channelId, Date.now())
209
212
  break
210
213
 
211
214
  case 'topUp':
@@ -216,8 +219,9 @@ export function session<const parameters extends session.Parameters>(
216
219
  payload,
217
220
  methodDetails,
218
221
  resolvedFeePayer,
222
+ feePayerPolicy,
219
223
  )
220
- lastOnChainVerified.set(payload.channelId, Date.now())
224
+ lastOnChainVerified.set(sessionReceipt.channelId, Date.now())
221
225
  break
222
226
 
223
227
  case 'voucher':
@@ -293,9 +297,13 @@ export declare namespace session {
293
297
  'feePayer' | 'recipient'
294
298
  >
295
299
 
300
+ type FeePayerPolicy = Partial<FeePayer.Policy>
301
+
296
302
  type Parameters = {
297
303
  /** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 5_000 */
298
304
  channelStateTtl?: number | undefined
305
+ /** Override the fee-sponsor policy used for sponsored open/topUp transactions. */
306
+ feePayerPolicy?: FeePayerPolicy | undefined
299
307
  /** Minimum voucher delta to accept (numeric string, default: "0"). */
300
308
  minVoucherDelta?: string | undefined
301
309
  /**
@@ -561,13 +569,11 @@ async function handleOpen(
561
569
  payload: SessionCredentialPayload & { action: 'open' },
562
570
  methodDetails: SessionMethodDetails,
563
571
  feePayer: viem_Account | undefined,
572
+ feePayerPolicy: session.FeePayerPolicy | undefined,
564
573
  waitForConfirmation: boolean,
565
574
  ): Promise<SessionReceipt> {
566
- const voucher = parseVoucherFromPayload(
567
- payload.channelId,
568
- payload.cumulativeAmount,
569
- payload.signature,
570
- )
575
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
576
+ const voucher = parseVoucherFromPayload(channelId, payload.cumulativeAmount, payload.signature)
571
577
 
572
578
  const recipient = challenge.request.recipient as Address
573
579
  const currency = challenge.request.currency as Address
@@ -577,9 +583,11 @@ async function handleOpen(
577
583
  client,
578
584
  serializedTransaction: payload.transaction,
579
585
  escrowContract: methodDetails.escrowContract,
580
- channelId: payload.channelId,
586
+ channelId,
581
587
  recipient,
582
588
  currency,
589
+ challengeExpires: challenge.expires,
590
+ feePayerPolicy,
583
591
  feePayer,
584
592
  waitForConfirmation,
585
593
  })
@@ -612,7 +620,7 @@ async function handleOpen(
612
620
  throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
613
621
  }
614
622
 
615
- const updated = await store.updateChannel(payload.channelId, (existing) => {
623
+ const updated = await store.updateChannel(channelId, (existing) => {
616
624
  if (existing) {
617
625
  if (voucher.cumulativeAmount <= existing.settledOnChain) {
618
626
  throw new VerificationFailedError({
@@ -644,7 +652,7 @@ async function handleOpen(
644
652
  }
645
653
  }
646
654
  return {
647
- channelId: payload.channelId,
655
+ channelId,
648
656
  chainId: methodDetails.chainId,
649
657
  escrowContract: methodDetails.escrowContract,
650
658
  closeRequestedAt: onChain.closeRequestedAt,
@@ -667,7 +675,7 @@ async function handleOpen(
667
675
 
668
676
  return createSessionReceipt({
669
677
  challengeId: challenge.id,
670
- channelId: payload.channelId,
678
+ channelId: updated.channelId,
671
679
  acceptedCumulative: updated.highestVoucherAmount,
672
680
  spent: updated.spent,
673
681
  units: updated.units,
@@ -689,8 +697,10 @@ async function handleTopUp(
689
697
  payload: SessionCredentialPayload & { action: 'topUp' },
690
698
  methodDetails: SessionMethodDetails,
691
699
  feePayer: viem_Account | undefined,
700
+ feePayerPolicy: session.FeePayerPolicy | undefined,
692
701
  ): Promise<SessionReceipt> {
693
- const channel = await store.getChannel(payload.channelId)
702
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
703
+ const channel = await store.getChannel(channelId)
694
704
  if (!channel) {
695
705
  throw new ChannelNotFoundError({ reason: 'channel not found' })
696
706
  }
@@ -701,21 +711,23 @@ async function handleTopUp(
701
711
  client,
702
712
  serializedTransaction: payload.transaction,
703
713
  escrowContract: methodDetails.escrowContract,
704
- channelId: payload.channelId,
714
+ channelId,
705
715
  currency: challenge.request.currency as Address,
706
716
  declaredDeposit,
707
717
  previousDeposit: channel.deposit,
718
+ challengeExpires: challenge.expires,
719
+ feePayerPolicy,
708
720
  feePayer,
709
721
  })
710
722
 
711
- const updated = await store.updateChannel(payload.channelId, (current) => {
723
+ const updated = await store.updateChannel(channelId, (current) => {
712
724
  if (!current) throw new ChannelNotFoundError({ reason: 'channel not found' })
713
725
  return { ...current, deposit: onChainDeposit }
714
726
  })
715
727
 
716
728
  return createSessionReceipt({
717
729
  challengeId: challenge.id,
718
- channelId: payload.channelId,
730
+ channelId: updated?.channelId ?? channel.channelId,
719
731
  acceptedCumulative: updated?.highestVoucherAmount ?? channel.highestVoucherAmount,
720
732
  spent: updated?.spent ?? channel.spent,
721
733
  units: updated?.units ?? channel.units,
@@ -735,7 +747,8 @@ async function handleVoucher(
735
747
  channelStateTtl: number,
736
748
  lastOnChainVerified: Map<Hex, number>,
737
749
  ): Promise<SessionReceipt> {
738
- const channel = await store.getChannel(payload.channelId)
750
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
751
+ const channel = await store.getChannel(channelId)
739
752
  if (!channel) {
740
753
  throw new ChannelNotFoundError({ reason: 'channel not found' })
741
754
  }
@@ -743,11 +756,7 @@ async function handleVoucher(
743
756
  throw new ChannelClosedError({ reason: 'channel is finalized' })
744
757
  }
745
758
 
746
- const voucher = parseVoucherFromPayload(
747
- payload.channelId,
748
- payload.cumulativeAmount,
749
- payload.signature,
750
- )
759
+ const voucher = parseVoucherFromPayload(channelId, payload.cumulativeAmount, payload.signature)
751
760
 
752
761
  // Use locally-stored channel state as a trusted cache instead of
753
762
  // reading on-chain for every voucher. The on-chain state is verified
@@ -759,7 +768,7 @@ async function handleVoucher(
759
768
  // To guard against the payer initiating a forced close while vouchers
760
769
  // are still being accepted, re-query on-chain state when the cache
761
770
  // exceeds the configured staleness TTL (default: 5s).
762
- const lastVerified = lastOnChainVerified.get(payload.channelId) ?? 0
771
+ const lastVerified = lastOnChainVerified.get(channelId) ?? 0
763
772
  const isStale = Date.now() - lastVerified > channelStateTtl
764
773
 
765
774
  const onChain = await (async () => {
@@ -767,13 +776,13 @@ async function handleVoucher(
767
776
  const onChainChannel = await getOnChainChannel(
768
777
  client,
769
778
  methodDetails.escrowContract,
770
- payload.channelId,
779
+ channelId,
771
780
  )
772
- lastOnChainVerified.set(payload.channelId, Date.now())
781
+ lastOnChainVerified.set(channelId, Date.now())
773
782
  // Persist closeRequestedAt so the cached path detects force-close
774
783
  // between re-queries.
775
784
  if (onChainChannel.closeRequestedAt !== 0n) {
776
- await store.updateChannel(payload.channelId, (current) =>
785
+ await store.updateChannel(channelId, (current) =>
777
786
  current ? { ...current, closeRequestedAt: onChainChannel.closeRequestedAt } : current,
778
787
  )
779
788
  }
@@ -796,7 +805,7 @@ async function handleVoucher(
796
805
  minVoucherDelta,
797
806
  challenge,
798
807
  channel,
799
- channelId: payload.channelId,
808
+ channelId,
800
809
  voucher,
801
810
  onChain,
802
811
  methodDetails,
@@ -815,7 +824,8 @@ async function handleClose(
815
824
  account?: viem_Account,
816
825
  feePayer?: viem_Account,
817
826
  ): Promise<SessionReceipt> {
818
- const channel = await store.getChannel(payload.channelId)
827
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
828
+ const channel = await store.getChannel(channelId)
819
829
  if (!channel) {
820
830
  throw new ChannelNotFoundError({ reason: 'channel not found' })
821
831
  }
@@ -823,13 +833,9 @@ async function handleClose(
823
833
  throw new ChannelClosedError({ reason: 'channel is already finalized' })
824
834
  }
825
835
 
826
- const voucher = parseVoucherFromPayload(
827
- payload.channelId,
828
- payload.cumulativeAmount,
829
- payload.signature,
830
- )
836
+ const voucher = parseVoucherFromPayload(channelId, payload.cumulativeAmount, payload.signature)
831
837
 
832
- const onChain = await getOnChainChannel(client, methodDetails.escrowContract, payload.channelId)
838
+ const onChain = await getOnChainChannel(client, methodDetails.escrowContract, channelId)
833
839
 
834
840
  if (onChain.finalized) {
835
841
  throw new ChannelClosedError({ reason: 'channel is finalized on-chain' })
@@ -867,7 +873,7 @@ async function handleClose(
867
873
  ...(feePayer && account ? { feePayer, account } : { account }),
868
874
  })
869
875
 
870
- const updated = await store.updateChannel(payload.channelId, (current) => {
876
+ const updated = await store.updateChannel(channelId, (current) => {
871
877
  if (!current) return null
872
878
  const updateVoucher = voucher.cumulativeAmount > current.highestVoucherAmount
873
879
  return {
@@ -883,7 +889,7 @@ async function handleClose(
883
889
 
884
890
  return createSessionReceipt({
885
891
  challengeId: challenge.id,
886
- channelId: payload.channelId,
892
+ channelId: updated?.channelId ?? channel.channelId,
887
893
  acceptedCumulative: voucher.cumulativeAmount,
888
894
  spent: updated?.spent ?? channel.spent,
889
895
  units: updated?.units ?? channel.units,
@@ -1,5 +1,5 @@
1
- import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem'
2
- import { waitForTransactionReceipt } from 'viem/actions'
1
+ import { type Address, encodeFunctionData, erc20Abi, type Hex, zeroAddress } from 'viem'
2
+ import { prepareTransactionRequest, signTransaction, waitForTransactionReceipt } from 'viem/actions'
3
3
  import { Addresses, Transaction } from 'viem/tempo'
4
4
  import { beforeAll, describe, expect, test } from 'vp/test'
5
5
  import { nodeEnv } from '~test/config.js'
@@ -17,10 +17,12 @@ import {
17
17
  broadcastOpenTransaction,
18
18
  broadcastTopUpTransaction,
19
19
  closeOnChain,
20
+ escrowAbi,
20
21
  getOnChainChannel,
21
22
  settleOnChain,
22
23
  verifyTopUpTransaction,
23
24
  } from './Chain.js'
25
+ import * as Channel from './Channel.js'
24
26
  import { signVoucher } from './Voucher.js'
25
27
 
26
28
  const isLocalnet = nodeEnv === 'localnet'
@@ -397,6 +399,116 @@ describe.runIf(isLocalnet)('on-chain', () => {
397
399
  ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
398
400
  })
399
401
 
402
+ test('fee-payer: rejects transactions whose gas budget exceeds sponsor policy', async () => {
403
+ const salt = nextSalt()
404
+ const deposit = 5_000_000n
405
+
406
+ const approveData = encodeFunctionData({
407
+ abi: erc20Abi,
408
+ functionName: 'approve',
409
+ args: [escrowContract, deposit],
410
+ })
411
+ const openData = encodeFunctionData({
412
+ abi: escrowAbi,
413
+ functionName: 'open',
414
+ args: [recipient, currency, deposit, salt, zeroAddress],
415
+ })
416
+
417
+ const channelId = Channel.computeId({
418
+ authorizedSigner: zeroAddress,
419
+ chainId: chain.id,
420
+ escrowContract,
421
+ payee: recipient,
422
+ payer: payer.address,
423
+ salt,
424
+ token: currency,
425
+ }) as Hex
426
+
427
+ const prepared = await prepareTransactionRequest(client, {
428
+ account: payer,
429
+ calls: [
430
+ { to: currency, data: approveData },
431
+ { to: escrowContract, data: openData },
432
+ ],
433
+ feePayer: true,
434
+ feeToken: currency,
435
+ } as never)
436
+ prepared.gas = 2_000_001n
437
+
438
+ const serializedTransaction = await signTransaction(client, prepared as never)
439
+
440
+ await expect(
441
+ broadcastOpenTransaction({
442
+ client,
443
+ serializedTransaction: serializedTransaction as Hex,
444
+ escrowContract,
445
+ channelId,
446
+ recipient,
447
+ currency,
448
+ feePayer: accounts[0],
449
+ }),
450
+ ).rejects.toThrow('gas exceeds sponsor policy')
451
+ })
452
+
453
+ test('fee-payer: rejects smuggled second open call', async () => {
454
+ const deposit = 5_000_000n
455
+ const smuggledDeposit = 7_000_000n
456
+ const salt = nextSalt()
457
+ const smuggledSalt = nextSalt()
458
+
459
+ const approveData = encodeFunctionData({
460
+ abi: erc20Abi,
461
+ functionName: 'approve',
462
+ args: [escrowContract, deposit + smuggledDeposit],
463
+ })
464
+ const openData = encodeFunctionData({
465
+ abi: escrowAbi,
466
+ functionName: 'open',
467
+ args: [recipient, currency, deposit, salt, zeroAddress],
468
+ })
469
+ const smuggledOpenData = encodeFunctionData({
470
+ abi: escrowAbi,
471
+ functionName: 'open',
472
+ args: [accounts[3].address, currency, smuggledDeposit, smuggledSalt, zeroAddress],
473
+ })
474
+
475
+ const channelId = Channel.computeId({
476
+ authorizedSigner: zeroAddress,
477
+ chainId: chain.id,
478
+ escrowContract,
479
+ payee: recipient,
480
+ payer: payer.address,
481
+ salt,
482
+ token: currency,
483
+ }) as Hex
484
+
485
+ const prepared = await prepareTransactionRequest(client, {
486
+ account: payer,
487
+ calls: [
488
+ { to: currency, data: approveData },
489
+ { to: escrowContract, data: openData },
490
+ { to: escrowContract, data: smuggledOpenData },
491
+ ],
492
+ feePayer: true,
493
+ feeToken: currency,
494
+ } as never)
495
+ prepared.gas = prepared.gas! + 5_000n
496
+
497
+ const serializedTransaction = await signTransaction(client, prepared as never)
498
+
499
+ await expect(
500
+ broadcastOpenTransaction({
501
+ client,
502
+ serializedTransaction: serializedTransaction as Hex,
503
+ escrowContract,
504
+ channelId,
505
+ recipient,
506
+ currency,
507
+ feePayer: accounts[0],
508
+ }),
509
+ ).rejects.toThrow('fee-sponsored open transaction contains a smuggled call')
510
+ })
511
+
400
512
  test('duplicate broadcast returns fallback with txHash undefined', async () => {
401
513
  const salt = nextSalt()
402
514
  const deposit = 5_000_000n
@@ -727,6 +839,117 @@ describe.runIf(isLocalnet)('on-chain', () => {
727
839
  }),
728
840
  ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
729
841
  })
842
+
843
+ test('fee-payer: rejects topUp transactions whose gas budget exceeds sponsor policy', async () => {
844
+ const salt = nextSalt()
845
+ const deposit = 5_000_000n
846
+ const topUpAmount = 3_000_000n
847
+
848
+ const { channelId } = await openChannel({
849
+ escrow: escrowContract,
850
+ payer,
851
+ payee: recipient,
852
+ token: currency,
853
+ deposit,
854
+ salt,
855
+ })
856
+
857
+ const approveData = encodeFunctionData({
858
+ abi: erc20Abi,
859
+ functionName: 'approve',
860
+ args: [escrowContract, topUpAmount],
861
+ })
862
+ const topUpData = encodeFunctionData({
863
+ abi: escrowAbi,
864
+ functionName: 'topUp',
865
+ args: [channelId, topUpAmount],
866
+ })
867
+
868
+ const prepared = await prepareTransactionRequest(client, {
869
+ account: payer,
870
+ calls: [
871
+ { to: currency, data: approveData },
872
+ { to: escrowContract, data: topUpData },
873
+ ],
874
+ feePayer: true,
875
+ feeToken: currency,
876
+ } as never)
877
+ prepared.gas = 2_000_001n
878
+
879
+ const serializedTransaction = await signTransaction(client, prepared as never)
880
+
881
+ await expect(
882
+ broadcastTopUpTransaction({
883
+ client,
884
+ serializedTransaction: serializedTransaction as Hex,
885
+ escrowContract,
886
+ channelId,
887
+ currency: asset,
888
+ declaredDeposit: topUpAmount,
889
+ previousDeposit: deposit,
890
+ feePayer: accounts[0],
891
+ }),
892
+ ).rejects.toThrow('gas exceeds sponsor policy')
893
+ })
894
+
895
+ test('fee-payer: rejects smuggled second topUp call', async () => {
896
+ const salt = nextSalt()
897
+ const deposit = 5_000_000n
898
+ const topUpAmount = 3_000_000n
899
+ const smuggledAmount = 4_000_000n
900
+
901
+ const { channelId } = await openChannel({
902
+ escrow: escrowContract,
903
+ payer,
904
+ payee: recipient,
905
+ token: currency,
906
+ deposit,
907
+ salt,
908
+ })
909
+
910
+ const approveData = encodeFunctionData({
911
+ abi: erc20Abi,
912
+ functionName: 'approve',
913
+ args: [escrowContract, topUpAmount + smuggledAmount],
914
+ })
915
+ const topUpData = encodeFunctionData({
916
+ abi: escrowAbi,
917
+ functionName: 'topUp',
918
+ args: [channelId, topUpAmount],
919
+ })
920
+ const smuggledTopUpData = encodeFunctionData({
921
+ abi: escrowAbi,
922
+ functionName: 'topUp',
923
+ args: [channelId, smuggledAmount],
924
+ })
925
+
926
+ const prepared = await prepareTransactionRequest(client, {
927
+ account: payer,
928
+ calls: [
929
+ { to: currency, data: approveData },
930
+ { to: escrowContract, data: topUpData },
931
+ { to: escrowContract, data: smuggledTopUpData },
932
+ ],
933
+ feePayer: true,
934
+ feeToken: currency,
935
+ } as never)
936
+ prepared.gas = prepared.gas! + 5_000n
937
+
938
+ const serializedTransaction = await signTransaction(client, prepared as never)
939
+
940
+ await expect(
941
+ broadcastTopUpTransaction({
942
+ client,
943
+ serializedTransaction: serializedTransaction as Hex,
944
+ escrowContract,
945
+ channelId,
946
+ currency: asset,
947
+ declaredDeposit: topUpAmount,
948
+ previousDeposit: deposit,
949
+ feePayer: accounts[0],
950
+ }),
951
+ ).rejects.toThrow('fee-sponsored topUp transaction contains a smuggled call')
952
+ })
730
953
  })
731
954
 
732
955
  describe('settleOnChain', () => {