@trucore/atf 1.0.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +535 -260
  2. package/dist/index.js +3686 -793
  3. package/package.json +50 -47
package/dist/index.js CHANGED
@@ -1,48 +1,48 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- // @trucore/atf v1.0.2 — Agent Transaction Firewall CLI
5
- // Built: 2026-02-27T23:06:46.876Z
6
- // Commit: 809c30e
4
+ // @trucore/atf v1.3.0 — Agent Transaction Firewall CLI
5
+ // Built: 2026-02-28T16:06:56.895Z
6
+ // Commit: 1bf6915
7
7
 
8
8
  // ---- src/constants.mjs ----
9
- /**
10
- * constants.mjs — shared constants for the ATF CLI
11
- *
12
- * Build-time placeholders (__BUILD_COMMIT__, __BUILD_DATE__) are replaced
13
- * by build.mjs during the bundling step.
14
- */
15
-
16
- const VERSION = "1.0.2";
17
- const DEFAULT_BASE_URL = "https://api.trucore.xyz";
18
- const BUILD_COMMIT = "809c30e";
19
- const BUILD_DATE = "2026-02-27T23:06:46.876Z";
20
- const SIMULATE_PATHS = ["/api/simulate", "/v1/simulate"];
9
+ /**
10
+ * constants.mjs — shared constants for the ATF CLI
11
+ *
12
+ * Build-time placeholders (__BUILD_COMMIT__, __BUILD_DATE__) are replaced
13
+ * by build.mjs during the bundling step.
14
+ */
15
+
16
+ const VERSION = "1.3.0";
17
+ const DEFAULT_BASE_URL = "https://api.trucore.xyz";
18
+ const BUILD_COMMIT = "1bf6915";
19
+ const BUILD_DATE = "2026-02-28T16:06:56.895Z";
20
+ const SIMULATE_PATHS = ["/api/simulate", "/v1/simulate"];
21
21
 
22
22
  // ---- src/redact.mjs ----
23
- /**
24
- * redact.mjs — token redaction utilities
25
- *
26
- * Ensures secrets (Bearer tokens, API keys) never appear in CLI output.
27
- * Used by approve, error handling, and the global catch handler.
28
- */
29
-
30
- const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/g;
31
-
32
- /**
33
- * Replace any occurrence of `token` with ***REDACTED***.
34
- * Also scrub Bearer patterns for defense-in-depth.
35
- */
36
- function redactToken(str, token) {
37
- if (!str || typeof str !== "string") return str || "";
38
- let result = str;
39
- if (token && typeof token === "string" && token.length > 0) {
40
- // split+join: safe literal replacement (no regex special-char issues)
41
- result = result.split(token).join("***REDACTED***");
42
- }
43
- result = result.replace(BEARER_PATTERN, "Bearer ***REDACTED***");
44
- return result;
45
- }
23
+ /**
24
+ * redact.mjs — token redaction utilities
25
+ *
26
+ * Ensures secrets (Bearer tokens, API keys) never appear in CLI output.
27
+ * Used by approve, error handling, and the global catch handler.
28
+ */
29
+
30
+ const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*/g;
31
+
32
+ /**
33
+ * Replace any occurrence of `token` with ***REDACTED***.
34
+ * Also scrub Bearer patterns for defense-in-depth.
35
+ */
36
+ function redactToken(str, token) {
37
+ if (!str || typeof str !== "string") return str || "";
38
+ let result = str;
39
+ if (token && typeof token === "string" && token.length > 0) {
40
+ // split+join: safe literal replacement (no regex special-char issues)
41
+ result = result.split(token).join("***REDACTED***");
42
+ }
43
+ result = result.replace(BEARER_PATTERN, "Bearer ***REDACTED***");
44
+ return result;
45
+ }
46
46
 
47
47
  // ---- src/presets.mjs ----
48
48
  /**
@@ -181,893 +181,3786 @@ function parseJsonBody(raw) {
181
181
  }
182
182
  }
183
183
 
184
- // ---- src/http.mjs ----
184
+ // ---- src/token_map.mjs ----
185
185
  /**
186
- * http.mjs — minimal HTTP helpers using Node 18+ built-in fetch.
187
- * Zero runtime dependencies.
186
+ * token_map.mjs — minimal token registry for Solana swap support
187
+ *
188
+ * v1 MVP: SOL, USDC, USDT only. Expand later.
189
+ * Each entry carries the mint address and decimal count so the CLI
190
+ * can convert human amounts to base-unit lamports / micro-units.
188
191
  */
189
192
 
190
- const DEFAULT_TIMEOUT_MS = 20_000;
191
- const MAX_TEXT_CAPTURE = 4096;
193
+ const TOKEN_MAP = {
194
+ SOL: {
195
+ symbol: "SOL",
196
+ mint: "So11111111111111111111111111111111111111112",
197
+ decimals: 9,
198
+ },
199
+ USDC: {
200
+ symbol: "USDC",
201
+ mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
202
+ decimals: 6,
203
+ },
204
+ USDT: {
205
+ symbol: "USDT",
206
+ mint: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
207
+ decimals: 6,
208
+ },
209
+ };
192
210
 
193
- function resolveTimeout(overrideMs) {
194
- if (overrideMs && overrideMs > 0) return overrideMs;
195
- const envMs = process.env.ATF_TIMEOUT_MS;
196
- if (envMs) {
197
- const parsed = parseInt(envMs, 10);
198
- if (parsed > 0) return parsed;
211
+ /**
212
+ * Resolve a token symbol or mint to a TOKEN_MAP entry.
213
+ * Case-insensitive for symbols; exact match for mints.
214
+ * Returns null if not found.
215
+ */
216
+ function resolveToken(symbolOrMint) {
217
+ if (!symbolOrMint || typeof symbolOrMint !== "string") return null;
218
+ const upper = symbolOrMint.toUpperCase();
219
+ if (TOKEN_MAP[upper]) return TOKEN_MAP[upper];
220
+ // Try mint match
221
+ for (const entry of Object.values(TOKEN_MAP)) {
222
+ if (entry.mint === symbolOrMint) return entry;
199
223
  }
200
- return DEFAULT_TIMEOUT_MS;
224
+ return null;
201
225
  }
202
226
 
203
- function generateRequestId() {
204
- const { randomBytes } = require("node:crypto");
205
- return randomBytes(8).toString("hex");
227
+ /**
228
+ * Convert a human-readable amount (e.g. 0.001) to base units (lamports).
229
+ * Always returns a string to avoid floating-point drift on large values.
230
+ */
231
+ function toBaseUnits(humanAmount, decimals) {
232
+ const parts = String(humanAmount).split(".");
233
+ const whole = parts[0] || "0";
234
+ let frac = parts[1] || "";
235
+ if (frac.length > decimals) {
236
+ frac = frac.slice(0, decimals); // truncate excess precision
237
+ }
238
+ frac = frac.padEnd(decimals, "0");
239
+ // Remove leading zeros from concatenated result
240
+ const raw = (whole + frac).replace(/^0+/, "") || "0";
241
+ return raw;
206
242
  }
243
+
244
+ // ---- src/http.mjs ----
245
+ /**
246
+ * http.mjs — minimal HTTP helpers using Node 18+ built-in fetch.
247
+ * Zero runtime dependencies.
248
+ */
249
+
250
+ const DEFAULT_TIMEOUT_MS = 20_000;
251
+ const MAX_TEXT_CAPTURE = 4096;
252
+
253
+ function resolveTimeout(overrideMs) {
254
+ if (overrideMs && overrideMs > 0) return overrideMs;
255
+ const envMs = process.env.ATF_TIMEOUT_MS;
256
+ if (envMs) {
257
+ const parsed = parseInt(envMs, 10);
258
+ if (parsed > 0) return parsed;
259
+ }
260
+ return DEFAULT_TIMEOUT_MS;
261
+ }
262
+
263
+ function generateRequestId() {
264
+ const { randomBytes } = require("node:crypto");
265
+ return randomBytes(8).toString("hex");
266
+ }
267
+
268
+ function stripTrailingSlash(url) {
269
+ return url.replace(/\/+$/, "");
270
+ }
271
+
272
+ async function getHealth(baseUrl, timeoutMs) {
273
+ const timeout = resolveTimeout(timeoutMs);
274
+ const requestId = generateRequestId();
275
+ const url = `${stripTrailingSlash(baseUrl)}/health`;
276
+ const res = await fetch(url, {
277
+ headers: { "X-Request-ID": requestId },
278
+ signal: AbortSignal.timeout(timeout),
279
+ });
280
+ const parsed = await parseResponse(res);
281
+ parsed.requestId = resolveRequestId(parsed, requestId);
282
+ return parsed;
283
+ }
284
+
285
+ async function postSimulate(baseUrl, body, apiKey, timeoutMs, path) {
286
+ const timeout = resolveTimeout(timeoutMs);
287
+ const requestId = generateRequestId();
288
+ const url = `${stripTrailingSlash(baseUrl)}${path || "/api/simulate"}`;
289
+ const headers = {
290
+ "Content-Type": "application/json",
291
+ "X-Request-ID": requestId,
292
+ };
293
+ if (apiKey) headers["x-api-key"] = apiKey;
294
+
295
+ const res = await fetch(url, {
296
+ method: "POST",
297
+ headers,
298
+ body: JSON.stringify(body),
299
+ signal: AbortSignal.timeout(timeout),
300
+ });
301
+ const parsed = await parseResponse(res);
302
+ parsed.requestId = resolveRequestId(parsed, requestId);
303
+ return parsed;
304
+ }
305
+
306
+ async function postApprove(baseUrl, intentId, token, timeoutMs) {
307
+ const timeout = resolveTimeout(timeoutMs);
308
+ const requestId = generateRequestId();
309
+ const url = `${stripTrailingSlash(baseUrl)}/v1/intents/approve`;
310
+ const headers = {
311
+ "Content-Type": "application/json",
312
+ Authorization: `Bearer ${token}`,
313
+ "X-Request-ID": requestId,
314
+ };
315
+ const res = await fetch(url, {
316
+ method: "POST",
317
+ headers,
318
+ body: JSON.stringify({ intent_id: intentId }),
319
+ signal: AbortSignal.timeout(timeout),
320
+ });
321
+ const parsed = await parseResponse(res);
322
+ parsed.requestId = resolveRequestId(parsed, requestId);
323
+ return parsed;
324
+ }
325
+
326
+ async function getReceiptSigningKey(baseUrl) {
327
+ const url = `${stripTrailingSlash(baseUrl)}/api/receipt-signing-key`;
328
+ try {
329
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
330
+ if (!res.ok) return { available: false };
331
+ const data = await res.json();
332
+ return data;
333
+ } catch {
334
+ return { available: false };
335
+ }
336
+ }
337
+
338
+ async function parseResponse(res) {
339
+ const rateLimitHeaders = {};
340
+ for (const h of ["x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset"]) {
341
+ const v = res.headers.get(h);
342
+ if (v !== null) rateLimitHeaders[h] = v;
343
+ }
344
+
345
+ // Capture server-echoed request ID from response header
346
+ const serverRequestId = res.headers.get("x-request-id") || null;
347
+
348
+ let data;
349
+ const rawText = await res.text();
350
+ const text = rawText.slice(0, MAX_TEXT_CAPTURE);
351
+ try {
352
+ data = JSON.parse(text);
353
+ } catch {
354
+ data = null;
355
+ }
356
+
357
+ return {
358
+ ok: res.ok,
359
+ status: res.status,
360
+ data,
361
+ text,
362
+ rateLimitHeaders,
363
+ retryAfter: res.headers.get("retry-after"),
364
+ serverRequestId,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Resolve the authoritative request ID.
370
+ * Precedence: body request_id > response header X-Request-ID > client-generated.
371
+ */
372
+ function resolveRequestId(parsed, clientRequestId) {
373
+ const bodyId = parsed.data && typeof parsed.data === "object" ? parsed.data.request_id : undefined;
374
+ return bodyId || parsed.serverRequestId || clientRequestId;
375
+ }
376
+
377
+ // ---- src/format.mjs ----
378
+ /**
379
+ * format.mjs — shared ANSI color constants
380
+ *
381
+ * Each command handles its own output formatting.
382
+ * COLORS is shared across errors.mjs, health.mjs, approve.mjs,
383
+ * version_cmd.mjs, simulate.mjs, and cli.mjs.
384
+ */
385
+
386
+ const COLORS = {
387
+ reset: "\x1b[0m",
388
+ bold: "\x1b[1m",
389
+ dim: "\x1b[2m",
390
+ green: "\x1b[32m",
391
+ red: "\x1b[31m",
392
+ yellow: "\x1b[33m",
393
+ cyan: "\x1b[36m",
394
+ gray: "\x1b[90m",
395
+ };
396
+
397
+ // ---- src/errors.mjs ----
398
+ /**
399
+ * errors.mjs — structured error model for the ATF CLI
400
+ *
401
+ * Error envelope: { ok: false, error: { code, message, details? } }
402
+ * Success envelope: { ok: true, ... }
403
+ *
404
+ * Exit codes:
405
+ * 0 — success (allowed, healthy, version)
406
+ * 1 — user error / denied / verify-failed / 401 / 403 / 404 / 409
407
+ * 2 — network / server error (fetch throw, timeout, 5xx, rate-limited)
408
+ */
409
+
410
+ const ERROR_CODES = {
411
+ USER_ERROR: "USER_ERROR",
412
+ DENIED: "DENIED",
413
+ NETWORK_ERROR: "NETWORK_ERROR",
414
+ SERVER_ERROR: "SERVER_ERROR",
415
+ VALIDATION_ERROR: "VALIDATION_ERROR",
416
+ AUTH_ERROR: "AUTH_ERROR",
417
+ VERIFY_FAILED: "VERIFY_FAILED",
418
+ };
419
+
420
+ function makeError(code, message, details) {
421
+ const err = { code, message };
422
+ if (details && typeof details === "object" && Object.keys(details).length > 0) {
423
+ err.details = details;
424
+ }
425
+ return { ok: false, error: err };
426
+ }
427
+
428
+ function exitCodeForError(code) {
429
+ switch (code) {
430
+ case ERROR_CODES.NETWORK_ERROR:
431
+ case ERROR_CODES.SERVER_ERROR:
432
+ return 2;
433
+ default:
434
+ return 1;
435
+ }
436
+ }
437
+
438
+ function formatErrorPretty(errEnvelope) {
439
+ const e = errEnvelope.error;
440
+ const noColor = process.env.NO_COLOR;
441
+ const c = noColor ? { reset: "", bold: "", red: "", dim: "", yellow: "", cyan: "" } : COLORS;
442
+ const lines = [];
443
+ lines.push("");
444
+ lines.push(`${c.bold}${c.red} \u2717 ERROR [${e.code}]${c.reset}`);
445
+ lines.push(` ${e.message}`);
446
+ if (e.details) {
447
+ if (e.details.hint) lines.push(`${c.dim} Hint: ${e.details.hint}${c.reset}`);
448
+ if (e.details.status) lines.push(`${c.dim} HTTP status: ${e.details.status}${c.reset}`);
449
+ if (e.details.request_id) lines.push(`${c.dim} Request ID: ${e.details.request_id}${c.reset}`);
450
+ }
451
+ lines.push("");
452
+ return lines.join("\n");
453
+ }
454
+
455
+ /**
456
+ * Returns true if `obj` looks like a structured ATF error envelope.
457
+ * Shape: { ok: false, error: { code: <string>, ... } }
458
+ * Used to distinguish app-level 404s from endpoint-miss 404s.
459
+ */
460
+ function isAtfErrorEnvelope(obj) {
461
+ return (
462
+ obj !== null &&
463
+ typeof obj === "object" &&
464
+ obj.ok === false &&
465
+ obj.error !== null &&
466
+ typeof obj.error === "object" &&
467
+ typeof obj.error.code === "string"
468
+ );
469
+ }
470
+
471
+ function exitWithError(code, message, hint, format, extra) {
472
+ const details = {};
473
+ if (hint) details.hint = hint;
474
+ if (extra && extra.status) details.status = extra.status;
475
+ if (extra && extra.requestId) details.request_id = extra.requestId;
476
+ if (extra && extra.path) details.path = extra.path;
477
+ if (extra && extra.attempted_paths) details.attempted_paths = extra.attempted_paths;
478
+ if (extra && extra.baseUrl) details.base_url = extra.baseUrl;
479
+ const err = makeError(code, message, Object.keys(details).length > 0 ? details : undefined);
480
+ // Top-level request_id for easy grep / support triage
481
+ if (extra && extra.requestId) err.request_id = extra.requestId;
482
+ if (format === "pretty") {
483
+ process.stderr.write(formatErrorPretty(err));
484
+ } else {
485
+ process.stdout.write(JSON.stringify(err, null, 2) + "\n");
486
+ }
487
+ process.exit(exitCodeForError(code));
488
+ }
489
+
490
+ // ---- src/config.mjs ----
491
+ /**
492
+ * config.mjs — Profile-based configuration system
493
+ *
494
+ * Config file locations:
495
+ * - Global: ~/.config/atf/config.json (Linux/macOS)
496
+ * %APPDATA%\atf\config.json (Windows)
497
+ * - Project: ./atf.config.json (repo-local, optional)
498
+ *
499
+ * Merge order: defaults < global < project < env vars < CLI flags
500
+ *
501
+ * Profile schema:
502
+ * { current_profile: "default", profiles: { "default": { ... } } }
503
+ */
207
504
 
208
- function stripTrailingSlash(url) {
209
- return url.replace(/\/+$/, "");
505
+ const CONFIG_DIR_NAME = "atf";
506
+ const CONFIG_FILE_NAME = "config.json";
507
+ const PROJECT_CONFIG_NAME = "atf.config.json";
508
+
509
+ /** Profile field defaults. */
510
+ const PROFILE_DEFAULTS = {
511
+ atf_base_url: "https://api.trucore.xyz",
512
+ solana_cluster: "mainnet",
513
+ rpc_url: null,
514
+ helius_api_key_ref: null,
515
+ helius_endpoint_kind: "solana-rpc",
516
+ jupiter_quote_url: null,
517
+ jupiter_swap_url: null,
518
+ default_slippage_bps: 50,
519
+ confirm: false,
520
+ commitment: "confirmed",
521
+ explorer: "solscan",
522
+ keypair_path: null,
523
+ tx: {
524
+ skip_preflight: false,
525
+ max_retries: 3,
526
+ timeout_ms: 60000,
527
+ },
528
+ };
529
+
530
+ /**
531
+ * Resolve the global config directory path.
532
+ * @returns {string}
533
+ */
534
+ function resolveConfigDir() {
535
+ const { join } = require("node:path");
536
+ const { homedir, platform } = require("node:os");
537
+ if (platform() === "win32") {
538
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
539
+ return join(appData, CONFIG_DIR_NAME);
540
+ }
541
+ return join(homedir(), ".config", CONFIG_DIR_NAME);
210
542
  }
211
543
 
212
- async function getHealth(baseUrl, timeoutMs) {
213
- const timeout = resolveTimeout(timeoutMs);
214
- const requestId = generateRequestId();
215
- const url = `${stripTrailingSlash(baseUrl)}/health`;
216
- const res = await fetch(url, {
217
- headers: { "X-Request-ID": requestId },
218
- signal: AbortSignal.timeout(timeout),
219
- });
220
- const parsed = await parseResponse(res);
221
- parsed.requestId = requestId;
222
- return parsed;
223
- }
224
-
225
- async function postSimulate(baseUrl, body, apiKey, timeoutMs, path) {
226
- const timeout = resolveTimeout(timeoutMs);
227
- const requestId = generateRequestId();
228
- const url = `${stripTrailingSlash(baseUrl)}${path || "/api/simulate"}`;
229
- const headers = {
230
- "Content-Type": "application/json",
231
- "X-Request-ID": requestId,
232
- };
233
- if (apiKey) headers["x-api-key"] = apiKey;
544
+ /**
545
+ * Resolve the global config file path.
546
+ */
547
+ function resolveGlobalConfigPath() {
548
+ const { join } = require("node:path");
549
+ return join(resolveConfigDir(), CONFIG_FILE_NAME);
550
+ }
234
551
 
235
- const res = await fetch(url, {
236
- method: "POST",
237
- headers,
238
- body: JSON.stringify(body),
239
- signal: AbortSignal.timeout(timeout),
240
- });
241
- const parsed = await parseResponse(res);
242
- parsed.requestId = requestId;
243
- return parsed;
244
- }
245
-
246
- async function postApprove(baseUrl, intentId, token, timeoutMs) {
247
- const timeout = resolveTimeout(timeoutMs);
248
- const requestId = generateRequestId();
249
- const url = `${stripTrailingSlash(baseUrl)}/v1/intents/approve`;
250
- const headers = {
251
- "Content-Type": "application/json",
252
- Authorization: `Bearer ${token}`,
253
- "X-Request-ID": requestId,
254
- };
255
- const res = await fetch(url, {
256
- method: "POST",
257
- headers,
258
- body: JSON.stringify({ intent_id: intentId }),
259
- signal: AbortSignal.timeout(timeout),
260
- });
261
- const parsed = await parseResponse(res);
262
- parsed.requestId = requestId;
263
- return parsed;
552
+ /**
553
+ * Resolve the project config file path (cwd-relative).
554
+ */
555
+ function resolveProjectConfigPath() {
556
+ const { join } = require("node:path");
557
+ return join(process.cwd(), PROJECT_CONFIG_NAME);
264
558
  }
265
559
 
266
- async function getReceiptSigningKey(baseUrl) {
267
- const url = `${stripTrailingSlash(baseUrl)}/api/receipt-signing-key`;
560
+ /**
561
+ * Read a JSON file or return null.
562
+ */
563
+ function readJsonFile(filePath) {
564
+ const { readFileSync, existsSync } = require("node:fs");
565
+ if (!existsSync(filePath)) return null;
268
566
  try {
269
- const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
270
- if (!res.ok) return { available: false };
271
- const data = await res.json();
272
- return data;
567
+ return JSON.parse(readFileSync(filePath, "utf8"));
273
568
  } catch {
274
- return { available: false };
569
+ return null;
275
570
  }
276
571
  }
277
572
 
278
- async function parseResponse(res) {
279
- const rateLimitHeaders = {};
280
- for (const h of ["x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset"]) {
281
- const v = res.headers.get(h);
282
- if (v !== null) rateLimitHeaders[h] = v;
283
- }
284
-
285
- let data;
286
- const rawText = await res.text();
287
- const text = rawText.slice(0, MAX_TEXT_CAPTURE);
288
- try {
289
- data = JSON.parse(text);
290
- } catch {
291
- data = null;
292
- }
573
+ /**
574
+ * Write a JSON file, creating directories as needed.
575
+ */
576
+ function writeJsonFile(filePath, data) {
577
+ const { writeFileSync, mkdirSync } = require("node:fs");
578
+ const { dirname } = require("node:path");
579
+ mkdirSync(dirname(filePath), { recursive: true });
580
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o644 });
581
+ }
293
582
 
583
+ /**
584
+ * Build a default, empty config skeleton.
585
+ */
586
+ function defaultConfig() {
294
587
  return {
295
- ok: res.ok,
296
- status: res.status,
297
- data,
298
- text,
299
- rateLimitHeaders,
300
- retryAfter: res.headers.get("retry-after"),
588
+ current_profile: "default",
589
+ profiles: {
590
+ default: { ...PROFILE_DEFAULTS, tx: { ...PROFILE_DEFAULTS.tx } },
591
+ },
301
592
  };
302
593
  }
303
-
304
- // ---- src/format.mjs ----
594
+
305
595
  /**
306
- * format.mjs shared ANSI color constants
307
- *
308
- * Each command handles its own output formatting.
309
- * COLORS is shared across errors.mjs, health.mjs, approve.mjs,
310
- * version_cmd.mjs, simulate.mjs, and cli.mjs.
311
- */
312
-
313
- const COLORS = {
314
- reset: "\x1b[0m",
315
- bold: "\x1b[1m",
316
- dim: "\x1b[2m",
317
- green: "\x1b[32m",
318
- red: "\x1b[31m",
319
- yellow: "\x1b[33m",
320
- cyan: "\x1b[36m",
321
- gray: "\x1b[90m",
322
- };
323
-
324
- // ---- src/errors.mjs ----
596
+ * Load global config from disk (or return defaults).
597
+ */
598
+ function loadGlobalConfig() {
599
+ const data = readJsonFile(resolveGlobalConfigPath());
600
+ if (!data || typeof data !== "object") return defaultConfig();
601
+ if (!data.profiles || typeof data.profiles !== "object") data.profiles = {};
602
+ if (!data.current_profile) data.current_profile = "default";
603
+ return data;
604
+ }
605
+
325
606
  /**
326
- * errors.mjs structured error model for the ATF CLI
327
- *
328
- * Error envelope: { ok: false, error: { code, message, details? } }
329
- * Success envelope: { ok: true, ... }
330
- *
331
- * Exit codes:
332
- * 0 — success (allowed, healthy, version)
333
- * 1 — user error / denied / verify-failed / 401 / 403 / 404 / 409
334
- * 2 — network / server error (fetch throw, timeout, 5xx, rate-limited)
335
- */
336
-
337
- const ERROR_CODES = {
338
- USER_ERROR: "USER_ERROR",
339
- DENIED: "DENIED",
340
- NETWORK_ERROR: "NETWORK_ERROR",
341
- SERVER_ERROR: "SERVER_ERROR",
342
- VALIDATION_ERROR: "VALIDATION_ERROR",
343
- AUTH_ERROR: "AUTH_ERROR",
344
- VERIFY_FAILED: "VERIFY_FAILED",
345
- };
607
+ * Load project config (or return null).
608
+ */
609
+ function loadProjectConfig() {
610
+ return readJsonFile(resolveProjectConfigPath());
611
+ }
346
612
 
347
- function makeError(code, message, details) {
348
- const err = { code, message };
349
- if (details && typeof details === "object" && Object.keys(details).length > 0) {
350
- err.details = details;
613
+ /**
614
+ * Merge two profile objects (shallow+tx sub-merge).
615
+ */
616
+ function mergeProfiles(base, override) {
617
+ if (!override) return { ...base };
618
+ const merged = { ...base, ...override };
619
+ if (base.tx || override.tx) {
620
+ merged.tx = { ...(base.tx || {}), ...(override.tx || {}) };
351
621
  }
352
- return { ok: false, error: err };
622
+ return merged;
353
623
  }
354
624
 
355
- function exitCodeForError(code) {
356
- switch (code) {
357
- case ERROR_CODES.NETWORK_ERROR:
358
- case ERROR_CODES.SERVER_ERROR:
359
- return 2;
360
- default:
361
- return 1;
625
+ /**
626
+ * Resolve the effective profile config.
627
+ * Merge order: PROFILE_DEFAULTS < global profile < project profile
628
+ */
629
+ function resolveEffectiveProfile(profileName) {
630
+ const global = loadGlobalConfig();
631
+ const project = loadProjectConfig();
632
+ const name = profileName || global.current_profile || "default";
633
+
634
+ let effective = { ...PROFILE_DEFAULTS, tx: { ...PROFILE_DEFAULTS.tx } };
635
+
636
+ // Merge global profile
637
+ if (global.profiles && global.profiles[name]) {
638
+ effective = mergeProfiles(effective, global.profiles[name]);
639
+ }
640
+
641
+ // Merge project profile
642
+ if (project && project.profiles && project.profiles[name]) {
643
+ effective = mergeProfiles(effective, project.profiles[name]);
362
644
  }
645
+
646
+ return { name, profile: effective };
647
+ }
648
+
649
+ /**
650
+ * Save global config to disk.
651
+ */
652
+ function saveGlobalConfig(config) {
653
+ writeJsonFile(resolveGlobalConfigPath(), config);
363
654
  }
364
655
 
365
- function formatErrorPretty(errEnvelope) {
366
- const e = errEnvelope.error;
367
- const noColor = process.env.NO_COLOR;
368
- const c = noColor ? { reset: "", bold: "", red: "", dim: "", yellow: "", cyan: "" } : COLORS;
369
- const lines = [];
370
- lines.push("");
371
- lines.push(`${c.bold}${c.red} \u2717 ERROR [${e.code}]${c.reset}`);
372
- lines.push(` ${e.message}`);
373
- if (e.details) {
374
- if (e.details.hint) lines.push(`${c.dim} Hint: ${e.details.hint}${c.reset}`);
375
- if (e.details.status) lines.push(`${c.dim} HTTP status: ${e.details.status}${c.reset}`);
376
- if (e.details.request_id) lines.push(`${c.dim} Request ID: ${e.details.request_id}${c.reset}`);
656
+ /**
657
+ * Detect Solana CLI config at ~/.config/solana/cli/config.yml
658
+ * Returns { keypair_path, cluster } or null.
659
+ */
660
+ function detectSolanaConfig() {
661
+ const { readFileSync, existsSync } = require("node:fs");
662
+ const { join } = require("node:path");
663
+ const { homedir } = require("node:os");
664
+ const solConfigPath = join(homedir(), ".config", "solana", "cli", "config.yml");
665
+ if (!existsSync(solConfigPath)) return null;
666
+ try {
667
+ const raw = readFileSync(solConfigPath, "utf8");
668
+ const result = {};
669
+ // Simple YAML parsing for keypair_path and json_rpc_url
670
+ const kpMatch = raw.match(/keypair_path:\s*(.+)/);
671
+ if (kpMatch) result.keypair_path = kpMatch[1].trim();
672
+ const rpcMatch = raw.match(/json_rpc_url:\s*(.+)/);
673
+ if (rpcMatch) {
674
+ const url = rpcMatch[1].trim();
675
+ if (url.includes("devnet")) result.cluster = "devnet";
676
+ else if (url.includes("mainnet")) result.cluster = "mainnet";
677
+ }
678
+ return Object.keys(result).length > 0 ? result : null;
679
+ } catch {
680
+ return null;
377
681
  }
378
- lines.push("");
379
- return lines.join("\n");
380
682
  }
381
683
 
382
684
  /**
383
- * Returns true if `obj` looks like a structured ATF error envelope.
384
- * Shape: { ok: false, error: { code: <string>, ... } }
385
- * Used to distinguish app-level 404s from endpoint-miss 404s.
685
+ * Set a dotted key in a profile. e.g. "tx.max_retries" 5
386
686
  */
387
- function isAtfErrorEnvelope(obj) {
388
- return (
389
- obj !== null &&
390
- typeof obj === "object" &&
391
- obj.ok === false &&
392
- obj.error !== null &&
393
- typeof obj.error === "object" &&
394
- typeof obj.error.code === "string"
395
- );
687
+ function setProfileKey(profile, key, value) {
688
+ const parts = key.split(".");
689
+ if (parts.length === 1) {
690
+ profile[key] = value;
691
+ } else if (parts.length === 2) {
692
+ if (!profile[parts[0]] || typeof profile[parts[0]] !== "object") {
693
+ profile[parts[0]] = {};
694
+ }
695
+ profile[parts[0]][parts[1]] = value;
696
+ }
396
697
  }
397
698
 
398
- function exitWithError(code, message, hint, format, extra) {
399
- const details = {};
400
- if (hint) details.hint = hint;
401
- if (extra && extra.status) details.status = extra.status;
402
- if (extra && extra.requestId) details.request_id = extra.requestId;
403
- if (extra && extra.path) details.path = extra.path;
404
- if (extra && extra.attempted_paths) details.attempted_paths = extra.attempted_paths;
405
- if (extra && extra.baseUrl) details.base_url = extra.baseUrl;
406
- const err = makeError(code, message, Object.keys(details).length > 0 ? details : undefined);
407
- if (format === "pretty") {
408
- process.stderr.write(formatErrorPretty(err));
409
- } else {
410
- process.stdout.write(JSON.stringify(err, null, 2) + "\n");
699
+ /**
700
+ * Get a dotted key from a profile.
701
+ */
702
+ function getProfileKey(profile, key) {
703
+ const parts = key.split(".");
704
+ if (parts.length === 1) return profile[key];
705
+ if (parts.length === 2 && profile[parts[0]] && typeof profile[parts[0]] === "object") {
706
+ return profile[parts[0]][parts[1]];
411
707
  }
412
- process.exit(exitCodeForError(code));
708
+ return undefined;
413
709
  }
414
-
415
- // ---- src/health.mjs ----
710
+
416
711
  /**
417
- * health.mjs health command implementation
418
- *
419
- * GET {base}/health — check API availability and measure latency.
420
- * Output: { ok:true, base_url, latency_ms, response:<healthJson> }
712
+ * Auto-coerce string values for known numeric/boolean profile fields.
421
713
  */
714
+ function coerceProfileValue(key, value) {
715
+ const booleanKeys = ["confirm", "tx.skip_preflight"];
716
+ const numberKeys = ["default_slippage_bps", "tx.max_retries", "tx.timeout_ms"];
717
+ if (booleanKeys.includes(key)) {
718
+ if (value === "true") return true;
719
+ if (value === "false") return false;
720
+ }
721
+ if (numberKeys.includes(key)) {
722
+ const n = parseInt(value, 10);
723
+ if (!Number.isNaN(n)) return n;
724
+ }
725
+ return value;
726
+ }
422
727
 
423
- async function runHealth(args) {
424
- const baseUrl = args.baseUrl;
728
+ // ── CLI handlers for config/profile commands ───────────────────────
729
+
730
+ async function runConfigInit(args) {
425
731
  const format = args.format;
426
- const timeoutMs = args.timeoutMs;
732
+ const configPath = resolveGlobalConfigPath();
733
+ const { existsSync } = require("node:fs");
427
734
 
428
- const start = Date.now();
429
- let response;
430
- try {
431
- response = await getHealth(baseUrl, timeoutMs);
432
- } catch (err) {
433
- exitWithError(
434
- ERROR_CODES.NETWORK_ERROR,
435
- `Cannot reach ${baseUrl}/health — ${err.message}`,
436
- "Check that the API is running and the base URL is correct.",
437
- format
438
- );
735
+ if (existsSync(configPath) && !args.yes) {
736
+ const result = { ok: true, message: "Config already exists.", path: configPath };
737
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
738
+ process.exit(0);
439
739
  }
440
- const latencyMs = Date.now() - start;
441
740
 
442
- if (!response.ok) {
443
- exitWithError(
444
- ERROR_CODES.SERVER_ERROR,
445
- `Health check failed with HTTP ${response.status}`,
446
- "The API may be down or misconfigured.",
447
- format,
448
- { status: response.status, requestId: response.requestId }
449
- );
741
+ const config = defaultConfig();
742
+
743
+ // Detect Solana CLI config
744
+ const solana = detectSolanaConfig();
745
+ if (solana) {
746
+ const profile = config.profiles.default;
747
+ if (solana.keypair_path) profile.keypair_path = solana.keypair_path;
748
+ if (solana.cluster) profile.solana_cluster = solana.cluster;
450
749
  }
451
750
 
751
+ saveGlobalConfig(config);
452
752
  const result = {
453
753
  ok: true,
454
- base_url: baseUrl,
455
- latency_ms: latencyMs,
456
- response: response.data || {},
754
+ message: "Config initialized.",
755
+ path: configPath,
756
+ solana_detected: !!solana,
757
+ current_profile: config.current_profile,
457
758
  };
759
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
760
+ }
458
761
 
459
- if (format === "pretty") {
460
- const noColor = process.env.NO_COLOR;
461
- const c = noColor ? { reset: "", bold: "", green: "", dim: "" } : COLORS;
462
- process.stdout.write(
463
- `\n${c.bold}${c.green} \u2713 HEALTHY${c.reset}\n` +
464
- `${c.dim} Base URL:${c.reset} ${baseUrl}\n` +
465
- `${c.dim} Latency:${c.reset} ${latencyMs}ms\n` +
466
- `${c.dim} HTTP:${c.reset} ${response.status}\n\n`
467
- );
468
- } else {
469
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
762
+ async function runConfigSet(args) {
763
+ const format = args.format;
764
+ const key = args._subArgs[0];
765
+ const rawValue = args._subArgs[1];
766
+ const profileName = args.profileFlag || null;
767
+
768
+ if (!key || rawValue === undefined) {
769
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf config set <key> <value> [--profile name]", null, format);
470
770
  }
471
- process.exit(0);
771
+
772
+ const value = coerceProfileValue(key, rawValue);
773
+ const config = loadGlobalConfig();
774
+ const name = profileName || config.current_profile || "default";
775
+ if (!config.profiles[name]) {
776
+ exitWithError(ERROR_CODES.USER_ERROR, `Profile "${name}" does not exist. Create it first: atf profile create ${name}`, null, format);
777
+ }
778
+ setProfileKey(config.profiles[name], key, value);
779
+ saveGlobalConfig(config);
780
+
781
+ const result = { ok: true, profile: name, key, value };
782
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
472
783
  }
473
-
474
- // ---- src/approve.mjs ----
475
- /**
476
- * approve.mjs — approve command implementation
477
- *
478
- * POST {base}/v1/intents/approve with Bearer token.
479
- * Approves a pending intent returned from a previous simulation.
480
- * Output: { ok:true, base_url, intent, response:<approveJson> }
481
- */
482
784
 
483
- async function runApprove(args) {
484
- const baseUrl = args.baseUrl;
785
+ async function runConfigGet(args) {
485
786
  const format = args.format;
486
- const token = args.token || args.apiKey;
487
- const intent = args.intent;
488
- const timeoutMs = args.timeoutMs;
787
+ const key = args._subArgs[0];
788
+ const profileName = args.profileFlag || null;
489
789
 
490
- if (!intent) {
491
- exitWithError(
492
- ERROR_CODES.USER_ERROR,
493
- "Missing --intent <id>",
494
- "Provide the intent ID returned from a simulation.",
495
- format
496
- );
790
+ if (!key) {
791
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf config get <key> [--profile name]", null, format);
497
792
  }
498
793
 
499
- if (!token) {
500
- exitWithError(
501
- ERROR_CODES.AUTH_ERROR,
502
- "Missing authentication token",
503
- "Provide --token <bearer> or set ATF_API_KEY env var.",
504
- format
505
- );
506
- }
794
+ const { name, profile } = resolveEffectiveProfile(profileName);
795
+ const value = getProfileKey(profile, key);
796
+ const result = { ok: true, profile: name, key, value: value !== undefined ? value : null };
797
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
798
+ }
507
799
 
508
- let response;
509
- try {
510
- response = await postApprove(baseUrl, intent, token, timeoutMs);
511
- } catch (err) {
512
- exitWithError(
513
- ERROR_CODES.NETWORK_ERROR,
514
- redactToken(`Cannot reach ${baseUrl} ${err.message}`, token),
515
- "Check your network connection and base URL.",
516
- format
517
- );
800
+ async function runConfigList(args) {
801
+ const profileName = args.profileFlag || null;
802
+ const { name, profile } = resolveEffectiveProfile(profileName);
803
+
804
+ // Redact any fields that might contain secrets
805
+ const safe = { ...profile };
806
+ if (safe.rpc_url && typeof safe.rpc_url === "string" && safe.rpc_url.includes("api-key=")) {
807
+ safe.rpc_url = safe.rpc_url.replace(/api-key=[^&]+/gi, "api-key=***REDACTED***");
808
+ }
809
+ if (safe.helius_api_key_ref) {
810
+ safe.helius_api_key_ref = "***REDACTED***";
518
811
  }
519
812
 
520
- if (!response.ok) {
521
- const data = response.data || {};
522
- const errCode =
523
- response.status >= 500
524
- ? ERROR_CODES.SERVER_ERROR
525
- : response.status === 401 || response.status === 403
526
- ? ERROR_CODES.AUTH_ERROR
527
- : ERROR_CODES.USER_ERROR;
528
- const hint =
529
- response.status === 401
530
- ? "Check your Bearer token."
531
- : response.status === 404
532
- ? "Intent not found — check the intent ID."
533
- : response.status === 409
534
- ? "Intent already approved or expired."
535
- : null;
536
- const msg = redactToken(data.message || data.detail || `HTTP ${response.status}`, token);
537
- exitWithError(
538
- errCode,
539
- msg,
540
- hint,
541
- format,
542
- { status: response.status, requestId: response.requestId || data.request_id }
543
- );
813
+ const result = { ok: true, profile: name, config: safe };
814
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
815
+ }
816
+
817
+ async function runProfileUse(args) {
818
+ const name = args._subArgs[0];
819
+ if (!name) {
820
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf profile use <name>", null, args.format);
544
821
  }
822
+ const config = loadGlobalConfig();
823
+ if (!config.profiles[name]) {
824
+ exitWithError(ERROR_CODES.USER_ERROR, `Profile "${name}" does not exist.`, null, args.format);
825
+ }
826
+ config.current_profile = name;
827
+ saveGlobalConfig(config);
828
+ process.stdout.write(JSON.stringify({ ok: true, current_profile: name }, null, 2) + "\n");
829
+ }
545
830
 
546
- const data = response.data || {};
547
- const result = { ok: true, base_url: baseUrl, intent, response: data };
548
- if (args.verbose && args._deprecatedIntentId) {
549
- result.warnings = ["--intent-id is deprecated; use --intent"];
831
+ async function runProfileList(args) {
832
+ const config = loadGlobalConfig();
833
+ const profiles = Object.keys(config.profiles);
834
+ const result = { ok: true, current_profile: config.current_profile, profiles };
835
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
836
+ }
837
+
838
+ async function runProfileCreate(args) {
839
+ const name = args._subArgs[0];
840
+ if (!name) {
841
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf profile create <name>", null, args.format);
550
842
  }
843
+ const config = loadGlobalConfig();
844
+ if (config.profiles[name]) {
845
+ exitWithError(ERROR_CODES.USER_ERROR, `Profile "${name}" already exists.`, null, args.format);
846
+ }
847
+ config.profiles[name] = { ...PROFILE_DEFAULTS, tx: { ...PROFILE_DEFAULTS.tx } };
848
+ saveGlobalConfig(config);
849
+ process.stdout.write(JSON.stringify({ ok: true, created: name }, null, 2) + "\n");
850
+ }
551
851
 
552
- if (format === "pretty") {
553
- const noColor = process.env.NO_COLOR;
554
- const c = noColor ? { reset: "", bold: "", green: "", dim: "" } : COLORS;
555
- process.stdout.write(
556
- `\n${c.bold}${c.green} \u2713 APPROVED${c.reset}\n` +
557
- `${c.dim} Intent:${c.reset} ${intent}\n` +
558
- (data.tx_hash
559
- ? `${c.dim} TX Hash:${c.reset} ${data.tx_hash}\n`
560
- : "") +
561
- `\n`
562
- );
563
- } else {
564
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
852
+ async function runProfileDelete(args) {
853
+ const name = args._subArgs[0];
854
+ if (!name) {
855
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf profile delete <name>", null, args.format);
565
856
  }
566
- process.exit(0);
857
+ if (name === "default") {
858
+ exitWithError(ERROR_CODES.USER_ERROR, "Cannot delete the default profile.", null, args.format);
859
+ }
860
+ const config = loadGlobalConfig();
861
+ if (!config.profiles[name]) {
862
+ exitWithError(ERROR_CODES.USER_ERROR, `Profile "${name}" does not exist.`, null, args.format);
863
+ }
864
+ delete config.profiles[name];
865
+ if (config.current_profile === name) config.current_profile = "default";
866
+ saveGlobalConfig(config);
867
+ process.stdout.write(JSON.stringify({ ok: true, deleted: name }, null, 2) + "\n");
567
868
  }
568
869
 
569
- // ---- src/version_cmd.mjs ----
870
+ // ---- src/secret_store.mjs ----
570
871
  /**
571
- * version_cmd.mjs — rich version command
872
+ * secret_store.mjs — file-based secret store with permission hardening
572
873
  *
573
- * Output: { ok:true, cli_version, node_version, platform, arch, default_base_url, build_commit, build_date }
874
+ * Stores secrets in ~/.config/atf/secrets.json with chmod 0600 (best-effort).
875
+ * Structure: { "helius": { "<profile>": "<api_key>" } }
876
+ *
877
+ * Never prints secret values. `atf secret list` shows only key names.
574
878
  */
575
879
 
576
- async function runVersion(args) {
577
- const format = args.format;
578
- const commit = BUILD_COMMIT === "__BUILD_COMMIT__" ? null : BUILD_COMMIT;
579
- const buildTime = BUILD_DATE === "__BUILD_DATE__" ? null : BUILD_DATE;
580
- const result = {
581
- ok: true,
582
- cli_version: VERSION,
583
- node_version: process.version,
584
- platform: process.platform,
585
- arch: process.arch,
586
- default_base_url: DEFAULT_BASE_URL,
587
- build_commit: commit,
588
- build_date: buildTime,
589
- };
880
+ const SECRETS_FILE_NAME = "secrets.json";
590
881
 
591
- if (format === "pretty") {
592
- const noColor = process.env.NO_COLOR;
593
- const c = noColor ? { reset: "", bold: "", dim: "" } : COLORS;
594
- process.stdout.write(
595
- `\n${c.bold} @trucore/atf v${VERSION}${c.reset}\n` +
596
- `${c.dim} Node:${c.reset} ${process.version}\n` +
597
- `${c.dim} Platform:${c.reset} ${process.platform}/${process.arch}\n` +
598
- `${c.dim} API:${c.reset} ${DEFAULT_BASE_URL}\n` +
599
- `${c.dim} Commit:${c.reset} ${commit || "dev"}\n` +
600
- `${c.dim} Built:${c.reset} ${buildTime || "dev"}\n\n`
601
- );
602
- } else {
603
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
604
- }
605
- process.exit(0);
606
- }
607
-
608
- // ---- src/simulate.mjs ----
609
882
  /**
610
- * simulate.mjs simulate command implementation
611
- *
612
- * Output: { ok:true, base_url, preset?, verified?:boolean, response:<simulateJson> }
883
+ * Resolve the secrets file path.
613
884
  */
885
+ function resolveSecretsPath() {
886
+ const { join } = require("node:path");
887
+ return join(resolveConfigDir(), SECRETS_FILE_NAME);
888
+ }
614
889
 
615
- async function runSimulate(args) {
616
- const baseUrl = args.baseUrl;
617
- const verify = args.verify;
618
- const format = args.format;
619
- const quiet = args.quiet;
620
- const apiKey = args.apiKey;
621
- const timeoutMs = args.timeoutMs;
622
-
623
- // Resolve body
624
- let body;
625
- let presetName = null;
626
- if (args.json) {
627
- const parsed = parseJsonBody(args.json);
628
- if (!parsed.ok) {
629
- exitWithError(
630
- ERROR_CODES.USER_ERROR,
631
- parsed.message,
632
- "Provide valid JSON via --json '{...}'",
633
- format
634
- );
635
- }
636
- body = parsed.body;
637
- } else if (args.preset) {
638
- presetName = args.preset;
639
- const check = validatePreset(presetName);
640
- if (!check.ok) {
641
- exitWithError(
642
- ERROR_CODES.USER_ERROR,
643
- check.message,
644
- `Available presets: ${PRESET_NAMES.join(", ")}`,
645
- format
646
- );
647
- }
648
- body = PRESETS[presetName].transaction;
649
- if (!quiet && format === "pretty") {
650
- process.stderr.write(
651
- `${COLORS.dim}Preset: ${presetName} — ${PRESETS[presetName].description}${COLORS.reset}\n`
652
- );
653
- }
654
- } else {
655
- exitWithError(
656
- ERROR_CODES.USER_ERROR,
657
- "Either --preset <name> or --json '<json>' is required.",
658
- `Available presets: ${PRESET_NAMES.join(", ")}`,
659
- format
660
- );
661
- }
890
+ /**
891
+ * Load secrets from disk (or return empty structure).
892
+ */
893
+ function loadSecrets() {
894
+ const path = resolveSecretsPath();
895
+ const data = readJsonFile(path);
896
+ if (!data || typeof data !== "object") return {};
897
+ return data;
898
+ }
662
899
 
663
- // Make request — try SIMULATE_PATHS in order; stop on first non-404.
664
- // If a 404 carries a structured ATF error envelope ({ok:false, error:{code:...}})
665
- // we treat it as an authoritative app-level response and do NOT fallback.
666
- let response;
667
- const attemptedPaths = [];
900
+ /**
901
+ * Save secrets to disk with 0600 permissions (best-effort).
902
+ */
903
+ function saveSecrets(secrets) {
904
+ const { writeFileSync, mkdirSync, chmodSync } = require("node:fs");
905
+ const { dirname } = require("node:path");
906
+ const filePath = resolveSecretsPath();
907
+ mkdirSync(dirname(filePath), { recursive: true });
908
+ writeFileSync(filePath, JSON.stringify(secrets, null, 2) + "\n", { mode: 0o600 });
668
909
  try {
669
- for (const path of SIMULATE_PATHS) {
670
- attemptedPaths.push(path);
671
- response = await postSimulate(baseUrl, body, apiKey, timeoutMs, path);
672
- if (response.status !== 404) break;
673
- // 404 with a structured ATF error envelope → authoritative, stop fallback
674
- if (isAtfErrorEnvelope(response.data)) break;
675
- }
676
- } catch (err) {
677
- exitWithError(
678
- ERROR_CODES.NETWORK_ERROR,
679
- `Network failure — ${err.message}`,
680
- "Check your network connection and base URL.",
681
- format
682
- );
910
+ chmodSync(filePath, 0o600);
911
+ } catch {
912
+ // Best-effort may fail on Windows
683
913
  }
914
+ }
684
915
 
685
- // Handle non-200
686
- if (!response.ok) {
687
- let errCode;
688
- let msg;
689
- let hint = null;
690
-
691
- if (response.status === 401 || response.status === 403) {
692
- errCode = ERROR_CODES.AUTH_ERROR;
693
- const data = response.data || {};
694
- msg = data.message || data.detail || `HTTP ${response.status}`;
695
- hint = response.status === 401
696
- ? "Check your API key or Bearer token."
697
- : "Access denied. Check your permissions.";
698
- } else if (response.status === 404) {
699
- if (isAtfErrorEnvelope(response.data)) {
700
- // Authoritative ATF 404 surface the server's error as-is
701
- errCode = response.data.error.code || ERROR_CODES.SERVER_ERROR;
702
- msg = response.data.error.message || `HTTP 404`;
703
- hint = (response.data.error.details && response.data.error.details.hint) || null;
704
- exitWithError(errCode, msg, hint, format, {
705
- status: 404,
706
- attempted_paths: attemptedPaths,
707
- baseUrl,
708
- requestId: response.requestId,
709
- });
710
- }
711
- // Endpoint miss — none of the paths exist on this server
712
- errCode = ERROR_CODES.SERVER_ERROR;
713
- msg = "SIMULATE_NOT_AVAILABLE";
714
- hint = "The simulate endpoint is not available on this server.";
715
- exitWithError(errCode, msg, hint, format, {
716
- status: 404,
717
- attempted_paths: attemptedPaths,
718
- baseUrl,
719
- requestId: response.requestId,
720
- });
721
- } else if (response.status === 422 || response.status === 400) {
722
- errCode = ERROR_CODES.VALIDATION_ERROR;
723
- const data = response.data || {};
724
- msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
725
- hint = "Check the request body format and required fields.";
726
- } else if (response.status === 429) {
727
- errCode = ERROR_CODES.SERVER_ERROR;
728
- const data = response.data || {};
729
- msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
730
- hint = response.retryAfter
731
- ? `Rate limited. Retry after ${response.retryAfter}s.`
732
- : "Rate limited. Try again later.";
733
- } else if (response.status >= 500) {
734
- errCode = ERROR_CODES.SERVER_ERROR;
735
- const data = response.data || {};
736
- msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
916
+ /**
917
+ * Get a secret value.
918
+ * @param {string} namespace e.g. "helius"
919
+ * @param {string} profile e.g. "default"
920
+ * @returns {string|null}
921
+ */
922
+ function getSecret(namespace, profile) {
923
+ const secrets = loadSecrets();
924
+ if (secrets[namespace] && typeof secrets[namespace] === "object") {
925
+ return secrets[namespace][profile] || null;
926
+ }
927
+ return null;
928
+ }
929
+
930
+ /**
931
+ * Set a secret value.
932
+ */
933
+ function setSecret(namespace, profile, value) {
934
+ const secrets = loadSecrets();
935
+ if (!secrets[namespace]) secrets[namespace] = {};
936
+ secrets[namespace][profile] = value;
937
+ saveSecrets(secrets);
938
+ }
939
+
940
+ /**
941
+ * Unset a secret value.
942
+ */
943
+ function unsetSecret(namespace, profile) {
944
+ const secrets = loadSecrets();
945
+ if (secrets[namespace] && secrets[namespace][profile]) {
946
+ delete secrets[namespace][profile];
947
+ if (Object.keys(secrets[namespace]).length === 0) delete secrets[namespace];
948
+ saveSecrets(secrets);
949
+ return true;
950
+ }
951
+ return false;
952
+ }
953
+
954
+ /**
955
+ * List secrets returns key/profile pairs only (no values).
956
+ */
957
+ function listSecretRefs() {
958
+ const secrets = loadSecrets();
959
+ const refs = [];
960
+ for (const [ns, profiles] of Object.entries(secrets)) {
961
+ if (typeof profiles === "object" && profiles !== null) {
962
+ for (const profile of Object.keys(profiles)) {
963
+ refs.push({ namespace: ns, profile });
964
+ }
965
+ }
966
+ }
967
+ return refs;
968
+ }
969
+
970
+ /**
971
+ * Resolve the Helius API key for a given profile.
972
+ * Priority: HELIUS_API_KEY env var > secrets store
973
+ */
974
+ function resolveHeliusApiKey(profileName) {
975
+ const envKey = process.env.HELIUS_API_KEY;
976
+ if (envKey && envKey.length > 0) return envKey;
977
+ return getSecret("helius", profileName || "default");
978
+ }
979
+
980
+ // ── CLI handlers for secret commands ───────────────────────────────
981
+
982
+ async function runSecretSet(args) {
983
+ const namespace = args._subArgs[0];
984
+ const profileName = args.profileFlag || null;
985
+
986
+ if (!namespace) {
987
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf secret set <namespace> [--profile name]", "Example: atf secret set helius --profile devnet", args.format);
988
+ }
989
+
990
+ const config = loadGlobalConfig();
991
+ const profile = profileName || config.current_profile || "default";
992
+
993
+ // Read from stdin (supports piped input)
994
+ let value = "";
995
+ if (process.stdin.isTTY) {
996
+ process.stderr.write(`Enter value for ${namespace} (profile: ${profile}): `);
997
+ }
998
+ const { readFileSync } = require("node:fs");
999
+ try {
1000
+ value = readFileSync("/dev/stdin", "utf8").trim();
1001
+ } catch {
1002
+ // Fallback for Windows or other environments
1003
+ try {
1004
+ value = readFileSync(0, "utf8").trim();
1005
+ } catch {
1006
+ exitWithError(ERROR_CODES.USER_ERROR, "Failed to read secret from stdin.", "Pipe the value: echo 'key' | atf secret set helius", args.format);
1007
+ }
1008
+ }
1009
+
1010
+ if (!value) {
1011
+ exitWithError(ERROR_CODES.USER_ERROR, "Empty secret value.", null, args.format);
1012
+ }
1013
+
1014
+ setSecret(namespace, profile, value);
1015
+ process.stdout.write(JSON.stringify({ ok: true, namespace, profile, message: "Secret saved." }, null, 2) + "\n");
1016
+ }
1017
+
1018
+ async function runSecretList(args) {
1019
+ const refs = listSecretRefs();
1020
+ process.stdout.write(JSON.stringify({ ok: true, secrets: refs }, null, 2) + "\n");
1021
+ }
1022
+
1023
+ async function runSecretUnset(args) {
1024
+ const namespace = args._subArgs[0];
1025
+ const profileName = args.profileFlag || null;
1026
+
1027
+ if (!namespace) {
1028
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf secret unset <namespace> [--profile name]", null, args.format);
1029
+ }
1030
+
1031
+ const config = loadGlobalConfig();
1032
+ const profile = profileName || config.current_profile || "default";
1033
+ const removed = unsetSecret(namespace, profile);
1034
+
1035
+ if (removed) {
1036
+ process.stdout.write(JSON.stringify({ ok: true, namespace, profile, message: "Secret removed." }, null, 2) + "\n");
1037
+ } else {
1038
+ exitWithError(ERROR_CODES.USER_ERROR, `No secret found for ${namespace} (profile: ${profile}).`, null, args.format);
1039
+ }
1040
+ }
1041
+
1042
+ // ---- src/helius.mjs ----
1043
+ /**
1044
+ * helius.mjs — Helius RPC URL builder and redaction
1045
+ *
1046
+ * If rpc_url is set to the literal string "helius", the URL is auto-built
1047
+ * from solana_cluster + resolved Helius API key:
1048
+ * mainnet → https://mainnet.helius-rpc.com/?api-key=<KEY>
1049
+ * devnet → https://devnet.helius-rpc.com/?api-key=<KEY>
1050
+ */
1051
+
1052
+ const HELIUS_RPC_HOSTS = {
1053
+ mainnet: "mainnet.helius-rpc.com",
1054
+ devnet: "devnet.helius-rpc.com",
1055
+ };
1056
+
1057
+ /**
1058
+ * Build a Helius RPC URL from cluster and API key.
1059
+ */
1060
+ function buildHeliusRpcUrl(cluster, apiKey) {
1061
+ const host = HELIUS_RPC_HOSTS[cluster] || HELIUS_RPC_HOSTS.mainnet;
1062
+ return `https://${host}/?api-key=${apiKey}`;
1063
+ }
1064
+
1065
+ /**
1066
+ * Redact api-key query param from a URL string.
1067
+ */
1068
+ function redactApiKeyInUrl(url) {
1069
+ if (!url || typeof url !== "string") return url || "";
1070
+ return url.replace(/api-key=[^&\s]+/gi, "api-key=***REDACTED***");
1071
+ }
1072
+
1073
+ /**
1074
+ * Extract the host portion from an RPC URL (safe for display).
1075
+ */
1076
+ function extractRpcHost(url) {
1077
+ if (!url || typeof url !== "string") return null;
1078
+ try {
1079
+ const parsed = new URL(url);
1080
+ return parsed.hostname;
1081
+ } catch {
1082
+ return null;
1083
+ }
1084
+ }
1085
+
1086
+ /**
1087
+ * Resolve the effective RPC URL for a profile.
1088
+ * Handles "helius" magic value, env overrides, and devnet defaults.
1089
+ *
1090
+ * Priority: CLI --rpc flag > profile rpc_url > defaults
1091
+ *
1092
+ * @param {object} profile Resolved effective profile
1093
+ * @param {string} profileName Profile name (for secret lookup)
1094
+ * @param {string|null} cliRpcOverride --rpc flag value
1095
+ * @param {boolean} isDevnet Whether devnet mode is active
1096
+ * @returns {{ rpcUrl: string, rpcHost: string|null }}
1097
+ */
1098
+ function resolveRpcUrl(profile, profileName, cliRpcOverride, isDevnet) {
1099
+ // CLI flag takes precedence
1100
+ if (cliRpcOverride && cliRpcOverride !== "helius") {
1101
+ return { rpcUrl: cliRpcOverride, rpcHost: extractRpcHost(cliRpcOverride) };
1102
+ }
1103
+
1104
+ const rpcSetting = cliRpcOverride || profile.rpc_url;
1105
+
1106
+ if (rpcSetting === "helius") {
1107
+ const apiKey = resolveHeliusApiKey(profileName);
1108
+ if (!apiKey) {
1109
+ exitWithError(
1110
+ ERROR_CODES.USER_ERROR,
1111
+ "Helius API key not found. Set HELIUS_API_KEY env var or run: atf secret set helius",
1112
+ null,
1113
+ "json"
1114
+ );
1115
+ }
1116
+ const cluster = isDevnet ? "devnet" : (profile.solana_cluster || "mainnet");
1117
+ const url = buildHeliusRpcUrl(cluster, apiKey);
1118
+ return { rpcUrl: url, rpcHost: HELIUS_RPC_HOSTS[cluster] || HELIUS_RPC_HOSTS.mainnet };
1119
+ }
1120
+
1121
+ if (rpcSetting) {
1122
+ return { rpcUrl: rpcSetting, rpcHost: extractRpcHost(rpcSetting) };
1123
+ }
1124
+
1125
+ // Defaults
1126
+ if (isDevnet) {
1127
+ const devnetDefault = process.env.ATF_DEVNET_RPC || DEVNET_RPC_DEFAULT;
1128
+ return { rpcUrl: devnetDefault, rpcHost: extractRpcHost(devnetDefault) };
1129
+ }
1130
+
1131
+ return { rpcUrl: "https://api.mainnet-beta.solana.com", rpcHost: "api.mainnet-beta.solana.com" };
1132
+ }
1133
+
1134
+ // ---- src/jupiter.mjs ----
1135
+ /**
1136
+ * jupiter.mjs — Jupiter DEX HTTP helpers (v6 API)
1137
+ *
1138
+ * Uses built-in fetch (Node 18+). Zero extra dependencies.
1139
+ * Only called from swap.mjs when ATF decision == allowed.
1140
+ */
1141
+
1142
+ const JUPITER_API_BASE = "https://quote-api.jup.ag/v6";
1143
+
1144
+ /**
1145
+ * Resolve the Jupiter API base URL.
1146
+ * Allows override via ATF_JUPITER_BASE env var (used in tests).
1147
+ */
1148
+ function resolveJupiterBase() {
1149
+ return process.env.ATF_JUPITER_BASE || JUPITER_API_BASE;
1150
+ }
1151
+
1152
+ /**
1153
+ * Get a swap quote from Jupiter.
1154
+ *
1155
+ * @param {object} opts
1156
+ * @param {string} opts.inputMint Solana mint address
1157
+ * @param {string} opts.outputMint Solana mint address
1158
+ * @param {string} opts.amount Base-unit amount string (lamports / micro-units)
1159
+ * @param {number} opts.slippageBps Slippage tolerance in basis points
1160
+ * @param {number} opts.timeoutMs HTTP timeout
1161
+ * @returns {Promise<object>} Jupiter quote response JSON
1162
+ */
1163
+ async function jupiterGetQuote(opts) {
1164
+ const url = new URL(`${resolveJupiterBase()}/quote`);
1165
+ url.searchParams.set("inputMint", opts.inputMint);
1166
+ url.searchParams.set("outputMint", opts.outputMint);
1167
+ url.searchParams.set("amount", opts.amount);
1168
+ url.searchParams.set("slippageBps", String(opts.slippageBps));
1169
+
1170
+ const res = await fetch(url.toString(), {
1171
+ signal: AbortSignal.timeout(opts.timeoutMs || 20_000),
1172
+ });
1173
+
1174
+ if (!res.ok) {
1175
+ const text = await res.text().catch(() => "");
1176
+ throw new Error(`Jupiter quote failed: HTTP ${res.status} — ${text.slice(0, 256)}`);
1177
+ }
1178
+ return res.json();
1179
+ }
1180
+
1181
+ /**
1182
+ * Request a swap transaction from Jupiter.
1183
+ * Returns the base64 swapTransaction ready for signing.
1184
+ *
1185
+ * @param {object} opts
1186
+ * @param {object} opts.quoteResponse Full quote response from jupiterGetQuote
1187
+ * @param {string} opts.userPublicKey Base58 public key of the wallet
1188
+ * @param {number} opts.timeoutMs HTTP timeout
1189
+ * @returns {Promise<{swapTransaction: string, lastValidBlockHeight: number}>}
1190
+ */
1191
+ async function jupiterGetSwapTx(opts) {
1192
+ const body = {
1193
+ quoteResponse: opts.quoteResponse,
1194
+ userPublicKey: opts.userPublicKey,
1195
+ wrapAndUnwrapSol: true,
1196
+ dynamicComputeUnitLimit: true,
1197
+ };
1198
+
1199
+ const res = await fetch(`${resolveJupiterBase()}/swap`, {
1200
+ method: "POST",
1201
+ headers: { "Content-Type": "application/json" },
1202
+ body: JSON.stringify(body),
1203
+ signal: AbortSignal.timeout(opts.timeoutMs || 20_000),
1204
+ });
1205
+
1206
+ if (!res.ok) {
1207
+ const text = await res.text().catch(() => "");
1208
+ throw new Error(`Jupiter swap-tx failed: HTTP ${res.status} — ${text.slice(0, 256)}`);
1209
+ }
1210
+ return res.json();
1211
+ }
1212
+
1213
+ // ---- src/solana_sign.mjs ----
1214
+ /**
1215
+ * solana_sign.mjs — Solana keypair loading, transaction signing, and RPC helpers.
1216
+ *
1217
+ * Runtime dependency: tweetnacl (ed25519).
1218
+ * No @solana/web3.js — we handle raw VersionedTransaction bytes directly.
1219
+ */
1220
+
1221
+ /* global nacl */
1222
+ // `nacl` is set up by the build bundle (tweetnacl is required at the top of dist/index.js)
1223
+ // In source form it is loaded via require() in the build.
1224
+
1225
+ /**
1226
+ * Load a Solana keypair from the standard id.json format.
1227
+ * id.json is a JSON array of 64 byte values [secretKey(32) + publicKey(32)].
1228
+ *
1229
+ * @param {string} keypairPath Absolute or relative path to id.json
1230
+ * @returns {{secretKey: Uint8Array, publicKey: Uint8Array}}
1231
+ */
1232
+ function loadSolanaKeypair(keypairPath) {
1233
+ const { readFileSync } = require("node:fs");
1234
+ const { resolve } = require("node:path");
1235
+ const resolved = resolve(keypairPath);
1236
+ let raw;
1237
+ try {
1238
+ raw = readFileSync(resolved, "utf8");
1239
+ } catch (err) {
1240
+ throw new Error(`Cannot read keypair file: ${err.message}`);
1241
+ }
1242
+ let arr;
1243
+ try {
1244
+ arr = JSON.parse(raw);
1245
+ } catch {
1246
+ throw new Error("Keypair file is not valid JSON (expected a byte array).");
1247
+ }
1248
+ if (!Array.isArray(arr) || arr.length !== 64) {
1249
+ throw new Error(`Keypair must be a JSON array of 64 bytes, got length ${Array.isArray(arr) ? arr.length : typeof arr}.`);
1250
+ }
1251
+ const secretKey = new Uint8Array(arr);
1252
+ const publicKey = secretKey.slice(32, 64);
1253
+ return { secretKey, publicKey };
1254
+ }
1255
+
1256
+ /**
1257
+ * Encode a Uint8Array to base58 (Bitcoin/Solana alphabet).
1258
+ * Compact pure-JS implementation — no external dependency.
1259
+ */
1260
+ function base58Encode(bytes) {
1261
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
1262
+ if (bytes.length === 0) return "";
1263
+ // Count leading zeros
1264
+ let zeros = 0;
1265
+ for (let i = 0; i < bytes.length && bytes[i] === 0; i++) zeros++;
1266
+ // Convert to base58
1267
+ const b58 = [];
1268
+ let num = [];
1269
+ for (const byte of bytes) {
1270
+ let carry = byte;
1271
+ for (let j = 0; j < num.length; j++) {
1272
+ carry += num[j] << 8;
1273
+ num[j] = carry % 58;
1274
+ carry = (carry / 58) | 0;
1275
+ }
1276
+ while (carry > 0) {
1277
+ num.push(carry % 58);
1278
+ carry = (carry / 58) | 0;
1279
+ }
1280
+ }
1281
+ // Leading '1's for zero bytes
1282
+ for (let i = 0; i < zeros; i++) b58.push(ALPHABET[0]);
1283
+ for (let i = num.length - 1; i >= 0; i--) b58.push(ALPHABET[num[i]]);
1284
+ return b58.join("");
1285
+ }
1286
+
1287
+ /**
1288
+ * Read a compact-u16 from a buffer at the given offset.
1289
+ * Returns { value, bytesRead }.
1290
+ */
1291
+ function readCompactU16(buf, offset) {
1292
+ let value = 0;
1293
+ let bytesRead = 0;
1294
+ for (let shift = 0; shift < 21; shift += 7) {
1295
+ if (offset + bytesRead >= buf.length) throw new Error("compact-u16 truncated");
1296
+ const b = buf[offset + bytesRead];
1297
+ bytesRead++;
1298
+ value |= (b & 0x7f) << shift;
1299
+ if ((b & 0x80) === 0) break;
1300
+ }
1301
+ return { value, bytesRead };
1302
+ }
1303
+
1304
+ /**
1305
+ * Sign a Solana VersionedTransaction (raw bytes) with a single-signer keypair.
1306
+ *
1307
+ * Transaction wire format:
1308
+ * [compact-u16 sig_count] [sig_count * 64 bytes sigs] [message bytes]
1309
+ *
1310
+ * We sign the message bytes, then replace the first 64-byte signature slot.
1311
+ *
1312
+ * @param {Uint8Array} txBytes Raw transaction bytes (from base64 decode)
1313
+ * @param {Uint8Array} secretKey 64-byte ed25519 secret key
1314
+ * @returns {Uint8Array} Signed transaction bytes
1315
+ */
1316
+ function signSolanaTransaction(txBytes, secretKey) {
1317
+ const nacl = require("tweetnacl");
1318
+ const { value: sigCount, bytesRead: headerLen } = readCompactU16(txBytes, 0);
1319
+ if (sigCount < 1) throw new Error("Transaction has 0 signature slots");
1320
+ const sigStart = headerLen;
1321
+ const msgStart = headerLen + sigCount * 64;
1322
+ if (msgStart > txBytes.length) throw new Error("Transaction too short for declared signatures");
1323
+ const messageBytes = txBytes.slice(msgStart);
1324
+ const sig = nacl.sign.detached(messageBytes, secretKey);
1325
+ // Replace first signature slot
1326
+ const signed = new Uint8Array(txBytes);
1327
+ signed.set(sig, sigStart);
1328
+ return signed;
1329
+ }
1330
+
1331
+ /**
1332
+ * Send a signed transaction via Solana JSON-RPC.
1333
+ *
1334
+ * @param {string} rpcUrl Solana RPC endpoint
1335
+ * @param {Uint8Array} signedTxBytes Signed transaction bytes
1336
+ * @param {number} timeoutMs HTTP timeout
1337
+ * @returns {Promise<string>} Transaction signature (base58)
1338
+ */
1339
+ async function solanaSendTransaction(rpcUrl, signedTxBytes, timeoutMs) {
1340
+ const b64 = Buffer.from(signedTxBytes).toString("base64");
1341
+ const body = {
1342
+ jsonrpc: "2.0",
1343
+ id: 1,
1344
+ method: "sendTransaction",
1345
+ params: [
1346
+ b64,
1347
+ {
1348
+ encoding: "base64",
1349
+ skipPreflight: false,
1350
+ maxRetries: 3,
1351
+ },
1352
+ ],
1353
+ };
1354
+ const res = await fetch(rpcUrl, {
1355
+ method: "POST",
1356
+ headers: { "Content-Type": "application/json" },
1357
+ body: JSON.stringify(body),
1358
+ signal: AbortSignal.timeout(timeoutMs || 30_000),
1359
+ });
1360
+ if (!res.ok) {
1361
+ const text = await res.text().catch(() => "");
1362
+ throw new Error(`Solana RPC sendTransaction failed: HTTP ${res.status} — ${text.slice(0, 256)}`);
1363
+ }
1364
+ const data = await res.json();
1365
+ if (data.error) {
1366
+ const errMsg = data.error.message || JSON.stringify(data.error);
1367
+ throw new Error(`Solana RPC error: ${errMsg}`);
1368
+ }
1369
+ return data.result; // base58 transaction signature
1370
+ }
1371
+
1372
+ /**
1373
+ * Poll getSignatureStatuses until confirmed/finalized or timeout.
1374
+ *
1375
+ * @param {string} rpcUrl Solana RPC endpoint
1376
+ * @param {string} signature Base58 transaction signature
1377
+ * @param {number} timeoutMs Max time to wait
1378
+ * @returns {Promise<object>} Final status object
1379
+ */
1380
+ async function solanaConfirmTransaction(rpcUrl, signature, timeoutMs) {
1381
+ const deadline = Date.now() + (timeoutMs || 60_000);
1382
+ const POLL_INTERVAL = 2000;
1383
+ while (Date.now() < deadline) {
1384
+ const body = {
1385
+ jsonrpc: "2.0",
1386
+ id: 1,
1387
+ method: "getSignatureStatuses",
1388
+ params: [[signature], { searchTransactionHistory: true }],
1389
+ };
1390
+ const res = await fetch(rpcUrl, {
1391
+ method: "POST",
1392
+ headers: { "Content-Type": "application/json" },
1393
+ body: JSON.stringify(body),
1394
+ signal: AbortSignal.timeout(10_000),
1395
+ });
1396
+ if (res.ok) {
1397
+ const data = await res.json();
1398
+ const statuses = data.result && data.result.value;
1399
+ if (statuses && statuses[0]) {
1400
+ const s = statuses[0];
1401
+ if (s.err) throw new Error(`Transaction failed on-chain: ${JSON.stringify(s.err)}`);
1402
+ if (s.confirmationStatus === "confirmed" || s.confirmationStatus === "finalized") {
1403
+ return s;
1404
+ }
1405
+ }
1406
+ }
1407
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
1408
+ }
1409
+ throw new Error(`Transaction not confirmed within ${timeoutMs}ms`);
1410
+ }
1411
+
1412
+ // ---- src/devnet.mjs ----
1413
+ /**
1414
+ * devnet.mjs — devnet-specific constants, token map, and RPC helpers.
1415
+ *
1416
+ * Devnet mints are NOT the same as mainnet mints. This module keeps
1417
+ * the mapping separate so --burner / --devnet never accidentally
1418
+ * touches mainnet tokens or endpoints.
1419
+ */
1420
+
1421
+ const DEVNET_RPC_DEFAULT = "https://api.devnet.solana.com";
1422
+
1423
+ /**
1424
+ * Well-known devnet token mints.
1425
+ * SOL (native) keeps the same system-program mint.
1426
+ * USDC uses the devnet faucet mint deployed by Circle / Solana Foundation.
1427
+ */
1428
+ const DEVNET_TOKEN_MAP = {
1429
+ SOL: {
1430
+ symbol: "SOL",
1431
+ mint: "So11111111111111111111111111111111111111112",
1432
+ decimals: 9,
1433
+ },
1434
+ USDC: {
1435
+ symbol: "USDC",
1436
+ mint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
1437
+ decimals: 6,
1438
+ },
1439
+ };
1440
+
1441
+ /**
1442
+ * Resolve a devnet token by symbol or mint.
1443
+ */
1444
+ function resolveDevnetToken(symbolOrMint) {
1445
+ if (!symbolOrMint || typeof symbolOrMint !== "string") return null;
1446
+ const upper = symbolOrMint.toUpperCase();
1447
+ if (DEVNET_TOKEN_MAP[upper]) return DEVNET_TOKEN_MAP[upper];
1448
+ for (const entry of Object.values(DEVNET_TOKEN_MAP)) {
1449
+ if (entry.mint === symbolOrMint) return entry;
1450
+ }
1451
+ return null;
1452
+ }
1453
+
1454
+ /**
1455
+ * Returns true if the given RPC URL is definitely a mainnet endpoint.
1456
+ * Conservative check: match common mainnet hostnames.
1457
+ */
1458
+ function isMainnetRpc(url) {
1459
+ if (!url || typeof url !== "string") return false;
1460
+ const lower = url.toLowerCase();
1461
+ return (
1462
+ lower.includes("mainnet-beta") ||
1463
+ lower.includes("mainnet.solana") ||
1464
+ lower.includes("solana-mainnet") ||
1465
+ (lower.includes("api.") && !lower.includes("devnet") && !lower.includes("testnet") && lower.includes("solana.com"))
1466
+ );
1467
+ }
1468
+
1469
+ /**
1470
+ * Resolve devnet RPC URL from flags / env.
1471
+ */
1472
+ function resolveDevnetRpc(flagRpc) {
1473
+ if (flagRpc) return flagRpc;
1474
+ return process.env.ATF_DEVNET_RPC || DEVNET_RPC_DEFAULT;
1475
+ }
1476
+
1477
+ /**
1478
+ * Request a devnet airdrop via JSON-RPC.
1479
+ *
1480
+ * @param {string} rpcUrl Devnet RPC endpoint
1481
+ * @param {string} pubkeyB58 Base58-encoded public key
1482
+ * @param {number} solAmount SOL amount to airdrop (e.g. 1 = 1 SOL)
1483
+ * @param {number} timeoutMs HTTP timeout
1484
+ * @returns {Promise<string>} Transaction signature
1485
+ */
1486
+ async function requestDevnetAirdrop(rpcUrl, pubkeyB58, solAmount, timeoutMs) {
1487
+ const lamports = Math.round(solAmount * 1_000_000_000);
1488
+ const body = {
1489
+ jsonrpc: "2.0",
1490
+ id: 1,
1491
+ method: "requestAirdrop",
1492
+ params: [pubkeyB58, lamports],
1493
+ };
1494
+ const res = await fetch(rpcUrl, {
1495
+ method: "POST",
1496
+ headers: { "Content-Type": "application/json" },
1497
+ body: JSON.stringify(body),
1498
+ signal: AbortSignal.timeout(timeoutMs || 30_000),
1499
+ });
1500
+ if (!res.ok) {
1501
+ const text = await res.text().catch(() => "");
1502
+ throw new Error(`Airdrop HTTP error: ${res.status} — ${text.slice(0, 256)}`);
1503
+ }
1504
+ const data = await res.json();
1505
+ if (data.error) {
1506
+ throw new Error(`Airdrop RPC error: ${data.error.message || JSON.stringify(data.error)}`);
1507
+ }
1508
+ return data.result; // tx signature
1509
+ }
1510
+
1511
+ /**
1512
+ * Build a SOL transfer transaction (system program) as raw bytes.
1513
+ *
1514
+ * This is a minimal Versioned Transaction (v0) with a single
1515
+ * SystemProgram.Transfer instruction — no @solana/web3.js needed.
1516
+ *
1517
+ * @param {string} fromPubkeyB58 Sender base58 pubkey
1518
+ * @param {string} toPubkeyB58 Recipient base58 pubkey
1519
+ * @param {number} lamports Amount in lamports
1520
+ * @param {string} recentBlockhash Base58 blockhash
1521
+ * @param {Uint8Array} fromPubkeyBytes 32-byte public key
1522
+ * @param {Uint8Array} toPubkeyBytes 32-byte public key
1523
+ * @returns {Uint8Array} Raw transaction bytes (unsigned, 1 sig slot)
1524
+ */
1525
+ function buildSolTransferTx(fromPubkeyBytes, toPubkeyBytes, lamports, recentBlockhashBytes) {
1526
+ // System program address (all zeros)
1527
+ const SYSTEM_PROGRAM = new Uint8Array(32);
1528
+
1529
+ // ── Build the v0 message ───────────────────────────────────────
1530
+ // Message header: [num_required_signatures, num_readonly_signed, num_readonly_unsigned]
1531
+ const header = new Uint8Array([1, 0, 1]);
1532
+
1533
+ // Account keys: [from, to, system_program]
1534
+ // from = signer + writable, to = writable, system = readonly
1535
+ const numAccounts = 3;
1536
+
1537
+ // Compile recent blockhash as 32 bytes (already decoded)
1538
+ // Instructions: single SystemProgram.Transfer
1539
+ // Transfer ix: program_id_index=2, accounts=[0,1], data=<4-byte-ix-index><8-byte-lamports-le>
1540
+ const ixProgramIndex = 2;
1541
+ const ixAccounts = new Uint8Array([0, 1]);
1542
+ // data: 4-byte little-endian transfer instruction index (2) + 8-byte little-endian lamports
1543
+ const ixData = new Uint8Array(12);
1544
+ // instruction index 2 = Transfer
1545
+ ixData[0] = 2; ixData[1] = 0; ixData[2] = 0; ixData[3] = 0;
1546
+ // lamports as 8-byte LE
1547
+ let remaining = lamports;
1548
+ for (let b = 0; b < 8; b++) {
1549
+ ixData[4 + b] = remaining & 0xff;
1550
+ remaining = Math.floor(remaining / 256);
1551
+ }
1552
+
1553
+ // Compose message body (v0 prefix + legacy-compatible structure)
1554
+ // Legacy message format (no version prefix in the message — v0 is encoded in the tx wrapper)
1555
+ // For simplicity we build a legacy transaction (not v0) since it's better supported for basic transfers.
1556
+ // Format: [header(3)][compact(numAccounts)][...accounts(numAccounts*32)][recentBlockhash(32)][compact(numIx)][ix...]
1557
+ const parts = [];
1558
+ parts.push(header);
1559
+ parts.push(new Uint8Array([numAccounts])); // compact-u16 for 3
1560
+ parts.push(fromPubkeyBytes);
1561
+ parts.push(toPubkeyBytes);
1562
+ parts.push(SYSTEM_PROGRAM);
1563
+ parts.push(recentBlockhashBytes); // 32 bytes
1564
+ parts.push(new Uint8Array([1])); // compact-u16 for 1 instruction
1565
+ // Instruction: [programIdIndex][compact(numAccounts)][...accountIndices][compact(dataLen)][...data]
1566
+ parts.push(new Uint8Array([ixProgramIndex]));
1567
+ parts.push(new Uint8Array([ixAccounts.length]));
1568
+ parts.push(ixAccounts);
1569
+ parts.push(new Uint8Array([ixData.length]));
1570
+ parts.push(ixData);
1571
+
1572
+ // Total message length
1573
+ let msgLen = 0;
1574
+ for (const p of parts) msgLen += p.length;
1575
+ const message = new Uint8Array(msgLen);
1576
+ let offset = 0;
1577
+ for (const p of parts) { message.set(p, offset); offset += p.length; }
1578
+
1579
+ // Wrap in transaction: [compact-u16 sigCount=1][64-byte zero sig][message]
1580
+ const sigCount = new Uint8Array([1]);
1581
+ const sigSlot = new Uint8Array(64); // zeroed — to be signed later
1582
+ const tx = new Uint8Array(1 + 64 + message.length);
1583
+ tx.set(sigCount, 0);
1584
+ tx.set(sigSlot, 1);
1585
+ tx.set(message, 65);
1586
+ return tx;
1587
+ }
1588
+
1589
+ /**
1590
+ * Fetch a recent blockhash from Solana RPC (devnet).
1591
+ *
1592
+ * @param {string} rpcUrl Devnet RPC
1593
+ * @param {number} timeoutMs
1594
+ * @returns {Promise<{blockhash: string, blockhashBytes: Uint8Array, lastValidBlockHeight: number}>}
1595
+ */
1596
+ async function getRecentBlockhash(rpcUrl, timeoutMs) {
1597
+ const body = {
1598
+ jsonrpc: "2.0",
1599
+ id: 1,
1600
+ method: "getLatestBlockhash",
1601
+ params: [{ commitment: "finalized" }],
1602
+ };
1603
+ const res = await fetch(rpcUrl, {
1604
+ method: "POST",
1605
+ headers: { "Content-Type": "application/json" },
1606
+ body: JSON.stringify(body),
1607
+ signal: AbortSignal.timeout(timeoutMs || 30_000),
1608
+ });
1609
+ if (!res.ok) throw new Error(`getLatestBlockhash HTTP ${res.status}`);
1610
+ const data = await res.json();
1611
+ if (data.error) throw new Error(`getLatestBlockhash RPC error: ${data.error.message}`);
1612
+ const bh = data.result.value.blockhash;
1613
+ const lbh = data.result.value.lastValidBlockHeight;
1614
+ // Decode base58 blockhash to bytes
1615
+ const bhBytes = base58Decode(bh);
1616
+ return { blockhash: bh, blockhashBytes: bhBytes, lastValidBlockHeight: lbh };
1617
+ }
1618
+
1619
+ /**
1620
+ * Decode a base58 string to Uint8Array.
1621
+ * Compact pure-JS implementation (inverse of base58Encode in solana_sign.mjs).
1622
+ */
1623
+ function base58Decode(str) {
1624
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
1625
+ const ALPHABET_MAP = {};
1626
+ for (let i = 0; i < ALPHABET.length; i++) ALPHABET_MAP[ALPHABET[i]] = i;
1627
+
1628
+ if (str.length === 0) return new Uint8Array(0);
1629
+ // Count leading '1's
1630
+ let zeros = 0;
1631
+ for (let i = 0; i < str.length && str[i] === "1"; i++) zeros++;
1632
+ // Decode
1633
+ const bytes = [];
1634
+ for (let i = zeros; i < str.length; i++) {
1635
+ const c = ALPHABET_MAP[str[i]];
1636
+ if (c === undefined) throw new Error(`Invalid base58 character: ${str[i]}`);
1637
+ let carry = c;
1638
+ for (let j = 0; j < bytes.length; j++) {
1639
+ carry += bytes[j] * 58;
1640
+ bytes[j] = carry & 0xff;
1641
+ carry >>= 8;
1642
+ }
1643
+ while (carry > 0) {
1644
+ bytes.push(carry & 0xff);
1645
+ carry >>= 8;
1646
+ }
1647
+ }
1648
+ // Add leading zero bytes
1649
+ for (let i = 0; i < zeros; i++) bytes.push(0);
1650
+ return new Uint8Array(bytes.reverse());
1651
+ }
1652
+
1653
+ /**
1654
+ * Faucet instructions for the user.
1655
+ */
1656
+ function faucetInstructions(pubkey) {
1657
+ return [
1658
+ `Your burner public key: ${pubkey}`,
1659
+ "",
1660
+ "To fund this wallet on devnet:",
1661
+ ` 1. CLI airdrop: solana airdrop 2 ${pubkey} --url devnet`,
1662
+ " 2. Web faucet: https://faucet.solana.com/",
1663
+ ` 3. ATF CLI: npx @trucore/atf swap --burner --devnet --airdrop 1`,
1664
+ "",
1665
+ "Note: public RPC airdrops are rate-limited. If airdrop fails,",
1666
+ "use the web faucet or a private devnet RPC.",
1667
+ ].join("\n");
1668
+ }
1669
+
1670
+ // ---- src/burner.mjs ----
1671
+ /**
1672
+ * burner.mjs — ephemeral keypair generation, save, and load for devnet usage.
1673
+ *
1674
+ * Uses tweetnacl (already a dependency) for ed25519 key generation.
1675
+ * Never prints secret key bytes. File permissions set to 0600 where possible.
1676
+ */
1677
+
1678
+ /**
1679
+ * Generate an ephemeral ed25519 keypair using tweetnacl.
1680
+ * Returns { secretKey: Uint8Array(64), publicKey: Uint8Array(32) }.
1681
+ */
1682
+ function generateBurnerKeypair() {
1683
+ const nacl = require("tweetnacl");
1684
+ const kp = nacl.sign.keyPair();
1685
+ return { secretKey: kp.secretKey, publicKey: kp.publicKey };
1686
+ }
1687
+
1688
+ /**
1689
+ * Save a burner keypair to disk in Solana id.json format (64-byte array).
1690
+ * Sets file permissions to 0600 (owner-only) on supported platforms.
1691
+ * Warns on Windows if unable to set permissions.
1692
+ *
1693
+ * @param {string} filePath Absolute or relative path
1694
+ * @param {Uint8Array} secretKey 64-byte ed25519 secret key
1695
+ */
1696
+ function saveBurnerKeypair(filePath, secretKey) {
1697
+ const { writeFileSync, chmodSync } = require("node:fs");
1698
+ const { resolve, dirname } = require("node:path");
1699
+ const { mkdirSync, existsSync } = require("node:fs");
1700
+ const resolved = resolve(filePath);
1701
+ const dir = dirname(resolved);
1702
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1703
+ // Solana id.json format: JSON array of 64 byte values
1704
+ const arr = Array.from(secretKey);
1705
+ writeFileSync(resolved, JSON.stringify(arr) + "\n", { mode: 0o600 });
1706
+ // Best-effort chmod for platforms that support it
1707
+ try {
1708
+ chmodSync(resolved, 0o600);
1709
+ } catch {
1710
+ // Windows or other OS that doesn't support chmod — warn but don't fail
1711
+ process.stderr.write("[burner] Warning: unable to set file permissions to 0600. Ensure the file is protected.\n");
1712
+ }
1713
+ return resolved;
1714
+ }
1715
+
1716
+ /**
1717
+ * Load a previously saved burner keypair.
1718
+ * Delegates to loadSolanaKeypair from solana_sign.mjs.
1719
+ *
1720
+ * @param {string} filePath Path to saved keypair JSON
1721
+ * @returns {{secretKey: Uint8Array, publicKey: Uint8Array}}
1722
+ */
1723
+ function loadBurnerKeypair(filePath) {
1724
+ return loadSolanaKeypair(filePath);
1725
+ }
1726
+
1727
+ // ---- src/health.mjs ----
1728
+ /**
1729
+ * health.mjs — health command implementation
1730
+ *
1731
+ * GET {base}/health — check API availability and measure latency.
1732
+ * Output: { ok:true, base_url, latency_ms, response:<healthJson> }
1733
+ */
1734
+
1735
+ async function runHealth(args) {
1736
+ const baseUrl = args.baseUrl;
1737
+ const format = args.format;
1738
+ const timeoutMs = args.timeoutMs;
1739
+
1740
+ const start = Date.now();
1741
+ let response;
1742
+ try {
1743
+ response = await getHealth(baseUrl, timeoutMs);
1744
+ } catch (err) {
1745
+ exitWithError(
1746
+ ERROR_CODES.NETWORK_ERROR,
1747
+ `Cannot reach ${baseUrl}/health — ${err.message}`,
1748
+ "Check that the API is running and the base URL is correct.",
1749
+ format
1750
+ );
1751
+ }
1752
+ const latencyMs = Date.now() - start;
1753
+
1754
+ if (!response.ok) {
1755
+ exitWithError(
1756
+ ERROR_CODES.SERVER_ERROR,
1757
+ `Health check failed with HTTP ${response.status}`,
1758
+ "The API may be down or misconfigured.",
1759
+ format,
1760
+ { status: response.status, requestId: response.requestId }
1761
+ );
1762
+ }
1763
+
1764
+ const result = {
1765
+ ok: true,
1766
+ request_id: response.requestId,
1767
+ base_url: baseUrl,
1768
+ latency_ms: latencyMs,
1769
+ response: response.data || {},
1770
+ };
1771
+
1772
+ if (format === "pretty") {
1773
+ const noColor = process.env.NO_COLOR;
1774
+ const c = noColor ? { reset: "", bold: "", green: "", dim: "" } : COLORS;
1775
+ process.stdout.write(
1776
+ `\n${c.bold}${c.green} \u2713 HEALTHY${c.reset}\n` +
1777
+ `${c.dim} Base URL:${c.reset} ${baseUrl}\n` +
1778
+ `${c.dim} Latency:${c.reset} ${latencyMs}ms\n` +
1779
+ `${c.dim} HTTP:${c.reset} ${response.status}\n\n`
1780
+ );
1781
+ } else {
1782
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1783
+ }
1784
+ process.exit(0);
1785
+ }
1786
+
1787
+ // ---- src/approve.mjs ----
1788
+ /**
1789
+ * approve.mjs — approve command implementation
1790
+ *
1791
+ * POST {base}/v1/intents/approve with Bearer token.
1792
+ * Approves a pending intent returned from a previous simulation.
1793
+ * Output: { ok:true, base_url, intent, response:<approveJson> }
1794
+ */
1795
+
1796
+ async function runApprove(args) {
1797
+ const baseUrl = args.baseUrl;
1798
+ const format = args.format;
1799
+ const token = args.token || args.apiKey;
1800
+ const intent = args.intent;
1801
+ const timeoutMs = args.timeoutMs;
1802
+
1803
+ if (!intent) {
1804
+ exitWithError(
1805
+ ERROR_CODES.USER_ERROR,
1806
+ "Missing --intent <id>",
1807
+ "Provide the intent ID returned from a simulation.",
1808
+ format
1809
+ );
1810
+ }
1811
+
1812
+ if (!token) {
1813
+ exitWithError(
1814
+ ERROR_CODES.AUTH_ERROR,
1815
+ "Missing authentication token",
1816
+ "Provide --token <bearer> or set ATF_API_KEY env var.",
1817
+ format
1818
+ );
1819
+ }
1820
+
1821
+ let response;
1822
+ try {
1823
+ response = await postApprove(baseUrl, intent, token, timeoutMs);
1824
+ } catch (err) {
1825
+ exitWithError(
1826
+ ERROR_CODES.NETWORK_ERROR,
1827
+ redactToken(`Cannot reach ${baseUrl} — ${err.message}`, token),
1828
+ "Check your network connection and base URL.",
1829
+ format
1830
+ );
1831
+ }
1832
+
1833
+ if (!response.ok) {
1834
+ const data = response.data || {};
1835
+ const errCode =
1836
+ response.status >= 500
1837
+ ? ERROR_CODES.SERVER_ERROR
1838
+ : response.status === 401 || response.status === 403
1839
+ ? ERROR_CODES.AUTH_ERROR
1840
+ : ERROR_CODES.USER_ERROR;
1841
+ const hint =
1842
+ response.status === 401
1843
+ ? "Check your Bearer token."
1844
+ : response.status === 404
1845
+ ? "Intent not found — check the intent ID."
1846
+ : response.status === 409
1847
+ ? "Intent already approved or expired."
1848
+ : null;
1849
+ const msg = redactToken(data.message || data.detail || `HTTP ${response.status}`, token);
1850
+ exitWithError(
1851
+ errCode,
1852
+ msg,
1853
+ hint,
1854
+ format,
1855
+ { status: response.status, requestId: response.requestId || data.request_id }
1856
+ );
1857
+ }
1858
+
1859
+ const data = response.data || {};
1860
+ const result = { ok: true, request_id: response.requestId, base_url: baseUrl, intent, response: data };
1861
+ if (args.verbose && args._deprecatedIntentId) {
1862
+ result.warnings = ["--intent-id is deprecated; use --intent"];
1863
+ }
1864
+
1865
+ if (format === "pretty") {
1866
+ const noColor = process.env.NO_COLOR;
1867
+ const c = noColor ? { reset: "", bold: "", green: "", dim: "" } : COLORS;
1868
+ process.stdout.write(
1869
+ `\n${c.bold}${c.green} \u2713 APPROVED${c.reset}\n` +
1870
+ `${c.dim} Intent:${c.reset} ${intent}\n` +
1871
+ (data.tx_hash
1872
+ ? `${c.dim} TX Hash:${c.reset} ${data.tx_hash}\n`
1873
+ : "") +
1874
+ `\n`
1875
+ );
1876
+ } else {
1877
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1878
+ }
1879
+ process.exit(0);
1880
+ }
1881
+
1882
+ // ---- src/version_cmd.mjs ----
1883
+ /**
1884
+ * version_cmd.mjs — rich version command
1885
+ *
1886
+ * Output: { ok:true, cli_version, node_version, platform, arch, default_base_url, build_commit, build_date }
1887
+ */
1888
+
1889
+ async function runVersion(args) {
1890
+ const format = args.format;
1891
+ const commit = BUILD_COMMIT === "__BUILD_COMMIT__" ? null : BUILD_COMMIT;
1892
+ const buildTime = BUILD_DATE === "__BUILD_DATE__" ? null : BUILD_DATE;
1893
+ const result = {
1894
+ ok: true,
1895
+ cli_version: VERSION,
1896
+ node_version: process.version,
1897
+ platform: process.platform,
1898
+ arch: process.arch,
1899
+ default_base_url: DEFAULT_BASE_URL,
1900
+ build_commit: commit,
1901
+ build_date: buildTime,
1902
+ };
1903
+
1904
+ if (format === "pretty") {
1905
+ const noColor = process.env.NO_COLOR;
1906
+ const c = noColor ? { reset: "", bold: "", dim: "" } : COLORS;
1907
+ process.stdout.write(
1908
+ `\n${c.bold} @trucore/atf v${VERSION}${c.reset}\n` +
1909
+ `${c.dim} Node:${c.reset} ${process.version}\n` +
1910
+ `${c.dim} Platform:${c.reset} ${process.platform}/${process.arch}\n` +
1911
+ `${c.dim} API:${c.reset} ${DEFAULT_BASE_URL}\n` +
1912
+ `${c.dim} Commit:${c.reset} ${commit || "dev"}\n` +
1913
+ `${c.dim} Built:${c.reset} ${buildTime || "dev"}\n\n`
1914
+ );
1915
+ } else {
1916
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1917
+ }
1918
+ process.exit(0);
1919
+ }
1920
+
1921
+ // ---- src/simulate.mjs ----
1922
+ /**
1923
+ * simulate.mjs — simulate command implementation
1924
+ *
1925
+ * Output: { ok:true, base_url, preset?, verified?:boolean, response:<simulateJson> }
1926
+ */
1927
+
1928
+ async function runSimulate(args) {
1929
+ const baseUrl = args.baseUrl;
1930
+ const verify = args.verify;
1931
+ const format = args.format;
1932
+ const quiet = args.quiet;
1933
+ const apiKey = args.apiKey;
1934
+ const timeoutMs = args.timeoutMs;
1935
+
1936
+ // Resolve body
1937
+ let body;
1938
+ let presetName = null;
1939
+ if (args.json) {
1940
+ const parsed = parseJsonBody(args.json);
1941
+ if (!parsed.ok) {
1942
+ exitWithError(
1943
+ ERROR_CODES.USER_ERROR,
1944
+ parsed.message,
1945
+ "Provide valid JSON via --json '{...}'",
1946
+ format
1947
+ );
1948
+ }
1949
+ body = parsed.body;
1950
+ } else if (args.preset) {
1951
+ presetName = args.preset;
1952
+ const check = validatePreset(presetName);
1953
+ if (!check.ok) {
1954
+ exitWithError(
1955
+ ERROR_CODES.USER_ERROR,
1956
+ check.message,
1957
+ `Available presets: ${PRESET_NAMES.join(", ")}`,
1958
+ format
1959
+ );
1960
+ }
1961
+ body = PRESETS[presetName].transaction;
1962
+ if (!quiet && format === "pretty") {
1963
+ process.stderr.write(
1964
+ `${COLORS.dim}Preset: ${presetName} — ${PRESETS[presetName].description}${COLORS.reset}\n`
1965
+ );
1966
+ }
1967
+ } else {
1968
+ exitWithError(
1969
+ ERROR_CODES.USER_ERROR,
1970
+ "Either --preset <name> or --json '<json>' is required.",
1971
+ `Available presets: ${PRESET_NAMES.join(", ")}`,
1972
+ format
1973
+ );
1974
+ }
1975
+
1976
+ // Make request — try SIMULATE_PATHS in order; stop on first non-404.
1977
+ // If a 404 carries a structured ATF error envelope ({ok:false, error:{code:...}})
1978
+ // we treat it as an authoritative app-level response and do NOT fallback.
1979
+ let response;
1980
+ const attemptedPaths = [];
1981
+ try {
1982
+ for (const path of SIMULATE_PATHS) {
1983
+ attemptedPaths.push(path);
1984
+ response = await postSimulate(baseUrl, body, apiKey, timeoutMs, path);
1985
+ if (response.status !== 404) break;
1986
+ // 404 with a structured ATF error envelope → authoritative, stop fallback
1987
+ if (isAtfErrorEnvelope(response.data)) break;
1988
+ }
1989
+ } catch (err) {
1990
+ exitWithError(
1991
+ ERROR_CODES.NETWORK_ERROR,
1992
+ `Network failure — ${err.message}`,
1993
+ "Check your network connection and base URL.",
1994
+ format
1995
+ );
1996
+ }
1997
+
1998
+ // Handle non-200
1999
+ if (!response.ok) {
2000
+ let errCode;
2001
+ let msg;
2002
+ let hint = null;
2003
+
2004
+ if (response.status === 401 || response.status === 403) {
2005
+ errCode = ERROR_CODES.AUTH_ERROR;
2006
+ const data = response.data || {};
2007
+ msg = data.message || data.detail || `HTTP ${response.status}`;
2008
+ hint = response.status === 401
2009
+ ? "Check your API key or Bearer token."
2010
+ : "Access denied. Check your permissions.";
2011
+ } else if (response.status === 404) {
2012
+ if (isAtfErrorEnvelope(response.data)) {
2013
+ // Authoritative ATF 404 — surface the server's error as-is
2014
+ errCode = response.data.error.code || ERROR_CODES.SERVER_ERROR;
2015
+ msg = response.data.error.message || `HTTP 404`;
2016
+ hint = (response.data.error.details && response.data.error.details.hint) || null;
2017
+ exitWithError(errCode, msg, hint, format, {
2018
+ status: 404,
2019
+ attempted_paths: attemptedPaths,
2020
+ baseUrl,
2021
+ requestId: response.requestId,
2022
+ });
2023
+ }
2024
+ // Endpoint miss — none of the paths exist on this server
2025
+ errCode = ERROR_CODES.SERVER_ERROR;
2026
+ msg = "SIMULATE_NOT_AVAILABLE";
2027
+ hint = "The simulate endpoint is not available on this server.";
2028
+ exitWithError(errCode, msg, hint, format, {
2029
+ status: 404,
2030
+ attempted_paths: attemptedPaths,
2031
+ baseUrl,
2032
+ requestId: response.requestId,
2033
+ });
2034
+ } else if (response.status === 422 || response.status === 400) {
2035
+ errCode = ERROR_CODES.VALIDATION_ERROR;
2036
+ const data = response.data || {};
2037
+ msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
2038
+ hint = "Check the request body format and required fields.";
2039
+ } else if (response.status === 429) {
2040
+ errCode = ERROR_CODES.SERVER_ERROR;
2041
+ const data = response.data || {};
2042
+ msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
2043
+ hint = response.retryAfter
2044
+ ? `Rate limited. Retry after ${response.retryAfter}s.`
2045
+ : "Rate limited. Try again later.";
2046
+ } else if (response.status >= 500) {
2047
+ errCode = ERROR_CODES.SERVER_ERROR;
2048
+ const data = response.data || {};
2049
+ msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
2050
+ } else {
2051
+ errCode = ERROR_CODES.USER_ERROR;
2052
+ const data = response.data || {};
2053
+ msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
2054
+ }
2055
+ exitWithError(errCode, msg, hint, format, { status: response.status, requestId: response.requestId });
2056
+ }
2057
+
2058
+ const data = response.data;
2059
+ if (!data || typeof data !== "object") {
2060
+ exitWithError(
2061
+ ERROR_CODES.SERVER_ERROR,
2062
+ "API returned non-JSON response.",
2063
+ null,
2064
+ format
2065
+ );
2066
+ }
2067
+
2068
+ // Extract fields — firewall-api returns decision/reasons (array);
2069
+ // legacy servers may return status/reason (string).
2070
+ const decision = (data.decision || data.status || "").toLowerCase();
2071
+ const reasons = Array.isArray(data.reasons) ? data.reasons : [];
2072
+ const reason = data.reason || data.deny_reason || data.message || ""; // legacy display
2073
+ const receipt_hash = data.receipt_hash || data.hash || "";
2074
+ const content_hash = data.content_hash || "";
2075
+ const policy_hash = data.policy_hash !== undefined ? data.policy_hash : null;
2076
+ const params = data.params || null;
2077
+ const displayReason = reasons.length > 0 ? reasons.join(", ") : reason;
2078
+
2079
+ // Validate receipt_hash
2080
+ if (receipt_hash && !isValidReceiptHash(receipt_hash)) {
2081
+ exitWithError(
2082
+ ERROR_CODES.VALIDATION_ERROR,
2083
+ `API returned invalid receipt_hash: "${receipt_hash}"`,
2084
+ "Expected: 64 lowercase hex characters.",
2085
+ format
2086
+ );
2087
+ }
2088
+
2089
+ // Validate content_hash if present
2090
+ if (content_hash && !isValidReceiptHash(content_hash)) {
2091
+ exitWithError(
2092
+ ERROR_CODES.VALIDATION_ERROR,
2093
+ `API returned invalid content_hash: "${content_hash}"`,
2094
+ "Expected: 64 lowercase hex characters.",
2095
+ format
2096
+ );
2097
+ }
2098
+
2099
+ // Verification (--verify only)
2100
+ let verified = undefined;
2101
+ if (verify) {
2102
+ if (content_hash) {
2103
+ const expectedHash = computeContentHash(decision, reasons, policy_hash, params);
2104
+ if (content_hash !== expectedHash) {
2105
+ exitWithError(
2106
+ ERROR_CODES.VERIFY_FAILED,
2107
+ `content_hash does not match local computation.`,
2108
+ `Expected: ${expectedHash.slice(0, 16)}... Got: ${content_hash.slice(0, 16)}... Open ${baseUrl}/verify?hash=${content_hash}`,
2109
+ format
2110
+ );
2111
+ }
2112
+ verified = true;
2113
+ } else if (receipt_hash) {
2114
+ // DEPRECATED legacy path — content_hash not returned by server.
2115
+ if (!quiet) {
2116
+ process.stderr.write(
2117
+ `${COLORS.yellow}[DEPRECATED] server did not return content_hash; falling back to legacy receipt_hash verification.${COLORS.reset}\n`
2118
+ );
2119
+ }
2120
+ const expectedHash = computeLegacyContentHash(decision, reason);
2121
+ if (receipt_hash !== expectedHash) {
2122
+ exitWithError(
2123
+ ERROR_CODES.VERIFY_FAILED,
2124
+ `receipt_hash does not match local computation (legacy).`,
2125
+ `Expected: ${expectedHash.slice(0, 16)}... Got: ${receipt_hash.slice(0, 16)}... Open ${baseUrl}/verify?hash=${receipt_hash}`,
2126
+ format
2127
+ );
2128
+ }
2129
+ verified = true;
2130
+ } else {
2131
+ verified = false;
2132
+ }
2133
+ }
2134
+
2135
+ // Build result envelope
2136
+ const result = { ok: true, request_id: response.requestId, base_url: baseUrl };
2137
+ result.preset = presetName || null;
2138
+ result.verified = verify ? (verified === true) : null;
2139
+ result.response = data;
2140
+
2141
+ // Format output (only reached if verify passed or not requested)
2142
+ if (format === "pretty") {
2143
+ const isAllowed = decision === "allowed" || decision === "approved" || decision === "approve";
2144
+ const noColor = process.env.NO_COLOR;
2145
+ const c = noColor
2146
+ ? { reset: "", bold: "", dim: "", green: "", red: "", yellow: "", cyan: "", gray: "" }
2147
+ : COLORS;
2148
+ const statusColor = isAllowed ? c.green : c.red;
2149
+ const statusIcon = isAllowed ? "\u2713" : "\u2717";
2150
+ const lines = [];
2151
+ lines.push("");
2152
+ lines.push(`${c.bold}${statusColor} ${statusIcon} ${decision.toUpperCase()}${c.reset}`);
2153
+ if (displayReason) lines.push(`${c.dim} Reason:${c.reset} ${displayReason}`);
2154
+ if (content_hash) lines.push(`${c.dim} Content hash:${c.reset} ${content_hash}`);
2155
+ if (receipt_hash) lines.push(`${c.dim} Receipt:${c.reset} ${receipt_hash}`);
2156
+ const verifyHash = content_hash || receipt_hash;
2157
+ if (verify && verifyHash) {
2158
+ lines.push("");
2159
+ lines.push(`${c.cyan}${c.bold} Trustless Verification${c.reset}`);
2160
+ lines.push(`${c.cyan} Verify: ${baseUrl}/verify?hash=${verifyHash}${c.reset}`);
2161
+ lines.push(`${c.dim} Don't trust TruCore \u2014 recompute/verify deterministically.${c.reset}`);
2162
+ }
2163
+ lines.push("");
2164
+ process.stdout.write(lines.join("\n") + "\n");
2165
+ } else {
2166
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2167
+ }
2168
+
2169
+ // If verify + receipt signing key available, print extra info
2170
+ if (verify && receipt_hash) {
2171
+ try {
2172
+ const keyInfo = await getReceiptSigningKey(baseUrl);
2173
+ if (keyInfo && keyInfo.available === true) {
2174
+ if (!quiet) {
2175
+ process.stderr.write(
2176
+ `${COLORS.dim} Receipt signing is available. ` +
2177
+ `Public key: ${keyInfo.public_key || "(see /api/receipt-signing-key)"}${COLORS.reset}\n`
2178
+ );
2179
+ process.stderr.write(
2180
+ `${COLORS.dim} You can verify the receipt signature via POST /api/receipt-signature${COLORS.reset}\n`
2181
+ );
2182
+ }
2183
+ }
2184
+ } catch {
2185
+ // Non-critical — ignore
2186
+ }
2187
+ }
2188
+
2189
+ // Exit code: allowed=0, denied=1
2190
+ if (decision === "allowed" || decision === "approve" || decision === "approved") {
2191
+ process.exit(0);
2192
+ } else {
2193
+ process.exit(1);
2194
+ }
2195
+ }
2196
+
2197
+ // ---- src/swap.mjs ----
2198
+ /**
2199
+ * swap.mjs — `swap` command implementation
2200
+ *
2201
+ * Executes a real Solana token swap gated by ATF policy evaluation:
2202
+ * 1) Build intent from CLI flags
2203
+ * 2) POST to ATF /v1/simulate for policy decision
2204
+ * 3) If denied → exit 1, no on-chain action
2205
+ * 4) If allowed → get Jupiter quote + swap tx → sign → send to Solana RPC
2206
+ *
2207
+ * Supports --dry-run (simulate + quote only; no sign/send).
2208
+ *
2209
+ * Devnet / burner mode:
2210
+ * --burner generates an ephemeral keypair (devnet only).
2211
+ * --devnet forces devnet RPC, token mints, and explorer links.
2212
+ * If Jupiter does not support devnet, falls back to a SOL transfer.
2213
+ */
2214
+
2215
+ async function runSwap(args) {
2216
+ const format = args.format;
2217
+
2218
+ // ── Burner / devnet safety rails ─────────────────────────────────
2219
+ const isBurner = args.burner || !!args.loadBurnerPath;
2220
+ const isDevnet = args.devnet || isBurner; // --burner implies --devnet
2221
+
2222
+ if (isBurner && !isDevnet) {
2223
+ // Shouldn't happen since we auto-enable, but belt-and-suspenders
2224
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "--burner requires --devnet.", null, format);
2225
+ }
2226
+
2227
+ if (isBurner && args.rpcUrl && isMainnetRpc(args.rpcUrl)) {
2228
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "--burner cannot be used with a mainnet RPC URL.", "Use --devnet or omit --rpc for devnet.", format);
2229
+ }
2230
+
2231
+ if (isBurner && !args.devnet) {
2232
+ // Auto-enable devnet and warn
2233
+ process.stderr.write(`${COLORS.yellow}[swap] --burner implies --devnet. Forcing devnet mode.${COLORS.reset}\n`);
2234
+ }
2235
+
2236
+ // ── Generate or load burner keypair ──────────────────────────────
2237
+ let burnerKeypair = null;
2238
+ let burnerPubkey = null;
2239
+ let savedBurnerTo = null;
2240
+
2241
+ if (args.loadBurnerPath) {
2242
+ try {
2243
+ burnerKeypair = loadBurnerKeypair(args.loadBurnerPath);
2244
+ } catch (err) {
2245
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, `Failed to load burner: ${err.message}`, null, format);
2246
+ }
2247
+ burnerPubkey = base58Encode(burnerKeypair.publicKey);
2248
+ } else if (args.burner) {
2249
+ burnerKeypair = generateBurnerKeypair();
2250
+ burnerPubkey = base58Encode(burnerKeypair.publicKey);
2251
+ if (args.verbose && !args.quiet) {
2252
+ process.stderr.write(`${COLORS.dim}[swap] Generated burner keypair: ${burnerPubkey}${COLORS.reset}\n`);
2253
+ }
2254
+ }
2255
+
2256
+ // Save burner if requested
2257
+ if (args.saveBurnerPath && burnerKeypair) {
2258
+ try {
2259
+ savedBurnerTo = saveBurnerKeypair(args.saveBurnerPath, burnerKeypair.secretKey);
2260
+ if (!args.quiet) {
2261
+ process.stderr.write(`${COLORS.dim}[swap] Burner keypair saved to: ${savedBurnerTo}${COLORS.reset}\n`);
2262
+ }
2263
+ } catch (err) {
2264
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, `Failed to save burner: ${err.message}`, null, format);
2265
+ }
2266
+ }
2267
+
2268
+ // ── Faucet mode: print instructions and exit ─────────────────────
2269
+ if (args.faucet) {
2270
+ if (!burnerKeypair) {
2271
+ // Generate one just for faucet info
2272
+ burnerKeypair = generateBurnerKeypair();
2273
+ burnerPubkey = base58Encode(burnerKeypair.publicKey);
2274
+ }
2275
+ const instructions = faucetInstructions(burnerPubkey);
2276
+ const result = {
2277
+ ok: true,
2278
+ network: "devnet",
2279
+ burner: { enabled: true, pubkey: burnerPubkey, saved_to: savedBurnerTo || null },
2280
+ faucet: { suggested: true, instructions },
2281
+ };
2282
+ if (format === "pretty") {
2283
+ process.stdout.write(`\n${instructions}\n\n`);
2284
+ } else {
2285
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2286
+ }
2287
+ process.exit(0);
2288
+ }
2289
+
2290
+ // ── Airdrop (devnet only) ────────────────────────────────────────
2291
+ const devnetRpc = isDevnet ? resolveDevnetRpc(args.rpcUrl) : null;
2292
+
2293
+ if (args.airdropSol) {
2294
+ if (!isDevnet) {
2295
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "--airdrop requires --devnet.", null, format);
2296
+ }
2297
+ if (!burnerKeypair && !args.keypairPath) {
2298
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "--airdrop requires --burner or --keypair.", null, format);
2299
+ }
2300
+ const airdropPubkey = burnerPubkey || (() => {
2301
+ const kp = loadSolanaKeypair(args.keypairPath);
2302
+ return base58Encode(kp.publicKey);
2303
+ })();
2304
+ if (args.verbose && !args.quiet) {
2305
+ process.stderr.write(`${COLORS.dim}[swap] Requesting airdrop of ${args.airdropSol} SOL to ${airdropPubkey}...${COLORS.reset}\n`);
2306
+ }
2307
+ try {
2308
+ const airdropSig = await requestDevnetAirdrop(devnetRpc, airdropPubkey, args.airdropSol, args.timeoutMs);
2309
+ if (!args.quiet) {
2310
+ process.stderr.write(`${COLORS.green}[swap] Airdrop requested: ${airdropSig}${COLORS.reset}\n`);
2311
+ process.stderr.write(`${COLORS.dim}[swap] Waiting a few seconds for confirmation...${COLORS.reset}\n`);
2312
+ }
2313
+ // Brief delay for airdrop to land
2314
+ await new Promise((r) => setTimeout(r, 3000));
2315
+ } catch (err) {
2316
+ process.stderr.write(`${COLORS.yellow}[swap] Airdrop failed: ${err.message}${COLORS.reset}\n`);
2317
+ process.stderr.write(`${COLORS.dim}[swap] This is common with public devnet RPCs. Try the web faucet:${COLORS.reset}\n`);
2318
+ process.stderr.write(`${COLORS.dim} https://faucet.solana.com/${COLORS.reset}\n`);
2319
+ // Don't exit — let the swap attempt continue if user has funds
2320
+ }
2321
+ }
2322
+
2323
+ // ── Validate required flags ──────────────────────────────────────
2324
+ if (!args.swapIn) {
2325
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "Missing required flag: --in <symbol|mint>", "Example: --in SOL", format);
2326
+ }
2327
+ if (!args.swapOut) {
2328
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "Missing required flag: --out <symbol|mint>", "Example: --out USDC", format);
2329
+ }
2330
+ if (!args.amountIn || isNaN(parseFloat(args.amountIn)) || parseFloat(args.amountIn) <= 0) {
2331
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "Missing or invalid flag: --amount-in <number>", "Must be a positive number, e.g. 0.001", format);
2332
+ }
2333
+ if (!burnerKeypair && !args.keypairPath) {
2334
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "Missing required flag: --keypair <path> or --burner", "Provide a Solana keypair or use --burner for devnet.", format);
2335
+ }
2336
+
2337
+ // ── Resolve tokens (devnet uses different mints) ─────────────────
2338
+ const resolveTokenFn = isDevnet ? resolveDevnetToken : resolveToken;
2339
+ const tokenMapKeys = isDevnet ? Object.keys(DEVNET_TOKEN_MAP) : Object.keys(TOKEN_MAP);
2340
+
2341
+ const inputToken = resolveTokenFn(args.swapIn);
2342
+ if (!inputToken) {
2343
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, `Unknown input token: "${args.swapIn}"`, `Supported (${isDevnet ? "devnet" : "mainnet"}): ${tokenMapKeys.join(", ")}`, format);
2344
+ }
2345
+ const outputToken = resolveTokenFn(args.swapOut);
2346
+ if (!outputToken) {
2347
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, `Unknown output token: "${args.swapOut}"`, `Supported (${isDevnet ? "devnet" : "mainnet"}): ${tokenMapKeys.join(", ")}`, format);
2348
+ }
2349
+ if (inputToken.mint === outputToken.mint) {
2350
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, "Input and output tokens must be different.", null, format);
2351
+ }
2352
+
2353
+ // ── Load keypair ─────────────────────────────────────────────────
2354
+ let keypair;
2355
+ if (burnerKeypair) {
2356
+ keypair = burnerKeypair;
2357
+ } else {
2358
+ try {
2359
+ keypair = loadSolanaKeypair(args.keypairPath);
2360
+ } catch (err) {
2361
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, `Keypair error: ${err.message}`, "Provide a valid Solana id.json file.", format);
2362
+ }
2363
+ }
2364
+ const walletPubkey = base58Encode(keypair.publicKey);
2365
+
2366
+ // ── Compute base units ───────────────────────────────────────────
2367
+ const amountBase = toBaseUnits(args.amountIn, inputToken.decimals);
2368
+ const slippageBps = args.slippageBps ?? 50;
2369
+ const rpcUrl = isDevnet ? (devnetRpc || resolveDevnetRpc(args.rpcUrl)) : (args.rpcUrl || "https://api.mainnet-beta.solana.com");
2370
+ const networkLabel = isDevnet ? "devnet" : "mainnet";
2371
+ const baseUrl = args.baseUrl;
2372
+ const timeoutMs = args.timeoutMs;
2373
+
2374
+ // Determine execution mode: devnet uses SOL transfer fallback (Jupiter may not support devnet)
2375
+ const executionMode = isDevnet ? "sol_transfer_devnet" : "jupiter_swap";
2376
+
2377
+ // ── Step 1: ATF policy gate ──────────────────────────────────────
2378
+ const intentBody = {
2379
+ chain_id: "solana",
2380
+ function_name: isDevnet ? "sol_transfer" : "swap",
2381
+ protocol: isDevnet ? "system_program" : "jupiter",
2382
+ from_address: walletPubkey,
2383
+ params: {
2384
+ input: inputToken.symbol,
2385
+ input_mint: inputToken.mint,
2386
+ output: outputToken.symbol,
2387
+ output_mint: outputToken.mint,
2388
+ amount_in: args.amountIn,
2389
+ amount_in_base: amountBase,
2390
+ slippage_bps: slippageBps,
2391
+ rpc: rpcUrl,
2392
+ network: networkLabel,
2393
+ execution_mode: executionMode,
2394
+ },
2395
+ };
2396
+
2397
+ if (args.verbose && !args.quiet) {
2398
+ process.stderr.write(`${COLORS.dim}[swap] ATF simulate → ${baseUrl}/v1/simulate${COLORS.reset}\n`);
2399
+ }
2400
+
2401
+ let atfResponse;
2402
+ try {
2403
+ atfResponse = await postSimulate(baseUrl, intentBody, args.apiKey, timeoutMs, "/v1/simulate");
2404
+ } catch (err) {
2405
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `ATF network failure — ${err.message}`, "Check your connection and --base-url.", format);
2406
+ }
2407
+
2408
+ if (!atfResponse.ok) {
2409
+ const data = atfResponse.data || {};
2410
+ const msg = data.message || data.detail || `HTTP ${atfResponse.status}`;
2411
+ exitWithError(
2412
+ atfResponse.status >= 500 ? ERROR_CODES.SERVER_ERROR : ERROR_CODES.USER_ERROR,
2413
+ `ATF simulate failed: ${msg}`,
2414
+ null,
2415
+ format,
2416
+ { status: atfResponse.status, requestId: atfResponse.requestId }
2417
+ );
2418
+ }
2419
+
2420
+ const atfData = atfResponse.data;
2421
+ const decision = (atfData.decision || atfData.status || "").toLowerCase();
2422
+ const receiptHash = atfData.receipt_hash || "";
2423
+ const contentHash = atfData.content_hash || "";
2424
+ const requestId = atfResponse.requestId;
2425
+
2426
+ // ── Verify if requested ──────────────────────────────────────────
2427
+ let verified = null;
2428
+ if (args.verify && contentHash) {
2429
+ const reasons = Array.isArray(atfData.reasons) ? atfData.reasons : [];
2430
+ const policyHash = atfData.policy_hash !== undefined ? atfData.policy_hash : null;
2431
+ const params = atfData.params || null;
2432
+ const expectedHash = computeContentHash(decision, reasons, policyHash, params);
2433
+ if (contentHash !== expectedHash) {
2434
+ exitWithError(ERROR_CODES.VERIFY_FAILED, "content_hash does not match local computation.", null, format);
2435
+ }
2436
+ verified = true;
2437
+ } else if (args.verify) {
2438
+ verified = false;
2439
+ }
2440
+
2441
+ // ── Check decision ───────────────────────────────────────────────
2442
+ const isAllowed = decision === "allowed" || decision === "approved" || decision === "approve";
2443
+ if (!isAllowed) {
2444
+ const reasons = Array.isArray(atfData.reasons) ? atfData.reasons : [];
2445
+ const result = {
2446
+ ok: false,
2447
+ request_id: requestId,
2448
+ base_url: baseUrl,
2449
+ preset: null,
2450
+ verified,
2451
+ response: atfData,
2452
+ };
2453
+ if (format === "pretty") {
2454
+ const c = args.noColor ? { reset: "", bold: "", red: "", dim: "" } : COLORS;
2455
+ process.stderr.write(`\n${c.bold}${c.red} ✗ DENIED${c.reset}\n`);
2456
+ if (reasons.length) process.stderr.write(`${c.dim} Reasons: ${reasons.join(", ")}${c.reset}\n`);
2457
+ process.stderr.write(`${c.dim} No on-chain transaction executed.${c.reset}\n\n`);
2458
+ } else {
2459
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2460
+ }
2461
+ process.exit(1);
2462
+ }
2463
+
2464
+ // ── Execution path branches on network mode ──────────────────────
2465
+
2466
+ if (executionMode === "sol_transfer_devnet") {
2467
+ // ══════════════════════════════════════════════════════════════
2468
+ // DEVNET: SOL transfer fallback (Jupiter doesn't support devnet)
2469
+ // ══════════════════════════════════════════════════════════════
2470
+
2471
+ // Dry-run: stop after ATF gate
2472
+ if (args.dryRun) {
2473
+ const result = {
2474
+ ok: true,
2475
+ request_id: requestId,
2476
+ base_url: baseUrl,
2477
+ preset: null,
2478
+ verified,
2479
+ network: networkLabel,
2480
+ execution_mode: executionMode,
2481
+ burner: isBurner ? { enabled: true, pubkey: walletPubkey, saved_to: savedBurnerTo || null } : undefined,
2482
+ response: atfData,
2483
+ dry_run: true,
2484
+ };
2485
+ if (format === "pretty") {
2486
+ const c = args.noColor ? { reset: "", bold: "", green: "", dim: "", cyan: "" } : COLORS;
2487
+ process.stdout.write(`\n${c.bold}${c.green} ✓ DRY-RUN ALLOWED (devnet)${c.reset}\n`);
2488
+ process.stdout.write(`${c.dim} Decision: ${decision}${c.reset}\n`);
2489
+ process.stdout.write(`${c.dim} Mode: SOL transfer (devnet fallback)${c.reset}\n`);
2490
+ if (contentHash) process.stdout.write(`${c.dim} Content hash: ${contentHash}${c.reset}\n`);
2491
+ process.stdout.write(`${c.cyan} No transaction signed or sent (--dry-run).${c.reset}\n\n`);
2492
+ } else {
2493
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2494
+ }
2495
+ process.exit(0);
2496
+ }
2497
+
2498
+ // Build SOL transfer: send to self (demo) — amount from --amount-in
2499
+ const lamports = Math.round(parseFloat(args.amountIn) * 1_000_000_000);
2500
+ const recipientPubkey = walletPubkey; // self-transfer for devnet demo
2501
+
2502
+ if (args.verbose && !args.quiet) {
2503
+ process.stderr.write(`${COLORS.dim}[swap] Devnet SOL transfer: ${args.amountIn} SOL (${lamports} lamports) → self${COLORS.reset}\n`);
2504
+ }
2505
+
2506
+ // Fetch recent blockhash
2507
+ let blockhashInfo;
2508
+ try {
2509
+ blockhashInfo = await getRecentBlockhash(rpcUrl, timeoutMs);
2510
+ } catch (err) {
2511
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `Failed to get blockhash — ${err.message}`, null, format);
2512
+ }
2513
+
2514
+ // Build transaction
2515
+ const recipientBytes = keypair.publicKey; // self-transfer
2516
+ const txBytes = buildSolTransferTx(
2517
+ keypair.publicKey,
2518
+ recipientBytes,
2519
+ lamports,
2520
+ blockhashInfo.blockhashBytes
2521
+ );
2522
+
2523
+ // Sign
2524
+ let signedBytes;
2525
+ try {
2526
+ signedBytes = signSolanaTransaction(new Uint8Array(txBytes), keypair.secretKey);
2527
+ } catch (err) {
2528
+ exitWithError(ERROR_CODES.SERVER_ERROR, `Signing failed: ${err.message}`, null, format);
2529
+ }
2530
+
2531
+ if (args.verbose && !args.quiet) {
2532
+ process.stderr.write(`${COLORS.dim}[swap] sendTransaction → ${rpcUrl}${COLORS.reset}\n`);
2533
+ }
2534
+
2535
+ // Send
2536
+ let signature;
2537
+ try {
2538
+ signature = await solanaSendTransaction(rpcUrl, signedBytes, timeoutMs);
2539
+ } catch (err) {
2540
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `Solana sendTransaction failed — ${err.message}`, null, format);
2541
+ }
2542
+
2543
+ // Confirm (optional)
2544
+ let confirmationStatus = null;
2545
+ if (args.confirm) {
2546
+ if (args.verbose && !args.quiet) {
2547
+ process.stderr.write(`${COLORS.dim}[swap] Confirming ${signature.slice(0, 12)}...${COLORS.reset}\n`);
2548
+ }
2549
+ try {
2550
+ const status = await solanaConfirmTransaction(rpcUrl, signature, args.confirmTimeoutMs || 60_000);
2551
+ confirmationStatus = status.confirmationStatus || "confirmed";
2552
+ } catch (err) {
2553
+ confirmationStatus = `unconfirmed: ${err.message}`;
2554
+ }
2555
+ }
2556
+
2557
+ // Output
2558
+ const explorerUrl = `https://solscan.io/tx/${signature}?cluster=devnet`;
2559
+ const result = {
2560
+ ok: true,
2561
+ request_id: requestId,
2562
+ base_url: baseUrl,
2563
+ preset: null,
2564
+ verified,
2565
+ network: networkLabel,
2566
+ execution_mode: executionMode,
2567
+ burner: isBurner ? { enabled: true, pubkey: walletPubkey, saved_to: savedBurnerTo || null } : undefined,
2568
+ response: {
2569
+ decision,
2570
+ receipt_hash: receiptHash,
2571
+ content_hash: contentHash,
2572
+ },
2573
+ execution: {
2574
+ rpc: rpcUrl,
2575
+ signature,
2576
+ explorer_url: explorerUrl,
2577
+ sol_transfer: {
2578
+ from: walletPubkey,
2579
+ to: recipientPubkey,
2580
+ lamports,
2581
+ },
2582
+ confirmation_status: confirmationStatus,
2583
+ sent_at: new Date().toISOString(),
2584
+ },
2585
+ };
2586
+
2587
+ if (format === "pretty") {
2588
+ const c = args.noColor ? { reset: "", bold: "", green: "", dim: "", cyan: "" } : COLORS;
2589
+ process.stdout.write(`\n${c.bold}${c.green} ✓ DEVNET TX EXECUTED (SOL transfer)${c.reset}\n`);
2590
+ process.stdout.write(`${c.dim} Signature: ${signature}${c.reset}\n`);
2591
+ process.stdout.write(`${c.cyan} Explorer: ${explorerUrl}${c.reset}\n`);
2592
+ if (walletPubkey) process.stdout.write(`${c.dim} Wallet: ${walletPubkey}${c.reset}\n`);
2593
+ if (contentHash) process.stdout.write(`${c.dim} Content hash: ${contentHash}${c.reset}\n`);
2594
+ if (confirmationStatus) process.stdout.write(`${c.dim} Confirmation: ${confirmationStatus}${c.reset}\n`);
2595
+ process.stdout.write("\n");
737
2596
  } else {
738
- errCode = ERROR_CODES.USER_ERROR;
739
- const data = response.data || {};
740
- msg = data.message || data.detail || data.error || `HTTP ${response.status}`;
2597
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2598
+ }
2599
+
2600
+ process.exit(0);
2601
+ }
2602
+
2603
+ // ══════════════════════════════════════════════════════════════════
2604
+ // MAINNET: Jupiter swap flow (unchanged)
2605
+ // ══════════════════════════════════════════════════════════════════
2606
+
2607
+ // ── Step 2: Jupiter quote ────────────────────────────────────────
2608
+ if (args.verbose && !args.quiet) {
2609
+ process.stderr.write(`${COLORS.dim}[swap] Jupiter quote → ${inputToken.symbol}→${outputToken.symbol} amount=${amountBase}${COLORS.reset}\n`);
2610
+ }
2611
+
2612
+ let quoteResponse;
2613
+ try {
2614
+ quoteResponse = await jupiterGetQuote({
2615
+ inputMint: inputToken.mint,
2616
+ outputMint: outputToken.mint,
2617
+ amount: amountBase,
2618
+ slippageBps,
2619
+ timeoutMs,
2620
+ });
2621
+ } catch (err) {
2622
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `Jupiter quote failed — ${err.message}`, null, format);
2623
+ }
2624
+
2625
+ // ── Dry-run: stop here ───────────────────────────────────────────
2626
+ if (args.dryRun) {
2627
+ const result = {
2628
+ ok: true,
2629
+ request_id: requestId,
2630
+ base_url: baseUrl,
2631
+ preset: null,
2632
+ verified,
2633
+ network: networkLabel,
2634
+ execution_mode: executionMode,
2635
+ response: atfData,
2636
+ dry_run: true,
2637
+ jupiter_quote: {
2638
+ inAmount: quoteResponse.inAmount,
2639
+ outAmount: quoteResponse.outAmount,
2640
+ priceImpactPct: quoteResponse.priceImpactPct,
2641
+ routePlan: quoteResponse.routePlan ? quoteResponse.routePlan.length + " hops" : "unknown",
2642
+ },
2643
+ };
2644
+ if (format === "pretty") {
2645
+ const c = args.noColor ? { reset: "", bold: "", green: "", dim: "", cyan: "" } : COLORS;
2646
+ process.stdout.write(`\n${c.bold}${c.green} ✓ DRY-RUN ALLOWED${c.reset}\n`);
2647
+ process.stdout.write(`${c.dim} Decision: ${decision}${c.reset}\n`);
2648
+ process.stdout.write(`${c.dim} Quote: ${quoteResponse.inAmount} → ${quoteResponse.outAmount}${c.reset}\n`);
2649
+ if (contentHash) process.stdout.write(`${c.dim} Content hash: ${contentHash}${c.reset}\n`);
2650
+ process.stdout.write(`${c.cyan} No transaction signed or sent (--dry-run).${c.reset}\n\n`);
2651
+ } else {
2652
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2653
+ }
2654
+ process.exit(0);
2655
+ }
2656
+
2657
+ // ── Step 3: Jupiter swap transaction ─────────────────────────────
2658
+ if (args.verbose && !args.quiet) {
2659
+ process.stderr.write(`${COLORS.dim}[swap] Jupiter swap-tx → ${walletPubkey.slice(0, 8)}...${COLORS.reset}\n`);
2660
+ }
2661
+
2662
+ let swapResult;
2663
+ try {
2664
+ swapResult = await jupiterGetSwapTx({
2665
+ quoteResponse,
2666
+ userPublicKey: walletPubkey,
2667
+ timeoutMs,
2668
+ });
2669
+ } catch (err) {
2670
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `Jupiter swap-tx failed — ${err.message}`, null, format);
2671
+ }
2672
+
2673
+ const swapTxB64 = swapResult.swapTransaction;
2674
+ if (!swapTxB64 || typeof swapTxB64 !== "string") {
2675
+ exitWithError(ERROR_CODES.SERVER_ERROR, "Jupiter returned no swapTransaction.", null, format);
2676
+ }
2677
+
2678
+ // ── Step 4: Sign and send ────────────────────────────────────────
2679
+ const txBytes = Buffer.from(swapTxB64, "base64");
2680
+ let signedBytes;
2681
+ try {
2682
+ signedBytes = signSolanaTransaction(new Uint8Array(txBytes), keypair.secretKey);
2683
+ } catch (err) {
2684
+ exitWithError(ERROR_CODES.SERVER_ERROR, `Signing failed: ${err.message}`, null, format);
2685
+ }
2686
+
2687
+ if (args.verbose && !args.quiet) {
2688
+ process.stderr.write(`${COLORS.dim}[swap] sendTransaction → ${rpcUrl}${COLORS.reset}\n`);
2689
+ }
2690
+
2691
+ let signature;
2692
+ try {
2693
+ signature = await solanaSendTransaction(rpcUrl, signedBytes, timeoutMs);
2694
+ } catch (err) {
2695
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `Solana sendTransaction failed — ${err.message}`, null, format);
2696
+ }
2697
+
2698
+ // ── Step 5 (optional): Confirm ───────────────────────────────────
2699
+ let confirmationStatus = null;
2700
+ if (args.confirm) {
2701
+ if (args.verbose && !args.quiet) {
2702
+ process.stderr.write(`${COLORS.dim}[swap] Confirming ${signature.slice(0, 12)}...${COLORS.reset}\n`);
2703
+ }
2704
+ try {
2705
+ const status = await solanaConfirmTransaction(rpcUrl, signature, args.confirmTimeoutMs || 60_000);
2706
+ confirmationStatus = status.confirmationStatus || "confirmed";
2707
+ } catch (err) {
2708
+ // Not fatal — tx was sent; just couldn't confirm in time
2709
+ confirmationStatus = `unconfirmed: ${err.message}`;
2710
+ }
2711
+ }
2712
+
2713
+ // ── Output envelope ──────────────────────────────────────────────
2714
+ const explorerUrl = `https://solscan.io/tx/${signature}`;
2715
+ const result = {
2716
+ ok: true,
2717
+ request_id: requestId,
2718
+ base_url: baseUrl,
2719
+ preset: null,
2720
+ verified,
2721
+ network: networkLabel,
2722
+ execution_mode: executionMode,
2723
+ response: {
2724
+ decision,
2725
+ receipt_hash: receiptHash,
2726
+ content_hash: contentHash,
2727
+ },
2728
+ execution: {
2729
+ rpc: rpcUrl,
2730
+ signature,
2731
+ explorer_url: explorerUrl,
2732
+ jupiter: {
2733
+ inAmount: quoteResponse.inAmount,
2734
+ outAmount: quoteResponse.outAmount,
2735
+ priceImpactPct: quoteResponse.priceImpactPct,
2736
+ },
2737
+ confirmation_status: confirmationStatus,
2738
+ sent_at: new Date().toISOString(),
2739
+ },
2740
+ };
2741
+
2742
+ if (format === "pretty") {
2743
+ const c = args.noColor ? { reset: "", bold: "", green: "", dim: "", cyan: "" } : COLORS;
2744
+ process.stdout.write(`\n${c.bold}${c.green} ✓ SWAP EXECUTED${c.reset}\n`);
2745
+ process.stdout.write(`${c.dim} Signature: ${signature}${c.reset}\n`);
2746
+ process.stdout.write(`${c.cyan} Explorer: ${explorerUrl}${c.reset}\n`);
2747
+ if (contentHash) process.stdout.write(`${c.dim} Content hash: ${contentHash}${c.reset}\n`);
2748
+ if (receiptHash) process.stdout.write(`${c.dim} Receipt hash: ${receiptHash}${c.reset}\n`);
2749
+ if (confirmationStatus) process.stdout.write(`${c.dim} Confirmation: ${confirmationStatus}${c.reset}\n`);
2750
+ process.stdout.write("\n");
2751
+ } else {
2752
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2753
+ }
2754
+
2755
+ process.exit(0);
2756
+ }
2757
+
2758
+ // ---- src/rpc_ping.mjs ----
2759
+ /**
2760
+ * rpc_ping.mjs — `atf rpc ping` command
2761
+ *
2762
+ * Calls getHealth or getVersion via Solana JSON-RPC.
2763
+ * Uses effective profile config for RPC URL resolution.
2764
+ *
2765
+ * Output: { ok, cluster, rpc_host, latency_ms, version, slot }
2766
+ */
2767
+
2768
+ async function runRpcPing(args) {
2769
+ const format = args.format;
2770
+ const profileName = args.profileFlag || null;
2771
+ const { name: pName, profile } = resolveEffectiveProfile(profileName);
2772
+ const isDevnet = args.devnet || profile.solana_cluster === "devnet";
2773
+ const { rpcUrl, rpcHost } = resolveRpcUrl(profile, pName, args.rpcUrl, isDevnet);
2774
+
2775
+ const timeoutMs = args.timeoutMs || 10000;
2776
+
2777
+ // Build JSON-RPC request for getVersion
2778
+ const rpcBody = JSON.stringify({
2779
+ jsonrpc: "2.0",
2780
+ id: 1,
2781
+ method: "getVersion",
2782
+ params: [],
2783
+ });
2784
+
2785
+ const start = Date.now();
2786
+ let rpcResult;
2787
+ try {
2788
+ const res = await fetch(rpcUrl, {
2789
+ method: "POST",
2790
+ headers: { "Content-Type": "application/json" },
2791
+ body: rpcBody,
2792
+ signal: AbortSignal.timeout(timeoutMs),
2793
+ });
2794
+ rpcResult = await res.json();
2795
+ } catch (err) {
2796
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `RPC ping failed: ${err.message}`, "Check your RPC URL and network connectivity.", format);
2797
+ }
2798
+ const latencyMs = Date.now() - start;
2799
+
2800
+ // Get slot via getSlot
2801
+ let slot = null;
2802
+ try {
2803
+ const slotRes = await fetch(rpcUrl, {
2804
+ method: "POST",
2805
+ headers: { "Content-Type": "application/json" },
2806
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "getSlot", params: [] }),
2807
+ signal: AbortSignal.timeout(timeoutMs),
2808
+ });
2809
+ const slotData = await slotRes.json();
2810
+ if (slotData && slotData.result !== undefined) slot = slotData.result;
2811
+ } catch {
2812
+ // Non-fatal — slot is optional
2813
+ }
2814
+
2815
+ const cluster = isDevnet ? "devnet" : (profile.solana_cluster || "mainnet");
2816
+ const version = rpcResult && rpcResult.result ? rpcResult.result["solana-core"] || null : null;
2817
+
2818
+ const result = {
2819
+ ok: true,
2820
+ cluster,
2821
+ rpc_host: rpcHost,
2822
+ latency_ms: latencyMs,
2823
+ version,
2824
+ slot,
2825
+ };
2826
+
2827
+ if (format === "pretty") {
2828
+ const noColor = args.noColor;
2829
+ const c = noColor ? { reset: "", bold: "", green: "", dim: "", cyan: "" } : COLORS;
2830
+ process.stdout.write(`\n${c.bold}${c.green} ✓ RPC Ping${c.reset}\n`);
2831
+ process.stdout.write(` Cluster: ${cluster}\n`);
2832
+ process.stdout.write(` Host: ${rpcHost}\n`);
2833
+ process.stdout.write(` Latency: ${latencyMs}ms\n`);
2834
+ if (version) process.stdout.write(` Version: ${version}\n`);
2835
+ if (slot !== null) process.stdout.write(` Slot: ${slot}\n`);
2836
+ process.stdout.write("\n");
2837
+ } else {
2838
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2839
+ }
2840
+ }
2841
+
2842
+ // ---- src/tx_sign.mjs ----
2843
+ /**
2844
+ * tx_sign.mjs — `atf tx sign` command (offline signing)
2845
+ *
2846
+ * Signs a base64-encoded Solana transaction without sending.
2847
+ * Useful for CI, airgapped workflows, and pipelines.
2848
+ *
2849
+ * Output: { ok, signed_tx_base64 }
2850
+ */
2851
+
2852
+ async function runTxSign(args) {
2853
+ const format = args.format;
2854
+ const profileName = args.profileFlag || null;
2855
+ const { name: pName, profile } = resolveEffectiveProfile(profileName);
2856
+
2857
+ // Resolve keypair
2858
+ const keypairPath = args.keypairPath || profile.keypair_path;
2859
+ if (!keypairPath) {
2860
+ exitWithError(ERROR_CODES.USER_ERROR, "Keypair path required. Use --keypair or set keypair_path in profile.", null, format);
2861
+ }
2862
+
2863
+ // Get transaction bytes
2864
+ let txBase64 = args.txBase64 || null;
2865
+ if (args.txFile) {
2866
+ const { readFileSync } = require("node:fs");
2867
+ try {
2868
+ txBase64 = readFileSync(args.txFile, "utf8").trim();
2869
+ } catch (err) {
2870
+ exitWithError(ERROR_CODES.USER_ERROR, `Cannot read tx file: ${err.message}`, null, format);
741
2871
  }
742
- exitWithError(errCode, msg, hint, format, { status: response.status, requestId: response.requestId });
743
2872
  }
744
2873
 
745
- const data = response.data;
746
- if (!data || typeof data !== "object") {
747
- exitWithError(
748
- ERROR_CODES.SERVER_ERROR,
749
- "API returned non-JSON response.",
750
- null,
751
- format
752
- );
2874
+ if (!txBase64) {
2875
+ exitWithError(ERROR_CODES.USER_ERROR, "Transaction required. Use --tx-base64 or --tx-file.", null, format);
753
2876
  }
754
2877
 
755
- // Extract fields — firewall-api returns decision/reasons (array);
756
- // legacy servers may return status/reason (string).
757
- const decision = (data.decision || data.status || "").toLowerCase();
758
- const reasons = Array.isArray(data.reasons) ? data.reasons : [];
759
- const reason = data.reason || data.deny_reason || data.message || ""; // legacy display
760
- const receipt_hash = data.receipt_hash || data.hash || "";
761
- const content_hash = data.content_hash || "";
762
- const policy_hash = data.policy_hash !== undefined ? data.policy_hash : null;
763
- const params = data.params || null;
764
- const displayReason = reasons.length > 0 ? reasons.join(", ") : reason;
2878
+ // Decode, sign, re-encode
2879
+ let txBytes;
2880
+ try {
2881
+ txBytes = Buffer.from(txBase64, "base64");
2882
+ } catch {
2883
+ exitWithError(ERROR_CODES.USER_ERROR, "Invalid base64 transaction.", null, format);
2884
+ }
765
2885
 
766
- // Validate receipt_hash
767
- if (receipt_hash && !isValidReceiptHash(receipt_hash)) {
768
- exitWithError(
769
- ERROR_CODES.VALIDATION_ERROR,
770
- `API returned invalid receipt_hash: "${receipt_hash}"`,
771
- "Expected: 64 lowercase hex characters.",
772
- format
773
- );
2886
+ let keypair;
2887
+ try {
2888
+ keypair = loadSolanaKeypair(keypairPath);
2889
+ } catch (err) {
2890
+ exitWithError(ERROR_CODES.USER_ERROR, `Keypair error: ${err.message}`, null, format);
774
2891
  }
775
2892
 
776
- // Validate content_hash if present
777
- if (content_hash && !isValidReceiptHash(content_hash)) {
778
- exitWithError(
779
- ERROR_CODES.VALIDATION_ERROR,
780
- `API returned invalid content_hash: "${content_hash}"`,
781
- "Expected: 64 lowercase hex characters.",
782
- format
783
- );
2893
+ let signedBytes;
2894
+ try {
2895
+ signedBytes = signTransaction(txBytes, keypair.secretKey);
2896
+ } catch (err) {
2897
+ exitWithError(ERROR_CODES.USER_ERROR, `Signing failed: ${err.message}`, null, format);
784
2898
  }
785
2899
 
786
- // Verification (--verify only)
787
- let verified = undefined;
788
- if (verify) {
789
- if (content_hash) {
790
- const expectedHash = computeContentHash(decision, reasons, policy_hash, params);
791
- if (content_hash !== expectedHash) {
792
- exitWithError(
793
- ERROR_CODES.VERIFY_FAILED,
794
- `content_hash does not match local computation.`,
795
- `Expected: ${expectedHash.slice(0, 16)}... Got: ${content_hash.slice(0, 16)}... Open ${baseUrl}/verify?hash=${content_hash}`,
796
- format
797
- );
798
- }
799
- verified = true;
800
- } else if (receipt_hash) {
801
- // DEPRECATED legacy path — content_hash not returned by server.
802
- if (!quiet) {
803
- process.stderr.write(
804
- `${COLORS.yellow}[DEPRECATED] server did not return content_hash; falling back to legacy receipt_hash verification.${COLORS.reset}\n`
805
- );
806
- }
807
- const expectedHash = computeLegacyContentHash(decision, reason);
808
- if (receipt_hash !== expectedHash) {
809
- exitWithError(
810
- ERROR_CODES.VERIFY_FAILED,
811
- `receipt_hash does not match local computation (legacy).`,
812
- `Expected: ${expectedHash.slice(0, 16)}... Got: ${receipt_hash.slice(0, 16)}... Open ${baseUrl}/verify?hash=${receipt_hash}`,
813
- format
814
- );
815
- }
816
- verified = true;
817
- } else {
818
- verified = false;
2900
+ const signedBase64 = Buffer.from(signedBytes).toString("base64");
2901
+ const result = { ok: true, signed_tx_base64: signedBase64 };
2902
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2903
+ }
2904
+
2905
+ // ---- src/tx_send.mjs ----
2906
+ /**
2907
+ * tx_send.mjs — `atf tx send` command
2908
+ *
2909
+ * Signs and sends a base64-encoded Solana transaction.
2910
+ * Prints signature + explorer URL.
2911
+ *
2912
+ * Supports --confirm to poll until finalized.
2913
+ */
2914
+
2915
+ async function runTxSend(args) {
2916
+ const format = args.format;
2917
+ const profileName = args.profileFlag || null;
2918
+ const { name: pName, profile } = resolveEffectiveProfile(profileName);
2919
+ const isDevnet = args.devnet || profile.solana_cluster === "devnet";
2920
+
2921
+ // Resolve keypair
2922
+ const keypairPath = args.keypairPath || profile.keypair_path;
2923
+ if (!keypairPath) {
2924
+ exitWithError(ERROR_CODES.USER_ERROR, "Keypair path required. Use --keypair or set keypair_path in profile.", null, format);
2925
+ }
2926
+
2927
+ // Resolve RPC
2928
+ const { rpcUrl, rpcHost } = resolveRpcUrl(profile, pName, args.rpcUrl, isDevnet);
2929
+
2930
+ // Get transaction bytes
2931
+ let txBase64 = args.txBase64 || null;
2932
+ if (args.txFile) {
2933
+ const { readFileSync } = require("node:fs");
2934
+ try {
2935
+ txBase64 = readFileSync(args.txFile, "utf8").trim();
2936
+ } catch (err) {
2937
+ exitWithError(ERROR_CODES.USER_ERROR, `Cannot read tx file: ${err.message}`, null, format);
819
2938
  }
820
2939
  }
821
2940
 
822
- // Build result envelope
823
- const result = { ok: true, base_url: baseUrl };
824
- result.preset = presetName || null;
825
- result.verified = verify ? (verified === true) : null;
826
- result.response = data;
2941
+ if (!txBase64) {
2942
+ exitWithError(ERROR_CODES.USER_ERROR, "Transaction required. Use --tx-base64 or --tx-file.", null, format);
2943
+ }
827
2944
 
828
- // Format output (only reached if verify passed or not requested)
829
- if (format === "pretty") {
830
- const isAllowed = decision === "allowed" || decision === "approved" || decision === "approve";
831
- const noColor = process.env.NO_COLOR;
832
- const c = noColor
833
- ? { reset: "", bold: "", dim: "", green: "", red: "", yellow: "", cyan: "", gray: "" }
834
- : COLORS;
835
- const statusColor = isAllowed ? c.green : c.red;
836
- const statusIcon = isAllowed ? "\u2713" : "\u2717";
837
- const lines = [];
838
- lines.push("");
839
- lines.push(`${c.bold}${statusColor} ${statusIcon} ${decision.toUpperCase()}${c.reset}`);
840
- if (displayReason) lines.push(`${c.dim} Reason:${c.reset} ${displayReason}`);
841
- if (content_hash) lines.push(`${c.dim} Content hash:${c.reset} ${content_hash}`);
842
- if (receipt_hash) lines.push(`${c.dim} Receipt:${c.reset} ${receipt_hash}`);
843
- const verifyHash = content_hash || receipt_hash;
844
- if (verify && verifyHash) {
845
- lines.push("");
846
- lines.push(`${c.cyan}${c.bold} Trustless Verification${c.reset}`);
847
- lines.push(`${c.cyan} Verify: ${baseUrl}/verify?hash=${verifyHash}${c.reset}`);
848
- lines.push(`${c.dim} Don't trust TruCore \u2014 recompute/verify deterministically.${c.reset}`);
2945
+ // Decode and sign
2946
+ let txBytes;
2947
+ try {
2948
+ txBytes = Buffer.from(txBase64, "base64");
2949
+ } catch {
2950
+ exitWithError(ERROR_CODES.USER_ERROR, "Invalid base64 transaction.", null, format);
2951
+ }
2952
+
2953
+ let keypair;
2954
+ try {
2955
+ keypair = loadSolanaKeypair(keypairPath);
2956
+ } catch (err) {
2957
+ exitWithError(ERROR_CODES.USER_ERROR, `Keypair error: ${err.message}`, null, format);
2958
+ }
2959
+
2960
+ let signedBytes;
2961
+ try {
2962
+ signedBytes = signTransaction(txBytes, keypair.secretKey);
2963
+ } catch (err) {
2964
+ exitWithError(ERROR_CODES.USER_ERROR, `Signing failed: ${err.message}`, null, format);
2965
+ }
2966
+
2967
+ const signedBase64 = Buffer.from(signedBytes).toString("base64");
2968
+
2969
+ // Send to RPC
2970
+ const skipPreflight = profile.tx ? profile.tx.skip_preflight : false;
2971
+ const maxRetries = profile.tx ? profile.tx.max_retries : 3;
2972
+ const commitment = profile.commitment || "confirmed";
2973
+
2974
+ if (args.verbose && !args.quiet) {
2975
+ process.stderr.write(`${COLORS.dim}[tx send] Sending to ${rpcHost || "RPC"}...${COLORS.reset}\n`);
2976
+ }
2977
+
2978
+ let signature;
2979
+ try {
2980
+ const sendRes = await fetch(rpcUrl, {
2981
+ method: "POST",
2982
+ headers: { "Content-Type": "application/json" },
2983
+ body: JSON.stringify({
2984
+ jsonrpc: "2.0",
2985
+ id: 1,
2986
+ method: "sendTransaction",
2987
+ params: [signedBase64, {
2988
+ encoding: "base64",
2989
+ skipPreflight,
2990
+ maxRetries,
2991
+ preflightCommitment: commitment,
2992
+ }],
2993
+ }),
2994
+ signal: AbortSignal.timeout(args.timeoutMs || 30000),
2995
+ });
2996
+ const sendData = await sendRes.json();
2997
+ if (sendData.error) {
2998
+ exitWithError(ERROR_CODES.SERVER_ERROR, `RPC sendTransaction error: ${sendData.error.message || JSON.stringify(sendData.error)}`, null, format);
849
2999
  }
850
- lines.push("");
851
- process.stdout.write(lines.join("\n") + "\n");
852
- } else {
853
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
3000
+ signature = sendData.result;
3001
+ } catch (err) {
3002
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `Failed to send transaction: ${err.message}`, null, format);
3003
+ }
3004
+
3005
+ // Build explorer URL
3006
+ const cluster = isDevnet ? "devnet" : (profile.solana_cluster || "mainnet");
3007
+ const explorerPref = profile.explorer || "solscan";
3008
+ const explorerUrl = buildExplorerUrl(signature, cluster, explorerPref);
3009
+
3010
+ // Optionally poll for confirmation
3011
+ const shouldConfirm = args.confirm || profile.confirm;
3012
+ let confirmStatus = null;
3013
+ if (shouldConfirm) {
3014
+ confirmStatus = await pollSignatureStatus(rpcUrl, signature, commitment, args.confirmTimeoutMs || 60000);
3015
+ }
3016
+
3017
+ const result = {
3018
+ ok: true,
3019
+ signature,
3020
+ explorer_url: explorerUrl,
3021
+ rpc_host: rpcHost,
3022
+ cluster,
3023
+ };
3024
+ if (confirmStatus !== null) result.confirmation = confirmStatus;
3025
+
3026
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
3027
+ }
3028
+
3029
+ /**
3030
+ * Build an explorer URL for a transaction signature.
3031
+ */
3032
+ function buildExplorerUrl(signature, cluster, explorer) {
3033
+ if (explorer === "solanafm") {
3034
+ const suffix = cluster === "devnet" ? "?cluster=devnet-solana" : "";
3035
+ return `https://solana.fm/tx/${signature}${suffix}`;
854
3036
  }
3037
+ // Default: solscan
3038
+ const suffix = cluster === "devnet" ? "?cluster=devnet" : "";
3039
+ return `https://solscan.io/tx/${signature}${suffix}`;
3040
+ }
3041
+
3042
+ /**
3043
+ * Poll getSignatureStatuses until confirmed/finalized or timeout.
3044
+ */
3045
+ async function pollSignatureStatus(rpcUrl, signature, commitment, timeoutMs) {
3046
+ const deadline = Date.now() + timeoutMs;
3047
+ const pollInterval = 2000;
855
3048
 
856
- // If verify + receipt signing key available, print extra info
857
- if (verify && receipt_hash) {
3049
+ while (Date.now() < deadline) {
858
3050
  try {
859
- const keyInfo = await getReceiptSigningKey(baseUrl);
860
- if (keyInfo && keyInfo.available === true) {
861
- if (!quiet) {
862
- process.stderr.write(
863
- `${COLORS.dim} Receipt signing is available. ` +
864
- `Public key: ${keyInfo.public_key || "(see /api/receipt-signing-key)"}${COLORS.reset}\n`
865
- );
866
- process.stderr.write(
867
- `${COLORS.dim} You can verify the receipt signature via POST /api/receipt-signature${COLORS.reset}\n`
868
- );
3051
+ const res = await fetch(rpcUrl, {
3052
+ method: "POST",
3053
+ headers: { "Content-Type": "application/json" },
3054
+ body: JSON.stringify({
3055
+ jsonrpc: "2.0",
3056
+ id: 1,
3057
+ method: "getSignatureStatuses",
3058
+ params: [[signature], { searchTransactionHistory: true }],
3059
+ }),
3060
+ signal: AbortSignal.timeout(10000),
3061
+ });
3062
+ const data = await res.json();
3063
+ if (data.result && data.result.value && data.result.value[0]) {
3064
+ const status = data.result.value[0];
3065
+ if (status.err) return { status: "failed", error: status.err };
3066
+ if (status.confirmationStatus === "finalized") return { status: "finalized" };
3067
+ if (status.confirmationStatus === commitment || status.confirmationStatus === "confirmed") {
3068
+ return { status: status.confirmationStatus };
869
3069
  }
870
3070
  }
871
3071
  } catch {
872
- // Non-critical ignore
3072
+ // Retry on network errors
873
3073
  }
3074
+ await new Promise((r) => setTimeout(r, pollInterval));
3075
+ }
3076
+ return { status: "timeout" };
3077
+ }
3078
+
3079
+ // ---- src/tx_status.mjs ----
3080
+ /**
3081
+ * tx_status.mjs — `atf tx status` command
3082
+ *
3083
+ * Polls a Solana signature status and reports confirmed/finalized.
3084
+ * Output: { ok, signature, status, slot, err }
3085
+ */
3086
+
3087
+ async function runTxStatus(args) {
3088
+ const format = args.format;
3089
+ const sig = args.sig || null;
3090
+ if (!sig) {
3091
+ exitWithError(ERROR_CODES.USER_ERROR, "Signature required. Use --sig <signature>.", null, format);
874
3092
  }
875
3093
 
876
- // Exit code: allowed=0, denied=1
877
- if (decision === "allowed" || decision === "approve" || decision === "approved") {
878
- process.exit(0);
3094
+ const profileName = args.profileFlag || null;
3095
+ const { name: pName, profile } = resolveEffectiveProfile(profileName);
3096
+ const isDevnet = args.devnet || profile.solana_cluster === "devnet";
3097
+ const { rpcUrl, rpcHost } = resolveRpcUrl(profile, pName, args.rpcUrl, isDevnet);
3098
+ const commitment = profile.commitment || "confirmed";
3099
+
3100
+ if (args.verbose && !args.quiet) {
3101
+ process.stderr.write(`${COLORS.dim}[tx status] Checking ${sig.slice(0, 12)}... on ${rpcHost || "RPC"}${COLORS.reset}\n`);
3102
+ }
3103
+
3104
+ let statusResult;
3105
+ try {
3106
+ const res = await fetch(rpcUrl, {
3107
+ method: "POST",
3108
+ headers: { "Content-Type": "application/json" },
3109
+ body: JSON.stringify({
3110
+ jsonrpc: "2.0",
3111
+ id: 1,
3112
+ method: "getSignatureStatuses",
3113
+ params: [[sig], { searchTransactionHistory: true }],
3114
+ }),
3115
+ signal: AbortSignal.timeout(args.timeoutMs || 10000),
3116
+ });
3117
+ statusResult = await res.json();
3118
+ } catch (err) {
3119
+ exitWithError(ERROR_CODES.NETWORK_ERROR, `RPC call failed: ${err.message}`, null, format);
3120
+ }
3121
+
3122
+ const value = statusResult.result && statusResult.result.value && statusResult.result.value[0];
3123
+
3124
+ if (!value) {
3125
+ const result = { ok: true, signature: sig, status: "not_found", slot: null, err: null };
3126
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
3127
+ return;
3128
+ }
3129
+
3130
+ const result = {
3131
+ ok: true,
3132
+ signature: sig,
3133
+ status: value.confirmationStatus || "unknown",
3134
+ slot: value.slot || null,
3135
+ err: value.err || null,
3136
+ };
3137
+
3138
+ if (format === "pretty") {
3139
+ const noColor = args.noColor;
3140
+ const c = noColor ? { reset: "", bold: "", green: "", red: "", dim: "" } : COLORS;
3141
+ const statusColor = value.err ? c.red : c.green;
3142
+ process.stdout.write(`\n${c.bold} Transaction Status${c.reset}\n`);
3143
+ process.stdout.write(` Signature: ${sig}\n`);
3144
+ process.stdout.write(` Status: ${statusColor}${result.status}${c.reset}\n`);
3145
+ if (result.slot) process.stdout.write(` Slot: ${result.slot}\n`);
3146
+ if (value.err) process.stdout.write(` Error: ${c.red}${JSON.stringify(value.err)}${c.reset}\n`);
3147
+ process.stdout.write("\n");
879
3148
  } else {
880
- process.exit(1);
3149
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
881
3150
  }
882
3151
  }
883
3152
 
884
- // ---- src/cli.mjs ----
3153
+ // ---- src/receipts_verify.mjs ----
885
3154
  /**
886
- * cli.mjs — argument parsing and entry point (zero deps)
3155
+ * receipts_verify.mjs — `atf receipts verify` command
887
3156
  *
888
- * VERSION, DEFAULT_BASE_URL, BUILD_COMMIT, BUILD_DATE are defined
889
- * in constants.mjs (concatenated above by build.mjs).
890
- */
891
-
892
- const HELP_TEXT = `
893
- @trucore/atf v${VERSION} — Agent Transaction Firewall CLI
894
-
895
- USAGE
896
- npx @trucore/atf@${VERSION} <command> [options]
897
-
898
- COMMANDS
899
- version Show CLI version and build metadata
900
- health Check API health and latency
901
- simulate Run a transaction simulation
902
- approve Approve a pending intent
903
-
904
- GLOBAL OPTIONS
905
- --base-url <url> API base URL (default: ${DEFAULT_BASE_URL})
906
- --timeout-ms <ms> Request timeout in ms (default: 20000)
907
- --format <fmt> Output format: json | pretty (default: json)
908
- --pretty Shorthand for --format pretty
909
- --no-color Disable ANSI colors
910
- --api-key <key> API key (also: ATF_API_KEY env var)
911
-
912
- SIMULATE OPTIONS
913
- --preset <name> Use a built-in preset: ${PRESET_NAMES.join(", ")}
914
- --json '<json>' Send raw JSON transaction body
915
- --verify Verify content_hash integrity
916
- --quiet Suppress non-essential output
917
-
918
- APPROVE OPTIONS
919
- --intent <id> Intent ID to approve (required)
920
- --token <bearer> Bearer token for auth (also: ATF_API_KEY env var)
921
-
922
- ENVIRONMENT
923
- ATF_API_KEY API key (sent as x-api-key header or Bearer token)
924
- ATF_BASE_URL Override default base URL
925
- ATF_TIMEOUT_MS Override default timeout (ms)
926
- NO_COLOR Disable ANSI colors when set
927
-
928
- EXIT CODES
929
- 0 Success (allowed, healthy, etc.)
930
- 1 User error or denied
931
- 2 Network / server error
932
-
933
- EXAMPLES
934
- npx @trucore/atf@${VERSION} version
935
- npx @trucore/atf@${VERSION} health
936
- npx @trucore/atf@${VERSION} simulate --preset swap_small --verify
937
- npx @trucore/atf@${VERSION} approve --intent abc123 --token mytoken
938
- `;
939
-
940
- function parseArgs(argv) {
941
- const defaultTimeout = (() => {
942
- const env = process.env.ATF_TIMEOUT_MS;
943
- if (env) { const n = parseInt(env, 10); if (n > 0) return n; }
944
- return 20000;
945
- })();
946
-
947
- const args = {
948
- command: null,
949
- preset: null,
950
- json: null,
951
- baseUrl: process.env.ATF_BASE_URL || DEFAULT_BASE_URL,
952
- verify: false,
953
- format: "json",
954
- quiet: false,
955
- apiKey: process.env.ATF_API_KEY || null,
956
- help: false,
957
- showVersion: false,
958
- timeoutMs: defaultTimeout,
959
- noColor: !!process.env.NO_COLOR,
960
- intent: null,
961
- token: null,
962
- verbose: false,
963
- _deprecatedIntentId: false,
3157
+ * Verifies content_hash / receipt_hash for a given JSON response.
3158
+ * Accepts --file <path> or --stdin.
3159
+ *
3160
+ * Output: { ok, content_hash_valid, receipt_hash_valid, details }
3161
+ */
3162
+
3163
+ async function runReceiptsVerify(args) {
3164
+ const format = args.format;
3165
+
3166
+ // Read response JSON
3167
+ let rawJson = null;
3168
+ if (args.receiptFile) {
3169
+ const { readFileSync } = require("node:fs");
3170
+ try {
3171
+ rawJson = readFileSync(args.receiptFile, "utf8").trim();
3172
+ } catch (err) {
3173
+ exitWithError(ERROR_CODES.USER_ERROR, `Cannot read file: ${err.message}`, null, format);
3174
+ }
3175
+ } else if (args.receiptStdin) {
3176
+ const { readFileSync } = require("node:fs");
3177
+ try {
3178
+ rawJson = readFileSync("/dev/stdin", "utf8").trim();
3179
+ } catch {
3180
+ try {
3181
+ rawJson = readFileSync(0, "utf8").trim();
3182
+ } catch {
3183
+ exitWithError(ERROR_CODES.USER_ERROR, "Failed to read from stdin.", null, format);
3184
+ }
3185
+ }
3186
+ } else {
3187
+ exitWithError(ERROR_CODES.USER_ERROR, "Provide a response to verify. Use --file <path> or --stdin.", null, format);
3188
+ }
3189
+
3190
+ if (!rawJson) {
3191
+ exitWithError(ERROR_CODES.USER_ERROR, "Empty input.", null, format);
3192
+ }
3193
+
3194
+ let data;
3195
+ try {
3196
+ data = JSON.parse(rawJson);
3197
+ } catch {
3198
+ exitWithError(ERROR_CODES.USER_ERROR, "Input is not valid JSON.", null, format);
3199
+ }
3200
+
3201
+ // Extract fields
3202
+ const decision = data.decision || null;
3203
+ const reasons = data.reasons || [];
3204
+ const reason = data.reason || "";
3205
+ const policyHash = data.policy_hash || null;
3206
+ const params = data.params || null;
3207
+ const contentHash = data.content_hash || null;
3208
+ const receiptHash = data.receipt_hash || null;
3209
+
3210
+ const results = {
3211
+ ok: true,
3212
+ content_hash_valid: null,
3213
+ receipt_hash_valid: null,
3214
+ details: {},
964
3215
  };
965
3216
 
966
- const raw = argv.slice(2);
967
- let i = 0;
968
-
969
- while (i < raw.length) {
970
- const arg = raw[i];
971
-
972
- if (arg === "--help" || arg === "-h") {
973
- args.help = true;
974
- i++;
975
- } else if (arg === "--version" || arg === "-V") {
976
- args.showVersion = true;
977
- i++;
978
- } else if (arg === "--verify") {
979
- args.verify = true;
980
- i++;
981
- } else if (arg === "--quiet" || arg === "-q") {
982
- args.quiet = true;
983
- i++;
984
- } else if (arg === "--pretty") {
985
- args.format = "pretty";
986
- i++;
987
- } else if (arg === "--no-color") {
988
- args.noColor = true;
989
- i++;
990
- } else if (arg === "--verbose") {
991
- args.verbose = true;
992
- i++;
993
- } else if (arg === "--preset") {
994
- args.preset = raw[++i] || null;
995
- i++;
996
- } else if (arg === "--json") {
997
- args.json = raw[++i] || null;
998
- i++;
999
- } else if (arg === "--base-url") {
1000
- args.baseUrl = raw[++i] || args.baseUrl;
1001
- i++;
1002
- } else if (arg === "--format") {
1003
- args.format = raw[++i] || args.format;
1004
- i++;
1005
- } else if (arg === "--api-key") {
1006
- args.apiKey = raw[++i] || args.apiKey;
1007
- i++;
1008
- } else if (arg === "--timeout-ms") {
1009
- args.timeoutMs = parseInt(raw[++i], 10) || defaultTimeout;
1010
- i++;
1011
- } else if (arg === "--intent" || arg === "--intent-id") {
1012
- if (arg === "--intent-id") args._deprecatedIntentId = true;
1013
- args.intent = raw[++i] || null;
1014
- i++;
1015
- } else if (arg === "--token") {
1016
- args.token = raw[++i] || null;
1017
- i++;
1018
- } else if (!arg.startsWith("-") && !args.command) {
1019
- args.command = arg;
1020
- i++;
1021
- } else {
1022
- // Unknown argument — structured JSON error, exit 1
1023
- const err = makeError("USER_ERROR", `Unknown argument: ${arg}`, { hint: "Run with --help for usage." });
1024
- process.stdout.write(JSON.stringify(err, null, 2) + "\n");
1025
- process.exit(1);
3217
+ // Verify content_hash (v2 format)
3218
+ if (contentHash && decision) {
3219
+ const expected = computeContentHash(decision, reasons, policyHash, params);
3220
+ results.content_hash_valid = (contentHash === expected);
3221
+ results.details.content_hash = {
3222
+ provided: contentHash,
3223
+ computed: expected,
3224
+ match: results.content_hash_valid,
3225
+ };
3226
+ }
3227
+
3228
+ // Verify receipt_hash (legacy format)
3229
+ if (receiptHash && decision) {
3230
+ const expectedLegacy = computeLegacyReceiptHash(decision, reason);
3231
+ results.receipt_hash_valid = (receiptHash === expectedLegacy);
3232
+ results.details.receipt_hash = {
3233
+ provided: receiptHash,
3234
+ computed: expectedLegacy,
3235
+ match: results.receipt_hash_valid,
3236
+ };
3237
+ }
3238
+
3239
+ if (results.content_hash_valid === null && results.receipt_hash_valid === null) {
3240
+ exitWithError(ERROR_CODES.USER_ERROR, "No content_hash or receipt_hash found in input.", "Ensure the input is an ATF simulation response.", format);
3241
+ }
3242
+
3243
+ // Overall verdict
3244
+ const anyFailed = (results.content_hash_valid === false) || (results.receipt_hash_valid === false);
3245
+ if (anyFailed) {
3246
+ results.ok = false;
3247
+ results.error = { code: "VERIFY_FAILED", message: "Hash verification failed." };
3248
+ }
3249
+
3250
+ if (format === "pretty") {
3251
+ const noColor = args.noColor;
3252
+ const c = noColor ? { reset: "", bold: "", green: "", red: "", dim: "" } : COLORS;
3253
+
3254
+ if (results.content_hash_valid !== null) {
3255
+ const icon = results.content_hash_valid ? `${c.green}✓` : `${c.red}✗`;
3256
+ process.stdout.write(`\n ${icon} content_hash: ${results.content_hash_valid ? "valid" : "INVALID"}${c.reset}\n`);
1026
3257
  }
3258
+ if (results.receipt_hash_valid !== null) {
3259
+ const icon = results.receipt_hash_valid ? `${c.green}✓` : `${c.red}✗`;
3260
+ process.stdout.write(` ${icon} receipt_hash: ${results.receipt_hash_valid ? "valid" : "INVALID"}${c.reset}\n`);
3261
+ }
3262
+ process.stdout.write("\n");
3263
+ } else {
3264
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1027
3265
  }
1028
3266
 
1029
- return args;
3267
+ if (anyFailed) process.exit(1);
3268
+ }
3269
+
3270
+ /**
3271
+ * Compute legacy receipt hash (decision + reason).
3272
+ * Must match Python server implementation.
3273
+ */
3274
+ function computeLegacyReceiptHash(decision, reason) {
3275
+ const { createHash } = require("node:crypto");
3276
+ const payload = {
3277
+ decision: (decision || "").toLowerCase(),
3278
+ reason: reason || "",
3279
+ };
3280
+ const keys = Object.keys(payload).sort();
3281
+ const sorted = {};
3282
+ for (const k of keys) sorted[k] = payload[k];
3283
+ return createHash("sha256").update(JSON.stringify(sorted), "utf8").digest("hex");
1030
3284
  }
3285
+
3286
+ // ---- src/whoami.mjs ----
3287
+ /**
3288
+ * whoami.mjs — `atf whoami` command
3289
+ *
3290
+ * Prints current profile, base URL, RPC host, cluster, and keypair path.
3291
+ * All secret values are redacted.
3292
+ */
1031
3293
 
1032
- async function main() {
1033
- const args = parseArgs(process.argv);
3294
+ async function runWhoami(args) {
3295
+ const format = args.format;
3296
+ const profileName = args.profileFlag || null;
3297
+ const { name: pName, profile } = resolveEffectiveProfile(profileName);
3298
+ const isDevnet = args.devnet || profile.solana_cluster === "devnet";
1034
3299
 
1035
- if (args.showVersion) {
1036
- process.stdout.write(`${VERSION}\n`);
1037
- process.exit(0);
3300
+ let rpcHost = null;
3301
+ try {
3302
+ const resolved = resolveRpcUrl(profile, pName, null, isDevnet);
3303
+ rpcHost = resolved.rpcHost;
3304
+ } catch {
3305
+ // RPC resolution might fail if helius key is missing; that's OK for whoami
1038
3306
  }
1039
3307
 
1040
- if (args.help || !args.command) {
1041
- process.stdout.write(HELP_TEXT);
1042
- process.exit(0);
3308
+ const result = {
3309
+ ok: true,
3310
+ profile: pName,
3311
+ atf_base_url: profile.atf_base_url || DEFAULT_BASE_URL,
3312
+ solana_cluster: profile.solana_cluster || "mainnet",
3313
+ rpc_host: rpcHost,
3314
+ commitment: profile.commitment || "confirmed",
3315
+ explorer: profile.explorer || "solscan",
3316
+ keypair_path: profile.keypair_path || null,
3317
+ default_slippage_bps: profile.default_slippage_bps || 50,
3318
+ cli_version: VERSION,
3319
+ };
3320
+
3321
+ if (format === "pretty") {
3322
+ const noColor = args.noColor;
3323
+ const c = noColor ? { reset: "", bold: "", dim: "", cyan: "", green: "" } : COLORS;
3324
+ process.stdout.write(`\n${c.bold}${c.cyan} @trucore/atf v${VERSION}${c.reset}\n`);
3325
+ process.stdout.write(` Profile: ${pName}\n`);
3326
+ process.stdout.write(` Base URL: ${result.atf_base_url}\n`);
3327
+ process.stdout.write(` Cluster: ${result.solana_cluster}\n`);
3328
+ process.stdout.write(` RPC Host: ${rpcHost || "(not configured)"}\n`);
3329
+ process.stdout.write(` Commitment: ${result.commitment}\n`);
3330
+ process.stdout.write(` Explorer: ${result.explorer}\n`);
3331
+ process.stdout.write(` Keypair: ${result.keypair_path || "(not set)"}\n`);
3332
+ process.stdout.write(` Slippage: ${result.default_slippage_bps} bps\n`);
3333
+ process.stdout.write("\n");
3334
+ } else {
3335
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
3336
+ }
3337
+ }
3338
+
3339
+ // ---- src/completion.mjs ----
3340
+ /**
3341
+ * completion.mjs — `atf completion` command
3342
+ *
3343
+ * Generates shell completion scripts for bash, zsh, fish, and powershell.
3344
+ * Zero dependencies — just prints the script to stdout.
3345
+ */
3346
+
3347
+ const COMPLETION_COMMANDS = [
3348
+ "version", "health", "simulate", "approve", "swap",
3349
+ "config", "profile", "secret", "rpc", "tx", "receipts",
3350
+ "whoami", "ls", "completion",
3351
+ ];
3352
+
3353
+ const COMPLETION_SUBCOMMANDS = {
3354
+ config: ["init", "set", "get", "list"],
3355
+ profile: ["use", "list", "create", "delete"],
3356
+ secret: ["set", "list", "unset"],
3357
+ rpc: ["ping"],
3358
+ tx: ["sign", "send", "status"],
3359
+ receipts: ["verify"],
3360
+ };
3361
+
3362
+ function generateBashCompletion() {
3363
+ const cmds = COMPLETION_COMMANDS.join(" ");
3364
+ const lines = [
3365
+ "# atf bash completion",
3366
+ "# Add to ~/.bashrc: eval \"$(atf completion bash)\"",
3367
+ "_atf_completions() {",
3368
+ " local cur prev commands",
3369
+ " COMPREPLY=()",
3370
+ " cur=\"${COMP_WORDS[COMP_CWORD]}\"",
3371
+ " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
3372
+ ` commands="${cmds}"`,
3373
+ "",
3374
+ " if [ $COMP_CWORD -eq 1 ]; then",
3375
+ " COMPREPLY=( $(compgen -W \"$commands\" -- \"$cur\") )",
3376
+ " return 0",
3377
+ " fi",
3378
+ "",
3379
+ ];
3380
+
3381
+ for (const [cmd, subs] of Object.entries(COMPLETION_SUBCOMMANDS)) {
3382
+ lines.push(` if [ "\${COMP_WORDS[1]}" = "${cmd}" ] && [ $COMP_CWORD -eq 2 ]; then`);
3383
+ lines.push(` COMPREPLY=( $(compgen -W "${subs.join(" ")}" -- "$cur") )`);
3384
+ lines.push(" return 0");
3385
+ lines.push(" fi");
3386
+ }
3387
+
3388
+ lines.push("}");
3389
+ lines.push("complete -F _atf_completions atf");
3390
+ return lines.join("\n") + "\n";
3391
+ }
3392
+
3393
+ function generateZshCompletion() {
3394
+ const cmds = COMPLETION_COMMANDS.map((c) => `'${c}:${c} command'`).join(" ");
3395
+ const lines = [
3396
+ "#compdef atf",
3397
+ "# atf zsh completion",
3398
+ "# Add to ~/.zshrc: eval \"$(atf completion zsh)\"",
3399
+ "_atf() {",
3400
+ " local -a commands",
3401
+ ` commands=(${cmds})`,
3402
+ "",
3403
+ " _arguments -C \\",
3404
+ " '1:command:->command' \\",
3405
+ " '*::arg:->args'",
3406
+ "",
3407
+ " case $state in",
3408
+ " command)",
3409
+ " _describe 'atf command' commands",
3410
+ " ;;",
3411
+ " args)",
3412
+ " case ${words[1]} in",
3413
+ ];
3414
+
3415
+ for (const [cmd, subs] of Object.entries(COMPLETION_SUBCOMMANDS)) {
3416
+ lines.push(` ${cmd})`);
3417
+ lines.push(` local -a subcmds=(${subs.map((s) => `'${s}'`).join(" ")})`);
3418
+ lines.push(` _describe '${cmd} subcommand' subcmds`);
3419
+ lines.push(" ;;");
3420
+ }
3421
+
3422
+ lines.push(" esac");
3423
+ lines.push(" ;;");
3424
+ lines.push(" esac");
3425
+ lines.push("}");
3426
+ lines.push("_atf");
3427
+ return lines.join("\n") + "\n";
3428
+ }
3429
+
3430
+ function generateFishCompletion() {
3431
+ const lines = [
3432
+ "# atf fish completion",
3433
+ "# Save to ~/.config/fish/completions/atf.fish",
3434
+ ];
3435
+
3436
+ for (const cmd of COMPLETION_COMMANDS) {
3437
+ lines.push(`complete -c atf -n '__fish_use_subcommand' -a '${cmd}' -d '${cmd}'`);
3438
+ }
3439
+
3440
+ for (const [cmd, subs] of Object.entries(COMPLETION_SUBCOMMANDS)) {
3441
+ for (const sub of subs) {
3442
+ lines.push(`complete -c atf -n '__fish_seen_subcommand_from ${cmd}' -a '${sub}' -d '${sub}'`);
3443
+ }
3444
+ }
3445
+
3446
+ return lines.join("\n") + "\n";
3447
+ }
3448
+
3449
+ function generatePowershellCompletion() {
3450
+ const cmds = COMPLETION_COMMANDS.map((c) => `'${c}'`).join(", ");
3451
+ const lines = [
3452
+ "# atf PowerShell completion",
3453
+ "# Add to $PROFILE: . (atf completion powershell)",
3454
+ "Register-ArgumentCompleter -CommandName atf -ScriptBlock {",
3455
+ " param($wordToComplete, $commandAst, $cursorPosition)",
3456
+ ` $commands = @(${cmds})`,
3457
+ " $commands | Where-Object { $_ -like \"$wordToComplete*\" } | ForEach-Object {",
3458
+ " [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)",
3459
+ " }",
3460
+ "}",
3461
+ ];
3462
+ return lines.join("\n") + "\n";
3463
+ }
3464
+
3465
+ async function runCompletion(args) {
3466
+ const shell = args.subCommand || args._subArgs[0];
3467
+ if (!shell) {
3468
+ exitWithError(ERROR_CODES.USER_ERROR, "Usage: atf completion <bash|zsh|fish|powershell>", null, args.format);
1043
3469
  }
1044
3470
 
1045
- switch (args.command) {
1046
- case "version":
1047
- await runVersion(args);
3471
+ let script;
3472
+ switch (shell.toLowerCase()) {
3473
+ case "bash":
3474
+ script = generateBashCompletion();
1048
3475
  break;
1049
- case "health":
1050
- await runHealth(args);
3476
+ case "zsh":
3477
+ script = generateZshCompletion();
1051
3478
  break;
1052
- case "simulate":
1053
- await runSimulate(args);
3479
+ case "fish":
3480
+ script = generateFishCompletion();
1054
3481
  break;
1055
- case "approve":
1056
- await runApprove(args);
3482
+ case "powershell":
3483
+ case "pwsh":
3484
+ script = generatePowershellCompletion();
1057
3485
  break;
1058
3486
  default:
1059
- exitWithError(ERROR_CODES.USER_ERROR, `Unknown command: ${args.command}`, "Run with --help for usage.", args.format);
3487
+ exitWithError(ERROR_CODES.USER_ERROR, `Unsupported shell: ${shell}. Use: bash, zsh, fish, or powershell.`, null, args.format);
1060
3488
  }
1061
3489
 
1062
- // Ensure clean exit — fetch() can leave open handles that prevent
1063
- // the event loop from draining naturally.
1064
- process.exit(0);
3490
+ process.stdout.write(script);
1065
3491
  }
1066
-
1067
- main().catch((err) => {
1068
- const msg = err && err.message ? err.message : String(err);
1069
- const safeMsg = typeof redactToken === "function" ? redactToken(msg) : msg;
1070
- process.stderr.write(`Fatal: ${safeMsg}\n`);
1071
- process.exit(2);
1072
- });
3492
+
3493
+ // ---- src/cli.mjs ----
3494
+ /**
3495
+ * cli.mjs argument parsing and entry point (zero deps)
3496
+ *
3497
+ * VERSION, DEFAULT_BASE_URL, BUILD_COMMIT, BUILD_DATE are defined
3498
+ * in constants.mjs (concatenated above by build.mjs).
3499
+ */
3500
+
3501
+ const HELP_TEXT = `
3502
+ @trucore/atf v${VERSION} — Agent Transaction Firewall CLI
3503
+
3504
+ USAGE
3505
+ npx @trucore/atf@${VERSION} <command> [options]
3506
+
3507
+ COMMANDS
3508
+ version Show CLI version and build metadata
3509
+ health Check API health and latency
3510
+ simulate Run a transaction simulation
3511
+ approve Approve a pending intent
3512
+ swap Execute a real Solana swap (Jupiter) gated by ATF policy
3513
+ whoami Show current profile, cluster, and RPC host
3514
+ config init Initialize global config (detects Solana CLI)
3515
+ config set Set a profile config key
3516
+ config get Get a profile config key
3517
+ config list Show effective merged config (secrets redacted)
3518
+ profile use Switch active profile
3519
+ profile list List all profiles
3520
+ profile create Create a new profile
3521
+ profile delete Delete a profile
3522
+ secret set Store a secret (e.g. Helius API key)
3523
+ secret list List stored secret references (no values)
3524
+ secret unset Remove a stored secret
3525
+ rpc ping Ping the configured Solana RPC endpoint
3526
+ tx sign Sign a transaction offline (no send)
3527
+ tx send Sign and send a transaction
3528
+ tx status Check transaction confirmation status
3529
+ receipts verify Verify content_hash / receipt_hash integrity
3530
+ completion Generate shell completion (bash|zsh|fish|powershell)
3531
+
3532
+ ALIASES
3533
+ ls Alias for config list
3534
+
3535
+ GLOBAL OPTIONS
3536
+ --base-url <url> API base URL (default: ${DEFAULT_BASE_URL})
3537
+ --timeout-ms <ms> Request timeout in ms (default: 20000)
3538
+ --format <fmt> Output format: json | pretty (default: json)
3539
+ --pretty Shorthand for --format pretty
3540
+ --no-color Disable ANSI colors
3541
+ --api-key <key> API key (also: ATF_API_KEY env var)
3542
+ --verbose Show detailed progress on stderr
3543
+ --profile <name> Use a specific config profile
3544
+
3545
+ SIMULATE OPTIONS
3546
+ --preset <name> Use a built-in preset: ${PRESET_NAMES.join(", ")}
3547
+ --json '<json>' Send raw JSON transaction body
3548
+ --verify Verify content_hash integrity
3549
+ --quiet Suppress non-essential output
3550
+
3551
+ APPROVE OPTIONS
3552
+ --intent <id> Intent ID to approve (required)
3553
+ --token <bearer> Bearer token for auth (also: ATF_API_KEY env var)
3554
+
3555
+ SWAP OPTIONS
3556
+ --in <symbol|mint> Input token (e.g. SOL)
3557
+ --out <symbol|mint> Output token (e.g. USDC)
3558
+ --amount-in <number> Amount of input token (human units, e.g. 0.001)
3559
+ --slippage-bps <int> Slippage tolerance in basis points (default: 50)
3560
+ --keypair <path> Path to Solana id.json keypair file
3561
+ --rpc <url> Solana RPC URL (or "helius" to auto-build from profile)
3562
+ --execute Opt-in to real on-chain execution (default: dry-run)
3563
+ --yes Skip interactive confirmation (for CI / scripts)
3564
+ --dry-run Alias: explicit dry-run mode (default without --execute)
3565
+ --confirm Poll signature status until confirmed or timeout
3566
+ --verify Verify content_hash integrity
3567
+
3568
+ DEVNET / BURNER OPTIONS (swap only)
3569
+ --burner Generate ephemeral keypair (devnet only, never printed)
3570
+ --devnet Force devnet mode (RPC, token mints, explorer links)
3571
+ --faucet Print faucet instructions + burner pubkey and exit
3572
+ --airdrop <sol> Request devnet SOL airdrop (e.g. --airdrop 1)
3573
+ --save-burner <path> Save burner keypair to disk (explicit opt-in, 0600 perms)
3574
+ --load-burner <path> Load a previously saved burner keypair (requires --devnet)
3575
+
3576
+ TX OPTIONS (tx sign / tx send)
3577
+ --tx-base64 <b64> Base64-encoded transaction
3578
+ --tx-file <path> Path to file containing base64 transaction
3579
+ --sig <signature> Transaction signature (for tx status)
3580
+
3581
+ RECEIPTS OPTIONS
3582
+ --file <path> Path to ATF response JSON (for receipts verify)
3583
+ --stdin Read ATF response from stdin (for receipts verify)
3584
+
3585
+ \u26a0 WARNING: The swap command signs and broadcasts real on-chain transactions.
3586
+ Use a burner wallet. Start with tiny amounts. Transactions are irreversible.
3587
+
3588
+ ENVIRONMENT
3589
+ ATF_API_KEY API key (sent as x-api-key header or Bearer token)
3590
+ ATF_BASE_URL Override default base URL
3591
+ ATF_TIMEOUT_MS Override default timeout (ms)
3592
+ ATF_DEVNET_RPC Override devnet RPC URL (default: https://api.devnet.solana.com)
3593
+ HELIUS_API_KEY Helius RPC API key (used when rpc_url=helius)
3594
+ NO_COLOR Disable ANSI colors when set
3595
+
3596
+ EXIT CODES
3597
+ 0 Success (allowed, healthy, etc.)
3598
+ 1 User error or denied
3599
+ 2 Network / server error
3600
+
3601
+ EXAMPLES
3602
+ npx @trucore/atf@${VERSION} version
3603
+ npx @trucore/atf@${VERSION} health
3604
+ npx @trucore/atf@${VERSION} whoami
3605
+ npx @trucore/atf@${VERSION} config init --yes
3606
+ npx @trucore/atf@${VERSION} profile create devnet
3607
+ npx @trucore/atf@${VERSION} config set rpc_url helius --profile devnet
3608
+ npx @trucore/atf@${VERSION} rpc ping --profile devnet
3609
+ npx @trucore/atf@${VERSION} simulate --preset swap_small --verify
3610
+ npx @trucore/atf@${VERSION} approve --intent abc123 --token mytoken
3611
+ npx @trucore/atf@${VERSION} swap --in SOL --out USDC --amount-in 0.001 --keypair ~/.config/solana/id.json --verify
3612
+ npx @trucore/atf@${VERSION} swap --in SOL --out USDC --amount-in 0.001 --execute --yes --confirm --verify
3613
+ npx @trucore/atf@${VERSION} swap --burner --devnet --faucet
3614
+ npx @trucore/atf@${VERSION} swap --burner --devnet --airdrop 1 --in SOL --out USDC --amount-in 0.01 --execute --yes --confirm
3615
+ npx @trucore/atf@${VERSION} tx sign --tx-base64 ... --keypair ~/.config/solana/id.json
3616
+ npx @trucore/atf@${VERSION} tx send --tx-base64 ... --keypair ~/.config/solana/id.json --confirm
3617
+ npx @trucore/atf@${VERSION} tx status --sig <signature>
3618
+ npx @trucore/atf@${VERSION} receipts verify --file response.json
3619
+ npx @trucore/atf@${VERSION} completion bash`;
3620
+
3621
+ function parseArgs(argv) {
3622
+ const defaultTimeout = (() => {
3623
+ const env = process.env.ATF_TIMEOUT_MS;
3624
+ if (env) { const n = parseInt(env, 10); if (n > 0) return n; }
3625
+ return 20000;
3626
+ })();
3627
+
3628
+ const args = {
3629
+ command: null,
3630
+ subCommand: null,
3631
+ preset: null,
3632
+ json: null,
3633
+ baseUrl: process.env.ATF_BASE_URL || DEFAULT_BASE_URL,
3634
+ verify: false,
3635
+ format: "json",
3636
+ quiet: false,
3637
+ apiKey: process.env.ATF_API_KEY || null,
3638
+ help: false,
3639
+ showVersion: false,
3640
+ timeoutMs: defaultTimeout,
3641
+ noColor: !!process.env.NO_COLOR,
3642
+ intent: null,
3643
+ token: null,
3644
+ verbose: false,
3645
+ _deprecatedIntentId: false,
3646
+ // swap-specific flags
3647
+ swapIn: null,
3648
+ swapOut: null,
3649
+ amountIn: null,
3650
+ slippageBps: 50,
3651
+ keypairPath: null,
3652
+ rpcUrl: null,
3653
+ dryRun: false,
3654
+ execute: false,
3655
+ yes: false,
3656
+ confirm: false,
3657
+ confirmTimeoutMs: 60_000,
3658
+ // burner / devnet flags
3659
+ burner: false,
3660
+ devnet: false,
3661
+ faucet: false,
3662
+ airdropSol: null,
3663
+ saveBurnerPath: null,
3664
+ loadBurnerPath: null,
3665
+ // profile / config flags
3666
+ profileFlag: null,
3667
+ _subArgs: [],
3668
+ // tx flags
3669
+ txBase64: null,
3670
+ txFile: null,
3671
+ sig: null,
3672
+ // receipts flags
3673
+ receiptFile: null,
3674
+ receiptStdin: false,
3675
+ };
3676
+
3677
+ const raw = argv.slice(2);
3678
+ let i = 0;
3679
+ // Commands that accept subcommands
3680
+ const MULTI_COMMANDS = new Set(["config", "profile", "secret", "rpc", "tx", "receipts", "completion"]);
3681
+
3682
+ while (i < raw.length) {
3683
+ const arg = raw[i];
3684
+
3685
+ if (arg === "--help" || arg === "-h") {
3686
+ args.help = true;
3687
+ i++;
3688
+ } else if (arg === "--version" || arg === "-V") {
3689
+ args.showVersion = true;
3690
+ i++;
3691
+ } else if (arg === "--verify") {
3692
+ args.verify = true;
3693
+ i++;
3694
+ } else if (arg === "--quiet" || arg === "-q") {
3695
+ args.quiet = true;
3696
+ i++;
3697
+ } else if (arg === "--pretty") {
3698
+ args.format = "pretty";
3699
+ i++;
3700
+ } else if (arg === "--no-color") {
3701
+ args.noColor = true;
3702
+ i++;
3703
+ } else if (arg === "--verbose") {
3704
+ args.verbose = true;
3705
+ i++;
3706
+ } else if (arg === "--preset") {
3707
+ args.preset = raw[++i] || null;
3708
+ i++;
3709
+ } else if (arg === "--json") {
3710
+ args.json = raw[++i] || null;
3711
+ i++;
3712
+ } else if (arg === "--base-url") {
3713
+ args.baseUrl = raw[++i] || args.baseUrl;
3714
+ i++;
3715
+ } else if (arg === "--format") {
3716
+ args.format = raw[++i] || args.format;
3717
+ i++;
3718
+ } else if (arg === "--api-key") {
3719
+ args.apiKey = raw[++i] || args.apiKey;
3720
+ i++;
3721
+ } else if (arg === "--timeout-ms") {
3722
+ args.timeoutMs = parseInt(raw[++i], 10) || defaultTimeout;
3723
+ i++;
3724
+ } else if (arg === "--intent" || arg === "--intent-id") {
3725
+ if (arg === "--intent-id") args._deprecatedIntentId = true;
3726
+ args.intent = raw[++i] || null;
3727
+ i++;
3728
+ } else if (arg === "--token") {
3729
+ args.token = raw[++i] || null;
3730
+ i++;
3731
+ } else if (arg === "--in") {
3732
+ args.swapIn = raw[++i] || null;
3733
+ i++;
3734
+ } else if (arg === "--out") {
3735
+ args.swapOut = raw[++i] || null;
3736
+ i++;
3737
+ } else if (arg === "--amount-in") {
3738
+ args.amountIn = raw[++i] || null;
3739
+ i++;
3740
+ } else if (arg === "--slippage-bps") {
3741
+ args.slippageBps = parseInt(raw[++i], 10) || 50;
3742
+ i++;
3743
+ } else if (arg === "--keypair") {
3744
+ args.keypairPath = raw[++i] || null;
3745
+ i++;
3746
+ } else if (arg === "--rpc") {
3747
+ args.rpcUrl = raw[++i] || null;
3748
+ i++;
3749
+ } else if (arg === "--profile") {
3750
+ args.profileFlag = raw[++i] || null;
3751
+ i++;
3752
+ } else if (arg === "--dry-run") {
3753
+ args.dryRun = true;
3754
+ i++;
3755
+ } else if (arg === "--execute") {
3756
+ args.execute = true;
3757
+ i++;
3758
+ } else if (arg === "--yes" || arg === "-y") {
3759
+ args.yes = true;
3760
+ i++;
3761
+ } else if (arg === "--confirm") {
3762
+ args.confirm = true;
3763
+ i++;
3764
+ } else if (arg === "--burner") {
3765
+ args.burner = true;
3766
+ i++;
3767
+ } else if (arg === "--devnet") {
3768
+ args.devnet = true;
3769
+ i++;
3770
+ } else if (arg === "--faucet") {
3771
+ args.faucet = true;
3772
+ i++;
3773
+ } else if (arg === "--airdrop") {
3774
+ args.airdropSol = parseFloat(raw[++i]) || null;
3775
+ i++;
3776
+ } else if (arg === "--save-burner") {
3777
+ args.saveBurnerPath = raw[++i] || null;
3778
+ i++;
3779
+ } else if (arg === "--load-burner") {
3780
+ args.loadBurnerPath = raw[++i] || null;
3781
+ i++;
3782
+ } else if (arg === "--tx-base64") {
3783
+ args.txBase64 = raw[++i] || null;
3784
+ i++;
3785
+ } else if (arg === "--tx-file") {
3786
+ args.txFile = raw[++i] || null;
3787
+ i++;
3788
+ } else if (arg === "--sig") {
3789
+ args.sig = raw[++i] || null;
3790
+ i++;
3791
+ } else if (arg === "--file") {
3792
+ args.receiptFile = raw[++i] || null;
3793
+ i++;
3794
+ } else if (arg === "--stdin") {
3795
+ args.receiptStdin = true;
3796
+ i++;
3797
+ } else if (!arg.startsWith("-") && !args.command) {
3798
+ args.command = arg;
3799
+ i++;
3800
+ // Collect subcommand + sub-args for multi-commands
3801
+ if (MULTI_COMMANDS.has(arg)) {
3802
+ while (i < raw.length && !raw[i].startsWith("-")) {
3803
+ if (!args.subCommand) {
3804
+ args.subCommand = raw[i];
3805
+ } else {
3806
+ args._subArgs.push(raw[i]);
3807
+ }
3808
+ i++;
3809
+ }
3810
+ }
3811
+ } else if (!arg.startsWith("-") && args.command && !args.subCommand && MULTI_COMMANDS.has(args.command)) {
3812
+ args.subCommand = arg;
3813
+ i++;
3814
+ } else if (!arg.startsWith("-") && args.command && args.subCommand) {
3815
+ args._subArgs.push(arg);
3816
+ i++;
3817
+ } else {
3818
+ // Unknown argument — structured JSON error, exit 1
3819
+ const err = makeError("USER_ERROR", `Unknown argument: ${arg}`, { hint: "Run with --help for usage." });
3820
+ process.stdout.write(JSON.stringify(err, null, 2) + "\n");
3821
+ process.exit(1);
3822
+ }
3823
+ }
3824
+
3825
+ return args;
3826
+ }
3827
+
3828
+ async function main() {
3829
+ const args = parseArgs(process.argv);
3830
+
3831
+ if (args.showVersion) {
3832
+ process.stdout.write(`${VERSION}\n`);
3833
+ process.exit(0);
3834
+ }
3835
+
3836
+ if (args.help || !args.command) {
3837
+ process.stdout.write(HELP_TEXT);
3838
+ process.exit(0);
3839
+ }
3840
+
3841
+ switch (args.command) {
3842
+ case "version":
3843
+ await runVersion(args);
3844
+ break;
3845
+ case "health":
3846
+ await runHealth(args);
3847
+ break;
3848
+ case "simulate":
3849
+ await runSimulate(args);
3850
+ break;
3851
+ case "approve":
3852
+ await runApprove(args);
3853
+ break;
3854
+ case "swap":
3855
+ await runSwap(args);
3856
+ break;
3857
+ case "whoami":
3858
+ await runWhoami(args);
3859
+ break;
3860
+ case "ls":
3861
+ // Alias: ls → config list
3862
+ await runConfigList(args);
3863
+ break;
3864
+ case "config":
3865
+ switch (args.subCommand) {
3866
+ case "init":
3867
+ await runConfigInit(args);
3868
+ break;
3869
+ case "set":
3870
+ await runConfigSet(args);
3871
+ break;
3872
+ case "get":
3873
+ await runConfigGet(args);
3874
+ break;
3875
+ case "list":
3876
+ await runConfigList(args);
3877
+ break;
3878
+ default:
3879
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown config subcommand: ${args.subCommand || "(none)"}`, "Available: init, set, get, list", args.format);
3880
+ }
3881
+ break;
3882
+ case "profile":
3883
+ switch (args.subCommand) {
3884
+ case "use":
3885
+ await runProfileUse(args);
3886
+ break;
3887
+ case "list":
3888
+ await runProfileList(args);
3889
+ break;
3890
+ case "create":
3891
+ await runProfileCreate(args);
3892
+ break;
3893
+ case "delete":
3894
+ await runProfileDelete(args);
3895
+ break;
3896
+ default:
3897
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown profile subcommand: ${args.subCommand || "(none)"}`, "Available: use, list, create, delete", args.format);
3898
+ }
3899
+ break;
3900
+ case "secret":
3901
+ switch (args.subCommand) {
3902
+ case "set":
3903
+ await runSecretSet(args);
3904
+ break;
3905
+ case "list":
3906
+ await runSecretList(args);
3907
+ break;
3908
+ case "unset":
3909
+ await runSecretUnset(args);
3910
+ break;
3911
+ default:
3912
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown secret subcommand: ${args.subCommand || "(none)"}`, "Available: set, list, unset", args.format);
3913
+ }
3914
+ break;
3915
+ case "rpc":
3916
+ switch (args.subCommand) {
3917
+ case "ping":
3918
+ await runRpcPing(args);
3919
+ break;
3920
+ default:
3921
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown rpc subcommand: ${args.subCommand || "(none)"}`, "Available: ping", args.format);
3922
+ }
3923
+ break;
3924
+ case "tx":
3925
+ switch (args.subCommand) {
3926
+ case "sign":
3927
+ await runTxSign(args);
3928
+ break;
3929
+ case "send":
3930
+ await runTxSend(args);
3931
+ break;
3932
+ case "status":
3933
+ await runTxStatus(args);
3934
+ break;
3935
+ default:
3936
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown tx subcommand: ${args.subCommand || "(none)"}`, "Available: sign, send, status", args.format);
3937
+ }
3938
+ break;
3939
+ case "receipts":
3940
+ switch (args.subCommand) {
3941
+ case "verify":
3942
+ await runReceiptsVerify(args);
3943
+ break;
3944
+ default:
3945
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown receipts subcommand: ${args.subCommand || "(none)"}`, "Available: verify", args.format);
3946
+ }
3947
+ break;
3948
+ case "completion":
3949
+ await runCompletion(args);
3950
+ break;
3951
+ default:
3952
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown command: ${args.command}`, "Run with --help for usage.", args.format);
3953
+ }
3954
+
3955
+ // Ensure clean exit — fetch() can leave open handles that prevent
3956
+ // the event loop from draining naturally.
3957
+ process.exit(0);
3958
+ }
3959
+
3960
+ main().catch((err) => {
3961
+ const msg = err && err.message ? err.message : String(err);
3962
+ const safeMsg = typeof redactToken === "function" ? redactToken(msg) : msg;
3963
+ process.stderr.write(`Fatal: ${safeMsg}\n`);
3964
+ process.exit(2);
3965
+ });
1073
3966