zest-rewards-v8
Zest Protocol rewards-v8
Rewards distribution contract for stSTX and stSTXbtc liquid staking on Stacks
| Contract | SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.rewards-v8 |
| Protocol | Zest Protocol — liquid staking (stSTX, stSTXbtc) on Stacks |
| Source | Verified on-chain via Hiro API |
| Lines of Code | ~373 |
| Clarity Version | Pre-Clarity 4 (uses legacy as-contract) |
| Audit Date | February 24, 2026 |
| Confidence | 🟢 HIGH — self-contained rewards distributor; all findings verified against on-chain source |
Overview
This contract manages the collection and linear distribution of staking rewards for Zest Protocol's liquid staking tokens (stSTX for STX stacking rewards, stSTXbtc for sBTC stacking rewards). Rewards earned during PoX cycle X are distributed gradually throughout cycle X+1, released in equal portions across configurable intervals.
The contract is well-structured with proper DAO access control on administrative functions. The process-rewards function validates all trait-based contract parameters against both stored addresses and DAO protocol membership. However, there are concerns around sBTC reward loss when token supplies reach zero, and the pre-Clarity 4 as-contract usage grants blanket authority to commission trait contracts.
Architecture
- Reward collection:
add-rewards/add-rewards-sbtcaccept rewards from callers, split them into commission and protocol portions, and pay pool-owner commissions immediately - Linear vesting:
process-rewardsreleases accumulated rewards proportionally based on elapsed intervals within the distribution cycle - Dual token support: STX rewards go to a reserve contract; sBTC rewards are split between ststxbtc-tracking (v1) and ststxbtc-tracking-v2 based on relative token supply
- DAO governance: All admin functions (interval length, commission contracts, fund recovery) are gated by
.dao check-is-protocol
Findings
M-01: sBTC rewards silently lost when both ststxbtc token supplies are zero
Location: process-rewards — sBTC distribution block
Description: When rewards-protocol-sbtc > 0, the contract calculates distribution between v1 and v2 token holders based on supply:
(let (
(ststxbtc-supply (unwrap-panic (contract-call? .ststxbtc-token get-total-supply)))
(ststxbtc-supply-v2 (unwrap-panic (contract-call? .ststxbtc-token-v2 get-total-supply)))
(total-supply (+ ststxbtc-supply ststxbtc-supply-v2))
(rewards-v1 (if (is-eq total-supply u0)
u0
(/ (* rewards-protocol-sbtc ststxbtc-supply) total-supply)
))
(rewards-v2 (if (is-eq total-supply u0)
u0
(- rewards-protocol-sbtc rewards-v1)
))
)
When total-supply = 0, both rewards-v1 and rewards-v2 are set to u0. Neither tracking contract receives any sBTC. However, the function still increments processed-protocol-sbtc by the full rewards-protocol-sbtc amount. The sBTC remains in the contract with no mechanism to distribute it to future holders.
Impact: If all stSTXbtc holders exit (both v1 and v2 supply reach zero) before rewards for a cycle are processed, those sBTC rewards are effectively locked. The get-sbtc admin function provides an escape hatch (DAO can recover the funds), but automatic distribution is permanently lost for those rewards. This is a realistic scenario during token migration periods.
Recommendation: Either revert when total-supply = 0 to defer processing until holders exist, or route orphaned rewards to the reserve:
(if (is-eq total-supply u0)
;; No holders — send to reserve or revert
(try! (as-contract (contract-call? 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token
transfer rewards-protocol-sbtc tx-sender reserve-address none)))
(begin
;; Normal v1/v2 split...
)
)
M-02: as-contract grants blanket authority to commission trait contracts
Location: process-rewards — commission calls
Description: The contract calls commission contracts using as-contract:
(try! (as-contract (contract-call? commission-ststx-contract add-commission
staking-contract rewards-commission-stx)))
In pre-Clarity 4, as-contract gives the callee full authority to act as the rewards contract — including transferring any STX or tokens held by the contract, not just the intended commission amount. While the commission contract must be both DAO-approved and match the stored address, a compromised or malicious commission contract could drain all funds.
Impact: If a malicious commission contract is approved by the DAO (via governance attack or compromised multisig), it could drain the entire STX and sBTC balance of the rewards contract during a process-rewards call. The dual validation (stored address + DAO check) provides good defense-in-depth, but the pre-Clarity 4 as-contract makes the blast radius larger than necessary.
Recommendation: Migrate to Clarity 4 and use as-contract? with explicit asset allowances:
;; Only allow transferring the exact commission amount
(try! (as-contract?
(with-stx rewards-commission-stx)
(contract-call? commission-ststx-contract add-commission
staking-contract rewards-commission-stx)))
L-01: Systematic rounding bias in sBTC distribution favors v2 token holders
Location: process-rewards — sBTC v1/v2 split
Description: The v1/v2 reward split uses integer division for v1, then gives the remainder to v2:
(rewards-v1 (/ (* rewards-protocol-sbtc ststxbtc-supply) total-supply))
(rewards-v2 (- rewards-protocol-sbtc rewards-v1))
Integer division truncates, so v1 always rounds down and v2 always gets the rounding dust. Over many cycles, v2 holders systematically receive slightly more than their proportional share.
Impact: The per-cycle bias is at most 1 sat (the truncation remainder). Over hundreds of cycles, v2 holders accumulate at most a few hundred sats more than strictly proportional. This is economically negligible but represents a systematic unfairness.
Recommendation: This is a known pattern in integer-math DeFi. Alternating which side gets the remainder (e.g., based on cycle parity) would eliminate the systematic bias, though the economic impact doesn't warrant the added complexity.
L-02: No minimum amount validation in add-rewards functions
Location: add-rewards and add-rewards-sbtc
Description: Both reward addition functions accept any stx-amount / sbtc-amount, including zero. With small amounts, the commission calculation (/ (* amount commission) DENOMINATOR_BPS) can round to zero, meaning the full amount bypasses commission. A zero-amount call succeeds, emitting events and updating maps with +0 values.
Impact: No direct financial loss (caller pays their own funds), but zero/dust-amount calls pollute event logs and waste block space. With very small amounts, commission rounding to zero means pool owners receive nothing while the protocol gets the full amount.
Recommendation: Add a minimum amount check: (asserts! (> stx-amount u0) (err ERR_INVALID_AMOUNT)). Consider a higher minimum to ensure commission calculations are meaningful.
I-01: Pre-Clarity 4 — recommend migration to as-contract?
Location: Entire contract
Description: The contract uses as-contract in multiple locations for STX transfers and sBTC transfers, as well as trait-based calls to commission and tracking contracts. Clarity 4 introduces as-contract? with explicit asset allowances (with-stx, with-ft) that limit the scope of authority granted to callees.
Recommendation: When deploying rewards-v9, target Clarity 4 and replace all as-contract calls with scoped as-contract?. This would mitigate M-02 at the language level.
I-02: Hardcoded sBTC token contract address
Location: add-rewards-sbtc, get-sbtc
Description: The sBTC token address is hardcoded as 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token throughout the contract. If sBTC migrates to a new contract (e.g., a v2 token), a new rewards contract version would need to be deployed.
Recommendation: This is acceptable for the current version since sBTC is canonical. A future version could use a configurable token address (DAO-settable), but the additional complexity may not be warranted given that a rewards contract upgrade would likely be needed anyway for other reasons.
Positive Observations
- Solid access control: All admin functions properly gated behind
.dao check-is-protocolusingcontract-caller(nottx-sender), preventing direct-call bypasses - Defense-in-depth on trait validation:
process-rewardsvalidates trait contracts against both stored addresses AND DAO protocol membership — compromise of one is insufficient - Linear vesting design: Gradual release across intervals prevents reward-sniping (deposit just before distribution, withdraw immediately after)
- Clean interval math:
set-rewards-interval-lengthvalidates that the interval divides the cycle length evenly, preventing remainder-based edge cases - Emergency fund recovery:
get-stxandget-sbtcallow DAO to recover funds in case of bugs (mitigates M-01) - Proper event emission: Both
add-rewardsandprocess-rewardsemit detailed print events for off-chain monitoring
Access Control Summary
| Function | Access | Notes |
|---|---|---|
add-rewards | Permissionless | Caller pays own funds — no restriction needed |
add-rewards-sbtc | Permissionless | Caller pays own funds — no restriction needed |
process-rewards | Permissionless (DAO-gated traits) | Anyone can trigger, but all contracts must be DAO-approved + match stored addresses |
get-stx | DAO protocol only | Emergency fund recovery |
get-sbtc | DAO protocol only | Emergency fund recovery |
set-rewards-interval-length | DAO protocol only | Validates interval divides cycle length |
set-ststx-commission-contract | DAO protocol only | Correctly guarded |
set-ststxbtc-commission-contract | DAO protocol only | Correctly guarded |
set-staking-contract-address | DAO protocol only | Correctly guarded |
Priority Score
| Metric | Score | Weight | Weighted |
|---|---|---|---|
| Financial Risk | 3 — DeFi staking rewards (holds STX + sBTC) | 3 | 9 |
| Deployment Likelihood | 3 — deployed on mainnet | 2 | 6 |
| Code Complexity | 2 — ~373 lines, multi-token distribution logic | 2 | 4 |
| User Exposure | 3 — Zest is a prominent Stacks protocol | 1.5 | 4.5 |
| Novelty | 2 — first rewards/vesting contract in collection | 1.5 | 3 |
| Total Score | 2.65 / 3.0 | ||
Clarity version penalty: -0.5 (pre-Clarity 4) → Adjusted: 2.15 / 3.0 — well above 1.8 threshold.