← Back to index

StackingDAO Pool Signer v1 — Security Audit

Pool signer contract — manages STX stacking delegation to PoX signers for the StackingDAO protocol

Contract: On-chain: SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-pool-signer-restake-v1 · 518 lines

Clarity version: Pre-Clarity 4 (uses as-contract, not as-contract?)

Related audits: StackingDAO Core · Core v2 · Core v3

Date: February 25, 2026

Auditor: cocoa007.btc

Audit confidence: Medium. Single-contract audit with clear logic flow. Depends on external contracts (.dao, .data-core-v1, .data-pools-v1) whose behavior is assumed correct. PoX-4 interactions verified against the protocol specification.

Summary

SeverityCount
MEDIUM2
LOW2
INFO3

Architecture Overview

This contract is StackingDAO's pool signer — it manages the delegation of STX to PoX-4 signers on behalf of the stacking pool. The contract:

Access control model: Three-way OR — pool-owner (mutable), any DAO-active contract via .dao, or the contract itself (via as-contract tx-sender). All admin and PoX wrapper functions use this same guard.

Key flow: prepare-stacking-dao → fetches delegate list from .data-pools-v1 → for each delegate: delegate-stack-stx (new) or extend+increase (existing) → aggregate-commit or aggregate-increase to the signer.

Findings

MEDIUM M-01: No timelock on critical admin state changes

Location: set-pool-owner, set-pox-reward-address

Description: The pool owner can instantly change both the pool owner address and the PoX reward address. A compromised pool-owner key allows an attacker to immediately redirect all future stacking rewards to their own address and lock out the legitimate owner.

(define-public (set-pox-reward-address (new-address { version: (buff 1), hashbytes: (buff 32) }))
  (begin
    (asserts! (or
      (is-eq contract-caller (var-get pool-owner))
      (is-eq true (contract-call? .dao get-contract-active contract-caller))
      (is-eq contract-caller (as-contract tx-sender)))
      (err ERR_UNAUTHORISED))
    (var-set pox-reward-address new-address)  ;; Immediate effect, no timelock
    (ok true)))

Impact: If the pool-owner private key is compromised, the attacker can redirect BTC stacking rewards and transfer ownership in a single transaction. Delegators' STX remains locked in stacking but rewards flow to the attacker for the duration of the cycle.

Recommendation: Implement a two-step ownership transfer pattern (propose + accept) and a timelock on reward address changes, giving delegators time to react. Alternatively, route all admin changes through the DAO governance exclusively.

MEDIUM M-02: Blanket asset access via pre-Clarity 4 as-contract

Location: All as-contract calls (lines throughout delegation, aggregation, PoX wrappers)

Description: The contract uses as-contract (pre-Clarity 4) which grants unrestricted asset transfer authority for the enclosed expression. While the contract currently only uses this authority for PoX stacking operations, any future code change or composability path could inadvertently transfer STX, FTs, or NFTs held by the contract.

;; Example: delegation function
(try! (as-contract (delegate-stack-stx delegate delegation-amount
  (get-pox-reward-address) burn-block-height u1)))

;; Clarity 4 equivalent (recommended):
(try! (as-contract? (delegate-stack-stx delegate delegation-amount
  (get-pox-reward-address) burn-block-height u1)
  with-stacking))

Impact: Low immediate risk (contract doesn't hold transferable assets), but increases the attack surface if the contract is upgraded or composed with other contracts that deposit assets.

Recommendation: Migrate to Clarity 4 and use as-contract? with explicit with-stacking allowance. This restricts the contract's authority to only stacking operations at the language level.

LOW L-01: Silent error swallowing for stacking threshold

Location: prepare-delegate-many

Description: The aggregation step silently ignores ERR_STACKING_THRESHOLD_NOT_MET (error 11). While this is intentional (small pools may not meet the minimum), it masks any other error with code 11 and provides no observability into how often the threshold is missed.

(match (aggregation)
  success true
  error (begin
    (asserts! (is-eq error u11) (err error))  ;; Only error 11 is swallowed
    true))

Impact: If the threshold is persistently not met, delegators' STX sits idle without earning rewards, and there's no on-chain signal to alert operators.

Recommendation: Emit a print event when the threshold is not met so off-chain monitoring can detect and alert on the condition: (begin (print { action: "threshold-not-met", cycle: next-cycle }) true)

LOW L-02: No cycle validation in set-cycle-signer-info

Location: set-cycle-signer-info

Description: The admin function accepts any reward cycle value, including past cycles. Setting signer info for an already-completed cycle wastes gas and creates confusing map state.

Impact: Low — only the admin can call this, and past-cycle signer info is never read by the aggregation logic (which always uses next-cycle). However, it could lead to operator confusion.

Recommendation: Add a check: (asserts! (>= reward-cycle (+ (current-pox-reward-cycle) u1)) (err ERR_INVALID_CYCLE))

INFO I-01: Missing print events for admin state changes

Location: set-pool-owner, set-pox-reward-address, set-cycle-to-index

Description: Admin functions that modify critical state (pool-owner, pox-reward-address, cycle-to-index) do not emit print events. This makes off-chain monitoring and audit trails harder to maintain.

Recommendation: Add print statements to all admin functions, e.g.: (print { action: "set-pool-owner", old-owner: (var-get pool-owner), new-owner: owner })

INFO I-02: can-prepare has no upper bound

Location: can-prepare

Description: The timing check (> burn-block-height (- start-block-next-cycle withdraw-offset)) only enforces a lower bound — it doesn't prevent preparation after the next cycle has already started. In practice, PoX-4 will reject stale operations, but an explicit upper bound would fail faster with a clearer error.

(define-read-only (can-prepare)
  (let (
    (current-cycle (current-pox-reward-cycle))
    (start-block-next-cycle (reward-cycle-to-burn-height (+ current-cycle u1)))
    (withdraw-offset (contract-call? .data-core-v1 get-cycle-withdraw-offset))
  )
    (> burn-block-height (- start-block-next-cycle withdraw-offset))))

Recommendation: Add: (and (> burn-block-height (- start-block-next-cycle withdraw-offset)) (< burn-block-height start-block-next-cycle))

INFO I-03: Excessive unwrap-panic usage in aggregation

Location: aggregation function

Description: The aggregation function calls (unwrap-panic (get pox-addr signer-info)) and similar patterns on fields of signer-info that is already validated as (is-some). While safe (the unwrap will never panic given the prior assert), using unwrap-panic is a code smell — if the logic is ever refactored and the assert is moved, the contract could abort entire transactions.

Recommendation: Use unwrap! with explicit error codes for defense-in-depth: (unwrap! (get pox-addr signer-info) (err ERR_MISSING_SIGNER_INFO))

Positive Patterns

Trust Assumptions

Independent audit by cocoa007.btc · Full audit portfolio · Not a professional security audit — see repo for methodology