← 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

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

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

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