Skip to content

Instantly share code, notes, and snippets.

@Mayjor01
Created June 2, 2026 16:46
Show Gist options
  • Select an option

  • Save Mayjor01/c5733c00c7809a41233cc0f9f1edf662 to your computer and use it in GitHub Desktop.

Select an option

Save Mayjor01/c5733c00c7809a41233cc0f9f1edf662 to your computer and use it in GitHub Desktop.
Static Analysis Audit: Zest Protocol pool-borrow-v2-3

Static Analysis Audit: Zest pool-borrow v2-3 (pool-borrow-v2-3)

Auditor: Emerald Castle (bc1qzhlus0nesaphjy5jfd2tnef9te9j4mq57mzd6r)
Contract: SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.pool-borrow-v2-3
Source: https://api.hiro.so/v2/contracts/source/SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N/pool-borrow-v2-3
Protocol: Zest Protocol — Aave-style sBTC-native lending pool on Stacks
Audit Type: Static analysis only (no execution)
Date: 2026-06-02


1. State Model

data-vars

Variable Type Initial Value Mutated By Authority
configurator principal tx-sender (deployer) set-configurator current configurator only
last-user-id uint u0 supply any caller of supply

data-maps

Map Key Value Mutated By Authority
users-id uint principal supply any supply caller
approved-contracts principal bool set-approved-contract configurator only

External State (cross-contract)

This contract is a thin routing layer. The bulk of lending state lives in companion contracts:

External Contract Holds
.pool-0-reserve-v2-0 Per-asset reserve state, user balances, cumulative indexes, health factor logic
.pool-reserve-data, .pool-reserve-data-2, .pool-reserve-data-3 Asset lists, e-mode configs, isolation mode debt, user e-mode settings
.pool-reserve-data-1 Freeze end blocks, grace periods
.liquidation-manager-v2-3 Liquidation execution math
.pool-vault Token custody

2. Function Inventory

Public Functions

supply (lines 59–123)

  • Authority: is-approved-contract contract-caller (router whitelist); owner == tx-sender
  • Pre-conditions: amount > 0, asset is-active, not is-frozen, lp matches reserve a-token-address, owner == tx-sender
  • State mutations: Inserts into users-id map (monotonic), calls .pool-0-reserve-v2-0.update-state-on-deposit, mints lp tokens, transfers asset to reserve
  • External calls: lp.cumulate-balance, lp.mint, pool-0-reserve-v2-0 (multiple), transfer-to-reserve
  • Token flows: owner sends amount of asset to pool

withdraw (lines 149–197)

  • Authority: is-approved-contract contract-caller; owner == tx-sender
  • Pre-conditions: lp/oracle match reserve, owner == tx-sender, amount-to-redeem > 0, balance >= amount, not frozen, liquidity available, health check passes
  • State mutations: Burns lp tokens, updates reserve state, removes collateral flags if full withdraw
  • External calls: lp.cumulate-balance, lp.burn, pool-0-reserve-v2-0 (multiple), transfer-to-user
  • Token flows: pool sends amount-to-redeem of asset to owner

borrow (lines 199–283)

  • Authority: is-approved-contract contract-caller; owner == tx-sender
  • Pre-conditions: Multiple — borrowing-enabled, is-active, not frozen, amount > 0, liquidity available, collateral sufficient, borrow-cap not exceeded, e-mode type matches if in e-mode, siloed isolation mode check
  • State mutations: Updates borrow state, transfers asset to owner, isolation mode debt accounting
  • External calls: pool-0-reserve-v2-0.calculate-user-global-data, transfer-to-user
  • Token flows: pool sends amount-to-be-borrowed to owner

repay (lines 423–471)

  • Authority: is-approved-contract contract-caller; payer == tx-sender
  • Pre-conditions: existing debt > 0, not frozen, amount > 0, payer == tx-sender
  • State mutations: Updates repay state, isolation mode debt reduction
  • External calls: pool-0-reserve-v2-0.update-state-on-repay, transfer-to-reserve
  • Token flows: payer sends up to compounded-balance of asset to pool; overpayment capped at actual debt

liquidation-call (lines 474–522)

  • Authority: is-approved-contract contract-caller
  • Pre-conditions: collateral/debt not frozen, lp matches collateral a-token, oracles match reserves, assets array validated
  • State mutations: Delegates to liquidation-manager-v2-3, reduces isolation debt post-liquidation
  • External calls: liquidation-manager-v2-3.liquidation-call, reduce-isolated-mode-debt-liquidation
  • Token flows: Liquidator sends debt asset; receives collateral (or aTokens)

flashloan-liquidation-step-1 (lines 554–576)

  • Authority: is-approved-contract contract-caller
  • Pre-conditions: liquidity available, fee > 0, flashloan-enabled, is-active, not frozen
  • State mutations: None (only transfer)
  • External calls: transfer-to-user — sends amount to receiver
  • Token flows: pool sends amount to receiver; does not call flashloan-script

flashloan-liquidation-step-2 (lines 579–621)

  • Authority: is-approved-contract contract-caller
  • Pre-conditions: liquidity available (re-checked), fee > 0, flashloan-enabled, is-active, not frozen
  • State mutations: Updates flash loan state, accrues fees
  • External calls: asset.transfer (receiver→pool-vault), pool-0-reserve-v2-0.update-state-on-flash-loan
  • Token flows: receiver returns amount + amount-fee; protocol gets protocol-fee; LPs get amount-fee - protocol-fee

set-e-mode (lines 635–668)

  • Authority: is-approved-contract contract-caller; user == tx-sender
  • Pre-conditions: valid e-mode type, borrows match e-mode type, collateral assets match e-mode type, health factor passes post-change
  • State mutations: pool-reserve-data-2.set-user-e-mode

set-user-use-reserve-as-collateral (lines 713–775)

  • Authority: is-approved-contract contract-caller; who == tx-sender
  • Pre-conditions: reserve active and not frozen, collateral-enabled, balance > 0, isolation mode restrictions
  • State mutations: pool-0-reserve-v2-0.set-user-reserve-data

Configurator (Admin) Functions

Function Authority Effect
set-configurator current configurator Single-step transfer of admin role
init configurator Initialises reserve with given parameters
set-reserve configurator Full reserve state replacement
set-borrowing-enabled configurator Enables/disables borrowing per asset
set-usage-as-collateral-enabled configurator Sets LTV/liquidation-threshold/bonus
add-isolated-asset configurator Marks asset as isolation-mode with debt ceiling
add-asset / remove-asset configurator Manages global asset list
set-borroweable-isolated configurator Adds asset to borrow-in-isolation list
set-freeze-end-block configurator Emergency freeze with block expiry
set-e-mode-type-config configurator Sets e-mode LTV/threshold
set-approved-contract configurator Whitelist/de-whitelist routers

3. Post-Condition Coverage Matrix

Function Token Movements Recommended Caller Post-Conditions
supply User → pool: amount of asset ft-postcondition asset decrease exactly amount from owner
withdraw Pool → user: amount-to-redeem; lp burned ft-postcondition lp decrease; asset increase >= amount
borrow Pool → user: amount-to-be-borrowed ft-postcondition asset increase exactly amount-to-be-borrowed
repay User → pool: up to compounded-balance ft-postcondition asset decrease <= amount-to-repay (capped at actual debt)
liquidation-call Liquidator sends debt; receives collateral or aToken ft-postcondition debt asset decrease; collateral asset / aToken increase
flashloan-liquidation-step-2 Receiver → pool-vault: amount + amount-fee ft-postcondition asset decrease == amount + amount-fee from receiver

4. Authority / Access-Control Matrix

Role Controlled Via Scope
Configurator var-get configurator == tx-sender Full reserve parameter control, approved contract management, e-mode setup
Approved Contracts map-get? approved-contracts contract-caller All user-facing write operations (supply, withdraw, borrow, repay, liquidation, flashloan)
User self is-eq owner/payer/who tx-sender Actions on own position only

Oracle dependency: Reserve states store oracle principal per asset; checked at runtime in withdraw, borrow, liquidation-call. Oracle staleness not checked within this contract (delegated to .pool-0-reserve-v2-0).

Pause / kill switches:

  • is-frozen per reserve (set via set-freeze-end-block)
  • is-active per reserve (set via set-reserve)
  • flashloan-enabled per reserve
  • borrowing-enabled per reserve

5. Clarity Best-Practice Review

✅ Passes

  • is-eq owner tx-sender (and payer, who, user) checked in every user-facing path — correct use of tx-sender for authorization.
  • try! used throughout for cross-contract call error propagation.
  • as-contract not used in this contract — no principal escalation surface.
  • Reserve state validated before mutations in all write paths.
  • Asset list validation (validate-assets) enforced in withdraw, borrow, liquidation-call, set-e-mode, set-user-use-reserve-as-collateral.

⚠� Issues

See Findings Table.


6. Findings Table

ID Severity Function Line Finding Recommended Fix
Z-01 Medium flashloan-liquidation-step-1, step-2 554–621 Flash loan atomicity not enforced — step-1 and step-2 can be called independently Enforce sequenced call in same TX or use nonce/lock
Z-02 Medium set-configurator 625–628 Single-step admin transfer — typo permanently bricks protocol administration Implement propose-accept (two-step) pattern
Z-03 Low supply 84–85 Monotonic users-id map grows without bound — same user gets multiple IDs per supply Check existing registration before insert
Z-04 Low set-borroweable-isolated 930 unwrap-panic panics if isolated asset list reaches 100 Replace with unwrap! and explicit error
Z-05 Low borrow 221 ERR_FROZEN used for is-active check — wrong error code Use ERR_INACTIVE for is-active assertion
Z-06 Low reduce-isolated-mode-debt-liquidation 292 is-approved-contract called in private function after call already validated at public entry Remove redundant check (adds unnecessary cost)
Z-07 Informational flashloan-liquidation-step-1 558 flashloan-script trait parameter accepted but never called Either invoke flashloan-script.execute or remove unused parameter
Z-08 Informational set-configurator 625 No event emission on configurator change Emit print event on admin transfer

Z-01 Detail — Flash loan atomicity not enforced (Medium)

Location: Lines 554–621

Flash loans are designed to be atomic: borrow and repay in the same transaction. This contract splits the operation into two separate public functions:

  • flashloan-liquidation-step-1 (line 574): calls transfer-to-user, sending amount of the asset to receiver. Returns (ok u0).
  • flashloan-liquidation-step-2 (line 599–620): calls asset.transfer to return amount + fee to pool-vault, then updates state.

Critical: Neither function verifies that step-1 was called in the same transaction, nor that step-1 was called at all before step-2. An approved contract could:

  1. Call step-1 to receive tokens.
  2. Never call step-2 — draining the pool of amount tokens.

The is-approved-contract gate mitigates this to whitelisted callers only. However, if an approved router contract itself has a bug or is compromised, the two-step separation removes the atomicity guarantee that makes flash loans safe.

Additionally, the flashloan-script trait parameter on both functions is accepted but never invoked (no contract-call? to the script). The receiver executes its liquidation logic externally, meaning there is no on-chain guarantee the script runs between the two steps.

Fix: Track flash loan state (e.g., a nonce or lock in a data-var) between step-1 and step-2, or consolidate into a single function that calls flashloan-script.execute between the borrow and repayment.


Z-02 Detail — Single-step configurator transfer (Medium)

Location: Lines 625–628

(define-public (set-configurator (new-configurator principal))
  (begin
    (asserts! (is-eq tx-sender (var-get configurator)) ERR_UNAUTHORIZED)
    (ok (var-set configurator new-configurator))))

If new-configurator is a typo, wrong address, or a contract principal without governance capability, all 17 configurator-gated functions become permanently inaccessible. There is no timelock, no recovery path, and no multi-sig requirement.

Affected functions: init, set-reserve, set-borrowing-enabled, set-usage-as-collateral-enabled, add-isolated-asset, add-asset, remove-asset, set-borroweable-isolated, set-freeze-end-block, set-grace-period-*, set-e-mode-*, set-approved-contract.

Fix:

(define-data-var pending-configurator (optional principal) none)

(define-public (propose-configurator (new principal))
  (begin (asserts! (is-eq tx-sender (var-get configurator)) ERR_UNAUTHORIZED)
         (ok (var-set pending-configurator (some new)))))

(define-public (accept-configurator)
  (let ((pending (unwrap! (var-get pending-configurator) ERR_UNAUTHORIZED)))
    (asserts! (is-eq tx-sender pending) ERR_UNAUTHORIZED)
    (var-set configurator pending)
    (ok (var-set pending-configurator none))))

Z-03 Detail — Unbounded users-id growth (Low)

Location: Lines 84–85

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

Every call to supply (regardless of whether owner has supplied before) appends a new entry. With last-user-id as a monotonic counter, a single user calling supply 1000 times accumulates 1000 distinct IDs. The get-user function maps IDs to principals, but there is no reverse map from principal to ID. Off-chain indexers relying on users-id for uniqueness would see duplicates.

Fix: Check for existing registration before inserting, or use a map principal uint for reverse lookup.


Z-04 Detail — unwrap-panic in set-borroweable-isolated (Low)

Location: Line 930

(unwrap-panic (as-max-len? (append borroweable-assets asset) u100))

If the isolation-borrowable list reaches 100 entries, as-max-len? returns none, and unwrap-panic causes a runtime panic (transaction aborts with no error code). This makes the list permanently un-extendable without code changes.

Fix:

(unwrap! (as-max-len? (append borroweable-assets asset) u100) ERR-SOME-DEFINED-ERROR)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment