Auditor: Emerald Castle (bc1qzhlus0nesaphjy5jfd2tnef9te9j4mq57mzd6r)
Contract: SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2
Protocol: Bitflow Stableswap v1.2 — stSTX/STX AMM using Curve-style StableSwap invariant
Audit Type: Static analysis only (no execution)
Date: 2026-06-02
| Variable | Type | Initial Value | Mutated By | Authority |
|---|---|---|---|---|
| staking-and-rewards-contract | principal | tx-sender (deployer) | set-staking-contract | admin, one-time only |
| staking-and-rewards-contract-is-set | bool | false | set-staking-contract | admin, one-time only |
| stacking-dao-contract | principal | SP4SZE494VC2YC5JYG7AYFQ44F5Q4PYV7DVMDPBG | set-stacking-dao-contract | admin |
| bitflow-contract | principal | SP1G6QWV6X9EVXH7QMMFVHNC3VHWJC28MHR6P8TX2 | set-bitflow-contract | admin |
| admins | (list 5 principal) | [tx-sender] | add-admin, remove-admin | existing admin |
| buy-fees | {lps, stacking-dao, bitflow: uint} | {3, 0, 2} bps | change-buy-fee | admin |
| sell-fees | {lps, stacking-dao, bitflow: uint} | {3, 195, 2} bps | change-sell-fee | admin |
| admin-swap-fees | {lps, stacking-dao, bitflow: uint} | {0, 0, 0} bps | change-admin-swap-fee | admin |
| liquidity-fees | uint | 3 bps | change-liquidity-fee | admin |
| helper-principal | principal | tx-sender | remove-admin | admin |
| convergence-threshold | uint | 2 | change-convergence-threshold | admin |
| Map | Key | Value | Mutated By |
|---|---|---|---|
| PairsDataMap | {y-token, lp-token} | {approval, total-shares, x-decimals, y-decimals, balance-x, balance-y, d, amplification-coefficient} | create-pair, swap-x-for-y, swap-y-for-x, add-liquidity, withdraw-liquidity, set-pair-approval, change-amplification-coefficient |
| CycleDataMap | {y-token, lp-token, cycle-num} | {cycle-fee-balance-x} | swap-x-for-y, swap-y-for-x |
| Constant | Value | Purpose |
|---|---|---|
| this-contract | (as-contract tx-sender) | Self-reference for as-contract calls |
| deployment-height | burn-block-height at deploy | Cycle offset base |
| cycle-length | 144 blocks | ~1 day cycle |
| index-list | u1..u384 | Newton-Raphson iteration count |
| number-of-tokens | 2 | Pool is 2-token |
| contract-deployer | tx-sender at deploy | Immutable deployer reference; cannot be removed from admins |
-
Authority: Open to all callers (pair must be approved)
-
Pre-conditions: Pair exists and approved, x-amount < 10× balance-x, dy > min-y-amount
-
State mutations: Updates PairsDataMap (balance-x, balance-y, d), updates CycleDataMap (cycle-fee-balance-x)
-
External calls:
stx-transfer?(swapper→contract, swapper→staking-rewards, swapper→stacking-dao, swapper→bitflow),y-token.transfer(contract→swapper) -
Token flows: Swapper sends STX, receives stSTX
-
Authority: Open to all callers (pair must be approved)
-
Pre-conditions: Pair exists and approved, y-amount < 10× balance-y, dx > min-x-amount
-
State mutations: Updates PairsDataMap, updates CycleDataMap
-
External calls:
y-token.transfer(swapper→contract),stx-transfer?(contract→staking-rewards, contract→stacking-dao, contract→bitflow, contract→swapper) -
Token flows: Swapper sends stSTX, receives STX
-
Authority: Open; pair must be approved and have existing shares (see Finding #5)
-
Pre-conditions: Pair exists and approved, at least one token > 0, d2 > d0, derived LP > min-lp-amount
-
State mutations: Updates PairsDataMap (balance-x, balance-y, total-shares, d), mints LP tokens
-
External calls:
stx-transfer?,y-token.transfer,lp-token.mint
-
Authority: Open
-
Pre-conditions: withdrawal-balance-x > min-x-amount, withdrawal-balance-y > min-y-amount
-
State mutations: Updates PairsDataMap (balance-x, balance-y, total-shares, d), burns LP tokens
-
External calls:
lp-token.burn,stx-transfer?(contract→remover),y-token.transfer(contract→remover)
| Function | Authority | Effect |
|---|---|---|
| create-pair | admin | Initialises pair, mints LP tokens, transfers initial liquidity |
| set-pair-approval | admin | Pauses/unpauses a pair |
| add-admin | admin | Appends to admins list (max 5) |
| remove-admin | admin | Filters admin from list; deployer cannot be removed |
| change-buy-fee | admin | Sets buy-fees (no cap) |
| change-sell-fee | admin | Sets sell-fees (no cap) |
| change-admin-swap-fee | admin | Sets admin-swap-fees |
| change-liquidity-fee | admin | Sets liquidity-fees (no cap) |
| change-amplification-coefficient | admin | Instantly updates A parameter |
| change-convergence-threshold | admin | Updates Newton-Raphson threshold |
| set-staking-contract | admin, one-time | Sets staking-and-rewards-contract; irreversible |
| set-stacking-dao-contract | admin | Updates stacking DAO fee recipient |
| set-bitflow-contract | admin | Updates Bitflow fee recipient |
| Function | Purpose |
|---|---|
| get-pair-data | Returns full PairsDataMap entry |
| get-cycle-data | Returns CycleDataMap entry |
| get-current-cycle | Derives cycle from burn-block-height |
| get-dx | Quotes STX output for stSTX input (sell path) |
| get-dy | Quotes stSTX output for STX input (buy path) |
| get-D | Computes StableSwap invariant D |
| get-x / get-y | Newton-Raphson AMM solvers |
| Function | Token Movements | Recommended Post-Conditions |
|---|---|---|
| swap-x-for-y | STX out from caller; fees to staking/dao/bitflow; stSTX in to caller | stx-postcondition caller sends exactly x-amount; ft-postcondition caller receives >= min-y-amount stSTX |
| swap-y-for-x | stSTX out from caller; STX in to caller; fees from contract | ft-postcondition caller sends exactly y-amount stSTX; stx-postcondition caller receives >= min-x-amount STX |
| add-liquidity | STX and stSTX out from caller; LP tokens minted to caller | stx-postcondition caller sends <= x-amount-added; ft-postcondition stSTX <= y-amount-added; LP tokens >= min-lp-amount |
| withdraw-liquidity | LP tokens burned from caller; STX and stSTX returned | ft-postcondition LP token decrease exactly lp-amount; STX received >= min-x-amount; stSTX received >= min-y-amount |
| Operation | Required Authority | Notes |
|---|---|---|
| Swap (buy/sell) | Any principal | Pair must be approval: true |
| Add/withdraw liquidity | Any principal | Pair must be approval: true |
| Create pair | Admin | Must be in admins list |
| Pause pair | Admin | set-pair-approval false |
| Change fees | Admin | No upper bound cap — see Finding #4 |
| Change A parameter | Admin | Instant change — see Finding #7 |
| Add/remove admin | Admin | Deployer cannot be removed (protected) |
| Set staking contract | Admin, one-time | Irreversible after is-set = true |
| Set fee recipients | Admin | Replaceable anytime |
Oracle dependency: None — this is a pure on-chain AMM with no external price feeds.
Pause mechanism: set-pair-approval false disables swaps and liquidity ops for a specific pair.
Kill switch: No global pause; individual pair approval suffices.
-
tx-senderis captured at entry (swapper/liquidity-provider) before any external calls — correct pattern. -
Uses
try!/unwrap!consistently in public functions for error propagation. -
as-contractused appropriately to escalate principal for contract-side transfers. -
Admin deployer cannot be removed, preventing a rogue-admin lockout.
-
staking-and-rewards-contracthas a one-time set guard preventing silent replacement.
See Findings Table (Section 6).
| ID | Severity | Function | Line | Finding | Recommended Fix |
|---|---|---|---|---|---|
| S-01 | Medium | swap-x-for-y, swap-y-for-x | 332–343, 454–465 | Admin fee guard is inverted — non-admins pay zero fees | Swap branch arms (see details below) |
| S-02 | Medium | get-dy, swap-x-for-y | 249, 355 | Fee calculation diverges between quote and execution | Align fee computation in get-dy with actual swap path |
| S-03 | Low | get-x, get-y (Newton-Raphson loops) | 206–218, 293–305 | Convergence failure silently returns u0 | Assert converged > u0 after fold |
| S-04 | Low | change-buy-fee, change-sell-fee, change-liquidity-fee | 984–1033 | No fee cap — admin can set fees to 10000 bps (100%) | Add upper-bound assertion (e.g., ≤ 1000 bps total) |
| S-05 | Low | add-liquidity | 636, 662 | First liquidity add always reverts — LP minting fails with zero shares | Add initialisation guard or separate first-deposit path |
| S-06 | Low | swap-y-for-x | 466 | total-swap-fee omits bitflow component | Include swap-fee-bitflow in sum |
| S-07 | Informational | change-amplification-coefficient | 1037–1054 | Instant A-parameter changes enable LP sandwich attacks | Add a time-ramp (e.g., 144-block minimum transition) |
| S-08 | Informational | set-staking-contract | 1072–1091 | One-time set with no correction path | Add emergency override for contract-deployer only |
Location: Lines 332–343 (swap-x-for-y), 454–465 (swap-y-for-x)
The fee tier selection in both swap functions reads:
(swap-fee-lps (if (is-some (index-of (var-get admins) tx-sender))
(get lps (var-get buy-fees)) ;; ← applied when IS admin
(get lps (var-get admin-swap-fees)) ;; ← applied when NOT admin
))
The comment on line 331 states: "Admins pay no fees on swaps". The variable admin-swap-fees is initialized to {lps: u0, stacking-dao: u0, bitflow: u0} — zero fees. But the branch condition applies buy-fees (5 bps total) to admins and admin-swap-fees (0 bps) to all non-admins.
Impact: Every retail swap call pays zero fees. The staking rewards contract, stacking DAO, and Bitflow all receive zero fee income from non-admin swaps. The fee transfer conditionals (lines 385–400 and 503–518) check > u0 before transferring, so they silently skip when fees are zero — there are no runtime errors, making this bug invisible without economic analysis.
Fix:
;; Correct: admins pay zero, everyone else pays normal fees
(swap-fee-lps (if (is-some (index-of (var-get admins) tx-sender))
(get lps (var-get admin-swap-fees)) ;; admin → zero fees
(get lps (var-get buy-fees)) ;; everyone else → normal fees
))
Location: get-dy line 249 vs swap-x-for-y line 355
In get-dy (the quote function):
(x-amount-total-fees-scaled (/ (* x-amount total-swap-fee) u10000))
This multiplies the unscaled x-amount (STX micro-units) by total-swap-fee, ignoring the decimal scaling applied before AMM math.
In actual swap-x-for-y:
(x-amount-fees-lps-scaled (/ (* x-amount-scaled swap-fee-lps) u10000))
(x-amount-fees-stacking-dao-scaled (/ (* x-amount-scaled swap-fee-stacking-dao) u10000))
(x-amount-fees-bitflow-scaled (/ (* x-amount-scaled swap-fee-bitflow) u10000))
(updated-x-amount-scaled (- x-amount-scaled (+ lps + dao + bitflow fees)))
Fees are deducted from the scaled amount per fee type, then summed.
For a 2-decimal stSTX token paired with 6-decimal STX (10000× scale factor), the quote will underestimate fees by the scaling factor, returning a falsely optimistic dy. Callers relying on get-dy for slippage checks receive stale quotes that deviate at execution.
Location: Lines 206–218 (x-for-loop), 293–305 (y-for-loop)
If convergence is not reached within 384 iterations (e.g., amplification-coefficient = 0, extreme pool imbalance, or convergence-threshold = 0 set by admin), the converged field remains u0. The fold returns u0 as the computed balance, causing swaps to price incorrectly with no revert.
;; Add after fold:
(asserts! (> (get converged result) u0) (err "err-convergence-failed"))
Location: Lines 636, 662
When current-total-shares = 0 (pool has been drained to zero), add-liquidity computes:
(/ (* current-total-shares (- d2 d0)) d0)
;; = (/ (* u0 ...) d0) = u0
Line 636 asserts this value > min-lp-amount, which fails for any min-lp-amount >= 0. Minting also passes u0 to lp-token.mint. The create-pair function handles initial deposit separately, so this only affects re-initialising a drained pool.