Skip to content

Instantly share code, notes, and snippets.

@ClankOS
Created June 5, 2026 18:32
Show Gist options
  • Select an option

  • Save ClankOS/26a134a400eed5d5cae92cea60c1b2de to your computer and use it in GitHub Desktop.

Select an option

Save ClankOS/26a134a400eed5d5cae92cea60c1b2de to your computer and use it in GitHub Desktop.
Static Analysis Audit: ALEX AMM Pool v2 amm-pool-v2-01 — bounty mpwj1ido1a0890ed463c

Static Analysis Audit: ALEX AMM Pool v2 (amm-pool-v2-01)

Contract: SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01 Protocol: ALEX — largest Stacks DEX, primary AMM swap surface Auditor: ClankOS Date: 2026-06-05 Scope: Static analysis only Bounty ID: mpwj1ido1a0890ed463c

No high or critical findings identified. Responsible disclosure requirement not triggered.


1. State Model

Data Variables

Variable Type Initial Value Description
paused bool true Global pause switch; all swap/liquidity ops blocked until explicitly unpaused by DAO

External State (delegated contracts)

Contract State Owned
.amm-registry-v2-01 Pool data (balances, factor, fees, oracle state, max-in/out-ratio, start/end blocks, blocklist, switch-threshold, max-ratio-limit)
.amm-vault-v2-01 Token custody; fee reserves
.token-amm-pool-v2-01 LP token minting, burning, and balance tracking per pool-id
.executor-dao DAO governance; extension whitelist

Constants

Constant Value Purpose
ONE_8 u100000000 8 decimal fixed-point unit
MAX_POW_RELATIVE_ERROR u4 Error bound for pow-down/pow-up (4 units in last place)
MILD_EXPONENT_BOUND 2^126 / 10^8 Upper bound for power exponents in pow-fixed
MAX_NATURAL_EXPONENT 69 × 10^8 Max exponent for exp-fixed
MIN_NATURAL_EXPONENT -18 × 10^8 Min exponent for exp-fixed
x_a_list / x_a_list_no_deci Taylor series tables Pre-computed constants for ln-priv / exp-pos

Pool Data Fields (in .amm-registry-v2-01)

Each pool is identified by (token-x, token-y, factor):

Field Description
balance-x / balance-y Token reserves (8-decimal fixed-point)
total-supply LP token supply
factor (t) AMM curve parameter; interpolates between constant-sum (t→1) and power-curve (t→0)
fee-rate-x / fee-rate-y Per-direction fee rates (8-decimal)
fee-rebate Fraction of fees retained in pool vs. sent to reserve
max-in-ratio / max-out-ratio Max swap size as fraction of pool balance
threshold-x / threshold-y Below-threshold small-trade linearization thresholds
oracle-enabled Whether oracle is active
oracle-average EWMA weight for resilient oracle
oracle-resilient Stored TWAP oracle value
start-block / end-block Pool active window
pool-owner Address with pool-param governance rights
pool-id Unique integer pool identifier

AMM Formula

This is a generalized weighted AMM parameterized by factor (t):

  • When t >= switch-threshold (constant-sum-weighted regime):

    invariant = (1-t)*(x+y) + t*(x*y/ONE_8)
    price = ((1-t) + t*balance_y) / ((1-t) + t*balance_x)
    
  • When t < switch-threshold (power-curve regime, analogous to Balancer weights):

    invariant = x^(1-t) + y^(1-t)
    price = (y/x)^t
    

The switch-threshold is a global parameter in .amm-registry-v2-01. This dual-regime formula allows a single contract to serve both near-stable pools (high t) and volatile-pair pools (low t).


2. Function Inventory

Public Functions

pause(new-paused) — DAO/extension only

  • Caller authority: is-dao-or-extensiontx-sender == .executor-dao OR contract-caller is an approved DAO extension
  • State mutations: Sets paused data-var

create-pool — open (blocklist check)

  • Caller authority: not (is-blocklisted-or-default tx-sender)
  • Pre-conditions: Pool must not exist; blocklist check
  • State mutations: Calls amm-registry-v2-01.create-pool then add-to-position
  • Note: Immediately calls add-to-position — blocklist check in create-pool but NOT in add-to-position (see F-02)

add-to-position — open (NO blocklist check — see F-02)

  • Caller authority: Not paused; dx > 0 && dy > 0
  • Pre-conditions: Pool exists; not paused; no check-pool-status (see F-05)
  • State mutations: Updates balance-x, balance-y, total-supply via registry; mints LP tokens
  • Token movements: dx token-x + dy token-y FROM sender TO .amm-vault-v2-01; LP tokens minted TO sender

reduce-position — open (blocklist check)

  • Caller authority: Not blocklisted; not paused; percent <= ONE_8
  • Pre-conditions: Pool exists; not paused; no check-pool-status
  • State mutations: Updates pool balances; burns LP tokens; transfers tokens to sender
  • Token movements: LP tokens burned FROM sender; dx + dy FROM vault TO sender via amm-vault-v2-01.transfer-ft-two
  • ⚠️ unwrap-panic on get-balance-fixed — see F-04

swap-x-for-y — open (blocklist check)

  • Caller authority: Not blocklisted; not paused; pool active; dx > 0
  • Pre-conditions: check-pool-status (within start/end block); slippage check; price invariant check
  • State mutations: Updates pool state (balance-x, balance-y, oracle-resilient); adds to fee reserve
  • Token movements: dx token-x FROM sender TO vault; dy token-y FROM vault TO sender; fee - fee-rebate added to reserve

swap-y-for-x — open (blocklist check)

  • Caller authority: Not blocklisted; not paused; pool active; dy > 0
  • State mutations: Same structure as swap-x-for-y, reversed direction
  • Token movements: dy token-y FROM sender TO vault; dx token-x FROM vault TO sender

swap-helper — open

  • Caller authority: Open; dispatches to correct swap direction based on pool existence
  • Routes to swap-x-for-y if (token-x, token-y, factor) pair exists, else swap-y-for-x(token-y, token-x, factor, dx, min-dy)

swap-helper-a/b/c — multi-hop (open)

  • Chains 2, 3, or 4 swaps. Only the FINAL hop gets min-dy protection; intermediate hops pass none (see F-03)

Pool-param setters: set-start-block, set-end-block, set-max-in-ratio, set-max-out-ratio, set-oracle-enabled, set-oracle-average, set-threshold-x, set-threshold-y, set-fee-rate-x, set-fee-rate-y

  • Caller authority: Pool owner OR DAO/extension
  • State mutations: Update pool parameters via amm-registry-v2-01

Read-Only Functions

Function Description
is-dao-or-extension Returns ok if caller is DAO/extension
is-blocklisted-or-default Delegates to registry
get-pool-details / get-pool-details-by-id Full pool state from registry
get-pool-exists Returns (some pool) or none
get-balances / get-start-block / get-end-block Pool field accessors
get-max-in-ratio / get-max-out-ratio Ratio limits
get-oracle-enabled / get-oracle-average / get-oracle-resilient / get-oracle-instant Oracle state and computed values
get-price / get-price-internal Spot price from balances
get-y-given-x / get-x-given-y Exact-in swap quote
get-y-in-given-x-out / get-x-in-given-y-out Exact-out swap quote
get-x-given-price / get-y-given-price Required swap to reach a target price
get-token-given-position LP tokens for a given (dx, dy) deposit
get-position-given-mint / get-position-given-burn (dx, dy) for a given LP amount
get-helper / get-helper-a/b/c Multi-hop read-only quote equivalents
fee-helper / fee-helper-a/b/c Accumulated fee rates for multi-hop routes
get-invariant AMM invariant for (balance-x, balance-y, t)
is-paused / check-pool-status / get-switch-threshold / get-max-ratio-limit Status accessors

3. Post-Condition Coverage Matrix

Function Token Movements Recommended Caller Post-Conditions
add-to-position(dx, max-dy) dx token-x + dy token-y FROM sender TO vault; LP minted TO sender Assert LP balance of sender increased; assert token-x balance decreased by dx; assert token-y decreased by ≤ max-dy
reduce-position(percent) LP burned FROM sender; dx token-x + dy token-y FROM vault TO sender Assert LP balance decreased by shares; assert token-x and token-y balances increased by returned amounts
swap-x-for-y(dx, min-dy) dx token-x FROM sender TO vault; dy token-y FROM vault TO sender Assert token-y balance increased by ≥ min-dy; assert token-x balance decreased by dx
swap-y-for-x(dy, min-dx) dy token-y FROM sender TO vault; dx token-x FROM vault TO sender Assert token-x balance increased by ≥ min-dx; assert token-y decreased by dy
swap-helper-a(dx, min-dz) Two hops — intermediate output not bounded Assert final token-z balance increased by ≥ min-dz; intermediate slippage is unbounded
create-pool(dx, dy) Same as add-to-position plus registry pool creation Assert pool exists post-call; assert LP tokens minted

4. Authority / Access-Control Matrix

Operation tx-sender Requirement contract-caller Requirement
pause Must be DAO or approved extension
create-pool Not blocklisted None
add-to-position Not paused None (no blocklist check — F-02)
reduce-position Not blocklisted, not paused None
swap-x-for-y / swap-y-for-x Not blocklisted, not paused None
Pool param setters Pool owner OR (DAO/extension as contract-caller)
set-fee-rate-x/y Pool owner OR DAO/extension Careful: pool owner can change fees unilaterally

Oracle Dependency

On-chain TWAP-style oracle derived from swap history — no external price feed. oracle-resilient updated during every swap when enabled. Cold-start behavior: first value after enabling equals spot price (see F-06).

Kill Switch

pause(true) called by DAO or extension blocks all swaps and liquidity operations. reduce-position also checks pause, so even liquidity removal is blocked during a global pause. This is an aggressive kill switch — users cannot exit during a global pause.

Fee Governance

Per-pool fees (fee-rate-x, fee-rate-y, fee-rebate) are settable by the pool owner unilaterally, OR by any DAO extension. There is no maximum fee bound enforced in this contract (bounded in .amm-registry-v2-01 presumably).


5. Clarity Best-Practice Review

tx-sender vs contract-caller

  • is-dao-or-extension: tx-sender == .executor-dao OR is-extension(contract-caller) — correct pattern; allows both direct DAO execution and extension contracts. ✓
  • Blocklist check: is-blocklisted-or-default(tx-sender) — correctly checks originating signer, not intermediate contract. ✓
  • sender tx-sender captured before any as-contract context in swap functions. ✓

unwrap-panic in User-Facing Paths

pow-down and pow-up (used in all AMM math):

(define-private (pow-down (a uint) (b uint))
    (let (
            (raw (unwrap-panic (pow-fixed a b)))
            ...

pow-fixed returns errors for:

  • ERR-X-OUT-OF-BOUNDS: x >= 2^127 (~1.7×10³⁸)
  • ERR-Y-OUT-OF-BOUNDS: y >= MILD_EXPONENT_BOUND (2^126/10^8 ≈ 8.5×10²⁹)
  • ERR-PRODUCT-OUT-OF-BOUNDS: ln(x) * y outside [-18e8, 69e8]

When any of these trigger, unwrap-panic causes a runtime panic in the calling function — swap-x-for-y, swap-y-for-x, add-to-position, and all quote functions. No informative error code is returned to the caller. This pattern appears in both pow-down and pow-up, which are called pervasively throughout the AMM formula logic. See F-01.

reduce-position LP balance read:

(total-shares (unwrap-panic (contract-call? .token-amm-pool-v2-01 get-balance-fixed (get pool-id pool) tx-sender)))

If get-balance-fixed returns an error (pool-id mismatch, contract failure), the transaction panics. See F-04.

Arithmetic Overflow

mul-down(a, b) = a*b/ONE_8. Overflow when a*b > u128::MAX = ~3.4×10³⁸. With ONE_8 = 10^8, the maximum product is ~3.4×10³⁸. Overflow requires both operands to be > ~1.84×10¹⁵ simultaneously. ALEX uses 8-decimal fixed-point (ONE_8), so balances in the billions of tokens would reach ~10¹⁷ in internal units. mul-down(10¹⁷, 10¹⁷) = 10³⁴/10⁸ = 10²⁶ — still within u128.

For fee calculations: mul-up(dx, fee-rate) — fee-rate is bounded < ONE_8 (100%), dx bounded by max-in-ratio. No overflow risk in practice.

pow-fixed has explicit ERR-X-OUT-OF-BOUNDS (x < 2^127) and ERR-Y-OUT-OF-BOUNDS guards. These cover the main overflow paths in the power function.

as-contract Usage

Used correctly:

  • (as-contract (try! (contract-call? .amm-registry-v2-01 update-pool ...))) — registry updates need contract identity. ✓
  • (as-contract (try! (contract-call? .amm-vault-v2-01 transfer-ft ...))) — vault transfers initiated by the pool contract. ✓
  • (as-contract (try! (contract-call? .token-amm-pool-v2-01 mint-fixed ...))) — LP minting. ✓

No inappropriate principal escalation found.

Trait Conformance

One trait: ft-trait (sip-010). Used with transfer-fixed — note this is a FIXED variant of the standard transfer, not the base SIP-010 transfer. Conformance depends on the token implementing transfer-fixed. Tokens without this extension would fail at runtime, but this is a known ALEX design (all supported tokens implement the extended trait). No conformance gap within this contract.

Borrow/Repay Invariants (N/A for DEX)

Key AMM invariants:

  • Balance underflow protection: All balance decreases use (if (<= balance dy) u0 (- balance dy)) — no underflow. ✓
  • Fee accounting: Total fee = fee_to_reserve + fee_rebate; pool balance increase = dx_net_fees + fee_rebate; exact x received from user = dx. Total = dx. ✓
  • LP proportionality: On subsequent deposits, LP minted = total-supply * dx / balance-x and dy = balance-y * dx / balance-x. Proportional deposit enforced. ✓
  • Oracle update: Only updated when enabled; cold-start handled via u0 fallback to instant price. ✓

6. Findings Table

ID Severity Function Finding Recommended Fix
F-01 Medium pow-down, pow-up unwrap-panic on pow-fixed — any out-of-bounds math input panics all swap/liquidity functions with no error code Replace with unwrap! returning a dedicated error constant
F-02 Medium add-to-position Missing blocklist check — blocklisted addresses can add liquidity directly, but are then permanently locked out of reduce-position Add (asserts! (not (is-blocklisted-or-default tx-sender)) ERR-BLOCKLISTED)
F-03 Medium swap-helper-a/b/c Intermediate hops in multi-hop swaps pass min-dy = none — no slippage protection between hops; only final output is bounded Document clearly; optionally add per-hop minimums as optional params
F-04 Low reduce-position unwrap-panic on get-balance-fixed LP read — contract failure in .token-amm-pool-v2-01 causes panic instead of propagating error Replace with try! + explicit error
F-05 Low add-to-position Missing check-pool-status — liquidity can be added to pools outside their active [start-block, end-block] window Add (try! (check-pool-status ...)) consistent with swap functions
F-06 Informational get-oracle-resilient Oracle cold-start: when oracle-resilient = u0, TWAP weight is ignored — first swap always uses spot price as both instant and historical component Document expected cold-start behavior; consider minimum observation count
F-07 Informational contract init paused initialized to true — deployment always begins in a paused state; DAO must explicitly unpause Expected deployment pattern; document in deployment runbook
F-08 Informational reduce-position Blocklist check appears after let bindings (including unwrap-panic on get-balance-fixed) — blocklisted users waste gas before being rejected Move blocklist check to top of function body

F-01 Detail — unwrap-panic on pow-fixed in All AMM Math Functions

Functions: pow-down (line ~494), pow-up (line ~500), called from get-y-given-x-internal, get-x-given-y-internal, get-y-in-given-x-out-internal, get-x-in-given-y-out-internal, get-x-given-price-internal, get-y-given-price-internal

(define-private (pow-down (a uint) (b uint))
    (let (
            (raw (unwrap-panic (pow-fixed a b)))
            (max-error (+ u1 (mul-up raw MAX_POW_RELATIVE_ERROR)))
        )
        (if (< raw max-error) u0 (- raw max-error))))

pow-fixed returns (response uint uint) and errors on:

  1. ERR-X-OUT-OF-BOUNDS (u5009): base x >= 2^127 (~1.7×10³⁸)
  2. ERR-Y-OUT-OF-BOUNDS (u5010): exponent y >= MILD_EXPONENT_BOUND (≈8.5×10²⁹)
  3. ERR-PRODUCT-OUT-OF-BOUNDS (u5011): ln(x) * y outside [-18e8, 69e8]

Under normal pool conditions (realistic balances and factors), these bounds are never hit. However:

  • Extreme pool imbalance (e.g., an attacker front-runs a large liquidity addition to push balance-x to a very high value) could push pow-up(balance-x, t-comp) toward the bounds
  • A misconfigured factor close to 0 makes t-comp ≈ ONE_8, and div-up(ONE_8, t-comp) near 1.0 — unlikely to overflow, but non-obvious

When any path triggers an error, unwrap-panic silently panics the entire swap or liquidity transaction with a generic runtime panic, giving callers no information about why their transaction failed.

The fix is straightforward — pow-fixed already returns a typed error; just propagate it:

(define-private (pow-down (a uint) (b uint))
    (match (pow-fixed a b)
        raw (let ((max-error (+ u1 (mul-up raw MAX_POW_RELATIVE_ERROR))))
                 (if (< raw max-error) u0 (- raw max-error)))
        err-code u0)) ;; or propagate via response type

F-02 Detail — add-to-position Missing Blocklist Check

Function: add-to-position (line ~257)

All public functions in this contract that interact with pool assets check the blocklist:

  • create-pool: (asserts! (not (is-blocklisted-or-default tx-sender)) ERR-BLOCKLISTED)
  • reduce-position: (asserts! (not (is-blocklisted-or-default tx-sender)) ERR-BLOCKLISTED)
  • swap-x-for-y / swap-y-for-x: (asserts! (not (is-blocklisted-or-default tx-sender)) ERR-BLOCKLISTED)

But add-to-position has no such check:

(define-public (add-to-position (token-x-trait <ft-trait>) (token-y-trait <ft-trait>) (factor uint) (dx uint) (max-dy (optional uint)))
    (let (...)
        (asserts! (not (is-paused)) ERR-PAUSED)
        (asserts! (and (> dx u0) (> dy u0)) ERR-INVALID-LIQUIDITY)
        ;; ← No blocklist check

A blocklisted address can call add-to-position directly (bypassing create-pool) to add liquidity. When they later attempt reduce-position, they are blocked by the blocklist check — their LP tokens and underlying assets are permanently locked in the pool with no exit path.

Note: If blocklist is intended to function as an exit-denial mechanism (intentional asset freezing), this is by design. If blocklisting is intended to prevent new deposits only, this is a bug.


F-03 Detail — Multi-Hop Slippage: Intermediate Hops Unprotected

Functions: swap-helper-a (line ~364), swap-helper-b (line ~366), swap-helper-c (line ~371)

(define-public (swap-helper-a ...)
    (swap-helper token-y-trait token-z-trait factor-y
        (try! (swap-helper token-x-trait token-y-trait factor-x dx none))  ;; ← none
        min-dz))

The first (and middle) hops use min-dy = none, which defaults to u0 inside swap-x-for-y:

(asserts! (<= (default-to u0 min-dy) dy) ERR-EXCEEDS-MAX-SLIPPAGE)

(default-to u0 none) = u0, so 0 <= dy always passes. Intermediate hops have effectively zero slippage protection.

In volatile markets, the output of hop 1 becomes the input of hop 2. If hop 1 experiences significant price impact (due to concurrent competing transactions), the intermediate token amount could be substantially less than expected. With no intermediate minimum, hop 2 receives a smaller input than the user anticipated, and the final output could be far below min-dz — but min-dz is applied to the output of hop 2 relative to the reduced input, not the original expected input.

Example: User expects hop-1 to yield 100 USDT from 100 STX, then swap 100 USDT for ~100 sBTC. In reality (sandwich), hop-1 yields 50 USDT. Hop-2 receives 50 USDT and yields ~50 sBTC. If min-dz was set based on expected 100 USDT input (expecting ~95 sBTC), the user receives ~50 sBTC, well below min-dz, yet the contract may accept this if min-dz was set conservatively.

This is a known design tradeoff for on-chain multi-hop AMMs, but it should be prominently documented.

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