zest-incentives

2026-01-01

Zest Protocol incentives.clar — Rewards/Incentives

Independent security audit by cocoa007

SourceZest-Protocol/zest-contracts @ 3564bc3
Pathonchain/contracts/borrow/production/rewards/incentives.clar
Lines383
ClarityPre-Clarity 4 (uses legacy as-contract)
Confidence🟡 MEDIUM — multi-contract system, external dependencies not fully verified
DateFebruary 27, 2026
1
High
2
Medium
2
Low
3
Info

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-vault require contract-caller in 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

HIGH H-01: First-Claim Retroactive Reward Windfall

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:

  1. Supply a large amount of sBTC to Zest lending → receive zsbtc
  2. Immediately call claim-rewards
  3. Receive wSTX rewards proportional to the entire program lifetime × balance
  4. 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 ...))
)
MEDIUM M-01: Owner-Controlled Price Oracle Enables Reward Manipulation

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.

MEDIUM M-02: Pre-Clarity 4 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).

LOW L-01: Only sBTC/wSTX Reward Pair Implemented

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.

LOW L-02: 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.

INFO I-01: No Event Emission for Reward Claims

Neither claim-rewards nor claim-rewards-to-vault emit print events. Off-chain indexers cannot track reward distributions without parsing transaction results.

INFO I-02: 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.

INFO I-03: Redundant 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-sender and contract-caller in 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-owner emits a print event for off-chain tracking
  • Approved contract revocation: Unlike some Stacks contracts, the set-approved-contract function accepts a bool, allowing revocation

Related Audits