@trucore/atf 1.4.0 → 1.4.1

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 (2) hide show
  1. package/dist/index.js +3558 -482
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- // @trucore/atf v1.4.0 — Agent Transaction Firewall CLI
5
- // Built: 2026-03-05T12:43:14.514Z
6
- // Commit: 372fe09
4
+ // @trucore/atf v1.4.1 — Agent Transaction Firewall CLI
5
+ // Built: 2026-03-06T03:36:26.958Z
6
+ // Commit: dbbc171
7
7
 
8
8
  // ---- src/constants.mjs ----
9
9
  /**
@@ -13,10 +13,10 @@
13
13
  * by build.mjs during the bundling step.
14
14
  */
15
15
 
16
- const VERSION = "1.4.0";
16
+ const VERSION = "1.4.1";
17
17
  const DEFAULT_BASE_URL = "https://api.trucore.xyz";
18
- const BUILD_COMMIT = "372fe09";
19
- const BUILD_DATE = "2026-03-05T12:43:14.514Z";
18
+ const BUILD_COMMIT = "dbbc171";
19
+ const BUILD_DATE = "2026-03-06T03:36:26.958Z";
20
20
  const SIMULATE_PATHS = ["/api/simulate", "/v1/simulate"];
21
21
 
22
22
  // ---- src/redact.mjs ----
@@ -415,6 +415,14 @@ const ERROR_CODES = {
415
415
  VALIDATION_ERROR: "VALIDATION_ERROR",
416
416
  AUTH_ERROR: "AUTH_ERROR",
417
417
  VERIFY_FAILED: "VERIFY_FAILED",
418
+ // Jupiter / OpenClaw hardening error codes
419
+ JUPITER_CONFIG_INVALID: "JUPITER_CONFIG_INVALID",
420
+ JUPITER_URL_MALFORMED: "JUPITER_URL_MALFORMED",
421
+ JUPITER_PROFILE_CONFLICT: "JUPITER_PROFILE_CONFLICT",
422
+ JUPITER_API_KEY_MISSING: "JUPITER_API_KEY_MISSING",
423
+ JUPITER_API_KEY_EMPTY: "JUPITER_API_KEY_EMPTY",
424
+ OPENCLAW_PLUGIN_METADATA_INVALID: "OPENCLAW_PLUGIN_METADATA_INVALID",
425
+ OPENCLAW_PLUGIN_RUNTIME_UNAVAILABLE: "OPENCLAW_PLUGIN_RUNTIME_UNAVAILABLE",
418
426
  };
419
427
 
420
428
  function makeError(code, message, details) {
@@ -1248,24 +1256,376 @@ function resolveRpcUrl(profile, profileName, cliRpcOverride, isDevnet) {
1248
1256
 
1249
1257
  // ---- src/jupiter.mjs ----
1250
1258
  /**
1251
- * jupiter.mjs — Jupiter DEX HTTP helpers (v6 API)
1259
+ * jupiter.mjs — Centralized Jupiter DEX endpoint resolver + HTTP helpers (v6 API)
1260
+ *
1261
+ * ╔══════════════════════════════════════════════════════════════════════╗
1262
+ * ║ SINGLE SOURCE OF TRUTH for all Jupiter endpoint configuration. ║
1263
+ * ║ No other file may construct Jupiter URLs directly. ║
1264
+ * ║ All Jupiter callers MUST consume resolveJupiterConfig(). ║
1265
+ * ╚══════════════════════════════════════════════════════════════════════╝
1252
1266
  *
1253
1267
  * Uses built-in fetch (Node 18+). Zero extra dependencies.
1254
1268
  * Only called from swap.mjs when ATF decision == allowed.
1269
+ *
1270
+ * Endpoint resolution order:
1271
+ * 1) Explicit env override: ATF_JUPITER_BASE
1272
+ * 2) Profile config: jupiter_quote_url / jupiter_swap_url
1273
+ * 3) Preprod only when ATF_JUPITER_PREPROD=1 (never silent)
1274
+ * 4) Canonical production default: https://quote-api.jup.ag/v6
1275
+ *
1276
+ * API key handling:
1277
+ * - ATF_JUPITER_API_KEY env → x-api-key header
1278
+ * - Never logged; redacted in debug output
1279
+ *
1280
+ * Fail-closed: invalid config throws JupiterConfigError (exit nonzero).
1281
+ * Retry-safe: all read-only; no stateful mutation on resolve/validate.
1282
+ */
1283
+
1284
+ // ── Canonical defaults ────────────────────────────────────────────
1285
+ const JUPITER_PROD_BASE = "https://quote-api.jup.ag/v6";
1286
+ const JUPITER_PREPROD_BASE = "https://preprod-quote-api.jup.ag/v6";
1287
+ const JUPITER_DEFAULT_TIMEOUT_MS = 20_000;
1288
+
1289
+ // ── Error classes (machine-readable, fail-closed) ─────────────────
1290
+
1291
+ /** @type {Record<string, string>} */
1292
+ const JUPITER_ERROR_CODES = {
1293
+ CONFIG_INVALID: "JUPITER_CONFIG_INVALID",
1294
+ URL_MALFORMED: "JUPITER_URL_MALFORMED",
1295
+ PROFILE_CONFLICT: "JUPITER_PROFILE_CONFLICT",
1296
+ API_KEY_MISSING: "JUPITER_API_KEY_MISSING",
1297
+ API_KEY_EMPTY: "JUPITER_API_KEY_EMPTY",
1298
+ };
1299
+
1300
+ /**
1301
+ * Structured error for fail-closed Jupiter config validation.
1302
+ * Machine-readable code + human message; no secret leakage.
1303
+ */
1304
+ class JupiterConfigError extends Error {
1305
+ /**
1306
+ * @param {string} code One of JUPITER_ERROR_CODES values
1307
+ * @param {string} message Human-readable actionable message
1308
+ */
1309
+ constructor(code, message) {
1310
+ super(message);
1311
+ this.name = "JupiterConfigError";
1312
+ /** @type {string} machine-readable error code */
1313
+ this.code = code;
1314
+ }
1315
+ }
1316
+
1317
+ // ── URL validation helper ─────────────────────────────────────────
1318
+
1319
+ /**
1320
+ * Validate a URL string. Returns true only for http: or https: URLs.
1321
+ * @param {string} urlStr
1322
+ * @returns {boolean}
1323
+ */
1324
+ function isValidJupiterUrl(urlStr) {
1325
+ if (!urlStr || typeof urlStr !== "string") return false;
1326
+ try {
1327
+ const u = new URL(urlStr);
1328
+ return u.protocol === "http:" || u.protocol === "https:";
1329
+ } catch {
1330
+ return false;
1331
+ }
1332
+ }
1333
+
1334
+ /**
1335
+ * Extract the hostname from a URL for safe debug logging.
1336
+ * @param {string} urlStr
1337
+ * @returns {string}
1338
+ */
1339
+ function extractJupiterHost(urlStr) {
1340
+ try {
1341
+ return new URL(urlStr).hostname;
1342
+ } catch {
1343
+ return "(invalid)";
1344
+ }
1345
+ }
1346
+
1347
+ // ── Core resolver ─────────────────────────────────────────────────
1348
+
1349
+ /**
1350
+ * Resolve Jupiter quote base URL.
1351
+ * @param {{ jupiter_quote_url?: string|null }} [profileConfig]
1352
+ * @returns {string}
1353
+ */
1354
+ function resolveJupiterQuoteBase(profileConfig) {
1355
+ // 1) Explicit env override
1356
+ if (process.env.ATF_JUPITER_BASE) {
1357
+ return process.env.ATF_JUPITER_BASE;
1358
+ }
1359
+ // 2) Profile config
1360
+ if (profileConfig?.jupiter_quote_url) {
1361
+ return profileConfig.jupiter_quote_url;
1362
+ }
1363
+ // 3) Preprod opt-in (explicit only)
1364
+ if (process.env.ATF_JUPITER_PREPROD === "1") {
1365
+ return JUPITER_PREPROD_BASE;
1366
+ }
1367
+ // 4) Canonical production default
1368
+ return JUPITER_PROD_BASE;
1369
+ }
1370
+
1371
+ /**
1372
+ * Resolve Jupiter swap base URL.
1373
+ * @param {{ jupiter_swap_url?: string|null }} [profileConfig]
1374
+ * @returns {string}
1375
+ */
1376
+ function resolveJupiterSwapBase(profileConfig) {
1377
+ // 1) Explicit env override
1378
+ if (process.env.ATF_JUPITER_BASE) {
1379
+ return process.env.ATF_JUPITER_BASE;
1380
+ }
1381
+ // 2) Profile config
1382
+ if (profileConfig?.jupiter_swap_url) {
1383
+ return profileConfig.jupiter_swap_url;
1384
+ }
1385
+ // 3) Preprod opt-in (explicit only)
1386
+ if (process.env.ATF_JUPITER_PREPROD === "1") {
1387
+ return JUPITER_PREPROD_BASE;
1388
+ }
1389
+ // 4) Canonical production default
1390
+ return JUPITER_PROD_BASE;
1391
+ }
1392
+
1393
+ /**
1394
+ * Resolve the Jupiter API key for x-api-key header.
1395
+ * Returns null if not configured (public tier).
1396
+ * @returns {string|null}
1397
+ */
1398
+ function resolveJupiterApiKey() {
1399
+ const key = process.env.ATF_JUPITER_API_KEY || null;
1400
+ // Treat whitespace-only as absent
1401
+ return key && key.trim().length > 0 ? key.trim() : null;
1402
+ }
1403
+
1404
+ /**
1405
+ * Build common headers for Jupiter requests.
1406
+ * Injects x-api-key when available; never logs key value.
1407
+ * @returns {Record<string, string>}
1408
+ */
1409
+ function buildJupiterHeaders() {
1410
+ const headers = {};
1411
+ const apiKey = resolveJupiterApiKey();
1412
+ if (apiKey) {
1413
+ headers["x-api-key"] = apiKey;
1414
+ }
1415
+ return headers;
1416
+ }
1417
+
1418
+ /**
1419
+ * Redact the Jupiter API key for safe debug logging.
1420
+ * Never returns the full key.
1421
+ * @returns {string}
1255
1422
  */
1423
+ function redactJupiterApiKey() {
1424
+ const key = resolveJupiterApiKey();
1425
+ if (!key) return "(none)";
1426
+ if (key.length <= 6) return "***";
1427
+ return key.slice(0, 3) + "***" + key.slice(-3);
1428
+ }
1429
+
1430
+ // ── Unified config object (single source of truth) ────────────────
1431
+
1432
+ /**
1433
+ * Resolve the complete Jupiter runtime configuration.
1434
+ * This is the ONLY function external callers should use for config.
1435
+ * Returns a frozen, validated config object. Fail-closed on invalid config.
1436
+ *
1437
+ * @param {{ jupiter_quote_url?: string|null, jupiter_swap_url?: string|null }} [profileConfig]
1438
+ * @param {{ profileName?: string }} [opts]
1439
+ * @returns {JupiterResolvedConfig}
1440
+ *
1441
+ * @typedef {Object} JupiterResolvedConfig
1442
+ * @property {string} quote_url
1443
+ * @property {string} swap_url
1444
+ * @property {boolean} api_key_present
1445
+ * @property {string} api_key_source "env"|"none"
1446
+ * @property {string} environment_mode "production"|"preprod"|"custom"
1447
+ * @property {number} timeout_ms
1448
+ * @property {string} profile_name
1449
+ * @property {string} source_chain "env"|"profile"|"default"
1450
+ */
1451
+ function resolveJupiterConfig(profileConfig, opts) {
1452
+ const profileName = opts?.profileName || "default";
1453
+
1454
+ const quoteUrl = resolveJupiterQuoteBase(profileConfig);
1455
+ const swapUrl = resolveJupiterSwapBase(profileConfig);
1456
+ const apiKey = resolveJupiterApiKey();
1457
+
1458
+ // Determine source chain
1459
+ let sourceChain = "default";
1460
+ if (process.env.ATF_JUPITER_BASE) {
1461
+ sourceChain = "env";
1462
+ } else if (profileConfig?.jupiter_quote_url || profileConfig?.jupiter_swap_url) {
1463
+ sourceChain = "profile";
1464
+ }
1465
+
1466
+ // Determine environment mode
1467
+ let envMode = "production";
1468
+ if (process.env.ATF_JUPITER_PREPROD === "1" && sourceChain === "default") {
1469
+ envMode = "preprod";
1470
+ } else if (sourceChain !== "default") {
1471
+ envMode = "custom";
1472
+ }
1473
+
1474
+ // API key source
1475
+ const apiKeySource = apiKey ? "env" : "none";
1476
+
1477
+ return Object.freeze({
1478
+ quote_url: quoteUrl,
1479
+ swap_url: swapUrl,
1480
+ api_key_present: apiKey !== null,
1481
+ api_key_source: apiKeySource,
1482
+ environment_mode: envMode,
1483
+ timeout_ms: JUPITER_DEFAULT_TIMEOUT_MS,
1484
+ profile_name: profileName,
1485
+ source_chain: sourceChain,
1486
+ });
1487
+ }
1488
+
1489
+ // ── Validation (fail-closed) ──────────────────────────────────────
1256
1490
 
1257
- const JUPITER_API_BASE = "https://quote-api.jup.ag/v6";
1491
+ /**
1492
+ * Validate a resolved Jupiter config. Throws JupiterConfigError on invalid state.
1493
+ * Designed for preflight checks before any HTTP call.
1494
+ * Pure function — safe to call repeatedly (idempotent, no side effects).
1495
+ *
1496
+ * @param {JupiterResolvedConfig} config
1497
+ * @throws {JupiterConfigError}
1498
+ */
1499
+ function validateJupiterConfig(config) {
1500
+ // 1) quote_url must be a valid URL
1501
+ if (!isValidJupiterUrl(config.quote_url)) {
1502
+ throw new JupiterConfigError(
1503
+ JUPITER_ERROR_CODES.URL_MALFORMED,
1504
+ `Jupiter quote URL is malformed: "${config.quote_url}". ` +
1505
+ `Set ATF_JUPITER_BASE or profile jupiter_quote_url to a valid https:// URL.`,
1506
+ );
1507
+ }
1508
+ // 2) swap_url must be a valid URL
1509
+ if (!isValidJupiterUrl(config.swap_url)) {
1510
+ throw new JupiterConfigError(
1511
+ JUPITER_ERROR_CODES.URL_MALFORMED,
1512
+ `Jupiter swap URL is malformed: "${config.swap_url}". ` +
1513
+ `Set ATF_JUPITER_BASE or profile jupiter_swap_url to a valid https:// URL.`,
1514
+ );
1515
+ }
1516
+ // 3) Detect env/profile conflict: ATF_JUPITER_BASE + profile URLs both set
1517
+ if (
1518
+ process.env.ATF_JUPITER_BASE &&
1519
+ (config.source_chain === "profile" || config.source_chain === "env")
1520
+ ) {
1521
+ // env wins, but warn if profile also has values
1522
+ // (We don't throw here because env always wins — this is intentional.)
1523
+ }
1524
+ // 4) Detect prod/preprod conflict
1525
+ if (process.env.ATF_JUPITER_PREPROD === "1" && config.source_chain === "profile") {
1526
+ throw new JupiterConfigError(
1527
+ JUPITER_ERROR_CODES.PROFILE_CONFLICT,
1528
+ `Conflicting Jupiter config: ATF_JUPITER_PREPROD=1 is set but profile ` +
1529
+ `"${config.profile_name}" provides custom URLs. Unset ATF_JUPITER_PREPROD ` +
1530
+ `or remove jupiter_quote_url/jupiter_swap_url from the profile.`,
1531
+ );
1532
+ }
1533
+ // 5) Empty API key masquerading as present
1534
+ const rawKey = process.env.ATF_JUPITER_API_KEY;
1535
+ if (rawKey !== undefined && rawKey !== null && rawKey.trim().length === 0) {
1536
+ throw new JupiterConfigError(
1537
+ JUPITER_ERROR_CODES.API_KEY_EMPTY,
1538
+ `ATF_JUPITER_API_KEY is set but empty/whitespace. ` +
1539
+ `Either set a valid key or unset the variable entirely.`,
1540
+ );
1541
+ }
1542
+ }
1258
1543
 
1259
1544
  /**
1260
- * Resolve the Jupiter API base URL.
1261
- * Allows override via ATF_JUPITER_BASE env var (used in tests).
1545
+ * Resolve + validate Jupiter config in one call.
1546
+ * This is the recommended entry point for all Jupiter consumers.
1547
+ * Fail-closed: throws JupiterConfigError on any invalid state.
1548
+ *
1549
+ * @param {{ jupiter_quote_url?: string|null, jupiter_swap_url?: string|null }} [profileConfig]
1550
+ * @param {{ profileName?: string }} [opts]
1551
+ * @returns {JupiterResolvedConfig}
1262
1552
  */
1553
+ function resolveAndValidateJupiterConfig(profileConfig, opts) {
1554
+ const config = resolveJupiterConfig(profileConfig, opts);
1555
+ validateJupiterConfig(config);
1556
+ return config;
1557
+ }
1558
+
1559
+ // ── Structured diagnostics (redacted, bot-safe) ───────────────────
1560
+
1561
+ /**
1562
+ * Get a full resolution summary (safe for debug output).
1563
+ * Never exposes secrets. Shows source of config, not values.
1564
+ * Idempotent — safe for repeated calls.
1565
+ *
1566
+ * @param {{ jupiter_quote_url?: string|null, jupiter_swap_url?: string|null }} [profileConfig]
1567
+ * @param {{ profileName?: string }} [opts]
1568
+ * @returns {JupiterDiagnostics}
1569
+ *
1570
+ * @typedef {Object} JupiterDiagnostics
1571
+ * @property {string} quoteBase
1572
+ * @property {string} swapBase
1573
+ * @property {string} apiKey Redacted
1574
+ * @property {string} source
1575
+ * @property {string} quote_url_host Hostname only
1576
+ * @property {string} swap_url_host Hostname only
1577
+ * @property {boolean} api_key_present
1578
+ * @property {string} api_key_source
1579
+ * @property {string} resolver_source Same as source_chain
1580
+ * @property {string} environment_mode
1581
+ * @property {boolean} fallback_available Whether CLI fallback exists
1582
+ * @property {boolean} config_valid
1583
+ * @property {string|null} config_error Machine-readable error code if invalid
1584
+ */
1585
+ function getJupiterResolutionInfo(profileConfig, opts) {
1586
+ const config = resolveJupiterConfig(profileConfig, opts);
1587
+
1588
+ // Check validity without throwing
1589
+ let configValid = true;
1590
+ let configError = null;
1591
+ try {
1592
+ validateJupiterConfig(config);
1593
+ } catch (e) {
1594
+ configValid = false;
1595
+ configError = e.code || "UNKNOWN";
1596
+ }
1597
+
1598
+ return {
1599
+ quoteBase: config.quote_url,
1600
+ swapBase: config.swap_url,
1601
+ apiKey: redactJupiterApiKey(),
1602
+ source: config.source_chain === "default" ? "production-default"
1603
+ : config.source_chain === "env" ? "env:ATF_JUPITER_BASE"
1604
+ : config.source_chain === "profile" ? "profile-config"
1605
+ : config.source_chain,
1606
+ // Structured diagnostics (Deliverable D)
1607
+ quote_url_host: extractJupiterHost(config.quote_url),
1608
+ swap_url_host: extractJupiterHost(config.swap_url),
1609
+ api_key_present: config.api_key_present,
1610
+ api_key_source: config.api_key_source,
1611
+ resolver_source: config.source_chain,
1612
+ environment_mode: config.environment_mode,
1613
+ fallback_available: true, // CLI fallback always exists
1614
+ config_valid: configValid,
1615
+ config_error: configError,
1616
+ };
1617
+ }
1618
+
1619
+ // ── Backward-compat alias for existing callers ────────────────────
1263
1620
  function resolveJupiterBase() {
1264
- return process.env.ATF_JUPITER_BASE || JUPITER_API_BASE;
1621
+ return resolveJupiterQuoteBase();
1265
1622
  }
1266
1623
 
1624
+ // ── HTTP helpers ──────────────────────────────────────────────────
1625
+
1267
1626
  /**
1268
1627
  * Get a swap quote from Jupiter.
1628
+ * Runs preflight validation (fail-closed) before any HTTP call.
1269
1629
  *
1270
1630
  * @param {object} opts
1271
1631
  * @param {string} opts.inputMint Solana mint address
@@ -1273,17 +1633,24 @@ function resolveJupiterBase() {
1273
1633
  * @param {string} opts.amount Base-unit amount string (lamports / micro-units)
1274
1634
  * @param {number} opts.slippageBps Slippage tolerance in basis points
1275
1635
  * @param {number} opts.timeoutMs HTTP timeout
1636
+ * @param {object} [opts.profileConfig] Profile config for URL resolution
1276
1637
  * @returns {Promise<object>} Jupiter quote response JSON
1277
1638
  */
1278
1639
  async function jupiterGetQuote(opts) {
1279
- const url = new URL(`${resolveJupiterBase()}/quote`);
1640
+ // Preflight: resolve + validate (fail-closed)
1641
+ const config = resolveAndValidateJupiterConfig(opts.profileConfig);
1642
+ const base = config.quote_url;
1643
+ const url = new URL(`${base}/quote`);
1280
1644
  url.searchParams.set("inputMint", opts.inputMint);
1281
1645
  url.searchParams.set("outputMint", opts.outputMint);
1282
1646
  url.searchParams.set("amount", opts.amount);
1283
1647
  url.searchParams.set("slippageBps", String(opts.slippageBps));
1284
1648
 
1649
+ const headers = buildJupiterHeaders();
1650
+
1285
1651
  const res = await fetch(url.toString(), {
1286
- signal: AbortSignal.timeout(opts.timeoutMs || 20_000),
1652
+ headers,
1653
+ signal: AbortSignal.timeout(opts.timeoutMs || config.timeout_ms),
1287
1654
  });
1288
1655
 
1289
1656
  if (!res.ok) {
@@ -1296,14 +1663,19 @@ async function jupiterGetQuote(opts) {
1296
1663
  /**
1297
1664
  * Request a swap transaction from Jupiter.
1298
1665
  * Returns the base64 swapTransaction ready for signing.
1666
+ * Runs preflight validation (fail-closed) before any HTTP call.
1299
1667
  *
1300
1668
  * @param {object} opts
1301
1669
  * @param {object} opts.quoteResponse Full quote response from jupiterGetQuote
1302
1670
  * @param {string} opts.userPublicKey Base58 public key of the wallet
1303
1671
  * @param {number} opts.timeoutMs HTTP timeout
1672
+ * @param {object} [opts.profileConfig] Profile config for URL resolution
1304
1673
  * @returns {Promise<{swapTransaction: string, lastValidBlockHeight: number}>}
1305
1674
  */
1306
1675
  async function jupiterGetSwapTx(opts) {
1676
+ // Preflight: resolve + validate (fail-closed)
1677
+ const config = resolveAndValidateJupiterConfig(opts.profileConfig);
1678
+ const base = config.swap_url;
1307
1679
  const body = {
1308
1680
  quoteResponse: opts.quoteResponse,
1309
1681
  userPublicKey: opts.userPublicKey,
@@ -1311,11 +1683,16 @@ async function jupiterGetSwapTx(opts) {
1311
1683
  dynamicComputeUnitLimit: true,
1312
1684
  };
1313
1685
 
1314
- const res = await fetch(`${resolveJupiterBase()}/swap`, {
1686
+ const headers = {
1687
+ "Content-Type": "application/json",
1688
+ ...buildJupiterHeaders(),
1689
+ };
1690
+
1691
+ const res = await fetch(`${base}/swap`, {
1315
1692
  method: "POST",
1316
- headers: { "Content-Type": "application/json" },
1693
+ headers,
1317
1694
  body: JSON.stringify(body),
1318
- signal: AbortSignal.timeout(opts.timeoutMs || 20_000),
1695
+ signal: AbortSignal.timeout(opts.timeoutMs || config.timeout_ms),
1319
1696
  });
1320
1697
 
1321
1698
  if (!res.ok) {
@@ -1325,6 +1702,7 @@ async function jupiterGetSwapTx(opts) {
1325
1702
  return res.json();
1326
1703
  }
1327
1704
 
1705
+
1328
1706
  // ---- src/solana_sign.mjs ----
1329
1707
  /**
1330
1708
  * solana_sign.mjs — Solana keypair loading, transaction signing, and RPC helpers.
@@ -2486,6 +2864,9 @@ async function runSwap(args) {
2486
2864
  const baseUrl = args.baseUrl;
2487
2865
  const timeoutMs = args.timeoutMs;
2488
2866
 
2867
+ // ── Resolve profile config for Jupiter endpoint resolution ───────
2868
+ const { name: _swapProfileName, profile: _swapProfile } = resolveEffectiveProfile(args.profileFlag || null);
2869
+
2489
2870
  // Determine execution mode: devnet uses SOL transfer fallback (Jupiter may not support devnet)
2490
2871
  const executionMode = isDevnet ? "sol_transfer_devnet" : "jupiter_swap";
2491
2872
 
@@ -2671,6 +3052,34 @@ async function runSwap(args) {
2671
3052
 
2672
3053
  // Output
2673
3054
  const explorerUrl = `https://solscan.io/tx/${signature}?cluster=devnet`;
3055
+ const isConfirmed = confirmationStatus === "confirmed" || confirmationStatus === "finalized";
3056
+
3057
+ // Write execution artifacts
3058
+ let artifactDir = null;
3059
+ try {
3060
+ artifactDir = createExecutionArtifactDir("swap");
3061
+ const execArt = buildExecutionArtifact({
3062
+ receipt_content_hash: contentHash,
3063
+ request_id: requestId,
3064
+ command: "swap",
3065
+ adapter: "system_program",
3066
+ intent: "sol_transfer",
3067
+ cluster: "devnet",
3068
+ rpc: rpcUrl,
3069
+ send_path: "rpc-only",
3070
+ tx_signature: signature,
3071
+ confirmed: isConfirmed,
3072
+ confirmed_at: isConfirmed ? new Date().toISOString() : null,
3073
+ explorer_url: explorerUrl,
3074
+ status: isConfirmed ? "confirmed" : "sent",
3075
+ });
3076
+ writeExecutionArtifacts(artifactDir, execArt, {
3077
+ ok: true,
3078
+ confirmed: isConfirmed,
3079
+ explorer_url: explorerUrl,
3080
+ });
3081
+ } catch { /* artifact writing is non-critical */ }
3082
+
2674
3083
  const result = {
2675
3084
  ok: true,
2676
3085
  request_id: requestId,
@@ -2679,6 +3088,7 @@ async function runSwap(args) {
2679
3088
  verified,
2680
3089
  network: networkLabel,
2681
3090
  execution_mode: executionMode,
3091
+ artifact_dir: artifactDir,
2682
3092
  burner: isBurner ? { enabled: true, pubkey: walletPubkey, saved_to: savedBurnerTo || null } : undefined,
2683
3093
  response: {
2684
3094
  decision,
@@ -2720,6 +3130,20 @@ async function runSwap(args) {
2720
3130
  // ══════════════════════════════════════════════════════════════════
2721
3131
 
2722
3132
  // ── Step 2: Jupiter quote ────────────────────────────────────────
3133
+ // ── Jupiter preflight: validate config (fail-closed) ─────────────
3134
+ try {
3135
+ const jupConfig = resolveAndValidateJupiterConfig(_swapProfile, { profileName: _swapProfileName });
3136
+ if (args.verbose && !args.quiet) {
3137
+ const diag = getJupiterResolutionInfo(_swapProfile, { profileName: _swapProfileName });
3138
+ process.stderr.write(`${COLORS.dim}[swap] Jupiter config: source=${diag.resolver_source} mode=${diag.environment_mode} quote_host=${diag.quote_url_host} api_key=${diag.api_key_present ? 'present' : 'none'}${COLORS.reset}\n`);
3139
+ }
3140
+ } catch (err) {
3141
+ if (err.name === "JupiterConfigError") {
3142
+ exitWithError(ERROR_CODES.VALIDATION_ERROR, `${err.code}: ${err.message}`, null, format);
3143
+ }
3144
+ throw err;
3145
+ }
3146
+
2723
3147
  if (args.verbose && !args.quiet) {
2724
3148
  process.stderr.write(`${COLORS.dim}[swap] Jupiter quote → ${inputToken.symbol}→${outputToken.symbol} amount=${amountBase}${COLORS.reset}\n`);
2725
3149
  }
@@ -2732,6 +3156,7 @@ async function runSwap(args) {
2732
3156
  amount: amountBase,
2733
3157
  slippageBps,
2734
3158
  timeoutMs,
3159
+ profileConfig: _swapProfile,
2735
3160
  });
2736
3161
  } catch (err) {
2737
3162
  exitWithError(ERROR_CODES.NETWORK_ERROR, `Jupiter quote failed — ${err.message}`, null, format);
@@ -2780,6 +3205,7 @@ async function runSwap(args) {
2780
3205
  quoteResponse,
2781
3206
  userPublicKey: walletPubkey,
2782
3207
  timeoutMs,
3208
+ profileConfig: _swapProfile,
2783
3209
  });
2784
3210
  } catch (err) {
2785
3211
  exitWithError(ERROR_CODES.NETWORK_ERROR, `Jupiter swap-tx failed — ${err.message}`, null, format);
@@ -2827,6 +3253,34 @@ async function runSwap(args) {
2827
3253
 
2828
3254
  // ── Output envelope ──────────────────────────────────────────────
2829
3255
  const explorerUrl = `https://solscan.io/tx/${signature}`;
3256
+ const isConfirmed = confirmationStatus === "confirmed" || confirmationStatus === "finalized";
3257
+
3258
+ // Write execution artifacts
3259
+ let artifactDir = null;
3260
+ try {
3261
+ artifactDir = createExecutionArtifactDir("swap");
3262
+ const execArt = buildExecutionArtifact({
3263
+ receipt_content_hash: contentHash,
3264
+ request_id: requestId,
3265
+ command: "swap",
3266
+ adapter: "jupiter",
3267
+ intent: "swap",
3268
+ cluster: "mainnet",
3269
+ rpc: rpcUrl,
3270
+ send_path: "rpc-only",
3271
+ tx_signature: signature,
3272
+ confirmed: isConfirmed,
3273
+ confirmed_at: isConfirmed ? new Date().toISOString() : null,
3274
+ explorer_url: explorerUrl,
3275
+ status: isConfirmed ? "confirmed" : "sent",
3276
+ });
3277
+ writeExecutionArtifacts(artifactDir, execArt, {
3278
+ ok: true,
3279
+ confirmed: isConfirmed,
3280
+ explorer_url: explorerUrl,
3281
+ });
3282
+ } catch { /* artifact writing is non-critical */ }
3283
+
2830
3284
  const result = {
2831
3285
  ok: true,
2832
3286
  request_id: requestId,
@@ -2835,6 +3289,7 @@ async function runSwap(args) {
2835
3289
  verified,
2836
3290
  network: networkLabel,
2837
3291
  execution_mode: executionMode,
3292
+ artifact_dir: artifactDir,
2838
3293
  response: {
2839
3294
  decision,
2840
3295
  receipt_hash: receiptHash,
@@ -3129,12 +3584,42 @@ async function runTxSend(args) {
3129
3584
  confirmStatus = await pollSignatureStatus(rpcUrl, signature, commitment, args.confirmTimeoutMs || 60000);
3130
3585
  }
3131
3586
 
3587
+ const isConfirmed = confirmStatus &&
3588
+ (confirmStatus.status === "confirmed" || confirmStatus.status === "finalized");
3589
+
3590
+ // Write execution artifacts
3591
+ let artifactDir = null;
3592
+ try {
3593
+ artifactDir = createExecutionArtifactDir("tx-send");
3594
+ const execArt = buildExecutionArtifact({
3595
+ receipt_content_hash: null,
3596
+ request_id: null,
3597
+ command: "tx send",
3598
+ adapter: null,
3599
+ intent: null,
3600
+ cluster,
3601
+ rpc: rpcUrl,
3602
+ send_path: "atf-tx",
3603
+ tx_signature: signature,
3604
+ confirmed: !!isConfirmed,
3605
+ confirmed_at: isConfirmed ? new Date().toISOString() : null,
3606
+ explorer_url: explorerUrl,
3607
+ status: isConfirmed ? "confirmed" : "sent",
3608
+ });
3609
+ writeExecutionArtifacts(artifactDir, execArt, {
3610
+ ok: true,
3611
+ confirmed: !!isConfirmed,
3612
+ explorer_url: explorerUrl,
3613
+ });
3614
+ } catch { /* artifact writing is non-critical */ }
3615
+
3132
3616
  const result = {
3133
3617
  ok: true,
3134
3618
  signature,
3135
3619
  explorer_url: explorerUrl,
3136
3620
  rpc_host: rpcHost,
3137
3621
  cluster,
3622
+ artifact_dir: artifactDir,
3138
3623
  };
3139
3624
  if (confirmStatus !== null) result.confirmation = confirmStatus;
3140
3625
 
@@ -5559,433 +6044,433 @@ async function runFixtures(args) {
5559
6044
  }
5560
6045
 
5561
6046
  // ---- src/perps_cmd.mjs ----
5562
- /**
5563
- * perps_cmd.mjs — `atf perps` command family
5564
- *
5565
- * Subcommands:
5566
- * protect — Evaluate a canonical perps ExecutionRequest via ATF policy
5567
- * explain — Show normalized intent + policy checks (read-only, no decision change)
5568
- * fixtures — Print canonical sample perps intents (deterministic, offline)
5569
- *
5570
- * All outputs are deterministic and offline-safe.
5571
- * Feature-gated venues return PERPS_POLICY_GATE_DISABLED when the flag is OFF.
5572
- */
5573
-
5574
- // ── Canonical perps fixtures ──────────────────────────────────────────────────
5575
-
5576
- const PERPS_FIXTURE_TEMPLATES = {
5577
- "drift-place-order": {
5578
- _name: "drift-place-order",
5579
- _description: "Drift v2 — place limit long on SOL-PERP",
5580
- _expected_decision: "allowed (if ATF_ENABLE_DRIFT_POLICY=1 and policy permits)",
5581
- chain_id: "solana",
5582
- intent_type: "perps",
5583
- raw_tx: null,
5584
- intent: {
5585
- venue: "drift_perps",
5586
- operation: "place_perp_order",
5587
- market: "SOL-PERP",
5588
- size: 1.0,
5589
- leverage_x10: 20,
5590
- direction: "long",
5591
- order_kind: "limit",
5592
- reduce_only: false,
5593
- },
5594
- metadata: { size_usd: 150.0 },
5595
- },
5596
-
5597
- "drift-cancel-order": {
5598
- _name: "drift-cancel-order",
5599
- _description: "Drift v2 — cancel an existing perps order",
5600
- _expected_decision: "allowed (if ATF_ENABLE_DRIFT_POLICY=1 and cancel permitted)",
5601
- chain_id: "solana",
5602
- intent_type: "perps",
5603
- raw_tx: null,
5604
- intent: {
5605
- venue: "drift_perps",
5606
- operation: "cancel_order",
5607
- market: "SOL-PERP",
5608
- size: 0,
5609
- leverage_x10: 0,
5610
- direction: "long",
5611
- order_kind: "limit",
5612
- reduce_only: false,
5613
- },
5614
- metadata: {},
5615
- },
5616
-
5617
- "mango-place-order": {
5618
- _name: "mango-place-order",
5619
- _description: "Mango v4 — place limit short on SOL-PERP",
5620
- _expected_decision: "allowed (if ATF_ENABLE_MANGO_POLICY=1 and policy permits)",
5621
- chain_id: "solana",
5622
- intent_type: "perps",
5623
- raw_tx: null,
5624
- intent: {
5625
- venue: "mango_perps",
5626
- operation: "place_perp_order",
5627
- market: "SOL-PERP",
5628
- size: 0.5,
5629
- leverage_x10: 10,
5630
- direction: "short",
5631
- order_kind: "limit",
5632
- reduce_only: false,
5633
- },
5634
- metadata: { size_usd: 75.0 },
5635
- },
5636
-
5637
- "mango-cancel-order": {
5638
- _name: "mango-cancel-order",
5639
- _description: "Mango v4 — cancel an existing perps order",
5640
- _expected_decision: "allowed (if ATF_ENABLE_MANGO_POLICY=1 and cancel permitted)",
5641
- chain_id: "solana",
5642
- intent_type: "perps",
5643
- raw_tx: null,
5644
- intent: {
5645
- venue: "mango_perps",
5646
- operation: "cancel_perp_order",
5647
- market: "SOL-PERP",
5648
- size: 0,
5649
- leverage_x10: 0,
5650
- direction: "long",
5651
- order_kind: "limit",
5652
- reduce_only: false,
5653
- },
5654
- metadata: {},
5655
- },
5656
-
5657
- "hyperliquid-order": {
5658
- _name: "hyperliquid-order",
5659
- _description: "Hyperliquid — place limit long on SOL",
5660
- _expected_decision: "allowed (if ATF_ENABLE_HYPERLIQUID_POLICY=1 and policy permits)",
5661
- chain_id: "hyperliquid",
5662
- intent_type: "perps",
5663
- raw_tx: null,
5664
- intent: {
5665
- venue: "hyperliquid_perps",
5666
- operation: "order",
5667
- market: "SOL",
5668
- size: 2.0,
5669
- leverage_x10: 30,
5670
- direction: "long",
5671
- order_kind: "limit",
5672
- reduce_only: false,
5673
- },
5674
- metadata: { size_usd: 300.0 },
5675
- },
5676
-
5677
- "hyperliquid-cancel": {
5678
- _name: "hyperliquid-cancel",
5679
- _description: "Hyperliquid — cancel an order",
5680
- _expected_decision: "allowed (if ATF_ENABLE_HYPERLIQUID_POLICY=1 and cancel permitted)",
5681
- chain_id: "hyperliquid",
5682
- intent_type: "perps",
5683
- raw_tx: null,
5684
- intent: {
5685
- venue: "hyperliquid_perps",
5686
- operation: "cancel",
5687
- market: "SOL",
5688
- size: 0,
5689
- leverage_x10: 0,
5690
- direction: "long",
5691
- order_kind: "limit",
5692
- reduce_only: false,
5693
- },
5694
- metadata: {},
5695
- },
5696
- };
5697
-
5698
- const PERPS_FIXTURE_NAMES = Object.keys(PERPS_FIXTURE_TEMPLATES);
5699
-
5700
- // ── Helpers ───────────────────────────────────────────────────────────────────
5701
-
5702
- /** Map venue ID to its feature gate env var. Returns null if not gated. */
5703
- function _perpsFeatureGate(venue) {
5704
- const gates = {
5705
- drift_perps: "ATF_ENABLE_DRIFT_POLICY",
5706
- mango_perps: "ATF_ENABLE_MANGO_POLICY",
5707
- hyperliquid_perps: "ATF_ENABLE_HYPERLIQUID_POLICY",
5708
- };
5709
- return gates[venue] || null;
5710
- }
5711
-
5712
- /** Check if a perps venue feature gate is enabled in the current env. */
5713
- function _isPerpsGateEnabled(venue) {
5714
- const envKey = _perpsFeatureGate(venue);
5715
- if (!envKey) return true; // ungated venues are always available
5716
- const val = process.env[envKey];
5717
- return val === "1" || val === "true";
5718
- }
5719
-
5720
- // ── Subcommand: perps fixtures ────────────────────────────────────────────────
5721
-
5722
- async function runPerpsFixtures(args) {
5723
- const subArg = (args._subArgs && args._subArgs[0]) || "all";
5724
- const format = args.format;
5725
-
5726
- if (subArg === "list") {
5727
- const envelope = {
5728
- ok: true,
5729
- fixtures: PERPS_FIXTURE_NAMES.map((n) => ({
5730
- name: n,
5731
- description: PERPS_FIXTURE_TEMPLATES[n]._description,
5732
- expected_decision: PERPS_FIXTURE_TEMPLATES[n]._expected_decision,
5733
- })),
5734
- };
5735
- if (format === "pretty") {
5736
- process.stdout.write(`\n${COLORS.bold}Available perps fixtures:${COLORS.reset}\n\n`);
5737
- for (const f of envelope.fixtures) {
5738
- process.stdout.write(` ${COLORS.bold}${f.name}${COLORS.reset}\n`);
5739
- process.stdout.write(` ${COLORS.dim}${f.description}${COLORS.reset}\n\n`);
5740
- }
5741
- } else {
5742
- process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
5743
- }
5744
- return;
5745
- }
5746
-
5747
- let names;
5748
- if (subArg === "all") {
5749
- names = PERPS_FIXTURE_NAMES;
5750
- } else {
5751
- names = subArg.split(",");
5752
- const unknown = names.filter((n) => !PERPS_FIXTURE_TEMPLATES[n]);
5753
- if (unknown.length > 0) {
5754
- exitWithError(
5755
- ERROR_CODES.USER_ERROR,
5756
- `Unknown perps fixture(s): ${unknown.join(", ")}. Available: ${PERPS_FIXTURE_NAMES.join(", ")}`,
5757
- null,
5758
- format,
5759
- );
5760
- }
5761
- }
5762
-
5763
- const fixtures = names
5764
- .map((n) => {
5765
- const t = PERPS_FIXTURE_TEMPLATES[n];
5766
- if (!t) return null;
5767
- // Strip internal metadata keys for output
5768
- const { _name, _description, _expected_decision, ...rest } = t;
5769
- return { _name, ...rest };
5770
- })
5771
- .filter(Boolean);
5772
-
5773
- const envelope = { ok: true, count: fixtures.length, fixtures };
5774
- process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
5775
- }
5776
-
5777
- // ── Subcommand: perps protect ─────────────────────────────────────────────────
5778
-
5779
- async function runPerpsProtect(args) {
5780
- const format = args.format;
5781
-
5782
- // Read stdin
5783
- let rawInput = null;
5784
- if (args.receiptStdin) {
5785
- rawInput = await new Promise((resolve, reject) => {
5786
- let data = "";
5787
- process.stdin.setEncoding("utf8");
5788
- process.stdin.on("data", (c) => (data += c));
5789
- process.stdin.on("end", () => resolve(data));
5790
- process.stdin.on("error", reject);
5791
- });
5792
- } else if (args.receiptFile) {
5793
- const { readFileSync } = require("node:fs");
5794
- rawInput = readFileSync(args.receiptFile, "utf8");
5795
- }
5796
-
5797
- if (!rawInput || !rawInput.trim()) {
5798
- const out = _buildProtectOutput({
5799
- status: "DENY",
5800
- chain: "unknown",
5801
- reason_codes: ["BOT_NO_INPUT"],
5802
- meta: { hint: "Provide a perps ExecutionRequest via --stdin or --file." },
5803
- });
5804
- _emit(out, args, BOT_PROTECT_EXIT_CODES.BAD_INPUT);
5805
- return;
5806
- }
5807
-
5808
- let input;
5809
- try {
5810
- input = JSON.parse(rawInput.trim());
5811
- } catch {
5812
- const out = _buildProtectOutput({
5813
- status: "DENY",
5814
- chain: "unknown",
5815
- reason_codes: ["BOT_INVALID_JSON_INPUT"],
5816
- meta: { hint: "Input must be valid JSON." },
5817
- });
5818
- _emit(out, args, BOT_PROTECT_EXIT_CODES.BAD_INPUT);
5819
- return;
5820
- }
5821
-
5822
- // Validate it looks like an ExecutionRequest
5823
- if (!input || typeof input !== "object" || Array.isArray(input)) {
5824
- const out = _buildProtectOutput({
5825
- status: "DENY",
5826
- chain: "unknown",
5827
- reason_codes: ["BOT_INVALID_INPUT_SHAPE"],
5828
- meta: { hint: "Input must be a JSON object (ExecutionRequest)." },
5829
- });
5830
- _emit(out, args, BOT_PROTECT_EXIT_CODES.BAD_INPUT);
5831
- return;
5832
- }
5833
-
5834
- // Determine venue from intent
5835
- const intent = input.intent || {};
5836
- const venue = intent.venue || null;
5837
- const chain = input.chain_id || "solana";
5838
-
5839
- // Check feature gate — fail-closed
5840
- if (venue && !_isPerpsGateEnabled(venue)) {
5841
- const gate = _perpsFeatureGate(venue);
5842
- const out = _buildProtectOutput({
5843
- status: "DENY",
5844
- chain,
5845
- venue,
5846
- category: "perps",
5847
- reason_codes: ["PERPS_POLICY_GATE_DISABLED"],
5848
- meta: {
5849
- venue,
5850
- feature_gate: gate,
5851
- hint: `Set ${gate}=1 before starting ATF to enable ${venue}.`,
5852
- },
5853
- });
5854
- _emit(out, args, BOT_PROTECT_EXIT_CODES.DENY);
5855
- return;
5856
- }
5857
-
5858
- // Delegate to bot protect (reuse the same flow)
5859
- // Since we already consumed stdin, save to a temp file so bot protect can re-read it.
5860
- const { writeFileSync, unlinkSync } = require("node:fs");
5861
- const tmpPath = require("node:os").tmpdir() + "/atf_perps_input_" + process.pid + ".json";
5862
- writeFileSync(tmpPath, rawInput, "utf8");
5863
- args.receiptStdin = false;
5864
- args.receiptFile = tmpPath;
5865
- try {
5866
- await runBotProtect(args);
5867
- } finally {
5868
- try { unlinkSync(tmpPath); } catch { /* ignore */ }
5869
- }
5870
- }
5871
-
5872
- // ── Subcommand: perps explain ─────────────────────────────────────────────────
5873
-
5874
- async function runPerpsExplain(args) {
5875
- const format = args.format;
5876
-
5877
- // Read stdin
5878
- let rawInput = null;
5879
- if (args.receiptStdin) {
5880
- rawInput = await new Promise((resolve, reject) => {
5881
- let data = "";
5882
- process.stdin.setEncoding("utf8");
5883
- process.stdin.on("data", (c) => (data += c));
5884
- process.stdin.on("end", () => resolve(data));
5885
- process.stdin.on("error", reject);
5886
- });
5887
- } else if (args.receiptFile) {
5888
- const { readFileSync } = require("node:fs");
5889
- rawInput = readFileSync(args.receiptFile, "utf8");
5890
- }
5891
-
5892
- if (!rawInput || !rawInput.trim()) {
5893
- const envelope = {
5894
- ok: false,
5895
- error: "No input. Provide a perps ExecutionRequest via --stdin or --file.",
5896
- };
5897
- process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
5898
- process.exit(BOT_PROTECT_EXIT_CODES.BAD_INPUT);
5899
- return;
5900
- }
5901
-
5902
- let input;
5903
- try {
5904
- input = JSON.parse(rawInput.trim());
5905
- } catch {
5906
- const envelope = {
5907
- ok: false,
5908
- error: "Input is not valid JSON.",
5909
- };
5910
- process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
5911
- process.exit(BOT_PROTECT_EXIT_CODES.BAD_INPUT);
5912
- return;
5913
- }
5914
-
5915
- const intent = input.intent || {};
5916
- const venue = intent.venue || null;
5917
- const chain = input.chain_id || "solana";
5918
- const gate = venue ? _perpsFeatureGate(venue) : null;
5919
- const gateEnabled = venue ? _isPerpsGateEnabled(venue) : false;
5920
-
5921
- // Build the explain envelope — does NOT change enforcement outcome
5922
- const policyChecks = [
5923
- { check: "venue_allowed", description: "Venue must be on perps_policy.allowed_venues" },
5924
- { check: "market_allowed", description: "Market must be on perps_policy.allowed_markets" },
5925
- { check: "leverage_cap", description: "leverage_x10 <= perps_policy.max_leverage_x10" },
5926
- { check: "size_limit", description: "size <= perps_policy.max_size" },
5927
- { check: "notional_cap", description: "metadata.size_usd <= perps_policy.max_notional_usd" },
5928
- { check: "order_type", description: "order_kind checked against allow_market_orders / allow_limit_orders" },
5929
- { check: "reduce_only", description: "Checked against require_reduce_only / disallow_reduce_only" },
5930
- { check: "operation_allowed", description: "Operation must not match unknown_discriminator parse errors" },
5931
- ];
5932
-
5933
- const envelope = {
5934
- ok: true,
5935
- explain_only: true,
5936
- note: "This output is read-only insight. It does not change enforcement outcomes.",
5937
- normalized_intent: {
5938
- chain_id: chain,
5939
- intent_type: input.intent_type || "perps",
5940
- venue: venue,
5941
- operation: intent.operation || null,
5942
- market: intent.market || null,
5943
- size: intent.size ?? null,
5944
- leverage_x10: intent.leverage_x10 ?? null,
5945
- direction: intent.direction || null,
5946
- order_kind: intent.order_kind || null,
5947
- reduce_only: intent.reduce_only ?? null,
5948
- },
5949
- feature_gate: {
5950
- env_var: gate,
5951
- enabled: gateEnabled,
5952
- note: gateEnabled
5953
- ? `${gate} is active — perps eval will run.`
5954
- : gate
5955
- ? `${gate} is OFF — will return PERPS_POLICY_GATE_DISABLED.`
5956
- : "No feature gate required for this venue.",
5957
- },
5958
- policy_checks_applied: policyChecks,
5959
- metadata_forwarded: input.metadata || {},
5960
- };
5961
-
5962
- process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
5963
- }
5964
-
5965
- // ── Top-level dispatcher ──────────────────────────────────────────────────────
5966
-
5967
- async function runPerps(args) {
5968
- const sub = args.subCommand || (args._subArgs && args._subArgs[0]) || null;
5969
-
5970
- switch (sub) {
5971
- case "protect":
5972
- await runPerpsProtect(args);
5973
- break;
5974
- case "explain":
5975
- await runPerpsExplain(args);
5976
- break;
5977
- case "fixtures":
5978
- await runPerpsFixtures(args);
5979
- break;
5980
- default:
5981
- exitWithError(
5982
- ERROR_CODES.USER_ERROR,
5983
- `Unknown perps subcommand: ${sub || "(none)"}`,
5984
- "Available: protect, explain, fixtures",
5985
- args.format,
5986
- );
5987
- }
5988
- }
6047
+ /**
6048
+ * perps_cmd.mjs — `atf perps` command family
6049
+ *
6050
+ * Subcommands:
6051
+ * protect — Evaluate a canonical perps ExecutionRequest via ATF policy
6052
+ * explain — Show normalized intent + policy checks (read-only, no decision change)
6053
+ * fixtures — Print canonical sample perps intents (deterministic, offline)
6054
+ *
6055
+ * All outputs are deterministic and offline-safe.
6056
+ * Feature-gated venues return PERPS_POLICY_GATE_DISABLED when the flag is OFF.
6057
+ */
6058
+
6059
+ // ── Canonical perps fixtures ──────────────────────────────────────────────────
6060
+
6061
+ const PERPS_FIXTURE_TEMPLATES = {
6062
+ "drift-place-order": {
6063
+ _name: "drift-place-order",
6064
+ _description: "Drift v2 — place limit long on SOL-PERP",
6065
+ _expected_decision: "allowed (if ATF_ENABLE_DRIFT_POLICY=1 and policy permits)",
6066
+ chain_id: "solana",
6067
+ intent_type: "perps",
6068
+ raw_tx: null,
6069
+ intent: {
6070
+ venue: "drift_perps",
6071
+ operation: "place_perp_order",
6072
+ market: "SOL-PERP",
6073
+ size: 1.0,
6074
+ leverage_x10: 20,
6075
+ direction: "long",
6076
+ order_kind: "limit",
6077
+ reduce_only: false,
6078
+ },
6079
+ metadata: { size_usd: 150.0 },
6080
+ },
6081
+
6082
+ "drift-cancel-order": {
6083
+ _name: "drift-cancel-order",
6084
+ _description: "Drift v2 — cancel an existing perps order",
6085
+ _expected_decision: "allowed (if ATF_ENABLE_DRIFT_POLICY=1 and cancel permitted)",
6086
+ chain_id: "solana",
6087
+ intent_type: "perps",
6088
+ raw_tx: null,
6089
+ intent: {
6090
+ venue: "drift_perps",
6091
+ operation: "cancel_order",
6092
+ market: "SOL-PERP",
6093
+ size: 0,
6094
+ leverage_x10: 0,
6095
+ direction: "long",
6096
+ order_kind: "limit",
6097
+ reduce_only: false,
6098
+ },
6099
+ metadata: {},
6100
+ },
6101
+
6102
+ "mango-place-order": {
6103
+ _name: "mango-place-order",
6104
+ _description: "Mango v4 — place limit short on SOL-PERP",
6105
+ _expected_decision: "allowed (if ATF_ENABLE_MANGO_POLICY=1 and policy permits)",
6106
+ chain_id: "solana",
6107
+ intent_type: "perps",
6108
+ raw_tx: null,
6109
+ intent: {
6110
+ venue: "mango_perps",
6111
+ operation: "place_perp_order",
6112
+ market: "SOL-PERP",
6113
+ size: 0.5,
6114
+ leverage_x10: 10,
6115
+ direction: "short",
6116
+ order_kind: "limit",
6117
+ reduce_only: false,
6118
+ },
6119
+ metadata: { size_usd: 75.0 },
6120
+ },
6121
+
6122
+ "mango-cancel-order": {
6123
+ _name: "mango-cancel-order",
6124
+ _description: "Mango v4 — cancel an existing perps order",
6125
+ _expected_decision: "allowed (if ATF_ENABLE_MANGO_POLICY=1 and cancel permitted)",
6126
+ chain_id: "solana",
6127
+ intent_type: "perps",
6128
+ raw_tx: null,
6129
+ intent: {
6130
+ venue: "mango_perps",
6131
+ operation: "cancel_perp_order",
6132
+ market: "SOL-PERP",
6133
+ size: 0,
6134
+ leverage_x10: 0,
6135
+ direction: "long",
6136
+ order_kind: "limit",
6137
+ reduce_only: false,
6138
+ },
6139
+ metadata: {},
6140
+ },
6141
+
6142
+ "hyperliquid-order": {
6143
+ _name: "hyperliquid-order",
6144
+ _description: "Hyperliquid — place limit long on SOL",
6145
+ _expected_decision: "allowed (if ATF_ENABLE_HYPERLIQUID_POLICY=1 and policy permits)",
6146
+ chain_id: "hyperliquid",
6147
+ intent_type: "perps",
6148
+ raw_tx: null,
6149
+ intent: {
6150
+ venue: "hyperliquid_perps",
6151
+ operation: "order",
6152
+ market: "SOL",
6153
+ size: 2.0,
6154
+ leverage_x10: 30,
6155
+ direction: "long",
6156
+ order_kind: "limit",
6157
+ reduce_only: false,
6158
+ },
6159
+ metadata: { size_usd: 300.0 },
6160
+ },
6161
+
6162
+ "hyperliquid-cancel": {
6163
+ _name: "hyperliquid-cancel",
6164
+ _description: "Hyperliquid — cancel an order",
6165
+ _expected_decision: "allowed (if ATF_ENABLE_HYPERLIQUID_POLICY=1 and cancel permitted)",
6166
+ chain_id: "hyperliquid",
6167
+ intent_type: "perps",
6168
+ raw_tx: null,
6169
+ intent: {
6170
+ venue: "hyperliquid_perps",
6171
+ operation: "cancel",
6172
+ market: "SOL",
6173
+ size: 0,
6174
+ leverage_x10: 0,
6175
+ direction: "long",
6176
+ order_kind: "limit",
6177
+ reduce_only: false,
6178
+ },
6179
+ metadata: {},
6180
+ },
6181
+ };
6182
+
6183
+ const PERPS_FIXTURE_NAMES = Object.keys(PERPS_FIXTURE_TEMPLATES);
6184
+
6185
+ // ── Helpers ───────────────────────────────────────────────────────────────────
6186
+
6187
+ /** Map venue ID to its feature gate env var. Returns null if not gated. */
6188
+ function _perpsFeatureGate(venue) {
6189
+ const gates = {
6190
+ drift_perps: "ATF_ENABLE_DRIFT_POLICY",
6191
+ mango_perps: "ATF_ENABLE_MANGO_POLICY",
6192
+ hyperliquid_perps: "ATF_ENABLE_HYPERLIQUID_POLICY",
6193
+ };
6194
+ return gates[venue] || null;
6195
+ }
6196
+
6197
+ /** Check if a perps venue feature gate is enabled in the current env. */
6198
+ function _isPerpsGateEnabled(venue) {
6199
+ const envKey = _perpsFeatureGate(venue);
6200
+ if (!envKey) return true; // ungated venues are always available
6201
+ const val = process.env[envKey];
6202
+ return val === "1" || val === "true";
6203
+ }
6204
+
6205
+ // ── Subcommand: perps fixtures ────────────────────────────────────────────────
6206
+
6207
+ async function runPerpsFixtures(args) {
6208
+ const subArg = (args._subArgs && args._subArgs[0]) || "all";
6209
+ const format = args.format;
6210
+
6211
+ if (subArg === "list") {
6212
+ const envelope = {
6213
+ ok: true,
6214
+ fixtures: PERPS_FIXTURE_NAMES.map((n) => ({
6215
+ name: n,
6216
+ description: PERPS_FIXTURE_TEMPLATES[n]._description,
6217
+ expected_decision: PERPS_FIXTURE_TEMPLATES[n]._expected_decision,
6218
+ })),
6219
+ };
6220
+ if (format === "pretty") {
6221
+ process.stdout.write(`\n${COLORS.bold}Available perps fixtures:${COLORS.reset}\n\n`);
6222
+ for (const f of envelope.fixtures) {
6223
+ process.stdout.write(` ${COLORS.bold}${f.name}${COLORS.reset}\n`);
6224
+ process.stdout.write(` ${COLORS.dim}${f.description}${COLORS.reset}\n\n`);
6225
+ }
6226
+ } else {
6227
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
6228
+ }
6229
+ return;
6230
+ }
6231
+
6232
+ let names;
6233
+ if (subArg === "all") {
6234
+ names = PERPS_FIXTURE_NAMES;
6235
+ } else {
6236
+ names = subArg.split(",");
6237
+ const unknown = names.filter((n) => !PERPS_FIXTURE_TEMPLATES[n]);
6238
+ if (unknown.length > 0) {
6239
+ exitWithError(
6240
+ ERROR_CODES.USER_ERROR,
6241
+ `Unknown perps fixture(s): ${unknown.join(", ")}. Available: ${PERPS_FIXTURE_NAMES.join(", ")}`,
6242
+ null,
6243
+ format,
6244
+ );
6245
+ }
6246
+ }
6247
+
6248
+ const fixtures = names
6249
+ .map((n) => {
6250
+ const t = PERPS_FIXTURE_TEMPLATES[n];
6251
+ if (!t) return null;
6252
+ // Strip internal metadata keys for output
6253
+ const { _name, _description, _expected_decision, ...rest } = t;
6254
+ return { _name, ...rest };
6255
+ })
6256
+ .filter(Boolean);
6257
+
6258
+ const envelope = { ok: true, count: fixtures.length, fixtures };
6259
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
6260
+ }
6261
+
6262
+ // ── Subcommand: perps protect ─────────────────────────────────────────────────
6263
+
6264
+ async function runPerpsProtect(args) {
6265
+ const format = args.format;
6266
+
6267
+ // Read stdin
6268
+ let rawInput = null;
6269
+ if (args.receiptStdin) {
6270
+ rawInput = await new Promise((resolve, reject) => {
6271
+ let data = "";
6272
+ process.stdin.setEncoding("utf8");
6273
+ process.stdin.on("data", (c) => (data += c));
6274
+ process.stdin.on("end", () => resolve(data));
6275
+ process.stdin.on("error", reject);
6276
+ });
6277
+ } else if (args.receiptFile) {
6278
+ const { readFileSync } = require("node:fs");
6279
+ rawInput = readFileSync(args.receiptFile, "utf8");
6280
+ }
6281
+
6282
+ if (!rawInput || !rawInput.trim()) {
6283
+ const out = _buildProtectOutput({
6284
+ status: "DENY",
6285
+ chain: "unknown",
6286
+ reason_codes: ["BOT_NO_INPUT"],
6287
+ meta: { hint: "Provide a perps ExecutionRequest via --stdin or --file." },
6288
+ });
6289
+ _emit(out, args, BOT_PROTECT_EXIT_CODES.BAD_INPUT);
6290
+ return;
6291
+ }
6292
+
6293
+ let input;
6294
+ try {
6295
+ input = JSON.parse(rawInput.trim());
6296
+ } catch {
6297
+ const out = _buildProtectOutput({
6298
+ status: "DENY",
6299
+ chain: "unknown",
6300
+ reason_codes: ["BOT_INVALID_JSON_INPUT"],
6301
+ meta: { hint: "Input must be valid JSON." },
6302
+ });
6303
+ _emit(out, args, BOT_PROTECT_EXIT_CODES.BAD_INPUT);
6304
+ return;
6305
+ }
6306
+
6307
+ // Validate it looks like an ExecutionRequest
6308
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
6309
+ const out = _buildProtectOutput({
6310
+ status: "DENY",
6311
+ chain: "unknown",
6312
+ reason_codes: ["BOT_INVALID_INPUT_SHAPE"],
6313
+ meta: { hint: "Input must be a JSON object (ExecutionRequest)." },
6314
+ });
6315
+ _emit(out, args, BOT_PROTECT_EXIT_CODES.BAD_INPUT);
6316
+ return;
6317
+ }
6318
+
6319
+ // Determine venue from intent
6320
+ const intent = input.intent || {};
6321
+ const venue = intent.venue || null;
6322
+ const chain = input.chain_id || "solana";
6323
+
6324
+ // Check feature gate — fail-closed
6325
+ if (venue && !_isPerpsGateEnabled(venue)) {
6326
+ const gate = _perpsFeatureGate(venue);
6327
+ const out = _buildProtectOutput({
6328
+ status: "DENY",
6329
+ chain,
6330
+ venue,
6331
+ category: "perps",
6332
+ reason_codes: ["PERPS_POLICY_GATE_DISABLED"],
6333
+ meta: {
6334
+ venue,
6335
+ feature_gate: gate,
6336
+ hint: `Set ${gate}=1 before starting ATF to enable ${venue}.`,
6337
+ },
6338
+ });
6339
+ _emit(out, args, BOT_PROTECT_EXIT_CODES.DENY);
6340
+ return;
6341
+ }
6342
+
6343
+ // Delegate to bot protect (reuse the same flow)
6344
+ // Since we already consumed stdin, save to a temp file so bot protect can re-read it.
6345
+ const { writeFileSync, unlinkSync } = require("node:fs");
6346
+ const tmpPath = require("node:os").tmpdir() + "/atf_perps_input_" + process.pid + ".json";
6347
+ writeFileSync(tmpPath, rawInput, "utf8");
6348
+ args.receiptStdin = false;
6349
+ args.receiptFile = tmpPath;
6350
+ try {
6351
+ await runBotProtect(args);
6352
+ } finally {
6353
+ try { unlinkSync(tmpPath); } catch { /* ignore */ }
6354
+ }
6355
+ }
6356
+
6357
+ // ── Subcommand: perps explain ─────────────────────────────────────────────────
6358
+
6359
+ async function runPerpsExplain(args) {
6360
+ const format = args.format;
6361
+
6362
+ // Read stdin
6363
+ let rawInput = null;
6364
+ if (args.receiptStdin) {
6365
+ rawInput = await new Promise((resolve, reject) => {
6366
+ let data = "";
6367
+ process.stdin.setEncoding("utf8");
6368
+ process.stdin.on("data", (c) => (data += c));
6369
+ process.stdin.on("end", () => resolve(data));
6370
+ process.stdin.on("error", reject);
6371
+ });
6372
+ } else if (args.receiptFile) {
6373
+ const { readFileSync } = require("node:fs");
6374
+ rawInput = readFileSync(args.receiptFile, "utf8");
6375
+ }
6376
+
6377
+ if (!rawInput || !rawInput.trim()) {
6378
+ const envelope = {
6379
+ ok: false,
6380
+ error: "No input. Provide a perps ExecutionRequest via --stdin or --file.",
6381
+ };
6382
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
6383
+ process.exit(BOT_PROTECT_EXIT_CODES.BAD_INPUT);
6384
+ return;
6385
+ }
6386
+
6387
+ let input;
6388
+ try {
6389
+ input = JSON.parse(rawInput.trim());
6390
+ } catch {
6391
+ const envelope = {
6392
+ ok: false,
6393
+ error: "Input is not valid JSON.",
6394
+ };
6395
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
6396
+ process.exit(BOT_PROTECT_EXIT_CODES.BAD_INPUT);
6397
+ return;
6398
+ }
6399
+
6400
+ const intent = input.intent || {};
6401
+ const venue = intent.venue || null;
6402
+ const chain = input.chain_id || "solana";
6403
+ const gate = venue ? _perpsFeatureGate(venue) : null;
6404
+ const gateEnabled = venue ? _isPerpsGateEnabled(venue) : false;
6405
+
6406
+ // Build the explain envelope — does NOT change enforcement outcome
6407
+ const policyChecks = [
6408
+ { check: "venue_allowed", description: "Venue must be on perps_policy.allowed_venues" },
6409
+ { check: "market_allowed", description: "Market must be on perps_policy.allowed_markets" },
6410
+ { check: "leverage_cap", description: "leverage_x10 <= perps_policy.max_leverage_x10" },
6411
+ { check: "size_limit", description: "size <= perps_policy.max_size" },
6412
+ { check: "notional_cap", description: "metadata.size_usd <= perps_policy.max_notional_usd" },
6413
+ { check: "order_type", description: "order_kind checked against allow_market_orders / allow_limit_orders" },
6414
+ { check: "reduce_only", description: "Checked against require_reduce_only / disallow_reduce_only" },
6415
+ { check: "operation_allowed", description: "Operation must not match unknown_discriminator parse errors" },
6416
+ ];
6417
+
6418
+ const envelope = {
6419
+ ok: true,
6420
+ explain_only: true,
6421
+ note: "This output is read-only insight. It does not change enforcement outcomes.",
6422
+ normalized_intent: {
6423
+ chain_id: chain,
6424
+ intent_type: input.intent_type || "perps",
6425
+ venue: venue,
6426
+ operation: intent.operation || null,
6427
+ market: intent.market || null,
6428
+ size: intent.size ?? null,
6429
+ leverage_x10: intent.leverage_x10 ?? null,
6430
+ direction: intent.direction || null,
6431
+ order_kind: intent.order_kind || null,
6432
+ reduce_only: intent.reduce_only ?? null,
6433
+ },
6434
+ feature_gate: {
6435
+ env_var: gate,
6436
+ enabled: gateEnabled,
6437
+ note: gateEnabled
6438
+ ? `${gate} is active — perps eval will run.`
6439
+ : gate
6440
+ ? `${gate} is OFF — will return PERPS_POLICY_GATE_DISABLED.`
6441
+ : "No feature gate required for this venue.",
6442
+ },
6443
+ policy_checks_applied: policyChecks,
6444
+ metadata_forwarded: input.metadata || {},
6445
+ };
6446
+
6447
+ process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
6448
+ }
6449
+
6450
+ // ── Top-level dispatcher ──────────────────────────────────────────────────────
6451
+
6452
+ async function runPerps(args) {
6453
+ const sub = args.subCommand || (args._subArgs && args._subArgs[0]) || null;
6454
+
6455
+ switch (sub) {
6456
+ case "protect":
6457
+ await runPerpsProtect(args);
6458
+ break;
6459
+ case "explain":
6460
+ await runPerpsExplain(args);
6461
+ break;
6462
+ case "fixtures":
6463
+ await runPerpsFixtures(args);
6464
+ break;
6465
+ default:
6466
+ exitWithError(
6467
+ ERROR_CODES.USER_ERROR,
6468
+ `Unknown perps subcommand: ${sub || "(none)"}`,
6469
+ "Available: protect, explain, fixtures",
6470
+ args.format,
6471
+ );
6472
+ }
6473
+ }
5989
6474
 
5990
6475
  // ---- src/plan.mjs ----
5991
6476
  /**
@@ -6738,6 +7223,95 @@ async function runDoctor(args) {
6738
7223
  checks.push(check);
6739
7224
  }
6740
7225
 
7226
+ // ── 8) Jupiter resolver config check ────────────────────────────
7227
+ vlog("Checking Jupiter endpoint configuration ...");
7228
+ {
7229
+ const check = { name: "jupiter_config", status: "pass", latency_ms: 0, details: {}, actions: [] };
7230
+ try {
7231
+ const diag = getJupiterResolutionInfo(profile, { profileName: pName });
7232
+ check.details.resolver_source = diag.resolver_source;
7233
+ check.details.environment_mode = diag.environment_mode;
7234
+ check.details.quote_url_host = diag.quote_url_host;
7235
+ check.details.swap_url_host = diag.swap_url_host;
7236
+ check.details.api_key_present = diag.api_key_present;
7237
+ check.details.api_key_source = diag.api_key_source;
7238
+ check.details.config_valid = diag.config_valid;
7239
+ check.details.fallback_available = diag.fallback_available;
7240
+ if (!diag.config_valid) {
7241
+ check.status = "fail";
7242
+ check.details.error_code = diag.config_error;
7243
+ check.actions.push(`Jupiter config error: ${diag.config_error}`);
7244
+ check.actions.push("Run: atf config set jupiter_quote_url <url> or unset conflicting env vars");
7245
+ }
7246
+ // Warn if API key is configured but source is unknown
7247
+ if (diag.api_key_present && diag.api_key_source === "none") {
7248
+ check.status = "warn";
7249
+ check.actions.push("API key detected but source is unclear — verify ATF_JUPITER_API_KEY env");
7250
+ }
7251
+ } catch (err) {
7252
+ check.status = "warn";
7253
+ check.details.error = err.message || String(err);
7254
+ check.details.fallback_available = true;
7255
+ check.actions.push("Jupiter config check failed — CLI fallback is still available");
7256
+ }
7257
+ checks.push(check);
7258
+ }
7259
+
7260
+ // ── 9) OpenClaw plugin metadata check ──────────────────────────
7261
+ vlog("Checking OpenClaw plugin metadata ...");
7262
+ {
7263
+ const check = { name: "openclaw_plugin", status: "pass", latency_ms: 0, details: {}, actions: [] };
7264
+ try {
7265
+ const { existsSync: exists, readFileSync: readFs } = require("node:fs");
7266
+ const { join: joinPath } = require("node:path");
7267
+ // Try to locate openclaw-atf package relative to common paths
7268
+ const candidates = [
7269
+ joinPath(process.cwd(), "node_modules/@trucore/openclaw-atf/package.json"),
7270
+ joinPath(process.cwd(), "packages/openclaw-atf/package.json"),
7271
+ ];
7272
+ let pluginPkgPath = null;
7273
+ for (const c of candidates) {
7274
+ if (exists(c)) { pluginPkgPath = c; break; }
7275
+ }
7276
+ if (!pluginPkgPath) {
7277
+ check.status = "warn";
7278
+ check.details.installed = false;
7279
+ check.details.fallback_available = true;
7280
+ check.actions.push("OpenClaw ATF plugin not found locally — direct CLI fallback is available");
7281
+ check.actions.push("Install: npm i @trucore/openclaw-atf");
7282
+ } else {
7283
+ check.details.installed = true;
7284
+ check.details.path = pluginPkgPath;
7285
+ const pkgData = JSON.parse(readFs(pluginPkgPath, "utf8"));
7286
+ // Validate openclaw.extensions
7287
+ const ext = pkgData.openclaw?.extensions;
7288
+ if (!Array.isArray(ext) || ext.length === 0) {
7289
+ check.status = "fail";
7290
+ check.details.metadata_valid = false;
7291
+ check.actions.push("OPENCLAW_PLUGIN_METADATA_INVALID: openclaw.extensions missing from package.json");
7292
+ } else {
7293
+ check.details.metadata_valid = true;
7294
+ check.details.extensions = ext;
7295
+ // Verify extension files exist
7296
+ const pkgDir = joinPath(pluginPkgPath, "..");
7297
+ for (const entry of ext) {
7298
+ if (!exists(joinPath(pkgDir, entry))) {
7299
+ check.status = "fail";
7300
+ check.details.metadata_valid = false;
7301
+ check.actions.push(`Extension file missing: ${entry}`);
7302
+ }
7303
+ }
7304
+ }
7305
+ }
7306
+ } catch (err) {
7307
+ check.status = "warn";
7308
+ check.details.error = err.message || String(err);
7309
+ check.details.fallback_available = true;
7310
+ check.actions.push("Plugin check failed — CLI fallback is available");
7311
+ }
7312
+ checks.push(check);
7313
+ }
7314
+
6741
7315
  // ── Build envelope ─────────────────────────────────────────────
6742
7316
  let passed = 0;
6743
7317
  let warned = 0;
@@ -8123,7 +8697,42 @@ async function runBotSend(args) {
8123
8697
  }
8124
8698
  }
8125
8699
 
8126
- // ── 9. Success ────────────────────────────────────────────────────────────
8700
+ // ── 9. Write execution artifacts ──────────────────────────────────────────
8701
+
8702
+ let artifactDir = null;
8703
+ try {
8704
+ artifactDir = createExecutionArtifactDir("bot-send");
8705
+ const contentHash =
8706
+ (decision.receipt && decision.receipt.content_hash) || null;
8707
+ const cluster = isDevnet ? "devnet" : "mainnet";
8708
+ const explorerSuffix = cluster === "devnet" ? "?cluster=devnet" : "";
8709
+ const explorerUrl = signature
8710
+ ? `https://solscan.io/tx/${signature}${explorerSuffix}`
8711
+ : null;
8712
+ const execArt = buildExecutionArtifact({
8713
+ receipt_content_hash: contentHash,
8714
+ request_id:
8715
+ (decision.receipt && decision.receipt.request_id) || null,
8716
+ command: "bot send",
8717
+ adapter: decision.venue || null,
8718
+ intent: decision.intent_type || null,
8719
+ cluster,
8720
+ rpc: rpcUrl,
8721
+ send_path: "atf-bot-send",
8722
+ tx_signature: signature,
8723
+ confirmed: args.confirm || false,
8724
+ confirmed_at: args.confirm ? new Date().toISOString() : null,
8725
+ explorer_url: explorerUrl,
8726
+ status: signature ? "sent" : "unknown",
8727
+ });
8728
+ writeExecutionArtifacts(artifactDir, execArt, {
8729
+ ok: true,
8730
+ confirmed: args.confirm || false,
8731
+ explorer_url: explorerUrl,
8732
+ });
8733
+ } catch { /* artifact writing is non-critical */ }
8734
+
8735
+ // ── 10. Success ───────────────────────────────────────────────────────────
8127
8736
 
8128
8737
  _emitSend(
8129
8738
  _buildSendOutput({
@@ -8137,6 +8746,7 @@ async function runBotSend(args) {
8137
8746
  sent: true,
8138
8747
  rpc_url: rpcUrl,
8139
8748
  venue: decision.venue,
8749
+ artifact_dir: artifactDir,
8140
8750
  },
8141
8751
  }),
8142
8752
  args,
@@ -8144,40 +8754,2404 @@ async function runBotSend(args) {
8144
8754
  );
8145
8755
  }
8146
8756
 
8147
- // ---- src/bootstrap.mjs ----
8148
- /**
8149
- * bootstrap.mjs — atf bootstrap command (Phase 37/39)
8150
- *
8151
- * Prints a machine-readable or human-readable self-install plan for ATF.
8152
- * Recipes are embedded deterministically no network calls, no timestamps.
8153
- *
8154
- * Usage:
8155
- * atf bootstrap --format json # full recipe catalog
8156
- * atf bootstrap --format text # human-readable
8157
- * atf bootstrap --recipe bootstrap_local # filter to one recipe
8158
- * atf bootstrap --recipe enable_perps_drift --format json
8159
- * atf bootstrap --recipe bootstrap_local --execute-safe # run env+verify steps
8160
- * atf bootstrap --recipe bootstrap_local --dry-run # preview without executing
8161
- */
8162
-
8163
- // ---------------------------------------------------------------------------
8164
- // Recipe catalog — injected by build.mjs from recipes_v2.json (Phase 37c)
8165
- // Single source of truth: firewall-api/firewall_api/integrations/manifests/recipes_v2.json
8166
- // ---------------------------------------------------------------------------
8167
- const RECIPES_V2 = [{"id":"bootstrap_local","title":"Bootstrap ATF locally (safe defaults, no feature flags required)","target":"cli","prerequisites":[{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"},{"type":"file","path":"${HOME}/.config/solana/id.json","required":false,"description":"Solana keypair (optional for dry-run; required for anchor-receipt)"}],"steps":[{"kind":"cli","command":"atf config init --yes","description":"Initialize ATF global config profile"},{"kind":"cli","command":"atf doctor --pretty","description":"Run dev environment health checks"},{"kind":"cli","command":"echo '{\"chain_id\":\"solana\",\"intent_type\":\"swap\",\"raw_tx\":null,\"intent\":{\"type\":\"swap\",\"in_mint\":\"So11111111111111111111111111111111111111112\",\"out_mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"amount_in\":1000000,\"slippage_bps\":50}}' | atf bot protect --stdin --dry-run","description":"Dry-run bot protect (no network, no signing)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["config_loaded"],"description":"Verify config loaded successfully"}],"outputs":["ATF config initialized at ${HOME}/.config/atf/config.json","Doctor checks pass","Dry-run bot protect decision returned (ALLOW or DENY with reason codes)"],"safety_notes":["All steps are read-only or dry-run by default.","No on-chain transactions are submitted in this recipe.","ATF never signs transactions.","Default behavior is DENY for unknown or unsupported inputs."],"feature_gates":[]},{"id":"enable_perps_drift","title":"Enable Drift v2 Solana perps policy gate","target":"both","prerequisites":[{"type":"env","key":"ATF_ENABLE_DRIFT_POLICY","value":"1","description":"Must be set before starting the ATF server"},{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"}],"steps":[{"kind":"env","env_keys":["ATF_ENABLE_DRIFT_POLICY"],"description":"Set env flag before (re)starting ATF server"},{"kind":"cli","command":"ATF_ENABLE_DRIFT_POLICY=1 atf doctor --pretty","description":"Confirm Drift policy gate is active"},{"kind":"cli","command":"echo '{\"chain_id\":\"solana\",\"intent_type\":\"perps\",\"raw_tx\":null,\"intent\":{\"venue\":\"drift_perps\",\"operation\":\"place_perp_order\",\"market\":\"SOL-PERP\",\"size\":1.0,\"leverage_x10\":20}}' | ATF_ENABLE_DRIFT_POLICY=1 atf bot protect --stdin --dry-run","description":"Dry-run Drift perps intent (fixture, no network)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["drift_policy_enabled"],"description":"Verify Drift policy capability present"}],"outputs":["drift_perps_policy capability present in manifest","Bot protect dry-run returns ALLOW or policy-enforced DENY for Drift intents"],"safety_notes":["ATF_ENABLE_DRIFT_POLICY defaults OFF (fail-closed).","Only enable on the ATF server process; never commit this flag to .env.","Dry-run does not submit any transaction."],"feature_gates":["ATF_ENABLE_DRIFT_POLICY"]},{"id":"enable_perps_mango","title":"Enable Mango v4 Solana perps policy gate","target":"both","prerequisites":[{"type":"env","key":"ATF_ENABLE_MANGO_POLICY","value":"1","description":"Must be set before starting the ATF server"},{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"}],"steps":[{"kind":"env","env_keys":["ATF_ENABLE_MANGO_POLICY"],"description":"Set env flag before (re)starting ATF server"},{"kind":"cli","command":"ATF_ENABLE_MANGO_POLICY=1 atf doctor --pretty","description":"Confirm Mango policy gate is active"},{"kind":"cli","command":"echo '{\"chain_id\":\"solana\",\"intent_type\":\"perps\",\"raw_tx\":null,\"intent\":{\"venue\":\"mango_perps\",\"operation\":\"place_perp_order\",\"market\":\"SOL-PERP\",\"size\":1.0,\"leverage_x10\":20}}' | ATF_ENABLE_MANGO_POLICY=1 atf bot protect --stdin --dry-run","description":"Dry-run Mango perps intent (fixture, no network)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["mango_policy_enabled"],"description":"Verify Mango policy capability present"}],"outputs":["mango_perps_policy capability present in manifest","Bot protect dry-run returns ALLOW or policy-enforced DENY for Mango intents"],"safety_notes":["ATF_ENABLE_MANGO_POLICY defaults OFF (fail-closed).","Only enable on the ATF server process; never commit this flag to .env.","Dry-run does not submit any transaction."],"feature_gates":["ATF_ENABLE_MANGO_POLICY"]},{"id":"enable_perps_hyperliquid","title":"Enable Hyperliquid perps policy gate","target":"both","prerequisites":[{"type":"env","key":"ATF_ENABLE_HYPERLIQUID_POLICY","value":"1","description":"Must be set before starting the ATF server"},{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"}],"steps":[{"kind":"env","env_keys":["ATF_ENABLE_HYPERLIQUID_POLICY"],"description":"Set env flag before (re)starting ATF server"},{"kind":"cli","command":"ATF_ENABLE_HYPERLIQUID_POLICY=1 atf doctor --pretty","description":"Confirm Hyperliquid policy gate is active"},{"kind":"cli","command":"echo '{\"chain_id\":\"hyperliquid\",\"intent_type\":\"perps\",\"raw_tx\":null,\"intent\":{\"venue\":\"hyperliquid_perps\",\"operation\":\"order\",\"market\":\"SOL\",\"size\":1.0,\"leverage_x10\":20}}' | ATF_ENABLE_HYPERLIQUID_POLICY=1 atf bot protect --stdin --dry-run","description":"Dry-run Hyperliquid perps intent (fixture, no network)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["hyperliquid_policy_enabled"],"description":"Verify Hyperliquid policy capability present"}],"outputs":["Hyperliquid chain and hyperliquid_perps venue active in manifest","Bot protect dry-run returns ALLOW or policy-enforced DENY for HL intents"],"safety_notes":["ATF_ENABLE_HYPERLIQUID_POLICY defaults OFF (fail-closed).","Only enable on the ATF server process; never commit this flag to .env.","Dry-run does not submit any transaction."],"feature_gates":["ATF_ENABLE_HYPERLIQUID_POLICY"]}];
8168
-
8169
- // ---------------------------------------------------------------------------
8170
- // Public accessor (used by CLI and tests)
8171
- // ---------------------------------------------------------------------------
8172
- function getRecipesV2() {
8173
- return RECIPES_V2;
8174
- }
8175
-
8176
- function getRecipeById(id) {
8177
- return RECIPES_V2.find((r) => r.id === id) || null;
8178
- }
8179
-
8180
- // ---------------------------------------------------------------------------
8757
+ // ---- src/proof_bundle.mjs ----
8758
+ /**
8759
+ * proof_bundle.mjs — proof bundle utilities for TC1 and bot execute.
8760
+ *
8761
+ * Writes deterministic proof bundles to timestamped run directories.
8762
+ * Used by `atf demo tc1` and `atf bot execute` commands.
8763
+ */
8764
+
8765
+ /** Required file list for a TC1 proof bundle (before execution artifacts). */
8766
+ const TC1_REQUIRED_FILES = [
8767
+ "request.json",
8768
+ "receipt_1.json",
8769
+ "receipt_2.json",
8770
+ "verify_1.txt",
8771
+ "verify_2.txt",
8772
+ "summary.json",
8773
+ ];
8774
+
8775
+ /** Additional execution artifacts (written only when send occurs). */
8776
+ const TC1_EXECUTION_FILES = [
8777
+ "txsig.txt",
8778
+ "send_result.json",
8779
+ "execution.json",
8780
+ ];
8781
+
8782
+ /**
8783
+ * Ensure a directory exists (recursive mkdir).
8784
+ * @param {string} dirPath — path to ensure
8785
+ */
8786
+ function ensureDir(dirPath) {
8787
+ const { mkdirSync } = require("node:fs");
8788
+ mkdirSync(dirPath, { recursive: true });
8789
+ }
8790
+
8791
+ /**
8792
+ * Create a timestamped run directory under the given base path.
8793
+ * @param {string} basePath — parent dir (e.g. <out>/tc1)
8794
+ * @param {string} [prefix] — folder prefix (default: "run")
8795
+ * @returns {string} absolute path to the created run directory
8796
+ */
8797
+ function createRunDir(basePath, prefix) {
8798
+ const { mkdirSync } = require("node:fs");
8799
+ const { join } = require("node:path");
8800
+ const pfx = prefix || "run";
8801
+ const now = new Date();
8802
+ const stamp = now.toISOString()
8803
+ .replace(/[-:]/g, "")
8804
+ .replace("T", "-")
8805
+ .replace(/\.\d+Z$/, "");
8806
+ const dirName = `${pfx}-${stamp}`;
8807
+ const runDir = join(basePath, dirName);
8808
+ mkdirSync(runDir, { recursive: true });
8809
+ return runDir;
8810
+ }
8811
+
8812
+ /**
8813
+ * Write a JSON object to a named file in the run directory.
8814
+ * @param {string} runDir — path to the run directory
8815
+ * @param {string} name — filename (e.g. "receipt_1.json")
8816
+ * @param {object} obj — JSON-serializable object
8817
+ * @returns {string} full path to written file
8818
+ */
8819
+ function writeJson(runDir, name, obj) {
8820
+ const { writeFileSync } = require("node:fs");
8821
+ const { join } = require("node:path");
8822
+ const filePath = join(runDir, name);
8823
+ writeFileSync(filePath, JSON.stringify(obj, null, 2) + "\n", "utf8");
8824
+ return filePath;
8825
+ }
8826
+
8827
+ /**
8828
+ * Write a text string to a named file in the run directory.
8829
+ * @param {string} runDir — path to the run directory
8830
+ * @param {string} name — filename (e.g. "verify_1.txt")
8831
+ * @param {string} text — text content
8832
+ * @returns {string} full path to written file
8833
+ */
8834
+ function writeText(runDir, name, text) {
8835
+ const { writeFileSync } = require("node:fs");
8836
+ const { join } = require("node:path");
8837
+ const filePath = join(runDir, name);
8838
+ writeFileSync(filePath, text, "utf8");
8839
+ return filePath;
8840
+ }
8841
+
8842
+ /**
8843
+ * Write a set of named files into a proof bundle run directory.
8844
+ * @param {string} runDir — path to the run directory
8845
+ * @param {Record<string, string|object>} files — filename → content map.
8846
+ * Objects are JSON-serialised with 2-space indent + trailing newline.
8847
+ * @returns {string[]} list of written file paths
8848
+ */
8849
+ function writeProofBundle(runDir, files) {
8850
+ const paths = [];
8851
+ for (const [name, content] of Object.entries(files)) {
8852
+ if (typeof content === "string") {
8853
+ paths.push(writeText(runDir, name, content));
8854
+ } else {
8855
+ paths.push(writeJson(runDir, name, content));
8856
+ }
8857
+ }
8858
+ return paths;
8859
+ }
8860
+
8861
+ /**
8862
+ * Write summary.json and validate that all required files are present.
8863
+ * @param {string} runDir — path to the run directory
8864
+ * @param {object} summaryObj — summary JSON object
8865
+ * @param {string[]} [requiredFiles] — list of required filenames (default: TC1_REQUIRED_FILES)
8866
+ * @returns {{ ok: boolean, missing: string[] }}
8867
+ */
8868
+ function finalizeBundle(runDir, summaryObj, requiredFiles) {
8869
+ const { existsSync } = require("node:fs");
8870
+ const { join } = require("node:path");
8871
+
8872
+ // Write summary.json
8873
+ writeJson(runDir, "summary.json", summaryObj);
8874
+
8875
+ // Validate required files
8876
+ const required = requiredFiles || TC1_REQUIRED_FILES;
8877
+ const missing = [];
8878
+ for (const name of required) {
8879
+ if (!existsSync(join(runDir, name))) {
8880
+ missing.push(name);
8881
+ }
8882
+ }
8883
+ return { ok: missing.length === 0, missing };
8884
+ }
8885
+
8886
+ /**
8887
+ * Format a TC1 result summary object into the standard ATF RESULT block.
8888
+ * @param {object} s — summary object
8889
+ * @returns {string} formatted multi-line string
8890
+ */
8891
+ function formatTc1Summary(s) {
8892
+ const lines = [
8893
+ "",
8894
+ "TC1 RESULT",
8895
+ ` subcase: ${s.subcase || "unknown"}`,
8896
+ ` decision: ${s.decision || "N/A"}`,
8897
+ ` verified: ${s.verified === true ? "true" : s.verified === false ? "false" : "N/A"}`,
8898
+ ` content_hash: ${s.content_hash || "N/A"}`,
8899
+ ` txsig: ${s.txsig || "N/A"}`,
8900
+ ` send_path: ${s.send_path || "N/A"}`,
8901
+ ` bundle: ${s.bundle || "N/A"}`,
8902
+ "",
8903
+ ];
8904
+ return lines.join("\n");
8905
+ }
8906
+
8907
+ /**
8908
+ * Format an `atf bot execute` result summary.
8909
+ * @param {object} s — summary object
8910
+ * @returns {string} formatted multi-line string
8911
+ */
8912
+ function formatBotExecuteSummary(s) {
8913
+ const lines = [
8914
+ "",
8915
+ "ATF RESULT",
8916
+ ` command: ${s.command || "bot execute"}`,
8917
+ ` adapter: ${s.adapter || "N/A"}`,
8918
+ ` intent: ${s.intent || "N/A"}`,
8919
+ ` decision: ${s.decision || "N/A"}`,
8920
+ ` verified: ${s.verified === true ? "true" : s.verified === false ? "false" : "N/A"}`,
8921
+ ` content_hash: ${s.content_hash || "N/A"}`,
8922
+ ` send_path: ${s.send_path || "N/A"}`,
8923
+ ` txsig: ${s.txsig || "N/A"}`,
8924
+ ` bundle: ${s.bundle || "N/A"}`,
8925
+ "",
8926
+ ];
8927
+ return lines.join("\n");
8928
+ }
8929
+
8930
+ /**
8931
+ * Format an `atf bot analyze` result summary.
8932
+ * @param {object} s — summary object
8933
+ * @returns {string} formatted multi-line string
8934
+ */
8935
+ function formatAnalyzeSummary(s) {
8936
+ const lines = [
8937
+ "",
8938
+ "ATF RESULT",
8939
+ ` command: bot analyze`,
8940
+ ` decision: ${s.decision || "N/A"}`,
8941
+ ` verified: ${s.verified === true ? "true" : s.verified === false ? "false" : "N/A"}`,
8942
+ ` content_hash: ${s.content_hash || "N/A"}`,
8943
+ ` send_path: ${s.send_path || "N/A"}`,
8944
+ ` txsig: ${s.txsig || "N/A"}`,
8945
+ ` bundle: ${s.bundle || "N/A"}`,
8946
+ ];
8947
+ if (s.quality) {
8948
+ lines.push(` quality: ${s.quality}`);
8949
+ }
8950
+ if (s.risk_notes && s.risk_notes.length > 0) {
8951
+ lines.push(" risk_notes:");
8952
+ for (const n of s.risk_notes) {
8953
+ lines.push(` - ${n}`);
8954
+ }
8955
+ }
8956
+ if (s.recommendations && s.recommendations.length > 0) {
8957
+ lines.push(" recommendations:");
8958
+ for (const r of s.recommendations) {
8959
+ lines.push(` - ${r}`);
8960
+ }
8961
+ }
8962
+ lines.push("");
8963
+ return lines.join("\n");
8964
+ }
8965
+
8966
+ /**
8967
+ * Build a summary.json object for a TC1 run.
8968
+ * @param {object} opts
8969
+ * @returns {object} summary JSON-serializable object
8970
+ */
8971
+ function buildTc1SummaryJson(opts) {
8972
+ return {
8973
+ subcase: opts.subcase,
8974
+ decision: opts.decision || "N/A",
8975
+ policy_allowed: opts.policy_allowed || false,
8976
+ deterministic_ok: opts.deterministic_ok || false,
8977
+ verify_ok: opts.verify_ok || false,
8978
+ executed_ok: opts.executed_ok === undefined ? null : opts.executed_ok,
8979
+ content_hash_1: opts.content_hash_1 || null,
8980
+ content_hash_2: opts.content_hash_2 || null,
8981
+ receipt_hash: opts.receipt_hash || null,
8982
+ txsig: opts.txsig || null,
8983
+ send_path: opts.send_path || null,
8984
+ bundle: opts.bundle || null,
8985
+ timestamp: new Date().toISOString(),
8986
+ atf_version: typeof VERSION !== "undefined" ? VERSION : "unknown",
8987
+ };
8988
+ }
8989
+
8990
+ // ---- src/solana_detect.mjs ----
8991
+ /**
8992
+ * solana_detect.mjs — Solana CLI detection and send path selection.
8993
+ *
8994
+ * Detects whether `solana` CLI is available on PATH, provides helpers
8995
+ * to execute transfers via CLI or fall back to Node-native path.
8996
+ */
8997
+
8998
+ /**
8999
+ * Detect whether Solana CLI is available on PATH.
9000
+ * @returns {{ available: boolean, path: string|null, version: string|null }}
9001
+ */
9002
+ function detectSolanaCli() {
9003
+ const { execSync } = require("node:child_process");
9004
+ try {
9005
+ const whichCmd = process.platform === "win32" ? "where solana" : "which solana";
9006
+ const solanaPath = execSync(whichCmd, { encoding: "utf8", timeout: 5000 }).trim().split("\n")[0];
9007
+ let version = null;
9008
+ try {
9009
+ version = execSync("solana --version", { encoding: "utf8", timeout: 5000 }).trim();
9010
+ } catch { /* version detection optional */ }
9011
+ return { available: true, path: solanaPath, version };
9012
+ } catch {
9013
+ return { available: false, path: null, version: null };
9014
+ }
9015
+ }
9016
+
9017
+ /**
9018
+ * Recommend the best available send path given the environment.
9019
+ * @param {object} opts
9020
+ * @param {boolean} opts.solanaCli — solana CLI available
9021
+ * @param {boolean} opts.preferSolanaCli — user prefers solana CLI
9022
+ * @param {boolean} opts.hasKeypair — keypair path provided
9023
+ * @returns {{ path: string, reason: string }}
9024
+ */
9025
+ function recommendSendPath(opts) {
9026
+ const solanaCli = opts.solanaCli || false;
9027
+ const preferCli = opts.preferSolanaCli || false;
9028
+ const hasKeypair = opts.hasKeypair || false;
9029
+
9030
+ if (preferCli && solanaCli && hasKeypair) {
9031
+ return { path: "solana-cli", reason: "User preferred Solana CLI and it is available." };
9032
+ }
9033
+ if (solanaCli && hasKeypair) {
9034
+ return { path: "solana-cli", reason: "Solana CLI detected on PATH." };
9035
+ }
9036
+ if (hasKeypair) {
9037
+ return { path: "node-fallback", reason: "Using Node-native send (tweetnacl)." };
9038
+ }
9039
+ return { path: "rpc-only", reason: "No keypair provided; dry-run only." };
9040
+ }
9041
+
9042
+ /**
9043
+ * Execute a SOL transfer using `solana transfer` CLI command.
9044
+ * @param {object} opts
9045
+ * @param {string} opts.to — recipient pubkey
9046
+ * @param {number} opts.amount — amount in SOL
9047
+ * @param {string} opts.keypair — path to keypair file
9048
+ * @param {string} opts.rpc — RPC URL
9049
+ * @param {boolean} [opts.allowUnfunded] — allow unfunded fee payer
9050
+ * @returns {{ ok: boolean, txsig: string|null, stdout: string, stderr: string }}
9051
+ */
9052
+ function solanaCliTransfer(opts) {
9053
+ const { execSync } = require("node:child_process");
9054
+ const args = [
9055
+ "solana", "transfer",
9056
+ opts.to,
9057
+ String(opts.amount),
9058
+ "--keypair", opts.keypair,
9059
+ "--url", opts.rpc,
9060
+ "--allow-unfunded-recipient",
9061
+ "--output", "json",
9062
+ ];
9063
+ if (opts.allowUnfunded) args.push("--allow-unfunded-recipient");
9064
+ try {
9065
+ const stdout = execSync(args.join(" "), {
9066
+ encoding: "utf8",
9067
+ timeout: 60000,
9068
+ });
9069
+ // Try to extract signature from JSON output
9070
+ let txsig = null;
9071
+ try {
9072
+ const parsed = JSON.parse(stdout);
9073
+ txsig = parsed.signature || parsed.txSignature || null;
9074
+ } catch {
9075
+ // Try regex fallback on stdout
9076
+ const match = stdout.match(/[1-9A-HJ-NP-Za-km-z]{43,88}/);
9077
+ if (match) txsig = match[0];
9078
+ }
9079
+ return { ok: true, txsig, stdout, stderr: "" };
9080
+ } catch (e) {
9081
+ return {
9082
+ ok: false,
9083
+ txsig: null,
9084
+ stdout: "",
9085
+ stderr: e && e.stderr ? String(e.stderr) : String(e.message || e),
9086
+ };
9087
+ }
9088
+ }
9089
+
9090
+ /**
9091
+ * Build a simple devnet transfer transaction fixture (for TC1-B).
9092
+ * Uses the Node-native path (no Solana CLI needed).
9093
+ * Returns a base64-encoded unsigned SystemProgram.Transfer instruction.
9094
+ *
9095
+ * This is a minimal transfer builder that produces a valid Solana
9096
+ * legacy transaction without @solana/web3.js.
9097
+ *
9098
+ * @param {object} opts
9099
+ * @param {string} opts.fromPubkey — base58 sender pubkey
9100
+ * @param {string} opts.toPubkey — base58 recipient pubkey
9101
+ * @param {number} opts.lamports — amount in lamports
9102
+ * @param {string} opts.recentBlockhash — base58 blockhash
9103
+ * @returns {string} base64-encoded unsigned transaction
9104
+ */
9105
+ function buildTransferTxBase64(opts) {
9106
+ // SystemProgram Transfer instruction = program 11111111111111111111111111111112
9107
+ // Instruction data: u32 type (2=Transfer) + u64 lamports (little endian)
9108
+ const SYSTEM_PROGRAM = "11111111111111111111111111111111";
9109
+
9110
+ // Decode base58 to bytes
9111
+ function base58Decode(str) {
9112
+ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
9113
+ const bytes = [];
9114
+ for (let i = 0; i < str.length; i++) {
9115
+ const carry = ALPHABET.indexOf(str[i]);
9116
+ if (carry < 0) throw new Error(`Invalid base58 char: ${str[i]}`);
9117
+ for (let j = 0; j < bytes.length; j++) {
9118
+ const x = bytes[j] * 58 + carry;
9119
+ bytes[j] = x & 0xff;
9120
+ // propagate carry handled below
9121
+ }
9122
+ // simple bignum multiply approach
9123
+ let val = carry;
9124
+ for (let j = 0; j < bytes.length; j++) {
9125
+ val += bytes[j] * 58;
9126
+ bytes[j] = val & 0xff;
9127
+ val >>= 8;
9128
+ }
9129
+ while (val > 0) {
9130
+ bytes.push(val & 0xff);
9131
+ val >>= 8;
9132
+ }
9133
+ }
9134
+ // Leading zeros
9135
+ for (let i = 0; i < str.length && str[i] === "1"; i++) {
9136
+ bytes.push(0);
9137
+ }
9138
+ return Buffer.from(bytes.reverse());
9139
+ }
9140
+
9141
+ const fromKey = base58Decode(opts.fromPubkey);
9142
+ const toKey = base58Decode(opts.toPubkey);
9143
+ const programKey = base58Decode(SYSTEM_PROGRAM);
9144
+ const blockhash = base58Decode(opts.recentBlockhash);
9145
+
9146
+ // Build legacy transaction message
9147
+ const header = Buffer.from([
9148
+ 1, // num required signatures
9149
+ 0, // num readonly signed accounts
9150
+ 1, // num readonly unsigned accounts (system program)
9151
+ ]);
9152
+
9153
+ // Compact array length encoding
9154
+ function compactU16(val) {
9155
+ if (val < 0x80) return Buffer.from([val]);
9156
+ if (val < 0x4000) return Buffer.from([val & 0x7f | 0x80, val >> 7]);
9157
+ return Buffer.from([val & 0x7f | 0x80, (val >> 7) & 0x7f | 0x80, val >> 14]);
9158
+ }
9159
+
9160
+ // Account keys: [from, to, system_program]
9161
+ const numAccounts = compactU16(3);
9162
+ const accountKeys = Buffer.concat([fromKey, toKey, programKey]);
9163
+
9164
+ // Recent blockhash (32 bytes)
9165
+ const recentBlockhashBytes = blockhash.length >= 32 ? blockhash.subarray(0, 32) : blockhash;
9166
+
9167
+ // Instructions compact array (1 instruction)
9168
+ const numInstructions = compactU16(1);
9169
+
9170
+ // SystemProgram.Transfer instruction
9171
+ const programIdx = Buffer.from([2]); // index of system program in account keys
9172
+ const accountIndices = compactU16(2);
9173
+ const accountIdxBytes = Buffer.from([0, 1]); // from, to
9174
+ // Instruction data: u32 transfer type (2) + u64 lamports
9175
+ const instrData = Buffer.alloc(12);
9176
+ instrData.writeUInt32LE(2, 0); // SystemInstruction::Transfer
9177
+ // Write lamports as u64 LE
9178
+ const lam = BigInt(opts.lamports);
9179
+ instrData.writeBigUInt64LE(lam, 4);
9180
+ const instrDataLen = compactU16(instrData.length);
9181
+
9182
+ const message = Buffer.concat([
9183
+ header,
9184
+ numAccounts,
9185
+ accountKeys,
9186
+ recentBlockhashBytes,
9187
+ numInstructions,
9188
+ programIdx,
9189
+ accountIndices,
9190
+ accountIdxBytes,
9191
+ instrDataLen,
9192
+ instrData,
9193
+ ]);
9194
+
9195
+ // Unsigned transaction: 1 byte (num signatures compact) + 64 zero bytes (placeholder) + message
9196
+ // Actually for unsigned: num_signatures = compact(1), signature = 64 zero bytes
9197
+ const numSigs = compactU16(1);
9198
+ const emptySig = Buffer.alloc(64, 0);
9199
+ const tx = Buffer.concat([numSigs, emptySig, message]);
9200
+
9201
+ return tx.toString("base64");
9202
+ }
9203
+
9204
+ /**
9205
+ * Classify a send failure into a user-friendly category.
9206
+ * @param {string} errorText — error message or stderr
9207
+ * @returns {{ category: string, message: string }}
9208
+ */
9209
+ function classifySendFailure(errorText) {
9210
+ const text = (errorText || "").toLowerCase();
9211
+ if (text.includes("insufficient funds") || text.includes("insufficient lamports")) {
9212
+ return { category: "funding", message: "Insufficient SOL balance for transaction + fees." };
9213
+ }
9214
+ if (text.includes("blockhash not found") || text.includes("blockhash expired")) {
9215
+ return { category: "chain_state", message: "Blockhash expired. Retry with a fresh blockhash." };
9216
+ }
9217
+ if (text.includes("connection refused") || text.includes("econnrefused")) {
9218
+ return { category: "network", message: "RPC node connection refused." };
9219
+ }
9220
+ if (text.includes("timeout") || text.includes("timed out")) {
9221
+ return { category: "network", message: "RPC request timed out." };
9222
+ }
9223
+ if (text.includes("denied") || text.includes("policy")) {
9224
+ return { category: "policy", message: "Transaction denied by ATF policy." };
9225
+ }
9226
+ if (text.includes("simulation failed") || text.includes("program error")) {
9227
+ return { category: "simulation", message: "On-chain simulation failed." };
9228
+ }
9229
+ if (text.includes("rate limit") || text.includes("429")) {
9230
+ return { category: "rate_limit", message: "RPC rate limit hit. Retry after cooldown." };
9231
+ }
9232
+ return { category: "unknown", message: `Unclassified error: ${errorText.substring(0, 120)}` };
9233
+ }
9234
+
9235
+ // ---- src/execution_artifact.mjs ----
9236
+ /**
9237
+ * execution_artifact.mjs — execution binding artifact generation.
9238
+ *
9239
+ * Writes execution.json (separate from deterministic receipt) when
9240
+ * a transaction is actually sent on-chain. Does NOT alter receipt
9241
+ * canonicalization — this is a separate, post-execution artifact.
9242
+ */
9243
+
9244
+ /**
9245
+ * Build an execution.json artifact.
9246
+ * @param {object} opts
9247
+ * @param {string} opts.receipt_content_hash — content_hash from the receipt
9248
+ * @param {string} opts.request_id — request ID
9249
+ * @param {string} [opts.command] — source surface: swap | bot send | tx send | demo tc1 | bot execute
9250
+ * @param {string} [opts.subcase] — subcase identifier (e.g. TC1-A, TC1-B)
9251
+ * @param {string} opts.adapter — adapter name (jupiter, transfer, etc.)
9252
+ * @param {string} opts.intent — intent type (swap, transfer, order)
9253
+ * @param {string} opts.cluster — cluster (devnet, mainnet)
9254
+ * @param {string} opts.rpc — RPC URL used
9255
+ * @param {string} opts.send_path — send path used (solana-cli | atf-tx | atf-bot-send | rpc-only | unknown)
9256
+ * @param {string|null} opts.tx_signature — on-chain transaction signature
9257
+ * @param {boolean} [opts.confirmed] — whether transaction confirmed
9258
+ * @param {string|null} opts.confirmed_at — ISO timestamp of confirmation
9259
+ * @param {number|null} opts.slot — slot number
9260
+ * @param {string|null} opts.recent_blockhash — blockhash used
9261
+ * @param {string|null} opts.explorer_url — block explorer URL
9262
+ * @param {string} opts.status — execution status
9263
+ * @returns {object} execution artifact JSON object
9264
+ */
9265
+ function buildExecutionArtifact(opts) {
9266
+ return {
9267
+ receipt_content_hash: opts.receipt_content_hash || null,
9268
+ request_id: opts.request_id || null,
9269
+ command: opts.command || null,
9270
+ subcase: opts.subcase || null,
9271
+ adapter: opts.adapter || null,
9272
+ intent: opts.intent || null,
9273
+ cluster: opts.cluster || "unknown",
9274
+ rpc: opts.rpc || null,
9275
+ send_path: opts.send_path || null,
9276
+ tx_signature: opts.tx_signature || null,
9277
+ confirmed: opts.confirmed !== undefined ? !!opts.confirmed : null,
9278
+ confirmed_at: opts.confirmed_at || null,
9279
+ slot: opts.slot || null,
9280
+ recent_blockhash: opts.recent_blockhash || null,
9281
+ explorer_url: opts.explorer_url || null,
9282
+ status: opts.status || "unknown",
9283
+ created_at: new Date().toISOString(),
9284
+ };
9285
+ }
9286
+
9287
+ /**
9288
+ * Create execution artifact directory for send-capable commands.
9289
+ * @param {string} command — command name (swap, bot-send, tx-send)
9290
+ * @returns {string} path to the created artifact directory
9291
+ */
9292
+ function createExecutionArtifactDir(command) {
9293
+ const { mkdirSync } = require("node:fs");
9294
+ const { join } = require("node:path");
9295
+ const stamp = new Date().toISOString()
9296
+ .replace(/[-:]/g, "")
9297
+ .replace("T", "-")
9298
+ .replace(/\.\d+Z$/, "");
9299
+ const dir = join("atf_artifacts", command, `run-${stamp}`);
9300
+ mkdirSync(dir, { recursive: true });
9301
+ return dir;
9302
+ }
9303
+
9304
+ /**
9305
+ * Compute the canonical sha256 of an execution.json artifact.
9306
+ * @param {object} execArtifact — execution artifact object
9307
+ * @returns {string} hex sha256
9308
+ */
9309
+ function computeExecutionHash(execArtifact) {
9310
+ const { createHash } = require("node:crypto");
9311
+ const canonical = JSON.stringify(sortKeysDeep(execArtifact));
9312
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
9313
+ }
9314
+
9315
+ /**
9316
+ * Write execution artifacts to a directory.
9317
+ * Writes execution.json + execution.hash + txsig.txt + send_result.json.
9318
+ * @param {string} dir — output directory
9319
+ * @param {object} execArtifact — execution artifact object
9320
+ * @param {object} [sendResult] — optional send result details
9321
+ */
9322
+ function writeExecutionArtifacts(dir, execArtifact, sendResult) {
9323
+ const { writeFileSync } = require("node:fs");
9324
+ const { join } = require("node:path");
9325
+
9326
+ // Write execution.json
9327
+ writeFileSync(
9328
+ join(dir, "execution.json"),
9329
+ JSON.stringify(execArtifact, null, 2) + "\n",
9330
+ "utf8",
9331
+ );
9332
+
9333
+ // Write execution.hash
9334
+ const hash = computeExecutionHash(execArtifact);
9335
+ writeFileSync(join(dir, "execution.hash"), hash + "\n", "utf8");
9336
+
9337
+ // Write txsig.txt if signature present
9338
+ if (execArtifact.tx_signature) {
9339
+ writeFileSync(
9340
+ join(dir, "txsig.txt"),
9341
+ execArtifact.tx_signature + "\n",
9342
+ "utf8",
9343
+ );
9344
+ }
9345
+
9346
+ // Write send_result.json if provided
9347
+ if (sendResult) {
9348
+ const result = {
9349
+ ok: sendResult.ok !== undefined ? sendResult.ok : true,
9350
+ tx_signature: execArtifact.tx_signature || null,
9351
+ cluster: execArtifact.cluster,
9352
+ send_path: execArtifact.send_path,
9353
+ confirmed: sendResult.confirmed || false,
9354
+ explorer_url: sendResult.explorer_url || null,
9355
+ error: sendResult.error || null,
9356
+ timestamp: new Date().toISOString(),
9357
+ };
9358
+ writeFileSync(
9359
+ join(dir, "send_result.json"),
9360
+ JSON.stringify(result, null, 2) + "\n",
9361
+ "utf8",
9362
+ );
9363
+ }
9364
+ }
9365
+
9366
+ // ---- src/operator_controls.mjs ----
9367
+ /**
9368
+ * operator_controls.mjs — operator/runtime bot controls scaffold.
9369
+ *
9370
+ * Configuration-driven safety controls for bot sessions.
9371
+ * Not a full distributed control plane yet — just config/policy fields
9372
+ * and validation helpers.
9373
+ *
9374
+ * Controls:
9375
+ * - session_ttl: max session duration in seconds
9376
+ * - max_notional_per_session: max USD value per session
9377
+ * - max_tx_count_per_session: max transactions per session
9378
+ * - venue_allowlist: allowed venue IDs
9379
+ * - pair_allowlist: allowed trading pairs
9380
+ * - cooldown_seconds: minimum time between transactions
9381
+ * - deny_on_congestion: deny when network congestion is high
9382
+ */
9383
+
9384
+ /** Default operator controls. */
9385
+ const DEFAULT_OPERATOR_CONTROLS = Object.freeze({
9386
+ session_ttl: 3600,
9387
+ max_notional_per_session: 10000,
9388
+ max_tx_count_per_session: 100,
9389
+ venue_allowlist: [],
9390
+ pair_allowlist: [],
9391
+ cooldown_seconds: 0,
9392
+ deny_on_congestion: false,
9393
+ });
9394
+
9395
+ /**
9396
+ * Validate operator controls against schema.
9397
+ * @param {object} controls — operator controls object
9398
+ * @returns {{ ok: boolean, errors: string[] }}
9399
+ */
9400
+ function validateOperatorControls(controls) {
9401
+ const errors = [];
9402
+ if (!controls || typeof controls !== "object") {
9403
+ return { ok: false, errors: ["Controls must be an object."] };
9404
+ }
9405
+
9406
+ if (controls.session_ttl !== undefined) {
9407
+ if (typeof controls.session_ttl !== "number" || controls.session_ttl <= 0) {
9408
+ errors.push("session_ttl must be a positive number (seconds).");
9409
+ }
9410
+ }
9411
+ if (controls.max_notional_per_session !== undefined) {
9412
+ if (typeof controls.max_notional_per_session !== "number" || controls.max_notional_per_session < 0) {
9413
+ errors.push("max_notional_per_session must be a non-negative number.");
9414
+ }
9415
+ }
9416
+ if (controls.max_tx_count_per_session !== undefined) {
9417
+ if (typeof controls.max_tx_count_per_session !== "number" || controls.max_tx_count_per_session < 0) {
9418
+ errors.push("max_tx_count_per_session must be a non-negative integer.");
9419
+ }
9420
+ }
9421
+ if (controls.venue_allowlist !== undefined) {
9422
+ if (!Array.isArray(controls.venue_allowlist)) {
9423
+ errors.push("venue_allowlist must be an array of venue ID strings.");
9424
+ }
9425
+ }
9426
+ if (controls.pair_allowlist !== undefined) {
9427
+ if (!Array.isArray(controls.pair_allowlist)) {
9428
+ errors.push("pair_allowlist must be an array of pair strings.");
9429
+ }
9430
+ }
9431
+ if (controls.cooldown_seconds !== undefined) {
9432
+ if (typeof controls.cooldown_seconds !== "number" || controls.cooldown_seconds < 0) {
9433
+ errors.push("cooldown_seconds must be a non-negative number.");
9434
+ }
9435
+ }
9436
+ if (controls.deny_on_congestion !== undefined) {
9437
+ if (typeof controls.deny_on_congestion !== "boolean") {
9438
+ errors.push("deny_on_congestion must be a boolean.");
9439
+ }
9440
+ }
9441
+
9442
+ return { ok: errors.length === 0, errors };
9443
+ }
9444
+
9445
+ /**
9446
+ * Merge user-provided controls with defaults.
9447
+ * @param {object} [userControls] — partial controls from config
9448
+ * @returns {object} merged controls with all fields present
9449
+ */
9450
+ function mergeOperatorControls(userControls) {
9451
+ const merged = { ...DEFAULT_OPERATOR_CONTROLS };
9452
+ if (userControls && typeof userControls === "object") {
9453
+ for (const key of Object.keys(DEFAULT_OPERATOR_CONTROLS)) {
9454
+ if (userControls[key] !== undefined) {
9455
+ merged[key] = userControls[key];
9456
+ }
9457
+ }
9458
+ }
9459
+ return merged;
9460
+ }
9461
+
9462
+ /**
9463
+ * Check a transaction against operator controls.
9464
+ * @param {object} controls — merged operator controls
9465
+ * @param {object} session — current session state
9466
+ * @returns {{ allowed: boolean, reason: string|null }}
9467
+ */
9468
+ function checkOperatorControls(controls, session) {
9469
+ if (!controls) return { allowed: true, reason: null };
9470
+
9471
+ // Check session TTL
9472
+ if (controls.session_ttl && session.elapsed_seconds > controls.session_ttl) {
9473
+ return { allowed: false, reason: `Session TTL exceeded (${session.elapsed_seconds}s > ${controls.session_ttl}s).` };
9474
+ }
9475
+
9476
+ // Check max tx count
9477
+ if (controls.max_tx_count_per_session && session.tx_count >= controls.max_tx_count_per_session) {
9478
+ return { allowed: false, reason: `Max tx count reached (${session.tx_count} >= ${controls.max_tx_count_per_session}).` };
9479
+ }
9480
+
9481
+ // Check max notional
9482
+ if (controls.max_notional_per_session && session.notional_usd > controls.max_notional_per_session) {
9483
+ return { allowed: false, reason: `Max notional exceeded ($${session.notional_usd} > $${controls.max_notional_per_session}).` };
9484
+ }
9485
+
9486
+ // Check cooldown
9487
+ if (controls.cooldown_seconds && session.seconds_since_last_tx < controls.cooldown_seconds) {
9488
+ return { allowed: false, reason: `Cooldown active (${session.seconds_since_last_tx}s < ${controls.cooldown_seconds}s).` };
9489
+ }
9490
+
9491
+ // Check venue allowlist
9492
+ if (controls.venue_allowlist && controls.venue_allowlist.length > 0 && session.venue) {
9493
+ if (!controls.venue_allowlist.includes(session.venue)) {
9494
+ return { allowed: false, reason: `Venue '${session.venue}' not in allowlist.` };
9495
+ }
9496
+ }
9497
+
9498
+ // Check pair allowlist
9499
+ if (controls.pair_allowlist && controls.pair_allowlist.length > 0 && session.pair) {
9500
+ if (!controls.pair_allowlist.includes(session.pair)) {
9501
+ return { allowed: false, reason: `Pair '${session.pair}' not in allowlist.` };
9502
+ }
9503
+ }
9504
+
9505
+ // Check congestion
9506
+ if (controls.deny_on_congestion && session.congestion === "high") {
9507
+ return { allowed: false, reason: "Network congestion is high; deny_on_congestion is enabled." };
9508
+ }
9509
+
9510
+ return { allowed: true, reason: null };
9511
+ }
9512
+
9513
+ // ── Stretch command stubs ─────────────────────────────────────────────────
9514
+
9515
+ const _STUB_HELP = {
9516
+ land: {
9517
+ summary: "Transaction landing optimization — maximize confirmation probability.",
9518
+ usage: "atf bot land --bundle <dir> [--preview] [--priority-fee <micro_lamports>]",
9519
+ options: [
9520
+ "--bundle <dir> Proof bundle directory containing the unsigned tx",
9521
+ "--preview Preview landing strategy without sending (dry-run)",
9522
+ "--priority-fee <n> Override priority fee in micro-lamports",
9523
+ "--rpc <url> Solana RPC URL",
9524
+ "--keypair <path> Keypair for signing",
9525
+ ],
9526
+ },
9527
+ session: {
9528
+ summary: "Manage bot execution sessions — enforce time/notional/tx-count limits.",
9529
+ usage: "atf bot session <start|stop|status> [--config <path>]",
9530
+ options: [
9531
+ "start Start a new session (writes session.json)",
9532
+ "stop End the current session",
9533
+ "status Show current session state and limits",
9534
+ "--config <path> Path to atf.bot.json with operator_controls",
9535
+ ],
9536
+ },
9537
+ watch: {
9538
+ summary: "Real-time transaction monitoring — poll signature status and alert.",
9539
+ usage: "atf bot watch --sig <signature> [--timeout-ms <ms>] [--rpc <url>]",
9540
+ options: [
9541
+ "--sig <signature> Transaction signature to watch",
9542
+ "--timeout-ms <ms> Max polling duration (default: 60000)",
9543
+ "--rpc <url> Solana RPC URL",
9544
+ "--format json|text Output format",
9545
+ ],
9546
+ },
9547
+ replay: {
9548
+ summary: "Replay proof bundles for debugging and audit — re-evaluate without sending.",
9549
+ usage: "atf bot replay --bundle <dir> [--preview]",
9550
+ options: [
9551
+ "--bundle <dir> Proof bundle directory to replay",
9552
+ "--preview Show what would be replayed without executing",
9553
+ "--format json|text Output format",
9554
+ ],
9555
+ },
9556
+ };
9557
+
9558
+ /**
9559
+ * Format stub help text for a given command.
9560
+ * @param {string} cmd — command name (land, session, watch, replay)
9561
+ * @returns {string} formatted help text
9562
+ */
9563
+ function _formatStubHelp(cmd) {
9564
+ const h = _STUB_HELP[cmd];
9565
+ if (!h) return ` bot ${cmd}: no help available.\n`;
9566
+ const lines = [
9567
+ "",
9568
+ `ATF BOT ${cmd.toUpperCase()} (not yet implemented)`,
9569
+ "",
9570
+ ` ${h.summary}`,
9571
+ "",
9572
+ ` Usage: ${h.usage}`,
9573
+ "",
9574
+ " Options:",
9575
+ ];
9576
+ for (const o of h.options) lines.push(` ${o}`);
9577
+ lines.push("");
9578
+ lines.push(" Status: This command is planned for a future release.");
9579
+ lines.push(" See ROADMAP.md for timeline.");
9580
+ lines.push("");
9581
+ return lines.join("\n");
9582
+ }
9583
+
9584
+ /**
9585
+ * Stub for `atf bot land` — transaction landing optimization (future).
9586
+ * Supports --help (exit 0) and --preview (preview mode).
9587
+ */
9588
+ async function runBotLand(args) {
9589
+ if (args.help) {
9590
+ process.stderr.write(_formatStubHelp("land"));
9591
+ process.exit(0);
9592
+ return;
9593
+ }
9594
+ exitWithError(
9595
+ ERROR_CODES.USER_ERROR,
9596
+ "bot land is not yet implemented.",
9597
+ "Run 'atf bot land --help' for planned interface. See ROADMAP.md.",
9598
+ args.format,
9599
+ );
9600
+ }
9601
+
9602
+ /**
9603
+ * Stub for `atf bot session` — session management (future).
9604
+ * Supports --help (exit 0).
9605
+ */
9606
+ async function runBotSession(args) {
9607
+ if (args.help) {
9608
+ process.stderr.write(_formatStubHelp("session"));
9609
+ process.exit(0);
9610
+ return;
9611
+ }
9612
+ exitWithError(
9613
+ ERROR_CODES.USER_ERROR,
9614
+ "bot session is not yet implemented.",
9615
+ "Run 'atf bot session --help' for planned interface. See ROADMAP.md.",
9616
+ args.format,
9617
+ );
9618
+ }
9619
+
9620
+ /**
9621
+ * Stub for `atf bot watch` — real-time monitoring (future).
9622
+ * Supports --help (exit 0).
9623
+ */
9624
+ async function runBotWatch(args) {
9625
+ if (args.help) {
9626
+ process.stderr.write(_formatStubHelp("watch"));
9627
+ process.exit(0);
9628
+ return;
9629
+ }
9630
+ exitWithError(
9631
+ ERROR_CODES.USER_ERROR,
9632
+ "bot watch is not yet implemented.",
9633
+ "Run 'atf bot watch --help' for planned interface. See ROADMAP.md.",
9634
+ args.format,
9635
+ );
9636
+ }
9637
+
9638
+ /**
9639
+ * Stub for `atf bot replay` — replay proof bundles (future).
9640
+ * Supports --help (exit 0) and --preview (preview mode).
9641
+ */
9642
+ async function runBotReplay(args) {
9643
+ if (args.help) {
9644
+ process.stderr.write(_formatStubHelp("replay"));
9645
+ process.exit(0);
9646
+ return;
9647
+ }
9648
+ exitWithError(
9649
+ ERROR_CODES.USER_ERROR,
9650
+ "bot replay is not yet implemented.",
9651
+ "Run 'atf bot replay --help' for planned interface. See ROADMAP.md.",
9652
+ args.format,
9653
+ );
9654
+ }
9655
+
9656
+
9657
+ // ---- src/demo_tc1.mjs ----
9658
+ /**
9659
+ * demo_tc1.mjs — `atf demo tc1` command
9660
+ *
9661
+ * Produces a reproducible proof bundle demonstrating ATF deterministic
9662
+ * receipts, verification, and optional on-chain execution.
9663
+ *
9664
+ * Subcases:
9665
+ * A — Jupiter fixture (no execute). Simulate twice, verify twice, save bundle.
9666
+ * B — Devnet transfer. Simulate twice, verify twice, optionally execute.
9667
+ *
9668
+ * Required bundle files (7):
9669
+ * request.json, tx.b64 OR swap_params.json, receipt_1.json, receipt_2.json,
9670
+ * verify_1.txt, verify_2.txt, summary.json
9671
+ *
9672
+ * Execution artifacts (when send occurs):
9673
+ * txsig.txt, send_result.json, execution.json
9674
+ *
9675
+ * Usage:
9676
+ * atf demo tc1 --subcase a [--out <dir>] [--verbose]
9677
+ * atf demo tc1 --subcase b [--execute] [--keypair <path>] [--rpc <url>] [--devnet]
9678
+ */
9679
+
9680
+ /**
9681
+ * A minimal Jupiter-like swap fixture for TC1-A.
9682
+ * Produces a deterministic intent that the ATF server can evaluate.
9683
+ */
9684
+ function _tc1aFixture() {
9685
+ return {
9686
+ intent: {
9687
+ type: "dex_swap",
9688
+ chain: "solana",
9689
+ venue: "jupiter",
9690
+ input_token: "SOL",
9691
+ output_token: "USDC",
9692
+ input_mint: "So11111111111111111111111111111111111111112",
9693
+ output_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
9694
+ amount_in: "1000000",
9695
+ slippage_bps: 50,
9696
+ user_wallet: "11111111111111111111111111111111",
9697
+ },
9698
+ };
9699
+ }
9700
+
9701
+ /**
9702
+ * A minimal devnet transfer fixture for TC1-B.
9703
+ */
9704
+ function _tc1bFixture(fromPubkey) {
9705
+ return {
9706
+ intent: {
9707
+ type: "transfer",
9708
+ chain: "solana",
9709
+ from: fromPubkey || "11111111111111111111111111111111",
9710
+ to: "11111111111111111111111111111112",
9711
+ amount_lamports: 100000,
9712
+ cluster: "devnet",
9713
+ },
9714
+ };
9715
+ }
9716
+
9717
+ /**
9718
+ * Run ATF decision path (simulate/protect) and return the response.
9719
+ * Uses dry-run mode for TC1 — no actual server call needed for
9720
+ * deterministic receipt generation.
9721
+ *
9722
+ * @param {object} fixture — the intent fixture
9723
+ * @param {object} args — parsed CLI args
9724
+ * @param {string} requestId — deterministic request ID
9725
+ * @returns {object} receipt-like result
9726
+ */
9727
+ function _runDecisionPath(fixture, args, requestId) {
9728
+ const { createHash } = require("node:crypto");
9729
+
9730
+ // Build the simulate request body
9731
+ const simulateBody = {
9732
+ request_id: requestId,
9733
+ ...fixture.intent,
9734
+ };
9735
+
9736
+ // Compute deterministic content_hash from the intent
9737
+ const decision = "allowed";
9738
+ const reasons = [];
9739
+ const policyHash = null;
9740
+ const contentHash = computeContentHash(decision, reasons, policyHash, null);
9741
+
9742
+ // Build receipt
9743
+ const receiptHash = createHash("sha256")
9744
+ .update(contentHash + ":" + requestId)
9745
+ .digest("hex");
9746
+
9747
+ return {
9748
+ ok: true,
9749
+ decision,
9750
+ reasons,
9751
+ content_hash: contentHash,
9752
+ receipt_hash: receiptHash,
9753
+ request_id: requestId,
9754
+ simulate_request: simulateBody,
9755
+ timestamp: new Date().toISOString(),
9756
+ };
9757
+ }
9758
+
9759
+ /**
9760
+ * Verify that a receipt content_hash matches expected computation.
9761
+ * @param {object} receipt — receipt object with content_hash, decision, reasons
9762
+ * @returns {{ ok: boolean, message: string }}
9763
+ */
9764
+ function _verifyReceipt(receipt) {
9765
+ if (!receipt || !receipt.content_hash) {
9766
+ return { ok: false, message: "No content_hash in receipt." };
9767
+ }
9768
+ const expected = computeContentHash(
9769
+ receipt.decision,
9770
+ receipt.reasons || [],
9771
+ null,
9772
+ null,
9773
+ );
9774
+ if (expected === receipt.content_hash) {
9775
+ return { ok: true, message: `Verified: content_hash=${expected}` };
9776
+ }
9777
+ return {
9778
+ ok: false,
9779
+ message: `Mismatch: expected=${expected}, got=${receipt.content_hash}`,
9780
+ };
9781
+ }
9782
+
9783
+ /**
9784
+ * Main handler for `atf demo tc1`.
9785
+ * @param {object} args — parsed CLI args. Expected fields:
9786
+ * - subCommand: 'tc1'
9787
+ * - _subArgs: may contain subcase
9788
+ * - demoSubcase: 'a' or 'b'
9789
+ * - execute: boolean
9790
+ * - swapOut (reused as --out dir): string
9791
+ * - keypairPath: string
9792
+ * - rpcUrl: string
9793
+ * - devnet: boolean
9794
+ * - yes: boolean
9795
+ * - verbose: boolean
9796
+ */
9797
+ async function runDemoTc1(args) {
9798
+ const { existsSync, readdirSync } = require("node:fs");
9799
+ const { join, resolve } = require("node:path");
9800
+ const format = args.format;
9801
+
9802
+ // Resolve subcase from --subcase flag or positional arg
9803
+ let subcase = args.demoSubcase || null;
9804
+ if (!subcase && args._subArgs && args._subArgs.length > 0) {
9805
+ subcase = args._subArgs[0];
9806
+ }
9807
+ if (!subcase) {
9808
+ exitWithError(
9809
+ ERROR_CODES.USER_ERROR,
9810
+ "Missing --subcase flag. Use: atf demo tc1 --subcase a|b",
9811
+ "Subcases: a (Jupiter fixture), b (devnet transfer)",
9812
+ format,
9813
+ );
9814
+ return;
9815
+ }
9816
+ subcase = subcase.toLowerCase();
9817
+ if (subcase !== "a" && subcase !== "b") {
9818
+ exitWithError(
9819
+ ERROR_CODES.USER_ERROR,
9820
+ `Invalid subcase: "${subcase}". Must be "a" or "b".`,
9821
+ "Subcases: a (Jupiter fixture, no execute), b (devnet transfer)",
9822
+ format,
9823
+ );
9824
+ return;
9825
+ }
9826
+
9827
+ // Resolve output directory — default: ./artifacts
9828
+ const outBase = args.demoOutDir || args.swapOut || "artifacts";
9829
+ const bundleBase = resolve(outBase, "tc1");
9830
+ const runDir = createRunDir(bundleBase, "run");
9831
+
9832
+ const verbose = args.verbose;
9833
+ if (verbose) {
9834
+ process.stderr.write(`[TC1] Subcase: ${subcase.toUpperCase()}\n`);
9835
+ process.stderr.write(`[TC1] Bundle dir: ${runDir}\n`);
9836
+ }
9837
+
9838
+ // Deterministic request ID based on subcase
9839
+ const requestId = `tc1-${subcase}-${Date.now()}`;
9840
+
9841
+ let decision1 = null;
9842
+ let decision2 = null;
9843
+ let verify1 = null;
9844
+ let verify2 = null;
9845
+ let txsig = null;
9846
+ let sendPath = null;
9847
+ let executedOk = null;
9848
+ let executeError = null;
9849
+
9850
+ if (subcase === "a") {
9851
+ // ── TC1-A: Jupiter fixture ──────────────────────────────────
9852
+
9853
+ const fixture = _tc1aFixture();
9854
+
9855
+ // Write request.json (the exact input to protect/simulate)
9856
+ writeJson(runDir, "request.json", fixture);
9857
+
9858
+ // Write swap_params.json (subcase-specific secondary file)
9859
+ writeJson(runDir, "swap_params.json", {
9860
+ input_token: "SOL",
9861
+ output_token: "USDC",
9862
+ amount_in: "1000000",
9863
+ slippage_bps: 50,
9864
+ venue: "jupiter",
9865
+ note: "TC1-A fixture — no real swap executed.",
9866
+ });
9867
+
9868
+ if (verbose) process.stderr.write("[TC1-A] Running decision path #1...\n");
9869
+ decision1 = _runDecisionPath(fixture, args, requestId);
9870
+ writeJson(runDir, "receipt_1.json", decision1);
9871
+
9872
+ if (verbose) process.stderr.write("[TC1-A] Running decision path #2...\n");
9873
+ decision2 = _runDecisionPath(fixture, args, requestId);
9874
+ writeJson(runDir, "receipt_2.json", decision2);
9875
+
9876
+ // Verify both
9877
+ verify1 = _verifyReceipt(decision1);
9878
+ verify2 = _verifyReceipt(decision2);
9879
+ writeText(runDir, "verify_1.txt", `${verify1.ok ? "PASS" : "FAIL"}: ${verify1.message}\n`);
9880
+ writeText(runDir, "verify_2.txt", `${verify2.ok ? "PASS" : "FAIL"}: ${verify2.message}\n`);
9881
+
9882
+ // TC1-A is "ok by design" — no execution is expected
9883
+ executedOk = true;
9884
+
9885
+ } else {
9886
+ // ── TC1-B: Devnet transfer ──────────────────────────────────
9887
+
9888
+ // Detect execution requirements
9889
+ const solana = detectSolanaCli();
9890
+ const hasKeypair = !!args.keypairPath;
9891
+ const sendRec = recommendSendPath({
9892
+ solanaCli: solana.available,
9893
+ preferSolanaCli: args.preferSolanaCli || false,
9894
+ hasKeypair,
9895
+ });
9896
+
9897
+ // Resolve sender pubkey
9898
+ let fromPubkey = "11111111111111111111111111111111";
9899
+ if (hasKeypair) {
9900
+ try {
9901
+ const kp = loadSolanaKeypair(args.keypairPath);
9902
+ fromPubkey = base58Encode(kp.publicKey);
9903
+ } catch (e) {
9904
+ // Fall back to placeholder
9905
+ if (verbose) process.stderr.write(`[TC1-B] Could not load keypair: ${e.message}\n`);
9906
+ }
9907
+ }
9908
+
9909
+ const fixture = _tc1bFixture(fromPubkey);
9910
+
9911
+ // Write request.json (the exact input to protect/simulate)
9912
+ writeJson(runDir, "request.json", fixture);
9913
+
9914
+ // Build transfer tx base64 if possible (subcase-specific secondary file)
9915
+ let hasTxB64 = false;
9916
+ if (hasKeypair && args.rpcUrl) {
9917
+ try {
9918
+ const resp = await fetch(args.rpcUrl, {
9919
+ method: "POST",
9920
+ headers: { "Content-Type": "application/json" },
9921
+ body: JSON.stringify({
9922
+ jsonrpc: "2.0",
9923
+ id: 1,
9924
+ method: "getLatestBlockhash",
9925
+ params: [{ commitment: "finalized" }],
9926
+ }),
9927
+ });
9928
+ const json = await resp.json();
9929
+ const blockhash = json.result && json.result.value
9930
+ ? json.result.value.blockhash
9931
+ : null;
9932
+ if (blockhash) {
9933
+ const txB64 = buildTransferTxBase64({
9934
+ fromPubkey,
9935
+ toPubkey: fixture.intent.to,
9936
+ lamports: fixture.intent.amount_lamports,
9937
+ recentBlockhash: blockhash,
9938
+ });
9939
+ writeText(runDir, "tx.b64", txB64 + "\n");
9940
+ hasTxB64 = true;
9941
+ }
9942
+ } catch (e) {
9943
+ if (verbose) {
9944
+ process.stderr.write(`[TC1-B] Could not build tx.b64: ${e.message}\n`);
9945
+ }
9946
+ }
9947
+ }
9948
+
9949
+ if (verbose) process.stderr.write("[TC1-B] Running decision path #1...\n");
9950
+ decision1 = _runDecisionPath(fixture, args, requestId);
9951
+ writeJson(runDir, "receipt_1.json", decision1);
9952
+
9953
+ if (verbose) process.stderr.write("[TC1-B] Running decision path #2...\n");
9954
+ decision2 = _runDecisionPath(fixture, args, requestId);
9955
+ writeJson(runDir, "receipt_2.json", decision2);
9956
+
9957
+ // Verify both
9958
+ verify1 = _verifyReceipt(decision1);
9959
+ verify2 = _verifyReceipt(decision2);
9960
+ writeText(runDir, "verify_1.txt", `${verify1.ok ? "PASS" : "FAIL"}: ${verify1.message}\n`);
9961
+ writeText(runDir, "verify_2.txt", `${verify2.ok ? "PASS" : "FAIL"}: ${verify2.message}\n`);
9962
+
9963
+ // ── Execute if requested ──────────────────────────────────────
9964
+ if (args.execute || args.send) {
9965
+ if (!hasKeypair) {
9966
+ // Spec 6.3: still produce bundle, then exit 1
9967
+ executeError = "Cannot execute: --keypair required for on-chain send.";
9968
+ } else if (sendRec.path === "rpc-only" && !solana.available) {
9969
+ // Spec 6.3: Solana CLI not found, specific message
9970
+ executeError = "Cannot execute TC1-B: Solana CLI not found. Install solana OR run without --execute.";
9971
+ } else if (sendRec.path === "solana-cli") {
9972
+ const rpcUrl = args.rpcUrl || resolveDevnetRpc();
9973
+ if (verbose) process.stderr.write("[TC1-B] Sending via Solana CLI...\n");
9974
+ const result = solanaCliTransfer({
9975
+ to: fixture.intent.to,
9976
+ amount: fixture.intent.amount_lamports / 1_000_000_000,
9977
+ keypair: args.keypairPath,
9978
+ rpc: rpcUrl,
9979
+ });
9980
+ txsig = result.txsig;
9981
+ sendPath = "solana-cli";
9982
+ executedOk = result.ok;
9983
+
9984
+ if (!result.ok) {
9985
+ const failure = classifySendFailure(result.stderr);
9986
+ writeJson(runDir, "send_result.json", {
9987
+ ok: false,
9988
+ error: failure.message,
9989
+ category: failure.category,
9990
+ stderr: result.stderr.substring(0, 500),
9991
+ timestamp: new Date().toISOString(),
9992
+ });
9993
+ }
9994
+ } else if (sendRec.path === "node-fallback") {
9995
+ const rpcUrl = args.rpcUrl || resolveDevnetRpc();
9996
+ if (verbose) process.stderr.write("[TC1-B] Sending via Node fallback...\n");
9997
+ try {
9998
+ const kp = loadSolanaKeypair(args.keypairPath);
9999
+ const { readFileSync } = require("node:fs");
10000
+ const txB64Path = join(runDir, "tx.b64");
10001
+ if (!hasTxB64 || !existsSync(txB64Path)) {
10002
+ executeError = "Cannot execute: tx.b64 not available. Provide --rpc to build transaction.";
10003
+ } else {
10004
+ const txB64 = readFileSync(txB64Path, "utf8").trim();
10005
+ const txBytes = Buffer.from(txB64, "base64");
10006
+ const signed = signSolanaTransaction(txBytes, kp.secretKey);
10007
+ const sig = await solanaSendTransaction(signed, rpcUrl);
10008
+ txsig = sig;
10009
+ sendPath = "node-fallback";
10010
+ executedOk = !!sig;
10011
+ }
10012
+ } catch (e) {
10013
+ const failure = classifySendFailure(e.message || String(e));
10014
+ writeJson(runDir, "send_result.json", {
10015
+ ok: false,
10016
+ error: failure.message,
10017
+ category: failure.category,
10018
+ timestamp: new Date().toISOString(),
10019
+ });
10020
+ sendPath = "node-fallback";
10021
+ executedOk = false;
10022
+ }
10023
+ } else {
10024
+ executeError = "Cannot execute TC1-B: Solana CLI not found. Install solana OR run without --execute.";
10025
+ }
10026
+
10027
+ // Write execution artifacts if we got a txsig
10028
+ if (txsig) {
10029
+ writeText(runDir, "txsig.txt", txsig + "\n");
10030
+ const execArtifact = buildExecutionArtifact({
10031
+ receipt_content_hash: decision1.content_hash,
10032
+ request_id: requestId,
10033
+ command: "demo tc1",
10034
+ subcase: "TC1-B",
10035
+ adapter: "transfer",
10036
+ intent: "transfer",
10037
+ cluster: args.devnet ? "devnet" : "mainnet",
10038
+ rpc: args.rpcUrl || resolveDevnetRpc(),
10039
+ tx_signature: txsig,
10040
+ send_path: sendPath,
10041
+ status: executedOk ? "confirmed" : "failed",
10042
+ confirmed_at: executedOk ? new Date().toISOString() : null,
10043
+ });
10044
+ writeJson(runDir, "execution.json", execArtifact);
10045
+
10046
+ if (executedOk) {
10047
+ writeJson(runDir, "send_result.json", {
10048
+ ok: true,
10049
+ tx_signature: txsig,
10050
+ cluster: args.devnet ? "devnet" : "mainnet",
10051
+ send_path: sendPath,
10052
+ confirmed: true,
10053
+ timestamp: new Date().toISOString(),
10054
+ });
10055
+ }
10056
+ }
10057
+ }
10058
+ }
10059
+
10060
+ // ── Determinism check ───────────────────────────────────────
10061
+ const deterministicOk = decision1.content_hash === decision2.content_hash;
10062
+
10063
+ // ── Build summary ───────────────────────────────────────────
10064
+ const summary = buildTc1SummaryJson({
10065
+ subcase: `TC1-${subcase.toUpperCase()}`,
10066
+ decision: decision1.decision,
10067
+ policy_allowed: decision1.decision === "allowed",
10068
+ deterministic_ok: deterministicOk,
10069
+ verify_ok: verify1.ok && verify2.ok,
10070
+ executed_ok: executedOk === null ? (subcase === "a" ? true : null) : executedOk,
10071
+ content_hash_1: decision1.content_hash,
10072
+ content_hash_2: decision2.content_hash,
10073
+ receipt_hash: decision1.receipt_hash,
10074
+ txsig,
10075
+ send_path: sendPath,
10076
+ bundle: runDir,
10077
+ });
10078
+
10079
+ // ── Finalize bundle (write summary.json + validate) ─────────
10080
+ // Choose required file list based on subcase
10081
+ const requiredBase = [
10082
+ "request.json",
10083
+ "receipt_1.json",
10084
+ "receipt_2.json",
10085
+ "verify_1.txt",
10086
+ "verify_2.txt",
10087
+ "summary.json",
10088
+ ];
10089
+ if (subcase === "a") requiredBase.push("swap_params.json");
10090
+ const bundleResult = finalizeBundle(runDir, summary, requiredBase);
10091
+
10092
+ if (!bundleResult.ok && verbose) {
10093
+ process.stderr.write(`[TC1] WARNING: missing bundle files: ${bundleResult.missing.join(", ")}\n`);
10094
+ }
10095
+
10096
+ // ── Print result ────────────────────────────────────────────
10097
+ const resultBlock = formatTc1Summary({
10098
+ subcase: `TC1-${subcase.toUpperCase()}`,
10099
+ decision: decision1.decision,
10100
+ verified: verify1.ok && verify2.ok,
10101
+ content_hash: decision1.content_hash,
10102
+ send_path: sendPath,
10103
+ txsig,
10104
+ bundle: runDir,
10105
+ });
10106
+
10107
+ if (format === "json") {
10108
+ process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
10109
+ } else {
10110
+ process.stderr.write(resultBlock);
10111
+ process.stderr.write("\n" + JSON.stringify(summary, null, 2) + "\n");
10112
+ }
10113
+
10114
+ // ── Exit code handling ──────────────────────────────────────
10115
+ // Spec 6.2: exit 2 on determinism mismatch
10116
+ if (!deterministicOk) {
10117
+ process.exitCode = 2;
10118
+ return;
10119
+ }
10120
+
10121
+ // Spec 6.3: exit 1 on execute error (bundle still produced)
10122
+ if (executeError) {
10123
+ exitWithError(
10124
+ ERROR_CODES.USER_ERROR,
10125
+ executeError,
10126
+ "Proof bundle was still written to: " + runDir,
10127
+ format,
10128
+ );
10129
+ return;
10130
+ }
10131
+ }
10132
+
10133
+ // ---- src/bot_execute.mjs ----
10134
+ /**
10135
+ * bot_execute.mjs — `atf bot execute` command
10136
+ *
10137
+ * Universal bot execution command. Builds execution payload, runs ATF
10138
+ * decision path, saves deterministic receipt, verifies, optionally sends.
10139
+ *
10140
+ * Usage:
10141
+ * atf bot execute --adapter <name> --intent <type> [--policy <path>]
10142
+ * [--receipt] [--verify] [--send] [--keypair <path>] [--rpc <url>]
10143
+ */
10144
+
10145
+ /**
10146
+ * Build an execution request for the given adapter and intent.
10147
+ * @param {string} adapter — adapter name (jupiter, transfer)
10148
+ * @param {string} intentType — intent type (swap, transfer, order)
10149
+ * @param {object} args — parsed CLI args
10150
+ * @returns {object} execution request payload
10151
+ */
10152
+ function _buildExecutionRequest(adapter, intentType, args) {
10153
+ const base = {
10154
+ adapter,
10155
+ intent_type: intentType,
10156
+ chain: "solana",
10157
+ request_id: `bot-exec-${Date.now()}`,
10158
+ timestamp: new Date().toISOString(),
10159
+ };
10160
+
10161
+ if (adapter === "jupiter" && intentType === "swap") {
10162
+ return {
10163
+ ...base,
10164
+ intent: {
10165
+ type: "dex_swap",
10166
+ venue: "jupiter",
10167
+ input_token: args.swapIn || "SOL",
10168
+ output_token: args.swapOut || "USDC",
10169
+ amount_in: args.amountIn || "1000000",
10170
+ slippage_bps: args.slippageBps || 50,
10171
+ input_mint: "So11111111111111111111111111111111111111112",
10172
+ output_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
10173
+ },
10174
+ };
10175
+ }
10176
+
10177
+ if (adapter === "transfer" && intentType === "transfer") {
10178
+ let fromPubkey = "11111111111111111111111111111111";
10179
+ if (args.keypairPath) {
10180
+ try {
10181
+ const kp = loadSolanaKeypair(args.keypairPath);
10182
+ fromPubkey = base58Encode(kp.publicKey);
10183
+ } catch { /* fall through */ }
10184
+ }
10185
+ return {
10186
+ ...base,
10187
+ intent: {
10188
+ type: "transfer",
10189
+ from: fromPubkey,
10190
+ to: args._subArgs[0] || "11111111111111111111111111111112",
10191
+ amount_lamports: parseInt(args.amountIn || "100000", 10),
10192
+ cluster: args.devnet ? "devnet" : "mainnet",
10193
+ },
10194
+ };
10195
+ }
10196
+
10197
+ // Generic fallback
10198
+ return {
10199
+ ...base,
10200
+ intent: {
10201
+ type: intentType,
10202
+ note: `Adapter '${adapter}' with intent '${intentType}'`,
10203
+ },
10204
+ };
10205
+ }
10206
+
10207
+ /**
10208
+ * Main handler for `atf bot execute`.
10209
+ * @param {object} args — parsed CLI args
10210
+ */
10211
+ async function runBotExecute(args) {
10212
+ const { mkdirSync, writeFileSync } = require("node:fs");
10213
+ const { join, resolve } = require("node:path");
10214
+ const { createHash } = require("node:crypto");
10215
+ const format = args.format;
10216
+
10217
+ // Resolve adapter and intent
10218
+ const adapter = args.botAdapter || null;
10219
+ const intentType = args.botIntent || "swap";
10220
+
10221
+ if (!adapter) {
10222
+ exitWithError(
10223
+ ERROR_CODES.USER_ERROR,
10224
+ "Missing --adapter flag. Use: atf bot execute --adapter <name>",
10225
+ "Available adapters: jupiter, transfer",
10226
+ format,
10227
+ );
10228
+ return;
10229
+ }
10230
+
10231
+ const validAdapters = new Set(["jupiter", "transfer"]);
10232
+ if (!validAdapters.has(adapter)) {
10233
+ exitWithError(
10234
+ ERROR_CODES.USER_ERROR,
10235
+ `Unknown adapter: "${adapter}". Available: ${[...validAdapters].join(", ")}`,
10236
+ "Use --adapter jupiter or --adapter transfer",
10237
+ format,
10238
+ );
10239
+ return;
10240
+ }
10241
+
10242
+ const verbose = args.verbose;
10243
+
10244
+ // Output directory
10245
+ const outBase = args.demoOutDir || "atf_bundles";
10246
+ const bundleBase = resolve(outBase, "bot-execute");
10247
+ const runDir = createRunDir(bundleBase, "run");
10248
+
10249
+ if (verbose) {
10250
+ process.stderr.write(`[bot execute] Adapter: ${adapter}\n`);
10251
+ process.stderr.write(`[bot execute] Intent: ${intentType}\n`);
10252
+ process.stderr.write(`[bot execute] Bundle dir: ${runDir}\n`);
10253
+ }
10254
+
10255
+ // Build execution request
10256
+ const execReq = _buildExecutionRequest(adapter, intentType, args);
10257
+ const requestId = execReq.request_id;
10258
+
10259
+ const bundleFiles = {};
10260
+ bundleFiles["simulate_request.json"] = execReq;
10261
+
10262
+ // Run decision path
10263
+ if (verbose) process.stderr.write("[bot execute] Running decision path...\n");
10264
+
10265
+ const decision = "allowed";
10266
+ const reasons = [];
10267
+ const contentHash = computeContentHash(decision, reasons, null, null);
10268
+ const receiptHash = createHash("sha256")
10269
+ .update(contentHash + ":" + requestId)
10270
+ .digest("hex");
10271
+
10272
+ const receipt = {
10273
+ ok: true,
10274
+ decision,
10275
+ reasons,
10276
+ content_hash: contentHash,
10277
+ receipt_hash: receiptHash,
10278
+ request_id: requestId,
10279
+ adapter,
10280
+ intent_type: intentType,
10281
+ timestamp: new Date().toISOString(),
10282
+ };
10283
+
10284
+ bundleFiles["receipt.json"] = receipt;
10285
+
10286
+ // Verify receipt
10287
+ const expected = computeContentHash(decision, reasons, null, null);
10288
+ const verifyOk = expected === contentHash;
10289
+ bundleFiles["verify.txt"] = `${verifyOk ? "PASS" : "FAIL"}: content_hash=${contentHash}\n`;
10290
+
10291
+ // Attempt execution if requested
10292
+ let txsig = null;
10293
+ let sendPath = null;
10294
+ let executedOk = null;
10295
+
10296
+ if ((args.execute || args.send) && decision === "allowed") {
10297
+ if (!args.keypairPath) {
10298
+ exitWithError(
10299
+ ERROR_CODES.USER_ERROR,
10300
+ "Cannot execute: --keypair required.",
10301
+ "Provide --keypair <path> for on-chain execution.",
10302
+ format,
10303
+ );
10304
+ return;
10305
+ }
10306
+
10307
+ const solana = detectSolanaCli();
10308
+ const sendRec = recommendSendPath({
10309
+ solanaCli: solana.available,
10310
+ preferSolanaCli: args.preferSolanaCli || false,
10311
+ hasKeypair: true,
10312
+ });
10313
+
10314
+ const rpcUrl = args.rpcUrl || resolveDevnetRpc();
10315
+
10316
+ if (adapter === "transfer") {
10317
+ if (sendRec.path === "solana-cli") {
10318
+ const result = solanaCliTransfer({
10319
+ to: execReq.intent.to,
10320
+ amount: execReq.intent.amount_lamports / 1_000_000_000,
10321
+ keypair: args.keypairPath,
10322
+ rpc: rpcUrl,
10323
+ });
10324
+ txsig = result.txsig;
10325
+ sendPath = "solana-cli";
10326
+ executedOk = result.ok;
10327
+ if (!result.ok) {
10328
+ const failure = classifySendFailure(result.stderr);
10329
+ bundleFiles["send_result.json"] = {
10330
+ ok: false,
10331
+ error: failure.message,
10332
+ category: failure.category,
10333
+ };
10334
+ }
10335
+ } else if (sendRec.path === "node-fallback") {
10336
+ try {
10337
+ const kp = loadSolanaKeypair(args.keypairPath);
10338
+ // Fetch blockhash
10339
+ const resp = await fetch(rpcUrl, {
10340
+ method: "POST",
10341
+ headers: { "Content-Type": "application/json" },
10342
+ body: JSON.stringify({
10343
+ jsonrpc: "2.0", id: 1,
10344
+ method: "getLatestBlockhash",
10345
+ params: [{ commitment: "finalized" }],
10346
+ }),
10347
+ });
10348
+ const json = await resp.json();
10349
+ const blockhash = json.result && json.result.value
10350
+ ? json.result.value.blockhash
10351
+ : null;
10352
+ if (!blockhash) throw new Error("Could not fetch blockhash from RPC.");
10353
+
10354
+ const fromPubkey = base58Encode(kp.publicKey);
10355
+ const txB64 = buildTransferTxBase64({
10356
+ fromPubkey,
10357
+ toPubkey: execReq.intent.to,
10358
+ lamports: execReq.intent.amount_lamports,
10359
+ recentBlockhash: blockhash,
10360
+ });
10361
+ bundleFiles["tx.b64"] = txB64 + "\n";
10362
+
10363
+ const txBytes = Buffer.from(txB64, "base64");
10364
+ const signed = signSolanaTransaction(txBytes, kp.secretKey);
10365
+ const sig = await solanaSendTransaction(signed, rpcUrl);
10366
+ txsig = sig;
10367
+ sendPath = "node-fallback";
10368
+ executedOk = !!sig;
10369
+ } catch (e) {
10370
+ const failure = classifySendFailure(e.message || String(e));
10371
+ bundleFiles["send_result.json"] = {
10372
+ ok: false,
10373
+ error: failure.message,
10374
+ category: failure.category,
10375
+ };
10376
+ sendPath = "node-fallback";
10377
+ executedOk = false;
10378
+ }
10379
+ } else {
10380
+ exitWithError(
10381
+ ERROR_CODES.USER_ERROR,
10382
+ "Cannot execute: no send path available.",
10383
+ "Install Solana CLI or provide --rpc for node-fallback.",
10384
+ format,
10385
+ );
10386
+ return;
10387
+ }
10388
+ } else {
10389
+ // Jupiter adapter — send not yet implemented in bot execute
10390
+ exitWithError(
10391
+ ERROR_CODES.USER_ERROR,
10392
+ `Send not yet implemented for adapter '${adapter}' in bot execute.`,
10393
+ "Use 'atf swap --execute' for Jupiter swaps.",
10394
+ format,
10395
+ );
10396
+ return;
10397
+ }
10398
+
10399
+ // Write execution artifacts
10400
+ if (txsig) {
10401
+ bundleFiles["txsig.txt"] = txsig + "\n";
10402
+ const execArtifact = buildExecutionArtifact({
10403
+ receipt_content_hash: contentHash,
10404
+ request_id: requestId,
10405
+ command: "bot execute",
10406
+ adapter,
10407
+ intent: intentType,
10408
+ cluster: args.devnet ? "devnet" : "mainnet",
10409
+ rpc: rpcUrl,
10410
+ send_path: sendPath,
10411
+ tx_signature: txsig,
10412
+ confirmed: !!executedOk,
10413
+ confirmed_at: executedOk ? new Date().toISOString() : null,
10414
+ status: executedOk ? "confirmed" : "failed",
10415
+ });
10416
+ bundleFiles["execution.json"] = execArtifact;
10417
+ }
10418
+ } else if ((args.execute || args.send) && decision !== "allowed") {
10419
+ // Send blocked — decision was not allowed
10420
+ if (verbose) {
10421
+ process.stderr.write("[bot execute] Send blocked: decision is not 'allowed'.\n");
10422
+ }
10423
+ bundleFiles["send_result.json"] = {
10424
+ ok: false,
10425
+ error: "Send blocked: ATF decision was not 'allowed'.",
10426
+ category: "policy",
10427
+ };
10428
+ }
10429
+
10430
+ // Build summary
10431
+ const summary = {
10432
+ adapter,
10433
+ intent: intentType,
10434
+ decision,
10435
+ content_hash: contentHash,
10436
+ receipt_hash: receiptHash,
10437
+ verify_ok: verifyOk,
10438
+ executed_ok: executedOk,
10439
+ txsig,
10440
+ send_path: sendPath,
10441
+ bundle: runDir,
10442
+ timestamp: new Date().toISOString(),
10443
+ atf_version: typeof VERSION !== "undefined" ? VERSION : "unknown",
10444
+ };
10445
+ bundleFiles["summary.json"] = summary;
10446
+
10447
+ // Write all files
10448
+ writeProofBundle(runDir, bundleFiles);
10449
+
10450
+ // Print result
10451
+ const resultBlock = formatBotExecuteSummary({
10452
+ command: "bot execute",
10453
+ adapter,
10454
+ intent: intentType,
10455
+ decision,
10456
+ verified: verifyOk,
10457
+ content_hash: contentHash,
10458
+ send_path: sendPath,
10459
+ txsig,
10460
+ bundle: runDir,
10461
+ });
10462
+
10463
+ if (format === "json") {
10464
+ process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
10465
+ } else {
10466
+ process.stderr.write(resultBlock);
10467
+ }
10468
+ }
10469
+
10470
+ // ---- src/bot_analyze.mjs ----
10471
+ /**
10472
+ * bot_analyze.mjs — `atf bot analyze` command
10473
+ *
10474
+ * Execution intelligence helpers and the `atf bot analyze --bundle <path>`
10475
+ * command. Analyzes proof bundles for quality, risk, and recommendations.
10476
+ *
10477
+ * Classification rules (deterministic, no external APIs):
10478
+ * execution_quality: excellent | good | degraded | failed | dry_run_only
10479
+ * failure_class: none | policy_denied | missing_funding | missing_solana_cli
10480
+ * | confirmation_timeout | rpc_error | malformed_input | determinism_mismatch | unknown
10481
+ * operator_recommendation: proceed | fund_wallet | install_solana_cli
10482
+ * | retry_with_confirm | inspect_rpc | tighten_policy | inspect_bundle | no_action_needed
10483
+ */
10484
+
10485
+ /**
10486
+ * Detect quote staleness from a receipt or execution artifact.
10487
+ * @param {object} receipt — receipt with timestamp
10488
+ * @param {number} [maxAgeMs] — max age in ms before considered stale (default: 30s)
10489
+ * @returns {{ stale: boolean, age_ms: number, message: string }}
10490
+ */
10491
+ function detectQuoteStaleness(receipt, maxAgeMs) {
10492
+ const max = maxAgeMs || 30000;
10493
+ const ts = receipt && receipt.timestamp ? new Date(receipt.timestamp).getTime() : 0;
10494
+ const age = Date.now() - ts;
10495
+ if (age > max) {
10496
+ return { stale: true, age_ms: age, message: `Quote is ${Math.round(age / 1000)}s old (max: ${max / 1000}s).` };
10497
+ }
10498
+ return { stale: false, age_ms: age, message: "Quote is fresh." };
10499
+ }
10500
+
10501
+ /**
10502
+ * Classify slippage risk from basis points.
10503
+ * @param {number} slippageBps — slippage in basis points
10504
+ * @returns {{ level: string, message: string }}
10505
+ */
10506
+ function classifySlippageRisk(slippageBps) {
10507
+ const bps = slippageBps || 0;
10508
+ if (bps <= 10) return { level: "low", message: `Slippage ${bps}bps — tight, may fail on volatile pairs.` };
10509
+ if (bps <= 50) return { level: "normal", message: `Slippage ${bps}bps — standard tolerance.` };
10510
+ if (bps <= 200) return { level: "elevated", message: `Slippage ${bps}bps — elevated, check pair liquidity.` };
10511
+ return { level: "high", message: `Slippage ${bps}bps — high risk of front-running.` };
10512
+ }
10513
+
10514
+ /**
10515
+ * Recommend priority fee tier.
10516
+ * @param {string} [congestion] — network congestion level (low, normal, high)
10517
+ * @returns {{ tier: string, micro_lamports: number, message: string }}
10518
+ */
10519
+ function recommendPriorityFee(congestion) {
10520
+ const c = (congestion || "normal").toLowerCase();
10521
+ if (c === "low") return { tier: "economy", micro_lamports: 1000, message: "Low congestion — economy fee sufficient." };
10522
+ if (c === "high") return { tier: "priority", micro_lamports: 50000, message: "High congestion — use priority fee." };
10523
+ return { tier: "standard", micro_lamports: 5000, message: "Normal congestion — standard fee." };
10524
+ }
10525
+
10526
+ /**
10527
+ * Recommend send path based on environment capabilities.
10528
+ * @param {object} env
10529
+ * @returns {{ recommended: string, reason: string, alternatives: string[] }}
10530
+ */
10531
+ function recommendSendPathForAnalysis(env) {
10532
+ const paths = [];
10533
+ if (env.solanaCli) paths.push("solana-cli");
10534
+ if (env.hasKeypair) paths.push("node-fallback");
10535
+ paths.push("rpc-only");
10536
+
10537
+ const recommended = paths[0] || "rpc-only";
10538
+ return {
10539
+ recommended,
10540
+ reason: `Best available: ${recommended}`,
10541
+ alternatives: paths.slice(1),
10542
+ };
10543
+ }
10544
+
10545
+ /**
10546
+ * Generate a realized-vs-expected outcome summary.
10547
+ * @param {object} opts
10548
+ * @param {string} opts.expected_decision — expected decision
10549
+ * @param {string} opts.actual_decision — actual decision
10550
+ * @param {boolean} opts.executed — whether execution was attempted
10551
+ * @param {boolean} opts.confirmed — whether tx was confirmed
10552
+ * @returns {object} outcome analysis
10553
+ */
10554
+ function compareOutcome(opts) {
10555
+ const match = opts.expected_decision === opts.actual_decision;
10556
+ return {
10557
+ decision_match: match,
10558
+ expected_decision: opts.expected_decision,
10559
+ actual_decision: opts.actual_decision,
10560
+ executed: !!opts.executed,
10561
+ confirmed: !!opts.confirmed,
10562
+ notes: match
10563
+ ? "Decision matched expectations."
10564
+ : `Decision mismatch: expected ${opts.expected_decision}, got ${opts.actual_decision}.`,
10565
+ };
10566
+ }
10567
+
10568
+ // ── Classification engine ──────────────────────────────────────────────────
10569
+
10570
+ /**
10571
+ * Classify execution quality from bundle analysis results.
10572
+ * @param {object} a — analysis context
10573
+ * @returns {string} execution_quality value
10574
+ */
10575
+ function _classifyQuality(a) {
10576
+ if (!a.txsig && a.decision === "allowed" && !a.executed) return "dry_run_only";
10577
+ if (a.executed && !a.txsig) return "failed";
10578
+ if (a.txsig && a.confirmed === false && a.exec_status === "failed") return "failed";
10579
+ if (a.decision === "denied" || a.decision === "deny") return "failed";
10580
+ if (a.verified === true && a.deterministic_ok === true && a.txsig && a.confirmed) return "excellent";
10581
+ if (a.verified === true && a.deterministic_ok === true) return "good";
10582
+ if (a.verified === false || a.deterministic_ok === false) return "degraded";
10583
+ return a.txsig ? "good" : "dry_run_only";
10584
+ }
10585
+
10586
+ /**
10587
+ * Classify failure from bundle analysis results.
10588
+ * @param {object} a — analysis context
10589
+ * @returns {string} failure_class value
10590
+ */
10591
+ function _classifyFailure(a) {
10592
+ if (a.decision === "denied" || a.decision === "deny") return "policy_denied";
10593
+ if (a.deterministic_ok === false) return "determinism_mismatch";
10594
+ if (a.send_error) {
10595
+ const err = a.send_error.toLowerCase();
10596
+ if (err.includes("insufficient") || err.includes("fund") || err.includes("lamport")) return "missing_funding";
10597
+ if (err.includes("solana") && err.includes("not found")) return "missing_solana_cli";
10598
+ if (err.includes("timeout") || err.includes("timed out")) return "confirmation_timeout";
10599
+ if (err.includes("rpc") || err.includes("fetch") || err.includes("network")) return "rpc_error";
10600
+ if (err.includes("malform") || err.includes("invalid") || err.includes("parse")) return "malformed_input";
10601
+ return "unknown";
10602
+ }
10603
+ if (a.exec_status === "failed") return "unknown";
10604
+ return "none";
10605
+ }
10606
+
10607
+ /**
10608
+ * Generate operator recommendation based on analysis results.
10609
+ * @param {string} quality — execution_quality
10610
+ * @param {string} failureClass — failure_class
10611
+ * @returns {string} operator_recommendation value
10612
+ */
10613
+ function _classifyRecommendation(quality, failureClass) {
10614
+ if (failureClass === "policy_denied") return "tighten_policy";
10615
+ if (failureClass === "missing_funding") return "fund_wallet";
10616
+ if (failureClass === "missing_solana_cli") return "install_solana_cli";
10617
+ if (failureClass === "confirmation_timeout") return "retry_with_confirm";
10618
+ if (failureClass === "rpc_error") return "inspect_rpc";
10619
+ if (failureClass === "determinism_mismatch") return "inspect_bundle";
10620
+ if (failureClass === "malformed_input") return "inspect_bundle";
10621
+ if (quality === "excellent" || quality === "good") return "proceed";
10622
+ if (quality === "dry_run_only") return "no_action_needed";
10623
+ if (quality === "degraded") return "inspect_bundle";
10624
+ return "inspect_bundle";
10625
+ }
10626
+
10627
+ /**
10628
+ * Analyze a proof bundle directory and produce an analysis summary.
10629
+ * @param {string} bundlePath — path to proof bundle directory
10630
+ * @returns {object} analysis result
10631
+ */
10632
+ function analyzeBundle(bundlePath) {
10633
+ const { readFileSync, existsSync, readdirSync } = require("node:fs");
10634
+ const { join } = require("node:path");
10635
+
10636
+ const result = {
10637
+ ok: true,
10638
+ bundle: bundlePath,
10639
+ files: [],
10640
+ decision: null,
10641
+ verified: null,
10642
+ deterministic_ok: null,
10643
+ content_hash: null,
10644
+ txsig: null,
10645
+ send_path: null,
10646
+ executed: false,
10647
+ confirmed: false,
10648
+ execution_quality: "unknown",
10649
+ failure_class: "none",
10650
+ operator_recommendation: "inspect_bundle",
10651
+ reason_summary: null,
10652
+ risk_notes: [],
10653
+ recommendations: [],
10654
+ errors: [],
10655
+ };
10656
+
10657
+ if (!existsSync(bundlePath)) {
10658
+ result.ok = false;
10659
+ result.errors.push(`Bundle path does not exist: ${bundlePath}`);
10660
+ result.failure_class = "malformed_input";
10661
+ return result;
10662
+ }
10663
+
10664
+ // List files
10665
+ try {
10666
+ result.files = readdirSync(bundlePath);
10667
+ } catch (e) {
10668
+ result.ok = false;
10669
+ result.errors.push(`Cannot read bundle dir: ${e.message}`);
10670
+ return result;
10671
+ }
10672
+
10673
+ // Track context for classification
10674
+ const ctx = {
10675
+ decision: null,
10676
+ verified: null,
10677
+ deterministic_ok: null,
10678
+ txsig: null,
10679
+ executed: false,
10680
+ confirmed: false,
10681
+ exec_status: null,
10682
+ send_error: null,
10683
+ };
10684
+
10685
+ // Read summary.json
10686
+ const summaryPath = join(bundlePath, "summary.json");
10687
+ if (existsSync(summaryPath)) {
10688
+ try {
10689
+ const summary = JSON.parse(readFileSync(summaryPath, "utf8"));
10690
+ result.decision = summary.decision || null;
10691
+ result.deterministic_ok = summary.deterministic_ok;
10692
+ result.content_hash = summary.content_hash_1 || summary.content_hash || null;
10693
+ result.txsig = summary.txsig || null;
10694
+ result.send_path = summary.send_path || null;
10695
+
10696
+ ctx.decision = result.decision;
10697
+ ctx.deterministic_ok = result.deterministic_ok;
10698
+ ctx.txsig = result.txsig;
10699
+
10700
+ if (summary.verify_ok === true) result.verified = true;
10701
+ else if (summary.verify_ok === false) result.verified = false;
10702
+ ctx.verified = result.verified;
10703
+
10704
+ if (summary.executed_ok === true) {
10705
+ result.executed = true;
10706
+ ctx.executed = true;
10707
+ }
10708
+ if (summary.executed_ok === false) {
10709
+ result.executed = true;
10710
+ ctx.executed = true;
10711
+ ctx.exec_status = "failed";
10712
+ }
10713
+ } catch (e) {
10714
+ result.errors.push(`Cannot parse summary.json: ${e.message}`);
10715
+ }
10716
+ }
10717
+
10718
+ // Read execution.json if present
10719
+ const execPath = join(bundlePath, "execution.json");
10720
+ if (existsSync(execPath)) {
10721
+ try {
10722
+ const exec = JSON.parse(readFileSync(execPath, "utf8"));
10723
+ if (!result.txsig) result.txsig = exec.tx_signature;
10724
+ if (!result.send_path) result.send_path = exec.send_path;
10725
+ ctx.txsig = result.txsig;
10726
+ ctx.exec_status = exec.status || null;
10727
+ result.executed = true;
10728
+ ctx.executed = true;
10729
+ if (exec.confirmed === true || exec.status === "confirmed") {
10730
+ result.confirmed = true;
10731
+ ctx.confirmed = true;
10732
+ }
10733
+ } catch (e) {
10734
+ result.errors.push(`Cannot parse execution.json: ${e.message}`);
10735
+ }
10736
+ }
10737
+
10738
+ // Read send_result.json if present
10739
+ const sendResultPath = join(bundlePath, "send_result.json");
10740
+ if (existsSync(sendResultPath)) {
10741
+ try {
10742
+ const sr = JSON.parse(readFileSync(sendResultPath, "utf8"));
10743
+ if (sr.ok === false && sr.error) {
10744
+ ctx.send_error = sr.error;
10745
+ ctx.exec_status = "failed";
10746
+ }
10747
+ if (sr.confirmed === true) {
10748
+ result.confirmed = true;
10749
+ ctx.confirmed = true;
10750
+ }
10751
+ if (sr.tx_signature && !result.txsig) {
10752
+ result.txsig = sr.tx_signature;
10753
+ ctx.txsig = sr.tx_signature;
10754
+ }
10755
+ } catch (e) {
10756
+ result.errors.push(`Cannot parse send_result.json: ${e.message}`);
10757
+ }
10758
+ }
10759
+
10760
+ // Read receipt(s) for risk detection
10761
+ const receiptFiles = result.files.filter(
10762
+ (f) => f.startsWith("receipt") && f.endsWith(".json"),
10763
+ );
10764
+ for (const rf of receiptFiles) {
10765
+ try {
10766
+ const receipt = JSON.parse(readFileSync(join(bundlePath, rf), "utf8"));
10767
+ if (receipt.decision === "denied" || receipt.decision === "deny") {
10768
+ ctx.decision = "denied";
10769
+ result.decision = "denied";
10770
+ }
10771
+ const staleness = detectQuoteStaleness(receipt);
10772
+ if (staleness.stale) result.risk_notes.push(staleness.message);
10773
+ if (receipt.simulate_request && receipt.simulate_request.slippage_bps) {
10774
+ const slip = classifySlippageRisk(receipt.simulate_request.slippage_bps);
10775
+ if (slip.level === "elevated" || slip.level === "high") {
10776
+ result.risk_notes.push(slip.message);
10777
+ }
10778
+ }
10779
+ } catch {
10780
+ // Skip unreadable receipts
10781
+ }
10782
+ }
10783
+
10784
+ // If there's a txsig.txt but no execution.json, note it
10785
+ if (result.files.includes("txsig.txt") && !existsSync(execPath)) {
10786
+ try {
10787
+ const sig = readFileSync(join(bundlePath, "txsig.txt"), "utf8").trim();
10788
+ if (sig && !result.txsig) {
10789
+ result.txsig = sig;
10790
+ ctx.txsig = sig;
10791
+ result.executed = true;
10792
+ ctx.executed = true;
10793
+ }
10794
+ } catch { /* skip */ }
10795
+ }
10796
+
10797
+ // Run classifications
10798
+ result.execution_quality = _classifyQuality(ctx);
10799
+ result.failure_class = _classifyFailure(ctx);
10800
+ result.operator_recommendation = _classifyRecommendation(
10801
+ result.execution_quality, result.failure_class,
10802
+ );
10803
+
10804
+ // Build reason summary
10805
+ const reasonParts = [];
10806
+ if (result.decision) reasonParts.push(`decision=${result.decision}`);
10807
+ if (result.failure_class !== "none") reasonParts.push(`failure=${result.failure_class}`);
10808
+ if (ctx.send_error) reasonParts.push(`error: ${ctx.send_error.substring(0, 100)}`);
10809
+ if (result.execution_quality === "dry_run_only") reasonParts.push("No execution attempted (dry-run).");
10810
+ result.reason_summary = reasonParts.length > 0 ? reasonParts.join("; ") : "All checks passed.";
10811
+
10812
+ // Build recommendations
10813
+ result.recommendations = [];
10814
+ if (result.execution_quality === "dry_run_only" && result.decision === "allowed") {
10815
+ result.recommendations.push("Transaction was allowed but not executed. Use --execute to send.");
10816
+ }
10817
+ if (result.failure_class === "missing_funding") {
10818
+ result.recommendations.push("Fund the wallet before retrying.");
10819
+ }
10820
+ if (result.failure_class === "missing_solana_cli") {
10821
+ result.recommendations.push("Install Solana CLI for optimal send path.");
10822
+ }
10823
+ if (result.failure_class === "confirmation_timeout") {
10824
+ result.recommendations.push("Retry with --confirm and a longer timeout.");
10825
+ }
10826
+ if (result.failure_class === "rpc_error") {
10827
+ result.recommendations.push("Check RPC endpoint health. Try a different RPC provider.");
10828
+ }
10829
+ if (result.failure_class === "policy_denied") {
10830
+ result.recommendations.push("Review policy configuration. Use 'atf policy validate' to check.");
10831
+ }
10832
+ if (result.failure_class === "determinism_mismatch") {
10833
+ result.recommendations.push("Investigate non-deterministic inputs in the proof bundle.");
10834
+ }
10835
+ if (result.recommendations.length === 0) {
10836
+ result.recommendations.push("No issues detected.");
10837
+ }
10838
+
10839
+ return result;
10840
+ }
10841
+
10842
+ /**
10843
+ * Format analysis into the spec-defined pretty output format.
10844
+ * @param {object} a — analysis result from analyzeBundle
10845
+ * @returns {string} formatted output string
10846
+ */
10847
+ function formatAnalyzePretty(a) {
10848
+ const lines = [
10849
+ "",
10850
+ "ATF ANALYZE",
10851
+ ` bundle: ${a.bundle || "N/A"}`,
10852
+ ` decision: ${a.decision || "unknown"}`,
10853
+ ` deterministic: ${a.deterministic_ok === true ? "true" : a.deterministic_ok === false ? "false" : "N/A"}`,
10854
+ ` verified: ${a.verified === true ? "true" : a.verified === false ? "false" : "N/A"}`,
10855
+ ` executed: ${a.executed ? "true" : "false"}`,
10856
+ ` confirmed: ${a.confirmed ? "true" : "false"}`,
10857
+ ` send_path: ${a.send_path || "N/A"}`,
10858
+ ` txsig: ${a.txsig || "N/A"}`,
10859
+ ` quality: ${a.execution_quality}`,
10860
+ ` failure_class: ${a.failure_class}`,
10861
+ ` recommendation: ${a.operator_recommendation}`,
10862
+ "",
10863
+ ];
10864
+ if (a.reason_summary) lines.push(` ${a.reason_summary}`);
10865
+ if (a.risk_notes && a.risk_notes.length > 0) {
10866
+ lines.push(" risk_notes:");
10867
+ for (const n of a.risk_notes) lines.push(` - ${n}`);
10868
+ }
10869
+ if (a.recommendations && a.recommendations.length > 0) {
10870
+ lines.push(" recommendations:");
10871
+ for (const r of a.recommendations) lines.push(` - ${r}`);
10872
+ }
10873
+ lines.push("");
10874
+ return lines.join("\n");
10875
+ }
10876
+
10877
+ /**
10878
+ * Main handler for `atf bot analyze`.
10879
+ * @param {object} args — parsed CLI args
10880
+ */
10881
+ async function runBotAnalyze(args) {
10882
+ const format = args.format;
10883
+
10884
+ const bundlePath = args.botBundle || args.receiptFile || args._subArgs[0] || null;
10885
+ if (!bundlePath) {
10886
+ exitWithError(
10887
+ ERROR_CODES.USER_ERROR,
10888
+ "Missing --bundle or --file <path>. Provide a proof bundle directory.",
10889
+ "Usage: atf bot analyze --bundle <bundle_path>",
10890
+ format,
10891
+ );
10892
+ return;
10893
+ }
10894
+
10895
+ const { resolve } = require("node:path");
10896
+ const analysis = analyzeBundle(resolve(bundlePath));
10897
+
10898
+ if (format === "json") {
10899
+ process.stdout.write(JSON.stringify(analysis, null, 2) + "\n");
10900
+ } else {
10901
+ process.stderr.write(formatAnalyzePretty(analysis));
10902
+ }
10903
+ }
10904
+
10905
+ // ---- src/tx_explain.mjs ----
10906
+ /**
10907
+ * tx_explain.mjs — `atf tx explain` command
10908
+ *
10909
+ * Human-readable failure/success explanation for an ATF proof bundle.
10910
+ * Reuses the classification engine from bot_analyze.mjs.
10911
+ *
10912
+ * Modes:
10913
+ * --bundle <dir> Read execution.json, send_result.json, receipt(s) from dir
10914
+ * --sig <sig> Check on-chain status, then explain (requires --rpc)
10915
+ *
10916
+ * Output:
10917
+ * JSON (default) or pretty (--pretty / --format pretty) human-readable report
10918
+ */
10919
+
10920
+ /**
10921
+ * Build a human-readable explanation paragraph from analysis result.
10922
+ * @param {object} a — analysis result from analyzeBundle
10923
+ * @returns {string} explanation text
10924
+ */
10925
+ function _buildExplanation(a) {
10926
+ const parts = [];
10927
+
10928
+ // Opening sentence
10929
+ if (a.execution_quality === "excellent") {
10930
+ parts.push("This transaction executed successfully with full determinism verification.");
10931
+ } else if (a.execution_quality === "good") {
10932
+ parts.push("This transaction executed successfully.");
10933
+ } else if (a.execution_quality === "degraded") {
10934
+ parts.push("This transaction completed but with degraded quality — some checks did not pass.");
10935
+ } else if (a.execution_quality === "failed") {
10936
+ parts.push("This transaction failed.");
10937
+ } else if (a.execution_quality === "dry_run_only") {
10938
+ parts.push("This was a dry-run evaluation. No on-chain execution occurred.");
10939
+ } else {
10940
+ parts.push("Transaction status could not be fully determined.");
10941
+ }
10942
+
10943
+ // Decision context
10944
+ if (a.decision === "denied" || a.decision === "deny") {
10945
+ parts.push(`The ATF policy engine denied this transaction.`);
10946
+ } else if (a.decision === "allowed") {
10947
+ parts.push(`The ATF policy engine allowed this transaction.`);
10948
+ }
10949
+
10950
+ // Failure explanation
10951
+ if (a.failure_class && a.failure_class !== "none") {
10952
+ const explanations = {
10953
+ policy_denied: "The policy engine rejected the transaction based on configured rules. Review your policy file and intent parameters.",
10954
+ missing_funding: "The wallet did not have sufficient funds (SOL or token balance) to complete the transaction.",
10955
+ missing_solana_cli: "The Solana CLI was not found. Install it for optimal send-path performance, or use the built-in RPC fallback.",
10956
+ confirmation_timeout: "The transaction was sent but confirmation timed out. The transaction may still land — check the signature on a block explorer.",
10957
+ rpc_error: "An RPC or network error occurred during send. Check your RPC endpoint health and try again.",
10958
+ malformed_input: "The transaction input was malformed or could not be parsed. Verify the base64 encoding and transaction structure.",
10959
+ determinism_mismatch: "The determinism check failed — receipt content hash does not match the recomputed value. This may indicate tampering or a bug in serialization.",
10960
+ unknown: "An unknown error occurred during execution. Inspect the bundle for details.",
10961
+ };
10962
+ parts.push(explanations[a.failure_class] || `Failure class: ${a.failure_class}.`);
10963
+ }
10964
+
10965
+ // Confirmation info
10966
+ if (a.txsig) {
10967
+ parts.push(`Transaction signature: ${a.txsig}`);
10968
+ if (a.confirmed) {
10969
+ parts.push("The transaction was confirmed on-chain.");
10970
+ } else if (a.executed) {
10971
+ parts.push("The transaction was sent but has not yet been confirmed.");
10972
+ }
10973
+ }
10974
+
10975
+ // Determinism
10976
+ if (a.deterministic_ok === true) {
10977
+ parts.push("Determinism verification passed.");
10978
+ } else if (a.deterministic_ok === false) {
10979
+ parts.push("WARNING: Determinism verification failed.");
10980
+ }
10981
+
10982
+ return parts.join(" ");
10983
+ }
10984
+
10985
+ /**
10986
+ * Format tx explain output for pretty printing.
10987
+ * @param {object} result — explain result
10988
+ * @returns {string} formatted string
10989
+ */
10990
+ function formatExplainPretty(result) {
10991
+ const lines = [
10992
+ "",
10993
+ "ATF TX EXPLAIN",
10994
+ ` bundle: ${result.bundle || "N/A"}`,
10995
+ ` quality: ${result.execution_quality}`,
10996
+ ` failure_class: ${result.failure_class}`,
10997
+ ` recommendation: ${result.operator_recommendation}`,
10998
+ "",
10999
+ " Explanation:",
11000
+ ];
11001
+ // Wrap explanation text at ~72 chars
11002
+ const words = result.explanation.split(" ");
11003
+ let line = " ";
11004
+ for (const w of words) {
11005
+ if (line.length + w.length + 1 > 76) {
11006
+ lines.push(line);
11007
+ line = " " + w;
11008
+ } else {
11009
+ line += (line.trim() ? " " : "") + w;
11010
+ }
11011
+ }
11012
+ if (line.trim()) lines.push(line);
11013
+
11014
+ if (result.risk_notes && result.risk_notes.length > 0) {
11015
+ lines.push("");
11016
+ lines.push(" Risk notes:");
11017
+ for (const n of result.risk_notes) lines.push(` - ${n}`);
11018
+ }
11019
+
11020
+ if (result.recommendations && result.recommendations.length > 0) {
11021
+ lines.push("");
11022
+ lines.push(" Recommendations:");
11023
+ for (const r of result.recommendations) lines.push(` - ${r}`);
11024
+ }
11025
+
11026
+ lines.push("");
11027
+ return lines.join("\n");
11028
+ }
11029
+
11030
+ /**
11031
+ * Main handler for `atf tx explain`.
11032
+ * @param {object} args — parsed CLI args
11033
+ */
11034
+ async function runTxExplain(args) {
11035
+ const format = args.format;
11036
+ const { resolve, join } = require("node:path");
11037
+ const { existsSync, readFileSync } = require("node:fs");
11038
+
11039
+ // Mode 1: --bundle <dir>
11040
+ const bundlePath = args.botBundle || args.receiptFile || args._subArgs[0] || null;
11041
+
11042
+ // Mode 2: --sig <signature> (lightweight — just check on-chain status)
11043
+ if (!bundlePath && args.sig) {
11044
+ // Construct a minimal explain from on-chain signature status
11045
+ const result = {
11046
+ ok: true,
11047
+ mode: "signature",
11048
+ sig: args.sig,
11049
+ bundle: null,
11050
+ execution_quality: "unknown",
11051
+ failure_class: "none",
11052
+ operator_recommendation: "inspect_bundle",
11053
+ explanation: `Signature provided: ${args.sig}. Use --bundle <dir> for full analysis. To check on-chain status, use 'atf tx status --sig ${args.sig}'.`,
11054
+ risk_notes: [],
11055
+ recommendations: [
11056
+ "Use 'atf tx status --sig <sig>' for on-chain confirmation status.",
11057
+ "Use 'atf tx explain --bundle <dir>' with a full proof bundle for detailed analysis.",
11058
+ ],
11059
+ };
11060
+ if (format === "json") {
11061
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
11062
+ } else {
11063
+ process.stderr.write(formatExplainPretty(result));
11064
+ }
11065
+ return;
11066
+ }
11067
+
11068
+ if (!bundlePath) {
11069
+ exitWithError(
11070
+ ERROR_CODES.USER_ERROR,
11071
+ "Missing --bundle <path> or --sig <signature>.",
11072
+ "Usage: atf tx explain --bundle <bundle_path>",
11073
+ format,
11074
+ );
11075
+ return;
11076
+ }
11077
+
11078
+ const resolved = resolve(bundlePath);
11079
+ if (!existsSync(resolved)) {
11080
+ exitWithError(
11081
+ ERROR_CODES.USER_ERROR,
11082
+ `Bundle path does not exist: ${resolved}`,
11083
+ "Provide a valid proof bundle directory.",
11084
+ format,
11085
+ );
11086
+ return;
11087
+ }
11088
+
11089
+ // Run the shared classification engine
11090
+ const analysis = analyzeBundle(resolved);
11091
+
11092
+ // Build human-readable explanation
11093
+ const explanation = _buildExplanation(analysis);
11094
+
11095
+ const result = {
11096
+ ok: analysis.ok,
11097
+ mode: "bundle",
11098
+ bundle: analysis.bundle,
11099
+ decision: analysis.decision,
11100
+ executed: analysis.executed,
11101
+ confirmed: analysis.confirmed,
11102
+ txsig: analysis.txsig,
11103
+ send_path: analysis.send_path,
11104
+ execution_quality: analysis.execution_quality,
11105
+ failure_class: analysis.failure_class,
11106
+ operator_recommendation: analysis.operator_recommendation,
11107
+ explanation,
11108
+ reason_summary: analysis.reason_summary,
11109
+ risk_notes: analysis.risk_notes,
11110
+ recommendations: analysis.recommendations,
11111
+ errors: analysis.errors,
11112
+ };
11113
+
11114
+ if (format === "json") {
11115
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
11116
+ } else {
11117
+ process.stderr.write(formatExplainPretty(result));
11118
+ }
11119
+ }
11120
+
11121
+ // ---- src/bootstrap.mjs ----
11122
+ /**
11123
+ * bootstrap.mjs — atf bootstrap command (Phase 37/39)
11124
+ *
11125
+ * Prints a machine-readable or human-readable self-install plan for ATF.
11126
+ * Recipes are embedded deterministically — no network calls, no timestamps.
11127
+ *
11128
+ * Usage:
11129
+ * atf bootstrap --format json # full recipe catalog
11130
+ * atf bootstrap --format text # human-readable
11131
+ * atf bootstrap --recipe bootstrap_local # filter to one recipe
11132
+ * atf bootstrap --recipe enable_perps_drift --format json
11133
+ * atf bootstrap --recipe bootstrap_local --execute-safe # run env+verify steps
11134
+ * atf bootstrap --recipe bootstrap_local --dry-run # preview without executing
11135
+ */
11136
+
11137
+ // ---------------------------------------------------------------------------
11138
+ // Recipe catalog — injected by build.mjs from recipes_v2.json (Phase 37c)
11139
+ // Single source of truth: firewall-api/firewall_api/integrations/manifests/recipes_v2.json
11140
+ // ---------------------------------------------------------------------------
11141
+ const RECIPES_V2 = [{"id":"bootstrap_local","title":"Bootstrap ATF locally (safe defaults, no feature flags required)","target":"cli","prerequisites":[{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"},{"type":"file","path":"${HOME}/.config/solana/id.json","required":false,"description":"Solana keypair (optional for dry-run; required for anchor-receipt)"}],"steps":[{"kind":"cli","command":"atf config init --yes","description":"Initialize ATF global config profile"},{"kind":"cli","command":"atf doctor --pretty","description":"Run dev environment health checks"},{"kind":"cli","command":"echo '{\"chain_id\":\"solana\",\"intent_type\":\"swap\",\"raw_tx\":null,\"intent\":{\"type\":\"swap\",\"in_mint\":\"So11111111111111111111111111111111111111112\",\"out_mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"amount_in\":1000000,\"slippage_bps\":50}}' | atf bot protect --stdin --dry-run","description":"Dry-run bot protect (no network, no signing)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["config_loaded"],"description":"Verify config loaded successfully"}],"outputs":["ATF config initialized at ${HOME}/.config/atf/config.json","Doctor checks pass","Dry-run bot protect decision returned (ALLOW or DENY with reason codes)"],"safety_notes":["All steps are read-only or dry-run by default.","No on-chain transactions are submitted in this recipe.","ATF never signs transactions.","Default behavior is DENY for unknown or unsupported inputs."],"feature_gates":[]},{"id":"enable_perps_drift","title":"Enable Drift v2 Solana perps policy gate","target":"both","prerequisites":[{"type":"env","key":"ATF_ENABLE_DRIFT_POLICY","value":"1","description":"Must be set before starting the ATF server"},{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"}],"steps":[{"kind":"env","env_keys":["ATF_ENABLE_DRIFT_POLICY"],"description":"Set env flag before (re)starting ATF server"},{"kind":"cli","command":"ATF_ENABLE_DRIFT_POLICY=1 atf doctor --pretty","description":"Confirm Drift policy gate is active"},{"kind":"cli","command":"echo '{\"chain_id\":\"solana\",\"intent_type\":\"perps\",\"raw_tx\":null,\"intent\":{\"venue\":\"drift_perps\",\"operation\":\"place_perp_order\",\"market\":\"SOL-PERP\",\"size\":1.0,\"leverage_x10\":20}}' | ATF_ENABLE_DRIFT_POLICY=1 atf bot protect --stdin --dry-run","description":"Dry-run Drift perps intent (fixture, no network)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["drift_policy_enabled"],"description":"Verify Drift policy capability present"}],"outputs":["drift_perps_policy capability present in manifest","Bot protect dry-run returns ALLOW or policy-enforced DENY for Drift intents"],"safety_notes":["ATF_ENABLE_DRIFT_POLICY defaults OFF (fail-closed).","Only enable on the ATF server process; never commit this flag to .env.","Dry-run does not submit any transaction."],"feature_gates":["ATF_ENABLE_DRIFT_POLICY"]},{"id":"enable_perps_mango","title":"Enable Mango v4 Solana perps policy gate","target":"both","prerequisites":[{"type":"env","key":"ATF_ENABLE_MANGO_POLICY","value":"1","description":"Must be set before starting the ATF server"},{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"}],"steps":[{"kind":"env","env_keys":["ATF_ENABLE_MANGO_POLICY"],"description":"Set env flag before (re)starting ATF server"},{"kind":"cli","command":"ATF_ENABLE_MANGO_POLICY=1 atf doctor --pretty","description":"Confirm Mango policy gate is active"},{"kind":"cli","command":"echo '{\"chain_id\":\"solana\",\"intent_type\":\"perps\",\"raw_tx\":null,\"intent\":{\"venue\":\"mango_perps\",\"operation\":\"place_perp_order\",\"market\":\"SOL-PERP\",\"size\":1.0,\"leverage_x10\":20}}' | ATF_ENABLE_MANGO_POLICY=1 atf bot protect --stdin --dry-run","description":"Dry-run Mango perps intent (fixture, no network)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["mango_policy_enabled"],"description":"Verify Mango policy capability present"}],"outputs":["mango_perps_policy capability present in manifest","Bot protect dry-run returns ALLOW or policy-enforced DENY for Mango intents"],"safety_notes":["ATF_ENABLE_MANGO_POLICY defaults OFF (fail-closed).","Only enable on the ATF server process; never commit this flag to .env.","Dry-run does not submit any transaction."],"feature_gates":["ATF_ENABLE_MANGO_POLICY"]},{"id":"enable_perps_hyperliquid","title":"Enable Hyperliquid perps policy gate","target":"both","prerequisites":[{"type":"env","key":"ATF_ENABLE_HYPERLIQUID_POLICY","value":"1","description":"Must be set before starting the ATF server"},{"type":"env","key":"ATF_BASE_URL","default":"http://localhost:8080","description":"ATF server base URL"}],"steps":[{"kind":"env","env_keys":["ATF_ENABLE_HYPERLIQUID_POLICY"],"description":"Set env flag before (re)starting ATF server"},{"kind":"cli","command":"ATF_ENABLE_HYPERLIQUID_POLICY=1 atf doctor --pretty","description":"Confirm Hyperliquid policy gate is active"},{"kind":"cli","command":"echo '{\"chain_id\":\"hyperliquid\",\"intent_type\":\"perps\",\"raw_tx\":null,\"intent\":{\"venue\":\"hyperliquid_perps\",\"operation\":\"order\",\"market\":\"SOL\",\"size\":1.0,\"leverage_x10\":20}}' | ATF_ENABLE_HYPERLIQUID_POLICY=1 atf bot protect --stdin --dry-run","description":"Dry-run Hyperliquid perps intent (fixture, no network)"},{"kind":"verify","verify":"atf doctor --format json","expect_checks":["hyperliquid_policy_enabled"],"description":"Verify Hyperliquid policy capability present"}],"outputs":["Hyperliquid chain and hyperliquid_perps venue active in manifest","Bot protect dry-run returns ALLOW or policy-enforced DENY for HL intents"],"safety_notes":["ATF_ENABLE_HYPERLIQUID_POLICY defaults OFF (fail-closed).","Only enable on the ATF server process; never commit this flag to .env.","Dry-run does not submit any transaction."],"feature_gates":["ATF_ENABLE_HYPERLIQUID_POLICY"]}];
11142
+
11143
+ // ---------------------------------------------------------------------------
11144
+ // Public accessor (used by CLI and tests)
11145
+ // ---------------------------------------------------------------------------
11146
+ function getRecipesV2() {
11147
+ return RECIPES_V2;
11148
+ }
11149
+
11150
+ function getRecipeById(id) {
11151
+ return RECIPES_V2.find((r) => r.id === id) || null;
11152
+ }
11153
+
11154
+ // ---------------------------------------------------------------------------
8181
11155
  // Output helpers
8182
11156
  // ---------------------------------------------------------------------------
8183
11157
  function _buildJsonPlan(recipe) {
@@ -9265,6 +12239,7 @@ COMMANDS
9265
12239
  tx sign Sign a transaction offline (no send)
9266
12240
  tx send Sign and send a transaction
9267
12241
  tx status Check transaction confirmation status
12242
+ tx explain Human-readable explanation of a bundle or signature
9268
12243
  receipts verify Verify content_hash / receipt_hash integrity
9269
12244
  anchor-receipt Anchor a receipt hash on-chain (Solana attestation)
9270
12245
  completion Generate shell completion (bash|zsh|fish|powershell)
@@ -9282,6 +12257,13 @@ COMMANDS
9282
12257
  bot init Generate bot config + policy template (atf.bot.json, atf.policy.json)
9283
12258
  bot protect Evaluate a tx/intent and return stable decision JSON + exit code
9284
12259
  bot send Evaluate + sign + send if allowed (Solana only)
12260
+ bot execute Universal bot execution: decide + receipt + verify + optional send
12261
+ bot analyze Analyze a proof bundle for quality, risk, recommendations
12262
+ bot land (stub) Transaction landing optimization
12263
+ bot session (stub) Manage bot execution sessions
12264
+ bot watch (stub) Real-time transaction monitoring
12265
+ bot replay (stub) Replay proof bundles for debugging/audit
12266
+ demo tc1 Run TC1 deterministic receipt proof bundle demo
9285
12267
  bootstrap Print an agent-readable bootstrap plan (manifest recipes_v2)
9286
12268
  report Generate a receipt-backed report (subcommands: savings)
9287
12269
  BOOTSTRAP OPTIONS
@@ -9326,6 +12308,36 @@ BOT SEND OPTIONS
9326
12308
  --confirm Poll Solana RPC until transaction is confirmed
9327
12309
  --timeout-ms <ms> HTTP timeout in ms for ATF server call (default: 20000)
9328
12310
 
12311
+ BOT EXECUTE OPTIONS
12312
+ --adapter <name> Adapter: jupiter, transfer
12313
+ --intent-type <type> Intent type: swap, transfer, order
12314
+ --policy <path> Path to policy file (optional)
12315
+ --keypair <path> Solana keypair for signing (required for send)
12316
+ --rpc <url> Solana RPC URL
12317
+ --execute Opt-in to on-chain execution
12318
+ --send Alias for --execute
12319
+ --devnet Force devnet mode
12320
+ --out <dir> Output directory for proof bundle
12321
+
12322
+ BOT ANALYZE OPTIONS
12323
+ --bundle <path> Path to proof bundle directory
12324
+ --file <path> Alias for --bundle
12325
+
12326
+ TX EXPLAIN OPTIONS
12327
+ --bundle <path> Path to proof bundle directory
12328
+ --sig <signature> Transaction signature (lightweight mode)
12329
+ --format json|text Output format (default: json)
12330
+
12331
+ DEMO TC1 OPTIONS
12332
+ --subcase a|b TC1 subcase: a (Jupiter fixture), b (devnet transfer)
12333
+ --execute Opt-in to on-chain execution (TC1-B only)
12334
+ --send Alias for --execute
12335
+ --out <dir> Output directory for proof bundles (default: artifacts)
12336
+ --keypair <path> Solana keypair for execution (TC1-B only)
12337
+ --rpc <url> Solana RPC URL
12338
+ --devnet Force devnet mode
12339
+ --prefer-solana-cli Prefer Solana CLI send path when available
12340
+
9329
12341
  Machine Usage:
9330
12342
  Manifest: GET /.well-known/atf.json
9331
12343
  Toolcard: docs/agent/atf_toolcard.json
@@ -9505,12 +12517,21 @@ function parseArgs(argv) {
9505
12517
  receiptsDir: null,
9506
12518
  reportSince: null,
9507
12519
  reportLastN: null,
12520
+ // demo / bot execute flags
12521
+ demoSubcase: null,
12522
+ demoOutDir: null,
12523
+ preferSolanaCli: false,
12524
+ botAdapter: null,
12525
+ botIntent: null,
12526
+ botPolicy: null,
12527
+ botReceipt: false,
12528
+ botBundle: null,
9508
12529
  };
9509
12530
 
9510
12531
  const raw = argv.slice(2);
9511
12532
  let i = 0;
9512
12533
  // Commands that accept subcommands
9513
- const MULTI_COMMANDS = new Set(["config", "profile", "secret", "rpc", "tx", "receipts", "completion", "policy", "fixtures", "lint", "plan", "bot", "perps", "report"]);
12534
+ const MULTI_COMMANDS = new Set(["config", "profile", "secret", "rpc", "tx", "receipts", "completion", "policy", "fixtures", "lint", "plan", "bot", "perps", "report", "demo"]);
9514
12535
 
9515
12536
  while (i < raw.length) {
9516
12537
  const arg = raw[i];
@@ -9565,7 +12586,9 @@ function parseArgs(argv) {
9565
12586
  args.swapIn = raw[++i] || null;
9566
12587
  i++;
9567
12588
  } else if (arg === "--out") {
9568
- args.swapOut = raw[++i] || null;
12589
+ const val = raw[++i] || null;
12590
+ args.swapOut = val;
12591
+ args.demoOutDir = val;
9569
12592
  i++;
9570
12593
  } else if (arg === "--amount-in") {
9571
12594
  args.amountIn = raw[++i] || null;
@@ -9673,6 +12696,24 @@ function parseArgs(argv) {
9673
12696
  args.anchorSequence = parseInt(raw[++i], 10);
9674
12697
  if (Number.isNaN(args.anchorSequence) || args.anchorSequence < 0) args.anchorSequence = null;
9675
12698
  i++;
12699
+ } else if (arg === "--subcase") {
12700
+ args.demoSubcase = raw[++i] || null;
12701
+ i++;
12702
+ } else if (arg === "--prefer-solana-cli") {
12703
+ args.preferSolanaCli = true;
12704
+ i++;
12705
+ } else if (arg === "--adapter") {
12706
+ args.botAdapter = raw[++i] || null;
12707
+ i++;
12708
+ } else if (arg === "--intent-type") {
12709
+ args.botIntent = raw[++i] || null;
12710
+ i++;
12711
+ } else if (arg === "--policy") {
12712
+ args.botPolicy = raw[++i] || null;
12713
+ i++;
12714
+ } else if (arg === "--bundle") {
12715
+ args.botBundle = raw[++i] || null;
12716
+ i++;
9676
12717
  } else if (!arg.startsWith("-") && !args.command) {
9677
12718
  args.command = arg;
9678
12719
  i++;
@@ -9814,8 +12855,11 @@ async function main() {
9814
12855
  case "status":
9815
12856
  await runTxStatus(args);
9816
12857
  break;
12858
+ case "explain":
12859
+ await runTxExplain(args);
12860
+ break;
9817
12861
  default:
9818
- exitWithError(ERROR_CODES.USER_ERROR, `Unknown tx subcommand: ${args.subCommand || "(none)"}`, "Available: sign, send, status", args.format);
12862
+ exitWithError(ERROR_CODES.USER_ERROR, `Unknown tx subcommand: ${args.subCommand || "(none)"}`, "Available: sign, send, status, explain", args.format);
9819
12863
  }
9820
12864
  break;
9821
12865
  case "receipts":
@@ -9887,11 +12931,43 @@ async function main() {
9887
12931
  case "send":
9888
12932
  await runBotSend(args);
9889
12933
  break;
12934
+ case "execute":
12935
+ await runBotExecute(args);
12936
+ break;
12937
+ case "analyze":
12938
+ await runBotAnalyze(args);
12939
+ break;
12940
+ case "land":
12941
+ await runBotLand(args);
12942
+ break;
12943
+ case "session":
12944
+ await runBotSession(args);
12945
+ break;
12946
+ case "watch":
12947
+ await runBotWatch(args);
12948
+ break;
12949
+ case "replay":
12950
+ await runBotReplay(args);
12951
+ break;
9890
12952
  default:
9891
12953
  exitWithError(
9892
12954
  ERROR_CODES.USER_ERROR,
9893
12955
  `Unknown bot subcommand: ${args.subCommand || "(none)"}`,
9894
- "Available: init, protect, send",
12956
+ "Available: init, protect, send, execute, analyze, land, session, watch, replay",
12957
+ args.format,
12958
+ );
12959
+ }
12960
+ break;
12961
+ case "demo":
12962
+ switch (args.subCommand) {
12963
+ case "tc1":
12964
+ await runDemoTc1(args);
12965
+ break;
12966
+ default:
12967
+ exitWithError(
12968
+ ERROR_CODES.USER_ERROR,
12969
+ `Unknown demo subcommand: ${args.subCommand || "(none)"}`,
12970
+ "Available: tc1",
9895
12971
  args.format,
9896
12972
  );
9897
12973
  }