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