← Back to index

StackingDAO Core v2 — Security Audit

Liquid stacking protocol — deposit STX, receive stSTX, withdraw via NFT receipt

Contract: On-chain: SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG.stacking-dao-core-v2 · Deploy height: 147394 · 301 lines

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

Prior version: StackingDAO Core v1 audit (referenced pox-3, now deprecated)

Date: February 25, 2026

Auditor: cocoa007.btc

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

Summary

SeverityCount
HIGH2
MEDIUM2
LOW1
INFO3

Architecture Overview

StackingDAO v2 is a liquid stacking protocol on Stacks. Users deposit STX and receive stSTX (a fungible token representing their staked position). Withdrawals are a two-phase process: init-withdraw mints an NFT receipt and locks stSTX, then withdraw burns both and returns STX after a PoX cycle boundary. A cancel-withdraw path lets users reclaim stSTX before the unlock height.

Key design: all external contracts (reserve, commission, staking, direct-helpers) are passed as trait parameters and validated against a DAO registry via check-is-protocol. This is a well-known Stacks pattern for upgradeable protocol components.

Documented Limitations

Findings

H-01 No fee cap — admin can set 100% deposit/withdrawal fee

Location: set-stack-fee, set-unstack-fee (lines 236–250)

Description: The stack-fee and unstack-fee variables (in basis points) have no upper bound validation. A compromised or malicious DAO governance contract could set fees to 10000 (100%), causing users to lose their entire deposit or withdrawal amount to fees.

(define-public (set-stack-fee (fee uint))
  (begin
    (try! (contract-call? .dao check-is-protocol contract-caller))
    ;; No max check — fee could be u10000 (100%)
    (var-set stack-fee fee)
    (ok true)
  )
)

Impact: If governance is compromised, all new deposits and withdrawals can be fully drained to fee recipients. Existing stSTX holders' withdrawals would also be affected.

Recommendation: Add a maximum fee constant (e.g., MAX_FEE u500 = 5%) and assert in both setters: (asserts! (<= fee MAX_FEE) (err ERR_MAX_FEE)). Note: v1 had MAX_COMMISSION u2000 (20%) — this guard was removed in v2.

H-02 Pre-Clarity 4 as-contract grants blanket asset authority

Location: Multiple: deposit (line 88), init-withdraw (line 120), cancel-withdraw (lines 158–160), withdraw (lines 186–194)

Description: The contract uses as-contract (pre-Clarity 4) throughout, which grants the called contract unrestricted access to all assets held by this contract principal. Any trait-parameterized contract called within as-contract blocks (reserve, commission, staking) could potentially move assets beyond what is intended.

Impact: While the DAO registry validation mitigates this (only approved contracts can be passed), if any approved contract has a vulnerability, it could abuse the blanket as-contract authority to drain STX or stSTX held by the core contract.

Recommendation: Migrate to Clarity 4 and use as-contract? with explicit asset allowances: (as-contract? (with-stx ...)). This enforces at the language level which assets can be moved, eliminating the risk even if a registered protocol contract is compromised.

M-01 Potential arithmetic underflow in get-withdraw-unlock-burn-height

Location: get-withdraw-unlock-burn-height (line 49)

Description: The expression (- start-block-next-cycle withdraw-offset) will panic if withdraw-offset exceeds start-block-next-cycle. While this is unlikely in practice (cycle start heights are large numbers), a misconfigured cycle-withdraw-offset in the data contract could cause all deposit, init-withdraw, and cancel-withdraw operations to fail (since they call functions that might reference this read-only).

(if (< burn-block-height (- start-block-next-cycle withdraw-offset))
  ;; underflow panics if withdraw-offset > start-block-next-cycle

Impact: A bad configuration value in .data-core-v1 could DoS the withdrawal timing calculation, though this read-only is only called by init-withdraw.

Recommendation: Use a safe subtraction pattern or validate the offset: (if (> start-block-next-cycle withdraw-offset) (- start-block-next-cycle withdraw-offset) u0)

M-02 cancel-withdraw round-trips STX through reserve unnecessarily

Location: cancel-withdraw (lines 158–160)

Description: To cancel a withdrawal, the contract calls request-stx-for-withdrawal (which transfers STX from reserve to this contract) and immediately sends it back with stx-transfer?. This round-trip exists because request-stx-for-withdrawal is the only way to decrement the stx-for-withdrawals counter. However, this creates a window where reserve funds are temporarily in the core contract, and the two-step operation could fail partway (e.g., if reserve has insufficient unlocked STX).

;; Request STX from reserve (decrements withdrawal counter)
(try! (as-contract (contract-call? reserve request-stx-for-withdrawal stx-amount tx-sender)))
;; Immediately send it back
(try! (as-contract (stx-transfer? stx-amount tx-sender (contract-of reserve))))

Impact: If the reserve lacks sufficient unlocked STX to fulfill the request, cancellation fails even though no STX actually needs to move. This could lock users into unwanted withdrawals during periods of high stacking utilization.

Recommendation: Add a dedicated cancel-stx-for-withdrawal function to the reserve trait that decrements the counter without moving STX.

L-01 withdraw uses strict > instead of >= for unlock height check

Location: withdraw (line 183)

Description: The assertion (asserts! (> burn-block-height unlock-burn-height) ...) requires the block height to be strictly greater than the unlock height. If the unlock height is exactly the start of the next cycle, this means users cannot withdraw in the first block of the cycle — they must wait one additional block.

(asserts! (> burn-block-height unlock-burn-height) (err ERR_WITHDRAW_LOCKED))

Impact: Minor UX issue — one-block delay on withdrawal availability. Not exploitable.

Recommendation: Consider using >= if the intent is to allow withdrawal once the cycle boundary is reached.

I-01 Deposit event logs stxstx-amount (typo for ststx-amount)

Location: deposit (line 95)

Description: The print statement uses key stxstx-amount instead of ststx-amount. This is inconsistent with other events and could confuse indexers.

(print { action: "deposit", data: { stacker: tx-sender, stx-amount: stx-amount,
  stxstx-amount: ststx-amount, ... } })

Recommendation: Fix to ststx-amount in a future version for consistency.

I-02 Exchange rate truncation on deposit and withdrawal

Location: deposit (line 76), init-withdraw (line 109)

Description: The stSTX↔STX conversions use integer division which truncates. On deposit: (/ (* stx-user-amount u1000000) stx-ststx). On withdrawal: (/ (* ststx-amount stx-ststx) u1000000). Small rounding losses accumulate in favor of the protocol.

Impact: Dust-level losses per transaction. Standard pattern in DeFi — not a bug, but users should be aware.

Recommendation: Informational only. Consider documenting the rounding behavior.

I-03 migrate-ststx has no re-entrancy guard or one-time flag

Location: migrate-ststx (lines 276–286)

Description: The migration function burns stSTX from v1 core and mints to v2 core. It is gated by check-is-protocol but has no flag to prevent multiple calls. If v1 core somehow re-acquires stSTX, migration could be called again.

Impact: Low — would only mint stSTX equal to what v1 core holds, and is admin-gated. But a one-time guard would be cleaner.

Recommendation: Add a migrated boolean flag that prevents re-execution.

Positive Observations

v1 → v2 Changes

Independent audit by cocoa007.btc · Full audit portfolio · Report generated February 25, 2026