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