mppx 0.3.14 → 0.3.16

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 (60) hide show
  1. package/README.md +1 -0
  2. package/dist/Challenge.d.ts +38 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +62 -0
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/bin.d.ts +3 -0
  7. package/dist/bin.d.ts.map +1 -0
  8. package/dist/bin.js +4 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/cli.d.ts +26 -2
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +1478 -915
  13. package/dist/cli.js.map +1 -1
  14. package/dist/client/Mppx.d.ts +2 -0
  15. package/dist/client/Mppx.d.ts.map +1 -1
  16. package/dist/client/Mppx.js +2 -0
  17. package/dist/client/Mppx.js.map +1 -1
  18. package/dist/client/internal/Fetch.d.ts.map +1 -1
  19. package/dist/client/internal/Fetch.js +16 -4
  20. package/dist/client/internal/Fetch.js.map +1 -1
  21. package/dist/middlewares/internal/mppx.d.ts +6 -1
  22. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  23. package/dist/middlewares/internal/mppx.js +4 -0
  24. package/dist/middlewares/internal/mppx.js.map +1 -1
  25. package/dist/server/Mppx.d.ts +79 -1
  26. package/dist/server/Mppx.d.ts.map +1 -1
  27. package/dist/server/Mppx.js +135 -7
  28. package/dist/server/Mppx.js.map +1 -1
  29. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  30. package/dist/tempo/client/ChannelOps.js +1 -0
  31. package/dist/tempo/client/ChannelOps.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts.map +1 -1
  33. package/dist/tempo/server/Charge.js +4 -4
  34. package/dist/tempo/server/Charge.js.map +1 -1
  35. package/dist/tempo/session/Chain.d.ts.map +1 -1
  36. package/dist/tempo/session/Chain.js +9 -6
  37. package/dist/tempo/session/Chain.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/Challenge.ts +72 -0
  40. package/src/bin.ts +4 -0
  41. package/src/cli.test.ts +180 -252
  42. package/src/cli.ts +1085 -485
  43. package/src/client/Mppx.test-d.ts +9 -0
  44. package/src/client/Mppx.test.ts +78 -0
  45. package/src/client/Mppx.ts +5 -0
  46. package/src/client/internal/Fetch.test.ts +1 -1
  47. package/src/client/internal/Fetch.ts +18 -6
  48. package/src/middlewares/internal/mppx.test.ts +152 -0
  49. package/src/middlewares/internal/mppx.ts +22 -3
  50. package/src/server/Mppx.test-d.ts +94 -299
  51. package/src/server/Mppx.test.ts +650 -0
  52. package/src/server/Mppx.ts +213 -9
  53. package/src/tempo/client/ChannelOps.ts +1 -0
  54. package/src/tempo/server/Charge.ts +4 -3
  55. package/src/tempo/session/Chain.ts +8 -5
  56. package/dist/tempo/internal/simulate.d.ts +0 -21
  57. package/dist/tempo/internal/simulate.d.ts.map +0 -1
  58. package/dist/tempo/internal/simulate.js +0 -31
  59. package/dist/tempo/internal/simulate.js.map +0 -1
  60. package/src/tempo/internal/simulate.ts +0 -49
package/src/cli.ts CHANGED
@@ -1,17 +1,15 @@
1
- #!/usr/bin/env node
2
1
  import * as child from 'node:child_process'
3
2
  import * as fs from 'node:fs'
4
3
  import { createRequire } from 'node:module'
5
4
  import * as os from 'node:os'
6
5
  import * as path from 'node:path'
7
6
  import * as readline from 'node:readline'
8
- import { cac } from 'cac'
7
+ import { Cli, z } from 'incur'
9
8
  import { Base64 } from 'ox'
10
9
  import type { Chain } from 'viem'
11
10
  import { type Address, createClient, http } from 'viem'
12
11
  import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
13
12
  import { tempo as tempoMainnet, tempoModerato } from 'viem/chains'
14
- import { type ZodMiniType, z } from 'zod/mini'
15
13
  import * as Challenge from './Challenge.js'
16
14
  import * as Credential from './Credential.js'
17
15
  import * as Mppx from './client/Mppx.js'
@@ -20,95 +18,142 @@ import { tempo } from './tempo/client/index.js'
20
18
  import type { SessionCredentialPayload } from './tempo/session/Types.js'
21
19
  import { signVoucher } from './tempo/session/Voucher.js'
22
20
 
21
+ function readStdin(): Promise<string> {
22
+ return new Promise((resolve, reject) => {
23
+ let data = ''
24
+ process.stdin.setEncoding('utf-8')
25
+ process.stdin.on('data', (chunk) => {
26
+ data += chunk
27
+ })
28
+ process.stdin.on('end', () => resolve(data.trim()))
29
+ process.stdin.on('error', reject)
30
+ })
31
+ }
32
+
23
33
  const require = createRequire(import.meta.url)
24
34
  const { name, version } = require('../package.json') as { name: string; version: string }
25
35
 
26
- const cli = cac(name)
27
-
28
- cli
29
- .command('[url]', 'Make HTTP request with automatic payment')
30
- .option('-a, --account <name>', 'Account name (env: MPPX_ACCOUNT)')
31
- .option('-d, --data <data>', 'Send request body (implies POST unless -X is set)')
32
- .option('-f, --fail', 'Fail silently on HTTP errors (exit 22)')
33
- .option('-i, --include', 'Include response headers in output')
34
- .option('-k, --insecure', 'Skip TLS certificate verification (true for localhost/.local)')
35
- .option(
36
- '-r, --rpc-url <url>',
37
- 'RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)',
38
- )
39
- .option('-s, --silent', 'Silent mode (suppress progress and info)')
40
- .option('-v, --verbose', 'Show request/response headers')
41
- .option('-A, --user-agent <ua>', 'Set User-Agent header')
42
- .option('-H, --header <header>', 'Add header (repeatable)')
43
- .option('-L, --location', 'Follow redirects')
44
- .option('-X, --method <method>', 'HTTP method')
45
- .option('-M, --method-opt <opt>', 'Method-specific option (key=value, repeatable)')
46
- .option('--confirm', 'Show confirmation prompts')
47
- .option('--json <json>', 'Send JSON body (sets Content-Type and Accept, implies POST)')
48
- .example(`${name} example.com/content`)
49
- .example(`${name} example.com/api --json '{"key":"value"}'`)
50
- .action(async (rawUrl: string | undefined, rawOptions: unknown) => {
51
- const options = parseOptions(
52
- z.object({
53
- account: z.optional(z.string()),
54
- confirm: z.optional(z.boolean()),
55
- data: z.optional(z.string()),
56
- fail: z.optional(z.boolean()),
57
- header: z.optional(z.union([z.string(), z.array(z.string())])),
58
- include: z.optional(z.boolean()),
59
- insecure: z.optional(z.boolean()),
60
- json: z.optional(z.string()),
61
- location: z.optional(z.boolean()),
62
- method: z.optional(z.string()),
63
- methodOpt: z.optional(z.union([z.string(), z.array(z.string())])),
64
- rpcUrl: z.optional(z.string()),
65
- silent: z.optional(z.boolean()),
66
- userAgent: z.optional(z.string()),
67
- verbose: z.optional(z.boolean()),
68
- }),
69
- rawOptions,
70
- )
36
+ const cli = Cli.create('mppx', {
37
+ version,
38
+ description: 'Make HTTP requests with automatic payment',
39
+ usage: [{ suffix: '<url> [options]' }],
40
+ args: z.object({
41
+ url: z.string().describe('URL to make payment request to'),
42
+ }),
43
+ options: z.object({
44
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
45
+ confirm: z.boolean().optional().describe('Show confirmation prompts'),
46
+ data: z.string().optional().describe('Send request body (implies POST unless -X is set)'),
47
+ fail: z.boolean().optional().describe('Fail silently on HTTP errors (exit 22)'),
48
+ header: z.array(z.string()).optional().describe('Add header (repeatable)'),
49
+ include: z.boolean().optional().describe('Include response headers in output'),
50
+ insecure: z
51
+ .boolean()
52
+ .optional()
53
+ .describe('Skip TLS certificate verification (true for localhost/.local)'),
54
+ jsonBody: z
55
+ .string()
56
+ .optional()
57
+ .describe('Send JSON body (sets Content-Type and Accept, implies POST)'),
58
+ location: z.boolean().optional().describe('Follow redirects'),
59
+ method: z.string().optional().describe('HTTP method'),
60
+ methodOpt: z
61
+ .array(z.string())
62
+ .optional()
63
+ .describe('Method-specific option (key=value, repeatable)'),
64
+ rpcUrl: z
65
+ .string()
66
+ .optional()
67
+ .describe('RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)'),
68
+ silent: z.boolean().optional().describe('Silent mode (suppress progress and info)'),
69
+ userAgent: z.string().optional().describe('Set User-Agent header'),
70
+ verbose: z
71
+ .number()
72
+ .default(0)
73
+ .meta({ count: true })
74
+ .describe('Verbosity (-v details, -vv headers)'),
75
+ }),
76
+ alias: {
77
+ account: 'a',
78
+ data: 'd',
79
+ fail: 'f',
80
+ header: 'H',
81
+ include: 'i',
82
+ insecure: 'k',
83
+ jsonBody: 'J',
84
+ location: 'L',
85
+ method: 'X',
86
+ methodOpt: 'M',
87
+ rpcUrl: 'r',
88
+ silent: 's',
89
+ userAgent: 'A',
90
+ verbose: 'v',
91
+ },
92
+ examples: [
93
+ { args: { url: 'example.com/content' }, description: 'Make a payment request' },
94
+ {
95
+ args: { url: 'example.com/api' },
96
+ options: { jsonBody: '{"key":"value"}' },
97
+ description: 'POST JSON with payment',
98
+ },
99
+ ],
100
+ async run({ args, options, error }) {
71
101
  const methodOpts = parseMethodOpts(options.methodOpt)
72
- if (!rawUrl) {
73
- cli.outputHelp()
74
- return
75
- }
76
102
 
77
103
  const silent = options.silent ?? false
78
104
  const info = silent ? (_msg: string) => {} : (msg: string) => process.stderr.write(msg)
79
- if (silent) options.confirm = false
105
+ let confirmEnabled = options.confirm ?? false
106
+ if (silent) confirmEnabled = false
80
107
 
81
108
  const accountName = resolveAccountName(options.account)
82
109
 
83
110
  const headers: Record<string, string> = {}
84
111
  if (options.header) {
85
- const headerList = Array.isArray(options.header) ? options.header : [options.header]
86
- for (const header of headerList) {
112
+ for (const header of options.header) {
87
113
  const index = header.indexOf(':')
88
114
  if (index === -1) {
89
- console.error(`Invalid header format: ${header}`)
90
- process.exit(1)
115
+ return error({
116
+ code: 'INVALID_HEADER',
117
+ message: `Invalid header format: ${header}`,
118
+ exitCode: 2,
119
+ })
91
120
  }
92
121
  headers[header.slice(0, index).trim()] = header.slice(index + 1).trim()
93
122
  }
94
123
  }
95
124
  headers['User-Agent'] = options.userAgent ?? `${name}/${version}`
96
125
 
126
+ const rawUrl = args.url
97
127
  const url = (() => {
98
128
  const hasProtocol = /^https?:\/\//.test(rawUrl)
99
- const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/.test(rawUrl)
129
+ const isLocal = /^(localhost|.*\.localhost|127\.0\.0\.1|\[::1\])(:\d+)?/.test(rawUrl)
100
130
  return hasProtocol ? rawUrl : `${isLocal ? 'http' : 'https'}://${rawUrl}`
101
131
  })()
102
132
  const { hostname } = new URL(url)
103
- if (options.insecure || hostname === 'localhost' || hostname.endsWith('.local')) {
133
+ if (
134
+ options.insecure ||
135
+ hostname === 'localhost' ||
136
+ hostname.endsWith('.localhost') ||
137
+ hostname.endsWith('.local')
138
+ ) {
104
139
  process.removeAllListeners('warning')
105
140
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
106
141
  }
107
142
 
143
+ // Node.js doesn't resolve *.localhost subdomains to loopback (unlike
144
+ // browsers per RFC 6761). Rewrite the URL to 127.0.0.1 and set the
145
+ // Host header so reverse proxies can route correctly.
146
+ const isSubLocalhost = hostname.endsWith('.localhost') && hostname !== 'localhost'
147
+ const fetchUrl = isSubLocalhost ? url.replace(hostname, '127.0.0.1') : url
148
+ if (isSubLocalhost) {
149
+ const { host } = new URL(url)
150
+ headers.Host = host
151
+ }
152
+
108
153
  try {
109
154
  const fetchInit: RequestInit = { redirect: options.location ? 'follow' : 'manual' }
110
- if (options.json) {
111
- fetchInit.body = options.json
155
+ if (options.jsonBody) {
156
+ fetchInit.body = options.jsonBody
112
157
  headers['Content-Type'] ??= 'application/json'
113
158
  headers.Accept ??= 'application/json'
114
159
  } else if (options.data) {
@@ -118,10 +163,10 @@ cli
118
163
  else if (fetchInit.body) fetchInit.method = 'POST'
119
164
  if (Object.keys(headers).length > 0) fetchInit.headers = headers
120
165
 
121
- const verbose = options.verbose ?? false
166
+ const verbose = options.verbose
122
167
 
123
168
  const printRequestHeaders = (reqUrl: string, init: RequestInit) => {
124
- if (!verbose) return
169
+ if (verbose < 2) return
125
170
  const { pathname, host } = new URL(reqUrl)
126
171
  const method = (init.method ?? 'GET').toUpperCase()
127
172
  info(`> ${method} ${pathname} HTTP/1.1\n`)
@@ -132,20 +177,25 @@ cli
132
177
  }
133
178
 
134
179
  const printResponseHeaders = (res: Response) => {
135
- if (!options.include && !verbose) return
180
+ if (!options.include && verbose < 2) return
136
181
  if (silent) return
137
182
  const status = `HTTP/1.1 ${res.status} ${res.statusText}`
138
- const out = verbose ? process.stderr : process.stdout
139
- const prefix = verbose ? '< ' : ''
183
+ const out = verbose >= 2 ? process.stderr : process.stdout
184
+ const prefix = verbose >= 2 ? '< ' : ''
140
185
  out.write(`${prefix}${status}\n`)
141
186
  for (const [k, v] of res.headers) out.write(`${prefix}${k}: ${v}\n`)
142
- out.write(verbose ? '<\n' : '\n')
187
+ out.write(verbose >= 2 ? '<\n' : '\n')
143
188
  }
144
189
 
145
190
  printRequestHeaders(url, fetchInit)
146
- const challengeResponse = await globalThis.fetch(url, fetchInit)
191
+ const challengeResponse = await globalThis.fetch(fetchUrl, fetchInit)
147
192
  if (challengeResponse.status !== 402) {
148
- if (options.fail && challengeResponse.status >= 400) process.exit(22)
193
+ if (options.fail && challengeResponse.status >= 400)
194
+ return error({
195
+ code: 'HTTP_ERROR',
196
+ message: `HTTP error ${challengeResponse.status}`,
197
+ exitCode: 22,
198
+ })
149
199
  printResponseHeaders(challengeResponse)
150
200
  console.log((await challengeResponse.text()).replace(/\n+$/, ''))
151
201
  return
@@ -165,29 +215,75 @@ cli
165
215
  // Tempo-specific setup (private key, viem account/client, token info)
166
216
  let account: ReturnType<typeof privateKeyToAccount> | undefined
167
217
  let client: ReturnType<typeof createClient> | undefined
218
+ let useTempoCliSign = false
168
219
  if (challenge.method === 'tempo') {
169
220
  const privateKey =
170
- process.env.MPPX_PRIVATE_KEY?.trim() || (await createKeychain(accountName).get())
171
- if (!privateKey) {
172
- if (options.account) console.error(`Account "${accountName}" not found.`)
173
- else console.error(`No account found.`)
174
- process.exit(1)
221
+ process.env.MPPX_PRIVATE_KEY?.trim() ||
222
+ (isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
223
+ if (!privateKey && isTempoAccount(accountName) && hasTempoCliSync()) {
224
+ useTempoCliSign = true
225
+ // Resolve wallet address from keys.toml for display/balance
226
+ const tempoEntry = resolveTempoAccount(accountName)
227
+ if (tempoEntry) {
228
+ const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
229
+ client = createClient({
230
+ chain: await resolveChain({ ...options, rpcUrl }),
231
+ transport: http(rpcUrl),
232
+ })
233
+ explorerUrl = client.chain?.blockExplorers?.default?.url
234
+ const tokenInfo = currency
235
+ ? await fetchTokenInfo(
236
+ client,
237
+ currency as Address,
238
+ tempoEntry.wallet_address as Address,
239
+ ).catch(() => undefined)
240
+ : undefined
241
+ tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
242
+ tokenDecimals =
243
+ tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
244
+ }
245
+ } else if (!privateKey) {
246
+ // tempo CLI not available — try silent fallback
247
+ const fallback = fallbackFromTempo()
248
+ if (fallback) {
249
+ const fallbackKey = await createKeychain(fallback).get()
250
+ if (fallbackKey) {
251
+ account = privateKeyToAccount(fallbackKey as `0x${string}`)
252
+ }
253
+ }
254
+ if (!account) {
255
+ if (options.account)
256
+ return error({
257
+ code: 'ACCOUNT_NOT_FOUND',
258
+ message: `Account "${accountName}" not found.`,
259
+ exitCode: 69,
260
+ })
261
+ else
262
+ return error({
263
+ code: 'ACCOUNT_NOT_FOUND',
264
+ message: 'No account found.',
265
+ exitCode: 69,
266
+ })
267
+ }
268
+ } else {
269
+ account = privateKeyToAccount(privateKey as `0x${string}`)
270
+ }
271
+ if (!useTempoCliSign && account) {
272
+ const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
273
+ client = createClient({
274
+ chain: await resolveChain({ ...options, rpcUrl }),
275
+ transport: http(rpcUrl),
276
+ })
277
+ explorerUrl = client.chain?.blockExplorers?.default?.url
278
+ const tokenInfo = currency
279
+ ? await fetchTokenInfo(client, currency as Address, account.address).catch(
280
+ () => undefined,
281
+ )
282
+ : undefined
283
+ tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
284
+ tokenDecimals =
285
+ tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
175
286
  }
176
- account = privateKeyToAccount(privateKey as `0x${string}`)
177
- const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
178
- client = createClient({
179
- chain: await resolveChain({ ...options, rpcUrl }),
180
- transport: http(rpcUrl),
181
- })
182
- explorerUrl = client.chain?.blockExplorers?.default?.url
183
- const tokenInfo = currency
184
- ? await fetchTokenInfo(client, currency as Address, account.address).catch(
185
- () => undefined,
186
- )
187
- : undefined
188
- tokenSymbol = tokenInfo?.symbol ?? currency ?? ''
189
- tokenDecimals =
190
- tokenInfo?.decimals ?? (challengeRequest.decimals as number | undefined) ?? 6
191
287
  }
192
288
 
193
289
  {
@@ -271,30 +367,45 @@ cli
271
367
  const pad = Math.max(...sections.flatMap(([, rows]) => rows.map(([k]) => k.length)))
272
368
  const indent = ` ${''.padEnd(pad)} `
273
369
 
274
- info(`${pc.bold(pc.yellow('Payment Required'))}\n`)
275
- for (const [title, rows] of sections) {
276
- info(`${pc.bold(title)}\n`)
277
- for (const [label, value] of rows) {
278
- const [first, ...rest] = value.split('\n')
279
- info(` ${pc.dim(label.padEnd(pad))} ${first}\n`)
280
- for (const line of rest) info(`${indent}${line}\n`)
370
+ if (verbose >= 1 || confirmEnabled) {
371
+ info(`${pc.bold(pc.yellow('Payment Required'))}\n`)
372
+ for (const [title, rows] of sections) {
373
+ info(`${pc.bold(title)}\n`)
374
+ for (const [label, value] of rows) {
375
+ const [first, ...rest] = value.split('\n')
376
+ info(` ${pc.dim(label.padEnd(pad))} ${first}\n`)
377
+ for (const line of rest) info(`${indent}${line}\n`)
378
+ }
281
379
  }
282
380
  }
283
- if (options.confirm) {
381
+ if (confirmEnabled) {
284
382
  info('\n')
285
383
  const ok = await confirm(`Proceed with ${challenge.intent}?`, true)
286
384
  if (!ok) {
287
385
  info('Aborted.\n')
288
- process.exit(0)
386
+ return
289
387
  }
290
388
  }
291
389
  }
292
390
 
293
391
  let credential: string
294
- if (challenge.method === 'tempo') {
392
+ if (challenge.method === 'tempo' && useTempoCliSign) {
393
+ const wwwAuth = challengeResponse.headers.get('www-authenticate')
394
+ if (!wwwAuth) {
395
+ return error({
396
+ code: 'MISSING_CHALLENGE',
397
+ message: 'No WWW-Authenticate header in 402 response.',
398
+ exitCode: 2,
399
+ })
400
+ }
401
+ credential = await tempoCliSign(wwwAuth)
402
+ } else if (challenge.method === 'tempo') {
295
403
  if (!account || !client) {
296
- console.error('Tempo requires a configured account.')
297
- process.exit(1)
404
+ return error({
405
+ code: 'ACCOUNT_NOT_FOUND',
406
+ message: 'Tempo requires a configured account.',
407
+ exitCode: 69,
408
+ })
298
409
  }
299
410
  const tempoOpts = parseOptions(
300
411
  z.object({
@@ -316,10 +427,12 @@ cli
316
427
  const resolved =
317
428
  suggestedDeposit ?? cliDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
318
429
  if (!resolved) {
319
- console.error(
320
- 'Session payment requires a deposit. Use -M deposit=<amount> or connect to testnet.',
321
- )
322
- process.exit(1)
430
+ return error({
431
+ code: 'MISSING_DEPOSIT',
432
+ message:
433
+ 'Session payment requires a deposit. Use -M deposit=<amount> or connect to testnet.',
434
+ exitCode: 2,
435
+ })
323
436
  }
324
437
  return resolved
325
438
  })(),
@@ -347,16 +460,19 @@ cli
347
460
  )
348
461
  const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
349
462
  if (!stripeSecretKey) {
350
- console.error(
351
- '\nMPPX_STRIPE_SECRET_KEY environment variable is required for Stripe payments.',
352
- )
353
- process.exit(1)
463
+ return error({
464
+ code: 'MISSING_ENV',
465
+ message: 'MPPX_STRIPE_SECRET_KEY environment variable is required for Stripe payments.',
466
+ exitCode: 2,
467
+ })
354
468
  }
355
469
  if (!stripeSecretKey.startsWith('sk_test_')) {
356
- console.error(
357
- '\nStripe CLI payments are currently only supported in test mode (sk_test_... keys).',
358
- )
359
- process.exit(1)
470
+ return error({
471
+ code: 'UNSUPPORTED_MODE',
472
+ message:
473
+ 'Stripe CLI payments are currently only supported in test mode (sk_test_... keys).',
474
+ exitCode: 2,
475
+ })
360
476
  }
361
477
  const mppx = Mppx.create({
362
478
  methods: [
@@ -415,10 +531,18 @@ cli
415
531
  const fallbackError = (await response.json()) as {
416
532
  error: { message: string }
417
533
  }
418
- throw new Error(`Failed to create SPT: ${fallbackError.error.message}`)
534
+ return error({
535
+ code: 'STRIPE_ERROR',
536
+ message: `Failed to create SPT: ${fallbackError.error.message}`,
537
+ exitCode: 77,
538
+ })
419
539
  }
420
540
  } else {
421
- throw new Error(`Failed to create SPT: ${errorBody.error.message}`)
541
+ return error({
542
+ code: 'STRIPE_ERROR',
543
+ message: `Failed to create SPT: ${errorBody.error.message}`,
544
+ exitCode: 77,
545
+ })
422
546
  }
423
547
  }
424
548
  const { id } = (await response.json()) as { id: string }
@@ -430,8 +554,11 @@ cli
430
554
  })
431
555
  credential = await mppx.createCredential(challengeResponse)
432
556
  } else {
433
- console.error(`Unsupported payment method: ${challenge.method}`)
434
- process.exit(1)
557
+ return error({
558
+ code: 'UNSUPPORTED_METHOD',
559
+ message: `Unsupported payment method: ${challenge.method}`,
560
+ exitCode: 2,
561
+ })
435
562
  }
436
563
 
437
564
  const sessionMd = challenge.request.methodDetails as
@@ -450,29 +577,81 @@ cli
450
577
  if ('cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount)
451
578
  sessionCumulativeAmount = BigInt(parsed.payload.cumulativeAmount)
452
579
 
453
- if (parsed.payload.action === 'open') {
454
- const depositRaw = challengeRequest.suggestedDeposit as string | undefined
455
- const depositDisplay = depositRaw
456
- ? ` ${pc.dim(`(deposit ${depositRaw} ${tokenSymbol})`)}`
457
- : ''
458
- const prefix = options.confirm ? '' : '\n'
459
- info(
460
- `${prefix}${pc.dim(`Channel opened ${parsed.payload.channelId}`)}${depositDisplay}\n`,
461
- )
462
- } else {
463
- const prefix = options.confirm ? '' : '\n'
464
- info(`${prefix}${pc.dim(`Channel reused ${parsed.payload.channelId}`)}\n`)
580
+ if (verbose >= 1) {
581
+ if (parsed.payload.action === 'open') {
582
+ const depositRaw = challengeRequest.suggestedDeposit as string | undefined
583
+ const depositDisplay = depositRaw
584
+ ? ` ${pc.dim(`(deposit ${depositRaw} ${tokenSymbol})`)}`
585
+ : ''
586
+ const prefix = confirmEnabled ? '' : '\n'
587
+ info(
588
+ `${prefix}${pc.dim(`Channel opened ${parsed.payload.channelId}`)}${depositDisplay}\n`,
589
+ )
590
+ } else {
591
+ const prefix = confirmEnabled ? '' : '\n'
592
+ info(`${prefix}${pc.dim(`Channel reused ${parsed.payload.channelId}`)}\n`)
593
+ }
465
594
  }
466
595
  }
467
596
 
468
597
  const credentialFetchInit = {
469
598
  ...fetchInit,
470
- headers: { ...(fetchInit.headers as Record<string, string>), Authorization: credential },
599
+ headers: {
600
+ ...(fetchInit.headers as Record<string, string>),
601
+ ...(challenge.intent === 'session' ? { Accept: 'text/event-stream' } : {}),
602
+ Authorization: credential,
603
+ },
471
604
  }
472
605
  printRequestHeaders(url, credentialFetchInit)
473
- const credentialResponse = await globalThis.fetch(url, credentialFetchInit)
606
+ let credentialResponse = await globalThis.fetch(fetchUrl, credentialFetchInit)
607
+
608
+ if (
609
+ challenge.intent === 'session' &&
610
+ credentialResponse.ok &&
611
+ !credentialResponse.headers.get('Content-Type')?.includes('text/event-stream')
612
+ ) {
613
+ const parsed = Credential.deserialize<SessionCredentialPayload>(credential)
614
+ if (parsed.payload.action === 'open' && 'cumulativeAmount' in parsed.payload) {
615
+ const tickAmount = BigInt(challenge.request.amount as string)
616
+ sessionCumulativeAmount = BigInt(parsed.payload.cumulativeAmount) + tickAmount
474
617
 
475
- if (options.fail && credentialResponse.status >= 400) process.exit(22)
618
+ if (sessionEscrowContract && account && client) {
619
+ const signature = await signVoucher(
620
+ client,
621
+ account,
622
+ { channelId: sessionChannelId!, cumulativeAmount: sessionCumulativeAmount },
623
+ sessionEscrowContract,
624
+ sessionChainId,
625
+ )
626
+ const voucherPayload: SessionCredentialPayload = {
627
+ action: 'voucher',
628
+ channelId: sessionChannelId!,
629
+ cumulativeAmount: sessionCumulativeAmount.toString(),
630
+ signature,
631
+ }
632
+ const voucherCred = Credential.serialize({
633
+ challenge,
634
+ payload: voucherPayload,
635
+ source: `did:pkh:eip155:${sessionChainId}:${account.address}`,
636
+ })
637
+ credentialResponse = await globalThis.fetch(fetchUrl, {
638
+ ...fetchInit,
639
+ headers: {
640
+ ...(fetchInit.headers as Record<string, string>),
641
+ Accept: 'text/event-stream',
642
+ Authorization: voucherCred,
643
+ },
644
+ })
645
+ }
646
+ }
647
+ }
648
+
649
+ if (options.fail && credentialResponse.status >= 400)
650
+ return error({
651
+ code: 'HTTP_ERROR',
652
+ message: `HTTP error ${credentialResponse.status}`,
653
+ exitCode: 22,
654
+ })
476
655
 
477
656
  if (credentialResponse.status === 402) {
478
657
  const body = await credentialResponse.text()
@@ -490,7 +669,7 @@ cli
490
669
  } catch {
491
670
  if (body) info(` ${body}\n`)
492
671
  }
493
- process.exit(1)
672
+ return error({ code: 'PAYMENT_REJECTED', message: 'Payment rejected', exitCode: 75 })
494
673
  } else {
495
674
  printResponseHeaders(credentialResponse)
496
675
 
@@ -509,49 +688,50 @@ cli
509
688
  if (sessionChannelId)
510
689
  writeChannelCumulative(sessionChannelId, sessionCumulativeAmount)
511
690
  }
512
- info(`\n${pc.bold(pc.green('Payment Receipt'))}\n`)
513
- const rows: [string, string][] = []
514
- const channelId = receiptJson.channelId
515
- const reference = receiptJson.reference
516
- const skipReference = channelId && reference && channelId === reference
517
- const receiptBalanceKeys = new Set(['acceptedCumulative', 'spent'])
518
- for (const [key, value] of Object.entries(receiptJson)) {
519
- if (value === undefined || shownKeys.has(key)) continue
520
- if (key === 'reference' && skipReference) continue
521
- if (receiptBalanceKeys.has(key) && typeof value === 'string') {
522
- rows.push([
523
- key,
524
- `${value} ${pc.dim(`(${fmtBalance(BigInt(value), tokenSymbol, tokenDecimals)})`)}`,
525
- ])
526
- } else if (
527
- (key === 'reference' || key === 'txHash') &&
528
- typeof value === 'string' &&
529
- explorerUrl
530
- ) {
531
- rows.push([key, pc.link(`${explorerUrl}/tx/${value}`, value)])
532
- } else if (
533
- key === 'reference' &&
534
- typeof value === 'string' &&
535
- challenge.method === 'stripe' &&
536
- value.startsWith('pi_')
537
- ) {
538
- const isTest = process.env.MPPX_STRIPE_SECRET_KEY?.startsWith('sk_test_')
539
- const dashboardUrl = `https://dashboard.stripe.com${isTest ? '/test' : ''}/payments/${value}`
540
- rows.push([key, pc.link(dashboardUrl, value)])
541
- } else rows.push([key, String(value)])
691
+ if (verbose >= 1) {
692
+ info(`\n${pc.bold(pc.green('Payment Receipt'))}\n`)
693
+ const rows: [string, string][] = []
694
+ const channelId = receiptJson.channelId
695
+ const reference = receiptJson.reference
696
+ const skipReference = channelId && reference && channelId === reference
697
+ const receiptBalanceKeys = new Set(['acceptedCumulative', 'spent'])
698
+ for (const [key, value] of Object.entries(receiptJson)) {
699
+ if (value === undefined || shownKeys.has(key)) continue
700
+ if (key === 'reference' && skipReference) continue
701
+ if (receiptBalanceKeys.has(key) && typeof value === 'string') {
702
+ rows.push([
703
+ key,
704
+ `${value} ${pc.dim(`(${fmtBalance(BigInt(value), tokenSymbol, tokenDecimals)})`)}`,
705
+ ])
706
+ } else if (
707
+ (key === 'reference' || key === 'txHash') &&
708
+ typeof value === 'string' &&
709
+ explorerUrl
710
+ ) {
711
+ rows.push([key, pc.link(`${explorerUrl}/tx/${value}`, value)])
712
+ } else if (
713
+ key === 'reference' &&
714
+ typeof value === 'string' &&
715
+ challenge.method === 'stripe' &&
716
+ value.startsWith('pi_')
717
+ ) {
718
+ const isTest = process.env.MPPX_STRIPE_SECRET_KEY?.startsWith('sk_test_')
719
+ const dashboardUrl = `https://dashboard.stripe.com${isTest ? '/test' : ''}/payments/${value}`
720
+ rows.push([key, pc.link(dashboardUrl, value)])
721
+ } else rows.push([key, String(value)])
722
+ }
723
+ rows.sort(([a], [b]) => a.localeCompare(b))
724
+ const pad = Math.max(...rows.map(([k]) => k.length))
725
+ for (const [label, value] of rows) info(` ${pc.dim(label.padEnd(pad))} ${value}\n`)
726
+ info('\n')
542
727
  }
543
- rows.sort(([a], [b]) => a.localeCompare(b))
544
- const pad = Math.max(...rows.map(([k]) => k.length))
545
- for (const [label, value] of rows) info(` ${pc.dim(label.padEnd(pad))} ${value}\n`)
546
- info('\n')
547
728
  } catch {}
548
729
  }
549
730
  const contentType = credentialResponse.headers.get('Content-Type') ?? ''
550
731
  if (contentType.includes('text/event-stream')) {
551
732
  const reader = credentialResponse.body?.getReader()
552
733
  if (!reader) {
553
- console.error('No response body')
554
- process.exit(1)
734
+ return error({ code: 'NO_RESPONSE_BODY', message: 'No response body' })
555
735
  }
556
736
  const decoder = new TextDecoder()
557
737
  let buffer = ''
@@ -644,7 +824,7 @@ cli
644
824
  },
645
825
  source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
646
826
  })
647
- await globalThis.fetch(url, {
827
+ await globalThis.fetch(fetchUrl, {
648
828
  method: 'POST',
649
829
  headers: { Authorization: voucherCred },
650
830
  })
@@ -658,36 +838,38 @@ cli
658
838
  continue
659
839
  }
660
840
  if (currentEvent === 'payment-receipt') {
661
- try {
662
- const receipt = JSON.parse(data) as Record<string, unknown>
663
- info(`\n\n${pc.bold(pc.green('Payment Receipt'))}\n`)
664
- const rows: [string, string][] = []
665
- const skipRef =
666
- receipt.channelId &&
667
- receipt.reference &&
668
- receipt.channelId === receipt.reference
669
- for (const [key, value] of Object.entries(receipt)) {
670
- if (value === undefined || shownKeys.has(key)) continue
671
- if (key === 'reference' && skipRef) continue
672
- const receiptBalanceKeys = ['acceptedCumulative', 'spent']
673
- if (receiptBalanceKeys.includes(key) && typeof value === 'string') {
674
- rows.push([
675
- key,
676
- `${value} ${pc.dim(`(${fmtBalance(BigInt(value), tokenSymbol, tokenDecimals)})`)}`,
677
- ])
678
- } else if (
679
- (key === 'reference' || key === 'txHash') &&
680
- typeof value === 'string' &&
681
- explorerUrl
682
- ) {
683
- rows.push([key, pc.link(`${explorerUrl}/tx/${value}`, value)])
684
- } else rows.push([key, String(value)])
685
- }
686
- rows.sort(([a], [b]) => a.localeCompare(b))
687
- const rpad = Math.max(...rows.map(([k]) => k.length))
688
- for (const [label, value] of rows)
689
- info(` ${pc.dim(label.padEnd(rpad))} ${value}\n`)
690
- } catch {}
841
+ if (verbose >= 1) {
842
+ try {
843
+ const receipt = JSON.parse(data) as Record<string, unknown>
844
+ info(`\n\n${pc.bold(pc.green('Payment Receipt'))}\n`)
845
+ const rows: [string, string][] = []
846
+ const skipRef =
847
+ receipt.channelId &&
848
+ receipt.reference &&
849
+ receipt.channelId === receipt.reference
850
+ for (const [key, value] of Object.entries(receipt)) {
851
+ if (value === undefined || shownKeys.has(key)) continue
852
+ if (key === 'reference' && skipRef) continue
853
+ const receiptBalanceKeys = ['acceptedCumulative', 'spent']
854
+ if (receiptBalanceKeys.includes(key) && typeof value === 'string') {
855
+ rows.push([
856
+ key,
857
+ `${value} ${pc.dim(`(${fmtBalance(BigInt(value), tokenSymbol, tokenDecimals)})`)}`,
858
+ ])
859
+ } else if (
860
+ (key === 'reference' || key === 'txHash') &&
861
+ typeof value === 'string' &&
862
+ explorerUrl
863
+ ) {
864
+ rows.push([key, pc.link(`${explorerUrl}/tx/${value}`, value)])
865
+ } else rows.push([key, String(value)])
866
+ }
867
+ rows.sort(([a], [b]) => a.localeCompare(b))
868
+ const rpad = Math.max(...rows.map(([k]) => k.length))
869
+ for (const [label, value] of rows)
870
+ info(` ${pc.dim(label.padEnd(rpad))} ${value}\n`)
871
+ } catch {}
872
+ }
691
873
  currentEvent = ''
692
874
  continue
693
875
  }
@@ -738,29 +920,31 @@ cli
738
920
  payload: closePayload,
739
921
  source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
740
922
  })
741
- const closeRes = await globalThis.fetch(url, {
923
+ const closeRes = await globalThis.fetch(fetchUrl, {
742
924
  method: 'POST',
743
925
  headers: { Authorization: closeCred },
744
926
  })
745
927
  if (closeRes.ok) {
746
- const closeReceiptHeader = closeRes.headers.get('Payment-Receipt')
747
- let closeTxHash: string | undefined
748
- if (closeReceiptHeader) {
749
- try {
750
- const r = JSON.parse(Base64.toString(closeReceiptHeader)) as Record<
751
- string,
752
- unknown
753
- >
754
- if (typeof r.txHash === 'string') closeTxHash = r.txHash
755
- } catch {}
928
+ if (verbose >= 1) {
929
+ const closeReceiptHeader = closeRes.headers.get('Payment-Receipt')
930
+ let closeTxHash: string | undefined
931
+ if (closeReceiptHeader) {
932
+ try {
933
+ const r = JSON.parse(Base64.toString(closeReceiptHeader)) as Record<
934
+ string,
935
+ unknown
936
+ >
937
+ if (typeof r.txHash === 'string') closeTxHash = r.txHash
938
+ } catch {}
939
+ }
940
+ const txInfo =
941
+ closeTxHash && explorerUrl
942
+ ? ` ${pc.dim(pc.link(`${explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
943
+ : ''
944
+ info(
945
+ `\n${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(cumulativeAmount, tokenSymbol, tokenDecimals)}.`)}${txInfo}\n`,
946
+ )
756
947
  }
757
- const txInfo =
758
- closeTxHash && explorerUrl
759
- ? ` ${pc.dim(pc.link(`${explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
760
- : ''
761
- info(
762
- `\n${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(cumulativeAmount, tokenSymbol, tokenDecimals)}.`)}${txInfo}\n`,
763
- )
764
948
  } else {
765
949
  info(
766
950
  `\n${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`,
@@ -777,11 +961,11 @@ cli
777
961
  sessionChannelId &&
778
962
  sessionEscrowContract &&
779
963
  sessionChainId
780
- if (shouldClose && options.confirm) {
964
+ if (shouldClose && confirmEnabled) {
781
965
  info('\n')
782
966
  }
783
- if (shouldClose && options.confirm && !(await confirm('Close channel?', true))) {
784
- info(`${pc.dim('Kept channel open.')}\n`)
967
+ if (shouldClose && confirmEnabled && !(await confirm('Close channel?', true))) {
968
+ if (verbose >= 1) info(`${pc.dim('Kept channel open.')}\n`)
785
969
  } else if (shouldClose) {
786
970
  const signature = await signVoucher(
787
971
  client!,
@@ -801,7 +985,7 @@ cli
801
985
  payload: closePayload,
802
986
  source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
803
987
  })
804
- const closeRes = await globalThis.fetch(url, {
988
+ const closeRes = await globalThis.fetch(fetchUrl, {
805
989
  ...fetchInit,
806
990
  headers: {
807
991
  ...(fetchInit.headers as Record<string, string>),
@@ -810,25 +994,27 @@ cli
810
994
  })
811
995
  if (closeRes.ok) {
812
996
  deleteChannelState(sessionChannelId!)
813
- const closeReceiptHeader = closeRes.headers.get('Payment-Receipt')
814
- let closeTxHash: string | undefined
815
- if (closeReceiptHeader) {
816
- try {
817
- const r = JSON.parse(Base64.toString(closeReceiptHeader)) as Record<
818
- string,
819
- unknown
820
- >
821
- if (typeof r.txHash === 'string') closeTxHash = r.txHash
822
- } catch {}
997
+ if (verbose >= 1) {
998
+ const closeReceiptHeader = closeRes.headers.get('Payment-Receipt')
999
+ let closeTxHash: string | undefined
1000
+ if (closeReceiptHeader) {
1001
+ try {
1002
+ const r = JSON.parse(Base64.toString(closeReceiptHeader)) as Record<
1003
+ string,
1004
+ unknown
1005
+ >
1006
+ if (typeof r.txHash === 'string') closeTxHash = r.txHash
1007
+ } catch {}
1008
+ }
1009
+ const txInfo =
1010
+ closeTxHash && explorerUrl
1011
+ ? ` ${pc.dim(pc.link(`${explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
1012
+ : ''
1013
+ const closePrefix = confirmEnabled ? '' : '\n'
1014
+ info(
1015
+ `${closePrefix}${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(sessionCumulativeAmount, tokenSymbol, tokenDecimals)}.`)}${txInfo}\n`,
1016
+ )
823
1017
  }
824
- const txInfo =
825
- closeTxHash && explorerUrl
826
- ? ` ${pc.dim(pc.link(`${explorerUrl}/tx/${closeTxHash}`, closeTxHash))}`
827
- : ''
828
- const closePrefix = options.confirm ? '' : '\n'
829
- info(
830
- `${closePrefix}${pc.dim('Channel closed.')} ${pc.dim(`Spent ${fmtBalance(sessionCumulativeAmount, tokenSymbol, tokenDecimals)}.`)}${txInfo}\n`,
831
- )
832
1018
  } else {
833
1019
  const closeBody = await closeRes.text().catch(() => '')
834
1020
  info(
@@ -855,272 +1041,572 @@ cli
855
1041
  if (cause && 'code' in cause) {
856
1042
  const code = cause.code as string
857
1043
  if (code === 'ENOTFOUND')
858
- console.error(`Could not resolve host "${hostname}". Check the URL and try again.`)
1044
+ return error({
1045
+ code: 'DNS_ERROR',
1046
+ message: `Could not resolve host "${hostname}". Check the URL and try again.`,
1047
+ exitCode: 6,
1048
+ })
859
1049
  else if (code === 'ECONNREFUSED')
860
- console.error(`Connection refused by "${hostname}". Is the server running?`)
861
- else if (code === 'ECONNRESET') console.error(`Connection to "${hostname}" was reset.`)
862
- else if (code === 'ETIMEDOUT') console.error(`Connection to "${hostname}" timed out.`)
1050
+ return error({
1051
+ code: 'CONNECTION_REFUSED',
1052
+ message: `Connection refused by "${hostname}". Is the server running?`,
1053
+ retryable: true,
1054
+ exitCode: 7,
1055
+ })
1056
+ else if (code === 'ECONNRESET')
1057
+ return error({
1058
+ code: 'CONNECTION_RESET',
1059
+ message: `Connection to "${hostname}" was reset.`,
1060
+ retryable: true,
1061
+ exitCode: 56,
1062
+ })
1063
+ else if (code === 'ETIMEDOUT')
1064
+ return error({
1065
+ code: 'CONNECTION_TIMEOUT',
1066
+ message: `Connection to "${hostname}" timed out.`,
1067
+ retryable: true,
1068
+ exitCode: 28,
1069
+ })
863
1070
  else if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')
864
- console.error(
865
- `TLS certificate error for "${hostname}". Use --insecure to skip verification.`,
866
- )
867
- else {
868
- console.error(`Request to "${hostname}" failed: ${cause.message}`)
869
- }
1071
+ return error({
1072
+ code: 'TLS_ERROR',
1073
+ message: `TLS certificate error for "${hostname}". Use --insecure to skip verification.`,
1074
+ exitCode: 60,
1075
+ })
1076
+ else
1077
+ return error({
1078
+ code: 'REQUEST_FAILED',
1079
+ message: `Request to "${hostname}" failed: ${cause.message}`,
1080
+ })
870
1081
  } else {
871
- console.error('Request failed:', err instanceof Error ? err.message : err)
872
- if (cause) console.error('Cause:', cause.message)
1082
+ const msg = err instanceof Error ? err.message : String(err)
1083
+ return error({
1084
+ code: 'REQUEST_FAILED',
1085
+ message: cause
1086
+ ? `Request failed: ${msg} (Cause: ${cause.message})`
1087
+ : `Request failed: ${msg}`,
1088
+ })
873
1089
  }
874
- process.exit(1)
875
1090
  }
876
- })
1091
+ },
1092
+ })
877
1093
 
878
- const accountOptionsSchema = z.object({
879
- account: z.optional(z.string()),
880
- rpcUrl: z.optional(z.string()),
881
- yes: z.optional(z.boolean()),
1094
+ const account = Cli.create('account', {
1095
+ description: 'Manage accounts (create, default, delete, fund, list, view)',
882
1096
  })
883
1097
 
884
- cli
885
- .command('account [action]', 'Manage accounts (create, default, delete, fund, list, view)')
886
- .option('-a, --account <name>', 'Account name (env: MPPX_ACCOUNT)')
887
- .option(
888
- '-r, --rpc-url <url>',
889
- 'RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)',
890
- )
891
- .option('--yes', 'DANGER!! Skip confirmation prompts')
892
- .action(async (action: string | undefined, rawOptions: unknown) => {
893
- if (!action) {
894
- cli.outputHelp()
1098
+ account.command('create', {
1099
+ description: 'Create new account',
1100
+ options: z.object({
1101
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
1102
+ rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
1103
+ }),
1104
+ alias: { account: 'a', rpcUrl: 'r' },
1105
+ async run({ options }) {
1106
+ let resolvedName = options.account
1107
+ if (!resolvedName) {
1108
+ const existing = await createKeychain().list()
1109
+ if (existing.length === 0) resolvedName = 'main'
1110
+ else {
1111
+ const input = await prompt('Account name')
1112
+ if (!input) return
1113
+ resolvedName = input
1114
+ }
1115
+ }
1116
+ let keychain = createKeychain(resolvedName)
1117
+ while (await keychain.get()) {
1118
+ process.stderr.write(`${pc.dim(`Account "${resolvedName}" already exists.`)}\n\n`)
1119
+ const input = await prompt('Enter different name')
1120
+ if (!input) return
1121
+ resolvedName = input
1122
+ keychain = createKeychain(resolvedName)
1123
+ }
1124
+ const privateKey = generatePrivateKey()
1125
+ const acct = privateKeyToAccount(privateKey)
1126
+ await keychain.set(privateKey)
1127
+ const accounts = await createKeychain().list()
1128
+ if (accounts.length === 1) createDefaultStore().set(resolvedName)
1129
+ console.log(`Account "${resolvedName}" saved to keychain.`)
1130
+ const explorerUrl = tempoMainnet.blockExplorers?.default?.url
1131
+ const addrDisplay = explorerUrl
1132
+ ? pc.link(`${explorerUrl}/address/${acct.address}`, acct.address)
1133
+ : acct.address
1134
+ console.log(pc.dim(`Address ${addrDisplay}`))
1135
+ resolveChain(options)
1136
+ .then((chain) => createClient({ chain, transport: http(options.rpcUrl) }))
1137
+ .then((client) =>
1138
+ import('viem/tempo').then(({ Actions }) =>
1139
+ Actions.faucet.fund(client, { account: acct }).catch(() => {}),
1140
+ ),
1141
+ )
1142
+ },
1143
+ })
1144
+
1145
+ account.command('default', {
1146
+ description: 'Set default account',
1147
+ options: z.object({
1148
+ account: z.string().describe('Account name'),
1149
+ }),
1150
+ alias: { account: 'a' },
1151
+ async run({ options, error }) {
1152
+ const accountName = options.account
1153
+ if (isTempoAccount(accountName)) {
1154
+ const tempoEntry = resolveTempoAccount(accountName)
1155
+ if (!tempoEntry) {
1156
+ return error({
1157
+ code: 'ACCOUNT_NOT_FOUND',
1158
+ message: `Account "${accountName}" not found. Is Tempo wallet configured?`,
1159
+ exitCode: 69,
1160
+ })
1161
+ }
1162
+ createDefaultStore().set(accountName)
1163
+ console.log(`Default account set to "${accountName}"`)
895
1164
  return
896
1165
  }
897
- const options = parseOptions(accountOptionsSchema, rawOptions)
898
- switch (action) {
899
- case 'create': {
900
- let resolvedName = options.account
901
- if (!resolvedName) {
902
- const existing = await createKeychain().list()
903
- if (existing.length === 0) resolvedName = 'main'
904
- else {
905
- const input = await prompt('Account name')
906
- if (!input) return
907
- resolvedName = input
908
- }
909
- }
910
- let keychain = createKeychain(resolvedName)
911
- while (await keychain.get()) {
912
- process.stderr.write(`${pc.dim(`Account "${resolvedName}" already exists.`)}\n\n`)
913
- const input = await prompt('Enter different name')
914
- if (!input) return
915
- resolvedName = input
916
- keychain = createKeychain(resolvedName)
917
- }
918
- const privateKey = generatePrivateKey()
919
- const account = privateKeyToAccount(privateKey)
920
- await keychain.set(privateKey)
921
- const accounts = await createKeychain().list()
922
- if (accounts.length === 1) createDefaultStore().set(resolvedName)
923
- console.log(`Account "${resolvedName}" saved to keychain.`)
924
- const explorerUrl = tempoMainnet.blockExplorers?.default?.url
925
- const addrDisplay = explorerUrl
926
- ? pc.link(`${explorerUrl}/address/${account.address}`, account.address)
927
- : account.address
928
- console.log(pc.dim(`Address ${addrDisplay}`))
929
- resolveChain(options)
930
- .then((chain) => createClient({ chain, transport: http(options.rpcUrl) }))
931
- .then((client) =>
932
- import('viem/tempo').then(({ Actions }) =>
933
- Actions.faucet.fund(client, { account }).catch(() => {}),
934
- ),
935
- )
1166
+ const key = await createKeychain(accountName).get()
1167
+ if (!key) {
1168
+ return error({
1169
+ code: 'ACCOUNT_NOT_FOUND',
1170
+ message: `Account "${accountName}" not found.`,
1171
+ exitCode: 69,
1172
+ })
1173
+ }
1174
+ createDefaultStore().set(accountName)
1175
+ console.log(`Default account set to "${accountName}"`)
1176
+ },
1177
+ })
1178
+
1179
+ account.command('delete', {
1180
+ description: 'Delete account',
1181
+ options: z.object({
1182
+ account: z.string().describe('Account name'),
1183
+ yes: z.boolean().optional().describe('DANGER!! Skip confirmation prompts'),
1184
+ }),
1185
+ alias: { account: 'a' },
1186
+ async run({ options, error }) {
1187
+ const keychain = createKeychain(options.account)
1188
+ const key = await keychain.get()
1189
+ if (!key) {
1190
+ return error({
1191
+ code: 'ACCOUNT_NOT_FOUND',
1192
+ message: `Account "${options.account}" not found.`,
1193
+ exitCode: 69,
1194
+ })
1195
+ }
1196
+ const acct = privateKeyToAccount(key as `0x${string}`)
1197
+ const balanceLines = await fetchBalanceLines(acct.address, { includeTestnet: false })
1198
+ if (!options.yes) {
1199
+ const explorerUrl = tempoMainnet.blockExplorers?.default?.url
1200
+ const addrDisplay = explorerUrl
1201
+ ? pc.link(`${explorerUrl}/address/${acct.address}`, acct.address)
1202
+ : acct.address
1203
+ process.stderr.write(pc.dim(`Delete account "${options.account}"\n`))
1204
+ process.stderr.write(pc.dim(` Address ${addrDisplay}\n`))
1205
+ for (let i = 0; i < balanceLines.length; i++)
1206
+ process.stderr.write(pc.dim(` ${i === 0 ? 'Balance' : ' '} ${balanceLines[i]}\n`))
1207
+ process.stderr.write(pc.dim('This action cannot be undone\n\n'))
1208
+ const confirmed = await confirm('Confirm delete?')
1209
+ if (!confirmed) {
1210
+ console.log('Canceled')
936
1211
  return
937
1212
  }
938
- case 'default': {
939
- const accountName = options.account
940
- if (!accountName) {
941
- console.error('-a, --account <name> is required for default.')
942
- process.exit(1)
943
- }
944
- const key = await createKeychain(accountName).get()
945
- if (!key) {
946
- console.log(`Account "${accountName}" not found.`)
947
- process.exit(1)
948
- }
949
- createDefaultStore().set(accountName)
950
- console.log(`Default account set to "${accountName}"`)
951
- return
1213
+ }
1214
+ await keychain.delete()
1215
+ const currentDefault = createDefaultStore().get()
1216
+ if (currentDefault === options.account) {
1217
+ const remaining = await createKeychain().list()
1218
+ if (remaining.length > 0) {
1219
+ createDefaultStore().set(remaining[0]!)
1220
+ console.log(`Default account set to "${remaining[0]}"`)
1221
+ } else {
1222
+ createDefaultStore().clear()
952
1223
  }
953
- case 'delete': {
954
- if (!options.account) {
955
- console.error('-a, --account <name> is required for delete.')
956
- process.exit(1)
957
- }
958
- const keychain = createKeychain(options.account)
959
- const key = await keychain.get()
960
- if (!key) {
961
- console.log(`Account "${options.account}" not found.`)
962
- process.exit(1)
963
- }
964
- const account = privateKeyToAccount(key as `0x${string}`)
965
- const balanceLines = await fetchBalanceLines(account.address, { includeTestnet: false })
966
- if (!options.yes) {
967
- const explorerUrl = tempoMainnet.blockExplorers?.default?.url
968
- const addrDisplay = explorerUrl
969
- ? pc.link(`${explorerUrl}/address/${account.address}`, account.address)
970
- : account.address
971
- process.stderr.write(pc.dim(`Delete account "${options.account}"\n`))
972
- process.stderr.write(pc.dim(` Address ${addrDisplay}\n`))
973
- for (let i = 0; i < balanceLines.length; i++)
974
- process.stderr.write(
975
- pc.dim(` ${i === 0 ? 'Balance' : ' '} ${balanceLines[i]}\n`),
976
- )
977
- process.stderr.write(pc.dim('This action cannot be undone\n\n'))
978
- const confirmed = await confirm('Confirm delete?')
979
- if (!confirmed) {
980
- console.log('Canceled')
981
- return
982
- }
983
- }
984
- await keychain.delete()
985
- const currentDefault = createDefaultStore().get()
986
- if (currentDefault === options.account) {
987
- const remaining = await createKeychain().list()
988
- if (remaining.length > 0) {
989
- createDefaultStore().set(remaining[0]!)
990
- console.log(`Default account set to "${remaining[0]}"`)
991
- } else {
992
- createDefaultStore().clear()
993
- }
994
- }
995
- console.log(`Account "${options.account}" deleted`)
996
- return
1224
+ }
1225
+ console.log(`Account "${options.account}" deleted`)
1226
+ },
1227
+ })
1228
+
1229
+ account.command('fund', {
1230
+ description: 'Fund account with testnet tokens',
1231
+ options: z.object({
1232
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
1233
+ rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
1234
+ }),
1235
+ alias: { account: 'a', rpcUrl: 'r' },
1236
+ async run({ options, error }) {
1237
+ const accountName = resolveAccountName(options.account)
1238
+ const keychain = createKeychain(accountName)
1239
+ const key = await keychain.get()
1240
+ if (!key) {
1241
+ if (options.account)
1242
+ return error({
1243
+ code: 'ACCOUNT_NOT_FOUND',
1244
+ message: `Account "${accountName}" not found.`,
1245
+ exitCode: 69,
1246
+ })
1247
+ else return error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
1248
+ }
1249
+ const acct = privateKeyToAccount(key as `0x${string}`)
1250
+ const chain = await resolveChain(options)
1251
+ const client = createClient({ chain, transport: http(options.rpcUrl) })
1252
+ console.log(`Funding "${accountName}" on ${chainName(chain)}`)
1253
+ try {
1254
+ const { Actions } = await import('viem/tempo')
1255
+ const hashes = await Actions.faucet.fund(client, { account: acct })
1256
+ const explorerUrl = chain.blockExplorers?.default?.url
1257
+ for (const hash of hashes) {
1258
+ const label = explorerUrl ? pc.link(`${explorerUrl}/tx/${hash}`, pc.gray(hash)) : hash
1259
+ console.log(` ${label}`)
997
1260
  }
998
- case 'fund': {
999
- const accountName = resolveAccountName(options.account)
1000
- const keychain = createKeychain(accountName)
1001
- const key = await keychain.get()
1002
- if (!key) {
1003
- if (options.account) console.log(`Account "${accountName}" not found.`)
1004
- else console.log(`No account found.`)
1005
- process.exit(1)
1006
- }
1007
- const account = privateKeyToAccount(key as `0x${string}`)
1008
- const chain = await resolveChain(options)
1009
- const client = createClient({ chain, transport: http(options.rpcUrl) })
1010
- console.log(`Funding "${accountName}" on ${chainName(chain)}`)
1011
- try {
1012
- const { Actions } = await import('viem/tempo')
1013
- const hashes = await Actions.faucet.fund(client, { account })
1014
- const explorerUrl = chain.blockExplorers?.default?.url
1015
- for (const hash of hashes) {
1016
- const label = explorerUrl ? pc.link(`${explorerUrl}/tx/${hash}`, pc.gray(hash)) : hash
1017
- console.log(` ${label}`)
1018
- }
1019
- const { waitForTransactionReceipt } = await import('viem/actions')
1020
- await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
1021
- console.log('Funded successfully')
1022
- } catch (err) {
1023
- console.error('Funding failed:', err instanceof Error ? err.message : err)
1024
- }
1025
- return
1261
+ const { waitForTransactionReceipt } = await import('viem/actions')
1262
+ await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
1263
+ console.log('Funded successfully')
1264
+ } catch (err) {
1265
+ console.error('Funding failed:', err instanceof Error ? err.message : err)
1266
+ }
1267
+ },
1268
+ })
1269
+
1270
+ account.command('list', {
1271
+ description: 'List all accounts',
1272
+ async run() {
1273
+ const currentDefault = createDefaultStore().get()
1274
+ const accounts = (await createKeychain().list()).sort()
1275
+ const resolved: { name: string; address: string; source?: string }[] = []
1276
+ for (const accountName of accounts) {
1277
+ const key = await createKeychain(accountName).get()
1278
+ if (!key) continue
1279
+ resolved.push({
1280
+ name: accountName,
1281
+ address: privateKeyToAccount(key as `0x${string}`).address,
1282
+ })
1283
+ }
1284
+ const tempoEntries = readTempoKeystore()
1285
+ for (let i = 0; i < tempoEntries.length; i++) {
1286
+ const entry = tempoEntries[i]!
1287
+ const tempoName = i === 0 ? 'tempo:default' : `tempo:${i}`
1288
+ if (entry.wallet_address)
1289
+ resolved.push({ name: tempoName, address: entry.wallet_address, source: 'tempo wallet' })
1290
+ }
1291
+ if (resolved.length === 0) {
1292
+ console.log(`No accounts found.`)
1293
+ return
1294
+ }
1295
+ const explorerUrl = tempoMainnet.blockExplorers?.default?.url
1296
+ const maxWidth = Math.max(
1297
+ ...resolved.map((e) => e.name.length + (e.name === currentDefault ? 1 : 0)),
1298
+ )
1299
+ for (const entry of resolved) {
1300
+ const isDefault = entry.name === currentDefault
1301
+ const label = isDefault ? `${entry.name}${pc.dim('*')}` : entry.name
1302
+ const width = entry.name.length + (isDefault ? 1 : 0)
1303
+ const addrDisplay = explorerUrl
1304
+ ? pc.link(`${explorerUrl}/address/${entry.address}`, entry.address)
1305
+ : entry.address
1306
+ const sourceLabel = entry.source ? ` ${pc.dim(`(${entry.source})`)}` : ''
1307
+ console.log(`${label}${' '.repeat(maxWidth - width + 2)}${pc.dim(addrDisplay)}${sourceLabel}`)
1308
+ }
1309
+ },
1310
+ })
1311
+
1312
+ account.command('view', {
1313
+ description: 'View account address',
1314
+ options: z.object({
1315
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
1316
+ rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
1317
+ }),
1318
+ alias: { account: 'a', rpcUrl: 'r' },
1319
+ async run({ options, error }) {
1320
+ const accountName = resolveAccountName(options.account)
1321
+
1322
+ if (isTempoAccount(accountName)) {
1323
+ const tempoEntry = resolveTempoAccount(accountName)
1324
+ if (!tempoEntry) {
1325
+ return error({
1326
+ code: 'ACCOUNT_NOT_FOUND',
1327
+ message: `Account "${accountName}" not found. Is Tempo wallet configured?`,
1328
+ exitCode: 69,
1329
+ })
1026
1330
  }
1027
- case 'list': {
1028
- const currentDefault = createDefaultStore().get()
1029
- const accounts = (await createKeychain().list()).sort()
1030
- if (accounts.length === 0) {
1031
- console.log(`No accounts found.`)
1032
- return
1033
- }
1034
- const entries = await Promise.all(
1035
- accounts.map(async (accountName) => {
1036
- const key = await createKeychain(accountName).get()
1037
- if (!key) return undefined
1038
- return {
1039
- name: accountName,
1040
- address: privateKeyToAccount(key as `0x${string}`).address,
1041
- }
1042
- }),
1043
- )
1044
- const resolved = entries.filter((e) => e !== undefined)
1045
- const explorerUrl = tempoMainnet.blockExplorers?.default?.url
1046
- const maxWidth = Math.max(
1047
- ...resolved.map((e) => e.name.length + (e.name === currentDefault ? 1 : 0)),
1048
- )
1049
- for (const entry of resolved) {
1050
- const isDefault = entry.name === currentDefault
1051
- const label = isDefault ? `${entry.name}${pc.dim('*')}` : entry.name
1052
- const width = entry.name.length + (isDefault ? 1 : 0)
1053
- const addrDisplay = explorerUrl
1054
- ? pc.link(`${explorerUrl}/address/${entry.address}`, entry.address)
1055
- : entry.address
1056
- console.log(`${label}${' '.repeat(maxWidth - width + 2)}${pc.dim(addrDisplay)}`)
1331
+ const address = tempoEntry.wallet_address as Address
1332
+ const rpcUrl = options.rpcUrl ?? (process.env.MPPX_RPC_URL || undefined)
1333
+ const chain = rpcUrl ? await resolveChain({ rpcUrl }) : tempoMainnet
1334
+ const explorerUrl = chain.blockExplorers?.default?.url
1335
+ const addrDisplay = explorerUrl
1336
+ ? pc.link(`${explorerUrl}/address/${address}`, address)
1337
+ : address
1338
+ console.log(`${pc.dim('Address')} ${addrDisplay}`)
1339
+
1340
+ const balanceLines = await fetchBalanceLines(
1341
+ address,
1342
+ chain && rpcUrl ? { chain, rpcUrl } : undefined,
1343
+ )
1344
+ for (let i = 0; i < balanceLines.length; i++)
1345
+ console.log(`${pc.dim(i === 0 ? 'Balance' : ' ')} ${balanceLines[i]}`)
1346
+
1347
+ console.log(`${pc.dim('Name')} ${accountName}`)
1348
+ console.log(`${pc.dim('Type')} ${tempoEntry.wallet_type} ${pc.dim('(tempo wallet)')}`)
1349
+ return
1350
+ }
1351
+
1352
+ const keychain = createKeychain(accountName)
1353
+ const key = await keychain.get()
1354
+ if (!key) {
1355
+ if (options.account)
1356
+ return error({
1357
+ code: 'ACCOUNT_NOT_FOUND',
1358
+ message: `Account "${accountName}" not found.`,
1359
+ exitCode: 69,
1360
+ })
1361
+ else return error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
1362
+ }
1363
+ const acct = privateKeyToAccount(key as `0x${string}`)
1364
+ const rpcUrl = options.rpcUrl ?? (process.env.MPPX_RPC_URL || undefined)
1365
+ const chain = rpcUrl ? await resolveChain({ rpcUrl }) : tempoMainnet
1366
+ const explorerUrl = chain.blockExplorers?.default?.url
1367
+ const addrDisplay = explorerUrl
1368
+ ? pc.link(`${explorerUrl}/address/${acct.address}`, acct.address)
1369
+ : acct.address
1370
+ console.log(`${pc.dim('Address')} ${addrDisplay}`)
1371
+
1372
+ const balanceLines = await fetchBalanceLines(
1373
+ acct.address,
1374
+ chain && rpcUrl ? { chain, rpcUrl } : undefined,
1375
+ )
1376
+ for (let i = 0; i < balanceLines.length; i++)
1377
+ console.log(`${pc.dim(i === 0 ? 'Balance' : ' ')} ${balanceLines[i]}`)
1378
+
1379
+ console.log(`${pc.dim('Name')} ${accountName}`)
1380
+ },
1381
+ })
1382
+
1383
+ cli.command(account)
1384
+
1385
+ const sign = Cli.create('sign', {
1386
+ description: 'Sign a payment challenge and output the Authorization header',
1387
+ usage: [
1388
+ { suffix: '--challenge <value> [options]' },
1389
+ { prefix: 'echo <challenge> |', suffix: '[options]' },
1390
+ ],
1391
+ options: z.object({
1392
+ account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
1393
+ challenge: z.string().optional().describe('WWW-Authenticate challenge value'),
1394
+ dryRun: z.boolean().optional().describe('Validate and parse the challenge without signing'),
1395
+ methodOpt: z
1396
+ .array(z.string())
1397
+ .optional()
1398
+ .describe('Method-specific option (key=value, repeatable)'),
1399
+ rpcUrl: z
1400
+ .string()
1401
+ .optional()
1402
+ .describe('RPC endpoint, defaults to public RPC for chain (env: MPPX_RPC_URL)'),
1403
+ }),
1404
+ alias: {
1405
+ account: 'a',
1406
+ challenge: 'c',
1407
+ methodOpt: 'M',
1408
+ rpcUrl: 'r',
1409
+ },
1410
+ async run({ options, format, error }) {
1411
+ const raw = options.challenge || (process.stdin.isTTY === false ? await readStdin() : undefined)
1412
+ if (!raw) {
1413
+ return error({
1414
+ code: 'NO_CHALLENGE',
1415
+ message: 'No challenge provided. Use --challenge or pipe via stdin.',
1416
+ exitCode: 2,
1417
+ })
1418
+ }
1419
+
1420
+ let challenge: Challenge.Challenge
1421
+ try {
1422
+ challenge = Challenge.deserialize(raw)
1423
+ } catch (err) {
1424
+ return error({
1425
+ code: 'INVALID_CHALLENGE',
1426
+ message: `Failed to parse challenge: ${err instanceof Error ? err.message : err}`,
1427
+ exitCode: 2,
1428
+ })
1429
+ }
1430
+
1431
+ if (options.dryRun) {
1432
+ process.stderr.write('Challenge is valid.\n')
1433
+ return
1434
+ }
1435
+
1436
+ if (challenge.method === 'tempo') {
1437
+ const accountName = resolveAccountName(options.account)
1438
+
1439
+ // Delegate to tempo CLI for tempo wallet accounts
1440
+ if (isTempoAccount(accountName) && hasTempoCliSync()) {
1441
+ const wwwAuth = Challenge.serialize(challenge)
1442
+ const result = await tempoCliSign(wwwAuth)
1443
+ if (format === 'json') {
1444
+ const tempoEntry = resolveTempoAccount(accountName)
1445
+ console.log(JSON.stringify({ authorization: result, from: tempoEntry?.wallet_address }))
1446
+ } else {
1447
+ console.log(result)
1057
1448
  }
1058
1449
  return
1059
1450
  }
1060
- case 'view': {
1061
- const accountName = resolveAccountName(options.account)
1062
- const keychain = createKeychain(accountName)
1063
- const key = await keychain.get()
1064
- if (!key) {
1065
- if (options.account) console.log(`Account "${accountName}" not found.`)
1066
- else console.log(`No account found.`)
1067
- process.exit(1)
1068
- }
1069
- const account = privateKeyToAccount(key as `0x${string}`)
1070
- const rpcUrl = options.rpcUrl ?? (process.env.MPPX_RPC_URL || undefined)
1071
- const chain = rpcUrl ? await resolveChain({ rpcUrl }) : tempoMainnet
1072
- const explorerUrl = chain.blockExplorers?.default?.url
1073
- const addrDisplay = explorerUrl
1074
- ? pc.link(`${explorerUrl}/address/${account.address}`, account.address)
1075
- : account.address
1076
- console.log(`${pc.dim('Address')} ${addrDisplay}`)
1077
-
1078
- const balanceLines = await fetchBalanceLines(
1079
- account.address,
1080
- chain && rpcUrl ? { chain, rpcUrl } : undefined,
1081
- )
1082
- for (let i = 0; i < balanceLines.length; i++)
1083
- console.log(`${pc.dim(i === 0 ? 'Balance' : ' ')} ${balanceLines[i]}`)
1084
1451
 
1085
- console.log(`${pc.dim('Name')} ${accountName}`)
1086
- return
1452
+ let privateKey =
1453
+ process.env.MPPX_PRIVATE_KEY?.trim() ||
1454
+ (isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
1455
+ if (!privateKey) {
1456
+ const fallback = fallbackFromTempo()
1457
+ if (fallback) privateKey = await createKeychain(fallback).get()
1087
1458
  }
1088
- default:
1089
- console.error(`Unknown action: ${action}`)
1090
- console.error('Available: create, default, delete, fund, list, view')
1091
- process.exit(1)
1092
- }
1093
- })
1459
+ if (!privateKey) {
1460
+ if (options.account)
1461
+ return error({
1462
+ code: 'ACCOUNT_NOT_FOUND',
1463
+ message: `Account "${accountName}" not found.`,
1464
+ exitCode: 69,
1465
+ })
1466
+ else return error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
1467
+ }
1468
+ const account = privateKeyToAccount(privateKey as `0x${string}`)
1469
+ const rpcUrl = options.rpcUrl ?? process.env.RPC_URL
1470
+ const client = createClient({
1471
+ chain: await resolveChain({ rpcUrl }),
1472
+ transport: http(rpcUrl),
1473
+ })
1474
+
1475
+ const methodOpts = parseMethodOpts(options.methodOpt)
1476
+ const tempoOpts = parseOptions(
1477
+ z.object({
1478
+ channel: z.optional(z.coerce.string()),
1479
+ deposit: z.optional(z.union([z.string(), z.number()])),
1480
+ }),
1481
+ methodOpts,
1482
+ )
1094
1483
 
1095
- cli.version(version, '-V, --version')
1096
-
1097
- cli.help((sections) => {
1098
- const isAccount = sections.some((s: { body?: string }) => s.body?.includes('$ mppx account'))
1099
- if (isAccount) {
1100
- const actionsSection = {
1101
- title: 'Actions',
1102
- body: [
1103
- ' create Create new account',
1104
- ' default Set default account',
1105
- ' delete Delete account',
1106
- ' fund Fund account with testnet tokens',
1107
- ' list List all accounts',
1108
- ' view View account address',
1109
- ].join('\n'),
1484
+ const mppx = Mppx.create({
1485
+ methods: tempo({
1486
+ account,
1487
+ getClient: () => client,
1488
+ deposit: (() => {
1489
+ if (challenge.intent !== 'session') return undefined
1490
+ const suggestedDeposit = (challenge.request as Record<string, unknown>)
1491
+ .suggestedDeposit as string | undefined
1492
+ const cliDeposit =
1493
+ tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
1494
+ return suggestedDeposit ?? cliDeposit
1495
+ })(),
1496
+ }),
1497
+ polyfill: false,
1498
+ })
1499
+
1500
+ const wwwAuth = Challenge.serialize(challenge)
1501
+ const fakeResponse = new Response(null, {
1502
+ status: 402,
1503
+ headers: { 'WWW-Authenticate': wwwAuth },
1504
+ })
1505
+ const credential = await mppx.createCredential(
1506
+ fakeResponse,
1507
+ (() => {
1508
+ if (!tempoOpts.channel) return undefined
1509
+ const channelId = tempoOpts.channel
1510
+ const saved = readChannelCumulative(channelId)
1511
+ return {
1512
+ channelId,
1513
+ ...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }),
1514
+ }
1515
+ })(),
1516
+ )
1517
+
1518
+ if (format === 'json') {
1519
+ console.log(JSON.stringify({ authorization: credential, from: account.address }))
1520
+ } else {
1521
+ console.log(credential)
1522
+ }
1523
+ } else if (challenge.method === 'stripe') {
1524
+ const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
1525
+ if (!stripeSecretKey) {
1526
+ return error({
1527
+ code: 'MISSING_ENV',
1528
+ message: 'MPPX_STRIPE_SECRET_KEY environment variable is required for Stripe payments.',
1529
+ exitCode: 2,
1530
+ })
1531
+ }
1532
+ const methodOpts = parseMethodOpts(options.methodOpt)
1533
+ const stripeOpts = parseOptions(z.object({ paymentMethod: z.string() }), methodOpts)
1534
+
1535
+ const mppx = Mppx.create({
1536
+ methods: [
1537
+ stripe.charge({
1538
+ paymentMethod: stripeOpts.paymentMethod,
1539
+ createToken: async ({
1540
+ paymentMethod,
1541
+ amount,
1542
+ currency,
1543
+ networkId,
1544
+ expiresAt,
1545
+ metadata,
1546
+ }) => {
1547
+ const body = new URLSearchParams({
1548
+ payment_method: paymentMethod!,
1549
+ 'usage_limits[currency]': currency,
1550
+ 'usage_limits[max_amount]': amount,
1551
+ 'usage_limits[expires_at]': expiresAt.toString(),
1552
+ })
1553
+ if (networkId) body.set('seller_details[network_id]', networkId)
1554
+ if (metadata) {
1555
+ for (const [key, value] of Object.entries(metadata))
1556
+ body.set(`metadata[${key}]`, value)
1557
+ }
1558
+ const sptUrl =
1559
+ process.env.MPPX_STRIPE_SPT_URL ??
1560
+ 'https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens'
1561
+ const response = await globalThis.fetch(sptUrl, {
1562
+ method: 'POST',
1563
+ headers: {
1564
+ Authorization: `Basic ${btoa(`${stripeSecretKey}:`)}`,
1565
+ 'Content-Type': 'application/x-www-form-urlencoded',
1566
+ },
1567
+ body,
1568
+ })
1569
+ if (!response.ok) {
1570
+ const errorBody = (await response.json()) as { error: { message: string } }
1571
+ return error({
1572
+ code: 'STRIPE_ERROR',
1573
+ message: `Failed to create SPT: ${errorBody.error.message}`,
1574
+ exitCode: 77,
1575
+ })
1576
+ }
1577
+ const { id } = (await response.json()) as { id: string }
1578
+ return id
1579
+ },
1580
+ }),
1581
+ ],
1582
+ polyfill: false,
1583
+ })
1584
+
1585
+ const wwwAuth = Challenge.serialize(challenge)
1586
+ const fakeResponse = new Response(null, {
1587
+ status: 402,
1588
+ headers: { 'WWW-Authenticate': wwwAuth },
1589
+ })
1590
+ const credential = await mppx.createCredential(fakeResponse)
1591
+
1592
+ if (format === 'json') {
1593
+ console.log(JSON.stringify({ authorization: credential }))
1594
+ } else {
1595
+ console.log(credential)
1596
+ }
1597
+ } else {
1598
+ return error({
1599
+ code: 'UNSUPPORTED_METHOD',
1600
+ message: `Unsupported payment method: ${challenge.method}`,
1601
+ exitCode: 2,
1602
+ })
1110
1603
  }
1111
- const optionsIndex = sections.findIndex((s: { title?: string }) => s.title === 'Options')
1112
- if (optionsIndex !== -1) sections.splice(optionsIndex, 0, actionsSection)
1113
- else sections.push(actionsSection)
1114
- }
1115
- return sections
1604
+ },
1116
1605
  })
1117
1606
 
1118
- try {
1119
- cli.parse()
1120
- } catch (err) {
1121
- console.error(err instanceof Error ? err.message : err)
1122
- process.exit(1)
1123
- }
1607
+ cli.command(sign)
1608
+
1609
+ export default cli
1124
1610
 
1125
1611
  /////////////////////////////////////////////////////////////////////////////////////////////////
1126
1612
 
@@ -1131,15 +1617,14 @@ function parseMethodOpts(raw: string | string[] | undefined): Record<string, str
1131
1617
  for (const item of list) {
1132
1618
  const idx = item.indexOf('=')
1133
1619
  if (idx === -1) {
1134
- console.error(`Invalid method option format: ${item} (expected key=value)`)
1135
- process.exit(1)
1620
+ throw new Error(`Invalid method option format: ${item} (expected key=value)`)
1136
1621
  }
1137
1622
  result[item.slice(0, idx)] = item.slice(idx + 1)
1138
1623
  }
1139
1624
  return result
1140
1625
  }
1141
1626
 
1142
- function parseOptions<const schema extends ZodMiniType>(
1627
+ function parseOptions<const schema extends z.ZodType>(
1143
1628
  schema: schema,
1144
1629
  rawOptions: unknown,
1145
1630
  ): z.output<schema> {
@@ -1226,6 +1711,121 @@ function resolveAccountName(explicit?: string): string {
1226
1711
  return createDefaultStore().get()
1227
1712
  }
1228
1713
 
1714
+ function isTempoAccount(accountName: string): boolean {
1715
+ return accountName.startsWith('tempo:')
1716
+ }
1717
+
1718
+ function tempoKeystorePath(): string {
1719
+ const platform = os.platform()
1720
+ if (platform === 'darwin')
1721
+ return path.join(os.homedir(), 'Library', 'Application Support', 'tempo', 'wallet', 'keys.toml')
1722
+ return path.join(
1723
+ process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'),
1724
+ 'tempo',
1725
+ 'wallet',
1726
+ 'keys.toml',
1727
+ )
1728
+ }
1729
+
1730
+ interface TempoKeyEntry {
1731
+ wallet_type: string
1732
+ wallet_address: string
1733
+ chain_id: number
1734
+ }
1735
+
1736
+ function readTempoKeystore(): TempoKeyEntry[] {
1737
+ try {
1738
+ const raw = fs.readFileSync(tempoKeystorePath(), 'utf-8')
1739
+ const entries: TempoKeyEntry[] = []
1740
+ let current: Partial<TempoKeyEntry> | undefined
1741
+ for (const line of raw.split('\n')) {
1742
+ const trimmed = line.trim()
1743
+ if (trimmed === '[[keys]]') {
1744
+ if (current?.wallet_address) entries.push(current as TempoKeyEntry)
1745
+ current = { wallet_type: 'local', wallet_address: '', chain_id: 0 }
1746
+ continue
1747
+ }
1748
+ if (!current) continue
1749
+ const m = trimmed.match(/^(\w+)\s*=\s*"?([^"]*)"?$/)
1750
+ if (!m) continue
1751
+ const [, key, value] = m
1752
+ if (key === 'wallet_type') current.wallet_type = value!
1753
+ else if (key === 'wallet_address') current.wallet_address = value!
1754
+ else if (key === 'chain_id') current.chain_id = Number.parseInt(value!, 10)
1755
+ }
1756
+ if (current?.wallet_address) entries.push(current as TempoKeyEntry)
1757
+ return entries
1758
+ } catch {
1759
+ return []
1760
+ }
1761
+ }
1762
+
1763
+ function resolveTempoAccount(accountName: string): TempoKeyEntry | undefined {
1764
+ const entries = readTempoKeystore()
1765
+ if (entries.length === 0) return undefined
1766
+ const suffix = accountName.slice('tempo:'.length)
1767
+ if (suffix === 'default' || suffix === '') return entries[0]
1768
+ const idx = Number.parseInt(suffix, 10)
1769
+ if (!Number.isNaN(idx) && idx >= 0 && idx < entries.length) return entries[idx]
1770
+ return undefined
1771
+ }
1772
+
1773
+ let _tempoCliAvailable: boolean | undefined
1774
+ function hasTempoCliSync(): boolean {
1775
+ if (_tempoCliAvailable !== undefined) return _tempoCliAvailable
1776
+ try {
1777
+ child.execFileSync('which', ['tempo'], { stdio: 'ignore' })
1778
+ _tempoCliAvailable = true
1779
+ } catch {
1780
+ _tempoCliAvailable = false
1781
+ }
1782
+ return _tempoCliAvailable
1783
+ }
1784
+
1785
+ async function tempoCliSign(wwwAuth: string): Promise<string> {
1786
+ return new Promise((resolve, reject) => {
1787
+ child.execFile('tempo', ['mpp', 'sign', '--challenge', wwwAuth], (error, stdout, stderr) => {
1788
+ if (error) {
1789
+ const msg = stderr?.trim() || error.message
1790
+ reject(new Error(`tempo mpp sign failed: ${msg}`))
1791
+ return
1792
+ }
1793
+ const trimmed = stdout.trim()
1794
+ if (!trimmed) {
1795
+ reject(new Error('tempo mpp sign returned empty output'))
1796
+ return
1797
+ }
1798
+ resolve(trimmed)
1799
+ })
1800
+ })
1801
+ }
1802
+
1803
+ function fallbackFromTempo(): string | undefined {
1804
+ const store = createDefaultStore()
1805
+ const currentDefault = store.get()
1806
+ if (!isTempoAccount(currentDefault)) return undefined
1807
+ if (hasTempoCliSync()) return undefined
1808
+ // tempo CLI not installed, fall back to first mppx account
1809
+ // (sync list via security dump-keychain to avoid async in hot path)
1810
+ const platform = os.platform()
1811
+ if (platform === 'darwin') {
1812
+ try {
1813
+ const stdout = child.execFileSync('security', ['dump-keychain'], { encoding: 'utf-8' })
1814
+ const mppxAccounts: string[] = []
1815
+ for (const block of stdout.split('keychain:')) {
1816
+ const serviceMatch = block.match(/"svce"<blob>="([^"]*)"/)
1817
+ const accountMatch = block.match(/"acct"<blob>="([^"]*)"/)
1818
+ if (serviceMatch?.[1] === name && accountMatch?.[1]) mppxAccounts.push(accountMatch[1])
1819
+ }
1820
+ if (mppxAccounts.length > 0) {
1821
+ store.set(mppxAccounts[0]!)
1822
+ return mppxAccounts[0]!
1823
+ }
1824
+ } catch {}
1825
+ }
1826
+ return undefined
1827
+ }
1828
+
1229
1829
  // biome-ignore format: compact shell commands
1230
1830
  function createKeychain(account = 'main') {
1231
1831
  const service = name