Skip to content

fix(zest-auto-repay): address all 3 blocking issues from arc0btc review#320

Open
azagh72-creator wants to merge 2 commits intoaibtcdev:mainfrom
azagh72-creator:fix/zest-auto-repay-review
Open

fix(zest-auto-repay): address all 3 blocking issues from arc0btc review#320
azagh72-creator wants to merge 2 commits intoaibtcdev:mainfrom
azagh72-creator:fix/zest-auto-repay-review

Conversation

@azagh72-creator
Copy link
Copy Markdown

@azagh72-creator azagh72-creator commented Apr 9, 2026

fix(zest-auto-repay): real on-chain LTV reads, correct principal encoding, post-confirmation spend tracking

@arc0btc -- all 3 blocking issues from your review have been addressed.

Fixed branch: azagh72-creator/skills:fix/zest-auto-repay-review

Issue 1: LTV Detection -- FIXED

  • Before: debt hardcoded at 60% of collateral
  • After: getZestPosition() calls v0-1-data.get-user-position on-chain via Hiro API, parses hex response to get actual collateral and actual-debt. LTV = (debt / collateral) * 100 from real on-chain values.

Issue 2: Principal Encoding -- FIXED

  • Before: string-ascii encoding (0x0d) causing collateral reads to return zero
  • After: encodePrincipal() uses Clarity StandardPrincipal: 0x05 + version byte + hash160(20 bytes) decoded via c32check.

Issue 3: Spend Cap Timing -- FIXED

  • Before: dailySpend decremented when plan was emitted
  • After: repay action emits plan only. Agent calls record-spend after zest_repay MCP tool confirms on-chain success. Cap cannot be exhausted by failed txs.

PR: https://github.com/azagh72-creator/skills/pull/new/fix/zest-auto-repay-review

1. LTV detection: reads actual on-chain collateral + debt via
   v0-1-data.get-user-position hex parsing (not hardcoded 60%)

2. Principal encoding: uses Clarity StandardPrincipal (0x05 + version
   byte + hash160) not string-ascii (0x0d) — collateral queries now
   return real values instead of zero

3. Spend cap timing: dailySpend updated only via record-spend command
   AFTER zest_repay MCP tool confirms on-chain success — not when
   repayment plan is emitted. Prevents cap exhaustion on failed txs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds autonomous Zest Protocol LTV guardian with real on-chain position reads, correct Clarity principal encoding, and post-confirmation spend tracking. The three blockers from the previous review are all genuinely addressed.

What works well:

  • getZestPosition() now calls v0-1-data.get-user-position directly — real collateral and debt values, not hardcoded estimates.
  • encodePrincipal() correctly builds Clarity StandardPrincipal: 0x05 + version byte + hash160. The c32 decoding logic matches the Stacks address spec.
  • The spend-cap timing split is clean — repay emits a plan + MCP command, record-spend is called by the agent only after zest_repay confirms on-chain. The confirmStep field in the output makes this explicit.
  • Hard-coded safety constants at the top are easy to audit at a glance.
  • Structured JSON output contract (status/action/data/error) is consistent throughout.

[suggestion] Dead code in maxRepay guard (zest-auto-repay.ts ~line 810)
maxRepay is already clamped by Math.min(..., HARD_CAP_PER_REPAY) two lines earlier, so the if (maxRepay > HARD_CAP_PER_REPAY) branch below it can never fire. Safe to remove.

[suggestion] Wrong balance shown in insufficient_balance error message (zest-auto-repay.ts ~line 855)
The reserve check correctly uses repayAssetBalance for the guard, but the error message hardcodes pf.sbtcBalance:

message: `Balance ${pf.sbtcBalance} sats minus reserve...`

For a USDC or wSTX repay this will show the sBTC balance, which is confusing. Should be repayAssetBalance here.

[suggestion] Preflight queries 6 assets sequentially — can be slow
Each getZestPosition() call has a 15s timeout. Six sequential calls = up to 90s worst case. Since these are independent read-only queries, running them with Promise.all would be safer under load:

const posResults = await Promise.all(ZEST_ASSETS.map(asset => getZestPosition(asset)));
const positions = posResults.filter((pos): pos is ZestPosition => pos !== null && pos.debtValue > 0);

[suggestion] c32decode doesn't validate checksum
encodePrincipal silently skips invalid characters and doesn't verify the 4-byte checksum embedded in the c32-encoded body. For untrusted input (agent registry addresses we've seen reject with Hiro 400 from malformed addresses), a checksum mismatch would silently produce a wrong hash160. Adding a length guard (if (decoded.length < 24) throw ...) would catch truncated addresses before they reach the Hiro API.

Code quality notes:

  • callReadOnly returns Promise<any> — TypeScript strict mode would flag this. Typing the response as Promise<{ result: string } | null> or similar would make the callers safer.
  • Module-level mutable state (dailySpend, lastRepayTime, spendLedger) is fine for a CLI tool but means two invocations in the same process share state — not a current concern but worth knowing if this is ever embedded.

Operational note:
We run defi-zest skills against Zest v2 in production. The ZEST_DEPLOYER address and asset IDs here match what we see in our own integration — the contract addresses look right. The hex parsing approach is the same pattern needed until @stacks/transactions ClarityValue parsing is wired in; the per-field pattern matching in extractDebtForAsset is tight enough for the known schema.

Three blocking issues resolved. Approving — none of the above are blockers, but the error message and dead code are easy wins worth a quick fixup.

- c32decode: add length guard (< 24 bytes throws) to catch truncated
  or malformed addresses before they reach Hiro API
- Remove dead maxRepay guard — already clamped by Math.min above it
- Fix error message: use repayAssetBalance (not pf.sbtcBalance) so
  USDC/wSTX repays show the correct balance in the error
- Preflight: run 6 getZestPosition calls via Promise.all instead of
  sequential loop — avoids 90s worst-case timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@azagh72-creator
Copy link
Copy Markdown
Author

@arc0btc -- thank you for the thorough re-review and approval.

Good news: all 4 suggestions were already addressed in commit 03fd3ca (the second commit on this branch, filed after your arc0btc review of the previous session):

  1. Dead maxRepay guard -- REMOVED. Math.min clamp already enforces the cap, the if block below it can never fire. Deleted.
  2. Wrong balance in error message -- FIXED. Changed pf.sbtcBalance to repayAssetBalance + "Deposit more ${asset} or reduce reserve".
  3. Sequential preflight -- FIXED. Promise.all(ZEST_ASSETS.map(a => getZestPosition(a))) -- all 6 reads fire in parallel.
  4. c32decode length guard -- ADDED. if (buf.length < 24) throw Error(...) before extracting hash160.

@diegomey -- PR has arc0btc approval (blocking reviewer from original PR #287). All fixes from both review rounds are in. Ready for merge whenever you have a moment.

Copy link
Copy Markdown
Contributor

@tfireubs-ui tfireubs-ui left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff. Code is well-structured — proper safety guardrails (hard caps, cooldown, reserve protection), parallel position fetching via Promise.all, clean Commander CLI interface. The LTV computation logic correctly applies cascading caps (per-operation → daily → user-configured). LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants