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.
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:
- 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 isadd-admin(attacker). - Swap paths use
amplification-coefficient * 2, while liquidity paths use the raw coefficient. The live pool's storeddmatches the swap convention, but LP minting uses the other curve and asymmetrically misprices one-sided deposits. - 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.
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,321get-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.
| 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.
| 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.
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-specificA.
Mutators:
create-paircreates an entry and seeds reserves/supply.set-pair-approvalchangesapproval.swap-x-for-yandswap-y-for-xupdate reserves and cachedd.add-liquidityupdates reserves, supply, andd.withdraw-liquidityupdates reserves, supply, andd.change-amplification-coefficientchangesAwithout recomputing cachedd.
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.
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.
- 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, andbitflow-contractreceive 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.
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.
| 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.
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.
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.
| 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 |
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.
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.
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.
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.
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.
get-Ddivides by2*x,2*y, and a derived denominator. Zero reserves are not handled.get-xandget-ydivide byannand reserve-derived values;A = 0is 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'suintruntime 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 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.
get-pair-datatypeslp-tokenas<sip-010-trait>, while state-changing functions use<lp-trait>. The function only needscontract-of, but the stricter/different trait requirement can unnecessarily reject an otherwise valid LP implementation.pair-nameincreate-pairis accepted but never stored or emitted.- There is no local verification that the LP contract's reported supply equals
PairsDataMap.total-shares.
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.
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.
| 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 |
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.
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.
Severity: Medium
When lp-amount == current-total-shares, proportional withdrawal computes:
withdrawal-balance-x == current-balance-xwithdrawal-balance-y == current-balance-ynew-balance-x == 0new-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
dto zero without invoking Newton math. - Otherwise compute
get-Dnormally. - Add tests for final exit, one-base-unit residual, and zero/near-zero reserves.
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.
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.
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.
- Replace governance
tx-senderauthorization and test nested calls. - Unify the amplification convention and determine whether historical LP accounting requires migration.
- Add the terminal withdrawal branch.
- Correct and bound fee/parameter governance.
- Make Newton failure explicit and add property tests over reserve imbalance, parameter ranges, and decimal combinations.
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.