Skip to content

Instantly share code, notes, and snippets.

@Mayjor01
Created June 2, 2026 16:43
Show Gist options
  • Select an option

  • Save Mayjor01/23f37e0e95f8de780a0f06d4d53c562b to your computer and use it in GitHub Desktop.

Select an option

Save Mayjor01/23f37e0e95f8de780a0f06d4d53c562b to your computer and use it in GitHub Desktop.
Static Analysis Audit: stSTX/STX Stableswap (stableswap-stx-ststx-v-1-2)

Static Analysis Audit: stSTX↔STX Stableswap Pool (stableswap-stx-ststx-v-1-2)

Auditor: Emerald Castle (bc1qzhlus0nesaphjy5jfd2tnef9te9j4mq57mzd6r)

Contract: SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2

Source: https://api.hiro.so/v2/contracts/source/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


1. State Model

data-vars

| 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 |

data-maps

| 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 |

constants

| 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 |


2. Function Inventory

Public Functions

swap-x-for-y (lines 321–439)

  • 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

swap-y-for-x (lines 443–557)

  • 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

add-liquidity (lines 568–680)

  • 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

withdraw-liquidity (lines 685–738)

  • 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)

Admin (Governance) Functions

| 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 |

Read-Only Functions

| 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 |


3. Post-Condition Coverage Matrix

| 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 |


4. Authority / Access-Control Matrix

| 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.


5. Clarity Best-Practice Review

✅ Passes

  • tx-sender is captured at entry (swapper / liquidity-provider) before any external calls — correct pattern.

  • Uses try! / unwrap! consistently in public functions for error propagation.

  • as-contract used appropriately to escalate principal for contract-side transfers.

  • Admin deployer cannot be removed, preventing a rogue-admin lockout.

  • staking-and-rewards-contract has a one-time set guard preventing silent replacement.

⚠️ Issues

See Findings Table (Section 6).


6. Findings Table

| 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 |


S-01 Detail — Admin fee guard is inverted (Medium)

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

))

S-02 Detail — Quote/execution fee divergence (Medium)

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.


S-03 Detail — Newton-Raphson convergence failure (Low)

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"))

S-05 Detail — First liquidity add reverts (Low)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment