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
@@ -31,6 +31,7 @@ import cli from './cli.js'
31
31
  const testPrivateKey = generatePrivateKey()
32
32
  const testAccount = privateKeyToAccount(testPrivateKey)
33
33
  const cliTestXdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-xdg-'))
34
+ const unfundedToken = '0x20c0000000000000000000000000000000000002' as Address
34
35
  const cliSessionFeePayerPolicy = {
35
36
  maxGas: 2_250_000n,
36
37
  maxTotalFee: 60_000_000_000_000_000n,
@@ -468,6 +469,199 @@ describe('basic charge (examples/basic)', () => {
468
469
  }
469
470
  })
470
471
 
472
+ test(
473
+ 'selects a later Tempo charge challenge when wallet has that token balance',
474
+ { timeout: 120_000 },
475
+ async () => {
476
+ const { Actions } = await import('viem/tempo')
477
+ const payerPrivateKey = generatePrivateKey()
478
+ const payer = privateKeyToAccount(payerPrivateKey)
479
+ await Actions.token.transferSync(client, {
480
+ account: accounts[0],
481
+ chain: client.chain,
482
+ token: asset,
483
+ to: payer.address,
484
+ amount: parseUnits('100', 6),
485
+ })
486
+ await Actions.token.transferSync(client, {
487
+ account: accounts[0],
488
+ chain: client.chain,
489
+ token: Addresses.pathUsd,
490
+ to: payer.address,
491
+ amount: parseUnits('100', 6),
492
+ })
493
+
494
+ const tempoMethod = tempo.charge({ getClient: () => client })
495
+ const server = Mppx_server.create({
496
+ methods: [tempoMethod],
497
+ realm: 'cli-test-balance-aware-offers',
498
+ secretKey: 'cli-test-secret-cli-test-secret-32',
499
+ })
500
+ let authorization: string | undefined
501
+
502
+ const httpServer = await Http.createServer(async (req, res) => {
503
+ if (req.headers.authorization) {
504
+ authorization = req.headers.authorization
505
+ res.end('paid-from-funded-token')
506
+ return
507
+ }
508
+
509
+ const result = await toNodeListener(
510
+ server.compose(
511
+ [
512
+ tempoMethod,
513
+ {
514
+ amount: '1',
515
+ currency: unfundedToken,
516
+ decimals: 6,
517
+ expires: new Date(Date.now() + 60_000).toISOString(),
518
+ recipient: accounts[0].address,
519
+ },
520
+ ],
521
+ [
522
+ tempoMethod,
523
+ {
524
+ amount: '1',
525
+ currency: asset,
526
+ decimals: 6,
527
+ expires: new Date(Date.now() + 60_000).toISOString(),
528
+ recipient: accounts[0].address,
529
+ },
530
+ ],
531
+ ),
532
+ )(req, res)
533
+ if (result.status === 402) return
534
+ res.end('unexpected')
535
+ })
536
+
537
+ try {
538
+ const { output, exitCode } = await serve([httpServer.url, '--rpc-url', rpcUrl, '-s'], {
539
+ env: { MPPX_PRIVATE_KEY: payerPrivateKey },
540
+ })
541
+
542
+ expect(exitCode, output).toBeUndefined()
543
+ expect(output).toContain('paid-from-funded-token')
544
+ expect(Credential.deserialize(authorization!).challenge.request.currency).toBe(asset)
545
+ } finally {
546
+ httpServer.close()
547
+ }
548
+ },
549
+ )
550
+
551
+ test('currency option selects a matching offered challenge', { timeout: 120_000 }, async () => {
552
+ const { Actions } = await import('viem/tempo')
553
+ const payerPrivateKey = generatePrivateKey()
554
+ const payer = privateKeyToAccount(payerPrivateKey)
555
+ await Actions.token.transferSync(client, {
556
+ account: accounts[0],
557
+ chain: client.chain,
558
+ token: asset,
559
+ to: payer.address,
560
+ amount: parseUnits('100', 6),
561
+ })
562
+ await Actions.token.transferSync(client, {
563
+ account: accounts[0],
564
+ chain: client.chain,
565
+ token: Addresses.pathUsd,
566
+ to: payer.address,
567
+ amount: parseUnits('100', 6),
568
+ })
569
+
570
+ const tempoMethod = tempo.charge({ getClient: () => client })
571
+ const server = Mppx_server.create({
572
+ methods: [tempoMethod],
573
+ realm: 'cli-test-currency-option',
574
+ secretKey: 'cli-test-secret-cli-test-secret-32',
575
+ })
576
+ let authorization: string | undefined
577
+
578
+ const httpServer = await Http.createServer(async (req, res) => {
579
+ if (req.headers.authorization) {
580
+ authorization = req.headers.authorization
581
+ res.end('paid-from-currency-option')
582
+ return
583
+ }
584
+
585
+ const result = await toNodeListener(
586
+ server.compose(
587
+ [
588
+ tempoMethod,
589
+ {
590
+ amount: '1',
591
+ currency: unfundedToken,
592
+ decimals: 6,
593
+ expires: new Date(Date.now() + 60_000).toISOString(),
594
+ recipient: accounts[0].address,
595
+ },
596
+ ],
597
+ [
598
+ tempoMethod,
599
+ {
600
+ amount: '1',
601
+ currency: asset,
602
+ decimals: 6,
603
+ expires: new Date(Date.now() + 60_000).toISOString(),
604
+ recipient: accounts[0].address,
605
+ },
606
+ ],
607
+ ),
608
+ )(req, res)
609
+ if (result.status === 402) return
610
+ res.end('unexpected')
611
+ })
612
+
613
+ try {
614
+ const { output, exitCode } = await serve(
615
+ [httpServer.url, '--rpc-url', rpcUrl, '--currency', asset, '-s'],
616
+ { env: { MPPX_PRIVATE_KEY: payerPrivateKey } },
617
+ )
618
+
619
+ expect(exitCode, output).toBeUndefined()
620
+ expect(output).toContain('paid-from-currency-option')
621
+ expect(Credential.deserialize(authorization!).challenge.request.currency).toBe(asset)
622
+ } finally {
623
+ httpServer.close()
624
+ }
625
+ })
626
+
627
+ test('currency option rejects currencies the server did not offer', async () => {
628
+ const tempoMethod = tempo.charge({ getClient: () => client })
629
+ const server = Mppx_server.create({
630
+ methods: [tempoMethod],
631
+ realm: 'cli-test-currency-unavailable',
632
+ secretKey: 'cli-test-secret-cli-test-secret-32',
633
+ })
634
+
635
+ const httpServer = await Http.createServer(async (req, res) => {
636
+ const result = await toNodeListener(
637
+ server.charge({
638
+ amount: '1',
639
+ currency: asset,
640
+ expires: new Date(Date.now() + 60_000).toISOString(),
641
+ recipient: accounts[0].address,
642
+ }),
643
+ )(req, res)
644
+ if (result.status === 402) return
645
+ res.end('unexpected')
646
+ })
647
+
648
+ try {
649
+ const { output, exitCode } = await serve([
650
+ httpServer.url,
651
+ '--currency',
652
+ Addresses.pathUsd,
653
+ '-s',
654
+ ])
655
+
656
+ expect(exitCode).toBe(2)
657
+ expect(output).toContain('UNSUPPORTED_CURRENCY')
658
+ expect(output).toContain(Addresses.pathUsd)
659
+ expect(output).toContain(asset)
660
+ } finally {
661
+ httpServer.close()
662
+ }
663
+ })
664
+
471
665
  test('selects a later supported challenge when the first offer is unsupported', async () => {
472
666
  const unsupportedMethod = Method.toServer(createMockChargeMethod('unknown'), {
473
667
  async verify() {
package/src/cli/cli.ts CHANGED
@@ -16,7 +16,11 @@ import { validate as validateDiscovery } from '../discovery/Validate.js'
16
16
  import { createDefaultStore, createKeychain, resolveAccountName } from './account.js'
17
17
  import { loadConfig, resolveAcceptPayment, selectChallenge } from './internal.js'
18
18
  import type { Plugin } from './plugins/plugin.js'
19
- import { readTempoKeystore, resolveTempoAccount } from './plugins/tempo.js'
19
+ import {
20
+ orderTempoChargeChallengesByBalance,
21
+ readTempoKeystore,
22
+ resolveTempoAccount,
23
+ } from './plugins/tempo.js'
20
24
  import {
21
25
  chainName,
22
26
  confirm,
@@ -151,6 +155,18 @@ function formatPayment(payment: unknown): string {
151
155
  .join(' ')
152
156
  }
153
157
 
158
+ function filterChallengesByCurrency(
159
+ challenges: readonly Challenge.Challenge[],
160
+ currency: string | undefined,
161
+ ): Challenge.Challenge[] {
162
+ if (!currency) return [...challenges]
163
+ const normalized = currency.toLowerCase()
164
+ return challenges.filter((challenge) => {
165
+ const offered = challenge.request.currency
166
+ return typeof offered === 'string' && offered.toLowerCase() === normalized
167
+ })
168
+ }
169
+
154
170
  async function fetchServicesRegistry(): Promise<ServiceRegistryService[]> {
155
171
  const url = process.env.MPPX_SERVICES_URL ?? servicesRegistryUrl
156
172
  const response = await globalThis.fetch(url)
@@ -198,6 +214,7 @@ const cli = Cli.create('mppx', {
198
214
  autoSwap: z.boolean().optional().describe('Auto-swap source tokens into payment currency'),
199
215
  config: z.string().optional().describe('Path to config file'),
200
216
  confirm: z.boolean().optional().default(false).describe('Show confirmation prompts'),
217
+ currency: z.string().optional().describe('Payment currency/token address to select'),
201
218
  data: z.string().optional().describe('Send request body (implies POST unless -X is set)'),
202
219
  fail: z.boolean().optional().describe('Fail silently on HTTP errors (exit 22)'),
203
220
  header: z.array(z.string()).optional().describe('Add header (repeatable)'),
@@ -357,12 +374,31 @@ const cli = Cli.create('mppx', {
357
374
  return
358
375
  }
359
376
 
360
- const selected = selectChallenge(
361
- Challenge.fromResponseList(challengeResponse),
362
- loaded?.config,
363
- )
377
+ const methodOpts = parseMethodOpts(c.options.methodOpt)
378
+ const offeredChallenges = Challenge.fromResponseList(challengeResponse)
379
+ const currencyChallenges = filterChallengesByCurrency(offeredChallenges, c.options.currency)
380
+ if (c.options.currency && currencyChallenges.length === 0) {
381
+ const offers = offeredChallenges
382
+ .map((challenge) => challenge.request.currency)
383
+ .filter((currency): currency is string => typeof currency === 'string')
384
+ .join(', ')
385
+ return c.error({
386
+ code: 'UNSUPPORTED_CURRENCY',
387
+ message: `Server did not offer payment currency ${c.options.currency}.${offers ? ` Offered currencies: ${offers}.` : ''}`,
388
+ exitCode: 2,
389
+ })
390
+ }
391
+ const challenges = await orderTempoChargeChallengesByBalance(currencyChallenges, {
392
+ options: {
393
+ account: c.options.account,
394
+ network: c.options.network,
395
+ rpcUrl: c.options.rpcUrl,
396
+ },
397
+ })
398
+
399
+ const selected = selectChallenge(challenges, loaded?.config)
364
400
  if (!selected) {
365
- const offers = Challenge.fromResponseList(challengeResponse)
401
+ const offers = challenges
366
402
  .map((challenge) => `${challenge.method}/${challenge.intent}`)
367
403
  .join(', ')
368
404
  return c.error({
@@ -393,7 +429,7 @@ const cli = Cli.create('mppx', {
393
429
  rpcUrl: c.options.rpcUrl,
394
430
  slippage: c.options.slippage,
395
431
  },
396
- methodOpts: parseMethodOpts(c.options.methodOpt),
432
+ methodOpts,
397
433
  })
398
434
  tokenSymbol = pluginResult.tokenSymbol
399
435
  tokenDecimals = pluginResult.tokenDecimals
@@ -41,35 +41,41 @@ describe('loadConfig', () => {
41
41
  expect(result?.path).toBe(configPath)
42
42
  })
43
43
 
44
+ test('loads config from explicit config file', async () => {
45
+ const configPath = path.join(tmpDir, 'custom.mjs')
46
+ fs.writeFileSync(configPath, 'export default { methods: [] }')
47
+ const result = await loadConfig(configPath)
48
+ expect(result?.config).toEqual({ methods: [] })
49
+ expect(result?.path).toBe(configPath)
50
+ })
51
+
44
52
  test('returns undefined when MPPX_CONFIG points to nonexistent file', async () => {
45
53
  vi.stubEnv('MPPX_CONFIG', path.join(tmpDir, 'nonexistent.ts'))
46
54
  const config = await loadConfig()
47
55
  expect(config).toBeUndefined()
48
56
  })
49
57
 
50
- test('loads mppx.config.mjs from cwd', async () => {
58
+ test('ignores mppx.config.mjs from cwd without explicit selection', async () => {
51
59
  const configPath = path.join(tmpDir, 'mppx.config.mjs')
52
60
  fs.writeFileSync(configPath, 'export default { methods: [] }')
53
61
  vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
54
62
  const result = await loadConfig()
55
- expect(result?.config).toEqual({ methods: [] })
56
- expect(result?.path).toBe(configPath)
63
+ expect(result).toBeUndefined()
57
64
  vi.mocked(process.cwd).mockRestore()
58
65
  })
59
66
 
60
- test('walks up from cwd to find config', async () => {
67
+ test('does not walk up from cwd to find config', async () => {
61
68
  const nested = path.join(tmpDir, 'a', 'b', 'c')
62
69
  fs.mkdirSync(nested, { recursive: true })
63
70
  const configPath = path.join(tmpDir, 'mppx.config.mjs')
64
71
  fs.writeFileSync(configPath, 'export default { methods: [] }')
65
72
  vi.spyOn(process, 'cwd').mockReturnValue(nested)
66
73
  const result = await loadConfig()
67
- expect(result?.config).toEqual({ methods: [] })
68
- expect(result?.path).toBe(configPath)
74
+ expect(result).toBeUndefined()
69
75
  vi.mocked(process.cwd).mockRestore()
70
76
  })
71
77
 
72
- test('MPPX_CONFIG takes priority over cwd config', async () => {
78
+ test('MPPX_CONFIG loads even when cwd config exists', async () => {
73
79
  fs.writeFileSync(path.join(tmpDir, 'mppx.config.mjs'), 'export default { methods: ["cwd"] }')
74
80
  const envConfig = path.join(tmpDir, 'env.mjs')
75
81
  fs.writeFileSync(envConfig, 'export default { methods: ["env"] }')
@@ -84,8 +84,7 @@ function supportsPlugin(plugin: Plugin, challenge: Challenge.Challenge): boolean
84
84
  return plugin.supports ? plugin.supports(challenge) : plugin.method === challenge.method
85
85
  }
86
86
 
87
- const CONFIG_NAMES = ['mppx.config.ts', 'mppx.config.js', 'mppx.config.mjs'] as const
88
-
87
+ /** Loads CLI config only from explicit, user-selected paths. */
89
88
  export async function loadConfig(
90
89
  configFile?: string | undefined,
91
90
  ): Promise<{ config: Config; path: string } | undefined> {
@@ -111,19 +110,5 @@ function resolveConfigPath(configFile?: string | undefined): string | undefined
111
110
  return undefined
112
111
  }
113
112
 
114
- // 2. Walk up from cwd, stopping at project root
115
- let dir = process.cwd()
116
- while (true) {
117
- for (const name of CONFIG_NAMES) {
118
- const candidate = path.join(dir, name)
119
- if (fs.existsSync(candidate)) return candidate
120
- }
121
- const isProjectRoot =
122
- fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))
123
- const parent = path.dirname(dir)
124
- if (isProjectRoot || parent === dir) break
125
- dir = parent
126
- }
127
-
128
113
  return undefined
129
114
  }
@@ -9,7 +9,9 @@ import { Base64 } from 'ox'
9
9
  import type { Address } from 'viem'
10
10
  import { createClient, http } from 'viem'
11
11
  import { privateKeyToAccount } from 'viem/accounts'
12
+ import { Actions } from 'viem/tempo'
12
13
 
14
+ import type * as Challenge from '../../Challenge.js'
13
15
  import { normalizeHeaders } from '../../client/internal/Fetch.js'
14
16
  import * as Constants from '../../Constants.js'
15
17
  import * as Credential from '../../Credential.js'
@@ -30,10 +32,88 @@ import {
30
32
  pc,
31
33
  resolveChain,
32
34
  resolveRpcUrl,
35
+ type Network,
33
36
  } from '../utils.js'
34
37
  import { createPlugin, type Plugin } from './plugin.js'
35
38
 
36
39
  const packageJson = createRequire(import.meta.url)('../../../package.json') as { name: string }
40
+ const booleanOption = z.union([
41
+ z.boolean(),
42
+ z.literal('true').transform(() => true),
43
+ z.literal('false').transform(() => false),
44
+ ])
45
+ const tempoOptionSchema = z.object({
46
+ autoSwap: z.optional(booleanOption),
47
+ channel: z.optional(z.coerce.string()),
48
+ deposit: z.optional(z.union([z.string(), z.number()])),
49
+ payWith: z.optional(z.string()),
50
+ slippage: z.optional(z.coerce.number()),
51
+ tokenIn: z.optional(z.string()),
52
+ })
53
+ const tempoOptionKeys = [
54
+ 'autoSwap',
55
+ 'channel',
56
+ 'deposit',
57
+ 'payWith',
58
+ 'slippage',
59
+ 'tokenIn',
60
+ ] as const
61
+ const tempoChargeChallengeScoreOrder = [
62
+ 'payable',
63
+ 'unknownBalance',
64
+ 'insufficientBalance',
65
+ 'unsupported',
66
+ ] as const
67
+
68
+ type TempoChargeChallengeScore = (typeof tempoChargeChallengeScoreOrder)[number]
69
+
70
+ /**
71
+ * Orders Tempo charge challenges by whether the configured wallet can satisfy them.
72
+ *
73
+ * Direct balances are preferred first. Unsupported or unpayable challenges keep
74
+ * their original relative order.
75
+ */
76
+ export async function orderTempoChargeChallengesByBalance(
77
+ challenges: readonly Challenge.Challenge[],
78
+ ctx: {
79
+ options: {
80
+ account?: string | undefined
81
+ network?: Network | undefined
82
+ rpcUrl?: string | undefined
83
+ }
84
+ },
85
+ ): Promise<Challenge.Challenge[]> {
86
+ const tempoPositions: number[] = []
87
+ const tempoChallenges: Challenge.Challenge[] = []
88
+ for (const [index, challenge] of challenges.entries()) {
89
+ if (!isTempoChargeChallenge(challenge)) continue
90
+ tempoPositions.push(index)
91
+ tempoChallenges.push(challenge)
92
+ }
93
+ if (tempoChallenges.length < 2) return [...challenges]
94
+
95
+ const probe = await resolveBalanceProbe(ctx).catch(() => undefined)
96
+ if (!probe) return [...challenges]
97
+
98
+ const scored = await Promise.all(
99
+ tempoChallenges.map(async (challenge, index) => ({
100
+ challenge,
101
+ index,
102
+ score: await scoreTempoChargeChallenge(challenge, probe),
103
+ })),
104
+ )
105
+ scored.sort(
106
+ (left, right) =>
107
+ tempoChargeChallengeScoreOrder.indexOf(left.score) -
108
+ tempoChargeChallengeScoreOrder.indexOf(right.score) || left.index - right.index,
109
+ )
110
+
111
+ const ordered = [...challenges]
112
+ for (const [index, position] of tempoPositions.entries()) {
113
+ ordered[position] = scored[index]!.challenge
114
+ }
115
+ return ordered
116
+ }
37
117
 
38
118
  export function tempo() {
39
119
  let _session:
@@ -60,23 +140,7 @@ export function tempo() {
60
140
  const accountName = resolveAccountName(options.account)
61
141
  const challengeRequest = challenge.request as Record<string, unknown>
62
142
  const currency = challengeRequest.currency as string | undefined
63
- const booleanOption = z.union([
64
- z.boolean(),
65
- z.literal('true').transform(() => true),
66
- z.literal('false').transform(() => false),
67
- ])
68
- const tempoOpts = parseOptions(
69
- z.object({
70
- autoSwap: z.optional(booleanOption),
71
- channel: z.optional(z.coerce.string()),
72
- deposit: z.optional(z.union([z.string(), z.number()])),
73
- payWith: z.optional(z.string()),
74
- slippage: z.optional(z.coerce.number()),
75
- tokenIn: z.optional(z.string()),
76
- }),
77
- methodOpts,
78
- ['autoSwap', 'channel', 'deposit', 'payWith', 'slippage', 'tokenIn'],
79
- )
143
+ const tempoOpts = parseTempoOptions(methodOpts)
80
144
  const autoSwap = resolveAutoSwap({
81
145
  autoSwap: tempoOpts.autoSwap ?? options.autoSwap,
82
146
  payWith: tempoOpts.payWith ?? options.payWith,
@@ -746,6 +810,85 @@ function detectTerminalBg(
746
810
 
747
811
  // --- Account helpers ---
748
812
 
813
+ type BalanceProbe = {
814
+ account: Address
815
+ client: ReturnType<typeof createClient>
816
+ }
817
+
818
+ function parseTempoOptions(rawOptions: Record<string, string>) {
819
+ return parseOptions(tempoOptionSchema, rawOptions, tempoOptionKeys)
820
+ }
821
+
822
+ function isTempoChargeChallenge(challenge: Challenge.Challenge): boolean {
823
+ return challenge.method === 'tempo' && challenge.intent === 'charge'
824
+ }
825
+
826
+ async function resolveBalanceProbe(ctx: {
827
+ options: {
828
+ account?: string | undefined
829
+ network?: Network | undefined
830
+ rpcUrl?: string | undefined
831
+ }
832
+ }): Promise<BalanceProbe | undefined> {
833
+ const accountName = resolveAccountName(ctx.options.account)
834
+ const privateKey =
835
+ process.env.MPPX_PRIVATE_KEY?.trim() ||
836
+ (isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
837
+
838
+ let account: Address | undefined
839
+ if (privateKey) {
840
+ account = privateKeyToAccount(privateKey as `0x${string}`).address
841
+ } else if (isTempoAccount(accountName) && hasTempoCliSync()) {
842
+ account = resolveTempoAccount(accountName)?.wallet_address as Address | undefined
843
+ } else {
844
+ const fallback = fallbackFromTempo()
845
+ if (fallback) {
846
+ const fallbackKey = await createKeychain(fallback).get()
847
+ if (fallbackKey) account = privateKeyToAccount(fallbackKey as `0x${string}`).address
848
+ }
849
+ }
850
+ if (!account) return undefined
851
+
852
+ const rpcUrl = resolveRpcUrl(ctx.options.rpcUrl, { network: ctx.options.network })
853
+ const client = createClient({
854
+ chain: await resolveChain({ network: ctx.options.network, rpcUrl }),
855
+ transport: http(rpcUrl),
856
+ })
857
+
858
+ return {
859
+ account,
860
+ client,
861
+ }
862
+ }
863
+
864
+ async function scoreTempoChargeChallenge(
865
+ challenge: Challenge.Challenge,
866
+ probe: BalanceProbe,
867
+ ): Promise<TempoChargeChallengeScore> {
868
+ const request = challenge.request as Record<string, unknown>
869
+ const currency = request.currency
870
+ const amount = request.amount
871
+ if (typeof currency !== 'string' || typeof amount !== 'string') return 'unsupported'
872
+
873
+ const requiredChainId = (request.methodDetails as { chainId?: number } | undefined)?.chainId
874
+ if (requiredChainId && probe.client.chain?.id && requiredChainId !== probe.client.chain.id) {
875
+ return 'unsupported'
876
+ }
877
+
878
+ const amountRaw = BigInt(amount)
879
+ if (amountRaw === 0n) return 'payable'
880
+
881
+ const balance = await Actions.token
882
+ .getBalance(probe.client as never, {
883
+ account: probe.account,
884
+ token: currency as Address,
885
+ })
886
+ .catch(() => undefined)
887
+ if (balance === undefined) return 'unknownBalance'
888
+ if (balance >= amountRaw) return 'payable'
889
+ return 'insufficientBalance'
890
+ }
891
+
749
892
  function parseOptions<const schema extends z.ZodType>(
750
893
  schema: schema,
751
894
  rawOptions: unknown,
@@ -62,6 +62,15 @@ describe('create.Config', () => {
62
62
  expectTypeOf<Config>().toHaveProperty('methods')
63
63
  })
64
64
 
65
+ test('accepts payment retry cap', () => {
66
+ const mppx = Mppx.create({
67
+ maxPaymentRetries: 1,
68
+ methods: [charge()],
69
+ })
70
+
71
+ expectTypeOf(mppx.fetch).toBeFunction()
72
+ })
73
+
65
74
  test('paymentPreferences callback exposes typed method keys', () => {
66
75
  const mppx = Mppx.create({
67
76
  methods: [tempo({ account: {} as Account })],
@@ -41,6 +41,47 @@ describe('Mppx.create', () => {
41
41
  expect(mppx.transport.name).toBe('mcp')
42
42
  })
43
43
 
44
+ test('behavior: passes maxPaymentRetries to fetch wrapper', async () => {
45
+ const testMethod = Method.toClient(
46
+ Method.from({
47
+ name: 'test',
48
+ intent: 'test',
49
+ schema: Methods.charge.schema,
50
+ }),
51
+ {
52
+ async createCredential() {
53
+ return 'credential'
54
+ },
55
+ },
56
+ )
57
+ const challenge = Challenge.from({
58
+ id: 'retry-cap',
59
+ realm,
60
+ method: 'test',
61
+ intent: 'test',
62
+ request: { amount: '1' },
63
+ })
64
+ let callCount = 0
65
+ const fetch = vi.fn(async () => {
66
+ callCount++
67
+ return new Response(null, {
68
+ status: 402,
69
+ headers: { 'WWW-Authenticate': Challenge.serialize(challenge) },
70
+ })
71
+ })
72
+ const mppx = Mppx.create({
73
+ fetch: fetch as typeof globalThis.fetch,
74
+ maxPaymentRetries: 1,
75
+ methods: [testMethod],
76
+ polyfill: false,
77
+ })
78
+
79
+ const response = await mppx.fetch('https://example.com/api')
80
+
81
+ expect(response.status).toBe(402)
82
+ expect(callCount).toBe(2)
83
+ })
84
+
44
85
  test('behavior: with multiple methods', () => {
45
86
  const stripeCharge = Method.from({
46
87
  name: 'stripe',
@@ -100,6 +100,7 @@ export function create<
100
100
  >,
101
101
  >(config: create.Config<methods, transport>): Mppx<methods, transport> {
102
102
  const {
103
+ maxPaymentRetries,
103
104
  onChallenge,
104
105
  orderChallenges,
105
106
  polyfill = true,
@@ -122,6 +123,7 @@ export function create<
122
123
  acceptPaymentPolicy,
123
124
  ...(config.fetch && { fetch: config.fetch }),
124
125
  eventDispatcher: events,
126
+ ...(maxPaymentRetries !== undefined && { maxPaymentRetries }),
125
127
  ...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
126
128
  ...(orderChallenges && { orderChallenges }),
127
129
  methods,
@@ -298,6 +300,8 @@ export declare namespace create {
298
300
  },
299
301
  ) => Promise<string | undefined>)
300
302
  | undefined
303
+ /** Maximum number of payment challenge retries after the initial response. @default 3 */
304
+ maxPaymentRetries?: Fetch.from.Config['maxPaymentRetries'] | undefined
301
305
  /** Filters and sorts supported challenges before credential creation. */
302
306
  orderChallenges?: AcceptPayment.OrderChallenges<FlattenMethods<methods>> | undefined
303
307
  /** Client-declared supported payment methods, keyed by typed `method/intent` strings. */