zest-incentives
Zest Protocol incentives.clar — Rewards/Incentives
Independent security audit by cocoa007
| Source | Zest-Protocol/zest-contracts @ 3564bc3 |
| Path | onchain/contracts/borrow/production/rewards/incentives.clar |
| Lines | 383 |
| Clarity | Pre-Clarity 4 (uses legacy as-contract) |
| Confidence | 🟡 MEDIUM — multi-contract system, external dependencies not fully verified |
| Date | February 27, 2026 |
Overview
This contract manages reward distribution for Zest Protocol liquidity providers. Users who supply sBTC to Zest's lending pool receive zsbtc LP tokens. The incentives contract tracks a cumulative reward index (similar to Aave/Compound interest accrual) and distributes wSTX rewards proportional to LP holdings over time.
The contract delegates extensively to external contracts: rewards-data / rewards-data-1 for state storage, pool-0-reserve-v2-0 for index calculations, and math-v2-0 for fixed-point arithmetic. Only the sBTC→wSTX reward pair is currently implemented; other pairs silently return zero.
Architecture
- Owner-gated admin:
set-price,set-precision,set-liquidity-rate,withdraw-assets,initialize-reward-program-data - Approved-contract ACL:
claim-rewards/claim-rewards-to-vaultrequirecontract-callerin allowlist +who == tx-sender - Vault accumulator: Unclaimed rewards accumulate in a vault (via
rewards-data-1); user claims flush the vault - Cumulative index model: Linear interest accrual per block, compounded into a cumulative index
Findings
Location: get-user-program-index-eval
When a user holds zsbtc LP tokens but has never claimed rewards (no stored program index), the function returns one (1e8 — the base index). The cumulated balance formula divides the current normalized income by this base index, effectively granting the user rewards for the entire program history, not just their holding period.
(match (get-user-program-index who supplied-asset reward-asset)
index (ok index)
(let ((balance (try! (contract-call? lp-supplied-asset get-balance who))))
(if (> balance u0)
(ok one) ;; ← base index = ALL historical rewards
(ok (get last-liquidity-cumulative-index ...))
)
)
)
Attack scenario:
- Supply a large amount of sBTC to Zest lending → receive zsbtc
- Immediately call
claim-rewards - Receive wSTX rewards proportional to the entire program lifetime × balance
- Withdraw sBTC from lending
Impact: New large depositors can drain wSTX rewards intended for long-term holders. The reward pool's wSTX balance acts as the ceiling — the attacker extracts up to the full contract balance.
Recommendation: For users with no stored index, always return the current last-liquidity-cumulative-index regardless of balance. This ensures they only earn rewards from their first interaction onward:
;; Fix: always use current index for uninitialized users
(if (> balance u0)
(ok (get last-liquidity-cumulative-index
(unwrap! (get-reward-program-income supplied-asset reward-asset) err-not-found)))
(ok (get last-liquidity-cumulative-index ...))
)
Location: set-price, convert-to
The contract owner can set arbitrary prices for any asset. The convert-to function uses these prices to calculate reward amounts (converting between sBTC denomination and wSTX denomination). A compromised or malicious owner could set extreme price ratios to inflate rewards for specific users or deflate them to effectively steal from the reward pool.
Impact: Owner can manipulate reward payouts by adjusting price ratios. No bounds checking, no deviation limits, no freshness requirements.
Recommendation: Use an external oracle (Pyth/DIA — Zest's v0-3-market already does this) or add max-deviation checks against the previous price.
as-contract Blanket Authority
Location: withdraw-assets, send-rewards
Both functions use legacy as-contract which grants unrestricted authority over ALL assets held by the contract. The withdraw-assets function is particularly concerning — it accepts an arbitrary <ft> trait parameter, meaning the owner can call it with any fungible token contract (including malicious implementations) under the contract's full authority.
(define-public (withdraw-assets (asset <ft>) (amount uint) (who principal))
(begin
(asserts! (is-contract-owner tx-sender) ERR_UNAUTHORIZED)
(as-contract (contract-call? asset transfer amount tx-sender who none))))
Impact: Malicious trait implementation could execute unexpected operations under contract identity. Owner can drain any token held by the contract.
Recommendation: Migrate to Clarity 4 as-contract? with explicit asset allowances: (with-ft .zsbtc-v2-0), (with-ft 'SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.wstx).
Location: update-claim-state
The claim logic only handles the case where supplied-asset = sbtc-token. All other asset pairs silently return (ok u0). Users calling claim-rewards with non-sBTC supplied assets receive no rewards and no error indication.
Recommendation: Return an explicit error for unsupported asset pairs to prevent silent claim failures.
unwrap-panic in Index Update
Location: update-cumulative-index
unwrap-panic on the set-reward-program-income result aborts the entire transaction with no meaningful error code if the external call fails.
Recommendation: Use unwrap! with a descriptive error constant.
Neither claim-rewards nor claim-rewards-to-vault emit print events. Off-chain indexers cannot track reward distributions without parsing transaction results.
withdraw-assets Accepts Arbitrary FT Trait
While owner-gated, the function accepts any <ft> trait — meaning the owner can withdraw any fungible token held by the contract, not just designated reward tokens. This is broad admin power with no asset-type restriction.
and in Condition
Location: update-claim-state
(if (and (is-eq (contract-of supplied-asset) sbtc-token)) — the and wrapping a single condition is functionally correct but syntactically redundant.
Positive Observations
- Dual authentication on claims: Requires both
who == tx-senderandcontract-callerin approved-contracts list — solid access control - Vault accumulator pattern: Separates reward accrual from claiming, allowing flexible claim timing
- Clean math delegation: All fixed-point arithmetic delegated to
math-v2-0, reducing inline math errors - Owner transfer with event:
set-contract-owneremits a print event for off-chain tracking - Approved contract revocation: Unlike some Stacks contracts, the
set-approved-contractfunction accepts a bool, allowing revocation