mppx 0.4.1 → 0.4.3

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 (63) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/README.md +30 -6
  3. package/dist/bin.js +2 -2
  4. package/dist/bin.js.map +1 -1
  5. package/dist/cli/account.d.ts +53 -0
  6. package/dist/cli/account.d.ts.map +1 -0
  7. package/dist/cli/account.js +156 -0
  8. package/dist/cli/account.js.map +1 -0
  9. package/dist/{cli.d.ts → cli/cli.d.ts} +4 -3
  10. package/dist/cli/cli.d.ts.map +1 -0
  11. package/dist/cli/cli.js +852 -0
  12. package/dist/cli/cli.js.map +1 -0
  13. package/dist/cli/config.d.ts +39 -0
  14. package/dist/cli/config.d.ts.map +1 -0
  15. package/dist/cli/config.js +30 -0
  16. package/dist/cli/config.js.map +1 -0
  17. package/dist/cli/internal.d.ts +16 -0
  18. package/dist/cli/internal.d.ts.map +1 -0
  19. package/dist/cli/internal.js +58 -0
  20. package/dist/cli/internal.js.map +1 -0
  21. package/dist/cli/plugins/index.d.ts +4 -0
  22. package/dist/cli/plugins/index.d.ts.map +1 -0
  23. package/dist/cli/plugins/index.js +4 -0
  24. package/dist/cli/plugins/index.js.map +1 -0
  25. package/dist/cli/plugins/plugin.d.ts +68 -0
  26. package/dist/cli/plugins/plugin.d.ts.map +1 -0
  27. package/dist/cli/plugins/plugin.js +4 -0
  28. package/dist/cli/plugins/plugin.js.map +1 -0
  29. package/dist/cli/plugins/stripe.d.ts +2 -0
  30. package/dist/cli/plugins/stripe.d.ts.map +1 -0
  31. package/dist/cli/plugins/stripe.js +118 -0
  32. package/dist/cli/plugins/stripe.js.map +1 -0
  33. package/dist/cli/plugins/tempo.d.ts +11 -0
  34. package/dist/cli/plugins/tempo.d.ts.map +1 -0
  35. package/dist/cli/plugins/tempo.js +706 -0
  36. package/dist/cli/plugins/tempo.js.map +1 -0
  37. package/dist/cli/utils.d.ts +93 -0
  38. package/dist/cli/utils.d.ts.map +1 -0
  39. package/dist/cli/utils.js +274 -0
  40. package/dist/cli/utils.js.map +1 -0
  41. package/dist/tempo/client/Methods.d.ts +1 -1
  42. package/dist/tempo/client/Session.d.ts +2 -2
  43. package/dist/tempo/internal/defaults.d.ts +1 -1
  44. package/dist/tempo/internal/defaults.js +1 -1
  45. package/package.json +12 -1
  46. package/src/bin.ts +2 -2
  47. package/src/cli/account.ts +157 -0
  48. package/src/{cli.test.ts → cli/cli.test.ts} +107 -51
  49. package/src/cli/cli.ts +907 -0
  50. package/src/cli/config.test.ts +82 -0
  51. package/src/cli/config.ts +44 -0
  52. package/src/cli/internal.ts +72 -0
  53. package/src/cli/plugins/index.ts +3 -0
  54. package/src/cli/plugins/plugin.ts +73 -0
  55. package/src/cli/plugins/stripe.ts +143 -0
  56. package/src/cli/plugins/tempo.ts +842 -0
  57. package/src/cli/utils.ts +336 -0
  58. package/src/tempo/internal/defaults.test.ts +1 -1
  59. package/src/tempo/internal/defaults.ts +1 -1
  60. package/dist/cli.d.ts.map +0 -1
  61. package/dist/cli.js +0 -1992
  62. package/dist/cli.js.map +0 -1
  63. package/src/cli.ts +0 -2178
package/src/cli.ts DELETED
@@ -1,2178 +0,0 @@
1
- import * as child from 'node:child_process'
2
- import * as fs from 'node:fs'
3
- import { createRequire } from 'node:module'
4
- import * as os from 'node:os'
5
- import * as path from 'node:path'
6
- import * as readline from 'node:readline'
7
- import { Cli, z } from 'incur'
8
- import { Base64 } from 'ox'
9
- import type { Chain } from 'viem'
10
- import { type Address, createClient, http } from 'viem'
11
- import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
12
- import { tempo as tempoMainnet, tempoModerato } from 'viem/chains'
13
- import * as Challenge from './Challenge.js'
14
- import * as Credential from './Credential.js'
15
- import * as Mppx from './client/Mppx.js'
16
- import { stripe } from './stripe/client/index.js'
17
- import { tempo } from './tempo/client/index.js'
18
- import type { SessionCredentialPayload } from './tempo/session/Types.js'
19
- import { signVoucher } from './tempo/session/Voucher.js'
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
-
33
- const require = createRequire(import.meta.url)
34
- const { name, version } = require('../package.json') as { name: string; version: string }
35
-
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 }) {
101
- const methodOpts = parseMethodOpts(options.methodOpt)
102
-
103
- const silent = options.silent ?? false
104
- const info = silent ? (_msg: string) => {} : (msg: string) => process.stderr.write(msg)
105
- let confirmEnabled = options.confirm ?? false
106
- if (silent) confirmEnabled = false
107
-
108
- const accountName = resolveAccountName(options.account)
109
-
110
- const headers: Record<string, string> = {}
111
- if (options.header) {
112
- for (const header of options.header) {
113
- const index = header.indexOf(':')
114
- if (index === -1) {
115
- return error({
116
- code: 'INVALID_HEADER',
117
- message: `Invalid header format: ${header}`,
118
- exitCode: 2,
119
- })
120
- }
121
- headers[header.slice(0, index).trim()] = header.slice(index + 1).trim()
122
- }
123
- }
124
- headers['User-Agent'] = options.userAgent ?? `${name}/${version}`
125
-
126
- const rawUrl = args.url
127
- const url = (() => {
128
- const hasProtocol = /^https?:\/\//.test(rawUrl)
129
- const isLocal = /^(localhost|.*\.localhost|127\.0\.0\.1|\[::1\])(:\d+)?/.test(rawUrl)
130
- return hasProtocol ? rawUrl : `${isLocal ? 'http' : 'https'}://${rawUrl}`
131
- })()
132
- const { hostname } = new URL(url)
133
- if (
134
- options.insecure ||
135
- hostname === 'localhost' ||
136
- hostname.endsWith('.localhost') ||
137
- hostname.endsWith('.local')
138
- ) {
139
- process.removeAllListeners('warning')
140
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
141
- }
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
-
153
- try {
154
- const fetchInit: RequestInit = { redirect: options.location ? 'follow' : 'manual' }
155
- if (options.jsonBody) {
156
- fetchInit.body = options.jsonBody
157
- headers['Content-Type'] ??= 'application/json'
158
- headers.Accept ??= 'application/json'
159
- } else if (options.data) {
160
- fetchInit.body = options.data
161
- }
162
- if (options.method) fetchInit.method = options.method.toUpperCase()
163
- else if (fetchInit.body) fetchInit.method = 'POST'
164
- if (Object.keys(headers).length > 0) fetchInit.headers = headers
165
-
166
- const verbose = options.verbose
167
-
168
- const printRequestHeaders = (reqUrl: string, init: RequestInit) => {
169
- if (verbose < 2) return
170
- const { pathname, host } = new URL(reqUrl)
171
- const method = (init.method ?? 'GET').toUpperCase()
172
- info(`> ${method} ${pathname} HTTP/1.1\n`)
173
- info(`> Host: ${host}\n`)
174
- for (const [k, v] of Object.entries((init.headers ?? {}) as Record<string, string>))
175
- info(`> ${k}: ${v}\n`)
176
- info('>\n')
177
- }
178
-
179
- const printResponseHeaders = (res: Response) => {
180
- if (!options.include && verbose < 2) return
181
- if (silent) return
182
- const status = `HTTP/1.1 ${res.status} ${res.statusText}`
183
- const out = verbose >= 2 ? process.stderr : process.stdout
184
- const prefix = verbose >= 2 ? '< ' : ''
185
- out.write(`${prefix}${status}\n`)
186
- for (const [k, v] of res.headers) out.write(`${prefix}${k}: ${v}\n`)
187
- out.write(verbose >= 2 ? '<\n' : '\n')
188
- }
189
-
190
- printRequestHeaders(url, fetchInit)
191
- const challengeResponse = await globalThis.fetch(fetchUrl, fetchInit)
192
- if (challengeResponse.status !== 402) {
193
- if (options.fail && challengeResponse.status >= 400)
194
- return error({
195
- code: 'HTTP_ERROR',
196
- message: `HTTP error ${challengeResponse.status}`,
197
- exitCode: 22,
198
- })
199
- printResponseHeaders(challengeResponse)
200
- console.log((await challengeResponse.text()).replace(/\n+$/, ''))
201
- return
202
- }
203
-
204
- const challenge = Challenge.fromResponse(challengeResponse)
205
- const challengeRequest = challenge.request as Record<string, unknown>
206
- const currency = challengeRequest.currency as string | undefined
207
- const shownKeys = new Set<string>()
208
-
209
- let tokenSymbol =
210
- challenge.method === 'stripe' ? (currency?.toUpperCase() ?? '') : (currency ?? '')
211
- let tokenDecimals =
212
- (challengeRequest.decimals as number | undefined) ?? (challenge.method === 'stripe' ? 2 : 6)
213
- let explorerUrl: string | undefined
214
-
215
- // Tempo-specific setup (private key, viem account/client, token info)
216
- let account: ReturnType<typeof privateKeyToAccount> | undefined
217
- let client: ReturnType<typeof createClient> | undefined
218
- let useTempoCliSign = false
219
- if (challenge.method === 'tempo') {
220
- const privateKey =
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
286
- }
287
- }
288
-
289
- {
290
- printResponseHeaders(challengeResponse)
291
- const request = challengeRequest
292
-
293
- const balanceKeys = new Set(['amount', 'suggestedDeposit', 'minVoucherDelta'])
294
- const skipKeys = new Set(['decimals', 'currency', 'methodDetails'])
295
- const fmtRequestValue = (key: string, value: unknown): string => {
296
- if (balanceKeys.has(key) && typeof value === 'string') {
297
- return `${value} ${pc.dim(`(${fmtBalance(BigInt(value), tokenSymbol, tokenDecimals)})`)}`
298
- }
299
- if (key === 'chainId' && typeof value === 'number') {
300
- const name = chainName({ id: value, name: '' })
301
- return name ? `${value} ${pc.dim(`(${name})`)}` : String(value)
302
- }
303
- if (typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value))
304
- return explorerUrl ? pc.link(`${explorerUrl}/address/${value}`, value) : value
305
- if (typeof value === 'string' && /^https?:\/\//.test(value)) return pc.link(value, value)
306
- return String(value)
307
- }
308
- const decodeMemo = (hex: string): string | undefined => {
309
- try {
310
- const stripped = hex.replace(/^0x0*/, '')
311
- if (!stripped) return undefined
312
- const bytes = Uint8Array.from(
313
- stripped.match(/.{1,2}/g)!.map((b) => Number.parseInt(b, 16)),
314
- )
315
- const decoded = new TextDecoder().decode(bytes)
316
- return /^[\x20-\x7e]+$/.test(decoded) ? decoded : undefined
317
- } catch {
318
- return undefined
319
- }
320
- }
321
-
322
- const skipChallengeKeys = new Set(['id', 'request'])
323
- const fmtChallengeValue = (key: string, value: unknown): string => {
324
- if (key === 'realm' && typeof value === 'string') {
325
- try {
326
- const realmUrl = new URL(value.includes('://') ? value : `https://${value}`)
327
- return pc.link(realmUrl.href, value)
328
- } catch {}
329
- }
330
- return String(value)
331
- }
332
- const challengeRows: [string, string][] = []
333
- for (const [key, value] of Object.entries(challenge)) {
334
- if (skipChallengeKeys.has(key) || value === undefined) continue
335
- challengeRows.push([key, fmtChallengeValue(key, value)])
336
- }
337
- challengeRows.sort(([a], [b]) => a.localeCompare(b))
338
-
339
- const requestRows: [string, string][] = []
340
- for (const [key, value] of Object.entries(request)) {
341
- if (skipKeys.has(key) || value === undefined) continue
342
- requestRows.push([key, fmtRequestValue(key, value)])
343
- }
344
- requestRows.sort(([a], [b]) => a.localeCompare(b))
345
-
346
- const detailRows: [string, string, string?][] = []
347
- const methodDetails = request.methodDetails as Record<string, unknown> | undefined
348
- if (methodDetails) {
349
- for (const [key, value] of Object.entries(methodDetails)) {
350
- if (value === undefined) continue
351
- if (key === 'memo' && typeof value === 'string') {
352
- const decoded = decodeMemo(value)
353
- detailRows.push([key, decoded ? `${decoded}\n${pc.dim(value)}` : value])
354
- } else {
355
- detailRows.push([key, fmtRequestValue(key, value)])
356
- }
357
- }
358
- detailRows.sort(([a], [b]) => a.localeCompare(b))
359
- }
360
-
361
- const sections: [string, [string, string][]][] = [
362
- ['Challenge', challengeRows],
363
- ['Request', requestRows],
364
- ...(detailRows.length ? [['Details', detailRows] as [string, [string, string][]]] : []),
365
- ]
366
- for (const [, rows] of sections) for (const [key] of rows) shownKeys.add(key)
367
- const pad = Math.max(...sections.flatMap(([, rows]) => rows.map(([k]) => k.length)))
368
- const indent = ` ${''.padEnd(pad)} `
369
-
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
- }
379
- }
380
- }
381
- if (confirmEnabled) {
382
- info('\n')
383
- const ok = await confirm(`Proceed with ${challenge.intent}?`, true)
384
- if (!ok) {
385
- info('Aborted.\n')
386
- return
387
- }
388
- }
389
- }
390
-
391
- let credential: string
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') {
403
- if (!account || !client) {
404
- return error({
405
- code: 'ACCOUNT_NOT_FOUND',
406
- message: 'Tempo requires a configured account.',
407
- exitCode: 69,
408
- })
409
- }
410
- const tempoOpts = parseOptions(
411
- z.object({
412
- channel: z.optional(z.coerce.string()),
413
- deposit: z.optional(z.union([z.string(), z.number()])),
414
- }),
415
- methodOpts,
416
- )
417
- const mppx = Mppx.create({
418
- methods: tempo({
419
- account,
420
- getClient: () => client!,
421
- deposit: (() => {
422
- if (challenge.intent !== 'session') return undefined
423
- const suggestedDeposit = (challenge.request as Record<string, unknown>)
424
- .suggestedDeposit as string | undefined
425
- const cliDeposit =
426
- tempoOpts.deposit !== undefined ? String(tempoOpts.deposit) : undefined
427
- const resolved =
428
- suggestedDeposit ?? cliDeposit ?? (isTestnet(client!.chain!) ? '10' : undefined)
429
- if (!resolved) {
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
- })
436
- }
437
- return resolved
438
- })(),
439
- }),
440
- polyfill: false,
441
- })
442
- credential = await mppx.createCredential(
443
- challengeResponse,
444
- (() => {
445
- if (!tempoOpts.channel) return undefined
446
- const channelId = tempoOpts.channel
447
- const saved = readChannelCumulative(channelId)
448
- return {
449
- channelId,
450
- ...(saved !== undefined && { cumulativeAmountRaw: saved.toString() }),
451
- }
452
- })(),
453
- )
454
- } else if (challenge.method === 'stripe') {
455
- const stripeOpts = parseOptions(
456
- z.object({
457
- paymentMethod: z.string(),
458
- }),
459
- methodOpts,
460
- )
461
- const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
462
- if (!stripeSecretKey) {
463
- return error({
464
- code: 'MISSING_ENV',
465
- message: 'MPPX_STRIPE_SECRET_KEY environment variable is required for Stripe payments.',
466
- exitCode: 2,
467
- })
468
- }
469
- if (!stripeSecretKey.startsWith('sk_test_')) {
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
- })
476
- }
477
- const mppx = Mppx.create({
478
- methods: [
479
- stripe.charge({
480
- paymentMethod: stripeOpts.paymentMethod,
481
- createToken: async ({
482
- paymentMethod,
483
- amount,
484
- currency,
485
- networkId,
486
- expiresAt,
487
- metadata,
488
- }) => {
489
- const body = new URLSearchParams({
490
- payment_method: paymentMethod!,
491
- 'usage_limits[currency]': currency,
492
- 'usage_limits[max_amount]': amount,
493
- 'usage_limits[expires_at]': expiresAt.toString(),
494
- })
495
- if (networkId) body.set('seller_details[network_id]', networkId)
496
- if (metadata) {
497
- for (const [key, value] of Object.entries(metadata)) {
498
- body.set(`metadata[${key}]`, value)
499
- }
500
- }
501
- const sptUrl =
502
- process.env.MPPX_STRIPE_SPT_URL ??
503
- 'https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens'
504
- const sptHeaders = {
505
- Authorization: `Basic ${btoa(`${stripeSecretKey}:`)}`,
506
- 'Content-Type': 'application/x-www-form-urlencoded',
507
- }
508
- let response = await globalThis.fetch(sptUrl, {
509
- method: 'POST',
510
- headers: sptHeaders,
511
- body,
512
- })
513
- if (!response.ok) {
514
- const errorBody = (await response.json()) as { error: { message: string } }
515
- if (
516
- (metadata || networkId) &&
517
- errorBody.error.message.includes('Received unknown parameter')
518
- ) {
519
- const fallbackBody = new URLSearchParams({
520
- payment_method: paymentMethod!,
521
- 'usage_limits[currency]': currency,
522
- 'usage_limits[max_amount]': amount,
523
- 'usage_limits[expires_at]': expiresAt.toString(),
524
- })
525
- response = await globalThis.fetch(sptUrl, {
526
- method: 'POST',
527
- headers: sptHeaders,
528
- body: fallbackBody,
529
- })
530
- if (!response.ok) {
531
- const fallbackError = (await response.json()) as {
532
- error: { message: string }
533
- }
534
- return error({
535
- code: 'STRIPE_ERROR',
536
- message: `Failed to create SPT: ${fallbackError.error.message}`,
537
- exitCode: 77,
538
- })
539
- }
540
- } else {
541
- return error({
542
- code: 'STRIPE_ERROR',
543
- message: `Failed to create SPT: ${errorBody.error.message}`,
544
- exitCode: 77,
545
- })
546
- }
547
- }
548
- const { id } = (await response.json()) as { id: string }
549
- return id
550
- },
551
- }),
552
- ],
553
- polyfill: false,
554
- })
555
- credential = await mppx.createCredential(challengeResponse)
556
- } else {
557
- return error({
558
- code: 'UNSUPPORTED_METHOD',
559
- message: `Unsupported payment method: ${challenge.method}`,
560
- exitCode: 2,
561
- })
562
- }
563
-
564
- const sessionMd = challenge.request.methodDetails as
565
- | { escrowContract?: string; chainId?: number }
566
- | undefined
567
- let sessionChannelId: `0x${string}` | undefined
568
- let sessionEscrowContract: Address | undefined
569
- let sessionChainId = 0
570
- let sessionCumulativeAmount = 0n
571
-
572
- if (challenge.intent === 'session') {
573
- const parsed = Credential.deserialize<SessionCredentialPayload>(credential)
574
- sessionChannelId = parsed.payload.channelId
575
- sessionChainId = sessionMd?.chainId ?? client?.chain?.id ?? 0
576
- sessionEscrowContract = sessionMd?.escrowContract as Address | undefined
577
- if ('cumulativeAmount' in parsed.payload && parsed.payload.cumulativeAmount)
578
- sessionCumulativeAmount = BigInt(parsed.payload.cumulativeAmount)
579
-
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
- }
594
- }
595
- }
596
-
597
- const credentialFetchInit = {
598
- ...fetchInit,
599
- headers: {
600
- ...(fetchInit.headers as Record<string, string>),
601
- ...(challenge.intent === 'session' ? { Accept: 'text/event-stream' } : {}),
602
- Authorization: credential,
603
- },
604
- }
605
- printRequestHeaders(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
617
-
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
- })
655
-
656
- if (credentialResponse.status === 402) {
657
- const body = await credentialResponse.text()
658
- info(`${pc.bold(pc.red('Payment Rejected'))}\n`)
659
- try {
660
- const problem = JSON.parse(body) as Record<string, unknown>
661
- const rows: [string, string][] = []
662
- for (const [key, value] of Object.entries(problem)) {
663
- if (value === undefined) continue
664
- rows.push([key, String(value)])
665
- }
666
- rows.sort(([a], [b]) => a.localeCompare(b))
667
- const pad = Math.max(...rows.map(([k]) => k.length))
668
- for (const [label, value] of rows) info(` ${pc.dim(label.padEnd(pad))} ${value}\n`)
669
- } catch {
670
- if (body) info(` ${body}\n`)
671
- }
672
- return error({ code: 'PAYMENT_REJECTED', message: 'Payment rejected', exitCode: 75 })
673
- } else {
674
- printResponseHeaders(credentialResponse)
675
-
676
- const receiptHeader = credentialResponse.headers.get('Payment-Receipt')
677
- if (receiptHeader) {
678
- try {
679
- const receiptJson = JSON.parse(Base64.toString(receiptHeader)) as Record<
680
- string,
681
- unknown
682
- >
683
- if (
684
- typeof receiptJson.acceptedCumulative === 'string' &&
685
- receiptJson.acceptedCumulative
686
- ) {
687
- sessionCumulativeAmount = BigInt(receiptJson.acceptedCumulative)
688
- if (sessionChannelId)
689
- writeChannelCumulative(sessionChannelId, sessionCumulativeAmount)
690
- }
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')
727
- }
728
- } catch {}
729
- }
730
- const contentType = credentialResponse.headers.get('Content-Type') ?? ''
731
- if (contentType.includes('text/event-stream')) {
732
- const reader = credentialResponse.body?.getReader()
733
- if (!reader) {
734
- return error({ code: 'NO_RESPONSE_BODY', message: 'No response body' })
735
- }
736
- const decoder = new TextDecoder()
737
- let buffer = ''
738
- let currentEvent = ''
739
-
740
- const sessionCred =
741
- challenge.intent === 'session'
742
- ? Credential.deserialize<SessionCredentialPayload>(credential)
743
- : undefined
744
- const channelId = sessionCred?.payload.channelId
745
- const md = challenge.request.methodDetails as
746
- | { escrowContract?: string; chainId?: number }
747
- | undefined
748
- const sessionChainId = md?.chainId ?? client?.chain?.id ?? 0
749
- const escrowContract = md?.escrowContract as Address | undefined
750
- let cumulativeAmount =
751
- sessionCred?.payload &&
752
- 'cumulativeAmount' in sessionCred.payload &&
753
- sessionCred.payload.cumulativeAmount
754
- ? BigInt(sessionCred.payload.cumulativeAmount)
755
- : 0n
756
- let _voucherSeq = 0
757
-
758
- const termBg = verbose ? await detectTerminalBg() : undefined
759
- const chunkBgs = (() => {
760
- if (!termBg || !pc.isColorSupported) return undefined
761
- const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)))
762
- const isDark = 0.299 * termBg.r + 0.587 * termBg.g + 0.114 * termBg.b < 128
763
- const offset = isDark ? 1 : -1
764
- const bgRgb = (d: number) => (s: string) => {
765
- const r = clamp(termBg.r + d * offset)
766
- const g = clamp(termBg.g + d * offset)
767
- const b = clamp(termBg.b + d * offset)
768
- return `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`
769
- }
770
- return [bgRgb(12), bgRgb(24)] as const
771
- })()
772
- let chunkIdx = 0
773
-
774
- const writeContent = (chunk: string) => {
775
- if (chunkBgs) {
776
- const bgFn = chunkBgs[chunkIdx % chunkBgs.length]!
777
- process.stdout.write(chunk.replace(/[^\n]+/g, (m) => bgFn(m)))
778
- chunkIdx++
779
- } else {
780
- process.stdout.write(chunk)
781
- }
782
- }
783
-
784
- const processLines = async (lines: string[]) => {
785
- for (const line of lines) {
786
- if (line.startsWith('event: ')) {
787
- currentEvent = line.slice(7).trim()
788
- continue
789
- }
790
- if (!line.startsWith('data: ')) {
791
- if (line === '') currentEvent = ''
792
- continue
793
- }
794
- const data = line.slice(6)
795
- if (data.trim() === '[DONE]') continue
796
- if (
797
- currentEvent === 'payment-need-voucher' &&
798
- channelId &&
799
- escrowContract &&
800
- sessionChainId
801
- ) {
802
- try {
803
- const event = JSON.parse(data) as {
804
- channelId: string
805
- requiredCumulative: string
806
- }
807
- const required = BigInt(event.requiredCumulative)
808
- cumulativeAmount = cumulativeAmount > required ? cumulativeAmount : required
809
-
810
- const signature = await signVoucher(
811
- client!,
812
- account!,
813
- { channelId, cumulativeAmount },
814
- escrowContract,
815
- sessionChainId,
816
- )
817
- const voucherCred = Credential.serialize({
818
- challenge,
819
- payload: {
820
- action: 'voucher',
821
- channelId,
822
- cumulativeAmount: cumulativeAmount.toString(),
823
- signature,
824
- },
825
- source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
826
- })
827
- await globalThis.fetch(fetchUrl, {
828
- method: 'POST',
829
- headers: { Authorization: voucherCred },
830
- })
831
- _voucherSeq++
832
- } catch (e) {
833
- info(
834
- pc.dim(pc.yellow(` [voucher failed: ${e instanceof Error ? e.message : e}]`)),
835
- )
836
- }
837
- currentEvent = ''
838
- continue
839
- }
840
- if (currentEvent === 'payment-receipt') {
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
- }
873
- currentEvent = ''
874
- continue
875
- }
876
- if (data.length === 0) {
877
- writeContent('\n')
878
- } else {
879
- try {
880
- const parsed = JSON.parse(data) as {
881
- token?: string
882
- choices?: { delta?: { content?: string } }[]
883
- }
884
-
885
- writeContent(parsed.token ?? parsed.choices?.[0]?.delta?.content ?? data)
886
- } catch {
887
- writeContent(data)
888
- }
889
- }
890
- currentEvent = ''
891
- }
892
- }
893
-
894
- while (true) {
895
- const { done, value } = await reader.read()
896
- if (done) break
897
- buffer += decoder.decode(value, { stream: true })
898
- const lines = buffer.split('\n')
899
- buffer = lines.pop()!
900
- await processLines(lines)
901
- }
902
- if (buffer.trim()) await processLines([buffer])
903
-
904
- if (channelId && escrowContract && sessionChainId) {
905
- const signature = await signVoucher(
906
- client!,
907
- account!,
908
- { channelId, cumulativeAmount },
909
- escrowContract,
910
- sessionChainId,
911
- )
912
- const closePayload: SessionCredentialPayload = {
913
- action: 'close',
914
- channelId,
915
- cumulativeAmount: cumulativeAmount.toString(),
916
- signature,
917
- }
918
- const closeCred = Credential.serialize({
919
- challenge,
920
- payload: closePayload,
921
- source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
922
- })
923
- const closeRes = await globalThis.fetch(fetchUrl, {
924
- method: 'POST',
925
- headers: { Authorization: closeCred },
926
- })
927
- if (closeRes.ok) {
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
- )
947
- }
948
- } else {
949
- info(
950
- `\n${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`,
951
- )
952
- }
953
- }
954
- } else {
955
- const body = (await credentialResponse.text()).replace(/\n+$/, '')
956
- console.log(body)
957
-
958
- const shouldClose =
959
- challenge.intent === 'session' &&
960
- credentialResponse.ok &&
961
- sessionChannelId &&
962
- sessionEscrowContract &&
963
- sessionChainId
964
- if (shouldClose && confirmEnabled) {
965
- info('\n')
966
- }
967
- if (shouldClose && confirmEnabled && !(await confirm('Close channel?', true))) {
968
- if (verbose >= 1) info(`${pc.dim('Kept channel open.')}\n`)
969
- } else if (shouldClose) {
970
- const signature = await signVoucher(
971
- client!,
972
- account!,
973
- { channelId: sessionChannelId!, cumulativeAmount: sessionCumulativeAmount },
974
- sessionEscrowContract!,
975
- sessionChainId,
976
- )
977
- const closePayload: SessionCredentialPayload = {
978
- action: 'close',
979
- channelId: sessionChannelId!,
980
- cumulativeAmount: sessionCumulativeAmount.toString(),
981
- signature,
982
- }
983
- const closeCred = Credential.serialize({
984
- challenge,
985
- payload: closePayload,
986
- source: `did:pkh:eip155:${sessionChainId}:${account!.address}`,
987
- })
988
- const closeRes = await globalThis.fetch(fetchUrl, {
989
- ...fetchInit,
990
- headers: {
991
- ...(fetchInit.headers as Record<string, string>),
992
- Authorization: closeCred,
993
- },
994
- })
995
- if (closeRes.ok) {
996
- deleteChannelState(sessionChannelId!)
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
- )
1017
- }
1018
- } else {
1019
- const closeBody = await closeRes.text().catch(() => '')
1020
- info(
1021
- `\n${pc.dim(pc.yellow('Channel close failed'))} ${pc.dim(`(${closeRes.status})`)}\n`,
1022
- )
1023
- info(
1024
- `${pc.dim(` channelId: ${sessionChannelId}`)}\n` +
1025
- `${pc.dim(` cumulativeAmount: ${sessionCumulativeAmount}`)}\n` +
1026
- `${pc.dim(` escrowContract: ${sessionEscrowContract}`)}\n` +
1027
- `${pc.dim(` chainId: ${sessionChainId}`)}\n` +
1028
- `${pc.dim(` account: ${account?.address}`)}\n` +
1029
- `${pc.dim(` response: ${closeBody || '(empty)'}`)}\n`,
1030
- )
1031
- }
1032
- }
1033
- }
1034
- }
1035
- } catch (err) {
1036
- // TODO: revert cast when https://github.com/wevm/zile/pull/26 is merged
1037
- const errCause =
1038
- err instanceof Error ? (err as unknown as Record<string, unknown>).cause : undefined
1039
- const cause = errCause instanceof Error ? errCause : undefined
1040
-
1041
- if (cause && 'code' in cause) {
1042
- const code = cause.code as string
1043
- if (code === 'ENOTFOUND')
1044
- return error({
1045
- code: 'DNS_ERROR',
1046
- message: `Could not resolve host "${hostname}". Check the URL and try again.`,
1047
- exitCode: 6,
1048
- })
1049
- else if (code === 'ECONNREFUSED')
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
- })
1070
- else if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')
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
- })
1081
- } else {
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
- })
1089
- }
1090
- }
1091
- },
1092
- })
1093
-
1094
- const account = Cli.create('account', {
1095
- description: 'Manage accounts (create, default, delete, fund, list, view)',
1096
- })
1097
-
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}"`)
1164
- return
1165
- }
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')
1211
- return
1212
- }
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()
1223
- }
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}`)
1260
- }
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
- })
1330
- }
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)
1448
- }
1449
- return
1450
- }
1451
-
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()
1458
- }
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
- )
1483
-
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
- })
1603
- }
1604
- },
1605
- })
1606
-
1607
- cli.command(sign)
1608
-
1609
- export default cli
1610
-
1611
- /////////////////////////////////////////////////////////////////////////////////////////////////
1612
-
1613
- function parseMethodOpts(raw: string | string[] | undefined): Record<string, string> {
1614
- if (!raw) return {}
1615
- const list = Array.isArray(raw) ? raw : [raw]
1616
- const result: Record<string, string> = {}
1617
- for (const item of list) {
1618
- const idx = item.indexOf('=')
1619
- if (idx === -1) {
1620
- throw new Error(`Invalid method option format: ${item} (expected key=value)`)
1621
- }
1622
- result[item.slice(0, idx)] = item.slice(idx + 1)
1623
- }
1624
- return result
1625
- }
1626
-
1627
- function parseOptions<const schema extends z.ZodType>(
1628
- schema: schema,
1629
- rawOptions: unknown,
1630
- ): z.output<schema> {
1631
- const result = schema.safeParse(rawOptions ?? {})
1632
- if (result.success) return result.data
1633
- const summary = result.error.issues
1634
- .map((issue) => {
1635
- const path = issue.path.length ? issue.path.join('.') : 'options'
1636
- return `${path}: ${issue.message}`
1637
- })
1638
- .join(', ')
1639
- throw new Error(`Invalid CLI options (${summary})`)
1640
- }
1641
-
1642
- function execCommand(
1643
- command: string,
1644
- args: string[],
1645
- ): Promise<{ stdout: string; stderr: string; error: Error | null }> {
1646
- return new Promise((resolve) => {
1647
- child.execFile(command, args, (error, stdout, stderr) => {
1648
- resolve({ stdout: stdout.trim(), stderr: stderr.trim(), error })
1649
- })
1650
- })
1651
- }
1652
-
1653
- function channelStateDir() {
1654
- return path.join(
1655
- process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
1656
- 'mppx',
1657
- 'channels',
1658
- )
1659
- }
1660
-
1661
- function readChannelCumulative(channelId: string): bigint | undefined {
1662
- try {
1663
- const raw = fs.readFileSync(path.join(channelStateDir(), channelId), 'utf-8').trim()
1664
- return raw ? BigInt(raw) : undefined
1665
- } catch {
1666
- return undefined
1667
- }
1668
- }
1669
-
1670
- function writeChannelCumulative(channelId: string, cumulative: bigint): void {
1671
- const dir = channelStateDir()
1672
- fs.mkdirSync(dir, { recursive: true })
1673
- fs.writeFileSync(path.join(dir, channelId), cumulative.toString(), 'utf-8')
1674
- }
1675
-
1676
- function deleteChannelState(channelId: string): void {
1677
- try {
1678
- fs.unlinkSync(path.join(channelStateDir(), channelId))
1679
- } catch {}
1680
- }
1681
-
1682
- function createDefaultStore() {
1683
- const configPath = path.join(
1684
- process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
1685
- 'mppx',
1686
- 'default',
1687
- )
1688
- return {
1689
- get(): string {
1690
- try {
1691
- return fs.readFileSync(configPath, 'utf-8').trim() || 'main'
1692
- } catch {
1693
- return 'main'
1694
- }
1695
- },
1696
- set(value: string): void {
1697
- fs.mkdirSync(path.dirname(configPath), { recursive: true })
1698
- fs.writeFileSync(configPath, value, 'utf-8')
1699
- },
1700
- clear(): void {
1701
- try {
1702
- fs.unlinkSync(configPath)
1703
- } catch {}
1704
- },
1705
- }
1706
- }
1707
-
1708
- function resolveAccountName(explicit?: string): string {
1709
- if (explicit) return explicit
1710
- if (process.env.MPPX_ACCOUNT?.trim()) return process.env.MPPX_ACCOUNT
1711
- return createDefaultStore().get()
1712
- }
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
-
1829
- // biome-ignore format: compact shell commands
1830
- function createKeychain(account = 'main') {
1831
- const service = name
1832
- return {
1833
- async list(): Promise<string[]> {
1834
- const platform = os.platform()
1835
- if (platform === 'darwin') {
1836
- const { stdout, error } = await execCommand('security', ['dump-keychain'])
1837
- if (error) return []
1838
- const accounts: string[] = []
1839
- const blocks = stdout.split('keychain:')
1840
- for (const block of blocks) {
1841
- const serviceMatch = block.match(/"svce"<blob>="([^"]*)"/)
1842
- const accountMatch = block.match(/"acct"<blob>="([^"]*)"/)
1843
- if (serviceMatch?.[1] === service && accountMatch?.[1]) accounts.push(accountMatch[1])
1844
- }
1845
- return accounts
1846
- }
1847
- if (platform === 'linux') {
1848
- const { stdout, stderr, error } = await execCommand('secret-tool', ['search', '--all', '--unlock', 'service', service])
1849
- if (error) return []
1850
- const combined = `${stdout}\n${stderr}`
1851
- const accounts: string[] = []
1852
- const matches = combined.matchAll(/\baccount = (.+)/g)
1853
- for (const match of matches) if (match[1]) accounts.push(match[1])
1854
- return accounts
1855
- }
1856
- throw new Error(`Unsupported platform: ${platform}`)
1857
- },
1858
- async get(): Promise<string | undefined> {
1859
- const platform = os.platform()
1860
- if (platform === 'darwin') {
1861
- const { stdout, error } = await execCommand('security', ['find-generic-password', '-s', service, '-a', account, '-w'])
1862
- return error ? undefined : stdout
1863
- }
1864
- if (platform === 'linux') {
1865
- const { stdout, error } = await execCommand('secret-tool', ['lookup', 'service', service, 'account', account])
1866
- return error ? undefined : stdout || undefined
1867
- }
1868
- throw new Error(`Unsupported platform: ${platform}`)
1869
- },
1870
- async set(value: string): Promise<void> {
1871
- const platform = os.platform()
1872
- if (platform === 'darwin') {
1873
- await execCommand('security', ['delete-generic-password', '-s', service, '-a', account])
1874
- const { error } = await execCommand('security', ['add-generic-password', '-s', service, '-a', account, '-w', value])
1875
- if (error) throw error
1876
- return
1877
- }
1878
- if (platform === 'linux') {
1879
- const proc = child.execFile('secret-tool', ['store', '--label', `${service} ${account}`, 'service', service, 'account', account])
1880
- proc.stdin?.write(value)
1881
- proc.stdin?.end()
1882
- return new Promise((resolve, reject) => {
1883
- proc.on('close', (code) => {
1884
- if (code === 0) resolve()
1885
- else reject(new Error(`secret-tool exited with code ${code}`))
1886
- })
1887
- proc.on('error', reject)
1888
- })
1889
- }
1890
- throw new Error(`Unsupported platform: ${platform}`)
1891
- },
1892
- async delete(): Promise<void> {
1893
- const platform = os.platform()
1894
- if (platform === 'darwin') {
1895
- await execCommand('security', ['delete-generic-password', '-s', service, '-a', account])
1896
- return
1897
- }
1898
- if (platform === 'linux') {
1899
- await execCommand('secret-tool', ['clear', 'service', service, 'account', account])
1900
- return
1901
- }
1902
- throw new Error(`Unsupported platform: ${platform}`)
1903
- },
1904
- }
1905
- }
1906
-
1907
- function prompt(message: string): Promise<string | undefined> {
1908
- const reader = readline.createInterface({ input: process.stdin, output: process.stderr })
1909
- return new Promise((resolve) => {
1910
- reader.on('close', () => resolve(undefined))
1911
- reader.question(`${pc.bold(`▸ ${message}:`)} `, (answer) => {
1912
- reader.close()
1913
- const value = answer.trim()
1914
- resolve(value || undefined)
1915
- })
1916
- })
1917
- }
1918
-
1919
- function confirm(prompt: string, defaultYes = false): Promise<boolean> {
1920
- const reader = readline.createInterface({ input: process.stdin, output: process.stderr })
1921
- return new Promise((resolve) => {
1922
- const hint = defaultYes ? '(Y/n)' : '(y/N)'
1923
- reader.question(`${pc.bold(`▸ ${prompt}`)} ${pc.dim(hint)} `, (answer) => {
1924
- reader.close()
1925
- const trimmed = answer.trim().toLowerCase()
1926
- resolve(trimmed === '' ? defaultYes : trimmed === 'y')
1927
- })
1928
- })
1929
- }
1930
-
1931
- // Inlined from https://github.com/alexeyraspopov/picocolors (ISC License)
1932
- const pc = (() => {
1933
- const p = process || ({} as NodeJS.Process)
1934
- const argv = p.argv || []
1935
- const env = p.env || {}
1936
- const isColorSupported =
1937
- !(!!env.NO_COLOR || argv.includes('--no-color')) &&
1938
- (!!env.FORCE_COLOR ||
1939
- argv.includes('--color') ||
1940
- ((p.stdout || ({} as NodeJS.WriteStream)).isTTY && env.TERM !== 'dumb') ||
1941
- !!env.CI)
1942
-
1943
- const replaceClose = (string: string, close: string, replace: string, index: number): string => {
1944
- let result = ''
1945
- let cursor = 0
1946
- let i = index
1947
- do {
1948
- result += string.substring(cursor, i) + replace
1949
- cursor = i + close.length
1950
- i = string.indexOf(close, cursor)
1951
- } while (~i)
1952
- return result + string.substring(cursor)
1953
- }
1954
-
1955
- const formatter =
1956
- (open: string, close: string, replace = open) =>
1957
- (input: unknown) => {
1958
- const string = `${input}`
1959
- const index = string.indexOf(close, open.length)
1960
- return ~index
1961
- ? open + replaceClose(string, close, replace, index) + close
1962
- : open + string + close
1963
- }
1964
-
1965
- const f = isColorSupported ? formatter : () => String
1966
- return {
1967
- isColorSupported,
1968
- reset: f('\x1b[0m', '\x1b[0m'),
1969
- bold: f('\x1b[1m', '\x1b[22m', '\x1b[22m\x1b[1m'),
1970
- dim: f('\x1b[2m', '\x1b[22m', '\x1b[22m\x1b[2m'),
1971
- italic: f('\x1b[3m', '\x1b[23m'),
1972
- underline: f('\x1b[4m', '\x1b[24m'),
1973
- inverse: f('\x1b[7m', '\x1b[27m'),
1974
- hidden: f('\x1b[8m', '\x1b[28m'),
1975
- strikethrough: f('\x1b[9m', '\x1b[29m'),
1976
- black: f('\x1b[30m', '\x1b[39m'),
1977
- red: f('\x1b[31m', '\x1b[39m'),
1978
- green: f('\x1b[32m', '\x1b[39m'),
1979
- yellow: f('\x1b[33m', '\x1b[39m'),
1980
- blue: f('\x1b[34m', '\x1b[39m'),
1981
- magenta: f('\x1b[35m', '\x1b[39m'),
1982
- cyan: f('\x1b[36m', '\x1b[39m'),
1983
- white: f('\x1b[37m', '\x1b[39m'),
1984
- gray: f('\x1b[90m', '\x1b[39m'),
1985
- bgBlack: f('\x1b[40m', '\x1b[49m'),
1986
- bgRed: f('\x1b[41m', '\x1b[49m'),
1987
- bgGreen: f('\x1b[42m', '\x1b[49m'),
1988
- bgYellow: f('\x1b[43m', '\x1b[49m'),
1989
- bgBlue: f('\x1b[44m', '\x1b[49m'),
1990
- bgMagenta: f('\x1b[45m', '\x1b[49m'),
1991
- bgCyan: f('\x1b[46m', '\x1b[49m'),
1992
- bgWhite: f('\x1b[47m', '\x1b[49m'),
1993
- blackBright: f('\x1b[90m', '\x1b[39m'),
1994
- redBright: f('\x1b[91m', '\x1b[39m'),
1995
- greenBright: f('\x1b[92m', '\x1b[39m'),
1996
- yellowBright: f('\x1b[93m', '\x1b[39m'),
1997
- blueBright: f('\x1b[94m', '\x1b[39m'),
1998
- magentaBright: f('\x1b[95m', '\x1b[39m'),
1999
- cyanBright: f('\x1b[96m', '\x1b[39m'),
2000
- whiteBright: f('\x1b[97m', '\x1b[39m'),
2001
- bgBlackBright: f('\x1b[100m', '\x1b[49m'),
2002
- bgRedBright: f('\x1b[101m', '\x1b[49m'),
2003
- bgGreenBright: f('\x1b[102m', '\x1b[49m'),
2004
- bgYellowBright: f('\x1b[103m', '\x1b[49m'),
2005
- bgBlueBright: f('\x1b[104m', '\x1b[49m'),
2006
- bgMagentaBright: f('\x1b[105m', '\x1b[49m'),
2007
- bgCyanBright: f('\x1b[106m', '\x1b[49m'),
2008
- bgWhiteBright: f('\x1b[107m', '\x1b[49m'),
2009
- link(url: string, text: string, noUnderline?: boolean) {
2010
- if (!isColorSupported) return text
2011
- return `\x1b]8;;${url}\x07${noUnderline ? text : pc.underline(text)}\x1b]8;;\x07`
2012
- },
2013
- }
2014
- })()
2015
-
2016
- async function resolveChain(opts: { rpcUrl?: string | undefined } = {}): Promise<Chain> {
2017
- if (!opts.rpcUrl) return tempoModerato
2018
- const { getChainId } = await import('viem/actions')
2019
- const chainId = await getChainId(createClient({ transport: http(opts.rpcUrl) }))
2020
- const allExports = Object.values(await import('viem/chains')) as unknown[]
2021
- const candidates = allExports.filter(
2022
- (c): c is Chain =>
2023
- typeof c === 'object' && c !== null && 'id' in c && (c as Chain).id === chainId,
2024
- )
2025
- const found = candidates.find((c) => 'serializers' in c && c.serializers) ?? candidates[0]
2026
- if (!found) throw new Error(`Unknown chain ID ${chainId} from RPC ${opts.rpcUrl}`)
2027
- return found
2028
- }
2029
-
2030
- function chainName(chain: { id: number; name: string }) {
2031
- const chainNames: Record<number, string> = {
2032
- [tempoMainnet.id]: 'mainnet',
2033
- [tempoModerato.id]: 'testnet',
2034
- }
2035
- return chainNames[chain.id] ?? chain.name
2036
- }
2037
-
2038
- const pathUsd = '0x20c0000000000000000000000000000000000000' as Address
2039
- const usdc = '0x20C000000000000000000000b9537d11c60E8b50' as Address
2040
- const mainnetTokens = [pathUsd, usdc] as const
2041
- const testnetTokens = [
2042
- '0x20c0000000000000000000000000000000000000',
2043
- '0x20c0000000000000000000000000000000000001',
2044
- '0x20c0000000000000000000000000000000000002',
2045
- '0x20c0000000000000000000000000000000000003',
2046
- ] as const
2047
-
2048
- function fmtBalance(
2049
- b: bigint,
2050
- symbol: string,
2051
- decimals = 6,
2052
- opts?: { explorerUrl?: string | undefined; token?: string | undefined },
2053
- ) {
2054
- const value = Number(b) / 10 ** decimals
2055
- const [int, dec] = value.toString().split('.')
2056
- const formatted = int!.replace(/\B(?=(\d{3})+(?!\d))/g, '_')
2057
- const sym =
2058
- opts?.explorerUrl && opts.token
2059
- ? pc.dim(pc.link(`${opts.explorerUrl}/token/${opts.token}`, symbol, true))
2060
- : pc.dim(symbol)
2061
- return `${dec ? `${formatted}.${dec}` : formatted} ${sym}`
2062
- }
2063
-
2064
- function isTestnet(chain: Chain) {
2065
- return chain.id !== tempoMainnet.id
2066
- }
2067
-
2068
- async function fetchTokenInfo(
2069
- client: ReturnType<typeof createClient>,
2070
- token: Address,
2071
- account: Address,
2072
- ) {
2073
- const { Actions } = await import('viem/tempo')
2074
- const [balance, metadata] = await Promise.all([
2075
- Actions.token.getBalance(client, { account, token }).catch(() => 0n),
2076
- Actions.token.getMetadata(client, { token }).catch(() => ({ symbol: token as string })),
2077
- ])
2078
- const knownSymbols: Record<string, string> = {
2079
- [pathUsd]: 'PathUSD',
2080
- [usdc]: 'USDC',
2081
- }
2082
- const symbol = knownSymbols[token] ?? metadata.symbol
2083
- const decimals = 'decimals' in metadata ? metadata.decimals : 6
2084
- return { balance, symbol, decimals, token }
2085
- }
2086
-
2087
- function detectTerminalBg(
2088
- timeoutMs = 100,
2089
- ): Promise<{ r: number; g: number; b: number } | undefined> {
2090
- if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(undefined)
2091
- return new Promise((resolve) => {
2092
- const wasRaw = process.stdin.isRaw
2093
- let buf = ''
2094
- const cleanup = () => {
2095
- clearTimeout(timer)
2096
- process.stdin.removeListener('data', onData)
2097
- if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw ?? false)
2098
- process.stdin.pause()
2099
- }
2100
- const timer = setTimeout(() => {
2101
- cleanup()
2102
- resolve(undefined)
2103
- }, timeoutMs)
2104
- const onData = (data: Buffer) => {
2105
- buf += data.toString()
2106
- // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence for terminal background detection
2107
- const match = buf.match(/\x1b\]11;rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i)
2108
- if (!match) return
2109
- cleanup()
2110
- const parse = (hex: string) => Number.parseInt(hex.slice(0, 2), 16)
2111
- resolve({ r: parse(match[1]!), g: parse(match[2]!), b: parse(match[3]!) })
2112
- }
2113
- process.stdin.setRawMode(true)
2114
- process.stdin.resume()
2115
- process.stdin.on('data', onData)
2116
- process.stdout.write('\x1b]11;?\x07')
2117
- })
2118
- }
2119
-
2120
- async function fetchBalanceLines(
2121
- address: Address,
2122
- opts?: { chain?: Chain; rpcUrl?: string; includeTestnet?: boolean },
2123
- ): Promise<string[]> {
2124
- if (opts?.chain) {
2125
- const client = createClient({ chain: opts.chain, transport: http(opts.rpcUrl) })
2126
- const explorerUrl = opts.chain.blockExplorers?.default?.url
2127
- const label = pc.dim(`(${chainName(opts.chain)})`)
2128
- if (isTestnet(opts.chain)) {
2129
- const results = await Promise.all(
2130
- testnetTokens.map((token) => fetchTokenInfo(client, token, address)),
2131
- )
2132
- return results
2133
- .filter((t) => t.balance > 0n)
2134
- .map(
2135
- (t) =>
2136
- `${fmtBalance(t.balance, t.symbol, t.decimals, { explorerUrl, token: t.token })} ${label}`,
2137
- )
2138
- }
2139
- const results = await Promise.all(
2140
- mainnetTokens.map((token) => fetchTokenInfo(client, token, address)),
2141
- )
2142
- return results.map(
2143
- (t) =>
2144
- `${fmtBalance(t.balance, t.symbol, t.decimals, { explorerUrl, token: t.token })} ${label}`,
2145
- )
2146
- }
2147
-
2148
- const mainnetClient = createClient({
2149
- chain: tempoMainnet,
2150
- transport: http(process.env.MPPX_RPC_URL || undefined),
2151
- })
2152
- const mainnetExplorerUrl = tempoMainnet.blockExplorers?.default?.url
2153
- const mainnetResults = await Promise.all(
2154
- mainnetTokens.map((token) => fetchTokenInfo(mainnetClient, token, address)),
2155
- )
2156
- const lines = mainnetResults.map((t) =>
2157
- fmtBalance(t.balance, t.symbol, t.decimals, {
2158
- explorerUrl: mainnetExplorerUrl,
2159
- token: t.token,
2160
- }),
2161
- )
2162
-
2163
- if (opts?.includeTestnet !== false) {
2164
- const testnetClient = createClient({ chain: tempoModerato, transport: http() })
2165
- const testnetExplorerUrl = tempoModerato.blockExplorers?.default?.url
2166
- const testnetResults = await Promise.all(
2167
- testnetTokens.map((token) => fetchTokenInfo(testnetClient, token, address)),
2168
- )
2169
- for (const t of testnetResults) {
2170
- if (t.balance > 0n)
2171
- lines.push(
2172
- `${fmtBalance(t.balance, t.symbol, t.decimals, { explorerUrl: testnetExplorerUrl, token: t.token })} ${pc.dim('(testnet)')}`,
2173
- )
2174
- }
2175
- }
2176
-
2177
- return lines
2178
- }