borrow-helper-v2-1-7
Zest Protocol borrow-helper-v2-1-7
Lending Helper / Router Contract — Independent Security Audit
Findings Summary
Overview
borrow-helper-v2-1-7 is a thin helper/router contract for the Zest Protocol lending platform on Stacks mainnet. It wraps the core pool-borrow-v2-4 contract, adding:
- Pyth oracle price feed updates (via
write-feed) before price-sensitive operations - Incentive/rewards claiming before supply and withdraw operations
- Detailed event emission (print statements) for off-chain indexing
- A convenience
supply-allfunction for batch multi-asset supply
The contract holds no state and no funds. All token transfers and access control are delegated to downstream contracts. This is an inherently low-risk architecture — the helper itself cannot lose funds. The primary risk surface is incorrect event data misleading off-chain systems.
Architecture
| Function | Delegates To | Extra Logic |
|---|---|---|
supply | pool-borrow-v2-4.supply | Claims incentive rewards first |
supply-all | supply (×10 assets) | Batch convenience wrapper |
borrow | pool-borrow-v2-4.borrow | Pyth feed update |
repay | pool-borrow-v2-4.repay | Event logging |
withdraw | pool-borrow-v2-4.withdraw | Pyth feed + incentive claim |
claim-rewards | incentives.claim-rewards | Rewards contract validation |
liquidation-call | pool-borrow-v2-4.liquidation-call | Pyth feed update |
set-e-mode | pool-borrow-v2-4.set-e-mode | Pyth feed update |
set-user-use-reserve-as-collateral | pool-borrow-v2-4.set-user-use-reserve-as-collateral | Pyth feed update |
Positive Observations
- Consistent
tx-sender == contract-callercheck on every public function — prevents smart-contract-mediated calls and reentrancy vectors - No
as-contractusage — despite being pre-Clarity 4, the contract never assumes contract identity for token operations - Rewards contract validation —
is-rewards-contractchecks the incentives trait against a stored whitelist, preventing malicious incentives contracts - Stateless, custody-free design — no maps, no data-vars (beyond constants), no stored funds
- Pyth oracle integration — price feeds updated atomically before price-sensitive operations (borrow, withdraw, liquidation, collateral toggle, e-mode)
Findings
M-01: Incorrect Reserve State in Liquidation Event
MEDIUMliquidation-call — print statementcollateral-reserve-state field in the liquidation event log queries debt-asset-principal instead of collateral-asset-principal. This emits the debt reserve state twice and never emits the actual collateral reserve state.;; BUG: uses debt-asset-principal instead of collateral-asset-principal
collateral-reserve-state: (try! (contract-call? .pool-0-reserve-v2-0
get-reserve-state debt-asset-principal)),
;; ^^^^^^^^^^^^^^^^^^^
;; Should be: collateral-asset-principal
collateral-reserve-state: (try! (contract-call? .pool-0-reserve-v2-0
get-reserve-state collateral-asset-principal)),L-01: Owner Parameter Not Validated Against tx-sender
LOWsupply, borrow, withdraw, claim-rewards, set-user-use-reserve-as-collateral, set-e-modeowner (or who/user) parameter without verifying it equals tx-sender. The contract checks (is-eq tx-sender contract-caller) but not (is-eq tx-sender owner). Whether this is exploitable depends entirely on the downstream pool-borrow-v2-4 access control.pool-borrow-v2-4 does not independently verify ownership:
supply: Caller pays tokens, credit goes to arbitraryowner— likely intentional (supply on behalf of)borrow: Could potentially borrow against someone else's collateralwithdraw: Could potentially withdraw someone else's supplied assetsclaim-rewards: Could claim someone else's rewards
(asserts! (is-eq tx-sender owner) ERR_UNAUTHORIZED) to functions where owner must equal caller, or document explicitly that pool-borrow-v2-4 enforces this. The "supply on behalf of" pattern should use a separate function.L-02: No Transaction Deadline on Price-Sensitive Operations
LOWborrow, liquidation-call, withdrawdeadline parameter:
(asserts! (< stacks-block-height deadline) (err u9999))I-01: liquidation-call Returns Hardcoded (ok u0)
INFOliquidation-callpool-borrow-v2-4.liquidation-call and returns (ok u0). All other functions return (ok true). The inconsistency means callers cannot distinguish between different liquidation outcomes.(ok true) for consistency.I-02: Hardcoded Token Addresses in supply-all
INFOsupply-allsupply-all function hardcodes 10 specific token contract addresses (ststx, aeusdc, wstx, diko, usdh, susdt, usda, sbtc, ststxbtc, alex) and their corresponding z-token LP contracts. Adding or removing supported assets requires deploying a new helper version.I-03: Pre-Clarity 4 Contract
INFOas-contract and holds no assets, the practical impact is zero. Clarity 4's as-contract? with explicit allowances would be relevant if the contract ever needed to act with its own authority.as-contract patterns.Trust Assumptions
This contract's security is almost entirely dependent on downstream contracts. The helper is a pass-through — it cannot independently lose funds. Key trust boundaries:
pool-borrow-v2-4— must enforce ownership checks on borrow, withdraw, collateral toggle, and e-mode changes. All financial logic lives here.pool-0-reserve-v2-0— must correctly track reserve and user state. Used only for read-only event data in this contract.- Pyth Oracle (
pyth-oracle-v4) — price feed integrity depends on Wormhole guardian signatures. The helper correctly passes through caller-provided bytes for on-chain verification. - Incentives trait contracts — validated against
rewards-datawhitelist before use. Trust is in the whitelist management.
Conclusion
This is a well-structured, low-risk helper contract. It follows the common Aave-style "helper" pattern — a thin routing layer that bundles oracle updates, incentive claims, and event emission around core protocol calls. The one material finding (M-01) is a copy-paste bug in the liquidation event log that could mislead off-chain systems. The architectural decision to keep the helper stateless and custody-free is sound and limits the blast radius of any bugs in this layer.