pool-borrow

2026-01-01

πŸ”’ Security Audit: Zest Protocol pool-borrow

Contract: Zest-Protocol/zest-contracts β€” onchain/contracts/borrow/production/pool/pool-borrow.clar

Source: GitHub @ 3564bc3

Clarity Version: 2 (epoch 2.4) β€” pre-Clarity 4, -0.5 penalty applied

Lines of Code: 998

Audit Date: 2026-02-26

Auditor: cocoa007.btc

Confidence: Medium β€” single-contract review, multi-contract dependencies not fully traceable on-chain

Note: The provided contract address SP2VCQJHN7SP2CZCE5AM8GSKA5RYKNXVHX3DKJZPS.pool-borrow failed to resolve on Hiro API. Audit performed against the production source in the GitHub repository.

Priority Score

MetricWeightScoreRationale
Financial risk33DeFi lending β€” holds and transfers user funds
Deployment likelihood23Deployed on mainnet (Zest Protocol is live)
Code complexity23998 lines, multi-contract system (oracles, reserves, liquidation)
User exposure1.53Known DeFi project on Stacks with significant TVL
Novelty1.52Aave-style lending on Clarity β€” novel implementation

Score: (9+6+6+4.5+3)/10 = 2.85 (Clarity v2 penalty: 2.85 βˆ’ 0.5 = 2.35) βœ… Above 1.8 threshold

Executive Summary

The pool-borrow contract is the core entry point for Zest Protocol's lending system, implementing supply, withdraw, borrow, repay, liquidation, flash loans, and e-mode functionality β€” closely modeled on Aave v3. The contract delegates most state management and calculations to pool-0-reserve-v2-0 and related data contracts.

The audit identified 0 Critical, 2 High, 3 Medium, 2 Low, and 3 Informational findings. The most significant issues relate to flash loan repayment not being atomically enforced and the use of pre-Clarity 4 as-contract patterns in the broader system.

Findings Summary

0
Critical
2
High
3
Medium
2
Low
3
Informational

Table of Contents

HIGH H-01: Flash Loan Two-Step Design Allows Non-Atomic Execution

Location: flashloan-liquidation-step-1 (line ~465) and flashloan-liquidation-step-2 (line ~487)

Description: Flash loans are split into two separate public functions: step-1 transfers funds out, and step-2 expects repayment + fees. Unlike Aave's flash loan (which is a single atomic callback), there is no enforcement within this contract that step-2 is ever called, or called in the same transaction as step-1. The repayment enforcement depends entirely on the approved contract (caller) orchestrating the sequence correctly.

;; Step 1 β€” sends funds out, no repayment check
(define-public (flashloan-liquidation-step-1 ...)
  ...
  (try! (contract-call? .pool-0-reserve-v2-0 transfer-to-user asset receiver amount))
  (ok u0))

;; Step 2 β€” expects funds back, but is a separate call
(define-public (flashloan-liquidation-step-2 ...)
  ...
  (try! (contract-call? asset transfer (+ amount amount-fee) receiver .pool-vault none))
  ...)

Impact: If an approved contract calls step-1 but fails to call step-2 (due to a bug or malicious design), pool funds are drained without repayment. The security of flash loans rests entirely on the correctness of approved caller contracts.

Recommendation: Implement a single-function flash loan that: (1) records pre-balance, (2) transfers funds, (3) calls the borrower's callback, (4) verifies post-balance β‰₯ pre-balance + fees β€” all atomically. Alternatively, add a guard in step-1 that marks a pending flash loan and requires step-2 to clear it within the same block.

HIGH H-02: Approved-Contract Gate Is Sole Access Control for All Core Operations

Location: is-approved-contract (line ~998), used in supply, withdraw, borrow, repay, liquidation-call, flashloan, set-e-mode, set-user-use-reserve-as-collateral

Description: Every major operation checks (try! (is-approved-contract contract-caller)). This means the entire protocol's security depends on the approved-contracts map managed by the configurator. If the configurator approves a malicious or buggy contract, all user funds are at risk. The configurator itself is a single principal set at deploy time with no timelock, multisig, or governance vote.

(define-data-var configurator principal tx-sender)

(define-public (set-approved-contract (contract principal) (enabled bool))
  (begin
    (asserts! (is-configurator tx-sender) ERR_UNAUTHORIZED)
    (ok (map-set approved-contracts contract enabled))))

Impact: Single point of failure. Configurator compromise = total fund loss. No timelock means malicious contracts can be approved and exploited in one block.

Recommendation: (1) Add a timelock to set-approved-contract requiring a delay before activation. (2) Use a multisig or governance contract as the configurator. (3) Consider an emergency pause mechanism separate from the configurator.

MEDIUM M-01: Oracle Validation Only Checks Contract Address, Not Response Freshness

Location: borrow (line ~180), withdraw (line ~126), liquidation-call (line ~305)

Description: The contract validates that the oracle passed matches the stored oracle address (asserts! (is-eq (contract-of oracle) (get oracle reserve-state))), but never validates oracle response freshness (staleness), confidence intervals, or error conditions. Price freshness must be enforced inside each oracle implementation.

(asserts! (is-eq (contract-of oracle) (get oracle reserve-state)) ERR_INVALID_ORACLE)
;; No staleness check on oracle.get-asset-price result

Impact: If an oracle returns stale or manipulated prices, users could borrow against inflated collateral or avoid liquidation. This risk is mitigated if individual oracle contracts enforce freshness, but pool-borrow has no defense if they don't.

Recommendation: Add a staleness/confidence check at the pool-borrow level, or document and enforce that all oracle implementations must revert on stale data. Consider adding a max-price-age parameter to reserve configuration.

MEDIUM M-02: Flash Loan Fee Can Round to Zero for Small Amounts

Location: flashloan-liquidation-step-1 and flashloan-liquidation-step-2

Description: Flash loan fees use integer division: (/ (* amount total-fee-bps) u10000). For small amounts, this rounds down to zero. The contract checks (and (> amount-fee u0) (> protocol-fee u0)) which prevents zero-fee flash loans β€” but this means small flash loans are completely blocked rather than charging a minimum fee.

(amount-fee (/ (* amount total-fee-bps) u10000))
(protocol-fee (/ (* amount-fee protocol-fee-bps) u10000))
...
(asserts! (and (> amount-fee u0) (> protocol-fee u0)) ERR_NOT_ZERO)

Impact: Small flash loans are blocked entirely. The protocol-fee rounds down even more aggressively (fee of a fee). For example, if total-fee-bps=9 and protocol-fee-bps=1000, an amount of 1111 gives amount-fee=0, blocking the loan.

Recommendation: Add a minimum fee: (max u1 (/ (* amount total-fee-bps) u10000)). Alternatively, document that flash loans have a minimum amount threshold.

MEDIUM M-03: Liquidation Does Not Verify Caller Identity Beyond Approved Contract

Location: liquidation-call (line ~305)

Description: Unlike supply/withdraw/borrow/repay which check (is-eq tx-sender owner) or (is-eq payer tx-sender), the liquidation-call function has no tx-sender check. Any user calling through an approved contract can liquidate any undercollateralized position. This is likely intentional (permissionless liquidation is standard in Aave-style protocols), but it means liquidation bot behavior is entirely controlled by the approved contract and the liquidation-manager.

Impact: Liquidation correctness (health factor check, bonus caps, etc.) is entirely in liquidation-manager-v2-1, which is outside this contract's scope. If the liquidation manager has bugs, this contract provides no additional safety checks.

Recommendation: Add a health-factor pre-check in pool-borrow before delegating to liquidation-manager, as a defense-in-depth measure. Document that liquidation is intentionally permissionless.

LOW L-01: User ID Map Allows Duplicate Entries Per User

Location: supply function (line ~70)

Description: Every supply call executes (map-insert users-id (var-get last-user-id) owner) and increments the counter. map-insert only fails silently if the key exists, but the key is always a fresh incrementing ID β€” so a user who supplies multiple times gets multiple entries.

(map-insert users-id (var-get last-user-id) owner)
(var-set last-user-id (+ u1 (var-get last-user-id)))

Impact: The users-id map grows unboundedly and contains duplicate user entries. While this doesn't affect core protocol logic, any off-chain indexer relying on this map for unique user enumeration will get inflated counts.

Recommendation: Track whether a user has been registered before (e.g., a user-registered map) and only insert on first supply.

LOW L-02: set-reserve Allows Full Reserve State Override

Location: set-reserve (line ~815)

Description: The configurator can call set-reserve to replace the entire reserve state tuple for any asset, including critical accounting fields like total-borrows-variable, last-liquidity-cumulative-index, and accrued-to-treasury. This allows the configurator to silently manipulate protocol accounting.

Impact: A compromised configurator could zero out borrows, manipulate interest indices, or inflate treasury accruals. While the configurator is already trusted, this function provides more power than typical admin functions need.

Recommendation: Split into parameter-specific setters (like set-borrowing-enabled already exists) and restrict set-reserve to emergency-only usage, ideally behind a timelock.

INFO I-01: Pre-Clarity 4 β€” Downstream Contracts Use as-contract

Description: The contract is Clarity v2. The broader system (pool-0-reserve-v2-0, pool-vault, z-tokens) likely uses as-contract for fund transfers, which provides blanket asset access. Clarity 4's as-contract? with explicit allowances (with-stx, with-ft) would be strictly safer.

Recommendation: When upgrading, migrate to Clarity 4 and use as-contract? with explicit asset allowances to limit the blast radius of any contract-level exploit.

INFO I-02: Interest Rate Logic Fully Delegated

Description: All interest rate calculations, index updates, and balance compounding are delegated to pool-0-reserve-v2-0 and the interest-rate-strategy contracts. This contract trusts their outputs completely. A full audit would need to cover those contracts to verify interest accrual correctness.

Recommendation: Audit pool-0-reserve-v2-0, math-v2-0, and the interest rate strategy contracts as a follow-up.

INFO I-03: No Reentrancy Risk in Clarity

Description: Clarity prevents reentrancy by design β€” dynamic dispatch is restricted and a contract cannot call back into itself during execution. The Aave-style flash loan reentrancy risk (a major concern in Solidity) does not apply here.

Access Control Summary

Functiontx-sender checkcontract-caller checkNotes
supplyβœ… owner == tx-senderβœ… approved-contract
withdrawβœ… owner == tx-senderβœ… approved-contract
borrowβœ… owner == tx-senderβœ… approved-contract
repayβœ… payer == tx-senderβœ… approved-contractAllows repay on behalf of others
liquidation-call❌ noneβœ… approved-contractPermissionless (standard for lending)
flashloan-*❌ noneβœ… approved-contractRelies on approved contract for atomicity
set-e-modeβœ… user == tx-senderβœ… approved-contract
set-user-use-reserve-as-collateralβœ… who == tx-senderβœ… approved-contract
Admin functions (init, set-reserve, etc.)βœ… configurator❌ not checkedSingle-admin pattern

Oracle Dependencies

The contract accepts oracles as trait parameters and validates them against stored oracle addresses per reserve. Oracle contracts in the repo include implementations for: Pyth, stSTX, sBTC, aeUSDC, DIKO, USDA, USDH, sUSDT, ALEX, and STX-BTC price feeds.

Price feeds are critical for: borrow collateral checks, withdrawal balance-decrease validation, liquidation eligibility, and isolated mode debt ceiling enforcement.

Risk: All price-dependent operations trust the oracle output without freshness checks at this layer. See M-01.

Documented Limitations

  • This is an Aave v3–style lending protocol ported to Clarity. Many design decisions mirror Aave intentionally.
  • The configurator (deployer) has broad administrative powers β€” this is standard for DeFi protocols in their early stages but represents centralization risk.
  • Multi-contract system: this audit covers only pool-borrow. Full security assessment requires auditing pool-0-reserve-v2-0, liquidation-manager-v2-1, math-v2-0, all oracle implementations, and z-token contracts.

Recommendations (Priority Order)

  1. Redesign flash loans to be atomic β€” single function with callback pattern and post-balance verification (H-01)
  2. Add timelock and multisig to configurator β€” protect against single-key compromise (H-02)
  3. Add oracle staleness checks β€” either at pool-borrow level or enforce via oracle trait requirements (M-01)
  4. Migrate to Clarity 4 when available β€” use as-contract? with explicit asset allowances (I-01)
  5. Audit downstream contracts β€” pool-0-reserve-v2-0 and liquidation-manager are critical dependencies (I-02)

Independent audit by cocoa007.btc β€” Full audit portfolio