Skip to content

Instantly share code, notes, and snippets.

@deanventor-max
Created June 12, 2026 02:49
Show Gist options
  • Select an option

  • Save deanventor-max/e31f4ae6a0bdeaf11ddaf5c437f64ba1 to your computer and use it in GitHub Desktop.

Select an option

Save deanventor-max/e31f4ae6a0bdeaf11ddaf5c437f64ba1 to your computer and use it in GitHub Desktop.
Static analysis: Bitflow stSTX/STX Stableswap Core v1.2

Static Analysis: Bitflow stSTX/STX Stableswap Core v1.2

Contract: SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2
Source: https://api.hiro.so/v2/contracts/source/SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M/stableswap-stx-ststx-v-1-2
Review date: 2026-06-12
Method: Manual static analysis, line-by-line state/authority review, and an integer-accurate JavaScript reproduction of the contract's 384-step Newton iteration.

Executive Summary

The contract has a compact external surface but combines custody, invariant math, fee routing, LP accounting, and mutable governance in one module. I identified three medium-severity issues:

  1. Every governance function authenticates the original transaction signer through tx-sender, so a malicious intermediary contract called by an administrator can invoke this contract with attacker-chosen governance parameters. The direct persistence path is add-admin(attacker).
  2. Swap paths use amplification-coefficient * 2, while liquidity paths use the raw coefficient. The live pool's stored d matches the swap convention, but LP minting uses the other curve and asymmetrically misprices one-sided deposits.
  3. Burning the final outstanding LP supply computes get-D(0, 0, A) before the burn and transfers. Its first Newton iteration divides by zero, preventing a complete pool exit.

No high or critical finding is asserted, so the bounty's private-disclosure requirement is not triggered.

Live-state validation

At review time the active pair used 6 decimals for both assets, A = 25, approximately 855,192.206068 STX, 161,691.003230 stSTX, 975,687.836815 LP shares, and stored d = 1,000,664,990,945. Re-running the contract's integer algorithm produced:

  • get-D(x, y, 25) = 986,853,553,321
  • get-D(x, y, 50) = 1,000,664,990,945

The exact match at Ann = A * 2 = 50 confirms that the stored swap invariant and the liquidity path use different effective curves.

The three fee records (buy-fees, sell-fees, and admin-swap-fees) all decoded to {lps: 3, stacking-dao: 0, bitflow: 2} at review time. Therefore, the inverted fee selector is a real source-code/configuration hazard, but it was not causing zero-fee public swaps in the reviewed live state.

1. State Model

Constants

Constant Initial value / meaning Mutation
this-contract Contract principal captured with (as-contract tx-sender) Immutable
deployment-height Burn height at deployment Immutable
cycle-length 144 burn blocks Immutable
index-list Integers 1 through 384 for fixed Newton iterations Immutable
number-of-tokens 2 Immutable
contract-deployer Deployment transaction sender Immutable

deployment-height and cycle-length define fee-accounting cycles. number-of-tokens is incorporated into swap invariant calls but is omitted by liquidity invariant calls, which causes F-02.

Data variables

Store Initial value Mutators Authority / notes
staking-and-rewards-contract Deployer set-staking-contract Any listed admin by tx-sender; can be set only once
staking-and-rewards-contract-is-set false set-staking-contract Same one-time transition
stacking-dao-contract SP4SZE...DPBG set-stacking-dao-contract Any listed admin by tx-sender
bitflow-contract SP1G6Q...8TX2 set-bitflow-contract Any listed admin by tx-sender
admins List containing deployer; maximum length 5 add-admin, remove-admin Any listed admin by tx-sender; deployer cannot be removed
buy-fees {lps: 3, stacking-dao: 0, bitflow: 2} change-buy-fee Any listed admin; no component or total cap
sell-fees {lps: 3, stacking-dao: 195, bitflow: 2} in source change-sell-fee Any listed admin; no component or total cap
admin-swap-fees All zero in source change-admin-swap-fee Any listed admin; selected for non-admin callers by current code
liquidity-fees 3 change-liquidity-fee Any listed admin; no cap
helper-principal Deployer remove-admin Scratch storage used by filter; persists as the last removed principal
convergence-threshold 2 change-convergence-threshold Any listed admin; no lower/upper bound

The source defaults and the live values differ for sell-fees and admin-swap-fees. This demonstrates that fee-related findings must distinguish deploy-time configuration from current state.

Maps

PairsDataMap

Key: {y-token, lp-token}.

Value:

  • approval: pair-level swap/deposit switch.
  • total-shares: accounting supply expected to match the LP token supply.
  • x-decimals, y-decimals: normalization metadata.
  • balance-x, balance-y: internal reserves used by pricing and withdrawals.
  • d: cached invariant value.
  • amplification-coefficient: pair-specific A.

Mutators:

  • create-pair creates an entry and seeds reserves/supply.
  • set-pair-approval changes approval.
  • swap-x-for-y and swap-y-for-x update reserves and cached d.
  • add-liquidity updates reserves, supply, and d.
  • withdraw-liquidity updates reserves, supply, and d.
  • change-amplification-coefficient changes A without recomputing cached d.

Authority is open for swaps and LP operations, and admin-only for pair creation, approval, and A changes. The map is the contract's accounting source of truth; it does not query actual contract token balances before pricing.

CycleDataMap

Key: {y-token, lp-token, cycle-num}.

Value: cycle-fee-balance-x, the X-side LP fee accumulated during that cycle.

Both swap functions increment it. There is no public claim or reset path in this contract; distribution is delegated to staking-and-rewards-contract.

External dependencies and oracle bindings

  • X is native STX.
  • Y is any contract satisfying the supplied SIP-010 trait parameter.
  • LP token is any contract satisfying the supplied LP trait parameter.
  • staking-and-rewards-contract, stacking-dao-contract, and bitflow-contract receive fees.
  • There is no price oracle. Pricing is entirely reserve/invariant based.
  • There is no timelock, multisig check, ramp scheduler, or external governance contract enforced locally.

2. Function Inventory

Read-only functions

All read-only functions are open and mutate no state.

Function Preconditions / authority Reads and result External calls
get-pair-data L108 Open Returns a PairsDataMap entry None
get-cycle-data L113 Open Returns a CycleDataMap entry None
get-current-cycle L118 Open; current height must not precede deployment Computes elapsed cycle None
get-cycle-from-height L123 Open; supplied height below deployment underflows Converts height to cycle None
get-starting-height-from-cycle L128 Open Converts cycle to height None
get-deployment-height L133 Open Returns deployment burn height None
get-dx L141 Pair must exist; invariant math must not abort Quotes Y-to-X using sell-fees and A * 2 None
get-x L177 Inputs must avoid zero divisors/underflow Solves X with fixed 384-step Newton loop None
get-dy L225 Pair must exist; invariant math must not abort Quotes X-to-Y using buy-fees and A * 2 None
get-y L263 Inputs must avoid zero divisors/underflow Solves Y with fixed 384-step Newton loop None
get-D L749 Both balances and ann must be nonzero for safe arithmetic Computes invariant through D-for-loop None

get-x, get-y, and get-D are exposed read-only helpers but depend on the mutable global convergence threshold.

User-facing public functions

Function Authority and checks State mutations External calls / transfers
swap-x-for-y L321 Open; pair exists and approved; x < 10 * reserve-x; output strictly greater than minimum X/Y reserves, cached d, cycle X fees STX from tx-sender to pool and fee recipients; SIP-010 Y from pool to tx-sender
swap-y-for-x L443 Open; pair exists and approved; y < 10 * reserve-y; output strictly greater than minimum X/Y reserves, cached d, cycle X fees SIP-010 Y from tx-sender to pool; STX from pool to user and fee recipients
add-liquidity L568 Open; pair exists and approved; at least one amount positive; d2 > d0; minted LP strictly greater than minimum Reserves, total shares, cached d STX/Y from user to pool and Bitflow; LP mint to user
withdraw-liquidity L685 Open; pair exists; both outputs strictly greater than minima Reserves, total shares, cached d LP burn from user; STX/Y from pool to user

withdraw-liquidity reads but does not assert approval, which allows exits while a pair is paused. This is a reasonable emergency-exit policy but should be documented explicitly.

Governance public functions

Every function below checks membership of tx-sender in admins, not the immediate contract-caller.

Function Preconditions State mutations External calls / transfers
create-pair L865 Admin; pair absent; nonzero/equal normalized seed balances Creates pair LP mint; STX and Y deposits from admin
set-pair-approval L915 Admin; pair exists Sets approval None
add-admin L937 Admin; candidate absent; list remains within 5 Appends admin None
remove-admin L956 Admin; target listed; target is not deployer Writes helper and filters admin list None
change-buy-fee L984 Admin Replaces buy fee tuple None
change-sell-fee L997 Admin Replaces sell fee tuple None
change-admin-swap-fee L1010 Admin Replaces alternate fee tuple None
change-liquidity-fee L1023 Admin Replaces liquidity fee None
change-amplification-coefficient L1037 Admin; pair exists Replaces A; does not recompute d None
change-convergence-threshold L1058 Admin Replaces threshold None
set-staking-contract L1072 Admin; not previously set Sets recipient and one-time flag None
set-stacking-dao-contract L1096 Admin Sets recipient None
set-bitflow-contract L1112 Admin Sets recipient None

There is no dedicated owner role beyond the non-removable deployer entry, and no function is restricted specifically to that deployer.

3. Post-Condition Coverage Matrix

Callers should use deny-mode post-conditions so any unlisted asset movement aborts the transaction. Because output checks use strict >, a UI promising minimum m should pass m - 1 when possible or clearly model the extra base unit.

Public function Intended movement Recommended caller protection
swap-x-for-y User sends exactly x-amount STX across pool/fee recipients; receives Y STX sent == x-amount; Y received >= desired minimum; no other assets sent
swap-y-for-x User sends exactly y-amount Y; receives STX Y sent == y-amount; STX received >= desired minimum; no other assets sent
add-liquidity User sends up to the supplied X and Y amounts; receives LP; imbalance fees go to Bitflow STX sent <= x-amount-added; Y sent <= y-amount-added; LP received >= desired minimum; no other assets sent
withdraw-liquidity User burns/sends exactly lp-amount; receives proportional X and Y LP sent/burned == lp-amount; STX and Y each received >= desired minimum; no other assets sent
create-pair Admin sends seed X/Y and receives initial LP STX sent == initial-x-bal; Y sent == initial-y-bal; LP received == normalized-x + normalized-y
set-pair-approval No token movement Deny mode with no positive asset allowances
add-admin No token movement Deny mode with no positive asset allowances
remove-admin No token movement Deny mode with no positive asset allowances
change-buy-fee No token movement Deny mode with no positive asset allowances
change-sell-fee No token movement Deny mode with no positive asset allowances
change-admin-swap-fee No token movement Deny mode with no positive asset allowances
change-liquidity-fee No token movement Deny mode with no positive asset allowances
change-amplification-coefficient No token movement Deny mode with no positive asset allowances
change-convergence-threshold No token movement Deny mode with no positive asset allowances
set-staking-contract No token movement Deny mode with no positive asset allowances
set-stacking-dao-contract No token movement Deny mode with no positive asset allowances
set-bitflow-contract No token movement Deny mode with no positive asset allowances

Post-conditions materially reduce the user-facing impact of malicious intermediary contracts because user functions also identify the asset owner as tx-sender. They do not fix governance calls, which usually move no assets and can therefore pass empty deny-mode post-conditions while changing authority.

4. Authority / Access-Control Matrix

Capability Who can invoke Delay / bounds Security consequence
Swap and quote Anyone Pair approval gates swaps only Public market surface
Add liquidity Anyone Pair approval required Public LP entry
Withdraw liquidity Anyone holding LP Not gated by approval Emergency exit remains available
Create pair Any admin by tx-sender No delay; list-based role Can bind arbitrary trait contracts and seed funds
Pause/unpause pair Any admin by tx-sender Immediate Stops swaps and deposits
Add/remove admins Any admin by tx-sender Max 5; deployer non-removable Any admin can expand authority or remove non-deployer peers
Change swap/liquidity fees Any admin by tx-sender Immediate; unbounded Can alter economics or make subtraction fail
Change amplification Any admin by tx-sender Immediate; unbounded; no ramp Abruptly changes curve and leaves cached d stale until next state update
Change convergence threshold Any admin by tx-sender Immediate; unbounded Can degrade or break invariant calculations
Set staking recipient Any admin by tx-sender One-time Mistake or malicious setting cannot be corrected locally
Set protocol recipients Any admin by tx-sender Immediate and repeatable Redirects future fee transfers

Pause / kill behavior

There is no global kill switch. approval is per pair and gates swaps plus deposits, but not withdrawals. There is no rescue function for excess tokens and no migration hook.

Amplification governance

The contract does not implement a gradual A ramp. change-amplification-coefficient takes effect immediately and does not update the cached invariant. Safe stable-swap governance normally constrains the range and rate of change and recomputes or invalidates dependent cached state.

Privileged-principal trust

The local model assumes every admin is equally trusted. However, F-01 means the effective trust boundary also includes every contract an admin can be induced to call. The deployer cannot be removed, but a captured admin slot can still pause the pool, alter parameters, redirect fees, or add further admins.

5. Clarity Best-Practice Review

tx-sender versus contract-caller

All governance checks use tx-sender. In a direct signed call, tx-sender and contract-caller are the same principal. In a composed call, tx-sender remains the original signer while contract-caller becomes the intermediary contract. This is exactly the authorization-confusion pattern for which Clarity guidance recommends checking contract-caller.

The user-facing functions also use tx-sender as the payer/recipient. This supports router composition but means callers must use post-conditions. Governance should not inherit that composability.

Panic-style unwraps

No unwrap-panic or unwrap-err-panic appears. External calls use unwrap! with descriptive string errors. Arithmetic failures such as division by zero still abort without a domain-specific error.

Arithmetic and invariant safety

  • get-D divides by 2*x, 2*y, and a derived denominator. Zero reserves are not handled.
  • get-x and get-y divide by ann and reserve-derived values; A = 0 is accepted by governance but unsafe.
  • Fee components and totals are unbounded. Values above 10,000 bps can underflow amount subtraction.
  • Multiplications such as D*D, D*partial, reserveshare, and amountfee have no explicit input caps beyond Clarity's uint runtime checks.
  • Swap size is capped below ten times one reserve, but liquidity additions and governance parameters have no comparable cap.
  • Failed convergence returns the sentinel u0, which callers do not distinguish from a computed value. Extreme governance settings can therefore turn a convergence failure into an arithmetic abort or nonsensical downstream delta.

as-contract usage

as-contract is used to transfer pool-owned assets and mint LP through the external token contract. This is expected, but correctness depends on the external SIP-010 and LP implementations respecting the provided sender and on callers attaching post-conditions.

Trait and interface observations

  • get-pair-data types lp-token as <sip-010-trait>, while state-changing functions use <lp-trait>. The function only needs contract-of, but the stricter/different trait requirement can unnecessarily reject an otherwise valid LP implementation.
  • pair-name in create-pair is accepted but never stored or emitted.
  • There is no local verification that the LP contract's reported supply equals PairsDataMap.total-shares.

Precision and rounding

Both live assets use six decimals, so the scale-up/down helpers are identity operations for the deployed pair. For a pair with unequal decimals, get-dy computes x-amount-total-fees-scaled from the unscaled x-amount at L249 instead of x-amount-scaled; the execution path calculates each fee from the scaled amount. That quote mismatch is dormant for this pair but should be corrected before reusing the contract.

All divisions round down. The contract's strict minimum assertions add a one-base-unit usability discrepancy at exact boundaries.

Fee-selector review

The comments say admins pay no fees, but both swaps select normal directional fees when the caller is an admin and admin-swap-fees otherwise. The branches are semantically inverted. Current on-chain configuration makes all three fee tuples equal at 5 bps, so no caller-tier difference was observed on 2026-06-12. A later fee update can reactivate the defect immediately.

6. Findings Table

ID Severity Function Line Finding Recommended fix
F-01 Medium All governance functions; direct persistence via add-admin 877, 922, 945, 960, 990, 1003, 1016, 1029, 1045, 1064, 1080, 1103, 1119 Governance authenticates tx-sender, allowing an intermediary contract called by an admin to submit attacker-chosen governance calls Require contract-caller to be an admin and preferably require tx-sender == contract-caller for sensitive direct calls
F-02 Medium add-liquidity, withdraw-liquidity versus swap/quote paths 596-612, 706 versus 161, 252, 357, 415, 475, 533 Liquidity accounting uses raw A, while swaps and cached d use A * 2, causing asymmetric LP mispricing and dilution Define one canonical Ann helper and use it in every invariant call; migrate/reconcile cached d
F-03 Medium withdraw-liquidity, get-D 697-706, 768-770 Withdrawing all outstanding LP makes both new reserves zero, then invariant calculation divides by zero before burn/transfers Special-case lp-amount == total-shares: set balances, shares, and d to zero without calling get-D
F-04 Low Both swap functions 331-343, 453-465 Admin/public fee branches are opposite the stated policy; currently latent because all live tuples are equal Swap the branches or rename the alternate schedule and add caller-tier tests
F-05 Low Fee, A, and convergence setters 984-1067 Privileged numeric settings are immediate and unbounded Enforce fee totals, nonzero/ranged A, bounded threshold, and delayed/ramped changes
F-06 Low get-D, get-x, get-y callers 187, 273, 750 Non-convergence returns u0 and is consumed without an explicit failure check Return a response/optional or assert the converged value is nonzero
F-07 Informational get-dy 249 Unequal-decimal quote path derives scaled fees from unscaled input Multiply the scaled input or sum the already-computed scaled fee components
F-08 Informational User public functions 376, 494, 636, 710, 713 Exact minimum output is rejected because checks use > Use >= unless the extra-unit requirement is deliberate and documented

Detailed Findings

F-01: Intermediary contract can inherit an administrator's authority

Severity: Medium

Every governance gate searches for tx-sender in admins. tx-sender is the original transaction signer across ordinary nested contract calls, while contract-caller identifies the immediate caller.

An attack requires an existing administrator to call an attacker-controlled contract, for example through a misleading claim, migration, router, or utility transaction. The malicious contract can then call:

(contract-call? 'SPQC38PW542EQJ5M11CR25P7BS1CA6QT4TBXGB3M.stableswap-stx-ststx-v-1-2 add-admin attacker)

Inside the pool, tx-sender is still the administrator, so L945 passes even though the immediate caller is malicious. The attacker gains a persistent admin slot and can subsequently call governance directly. Available effects include pausing the pair, changing fee recipients, changing fees or invariant parameters, and removing non-deployer administrators.

This is rated Medium because exploitation depends on a privileged signer initiating a transaction to the malicious intermediary. The resulting control is broad, but no assumption is made that an administrator has already done so.

Fix: For governance, require the immediate caller to be listed:

(asserts! (is-some (index-of (var-get admins) contract-caller)) (err "err-not-admin"))

For a deliberately non-composable admin surface, additionally require (is-eq tx-sender contract-caller). Apply the rule consistently to all governance functions and test direct versus nested calls.

F-02: Liquidity and swap paths use different amplification conventions

Severity: Medium

Swap and quote paths call invariant helpers with:

(* (get amplification-coefficient pair-data) number-of-tokens)

Liquidity paths pass current-amplification-coefficient directly. Consequently, LP shares are minted against a different curve from the curve used to price swaps and update cached d.

Using the live balances and exact integer logic:

Scenario Liquidity path A=25 Swap-consistent Ann=50 Difference
Current D 986,853,553,321 1,000,664,990,945 -13,811,437,624
Add 10,000 STX only: LP minted 9,122.313552 9,333.132938 -210.819386 LP
Add 10,000 stSTX only: LP minted 11,955.875784 10,895.924806 +1,059.950978 LP

The stSTX-only case over-mints relative to the active swap curve, diluting existing LP holders. The STX-only case under-mints, harming the depositor. The direction depends on reserve imbalance and deposit composition, which makes the discrepancy economically exploitable around large one-sided deposits even though the contract charges a small imbalance fee.

Fix: Store either A or Ann, document which one it is, and pass a single canonical derived value to every get-D, get-x, and get-y call. Add invariant tests covering balanced and highly imbalanced reserves, both one-sided deposit directions, swaps, and subsequent withdrawals. Reconcile cached d during upgrade/migration.

F-03: Final LP cannot withdraw the complete pool

Severity: Medium

When lp-amount == current-total-shares, proportional withdrawal computes:

  • withdrawal-balance-x == current-balance-x
  • withdrawal-balance-y == current-balance-y
  • new-balance-x == 0
  • new-balance-y == 0

The let binding then eagerly evaluates get-D(0, 0, A) at L706. D-for-loop divides by 2 * current-x-bal at L768 and by 2 * current-y-bal at L770. The call aborts before the LP burn, transfers, and map update.

The practical impact is a permanently unwithdrawable residual whenever one holder owns the entire remaining supply. A holder can withdraw slightly less than all shares, but some reserves and LP supply must remain.

Fix: Handle the terminal state explicitly:

  • Validate lp-amount <= current-total-shares.
  • If equal, set new reserves, total shares, and d to zero without invoking Newton math.
  • Otherwise compute get-D normally.
  • Add tests for final exit, one-base-unit residual, and zero/near-zero reserves.

F-04: Fee-tier selector is inverted but currently masked by configuration

Severity: Low

The comment says admins pay no fees, and the source initializes admin-swap-fees to zero. The implementation instead gives admins buy-fees/sell-fees and gives non-admins admin-swap-fees.

Unlike reports based only on source defaults, this review checked current state. All three fee tuples were 5 bps at review time, so the defect was economically neutral for the deployed pair. It becomes active as soon as governance differentiates the schedules.

Fix: Reverse the conditions to match the stated policy, or rename the variables if the intended policy changed. Test both caller classes after every fee update. Quote functions also need a clearly documented caller-tier assumption.

F-05: Numeric governance controls lack safety bounds and delay

Severity: Low

Fee components can exceed 10,000 bps, A can be zero, and the convergence threshold can be arbitrarily large. These settings can underflow swap subtraction, divide by zero, or accept materially coarse invariant solutions. Changes are immediate and any admin can make them.

Fix: Add conservative per-component and total fee caps, require a documented nonzero A range, bound the convergence threshold, and ramp or timelock material curve changes.

F-06: Newton non-convergence silently becomes zero

Severity: Low

Each fold initializes converged to zero and returns that field. If 384 steps do not meet the mutable threshold, callers receive zero rather than an error. Downstream arithmetic may abort or interpret zero as a reserve/invariant result.

Under normal live parameters the sampled calculations converge. The issue becomes more reachable with extreme admin-controlled settings or generalized pair inputs.

Fix: Return a response/optional that distinguishes convergence failure, or assert nonzero immediately after each helper call.

Remediation Priority

  1. Replace governance tx-sender authorization and test nested calls.
  2. Unify the amplification convention and determine whether historical LP accounting requires migration.
  3. Add the terminal withdrawal branch.
  4. Correct and bound fee/parameter governance.
  5. Make Newton failure explicit and add property tests over reserve imbalance, parameter ranges, and decimal combinations.

Reproduction Notes

The numerical checks used JavaScript BigInt with truncating integer division and the same:

  • 384 maximum iterations,
  • two-token partial-D formula,
  • convergence threshold of 2,
  • live reserves, total shares, fee value, and amplification coefficient.

The Ann=50 result exactly matching the live cached d is the primary cross-check that the reproduction follows the deployed swap path.

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