Lending/Borrowing Market Controller β Independent Security Audit
v0-3-market is the central controller for Zest Protocol's lending/borrowing market on Stacks mainnet. It orchestrates six underlying vault contracts (STX, sBTC, stSTX, USDC, USDH, stSTXbtc), manages oracle price feeds (Pyth and DIA), and implements a sophisticated graduated liquidation mechanism with configurable curve exponents and grace periods.
Architecture highlights:
as-contract? with explicit with-stx / with-ft allowances in supply-collateral-add, strictly limiting what assets the contract can move under its own authority. This is the gold standard for Clarity asset safety.stacks-block-time + asset ID.last-borrow-block check prevents flash-loan-based borrow-then-liquidate attacks.supply-collateral-add and collateral-remove-redeem provide atomic depositβcollateralize and decollateralizeβredeem flows.Overall assessment: This is one of the most well-engineered Clarity contracts I've audited. The code demonstrates deep understanding of DeFi lending mechanics, proper Clarity 4 idioms, and careful attention to edge cases (disabled collateral pricing, bad debt socialization, oracle staleness). No critical or high severity findings. The medium findings relate to edge-case behavior in the graduated liquidation math and bad debt socialization trigger conditions.
calc-liq-factor-exp (line ~710)The graduated liquidation system supports a configurable curve exponent to control how aggressively liquidation percentage scales. However, the implementation only handles three cases:
exp == BPS (1.0) β linear (correct)exp > BPS (>1.0) β integer power with scaling (correct for integer exponents)exp < BPS (<1.0) β always uses sqrti (0.5 power), regardless of actual exponentIf the DAO configures a curve exponent of, say, 0.25 (2500 BPS) or 0.75 (7500 BPS), the actual behavior is always βx (0.5 power). This means:
(define-private (calc-liq-factor-exp (factor uint) (exp uint))
(if (is-eq exp BPS)
factor
(if (> exp BPS)
(/ (pow factor (/ exp BPS)) (pow BPS (- (/ exp BPS) u1)))
(sqrti (* factor BPS))))) ;; assume factor^0.5 β ALWAYS 0.5 regardless of exp
liquidate β bad debt socialization check (line ~1520)Bad debt socialization only triggers when the borrower's position has exactly one collateral type remaining AND that collateral is fully seized:
(no-collateral-left (and
(is-eq (len (get collateral pos-full)) u1)
(is-eq coll-removed u0)))
For multi-collateral positions where collateral is depleted across multiple liquidation calls targeting different collateral types, the trigger depends on whether v0-market-vault removes zero-balance entries from the collateral list:
len never reaches 1 for originally multi-collateral positions, and bad debt is never socialized β it remains as phantom debt in the system.v0-market-vault retains zero-balance collateral entries, bad debt from multi-collateral positions accumulates without socialization. This creates a growing gap between actual vault assets and tracked debt, eventually causing depositor losses on withdrawal.v0-market-vault.collateral-remove removes entries when balance hits zero, or (b) change the check to sum all collateral amounts rather than checking list length: (is-eq total-coll-remaining u0).liquidate-multi Does Not Support Inline Price Feedscall-liquidate / liquidate-multi (line ~755)The batch liquidation helper liquidate-multi hardcodes none for the price-feeds parameter. Liquidators must update Pyth price feeds in a separate transaction before calling liquidate-multi. If the price feed update and batch liquidation land in different blocks, the prices may become stale, causing the batch to fail.
(define-private (call-liquidate (position { ... }))
(liquidate ...
none ;; collateral-receiver defaults to liquidator
none)) ;; price-feeds not supported in batch
liquidate() with inline feeds is unaffected.liquidate-multi level and pass it to the first call-liquidate invocation (subsequent calls in the same block can use cached prices).borrow, repay, collateral-add, collateral-removeNone of the lending operations accept a deadline parameter. If a transaction is delayed in the mempool, it may execute at a significantly different interest index than when the user signed it. For borrow, a delayed transaction results in more scaled debt than expected. For repay, a delayed transaction may not fully repay the intended amount due to accrued interest.
min-out / min-shares on deposit/redeem operations for slippage protection, but no equivalent exists for borrow/repay index drift.max-block-time parameter to borrow and repay for users who want transaction freshness guarantees.vault-system-repayvault-system-repay (line ~169)The vault-system-repay private function accepts ft (<ft-trait>) and ft-address (principal) parameters but never uses them β vault routing is purely based on aid. These parameters are vestigial and add confusion without functional impact.
(define-private (vault-system-repay (aid uint) (amount uint) (ft <ft-trait>) (ft-address principal))
(if (is-eq aid STX) (contract-call? .v0-vault-stx system-repay amount)
;; ... ft and ft-address never referenced
contract-caller == tx-sender Restriction Limits Composabilitycollateral-add, borrow, repay, liquidateMultiple functions assert (is-eq contract-caller tx-sender), which prevents smart contract wallets, routers, or automation contracts from calling the market on behalf of users. This is an intentional security measure that eliminates an entire class of proxy attacks, but it means:
supply-collateral-add, collateral-remove-redeem, liquidate-redeem) that reduce the need for external routers.supply-collateral-add (line ~1190)The supply-collateral-add function demonstrates best-practice Clarity 4 usage with as-contract? and explicit asset allowances:
;; For wSTX: explicitly allows only STX movement
(as-contract? ((with-stx amount))
(try! (vault-deposit asset-id amount min-shares account)))
;; For other tokens: explicitly allows only the specific FT
(as-contract? ((with-ft ft-address "*" amount))
(try! (vault-deposit asset-id amount min-shares account)))
This ensures the contract can only move the exact token type and amount needed, even when operating under its own authority. This is strictly safer than pre-Clarity 4 as-contract which grants blanket asset access.
last-borrow-block check prevents oracle-frontrunning flash loan attacks β a finding commonly missed in other lending protocols.collateral-remove correctly handles disabled collateral assets by resolving their price on-demand rather than failing. Users can always exit disabled collateral positions.collateral-add with new collateral types validates that future borrowing capacity doesn't decrease when the egroup changes β prevents users from accidentally bricking their positions. ββββββββββββββββββββ
β v0-3-market β β This contract
β (Controller) β
ββββββββββ¬ββββββββββ
β
ββββββββββββ¬ββββββββΌββββββββ¬βββββββββββ¬βββββββββββ
βΌ βΌ βΌ βΌ βΌ βΌ
v0-vault- v0-vault- v0-vault- v0-vault- v0-vault- v0-vault-
stx sbtc ststx usdc usdh ststxbtc
β β β β β β
βΌ βΌ βΌ βΌ βΌ βΌ
wSTX sBTC stSTX USDC USDH stSTXbtc
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
β v0-assetsβ βv0-egroup β βv0-market-β β Oracles β
β(registry)β β (risk) β β vault β βPyth / DIA β
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
| Category | Assessment |
|---|---|
| Funds at risk | Yes β manages deposits, borrows, and liquidations across 6 asset vaults |
| Oracle dependency | Pyth (primary) + DIA (secondary) with confidence + staleness checks |
| Admin privileges | DAO executor can pause liquidations, set grace periods, configure oracle confidence |
| Composability | Limited β contract-caller == tx-sender blocks proxy contracts |
| Clarity version | Clarity 4 β β uses as-contract? with explicit asset allowances |
| Multi-contract risk | Depends on 6 vault contracts + asset registry + egroup resolver + market-vault |