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
| 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 |
| Map | Key | Value | Mutated By | Authority |
|---|---|---|---|---|
users-id |
uint | principal | supply |
any supply caller |
approved-contracts |
principal | bool | set-approved-contract |
configurator only |
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 |
- 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-idmap (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:
ownersendsamountof asset to pool
- 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-redeemof asset to owner
- 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-borrowedto owner
- 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-balanceof asset to pool; overpayment capped at actual debt
- 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)
- 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— sendsamounttoreceiver - Token flows: pool sends
amounttoreceiver; does not call flashloan-script
- 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:
receiverreturnsamount + amount-fee; protocol getsprotocol-fee; LPs getamount-fee - protocol-fee
- 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
- 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
| 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 |
| 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 |
| 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-frozenper reserve (set viaset-freeze-end-block)is-activeper reserve (set viaset-reserve)flashloan-enabledper reserveborrowing-enabledper reserve
is-eq owner tx-sender(andpayer,who,user) checked in every user-facing path — correct use oftx-senderfor authorization.try!used throughout for cross-contract call error propagation.as-contractnot used in this contract — no principal escalation surface.- Reserve state validated before mutations in all write paths.
- Asset list validation (
validate-assets) enforced inwithdraw,borrow,liquidation-call,set-e-mode,set-user-use-reserve-as-collateral.
See 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 |
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): callstransfer-to-user, sendingamountof the asset toreceiver. Returns(ok u0).flashloan-liquidation-step-2(line 599–620): callsasset.transferto returnamount + feetopool-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:
- Call step-1 to receive tokens.
- Never call step-2 — draining the pool of
amounttokens.
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.
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))))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.
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)