# Static Analysis Report: Zest `pool-borrow-v2-3` ## Scope - Bounty: `mpwj1rjde88d5b53b990` - Contract: `SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.pool-borrow-v2-3` - Review type: manual Clarity static analysis - Reviewed source length: 1005 lines - Reviewer: `Void Kai` This review focused on authorization boundaries, reserve-accounting invariants, collateral/e-mode configuration, isolated-mode list handling, and user-facing state transitions. Previously reported community issues around split flashloan execution, `users-id` growth, single-step configurator transfer, and the inactive/frozen borrow error-code mismatch were treated as known context and not re-submitted as primary findings. ## Executive Summary No high- or critical-severity exploit was identified in this pass. The highest-value remaining issues are defense-in-depth weaknesses around configurator-controlled state. The contract gives the configurator broad power to overwrite live reserve accounting and set collateral/e-mode risk parameters without local invariant checks. While the configurator is privileged, these paths are security-relevant in a lending protocol because a malformed transaction, compromised configurator, or integration bug can immediately affect solvency calculations, liquidation thresholds, and market availability. ## State Model ### Constants - `max-value`: sentinel value fetched from `.math-v2-0` - `one-8`: fixed precision one value fetched from `.math-v2-0` - Error constants: `ERR_UNAUTHORIZED` through `ERR_MUST_ENABLE_ASSET_OF_E_MODE` - `e-mode-disabled-type`: `0x00` ### Maps and Vars - `users-id`: maps monotonically assigned IDs to principals - `last-user-id`: monotonically incremented on every `supply` - `configurator`: privileged principal for reserve, collateral, e-mode, list, and approved-contract administration - `approved-contracts`: principal allowlist used to gate helper-contract callers ### External State Dependencies The contract relies heavily on external reserve/data modules: - `.pool-0-reserve-v2-0`: reserve state, user reserve data, collateral checks, transfers, reserve liquidity, e-mode lookups, asset validation helpers - `.pool-reserve-data`, `.pool-reserve-data-1`, `.pool-reserve-data-2`, `.pool-reserve-data-3`: reserve lists, freeze/grace state, e-mode config, isolated-debt accounting - `.liquidation-manager-v2-3`: liquidation execution - Asset, a-token, flashloan, oracle, and redeemable traits supplied as parameters ## Public Function Inventory ### User and Market Actions - `supply`: deposits an asset, optionally enables first-supply collateral, mints the reserve a-token, and transfers underlying to reserve. - `withdraw`: burns a-token balance, checks collateral decrease, updates state, and transfers underlying out. - `borrow`: validates reserve, oracle, z-token, approved caller, assets, collateral, borrow cap, and transfers borrowed liquidity. - `repay`: caps overpayment to due amount, reduces isolated debt where relevant, updates reserve state, and transfers repayment to reserve. - `liquidation-call`: validates collateral/debt reserve metadata, delegates liquidation, then reduces isolated debt by actual liquidated debt. - `flashloan-liquidation-step-1`: transfers flashloan principal to receiver. - `flashloan-liquidation-step-2`: collects principal plus fee and updates flashloan state. - `set-e-mode`: lets a user switch e-mode after borrow/collateral-type checks and a health-factor check. - `set-user-use-reserve-as-collateral`: toggles collateral usage with isolation/e-mode/health checks. ### Configurator Actions - `set-configurator` - `init` - `set-reserve` - `set-borrowing-enabled` - `set-usage-as-collateral-enabled` - `add-isolated-asset` - `add-asset` - `remove-asset` - `remove-isolated-asset` - `set-borroweable-isolated` - `remove-borroweable-isolated` - `set-freeze-end-block` - `set-grace-period-time` - `set-grace-period-enabled` - `set-e-mode-type-enabled` - `set-e-mode-type-config` - `set-asset-e-mode-type` - `set-approved-contract` ### Read-Only and Private Helpers - Asset and reserve validation: `validate-assets`, `check-assets`, `get-assets`, `get-reserve-state` - Collateral/e-mode: `validate-use-as-collateral`, `can-enable-e-mode`, `collateral-assets-are-of-e-mode-type`, `collateral-assets-of-e-mode-type`, `get-asset-e-mode-type`, `get-user-e-mode`, `is-in-e-mode` - Isolation accounting: `validate-borrow-in-isolated-mode`, `calculate-total-isolated-debt`, `acc-debt`, `reduce-isolated-mode-debt`, `reduce-isolated-mode-debt-liquidation`, `get-asset-isolation-mode-debt`, `get-borroweable-isolated` - Utility: `calculate-price`, `mul-to-fixed-precision`, `filter-asset`, `is-configurator`, `is-approved-contract`, `get-user-reserve-data` ## Postcondition and Invariant Review | Flow | Main enforced checks | Residual concern | | --- | --- | --- | | `supply` | positive amount, active, not frozen, z-token match, owner is `tx-sender`, approved caller, supply cap | `users-id` accounting grows per supply call; known community finding | | `withdraw` | approved caller, canonical asset tuple, z-token/oracle match, owner auth, liquidity, balance-decrease check | caller must provide exact full asset tuple list/order | | `borrow` | approved caller, canonical asset tuple, owner auth, z-token/oracle, borrow enabled, active/frozen, liquidity, collateral, cap | inactive reserve returns `ERR_FROZEN`; known community finding | | `repay` | approved caller, debt exists, reserve not frozen, payer auth, overpay capped | no issue found | | `liquidation-call` | approved caller, reserve not frozen, z-token/oracle checks, delegated liquidation result matched | depends on liquidation manager correctness | | `set-reserve` | configurator only | full live reserve state can be overwritten without local invariants | | `set-usage-as-collateral-enabled` | configurator only | no local LTV/liquidation-threshold/bonus relationship checks | | `set-e-mode-type-config` | configurator only | no local e-mode LTV/liquidation-threshold relationship checks | | `set-borroweable-isolated` | configurator only | duplicate entries and full-list panic possible | ## Authority and Access Control Matrix | Function class | Authorization model | Assessment | | --- | --- | --- | | User actions | `tx-sender` must equal the supplied user/owner/payer where user funds move | Generally sound | | Helper-mediated actions | `is-approved-contract contract-caller` | Sound as an allowlist, but correctness depends on approved wrappers preserving postconditions | | Configurator actions | `is-configurator tx-sender` | Broad authority; missing staged transfer/timelock and local invariant checks increase operational risk | | Read-only helpers | no mutation | Safe | ## Findings ### ZEST-01: `set-reserve` Allows Full Live Accounting-State Overwrite - Severity: Medium - Location: `set-reserve` - Category: privileged-state invariant risk The configurator can call `set-reserve` with a full reserve-state tuple and pass it directly to `.pool-reserve-data set-reserve-state`. The tuple includes not only policy fields such as caps, oracle, and feature flags, but also live accounting fields: - `last-liquidity-cumulative-index` - `current-liquidity-rate` - `total-borrows-stable` - `total-borrows-variable` - `current-variable-borrow-rate` - `current-stable-borrow-rate` - `current-average-stable-borrow-rate` - `last-variable-borrow-cumulative-index` - `last-updated-block` - `accrued-to-treasury` Because the wrapper does not preserve these fields or enforce relationships between old and new state, a malformed configurator transaction can overwrite live debt totals, indexes, treasury accruals, or timestamps. In a lending protocol, those values are inputs to borrow capacity, available liquidity, repayment, interest accrual, and liquidation math. #### Impact This is not an unprivileged exploit. The impact is a privileged or operational failure mode: compromised governance, a mistaken admin script, or a bad integration can directly corrupt reserve accounting in one call. Depending on the overwritten fields, downstream effects can include incorrect user health calculations, incorrect borrow-cap enforcement, incorrect interest accrual, and broken market accounting. #### Evidence `init` constructs a fresh reserve state and is appropriate for bootstrapping. `set-reserve`, however, exposes the same full tuple for arbitrary later updates and forwards it without local checks. More constrained setters such as `set-borrowing-enabled` merge one field over an existing reserve state; `set-reserve` bypasses that safer pattern. #### Recommendation Split reserve administration into typed, narrow setters: - Keep live accounting fields updateable only by protocol accounting flows. - Keep risk parameters updateable only through dedicated setters with bounds. - If a full-state setter must remain, enforce invariant checks against current state before writing: - Preserve debt totals unless an explicit migration flag is active. - Preserve cumulative indexes unless a controlled migration path updates them. - Require `last-updated-block <= block-height` and monotonicity where expected. - Validate `a-token-address`, oracle, decimals, caps, and collateral parameters. ### ZEST-02: Collateral and E-Mode Risk Parameters Lack Local Relationship Checks - Severity: Medium - Location: `set-usage-as-collateral-enabled`, `set-e-mode-type-config` - Category: risk-parameter validation Two configurator functions accept collateral-risk values directly: - `set-usage-as-collateral-enabled(asset, enabled, base-ltv-as-collateral, liquidation-threshold, liquidation-bonus)` - `set-e-mode-type-config(e-mode-type, ltv, liquidation-threshold)` Neither function locally enforces common lending invariants such as: - `base-ltv-as-collateral <= liquidation-threshold` - `ltv <= liquidation-threshold` - thresholds bounded by the protocol fixed-precision one value - liquidation bonus bounded to a sane range - collateral disabled implies zeroed LTV/threshold/bonus, or a documented exception Because user health, borrow capacity, and collateral checks consume these values through reserve/global-data calculations, malformed parameters can make accounts borrow too much, become impossible to liquidate correctly, or fail health checks unexpectedly. #### Impact This is privileged but security-relevant. A single bad parameter update can affect all users of a reserve or e-mode category. In the worst operational case, a reserve can be configured with inconsistent health-factor math, exposing lenders to undercollateralized borrows or trapping users in states that cannot be safely unwound. #### Recommendation Add explicit validation before writing collateral or e-mode config: - For reserve collateral: - if `enabled = false`, require `base-ltv-as-collateral = 0`, `liquidation-threshold = 0`, and `liquidation-bonus = 0`, unless a clear migration mode is used. - if enabled, require `0 < base-ltv-as-collateral <= liquidation-threshold <= one-8`. - require liquidation bonus to be within a protocol-defined minimum/maximum. - For e-mode: - require `ltv <= liquidation-threshold <= one-8`. - consider rejecting configs for disabled e-mode types or forcing explicit enablement order. ### ZEST-03: Borrowable-Isolated List Can Accumulate Duplicates and Panic at Capacity - Severity: Low - Location: `set-borroweable-isolated`, `remove-borroweable-isolated`, `filter-asset` - Category: list hygiene / availability `set-borroweable-isolated` appends the supplied asset to the current borrowable-isolated list and wraps the append with `unwrap-panic (as-max-len? ... u100)`. It does not check whether the asset is already present. Consequences: - Re-adding the same asset consumes another slot. - Duplicate additions can fill the 100-entry list with repeated principals. - Once full, any further append panics rather than returning a domain error. - `remove-borroweable-isolated` filters all matching entries, so a duplicate-filled list can be cleaned up, but only if the configurator realizes the duplication and calls removal. #### Impact This is a low-severity operational issue. It does not give an unprivileged caller control, but it can degrade configurator operations and create surprising list behavior. #### Recommendation Before appending: - return success without mutation if the asset is already present, or return a specific duplicate error; - return a normal error if the list is at capacity; - optionally keep a `map` membership index if list membership is frequently updated. ### ZEST-04: Freeze and Grace Period Controls Have No Local Caps - Severity: Low - Location: `set-freeze-end-block`, `set-grace-period-time`, `set-grace-period-enabled` - Category: privileged availability risk The configurator can set freeze end blocks and grace-period time/enabled flags without local range checks. This is expected for an emergency-control surface, but it also means a malformed transaction can place a reserve into an unexpectedly long grace or freeze window. #### Impact This is an operational availability risk rather than an unprivileged exploit. A compromised or mistaken configurator can disrupt market behavior for longer than intended. #### Recommendation Use local maximums, governance timelocks, or staged updates for non-emergency changes. For emergency mode, emit structured events and require follow-up review automation so long-duration settings are visible. ### ZEST-05: `validate-assets` Exact Order/Length Requirement Is Integration-Fragile - Severity: Informational - Location: `validate-assets`, `check-assets` - Category: integration safety `validate-assets` requires the caller-supplied asset tuple list to have exactly the same length and order as `get-assets`, then validates each corresponding tuple. This is strict and safe, but fragile for integrations: any wrapper passing a stale list, missing newly added asset, or alternate order will fail even when the individual asset tuples are otherwise correct. #### Impact No direct exploit was identified. The concern is integration reliability, especially for frontends or approved helper contracts that must keep asset-list construction synchronized with reserve registry changes. #### Recommendation Expose a canonical read-only helper that returns the exact typed tuple list required by user flows, or provide an order-independent validation path where feasible. At minimum, document that user-facing wrappers must pass the full canonical asset list in registry order. ## Clarity Best-Practice Notes - `tx-sender` is used for user-level ownership checks, while `contract-caller` is used for approved helper gating. This separation is appropriate for wrapper-mediated flows. - `try!` is used consistently around fallible external calls. - `unwrap-panic` appears in `supply` for collateral validation and in list append/filter helpers. Where possible, prefer explicit domain errors over panic paths so callers can distinguish configuration mistakes from runtime panics. - Arithmetic is generally guarded by surrounding cap and liquidity checks, but risk-parameter setters should bound values before downstream calculations consume them. - Trait parameters are validated against reserve metadata in user flows, reducing risk of caller-supplied mismatched assets/oracles. ## Recommended Fix Priority 1. Split or constrain `set-reserve` so live accounting fields cannot be overwritten through a generic admin tuple. 2. Add local collateral/e-mode parameter invariants before writing risk config. 3. Add duplicate and capacity guards for borrowable-isolated list updates. 4. Add cap/timelock/monitoring safeguards around freeze and grace-period configuration. 5. Document or expose canonical asset-list construction for approved wrappers and frontends. ## Conclusion The contract enforces core user authorization and asset metadata checks well, but several configurator paths are too permissive for a lending protocol where reserve accounting and risk parameters directly drive solvency. Tightening those privileged surfaces would materially reduce governance, deployment, and integration risk even if no unprivileged high-severity exploit exists in the reviewed code.