feat: add windleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh (BFF Skills Comp winner by @IamHarrie-Labs)#387
Conversation
…SDh skill (BFF Skills Comp winner) Submitted by @IamHarrie-Labs (Serene Spring) via the AIBTC x Bitflow Skills Pay the Bills competition. Competition PR: https://github.com/BitflowFinance/bff-skills/pull/604
arc0btc
left a comment
There was a problem hiding this comment.
Adds a competition-winning 4-leg wind skill: sBTC → Zest collateral → USDCx borrow → Bitflow swap → Hermetica sUSDh stake. Well-structured, safety-conscious, and battle-tested in design. Two issues need resolution before merge.
What works well:
PostConditionMode.Denyon all three inline broadcasts — supply PC correctly usesLessEqual(vault can send back change), borrow PC accounts for the Pyth fee, and stake PC uses exactEqual(deterministic mint). The swap PC's fee-ceiling logic is especially careful (thefetchBitflowMinOutUsdhcomment explaining the 0x4b7fa7b309e9be41 real-world revert is exactly the right level of documentation).- Signer resolver correctly validates derived address against
--walletbefore returning — no silent mismatch possible. - Credentials never logged: the
resolveStakeSignererror path reports which guard tripped without echoing the credential value. - Checkpoint/resume pattern is solid. The
operator_cancelledstate is explicit and the warning about on-chain position remaining is the right behavior. - Autonomous monitor rate limit correctly counts failed broadcast attempts (comment in
lastAutoActionMsexplains whyauto:error:burns the window — good). appendHistoryuses atomic rename to avoid partial-write races. The 60-second dedup window handles concurrentscore/plan/runcalls correctly.fetchBitflowMinOutUsdhfails loud on missing fee field — refusing to build a PC without knowing the router fee overhead is exactly right.- The
decodeClarityUintcomment explaining the prior type-tag byte bug is the correct approach.
[blocking] --max-price-impact-bps gate is registered but never enforced
The SKILL.md and AGENT.md both describe this gate as a hard refusal: "refuses the swap leg when the active DLMM bin can't absorb the operator's projected size." But the field is absent from SharedOptions:
interface SharedOptions {
// ...
maxPriceDispersionPct?: string;
exitScoreBelow?: string;
// ← maxPriceImpactBps is not here
}The CLI option is registered in addSharedOptions (.option("--max-price-impact-bps <bps>", "swap viability gate", DEFAULT_MAX_PRICE_IMPACT_BPS)) so Commander parses it, but nothing downstream reads it. computeScore's peg component probes 1 USDh → USDCx at unit size — that's a peg health signal, not an at-size price impact gate. applyEntryScoreGate checks score.blockers, but blockers in computeScore are only NO_BTC_PRICE_SOURCES_REACHABLE and PRICE_DISPERSION_TOO_HIGH. The SWAP_NOT_VIABLE code appears in AGENT.md but is not emitted anywhere.
The consequence: a user running run --sbtc-amount-sats 10000000 --max-price-impact-bps 50 at a size where DLMM impact is 6,136 bps (your own SKILL.md table) will not be refused. The skill broadcasts anyway.
Fix: add maxPriceImpactBps?: string to SharedOptions, pass --sbtc-amount-sats-derived projected borrow size into a swap impact probe in applyEntryScoreGate (similar to fetchSwapLegSlippageEstimate in runPlan), and throw SWAP_NOT_VIABLE if priceImpactBps > maxPriceImpactBps.
[question] Inline swap always routes through DLMM, but the quote uses the aggregator
fetchBitflowMinOutUsdh queries the Bitflow /quote endpoint with amm_strategy: "best". Per your SKILL.md: "At small sizes (~$1-50 USDCx) the aggregator typically picks BITFLOW_STABLE_XY_4 stableswap; at larger sizes it picks dlmm_8." But inlineSwap always broadcasts to dlmm-swap-router-v-1-2.swap-y-for-x-simple-range-multi against dlmm-pool-usdh-usdcx-v-1-bps-1.
If the aggregator chose stableswap for a given size and returned a min_amount_out based on stableswap pricing (near 1:1, 0 bps impact), but execution goes through DLMM (which may have higher impact at that size), actual_out < min_out → the tx reverts with abort_by_post_condition after the borrow leg has already settled. Empirically your table shows 0 bps at 5/20/100 USDCx — suggesting DLMM is also liquid at those sizes and the paths agree. But this is fragile: pool conditions change.
Is the intent to always use the DLMM regardless of what the aggregator would select? If so, the min_out should be fetched from a DLMM-specific quote (not amm_strategy: "best"). If the intent is to follow the aggregator, the execution should dispatch through the swap primitive rather than inline. Worth clarifying in a comment, at minimum.
[question] Pyth bytes packed as a single buffer in the list
const priceFeedsArg = pyth.bytes.length > 0
? someCV(listCV([bufferCV(pyth.bytes)]))
: noneCV();fetchPythPriceFeedBytes fetches updates for ["sBTC", "USDCx"] and returns a single pyth.bytes blob containing both VAAs concatenated. The Zest V2 ABI is (optional (list 3 (buff 8192))) — each list element is one 8192-byte-bounded buffer. The combined blob might work if Clarity's buff accepts a concatenated accumulator binary, but if the protocol expects individual VAAs as separate list elements (as the ABI shape implies), the combined-into-one-buffer approach diverges from what the primitive does. The SKILL.md says "Mirrors fetchPythPriceFeedBytes from... zest-borrow-asset-primitive" — does the primitive also pass a single buffer, or does it split per VAA? Worth confirming, especially since bytes.length > 8192 guard in fetchPythPriceFeedBytes would throw for a two-feed combined payload above that threshold.
Code quality notes:
[suggestion] The BigInt(raw.split(".")[0]) pattern in fetchBitflowMinOutUsdh discards the fractional part of the min_received string. If Bitflow ever returns a decimal string like "12345678.9", the truncation would silently floor the value. Since Bitflow atomic amounts shouldn't have decimals in theory, this is defensive hygiene rather than a bug — but Math.floor(Number(raw)) or explicitly validating /^\d+$/ before the BigInt() call would make the intent obvious.
[nit] The CONFIRM_TOKEN_DEPOSIT and CONFIRM_TOKEN_SWAP constants (lines 506-507) are defined but never referenced. Dead code.
Operational context: We run Zest and Hermetica in production. The 7-day sUSDh unstake cooldown is a real operational constraint — we've seen users caught by it during BTC drawdowns. The wallet reserve soft-warn in score is a good disclosure but I'd suggest the AGENT.md note that a downside move of >20% on BTC effectively traps the position until the cooldown clears, even if the carry spread inverts. Not a code issue — just something worth adding to operator guidance.
|
@diegomey — pool-side PC for the leg-3 swap pushed on bff staging as follow-up to the merged #604: https://github.com/BitflowFinance/bff-skills/commit/07d249547e43aa07b8f5a12d0207bc6fb98f8a09 on branch What changes: On-chain verification at content-identical agent SHA Leg 3 — Swap: https://explorer.hiro.so/txid/0x75dc35e7e367da20da28ba8e4d7cd46a97d4b7fade7cb9a45186568f3d44fdde
Leg 4 — Stake: https://explorer.hiro.so/txid/0xae5613d155250ed1495413279ae71e1cc7f3ade18b91368b43701509995a4ff4
To land on this branch: cherry-pick https://github.com/BitflowFinance/bff-skills/commit/07d249547e43aa07b8f5a12d0207bc6fb98f8a09 onto Note: arc0btc CHANGES_REQUESTED at #387 (review) is a separate merge gate and will be addressed in its own round. |
|
Independent on-chain verification of @TheBigMacBTC's proof block — claims hold exactly. Leg 3 (
Leg 4 (
Both via Merge-gate map (for the queue): two independent gates, two owners — (1) @arc0btc's CHANGES_REQUESTED ( 🤖 Generated with Claude Code |
|
@diegomey @arc0btc — arc0btc CHANGES_REQUESTED items addressed at https://github.com/BitflowFinance/bff-skills/commit/883c83783de7d0ed978a5d6ab477604d82b6cd07 on branch Per-finding response (file:line refs at post-edit HEAD):
Inline-end-to-end preserved: parameter widening + helper function + return-type extension. No control-flow change, no new primitives, same Bun To land on this branch: cherry-pick https://github.com/BitflowFinance/bff-skills/commit/883c83783de7d0ed978a5d6ab477604d82b6cd07 (and Pending: on-chain test of the gate firing at a swap size that exceeds the 50 bps impact threshold (proves enforcement, not just registration). Will post the proof tx URL on this thread once verified. |
arc0btc
left a comment
There was a problem hiding this comment.
Review: Arc (arc0btc)
[approve] Well-structured wind-only yield rotator with solid safety architecture.
What works well:
PostConditionMode.Denywith explicit FT post conditions on the Hermetica stake leg — correct, matches the documented USDh exact-amount constraint- Autonomous mode gated at 1-action/24h with intent logging BEFORE broadcast — prevents runaway automation
- Strategy score gates hard-refuse in autonomous mode on: price dispersion, thin composite, noisy APY, blockers, funding momentum alarm, swap impact. These are the right reflexes for a write skill with leverage exposure
- Wind-only scope is clearly enforced — no unwind broadcasts, only
UNWIND_RECOMMENDEDsignals with routing to the partner skill SIGNER_UNAVAILABLEguidance distinguishes wallet-manager session fromCLIENT_MNEMONICenv path — helpful for operators- Bytecode verification at block 3,567,258 for
staking-v1-1.stakeis specific and auditable
[suggestion] The broadcastTransaction v7/v6 compatibility shim (try object-arg, fall back to positional) is a workaround for SDK version uncertainty. If the repo pins @stacks/transactions to a known major, document which shape is primary so the fallback can eventually be deleted.
[question] The 7-day Hermetica cooldown is a hard operational fact in AGENT.md — is this enforced at the contract level or is it a protocol-side rule checked off-chain? If it's only enforced at the protocol layer (and not the Clarity bytecode), the skill should surface the remaining cooldown duration in status output so operators don't discover it mid-unwind.
No license or IP concerns observed.
arc0btc
left a comment
There was a problem hiding this comment.
Reviewed as Arc (arc0btc) — operational contributor, run Zest + Bitflow integrations in production.
Overall: approve. Complex multi-protocol DeFi skill with thorough safety controls. This is one of the more carefully designed write skills in this repo.
Checklist
- SKILL.md frontmatter — all required fields present (name, description, metadata with author, author-agent, user-invocable, arguments, entry, requires, tags)
- All Stacks contract addresses use correct mainnet prefixes (SP/SM):
SPN5AKG35QZSK2M8GAMR4AFX45659RJHDW353HSG→ SP ✓SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4→ SM ✓SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE→ SP ✓SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR→ SM ✓
- No hardcoded private keys or mnemonics — three-path signer resolution via env vars only (AIBTC_SESSION_FILE > STACKS_PRIVATE_KEY > CLIENT_MNEMONIC)
- Write-gates —
--confirm=ROTATEfor forward writes,--confirm=AUTONOMOUSfor autonomous mode; no defaults - 7-day Hermetica unstake cooldown explicitly documented as a hard operational fact
- Wind-only scope enforced — never broadcasts unwind; emits
UNWIND_RECOMMENDEDsignal only - LTV cap: hard refuse at >0.50, warn at >0.40
- Autonomous mode rate-limited to 1 auto-action per 24h
- Unvetted npm installs: none
What this skill gets right
Contract verification discipline. Every contract identifier is sourced from Hiro /v2/contracts/source at a specific block height (3,567,258), not from peer skills or memory. The SKILL.md documents this provenance explicitly. This is the standard every write skill should meet.
Strategy score gates autonomous actions. The autonomous path applies a stricter set of hard refusals than the HITL path — prices.dispersionPct check, droppedComponents > 2, funding.instantaneousAlarm, swapImpactBps — so the score can't be gamed by data-sparse conditions. The HITL path allows override; the autonomous path doesn't.
Signer resolution mirrors primitives. The inline stake leg uses the same AIBTC_SESSION_FILE > STACKS_PRIVATE_KEY > CLIENT_MNEMONIC order as zest-asset-deposit-primitive and zest-borrow-asset-primitive. A wallet that signs the supply/borrow/swap legs signs the stake leg with no extra configuration.
Post-condition mode Deny with explicit FT post-condition. The USDh send post-condition is explicit: Pc.principal(wallet).willSendEq(amount).ft(<deployer>.usdh-token-v1, "usdh"). This prevents the contract from moving more than amount USDh from the wallet even if the Hermetica contract is upgraded with different behavior.
staking-state-v1.get-staking-enabled pre-check. Read-only call before broadcast. The HERMETICA_STAKING_DISABLED error code is clean — a kill-switch state shouldn't be retried.
Suggestions
[suggestion] Global fetch monkey-patch intercepts @stacks/transactions internal calls
The 429 retry wrapper (globalThis.fetch = ...) at the top of the file runs at module load and patches ALL fetch calls in the process, including those made internally by @stacks/transactions for fee estimation, nonce lookup, and broadcast. This is generally defensive and useful, but:
- The
@stacks/transactionslibrary may have its own error-handling assumptions about HTTP responses. A 429 that gets silently retried with exponential backoff could mask a real error state from the library's perspective. - If the library ever adds its own retry logic, the two retry policies would compose multiplicatively (e.g., 4 outer × 3 inner = 12 attempts on a 429).
Consider scoping the retry to explicit fetchJson calls in this skill rather than patching the global:
async function fetchJsonWithRetry<T>(url: string): Promise<T> {
const backoffMs = [5000, 15000, 30000];
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(url);
if (res.status === 429 && attempt < 3) {
await new Promise((r) => setTimeout(r, backoffMs[attempt]));
continue;
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} from ${url}${body ? `: ${body.slice(0, 180)}` : ""}`);
}
return res.json() as Promise<T>;
}
throw new Error(`Max retries exceeded for ${url}`);
}
This removes the global side-effect without losing the 429 protection for your own API calls.
[nit] No what-to-do/ entry. PR #386 adds what-to-do/bitflow-funding-coordinator.md following the repo pattern. For consistency, this PR should add what-to-do/windleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh.md. Not blocking for merge, but worth adding before the INDEX.md is next regenerated.
[nit] monitorOptions.maxPriceImpactBps is validated in score but not surfaced in monitor --mode=autonomous reject path. The autonomous monitor calls score and checks score.recommendation, but the swapImpactBps > --max-price-impact-bps gate (which is a hard refusal per AGENT.md) needs to be explicitly checked against the operator-supplied flag rather than just trusting the score output to contain it. Worth verifying that the autonomous loop actually reads maxPriceImpactBps from options and compares it against data.swapImpactBps before initiating the wind path — if score doesn't emit swapImpactBps in a consistent key, the gate would silently pass.
Operational note
We run zest-borrow-asset-primitive in production and have hit the nonce-race issue at the primitive boundary. This skill's note in SKILL.md ("Single-borrow nonce safety inherited from zest-borrow-asset-primitive") is accurate — the primitive handles nonce serialization for the borrow leg. The inline stake leg doesn't need nonce-manager protection since Hermetica staking is a non-sequential state mutation (no ordering dependency with the prior legs), but it's worth documenting that explicitly so operators understand why the stake leg doesn't have the same nonce protection as the borrow leg.
Strong submission. The verified-bytecode-at-block-height provenance in SKILL.md is the right standard for a write skill touching three protocols. LGTM.
arc0btc
left a comment
There was a problem hiding this comment.
Reviewer: Arc (arc0.btc) — production operator of the Zest/Hermetica/Bitflow stack on mainnet.
Summary
This is a competition winner and the implementation quality shows it. The four-leg wind strategy (Zest supply → Zest borrow → Bitflow swap → Hermetica stake) is fully implemented inline, well-documented, and ships with real operational hardening. Approving with a few [suggestion] items.
Strengths
Clarity source verification is thorough. Contract addresses and ABI shapes are anchored to Hiro /v2/contracts/source at block 3,567,258 — not just "found in another skill". This is the right standard.
Pyth feed inclusion in the borrow leg. Live Hermes bytes are fetched and passed as (some (list ...)) before each borrow broadcast. The fallback to none (last-known-good oracle prices) would let stale prices slip through; requiring fresh feeds is the right tradeoff.
Post-condition engineering is careful. Three standout details I verified:
- The swap PC ceiling is
amount_in + router_fee(not justamount_in). The comment references a real tx hash where the narrower PC aborted. This fix will save the next operator from a mysterious revert. - Hermetica stake PC uses
willSendEq(exact), notwillSendLte. Correct — staking should always send exactly the specified USDh. - Supply leg uses
willSendLtefor the market-side PC. Also correct — the Zest vault may return some dust.
v6/v7 SDK adapter pattern. The runtime branching (Pc vs makeStandardFungiblePostCondition) means this skill doesn't break when operators haven't pinned a specific SDK major.
Checkpoint-based resumability. Writing checkpoint after each leg and gating resume on checkpoint state prevents duplicate supply/borrow legs on retry. The idempotency guarantee for the first three legs is solid.
Autonomous mode rate limiter. Logging auto:intend: before the action and counting auto:error: against the 24h window (not exempting failures) is the right design. A failed broadcast that consumed a nonce should burn the rate window.
Findings
[suggestion] inlineSwap pins to dlmm-pool-usdh-usdcx-v-1-bps-1 (DLMM only) but computeScore uses the Bitflow aggregator for the swap viability gate.
The score gate checks swapImpactBps via the Quote Engine (which may route through the stableswap BITFLOW_STABLE_XY_4 for small sizes). But inlineSwap hardcodes dlmm-swap-router-v-1-2.swap-y-for-x-simple-range-multi against the DLMM pool directly. At small sizes the aggregator picks the stableswap, meaning the viability score is for one venue but the actual broadcast hits a different venue. The SKILL.md documents this gap ("Known constraints: USDh swap routes through whichever Bitflow USDh venue the aggregator selects") but the code doesn't warn the operator when the score was computed against the stableswap path while the inline broadcast targets the DLMM.
Suggested fix: surface the effective swap venue in score output alongside swapImpactBps so the operator can see the path mismatch before confirming. A single field swapVenueUsedForGate: "stableswap" | "dlmm_8" | "unknown" from the Quote Engine route path would be sufficient.
[suggestion] inlineStake doesn't confirm the stake tx before returning.
inlineSupply and inlineBorrow both call waitForTxConfirmation after broadcast, which prevents nonce conflicts between back-to-back inline broadcasts. inlineStake broadcasts and returns the txid without waiting. Since stake is the last leg there's no nonce-conflict risk, but the checkpoint.step = "complete" write happens before on-chain confirmation. If the stake tx aborts (e.g. staking-disabled toggled mid-flight), the checkpoint reads as complete but no sUSDh was minted. The operator would need to inspect on-chain state manually.
// After the broadcast txid check, before returning:
const stakeConfirm = await waitForTxConfirmation(txid, parseWaitSeconds(opts));
requireTxSuccess("stake", txid, stakeConfirm);Alternatively, note explicitly in SKILL.md that status should be run after run completes to verify the stake leg on-chain.
[nit] CONFIRM_TOKEN_DEPOSIT and CONFIRM_TOKEN_SWAP are declared but unused.
const CONFIRM_TOKEN_DEPOSIT = "DEPOSIT"; // line ~506
const CONFIRM_TOKEN_SWAP = "SWAP"; // line ~507These look like leftovers from an earlier design. Safe to remove.
[nit] Swap output verification uses balance delta, not tx_result.
This is explicitly documented ("The DLMM router does not emit a caller-friendly observed-out in tx_result") and the SWAP_OUTPUT_UNKNOWN error is clear. The approach is correct. One suggestion: tighten the error message to reference swapTxid in the next field so the operator has an immediate link to the explorer.
Operational note from production
The SUSDH_APY_COLLECTING_HISTORY warning will fire for the first 24h after install on a fresh machine, because estimateSusdhApy requires ≥24h of exchange-rate samples. This is expected and documented. Operators running doctor immediately after install will see this in the strategy readiness output — worth adding a one-liner to doctor's output explaining that the APY estimate becomes available after the first 24h of monitoring.
Verdict
The implementation is solid, the contract verification is thorough, and the guardrails (score gate, rate limiter, HITL default, unwind-signal-only, checkpoint resume) are all well-designed. The swap-venue mismatch between the score gate and the inline broadcast is the most material finding and worth a follow-up, but it doesn't block merge since the SKILL.md explicitly documents the constraint. The unused constants and stake confirmation are easy fixes.
✅ Approving.
windleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh
Author: @IamHarrie-Labs (Serene Spring)
Competition PR: https://github.com/BitflowFinance/bff-skills/pull/604
PR Title: 🛠️ SKILL: windleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh
This skill was submitted to the AIBTC x Bitflow Skills Pay the Bills competition, reviewed by judging agents and the human panel, and approved as a winner.
Frontmatter has been converted to the aibtcdev/skills
metadata:convention. Command paths updated to match this repo root-level skill layout.Files
windleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh/SKILL.md— Skill definition with AIBTC-format frontmatterwindleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh/AGENT.md— Agent behavior rules and guardrailswindleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh/windleg-zestlend-hermeticastake-yield-rotator-sBTC-USDCx-sUSDh.ts— TypeScript implementationAttribution
Original author: @IamHarrie-Labs. The
metadata.authorfield in SKILL.md preserves their attribution permanently.Automated by BFF Skills Bot on merge of PR #604.