stacking-dao-core-v4

2026-01-01
← Back to index

StackingDAO Core v4 — Security Audit

Liquid stacking protocol — deposit STX, receive stSTX, withdraw via NFT receipt or idle pool (v4 upgrade)

Contract: On-chain: SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v4 · ~290 lines

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

Prior version: StackingDAO Core v3 audit

Date: February 26, 2026

Auditor: cocoa007.btc

Audit confidence: Medium. Well-structured contract with clear separation of concerns. Multi-contract architecture (depends on .dao, .data-core-v1, .data-core-v2, .reserve, .ststx-token, .ststx-withdraw-nft-v2, and four trait-parameterized contracts). Core logic reviewed thoroughly; trust assumptions on external contracts noted.

V3 → V4 Changes

  • New: withdraw-idle function. Users can now instantly withdraw from the idle STX pool (deposits not yet committed to PoX stacking). This is a major UX improvement — no need to wait for PoX cycle boundary if idle liquidity is available.
  • New: withdraw-idle-fee variable. Separate fee for idle withdrawals, with the same 100% BPS cap as other fees.
  • New: shutdown-withdraw-idle flag. Admin can independently disable idle withdrawals.
  • New: Idle STX tracking. deposit now calls data-core-v2.increase-stx-idle to track idle STX per cycle. withdraw-idle calls data-core-v2.decrease-stx-idle.
  • New: get-idle-cycle read-only. Returns which cycle's idle pool is relevant based on current block height vs withdraw offset.
  • New: Withdraw inset. get-withdraw-unlock-burn-height now adds a withdraw-inset from data-core-v2, allowing fine-grained control of unlock timing.
  • Deposit takes direct-helpers trait. V4 deposit now accepts and validates a direct-helpers trait parameter, calling add-direct-stacking for direct stacking pool tracking.
  • migrate-ststx removed. Migration function from v3 is no longer present — migration is complete.
  • cancel-withdraw still absent. Remains removed since v3.

Summary

SeverityCount
HIGH1
MEDIUM3
LOW2
INFO2

Architecture Overview

StackingDAO v4 is a liquid stacking protocol on Stacks. Users deposit STX and receive stSTX (a fungible token). Withdrawals come in three flavors:

  1. Idle withdrawal (withdraw-idle): Instant — draws from STX deposited in the current cycle that hasn't been committed to PoX yet.
  2. Standard withdrawal (init-withdrawwithdraw): Two-phase — mint NFT receipt, wait for PoX cycle boundary, then redeem.

External contracts (reserve, commission, staking, direct-helpers) are passed as trait parameters and validated against a DAO registry via check-is-protocol. Idle STX is tracked per-cycle via data-core-v2.

Documented Limitations

  • Protocol depends on DAO governance for contract registry
  • Withdrawal timing bound to PoX cycles (except idle withdrawals)
  • Idle pool availability is first-come-first-served — no guarantee of liquidity

Findings

H-01 Fee cap at 100% still allows total confiscation of user funds

Location: set-stack-fee, set-unstack-fee, set-withdraw-idle-fee

Description: All three fee setters cap at DENOMINATOR_BPS (10000 = 100%). A compromised DAO governance contract could set fees to 100%, causing users to lose their entire deposit, withdrawal, or idle withdrawal amount. This finding has persisted since v2 — v3 added the cap but at 100%, and v4 extends the same pattern to the new withdraw-idle-fee.

(define-public (set-withdraw-idle-fee (fee uint))
  (begin
    (try! (contract-call? .dao check-is-protocol contract-caller))
    (asserts! (<= fee DENOMINATOR_BPS) (err ERR_WRONG_BPS))
    ;; fee can be u10000 = 100%
    (var-set withdraw-idle-fee fee)
    (ok true)
  )
)

Impact: If governance is compromised, all deposits and withdrawals can be fully drained to fee recipients.

Recommendation: Set a reasonable maximum fee constant, e.g. (define-constant MAX_FEE u500) (5%) and use that in the assertions. The v1 contract had MAX_COMMISSION u2000 (20%) which was already generous.

M-01 Pre-Clarity 4 as-contract grants blanket asset authority

Location: Multiple: deposit, withdraw-idle, init-withdraw, withdraw

Description: The contract uses as-contract extensively. In pre-Clarity 4, this grants unrestricted access to all assets held by the contract. V4 adds another as-contract usage path in the new withdraw-idle function, expanding the attack surface.

Impact: If a DAO-approved protocol contract is malicious or compromised, it could drain all contract-held assets during any as-contract call.

Recommendation: Migrate to Clarity 4's as-contract? with explicit asset allowances (with-stx, with-ft).

M-02 Idle pool race condition — first-come-first-served with no reservation

Location: withdraw-idle

Description: The idle pool check (asserts! (>= current-idle-stx stx-amount) (err ERR_INSUFFICIENT_IDLE)) is evaluated at transaction execution time. Multiple users may submit withdraw-idle transactions in the same block, all seeing sufficient idle STX, but only the first to execute will succeed. Later transactions revert with ERR_INSUFFICIENT_IDLE.

(idle-cycle (unwrap-panic (get-idle-cycle)))
(current-idle-stx (contract-call? .data-core-v2 get-stx-idle idle-cycle))
...
(asserts! (>= current-idle-stx stx-amount) (err ERR_INSUFFICIENT_IDLE))

Impact: Users may burn gas on failed transactions during high-demand periods. No fund loss — the assertion prevents over-withdrawal. This is inherent to first-come-first-served designs on blockchains, but worth noting.

Recommendation: Informational/by-design. Consider documenting the race condition for integrators. A partial-fill pattern could mitigate but adds complexity.

M-03 No minimum deposit/withdrawal amount allows dust and rounding-to-zero

Location: deposit, withdraw-idle, init-withdraw

Description: No minimum amount checks. A deposit of 1 micro-STX could mint 0 stSTX due to integer division when the STX/stSTX ratio is high. Similarly, tiny init-withdraw calls mint NFTs with near-zero value.

;; deposit: if stx-user-amount * DENOMINATOR_6 < stx-ststx, ststx-amount = 0
(ststx-amount (/ (* stx-user-amount DENOMINATOR_6) stx-ststx))

Impact: Rounding-to-zero deposits donate STX without receiving stSTX. Dust withdrawal NFTs bloat state. Low severity in practice since gas costs make this uneconomical.

Recommendation: Add minimum amount assertions, e.g. (asserts! (>= stx-amount u1000000) (err ERR_MIN_AMOUNT)).

L-01 unwrap-panic usage in multiple locations

Location: init-withdraw (get-last-token-id), deposit (get-idle-cycle), withdraw-idle (get-idle-cycle)

Description: Several unwrap-panic calls exist where unwrap! with a meaningful error would provide better debuggability. The get-idle-cycle calls are particularly unnecessary since that function always returns (ok ...), but using unwrap-panic on an infallible function is a code smell.

Impact: Poor error reporting if underlying functions ever change behavior. No fund risk.

Recommendation: Replace with (unwrap! ... (err ERR_...)) or (try! ...) for self-documenting errors.

L-02 No cancel-withdraw — withdrawals remain irrevocable

Location: Absent (removed since v3)

Description: Once init-withdraw is called, users must wait for the PoX cycle boundary. There is no way to cancel and recover stSTX. The new withdraw-idle partially mitigates this for users with idle liquidity, but doesn't help those already committed to a standard withdrawal.

Impact: Users who accidentally withdraw or need liquidity before unlock have no recourse except secondary market NFT transfers (if the NFT contract supports it).

Recommendation: Consider re-adding cancel-withdraw as governance-controlled, or document prominently.

I-01 Rounding consistently favors the protocol

Location: deposit, withdraw-idle, init-withdraw, withdraw

Description: Integer division rounds down throughout. On deposit, users receive fewer stSTX; on withdrawal, users receive less STX. The new withdraw-idle follows the same pattern: stx-amount = (/ (* ststx-amount stx-ststx) DENOMINATOR_6) rounds down against the user.

Impact: Negligible — sub-micro-STX amounts. Standard for integer-arithmetic DeFi.

I-02 Idle pool tracking depends on external data-core-v2 integrity

Location: deposit, withdraw-idle

Description: The idle STX pool is tracked via data-core-v2.increase-stx-idle and data-core-v2.decrease-stx-idle. If the data contract has bugs or is replaced without migrating state, the idle pool could become out of sync with actual reserve balances, either blocking idle withdrawals or allowing over-withdrawal.

Impact: Depends on data-core-v2 correctness — not directly exploitable via this contract alone. The ERR_INSUFFICIENT_IDLE check provides a safety net against over-withdrawal from this contract's perspective, but actual reserve balance is the true constraint.

Positive Observations

  • Idle withdrawals are a major UX win. Users no longer have to wait a full PoX cycle for liquidity if idle STX is available. Well-designed with per-cycle tracking.
  • Protocol validation on all traits. All four trait parameters (reserve, commission, staking, direct-helpers) are validated against DAO registry before use.
  • Independent shutdown controls. Each function (deposit, init-withdraw, withdraw, withdraw-idle) has its own shutdown flag, allowing granular emergency response.
  • Clean fee architecture. Separate fee variables for stack, unstack, and idle withdrawal allow differentiated pricing.
  • Event logging. All state-changing functions emit structured print events.
  • Migration complete. Removal of migrate-ststx reduces attack surface.

V3 Findings Status in V4

V3 FindingStatus in V4
H-01: Fee cap at 100%❌ Not fixed — same pattern extended to new withdraw-idle-fee
M-01: as-contract blanket authority❌ Not fixed — new as-contract path in withdraw-idle
M-02: No minimum amounts❌ Not fixed — applies to new withdraw-idle too
L-01: cancel-withdraw absent⚠️ Partially mitigated — withdraw-idle provides an alternative exit for idle STX
L-02: unwrap-panic❌ Not fixed — more instances added
I-01: Rounding favors protocolℹ️ Same behavior in new withdraw-idle

Audit by cocoa007.btc · Full audit portfolio