#!/usr/bin/env tsx import { createHash, createHmac, randomUUID } from "node:crypto"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { ed25519 } from "@noble/curves/ed25519"; import bs58 from "bs58"; import { Keypair, VersionedTransaction } from "@solana/web3.js"; type CommandContext = { positionals: string[]; flags: Record; }; type BotCredentials = { wallet: string; key_id: string; secret: string; api_base?: string; label?: string; scopes?: string[]; created_at_ms?: number; }; type UnsignedBotEnvelope = { wallet: string; action: "list_bot_keys" | "create_bot_key" | "revoke_bot_key"; timestamp_ms: number; nonce: string; payload: Payload; }; const BOT_ACTION_VERSION = "DART_EXCHANGE_BOT_ACTION_V1"; const BOT_HMAC_VERSION = "DART_EXCHANGE_BOT_HMAC_V1"; function usage(): never { console.log(`dart_bot.ts Commands: help create-wallet --out redeem-access --router-base --keypair --code create-api-key --api-base --keypair --label [--out ] [--scopes ] quote --credentials [--api-base ] --input-mint --output-mint --amount [--lanes ] [--slippage-bps ] [--debug] bot-request --credentials [--api-base ] --path [--method ] [--body ] [--body-file ] [--out ] swap-and-relay --credentials --keypair [--api-base ] --input-mint --output-mint --amount [--lane ] [--slippage-bps ] [--route-id ] [--ordering-mode ] [--confirm ] [--dry-run] [--debug] [--out ] `); process.exit(0); } function parseArgs(argv: string[]): CommandContext { const positionals: string[] = []; const flags: Record = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]!; if (!token.startsWith("--")) { positionals.push(token); continue; } const trimmed = token.slice(2); const eqIndex = trimmed.indexOf("="); if (eqIndex >= 0) { const key = trimmed.slice(0, eqIndex); const value = trimmed.slice(eqIndex + 1); flags[key] = value; continue; } const next = argv[index + 1]; if (!next || next.startsWith("--")) { flags[trimmed] = true; continue; } flags[trimmed] = next; index += 1; } return { positionals, flags }; } function requireFlag(flags: Record, name: string): string { const value = flags[name]; if (typeof value !== "string" || value.trim() === "") { throw new Error(`Missing required flag --${name}`); } return value.trim(); } function optionalFlag(flags: Record, name: string): string | null { const value = flags[name]; if (typeof value !== "string") return null; return value.trim(); } function booleanFlag( flags: Record, name: string, defaultValue = false, ): boolean { const value = flags[name]; if (value === undefined) return defaultValue; if (value === true) return true; const normalized = String(value).trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalized)) return true; if (["0", "false", "no", "off"].includes(normalized)) return false; return defaultValue; } function sanitizeLabel(value: string): string { return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); } function normalizeBaseUrl(raw: string): string { return raw.trim().replace(/\/+$/, ""); } function normalizeApiPath(raw: string): string { return raw.startsWith("/") ? raw : `/${raw}`; } function sha256Hex(input: string): string { return createHash("sha256").update(input).digest("hex"); } function stableStringify(value: unknown): string { if ( value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) { return JSON.stringify(value); } if (typeof value === "bigint") { return JSON.stringify(value.toString()); } if ( value === undefined || typeof value === "function" || typeof value === "symbol" ) { return "null"; } if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; } if (value instanceof Date) { return JSON.stringify(value.toISOString()); } const entries = Object.entries(value as Record) .filter(([, entryValue]) => entryValue !== undefined) .sort(([a], [b]) => a.localeCompare(b)); return `{${entries .map( ([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`, ) .join(",")}}`; } function createBotActionMessage(envelope: UnsignedBotEnvelope): string { return [ BOT_ACTION_VERSION, envelope.wallet, envelope.action, String(envelope.timestamp_ms), envelope.nonce, stableStringify(envelope.payload), ].join("\n"); } function createBotHmacMessage(args: { method: string; pathWithQuery: string; timestampMs: number | string; nonce: string; bodyHashHex: string; }): string { return [ BOT_HMAC_VERSION, args.method.toUpperCase(), args.pathWithQuery, String(args.timestampMs), args.nonce, args.bodyHashHex, ].join("\n"); } function signMessageBase58(keypair: Keypair, message: string): string { const signature = ed25519.sign( new TextEncoder().encode(message), keypair.secretKey.slice(0, 32), ); return bs58.encode(signature); } async function fileExists(filePath: string): Promise { try { await access(filePath); return true; } catch { return false; } } async function writeJson(filePath: string, value: unknown): Promise { const absolute = resolve(filePath); await mkdir(dirname(absolute), { recursive: true }); await writeFile(absolute, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } async function readJson(filePath: string): Promise { const absolute = resolve(filePath); const raw = await readFile(absolute, "utf8"); return JSON.parse(raw) as unknown; } async function loadKeypair(filePath: string): Promise { const parsed = await readJson(filePath); if (!Array.isArray(parsed)) { throw new Error(`Keypair file must contain a JSON array: ${filePath}`); } const bytes = Uint8Array.from(parsed.map((value) => Number(value))); return Keypair.fromSecretKey(bytes); } async function loadCredentials(filePath: string): Promise { const parsed = await readJson(filePath); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`Credentials file must contain a JSON object: ${filePath}`); } const credentials = parsed as Partial; if (!credentials.wallet || !credentials.key_id || !credentials.secret) { throw new Error(`Credentials file is missing wallet/key_id/secret: ${filePath}`); } return credentials as BotCredentials; } async function parseResponse(response: Response): Promise<{ ok: boolean; status: number; text: string; json: unknown | null; }> { const text = await response.text().catch(() => ""); const json = (() => { if (!text) return null; try { return JSON.parse(text) as unknown; } catch { return null; } })(); return { ok: response.ok, status: response.status, text, json, }; } function printResult(value: unknown): void { if (typeof value === "string") { console.log(value); return; } console.log(JSON.stringify(value, null, 2)); } function getDefaultApiBase(flags: Record, credentials?: BotCredentials): string { const fromFlag = optionalFlag(flags, "api-base"); if (fromFlag) return normalizeBaseUrl(fromFlag); if (credentials?.api_base) return normalizeBaseUrl(credentials.api_base); throw new Error("Missing --api-base and credentials file has no api_base"); } async function sendBotRequest(args: { apiBase: string; credentials: BotCredentials; path: string; method: string; rawBody: string; }): Promise { const timestampMs = Date.now(); const nonce = randomUUID(); const pathWithQuery = normalizeApiPath(args.path); const secretHashHex = sha256Hex(args.credentials.secret); const bodyHashHex = sha256Hex(args.rawBody); const signature = createHmac("sha256", Buffer.from(secretHashHex, "hex")) .update( createBotHmacMessage({ method: args.method, pathWithQuery, timestampMs, nonce, bodyHashHex, }), ) .digest("hex"); const response = await fetch(`${normalizeBaseUrl(args.apiBase)}${pathWithQuery}`, { method: args.method.toUpperCase(), headers: { "Content-Type": "application/json", "x-dex-key-id": args.credentials.key_id, "x-dex-timestamp": String(timestampMs), "x-dex-nonce": nonce, "x-dex-signature": signature, }, body: args.rawBody, }); const parsed = await parseResponse(response); if (!parsed.ok) { throw new Error( `Bot request failed (${parsed.status}): ${parsed.text || response.statusText}`, ); } return parsed.json ?? parsed.text; } async function commandCreateWallet(context: CommandContext): Promise { const out = resolve(requireFlag(context.flags, "out")); if ((await fileExists(out)) && !booleanFlag(context.flags, "force")) { throw new Error(`Refusing to overwrite existing file: ${out}`); } const keypair = Keypair.generate(); await writeJson(out, Array.from(keypair.secretKey)); printResult({ wallet: keypair.publicKey.toBase58(), keypair_path: out, }); } async function commandRedeemAccess(context: CommandContext): Promise { const routerBase = normalizeBaseUrl(requireFlag(context.flags, "router-base")); const code = requireFlag(context.flags, "code"); const keypair = await loadKeypair(requireFlag(context.flags, "keypair")); const wallet = keypair.publicKey.toBase58(); const message = `Dart Exchange access: redeem code ${code} for wallet ${wallet}`; const signature = signMessageBase58(keypair, message); const response = await fetch(`${routerBase}/v1/access/redeem`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ wallet, code, signature, message, }), }); const parsed = await parseResponse(response); if (!parsed.ok) { throw new Error( `Access redemption failed (${parsed.status}): ${parsed.text || response.statusText}`, ); } printResult(parsed.json ?? parsed.text); } async function commandCreateApiKey(context: CommandContext): Promise { const apiBase = normalizeBaseUrl(requireFlag(context.flags, "api-base")); const label = requireFlag(context.flags, "label"); const keypair = await loadKeypair(requireFlag(context.flags, "keypair")); const wallet = keypair.publicKey.toBase58(); const scopes = optionalFlag(context.flags, "scopes") ?.split(",") .map((value) => value.trim()) .filter(Boolean); const payload = scopes && scopes.length > 0 ? { label, scopes } : { label }; const unsignedEnvelope: UnsignedBotEnvelope = { wallet, action: "create_bot_key", timestamp_ms: Date.now(), nonce: randomUUID(), payload, }; const signature = signMessageBase58( keypair, createBotActionMessage(unsignedEnvelope), ); const response = await fetch(`${apiBase}/api/bot-keys/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...unsignedEnvelope, signature, }), }); const parsed = await parseResponse(response); if (!parsed.ok || !parsed.json || typeof parsed.json !== "object") { throw new Error( `API key creation failed (${parsed.status}): ${parsed.text || response.statusText}`, ); } const result = parsed.json as { key?: { key_id?: string; label?: string; scopes?: string[]; created_at_ms?: number; }; secret?: string; }; if (!result.key?.key_id || !result.secret) { throw new Error(`API key creation returned an incomplete response: ${parsed.text}`); } const outputPath = resolve( optionalFlag(context.flags, "out") || `.tmp/${sanitizeLabel(label) || "bot"}-credentials.json`, ); const credentials: BotCredentials = { wallet, key_id: result.key.key_id, secret: result.secret, api_base: apiBase, label: result.key.label || label, scopes: result.key.scopes, created_at_ms: result.key.created_at_ms, }; await writeJson(outputPath, credentials); printResult({ wallet, key_id: credentials.key_id, label: credentials.label, scopes: credentials.scopes, api_base: credentials.api_base, credentials_path: outputPath, secret_saved: true, }); } async function commandQuote(context: CommandContext): Promise { const credentials = await loadCredentials(requireFlag(context.flags, "credentials")); const apiBase = getDefaultApiBase(context.flags, credentials); const lanes = (optionalFlag(context.flags, "lanes") || "safe,best_price") .split(",") .map((value) => value.trim()) .filter(Boolean); const body = { lanes, user_pubkey: credentials.wallet, input_mint: requireFlag(context.flags, "input-mint"), output_mint: requireFlag(context.flags, "output-mint"), in_amount: requireFlag(context.flags, "amount"), slippage_bps: Number(optionalFlag(context.flags, "slippage-bps") || "100"), debug: booleanFlag(context.flags, "debug"), }; const response = await sendBotRequest({ apiBase, credentials, path: "/api/bot/quotes", method: "POST", rawBody: JSON.stringify(body), }); const out = optionalFlag(context.flags, "out"); if (out) { await writeJson(out, response); } printResult(response); } async function commandBotRequest(context: CommandContext): Promise { const credentials = await loadCredentials(requireFlag(context.flags, "credentials")); const apiBase = getDefaultApiBase(context.flags, credentials); const path = requireFlag(context.flags, "path"); const method = optionalFlag(context.flags, "method") || "POST"; let rawBody = "{}"; const bodyFile = optionalFlag(context.flags, "body-file"); const bodyInline = optionalFlag(context.flags, "body"); if (bodyFile && bodyInline) { throw new Error("Use only one of --body or --body-file"); } if (bodyFile) { rawBody = await readFile(resolve(bodyFile), "utf8"); } else if (bodyInline) { rawBody = bodyInline; } const response = await sendBotRequest({ apiBase, credentials, path, method, rawBody, }); const out = optionalFlag(context.flags, "out"); if (out) { await writeJson(out, response); } printResult(response); } async function commandSwapAndRelay(context: CommandContext): Promise { const credentials = await loadCredentials(requireFlag(context.flags, "credentials")); const apiBase = getDefaultApiBase(context.flags, credentials); const keypair = await loadKeypair(requireFlag(context.flags, "keypair")); const wallet = keypair.publicKey.toBase58(); if (wallet !== credentials.wallet) { throw new Error( `Wallet mismatch: credentials belong to ${credentials.wallet}, keypair is ${wallet}`, ); } const lane = optionalFlag(context.flags, "lane") || "safe"; const slippageBps = Number(optionalFlag(context.flags, "slippage-bps") || "100"); const routeId = optionalFlag(context.flags, "route-id"); const orderingMode = optionalFlag(context.flags, "ordering-mode") || "fair_preferred"; const confirm = booleanFlag(context.flags, "confirm", true); const debug = booleanFlag(context.flags, "debug"); const swapBody: Record = { lane, user_pubkey: wallet, input_mint: requireFlag(context.flags, "input-mint"), output_mint: requireFlag(context.flags, "output-mint"), in_amount: requireFlag(context.flags, "amount"), slippage_bps: slippageBps, debug, }; if (lane === "pro") { swapBody.pro_ack = true; } if (routeId) { swapBody.route_id = routeId; } const swapResponse = await sendBotRequest({ apiBase, credentials, path: "/api/bot/swap", method: "POST", rawBody: JSON.stringify(swapBody), }); if ( !swapResponse || typeof swapResponse !== "object" || Array.isArray(swapResponse) ) { throw new Error("Unexpected swap response"); } const compiledTxBase64 = String( (swapResponse as { compiled_tx_base64?: string | null }).compiled_tx_base64 || "", ).trim(); if (!compiledTxBase64 || booleanFlag(context.flags, "dry-run")) { if (optionalFlag(context.flags, "out")) { await writeJson(optionalFlag(context.flags, "out")!, { swap: swapResponse }); } printResult({ swap: swapResponse }); return; } const tx = VersionedTransaction.deserialize(Buffer.from(compiledTxBase64, "base64")); tx.sign([keypair]); const signedTransactionBase64 = Buffer.from(tx.serialize()).toString("base64"); const relayBody = { network: "mainnet", type: "versioned", transaction: signedTransactionBase64, confirm, ordering_mode: orderingMode, }; const relayResponse = await sendBotRequest({ apiBase, credentials, path: "/api/bot/relay", method: "POST", rawBody: JSON.stringify(relayBody), }); const combined = { swap: swapResponse, relay: relayResponse, }; const out = optionalFlag(context.flags, "out"); if (out) { await writeJson(out, combined); } printResult(combined); } async function main(): Promise { const context = parseArgs(process.argv.slice(2)); const command = context.positionals[0] || "help"; switch (command) { case "help": usage(); break; case "create-wallet": await commandCreateWallet(context); break; case "redeem-access": await commandRedeemAccess(context); break; case "create-api-key": await commandCreateApiKey(context); break; case "quote": await commandQuote(context); break; case "bot-request": await commandBotRequest(context); break; case "swap-and-relay": await commandSwapAndRelay(context); break; default: throw new Error(`Unknown command: ${command}`); } } main().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(message); process.exit(1); });