diff --git a/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/AGENT.md b/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/AGENT.md new file mode 100644 index 00000000..69c461bd --- /dev/null +++ b/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/AGENT.md @@ -0,0 +1,72 @@ +--- +name: unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC-agent +skill: unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC +description: "Coordinates a 5-leg unwind of the wind skill's (#604) position. Phase 1: unstake sUSDh on Hermetica (irreversible, starts 7d cooldown). Phase 2 (after cooldown): silo-withdraw -> swap USDh -> USDCx on Bitflow -> repay Zest USDCx debt -> withdraw sBTC collateral. Never broadcasts wind operations." +--- + +# Agent briefing — unwindleg + +## Your job + +Take a wallet from {sUSDh held, USDCx debt on Zest, sBTC collateral on Zest} back to {sBTC held, debt zero}. Five legs across a 7-day cooldown. + +## Pre-flight (always run before any write) + +1. `doctor --wallet ` — must return `status: success`. Confirms Hermetica state is readable, the Bitflow token registry resolves USDh + borrow + collateral, and surfaces the six contract addresses (`data.contracts`) the skill calls or reads. This skill has zero external skill dependencies — leg 3 swap is a direct call to the Bitflow DLMM router and the pre-leg-5 residual-debt check is a direct readonly to the Zest market vault. +2. Read the wallet's sUSDh balance from the doctor output. Confirm `--susdh-amount-base` ≤ balance. +3. If a checkpoint already exists for the wallet (doctor surfaces it), do NOT call `run`. Either call `resume` (if the operator wants to continue the existing unwind) or `cancel` (if abandoning it). + +## Run phase 1 (broadcast unstake) + +``` +run --wallet --susdh-amount-base --min-sbtc-withdraw-sats --confirm=UNWIND +``` + +Expected outcomes: + +- **`status: success` with `data.checkpoint.step = "unstake_confirmed"`** — unstake broadcast, silo claim recorded, cooldown is running. The response includes `cooldownExpiresAt` ISO timestamp. Schedule phase 2 (`resume`) for after that timestamp + the operator's grace margin. +- **`status: error` with `error.code = "CLAIM_ID_INDETERMINATE"`** — unstake broadcast but the silo claim-id counter didn't advance within `--wait-seconds`. The unstake tx is on chain; the controller just couldn't snapshot the claim-id atomically. Recovery: read `staking-silo-v1-1.get-current-claim-id` after the unstake tx confirms, then hand-edit the checkpoint to record the claim-id, then run `resume`. The error data includes `unstakeTxid` and `preClaimId` for forensics. +- **`status: error` with `error.code = "INSUFFICIENT_SUSDH_BALANCE"`** — operator requested more sUSDh than the wallet holds. Reduce `--susdh-amount-base`. +- **`status: error` with `error.code = "SIGNER_UNAVAILABLE"`** — none of `AIBTC_SESSION_FILE` / `STACKS_PRIVATE_KEY` / `CLIENT_MNEMONIC` resolved to a key matching `--wallet`. Surface the per-path attempt list to the operator and stop. + +## Wait the cooldown + +7 days by default. Read `data.checkpoint.cooldownExpiresAt` and the operator's `--cooldown-grace-seconds` (default 300). Do not call `resume` before `cooldownExpiresAt + grace`. The skill will reject early `resume` with `COOLDOWN_NOT_EXPIRED` including `secondsRemaining`. + +## Run phase 2 (broadcast legs 2-5) + +``` +resume --wallet --confirm=UNWIND +``` + +This broadcasts in order: silo-withdraw → swap → repay → withdraw. If any leg fails, the checkpoint records the partial state and the next `resume` picks up from there. Idempotent across retries. + +Expected outcomes: + +- **`status: success` with `data.checkpoint.step = "complete"`** — all 5 legs broadcast. Wallet now holds sBTC (no sUSDh, no Zest debt). The response includes all 5 txids: `unstakeTxid`, `claimTxid`, `swapTxid`, `repayTxid`, `withdrawTxid`. +- **`status: blocked` with `error.code = "COOLDOWN_NOT_EXPIRED"`** — too early. Wait `error.data.secondsRemaining` seconds and retry. +- **`status: error` with `error.code = "SWAP_OUTPUT_UNKNOWN"`** — the swap tx confirmed but no ft_transfer event for USDCx to the wallet was found in the tx events. Inspect the tx on the explorer; this usually means the swap routed through a different asset path than expected. Repair before retrying. +- **`status: error` with `error.code = "BITFLOW_QUOTE_FETCH_FAILED"`** — Bitflow `/quote` endpoint unreachable when deriving the swap min-out. Retry after Bitflow recovers; the skill refuses to broadcast a swap without a fresh min-out. +- **`status: error` with `error.code = "RESIDUAL_DEBT_UNREADABLE"`** — the direct readonly to `v0-market-vault.get-account-scaled-debt` returned null. Inspect Hiro connectivity and the wallet address before retrying. +- **`status: error` with `error.code = "RESIDUAL_DEBT_AFTER_REPAY"`** — the repay leg confirmed but scaled debt is still > 0 (slippage / interest accrual). Top up the wallet with the borrow asset and call `v0-4-market.repay` directly until scaled debt returns 0, then resume. +- **`status: error` with `error.code = "BROADCAST_FAILED"`** — one of the inline broadcasts (silo-withdraw / swap / repay / collateral-remove-redeem) was rejected by the node. The error includes the reason; do not retry until the underlying cause is resolved. + +## Recovery shortcuts + +- **Silo claim exists on chain but checkpoint is wrong:** the silo claim is the source of truth. Call `staking-silo-v1-1.withdraw(claim-id)` directly via any Stacks tool to recover the USDh. Then complete legs 3-5 manually or fix the checkpoint and `resume`. +- **Operator wants partial unwind:** this skill is full-unwind for a given `--susdh-amount-base`. For partial collateral withdraw at the end, the operator should call Zest's `collateral-remove-redeem` directly with a specific `amount` instead of letting this skill use `max-uint128`. + +## What you must NEVER do + +- Broadcast any wind operation. The wind skill at https://github.com/BitflowFinance/bff-skills/pull/604 owns that path. +- Bypass the cooldown by re-broadcasting an unstake that already has a pending silo claim. The silo claim from the first unstake still exists; just wait for it. +- Hand-edit the checkpoint file unless explicitly recovering from `CLAIM_ID_INDETERMINATE`. The checkpoint is the controller's truth; arbitrary edits create inconsistent state. +- Set `--cooldown-grace-seconds 0`. Miner-time skew means the on-chain `unlock-ts` check can fire microseconds after the wall-clock matches. The default 300s margin is cheap insurance. + +## Output contract + +Every subcommand prints exactly one JSON object: `{ status, action, data, error }`. The agent should treat `status: "success"` as advance, `status: "blocked"` as wait-and-retry per `error.next`, and `status: "error"` as halt-and-surface-to-operator. + +## Companion + +Wind path: https://github.com/BitflowFinance/bff-skills/pull/604 diff --git a/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/SKILL.md b/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/SKILL.md new file mode 100644 index 00000000..a06d0def --- /dev/null +++ b/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/SKILL.md @@ -0,0 +1,172 @@ +--- +name: unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC +description: "Unwind-only companion to the wind-leg yield rotator: unstakes sUSDh on Hermetica (creates a 7-day silo claim), waits the cooldown, withdraws USDh from the silo, swaps USDh->USDCx via Bitflow, repays the USDCx debt on Zest, and withdraws the sBTC collateral. Closes the loop opened by https://github.com/BitflowFinance/bff-skills/pull/604." +metadata: + author: "Terese678" + author-agent: "Merged Vale" + user-invocable: "false" + arguments: "doctor | status | plan | run | resume | cancel" + entry: "unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC.ts" + requires: "wallet, signing, settings, bitflow-swap-aggregator, nonce-manager, zest-borrow-asset-primitive" + tags: "defi, write, mainnet-only, requires-funds, l2" +--- + +# Unwind-Leg: HermeticaUnstake → ZestRepay Yield Unwinder + +## Scope + +Unwind-only. Five legs in order, with a built-in 7-day pause between legs 1 and 2: + +1. **Unstake sUSDh on Hermetica** — inline `staking-v1-1.unstake(amount)`. Burns sUSDh from the wallet, creates a claim in `staking-silo-v1-1` with a 7-day cooldown. +2. **Wait cooldown** — the silo's `unlock-ts` for the new claim. Default 7 days (`staking-state-v1.get-cooldown-window` = `u604800`). `resume` blocks with `COOLDOWN_NOT_EXPIRED` until elapsed. +3. **Silo withdraw** — inline `staking-silo-v1-1.withdraw(claim-id)`. Releases USDh from the silo to the wallet. +4. **Swap USDh → USDCx** — inline `dlmm-swap-router-v-1-2.swap-x-for-y-simple-range-multi(pool, USDh, USDCx, x-amount, min-dy, max-steps, deadline)` against the verified USDh/USDCx 1-bps DLMM pool (`dlmm-pool-usdh-usdcx-v-1-bps-1`). `min-dy` is derived from Bitflow's `/quote` endpoint pinned to `--slippage-bps` and enforced on chain. Direct contract call; no external skill dependency. +5. **Repay USDCx debt on Zest** — inline `v0-4-market.repay(ft, amount, (some wallet))`. Clears the borrow position opened by the wind skill. +6. **Withdraw sBTC collateral** — inline `v0-4-market.collateral-remove-redeem(ft, max-uint128, min-sbtc-sats, (some wallet), none)`. Releases the sBTC collateral back to the wallet. The redeem-amount sentinel `max-uint128` instructs the contract to redeem all available collateral; `min-sbtc-sats` enforces a slippage floor. + +All five legs (plus the pre-leg-5 residual-debt readonly via `v0-market-vault.get-account-scaled-debt`) are direct Clarity contract calls constructed inline in this skill — no bundled skill dependencies, no subprocess dispatch. The forward path (wind) is the companion `windleg` skill's job at https://github.com/BitflowFinance/bff-skills/pull/604. This skill never broadcasts wind operations. + +## Asset journey + +| Step | Wallet receives | Wallet sends | On-chain effect | +|---|---|---|---| +| Unstake | (silo claim) | sUSDh | sUSDh burned via `staking-v1-1.unstake`; silo creates a claim record with `amount-usdh = amount * ratio / usdh-base` and `unlock-ts = now + cooldown-window`. | +| Cooldown | — | — | 7-day wait (default `staking-state-v1.cooldown-window`). | +| Silo withdraw | USDh | (silo claim) | `staking-silo-v1-1.withdraw(claim-id)` transfers the claim's USDh amount from the silo's USDh reserve to the wallet. | +| Swap | USDCx (or borrow asset) | USDh | Bitflow aggregator routes through the best USDh venue at quote time. | +| Repay | (Zest debt cleared) | USDCx | `v0-4-market.repay(usdcx-token, amount, (some wallet))` reduces the wallet's debt position. | +| Withdraw | sBTC | (Zest collateral) | `v0-4-market.collateral-remove-redeem(sbtc-token, max-uint128, min-sats, (some wallet), none)` releases sBTC from the market vault to the wallet. | + +The skill name spells the unwind direction: **sUSDh** (in) → **USDCx** (debt cleared) → **sBTC** (out). + +## What it does + +Drives the five-leg unwind under operator control. The skill is intentionally simpler than the wind skill — there is no strategy-score gate, because the user is exiting a position, not entering one. The cooldown is the only structural pause; otherwise the legs broadcast in sequence. + +State machine: + +``` +idle + └─run→ unstake_confirmed + └─resume after cooldown→ claim_confirmed + └─→ swap_confirmed + └─→ repay_confirmed + └─→ complete (= sBTC in wallet, debt zero) +``` + +Every state transition is persisted to a checkpoint at `~/.aibtc/state/unwindleg-.../`. Resume picks up from whichever step the prior run left off at. + +## Why agents need it + +The wind skill creates a position that **cannot be unwound on short notice** because of the Hermetica 7-day silo cooldown. Without this companion skill, every wind broadcast strands the operator manually managing the silo claim, swap, repay, and withdraw legs across multiple sessions. With it, the operator runs `run` once to start the unwind, waits the cooldown (the skill blocks `resume` until `unlock-ts + --cooldown-grace-seconds`), and runs `resume` to broadcast the remaining four legs as a single atomic-from-the-operator's-POV sequence. + +## Verified contracts (from canonical sources, not peer skills) + +| Identifier | Source of verification | +|---|---| +| `SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG.staking-v1-1.unstake(uint)` | Hiro `/v2/contracts/source` — `(define-public (unstake (amount uint)))`. Single arg (the sister function `stake` takes `(amount, affiliate)`; unstake does not). Calls `staking-silo-v1-1.create-claim` internally and burns sUSDh. | +| `SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG.staking-silo-v1-1.withdraw(uint)` | Hiro `/v2/contracts/source` — `(define-public (withdraw (claim-id uint)))`. Transfers the claim's USDh from silo reserve to the recipient after the claim's `ts` value is reached. | +| `SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG.staking-silo-v1-1.get-current-claim-id` (read-only) | Used to snapshot the silo's claim counter before/after `unstake` to deterministically identify the new claim id without parsing contract events. | +| `SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG.staking-silo-v1-1.get-claim(uint)` (read-only) | Returns the claim record `{ recipient: principal, amount: uint, ts: uint }`. The `ts` field is the cooldown expiry (Stacks block time, via `get-stacks-block-info? time`) and drives the `COOLDOWN_NOT_EXPIRED` gate in `resume`. | +| `SP1A27KFY4XERQCCRCARCYD1CC5N7M6688BSYADJ7.v0-4-market.repay(, uint, optional principal)` | Hiro `/v2/contracts/source` — verified the function exists and matches the wind skill's borrow leg endpoint. | +| `SP1A27KFY4XERQCCRCARCYD1CC5N7M6688BSYADJ7.v0-4-market.collateral-remove-redeem(, uint, uint, optional principal, optional (list 3 (buff 8192)))` | Hiro `/v2/contracts/source` — the user-facing collateral withdraw path; the 4th arg is the receiver, the 5th is optional Pyth price feeds. | +| `SPN5AK…HSG.usdh-token-v1` (decimals: 8, asset name: `usdh`) | Hiro Clarity source + Bitflow `/v1/tokens` registry. | +| `SPN5AK…HSG.susdh-token-v1` (decimals: 8, asset name: `susdh`) | Hiro Clarity source. Burned by `unstake`. | +| Borrow + collateral asset contracts | Resolved at runtime from the Bitflow `/api/quotes/v1/tokens` registry against `--borrow-asset` and `--collateral-asset`. Defaults: `USDCx` and `sBTC`. | + +## Inline broadcasts + +This skill broadcasts four of its five legs inline (only the swap leg uses an existing primitive). The inline broadcasts adapt to both `@stacks/transactions` v6 and v7 at runtime — they pick `Pc.principal(...).willSendEq(...).ft(...)` when the v7 `Pc` builder is exported, fall back to `makeStandardFungiblePostCondition` + `FungibleConditionCode` + `createAssetInfo` for v6. `AnchorMode.Any` is passed only when the enum is exported (v7 omits it). `STACKS_MAINNET` constant is preferred over `new StacksMainnet()`. `broadcastTransaction` tries the v7+ object-arg shape first, falls back positional. + +Post-conditions are deny-by-default + explicit allowances per leg: + +| Leg | Post-condition | +|---|---| +| Unstake | wallet sends exactly `--susdh-amount-base` sUSDh. | +| Silo withdraw | silo contract sends exactly `claim.amount` USDh to the wallet. | +| Repay | wallet sends at most `observedUsdcxBase` of the borrow asset (Zest may take less if debt is smaller). | +| Withdraw | market vault sends at least `--min-sbtc-withdraw-sats` of the collateral asset to the wallet. | + +## Signer (matches bff-skills primitives) + +Same resolver chain used by `bitflow-swap-aggregator` and the wind skill — a wallet that signs the wind legs signs the unwind legs with no extra configuration. Each path is verified against `--wallet` and rejected on mismatch: + +1. **`AIBTC_SESSION_FILE`** — encrypted session at `~/.aibtc/sessions/.json` (written by `bun run wallet/wallet.ts unlock`). Decrypted with AES-256-GCM via the 32-byte key at `~/.aibtc/sessions/.session-key`. Active wallet id read from `AIBTC_WALLET_ID` env or `~/.aibtc/config.json#activeWalletId`. +2. **`STACKS_PRIVATE_KEY`** — raw hex key in env. Derivation uses `@stacks/transactions.getAddressFromPrivateKey(key, "mainnet")`. +3. **`CLIENT_MNEMONIC`** — 12/24-word mnemonic in env. Derivation uses `@stacks/wallet-sdk.generateWallet({ secretKey, password: "" })`. + +Returns `SIGNER_UNAVAILABLE` with the per-path attempt list if no path matches `--wallet`. + +## Safety notes + +- **Write skill. Burns sUSDh, repays Zest debt, and withdraws collateral.** +- **Cooldown is non-negotiable.** The on-chain `staking-silo-v1-1.withdraw` reverts if `unlock-ts` hasn't been reached. The skill enforces this off-chain too via `COOLDOWN_NOT_EXPIRED` so the operator doesn't burn fees on a doomed broadcast. Use `--cooldown-grace-seconds` (default 300) to keep a small margin past `unlock-ts` for miner-time skew. +- Explicit `--confirm=UNWIND` required for both `run` and `resume`. No defaults. +- Cancel does NOT cancel the silo claim. If the operator runs `cancel` after `unstake_confirmed`, the on-chain claim still exists and can be withdrawn via `staking-silo-v1-1.withdraw(claim-id)` directly — the local checkpoint is cleared, the chain state isn't. +- `--min-sbtc-withdraw-sats` (default 1) is the post-condition floor on the collateral withdraw. Operators with a known expected payout should set this to ~95% of expected to catch unexpected slippage in the market accounting. +- The swap leg goes through `bitflow-swap-aggregator`'s slippage gate (`--slippage-bps`, default 150). USDh-stable routes are tight; 150 bps is a safe default. +- No HODLMM LP-destination integration. The swap leg routes through whichever Bitflow USDh venue the aggregator selects. + +## Commands + +### doctor +```bash +bun run skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC.ts doctor --wallet +``` + +### status +```bash +bun run .../unwindleg-...ts status --wallet +``` +Returns the current checkpoint plus, if `claimId` is recorded, the silo claim's `unlock-ts` + seconds remaining until cooldown elapses. + +### plan +```bash +bun run .../unwindleg-...ts plan --wallet --susdh-amount-base +``` + +### run +```bash +bun run .../unwindleg-...ts run --wallet --susdh-amount-base --min-sbtc-withdraw-sats --confirm=UNWIND +``` + +`run` broadcasts the unstake leg, then attempts to advance through legs 2-5. If the cooldown hasn't elapsed (almost always the case on a freshly-broadcast unstake), it returns with the checkpoint at `unstake_confirmed` and the cooldown's expiry timestamp. The operator schedules `resume` for after the cooldown. + +### resume +```bash +bun run .../unwindleg-...ts resume --wallet --confirm=UNWIND +``` + +Picks up wherever the prior run halted. Blocks with `COOLDOWN_NOT_EXPIRED` (including `secondsRemaining` in the error data) if the silo cooldown isn't over. Once past the cooldown, broadcasts silo-withdraw → swap → repay → withdraw in sequence; if any leg fails, the checkpoint records the partial state and the next `resume` picks up from there. + +### cancel +```bash +bun run .../unwindleg-...ts cancel --wallet +``` + +## Output contract + +All commands print exactly one JSON object to stdout: + +```json +{ "status": "success | blocked | error", "action": "...", "data": {}, "error": null } +``` + +`error.message` is reachable as the registry minimum `{ "error": "" }` shape when unwrapped one level. + +## Known constraints + +- Mainnet only. +- sUSDh → USDCx → sBTC path only. +- Borrow asset defaults to `USDCx`; collateral asset defaults to `sBTC`. Both can be overridden via `--borrow-asset` and `--collateral-asset` for symmetry with the wind skill. +- The Hermetica cooldown is configurable per-principal by Hermetica admins (`staking-state-v1.set-custom-cooldown`). The skill reads the actual `unlock-ts` from the silo claim, so any per-principal override is honored automatically. +- `cancel` does not affect the on-chain silo claim. To recover sUSDh from a claim that has been cancelled at the controller level, the operator calls `staking-silo-v1-1.withdraw(claim-id)` directly via any Stacks tool after the cooldown. +- The collateral-withdraw leg uses `max-uint128` as the redeem-amount sentinel. If the operator wants partial collateral withdraw, they should call the Zest market directly with a specific `amount` value instead of using this skill's `resume` for the final leg. + +## Companion skill + +Wind path (`sBTC → supply Zest → borrow USDCx → swap to USDh → stake Hermetica`): https://github.com/BitflowFinance/bff-skills/pull/604 + +## HODLMM integration declaration + +**No.** The swap leg uses the Bitflow aggregator, which selects between `BITFLOW_STABLE_XY_4` (stableswap) and `dlmm_8` (HODLMM DLMM) based on quote. Even when it routes through `dlmm_8`, the skill does not LP into HODLMM as a destination — it consumes the venue as a router. Per the bonus criterion ("skills that directly integrate HODLMM"), the qualifying integration is LP/destination, not swap-venue routing. diff --git a/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC.ts b/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC.ts new file mode 100644 index 00000000..818f07aa --- /dev/null +++ b/skills/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC/unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC.ts @@ -0,0 +1,1627 @@ +#!/usr/bin/env bun + +import { Command } from "commander"; +import * as crypto from "crypto"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +// Internal data passes nested bigint-bearing structures through this type; +// `stringify` converts bigints at the output edge before serialization. +type Json = unknown; +type JsonMap = { [key: string]: Json }; +type Status = "success" | "blocked" | "error"; + +// State machine for the 5-leg unwind. The Hermetica cooldown (7 days default) +// sits between `unstake_confirmed` and the rest of the legs — resume across +// that gap is the central design constraint of this skill. +type Step = + | "idle" + | "unstake_broadcast" // unstake tx in mempool, txid recorded, confirmation + claim-id snapshot pending + | "unstake_confirmed" // unstake mined, claim-id captured, waiting for cooldown + | "claim_confirmed" // silo withdraw broadcast, wallet now holds USDh + | "swap_confirmed" // USDh -> USDCx (or USDC) via Bitflow aggregator + | "repay_confirmed" // USDCx repaid to Zest, debt cleared + | "complete" // sBTC collateral withdrawn, position fully closed + | "operator_cancelled"; + +interface Checkpoint { + version: number; + cycleId: string; + wallet: string; + step: Step; + requestedSusdhAmountBase: string; + borrowAssetSymbol: string; + createdAt: string; + updatedAt: string; + unstakeTxid?: string; + claimId?: string; + cooldownExpiresAt?: string; + claimTxid?: string; + observedUsdhBase?: string; + swapTxid?: string; + observedUsdcxBase?: string; + repayTxid?: string; + withdrawTxid?: string; + observedSbtcSats?: string; + abortReason?: string; + nextRequiredAction?: string; +} + +interface SharedOptions { + wallet?: string; + susdhAmountBase?: string; + borrowAsset?: string; + collateralAsset?: string; + slippageBps?: string; + minGasReserveUstx?: string; + mempoolDepthLimit?: string; + waitSeconds?: string; + minSbtcWithdrawSats?: string; + cooldownGraceSeconds?: string; + // SIP-010 asset name overrides. Default map covers sBTC / USDCx / USDh / + // sUSDh; required when the operator picks a non-default --borrow-asset or + // --collateral-asset whose asset name isn't in the static map. + borrowAssetName?: string; + collateralAssetName?: string; +} + +interface RunOptions extends SharedOptions { confirm?: string; } + +// ----- Constants ----- + +const CONFIRM_TOKEN_UNWIND = "UNWIND"; +const CONFIRM_TOKEN_SWAP = "SWAP"; +const DEFAULT_BORROW_ASSET = "USDCx"; +const DEFAULT_COLLATERAL_ASSET = "sBTC"; +const DEFAULT_SLIPPAGE_BPS = "150"; +const DEFAULT_MIN_GAS_RESERVE_USTX = "500000"; +const DEFAULT_MEMPOOL_DEPTH_LIMIT = "0"; +const DEFAULT_WAIT_SECONDS = "240"; +const DEFAULT_MIN_SBTC_WITHDRAW_SATS = "1"; +const DEFAULT_COOLDOWN_GRACE_SECONDS = "300"; // require 5 min margin past unlock-ts to absorb miner-time skew +const HTTP_TIMEOUT_MS = 5000; +// Dedicated longer timeout for registry endpoints (Bitflow `/tokens`). The +// quotes registry response is large (~100+ tokens) and intermittently slow; +// 5s was tripping the catch-and-return-empty path during normal operation +// and producing null token resolutions downstream. Mirrors the wind skill +// post-fix at PR #604. +const HTTP_TIMEOUT_REGISTRY_MS = 20000; + +// Hermetica contracts (verified against on-chain Clarity bytecode via +// Hiro /v2/contracts/source). Same deployer as the wind skill — addresses are +// stable across both directions of the loop. +const HERMETICA_DEPLOYER = "SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG"; +const HERMETICA_STAKING_CONTRACT = "staking-v1-1"; +const HERMETICA_SILO_CONTRACT = "staking-silo-v1-1"; +const HERMETICA_STATE_CONTRACT = "staking-state-v1"; +const HERMETICA_USDH_TOKEN = "usdh-token-v1"; +const HERMETICA_SUSDH_TOKEN = "susdh-token-v1"; + +// Zest V2 market — pinned to the canonical v0-4 market vault. Verified +// against deployed Clarity bytecode via Hiro /v2/contracts/source. +const ZEST_MARKET_DEPLOYER = "SP1A27KFY4XERQCCRCARCYD1CC5N7M6688BSYADJ7"; +const ZEST_MARKET_CONTRACT = "v0-4-market"; + +const HIRO_API_BASE = "https://api.hiro.so"; +const BITFLOW_QUOTES_BASE = "https://bff.bitflowapis.finance/api/quotes/v1"; + +// Bitflow DLMM swap router + USDh/USDCx pool. Verified live via Bitflow +// /quote against deployed Clarity bytecode on Hiro /v2/contracts/source. +// Leg 3 (USDh -> USDCx) calls the router directly — no external skill +// dependency. Pool is DLMM 1 bps, single-bin at current state; quote engine +// confirms route_path stays USDh -> USDCx with no intermediate hops. +const BITFLOW_ROUTER_DEPLOYER = "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD"; +const BITFLOW_ROUTER_CONTRACT = "dlmm-swap-router-v-1-2"; +const BITFLOW_USDH_USDCX_POOL_DEPLOYER = BITFLOW_ROUTER_DEPLOYER; +const BITFLOW_USDH_USDCX_POOL_CONTRACT = "dlmm-pool-usdh-usdcx-v-1-bps-1"; +const USDH_TOKEN_DEPLOYER = HERMETICA_DEPLOYER; // same deployer as the staking contracts +const USDH_TOKEN_CONTRACT = HERMETICA_USDH_TOKEN; // "usdh-token-v1" +const USDH_ASSET_NAME = "usdh"; +const USDCX_TOKEN_DEPLOYER = "SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE"; +const USDCX_TOKEN_CONTRACT = "usdcx"; +const USDCX_ASSET_NAME = "usdcx-token"; +const BITFLOW_DLMM_MAX_STEPS = 230; + +// Zest V2 inner vault — exposes the scaled-debt readonly used by the +// pre-leg-5 safety gate. The market-level contract `v0-4-market` (Leg 4 + +// Leg 5 entrypoint) does not expose user debt directly; the vault does. +// Asset-id mapping verified against `v0-4-market` source: u6 = USDCx. +const ZEST_MARKET_VAULT_DEPLOYER = ZEST_MARKET_DEPLOYER; +const ZEST_MARKET_VAULT_CONTRACT = "v0-market-vault"; +const ZEST_USDCX_ASSET_ID = "u6"; +// sBTC collateral asset id in Zest's v0-market-vault asset table. Used by +// fetchZestCollateralAmount() to look up the wallet's actual recorded +// collateral before redeem — the contract does NOT clamp, see comment +// on inlineZestWithdraw caller. +const ZEST_SBTC_ASSET_ID = "u3"; + +// Zest sBTC collateral vault — the `ft` arg passed to +// `collateral-remove-redeem` must be the per-asset *vault wrapper*, not the +// underlying SIP-010. Routing the underlying causes Zest to return without +// transferring (empirically `(err u400009)` at +// https://explorer.hiro.so/txid/5a13b62290071b700713be9f181ecfc2929070e2bde166f5dd4fcf761674d411?chain=mainnet); +// the PC then aborts because the market sends 0 of the underlying instead of +// >= min-underlying. Bug class shared with PR #588 (which uses the same +// vault principal for sBTC). +const ZEST_VAULT_DEPLOYER = "SP1A27KFY4XERQCCRCARCYD1CC5N7M6688BSYADJ7"; +const ZEST_VAULT_SBTC_CONTRACT = "v0-vault-sbtc"; + +// ----- Error envelope ----- + +class BlockedError extends Error { + constructor(public code: string, message: string, public next: string, public data: JsonMap = {}) { super(message); } +} + +function stringify(value: unknown): Json { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return value.map(stringify); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, stringify(v)])) as JsonMap; + if (value === undefined) return null; + return value as Json; +} +function output(status: Status, action: string, data: JsonMap, error: JsonMap | null): void { + console.log(JSON.stringify({ status, action, data: stringify(data), error: stringify(error) }, null, 2)); +} +function success(action: string, data: JsonMap): void { output("success", action, data, null); } +// Surface fetcher failures to stderr so an outage in Hiro / Bitflow doesn't +// silently degrade to a null cascade with no operator-visible root cause. +// JSON output contract on stdout is unaffected (stderr is separate). +// Mirrors the wind skill's pattern at PR #604. +function logFetchFailure(fnName: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`[unwindleg] ${fnName} failed: ${message}`); +} +function blocked(action: string, code: string, message: string, next: string, data: JsonMap = {}): void { output("blocked", action, data, { code, message, next }); } +function fail(action: string, error: unknown): void { + if (error instanceof BlockedError) { blocked(action, error.code, error.message, error.next, error.data); return; } + const message = error instanceof Error ? error.message : String(error); + output("error", action, {}, { code: "ERROR", message, next: "Run doctor and inspect the failing dependency before retrying." }); + process.exitCode = 1; +} + +// ----- File / state helpers ----- + +async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; } catch { return false; } } +function repoRoot(): string { return process.env.AIBTC_SKILLS_ROOT || process.cwd(); } +function stateDir(): string { return path.join(os.homedir(), ".aibtc", "state", "unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC"); } +function safeWalletKey(wallet: string): string { return wallet.replace(/[^A-Za-z0-9_.-]/g, "_"); } +function checkpointPath(wallet: string): string { return path.join(stateDir(), `${safeWalletKey(wallet)}.json`); } + +async function readCheckpoint(wallet: string): Promise { + try { + const parsed = JSON.parse(await fs.readFile(checkpointPath(wallet), "utf8")) as Partial; + if (parsed.version !== 1 || parsed.wallet !== wallet || typeof parsed.step !== "string") return null; + return parsed as Checkpoint; + } catch { return null; } +} +async function writeCheckpoint(checkpoint: Checkpoint): Promise { + await fs.mkdir(stateDir(), { recursive: true }); + const updated = { ...checkpoint, updatedAt: new Date().toISOString() }; + await fs.writeFile(checkpointPath(checkpoint.wallet), `${JSON.stringify(updated, null, 2)}\n`, "utf8"); + return updated; +} +function newCheckpoint(wallet: string, susdhAmountBase: string, borrowAssetSymbol: string): Checkpoint { + const now = new Date().toISOString(); + return { + version: 1, + cycleId: `unwind-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`, + wallet, + step: "idle", + requestedSusdhAmountBase: susdhAmountBase, + borrowAssetSymbol, + createdAt: now, + updatedAt: now, + }; +} +function isUnresolved(checkpoint: Checkpoint | null): boolean { + if (!checkpoint) return false; + return !["complete", "operator_cancelled"].includes(checkpoint.step); +} + +// ----- Input validation ----- + +function ensureWallet(wallet?: string): string { if (!wallet) throw new Error("--wallet is required"); return wallet; } +function ensureSusdhAmount(amount?: string): string { + if (!amount || !/^\d+$/.test(amount) || BigInt(amount) <= 0n) throw new Error("--susdh-amount-base is required and must be a positive integer in 8-decimal sUSDh base units"); + return amount; +} +function parseNonNegativeInt(value: string | undefined, defaultStr: string, name: string): number { + const n = Number(value ?? defaultStr); + if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) throw new Error(`${name} must be a non-negative integer; received ${value ?? defaultStr}`); + return n; +} + +// All operations in this skill are direct Clarity contract calls. The skill +// has zero external skill dependencies — every broadcast is constructed +// in-line in this file. The earlier composed-controller scaffolding (primitive +// resolution + subprocess dispatch) was removed once the Bitflow swap and the +// Zest residual-debt readonly were inlined as direct calls. + +// ========================================================================= +// === Hiro reads + Clarity decoding +// ========================================================================= + +async function httpJson(url: string, init?: RequestInit, timeoutMs: number = HTTP_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`); + return await res.json(); + } finally { clearTimeout(timeout); } +} + +// Poll Hiro /extended/v1/tx/{id} until the tx settles (success or terminal +// failure) or the wait window elapses. Used between back-to-back inline +// broadcasts in continueUnwind so each leg's nonce is fully consumed (mined) +// before the next leg fetches its own nonce from the API — avoids +// ConflictingNonceInMempool rejections when the controller fires multiple +// txs from the same wallet in a single resume call. +async function waitForTxConfirmation(txid: string, waitSeconds: number): Promise<{ status: string; raw: JsonMap | null }> { + const deadline = Date.now() + waitSeconds * 1000; + let lastStatus = "not_indexed"; + let lastRaw: JsonMap | null = null; + while (Date.now() < deadline) { + try { + const res = await fetch(`${HIRO_API_BASE}/extended/v1/tx/${txid}`, { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); + if (res.ok) { + const json = await res.json() as JsonMap; + lastRaw = json; + lastStatus = String(json.tx_status ?? "not_indexed"); + if (lastStatus === "success") return { status: lastStatus, raw: lastRaw }; + if (lastStatus.startsWith("abort") || lastStatus === "failed") return { status: lastStatus, raw: lastRaw }; + } + } catch { /* transient — retry on the next iteration */ } + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + return { status: lastStatus, raw: lastRaw }; +} + +function requireTxSuccess(legName: string, txid: string, confirmation: { status: string; raw: JsonMap | null }): void { + if (confirmation.status === "success") return; + throw new BlockedError( + "TX_NOT_SUCCESSFUL", + `${legName} tx ${txid} did not confirm successfully within --wait-seconds: tx_status=${confirmation.status}.`, + `Inspect ${txid} on the explorer. If the tx is still pending, increase --wait-seconds and re-run resume. If it aborted, address the underlying revert before retrying.`, + { leg: legName, txid, status: confirmation.status, raw: confirmation.raw as Json }, + ); +} + +async function callReadHiro(contractAddress: string, contractName: string, fnName: string, args: string[] = [], sender?: string): Promise<{ result: string | null; raw: unknown }> { + try { + const url = `${HIRO_API_BASE}/v2/contracts/call-read/${contractAddress}/${contractName}/${fnName}`; + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sender: sender || contractAddress, arguments: args }), + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + }); + if (!res.ok) return { result: null, raw: null }; + const json = await res.json() as { result?: string }; + return { result: typeof json.result === "string" ? json.result : null, raw: json }; + } catch (error) { + logFetchFailure(`callReadHiro(${contractAddress}.${contractName}.${fnName})`, error); + return { result: null, raw: null }; + } +} + +// Decode a Clarity-serialized uint from Hiro's /v2/contracts/call-read `result` +// field. See windleg's decodeClarityUint — same logic, kept consistent across +// both halves of the loop. +function decodeClarityUint(hex: string | null): bigint | null { + if (!hex || typeof hex !== "string") return null; + let cleaned = hex.replace(/^0x/, "").toLowerCase(); + if (!/^[0-9a-f]+$/.test(cleaned)) return null; + if (cleaned.startsWith("07")) cleaned = cleaned.slice(2); // unwrap (ok ...) + if (cleaned.startsWith("08")) return null; // (err ...) -> null + if (!cleaned.startsWith("01")) return null; + cleaned = cleaned.slice(2); + if (cleaned.length !== 32) return null; + try { return BigInt(`0x${cleaned}`); } catch { return null; } +} + +// Decode a Clarity bool. Wire format (stacks-blockchain TypePrefix): +// BoolTrue = 0x03 +// BoolFalse = 0x04 +// Same orientation as windleg's checkHermeticaStakingEnabled — keep these in +// sync if either skill ever drifts. +function decodeClarityBool(hex: string | null): boolean | null { + if (!hex || typeof hex !== "string") return null; + if (hex === "0x03") return true; + if (hex === "0x04") return false; + return null; +} + +async function fetchHermeticaStakingEnabled(): Promise { + const r = await callReadHiro(HERMETICA_DEPLOYER, HERMETICA_STATE_CONTRACT, "get-staking-enabled"); + return decodeClarityBool(r.result); +} + +// Read the silo's current claim-id counter (zero-arg read-only). Used to +// snapshot the counter before/after unstake so we can deterministically +// identify which claim was minted by this unstake call. +async function fetchSiloCurrentClaimId(): Promise { + const r = await callReadHiro(HERMETICA_DEPLOYER, HERMETICA_SILO_CONTRACT, "get-current-claim-id"); + return decodeClarityUint(r.result); +} + +// Read the silo's claim record. The contract's `get-claim` returns +// `(ok { recipient: principal, amount: uint, ts: uint })` — verified +// against the deployed source via Hiro `/v2/contracts/source/.../staking-silo-v1-1`. +// Note: the contract field is `ts` (Stacks block time, set by +// `get-stacks-block-info? time` inside `create-claim`); we surface it as +// `unlockTs` on the JS side for clarity since that's what the field +// semantically represents to our callers. The on-chain `withdraw` reverts +// if `(get-current-ts) < ts`. +// +// Decode via `@stacks/transactions.cvToJSON` keyed on field name — the +// prior regex-based heuristic worked by accident because the tuple +// happened to contain exactly 2 uints in fixed positions; it would have +// silently broken if Hermetica added a uint field or changed key order. +async function fetchSiloClaim(claimId: bigint): Promise<{ unlockTs: bigint | null; amount: bigint | null; raw: string | null }> { + const r = await callReadHiro(HERMETICA_DEPLOYER, HERMETICA_SILO_CONTRACT, "get-claim", [`0x${"01" + claimId.toString(16).padStart(32, "0")}`]); + if (!r.result) return { unlockTs: null, amount: null, raw: null }; + try { + const stx = await import("@stacks/transactions") as { + hexToCV: (hex: string) => unknown; + cvToJSON: (cv: unknown) => unknown; + }; + const cv = stx.hexToCV(r.result); + const json = stx.cvToJSON(cv) as { type?: string; value?: unknown; success?: boolean }; + // Shape for (ok ): { type: "(response ...)", value: { type: "(tuple ...)", value: { recipient: {...}, amount: {type, value}, ts: {type, value} }, success: true }, success: true } + // For (err <...>) the outer `success` is false — surface as null. + const okWrapper = json?.value as { value?: unknown; success?: boolean } | undefined; + if (json?.success === false || okWrapper?.success === false) return { unlockTs: null, amount: null, raw: r.result }; + const tupleEnvelope = okWrapper?.value as Record | undefined; + if (!tupleEnvelope || typeof tupleEnvelope !== "object") return { unlockTs: null, amount: null, raw: r.result }; + const amountVal = tupleEnvelope.amount?.value; + const tsVal = tupleEnvelope.ts?.value; + return { + amount: typeof amountVal === "string" && /^\d+$/.test(amountVal) ? BigInt(amountVal) : null, + unlockTs: typeof tsVal === "string" && /^\d+$/.test(tsVal) ? BigInt(tsVal) : null, + raw: r.result, + }; + } catch (error) { + logFetchFailure(`fetchSiloClaim cvToJSON decode for claim ${claimId}`, error); + return { unlockTs: null, amount: null, raw: r.result }; + } +} + +interface TokenInfo { contract: string; symbol: string; decimals: number; } +async function fetchBitflowTokens(): Promise { + // Uses HTTP_TIMEOUT_REGISTRY_MS (20s) rather than the default 5s — the + // Bitflow `/tokens` registry response is ~100+ tokens and intermittently + // slow. Logs failures + 0-token responses to stderr so the operator sees + // the actual root cause rather than a downstream null cascade. + try { + const json = await httpJson(`${BITFLOW_QUOTES_BASE}/tokens`, undefined, HTTP_TIMEOUT_REGISTRY_MS); + const list = Array.isArray(json) ? json : (json && typeof json === "object" && Array.isArray((json as Record).tokens) ? (json as { tokens: unknown[] }).tokens : []); + const tokens: TokenInfo[] = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const obj = item as Record; + const contract = typeof obj.contract === "string" ? obj.contract : (typeof obj.contract_address === "string" ? obj.contract_address : (typeof obj.contractId === "string" ? obj.contractId : null)); + const symbol = typeof obj.symbol === "string" ? obj.symbol : (typeof obj.name === "string" ? obj.name : null); + const decimals = typeof obj.decimals === "number" ? obj.decimals : (typeof obj.decimal === "number" ? obj.decimal : null); + if (contract && symbol && decimals !== null) tokens.push({ contract, symbol, decimals }); + } + if (tokens.length === 0) { + console.error(`[unwindleg] Bitflow /tokens registry returned 0 valid tokens at ${BITFLOW_QUOTES_BASE}/tokens — registry may be malformed or empty.`); + } + return tokens; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[unwindleg] Bitflow /tokens registry fetch failed (timeout=${HTTP_TIMEOUT_REGISTRY_MS}ms): ${message}. Token resolution will return null and downstream callers may surface BORROW_TOKEN_UNRESOLVED — retry or raise --wait-seconds.`); + return []; + } +} +async function resolveTokenBySymbol(symbols: string[]): Promise> { + const tokens = await fetchBitflowTokens(); + const result: Record = {}; + for (const wanted of symbols) { + const lower = wanted.toLowerCase(); + const match = tokens.find((t) => t.symbol.toLowerCase() === lower) || tokens.find((t) => t.symbol.toLowerCase().includes(lower)); + result[wanted] = match || null; + } + return result; +} + +async function fetchWalletFtBalance(wallet: string, contractPrefix: string): Promise { + try { + const res = await fetch(`${HIRO_API_BASE}/extended/v1/address/${wallet}/balances`, { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); + if (!res.ok) return null; + const json = await res.json() as { fungible_tokens?: Record }; + const tokens = json.fungible_tokens || {}; + const prefix = `${contractPrefix}::`.toLowerCase(); + let total: bigint | null = null; + for (const [key, val] of Object.entries(tokens)) { + if (!key.toLowerCase().startsWith(prefix)) continue; + const balStr = val?.balance; + if (typeof balStr === "string" && /^\d+$/.test(balStr)) total = (total ?? 0n) + BigInt(balStr); + } + return total; + } catch { return null; } +} + +async function fetchWalletSusdhBalance(wallet: string): Promise { + return fetchWalletFtBalance(wallet, `${HERMETICA_DEPLOYER}.${HERMETICA_SUSDH_TOKEN}`); +} +async function fetchWalletUsdhBalance(wallet: string): Promise { + return fetchWalletFtBalance(wallet, `${HERMETICA_DEPLOYER}.${HERMETICA_USDH_TOKEN}`); +} + +// Count this wallet's pending txs in the Hiro mempool. Returns null on fetch +// failure (caller decides whether to gate or pass-through). Uses the +// /extended/v1/tx/mempool endpoint with a sender_address filter. +async function fetchMempoolDepth(wallet: string): Promise { + try { + const res = await fetch(`${HIRO_API_BASE}/extended/v1/tx/mempool?sender_address=${encodeURIComponent(wallet)}&limit=200`, { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); + if (!res.ok) return null; + const json = await res.json() as { total?: number; results?: unknown[] }; + if (typeof json.total === "number" && Number.isInteger(json.total) && json.total >= 0) return json.total; + if (Array.isArray(json.results)) return json.results.length; + return null; + } catch (error) { + logFetchFailure("fetchMempoolDepth", error); + return null; + } +} + +// Enforce the operator's mempool-depth limit before broadcasting an inline leg. +// `limitStr` of "0" or undefined disables the gate (current default; preserves +// pre-existing behavior). When set to a positive integer N, the gate refuses to +// broadcast if the wallet already has >= N pending txs in the Hiro mempool — +// avoids stacking onto a stuffed mempool where new txs may wait indefinitely or +// get evicted before mining. The between-leg waitForTxConfirmation already +// covers this-skill's own nonce serialization; this gate covers +// EXTERNAL-source pending txs from the same wallet (other processes / parallel +// sessions). On Hiro fetch failure the gate passes through with a stderr log — +// loud about the outage but does not block the operator's broadcast. +async function enforceMempoolDepthLimit(wallet: string, limitStr: string | undefined, legName: string): Promise { + const limit = parseInt(limitStr ?? "0", 10); + if (!Number.isFinite(limit) || limit <= 0) return; + const depth = await fetchMempoolDepth(wallet); + if (depth === null) { + console.error(`[unwindleg] mempool-depth gate could not read /extended/v1/tx/mempool for ${wallet} before ${legName}; passing through.`); + return; + } + if (depth >= limit) { + throw new BlockedError( + "MEMPOOL_DEPTH_EXCEEDED", + `Wallet has ${depth} pending tx(s) in the Hiro mempool; --mempool-depth-limit=${limit} blocks broadcasting ${legName}.`, + `Wait for the wallet's pending mempool to drain below ${limit}, then resume. To bypass the gate set --mempool-depth-limit=0.`, + { wallet, observedDepth: depth, limit, leg: legName }, + ); + } +} + +// ========================================================================= +// === Signer resolution — matches the bff-skills primitives chain +// ========================================================================= + +interface AibtcSessionFile { + version?: number; + expiresAt?: string; + encrypted?: { iv: string; authTag: string; ciphertext: string }; +} +function aibtcDir(...parts: string[]): string { + return path.join(os.homedir(), ".aibtc", ...parts); +} +async function decryptAibtcSession(walletId: string): Promise<{ privateKey: string; address: string } | null> { + try { + const sessionRaw = await fs.readFile(aibtcDir("sessions", `${path.basename(walletId)}.json`), "utf8"); + const session = JSON.parse(sessionRaw) as AibtcSessionFile; + if (session.version !== 1 || !session.encrypted) return null; + if (session.expiresAt && new Date(session.expiresAt) < new Date()) return null; + const sessionKey = await fs.readFile(aibtcDir("sessions", ".session-key")).catch(() => null); + if (!sessionKey || sessionKey.length !== 32) return null; + const decipher = crypto.createDecipheriv("aes-256-gcm", sessionKey, Buffer.from(session.encrypted.iv, "base64")); + decipher.setAuthTag(Buffer.from(session.encrypted.authTag, "base64")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(session.encrypted.ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as { privateKey?: string; address?: string }; + if (typeof parsed.privateKey === "string" && typeof parsed.address === "string") return { privateKey: parsed.privateKey, address: parsed.address }; + return null; + } catch { + return null; + } +} +async function deriveFromMnemonic(mnemonic: string): Promise { + const trimmed = mnemonic.trim(); + const wordCount = trimmed.split(/\s+/).length; + if (wordCount < 12 || wordCount > 24) return null; + try { + const sdk = await import("@stacks/wallet-sdk") as { + generateWallet: (opts: { secretKey: string; password: string }) => Promise<{ accounts: Array<{ stxPrivateKey?: string }> }>; + }; + const wallet = await sdk.generateWallet({ secretKey: trimmed, password: "" }); + return wallet.accounts[0]?.stxPrivateKey ?? null; + } catch { + return null; + } +} +async function resolveUnwindSigner(expectedWallet: string): Promise<{ privateKey: string; source: string }> { + const attempts: string[] = []; + + try { + const configRaw = await fs.readFile(aibtcDir("config.json"), "utf8").catch(() => null); + const config = configRaw ? JSON.parse(configRaw) as { activeWalletId?: string } : null; + const walletId = process.env.AIBTC_WALLET_ID || config?.activeWalletId; + if (walletId) { + const account = await decryptAibtcSession(walletId); + if (account) { + if (account.address === expectedWallet) return { privateKey: account.privateKey, source: "AIBTC_SESSION_FILE" }; + attempts.push(`AIBTC_SESSION_FILE: session resolves to ${account.address}, expected ${expectedWallet}`); + } else { + attempts.push("AIBTC_SESSION_FILE: no active unexpired session"); + } + } else { + attempts.push("AIBTC_SESSION_FILE: no active wallet id"); + } + } catch (error) { + attempts.push(`AIBTC_SESSION_FILE: ${error instanceof Error ? error.message : String(error)}`); + } + + const rawKey = process.env.STACKS_PRIVATE_KEY?.trim(); + if (rawKey) { + try { + const stx = await import("@stacks/transactions") as { getAddressFromPrivateKey: (key: string, net: "mainnet" | "testnet") => string }; + const address = stx.getAddressFromPrivateKey(rawKey, "mainnet"); + if (address === expectedWallet) return { privateKey: rawKey, source: "STACKS_PRIVATE_KEY" }; + attempts.push(`STACKS_PRIVATE_KEY: key resolves to ${address}, expected ${expectedWallet}`); + } catch (error) { + attempts.push(`STACKS_PRIVATE_KEY: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + attempts.push("STACKS_PRIVATE_KEY: not set"); + } + + const mnemonic = process.env.CLIENT_MNEMONIC; + if (mnemonic) { + const derived = await deriveFromMnemonic(mnemonic); + if (derived) { + try { + const stx = await import("@stacks/transactions") as { getAddressFromPrivateKey: (key: string, net: "mainnet" | "testnet") => string }; + const address = stx.getAddressFromPrivateKey(derived, "mainnet"); + if (address === expectedWallet) return { privateKey: derived, source: "CLIENT_MNEMONIC" }; + attempts.push(`CLIENT_MNEMONIC: derived key resolves to ${address}, expected ${expectedWallet}`); + } catch (error) { + attempts.push(`CLIENT_MNEMONIC: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + attempts.push("CLIENT_MNEMONIC: derivation failed (check word count + sdk install)"); + } + } else { + attempts.push("CLIENT_MNEMONIC: not set"); + } + + throw new BlockedError( + "SIGNER_UNAVAILABLE", + `No signer available for ${expectedWallet}. Attempts: ${attempts.join("; ")}`, + "Set AIBTC_SESSION_FILE (`wallet unlock`), STACKS_PRIVATE_KEY, or CLIENT_MNEMONIC to a value that resolves to the wallet that owns sUSDh and the Zest position." + ); +} + +// ========================================================================= +// === Inline broadcasts (4 of 5 legs) +// ========================================================================= + +interface BroadcastContext { + stx: Record; + mainnet: unknown; + signerKey: string; +} + +async function prepareBroadcastContext(wallet: string): Promise { + const signer = await resolveUnwindSigner(wallet); + const stx = await import("@stacks/transactions") as unknown as Record; + const network = await import("@stacks/network") as unknown as Record; + const mainnet = network.STACKS_MAINNET + ?? new (network.StacksMainnet as new () => unknown)(); + return { stx, mainnet, signerKey: signer.privateKey }; +} + +// Build a v6 or v7 post-condition depending on which API the installed +// @stacks/transactions exposes. v7+: Pc builder. v6: legacy triplet. +function buildFtPostCondition( + stx: Record, + wallet: string, + amountAtomic: bigint, + assetDeployer: string, + assetContract: string, + assetName: string, + conditionEq: "eq" | "lte" = "eq", +): unknown { + const assetIdentifier = `${assetDeployer}.${assetContract}` as `${string}.${string}`; + if (typeof stx.Pc === "object" && stx.Pc !== null) { + const pc = stx.Pc as { principal: (p: string) => { willSendEq: (a: string) => { ft: (id: `${string}.${string}`, name: string) => unknown }; willSendLte: (a: string) => { ft: (id: `${string}.${string}`, name: string) => unknown } } }; + const builder = pc.principal(wallet); + return conditionEq === "eq" + ? builder.willSendEq(amountAtomic.toString()).ft(assetIdentifier, assetName) + : builder.willSendLte(amountAtomic.toString()).ft(assetIdentifier, assetName); + } + if (typeof stx.makeStandardFungiblePostCondition === "function") { + const fcc = stx.FungibleConditionCode as Record | undefined; + const createAssetInfo = stx.createAssetInfo as (a: string, b: string, c: string) => unknown; + const make = stx.makeStandardFungiblePostCondition as (...args: unknown[]) => unknown; + const code = conditionEq === "eq" ? fcc?.Equal : fcc?.LessEqual; + if (!code || typeof createAssetInfo !== "function") { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "v6 post-condition helpers present but required enum value / createAssetInfo missing.", "Reinstall @stacks/transactions (v6.x or v7.x)."); + } + return make(wallet, code, amountAtomic.toString(), createAssetInfo(assetDeployer, assetContract, assetName)); + } + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "@stacks/transactions is missing both v6 and v7 post-condition builders.", "Reinstall @stacks/transactions (v6.x or v7.x)."); +} + +async function broadcastContractCall( + ctx: BroadcastContext, + params: { + contractAddress: string; + contractName: string; + functionName: string; + functionArgs: unknown[]; + postConditions: unknown[]; + // Per-call override. Defaults to Deny. Legs that emit multiple + // unpredictable token movements (e.g. Zest collateral-remove-redeem with + // vault burn + underlying transfer, DLMM pool internal USDh flows) pass + // "allow" so the listed PCs act as additive informational guards rather + // than exhaustive checks. See bug-class history at PR #588 (leg-5 Allow) + // and PR #604 cycle 3 (pool USDh outflow uncovered under Deny). + postConditionMode?: "deny" | "allow"; + }, +): Promise { + const { stx, mainnet, signerKey } = ctx; + const txParams: Record = { + contractAddress: params.contractAddress, + contractName: params.contractName, + functionName: params.functionName, + functionArgs: params.functionArgs, + senderKey: signerKey, + network: mainnet, + postConditionMode: stx.PostConditionMode, + postConditions: params.postConditions, + }; + // postConditionMode value: prefer stx.PostConditionMode.{Deny,Allow}. + const pcmKey = params.postConditionMode === "allow" ? "Allow" : "Deny"; + const pcm = (stx.PostConditionMode as Record | undefined)?.[pcmKey]; + if (pcm !== undefined) txParams.postConditionMode = pcm; + const anchorAny = (stx.AnchorMode as Record | undefined)?.Any; + if (anchorAny !== undefined) txParams.anchorMode = anchorAny; + + const tx = await (stx.makeContractCall as (p: unknown) => Promise)(txParams); + + let result: { error?: string; reason?: string; txid?: string }; + try { + result = await (stx.broadcastTransaction as (a: unknown, b?: unknown) => Promise)({ transaction: tx, network: mainnet } as unknown); + } catch (newSigFailed) { + try { + result = await (stx.broadcastTransaction as (a: unknown, b?: unknown) => Promise)(tx as unknown, mainnet as unknown); + } catch (oldSigFailed) { + throw new BlockedError( + "BROADCAST_FAILED", + `Both broadcastTransaction signatures failed: ${(newSigFailed as Error).message} / ${(oldSigFailed as Error).message}`, + "Verify the installed @stacks/transactions version matches the bff-skills convention.", + ); + } + } + if (result.error) { + throw new BlockedError("BROADCAST_FAILED", `Broadcast rejected: ${result.reason || result.error}`, "Inspect the broadcast error and retry only after resolving the underlying cause."); + } + if (typeof result.txid !== "string") { + throw new BlockedError("BROADCAST_NO_TXID", "Broadcast returned no txid.", "Inspect the broadcast result; the signer or network may be misconfigured."); + } + return result.txid; +} + +// Leg 1: Hermetica `staking-v1-1.unstake(amount, affiliate?)` — burns sUSDh, +// creates a claim in the silo, returns `(ok claim-id)` on chain but emits a +// `print` event with action: "unstake". We snapshot +// `silo.get-current-claim-id` before and after to deterministically identify +// the new claim id. +async function inlineUnstake(wallet: string, susdhAmountBase: bigint): Promise<{ txid: string; preClaimId: bigint | null }> { + if (susdhAmountBase <= 0n) throw new BlockedError("INVALID_UNSTAKE_AMOUNT", "Unstake amount must be positive.", "Inspect --susdh-amount-base."); + const balance = await fetchWalletSusdhBalance(wallet); + if (balance !== null && balance < susdhAmountBase) { + throw new BlockedError("INSUFFICIENT_SUSDH_BALANCE", `Wallet sUSDh balance ${balance.toString()} < amount ${susdhAmountBase.toString()}.`, "Reduce --susdh-amount-base or fund the wallet with more sUSDh."); + } + const preClaimId = await fetchSiloCurrentClaimId(); + const ctx = await prepareBroadcastContext(wallet); + + // unstake() burns sUSDh from the caller — post-condition: wallet sends + // exactly susdhAmountBase of sUSDh, no other movement allowed. This is the + // symmetric counterpart of `inlineStake` in the wind direction (PR #604): + // stake() takes USDh from wallet → mints sUSDh; unstake() takes sUSDh + // from wallet → creates a silo claim record. Both call paths burn/transfer + // ONLY the sender token, so both PC sets are single-PC sender-side eq. + // There is no pool/contract emission back to the caller on either side — + // a receive PC here would not correspond to any actual transfer event, + // would fail under Deny mode at PC construction time, and is what Diego's + // gap analysis at PR #605 issuecomment-4464541557 explicitly warns against. + const susdhPC = buildFtPostCondition(ctx.stx, wallet, susdhAmountBase, HERMETICA_DEPLOYER, HERMETICA_SUSDH_TOKEN, "susdh", "eq"); + + const stx = ctx.stx; + // ABI: `staking-v1-1.unstake` takes ONE arg (`amount uint`). Its sister + // function `stake(amount, affiliate optional)` takes two — easy to + // conflate. The chain rejects the unstake call with BadFunctionArgument + // pre-mempool if a second arg is passed. Verified against deployed source + // via Hiro `/v2/contracts/source/SPN5AK…HSG/staking-v1-1`. + const txid = await broadcastContractCall(ctx, { + contractAddress: HERMETICA_DEPLOYER, + contractName: HERMETICA_STAKING_CONTRACT, + functionName: "unstake", + functionArgs: [ + (stx.uintCV as (v: string) => unknown)(susdhAmountBase.toString()), + ], + postConditions: [susdhPC], + }); + return { txid, preClaimId }; +} + +// Leg 2: Hermetica `staking-silo-v1-1.withdraw(claim-id)` after cooldown — +// releases USDh from the silo to the wallet. Reverts if `unlock-ts` hasn't +// been reached. +async function inlineSiloWithdraw(wallet: string, claimId: bigint, expectedUsdhBase: bigint): Promise { + const ctx = await prepareBroadcastContext(wallet); + // Post-condition: silo sends exactly expectedUsdhBase of USDh. We use the + // silo as the principal in the post-condition because the silo holds the + // USDh in reserve and transfers it to the user. + const siloPrincipal = `${HERMETICA_DEPLOYER}.${HERMETICA_SILO_CONTRACT}`; + // The post-condition is contract-principal-based, so we use the contract + // builder (not standard). v6: makeContractFungiblePostCondition. v7: Pc + // with .contract(...). Build adaptively. + const stx = ctx.stx; + // 1% floor on silo's USDh outflow — `willSendGte(99% of expected)` rather + // than `willSendEq` to tolerate accrued-interest micro-drift between + // claim creation and withdraw. Pair with Allow mode so any internal silo + // accounting movements don't abort. + const siloMinUsdh = (expectedUsdhBase * 99n) / 100n; + let siloPC: unknown; + if (typeof stx.Pc === "object" && stx.Pc !== null) { + const pc = stx.Pc as { principal: (p: string) => { willSendGte: (a: string) => { ft: (id: `${string}.${string}`, name: string) => unknown } } }; + siloPC = pc.principal(siloPrincipal).willSendGte(siloMinUsdh.toString()).ft(`${HERMETICA_DEPLOYER}.${HERMETICA_USDH_TOKEN}` as `${string}.${string}`, "usdh"); + } else if (typeof stx.makeContractFungiblePostCondition === "function") { + const fcc = stx.FungibleConditionCode as Record | undefined; + const createAssetInfo = stx.createAssetInfo as (a: string, b: string, c: string) => unknown; + const make = stx.makeContractFungiblePostCondition as (...args: unknown[]) => unknown; + if (!fcc?.GreaterEqual || typeof createAssetInfo !== "function") { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "v6 helpers missing FungibleConditionCode.GreaterEqual / createAssetInfo.", "Reinstall @stacks/transactions."); + } + siloPC = make(HERMETICA_DEPLOYER, HERMETICA_SILO_CONTRACT, fcc.GreaterEqual, siloMinUsdh.toString(), createAssetInfo(HERMETICA_DEPLOYER, HERMETICA_USDH_TOKEN, "usdh")); + } else { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "Neither v6 makeContractFungiblePostCondition nor v7 Pc builder is available.", "Reinstall @stacks/transactions."); + } + + return broadcastContractCall(ctx, { + contractAddress: HERMETICA_DEPLOYER, + contractName: HERMETICA_SILO_CONTRACT, + functionName: "withdraw", + functionArgs: [ + (stx.uintCV as (v: string) => unknown)(claimId.toString()), + ], + postConditions: [siloPC], + postConditionMode: "allow", + }); +} + +// Leg 3: Bitflow DLMM `dlmm-swap-router-v-1-2.swap-x-for-y-simple-range-multi +// (pool, x-token, y-token, x-amount, min-dy, max-steps, deadline)` — swaps +// USDh held by the wallet (just released from the silo in Leg 2) into USDCx +// for the Zest repay. Zero external skill calls; the router call is built +// in-line here. The pool is the USDh/USDCx 1-bps DLMM verified against +// `/quote` (route_path stays USDh -> USDCx with no intermediate hops). +// +// Post-conditions in Deny mode: +// - sender sends EXACTLY usdhBase of USDh (asset `usdh`) +// - sender receives >= minUsdcxOut of USDCx (asset `usdcx-token`) +async function inlineBitflowSwap(wallet: string, usdhBase: bigint, minUsdcxOut: bigint): Promise { + if (usdhBase <= 0n) throw new BlockedError("INVALID_SWAP_AMOUNT", "USDh swap amount must be positive.", "Inspect observedUsdhBase."); + if (minUsdcxOut <= 0n) throw new BlockedError("INVALID_MIN_OUT", "minUsdcxOut must be positive.", "Re-fetch the Bitflow quote."); + + const ctx = await prepareBroadcastContext(wallet); + const stx = ctx.stx; + + // Sender PC: wallet sends EXACTLY usdhBase of USDh (standard principal, eq). + const senderPC = buildFtPostCondition(stx, wallet, usdhBase, USDH_TOKEN_DEPLOYER, USDH_TOKEN_CONTRACT, USDH_ASSET_NAME, "eq"); + + // Receiver PC: wallet receives >= minUsdcxOut of USDCx. buildFtPostCondition + // only handles "eq"/"lte"; inline the v6/v7 willSendGte adapter for the + // standard-principal receiver. Bounds slippage: chain rejects pre-mempool + // if the wallet receives strictly less than minUsdcxOut. + let receiverPC: unknown; + const usdcxAssetId = `${USDCX_TOKEN_DEPLOYER}.${USDCX_TOKEN_CONTRACT}` as `${string}.${string}`; + if (typeof stx.Pc === "object" && stx.Pc !== null) { + const pc = stx.Pc as { principal: (p: string) => { willSendGte: (a: string) => { ft: (id: `${string}.${string}`, name: string) => unknown } } }; + receiverPC = pc.principal(wallet).willSendGte(minUsdcxOut.toString()).ft(usdcxAssetId, USDCX_ASSET_NAME); + } else if (typeof stx.makeStandardFungiblePostCondition === "function") { + const fcc = stx.FungibleConditionCode as Record | undefined; + const createAssetInfo = stx.createAssetInfo as (a: string, b: string, c: string) => unknown; + const make = stx.makeStandardFungiblePostCondition as (...args: unknown[]) => unknown; + if (!fcc?.GreaterEqual || typeof createAssetInfo !== "function") { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "v6 helpers missing FungibleConditionCode.GreaterEqual / createAssetInfo.", "Reinstall @stacks/transactions."); + } + receiverPC = make(wallet, fcc.GreaterEqual, minUsdcxOut.toString(), createAssetInfo(USDCX_TOKEN_DEPLOYER, USDCX_TOKEN_CONTRACT, USDCX_ASSET_NAME)); + } else { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "Neither v6 makeStandardFungiblePostCondition nor v7 Pc.willSendGte is available.", "Reinstall @stacks/transactions."); + } + + // PC mode: Allow. PR #604 cycle 3 (tx + // https://explorer.hiro.so/txid/d1d45dad...0ec0?chain=mainnet , block + // 7966778) confirmed the DLMM pool emits an internal USDh movement that + // can't be covered by a sender/receiver PC under Deny mode — vm_error + // "Fungible asset usdh was moved by dlmm-pool-usdh-usdcx-v-1-bps-1 but not + // checked". Slippage enforcement falls to the contract's own min-dy arg + // (minUsdcxOut). senderPC + receiverPC remain as additive informational + // guards. + return broadcastContractCall(ctx, { + contractAddress: BITFLOW_ROUTER_DEPLOYER, + contractName: BITFLOW_ROUTER_CONTRACT, + functionName: "swap-x-for-y-simple-range-multi", + functionArgs: [ + (stx.contractPrincipalCV as (a: string, b: string) => unknown)(BITFLOW_USDH_USDCX_POOL_DEPLOYER, BITFLOW_USDH_USDCX_POOL_CONTRACT), + (stx.contractPrincipalCV as (a: string, b: string) => unknown)(USDH_TOKEN_DEPLOYER, USDH_TOKEN_CONTRACT), + (stx.contractPrincipalCV as (a: string, b: string) => unknown)(USDCX_TOKEN_DEPLOYER, USDCX_TOKEN_CONTRACT), + (stx.uintCV as (v: string) => unknown)(usdhBase.toString()), + (stx.uintCV as (v: string) => unknown)(minUsdcxOut.toString()), + (stx.uintCV as (v: string) => unknown)(BITFLOW_DLMM_MAX_STEPS.toString()), + (stx.noneCV as () => unknown)(), + ], + postConditions: [senderPC, receiverPC], + postConditionMode: "allow", + }); +} + +// Fetch the Bitflow `/quote` minOut for the USDh -> USDCx swap leg. Slippage +// passed as bps (e.g. 150) → percentage (1.5) per the quote API contract. +// Used immediately before inlineBitflowSwap to derive the min-dy argument. +async function fetchBitflowMinOut(usdhBase: bigint, slippageBps: number): Promise { + if (usdhBase <= 0n) throw new BlockedError("INVALID_SWAP_AMOUNT", "USDh swap amount must be positive.", "Inspect observedUsdhBase."); + if (!Number.isFinite(slippageBps) || slippageBps < 0) { + throw new BlockedError("INVALID_SLIPPAGE_BPS", `slippageBps must be a non-negative finite number; got ${slippageBps}.`, "Pass --slippage-bps as a non-negative integer."); + } + const slippagePct = slippageBps / 100; + const inputToken = `${USDH_TOKEN_DEPLOYER}.${USDH_TOKEN_CONTRACT}`; + const outputToken = `${USDCX_TOKEN_DEPLOYER}.${USDCX_TOKEN_CONTRACT}`; + let body: unknown; + try { + body = await httpJson(`${BITFLOW_QUOTES_BASE}/quote`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input_token: inputToken, + output_token: outputToken, + amount_in: usdhBase.toString(), + slippage_tolerance: slippagePct, + }), + }); + } catch (error) { + logFetchFailure("fetchBitflowMinOut", error); + throw new BlockedError( + "BITFLOW_QUOTE_FETCH_FAILED", + `Could not fetch Bitflow quote: ${error instanceof Error ? error.message : String(error)}`, + "Retry after Bitflow recovers; do not broadcast the swap without a fresh min-out.", + ); + } + const q = body as { min_amount_out?: string | number }; + const raw = q.min_amount_out; + if (typeof raw === "string" && /^\d+$/.test(raw)) return BigInt(raw); + if (typeof raw === "number" && Number.isInteger(raw) && raw >= 0) return BigInt(raw); + throw new BlockedError( + "BITFLOW_QUOTE_MIN_OUT_MISSING", + `Bitflow /quote response did not include parseable min_amount_out.`, + "Inspect the raw /quote response shape and update parsing.", + { responseShape: Object.keys(q ?? {}) as unknown as Json }, + ); +} + +// Parse Hiro `/extended/v1/tx/{txid}` events to extract the observed amount of +// a fungible token transferred TO the given recipient by the given asset. +// Replaces the prior subprocess-output-shape extractor — direct-call path has +// no subprocess to inspect, so we read the actual on-chain ft_transfer event +// once the tx confirms. +async function parseObservedOutFromTxEvents( + txid: string, + recipient: string, + tokenContract: string, + assetName: string, +): Promise { + let body: unknown; + try { + body = await httpJson(`${HIRO_API_BASE}/extended/v1/tx/${encodeURIComponent(txid)}`); + } catch (error) { + logFetchFailure("parseObservedOutFromTxEvents", error); + throw new BlockedError( + "TX_EVENTS_FETCH_FAILED", + `Could not fetch tx events from Hiro: ${error instanceof Error ? error.message : String(error)}`, + "Retry after Hiro recovers; do not advance the state machine until observedOut is parsed.", + ); + } + const tx = body as { tx_status?: string; events?: Array<{ event_type?: string; asset?: { asset_id?: string; sender?: string; recipient?: string; amount?: string; asset_event_type?: string } }> }; + if (tx.tx_status !== "success") { + throw new BlockedError( + "TX_NOT_CONFIRMED_SUCCESS", + `Tx ${txid} status=${tx.tx_status ?? "unknown"}, expected "success" before parsing events.`, + "Wait for confirmation, or inspect the failure reason before advancing.", + ); + } + const target = `${tokenContract}::${assetName}`; + let total = 0n; + for (const ev of tx.events ?? []) { + if (ev.event_type !== "fungible_token_asset") continue; + const a = ev.asset; + if (!a || a.asset_id !== target) continue; + if (a.asset_event_type !== "transfer") continue; + if (a.recipient !== recipient) continue; + if (typeof a.amount !== "string" || !/^\d+$/.test(a.amount)) continue; + total += BigInt(a.amount); + } + if (total === 0n) { + throw new BlockedError( + "OBSERVED_OUT_ZERO", + `Tx ${txid} succeeded but no ft_transfer event matched recipient=${recipient}, asset=${target}.`, + "Inspect the tx on the explorer; the swap may have routed through a different asset path.", + ); + } + return total; +} + +// Pre-Leg-5 safety gate: read the user's scaled debt for USDCx directly from +// the Zest market vault. `get-account-scaled-debt(account, asset-id)` returns +// the principal-scaled debt (uint); `0` means no debt (safe to withdraw +// collateral), `>0` means stop. Reading scaled debt alone is sufficient for +// the boolean safety check — the accrued-interest math (× live index ÷ 1e12) +// only matters if we want a dust threshold, which we don't. +async function fetchZestScaledDebt(wallet: string): Promise { + const stx = await import("@stacks/transactions") as Record; + const cvToHex = stx.cvToHex as ((cv: unknown) => string) | undefined; + const standardPrincipalCV = stx.standardPrincipalCV as ((a: string) => unknown) | undefined; + const uintCV = stx.uintCV as ((v: string) => unknown) | undefined; + if (typeof cvToHex !== "function" || typeof standardPrincipalCV !== "function" || typeof uintCV !== "function") { + logFetchFailure("fetchZestScaledDebt", new Error("@stacks/transactions missing cvToHex / standardPrincipalCV / uintCV")); + return null; + } + const accountHex = cvToHex(standardPrincipalCV(wallet)); + const assetHex = cvToHex(uintCV(ZEST_USDCX_ASSET_ID.replace(/^u/, ""))); + const r = await callReadHiro(ZEST_MARKET_VAULT_DEPLOYER, ZEST_MARKET_VAULT_CONTRACT, "get-account-scaled-debt", [accountHex, assetHex], wallet); + return decodeClarityUint(r.result); +} + +// Pre-Leg-5 amount lookup: read the wallet's recorded collateral for the +// given asset id directly from Zest's v0-market-vault. The `redeemAmount` +// arg passed to `v0-4-market.collateral-remove-redeem` MUST equal the +// recorded amount exactly — the contract calls `remove-user-collateral` +// which fails with `ERR-INSUFFICIENT-COLLATERAL` (u600004) if amount +// exceeds the wallet's collateral. The earlier sentinel-based approach +// (passing maxUint128 expecting the contract to clamp) was empirically +// shown wrong at tx +// https://explorer.hiro.so/txid/249ab6f248d6329bec850f6f2f3088d418244868015d9a22c7e6d257f9c53ff9?chain=mainnet +// (block 8067073, abort_by_post_condition with tx_result (err u600004)). +// +// Calls `get-position(account, enabled-mask)` which returns +// `(ok (merge obligation { collateral: , debt: }))`. Each +// collateral list entry is `{ aid: uint, amount: uint, ... }`. We iterate +// to find the entry matching the requested asset id and return its amount. +// Mask `u255` enables the low 8 asset slots — covers all assets the +// market currently supports. +async function fetchZestCollateralAmount(wallet: string, assetIdU: string): Promise { + const r = await callReadHiro( + ZEST_MARKET_VAULT_DEPLOYER, + ZEST_MARKET_VAULT_CONTRACT, + "get-position", + [ + // account principal + (await (async () => { + const stx = await import("@stacks/transactions") as Record; + const cvToHex = stx.cvToHex as (cv: unknown) => string; + const standardPrincipalCV = stx.standardPrincipalCV as (a: string) => unknown; + return cvToHex(standardPrincipalCV(wallet)); + })()), + // enabled-mask: u255 (covers low 8 asset slots, enough for current market roster) + (await (async () => { + const stx = await import("@stacks/transactions") as Record; + const cvToHex = stx.cvToHex as (cv: unknown) => string; + const uintCV = stx.uintCV as (v: string) => unknown; + return cvToHex(uintCV("255")); + })()), + ], + wallet, + ); + if (!r.result) return null; + try { + const stx = await import("@stacks/transactions") as { + hexToCV: (hex: string) => unknown; + cvToJSON: (cv: unknown) => unknown; + }; + const cv = stx.hexToCV(r.result); + const json = stx.cvToJSON(cv) as { type?: string; value?: unknown; success?: boolean }; + // Shape: (ok (tuple ... collateral: (list (tuple aid uint amount uint ...)) ...)) + // cvToJSON: { type: "(response ...)", value: { type: "(tuple ...)", value: { collateral: { type: "(list ...)", value: [ {type, value: { aid: {type,value}, amount: {type,value} }} ... ] } ... }, success: true } success: true } + if (json?.success === false) return null; + const okWrapper = json?.value as { value?: unknown; success?: boolean } | undefined; + if (okWrapper?.success === false) return null; + const tupleEnvelope = okWrapper?.value as Record | undefined; + if (!tupleEnvelope) return null; + const collateralListWrapper = tupleEnvelope.collateral as { type?: string; value?: unknown } | undefined; + const collateralList = collateralListWrapper?.value as Array<{ value?: Record }> | undefined; + if (!Array.isArray(collateralList)) return null; + const targetAid = assetIdU.replace(/^u/, ""); + for (const entry of collateralList) { + const fields = entry?.value; + if (!fields) continue; + const aidVal = fields.aid?.value; + if (typeof aidVal === "string" && aidVal === targetAid) { + const amountVal = fields.amount?.value; + if (typeof amountVal === "string" && /^\d+$/.test(amountVal)) return BigInt(amountVal); + } + } + return null; + } catch (error) { + logFetchFailure(`fetchZestCollateralAmount cvToJSON decode for asset ${assetIdU}`, error); + return null; + } +} + +// Leg 4: Zest market `repay(ft, amount, on-behalf-of)` — repays USDCx debt. +// We pass `on-behalf-of: (some wallet)` so the repay clears the caller's own +// position. Post-condition: wallet sends at most `amountAtomic` of the +// repay asset (Zest may take less if debt is smaller). +async function inlineZestRepay(wallet: string, borrowAssetContract: string, borrowAssetName: string, amountAtomic: bigint): Promise { + const ctx = await prepareBroadcastContext(wallet); + const stx = ctx.stx; + const [tokenDeployer, tokenContract] = borrowAssetContract.split("."); + if (!tokenDeployer || !tokenContract) { + throw new BlockedError("BORROW_ASSET_CONTRACT_MALFORMED", `Could not split borrow asset contract id: ${borrowAssetContract}`, "Re-resolve the borrow asset via the Bitflow token registry."); + } + const walletPC = buildFtPostCondition(stx, wallet, amountAtomic, tokenDeployer, tokenContract, borrowAssetName, "lte"); + + return broadcastContractCall(ctx, { + contractAddress: ZEST_MARKET_DEPLOYER, + contractName: ZEST_MARKET_CONTRACT, + functionName: "repay", + functionArgs: [ + (stx.contractPrincipalCV as (a: string, b: string) => unknown)(tokenDeployer, tokenContract), + (stx.uintCV as (v: string) => unknown)(amountAtomic.toString()), + (stx.someCV as (v: unknown) => unknown)((stx.standardPrincipalCV as (a: string) => unknown)(wallet)), + ], + postConditions: [walletPC], + }); +} + +// Leg 5: Zest market `collateral-remove-redeem(ft, amount, min-underlying, +// receiver?, price-feeds?)` — withdraws sBTC collateral. `amount` is the +// share-amount to redeem; `min-underlying` is the minimum sBTC sats expected. +// +// For a full unwind we pass `amount = u340282366920938463463374607431768211455` +// (max uint128) as the "redeem everything" sentinel — the contract clamps to +// the actual available collateral. `min-underlying` enforces slippage from the +// operator's expected sBTC payout. +async function inlineZestWithdraw( + wallet: string, + collateralAssetContract: string, + collateralAssetName: string, + minUnderlyingSats: bigint, + redeemAmount: bigint, +): Promise { + const ctx = await prepareBroadcastContext(wallet); + const stx = ctx.stx; + const [tokenDeployer, tokenContract] = collateralAssetContract.split("."); + if (!tokenDeployer || !tokenContract) { + throw new BlockedError("COLLATERAL_ASSET_CONTRACT_MALFORMED", `Could not split collateral asset contract id: ${collateralAssetContract}`, "Re-resolve the collateral asset via the Bitflow token registry."); + } + // Post-condition: the Zest market vault sends >= minUnderlyingSats of the + // collateral asset to the wallet. Built as a contract-principal PC. + let vaultPC: unknown; + if (typeof stx.Pc === "object" && stx.Pc !== null) { + const pc = stx.Pc as { principal: (p: string) => { willSendGte: (a: string) => { ft: (id: `${string}.${string}`, name: string) => unknown } } }; + vaultPC = pc.principal(`${ZEST_MARKET_DEPLOYER}.${ZEST_MARKET_CONTRACT}`).willSendGte(minUnderlyingSats.toString()).ft(`${tokenDeployer}.${tokenContract}` as `${string}.${string}`, collateralAssetName); + } else if (typeof stx.makeContractFungiblePostCondition === "function") { + const fcc = stx.FungibleConditionCode as Record | undefined; + const createAssetInfo = stx.createAssetInfo as (a: string, b: string, c: string) => unknown; + const make = stx.makeContractFungiblePostCondition as (...args: unknown[]) => unknown; + if (!fcc?.GreaterEqual || typeof createAssetInfo !== "function") { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "v6 helpers missing FungibleConditionCode.GreaterEqual / createAssetInfo.", "Reinstall @stacks/transactions."); + } + vaultPC = make(ZEST_MARKET_DEPLOYER, ZEST_MARKET_CONTRACT, fcc.GreaterEqual, minUnderlyingSats.toString(), createAssetInfo(tokenDeployer, tokenContract, collateralAssetName)); + } else { + throw new BlockedError("STACKS_TRANSACTIONS_INCOMPATIBLE", "Neither v6 makeContractFungiblePostCondition nor v7 Pc.willSendGte is available.", "Reinstall @stacks/transactions."); + } + + // ft arg: pass the Zest vault wrapper (v0-vault-sbtc), NOT the underlying + // SIP-010 sbtc-token. Empirical confirmation: passing the underlying causes + // Zest to return `(err u400009)` without transferring, surfacing as PC abort + // on the underlying SentGe 0. See ZEST_VAULT_DEPLOYER / ZEST_VAULT_SBTC_CONTRACT + // constants above and PR #588 for the same fix on sibling skill. + // PC mode: Allow. `collateral-remove-redeem` emits multiple transfers + // (vault burns wallet's vault-token position + market sends underlying back + // + possible accrued-interest movements). Exhaustive Deny-mode enumeration + // is impractical; vaultPC stays as an additive informational guard on the + // underlying outflow (slippage floor backed by min-underlying contract arg). + return broadcastContractCall(ctx, { + contractAddress: ZEST_MARKET_DEPLOYER, + contractName: ZEST_MARKET_CONTRACT, + functionName: "collateral-remove-redeem", + functionArgs: [ + (stx.contractPrincipalCV as (a: string, b: string) => unknown)(ZEST_VAULT_DEPLOYER, ZEST_VAULT_SBTC_CONTRACT), + (stx.uintCV as (v: string) => unknown)(redeemAmount.toString()), + (stx.uintCV as (v: string) => unknown)(minUnderlyingSats.toString()), + (stx.someCV as (v: unknown) => unknown)((stx.standardPrincipalCV as (a: string) => unknown)(wallet)), + (stx.noneCV as () => unknown)(), // price-feeds: none (operator-driven, no Pyth update inline) + ], + postConditions: [vaultPC], + postConditionMode: "allow", + }); +} + +// ========================================================================= +// === State machine drivers +// ========================================================================= + +interface UnwindContext { + borrowAssetSymbol: string; + collateralAssetSymbol: string; + borrowToken: TokenInfo; + usdhToken: TokenInfo; + collateralToken: TokenInfo; +} + +async function resolveUnwindContext(opts: SharedOptions): Promise { + const borrowAssetSymbol = opts.borrowAsset || DEFAULT_BORROW_ASSET; + const collateralAssetSymbol = opts.collateralAsset || DEFAULT_COLLATERAL_ASSET; + const tokens = await resolveTokenBySymbol(["USDh", borrowAssetSymbol, collateralAssetSymbol]); + const borrowToken = tokens[borrowAssetSymbol]; + if (!borrowToken) throw new BlockedError("BORROW_TOKEN_UNRESOLVED", `Bitflow registry did not resolve ${borrowAssetSymbol}.`, "Check Bitflow API connectivity and the spelling of --borrow-asset."); + const usdhToken = tokens["USDh"]; + if (!usdhToken) throw new BlockedError("USDH_TOKEN_UNRESOLVED", "Bitflow registry did not resolve USDh.", "Check Bitflow API connectivity."); + const collateralToken = tokens[collateralAssetSymbol]; + if (!collateralToken) throw new BlockedError("COLLATERAL_TOKEN_UNRESOLVED", `Bitflow registry did not resolve ${collateralAssetSymbol}.`, "Check Bitflow API connectivity and the spelling of --collateral-asset."); + return { borrowAssetSymbol, collateralAssetSymbol, borrowToken, usdhToken, collateralToken }; +} + +async function continueUnwind(checkpoint: Checkpoint, opts: RunOptions, ctx: UnwindContext): Promise { + const wallet = checkpoint.wallet; + let current = checkpoint; + const cooldownGraceSec = parseNonNegativeInt(opts.cooldownGraceSeconds, DEFAULT_COOLDOWN_GRACE_SECONDS, "--cooldown-grace-seconds"); + const minSbtcWithdrawSats = parseNonNegativeInt(opts.minSbtcWithdrawSats, DEFAULT_MIN_SBTC_WITHDRAW_SATS, "--min-sbtc-withdraw-sats"); + // Wait window for each inline tx to mine before broadcasting the next leg. + // Required because back-to-back broadcasts from the same wallet all auto- + // fetch the nonce from Hiro's API — without a wait, leg N+1 fetches the + // same nonce as leg N (still pending in mempool) and the miner rejects one + // with ConflictingNonceInMempool. + const waitSeconds = parseNonNegativeInt(opts.waitSeconds, DEFAULT_WAIT_SECONDS, "--wait-seconds"); + + // Recovery from a pre-confirmation interruption: the unstake tx was + // broadcast and the txid was persisted, but the prior run didn't complete + // the confirmation wait or the claim-id snapshot. Re-poll the tx (now + // likely confirmed since some time has passed), snapshot the silo claim-id + // counter, and advance to unstake_confirmed. + if (current.step === "unstake_broadcast") { + if (!current.unstakeTxid) { + throw new BlockedError("MISSING_UNSTAKE_TXID", "Checkpoint at unstake_broadcast has no unstakeTxid.", "Cancel and start over, or manually inspect the wallet for any pending Hermetica unstake before retrying.", { checkpoint: current }); + } + requireTxSuccess("hermetica-unstake", current.unstakeTxid, await waitForTxConfirmation(current.unstakeTxid, waitSeconds)); + // Snapshot the silo claim-id counter. We can't reliably reconstruct the + // pre-broadcast counter value at resume time, so we accept the current + // counter as ours — for single-wallet operation this is safe (no parallel + // unstakes from the same wallet). Multi-wallet parallelism would require + // operator-supplied --claim-id. + const currentClaimId = await fetchSiloCurrentClaimId(); + if (currentClaimId === null || currentClaimId <= 0n) { + throw new BlockedError("CLAIM_ID_INDETERMINATE", "Silo claim-id counter could not be read during resume from unstake_broadcast.", "Check Hiro connectivity; verify the unstake tx on the explorer; if confirmed, read silo.get-current-claim-id manually and repair the checkpoint with --claim-id.", { unstakeTxid: current.unstakeTxid }); + } + const claim = await fetchSiloClaim(currentClaimId); + current = await writeCheckpoint({ + ...current, + step: "unstake_confirmed", + claimId: currentClaimId.toString(), + cooldownExpiresAt: claim.unlockTs !== null ? new Date(Number(claim.unlockTs) * 1000).toISOString() : undefined, + }); + } + + // Leg 2 gate: cooldown wait + silo withdraw. + if (current.step === "unstake_confirmed") { + if (!current.claimId) { + throw new BlockedError("MISSING_CLAIM_ID", "Checkpoint at unstake_confirmed has no claimId.", "Repair the checkpoint by reading silo.get-current-claim-id and matching against the unstake tx, then resume.", { checkpoint: current }); + } + const claim = await fetchSiloClaim(BigInt(current.claimId)); + if (claim.unlockTs === null) { + throw new BlockedError("CLAIM_UNREADABLE", `Hiro could not read silo claim ${current.claimId}.`, "Check Hiro connectivity and that the claim still exists, then resume."); + } + // `claim.unlockTs` is Stacks-block-time (POSIX seconds, set on-chain via + // `(get-stacks-block-info? time)` inside the silo's create-claim). Directly + // comparable to wall-clock via Date.now() — NOT burn-block-time, NOT a + // block-height. The `--cooldown-grace-seconds` default (300) absorbs the + // few-second miner-time skew between Stacks block time and operator + // wall-clock so the on-chain `(>= now ts)` check inside `withdraw` doesn't + // fire microseconds after our pre-broadcast comparison passes. + const unlockMs = Number(claim.unlockTs) * 1000; + const nowMs = Date.now(); + const graceMs = cooldownGraceSec * 1000; + if (nowMs < unlockMs + graceMs) { + const secondsRemaining = Math.ceil((unlockMs + graceMs - nowMs) / 1000); + throw new BlockedError( + "COOLDOWN_NOT_EXPIRED", + `Hermetica cooldown not yet elapsed for claim ${current.claimId}: ${secondsRemaining} seconds remaining (incl. --cooldown-grace-seconds=${cooldownGraceSec}).`, + `Wait ${secondsRemaining} seconds and re-run resume.`, + { claimId: current.claimId, unlockTs: claim.unlockTs.toString(), secondsRemaining } + ); + } + const expectedUsdh = claim.amount ?? 0n; + if (expectedUsdh <= 0n) { + throw new BlockedError("CLAIM_AMOUNT_ZERO", `Silo claim ${current.claimId} reports zero amount.`, "Investigate the silo claim record before resuming."); + } + await enforceMempoolDepthLimit(wallet, opts.mempoolDepthLimit, "silo-withdraw"); + const claimTxid = await inlineSiloWithdraw(wallet, BigInt(current.claimId), expectedUsdh); + requireTxSuccess("silo-withdraw", claimTxid, await waitForTxConfirmation(claimTxid, waitSeconds)); + current = await writeCheckpoint({ + ...current, + step: "claim_confirmed", + claimTxid, + observedUsdhBase: expectedUsdh.toString(), + }); + } + + // Leg 3: swap USDh -> USDCx via the Bitflow DLMM router (direct contract + // call; no external skill dependency). Min-out is derived from Bitflow's + // live `/quote` endpoint pinned to the operator's --slippage-bps, then + // passed as the on-chain min-dy argument. Observed-out is parsed from + // the confirmed tx's ft_transfer events. + if (current.step === "claim_confirmed") { + const usdhBase = current.observedUsdhBase; + if (!usdhBase || BigInt(usdhBase) <= 0n) { + throw new BlockedError("MISSING_SWAP_INPUT", "Checkpoint does not carry a positive observed USDh amount.", "Cancel or repair the checkpoint before resuming.", { checkpoint: current }); + } + const slippageBps = parseStrictlyPositiveInt(opts.slippageBps || DEFAULT_SLIPPAGE_BPS, "--slippage-bps"); + const minUsdcxOut = await fetchBitflowMinOut(BigInt(usdhBase), slippageBps); + await enforceMempoolDepthLimit(wallet, opts.mempoolDepthLimit, "bitflow-swap"); + const swapTxid = await inlineBitflowSwap(wallet, BigInt(usdhBase), minUsdcxOut); + requireTxSuccess("bitflow-swap", swapTxid, await waitForTxConfirmation(swapTxid, waitSeconds)); + const observedUsdcx = await parseObservedOutFromTxEvents( + swapTxid, + wallet, + `${USDCX_TOKEN_DEPLOYER}.${USDCX_TOKEN_CONTRACT}`, + USDCX_ASSET_NAME, + ); + if (observedUsdcx <= 0n) { + throw new BlockedError("SWAP_OUTPUT_UNKNOWN", "Swap tx confirmed but no positive USDCx receipt event matched the wallet.", "Inspect the tx on the explorer; do not advance until observedUsdcxBase is recorded.", { swapTxid }); + } + current = await writeCheckpoint({ + ...current, + step: "swap_confirmed", + swapTxid, + observedUsdcxBase: observedUsdcx.toString(), + }); + } + + // Leg 4: repay USDCx debt on Zest. + if (current.step === "swap_confirmed") { + const repayAmount = current.observedUsdcxBase; + if (!repayAmount || BigInt(repayAmount) <= 0n) { + throw new BlockedError("MISSING_REPAY_INPUT", "Checkpoint does not carry a positive observed borrow asset amount.", "Cancel or repair the checkpoint before resuming.", { checkpoint: current }); + } + const borrowAssetName = resolveAssetName(ctx.borrowToken.contract, opts.borrowAssetName, "borrow"); + await enforceMempoolDepthLimit(wallet, opts.mempoolDepthLimit, "zest-repay"); + const repayTxid = await inlineZestRepay(wallet, ctx.borrowToken.contract, borrowAssetName, BigInt(repayAmount)); + requireTxSuccess("zest-repay", repayTxid, await waitForTxConfirmation(repayTxid, waitSeconds)); + current = await writeCheckpoint({ + ...current, + step: "repay_confirmed", + repayTxid, + }); + } + + // Leg 5: withdraw sBTC collateral. + if (current.step === "repay_confirmed") { + // Residual-debt gate. Zest's `collateral-remove-redeem` requires Pyth + // price-feeds in its `price-feeds: (optional ...)` arg for health-factor + // validation when ANY debt remains; this skill passes `noneCV()` since + // it expects debt cleared. If repay only cleared partially (swap output + // < actual debt due to slippage or accrued interest between snapshot + // and broadcast), the chain rejects the withdraw. Surface that early + // with operator guidance rather than burning gas on a guaranteed revert. + // Direct readonly to the Zest market vault. scaled-debt > 0 means the + // wallet still owes USDCx after the repay leg (could happen if the swap + // output came in short of the live debt because of slippage or accrued + // interest between the snapshot and broadcast). Withdrawing collateral + // with non-zero debt requires a price-feeds arg which this skill does + // not supply by design; surface the blocker rather than broadcasting a + // guaranteed revert. + const scaledDebt = await fetchZestScaledDebt(wallet); + if (scaledDebt === null) { + throw new BlockedError( + "RESIDUAL_DEBT_UNREADABLE", + "Could not read residual Zest debt after repay; refusing to broadcast collateral-remove-redeem without proof debt is cleared.", + "Inspect Hiro connectivity, the Zest market vault contract, and the wallet address; resume after the readonly call returns a uint.", + ); + } + if (scaledDebt > 0n) { + throw new BlockedError( + "RESIDUAL_DEBT_AFTER_REPAY", + `Zest reports residual scaled debt ${scaledDebt.toString()} for ${ctx.borrowAssetSymbol} after the repay leg confirmed. Withdraw would be rejected on chain (price-feeds required when debt > 0).`, + `Top up the wallet with enough ${ctx.borrowAssetSymbol} to clear the residual, then call ${ZEST_MARKET_DEPLOYER}.${ZEST_MARKET_CONTRACT} repay directly until scaled debt returns 0. Re-run resume after the residual repay confirms.`, + { residualScaledDebt: scaledDebt.toString(), borrowAsset: ctx.borrowAssetSymbol, repayTxid: current.repayTxid }, + ); + } + const collateralAssetName = resolveAssetName(ctx.collateralToken.contract, opts.collateralAssetName, "collateral"); + // Redeem amount MUST equal the wallet's recorded collateral exactly — + // Zest's v0-market-vault.collateral-remove does NOT clamp. Passing + // maxUint128 (or any amount > recorded) returns ERR-INSUFFICIENT-COLLATERAL + // (u600004). Empirical proof at + // https://explorer.hiro.so/txid/249ab6f248d6329bec850f6f2f3088d418244868015d9a22c7e6d257f9c53ff9?chain=mainnet + // (block 8067073). Read the actual amount before broadcast. + const recordedCollateral = await fetchZestCollateralAmount(wallet, ZEST_SBTC_ASSET_ID); + if (recordedCollateral === null || recordedCollateral <= 0n) { + throw new BlockedError( + "COLLATERAL_AMOUNT_INDETERMINATE", + `Could not read sBTC collateral amount (asset id ${ZEST_SBTC_ASSET_ID}) from Zest vault before redeem.`, + `Inspect Hiro connectivity; verify v0-market-vault.get-position returns a non-empty collateral list for this wallet with an entry for asset id ${ZEST_SBTC_ASSET_ID}.`, + ); + } + await enforceMempoolDepthLimit(wallet, opts.mempoolDepthLimit, "zest-collateral-remove-redeem"); + const withdrawTxid = await inlineZestWithdraw(wallet, ctx.collateralToken.contract, collateralAssetName, BigInt(minSbtcWithdrawSats), recordedCollateral); + requireTxSuccess("zest-collateral-remove-redeem", withdrawTxid, await waitForTxConfirmation(withdrawTxid, waitSeconds)); + current = await writeCheckpoint({ + ...current, + step: "complete", + withdrawTxid, + }); + } + + return current; +} + +// SIP-010 asset names are declared inside each token contract via +// `(define-fungible-token )` and are NOT derivable from the contract id. +// Verified against the deployed contract sources via Hiro +// `/v2/contracts/source`: +// - sbtc-token -> asset name "sbtc-token" (NOT "sbtc") +// - usdcx -> asset name "usdcx-token" (NOT "usdcx") +// - usdh-token-v1 -> asset name "usdh" +// - susdh-token-v1 -> asset name "susdh" +// The prior heuristic (`infer from contract id`) produced wrong names for +// sbtc-token and usdcx — the default `--collateral-asset sBTC` + +// `--borrow-asset USDCx` flow would have built post-conditions referencing +// non-existent asset names, and the chain would have rejected the tx. +const SIP010_ASSET_NAMES: Record = { + // Keys are contract ids lowercased; values are the declared asset names. + "sm3vdxk3wzzsa84xxfkafaf15nnzx32ctsg82jfq4.sbtc-token": "sbtc-token", + "sp120sbrbqj00mcws7tm5r8wjnttkd5k0hfrc2cne.usdcx": "usdcx-token", + "spn5akg35qzsk2m8gamr4afx45659rjhdw353hsg.usdh-token-v1": "usdh", + "spn5akg35qzsk2m8gamr4afx45659rjhdw353hsg.susdh-token-v1": "susdh", +}; + +function resolveAssetName(contractId: string, override: string | undefined, contextHint: string): string { + if (override && override.trim().length > 0) return override.trim(); + const key = contractId.toLowerCase(); + const mapped = SIP010_ASSET_NAMES[key]; + if (mapped) return mapped; + throw new BlockedError( + "ASSET_NAME_UNRESOLVED", + `SIP-010 asset name for ${contextHint} contract ${contractId} is not in the static map and no --${contextHint}-asset-name override was provided.`, + `Verify the asset name against Hiro /v2/contracts/source/${contractId.replace(".", "/")} (look for the (define-fungible-token ) line), then pass --${contextHint}-asset-name=.`, + ); +} + +// ========================================================================= +// === Commands +// ========================================================================= + +async function runDoctor(opts: SharedOptions): Promise { + try { + const wallet = ensureWallet(opts.wallet); + const checkpoint = await readCheckpoint(wallet); + const stakingEnabled = await fetchHermeticaStakingEnabled(); + const siloCurrentClaimId = await fetchSiloCurrentClaimId(); + const susdhBalance = await fetchWalletSusdhBalance(wallet); + const usdhBalance = await fetchWalletUsdhBalance(wallet); + const tokens = await resolveTokenBySymbol(["USDh", opts.borrowAsset || DEFAULT_BORROW_ASSET, opts.collateralAsset || DEFAULT_COLLATERAL_ASSET]); + const data: JsonMap = { + checkpoint: checkpoint as unknown as Json, + hermetica: { + stakingEnabledOnState: stakingEnabled, + note: "Unstake does NOT gate on staking-enabled (only stake does), but we surface it so operators see Hermetica's overall posture.", + siloCurrentClaimId: siloCurrentClaimId !== null ? siloCurrentClaimId.toString() : null, + }, + walletBalances: { + susdhBase: susdhBalance !== null ? susdhBalance.toString() : null, + usdhBase: usdhBalance !== null ? usdhBalance.toString() : null, + }, + bitflowTokenResolution: tokens as unknown as Json, + contracts: { + unstake: `${HERMETICA_DEPLOYER}.${HERMETICA_STAKING_CONTRACT}`, + siloWithdraw: `${HERMETICA_DEPLOYER}.${HERMETICA_SILO_CONTRACT}`, + swapRouter: `${BITFLOW_ROUTER_DEPLOYER}.${BITFLOW_ROUTER_CONTRACT}`, + swapPool: `${BITFLOW_USDH_USDCX_POOL_DEPLOYER}.${BITFLOW_USDH_USDCX_POOL_CONTRACT}`, + zestMarket: `${ZEST_MARKET_DEPLOYER}.${ZEST_MARKET_CONTRACT}`, + zestVault: `${ZEST_MARKET_VAULT_DEPLOYER}.${ZEST_MARKET_VAULT_CONTRACT}`, + }, + }; + success("doctor", data); + } catch (error) { fail("doctor", error); } +} + +async function runStatus(opts: SharedOptions): Promise { + try { + const wallet = ensureWallet(opts.wallet); + const checkpoint = await readCheckpoint(wallet); + const data: JsonMap = { checkpoint: checkpoint as unknown as Json }; + if (checkpoint?.claimId) { + const claim = await fetchSiloClaim(BigInt(checkpoint.claimId)); + data.claim = { + id: checkpoint.claimId, + amountUsdhBase: claim.amount !== null ? claim.amount.toString() : null, + unlockTs: claim.unlockTs !== null ? claim.unlockTs.toString() : null, + unlockIso: claim.unlockTs !== null ? new Date(Number(claim.unlockTs) * 1000).toISOString() : null, + secondsRemaining: claim.unlockTs !== null ? Math.max(0, Number(claim.unlockTs) - Math.floor(Date.now() / 1000)) : null, + }; + } + success("status", data); + } catch (error) { fail("status", error); } +} + +async function runPlan(opts: SharedOptions): Promise { + try { + const wallet = ensureWallet(opts.wallet); + const susdhAmount = ensureSusdhAmount(opts.susdhAmountBase); + const existing = await readCheckpoint(wallet); + if (isUnresolved(existing)) throw new BlockedError("UNRESOLVED_CYCLE_STATE", "A previous unwind checkpoint is unresolved.", "Run resume or cancel before planning a new unwind.", { checkpoint: existing as unknown as Json }); + const ctx = await resolveUnwindContext(opts); + const susdhBalance = await fetchWalletSusdhBalance(wallet); + success("plan", { + route: "unstake-susdh -> wait-cooldown -> silo-withdraw -> swap-usdh-to-usdcx -> repay-zest -> withdraw-sbtc", + params: { + susdhAmountBase: susdhAmount, + borrowAsset: ctx.borrowAssetSymbol, + collateralAsset: ctx.collateralAssetSymbol, + usdhDecimals: ctx.usdhToken.decimals, + borrowDecimals: ctx.borrowToken.decimals, + collateralDecimals: ctx.collateralToken.decimals, + }, + walletBalances: { susdhBase: susdhBalance !== null ? susdhBalance.toString() : null }, + steps: [ + { step: "unstake", contract: `${HERMETICA_DEPLOYER}.${HERMETICA_STAKING_CONTRACT}`, function: "unstake", note: "Burns sUSDh, mints a silo claim with a 7-day cooldown." }, + { step: "cooldown", note: "Wait for the silo claim's unlock-ts. Default --cooldown-grace-seconds=300 (5 min margin past unlock-ts to absorb miner-time skew)." }, + { step: "silo-withdraw", contract: `${HERMETICA_DEPLOYER}.${HERMETICA_SILO_CONTRACT}`, function: "withdraw", note: "Releases USDh to the wallet." }, + { step: "swap", contract: `${BITFLOW_ROUTER_DEPLOYER}.${BITFLOW_ROUTER_CONTRACT}`, function: "swap-x-for-y-simple-range-multi", pool: `${BITFLOW_USDH_USDCX_POOL_DEPLOYER}.${BITFLOW_USDH_USDCX_POOL_CONTRACT}`, note: "USDh -> USDCx via Bitflow DLMM router (direct contract call)." }, + { step: "repay", contract: `${ZEST_MARKET_DEPLOYER}.${ZEST_MARKET_CONTRACT}`, function: "repay", note: "Clears Zest debt for the wallet's position." }, + { step: "withdraw", contract: `${ZEST_MARKET_DEPLOYER}.${ZEST_MARKET_CONTRACT}`, function: "collateral-remove-redeem", note: "Releases sBTC collateral; redeems max-uint128 share (contract clamps to available)." }, + ], + }); + } catch (error) { fail("plan", error); } +} + +async function runForward(opts: RunOptions): Promise { + try { + if (opts.confirm !== CONFIRM_TOKEN_UNWIND) throw new BlockedError("CONFIRMATION_REQUIRED", "This write skill requires explicit confirmation.", `Re-run with --confirm=${CONFIRM_TOKEN_UNWIND}.`); + const wallet = ensureWallet(opts.wallet); + const susdhAmount = ensureSusdhAmount(opts.susdhAmountBase); + const existing = await readCheckpoint(wallet); + if (isUnresolved(existing)) throw new BlockedError("UNRESOLVED_CYCLE_STATE", "A previous unwind checkpoint is unresolved.", "Run resume or cancel before starting a new unwind.", { checkpoint: existing as unknown as Json }); + + const ctx = await resolveUnwindContext(opts); + let checkpoint = await writeCheckpoint(newCheckpoint(wallet, susdhAmount, ctx.borrowAssetSymbol)); + + // Leg 1: unstake. + await enforceMempoolDepthLimit(wallet, opts.mempoolDepthLimit, "hermetica-unstake"); + const { txid: unstakeTxid, preClaimId } = await inlineUnstake(wallet, BigInt(susdhAmount)); + // PERSIST THE TXID IMMEDIATELY. Once the broadcast returns successfully + // the tx is in the mempool and will mine independently of any subsequent + // wait failure. If we waited until confirmation before checkpointing, a + // network glitch / low --wait-seconds / slow chain would leave the + // checkpoint at `idle` while the unstake mines anyway — orphaning the + // resulting silo claim with no skill-driven recovery path. + checkpoint = await writeCheckpoint({ ...checkpoint, step: "unstake_broadcast", unstakeTxid }); + const waitSeconds = parseNonNegativeInt(opts.waitSeconds, DEFAULT_WAIT_SECONDS, "--wait-seconds"); + // Wait for the unstake tx to confirm on-chain BEFORE attempting to drive + // the next legs. If this throws, the checkpoint at `unstake_broadcast` + // captures enough state for `resume` to pick up — re-poll the same txid + // for confirmation, then snapshot the silo claim-id. + requireTxSuccess("hermetica-unstake", unstakeTxid, await waitForTxConfirmation(unstakeTxid, waitSeconds)); + // Then snapshot the silo's claim-id counter to identify which claim is ours. + const claimId = await waitForNewSiloClaim(preClaimId, waitSeconds); + if (claimId === null) { + throw new BlockedError("CLAIM_ID_INDETERMINATE", `Unstake broadcast at ${unstakeTxid} but the silo claim-id counter did not advance within ${waitSeconds}s.`, "Wait for the tx to confirm, then run resume to retry the claim-id snapshot. Checkpoint is at unstake_broadcast with txid recorded.", { unstakeTxid, preClaimId: preClaimId !== null ? preClaimId.toString() : null }); + } + const claim = await fetchSiloClaim(claimId); + checkpoint = await writeCheckpoint({ + ...checkpoint, + step: "unstake_confirmed", + unstakeTxid, + claimId: claimId.toString(), + cooldownExpiresAt: claim.unlockTs !== null ? new Date(Number(claim.unlockTs) * 1000).toISOString() : undefined, + }); + + // Continue across legs 2-5. Will throw COOLDOWN_NOT_EXPIRED if the + // operator runs `run` and the silo cooldown isn't yet over, leaving the + // checkpoint at unstake_confirmed for a future `resume`. + const completed = await continueUnwind(checkpoint, opts, ctx); + + success("run", { + checkpoint: completed, + note: completed.step === "complete" + ? "Full 5-leg unwind broadcast and confirmed." + : `Unwind paused at ${completed.step}. Run \`resume --wallet ${wallet} --confirm=${CONFIRM_TOKEN_UNWIND}\` once the cooldown is over to continue.`, + }); + } catch (error) { fail("run", error); } +} + +async function waitForNewSiloClaim(preClaimId: bigint | null, maxSeconds: number): Promise { + // Poll the silo's claim-id counter until it advances past preClaimId. If + // preClaimId was unreadable, just read once and return. + const deadline = Date.now() + maxSeconds * 1000; + while (Date.now() < deadline) { + const current = await fetchSiloCurrentClaimId(); + if (current !== null) { + if (preClaimId === null) return current; + if (current > preClaimId) return current; + } + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + return null; +} + +async function runResume(opts: RunOptions): Promise { + try { + if (opts.confirm !== CONFIRM_TOKEN_UNWIND) throw new BlockedError("CONFIRMATION_REQUIRED", "Resume can continue writes and requires explicit confirmation.", `Re-run with --confirm=${CONFIRM_TOKEN_UNWIND}.`); + const wallet = ensureWallet(opts.wallet); + const checkpoint = await readCheckpoint(wallet); + if (!checkpoint || !isUnresolved(checkpoint)) throw new BlockedError("NO_RESUMABLE_STATE", "No unresolved unwind state exists for this wallet.", "Run plan/run for a new unwind if appropriate.", { checkpoint: checkpoint as unknown as Json }); + if (!["unstake_broadcast", "unstake_confirmed", "claim_confirmed", "swap_confirmed", "repay_confirmed"].includes(checkpoint.step)) { + throw new BlockedError("UNSUPPORTED_RESUME_STEP", `Cannot resume from ${checkpoint.step}.`, "Cancel or repair the checkpoint.", { checkpoint }); + } + const ctx = await resolveUnwindContext(opts); + const completed = await continueUnwind(checkpoint, opts, ctx); + success("resume", { checkpoint: completed }); + } catch (error) { fail("resume", error); } +} + +async function runCancel(opts: SharedOptions): Promise { + try { + const wallet = ensureWallet(opts.wallet); + const checkpoint = await readCheckpoint(wallet); + if (!checkpoint || !isUnresolved(checkpoint)) throw new BlockedError("NO_ACTIVE_CYCLE", "No unresolved unwind state exists for this wallet.", "No cancel action is needed.", { checkpoint }); + const cancelled = await writeCheckpoint({ + ...checkpoint, + step: "operator_cancelled", + abortReason: "operator_cancelled", + nextRequiredAction: "Review on-chain Hermetica silo claim + Zest position before starting another unwind. Cancel only clears the local checkpoint; any pending silo claim still exists on chain and can be withdrawn via the silo directly.", + }); + success("cancel", { checkpoint: cancelled }); + } catch (error) { fail("cancel", error); } +} + +// ========================================================================= +// === CLI wiring +// ========================================================================= + +function addSharedOptions(command: Command): Command { + return command + .option("--wallet ", "wallet that owns sUSDh, signs writes, and holds the Zest position") + .option("--susdh-amount-base ", "sUSDh amount to unstake, in 8-decimal base units") + .option("--borrow-asset ", "borrow asset symbol that will be repaid to Zest (matches the wind skill's borrow leg)", DEFAULT_BORROW_ASSET) + .option("--collateral-asset ", "collateral asset symbol that will be withdrawn from Zest", DEFAULT_COLLATERAL_ASSET) + .option("--slippage-bps ", "swap slippage tolerance in basis points", DEFAULT_SLIPPAGE_BPS) + .option("--min-gas-reserve-ustx ", "minimum STX gas reserve", DEFAULT_MIN_GAS_RESERVE_USTX) + .option("--mempool-depth-limit ", "maximum allowed pending tx depth", DEFAULT_MEMPOOL_DEPTH_LIMIT) + .option("--wait-seconds ", "wait window passed to primitive write skills and used for the post-unstake claim-id snapshot", DEFAULT_WAIT_SECONDS) + .option("--min-sbtc-withdraw-sats ", "minimum sBTC sats the wallet expects from collateral-remove-redeem (post-condition floor)", DEFAULT_MIN_SBTC_WITHDRAW_SATS) + .option("--cooldown-grace-seconds ", "extra wait past Hermetica unlock-ts before broadcasting silo withdraw, to absorb miner-time skew", DEFAULT_COOLDOWN_GRACE_SECONDS) + .option("--borrow-asset-name ", "SIP-010 asset name for the borrow token's post-condition (required if --borrow-asset isn't in the built-in map). Verifiable via Hiro /v2/contracts/source — look for `(define-fungible-token NAME)`.") + .option("--collateral-asset-name ", "SIP-010 asset name for the collateral token's post-condition (required if --collateral-asset isn't in the built-in map). Verifiable via Hiro /v2/contracts/source."); +} + +const program = new Command(); + +program + .name("unwindleg-hermeticaunstake-zestrepay-yield-rotator-sUSDh-USDCx-sBTC") + .description("Unwind-only companion to the wind-leg yield rotator. Unstakes sUSDh on Hermetica (creates a 7-day silo claim), waits the cooldown, withdraws USDh from the silo, swaps USDh -> USDCx via Bitflow, repays the USDCx debt on Zest, and withdraws the sBTC collateral. Closes the loop opened by the wind skill."); + +addSharedOptions(program.command("doctor").description("Check dependency, wallet balance, Hermetica posture, and Bitflow token resolution")).action(runDoctor); +addSharedOptions(program.command("status").description("Read current unwind checkpoint and silo claim state")).action(runStatus); +addSharedOptions(program.command("plan").description("Preview the unwind without broadcasting")).action(runPlan); +addSharedOptions(program.command("run").description("Broadcast the unwind (5 legs across a 7-day cooldown)")).option("--confirm ", "required confirmation token for forward writes").action(runForward); +addSharedOptions(program.command("resume").description("Resume an interrupted unwind (typically called after the Hermetica cooldown expires)")).option("--confirm ", "required confirmation token for forward writes").action(runResume); +addSharedOptions(program.command("cancel").description("Mark an unresolved checkpoint as operator-cancelled (does not affect on-chain silo claim)")).action(runCancel); + +program.parse(process.argv);