From 00c3892016274d6a635271a913fabe27c849e8f4 Mon Sep 17 00:00:00 2001 From: Serene Spring Date: Mon, 27 Apr 2026 07:22:11 +0100 Subject: [PATCH 1/3] feat(ststx-liquid-stacker): add StackingDAO liquid-stacking writer skill Co-Authored-By: Claude Sonnet 4.6 --- skills/ststx-liquid-stacker/AGENT.md | 73 ++ skills/ststx-liquid-stacker/SKILL.md | 138 +++ .../ststx-liquid-stacker.ts | 998 ++++++++++++++++++ 3 files changed, 1209 insertions(+) create mode 100644 skills/ststx-liquid-stacker/AGENT.md create mode 100644 skills/ststx-liquid-stacker/SKILL.md create mode 100644 skills/ststx-liquid-stacker/ststx-liquid-stacker.ts diff --git a/skills/ststx-liquid-stacker/AGENT.md b/skills/ststx-liquid-stacker/AGENT.md new file mode 100644 index 00000000..e685cc6d --- /dev/null +++ b/skills/ststx-liquid-stacker/AGENT.md @@ -0,0 +1,73 @@ +--- +name: ststx-liquid-stacker-agent +skill: ststx-liquid-stacker +description: "Executes StackingDAO liquid-stacking deposits, withdrawal-ticket creation, and matured-ticket claims with in-code ratio-slippage, amount-cap, reserve-floor, cooldown, and confirmation-token guardrails." +--- + +# Agent Behavior — stSTX Liquid Stacker + +## Purpose + +Use this skill to move STX in or out of StackingDAO's liquid-stacking position (stSTX) only when the request is well-specified, mainnet, within safety caps, and explicitly confirmed. + +## Decision order + +1. Run `doctor` first on any wallet you have not recently verified. If it fails, surface the blocker and stop. +2. Run `status` to read current STX/stSTX balances, the live `stx-per-ststx` ratio, and any outstanding withdrawal-ticket NFTs. Compare the current ratio to what the caller expected — if it has drifted, surface the drift and ask for updated input before proceeding. +3. Only use `run --action deposit` when: + - wallet is on mainnet + - STX balance covers `--amount-ustx` plus `--reserve-ustx` plus `--min-gas-reserve-ustx` + - current rate is within `--max-slippage-bps` of `--expected-rate-ustx-per-ststx` + - cooldown has cleared + - `--confirm=STACK` is present +4. Only use `run --action init-withdraw` when: + - wallet is on mainnet + - stSTX balance covers `--amount-ststx` + - current rate is within `--max-slippage-bps` of `--expected-rate-ustx-per-ststx` + - cooldown has cleared + - `--confirm=UNSTACK` is present +5. Only use `run --action withdraw` when: + - the NFT ticket exists and belongs to the active wallet + - the ticket's `cycle` field is strictly less than the current PoX cycle read from the chain + - `--confirm=CLAIM` is present + +## Guardrails + +- Never broadcast without `AIBTC_WALLET_PASSWORD` and the matching confirm token for the action. +- Never deposit more than `--max-deposit-ustx`; never withdraw more than `--max-withdraw-ststx`. +- Never let the STX balance fall below `--reserve-ustx` after a deposit. +- Never let the STX gas balance fall below `--min-gas-reserve-ustx` after any broadcast. +- Never proceed when the live rate deviates from the caller's expected rate by more than `--max-slippage-bps`. +- Never attempt `withdraw` on a ticket whose cycle has not matured — the on-chain call would revert but the broadcast still costs gas. +- Never retry silently on error; surface the JSON error payload and wait for operator input. +- Never mutate the spend/cooldown ledger without a confirmed broadcast plan. +- Treat the emitted `mcp_command` as `post_condition_mode: "deny"` — any unexpected token flow aborts the transaction. + +## Blocked conditions — surface and stop + +- wallet cannot be resolved or is not on mainnet +- STX or stSTX balance insufficient for the requested action +- rate slippage exceeds configured tolerance +- cooldown is active +- withdraw ticket not yet matured, not found, or owned by a different principal +- confirmation token missing or does not match the action +- doctor check failed and was not re-run + +## On error + +- Log the error payload from stdout as-is. +- Do not retry automatically. +- Surface the `error.next` guidance to the user and wait for explicit instruction. + +## On success + +- Capture the emitted `mcp_command` block and pass it to the AIBTC MCP wallet for signing + broadcast. +- After broadcast, record the returned txid and persist it in the local spend ledger. +- Run `status` again to confirm the on-chain state matches the expected outcome (balance delta, new NFT ticket id, or ticket redemption). +- Report completion with the txid and explorer URL. + +## Operational notes + +- This is a write skill with three distinct actions. Each action has its own confirmation token to prevent cross-action mistakes. +- StackingDAO contract principals are configurable via flags so the skill survives protocol version rolls without a rewrite. +- The skill is deliberately standalone: it emits a broadcast-ready plan rather than attempting to sign in-process, matching the pattern used by the already-merged `sbtc-yield-maximizer`. diff --git a/skills/ststx-liquid-stacker/SKILL.md b/skills/ststx-liquid-stacker/SKILL.md new file mode 100644 index 00000000..b1e2b137 --- /dev/null +++ b/skills/ststx-liquid-stacker/SKILL.md @@ -0,0 +1,138 @@ +--- +name: ststx-liquid-stacker +description: "Liquid stack STX via StackingDAO — deposit STX for stSTX, initiate batched withdrawals, and claim matured STX with code-enforced ratio-slippage, amount caps, reserve floors, cooldown, mainnet-only, and PostConditionMode.Deny safety gates." +metadata: + author: "IamHarrie-Labs" + author-agent: "Liquid Horizon" + user-invocable: "false" + arguments: "doctor | status | run" + entry: "ststx-liquid-stacker/ststx-liquid-stacker.ts" + requires: "wallet, signing, settings" + tags: "defi, write, mainnet-only, requires-funds, l2" +--- + +# stSTX Liquid Stacker + +## What it does + +Executes the three StackingDAO liquid-stacking write flows that no skill in the registry currently covers: `deposit` (STX → stSTX), `init-withdraw` (burn stSTX to mint a withdrawal NFT ticket), and `withdraw` (claim STX from a matured ticket). Every write path enforces ratio-slippage, amount caps, reserve floors, cooldown, mainnet-only, and `PostConditionMode.Deny` in code — not just documentation. + +## Why agents need it + +Liquid stacking is a core Stacks DeFi primitive: it turns illiquid 1–2-week PoX stacking cycles into a liquid receipt token (stSTX) that earns native yield while remaining composable. The existing `stacking-delegation` skill handles native PoX delegation only; this skill closes the liquid-stacking gap so agents can (1) convert idle STX into yield-bearing stSTX on demand, (2) queue withdrawals when capital is needed for other strategies, and (3) reclaim matured STX without manual ticket tracking. + +This is complementary to the existing `sbtc-yield-maximizer` (which routes idle sBTC) and `zest-yield-manager` (which handles Zest supply) — together they give agents full-coverage STX, stSTX, and sBTC yield execution across the three largest Stacks yield surfaces. + +## Safety notes + +- **Writes to chain.** `run deposit`, `run init-withdraw`, and `run withdraw` all broadcast real transactions on Stacks mainnet via the AIBTC MCP wallet. +- **Mainnet only.** StackingDAO core contracts referenced by default are mainnet deployments; the skill refuses to execute against testnet. +- **Irreversible.** `init-withdraw` burns stSTX and mints a withdrawal NFT. It cannot be reversed within the cycle. `withdraw` spends a matured ticket; once redeemed it is gone. +- **Ratio slippage enforced.** Every `deposit` and `init-withdraw` reads `get-stx-per-ststx` from the reserve before execution and refuses to broadcast if the current rate deviates from the caller-provided `--expected-rate-ustx-per-ststx` by more than `--max-slippage-bps`. +- **Amount caps enforced.** `--max-deposit-ustx`, `--max-withdraw-ststx`, and a hard-coded per-operation safety ceiling are applied in code. The wallet retains at least `--reserve-ustx` after the deposit path. +- **Gas reserve enforced.** The wallet must keep at least `--min-gas-reserve-ustx` for transaction fees post-broadcast. +- **Cooldown enforced.** A per-action cooldown (`--cooldown-seconds`) prevents accidental double-execution. +- **Confirmation token required.** Write paths refuse to broadcast without the matching `--confirm=STACK`, `--confirm=UNSTACK`, or `--confirm=CLAIM` token. +- **PostConditionMode.Deny.** All emitted MCP contract-call plans request `post_condition_mode: "deny"` — any unexpected token movement aborts the transaction. +- **Cycle awareness on withdraw.** `run withdraw --id ` reads the ticket's `cycle` field and refuses to broadcast until the StackingDAO current cycle has advanced past it. + +## Commands + +### doctor +Verifies wallet resolution, STX balance, stSTX balance, StackingDAO contract reachability, current stSTX/STX ratio, and cooldown state. + +```bash +bun run skills/ststx-liquid-stacker/ststx-liquid-stacker.ts doctor +``` + +### status +Read-only snapshot: live balances, current `stx-per-ststx` ratio, any outstanding withdrawal NFT tickets the wallet holds, and which cycle each ticket matures in. + +```bash +bun run skills/ststx-liquid-stacker/ststx-liquid-stacker.ts status +``` + +### run +Executes one of the three write flows. Action is explicit — the skill never infers intent. + +```bash +# Deposit STX → mint stSTX +bun run skills/ststx-liquid-stacker/ststx-liquid-stacker.ts run \ + --action deposit \ + --amount-ustx 1000000 \ + --expected-rate-ustx-per-ststx 1050000 \ + --max-slippage-bps 50 \ + --confirm=STACK + +# Burn stSTX → mint withdrawal NFT ticket +bun run skills/ststx-liquid-stacker/ststx-liquid-stacker.ts run \ + --action init-withdraw \ + --amount-ststx 1000000 \ + --expected-rate-ustx-per-ststx 1050000 \ + --max-slippage-bps 50 \ + --confirm=UNSTACK + +# Claim matured withdrawal ticket +bun run skills/ststx-liquid-stacker/ststx-liquid-stacker.ts run \ + --action withdraw \ + --id 1234 \ + --confirm=CLAIM +``` + +## Output contract + +All outputs are JSON to stdout. + +**Success (write plan emitted):** + +```json +{ + "status": "success", + "action": "Deposit STX → stSTX via StackingDAO core-v2", + "data": { + "operation": "deposit", + "amount_ustx": 1000000, + "expected_ststx_minted": 952380, + "current_rate_ustx_per_ststx": 1050000, + "slippage_bps_observed": 0, + "mcp_command": { + "tool": "call_contract", + "params": { + "contract_address": "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG", + "contract_name": "stacking-dao-core-v2", + "function_name": "deposit", + "post_condition_mode": "deny" + } + } + }, + "error": null +} +``` + +**Blocked:** + +```json +{ + "status": "blocked", + "action": "aborted", + "data": null, + "error": { + "code": "RATE_SLIPPAGE_EXCEEDED", + "message": "Current rate 1080000 deviates 285 bps from expected 1050000 (max 50 bps)", + "next": "Re-read rate with `status` and re-submit with an updated --expected-rate or a wider --max-slippage-bps (operator-approved)" + } +} +``` + +**Error:** + +```json +{ "error": "descriptive message" } +``` + +## Known constraints + +- StackingDAO contracts are trait-based; the skill passes the canonical `reserve-v1`, `commission-v2`, `staking-v0`, and `direct-helpers-v3` principals. These are overridable via flags so the skill survives protocol-version rolls. +- Withdrawal tickets are NFTs minted by `stacking-dao-core-v2`; maturity is measured in PoX cycles (~2 weeks each on mainnet). The skill reads the current cycle from the PoX contract to gate `withdraw` claims. +- Requires live STX for deposits and live stSTX for withdrawals; `doctor` blocks on insufficient balance. +- The skill emits an `mcp_command` block for the AIBTC MCP wallet to sign and broadcast. This matches the pattern used by the merged `sbtc-yield-maximizer` and the submitted `zest-liquidation-executor` skills. diff --git a/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts b/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts new file mode 100644 index 00000000..3d1d2057 --- /dev/null +++ b/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts @@ -0,0 +1,998 @@ +#!/usr/bin/env bun +/** + * ststx-liquid-stacker — StackingDAO Liquid Stacking Manager + * + * Covers the three write flows that unlock liquid stacking on Stacks: + * 1. deposit STX -> stSTX (core-v2 `deposit`) + * 2. init-withdraw stSTX -> withdrawal NFT ticket (core-v2 `init-withdraw`) + * 3. withdraw NFT ticket (matured) -> STX (core-v2 `withdraw`) + * + * Every write path enforces in code (not just docs): + * - mainnet-only principal inspection + * - live stx-per-ststx ratio vs caller-supplied expected rate (bps slippage) + * - amount caps (per-op soft cap via flag + hard ceiling constant) + * - reserve floor on STX (post-deposit liquidity) + * - gas reserve floor on STX (post-broadcast) + * - cooldown between same-action broadcasts + * - confirmation token specific to action + * - PoX cycle maturity for withdraw-claim + * - PostConditionMode.Deny on the emitted MCP contract-call plan + * + * This skill emits a broadcast-ready `mcp_command` block for the AIBTC + * MCP wallet to sign and broadcast, matching the pattern used by the + * merged sbtc-yield-maximizer and submitted zest-liquidation-executor. + * + * Author: IamHarrie-Labs + * Agent: Liquid Horizon — Autonomous Liquid-Stacking Router + */ + +import { Command } from "commander"; +import { readFileSync, writeFileSync, existsSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; + +// ═══════════════════════════════════════════════════════════════════════════ +// SAFETY CONSTANTS — hard-coded, cannot be overridden by flags. +// ═══════════════════════════════════════════════════════════════════════════ +const HARD_CAP_PER_DEPOSIT_USTX = 500_000_000_000; // 500,000 STX — absolute per-op deposit ceiling +const HARD_CAP_PER_WITHDRAW_STSTX = 500_000_000_000; // 500,000 stSTX — absolute per-op withdraw ceiling +const HARD_CAP_DAILY_USTX = 1_000_000_000_000; // 1,000,000 STX — per-agent daily cap (either direction) +const DEFAULT_MIN_GAS_USTX = 1_000_000; // 1 STX minimum for gas +const DEFAULT_RESERVE_USTX = 1_000_000; // 1 STX kept as spendable reserve post-deposit +const DEFAULT_COOLDOWN_SECONDS = 120; // 2 minutes between same-action broadcasts +const DEFAULT_MAX_SLIPPAGE_BPS = 50; // 0.50% max deviation from expected rate +const SLIPPAGE_BPS_FLOOR = 1; // cannot disable the check +const SLIPPAGE_BPS_CEILING = 500; // 5% maximum tolerance the skill will accept +const FETCH_TIMEOUT_MS = 15_000; +const HIRO_API = "https://api.hiro.so"; + +// ═══════════════════════════════════════════════════════════════════════════ +// STACKINGDAO MAINNET CONTRACTS (overridable via flags for protocol version rolls) +// ═══════════════════════════════════════════════════════════════════════════ +const DEFAULT_CORE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v2"; +const DEFAULT_STSTX_TOKEN = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token"; +const DEFAULT_RESERVE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.reserve-v1"; +const DEFAULT_COMMISSION = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.commission-v2"; +const DEFAULT_STAKING = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.staking-v0"; +const DEFAULT_DIRECT_HELPERS = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.direct-helpers-v3"; +const POX_INFO_ENDPOINT = "/v2/pox"; + +// ═══════════════════════════════════════════════════════════════════════════ +// PERSISTENT COOLDOWN + SPEND LEDGER +// ═══════════════════════════════════════════════════════════════════════════ +interface LedgerEntry { + ts: string; + action: "deposit" | "init-withdraw" | "withdraw"; + amount: number; + txPlanHash: string; +} +interface Ledger { + date: string; + totalUstxMoved: number; + lastEpoch: Record; // action -> epoch seconds + entries: LedgerEntry[]; +} + +const LEDGER_FILE = join(homedir(), ".ststx-liquid-stacker-ledger.json"); + +function loadLedger(): Ledger { + const today = new Date().toISOString().slice(0, 10); + try { + if (existsSync(LEDGER_FILE)) { + const raw = JSON.parse(readFileSync(LEDGER_FILE, "utf8")) as Ledger; + if (raw.date === today) return raw; + } + } catch { + /* corrupt file — fresh start */ + } + return { date: today, totalUstxMoved: 0, lastEpoch: {}, entries: [] }; +} + +function saveLedger(l: Ledger): void { + writeFileSync(LEDGER_FILE, JSON.stringify(l, null, 2), "utf8"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// JSON OUTPUT HELPERS — mirror the contract used by sbtc-yield-maximizer +// ═══════════════════════════════════════════════════════════════════════════ +function out(status: "success" | "error" | "blocked", action: string, data: unknown, error: unknown = null) { + console.log(JSON.stringify({ status, action, data, error })); +} +function fail(code: string, message: string, next: string) { + console.log(JSON.stringify({ status: "error", action: "aborted", data: null, error: { code, message, next } })); +} +function blocked(code: string, message: string, next: string) { + console.log(JSON.stringify({ status: "blocked", action: "aborted", data: null, error: { code, message, next } })); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HIRO API HELPERS +// ═══════════════════════════════════════════════════════════════════════════ +async function hiroFetch(path: string): Promise { + try { + const res = await fetch(`${HIRO_API}${path}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return null; + return res.json() as Promise; + } catch { + return null; + } +} + +async function callReadOnly( + contract: string, + fnName: string, + args: string[], + sender: string +): Promise { + const [addr, name] = contract.split("."); + try { + const res = await fetch( + `${HIRO_API}/v2/contracts/call-read/${addr}/${name}/${fnName}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sender, arguments: args }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + } + ); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +function encodeUintHex(value: number | bigint): string { + const buf = Buffer.alloc(17); + buf[0] = 0x01; // clarity uint tag + const big = BigInt(value); + for (let i = 16; i >= 1; i--) { + buf[i] = Number(big >> BigInt((16 - i) * 8)) & 0xff; + } + return "0x" + buf.toString("hex"); +} + +function parseOkUintHex(result: string | undefined): bigint { + if (!result) return 0n; + // (ok uint) serialised: 0x07 0x01 + 16 byte big-endian uint + const hex = result.startsWith("0x") ? result.slice(2) : result; + if (hex.startsWith("07") && hex.length >= 36) { + const inner = hex.slice(2); + if (inner.startsWith("01")) { + let v = 0n; + for (let i = 0; i < 16; i++) { + v = (v << 8n) + BigInt(parseInt(inner.slice(2 + i * 2, 4 + i * 2), 16)); + } + return v; + } + } + if (hex.startsWith("01") && hex.length >= 34) { + let v = 0n; + for (let i = 0; i < 16; i++) { + v = (v << 8n) + BigInt(parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16)); + } + return v; + } + return 0n; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// WALLET + BALANCE HELPERS +// ═══════════════════════════════════════════════════════════════════════════ +function getWallet(): string { + const addr = process.env.STACKS_ADDRESS || process.env.STX_ADDRESS; + if (!addr) throw new Error("STACKS_ADDRESS not set — run AIBTC wallet unlock first"); + return addr; +} + +function isMainnetPrincipal(addr: string): boolean { + // mainnet prefixes: SP / SM. testnet prefixes: ST / SN. + return /^S[PM][A-Z0-9]+$/.test(addr); +} + +async function getStxBalance(address: string): Promise { + const data = await hiroFetch(`/extended/v1/address/${address}/stx`); + if (!data) return 0; + return parseInt(data.balance || "0", 10) - parseInt(data.locked || "0", 10); +} + +async function getTokenBalance(address: string, tokenContract: string): Promise { + const data = await hiroFetch(`/extended/v1/address/${address}/balances`); + if (!data?.fungible_tokens) return 0; + const key = Object.keys(data.fungible_tokens).find((k) => k.startsWith(tokenContract)); + if (!key) return 0; + return parseInt(data.fungible_tokens[key].balance || "0", 10); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STACKINGDAO READS +// ═══════════════════════════════════════════════════════════════════════════ +/** + * Derive the live stx-per-ststx ratio from on-chain supply data. + * + * Formula: ratio = (reserve.get-total-stx * 1_000_000) / ststx-token.get-total-supply + * + * A ratio of 1_050_000 means 1 stSTX ≈ 1.05 STX (stSTX accrues yield over time). + * + * We also read `core-v2.current-pox-reward-cycle` here as a side-effect since + * both calls share the same Hiro API path style. + */ +async function getStxPerStstx( + reserveContract: string, + ststxToken: string, + sender: string +): Promise { + const [reserveAddr, reserveName] = reserveContract.split("."); + const [tokenAddr, tokenName] = ststxToken.split("."); + + // reserve-v1.get-total-stx — returns uint (no wrapper) + const totalStxRes = await callReadOnly(reserveContract, "get-total-stx", [], sender); + // ststx-token.get-total-supply — returns (ok uint) + const totalSupplyRes = await callReadOnly(ststxToken, "get-total-supply", [], sender); + + if (!totalStxRes?.result || !totalSupplyRes?.result) return null; + + // get-total-stx returns a plain uint (0x01 + 16 bytes) + const totalStx = parseOkUintHex(totalStxRes.result) || parseRawUintHex(totalStxRes.result); + // get-total-supply returns (ok uint) + const totalSupply = parseOkUintHex(totalSupplyRes.result); + + if (totalStx <= 0n || totalSupply <= 0n) return null; + return (totalStx * 1_000_000n) / totalSupply; +} + +/** Parse a raw Clarity uint (tag 0x01 + 16 bytes) with no ok-wrapper. */ +function parseRawUintHex(result: string | undefined): bigint { + if (!result) return 0n; + const hex = result.startsWith("0x") ? result.slice(2) : result; + if (hex.startsWith("01") && hex.length >= 34) { + let v = 0n; + for (let i = 0; i < 16; i++) { + v = (v << 8n) + BigInt(parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16)); + } + return v; + } + return 0n; +} + +/** Encode a principal as a Clarity argument for /call-read. */ +function clarityPrincipalArg(principal: string): string { + // For the Hiro call-read endpoint, principals are passed as hex-serialised + // Clarity values. We compose them as string-ascii wrappers since the API + // also accepts that form for flexibility. + const buf = Buffer.from(principal, "utf8"); + const header = Buffer.alloc(5); + header[0] = 0x0d; // string-ascii tag + header.writeUInt32BE(buf.length, 1); + return "0x" + Buffer.concat([header, buf]).toString("hex"); +} + +/** + * Read the current PoX reward cycle from the core contract directly + * (core-v2.current-pox-reward-cycle — no args, returns uint). + * Falls back to the Hiro /v2/pox endpoint. + */ +async function getCurrentPoxCycle(core: string, sender: string): Promise { + const res = await callReadOnly(core, "current-pox-reward-cycle", [], sender); + if (res?.result) { + const v = parseRawUintHex(res.result) || parseOkUintHex(res.result); + if (v > 0n) return Number(v); + } + // Fallback: Hiro PoX info endpoint + const data = await hiroFetch("/v2/pox"); + if (data) return typeof data.current_cycle?.id === "number" ? data.current_cycle.id : null; + return null; +} + +/** + * Find withdrawal-ticket NFTs held by the wallet. StackingDAO mints these + * from `stacking-dao-core-v2` on `init-withdraw`. The NFT asset identifier + * is `::ststx-withdraw-nft` in the canonical deploy. + */ +async function getWithdrawalTickets(address: string, core: string): Promise> { + const data = await hiroFetch( + `/extended/v1/tokens/nft/holdings?principal=${address}&asset_identifiers=${encodeURIComponent( + `${core}::ststx-withdraw-nft` + )}&limit=50` + ); + if (!data?.results) return []; + const out: Array<{ id: number; assetId: string }> = []; + for (const row of data.results) { + const repr: string = row.value?.repr || ""; + const m = repr.match(/u(\d+)/); + if (m) out.push({ id: parseInt(m[1], 10), assetId: row.asset_identifier }); + } + return out; +} + +/** Read a ticket's stored maturity cycle from the core contract. */ +async function getTicketCycle(core: string, nftId: number, sender: string): Promise { + // Clarity: (get-withdraw-request (id uint)) -> (optional { cycle-id: uint, ... }) + const res = await callReadOnly( + core, + "get-withdraw-request", + [encodeUintHex(nftId)], + sender + ); + if (!res?.result) return null; + // The serialised optional-some-tuple is complex; we conservatively pull the first uint we see. + const hex = typeof res.result === "string" ? res.result : ""; + const m = hex.match(/01([0-9a-f]{32})/); + if (!m) return null; + try { + return parseInt(m[1].slice(-8), 16); + } catch { + return null; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// COMMANDS +// ═══════════════════════════════════════════════════════════════════════════ +const program = new Command(); + +program + .name("ststx-liquid-stacker") + .description("StackingDAO liquid-stacking writer: deposit STX, init-withdraw stSTX, claim matured tickets") + .version("1.0.0"); + +// ── SHARED OPTIONS (contracts are configurable for version rolls) ───────── +function addContractFlags(cmd: Command): Command { + return cmd + .option("--core ", "StackingDAO core contract", DEFAULT_CORE) + .option("--ststx-token ", "stSTX fungible token contract", DEFAULT_STSTX_TOKEN) + .option("--reserve-contract ", "StackingDAO reserve contract", DEFAULT_RESERVE) + .option("--commission-contract ", "StackingDAO commission contract", DEFAULT_COMMISSION) + .option("--staking-contract ", "StackingDAO staking contract", DEFAULT_STAKING) + .option("--direct-helpers-contract ", "StackingDAO direct-helpers contract", DEFAULT_DIRECT_HELPERS); +} + +// ── DOCTOR ───────────────────────────────────────────────────────────────── +addContractFlags( + program + .command("doctor") + .description("Verify wallet, balances, contract reachability, and current ratio") +) + .action(async (opts) => { + const checks: Record = {}; + let wallet: string | null = null; + + try { + wallet = getWallet(); + checks.wallet = { ok: true, detail: wallet }; + } catch (e: any) { + checks.wallet = { ok: false, detail: e.message }; + } + + if (wallet) { + checks.mainnet = { + ok: isMainnetPrincipal(wallet), + detail: isMainnetPrincipal(wallet) ? "wallet is mainnet (SP/SM)" : "wallet is NOT mainnet — skill refuses to execute", + }; + + const stx = await getStxBalance(wallet); + checks.stx_balance = { + ok: stx >= DEFAULT_MIN_GAS_USTX, + detail: `${stx} uSTX (gas min ${DEFAULT_MIN_GAS_USTX})`, + }; + + const ststx = await getTokenBalance(wallet, opts.ststxToken); + checks.ststx_balance = { ok: true, detail: `${ststx} (stSTX micro-units)` }; + } + + // Contract reachability + for (const [label, principal] of [ + ["core", opts.core], + ["reserve", opts.reserveContract], + ["commission", opts.commissionContract], + ["staking", opts.stakingContract], + ["direct_helpers", opts.directHelpersContract], + ["ststx_token", opts.ststxToken], + ]) { + const [addr, name] = (principal as string).split("."); + const res = await hiroFetch(`/v2/contracts/interface/${addr}/${name}`); + checks[`contract_${label}`] = { + ok: !!res, + detail: res ? `${principal} reachable` : `${principal} unreachable`, + }; + } + + // Rate read + if (wallet && isMainnetPrincipal(wallet)) { + const rate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); + checks.ratio_read = { + ok: rate !== null, + detail: rate !== null ? `1 stSTX = ${rate} uSTX (total-stx / total-supply derived)` : "could not read ratio", + }; + } + + // Cooldown + const ledger = loadLedger(); + const now = Date.now() / 1000; + checks.cooldowns = { + ok: true, + detail: (["deposit", "init-withdraw", "withdraw"] as const) + .map((a) => { + const last = ledger.lastEpoch[a] || 0; + const rem = Math.max(0, DEFAULT_COOLDOWN_SECONDS - (now - last)); + return `${a}=${rem === 0 ? "ready" : `${Math.ceil(rem)}s`}`; + }) + .join(", "), + }; + checks.daily_cap_remaining = { + ok: ledger.totalUstxMoved < HARD_CAP_DAILY_USTX, + detail: `${HARD_CAP_DAILY_USTX - ledger.totalUstxMoved} uSTX of ${HARD_CAP_DAILY_USTX} remaining today`, + }; + + const allOk = Object.values(checks).every((c) => c.ok); + if (allOk) { + out("success", "Environment ready — all checks passed", { + wallet, + checks, + safety_limits: { + hard_cap_per_deposit_ustx: HARD_CAP_PER_DEPOSIT_USTX, + hard_cap_per_withdraw_ststx: HARD_CAP_PER_WITHDRAW_STSTX, + hard_cap_daily_ustx: HARD_CAP_DAILY_USTX, + cooldown_seconds: DEFAULT_COOLDOWN_SECONDS, + slippage_bps_ceiling: SLIPPAGE_BPS_CEILING, + }, + next: "Run `status` to read live rate, then `run --action `", + }); + } else { + const blockers = Object.entries(checks) + .filter(([, c]) => !c.ok) + .map(([k, c]) => `${k}: ${c.detail}`); + blocked("preflight_failed", blockers.join("; "), "Fix listed blockers and re-run doctor"); + } + }); + +// ── STATUS ───────────────────────────────────────────────────────────────── +addContractFlags( + program + .command("status") + .description("Read live balances, current rate, and outstanding withdrawal tickets") +) + .action(async (opts) => { + let wallet: string; + try { + wallet = getWallet(); + } catch (e: any) { + fail("no_wallet", e.message, "Run AIBTC wallet unlock or set STACKS_ADDRESS"); + return; + } + if (!isMainnetPrincipal(wallet)) { + blocked("not_mainnet", `Wallet ${wallet} is not a mainnet principal`, "Use a mainnet wallet (SP/SM prefix)"); + return; + } + + const [stx, ststx, rate, cycle, tickets] = await Promise.all([ + getStxBalance(wallet), + getTokenBalance(wallet, opts.ststxToken), + getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet), + getCurrentPoxCycle(opts.core, wallet), + getWithdrawalTickets(wallet, opts.core), + ]); + + const ticketDetails: Array<{ id: number; cycle_id: number | null; matured: boolean | null }> = []; + for (const t of tickets) { + const tCycle = await getTicketCycle(opts.core, t.id, wallet); + ticketDetails.push({ + id: t.id, + cycle_id: tCycle, + matured: tCycle === null || cycle === null ? null : cycle > tCycle, + }); + } + + out("success", "Live status snapshot", { + wallet, + balances: { + stx_ustx: stx, + ststx_microunits: ststx, + }, + rate: { + ustx_per_ststx: rate !== null ? rate.toString() : null, + note: "1 stSTX ≈ rate / 1_000_000 STX", + }, + pox_current_cycle: cycle, + withdrawal_tickets: ticketDetails, + ready_actions: { + deposit: stx > DEFAULT_RESERVE_USTX + DEFAULT_MIN_GAS_USTX, + init_withdraw: ststx > 0, + withdraw: ticketDetails.some((t) => t.matured === true), + }, + }); + }); + +// ── RUN ──────────────────────────────────────────────────────────────────── +addContractFlags( + program + .command("run") + .description("Execute a StackingDAO write action") + .requiredOption("--action ", "deposit | init-withdraw | withdraw") + .option("--amount-ustx ", "uSTX amount to deposit (for --action deposit)", "0") + .option("--amount-ststx ", "stSTX micro-unit amount to queue for withdraw (for --action init-withdraw)", "0") + .option("--id ", "Withdrawal ticket NFT id (for --action withdraw)", "0") + .option("--expected-rate-ustx-per-ststx ", "Caller's expected stx-per-ststx rate for slippage gate", "0") + .option("--max-slippage-bps ", `Max deviation vs expected rate (floor ${SLIPPAGE_BPS_FLOOR}, ceiling ${SLIPPAGE_BPS_CEILING})`, String(DEFAULT_MAX_SLIPPAGE_BPS)) + .option("--max-deposit-ustx ", "Per-op deposit cap", String(HARD_CAP_PER_DEPOSIT_USTX)) + .option("--max-withdraw-ststx ", "Per-op withdraw cap", String(HARD_CAP_PER_WITHDRAW_STSTX)) + .option("--reserve-ustx ", "Minimum STX balance preserved after deposit", String(DEFAULT_RESERVE_USTX)) + .option("--min-gas-reserve-ustx ", "Minimum STX retained for gas", String(DEFAULT_MIN_GAS_USTX)) + .option("--cooldown-seconds ", "Cooldown between same-action broadcasts", String(DEFAULT_COOLDOWN_SECONDS)) + .option("--referrer ", "Optional referrer principal (StackingDAO)", "") + .option("--pool ", "Optional stacking pool override", "") + .option("--confirm ", "Action-specific confirmation token (STACK|UNSTACK|CLAIM)", "") + .option("--dry-run", "Build and print the broadcast plan but do not update the ledger", false) +) + .action(async (opts) => { + const action = opts.action as "deposit" | "init-withdraw" | "withdraw"; + if (!["deposit", "init-withdraw", "withdraw"].includes(action)) { + fail("unknown_action", `Action '${action}' not recognised`, "Use deposit | init-withdraw | withdraw"); + return; + } + + // ── Wallet + mainnet check ────────────────────────────────────────── + let wallet: string; + try { + wallet = getWallet(); + } catch (e: any) { + fail("no_wallet", e.message, "Run AIBTC wallet unlock or set STACKS_ADDRESS"); + return; + } + if (!isMainnetPrincipal(wallet)) { + blocked("not_mainnet", `Wallet ${wallet} is not a mainnet principal`, "Use a mainnet (SP/SM) wallet"); + return; + } + + // ── Confirmation token ────────────────────────────────────────────── + const expectedConfirm = + action === "deposit" ? "STACK" : action === "init-withdraw" ? "UNSTACK" : "CLAIM"; + if (opts.confirm !== expectedConfirm) { + blocked( + "confirm_missing", + `--confirm=${expectedConfirm} required for --action ${action}`, + `Re-run with --confirm=${expectedConfirm} once the plan has been reviewed` + ); + return; + } + + // ── Slippage bounds normalisation ─────────────────────────────────── + const slippageBps = Math.max( + SLIPPAGE_BPS_FLOOR, + Math.min(SLIPPAGE_BPS_CEILING, parseInt(opts.maxSlippageBps, 10) || DEFAULT_MAX_SLIPPAGE_BPS) + ); + + // ── Cooldown ──────────────────────────────────────────────────────── + const ledger = loadLedger(); + const now = Date.now() / 1000; + const cooldown = parseInt(opts.cooldownSeconds, 10) || DEFAULT_COOLDOWN_SECONDS; + const lastEpoch = ledger.lastEpoch[action] || 0; + if (lastEpoch && now - lastEpoch < cooldown) { + blocked( + "cooldown_active", + `${Math.ceil(cooldown - (now - lastEpoch))}s cooldown remaining on action ${action}`, + "Wait for cooldown to clear or wait for previous broadcast to confirm" + ); + return; + } + + // ── Daily cap ─────────────────────────────────────────────────────── + if (ledger.totalUstxMoved >= HARD_CAP_DAILY_USTX) { + blocked( + "daily_cap_reached", + `Daily cap ${HARD_CAP_DAILY_USTX} uSTX reached`, + "Cap resets at 00:00 UTC" + ); + return; + } + + // ── Gas pre-check (all actions) ───────────────────────────────────── + const minGas = parseInt(opts.minGasReserveUstx, 10) || DEFAULT_MIN_GAS_USTX; + const stxBal = await getStxBalance(wallet); + if (stxBal < minGas) { + blocked( + "insufficient_gas", + `STX balance ${stxBal} uSTX < required ${minGas} uSTX`, + "Top up STX for gas" + ); + return; + } + + // ═══════════════════════════════════════════════════════════════════ + // ── DEPOSIT ──────────────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════ + if (action === "deposit") { + const amountUstx = parseInt(opts.amountUstx, 10); + if (!Number.isFinite(amountUstx) || amountUstx <= 0) { + fail("bad_amount", "--amount-ustx must be a positive integer", "Supply the deposit amount in uSTX (1 STX = 1_000_000 uSTX)"); + return; + } + const capPerOp = Math.min( + HARD_CAP_PER_DEPOSIT_USTX, + parseInt(opts.maxDepositUstx, 10) || HARD_CAP_PER_DEPOSIT_USTX + ); + if (amountUstx > capPerOp) { + blocked( + "exceeds_per_op_cap", + `amount ${amountUstx} uSTX > per-op cap ${capPerOp} uSTX`, + "Reduce --amount-ustx or raise --max-deposit-ustx (cannot exceed hard cap)" + ); + return; + } + if (ledger.totalUstxMoved + amountUstx > HARD_CAP_DAILY_USTX) { + blocked( + "exceeds_daily_cap", + `deposit would push daily volume over ${HARD_CAP_DAILY_USTX} uSTX`, + "Wait until daily cap resets or reduce --amount-ustx" + ); + return; + } + + const reserve = parseInt(opts.reserveUstx, 10) || DEFAULT_RESERVE_USTX; + if (stxBal - amountUstx < reserve + minGas) { + blocked( + "reserve_violation", + `post-deposit STX ${stxBal - amountUstx} uSTX < reserve ${reserve} + gas ${minGas}`, + "Lower --amount-ustx or lower --reserve-ustx (explicit operator approval)" + ); + return; + } + + // ── Slippage gate ───────────────────────────────────────────────── + const expectedRate = BigInt(opts.expectedRateUstxPerStstx || "0"); + if (expectedRate <= 0n) { + fail( + "expected_rate_missing", + "--expected-rate-ustx-per-ststx required — read current rate from `status` first", + "Run `status` to get the live stx-per-ststx, then pass it here so slippage can be enforced" + ); + return; + } + const liveRate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); + if (liveRate === null || liveRate <= 0n) { + fail( + "rate_read_failed", + "Could not read live stx-per-ststx from core contract", + "Confirm --core and --reserve-contract principals, and that Hiro API is reachable" + ); + return; + } + const deviationBps = + Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); + const absDev = Math.abs(deviationBps); + if (absDev > slippageBps) { + blocked( + "rate_slippage_exceeded", + `Current rate ${liveRate} deviates ${absDev} bps from expected ${expectedRate} (max ${slippageBps} bps)`, + "Run `status` to read the fresh rate, then resubmit with updated --expected-rate or widen --max-slippage-bps (operator-approved, cannot exceed ceiling)" + ); + return; + } + + // Plan + const expectedStstx = (BigInt(amountUstx) * 1_000_000n) / liveRate; + const [coreAddr, coreName] = (opts.core as string).split("."); + const planHash = `deposit:${wallet}:${amountUstx}:${now.toFixed(0)}`; + + out("success", "Deposit STX → stSTX via StackingDAO core", { + operation: "deposit", + wallet, + amount_ustx: amountUstx, + expected_stx_per_ststx: expectedRate.toString(), + live_stx_per_ststx: liveRate.toString(), + slippage_bps_observed: absDev, + slippage_bps_allowed: slippageBps, + estimated_ststx_minted: expectedStstx.toString(), + mcp_command: { + tool: "call_contract", + params: { + contract_address: coreAddr, + contract_name: coreName, + function_name: "deposit", + function_args: [ + `{ type: "trait_reference", value: "${opts.reserveContract}" }`, + `{ type: "trait_reference", value: "${opts.commissionContract}" }`, + `{ type: "trait_reference", value: "${opts.stakingContract}" }`, + `{ type: "trait_reference", value: "${opts.directHelpersContract}" }`, + `{ type: "uint", value: "${amountUstx}" }`, + opts.referrer + ? `{ type: "optional", value: { type: "principal", value: "${opts.referrer}" } }` + : `{ type: "optional", value: null }`, + opts.pool + ? `{ type: "optional", value: { type: "principal", value: "${opts.pool}" } }` + : `{ type: "optional", value: null }`, + ], + post_condition_mode: "deny", + post_conditions: [ + { + type: "stx", + principal: wallet, + condition: "sent_eq", + amount: amountUstx, + }, + { + type: "ft", + principal: wallet, + condition: "received_gte", + asset: opts.ststxToken, + amount: Number((expectedStstx * BigInt(10_000 - slippageBps)) / 10_000n), + }, + ], + }, + description: `Deposit ${amountUstx} uSTX → stSTX (expect ≥ ${expectedStstx} stSTX micro-units)`, + }, + safety_checks: { + mainnet_wallet: true, + within_per_op_cap: true, + within_daily_cap: true, + reserve_preserved: true, + gas_preserved: true, + cooldown_clear: true, + slippage_within_tolerance: true, + confirm_token_matched: true, + post_condition_mode: "deny", + }, + }); + + if (!opts.dryRun) { + ledger.lastEpoch[action] = now; + ledger.totalUstxMoved += amountUstx; + ledger.entries.push({ + ts: new Date().toISOString(), + action, + amount: amountUstx, + txPlanHash: planHash, + }); + saveLedger(ledger); + } + return; + } + + // ═══════════════════════════════════════════════════════════════════ + // ── INIT-WITHDRAW ───────────────────────────────────────────────── + // ═══════════════════════════════════════════════════════════════════ + if (action === "init-withdraw") { + const amountStstx = parseInt(opts.amountStstx, 10); + if (!Number.isFinite(amountStstx) || amountStstx <= 0) { + fail("bad_amount", "--amount-ststx must be a positive integer", "Supply the withdraw amount in stSTX micro-units"); + return; + } + const capPerOp = Math.min( + HARD_CAP_PER_WITHDRAW_STSTX, + parseInt(opts.maxWithdrawStstx, 10) || HARD_CAP_PER_WITHDRAW_STSTX + ); + if (amountStstx > capPerOp) { + blocked( + "exceeds_per_op_cap", + `amount ${amountStstx} stSTX > per-op cap ${capPerOp}`, + "Reduce --amount-ststx or raise --max-withdraw-ststx (cannot exceed hard cap)" + ); + return; + } + + const ststxBal = await getTokenBalance(wallet, opts.ststxToken); + if (ststxBal < amountStstx) { + blocked( + "insufficient_ststx", + `stSTX balance ${ststxBal} < requested ${amountStstx}`, + "Reduce --amount-ststx to at most the wallet balance" + ); + return; + } + + // Slippage gate uses the same expected-rate input so the caller + // knows the STX redemption value queued behind the NFT ticket. + const expectedRate = BigInt(opts.expectedRateUstxPerStstx || "0"); + if (expectedRate <= 0n) { + fail( + "expected_rate_missing", + "--expected-rate-ustx-per-ststx required — read current rate from `status` first", + "Pass the live rate so the queued redemption value is pinned within slippage" + ); + return; + } + const liveRate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); + if (liveRate === null || liveRate <= 0n) { + fail("rate_read_failed", "Could not read live stx-per-ststx", "Check --reserve-contract / --ststx-token principals"); + return; + } + const deviationBps = Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); + const absDev = Math.abs(deviationBps); + if (absDev > slippageBps) { + blocked( + "rate_slippage_exceeded", + `Current rate ${liveRate} deviates ${absDev} bps from expected ${expectedRate} (max ${slippageBps} bps)`, + "Re-read rate with `status` and resubmit" + ); + return; + } + + if (ledger.totalUstxMoved + amountStstx > HARD_CAP_DAILY_USTX) { + blocked( + "exceeds_daily_cap", + "init-withdraw would push daily volume over cap", + "Wait for cap reset or reduce --amount-ststx" + ); + return; + } + + const queuedStxValue = (BigInt(amountStstx) * liveRate) / 1_000_000n; + const [coreAddr, coreName] = (opts.core as string).split("."); + const planHash = `init-withdraw:${wallet}:${amountStstx}:${now.toFixed(0)}`; + + out("success", "Init-withdraw stSTX → withdrawal NFT ticket", { + operation: "init-withdraw", + wallet, + amount_ststx: amountStstx, + expected_stx_queued: queuedStxValue.toString(), + live_stx_per_ststx: liveRate.toString(), + slippage_bps_observed: absDev, + slippage_bps_allowed: slippageBps, + mcp_command: { + tool: "call_contract", + params: { + contract_address: coreAddr, + contract_name: coreName, + function_name: "init-withdraw", + function_args: [ + // Matches core-v2 signature: (reserve ) (direct-helpers ) (ststx-amount uint) + `{ type: "trait_reference", value: "${opts.reserveContract}" }`, + `{ type: "trait_reference", value: "${opts.directHelpersContract}" }`, + `{ type: "uint", value: "${amountStstx}" }`, + ], + post_condition_mode: "deny", + post_conditions: [ + { + type: "ft", + principal: wallet, + condition: "sent_eq", + asset: opts.ststxToken, + amount: amountStstx, + }, + ], + }, + description: `Burn ${amountStstx} stSTX → mint withdrawal NFT ticket (claims ~${queuedStxValue} uSTX after cycle matures)`, + }, + safety_checks: { + mainnet_wallet: true, + sufficient_ststx: true, + within_per_op_cap: true, + within_daily_cap: true, + cooldown_clear: true, + slippage_within_tolerance: true, + confirm_token_matched: true, + post_condition_mode: "deny", + }, + notes: [ + "Ticket maturity is 1 PoX cycle (~2 weeks on mainnet).", + "After broadcast, run `status` to find the new NFT id and record it for later `run --action withdraw --id `.", + ], + }); + + if (!opts.dryRun) { + ledger.lastEpoch[action] = now; + ledger.totalUstxMoved += amountStstx; + ledger.entries.push({ + ts: new Date().toISOString(), + action, + amount: amountStstx, + txPlanHash: planHash, + }); + saveLedger(ledger); + } + return; + } + + // ═══════════════════════════════════════════════════════════════════ + // ── WITHDRAW (CLAIM MATURED TICKET) ─────────────────────────────── + // ═══════════════════════════════════════════════════════════════════ + if (action === "withdraw") { + const nftId = parseInt(opts.id, 10); + if (!Number.isFinite(nftId) || nftId <= 0) { + fail("bad_id", "--id must be a positive integer NFT id", "Get ids from `run status` > withdrawal_tickets"); + return; + } + + // Ownership check + const tickets = await getWithdrawalTickets(wallet, opts.core); + const owned = tickets.find((t) => t.id === nftId); + if (!owned) { + blocked( + "ticket_not_owned", + `Wallet ${wallet} does not hold withdrawal ticket #${nftId}`, + "Confirm --id matches an NFT held by the active wallet" + ); + return; + } + + // Maturity check + const [ticketCycle, currentCycle] = await Promise.all([ + getTicketCycle(opts.core, nftId, wallet), + getCurrentPoxCycle(opts.core, wallet), + ]); + if (ticketCycle === null || currentCycle === null) { + fail( + "cycle_read_failed", + "Could not read ticket maturity cycle or current PoX cycle", + "Check Hiro API reachability and core-contract principal" + ); + return; + } + if (currentCycle <= ticketCycle) { + blocked( + "ticket_not_matured", + `Ticket #${nftId} matures in cycle ${ticketCycle}; current PoX cycle is ${currentCycle}`, + `Wait until cycle > ${ticketCycle} before broadcasting` + ); + return; + } + + const [coreAddr, coreName] = (opts.core as string).split("."); + const planHash = `withdraw:${wallet}:${nftId}:${now.toFixed(0)}`; + + out("success", "Claim matured withdrawal ticket → STX", { + operation: "withdraw", + wallet, + nft_id: nftId, + ticket_cycle: ticketCycle, + current_cycle: currentCycle, + mcp_command: { + tool: "call_contract", + params: { + contract_address: coreAddr, + contract_name: coreName, + function_name: "withdraw", + function_args: [ + // Matches core-v2 signature: (reserve ) (commission-contract ) (staking-contract ) (nft-id uint) + `{ type: "trait_reference", value: "${opts.reserveContract}" }`, + `{ type: "trait_reference", value: "${opts.commissionContract}" }`, + `{ type: "trait_reference", value: "${opts.stakingContract}" }`, + `{ type: "uint", value: "${nftId}" }`, + ], + post_condition_mode: "deny", + post_conditions: [ + { + type: "nft", + principal: wallet, + condition: "sent", + asset: `${opts.core}::ststx-withdraw-nft`, + id: nftId, + }, + { + type: "stx", + principal: wallet, + condition: "received_gt", + amount: 0, + }, + ], + }, + description: `Claim STX from matured withdrawal ticket #${nftId}`, + }, + safety_checks: { + mainnet_wallet: true, + ticket_ownership_verified: true, + ticket_matured: true, + cooldown_clear: true, + confirm_token_matched: true, + post_condition_mode: "deny", + }, + }); + + if (!opts.dryRun) { + ledger.lastEpoch[action] = now; + ledger.entries.push({ + ts: new Date().toISOString(), + action, + amount: nftId, + txPlanHash: planHash, + }); + saveLedger(ledger); + } + return; + } + }); + +program.parse(); From dc669d213df35d61b3d853778f70bbf021fa435a Mon Sep 17 00:00:00 2001 From: Serene Spring Date: Mon, 27 Apr 2026 13:01:06 +0100 Subject: [PATCH 2/3] fix(ststx-liquid-stacker): rebuild as in-skill executor with broadcastTransaction Replace mcp_command plan emission with direct callContract + awaitConfirmation via the AIBTC wallet manager and @stacks/transactions contractPrincipalCV args. Every write action (deposit, init-withdraw, withdraw) now broadcasts on-chain and polls for tx_status: success before returning. PostConditionMode.Deny is enforced at the actual broadcast layer, not on an emitted plan. Co-Authored-By: Claude Sonnet 4.6 --- skills/ststx-liquid-stacker/SKILL.md | 42 +- .../ststx-liquid-stacker.ts | 743 +++++++++--------- 2 files changed, 409 insertions(+), 376 deletions(-) diff --git a/skills/ststx-liquid-stacker/SKILL.md b/skills/ststx-liquid-stacker/SKILL.md index b1e2b137..0282f545 100644 --- a/skills/ststx-liquid-stacker/SKILL.md +++ b/skills/ststx-liquid-stacker/SKILL.md @@ -33,7 +33,7 @@ This is complementary to the existing `sbtc-yield-maximizer` (which routes idle - **Gas reserve enforced.** The wallet must keep at least `--min-gas-reserve-ustx` for transaction fees post-broadcast. - **Cooldown enforced.** A per-action cooldown (`--cooldown-seconds`) prevents accidental double-execution. - **Confirmation token required.** Write paths refuse to broadcast without the matching `--confirm=STACK`, `--confirm=UNSTACK`, or `--confirm=CLAIM` token. -- **PostConditionMode.Deny.** All emitted MCP contract-call plans request `post_condition_mode: "deny"` — any unexpected token movement aborts the transaction. +- **PostConditionMode.Deny.** Every broadcast transaction is built with `PostConditionMode.Deny` — any unexpected token movement aborts the transaction on-chain. - **Cycle awareness on withdraw.** `run withdraw --id ` reads the ticket's `cycle` field and refuses to broadcast until the StackingDAO current cycle has advanced past it. ## Commands @@ -83,27 +83,22 @@ bun run skills/ststx-liquid-stacker/ststx-liquid-stacker.ts run \ All outputs are JSON to stdout. -**Success (write plan emitted):** +**Success (broadcast confirmed):** ```json { "status": "success", - "action": "Deposit STX → stSTX via StackingDAO core-v2", + "action": "Deposit broadcast and confirmed on Stacks mainnet", "data": { "operation": "deposit", + "wallet": "SP...", + "txid": "abc123...", + "explorer_url": "https://explorer.hiro.so/txid/0xabc123...?chain=mainnet", + "tx_status": "success", "amount_ustx": 1000000, - "expected_ststx_minted": 952380, - "current_rate_ustx_per_ststx": 1050000, - "slippage_bps_observed": 0, - "mcp_command": { - "tool": "call_contract", - "params": { - "contract_address": "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG", - "contract_name": "stacking-dao-core-v2", - "function_name": "deposit", - "post_condition_mode": "deny" - } - } + "live_stx_per_ststx": "1865545", + "estimated_ststx_minted": "536036", + "slippage_bps_observed": 0 }, "error": null } @@ -117,9 +112,9 @@ All outputs are JSON to stdout. "action": "aborted", "data": null, "error": { - "code": "RATE_SLIPPAGE_EXCEEDED", - "message": "Current rate 1080000 deviates 285 bps from expected 1050000 (max 50 bps)", - "next": "Re-read rate with `status` and re-submit with an updated --expected-rate or a wider --max-slippage-bps (operator-approved)" + "code": "rate_slippage_exceeded", + "message": "Current rate 1880000 deviates 78 bps from expected 1865545 (max 50 bps)", + "next": "Re-read rate with `status` and re-submit with an updated --expected-rate or a wider --max-slippage-bps" } } ``` @@ -127,12 +122,17 @@ All outputs are JSON to stdout. **Error:** ```json -{ "error": "descriptive message" } +{ + "status": "error", + "action": "aborted", + "data": null, + "error": { "code": "broadcast_failed", "message": "...", "next": "..." } +} ``` ## Known constraints -- StackingDAO contracts are trait-based; the skill passes the canonical `reserve-v1`, `commission-v2`, `staking-v0`, and `direct-helpers-v3` principals. These are overridable via flags so the skill survives protocol-version rolls. +- StackingDAO contracts are trait-based; the skill passes the canonical `reserve-v1`, `commission-v2`, `staking-v0`, and `direct-helpers-v3` principals as `contractPrincipalCV` arguments. These are overridable via flags so the skill survives protocol-version rolls. - Withdrawal tickets are NFTs minted by `stacking-dao-core-v2`; maturity is measured in PoX cycles (~2 weeks each on mainnet). The skill reads the current cycle from the PoX contract to gate `withdraw` claims. - Requires live STX for deposits and live stSTX for withdrawals; `doctor` blocks on insufficient balance. -- The skill emits an `mcp_command` block for the AIBTC MCP wallet to sign and broadcast. This matches the pattern used by the merged `sbtc-yield-maximizer` and the submitted `zest-liquidation-executor` skills. +- `AIBTC_WALLET_PASSWORD` must be set for `run` — the skill unlocks the AIBTC wallet manager to sign and broadcast transactions directly. diff --git a/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts b/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts index 3d1d2057..d353955e 100644 --- a/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts +++ b/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts @@ -16,11 +16,10 @@ * - cooldown between same-action broadcasts * - confirmation token specific to action * - PoX cycle maturity for withdraw-claim - * - PostConditionMode.Deny on the emitted MCP contract-call plan + * - PostConditionMode.Deny on every broadcast transaction * - * This skill emits a broadcast-ready `mcp_command` block for the AIBTC - * MCP wallet to sign and broadcast, matching the pattern used by the - * merged sbtc-yield-maximizer and submitted zest-liquidation-executor. + * Transactions are broadcast directly via @stacks/transactions + the AIBTC + * wallet manager. The skill awaits on-chain confirmation before returning. * * Author: IamHarrie-Labs * Agent: Liquid Horizon — Autonomous Liquid-Stacking Router @@ -30,32 +29,43 @@ import { Command } from "commander"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { join } from "path"; import { homedir } from "os"; +import { + PostConditionMode, + contractPrincipalCV, + uintCV, + noneCV, + Pc, +} from "@stacks/transactions"; +import type { ContractCallOptions } from "@aibtc/mcp-server/dist/transactions/builder.js"; +import { callContract, signContractCall } from "@aibtc/mcp-server/dist/transactions/builder.js"; +import { getWalletManager } from "@aibtc/mcp-server/dist/services/wallet-manager.js"; // ═══════════════════════════════════════════════════════════════════════════ // SAFETY CONSTANTS — hard-coded, cannot be overridden by flags. // ═══════════════════════════════════════════════════════════════════════════ -const HARD_CAP_PER_DEPOSIT_USTX = 500_000_000_000; // 500,000 STX — absolute per-op deposit ceiling +const HARD_CAP_PER_DEPOSIT_USTX = 500_000_000_000; // 500,000 STX — absolute per-op deposit ceiling const HARD_CAP_PER_WITHDRAW_STSTX = 500_000_000_000; // 500,000 stSTX — absolute per-op withdraw ceiling -const HARD_CAP_DAILY_USTX = 1_000_000_000_000; // 1,000,000 STX — per-agent daily cap (either direction) -const DEFAULT_MIN_GAS_USTX = 1_000_000; // 1 STX minimum for gas -const DEFAULT_RESERVE_USTX = 1_000_000; // 1 STX kept as spendable reserve post-deposit -const DEFAULT_COOLDOWN_SECONDS = 120; // 2 minutes between same-action broadcasts -const DEFAULT_MAX_SLIPPAGE_BPS = 50; // 0.50% max deviation from expected rate -const SLIPPAGE_BPS_FLOOR = 1; // cannot disable the check -const SLIPPAGE_BPS_CEILING = 500; // 5% maximum tolerance the skill will accept -const FETCH_TIMEOUT_MS = 15_000; -const HIRO_API = "https://api.hiro.so"; +const HARD_CAP_DAILY_USTX = 1_000_000_000_000; // 1,000,000 STX — per-agent daily cap +const DEFAULT_MIN_GAS_USTX = 1_000_000; // 1 STX minimum for gas +const DEFAULT_RESERVE_USTX = 1_000_000; // 1 STX kept as spendable reserve post-deposit +const DEFAULT_COOLDOWN_SECONDS = 120; // 2 minutes between same-action broadcasts +const DEFAULT_MAX_SLIPPAGE_BPS = 50; // 0.50% max deviation from expected rate +const SLIPPAGE_BPS_FLOOR = 1; // cannot disable the check +const SLIPPAGE_BPS_CEILING = 500; // 5% maximum tolerance the skill will accept +const FETCH_TIMEOUT_MS = 15_000; +const HIRO_API = "https://api.hiro.so"; +const TX_POLL_INTERVAL_MS = 10_000; // 10s between status checks +const TX_POLL_MAX_ATTEMPTS = 30; // 5 minutes total wait time // ═══════════════════════════════════════════════════════════════════════════ // STACKINGDAO MAINNET CONTRACTS (overridable via flags for protocol version rolls) // ═══════════════════════════════════════════════════════════════════════════ -const DEFAULT_CORE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v2"; -const DEFAULT_STSTX_TOKEN = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token"; -const DEFAULT_RESERVE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.reserve-v1"; -const DEFAULT_COMMISSION = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.commission-v2"; -const DEFAULT_STAKING = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.staking-v0"; -const DEFAULT_DIRECT_HELPERS = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.direct-helpers-v3"; -const POX_INFO_ENDPOINT = "/v2/pox"; +const DEFAULT_CORE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v2"; +const DEFAULT_STSTX_TOKEN = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token"; +const DEFAULT_RESERVE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.reserve-v1"; +const DEFAULT_COMMISSION = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.commission-v2"; +const DEFAULT_STAKING = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.staking-v0"; +const DEFAULT_DIRECT_HELPERS = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.direct-helpers-v3"; // ═══════════════════════════════════════════════════════════════════════════ // PERSISTENT COOLDOWN + SPEND LEDGER @@ -64,7 +74,7 @@ interface LedgerEntry { ts: string; action: "deposit" | "init-withdraw" | "withdraw"; amount: number; - txPlanHash: string; + txid: string; } interface Ledger { date: string; @@ -93,7 +103,7 @@ function saveLedger(l: Ledger): void { } // ═══════════════════════════════════════════════════════════════════════════ -// JSON OUTPUT HELPERS — mirror the contract used by sbtc-yield-maximizer +// JSON OUTPUT HELPERS // ═══════════════════════════════════════════════════════════════════════════ function out(status: "success" | "error" | "blocked", action: string, data: unknown, error: unknown = null) { console.log(JSON.stringify({ status, action, data, error })); @@ -157,7 +167,6 @@ function encodeUintHex(value: number | bigint): string { function parseOkUintHex(result: string | undefined): bigint { if (!result) return 0n; - // (ok uint) serialised: 0x07 0x01 + 16 byte big-endian uint const hex = result.startsWith("0x") ? result.slice(2) : result; if (hex.startsWith("07") && hex.length >= 36) { const inner = hex.slice(2); @@ -179,6 +188,19 @@ function parseOkUintHex(result: string | undefined): bigint { return 0n; } +function parseRawUintHex(result: string | undefined): bigint { + if (!result) return 0n; + const hex = result.startsWith("0x") ? result.slice(2) : result; + if (hex.startsWith("01") && hex.length >= 34) { + let v = 0n; + for (let i = 0; i < 16; i++) { + v = (v << 8n) + BigInt(parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16)); + } + return v; + } + return 0n; +} + // ═══════════════════════════════════════════════════════════════════════════ // WALLET + BALANCE HELPERS // ═══════════════════════════════════════════════════════════════════════════ @@ -189,7 +211,6 @@ function getWallet(): string { } function isMainnetPrincipal(addr: string): boolean { - // mainnet prefixes: SP / SM. testnet prefixes: ST / SN. return /^S[PM][A-Z0-9]+$/.test(addr); } @@ -210,88 +231,31 @@ async function getTokenBalance(address: string, tokenContract: string): Promise< // ═══════════════════════════════════════════════════════════════════════════ // STACKINGDAO READS // ═══════════════════════════════════════════════════════════════════════════ -/** - * Derive the live stx-per-ststx ratio from on-chain supply data. - * - * Formula: ratio = (reserve.get-total-stx * 1_000_000) / ststx-token.get-total-supply - * - * A ratio of 1_050_000 means 1 stSTX ≈ 1.05 STX (stSTX accrues yield over time). - * - * We also read `core-v2.current-pox-reward-cycle` here as a side-effect since - * both calls share the same Hiro API path style. - */ async function getStxPerStstx( reserveContract: string, ststxToken: string, sender: string ): Promise { - const [reserveAddr, reserveName] = reserveContract.split("."); - const [tokenAddr, tokenName] = ststxToken.split("."); - - // reserve-v1.get-total-stx — returns uint (no wrapper) const totalStxRes = await callReadOnly(reserveContract, "get-total-stx", [], sender); - // ststx-token.get-total-supply — returns (ok uint) const totalSupplyRes = await callReadOnly(ststxToken, "get-total-supply", [], sender); - if (!totalStxRes?.result || !totalSupplyRes?.result) return null; - - // get-total-stx returns a plain uint (0x01 + 16 bytes) const totalStx = parseOkUintHex(totalStxRes.result) || parseRawUintHex(totalStxRes.result); - // get-total-supply returns (ok uint) const totalSupply = parseOkUintHex(totalSupplyRes.result); - if (totalStx <= 0n || totalSupply <= 0n) return null; return (totalStx * 1_000_000n) / totalSupply; } -/** Parse a raw Clarity uint (tag 0x01 + 16 bytes) with no ok-wrapper. */ -function parseRawUintHex(result: string | undefined): bigint { - if (!result) return 0n; - const hex = result.startsWith("0x") ? result.slice(2) : result; - if (hex.startsWith("01") && hex.length >= 34) { - let v = 0n; - for (let i = 0; i < 16; i++) { - v = (v << 8n) + BigInt(parseInt(hex.slice(2 + i * 2, 4 + i * 2), 16)); - } - return v; - } - return 0n; -} - -/** Encode a principal as a Clarity argument for /call-read. */ -function clarityPrincipalArg(principal: string): string { - // For the Hiro call-read endpoint, principals are passed as hex-serialised - // Clarity values. We compose them as string-ascii wrappers since the API - // also accepts that form for flexibility. - const buf = Buffer.from(principal, "utf8"); - const header = Buffer.alloc(5); - header[0] = 0x0d; // string-ascii tag - header.writeUInt32BE(buf.length, 1); - return "0x" + Buffer.concat([header, buf]).toString("hex"); -} - -/** - * Read the current PoX reward cycle from the core contract directly - * (core-v2.current-pox-reward-cycle — no args, returns uint). - * Falls back to the Hiro /v2/pox endpoint. - */ async function getCurrentPoxCycle(core: string, sender: string): Promise { const res = await callReadOnly(core, "current-pox-reward-cycle", [], sender); if (res?.result) { const v = parseRawUintHex(res.result) || parseOkUintHex(res.result); if (v > 0n) return Number(v); } - // Fallback: Hiro PoX info endpoint const data = await hiroFetch("/v2/pox"); if (data) return typeof data.current_cycle?.id === "number" ? data.current_cycle.id : null; return null; } -/** - * Find withdrawal-ticket NFTs held by the wallet. StackingDAO mints these - * from `stacking-dao-core-v2` on `init-withdraw`. The NFT asset identifier - * is `::ststx-withdraw-nft` in the canonical deploy. - */ async function getWithdrawalTickets(address: string, core: string): Promise> { const data = await hiroFetch( `/extended/v1/tokens/nft/holdings?principal=${address}&asset_identifiers=${encodeURIComponent( @@ -299,26 +263,18 @@ async function getWithdrawalTickets(address: string, core: string): Promise = []; + const result: Array<{ id: number; assetId: string }> = []; for (const row of data.results) { const repr: string = row.value?.repr || ""; const m = repr.match(/u(\d+)/); - if (m) out.push({ id: parseInt(m[1], 10), assetId: row.asset_identifier }); + if (m) result.push({ id: parseInt(m[1], 10), assetId: row.asset_identifier }); } - return out; + return result; } -/** Read a ticket's stored maturity cycle from the core contract. */ async function getTicketCycle(core: string, nftId: number, sender: string): Promise { - // Clarity: (get-withdraw-request (id uint)) -> (optional { cycle-id: uint, ... }) - const res = await callReadOnly( - core, - "get-withdraw-request", - [encodeUintHex(nftId)], - sender - ); + const res = await callReadOnly(core, "get-withdraw-request", [encodeUintHex(nftId)], sender); if (!res?.result) return null; - // The serialised optional-some-tuple is complex; we conservatively pull the first uint we see. const hex = typeof res.result === "string" ? res.result : ""; const m = hex.match(/01([0-9a-f]{32})/); if (!m) return null; @@ -329,6 +285,21 @@ async function getTicketCycle(core: string, nftId: number, sender: string): Prom } } +// ═══════════════════════════════════════════════════════════════════════════ +// TX CONFIRMATION POLLER +// ═══════════════════════════════════════════════════════════════════════════ +async function awaitConfirmation(txid: string): Promise<"success" | "failed" | "pending"> { + for (let i = 0; i < TX_POLL_MAX_ATTEMPTS; i++) { + await new Promise((r) => setTimeout(r, TX_POLL_INTERVAL_MS)); + const data = await hiroFetch(`/extended/v1/tx/0x${txid}`); + if (!data) continue; + const status: string = data.tx_status ?? ""; + if (status === "success") return "success"; + if (status.startsWith("abort") || status === "failed" || status === "rejected") return "failed"; + } + return "pending"; +} + // ═══════════════════════════════════════════════════════════════════════════ // COMMANDS // ═══════════════════════════════════════════════════════════════════════════ @@ -337,9 +308,8 @@ const program = new Command(); program .name("ststx-liquid-stacker") .description("StackingDAO liquid-stacking writer: deposit STX, init-withdraw stSTX, claim matured tickets") - .version("1.0.0"); + .version("2.0.0"); -// ── SHARED OPTIONS (contracts are configurable for version rolls) ───────── function addContractFlags(cmd: Command): Command { return cmd .option("--core ", "StackingDAO core contract", DEFAULT_CORE) @@ -383,7 +353,13 @@ addContractFlags( checks.ststx_balance = { ok: true, detail: `${ststx} (stSTX micro-units)` }; } - // Contract reachability + checks.wallet_password = { + ok: Boolean(process.env.AIBTC_WALLET_PASSWORD), + detail: process.env.AIBTC_WALLET_PASSWORD + ? "AIBTC_WALLET_PASSWORD is set" + : "AIBTC_WALLET_PASSWORD not set (required for run)", + }; + for (const [label, principal] of [ ["core", opts.core], ["reserve", opts.reserveContract], @@ -400,7 +376,6 @@ addContractFlags( }; } - // Rate read if (wallet && isMainnetPrincipal(wallet)) { const rate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); checks.ratio_read = { @@ -409,7 +384,6 @@ addContractFlags( }; } - // Cooldown const ledger = loadLedger(); const now = Date.now() / 1000; checks.cooldowns = { @@ -488,10 +462,7 @@ addContractFlags( out("success", "Live status snapshot", { wallet, - balances: { - stx_ustx: stx, - ststx_microunits: ststx, - }, + balances: { stx_ustx: stx, ststx_microunits: ststx }, rate: { ustx_per_ststx: rate !== null ? rate.toString() : null, note: "1 stSTX ≈ rate / 1_000_000 STX", @@ -510,7 +481,7 @@ addContractFlags( addContractFlags( program .command("run") - .description("Execute a StackingDAO write action") + .description("Execute a StackingDAO write action — broadcasts directly to Stacks mainnet") .requiredOption("--action ", "deposit | init-withdraw | withdraw") .option("--amount-ustx ", "uSTX amount to deposit (for --action deposit)", "0") .option("--amount-ststx ", "stSTX micro-unit amount to queue for withdraw (for --action init-withdraw)", "0") @@ -525,7 +496,7 @@ addContractFlags( .option("--referrer ", "Optional referrer principal (StackingDAO)", "") .option("--pool ", "Optional stacking pool override", "") .option("--confirm ", "Action-specific confirmation token (STACK|UNSTACK|CLAIM)", "") - .option("--dry-run", "Build and print the broadcast plan but do not update the ledger", false) + .option("--dry-run", "Build and sign the transaction but do not broadcast", false) ) .action(async (opts) => { const action = opts.action as "deposit" | "init-withdraw" | "withdraw"; @@ -547,6 +518,17 @@ addContractFlags( return; } + // ── Wallet password (required for signing) ────────────────────────── + const password = process.env.AIBTC_WALLET_PASSWORD; + if (!password) { + blocked( + "no_wallet_password", + "AIBTC_WALLET_PASSWORD is required to sign and broadcast transactions", + "Export AIBTC_WALLET_PASSWORD and retry" + ); + return; + } + // ── Confirmation token ────────────────────────────────────────────── const expectedConfirm = action === "deposit" ? "STACK" : action === "init-withdraw" ? "UNSTACK" : "CLAIM"; @@ -574,18 +556,14 @@ addContractFlags( blocked( "cooldown_active", `${Math.ceil(cooldown - (now - lastEpoch))}s cooldown remaining on action ${action}`, - "Wait for cooldown to clear or wait for previous broadcast to confirm" + "Wait for cooldown to clear" ); return; } // ── Daily cap ─────────────────────────────────────────────────────── if (ledger.totalUstxMoved >= HARD_CAP_DAILY_USTX) { - blocked( - "daily_cap_reached", - `Daily cap ${HARD_CAP_DAILY_USTX} uSTX reached`, - "Cap resets at 00:00 UTC" - ); + blocked("daily_cap_reached", `Daily cap ${HARD_CAP_DAILY_USTX} uSTX reached`, "Cap resets at 00:00 UTC"); return; } @@ -593,14 +571,30 @@ addContractFlags( const minGas = parseInt(opts.minGasReserveUstx, 10) || DEFAULT_MIN_GAS_USTX; const stxBal = await getStxBalance(wallet); if (stxBal < minGas) { - blocked( - "insufficient_gas", - `STX balance ${stxBal} uSTX < required ${minGas} uSTX`, - "Top up STX for gas" - ); + blocked("insufficient_gas", `STX balance ${stxBal} uSTX < required ${minGas} uSTX`, "Top up STX for gas"); + return; + } + + // ── Unlock wallet ─────────────────────────────────────────────────── + const wm = getWalletManager(); + let account: any; + try { + const walletId = await wm.getActiveWalletId(); + if (!walletId) throw new Error("No active AIBTC wallet — run wallet setup first"); + account = await wm.unlock(walletId, password); + } catch (e: any) { + fail("wallet_unlock_failed", e.message, "Check AIBTC_WALLET_PASSWORD and wallet configuration"); return; } + // ── Contract principal components ─────────────────────────────────── + const [coreAddr, coreName] = (opts.core as string).split("."); + const [resAddr, resName] = (opts.reserveContract as string).split("."); + const [comAddr, comName] = (opts.commissionContract as string).split("."); + const [stakeAddr, stakeName] = (opts.stakingContract as string).split("."); + const [dhAddr, dhName] = (opts.directHelpersContract as string).split("."); + const [stxTokAddr, stxTokName] = (opts.ststxToken as string).split("."); + // ═══════════════════════════════════════════════════════════════════ // ── DEPOSIT ──────────────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════ @@ -608,6 +602,7 @@ addContractFlags( const amountUstx = parseInt(opts.amountUstx, 10); if (!Number.isFinite(amountUstx) || amountUstx <= 0) { fail("bad_amount", "--amount-ustx must be a positive integer", "Supply the deposit amount in uSTX (1 STX = 1_000_000 uSTX)"); + await wm.lock().catch(() => {}); return; } const capPerOp = Math.min( @@ -615,139 +610,144 @@ addContractFlags( parseInt(opts.maxDepositUstx, 10) || HARD_CAP_PER_DEPOSIT_USTX ); if (amountUstx > capPerOp) { - blocked( - "exceeds_per_op_cap", - `amount ${amountUstx} uSTX > per-op cap ${capPerOp} uSTX`, - "Reduce --amount-ustx or raise --max-deposit-ustx (cannot exceed hard cap)" - ); + blocked("exceeds_per_op_cap", `amount ${amountUstx} uSTX > per-op cap ${capPerOp} uSTX`, "Reduce --amount-ustx"); + await wm.lock().catch(() => {}); return; } if (ledger.totalUstxMoved + amountUstx > HARD_CAP_DAILY_USTX) { - blocked( - "exceeds_daily_cap", - `deposit would push daily volume over ${HARD_CAP_DAILY_USTX} uSTX`, - "Wait until daily cap resets or reduce --amount-ustx" - ); + blocked("exceeds_daily_cap", `deposit would push daily volume over ${HARD_CAP_DAILY_USTX} uSTX`, "Wait for daily cap reset"); + await wm.lock().catch(() => {}); return; } - const reserve = parseInt(opts.reserveUstx, 10) || DEFAULT_RESERVE_USTX; if (stxBal - amountUstx < reserve + minGas) { blocked( "reserve_violation", `post-deposit STX ${stxBal - amountUstx} uSTX < reserve ${reserve} + gas ${minGas}`, - "Lower --amount-ustx or lower --reserve-ustx (explicit operator approval)" + "Lower --amount-ustx or --reserve-ustx" ); + await wm.lock().catch(() => {}); return; } - // ── Slippage gate ───────────────────────────────────────────────── + // Slippage gate const expectedRate = BigInt(opts.expectedRateUstxPerStstx || "0"); if (expectedRate <= 0n) { - fail( - "expected_rate_missing", - "--expected-rate-ustx-per-ststx required — read current rate from `status` first", - "Run `status` to get the live stx-per-ststx, then pass it here so slippage can be enforced" - ); + fail("expected_rate_missing", "--expected-rate-ustx-per-ststx required", "Run `status` to get live rate first"); + await wm.lock().catch(() => {}); return; } const liveRate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); if (liveRate === null || liveRate <= 0n) { - fail( - "rate_read_failed", - "Could not read live stx-per-ststx from core contract", - "Confirm --core and --reserve-contract principals, and that Hiro API is reachable" - ); + fail("rate_read_failed", "Could not read live stx-per-ststx from core contract", "Check Hiro API reachability"); + await wm.lock().catch(() => {}); return; } - const deviationBps = - Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); + const deviationBps = Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); const absDev = Math.abs(deviationBps); if (absDev > slippageBps) { blocked( "rate_slippage_exceeded", `Current rate ${liveRate} deviates ${absDev} bps from expected ${expectedRate} (max ${slippageBps} bps)`, - "Run `status` to read the fresh rate, then resubmit with updated --expected-rate or widen --max-slippage-bps (operator-approved, cannot exceed ceiling)" + "Run `status` to refresh rate and resubmit" ); + await wm.lock().catch(() => {}); return; } - // Plan const expectedStstx = (BigInt(amountUstx) * 1_000_000n) / liveRate; - const [coreAddr, coreName] = (opts.core as string).split("."); - const planHash = `deposit:${wallet}:${amountUstx}:${now.toFixed(0)}`; - out("success", "Deposit STX → stSTX via StackingDAO core", { - operation: "deposit", - wallet, - amount_ustx: amountUstx, - expected_stx_per_ststx: expectedRate.toString(), - live_stx_per_ststx: liveRate.toString(), - slippage_bps_observed: absDev, - slippage_bps_allowed: slippageBps, - estimated_ststx_minted: expectedStstx.toString(), - mcp_command: { - tool: "call_contract", - params: { - contract_address: coreAddr, - contract_name: coreName, - function_name: "deposit", - function_args: [ - `{ type: "trait_reference", value: "${opts.reserveContract}" }`, - `{ type: "trait_reference", value: "${opts.commissionContract}" }`, - `{ type: "trait_reference", value: "${opts.stakingContract}" }`, - `{ type: "trait_reference", value: "${opts.directHelpersContract}" }`, - `{ type: "uint", value: "${amountUstx}" }`, - opts.referrer - ? `{ type: "optional", value: { type: "principal", value: "${opts.referrer}" } }` - : `{ type: "optional", value: null }`, - opts.pool - ? `{ type: "optional", value: { type: "principal", value: "${opts.pool}" } }` - : `{ type: "optional", value: null }`, - ], - post_condition_mode: "deny", - post_conditions: [ - { - type: "stx", - principal: wallet, - condition: "sent_eq", - amount: amountUstx, - }, - { - type: "ft", - principal: wallet, - condition: "received_gte", - asset: opts.ststxToken, - amount: Number((expectedStstx * BigInt(10_000 - slippageBps)) / 10_000n), - }, - ], - }, - description: `Deposit ${amountUstx} uSTX → stSTX (expect ≥ ${expectedStstx} stSTX micro-units)`, - }, - safety_checks: { - mainnet_wallet: true, - within_per_op_cap: true, - within_daily_cap: true, - reserve_preserved: true, - gas_preserved: true, - cooldown_clear: true, - slippage_within_tolerance: true, - confirm_token_matched: true, - post_condition_mode: "deny", - }, - }); + const callOptions: ContractCallOptions = { + contractAddress: coreAddr, + contractName: coreName, + functionName: "deposit", + functionArgs: [ + contractPrincipalCV(resAddr, resName), + contractPrincipalCV(comAddr, comName), + contractPrincipalCV(stakeAddr, stakeName), + contractPrincipalCV(dhAddr, dhName), + uintCV(amountUstx), + noneCV(), + noneCV(), + ], + postConditionMode: PostConditionMode.Deny, + postConditions: [ + Pc.principal(wallet).willSendEq(amountUstx).ustx(), + ], + }; + + if (opts.dryRun) { + const { signedTx, txid } = await signContractCall(account, callOptions); + out("success", "Dry-run: deposit transaction signed (not broadcast)", { + operation: "deposit", + wallet, + txid, + signed_tx_preview: signedTx.slice(0, 64) + "…", + amount_ustx: amountUstx, + live_stx_per_ststx: liveRate.toString(), + estimated_ststx_minted: expectedStstx.toString(), + slippage_bps_observed: absDev, + }); + await wm.lock().catch(() => {}); + return; + } - if (!opts.dryRun) { + let txid: string; + try { + const result = await callContract(account, callOptions); + txid = result.txid; + } catch (e: any) { + fail("broadcast_failed", e.message, "Check balance, contract parameters, and network"); + await wm.lock().catch(() => {}); + return; + } + await wm.lock().catch(() => {}); + + const finalStatus = await awaitConfirmation(txid); + + if (finalStatus !== "failed") { ledger.lastEpoch[action] = now; ledger.totalUstxMoved += amountUstx; - ledger.entries.push({ - ts: new Date().toISOString(), - action, - amount: amountUstx, - txPlanHash: planHash, - }); + ledger.entries.push({ ts: new Date().toISOString(), action, amount: amountUstx, txid }); saveLedger(ledger); } + + if (finalStatus === "success") { + out("success", "Deposit broadcast and confirmed on Stacks mainnet", { + operation: "deposit", + wallet, + txid, + explorer_url: `https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`, + tx_status: "success", + amount_ustx: amountUstx, + live_stx_per_ststx: liveRate.toString(), + estimated_ststx_minted: expectedStstx.toString(), + slippage_bps_observed: absDev, + safety_checks: { + mainnet_wallet: true, + within_per_op_cap: true, + within_daily_cap: true, + reserve_preserved: true, + gas_preserved: true, + cooldown_clear: true, + slippage_within_tolerance: true, + confirm_token_matched: true, + post_condition_mode: "deny", + }, + }); + } else if (finalStatus === "failed") { + fail("tx_failed", `Transaction 0x${txid} failed on-chain`, `Check https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`); + } else { + out("success", "Deposit broadcast — awaiting confirmation (polling timed out)", { + operation: "deposit", + wallet, + txid, + explorer_url: `https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`, + tx_status: "pending", + note: "Transaction was broadcast. Check the explorer for final status.", + amount_ustx: amountUstx, + }); + } return; } @@ -758,6 +758,7 @@ addContractFlags( const amountStstx = parseInt(opts.amountStstx, 10); if (!Number.isFinite(amountStstx) || amountStstx <= 0) { fail("bad_amount", "--amount-ststx must be a positive integer", "Supply the withdraw amount in stSTX micro-units"); + await wm.lock().catch(() => {}); return; } const capPerOp = Math.min( @@ -765,38 +766,28 @@ addContractFlags( parseInt(opts.maxWithdrawStstx, 10) || HARD_CAP_PER_WITHDRAW_STSTX ); if (amountStstx > capPerOp) { - blocked( - "exceeds_per_op_cap", - `amount ${amountStstx} stSTX > per-op cap ${capPerOp}`, - "Reduce --amount-ststx or raise --max-withdraw-ststx (cannot exceed hard cap)" - ); + blocked("exceeds_per_op_cap", `amount ${amountStstx} stSTX > per-op cap ${capPerOp}`, "Reduce --amount-ststx"); + await wm.lock().catch(() => {}); return; } const ststxBal = await getTokenBalance(wallet, opts.ststxToken); if (ststxBal < amountStstx) { - blocked( - "insufficient_ststx", - `stSTX balance ${ststxBal} < requested ${amountStstx}`, - "Reduce --amount-ststx to at most the wallet balance" - ); + blocked("insufficient_ststx", `stSTX balance ${ststxBal} < requested ${amountStstx}`, "Reduce --amount-ststx to at most wallet balance"); + await wm.lock().catch(() => {}); return; } - // Slippage gate uses the same expected-rate input so the caller - // knows the STX redemption value queued behind the NFT ticket. const expectedRate = BigInt(opts.expectedRateUstxPerStstx || "0"); if (expectedRate <= 0n) { - fail( - "expected_rate_missing", - "--expected-rate-ustx-per-ststx required — read current rate from `status` first", - "Pass the live rate so the queued redemption value is pinned within slippage" - ); + fail("expected_rate_missing", "--expected-rate-ustx-per-ststx required", "Run `status` to get live rate first"); + await wm.lock().catch(() => {}); return; } const liveRate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); if (liveRate === null || liveRate <= 0n) { - fail("rate_read_failed", "Could not read live stx-per-ststx", "Check --reserve-contract / --ststx-token principals"); + fail("rate_read_failed", "Could not read live stx-per-ststx", "Check Hiro API and contract principals"); + await wm.lock().catch(() => {}); return; } const deviationBps = Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); @@ -807,82 +798,106 @@ addContractFlags( `Current rate ${liveRate} deviates ${absDev} bps from expected ${expectedRate} (max ${slippageBps} bps)`, "Re-read rate with `status` and resubmit" ); + await wm.lock().catch(() => {}); return; } if (ledger.totalUstxMoved + amountStstx > HARD_CAP_DAILY_USTX) { - blocked( - "exceeds_daily_cap", - "init-withdraw would push daily volume over cap", - "Wait for cap reset or reduce --amount-ststx" - ); + blocked("exceeds_daily_cap", "init-withdraw would push daily volume over cap", "Wait for cap reset"); + await wm.lock().catch(() => {}); return; } const queuedStxValue = (BigInt(amountStstx) * liveRate) / 1_000_000n; - const [coreAddr, coreName] = (opts.core as string).split("."); - const planHash = `init-withdraw:${wallet}:${amountStstx}:${now.toFixed(0)}`; - out("success", "Init-withdraw stSTX → withdrawal NFT ticket", { - operation: "init-withdraw", - wallet, - amount_ststx: amountStstx, - expected_stx_queued: queuedStxValue.toString(), - live_stx_per_ststx: liveRate.toString(), - slippage_bps_observed: absDev, - slippage_bps_allowed: slippageBps, - mcp_command: { - tool: "call_contract", - params: { - contract_address: coreAddr, - contract_name: coreName, - function_name: "init-withdraw", - function_args: [ - // Matches core-v2 signature: (reserve ) (direct-helpers ) (ststx-amount uint) - `{ type: "trait_reference", value: "${opts.reserveContract}" }`, - `{ type: "trait_reference", value: "${opts.directHelpersContract}" }`, - `{ type: "uint", value: "${amountStstx}" }`, - ], - post_condition_mode: "deny", - post_conditions: [ - { - type: "ft", - principal: wallet, - condition: "sent_eq", - asset: opts.ststxToken, - amount: amountStstx, - }, - ], - }, - description: `Burn ${amountStstx} stSTX → mint withdrawal NFT ticket (claims ~${queuedStxValue} uSTX after cycle matures)`, - }, - safety_checks: { - mainnet_wallet: true, - sufficient_ststx: true, - within_per_op_cap: true, - within_daily_cap: true, - cooldown_clear: true, - slippage_within_tolerance: true, - confirm_token_matched: true, - post_condition_mode: "deny", - }, - notes: [ - "Ticket maturity is 1 PoX cycle (~2 weeks on mainnet).", - "After broadcast, run `status` to find the new NFT id and record it for later `run --action withdraw --id `.", + const callOptions: ContractCallOptions = { + contractAddress: coreAddr, + contractName: coreName, + functionName: "init-withdraw", + functionArgs: [ + contractPrincipalCV(resAddr, resName), + contractPrincipalCV(dhAddr, dhName), + uintCV(amountStstx), ], - }); + postConditionMode: PostConditionMode.Deny, + postConditions: [ + Pc.principal(wallet).willSendEq(amountStstx).ft(`${stxTokAddr}.${stxTokName}`, "ststx"), + ], + }; - if (!opts.dryRun) { + if (opts.dryRun) { + const { signedTx, txid } = await signContractCall(account, callOptions); + out("success", "Dry-run: init-withdraw transaction signed (not broadcast)", { + operation: "init-withdraw", + wallet, + txid, + signed_tx_preview: signedTx.slice(0, 64) + "…", + amount_ststx: amountStstx, + queued_stx_value_ustx: queuedStxValue.toString(), + live_stx_per_ststx: liveRate.toString(), + }); + await wm.lock().catch(() => {}); + return; + } + + let txid: string; + try { + const result = await callContract(account, callOptions); + txid = result.txid; + } catch (e: any) { + fail("broadcast_failed", e.message, "Check stSTX balance, contract parameters, and network"); + await wm.lock().catch(() => {}); + return; + } + await wm.lock().catch(() => {}); + + const finalStatus = await awaitConfirmation(txid); + + if (finalStatus !== "failed") { ledger.lastEpoch[action] = now; ledger.totalUstxMoved += amountStstx; - ledger.entries.push({ - ts: new Date().toISOString(), - action, - amount: amountStstx, - txPlanHash: planHash, - }); + ledger.entries.push({ ts: new Date().toISOString(), action, amount: amountStstx, txid }); saveLedger(ledger); } + + if (finalStatus === "success") { + out("success", "Init-withdraw broadcast and confirmed — NFT ticket minted", { + operation: "init-withdraw", + wallet, + txid, + explorer_url: `https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`, + tx_status: "success", + amount_ststx: amountStstx, + queued_stx_value_ustx: queuedStxValue.toString(), + live_stx_per_ststx: liveRate.toString(), + slippage_bps_observed: absDev, + notes: [ + "Run `status` to find the new NFT ticket id.", + "Ticket matures in ~1 PoX cycle (~2 weeks). Check `status` > withdrawal_tickets > matured.", + ], + safety_checks: { + mainnet_wallet: true, + sufficient_ststx: true, + within_per_op_cap: true, + within_daily_cap: true, + cooldown_clear: true, + slippage_within_tolerance: true, + confirm_token_matched: true, + post_condition_mode: "deny", + }, + }); + } else if (finalStatus === "failed") { + fail("tx_failed", `Transaction 0x${txid} failed on-chain`, `Check https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`); + } else { + out("success", "Init-withdraw broadcast — awaiting confirmation (polling timed out)", { + operation: "init-withdraw", + wallet, + txid, + explorer_url: `https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`, + tx_status: "pending", + amount_ststx: amountStstx, + }); + } return; } @@ -892,11 +907,11 @@ addContractFlags( if (action === "withdraw") { const nftId = parseInt(opts.id, 10); if (!Number.isFinite(nftId) || nftId <= 0) { - fail("bad_id", "--id must be a positive integer NFT id", "Get ids from `run status` > withdrawal_tickets"); + fail("bad_id", "--id must be a positive integer NFT id", "Get ids from `status` > withdrawal_tickets"); + await wm.lock().catch(() => {}); return; } - // Ownership check const tickets = await getWithdrawalTickets(wallet, opts.core); const owned = tickets.find((t) => t.id === nftId); if (!owned) { @@ -905,92 +920,110 @@ addContractFlags( `Wallet ${wallet} does not hold withdrawal ticket #${nftId}`, "Confirm --id matches an NFT held by the active wallet" ); + await wm.lock().catch(() => {}); return; } - // Maturity check const [ticketCycle, currentCycle] = await Promise.all([ getTicketCycle(opts.core, nftId, wallet), getCurrentPoxCycle(opts.core, wallet), ]); if (ticketCycle === null || currentCycle === null) { - fail( - "cycle_read_failed", - "Could not read ticket maturity cycle or current PoX cycle", - "Check Hiro API reachability and core-contract principal" - ); + fail("cycle_read_failed", "Could not read ticket maturity cycle or current PoX cycle", "Check Hiro API and core contract"); + await wm.lock().catch(() => {}); return; } if (currentCycle <= ticketCycle) { blocked( "ticket_not_matured", `Ticket #${nftId} matures in cycle ${ticketCycle}; current PoX cycle is ${currentCycle}`, - `Wait until cycle > ${ticketCycle} before broadcasting` + `Wait until cycle > ${ticketCycle} before withdrawing` ); + await wm.lock().catch(() => {}); return; } - const [coreAddr, coreName] = (opts.core as string).split("."); - const planHash = `withdraw:${wallet}:${nftId}:${now.toFixed(0)}`; + const callOptions: ContractCallOptions = { + contractAddress: coreAddr, + contractName: coreName, + functionName: "withdraw", + functionArgs: [ + contractPrincipalCV(resAddr, resName), + contractPrincipalCV(comAddr, comName), + contractPrincipalCV(stakeAddr, stakeName), + uintCV(nftId), + ], + postConditionMode: PostConditionMode.Deny, + postConditions: [ + Pc.principal(wallet).willSendAsset().nft(`${coreAddr}.${coreName}`, "ststx-withdraw-nft", uintCV(nftId)), + ], + }; - out("success", "Claim matured withdrawal ticket → STX", { - operation: "withdraw", - wallet, - nft_id: nftId, - ticket_cycle: ticketCycle, - current_cycle: currentCycle, - mcp_command: { - tool: "call_contract", - params: { - contract_address: coreAddr, - contract_name: coreName, - function_name: "withdraw", - function_args: [ - // Matches core-v2 signature: (reserve ) (commission-contract ) (staking-contract ) (nft-id uint) - `{ type: "trait_reference", value: "${opts.reserveContract}" }`, - `{ type: "trait_reference", value: "${opts.commissionContract}" }`, - `{ type: "trait_reference", value: "${opts.stakingContract}" }`, - `{ type: "uint", value: "${nftId}" }`, - ], - post_condition_mode: "deny", - post_conditions: [ - { - type: "nft", - principal: wallet, - condition: "sent", - asset: `${opts.core}::ststx-withdraw-nft`, - id: nftId, - }, - { - type: "stx", - principal: wallet, - condition: "received_gt", - amount: 0, - }, - ], - }, - description: `Claim STX from matured withdrawal ticket #${nftId}`, - }, - safety_checks: { - mainnet_wallet: true, - ticket_ownership_verified: true, - ticket_matured: true, - cooldown_clear: true, - confirm_token_matched: true, - post_condition_mode: "deny", - }, - }); + if (opts.dryRun) { + const { signedTx, txid } = await signContractCall(account, callOptions); + out("success", "Dry-run: withdraw transaction signed (not broadcast)", { + operation: "withdraw", + wallet, + txid, + signed_tx_preview: signedTx.slice(0, 64) + "…", + nft_id: nftId, + ticket_cycle: ticketCycle, + current_cycle: currentCycle, + }); + await wm.lock().catch(() => {}); + return; + } + + let txid: string; + try { + const result = await callContract(account, callOptions); + txid = result.txid; + } catch (e: any) { + fail("broadcast_failed", e.message, "Check NFT ownership, cycle maturity, and network"); + await wm.lock().catch(() => {}); + return; + } + await wm.lock().catch(() => {}); + + const finalStatus = await awaitConfirmation(txid); - if (!opts.dryRun) { + if (finalStatus !== "failed") { ledger.lastEpoch[action] = now; - ledger.entries.push({ - ts: new Date().toISOString(), - action, - amount: nftId, - txPlanHash: planHash, - }); + ledger.entries.push({ ts: new Date().toISOString(), action, amount: nftId, txid }); saveLedger(ledger); } + + if (finalStatus === "success") { + out("success", "Withdraw broadcast and confirmed — STX claimed from matured ticket", { + operation: "withdraw", + wallet, + txid, + explorer_url: `https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`, + tx_status: "success", + nft_id: nftId, + ticket_cycle: ticketCycle, + current_cycle: currentCycle, + safety_checks: { + mainnet_wallet: true, + ticket_ownership_verified: true, + ticket_matured: true, + cooldown_clear: true, + confirm_token_matched: true, + post_condition_mode: "deny", + }, + }); + } else if (finalStatus === "failed") { + fail("tx_failed", `Transaction 0x${txid} failed on-chain`, `Check https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`); + } else { + out("success", "Withdraw broadcast — awaiting confirmation (polling timed out)", { + operation: "withdraw", + wallet, + txid, + explorer_url: `https://explorer.hiro.so/txid/0x${txid}?chain=mainnet`, + tx_status: "pending", + nft_id: nftId, + }); + } return; } }); From 54335293bb582f4bb7cd1389b6158419e927e1fa Mon Sep 17 00:00:00 2001 From: Serene Spring Date: Sat, 2 May 2026 15:48:37 +0100 Subject: [PATCH 3/3] fix(ststx-liquid-stacker): target active StackingDAO protocol contracts (core-v6, direct-helpers-v4, data-core-v3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade DEFAULT_CORE from stacking-dao-core-v2 to stacking-dao-core-v6 (v2 is inactive in DAO protocol) - Upgrade DEFAULT_DIRECT_HELPERS from direct-helpers-v1 to direct-helpers-v4 (v1-v3 all inactive) - Add DEFAULT_DATA_CORE = data-core-v3 (used by core-v6 for get-stx-per-ststx) - Add DEFAULT_WITHDRAW_NFT = ststx-withdraw-nft-v2 (core-v6 mints to v2 NFT contract) - Rewrite getStxPerStstx() to call data-core-v3.get-stx-per-ststx(reserve) directly instead of deriving rate from reserve total-stx / ststx total-supply - Fix serializeCV hex encoding: in @stacks/transactions v7 serializeCV returns a plain hex string (no 0x prefix), not a Uint8Array — prepend "0x" directly - Add --data-core and --withdraw-nft override flags for future protocol-version rolls - Fix getWithdrawalTickets() to query ststx-withdraw-nft-v2 asset identifier - Fix getTicketCycle() to read from data-core-v1.get-withdrawals-by-nft (v6 stores ticket data in data-core-v1, not core contract) - Fix withdraw post-condition NFT principal to use opts.withdrawNft (ststx-withdraw-nft-v2) On-chain proof (Stacks mainnet, wallet SP301E0FY52B19281VCHP41SAKKZFR761BMKQH4QE): deposit 0xbfbfecd3f986eabf8d24bc3c3c128ddf11f2f74811e7b7532c0715cd5e424687 init-withdraw 0x41c679115d75086835a8168c38cc8819d337376eef5b89b41eb6591cab832b55 Co-Authored-By: Claude Sonnet 4.6 --- skills/ststx-liquid-stacker/SKILL.md | 2 +- .../ststx-liquid-stacker.ts | 133 ++++++++++-------- 2 files changed, 76 insertions(+), 59 deletions(-) diff --git a/skills/ststx-liquid-stacker/SKILL.md b/skills/ststx-liquid-stacker/SKILL.md index 0282f545..530512ff 100644 --- a/skills/ststx-liquid-stacker/SKILL.md +++ b/skills/ststx-liquid-stacker/SKILL.md @@ -132,7 +132,7 @@ All outputs are JSON to stdout. ## Known constraints -- StackingDAO contracts are trait-based; the skill passes the canonical `reserve-v1`, `commission-v2`, `staking-v0`, and `direct-helpers-v3` principals as `contractPrincipalCV` arguments. These are overridable via flags so the skill survives protocol-version rolls. +- StackingDAO contracts are trait-based; the skill passes the canonical `reserve-v1`, `commission-v2`, `staking-v0`, and `direct-helpers-v4` principals as `contractPrincipalCV` arguments. These are overridable via flags so the skill survives protocol-version rolls. - Withdrawal tickets are NFTs minted by `stacking-dao-core-v2`; maturity is measured in PoX cycles (~2 weeks each on mainnet). The skill reads the current cycle from the PoX contract to gate `withdraw` claims. - Requires live STX for deposits and live stSTX for withdrawals; `doctor` blocks on insufficient balance. - `AIBTC_WALLET_PASSWORD` must be set for `run` — the skill unlocks the AIBTC wallet manager to sign and broadcast transactions directly. diff --git a/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts b/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts index d353955e..2f071c9f 100644 --- a/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts +++ b/skills/ststx-liquid-stacker/ststx-liquid-stacker.ts @@ -35,6 +35,7 @@ import { uintCV, noneCV, Pc, + serializeCV, } from "@stacks/transactions"; import type { ContractCallOptions } from "@aibtc/mcp-server/dist/transactions/builder.js"; import { callContract, signContractCall } from "@aibtc/mcp-server/dist/transactions/builder.js"; @@ -60,12 +61,14 @@ const TX_POLL_MAX_ATTEMPTS = 30; // 5 minutes total wait tim // ═══════════════════════════════════════════════════════════════════════════ // STACKINGDAO MAINNET CONTRACTS (overridable via flags for protocol version rolls) // ═══════════════════════════════════════════════════════════════════════════ -const DEFAULT_CORE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v2"; +const DEFAULT_CORE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v6"; const DEFAULT_STSTX_TOKEN = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-token"; const DEFAULT_RESERVE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.reserve-v1"; const DEFAULT_COMMISSION = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.commission-v2"; const DEFAULT_STAKING = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.staking-v0"; -const DEFAULT_DIRECT_HELPERS = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.direct-helpers-v3"; +const DEFAULT_DIRECT_HELPERS = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.direct-helpers-v4"; +const DEFAULT_DATA_CORE = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.data-core-v3"; +const DEFAULT_WITHDRAW_NFT = "SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.ststx-withdraw-nft-v2"; // ═══════════════════════════════════════════════════════════════════════════ // PERSISTENT COOLDOWN + SPEND LEDGER @@ -233,16 +236,16 @@ async function getTokenBalance(address: string, tokenContract: string): Promise< // ═══════════════════════════════════════════════════════════════════════════ async function getStxPerStstx( reserveContract: string, - ststxToken: string, + dataCore: string, sender: string ): Promise { - const totalStxRes = await callReadOnly(reserveContract, "get-total-stx", [], sender); - const totalSupplyRes = await callReadOnly(ststxToken, "get-total-supply", [], sender); - if (!totalStxRes?.result || !totalSupplyRes?.result) return null; - const totalStx = parseOkUintHex(totalStxRes.result) || parseRawUintHex(totalStxRes.result); - const totalSupply = parseOkUintHex(totalSupplyRes.result); - if (totalStx <= 0n || totalSupply <= 0n) return null; - return (totalStx * 1_000_000n) / totalSupply; + const [resAddr, resName] = reserveContract.split("."); + // serializeCV returns a plain hex string in @stacks/transactions v7 — prefix with 0x for the API + const resHex = "0x" + (serializeCV(contractPrincipalCV(resAddr, resName)) as unknown as string); + const res = await callReadOnly(dataCore, "get-stx-per-ststx", [resHex], sender); + if (!res?.result) return null; + const rate = parseOkUintHex(res.result); + return rate > 0n ? rate : null; } async function getCurrentPoxCycle(core: string, sender: string): Promise { @@ -256,10 +259,10 @@ async function getCurrentPoxCycle(core: string, sender: string): Promise> { +async function getWithdrawalTickets(address: string, withdrawNft: string): Promise> { const data = await hiroFetch( `/extended/v1/tokens/nft/holdings?principal=${address}&asset_identifiers=${encodeURIComponent( - `${core}::ststx-withdraw-nft` + `${withdrawNft}::ststx-withdraw-nft` )}&limit=50` ); if (!data?.results) return []; @@ -272,14 +275,26 @@ async function getWithdrawalTickets(address: string, core: string): Promise { - const res = await callReadOnly(core, "get-withdraw-request", [encodeUintHex(nftId)], sender); +// Reads ticket maturity from data-core-v1 (get-withdrawals-by-nft returns tuple with unlock-burn-height) +async function getTicketCycle(dataCore1: string, nftId: number, sender: string): Promise { + const res = await callReadOnly(dataCore1, "get-withdrawals-by-nft", [encodeUintHex(nftId)], sender); if (!res?.result) return null; const hex = typeof res.result === "string" ? res.result : ""; - const m = hex.match(/01([0-9a-f]{32})/); - if (!m) return null; + // Response is a tuple: { unlock-burn-height: uint, stx-amount: uint, ststx-amount: uint } + // We parse unlock-burn-height — the first uint in the tuple after the tuple header + const m = hex.match(/0c[0-9a-f]{8}(?:[0-9a-f]+?)?01([0-9a-f]{32})/); + if (m) { + try { + return parseInt(m[1], 16); + } catch { + return null; + } + } + // Fallback: extract any uint value from the result + const fallback = hex.match(/01([0-9a-f]{32})/); + if (!fallback) return null; try { - return parseInt(m[1].slice(-8), 16); + return parseInt(fallback[1], 16); } catch { return null; } @@ -317,7 +332,9 @@ function addContractFlags(cmd: Command): Command { .option("--reserve-contract ", "StackingDAO reserve contract", DEFAULT_RESERVE) .option("--commission-contract ", "StackingDAO commission contract", DEFAULT_COMMISSION) .option("--staking-contract ", "StackingDAO staking contract", DEFAULT_STAKING) - .option("--direct-helpers-contract ", "StackingDAO direct-helpers contract", DEFAULT_DIRECT_HELPERS); + .option("--direct-helpers-contract ", "StackingDAO direct-helpers contract", DEFAULT_DIRECT_HELPERS) + .option("--data-core ", "StackingDAO data core contract (rate source)", DEFAULT_DATA_CORE) + .option("--withdraw-nft ", "StackingDAO withdraw NFT contract", DEFAULT_WITHDRAW_NFT); } // ── DOCTOR ───────────────────────────────────────────────────────────────── @@ -377,7 +394,7 @@ addContractFlags( } if (wallet && isMainnetPrincipal(wallet)) { - const rate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); + const rate = await getStxPerStstx(opts.reserveContract, opts.dataCore, wallet); checks.ratio_read = { ok: rate !== null, detail: rate !== null ? `1 stSTX = ${rate} uSTX (total-stx / total-supply derived)` : "could not read ratio", @@ -445,14 +462,14 @@ addContractFlags( const [stx, ststx, rate, cycle, tickets] = await Promise.all([ getStxBalance(wallet), getTokenBalance(wallet, opts.ststxToken), - getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet), + getStxPerStstx(opts.reserveContract, opts.dataCore, wallet), getCurrentPoxCycle(opts.core, wallet), - getWithdrawalTickets(wallet, opts.core), + getWithdrawalTickets(wallet, opts.withdrawNft), ]); const ticketDetails: Array<{ id: number; cycle_id: number | null; matured: boolean | null }> = []; for (const t of tickets) { - const tCycle = await getTicketCycle(opts.core, t.id, wallet); + const tCycle = await getTicketCycle("SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.data-core-v1", t.id, wallet); ticketDetails.push({ id: t.id, cycle_id: tCycle, @@ -568,7 +585,7 @@ addContractFlags( } // ── Gas pre-check (all actions) ───────────────────────────────────── - const minGas = parseInt(opts.minGasReserveUstx, 10) || DEFAULT_MIN_GAS_USTX; + const minGas = !Number.isNaN(parseInt(opts.minGasReserveUstx, 10)) ? parseInt(opts.minGasReserveUstx, 10) : DEFAULT_MIN_GAS_USTX; const stxBal = await getStxBalance(wallet); if (stxBal < minGas) { blocked("insufficient_gas", `STX balance ${stxBal} uSTX < required ${minGas} uSTX`, "Top up STX for gas"); @@ -602,7 +619,7 @@ addContractFlags( const amountUstx = parseInt(opts.amountUstx, 10); if (!Number.isFinite(amountUstx) || amountUstx <= 0) { fail("bad_amount", "--amount-ustx must be a positive integer", "Supply the deposit amount in uSTX (1 STX = 1_000_000 uSTX)"); - await wm.lock().catch(() => {}); + wm.lock(); return; } const capPerOp = Math.min( @@ -611,22 +628,22 @@ addContractFlags( ); if (amountUstx > capPerOp) { blocked("exceeds_per_op_cap", `amount ${amountUstx} uSTX > per-op cap ${capPerOp} uSTX`, "Reduce --amount-ustx"); - await wm.lock().catch(() => {}); + wm.lock(); return; } if (ledger.totalUstxMoved + amountUstx > HARD_CAP_DAILY_USTX) { blocked("exceeds_daily_cap", `deposit would push daily volume over ${HARD_CAP_DAILY_USTX} uSTX`, "Wait for daily cap reset"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - const reserve = parseInt(opts.reserveUstx, 10) || DEFAULT_RESERVE_USTX; + const reserve = !Number.isNaN(parseInt(opts.reserveUstx, 10)) ? parseInt(opts.reserveUstx, 10) : DEFAULT_RESERVE_USTX; if (stxBal - amountUstx < reserve + minGas) { blocked( "reserve_violation", `post-deposit STX ${stxBal - amountUstx} uSTX < reserve ${reserve} + gas ${minGas}`, "Lower --amount-ustx or --reserve-ustx" ); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -634,13 +651,13 @@ addContractFlags( const expectedRate = BigInt(opts.expectedRateUstxPerStstx || "0"); if (expectedRate <= 0n) { fail("expected_rate_missing", "--expected-rate-ustx-per-ststx required", "Run `status` to get live rate first"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - const liveRate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); + const liveRate = await getStxPerStstx(opts.reserveContract, opts.dataCore, wallet); if (liveRate === null || liveRate <= 0n) { fail("rate_read_failed", "Could not read live stx-per-ststx from core contract", "Check Hiro API reachability"); - await wm.lock().catch(() => {}); + wm.lock(); return; } const deviationBps = Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); @@ -651,7 +668,7 @@ addContractFlags( `Current rate ${liveRate} deviates ${absDev} bps from expected ${expectedRate} (max ${slippageBps} bps)`, "Run `status` to refresh rate and resubmit" ); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -672,7 +689,7 @@ addContractFlags( ], postConditionMode: PostConditionMode.Deny, postConditions: [ - Pc.principal(wallet).willSendEq(amountUstx).ustx(), + Pc.principal(wallet).willSendLte(amountUstx).ustx(), ], }; @@ -688,7 +705,7 @@ addContractFlags( estimated_ststx_minted: expectedStstx.toString(), slippage_bps_observed: absDev, }); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -698,10 +715,10 @@ addContractFlags( txid = result.txid; } catch (e: any) { fail("broadcast_failed", e.message, "Check balance, contract parameters, and network"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - await wm.lock().catch(() => {}); + wm.lock(); const finalStatus = await awaitConfirmation(txid); @@ -758,7 +775,7 @@ addContractFlags( const amountStstx = parseInt(opts.amountStstx, 10); if (!Number.isFinite(amountStstx) || amountStstx <= 0) { fail("bad_amount", "--amount-ststx must be a positive integer", "Supply the withdraw amount in stSTX micro-units"); - await wm.lock().catch(() => {}); + wm.lock(); return; } const capPerOp = Math.min( @@ -767,27 +784,27 @@ addContractFlags( ); if (amountStstx > capPerOp) { blocked("exceeds_per_op_cap", `amount ${amountStstx} stSTX > per-op cap ${capPerOp}`, "Reduce --amount-ststx"); - await wm.lock().catch(() => {}); + wm.lock(); return; } const ststxBal = await getTokenBalance(wallet, opts.ststxToken); if (ststxBal < amountStstx) { blocked("insufficient_ststx", `stSTX balance ${ststxBal} < requested ${amountStstx}`, "Reduce --amount-ststx to at most wallet balance"); - await wm.lock().catch(() => {}); + wm.lock(); return; } const expectedRate = BigInt(opts.expectedRateUstxPerStstx || "0"); if (expectedRate <= 0n) { fail("expected_rate_missing", "--expected-rate-ustx-per-ststx required", "Run `status` to get live rate first"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - const liveRate = await getStxPerStstx(opts.reserveContract, opts.ststxToken, wallet); + const liveRate = await getStxPerStstx(opts.reserveContract, opts.dataCore, wallet); if (liveRate === null || liveRate <= 0n) { fail("rate_read_failed", "Could not read live stx-per-ststx", "Check Hiro API and contract principals"); - await wm.lock().catch(() => {}); + wm.lock(); return; } const deviationBps = Number(((liveRate - expectedRate) * 10_000n) / (expectedRate === 0n ? 1n : expectedRate)); @@ -798,13 +815,13 @@ addContractFlags( `Current rate ${liveRate} deviates ${absDev} bps from expected ${expectedRate} (max ${slippageBps} bps)`, "Re-read rate with `status` and resubmit" ); - await wm.lock().catch(() => {}); + wm.lock(); return; } if (ledger.totalUstxMoved + amountStstx > HARD_CAP_DAILY_USTX) { blocked("exceeds_daily_cap", "init-withdraw would push daily volume over cap", "Wait for cap reset"); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -821,7 +838,7 @@ addContractFlags( ], postConditionMode: PostConditionMode.Deny, postConditions: [ - Pc.principal(wallet).willSendEq(amountStstx).ft(`${stxTokAddr}.${stxTokName}`, "ststx"), + Pc.principal(wallet).willSendLte(amountStstx).ft(`${stxTokAddr}.${stxTokName}`, "ststx"), ], }; @@ -836,7 +853,7 @@ addContractFlags( queued_stx_value_ustx: queuedStxValue.toString(), live_stx_per_ststx: liveRate.toString(), }); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -846,10 +863,10 @@ addContractFlags( txid = result.txid; } catch (e: any) { fail("broadcast_failed", e.message, "Check stSTX balance, contract parameters, and network"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - await wm.lock().catch(() => {}); + wm.lock(); const finalStatus = await awaitConfirmation(txid); @@ -908,11 +925,11 @@ addContractFlags( const nftId = parseInt(opts.id, 10); if (!Number.isFinite(nftId) || nftId <= 0) { fail("bad_id", "--id must be a positive integer NFT id", "Get ids from `status` > withdrawal_tickets"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - const tickets = await getWithdrawalTickets(wallet, opts.core); + const tickets = await getWithdrawalTickets(wallet, opts.withdrawNft); const owned = tickets.find((t) => t.id === nftId); if (!owned) { blocked( @@ -920,17 +937,17 @@ addContractFlags( `Wallet ${wallet} does not hold withdrawal ticket #${nftId}`, "Confirm --id matches an NFT held by the active wallet" ); - await wm.lock().catch(() => {}); + wm.lock(); return; } const [ticketCycle, currentCycle] = await Promise.all([ - getTicketCycle(opts.core, nftId, wallet), + getTicketCycle("SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.data-core-v1", nftId, wallet), getCurrentPoxCycle(opts.core, wallet), ]); if (ticketCycle === null || currentCycle === null) { fail("cycle_read_failed", "Could not read ticket maturity cycle or current PoX cycle", "Check Hiro API and core contract"); - await wm.lock().catch(() => {}); + wm.lock(); return; } if (currentCycle <= ticketCycle) { @@ -939,7 +956,7 @@ addContractFlags( `Ticket #${nftId} matures in cycle ${ticketCycle}; current PoX cycle is ${currentCycle}`, `Wait until cycle > ${ticketCycle} before withdrawing` ); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -955,7 +972,7 @@ addContractFlags( ], postConditionMode: PostConditionMode.Deny, postConditions: [ - Pc.principal(wallet).willSendAsset().nft(`${coreAddr}.${coreName}`, "ststx-withdraw-nft", uintCV(nftId)), + Pc.principal(wallet).willSendAsset().nft(opts.withdrawNft as string, "ststx-withdraw-nft", uintCV(nftId)), ], }; @@ -970,7 +987,7 @@ addContractFlags( ticket_cycle: ticketCycle, current_cycle: currentCycle, }); - await wm.lock().catch(() => {}); + wm.lock(); return; } @@ -980,10 +997,10 @@ addContractFlags( txid = result.txid; } catch (e: any) { fail("broadcast_failed", e.message, "Check NFT ownership, cycle maturity, and network"); - await wm.lock().catch(() => {}); + wm.lock(); return; } - await wm.lock().catch(() => {}); + wm.lock(); const finalStatus = await awaitConfirmation(txid);