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.
| Variable | Type | Initial Value | Description |
|---|---|---|---|
paused |
bool |
true |
Global pause switch; all swap/liquidity ops blocked until explicitly unpaused by DAO |
| 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 |
| 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 |
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 |
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).
- Caller authority:
is-dao-or-extension—tx-sender == .executor-daoORcontract-calleris an approved DAO extension - State mutations: Sets
pauseddata-var
- 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-poolthenadd-to-position - Note: Immediately calls
add-to-position— blocklist check increate-poolbut NOT inadd-to-position(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-supplyvia registry; mints LP tokens - Token movements:
dxtoken-x +dytoken-y FROM sender TO.amm-vault-v2-01; LP tokens minted TO sender
- 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+dyFROM vault TO sender viaamm-vault-v2-01.transfer-ft-two ⚠️ unwrap-paniconget-balance-fixed— see F-04
- 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:
dxtoken-x FROM sender TO vault;dytoken-y FROM vault TO sender;fee - fee-rebateadded to reserve
- Caller authority: Not blocklisted; not paused; pool active;
dy > 0 - State mutations: Same structure as swap-x-for-y, reversed direction
- Token movements:
dytoken-y FROM sender TO vault;dxtoken-x FROM vault TO sender
- Caller authority: Open; dispatches to correct swap direction based on pool existence
- Routes to
swap-x-for-yif(token-x, token-y, factor)pair exists, elseswap-y-for-x(token-y, token-x, factor, dx, min-dy)
- Chains 2, 3, or 4 swaps. Only the FINAL hop gets
min-dyprotection; intermediate hops passnone(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
| 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 |
| 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 |
| 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 |
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).
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.
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).
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-sendercaptured before anyas-contractcontext in swap functions. ✓
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) * youtside[-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.
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.
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.
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.
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-xanddy = balance-y * dx / balance-x. Proportional deposit enforced. ✓ - Oracle update: Only updated when enabled; cold-start handled via
u0fallback to instant price. ✓
| 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 |
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:
ERR-X-OUT-OF-BOUNDS (u5009): basex >= 2^127(~1.7×10³⁸)ERR-Y-OUT-OF-BOUNDS (u5010): exponenty >= MILD_EXPONENT_BOUND(≈8.5×10²⁹)ERR-PRODUCT-OUT-OF-BOUNDS (u5011):ln(x) * youtside [-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
factorclose to 0 makest-comp ≈ ONE_8, anddiv-up(ONE_8, t-comp)near1.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 typeFunction: 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 checkA 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.
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.