From e165e2e9a080a7a9a2769d458e9cbfe52930e3de Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 30 Apr 2026 02:22:58 -0700 Subject: [PATCH] fix(stacks-alpha-engine): post-#339 audit sweep (rebased) Squashed rebase of #346 (cliqueengagements). Original 18 commits replaced with single squash on current main + manifest regen. All audit-sweep content from the original PR preserved (+496/-105 lines across stacks-alpha-engine.ts/SKILL.md/AGENT.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- skills.json | 4 +- stacks-alpha-engine/AGENT.md | 10 +- stacks-alpha-engine/SKILL.md | 122 ++++-- stacks-alpha-engine/stacks-alpha-engine.ts | 465 +++++++++++++++++---- 4 files changed, 496 insertions(+), 105 deletions(-) diff --git a/skills.json b/skills.json index 253d4b1..5c2da11 100644 --- a/skills.json +++ b/skills.json @@ -1,6 +1,6 @@ { "version": "0.40.0", - "generated": "2026-04-30T09:12:20.112Z", + "generated": "2026-04-30T09:22:45.769Z", "skills": [ { "name": "agent-lookup", @@ -2118,6 +2118,8 @@ "scan", "deploy", "withdraw", + "borrow", + "repay", "rebalance", "migrate", "emergency", diff --git a/stacks-alpha-engine/AGENT.md b/stacks-alpha-engine/AGENT.md index cfd17a1..4305c4b 100644 --- a/stacks-alpha-engine/AGENT.md +++ b/stacks-alpha-engine/AGENT.md @@ -53,10 +53,12 @@ description: "Autonomous yield executor that scans 6 tokens across 4 Stacks DeFi ## Protocol-Specific Rules ### Zest v2 -- Supply via `zest_supply` (MCP native) — accepts sBTC, wSTX, stSTX, USDC, USDh -- Withdraw via `zest_withdraw` (MCP native) +- Supply sBTC via `zest_supply` (MCP native; routes to `v0-4-market.supply-collateral-add`) +- Withdraw sBTC via `zest_withdraw` (MCP native; routes to `v0-4-market.collateral-remove-redeem`) +- Borrow USDh via `zest_borrow` (MCP native; routes to `v0-4-market.borrow`) — **USDh only** by `validTokens_borrowRepay` gate. USDCx/wSTX/stSTX return `abort_by_response (err none)` on MCP probe, likely an upstream `borrow-helper-v2-1-7` routing gap; refused to save gas. +- Repay USDh via `zest_repay` (MCP native; routes to `v0-4-market.repay`) - APY read live from vault utilization + interest rate -- Currently low utilization — APY may be 0%. Skip in recommendations unless user forces. +- Currently low supply APY — `deploy --protocol zest` is YTG-gated and typically refuses without `--force`. Borrow path is the interesting leg — see "Leveraged-yield pattern" in SKILL.md. ### Hermetica - Stake USDh via `call_contract` -> `staking-v1-1.stake(amount: uint, affiliate: none)` @@ -91,7 +93,7 @@ When PoR signal is RED or user runs `emergency`: ## What This Agent Does NOT Do - Does not hold private keys or sign transactions directly -- Does not borrow or leverage (yield optimization only) +- Does not borrow any non-USDh Zest asset (refused pre-broadcast; see Zest v2 rules above) - Does not mint USDh via Hermetica minting-v1 (blocked by trait_reference) - Does not add sBTC collateral to Granite borrower-v1 (blocked by trait_reference) - Does not make investment recommendations (data-driven options, not financial advice) diff --git a/stacks-alpha-engine/SKILL.md b/stacks-alpha-engine/SKILL.md index 218685d..ebd904c 100644 --- a/stacks-alpha-engine/SKILL.md +++ b/stacks-alpha-engine/SKILL.md @@ -5,7 +5,7 @@ metadata: author: "cliqueengagements" author-agent: "Micro Basilisk (Agent 77) — SP219TWC8G12CSX5AB093127NC82KYQWEH8ADD1AY | bc1qzh2z92dlvccxq5w756qppzz8fymhgrt2dv8cf5" user-invocable: "false" - arguments: "doctor | scan | deploy | withdraw | rebalance | migrate | emergency | install-packs" + arguments: "doctor | scan | deploy | withdraw | borrow | repay | rebalance | migrate | emergency | install-packs" entry: "stacks-alpha-engine/stacks-alpha-engine.ts" requires: "wallet, signing, settings" tags: "defi, write, mainnet-only, requires-funds, l2" @@ -19,12 +19,12 @@ Cross-protocol yield executor covering **all 4 major Stacks DeFi protocols** — **Protocol coverage:** -| Protocol | Token(s) | Deposit | Withdraw | Method | -|----------|---------|---------|----------|--------| -| Zest v2 | sBTC, wSTX, stSTX, USDC, USDh | `zest_supply` | `zest_withdraw` | MCP native | -| Hermetica | USDh -> sUSDh | `staking-v1-1.stake(amount, affiliate)` | `staking-v1-1.unstake` + `silo.withdraw` | call_contract | -| Granite | aeUSDC | `liquidity-provider-v1.deposit` | `.redeem` (ERC-4626 shares) | call_contract | -| HODLMM | sBTC, STX, USDCx, USDh, aeUSDC (per pool) | `add-liquidity-simple` | `withdraw-liquidity-simple` | Bitflow skill | +| Protocol | Token(s) | Deposit | Withdraw | Debt (borrow/repay) | Method | +|----------|---------|---------|----------|---------------------|--------| +| Zest v2 | sBTC (supply), USDh (borrow) | `zest_supply` | `zest_withdraw` | `zest_borrow` / `zest_repay` — USDh only | MCP native | +| Hermetica | USDh -> sUSDh | `staking-v1-1.stake(amount, affiliate)` | `staking-v1-1.unstake` + `silo.withdraw` | — | call_contract | +| Granite | aeUSDC | `liquidity-provider-v1.deposit` | `.redeem` (ERC-4626 shares) | — | call_contract | +| HODLMM | sBTC, STX, USDCx, USDh, aeUSDC (per pool) | `add-liquidity-simple` | `withdraw-liquidity-simple` | — | Bitflow skill | **3-tier yield mapping:** @@ -41,26 +41,95 @@ No other skill covers all 4 Stacks DeFi protocols with working read AND write pa ## On-chain proof - **Zest sBTC supply**: [txid b8ec03c3ba85c40840cdc933b61a14faf2a9516e1ce1314d9768228f3328803f](https://explorer.hiro.so/txid/b8ec03c3ba85c40840cdc933b61a14faf2a9516e1ce1314d9768228f3328803f?chain=mainnet) — 14,336 zsBTC shares received (block 7,495,066) +- **Zest sBTC supply (refresh)**: [`0x315a6d54…`](https://explorer.hiro.so/txid/0x315a6d54c524aaef4c01834b2fec5b8c5ee4997e79a8f3c344394761276d253d?chain=mainnet) — 10,000 sats → 9,995 zsBTC via `v0-4-market.supply-collateral-add` (same contract MCP `zest_supply` routes to) +- **Zest sBTC withdraw**: [`0x016c3996…`](https://explorer.hiro.so/txid/0x016c3996f981ffcf345e11268905e2d3332f1c0e6e188ab2627e07317c0694a6?chain=mainnet) — 15,335 zsBTC → 15,342 sats sBTC via `v0-4-market.collateral-remove-redeem` +- **Zest USDh borrow**: [`0x2b465aae…`](https://explorer.hiro.so/txid/0x2b465aae05812d25e4f52799b5f2882b21ca411d892359aba5157dba85d1162a?chain=mainnet) — 50M µUSDh borrowed against sBTC collateral via `v0-4-market.borrow` +- **Zest USDh repay**: [`0xd3b46ae7…`](https://explorer.hiro.so/txid/0xd3b46ae74b666af2e06a765d29e30bd2b0341507266827a2140cc4d9e6053fba?chain=mainnet) — full 50M µUSDh debt cleared via `v0-4-market.repay` - **Hermetica staking**: USDh stake via `staking-v1-1.stake` — [`e8b2213d...`](https://explorer.hiro.so/txid/e8b2213d39faf2e9ccfe52bc3cbe33885303aa01c63f93badd3e8a41900a2ecf?chain=mainnet) (block 7,512,730) -- **Granite aeUSDC deposit**: `liquidity-provider-v1.deposit` — [`205bf3f1...`](https://explorer.hiro.so/txid/205bf3f135c5f1cddd8323c1a1a054f3a63ac81904c4244a763b0ce4b26c3352?chain=mainnet) (block 7,512,722) +- **Hermetica unstake**: sUSDh → 7-day silo claim via `staking-v1-1.unstake` — [`0x7834cd32…`](https://explorer.hiro.so/txid/0x7834cd325b986f2db2275b3fe867ca094c3c375d67a77d7f5fb3858d0f94eaad?chain=mainnet) — 408,500,348 sUSDh burned → 5.007 USDh in silo claim 2157 (ratio 1.2257, block 7,703,650) +- **Granite aeUSDC deposit** (write-path proof for `lp-v1.deposit`): [`0x205bf3f1`](https://explorer.hiro.so/txid/0x205bf3f135c5f1cddd8323c1a1a054f3a63ac81904c4244a763b0ce4b26c3352?chain=mainnet) — 4,997,500 µaeUSDC supplied → 4,936,276 lp-token minted on `state-v1` (block 7,512,722) +- **Granite redeem** (with corrected 3-PC shape): [`0xd4aa0c4e…`](https://explorer.hiro.so/txid/0xd4aa0c4ed51b0951e91bb6680e44bc01da36722525fa7b28c39d98219e3eeba9?chain=mainnet) — 4,936,276 lp-token burned → 4,999,538 aeUSDC (ratio 1.0128) - **HODLMM add-liquidity**: [`f2ffb41e...`](https://explorer.hiro.so/txid/f2ffb41e1f29a5c5ee5fa0df628a700e21bf14a4aabbd334b5f49b98bab9e315?chain=mainnet) — dlmm-liquidity-router (block 7,423,687) +## Leveraged-yield pattern + +A composition of Zest supply + Zest borrow + Hermetica stake unlocks positive-carry leveraged yield without selling sBTC. Each leg is a supported skill command: + +```bash +# ---- enter position ---- +deploy --protocol zest --token sbtc --amount # supply sBTC to Zest +borrow --protocol zest --token usdh --amount # take USDh debt (~7% APR) +deploy --protocol hermetica --token usdh --amount # stake for 40% APY + +# ---- earning ~33% positive carry on debt_micro_usdh while sBTC exposure preserved ---- + +# ---- exit position ---- +withdraw --protocol hermetica # unstake sUSDh → creates 7-day silo claim +# ... wait 7 days, then claim via staking-silo-v1-1.withdraw(claim-id) ... +repay --protocol zest --token usdh --amount # close the debt +withdraw --protocol zest # recover sBTC collateral +``` + +**Economic rationale:** Hermetica USDh stake APY (~40% per live scan) − Zest USDh borrow APR (~7%) = **~33% positive carry** on the borrowed amount, with sBTC price exposure retained on-chain. Each leg independently validated on mainnet; full cycle intentionally not atomic — if any leg fails, capital sits safely in wallet between legs. + +### Silo-claim call shape (manual leg) + +After the 7-day cooldown elapses, the silo claim is a single `call_contract` step. The skill does not wrap this leg as a `claim-silo` subcommand because: + +1. **Stateful claim-id.** The `claim-id` is an artifact returned by the prior `staking-v1-1.unstake` response 7 days earlier. Wrapping it requires either persistent skill state (out of scope for a stateless tool) or a `--claim-id ` CLI arg that adds no friction over a direct `call_contract`. +2. **Once-per-unstake event.** Unlike `stake`/`unstake`/`withdraw` which fire repeatedly, silo claim runs exactly once per unstake. Copy-paste from this doc is lower-overhead than another command surface. + +Pre-check the claim is still pending and the cooldown has elapsed: + +```clarity +(contract-call? .staking-silo-v1-1 get-claim u) +;; → (ok { amount: uint, recipient: principal, ts: uint }) +(contract-call? .staking-silo-v1-1 get-current-ts) +;; → must be ≥ claim.ts +``` + +Then submit: + +```ts +call_contract({ + contractAddress: "SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG", + contractName: "staking-silo-v1-1", + functionName: "withdraw", + functionArgs: [{ type: "uint", value: "" }], + postConditionMode: "deny", + postConditions: [{ + type: "ft", + principal: "SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG.staking-silo-v1-1", + asset: "SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG.usdh-token-v1", + assetName: "usdh", + conditionCode: "gte", + amount: "", + }], +}); +``` + +PC mode is `deny` because the silo-claim flow is contract→wallet — opposite direction to `stake`/`unstake` (wallet→contract, handled by `allow + outgoing user lte` per the PC table below). Receive-side floor (`silo sent_gte amount`) mirrors the Granite redeem shape: Stacks FT PCs track outflows from the named principal, so the contract sender is the only place a protective constraint can express on this leg. + +**On-chain proof:** [`0xe1f1598b…`](https://explorer.hiro.so/txid/0xe1f1598b6355f9b7fbe54599ed11e0609a7d1af46265feb0c88482e145902cc5?chain=mainnet) — silo claim u2157, 500,699,105 µUSDh redeemed (block 7,789,631). + ## Safety notes Stacks Alpha Engine uses a **defense-in-depth** approach. Stacks post-conditions are the standard safety mechanism, but DeFi operations that mint or burn tokens (LP shares, sUSDh) cannot be expressed as sender-side post-conditions. The engine compensates with layered gates that must all pass before any write executes. -### Why `postConditionMode: "allow"` +### Post-condition modes per operation -Deposit, stake, unstake, and swap paths use `postConditionMode: "allow"` because: +The engine uses `postConditionMode: "deny"` only where the on-chain flow is unambiguous +(fixed, sender-expressible FT movements). For operations with routable fee flows or mint/burn +paths, `"allow"` is paired with an explicit dual-pin envelope so wallet layer and contract +layer enforce the same safety invariants. -| Operation | Why `deny` mode is impossible | -|-----------|-------------------------------| -| Hermetica stake | Mints sUSDh back to caller — mint is not a sender-side transfer | -| Hermetica unstake | Burns sUSDh and creates a claim — burn is not expressible as sender post-condition | -| Granite deposit | Mints LP tokens back to caller — same mint issue | -| DLMM swap | Router may touch intermediate pools — sender can't predict exact hops | - -Where `deny` mode IS possible, the engine uses it. Granite `redeem` has explicit post-conditions: `lte` cap on pool outflow + `gte: "1"` floor on wallet receive. +| Operation | Mode | Rationale | +|-----------|------|-----------| +| DLMM swap (`swap-simple-multi`) | **allow** + dual-pin | Envelope: `Pc.principal(sender).willSendLte(amount_in)` on input + `Pc.principal(pool).willSendGte(min_out)` on output. Matches the sibling skill's pattern validated in [`bff-skills#494`](https://github.com/BitflowFinance/bff-skills/pull/494) (commit [`02d1098c`](https://github.com/cliqueengagements/bff-skills/commit/02d1098c), on-chain proof tx [`0xf4f49328…`](https://explorer.hiro.so/txid/0xf4f4932800a80234845a8d199556ad9c0ff4aa99874a95c819c13779b164cbc8?chain=mainnet)). Allow mode preserved because protocol/provider fees accrue inside `dlmm-core`'s `unclaimed-protocol-fees` map and bin balances without emitting FT transfer events on the swap tx; the pool-side `willSendGte` pin IS the receive-side fund-safety protection. Empirically Deny + 2 PCs under-specifies stable-stable pools (tx [`0x5986066a…`](https://explorer.hiro.so/txid/0x5986066a93b3c8e6466d4f3f2da33a4fbe3e703fe81ca2dc23b0fe0d5f945531?chain=mainnet) aborted on dlmm_7). | +| Granite `redeem` | **deny** | 3-PC envelope (rebuilt in commit `3c12b0f` against on-chain reference tx [`0xd0bb0059…`](https://explorer.hiro.so/txid/0xd0bb0059b72e5f5d75a4dd1bedb12e44e32790567bc282184ca5309641a8f44f?chain=mainnet) and proof tx [`0xd4aa0c4e…`](https://explorer.hiro.so/txid/0xd4aa0c4ed51b0951e91bb6680e44bc01da36722525fa7b28c39d98219e3eeba9?chain=mainnet)): pool (`state-v1`) sends aeUSDC `gte` shares (receive-side floor) + pool sends aeUSDC `lte` shares × 2 (defensive overpayment cap, per @arc0btc's review) + wallet sends `lp-token` `gte` shares (burn-side floor on caller). Two distinct FT flows (pool → caller for aeUSDC; caller → contract for lp-token burn) so each leg is bound separately. The earlier shape (`lte` on `liquidity-provider-v1` + `gte:"1"` on wallet receive) aborted on-chain in both Deny ([`0x5780062068…`](https://explorer.hiro.so/txid/0x5780062068?chain=mainnet)) and Allow ([`0x60e2f84b83…`](https://explorer.hiro.so/txid/0x60e2f84b83?chain=mainnet)) modes because the principal/asset bindings did not match the real flow. | +| Hermetica `stake` | allow | Mints sUSDh back to caller — mint is not a sender-side transfer. Outgoing USDh `lte` PC asserted as belt-and-suspenders. | +| Hermetica `unstake` | allow | Burns sUSDh and creates a claim — burn is not expressible as sender PC. Outgoing sUSDh `lte` PC asserted. | +| Granite `deposit` | allow | Mints LP tokens back to caller — same mint issue. Outgoing aeUSDC `lte` PC asserted. | ### What provides safety instead @@ -111,6 +180,8 @@ All commands output JSON to stdout: | `scan` | read | Full report: 6 tokens, 4 protocols, 3-tier yields, PoR, safety gates | | `deploy` | write | Deploy capital to a protocol (with --token flag for specific token) | | `withdraw` | write | Pull capital from a specific protocol | +| `borrow` | write | Borrow a debt asset against existing Zest collateral (USDh only — leveraged-yield leg) | +| `repay` | write | Repay a borrowed Zest debt asset | | `rebalance` | write | Withdraw out-of-range HODLMM bins, re-add centered on active bin | | `migrate` | write | Cross-protocol capital movement (withdraw A + deploy B) | | `emergency` | write | Withdraw ALL positions across all 4 protocols | @@ -118,12 +189,12 @@ All commands output JSON to stdout: ## Write Paths (verified on-chain) -| Protocol | Deposit | Withdraw | Token | Method | -|----------|---------|----------|-------|--------| -| Zest v2 | `zest_supply` | `zest_withdraw` | sBTC | MCP native | -| Hermetica | `staking-v1-1.stake(uint, optional buff)` | `staking-v1-1.unstake(uint)` + `silo-v1-1.withdraw(uint)` | USDh/sUSDh | call_contract | -| Granite | `lp-v1.deposit(assets, principal)` | `lp-v1.redeem(shares, principal)` | aeUSDC | call_contract | -| HODLMM | `add-liquidity-simple` | `withdraw-liquidity-simple` | per pool pair | Bitflow skill | +| Protocol | Deposit | Withdraw | Debt (borrow/repay) | Token | Method | +|----------|---------|----------|---------------------|-------|--------| +| Zest v2 | `zest_supply` | `zest_withdraw` | `zest_borrow` / `zest_repay` (USDh only) | sBTC (supply), USDh (borrow) | MCP native | +| Hermetica | `staking-v1-1.stake(uint, optional buff)` | `staking-v1-1.unstake(uint)` + `silo-v1-1.withdraw(uint)` | — | USDh/sUSDh | call_contract | +| Granite | `lp-v1.deposit(assets, principal)` | `lp-v1.redeem(shares, principal)` | — | aeUSDC | call_contract | +| HODLMM | `add-liquidity-simple` | `withdraw-liquidity-simple` | — | per pool pair | Bitflow skill | All 4 protocols have **zero trait_reference** requirements in their write paths. @@ -160,6 +231,9 @@ All 4 protocols have **zero trait_reference** requirements in their write paths. ### Granite Borrower Path (Blocked) Granite `borrower-v1.add-collateral` requires `trait_reference` — blocked by MCP. The engine uses the **LP deposit path** (aeUSDC supply) which works without trait_reference. +### Zest borrow/repay asset restriction (USDh only) +`zest_borrow` via MCP succeeds only for USDh. Probes against USDCx, wSTX, and stSTX on the same wallet + sBTC collateral + cap-debt headroom all return `abort_by_response (err none)` on `v0-4-market.borrow`. Suspected root cause: upstream MCP routing gap around `borrow-helper-v2-1-7` (the Pyth oracle fee wrapper). The skill refuses non-USDh borrow with `zest borrow does not accept . Valid: usdh` to save gas rather than broadcast known-failing txs. Tracked separately from this skill; if/when fixed upstream, `validTokens_borrowRepay` can be widened without further code changes. + ### Hermetica Minting (Blocked) Hermetica `minting-v1.request-mint` requires 4x `trait_reference`. Workaround: swap via Bitflow DLMM router (`dlmm-swap-router-v-1-1.swap-simple-multi`) then stake. The engine generates executable `call_contract` instructions for both steps. diff --git a/stacks-alpha-engine/stacks-alpha-engine.ts b/stacks-alpha-engine/stacks-alpha-engine.ts index f416401..f3ae080 100644 --- a/stacks-alpha-engine/stacks-alpha-engine.ts +++ b/stacks-alpha-engine/stacks-alpha-engine.ts @@ -16,10 +16,10 @@ * Safety: every write runs Scout -> Reserve -> Guardian -> Executor. No bypasses. * * Protocols & tokens: - * Zest — supply sBTC, wSTX, stSTX, USDC, USDh (MCP native zest_supply/withdraw) - * Hermetica — stake USDh -> sUSDh (call_contract staking-v1-1) - * Granite — deposit aeUSDC to LP (call_contract liquidity-provider-v1) - * HODLMM — LP in sBTC/STX/USDCx/USDh/aeUSDC pools (Bitflow skill) + * Zest — supply/withdraw sBTC; borrow/repay USDh (MCP native zest_supply/withdraw/borrow/repay) + * Hermetica — stake USDh -> sUSDh (call_contract staking-v1-1) + * Granite — deposit aeUSDC to LP (call_contract liquidity-provider-v1) + * HODLMM — LP in sBTC/STX/USDCx/USDh/aeUSDC pools (Bitflow skill) * * Usage: * bun run stacks-alpha-engine/stacks-alpha-engine.ts doctor @@ -27,7 +27,7 @@ * bun run stacks-alpha-engine/stacks-alpha-engine.ts deploy --wallet --protocol hermetica --token usdh --amount 1000000 * bun run stacks-alpha-engine/stacks-alpha-engine.ts withdraw --wallet --protocol zest --token sbtc * bun run stacks-alpha-engine/stacks-alpha-engine.ts rebalance --wallet --pool-id dlmm_1 - * bun run stacks-alpha-engine/stacks-alpha-engine.ts migrate --wallet --from zest --to hermetica + * bun run stacks-alpha-engine/stacks-alpha-engine.ts migrate --wallet --from zest --to hermetica --amount 1000000 * bun run stacks-alpha-engine/stacks-alpha-engine.ts emergency --wallet */ @@ -104,12 +104,18 @@ interface TokenMeta { symbol: string; contract: string; decimals: number; ftSuff const TOKENS: Record = { sbtc: { symbol: "sBTC", contract: SBTC_TOKEN, decimals: 8, ftSuffix: "::sbtc-token" }, stx: { symbol: "STX", contract: "stx", decimals: 6, ftSuffix: "" }, - usdcx: { symbol: "USDCx", contract: USDCX_TOKEN, decimals: 6, ftSuffix: "::usdcx" }, - usdh: { symbol: "USDh", contract: USDH_TOKEN, decimals: 8, ftSuffix: "::usdh-token" }, - susdh: { symbol: "sUSDh", contract: SUSDH_TOKEN, decimals: 8, ftSuffix: "::susdh-token" }, - aeusdc: { symbol: "aeUSDC", contract: AEUSDC_TOKEN, decimals: 6, ftSuffix: "::bridged-usdc" }, + usdcx: { symbol: "USDCx", contract: USDCX_TOKEN, decimals: 6, ftSuffix: "::usdcx-token" }, + usdh: { symbol: "USDh", contract: USDH_TOKEN, decimals: 8, ftSuffix: "::usdh" }, + susdh: { symbol: "sUSDh", contract: SUSDH_TOKEN, decimals: 8, ftSuffix: "::susdh" }, + aeusdc: { symbol: "aeUSDC", contract: AEUSDC_TOKEN, decimals: 6, ftSuffix: "::aeUSDC" }, }; +// Reverse lookup: token contract principal → TokenMeta. Used to derive asset_name + decimals +// from on-chain route data (xToken/yToken/xForY) when building DLMM swap post-conditions. +const TOKENS_BY_CONTRACT: Record = Object.fromEntries( + Object.values(TOKENS).filter(t => t.contract !== "stx").map(t => [t.contract, t]), +); + // == Types ==================================================================== interface PoolDef { id: number; contract: string; name: string; tokenX: string; tokenY: string } interface TokenBalance { amount: number; usd: number } @@ -964,49 +970,70 @@ function writeState(state: EngineState): void { writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); } -async function checkGuardian(scout: ScoutResult): Promise { +async function checkGuardian( + scout: ScoutResult, + opts: { targetPoolId?: string } = {}, +): Promise { const refusals: string[] = []; + // Target pool for slippage + volume gates. Defaults to dlmm_1 (sBTC/USDCx canonical pool) + // for read-only `scan` and any operation that doesn't pre-resolve a route. Callers that + // know which pool the upcoming write will touch (e.g. hermetica deploy → dlmm_8 USDh/USDCx, + // granite deploy → dlmm_7 aeUSDC/USDCx) should pass the actual pool so the gate measures + // the right liquidity venue. + const targetPoolId = opts.targetPoolId ?? "dlmm_1"; + const targetPoolDef = HODLMM_POOLS.find(p => `dlmm_${p.id}` === targetPoolId) ?? HODLMM_POOLS[0]; + // 1. Price source gate const pricesOk = scout.prices.sbtc > 0 && scout.prices.stx > 0; if (!pricesOk) refusals.push("Price data unavailable — cannot calculate USD values safely"); - // 2. Slippage check (HODLMM active bin vs market price) + // 2. Slippage check (HODLMM active bin vs market price) — measured against targetPoolId let slippagePct = 0; let slippageOk = true; const guardianPools = await fetchBitflowPools().catch(() => [] as BitflowPoolData[]); try { - const dlmm1 = guardianPools.find(p => p.poolId === "dlmm_1"); - if (dlmm1?.tokens) { - const pool1 = HODLMM_POOLS[0]; - const abr = await callReadOnly(pool1.contract, "get-active-bin-id", []); + const targetPool = guardianPools.find(p => p.poolId === targetPoolId); + if (targetPool?.tokens) { + const abr = await callReadOnly(targetPoolDef.contract, "get-active-bin-id", []); if (abr.okay && abr.result) { - const binsData = await fetchJson<{ bins?: Array<{ bin_id: number; price?: string }>; active_bin_id?: number }>(`${BITFLOW_API}/api/quotes/v1/bins/dlmm_1`); + const binsData = await fetchJson<{ bins?: Array<{ bin_id: number; price?: string }>; active_bin_id?: number }>(`${BITFLOW_API}/api/quotes/v1/bins/${targetPoolId}`); const activeBinId = binsData.active_bin_id ?? 0; const activeBinData = binsData.bins?.find(b => b.bin_id === activeBinId); if (activeBinData?.price) { const binPrice = parseFloat(activeBinData.price); - const hodlmmPriceUsd = (binPrice / PRICE_SCALE) * Math.pow(10, dlmm1.tokens.tokenX.decimals - dlmm1.tokens.tokenY.decimals); - const marketPrice = dlmm1.tokens.tokenX.priceUsd; + const hodlmmPriceUsd = (binPrice / PRICE_SCALE) * Math.pow(10, targetPool.tokens.tokenX.decimals - targetPool.tokens.tokenY.decimals); + const marketPrice = targetPool.tokens.tokenX.priceUsd; if (marketPrice > 0) { slippagePct = round(Math.abs(hodlmmPriceUsd - marketPrice) / marketPrice * 100, 4); slippageOk = slippagePct <= MAX_SLIPPAGE_PCT; - if (!slippageOk) refusals.push(`Slippage ${slippagePct}% > ${MAX_SLIPPAGE_PCT}% cap`); + if (!slippageOk) refusals.push(`Slippage ${slippagePct}% > ${MAX_SLIPPAGE_PCT}% cap on ${targetPoolId}`); } } } } - } catch { /* slippage check unavailable — allow */ } + } catch (e) { + // Fail-CLOSED: a thrown slippage check used to silently leave slippageOk=true + // (the initial value), letting writes proceed against unmeasured price drift. + // Refuse instead and record the reason so the operator sees why. + slippageOk = false; + refusals.push(`Slippage check unavailable (${(e as Error).message ?? "error"}) — refusing as safety default`); + } - // 3. Volume gate + // 3. Volume gate — measured against targetPoolId, not always dlmm_1 let volumeUsd = 0; let volumeOk = true; try { - const dlmm1 = guardianPools.find(p => p.poolId === "dlmm_1"); - volumeUsd = dlmm1?.volumeUsd1d ?? 0; + const targetPool = guardianPools.find(p => p.poolId === targetPoolId); + volumeUsd = targetPool?.volumeUsd1d ?? 0; volumeOk = volumeUsd >= MIN_24H_VOLUME_USD; - if (!volumeOk) refusals.push(`24h volume $${Math.round(volumeUsd)} < $${MIN_24H_VOLUME_USD} minimum`); - } catch { /* unavailable */ } + if (!volumeOk) refusals.push(`24h volume $${Math.round(volumeUsd)} on ${targetPoolId} < $${MIN_24H_VOLUME_USD} minimum`); + } catch (e) { + // Fail-CLOSED on volume API failure — refusing is safer than allowing a + // write into a pool whose liquidity we couldn't measure. + volumeOk = false; + refusals.push(`Volume check unavailable (${(e as Error).message ?? "error"}) — refusing as safety default`); + } // 4. Gas gate let gasStx = 0; @@ -1016,7 +1043,12 @@ async function checkGuardian(scout: ScoutResult): Promise { gasStx = round((fees.transfer_fee_estimate ?? 6) * 3600 / 1e6, 2); gasOk = gasStx <= MAX_GAS_STX; if (!gasOk) refusals.push(`Estimated gas ${gasStx} STX > ${MAX_GAS_STX} STX cap`); - } catch { /* allow */ } + } catch (e) { + // Fail-CLOSED on Hiro fees endpoint failure — without a gas estimate we + // could pay 10-100x normal cost, so refuse rather than guess. + gasOk = false; + refusals.push(`Gas estimate unavailable (${(e as Error).message ?? "error"}) — refusing as safety default`); + } // 5. Cooldown const state = readState(); @@ -1060,10 +1092,12 @@ interface ExecuteInstruction { // Maps (tokenIn, tokenOut) to the DLMM pool and direction for swap-simple-multi. // Each route is a single-hop swap through a known Bitflow DLMM pool. interface DlmmSwapRoute { - pool: string; // pool contract principal - xToken: string; // x-token-trait principal (the pool's X token contract) - yToken: string; // y-token-trait principal (the pool's Y token contract) - xForY: boolean; // true = selling X for Y, false = selling Y for X + pool: string; // pool contract principal + xToken: string; // x-token-trait principal (the pool's X token contract) + yToken: string; // y-token-trait principal (the pool's Y token contract) + xForY: boolean; // true = selling X for Y, false = selling Y for X + inputSymbol: string; // symbol of the input token (key into TOKENS) + outputSymbol: string; // symbol of the output token (key into TOKENS) } function getDlmmSwapRoute(tokenIn: string, tokenOut: string): DlmmSwapRoute | null { @@ -1072,6 +1106,7 @@ function getDlmmSwapRoute(tokenIn: string, tokenOut: string): DlmmSwapRoute | nu return { pool: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-aeusdc-usdcx-v-1-bps-1", xToken: AEUSDC_TOKEN, yToken: USDCX_TOKEN, xForY: false, + inputSymbol: tokenIn, outputSymbol: tokenOut, }; } // USDCx → USDh (pool: USDh/USDCx, selling Y for X) @@ -1079,6 +1114,7 @@ function getDlmmSwapRoute(tokenIn: string, tokenOut: string): DlmmSwapRoute | nu return { pool: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-usdh-usdcx-v-1-bps-1", xToken: USDH_TOKEN, yToken: USDCX_TOKEN, xForY: false, + inputSymbol: tokenIn, outputSymbol: tokenOut, }; } // sBTC → USDCx (pool: sBTC/USDCx 10bps, selling X for Y) @@ -1086,6 +1122,7 @@ function getDlmmSwapRoute(tokenIn: string, tokenOut: string): DlmmSwapRoute | nu return { pool: "SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-pool-sbtc-usdcx-v-1-bps-10", xToken: SBTC_TOKEN, yToken: USDCX_TOKEN, xForY: true, + inputSymbol: tokenIn, outputSymbol: tokenOut, }; } return null; @@ -1099,11 +1136,109 @@ function defaultSlippagePct(route: DlmmSwapRoute): number { return bothStable ? 0.5 : 3; } +// Map an upcoming operation to the DLMM pool whose liquidity it will actually touch, +// so the Guardian's slippage + volume gates can measure the right venue. Defaults to +// dlmm_1 (the canonical sBTC/USDCx pool) when an operation doesn't pre-resolve a route +// or when the touched pool isn't a HODLMM pool tracked here. +function inferTargetPoolId(command: string, opts: Record): string { + const protocol = opts.protocol; + const token = opts.token; + if (command === "deploy") { + if (protocol === "hermetica" && token && token !== "usdh") return "dlmm_8"; // USDh/USDCx swap pool + if (protocol === "granite" && token && token !== "aeusdc") return "dlmm_7"; // aeUSDC/USDCx swap pool + if (protocol === "hodlmm") return ((opts as Record).poolId ?? "dlmm_1"); // honor --pool-id for HODLMM direct deploy + return "dlmm_1"; + } + if (command === "migrate") { + if (opts.to === "hermetica") return "dlmm_8"; + if (opts.to === "granite") return "dlmm_7"; + if (opts.to === "hodlmm") return ((opts as Record).poolId ?? "dlmm_1"); + return "dlmm_1"; + } + return "dlmm_1"; +} + +// Estimate expected swap output in output-token atomic units, given input amount + scout prices. +// Used by callers of buildDlmmSwapInstruction to derive the min-received guard correctly. +function expectedSwapOutput( + inputAmount: number, + inputSymbol: string, + outputSymbol: string, + prices: { sbtc: number; stx: number; usdcx: number; usdh: number; aeusdc: number }, +): number { + const inputMeta = TOKENS[inputSymbol]; + const outputMeta = TOKENS[outputSymbol]; + if (!inputMeta || !outputMeta) return 0; + const priceMap = prices as Record; + const inputPrice = priceMap[inputSymbol] ?? (["usdcx","usdh","aeusdc","susdh"].includes(inputSymbol) ? 1 : 0); + const outputPrice = priceMap[outputSymbol] ?? (["usdcx","usdh","aeusdc","susdh"].includes(outputSymbol) ? 1 : 0); + if (inputPrice <= 0 || outputPrice <= 0) return 0; + const inputUsd = (inputAmount / Math.pow(10, inputMeta.decimals)) * inputPrice; + const expectedOutUnits = inputUsd / outputPrice; + return Math.floor(expectedOutUnits * Math.pow(10, outputMeta.decimals)); +} + // Build a call_contract instruction for a Bitflow DLMM swap. // Uses swap-simple-multi with a single swap in the list. -function buildDlmmSwapInstruction(route: DlmmSwapRoute, amount: number, slippagePct?: number): ExecuteInstruction { +// +// Safety pattern (matches sibling skill bff-skills#494 commit 02d1098c, on-chain +// proof tx 0xf4f4932800a80234845a8d199556ad9c0ff4aa99874a95c819c13779b164cbc8): +// - postConditionMode: "allow" with a dual-pin envelope +// - 2-entry post-conditions: caller's max input (willSendLte) + pool's min output (willSendGte) +// - min-received computed in OUTPUT-token atomic units (caller passes `expectedOut`) +// - max-steps u230 (per macbotmini-eng's #339 audit (d)) +// +// Rationale for Allow-not-Deny: @macbotmini-eng's #494 audit established that DLMM swap +// fees accrue inside dlmm-core's unclaimed-protocol-fees map and bin balances — they do +// NOT emit FT transfer events on the swap tx (verified on-chain against 0x134df5e1 / +// 0x5195822e / #494 proof tx 0xf4f49328). Under that verified fee flow, the pool-side +// willSendGte pin IS the receive-side fund-safety protection; Deny mode adds no further +// guarantee AND empirically over-constrains on stable-stable pools (tx 0x5986066a on +// dlmm_7 aborted with abort_by_post_condition under Deny+2PC while the same envelope +// shape works on dlmm_1 / dlmm_6 / dlmm_3 under Allow). Deny mode is reserved here +// for unambiguous flows where the FT movement is fully sender-expressible — the +// Granite redeem path below is the example. +function buildDlmmSwapInstruction( + route: DlmmSwapRoute, + caller: string, + amount: number, + expectedOut: number, + slippagePct?: number, +): ExecuteInstruction { slippagePct = slippagePct ?? defaultSlippagePct(route); - const minReceived = Math.floor(amount * (1 - slippagePct / 100)); + // min-received is in OUTPUT-token atomic units (router arg expectation, verified against mainnet refs). + const minReceived = Math.max(1, Math.floor(expectedOut * (1 - slippagePct / 100))); + + // Derive actual on-chain swap assets from route direction (single source of truth). + const inputAsset = route.xForY ? route.xToken : route.yToken; + const outputAsset = route.xForY ? route.yToken : route.xToken; + const inputMeta = TOKENS_BY_CONTRACT[inputAsset]; + const outputMeta = TOKENS_BY_CONTRACT[outputAsset]; + + // 2-entry post-condition envelope mirroring the author's mainnet pattern: + // PC[0] caller sends ≤ amount of input asset + // PC[1] pool sends ≥ minReceived of output asset (output sourced from pool reserves) + const postConditions: Array> = [ + { + type: "ft", + principal: caller, + asset: inputAsset, + assetName: inputMeta?.ftSuffix.replace("::", "") ?? "", + conditionCode: "lte", + amount: String(amount), + }, + { + type: "ft", + principal: route.pool, + asset: outputAsset, + assetName: outputMeta?.ftSuffix.replace("::", "") ?? "", + conditionCode: "gte", + amount: String(minReceived), + }, + ]; + + const inSym = inputMeta?.symbol ?? route.inputSymbol; + const outSym = outputMeta?.symbol ?? route.outputSymbol; return { tool: "call_contract", params: { @@ -1114,7 +1249,7 @@ function buildDlmmSwapInstruction(route: DlmmSwapRoute, amount: number, slippage type: "list", value: [{ type: "tuple", value: { amount: { type: "uint", value: String(amount) }, - "max-steps": { type: "uint", value: "6" }, + "max-steps": { type: "uint", value: "230" }, "min-received": { type: "uint", value: String(minReceived) }, "pool-trait": { type: "principal", value: route.pool }, "x-for-y": { type: "bool", value: route.xForY }, @@ -1124,12 +1259,15 @@ function buildDlmmSwapInstruction(route: DlmmSwapRoute, amount: number, slippage }], }], postConditionMode: "allow", + postConditions, + requires_residual_check: true, + _note: "Agent runtime: read consumed-in from tx receipt before chained deploy step. If consumed-in < amount, surface residual to caller (max-steps may have capped fold).", }, - description: `Swap ${amount} via Bitflow DLMM (${route.xForY ? "X→Y" : "Y→X"}, min-received: ${minReceived}, ${slippagePct}% slippage)`, + description: `Swap ${amount} ${inSym} → min ${minReceived} ${outSym} via Bitflow DLMM (allow + dual-pin, ${slippagePct}% slip)`, }; } -function buildDeployInstructions(protocol: Protocol, amount: number, token: string, scout: ScoutResult): ExecuteInstruction[] { +function buildDeployInstructions(protocol: Protocol, amount: number, token: string, scout: ScoutResult, poolId: string = "dlmm_1"): ExecuteInstruction[] { const instructions: ExecuteInstruction[] = []; const wallet = scout.wallet; @@ -1159,7 +1297,7 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri // allow mode required: staking mints sUSDh back to caller (not expressible as sender-side PC). // Belt-and-suspenders: outgoing USDh transfer is still asserted. postConditions: [ - { type: "ft", principal: wallet, asset: USDH_TOKEN, assetName: "usdh-token", conditionCode: "lte", amount }, + { type: "ft", principal: wallet, asset: USDH_TOKEN, assetName: "usdh", conditionCode: "lte", amount }, ], }, description: `Stake ${amount} USDh into Hermetica sUSDh (earning yield)`, @@ -1171,10 +1309,15 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri instructions.push({ tool: "info", params: {}, description: `No DLMM swap route from ${token} to USDh. Acquire USDh manually.` }); break; } - instructions.push(buildDlmmSwapInstruction(swapRoute, amount)); - // Step 2 amount depends on Step 1 swap output — use input amount as estimate + const expectedUsdh = expectedSwapOutput(amount, token, "usdh", scout.prices); + if (expectedUsdh <= 0) { + instructions.push({ tool: "info", params: {}, description: `Cannot swap ${token} → USDh: price feed unavailable to size min-received guard. Re-run after prices recover.` }); + break; + } + instructions.push(buildDlmmSwapInstruction(swapRoute, wallet, amount, expectedUsdh)); + // Step 2 amount depends on Step 1 swap output — use expected output as estimate // Agent must read swap tx result and substitute actual received amount before executing - const hermeticaEstimate = String(amount); + const hermeticaEstimate = String(expectedUsdh); instructions.push({ tool: "call_contract", params: { @@ -1186,7 +1329,7 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri // allow mode required: staking mints sUSDh (not expressible as sender-side PC). // Belt-and-suspenders: outgoing USDh transfer is still asserted. postConditions: [ - { type: "ft", principal: wallet, asset: USDH_TOKEN, assetName: "usdh-token", conditionCode: "lte", amount: hermeticaEstimate }, + { type: "ft", principal: wallet, asset: USDH_TOKEN, assetName: "usdh", conditionCode: "lte", amount: hermeticaEstimate }, ], requires_substitution: true, _note: "SEQUENTIAL: execute after Step 1 confirms. Replace amount with actual swap output from tx receipt.", @@ -1216,7 +1359,7 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri // allow mode required: deposit mints LP tokens back to caller (not expressible as sender-side PC). // Belt-and-suspenders: outgoing aeUSDC transfer is still asserted. postConditions: [ - { type: "ft", principal: wallet, asset: AEUSDC_TOKEN, assetName: "bridged-usdc", conditionCode: "lte", amount }, + { type: "ft", principal: wallet, asset: AEUSDC_TOKEN, assetName: "aeUSDC", conditionCode: "lte", amount }, ], }, description: `Deposit ${amount} aeUSDC to Granite lending pool`, @@ -1228,10 +1371,15 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri instructions.push({ tool: "info", params: {}, description: `No DLMM swap route from ${token} to aeUSDC. Acquire aeUSDC manually.` }); break; } - instructions.push(buildDlmmSwapInstruction(swapRoute, amount)); - // Step 2 amount depends on Step 1 swap output — use input amount as estimate + const expectedAeusdc = expectedSwapOutput(amount, token, "aeusdc", scout.prices); + if (expectedAeusdc <= 0) { + instructions.push({ tool: "info", params: {}, description: `Cannot swap ${token} → aeUSDC: price feed unavailable to size min-received guard. Re-run after prices recover.` }); + break; + } + instructions.push(buildDlmmSwapInstruction(swapRoute, wallet, amount, expectedAeusdc)); + // Step 2 amount depends on Step 1 swap output — use expected output as estimate // Agent must read swap tx result and substitute actual received amount before executing - const graniteEstimate = String(amount); + const graniteEstimate = String(expectedAeusdc); instructions.push({ tool: "call_contract", params: { @@ -1246,7 +1394,7 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri // allow mode required: deposit mints LP tokens (not expressible as sender-side PC). // Belt-and-suspenders: outgoing aeUSDC transfer is still asserted. postConditions: [ - { type: "ft", principal: wallet, asset: AEUSDC_TOKEN, assetName: "bridged-usdc", conditionCode: "lte", amount: graniteEstimate }, + { type: "ft", principal: wallet, asset: AEUSDC_TOKEN, assetName: "aeUSDC", conditionCode: "lte", amount: graniteEstimate }, ], requires_substitution: true, _note: "SEQUENTIAL: execute after Step 1 confirms. Replace amount with actual swap output from tx receipt.", @@ -1257,23 +1405,77 @@ function buildDeployInstructions(protocol: Protocol, amount: number, token: stri break; case "hodlmm": { - const hasSbtc = scout.balances.sbtc.amount > 0; - const hasUsdcx = scout.balances.usdcx.amount > 0; + // Parametric on --pool-id (mirrors the rebalance path pattern). Resolves the + // pool definition from HODLMM_POOLS and generalises bin construction to use + // the chosen pool's tokenX/tokenY instead of the prior sbtc/usdcx hardcode. + // Refuses if the caller's --token does not match either of the pool's tokens + // (safety floor — prevents silent no-op deposits when the token is unrelated + // to the pool). + const pool = HODLMM_POOLS.find(p => `dlmm_${p.id}` === poolId); + if (!pool) { + instructions.push({ + tool: "info", + params: {}, + description: `Unknown HODLMM pool ${poolId}. Known pools: ${HODLMM_POOLS.map(p => `dlmm_${p.id}`).join(", ")}.`, + }); + break; + } + if (token !== pool.tokenX && token !== pool.tokenY) { + instructions.push({ + tool: "info", + params: {}, + description: `HODLMM pool ${poolId} (${pool.name}) accepts ${pool.tokenX.toUpperCase()} or ${pool.tokenY.toUpperCase()} only (got ${token}). Acquire ${pool.tokenX}/${pool.tokenY} first or choose a different --pool-id.`, + }); + break; + } + + // Read atomic balances of the pool's two tokens from scout. `scout.balances` + // is typed with fixed keys, so we cast through Record to index by + // the pool's token-symbol strings. + const balances = scout.balances as unknown as Record; + const xMeta = TOKENS[pool.tokenX]; + const yMeta = TOKENS[pool.tokenY]; + const xBalAtomic = Math.floor((balances[pool.tokenX]?.amount ?? 0) * Math.pow(10, xMeta?.decimals ?? 6)); + const yBalAtomic = Math.floor((balances[pool.tokenY]?.amount ?? 0) * Math.pow(10, yMeta?.decimals ?? 6)); + const hasX = xBalAtomic > 0; + const hasY = yBalAtomic > 0; + + if (!hasX && !hasY) { + instructions.push({ + tool: "info", + params: {}, + description: `HODLMM deploy to ${poolId} (${pool.name}) requires ${pool.tokenX.toUpperCase()} and/or ${pool.tokenY.toUpperCase()} in wallet — neither present.`, + }); + break; + } + const bins: Array<{ activeBinOffset: number; xAmount: string; yAmount: string }> = []; - if (hasSbtc && hasUsdcx) { - bins.push({ activeBinOffset: 0, xAmount: String(amount), yAmount: String(Math.floor(scout.balances.usdcx.amount * 1e6)) }); - } else if (hasSbtc) { - for (let i = 1; i <= 5; i++) bins.push({ activeBinOffset: i, xAmount: String(Math.floor(amount / 5)), yAmount: "0" }); - } else if (hasUsdcx) { - const usdcxMicro = Math.floor(scout.balances.usdcx.amount * 1e6); - for (let i = -5; i <= -1; i++) bins.push({ activeBinOffset: i, xAmount: "0", yAmount: String(Math.floor(usdcxMicro / 5)) }); + if (hasX && hasY) { + // Both tokens at active bin: dlmm-core allows both nonzero at bin-id == active-bin-id. + // `amount` is interpreted as the chosen --token's atomic amount; the counter-token + // pairs the full available balance. + const xAmt = token === pool.tokenX ? amount : xBalAtomic; + const yAmt = token === pool.tokenY ? amount : yBalAtomic; + bins.push({ activeBinOffset: 0, xAmount: String(xAmt), yAmount: String(yAmt) }); + } else if (hasX) { + // X-only deposit. Per dlmm-core-v-1-1 invariant (lines 1672-1674): + // (asserts! (or (>= bin-id active-bin-id) (is-eq x-amount u0)) ERR_INVALID_X_AMOUNT) + // X amounts are only allowed at bin-id ≥ active-bin-id — above (or at) active. + const xAmt = token === pool.tokenX ? amount : xBalAtomic; + for (let i = 1; i <= 5; i++) bins.push({ activeBinOffset: i, xAmount: String(Math.floor(xAmt / 5)), yAmount: "0" }); + } else { + // Y-only deposit. Per dlmm-core-v-1-1 invariant (lines 1672-1674): + // (asserts! (or (<= bin-id active-bin-id) (is-eq y-amount u0)) ERR_INVALID_Y_AMOUNT) + // Y amounts are only allowed at bin-id ≤ active-bin-id — below (or at) active. + const yAmt = token === pool.tokenY ? amount : yBalAtomic; + for (let i = -5; i <= -1; i++) bins.push({ activeBinOffset: i, xAmount: "0", yAmount: String(Math.floor(yAmt / 5)) }); } instructions.push({ tool: "bitflow:add-liquidity-simple", - params: { poolId: "dlmm_1", bins: JSON.stringify(bins) }, - description: `Add liquidity to HODLMM sBTC-USDCx-10bps pool (${bins.length} bins)`, + params: { poolId, bins: JSON.stringify(bins) }, + description: `Add liquidity to HODLMM ${pool.name} (${bins.length} bins)`, }); break; } @@ -1306,7 +1508,7 @@ function buildWithdrawInstructions(protocol: Protocol, scout: ScoutResult): Exec // allow mode required: unstake burns sUSDh and creates a claim (not expressible as sender-side PC). // Belt-and-suspenders: outgoing sUSDh transfer is still asserted. postConditions: [ - { type: "ft", principal: wallet, asset: SUSDH_TOKEN, assetName: "susdh-token", conditionCode: "lte", amount: String(susdhSats) }, + { type: "ft", principal: wallet, asset: SUSDH_TOKEN, assetName: "susdh", conditionCode: "lte", amount: String(susdhSats) }, ], }, description: `Unstake ${susdhSats} sUSDh (creates claim in staking-silo)`, @@ -1324,28 +1526,50 @@ function buildWithdrawInstructions(protocol: Protocol, scout: ScoutResult): Exec const granitePos = scout.positions.granite; const shares = granitePos.lp_shares ?? "0"; if (shares === "0") return [{ tool: "info", params: {}, description: "No Granite LP position to withdraw" }]; - // Granite follows ERC-4626: redeem(shares) burns share count, withdraw(assets) takes asset amount. - // We have the share count, so use redeem(). + // Granite follows ERC-4626: redeem(shares) burns share count, returns aeUSDC. const sharesNum = BigInt(shares); - const expectedAeusdc = String(sharesNum + sharesNum / 10n); // shares + 10% interest buffer for post-condition + // Upper cap shares*2 — defensive against a buggy pool over-paying/draining + // while admitting long-held positions whose accrued interest can exceed a + // narrower +10% buffer. Empirically wide enough for healthy redeems. + const expectedAeusdcCap = String(sharesNum * 2n); return [{ tool: "call_contract", params: { contractAddress: "SP26NGV9AFZBX7XBDBS2C7EC7FCPSAV9PKREQNMVS", contractName: "liquidity-provider-v1", functionName: "redeem", functionArgs: [{ type: "uint", value: shares }, { type: "principal", value: wallet }], + postConditionMode: "deny", + // Post-condition anchoring per reference tx 0xd0bb0059b72e5f5d75a4dd1bedb12e44e32790567bc282184ca5309641a8f44f + // and live proof tx 0xd4aa0c4ed51b0951e91bb6680e44bc01da36722525fa7b28c39d98219e3eeba9 (2026-04-22). + // Stacks FT PCs track OUTFLOWS from the named principal. aeUSDC on Granite flows + // OUT OF state-v1 (not liquidity-provider-v1; the latter is a controller wrapper), + // and the asset_name on the token contract is "aeUSDC" (not "bridged-usdc"); the + // wallet BURNS lp-token (asset defined on state-v1), receives aeUSDC. The prior + // shape (lte on lp-v1, gte:1 on wallet for aeUSDC) aborted on-chain in both deny + // (tx 0x5780062068…) and allow (tx 0x60e2f84b83…) modes because all three PC fields + // bound to principals/assets that don't match the real FT flow. postConditions: [ - // Cap outflow from pool (upper bound) + // Receive-side floor: state-v1 sends aeUSDC ≥ shares (a healthy pool pays ≥ + // shares worth of aeUSDC; anything less signals a buggy pool or oracle drift). { - type: "ft", principal: "SP26NGV9AFZBX7XBDBS2C7EC7FCPSAV9PKREQNMVS.liquidity-provider-v1", - asset: AEUSDC_TOKEN, assetName: "bridged-usdc", - conditionCode: "lte", amount: expectedAeusdc, + type: "ft", principal: GRANITE_STATE, + asset: AEUSDC_TOKEN, assetName: "aeUSDC", + conditionCode: "gte", amount: shares, }, - // Guarantee wallet receives non-zero aeUSDC — catches bugged pool returning 0 + // Receive-side cap: state-v1 sends aeUSDC ≤ shares*2 — defensive against a + // buggy pool overpaying/draining (allows accrued interest, blocks runaway). + { + type: "ft", principal: GRANITE_STATE, + asset: AEUSDC_TOKEN, assetName: "aeUSDC", + conditionCode: "lte", amount: expectedAeusdcCap, + }, + // Burn floor: wallet sends ≥ shares of lp-token (the redeem burn). Binds the + // caller-side outflow to the actual share count, so a buggy call path that tried + // to burn more shares than requested would abort. { type: "ft", principal: wallet, - asset: AEUSDC_TOKEN, assetName: "bridged-usdc", - conditionCode: "gte", amount: "1", + asset: GRANITE_STATE, assetName: "lp-token", + conditionCode: "gte", amount: shares, }, ], }, @@ -1417,10 +1641,38 @@ async function _runPipeline(wallet: string, command: string, opts: Record = { zest: ["usdh"] }; + if (!validTokens_borrowRepay[protocol].includes(token)) { + return { status: "error", command, error: `${protocol} ${command} does not accept ${token}. Valid: ${validTokens_borrowRepay[protocol].join(", ")}` }; + } + } if (command === "migrate") { if (!opts.from || !opts.to || opts.from === opts.to) { return { status: "error", command, error: "Specify --from and --to (different protocols)" }; } + // --amount is required. Earlier versions defaulted to the wallet sBTC balance, which + // silently produced zero-amount deploys when migrating from stablecoin protocols + // (hermetica/granite) on wallets without sBTC. Force the caller to be explicit. + const parsedAmt = parseInt(opts.amount ?? "", 10); + if (isNaN(parsedAmt) || parsedAmt <= 0) { + return { status: "error", command, error: "migrate requires --amount (positive integer, smallest unit of target token). Run 'scan' to inspect current positions." }; + } } // Step 1: Scout @@ -1475,8 +1727,10 @@ async function _runPipeline(wallet: string, command: string, opts: Record).poolId ?? "dlmm_1")); + description = `Deploy ${amount} ${token} to ${protocol}${protocol === "hodlmm" ? ` (${((opts as Record).poolId ?? "dlmm_1")})` : ""}`; break; } @@ -1530,7 +1784,7 @@ async function _runPipeline(wallet: string, command: string, opts: Record).poolId ?? "dlmm_1"); const poolNum = parseInt(poolId.replace("dlmm_", ""), 10); const pool = scout.positions.hodlmm.pools.find(p => p.pool_id === poolNum); if (!pool) return { status: "error", command, error: `No position found in pool ${poolId}` }; @@ -1554,17 +1808,45 @@ async function _runPipeline(wallet: string, command: string, opts: Record 0) already validated in Step 0 above const from = opts.from as Protocol; const to = opts.to as Protocol; instructions.push(...buildWithdrawInstructions(from, scout)); const token = opts.token ?? inferToken(to); - const parsedAmt = opts.amount ? parseInt(opts.amount, 10) : NaN; - const amount = isNaN(parsedAmt) ? Math.floor(scout.balances.sbtc.amount * 1e8) : parsedAmt; - instructions.push(...buildDeployInstructions(to, amount, token, scout)); + const amount = parseInt(opts.amount!, 10); + instructions.push(...buildDeployInstructions(to, amount, token, scout, ((opts as Record).poolId ?? "dlmm_1"))); description = `Migrate from ${from} to ${to}`; break; } + + case "borrow": { + // Input already validated in Step 0 (zest-only, USDh-only, positive amount). + // Borrow is the debt-issuance leg of the leveraged-yield pattern. No YTG gate + // applies — the earn leg (Hermetica stake of the borrowed USDh) is where YTG + // is evaluated on its own `deploy` call. + const token = (opts.token ?? "usdh").toUpperCase(); + const amount = parseInt(opts.amount!, 10); + instructions.push({ + tool: "zest_borrow", + params: { asset: token, amount: String(amount) }, + description: `Borrow ${amount} ${token} from Zest v2 against existing collateral`, + }); + description = `Borrow ${amount} ${token} from Zest`; + break; + } + + case "repay": { + // Input already validated in Step 0. + const token = (opts.token ?? "usdh").toUpperCase(); + const amount = parseInt(opts.amount!, 10); + instructions.push({ + tool: "zest_repay", + params: { asset: token, amount: String(amount) }, + description: `Repay ${amount} ${token} debt to Zest v2`, + }); + description = `Repay ${amount} ${token} to Zest`; + break; + } } if (!confirmed) { @@ -1902,6 +2184,7 @@ program .requiredOption("--protocol ", "Target protocol: zest, hermetica, granite, hodlmm") .requiredOption("--amount ", "Amount in smallest unit (sats for sBTC, micro for stablecoins)") .option("--token ", "Token to deploy (default: inferred from protocol)") + .option("--pool-id ", "HODLMM pool ID for protocol=hodlmm (default: dlmm_1). Ignored for other protocols.", "dlmm_1") .option("--force", "Override 0% APY refusal") .option("--confirm", "Execute the transaction (without this flag, outputs a dry-run preview)") .action(async (opts: Record) => { @@ -1934,6 +2217,34 @@ program if (result.status !== "ok") process.exit(1); }); +program + .command("borrow") + .description("Borrow a debt asset against existing Zest collateral (leveraged-yield leg)") + .requiredOption("--wallet
", "Stacks wallet address (SP...)") + .requiredOption("--protocol ", "Protocol: zest") + .requiredOption("--token ", "Borrow asset (Zest: usdh)") + .requiredOption("--amount ", "Amount in smallest unit (µUSDh for usdh; USDh has 8 decimals)") + .option("--confirm", "Execute the transaction (without this flag, outputs a dry-run preview)") + .action(async (opts: Record) => { + const result = await runPipeline(opts.wallet, "borrow", opts); + console.log(JSON.stringify(result, null, 2)); + if (result.status !== "ok") process.exit(1); + }); + +program + .command("repay") + .description("Repay a borrowed Zest debt asset") + .requiredOption("--wallet
", "Stacks wallet address (SP...)") + .requiredOption("--protocol ", "Protocol: zest") + .requiredOption("--token ", "Repay asset (Zest: usdh)") + .requiredOption("--amount ", "Amount in smallest unit (µUSDh for usdh)") + .option("--confirm", "Execute the transaction (without this flag, outputs a dry-run preview)") + .action(async (opts: Record) => { + const result = await runPipeline(opts.wallet, "repay", opts); + console.log(JSON.stringify(result, null, 2)); + if (result.status !== "ok") process.exit(1); + }); + program .command("migrate") .description("Move capital from one protocol to another (withdraw + deploy)") @@ -1942,6 +2253,7 @@ program .requiredOption("--to ", "Target protocol: zest, hermetica, granite, hodlmm") .option("--token ", "Token to deploy into target (default: inferred)") .option("--amount ", "Amount in smallest unit (default: all)") + .option("--pool-id ", "HODLMM pool ID when --to=hodlmm (default: dlmm_1). Ignored otherwise.", "dlmm_1") .option("--confirm", "Execute the transaction (without this flag, outputs a dry-run preview)") .action(async (opts: Record) => { const result = await runPipeline(opts.wallet, "migrate", opts); @@ -1957,6 +2269,7 @@ program .action(async (opts: Record) => { const result = await runPipeline(opts.wallet, "emergency", opts); console.log(JSON.stringify(result, null, 2)); + if (result.status !== "ok") process.exit(1); }); program