@trucore/atf 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3558 -482
- 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.
|
|
5
|
-
// Built: 2026-03-
|
|
6
|
-
// Commit:
|
|
4
|
+
// @trucore/atf v1.4.2 — Agent Transaction Firewall CLI
|
|
5
|
+
// Built: 2026-03-06T03:37:09.328Z
|
|
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.
|
|
16
|
+
const VERSION = "1.4.2";
|
|
17
17
|
const DEFAULT_BASE_URL = "https://api.trucore.xyz";
|
|
18
|
-
const BUILD_COMMIT = "
|
|
19
|
-
const BUILD_DATE = "2026-03-
|
|
18
|
+
const BUILD_COMMIT = "dbbc171";
|
|
19
|
+
const BUILD_DATE = "2026-03-06T03:37:09.328Z";
|
|
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
|
-
|
|
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
|
|
1261
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1693
|
+
headers,
|
|
1317
1694
|
body: JSON.stringify(body),
|
|
1318
|
-
signal: AbortSignal.timeout(opts.timeoutMs ||
|
|
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.
|
|
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/
|
|
8148
|
-
/**
|
|
8149
|
-
*
|
|
8150
|
-
*
|
|
8151
|
-
*
|
|
8152
|
-
*
|
|
8153
|
-
|
|
8154
|
-
|
|
8155
|
-
|
|
8156
|
-
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
}
|
|
8175
|
-
|
|
8176
|
-
function
|
|
8177
|
-
|
|
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
|
-
|
|
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
|
}
|