mppx 0.3.14 → 0.3.15
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.
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +26 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1478 -915
- package/dist/cli.js.map +1 -1
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +2 -0
- package/dist/client/Mppx.js.map +1 -1
- package/package.json +4 -4
- package/src/bin.ts +4 -0
- package/src/cli.test.ts +180 -252
- package/src/cli.ts +1085 -485
- package/src/client/Mppx.test-d.ts +9 -0
- package/src/client/Mppx.test.ts +78 -0
- package/src/client/Mppx.ts +5 -0
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 {
|
|
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 =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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 (
|
|
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.
|
|
111
|
-
fetchInit.body = options.
|
|
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
|
|
166
|
+
const verbose = options.verbose
|
|
122
167
|
|
|
123
168
|
const printRequestHeaders = (reqUrl: string, init: RequestInit) => {
|
|
124
|
-
if (
|
|
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 &&
|
|
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(
|
|
191
|
+
const challengeResponse = await globalThis.fetch(fetchUrl, fetchInit)
|
|
147
192
|
if (challengeResponse.status !== 402) {
|
|
148
|
-
if (options.fail && challengeResponse.status >= 400)
|
|
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() ||
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const [
|
|
279
|
-
|
|
280
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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 (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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(
|
|
923
|
+
const closeRes = await globalThis.fetch(fetchUrl, {
|
|
742
924
|
method: 'POST',
|
|
743
925
|
headers: { Authorization: closeCred },
|
|
744
926
|
})
|
|
745
927
|
if (closeRes.ok) {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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 &&
|
|
964
|
+
if (shouldClose && confirmEnabled) {
|
|
781
965
|
info('\n')
|
|
782
966
|
}
|
|
783
|
-
if (shouldClose &&
|
|
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(
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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
|
|
879
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
.
|
|
887
|
-
|
|
888
|
-
'
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
if (!
|
|
894
|
-
|
|
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
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1112
|
-
if (optionsIndex !== -1) sections.splice(optionsIndex, 0, actionsSection)
|
|
1113
|
-
else sections.push(actionsSection)
|
|
1114
|
-
}
|
|
1115
|
-
return sections
|
|
1604
|
+
},
|
|
1116
1605
|
})
|
|
1117
1606
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
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
|
|
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
|