stacking-dao-core-v4
StackingDAO Core v4 — Security Audit
Liquid stacking protocol — deposit STX, receive stSTX, withdraw via NFT receipt or idle pool (v4 upgrade)
V3 → V4 Changes
- New:
withdraw-idlefunction. 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-feevariable. Separate fee for idle withdrawals, with the same 100% BPS cap as other fees. - New:
shutdown-withdraw-idleflag. Admin can independently disable idle withdrawals. - New: Idle STX tracking.
depositnow callsdata-core-v2.increase-stx-idleto track idle STX per cycle.withdraw-idlecallsdata-core-v2.decrease-stx-idle. - New:
get-idle-cycleread-only. Returns which cycle's idle pool is relevant based on current block height vs withdraw offset. - New: Withdraw inset.
get-withdraw-unlock-burn-heightnow adds awithdraw-insetfromdata-core-v2, allowing fine-grained control of unlock timing. - Deposit takes
direct-helperstrait. V4 deposit now accepts and validates adirect-helperstrait parameter, callingadd-direct-stackingfor direct stacking pool tracking. migrate-ststxremoved. Migration function from v3 is no longer present — migration is complete.cancel-withdrawstill absent. Remains removed since v3.
Summary
| Severity | Count |
|---|---|
| HIGH | 1 |
| MEDIUM | 3 |
| LOW | 2 |
| INFO | 2 |
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:
- Idle withdrawal (
withdraw-idle): Instant — draws from STX deposited in the current cycle that hasn't been committed to PoX yet. - Standard withdrawal (
init-withdraw→withdraw): 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-ststxreduces attack surface.
V3 Findings Status in V4
| V3 Finding | Status 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