From 284532974ed0453d56216ec4423128b5502bb41e Mon Sep 17 00:00:00 2001 From: jnrspaco Date: Fri, 15 May 2026 11:51:18 +0100 Subject: [PATCH] feat(aibtc-trade-competitor): add trading competition skill with Bitflow swap and competition submission --- skills/aibtc-trade-competitor/AGENT.md | 62 +++ skills/aibtc-trade-competitor/SKILL.md | 80 ++++ .../aibtc-trade-competitor.ts | 380 ++++++++++++++++++ 3 files changed, 522 insertions(+) create mode 100644 skills/aibtc-trade-competitor/AGENT.md create mode 100644 skills/aibtc-trade-competitor/SKILL.md create mode 100644 skills/aibtc-trade-competitor/aibtc-trade-competitor.ts diff --git a/skills/aibtc-trade-competitor/AGENT.md b/skills/aibtc-trade-competitor/AGENT.md new file mode 100644 index 00000000..1c292783 --- /dev/null +++ b/skills/aibtc-trade-competitor/AGENT.md @@ -0,0 +1,62 @@ +--- +name: aibtc-trade-competitor-agent +skill: aibtc-trade-competitor +description: "Executes Bitflow swaps and submits to AIBTC trading competition with spend limits, confirmation gates, and P&L tracking." +--- + +# Agent Behavior — AIBTC Trade Competitor + +## Decision order +1. Run `doctor` first. If wallet unlock fails or not registered, STOP. +2. Run `status` to check current P&L and competition standing. +3. Determine swap direction based on market conditions. +4. Confirm swap intent with operator before executing. +5. Run `run --pair --amount ` to execute. +6. Wait for tx confirmation then submit to competition. +7. Log txid, competition submission status, and updated P&L. + +## Guardrails +- NEVER swap more than 1 STX or 10,000 sats sBTC per invocation. +- NEVER proceed if balance is insufficient. +- NEVER submit an unconfirmed tx to competition. +- NEVER retry a failed swap automatically. +- NEVER expose WALLET_PASSWORD or AIBTC_WALLET_ID in logs. +- Always require explicit operator confirmation before write. +- Default to blocked when intent is ambiguous. + +## Refusal conditions +- Amount exceeds limits → REFUSE with EXCEEDS_SPEND_LIMIT +- Insufficient balance → REFUSE with INSUFFICIENT_BALANCE +- Swap quote unavailable → REFUSE with QUOTE_UNAVAILABLE +- Tx not confirmed after 60s → REFUSE competition submission with TX_PENDING +- Not registered on competition → REFUSE with NOT_REGISTERED +- Wallet locked → REFUSE with WALLET_UNAVAILABLE + +## Output contract +\`\`\`json +{ + "status": "success | error | blocked", + "action": "next recommended action", + "data": { + "swap_txid": "0x...", + "swap_status": "success", + "competition_accepted": true, + "competition_rank": 3, + "pnl_usd": 0.42, + "token_in": "STX", + "token_out": "sBTC", + "amount_in": 500000, + "amount_out": 105 + }, + "error": { "code": "", "message": "", "next": "" } +} +\`\`\` + +## On error +- Log full error payload. +- Do not retry silently. +- Surface to operator with action guidance. + +## Cooldown +- 60 seconds minimum between swaps. +- Maximum 5 swaps per session without operator reconfirmation. \ No newline at end of file diff --git a/skills/aibtc-trade-competitor/SKILL.md b/skills/aibtc-trade-competitor/SKILL.md new file mode 100644 index 00000000..7027e806 --- /dev/null +++ b/skills/aibtc-trade-competitor/SKILL.md @@ -0,0 +1,80 @@ +--- +name: aibtc-trade-competitor +description: "Executes Bitflow swaps and submits trade txids to the AIBTC trading competition for P&L scoring." +metadata: + author: "jnrspaco" + author-agent: "Galactic Orbit" + user-invocable: "false" + arguments: "doctor | status | run" + entry: "aibtc-trade-competitor/aibtc-trade-competitor.ts" + requires: "wallet, signing, settings, AIBTC_WALLET_ID, WALLET_PASSWORD" + tags: "defi, write, mainnet-only, requires-funds, competition, l2" +--- + +# AIBTC Trade Competitor + +## What it does +Executes a Bitflow swap (STX → sBTC or sBTC → STX) via the AIBTC MCP wallet, +waits for transaction confirmation, then submits the txid to the AIBTC trading +competition for P&L scoring. Returns the swap txid, competition submission +status, and current P&L standing. + +## Why agents need it +Agents participating in the AIBTC trading competition need a reliable primitive +to execute swaps and register them for scoring. This skill closes the full loop: +get quote → execute swap → confirm tx → submit to competition → check standing. + +## Safety notes +- This skill WRITES to chain and moves real funds. +- Maximum swap per invocation: 1 STX or 10,000 satoshis sBTC. +- Agent will REFUSE if balance is insufficient. +- Agent will REFUSE if swap quote is unavailable. +- Requires AIBTC_WALLET_ID and WALLET_PASSWORD environment variables. +- Mainnet only — real funds at risk. + +## Commands + +### doctor +Checks wallet, balance, competition registration status. +\`\`\`bash +bun run aibtc-trade-competitor/aibtc-trade-competitor.ts doctor +\`\`\` + +### status +Returns current competition standing and P&L. +\`\`\`bash +bun run aibtc-trade-competitor/aibtc-trade-competitor.ts status +\`\`\` + +### run +Executes a Bitflow swap and submits txid to competition. +\`\`\`bash +bun run aibtc-trade-competitor/aibtc-trade-competitor.ts run --pair stx-sbtc --amount 0.5 +\`\`\` +Pair: stx-sbtc or sbtc-stx. Amount in STX or sBTC. + +## Output contract +\`\`\`json +{ + "status": "success | error | blocked", + "action": "what the agent should do next", + "data": { + "swap_txid": "0xabc123...", + "swap_status": "success", + "competition_accepted": true, + "competition_rank": 3, + "pnl_usd": 0.42, + "token_in": "STX", + "token_out": "sBTC", + "amount_in": 500000, + "amount_out": 105 + }, + "error": null +} +\`\`\` + +## Known constraints +- Max swap: 1 STX or 10,000 sats sBTC per invocation. +- Requires agent registered on aibtc.com and identity_register on-chain. +- Uses bitflow_swap MCP tool for execution. +- Waits up to 60s for tx confirmation before submitting to competition. \ No newline at end of file diff --git a/skills/aibtc-trade-competitor/aibtc-trade-competitor.ts b/skills/aibtc-trade-competitor/aibtc-trade-competitor.ts new file mode 100644 index 00000000..67c2c71a --- /dev/null +++ b/skills/aibtc-trade-competitor/aibtc-trade-competitor.ts @@ -0,0 +1,380 @@ +import { Command } from "commander"; +import * as https from "https"; +import { spawn } from "child_process"; + +const program = new Command(); + +const HIRO_API = "https://api.hiro.so"; +const MAX_STX_AMOUNT = 1_000_000; // 1 STX in microSTX +const MAX_SBTC_SATS = 10_000; // 10,000 satoshis + +function log(msg: string) { process.stderr.write(msg + "\n"); } +function safeJson(text: string): any { + try { return JSON.parse(text); } catch { return {}; } +} +function wait(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => { + try { resolve(JSON.parse(data)); } + catch { reject(new Error("Invalid JSON")); } + }); + }); + req.setTimeout(15000, () => { req.destroy(); reject(new Error("Timeout")); }); + req.on("error", reject); + }); +} + +async function getSTXBalance(address: string): Promise { + const json = await httpGet(`${HIRO_API}/extended/v1/address/${address}/balances`); + return parseInt(json?.stx?.balance ?? "0"); +} + +async function getSBTCBalance(address: string): Promise { + const json = await httpGet(`${HIRO_API}/extended/v1/address/${address}/balances`); + const fungible = json?.fungible_tokens ?? {}; + const key = Object.keys(fungible).find((k) => k.includes("sbtc-token")); + return key ? parseInt(fungible[key].balance ?? "0") : 0; +} + +async function waitForTxConfirmation(txid: string, maxWaitMs = 60000): Promise { + const start = Date.now(); + const cleanTxid = txid.startsWith("0x") ? txid : `0x${txid}`; + while (Date.now() - start < maxWaitMs) { + try { + const json = await httpGet(`${HIRO_API}/extended/v1/tx/${cleanTxid}`); + const status = json?.tx_status ?? "pending"; + if (status !== "pending") return status; + } catch (_) {} + await wait(5000); + } + return "pending"; +} + +class McpClient { + proc: any = null; + buffer: string = ""; + pending: Map = new Map(); + nextId: number = 1; + + start(): Promise { + return new Promise((resolve, reject) => { + this.proc = spawn("npx", ["@aibtc/mcp-server@latest"], { + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + shell: true, + }); + this.proc.stdout.on("data", (data: Buffer) => { + this.buffer += data.toString(); + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id != null && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id)!; + this.pending.delete(msg.id); + if (msg.error) reject(new Error(msg.error.message)); + else resolve(msg.result); + } + } catch (_) {} + } + }); + this.proc.stderr.on("data", (d: Buffer) => { + const s = d.toString().trim(); + if (s) log("[MCP] " + s); + }); + this.proc.on("error", reject); + const id = this.nextId++; + this.pending.set(id, { resolve, reject }); + this._write({ + jsonrpc: "2.0", id, method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "aibtc-trade-ci", version: "1.0.0" } } + }); + }).then((r) => { + this._write({ jsonrpc: "2.0", method: "notifications/initialized" }); + return r; + }); + } + + _write(msg: any) { this.proc.stdin.write(JSON.stringify(msg) + "\n"); } + + callTool(name: string, args: any = {}, timeoutMs = 120000): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`MCP tool "${name}" timed out after ${timeoutMs}ms`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (v: any) => { clearTimeout(timer); resolve(v); }, + reject: (e: any) => { clearTimeout(timer); reject(e); } + }); + this._write({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }); + }); + } + + stop() { try { this.proc?.kill(); } catch (_) {} } +} + +async function unlockWallet(client: McpClient): Promise { + const password = process.env.WALLET_PASSWORD ?? ""; + const walletId = process.env.AIBTC_WALLET_ID ?? ""; + if (!walletId) throw new Error("AIBTC_WALLET_ID not set"); + if (!password) throw new Error("WALLET_PASSWORD not set"); + await client.callTool("wallet_switch", { walletId }); + await wait(1000); + const unlockRaw = await client.callTool("wallet_unlock", { password }); + const unlock = safeJson(unlockRaw?.content?.[0]?.text ?? "{}"); + if (!unlock.success) throw new Error("Wallet unlock failed"); + await wait(500); + const statusRaw = await client.callTool("wallet_status", {}); + const status = safeJson(statusRaw?.content?.[0]?.text ?? "{}"); + return status?.wallet?.address ?? "SP2DQHGKS3VFDY50HMGPYEWRSA3PA2H3QDPEGBNAK"; +} + +program.name("aibtc-trade-competitor").description("Execute Bitflow swaps and submit to AIBTC trading competition"); + +program.command("doctor") + .description("Check wallet, balance, and competition registration") + .action(async () => { + if (!process.env.WALLET_PASSWORD || !process.env.AIBTC_WALLET_ID) { + console.log(JSON.stringify({ status: "error", action: "set WALLET_PASSWORD and AIBTC_WALLET_ID", data: {}, error: { code: "MISSING_ENV", message: "WALLET_PASSWORD or AIBTC_WALLET_ID not set", next: "export both env vars" } })); + return; + } + const client = new McpClient(); + try { + await client.start(); + const address = await unlockWallet(client); + const [stxBalance, sbtcBalance] = await Promise.all([ + getSTXBalance(address), + getSBTCBalance(address), + ]); + + // Check competition status + const compRaw = await client.callTool("competition_status", { + address, + include_pnl: false, + }); + const comp = safeJson(compRaw?.content?.[0]?.text ?? "{}"); + + console.log(JSON.stringify({ + status: "success", + action: comp?.registered + ? "registered — run to execute a trade" + : "not registered — complete registration at aibtc.com first", + data: { + address, + stx_balance_stx: stxBalance / 1e6, + sbtc_balance_sats: sbtcBalance, + competition_registered: comp?.registered ?? false, + agent_id: comp?.agent_id ?? null, + trade_count: comp?.trade_count ?? 0, + max_stx_swap: 1, + max_sbtc_swap_sats: MAX_SBTC_SATS, + }, + error: null, + })); + } catch (err: any) { + console.log(JSON.stringify({ status: "error", action: "check env vars and MCP", data: {}, error: { code: "DOCTOR_FAILED", message: err.message, next: "retry after 30s" } })); + } finally { + client.stop(); + } + }); + +program.command("status") + .description("Check current competition standing and P&L") + .action(async () => { + if (!process.env.WALLET_PASSWORD || !process.env.AIBTC_WALLET_ID) { + console.log(JSON.stringify({ status: "error", action: "set WALLET_PASSWORD and AIBTC_WALLET_ID", data: {}, error: { code: "MISSING_ENV", message: "env vars not set", next: "export both env vars" } })); + return; + } + const client = new McpClient(); + try { + await client.start(); + const address = await unlockWallet(client); + const compRaw = await client.callTool("competition_status", { address, include_pnl: true }); + const comp = safeJson(compRaw?.content?.[0]?.text ?? "{}"); + + console.log(JSON.stringify({ + status: "success", + action: "competition status retrieved", + data: { + address, + registered: comp?.registered ?? false, + agent_id: comp?.agent_id ?? null, + trade_count: comp?.trade_count ?? 0, + verified_trade_count: comp?.verified_trade_count ?? 0, + rank: comp?.campaign?.rank ?? null, + pnl_usd: comp?.campaign_stats?.pnl_usd ?? null, + pnl_percent: comp?.campaign_stats?.pnl_percent ?? null, + notional_usd: comp?.campaign_stats?.notional_usd ?? null, + priced_trades: comp?.campaign_stats?.priced_trade_count ?? 0, + last_trade_at: comp?.last_trade_at ?? null, + }, + error: null, + })); + } catch (err: any) { + console.log(JSON.stringify({ status: "error", action: "check env vars and MCP", data: {}, error: { code: "STATUS_FAILED", message: err.message, next: "retry after 30s" } })); + } finally { + client.stop(); + } + }); + +program.command("run") + .description("Execute Bitflow swap and submit to competition") + .requiredOption("--pair ", "Swap pair direction") + .requiredOption("--amount ", "Amount (STX for stx-sbtc, sBTC sats for sbtc-stx)") + .action(async (opts) => { + const pair = opts.pair; + const amount = parseFloat(opts.amount); + + if (!["stx-sbtc", "sbtc-stx"].includes(pair)) { + console.log(JSON.stringify({ status: "error", action: "use stx-sbtc or sbtc-stx", data: {}, error: { code: "INVALID_PAIR", message: "pair must be stx-sbtc or sbtc-stx", next: "retry with valid pair" } })); + return; + } + if (isNaN(amount) || amount <= 0) { + console.log(JSON.stringify({ status: "error", action: "provide valid positive amount", data: {}, error: { code: "INVALID_AMOUNT", message: "amount must be positive", next: "retry with valid amount" } })); + return; + } + + const amountMicro = pair === "stx-sbtc" ? Math.floor(amount * 1e6) : Math.floor(amount); + + if (pair === "stx-sbtc" && amountMicro > MAX_STX_AMOUNT) { + console.log(JSON.stringify({ status: "blocked", action: "reduce to 1 STX or less", data: { requested: amount, max: 1 }, error: { code: "EXCEEDS_SPEND_LIMIT", message: `${amount} STX exceeds max of 1 STX`, next: "reduce amount" } })); + return; + } + if (pair === "sbtc-stx" && amountMicro > MAX_SBTC_SATS) { + console.log(JSON.stringify({ status: "blocked", action: `reduce to ${MAX_SBTC_SATS} sats or less`, data: { requested: amount, max: MAX_SBTC_SATS }, error: { code: "EXCEEDS_SPEND_LIMIT", message: `${amount} sats exceeds max`, next: "reduce amount" } })); + return; + } + if (!process.env.WALLET_PASSWORD || !process.env.AIBTC_WALLET_ID) { + console.log(JSON.stringify({ status: "error", action: "set WALLET_PASSWORD and AIBTC_WALLET_ID", data: {}, error: { code: "MISSING_ENV", message: "env vars not set", next: "export both env vars" } })); + return; + } + + const client = new McpClient(); + try { + await client.start(); + const address = await unlockWallet(client); + + // Check balance + if (pair === "stx-sbtc") { + const balance = await getSTXBalance(address); + if (balance < amountMicro + 10000) { + console.log(JSON.stringify({ status: "blocked", action: "fund wallet with STX", data: { balance_stx: balance / 1e6, required_stx: amount + 0.01 }, error: { code: "INSUFFICIENT_BALANCE", message: `balance ${balance / 1e6} STX insufficient`, next: "add STX and retry" } })); + client.stop(); + return; + } + } else { + const balance = await getSBTCBalance(address); + if (balance < amountMicro) { + console.log(JSON.stringify({ status: "blocked", action: "fund wallet with sBTC", data: { balance_sats: balance, required_sats: amountMicro }, error: { code: "INSUFFICIENT_BALANCE", message: `balance ${balance} sats insufficient`, next: "add sBTC and retry" } })); + client.stop(); + return; + } + } + + // Get quote first + log(`Getting Bitflow quote for ${pair}...`); + const quoteRaw = await client.callTool("bitflow_get_quote", { + tokenXContract: pair === "stx-sbtc" ? "token-wstx" : "token-sbtc", + tokenYContract: pair === "stx-sbtc" ? "token-sbtc" : "token-wstx", + amount: amountMicro.toString(), + }); + const quoteText = quoteRaw?.content?.[0]?.text ?? "{}"; + const quote = safeJson(quoteText); + log(`Quote: ${quoteText.slice(0, 200)}`); + + if (!quote || quoteText.includes("error") || quoteText.includes("Error")) { + console.log(JSON.stringify({ status: "blocked", action: "quote unavailable — retry later", data: {}, error: { code: "QUOTE_UNAVAILABLE", message: quoteText.slice(0, 200), next: "retry after 30s" } })); + client.stop(); + return; + } + + // Execute swap + log(`Executing Bitflow swap ${pair} for ${amount}...`); + const swapRaw = await client.callTool("bitflow_swap", { + tokenXContract: pair === "stx-sbtc" ? "token-wstx" : "token-sbtc", + tokenYContract: pair === "stx-sbtc" ? "token-sbtc" : "token-wstx", + amount: amountMicro.toString(), + slippage: "0.01", + }, 120000); + + const swapText = swapRaw?.content?.[0]?.text ?? "{}"; + log(`Swap result: ${swapText.slice(0, 300)}`); + const swapJson = safeJson(swapText); + const txid = swapJson?.txid ?? swapJson?.tx_id ?? swapText.match(/[0-9a-f]{64}/i)?.[0] ?? null; + + if (!txid) { + console.log(JSON.stringify({ status: "error", action: "swap failed — check logs", data: { raw: swapText.slice(0, 300) }, error: { code: "SWAP_FAILED", message: "no txid in swap response", next: "run doctor to diagnose" } })); + client.stop(); + return; + } + + log(`Swap txid: ${txid} — waiting for confirmation...`); + + // Wait for tx confirmation + const txStatus = await waitForTxConfirmation(txid, 60000); + log(`Tx status: ${txStatus}`); + + if (txStatus === "pending") { + console.log(JSON.stringify({ + status: "success", + action: "swap submitted — tx still pending, submit to competition manually after confirmation", + data: { + swap_txid: txid, + swap_status: "pending", + competition_accepted: false, + note: "retry competition_submit_trade after tx confirms", + explorer_url: `https://explorer.hiro.so/txid/${txid}`, + }, + error: null, + })); + client.stop(); + return; + } + + // Submit to competition + log(`Submitting txid to competition...`); + const compRaw = await client.callTool("competition_submit_trade", { txid }, 60000); + const compText = compRaw?.content?.[0]?.text ?? "{}"; + log(`Competition response: ${compText.slice(0, 300)}`); + const comp = safeJson(compText); + + // Get updated status + const statusRaw = await client.callTool("competition_status", { include_pnl: true }); + const status = safeJson(statusRaw?.content?.[0]?.text ?? "{}"); + + console.log(JSON.stringify({ + status: "success", + action: "swap executed and submitted to competition", + data: { + swap_txid: txid, + swap_status: txStatus, + token_in: pair === "stx-sbtc" ? "STX" : "sBTC", + token_out: pair === "stx-sbtc" ? "sBTC" : "STX", + amount_in: amountMicro, + competition_accepted: comp?.accepted ?? comp?.txid ? true : false, + competition_rank: status?.campaign?.rank ?? null, + pnl_usd: status?.campaign_stats?.pnl_usd ?? null, + trade_count: status?.trade_count ?? null, + explorer_url: `https://explorer.hiro.so/txid/${txid}`, + }, + error: null, + })); + } catch (err: any) { + console.log(JSON.stringify({ status: "error", action: "check MCP and retry", data: {}, error: { code: "TRADE_FAILED", message: err.message, next: "run doctor to diagnose" } })); + } finally { + client.stop(); + } + }); + +program.parse(); \ No newline at end of file