mppx 0.6.16 → 0.6.18

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 (61) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +0 -10
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Receipt.d.ts +0 -5
  5. package/dist/Receipt.d.ts.map +1 -1
  6. package/dist/cli/cli.d.ts +4 -0
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +230 -10
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/plugins/plugin.d.ts +5 -0
  11. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  12. package/dist/cli/plugins/plugin.js.map +1 -1
  13. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.js +7 -2
  15. package/dist/cli/plugins/stripe.js.map +1 -1
  16. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  17. package/dist/cli/plugins/tempo.js +69 -9
  18. package/dist/cli/plugins/tempo.js.map +1 -1
  19. package/dist/cli/utils.d.ts +10 -2
  20. package/dist/cli/utils.d.ts.map +1 -1
  21. package/dist/cli/utils.js +11 -4
  22. package/dist/cli/utils.js.map +1 -1
  23. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  24. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  25. package/dist/stripe/server/internal/html.gen.js +1 -1
  26. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  27. package/dist/tempo/internal/fee-token.d.ts +7 -0
  28. package/dist/tempo/internal/fee-token.d.ts.map +1 -0
  29. package/dist/tempo/internal/fee-token.js +44 -0
  30. package/dist/tempo/internal/fee-token.js.map +1 -0
  31. package/dist/tempo/server/Session.d.ts.map +1 -1
  32. package/dist/tempo/server/Session.js +2 -0
  33. package/dist/tempo/server/Session.js.map +1 -1
  34. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  35. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  36. package/dist/tempo/server/internal/html.gen.js +1 -1
  37. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  38. package/dist/tempo/session/Chain.d.ts +4 -0
  39. package/dist/tempo/session/Chain.d.ts.map +1 -1
  40. package/dist/tempo/session/Chain.js +19 -35
  41. package/dist/tempo/session/Chain.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/cli/cli.test.ts +120 -0
  44. package/src/cli/cli.ts +254 -10
  45. package/src/cli/mcp.test.ts +3 -0
  46. package/src/cli/plugins/plugin.ts +9 -1
  47. package/src/cli/plugins/stripe.ts +7 -0
  48. package/src/cli/plugins/tempo.ts +83 -12
  49. package/src/cli/utils.test.ts +12 -1
  50. package/src/cli/utils.ts +23 -5
  51. package/src/stripe/server/internal/html/node_modules/.bin/mppx +22 -0
  52. package/src/stripe/server/internal/html/node_modules/.bin/mppx.src +3 -2
  53. package/src/stripe/server/internal/html.gen.ts +1 -1
  54. package/src/tempo/internal/fee-token.test.ts +123 -0
  55. package/src/tempo/internal/fee-token.ts +51 -0
  56. package/src/tempo/server/Charge.test.ts +18 -2
  57. package/src/tempo/server/Session.ts +2 -0
  58. package/src/tempo/server/internal/html/node_modules/.bin/mppx +22 -0
  59. package/src/tempo/server/internal/html/node_modules/.bin/mppx.src +3 -2
  60. package/src/tempo/server/internal/html.gen.ts +1 -1
  61. package/src/tempo/session/Chain.ts +54 -42
package/src/cli/cli.ts CHANGED
@@ -60,6 +60,24 @@ const discoveryIssueSchema = z.object({
60
60
  severity: z.string(),
61
61
  })
62
62
 
63
+ const serviceSummarySchema = z.object({
64
+ description: z.string().optional(),
65
+ id: z.string(),
66
+ name: z.string().optional(),
67
+ paidEndpoints: z.number(),
68
+ status: z.string().optional(),
69
+ url: z.string().optional(),
70
+ })
71
+
72
+ const serviceEndpointSchema = z.object({
73
+ description: z.string().optional(),
74
+ method: z.string(),
75
+ path: z.string(),
76
+ payment: z.unknown().optional(),
77
+ })
78
+
79
+ const servicesRegistryUrl = 'https://mpp.dev/api/services'
80
+
63
81
  function shouldReturnStructured(c: { format: string; formatExplicit: boolean }) {
64
82
  return c.format === 'json' && c.formatExplicit
65
83
  }
@@ -79,6 +97,93 @@ function canReadCommandStdin() {
79
97
  return process.stdin.listenerCount('data') === 0 && process.stdin.listenerCount('readable') === 0
80
98
  }
81
99
 
100
+ type ServiceRegistryService = Record<string, unknown>
101
+ type ServiceRegistryEndpoint = Record<string, unknown>
102
+
103
+ function getString(value: unknown): string | undefined {
104
+ return typeof value === 'string' ? value : undefined
105
+ }
106
+
107
+ function getEndpoints(service: ServiceRegistryService): ServiceRegistryEndpoint[] {
108
+ return Array.isArray(service.endpoints)
109
+ ? service.endpoints.filter(
110
+ (endpoint): endpoint is ServiceRegistryEndpoint =>
111
+ typeof endpoint === 'object' && endpoint !== null,
112
+ )
113
+ : []
114
+ }
115
+
116
+ function summarizeService(service: ServiceRegistryService) {
117
+ const endpoints = getEndpoints(service)
118
+ return {
119
+ ...(getString(service.description) ? { description: getString(service.description) } : {}),
120
+ id: getString(service.id) ?? getString(service.name) ?? 'unknown',
121
+ ...(getString(service.name) ? { name: getString(service.name) } : {}),
122
+ paidEndpoints: endpoints.filter((endpoint) => endpoint.payment).length,
123
+ ...(getString(service.status) ? { status: getString(service.status) } : {}),
124
+ ...(getString(service.serviceUrl) || getString(service.url)
125
+ ? { url: getString(service.serviceUrl) ?? getString(service.url) }
126
+ : {}),
127
+ }
128
+ }
129
+
130
+ function summarizeEndpoint(endpoint: ServiceRegistryEndpoint) {
131
+ return {
132
+ ...(getString(endpoint.description) ? { description: getString(endpoint.description) } : {}),
133
+ method: getString(endpoint.method) ?? 'GET',
134
+ path: getString(endpoint.path) ?? '/',
135
+ ...(endpoint.payment !== undefined ? { payment: endpoint.payment } : {}),
136
+ }
137
+ }
138
+
139
+ function formatPayment(payment: unknown): string {
140
+ if (!payment) return 'free'
141
+ if (typeof payment !== 'object') return String(payment)
142
+ const p = payment as Record<string, unknown>
143
+ const amount = getString(p.amount)
144
+ const currency = getString(p.currency)
145
+ const method = getString(p.method)
146
+ const intent = getString(p.intent)
147
+ return [amount, currency, method && intent ? `${method}/${intent}` : (method ?? intent)]
148
+ .filter(Boolean)
149
+ .join(' ')
150
+ }
151
+
152
+ async function fetchServicesRegistry(): Promise<ServiceRegistryService[]> {
153
+ const url = process.env.MPPX_SERVICES_URL ?? servicesRegistryUrl
154
+ const response = await globalThis.fetch(url)
155
+ if (!response.ok)
156
+ throw new Errors.IncurError({
157
+ code: 'SERVICES_FETCH_FAILED',
158
+ message: `Failed to fetch services registry: HTTP ${response.status}`,
159
+ exitCode: 1,
160
+ })
161
+ const json = (await response.json()) as unknown
162
+ if (
163
+ !json ||
164
+ typeof json !== 'object' ||
165
+ !Array.isArray((json as { services?: unknown }).services)
166
+ )
167
+ throw new Errors.IncurError({
168
+ code: 'SERVICES_INVALID',
169
+ message: 'Services registry response did not contain a services array.',
170
+ exitCode: 1,
171
+ })
172
+ return (json as { services: ServiceRegistryService[] }).services
173
+ }
174
+
175
+ function findService(
176
+ services: ServiceRegistryService[],
177
+ id: string,
178
+ ): ServiceRegistryService | undefined {
179
+ const needle = id.toLowerCase()
180
+ return services.find((service) => {
181
+ const serviceId = getString(service.id)?.toLowerCase()
182
+ const serviceName = getString(service.name)?.toLowerCase()
183
+ return serviceId === needle || serviceName === needle
184
+ })
185
+ }
186
+
82
187
  const cli = Cli.create('mppx', {
83
188
  version: packageJson.version,
84
189
  description: 'Make HTTP requests with automatic payment handling',
@@ -88,6 +193,7 @@ const cli = Cli.create('mppx', {
88
193
  }),
89
194
  options: z.object({
90
195
  account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
196
+ autoSwap: z.boolean().optional().describe('Auto-swap source tokens into payment currency'),
91
197
  config: z.string().optional().describe('Path to config file'),
92
198
  confirm: z.boolean().optional().default(false).describe('Show confirmation prompts'),
93
199
  data: z.string().optional().describe('Send request body (implies POST unless -X is set)'),
@@ -108,11 +214,14 @@ const cli = Cli.create('mppx', {
108
214
  .array(z.string())
109
215
  .optional()
110
216
  .describe('Method-specific option (key=value, repeatable)'),
217
+ network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
218
+ payWith: z.string().optional().describe('Source token for Tempo auto-swap'),
111
219
  rpcUrl: z
112
220
  .string()
113
221
  .optional()
114
222
  .describe('RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)'),
115
223
  silent: z.boolean().default(false).describe('Silent mode (suppress progress and info)'),
224
+ slippage: z.number().optional().describe('Tempo auto-swap max slippage percentage'),
116
225
  userAgent: z
117
226
  .string()
118
227
  .optional()
@@ -274,7 +383,14 @@ const cli = Cli.create('mppx', {
274
383
  if (plugin) {
275
384
  pluginResult = await plugin.setup({
276
385
  challenge,
277
- options: { account: c.options.account, rpcUrl: c.options.rpcUrl },
386
+ options: {
387
+ account: c.options.account,
388
+ autoSwap: c.options.autoSwap,
389
+ network: c.options.network,
390
+ payWith: c.options.payWith,
391
+ rpcUrl: c.options.rpcUrl,
392
+ slippage: c.options.slippage,
393
+ },
278
394
  methodOpts: parseMethodOpts(c.options.methodOpt),
279
395
  })
280
396
  tokenSymbol = pluginResult.tokenSymbol
@@ -542,6 +658,7 @@ const account = Cli.create('account', {
542
658
  description: 'Create new account',
543
659
  options: z.object({
544
660
  account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
661
+ network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
545
662
  rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
546
663
  }),
547
664
  output: z.object({ address: z.string(), name: z.string() }),
@@ -587,8 +704,8 @@ const account = Cli.create('account', {
587
704
  const addrDisplay = explorerUrl
588
705
  ? link(`${explorerUrl}/address/${acct.address}`, acct.address)
589
706
  : acct.address
590
- const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
591
- resolveChain({ rpcUrl })
707
+ const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
708
+ resolveChain({ network: c.options.network, rpcUrl })
592
709
  .then((chain) => createClient({ chain, transport: http(rpcUrl) }))
593
710
  .then((client) =>
594
711
  import('viem/tempo').then(({ Actions }) =>
@@ -702,6 +819,7 @@ const account = Cli.create('account', {
702
819
  description: 'Fund account with testnet tokens',
703
820
  options: z.object({
704
821
  account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
822
+ network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
705
823
  rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
706
824
  }),
707
825
  output: z.object({ account: z.string(), chain: z.string(), transactions: z.array(z.string()) }),
@@ -722,8 +840,8 @@ const account = Cli.create('account', {
722
840
  return c.error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
723
841
  }
724
842
  const acct = privateKeyToAccount(key as `0x${string}`)
725
- const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
726
- const chain = await resolveChain({ rpcUrl })
843
+ const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
844
+ const chain = await resolveChain({ network: c.options.network, rpcUrl })
727
845
  const client = createClient({ chain, transport: http(rpcUrl) })
728
846
  if (!structured) console.log(`Funding "${accountName}" on ${chainName(chain)}`)
729
847
  try {
@@ -852,6 +970,7 @@ const account = Cli.create('account', {
852
970
  description: 'View account address',
853
971
  options: z.object({
854
972
  account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
973
+ network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
855
974
  rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
856
975
  }),
857
976
  output: accountViewSchema,
@@ -869,8 +988,8 @@ const account = Cli.create('account', {
869
988
  })
870
989
  }
871
990
  const address = tempoEntry.wallet_address as Address
872
- const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
873
- const chain = await resolveChain({ rpcUrl })
991
+ const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
992
+ const chain = await resolveChain({ network: c.options.network, rpcUrl })
874
993
  const explorerUrl = chain.blockExplorers?.default?.url
875
994
  const addrDisplay = explorerUrl
876
995
  ? link(`${explorerUrl}/address/${address}`, address)
@@ -913,8 +1032,8 @@ const account = Cli.create('account', {
913
1032
  return c.error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
914
1033
  }
915
1034
  const acct = privateKeyToAccount(key as `0x${string}`)
916
- const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
917
- const chain = await resolveChain({ rpcUrl })
1035
+ const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
1036
+ const chain = await resolveChain({ network: c.options.network, rpcUrl })
918
1037
  const explorerUrl = chain.blockExplorers?.default?.url
919
1038
  const addrDisplay = explorerUrl
920
1039
  ? link(`${explorerUrl}/address/${acct.address}`, acct.address)
@@ -948,14 +1067,18 @@ const sign = Cli.create('sign', {
948
1067
  challenge: z.string().optional().describe('WWW-Authenticate challenge value'),
949
1068
  config: z.string().optional().describe('Path to config file'),
950
1069
  dryRun: z.boolean().optional().describe('Validate and parse the challenge without signing'),
1070
+ autoSwap: z.boolean().optional().describe('Auto-swap source tokens into payment currency'),
951
1071
  methodOpt: z
952
1072
  .array(z.string())
953
1073
  .optional()
954
1074
  .describe('Method-specific option (key=value, repeatable)'),
1075
+ network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
1076
+ payWith: z.string().optional().describe('Source token for Tempo auto-swap'),
955
1077
  rpcUrl: z
956
1078
  .string()
957
1079
  .optional()
958
1080
  .describe('RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)'),
1081
+ slippage: z.number().optional().describe('Tempo auto-swap max slippage percentage'),
959
1082
  }),
960
1083
  output: z.object({ authorization: z.string() }),
961
1084
  alias: {
@@ -1029,7 +1152,14 @@ const sign = Cli.create('sign', {
1029
1152
  if (plugin) {
1030
1153
  const result = await plugin.setup({
1031
1154
  challenge,
1032
- options: { account: c.options.account, rpcUrl: c.options.rpcUrl },
1155
+ options: {
1156
+ account: c.options.account,
1157
+ autoSwap: c.options.autoSwap,
1158
+ network: c.options.network,
1159
+ payWith: c.options.payWith,
1160
+ rpcUrl: c.options.rpcUrl,
1161
+ slippage: c.options.slippage,
1162
+ },
1033
1163
  methodOpts,
1034
1164
  })
1035
1165
  if (result.createCredential) {
@@ -1104,6 +1234,119 @@ export default defineConfig({
1104
1234
  },
1105
1235
  })
1106
1236
 
1237
+ const services = Cli.create('services', {
1238
+ description: 'Browse the MPP services registry',
1239
+ })
1240
+ .command('list', {
1241
+ description: 'List registered MPP services',
1242
+ options: z.object({
1243
+ query: z.string().optional().describe('Filter by id, name, category, tag, or description'),
1244
+ }),
1245
+ output: z.object({ services: z.array(serviceSummarySchema) }),
1246
+ alias: { query: 'q' },
1247
+ async run(c) {
1248
+ const query = c.options.query?.toLowerCase()
1249
+ const registry = await fetchServicesRegistry()
1250
+ const filtered = query
1251
+ ? registry.filter((service) =>
1252
+ [
1253
+ service.id,
1254
+ service.name,
1255
+ service.description,
1256
+ ...(Array.isArray(service.categories) ? service.categories : []),
1257
+ ...(Array.isArray(service.tags) ? service.tags : []),
1258
+ ]
1259
+ .filter((value): value is string => typeof value === 'string')
1260
+ .some((value) => value.toLowerCase().includes(query)),
1261
+ )
1262
+ : registry
1263
+ const summaries = filtered.map(summarizeService).sort((a, b) => a.id.localeCompare(b.id))
1264
+ return outputResult(c, { services: summaries }, () => {
1265
+ if (summaries.length === 0) {
1266
+ console.log('No services found.')
1267
+ return
1268
+ }
1269
+ const idWidth = Math.max(...summaries.map((service) => service.id.length))
1270
+ const paidWidth = Math.max(
1271
+ ...summaries.map((service) => String(service.paidEndpoints).length),
1272
+ )
1273
+ for (const service of summaries) {
1274
+ const name = service.name && service.name !== service.id ? ` ${service.name}` : ''
1275
+ const url = service.url ? ` ${pc.dim(service.url)}` : ''
1276
+ console.log(
1277
+ `${service.id.padEnd(idWidth)} ${String(service.paidEndpoints).padStart(paidWidth)} paid${name}${url}`,
1278
+ )
1279
+ }
1280
+ })
1281
+ },
1282
+ })
1283
+ .command('show', {
1284
+ description: 'Show one registered MPP service',
1285
+ args: z.object({
1286
+ service: z.string().describe('Service id or name'),
1287
+ }),
1288
+ output: z.object({ service: z.record(z.string(), z.unknown()) }),
1289
+ async run(c) {
1290
+ const registry = await fetchServicesRegistry()
1291
+ const service = findService(registry, c.args.service)
1292
+ if (!service)
1293
+ return c.error({
1294
+ code: 'SERVICE_NOT_FOUND',
1295
+ message: `Service not found: ${c.args.service}`,
1296
+ exitCode: 1,
1297
+ })
1298
+ const summary = summarizeService(service)
1299
+ const endpoints = getEndpoints(service)
1300
+ return outputResult(c, { service }, () => {
1301
+ console.log(`${summary.name ?? summary.id} ${pc.dim(`(${summary.id})`)}`)
1302
+ if (summary.description) console.log(summary.description)
1303
+ if (summary.url) console.log(`${pc.dim('URL')} ${link(summary.url, summary.url)}`)
1304
+ if (summary.status) console.log(`${pc.dim('Status')} ${summary.status}`)
1305
+ console.log(`${pc.dim('Endpoints')} ${endpoints.length} (${summary.paidEndpoints} paid)`)
1306
+ const docs = service.docs as Record<string, unknown> | undefined
1307
+ const homepage = docs && getString(docs.homepage)
1308
+ if (homepage) console.log(`${pc.dim('Docs')} ${link(homepage, homepage)}`)
1309
+ })
1310
+ },
1311
+ })
1312
+ .command('endpoints', {
1313
+ description: 'List endpoints for a registered MPP service',
1314
+ args: z.object({
1315
+ service: z.string().describe('Service id or name'),
1316
+ }),
1317
+ output: z.object({
1318
+ endpoints: z.array(serviceEndpointSchema),
1319
+ service: serviceSummarySchema,
1320
+ }),
1321
+ async run(c) {
1322
+ const registry = await fetchServicesRegistry()
1323
+ const service = findService(registry, c.args.service)
1324
+ if (!service)
1325
+ return c.error({
1326
+ code: 'SERVICE_NOT_FOUND',
1327
+ message: `Service not found: ${c.args.service}`,
1328
+ exitCode: 1,
1329
+ })
1330
+ const summary = summarizeService(service)
1331
+ const endpoints = getEndpoints(service).map(summarizeEndpoint)
1332
+ return outputResult(c, { endpoints, service: summary }, () => {
1333
+ if (endpoints.length === 0) {
1334
+ console.log(`No endpoints found for ${summary.id}.`)
1335
+ return
1336
+ }
1337
+ const methodWidth = Math.max(...endpoints.map((endpoint) => endpoint.method.length))
1338
+ const pathWidth = Math.max(...endpoints.map((endpoint) => endpoint.path.length))
1339
+ for (const endpoint of endpoints) {
1340
+ const payment = formatPayment(endpoint.payment)
1341
+ const description = endpoint.description ? ` ${pc.dim(endpoint.description)}` : ''
1342
+ console.log(
1343
+ `${endpoint.method.padEnd(methodWidth)} ${endpoint.path.padEnd(pathWidth)} ${payment}${description}`,
1344
+ )
1345
+ }
1346
+ })
1347
+ },
1348
+ })
1349
+
1107
1350
  const discover = Cli.create('discover', {
1108
1351
  description: 'Discovery tooling',
1109
1352
  })
@@ -1281,6 +1524,7 @@ const discover = Cli.create('discover', {
1281
1524
  cli.command(account)
1282
1525
  cli.command(discover)
1283
1526
  cli.command(init)
1527
+ cli.command(services)
1284
1528
  cli.command(sign)
1285
1529
 
1286
1530
  export default cli
@@ -176,6 +176,9 @@ test('tools/list exposes mppx commands with input and output schemas', async ()
176
176
  'discover_generate',
177
177
  'discover_validate',
178
178
  'init',
179
+ 'services_endpoints',
180
+ 'services_list',
181
+ 'services_show',
179
182
  'sign',
180
183
  ])
181
184
  expect(tools.find((tool: { name: string }) => tool.name === 'account_list').outputSchema).toEqual(
@@ -1,5 +1,6 @@
1
1
  import type * as Challenge from '../../Challenge.js'
2
2
  import type * as Method from '../../Method.js'
3
+ import type { Network } from '../utils.js'
3
4
 
4
5
  export function createPlugin(plugin: Plugin): Plugin {
5
6
  return plugin
@@ -18,7 +19,14 @@ export interface Plugin {
18
19
  */
19
20
  setup(ctx: {
20
21
  challenge: Challenge.Challenge
21
- options: { account?: string | undefined; rpcUrl?: string | undefined }
22
+ options: {
23
+ account?: string | undefined
24
+ autoSwap?: boolean | undefined
25
+ network?: Network | undefined
26
+ payWith?: string | undefined
27
+ rpcUrl?: string | undefined
28
+ slippage?: number | undefined
29
+ }
22
30
  methodOpts: Record<string, string>
23
31
  }): Promise<{
24
32
  /** Token symbol for display (e.g., 'PathUSD', 'USD') */
@@ -20,6 +20,7 @@ export function stripe() {
20
20
  paymentMethod: z.string(),
21
21
  }),
22
22
  methodOpts,
23
+ ['paymentMethod'],
23
24
  )
24
25
 
25
26
  const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
@@ -134,7 +135,13 @@ export function stripe() {
134
135
  function parseOptions<const schema extends z.ZodType>(
135
136
  schema: schema,
136
137
  rawOptions: unknown,
138
+ allowedKeys: readonly string[],
137
139
  ): z.output<schema> {
140
+ if (rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)) {
141
+ const unknownKeys = Object.keys(rawOptions).filter((key) => !allowedKeys.includes(key))
142
+ if (unknownKeys.length)
143
+ throw new Error(`Unsupported CLI method option(s): ${unknownKeys.join(', ')}`)
144
+ }
138
145
  const result = schema.safeParse(rawOptions ?? {})
139
146
  if (result.success) return result.data
140
147
  const summary = result.error.issues
@@ -54,6 +54,29 @@ export function tempo() {
54
54
  const accountName = resolveAccountName(options.account)
55
55
  const challengeRequest = challenge.request as Record<string, unknown>
56
56
  const currency = challengeRequest.currency as string | undefined
57
+ const booleanOption = z.union([
58
+ z.boolean(),
59
+ z.literal('true').transform(() => true),
60
+ z.literal('false').transform(() => false),
61
+ ])
62
+ const tempoOpts = parseOptions(
63
+ z.object({
64
+ autoSwap: z.optional(booleanOption),
65
+ channel: z.optional(z.coerce.string()),
66
+ deposit: z.optional(z.union([z.string(), z.number()])),
67
+ payWith: z.optional(z.string()),
68
+ slippage: z.optional(z.coerce.number()),
69
+ tokenIn: z.optional(z.string()),
70
+ }),
71
+ methodOpts,
72
+ ['autoSwap', 'channel', 'deposit', 'payWith', 'slippage', 'tokenIn'],
73
+ )
74
+ const autoSwap = resolveAutoSwap({
75
+ autoSwap: tempoOpts.autoSwap ?? options.autoSwap,
76
+ payWith: tempoOpts.payWith ?? options.payWith,
77
+ slippage: tempoOpts.slippage ?? options.slippage,
78
+ tokenIn: tempoOpts.tokenIn,
79
+ })
57
80
 
58
81
  let tokenSymbol = currency ?? ''
59
82
  let tokenDecimals = (challengeRequest.decimals as number | undefined) ?? 6
@@ -71,11 +94,12 @@ export function tempo() {
71
94
  useTempoCliSign = true
72
95
  const tempoEntry = resolveTempoAccount(accountName)
73
96
  if (tempoEntry) {
74
- const rpcUrl = resolveRpcUrl(options.rpcUrl)
97
+ const rpcUrl = resolveRpcUrl(options.rpcUrl, { network: options.network })
75
98
  client = createClient({
76
- chain: await resolveChain({ rpcUrl }),
99
+ chain: await resolveChain({ network: options.network, rpcUrl }),
77
100
  transport: http(rpcUrl),
78
101
  })
102
+ assertChallengeChain({ challenge, clientChainId: client.chain?.id })
79
103
  explorerUrl = client.chain?.blockExplorers?.default?.url
80
104
  const tokenInfo = currency
81
105
  ? await fetchTokenInfo(
@@ -111,11 +135,12 @@ export function tempo() {
111
135
  } else account = privateKeyToAccount(privateKey as `0x${string}`)
112
136
 
113
137
  if (!useTempoCliSign && account) {
114
- const rpcUrl = resolveRpcUrl(options.rpcUrl)
138
+ const rpcUrl = resolveRpcUrl(options.rpcUrl, { network: options.network })
115
139
  client = createClient({
116
- chain: await resolveChain({ rpcUrl }),
140
+ chain: await resolveChain({ network: options.network, rpcUrl }),
117
141
  transport: http(rpcUrl),
118
142
  })
143
+ assertChallengeChain({ challenge, clientChainId: client.chain?.id })
119
144
  explorerUrl = client.chain?.blockExplorers?.default?.url
120
145
  const tokenInfo = currency
121
146
  ? await fetchTokenInfo(client, currency as Address, account.address).catch(
@@ -147,17 +172,10 @@ export function tempo() {
147
172
  exitCode: 69,
148
173
  })
149
174
 
150
- const tempoOpts = parseOptions(
151
- z.object({
152
- channel: z.optional(z.coerce.string()),
153
- deposit: z.optional(z.union([z.string(), z.number()])),
154
- }),
155
- methodOpts,
156
- )
157
-
158
175
  const methods = tempoMethods({
159
176
  account,
160
177
  getClient: () => client!,
178
+ ...(autoSwap !== undefined ? { autoSwap } : {}),
161
179
  deposit: (() => {
162
180
  if (challenge.intent !== 'session') return undefined
163
181
  const suggestedDeposit = (challenge.request as Record<string, unknown>)
@@ -718,7 +736,13 @@ function detectTerminalBg(
718
736
  function parseOptions<const schema extends z.ZodType>(
719
737
  schema: schema,
720
738
  rawOptions: unknown,
739
+ allowedKeys: readonly string[],
721
740
  ): z.output<schema> {
741
+ if (rawOptions && typeof rawOptions === 'object' && !Array.isArray(rawOptions)) {
742
+ const unknownKeys = Object.keys(rawOptions).filter((key) => !allowedKeys.includes(key))
743
+ if (unknownKeys.length)
744
+ throw new Error(`Unsupported CLI method option(s): ${unknownKeys.join(', ')}`)
745
+ }
722
746
  const result = schema.safeParse(rawOptions ?? {})
723
747
  if (result.success) return result.data
724
748
  const summary = result.error.issues
@@ -730,6 +754,53 @@ function parseOptions<const schema extends z.ZodType>(
730
754
  throw new Error(`Invalid CLI options (${summary})`)
731
755
  }
732
756
 
757
+ function assertChallengeChain(opts: {
758
+ challenge: { request: Record<string, unknown> }
759
+ clientChainId?: number | undefined
760
+ }) {
761
+ const methodDetails = opts.challenge.request.methodDetails as
762
+ | { chainId?: number | undefined }
763
+ | undefined
764
+ const requiredChainId = methodDetails?.chainId
765
+ if (!requiredChainId || !opts.clientChainId || requiredChainId === opts.clientChainId) return
766
+ const hint =
767
+ requiredChainId === 4217
768
+ ? ' Use --network mainnet or --rpc-url https://rpc.tempo.xyz.'
769
+ : requiredChainId === 42431
770
+ ? ' Use --network testnet or --rpc-url https://rpc.moderato.tempo.xyz.'
771
+ : ''
772
+ throw new Errors.IncurError({
773
+ code: 'CHAIN_MISMATCH',
774
+ message: `Challenge requires chainId ${requiredChainId}, but RPC is chainId ${opts.clientChainId}.${hint}`,
775
+ exitCode: 2,
776
+ })
777
+ }
778
+
779
+ function parseTokenList(value: string | undefined): Address[] | undefined {
780
+ if (!value) return undefined
781
+ return value
782
+ .split(',')
783
+ .map((token) => token.trim())
784
+ .filter(Boolean) as Address[]
785
+ }
786
+
787
+ function resolveAutoSwap(opts: {
788
+ autoSwap?: boolean | undefined
789
+ payWith?: string | undefined
790
+ slippage?: number | undefined
791
+ tokenIn?: string | undefined
792
+ }) {
793
+ const tokenIn = parseTokenList(opts.tokenIn) ?? parseTokenList(opts.payWith)
794
+ if (!opts.autoSwap && !tokenIn && opts.slippage === undefined) return undefined
795
+ if (opts.autoSwap === false && !tokenIn && opts.slippage === undefined) return false
796
+ if (opts.slippage !== undefined && (!Number.isFinite(opts.slippage) || opts.slippage < 0))
797
+ throw new Error('Invalid CLI options (slippage: expected a non-negative number)')
798
+ return {
799
+ ...(tokenIn ? { tokenIn } : {}),
800
+ ...(opts.slippage !== undefined ? { slippage: opts.slippage } : {}),
801
+ }
802
+ }
803
+
733
804
  function channelStateDir() {
734
805
  return path.join(
735
806
  process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
@@ -1,7 +1,7 @@
1
1
  import { tempo as tempoMainnet, tempoModerato } from 'viem/chains'
2
2
  import { afterEach, describe, expect, test } from 'vp/test'
3
3
 
4
- import { resolveChain, resolveRpcUrl } from './utils.js'
4
+ import { networkRpcUrls, resolveChain, resolveRpcUrl } from './utils.js'
5
5
 
6
6
  describe('resolveRpcUrl', () => {
7
7
  afterEach(() => {
@@ -14,6 +14,17 @@ describe('resolveRpcUrl', () => {
14
14
  expect(resolveRpcUrl('https://explicit.example.com')).toBe('https://explicit.example.com')
15
15
  })
16
16
 
17
+ test('uses network default before env vars', () => {
18
+ process.env.MPPX_RPC_URL = 'https://env.example.com'
19
+ expect(resolveRpcUrl(undefined, { network: 'testnet' })).toBe(networkRpcUrls.testnet)
20
+ })
21
+
22
+ test('prefers explicit rpc url over network default', () => {
23
+ expect(resolveRpcUrl('https://explicit.example.com', { network: 'mainnet' })).toBe(
24
+ 'https://explicit.example.com',
25
+ )
26
+ })
27
+
17
28
  test('falls back to MPPX_RPC_URL env var', () => {
18
29
  process.env.MPPX_RPC_URL = 'https://mppx.example.com'
19
30
  process.env.RPC_URL = 'https://rpc.example.com'
package/src/cli/utils.ts CHANGED
@@ -4,6 +4,8 @@ import type { Chain } from 'viem'
4
4
  import { type Address, createClient, http } from 'viem'
5
5
  import { tempo as tempoMainnet, tempoModerato } from 'viem/chains'
6
6
 
7
+ import * as defaults from '../tempo/internal/defaults.js'
8
+
7
9
  // Inlined from https://github.com/alexeyraspopov/picocolors (ISC License)
8
10
  export const pc = (() => {
9
11
  const p = process || ({} as NodeJS.Process)
@@ -221,13 +223,29 @@ export function fmtBalance(
221
223
  return `${dec ? `${formatted}.${dec}` : formatted} ${sym}`
222
224
  }
223
225
 
224
- /** Resolve RPC URL from explicit option, then MPPX_RPC_URL, then RPC_URL env vars. */
225
- export function resolveRpcUrl(explicit?: string | undefined): string | undefined {
226
- return explicit ?? (process.env.MPPX_RPC_URL?.trim() || process.env.RPC_URL?.trim() || undefined)
226
+ export type Network = 'mainnet' | 'testnet'
227
+
228
+ export const networkRpcUrls = {
229
+ mainnet: defaults.rpcUrl[defaults.chainId.mainnet],
230
+ testnet: defaults.rpcUrl[defaults.chainId.testnet],
231
+ } as const satisfies Record<Network, string>
232
+
233
+ /** Resolve RPC URL from explicit option, network option, then MPPX_RPC_URL/RPC_URL env vars. */
234
+ export function resolveRpcUrl(
235
+ explicit?: string | undefined,
236
+ options: { network?: Network | undefined } = {},
237
+ ): string | undefined {
238
+ return (
239
+ explicit ??
240
+ (options.network ? networkRpcUrls[options.network] : undefined) ??
241
+ (process.env.MPPX_RPC_URL?.trim() || process.env.RPC_URL?.trim() || undefined)
242
+ )
227
243
  }
228
244
 
229
- export async function resolveChain(opts: { rpcUrl?: string | undefined } = {}): Promise<Chain> {
230
- const rpcUrl = resolveRpcUrl(opts.rpcUrl)
245
+ export async function resolveChain(
246
+ opts: { network?: Network | undefined; rpcUrl?: string | undefined } = {},
247
+ ): Promise<Chain> {
248
+ const rpcUrl = resolveRpcUrl(opts.rpcUrl, { network: opts.network })
231
249
  if (!rpcUrl) return tempoMainnet
232
250
  const { getChainId } = await import('viem/actions')
233
251
  const chainId = await getChainId(createClient({ transport: http(rpcUrl) }))
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*|*MINGW*|*MSYS*)
6
+ if command -v cygpath > /dev/null 2>&1; then
7
+ basedir=`cygpath -w "$basedir"`
8
+ fi
9
+ ;;
10
+ esac
11
+
12
+ if [ -z "$NODE_PATH" ]; then
13
+ export NODE_PATH="/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules"
14
+ else
15
+ export NODE_PATH="/home/runner/work/mppx/mppx/node_modules/.pnpm/node_modules:$NODE_PATH"
16
+ fi
17
+ if [ -x "$basedir/node" ]; then
18
+ exec "$basedir/node" "$basedir/../mppx/dist/bin.js" "$@"
19
+ else
20
+ exec node "$basedir/../mppx/dist/bin.js" "$@"
21
+ fi
22
+ # cmd-shim-target=/home/runner/work/mppx/mppx/src/stripe/server/internal/html/node_modules/mppx/dist/bin.js