mppx 0.8.0 → 0.8.2

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 (89) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/Errors.d.ts +13 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +12 -0
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/cli/cli.d.ts +2 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +35 -4
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/internal.d.ts +1 -0
  11. package/dist/cli/internal.d.ts.map +1 -1
  12. package/dist/cli/internal.js +1 -15
  13. package/dist/cli/internal.js.map +1 -1
  14. package/dist/cli/plugins/tempo.d.ts +15 -0
  15. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  16. package/dist/cli/plugins/tempo.js +124 -14
  17. package/dist/cli/plugins/tempo.js.map +1 -1
  18. package/dist/client/Mppx.d.ts +2 -0
  19. package/dist/client/Mppx.d.ts.map +1 -1
  20. package/dist/client/Mppx.js +2 -1
  21. package/dist/client/Mppx.js.map +1 -1
  22. package/dist/client/Transport.d.ts.map +1 -1
  23. package/dist/client/Transport.js +11 -12
  24. package/dist/client/Transport.js.map +1 -1
  25. package/dist/client/internal/Fetch.d.ts +2 -0
  26. package/dist/client/internal/Fetch.d.ts.map +1 -1
  27. package/dist/client/internal/Fetch.js +55 -50
  28. package/dist/client/internal/Fetch.js.map +1 -1
  29. package/dist/client/internal/protocols/Mcp.d.ts +6 -0
  30. package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
  31. package/dist/client/internal/protocols/Mcp.js +28 -10
  32. package/dist/client/internal/protocols/Mcp.js.map +1 -1
  33. package/dist/client/internal/protocols/X402.d.ts.map +1 -1
  34. package/dist/client/internal/protocols/X402.js +18 -12
  35. package/dist/client/internal/protocols/X402.js.map +1 -1
  36. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  37. package/dist/stripe/server/internal/html.gen.js +1 -1
  38. package/dist/tempo/internal/fee-payer.d.ts +17 -0
  39. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  40. package/dist/tempo/internal/fee-payer.js +105 -25
  41. package/dist/tempo/internal/fee-payer.js.map +1 -1
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +4 -0
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  46. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.js +1 -1
  48. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  49. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  50. package/dist/tempo/session/client/SessionManager.js +47 -5
  51. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  52. package/dist/tempo/session/server/CredentialVerification.d.ts +6 -0
  53. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  54. package/dist/tempo/session/server/CredentialVerification.js +13 -0
  55. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  56. package/dist/x402/Header.d.ts +10 -1
  57. package/dist/x402/Header.d.ts.map +1 -1
  58. package/dist/x402/Header.js +27 -1
  59. package/dist/x402/Header.js.map +1 -1
  60. package/package.json +3 -3
  61. package/src/Errors.test.ts +28 -0
  62. package/src/Errors.ts +19 -0
  63. package/src/cli/cli.test.ts +194 -0
  64. package/src/cli/cli.ts +43 -7
  65. package/src/cli/config.test.ts +13 -7
  66. package/src/cli/internal.ts +1 -16
  67. package/src/cli/plugins/tempo.ts +160 -17
  68. package/src/client/Mppx.test-d.ts +9 -0
  69. package/src/client/Mppx.test.ts +41 -0
  70. package/src/client/Mppx.ts +4 -0
  71. package/src/client/Transport.test.ts +124 -0
  72. package/src/client/Transport.ts +11 -9
  73. package/src/client/internal/Fetch.test-d.ts +9 -0
  74. package/src/client/internal/Fetch.test.ts +186 -4
  75. package/src/client/internal/Fetch.ts +85 -76
  76. package/src/client/internal/protocols/Mcp.ts +39 -15
  77. package/src/client/internal/protocols/X402.ts +9 -5
  78. package/src/server/Mppx.test.ts +3 -0
  79. package/src/stripe/server/internal/html.gen.ts +1 -1
  80. package/src/tempo/internal/fee-payer.test.ts +130 -0
  81. package/src/tempo/internal/fee-payer.ts +157 -36
  82. package/src/tempo/server/Charge.ts +4 -0
  83. package/src/tempo/server/internal/html/package.json +2 -2
  84. package/src/tempo/server/internal/html.gen.ts +1 -1
  85. package/src/tempo/session/client/SessionManager.test.ts +57 -4
  86. package/src/tempo/session/client/SessionManager.ts +50 -6
  87. package/src/tempo/session/server/CredentialVerification.test.ts +36 -0
  88. package/src/tempo/session/server/CredentialVerification.ts +18 -0
  89. package/src/x402/Header.ts +45 -0
@@ -499,6 +499,10 @@ describe('fillHostedFeePayerTransaction', () => {
499
499
  signature: { r: 1n, s: 1n, yParity: 0 } as any,
500
500
  validBefore: Math.floor(Date.now() / 1_000) + 300,
501
501
  } as const
502
+ const hostedContext = {
503
+ chainId: defaults.chainId.mainnet,
504
+ details,
505
+ } as const
502
506
 
503
507
  test('uses hosted fillTransaction and preserves sender-committed fields', async () => {
504
508
  // Sign over the payload built from the actual RPC request body so this
@@ -569,6 +573,7 @@ describe('fillHostedFeePayerTransaction', () => {
569
573
 
570
574
  const result = await fillHostedFeePayerTransaction({
571
575
  allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
576
+ ...hostedContext,
572
577
  transaction: hostedTransaction as any,
573
578
  url: 'https://sponsor.example/tp_key',
574
579
  })
@@ -621,6 +626,7 @@ describe('fillHostedFeePayerTransaction', () => {
621
626
  await expect(
622
627
  fillHostedFeePayerTransaction({
623
628
  allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
629
+ ...hostedContext,
624
630
  transaction: hostedTransaction as any,
625
631
  url: 'https://sponsor.example/tp_key',
626
632
  }),
@@ -643,6 +649,7 @@ describe('fillHostedFeePayerTransaction', () => {
643
649
  await expect(
644
650
  fillHostedFeePayerTransaction({
645
651
  allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
652
+ ...hostedContext,
646
653
  transaction: hostedTransaction as any,
647
654
  url: 'https://sponsor.example/tp_key',
648
655
  }),
@@ -663,11 +670,69 @@ describe('fillHostedFeePayerTransaction', () => {
663
670
  await expect(
664
671
  fillHostedFeePayerTransaction({
665
672
  allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
673
+ ...hostedContext,
666
674
  transaction: hostedTransaction as any,
667
675
  url: 'https://sponsor.example/tp_key',
668
676
  }),
669
677
  ).rejects.toThrow('Invalid or revoked API key')
670
678
  })
679
+
680
+ test('error: enforces sponsor policy before requesting hosted fill', async () => {
681
+ const fetchMock = vi.fn()
682
+ vi.stubGlobal('fetch', fetchMock)
683
+
684
+ await expect(
685
+ fillHostedFeePayerTransaction({
686
+ allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
687
+ ...hostedContext,
688
+ policy: { maxGas: hostedTransaction.gas - 1n },
689
+ transaction: hostedTransaction as any,
690
+ url: 'https://sponsor.example/tp_key',
691
+ }),
692
+ ).rejects.toThrow('gas exceeds sponsor policy')
693
+ expect(fetchMock).not.toHaveBeenCalled()
694
+ })
695
+
696
+ test('error: rejects non-empty access lists before requesting hosted fill', async () => {
697
+ const fetchMock = vi.fn()
698
+ vi.stubGlobal('fetch', fetchMock)
699
+
700
+ await expect(
701
+ fillHostedFeePayerTransaction({
702
+ allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
703
+ ...hostedContext,
704
+ transaction: {
705
+ ...hostedTransaction,
706
+ accessList: [{ address: bogus, storageKeys: [] }],
707
+ } as any,
708
+ url: 'https://sponsor.example/tp_key',
709
+ }),
710
+ ).rejects.toThrow('accessList is not allowed')
711
+ expect(fetchMock).not.toHaveBeenCalled()
712
+ })
713
+
714
+ test('error: rejects padded calldata before requesting hosted fill', async () => {
715
+ const fetchMock = vi.fn()
716
+ vi.stubGlobal('fetch', fetchMock)
717
+
718
+ await expect(
719
+ fillHostedFeePayerTransaction({
720
+ allowedFeeTokens: defaultAllowedFeeTokens(defaults.chainId.mainnet),
721
+ ...hostedContext,
722
+ transaction: {
723
+ ...hostedTransaction,
724
+ calls: [
725
+ {
726
+ ...hostedTransaction.calls[0],
727
+ data: `${hostedTransaction.calls[0]!.data}01`,
728
+ },
729
+ ],
730
+ } as any,
731
+ url: 'https://sponsor.example/tp_key',
732
+ }),
733
+ ).rejects.toThrow('calldata is not canonical')
734
+ expect(fetchMock).not.toHaveBeenCalled()
735
+ })
671
736
  })
672
737
 
673
738
  describe('simulationTransaction', () => {
@@ -948,4 +1013,69 @@ describe('prepareSponsoredTransaction', () => {
948
1013
  }),
949
1014
  ).toThrow('validity window exceeds sponsor policy')
950
1015
  })
1016
+
1017
+ test('error: rejects non-empty access lists', () => {
1018
+ expect(() =>
1019
+ prepareSponsoredTransaction({
1020
+ account: sponsor,
1021
+ chainId: 42431,
1022
+ details,
1023
+ allowedFeeTokens: [bogus],
1024
+ transaction: {
1025
+ ...baseTransaction,
1026
+ accessList: [{ address: bogus, storageKeys: [] }],
1027
+ } as any,
1028
+ }),
1029
+ ).toThrow('accessList is not allowed')
1030
+ })
1031
+
1032
+ test('error: rejects padded calldata', () => {
1033
+ expect(() =>
1034
+ prepareSponsoredTransaction({
1035
+ account: sponsor,
1036
+ chainId: 42431,
1037
+ details,
1038
+ allowedFeeTokens: [bogus],
1039
+ transaction: {
1040
+ ...baseTransaction,
1041
+ calls: [
1042
+ {
1043
+ ...baseTransaction.calls[0],
1044
+ data: `${baseTransaction.calls[0]!.data}01`,
1045
+ },
1046
+ ],
1047
+ } as any,
1048
+ }),
1049
+ ).toThrow('calldata is not canonical')
1050
+ })
1051
+
1052
+ test('error: rejects missing calls', () => {
1053
+ expect(() =>
1054
+ prepareSponsoredTransaction({
1055
+ account: sponsor,
1056
+ chainId: 42431,
1057
+ details,
1058
+ allowedFeeTokens: [bogus],
1059
+ transaction: {
1060
+ ...baseTransaction,
1061
+ calls: undefined,
1062
+ } as any,
1063
+ }),
1064
+ ).toThrow('must declare calls')
1065
+ })
1066
+
1067
+ test('error: rejects nonzero call value', () => {
1068
+ expect(() =>
1069
+ prepareSponsoredTransaction({
1070
+ account: sponsor,
1071
+ chainId: 42431,
1072
+ details,
1073
+ allowedFeeTokens: [bogus],
1074
+ transaction: {
1075
+ ...baseTransaction,
1076
+ calls: [{ ...baseTransaction.calls[0], value: 1n }],
1077
+ } as any,
1078
+ }),
1079
+ ).toThrow('call value is not allowed')
1080
+ })
951
1081
  })
@@ -3,7 +3,7 @@ import type { TempoAddress } from 'ox/tempo'
3
3
  import { TxEnvelopeTempo } from 'ox/tempo'
4
4
  import type { Hex } from 'viem'
5
5
  import type { Account } from 'viem'
6
- import { decodeFunctionData, maxUint256, toHex } from 'viem'
6
+ import { decodeFunctionData, encodeFunctionData, maxUint256, toHex } from 'viem'
7
7
  import { Abis, Addresses, Transaction } from 'viem/tempo'
8
8
 
9
9
  import * as TempoAddress_internal from './address.js'
@@ -181,10 +181,22 @@ function hostedFeePayerRequest(transaction: SponsoredTransaction) {
181
181
  */
182
182
  export async function fillHostedFeePayerTransaction(parameters: {
183
183
  allowedFeeTokens: readonly TempoAddress.Address[]
184
+ challengeExpires?: string | undefined
185
+ chainId: number
186
+ details: Record<string, string>
187
+ policy?: Partial<Policy> | undefined
184
188
  transaction: SponsoredTransaction
185
189
  url: string
186
190
  }) {
187
- const { allowedFeeTokens, transaction, url } = parameters
191
+ const { allowedFeeTokens, challengeExpires, chainId, details, policy, transaction, url } =
192
+ parameters
193
+ assertTransactionPolicy({
194
+ challengeExpires,
195
+ chainId,
196
+ details,
197
+ policy,
198
+ transaction,
199
+ })
188
200
  const response = await fetch(url, {
189
201
  body: JSON.stringify(
190
202
  {
@@ -307,6 +319,74 @@ function isExpiringNonceKey(nonceKey: SponsoredTransaction['nonceKey']): boolean
307
319
  return nonceKey === 'expiring' || nonceKey === maxUint256
308
320
  }
309
321
 
322
+ const sponsoredCallAbi = [...Abis.tip20, ...Abis.stablecoinDex] as const
323
+ const sponsoredCallSelectors = new Set([
324
+ Selectors.approve,
325
+ Selectors.transfer,
326
+ Selectors.transferWithMemo,
327
+ Selectors.swapExactAmountOut,
328
+ ])
329
+
330
+ function canonicalSponsoredCallData(
331
+ data: Hex,
332
+ call: string,
333
+ fail: (reason: string, extra?: Record<string, string>) => never,
334
+ ) {
335
+ if (!sponsoredCallSelectors.has(data.slice(0, 10) as Hex)) return undefined
336
+
337
+ let decoded: ReturnType<typeof decodeFunctionData<typeof sponsoredCallAbi>>
338
+ try {
339
+ decoded = decodeFunctionData({ abi: sponsoredCallAbi, data })
340
+ } catch {
341
+ fail('fee-sponsored transaction call calldata is invalid', { call })
342
+ }
343
+ return encodeFunctionData({
344
+ abi: sponsoredCallAbi,
345
+ functionName: decoded.functionName,
346
+ args: decoded.args,
347
+ })
348
+ }
349
+
350
+ /**
351
+ * Rejects sponsored transaction fields that increase intrinsic gas without
352
+ * changing the decoded payment intent.
353
+ */
354
+ function assertCanonicalSponsoredTransaction(
355
+ transaction: SponsoredTransaction,
356
+ fail: (reason: string, extra?: Record<string, string>) => never,
357
+ ) {
358
+ if (transaction.accessList !== undefined && transaction.accessList.length > 0)
359
+ fail('fee-sponsored transaction accessList is not allowed', {
360
+ accessListEntries: String(transaction.accessList.length),
361
+ })
362
+
363
+ const calls = transaction.calls
364
+ if (!Array.isArray(calls) || calls.length === 0)
365
+ fail('fee-sponsored transaction must declare calls')
366
+
367
+ for (const [index, call] of calls.entries()) {
368
+ const callIndex = String(index)
369
+ const value = call.value
370
+ if (value !== undefined && value !== 0n)
371
+ fail('fee-sponsored transaction call value is not allowed', {
372
+ call: callIndex,
373
+ value: value.toString(),
374
+ })
375
+
376
+ const data = call.data
377
+ if (!data)
378
+ fail('fee-sponsored transaction call is missing calldata', {
379
+ call: callIndex,
380
+ })
381
+
382
+ const canonical = canonicalSponsoredCallData(data, callIndex, fail)
383
+ if (canonical && data.toLowerCase() !== canonical.toLowerCase())
384
+ fail('fee-sponsored transaction call calldata is not canonical', {
385
+ call: callIndex,
386
+ })
387
+ }
388
+ }
389
+
310
390
  /** Validates that a set of transaction calls matches an allowed fee-payer pattern. */
311
391
  export function validateCalls(
312
392
  calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[],
@@ -433,9 +513,8 @@ export function validateCalls(
433
513
  }
434
514
  }
435
515
 
436
- export function prepareSponsoredTransaction(parameters: {
437
- account: Account
438
- allowedFeeTokens?: readonly TempoAddress.Address[] | undefined
516
+ /** Validates sponsor fee policy limits for a fee-payer transaction. */
517
+ export function assertTransactionPolicy(parameters: {
439
518
  challengeExpires?: string | undefined
440
519
  chainId: number
441
520
  details: Record<string, string>
@@ -444,8 +523,6 @@ export function prepareSponsoredTransaction(parameters: {
444
523
  transaction: SponsoredTransaction
445
524
  }) {
446
525
  const {
447
- account,
448
- allowedFeeTokens,
449
526
  challengeExpires,
450
527
  chainId,
451
528
  details,
@@ -454,22 +531,12 @@ export function prepareSponsoredTransaction(parameters: {
454
531
  transaction,
455
532
  } = parameters
456
533
  const policy = getPolicy(chainId, policyOverrides)
457
- const transactionRecord = transaction as Record<string, unknown>
458
-
459
534
  const {
460
- accessList,
461
- calls,
462
535
  chainId: transactionChainId,
463
- feeToken,
464
- from,
465
536
  gas,
466
- keyAuthorization,
467
537
  maxFeePerGas,
468
538
  maxPriorityFeePerGas,
469
- nonce,
470
539
  nonceKey,
471
- signature,
472
- validAfter,
473
540
  validBefore,
474
541
  } = transaction
475
542
 
@@ -477,25 +544,6 @@ export function prepareSponsoredTransaction(parameters: {
477
544
  throw new FeePayerValidationError(reason, { ...details, ...extra })
478
545
  }
479
546
 
480
- const unsupportedKeys = Object.entries(transaction).flatMap(([key, value]) => {
481
- if (value === undefined) return []
482
- if (supportedTransactionKeys.has(key)) return []
483
- return [key]
484
- })
485
- if (unsupportedKeys.length > 0)
486
- fail('fee-sponsored transaction contains unsupported fields', {
487
- unsupportedFields: unsupportedKeys.join(', '),
488
- })
489
-
490
- const rejectedKeys = rejectedTransactionKeys.filter((key) => {
491
- const value = transactionRecord[key]
492
- return value !== undefined && value !== null
493
- })
494
- if (rejectedKeys.length > 0)
495
- fail('fee-sponsored transaction contains rejected fields', {
496
- rejectedFields: rejectedKeys.join(', '),
497
- })
498
-
499
547
  if (transaction.type !== undefined && transaction.type !== 'tempo')
500
548
  fail('fee-sponsored transaction type is invalid', {
501
549
  type: String(transaction.type),
@@ -506,6 +554,8 @@ export function prepareSponsoredTransaction(parameters: {
506
554
  chainId: String(transactionChainId),
507
555
  })
508
556
 
557
+ assertCanonicalSponsoredTransaction(transaction, fail)
558
+
509
559
  if (gas === undefined || gas <= 0n) fail('fee-sponsored transaction must declare gas')
510
560
  const gasLimit = gas
511
561
  if (gasLimit > policy.maxGas)
@@ -562,6 +612,77 @@ export function prepareSponsoredTransaction(parameters: {
562
612
  validBefore: String(validBeforeValue),
563
613
  })
564
614
 
615
+ return { gasLimit, maxFeePerGasValue, validBeforeValue }
616
+ }
617
+
618
+ export function prepareSponsoredTransaction(parameters: {
619
+ account: Account
620
+ allowedFeeTokens?: readonly TempoAddress.Address[] | undefined
621
+ challengeExpires?: string | undefined
622
+ chainId: number
623
+ details: Record<string, string>
624
+ now?: Date | undefined
625
+ policy?: Partial<Policy> | undefined
626
+ transaction: SponsoredTransaction
627
+ }) {
628
+ const {
629
+ account,
630
+ allowedFeeTokens,
631
+ challengeExpires,
632
+ chainId,
633
+ details,
634
+ now = new Date(),
635
+ policy: policyOverrides,
636
+ transaction,
637
+ } = parameters
638
+ const transactionRecord = transaction as Record<string, unknown>
639
+
640
+ const {
641
+ accessList,
642
+ calls,
643
+ chainId: transactionChainId,
644
+ feeToken,
645
+ from,
646
+ keyAuthorization,
647
+ maxPriorityFeePerGas,
648
+ nonce,
649
+ nonceKey,
650
+ signature,
651
+ validAfter,
652
+ } = transaction
653
+
654
+ const fail = (reason: string, extra: Record<string, string> = {}) => {
655
+ throw new FeePayerValidationError(reason, { ...details, ...extra })
656
+ }
657
+
658
+ const unsupportedKeys = Object.entries(transaction).flatMap(([key, value]) => {
659
+ if (value === undefined) return []
660
+ if (supportedTransactionKeys.has(key)) return []
661
+ return [key]
662
+ })
663
+ if (unsupportedKeys.length > 0)
664
+ fail('fee-sponsored transaction contains unsupported fields', {
665
+ unsupportedFields: unsupportedKeys.join(', '),
666
+ })
667
+
668
+ const rejectedKeys = rejectedTransactionKeys.filter((key) => {
669
+ const value = transactionRecord[key]
670
+ return value !== undefined && value !== null
671
+ })
672
+ if (rejectedKeys.length > 0)
673
+ fail('fee-sponsored transaction contains rejected fields', {
674
+ rejectedFields: rejectedKeys.join(', '),
675
+ })
676
+
677
+ const { gasLimit, maxFeePerGasValue, validBeforeValue } = assertTransactionPolicy({
678
+ challengeExpires,
679
+ chainId,
680
+ details,
681
+ now,
682
+ policy: policyOverrides,
683
+ transaction,
684
+ })
685
+
565
686
  const normalizedFeeToken = (() => {
566
687
  if (feeToken === undefined) return undefined
567
688
  if (typeof feeToken !== 'string') fail('fee-sponsored transaction feeToken is invalid')
@@ -441,6 +441,10 @@ export function charge<const parameters extends charge.Parameters>(
441
441
  if (feePayerUrl && isFeePayerTx) {
442
442
  const hosted = await FeePayer.fillHostedFeePayerTransaction({
443
443
  allowedFeeTokens,
444
+ challengeExpires: expires,
445
+ chainId: chainId ?? client.chain!.id,
446
+ details: { amount, currency, recipient },
447
+ policy: feePayerPolicy,
444
448
  transaction,
445
449
  url: feePayerUrl,
446
450
  })
@@ -3,8 +3,8 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "accounts": "0.14.9",
6
+ "accounts": "0.14.11",
7
7
  "mppx": "workspace:*",
8
- "viem": "2.52.2"
8
+ "viem": "2.53.1"
9
9
  }
10
10
  }