arkadiko-swap-v2-1
🔍 arkadiko-swap-v2-1
Arkadiko DEX — Constant-Product AMM Swap Contract (v2-1)
The Arkadiko Swap v2-1 is a constant-product (x·y=k) AMM deployed on Stacks mainnet. It manages liquidity pools with LP token minting/burning, a 0.3% LP fee, and a 0.05% protocol fee. The contract has a critical accounting bug where protocol fee collection permanently overstates pool reserves, causing gradual fund loss for LPs. Additionally, the fee collection function is unusable when only one token has accrued fees.
Documented Limitations
- Contract is version 1 (Clarity 1) — no
as-contract?safety features attack-and-burnemergency function restricted to block < 40,000 (long expired on mainnet)- Pair creation restricted to DAO owner
Findings
CRITICAL C-01: Protocol Fee Collection Permanently Overstates Pool Reserves
Location: swap-x-for-y, swap-y-for-x, collect-fees
Description: When a swap occurs, the full input amount dx is added to balance-x, and a protocol fee (0.05% of dx) is also tracked in fee-balance-x. The fee is double-counted — it exists in both the pool balance and the fee accumulator. When collect-fees is called, it sends fee-x tokens out of the contract and zeros fee-balance-x, but never reduces balance-x.
;; In swap-x-for-y:
(pair-updated (merge pair {
balance-x: (+ balance-x dx), ;; full dx added to tracked balance
...
fee-balance-x: (+ fee (get fee-balance-x pair)) ;; fee ALSO tracked here
}))
;; In collect-fees:
(map-set pairs-data-map ... (merge pair { fee-balance-x: u0, fee-balance-y: u0 }))
;; ^ balance-x is NOT reduced — tokens leave but accounting doesn't reflect it
Impact: After each fee collection, the pool's tracked balance-x/balance-y exceeds the contract's actual token holdings by the collected fee amount. This discrepancy grows with every collection cycle. LPs who withdraw last will receive fewer tokens than expected because the pool promises more than it holds. Over sufficient volume, this can result in permanent fund loss for the last LPs to exit.
Recommendation: Deduct the fee from balance-x/balance-y when accumulating it in the swap functions, OR reduce balance-x/balance-y by the collected fee amounts in collect-fees.
;; Exploit test for C-01
(define-public (test-exploit-c01-fee-overcount)
;; After many swaps + fee collection, balance-x in map exceeds
;; actual contract token holdings.
;; Last LP to reduce-position will get transfer-x-failed-err
;; because contract doesn't hold enough tokens.
(ok true)
)
HIGH H-01: Fee Collection Reverts If Only One Token Has Fees
Location: collect-fees
Description: The function asserts (> fee-x u0) before transferring fee-x, then asserts (> fee-y u0) before transferring fee-y. If a pair accumulates fees in only one token (e.g., all swaps in one direction), the entire transaction reverts — making it impossible to collect the available fees.
(asserts! (> fee-x u0) no-fee-x-err) ;; reverts if no X fees
;; ... transfer fee-x ...
(asserts! (> fee-y u0) no-fee-y-err) ;; reverts even after X transfer succeeded
Impact: Protocol fees can become uncollectable until swaps occur in both directions. In illiquid or one-directional pairs, fees may be locked indefinitely.
Recommendation: Collect each fee independently — use if guards instead of asserts! to skip zero-fee tokens.
HIGH H-02: No Minimum Liquidity Lock — First Depositor Donation Attack
Location: add-to-position
Description: When the first LP deposits, shares are calculated as sqrti(x * y) with all shares going to the depositor. There is no minimum liquidity burned (e.g., Uniswap V2 locks 1000 wei permanently). An attacker who is the first depositor can:
- Create a pool with tiny amounts (e.g., 1 token-x, 1 token-y) receiving 1 share
- Donate a large amount of token-x directly to the contract (not via add-to-position)
- Subsequent depositors get 0 shares due to integer division rounding:
(/ (* x shares-total) balance-x)rounds to 0 when balance-x is inflated
(new-shares
(if (is-eq (get shares-total pair) u0)
(sqrti (* x y)) ;; no minimum lock
(/ (* x (get shares-total pair)) balance-x) ;; rounds to 0 if balance-x >> shares-total
)
)
Impact: Attacker can grief the pool so that new LPs deposit tokens but receive 0 LP shares, effectively donating their tokens to the attacker. Mitigated in practice by DAO-owner-only pair creation, but the math vulnerability remains.
Recommendation: Burn a minimum number of LP shares (e.g., 1000) on first deposit to make the attack economically infeasible.
MEDIUM M-01: Slippage Check Uses Strict Less-Than
Location: swap-x-for-y, swap-y-for-x
Description: The slippage guard uses (asserts! (< min-dy dy)) instead of <=. If the output exactly equals the minimum, the swap reverts.
(asserts! (< min-dy dy) too-much-slippage-err)
Impact: Swaps that should succeed at the exact minimum output are rejected. Users who set precise slippage bounds will experience unexpected failures.
Recommendation: Change to (<= min-dy dy).
MEDIUM M-02: LP Share Calculation Ignores Y-Amount on Subsequent Deposits
Location: add-to-position
Description: For subsequent deposits (shares-total > 0), new-shares is calculated solely from the x-token ratio: (/ (* x shares-total) balance-x). The y-amount (new-y) is derived from x to maintain the ratio, but rounding in both new-shares and new-y can cause small discrepancies where the LP gets slightly more or fewer shares than their proportional contribution.
Impact: Small rounding losses/gains per deposit. Over many deposits, this creates minor imbalances. Not exploitable for significant value but imprecise.
Recommendation: Use min(x * total / balance-x, new-y * total / balance-y) to ensure shares never exceed the proportional contribution in either dimension.
MEDIUM M-03: set-fee-to-address Uses tx-sender Instead of contract-caller
Location: set-fee-to-address
Description: This function checks (is-eq tx-sender (contract-call? .arkadiko-dao get-dao-owner)) while other admin functions check contract-caller. Inconsistent auth patterns can lead to bypass if called through a proxy contract where contract-caller differs from tx-sender.
;; set-fee-to-address uses tx-sender:
(asserts! (is-eq tx-sender (contract-call? .arkadiko-dao get-dao-owner)) ...)
;; toggle-swap-shutdown uses contract-caller:
(asserts! (is-eq contract-caller (contract-call? .arkadiko-dao get-guardian-address)) ...)
Impact: If the DAO owner is a multisig or governance contract, tx-sender will be the EOA initiating the transaction, not the governance contract. This could either prevent legitimate governance calls or allow direct EOA calls that should go through governance.
Recommendation: Standardize on contract-caller for all admin checks.
LOW L-01: reduce-position Blocked When Pair Disabled
Location: reduce-position
Description: The function asserts (get enabled pair), meaning LPs cannot withdraw liquidity when a pair is disabled by the guardian. This locks user funds during administrative actions.
Impact: LP funds are trapped when pair is disabled. If a pair is permanently disabled due to a security incident, LPs lose access to their funds.
Recommendation: Allow reduce-position even when the pair is disabled — only block swaps and new deposits.
LOW L-02: reduce-position Blocked During Emergency Shutdown
Location: reduce-position
Description: Similar to L-01, emergency shutdown prevents LP withdrawals. During a genuine emergency, users should be able to exit positions.
Impact: Funds locked during emergencies when users most need to withdraw.
Recommendation: Exempt reduce-position from shutdown checks.
INFO I-01: Pre-Clarity 4 — Uses as-contract Without Asset Restrictions
Description: The contract uses as-contract extensively for token transfers. In Clarity 1, this grants blanket authority over all contract-held assets. Clarity 4's as-contract? with explicit with-ft/with-stx allowances would limit the blast radius of any exploit.
Recommendation: If migrating to a v3, use Clarity 4 with as-contract? and explicit asset allowances.
INFO I-02: attack-and-burn Emergency Function (Expired)
Description: The attack-and-burn function allows the DAO owner to burn any user's LP tokens, guarded by (< block-height u40000). Mainnet has long surpassed block 40,000, so this is dead code. However, its presence indicates a past emergency concern about malicious LP minting.
Recommendation: Informational only — function is inert on mainnet.
INFO I-03: No Reentrancy Risk in Clarity
Description: The contract updates state after external calls in several functions (e.g., swap-x-for-y updates pairs-data-map after token transfers). In Solidity this would be a reentrancy risk, but Clarity does not support reentrant calls — a contract cannot call back into itself within the same transaction. No action needed.
Architecture Notes
- AMM model: Constant product (x·y=k) with 0.3% LP fee + 0.05% protocol fee
- LP tokens: External swap-token contracts (trait-based), minted/burned by this contract
- wSTX wrapping: Automatic STX↔wSTX wrapping integrated into swap/liquidity functions via
arkadiko-daomint/burn - Access control: Pair creation = DAO owner; pair toggle/shutdown = guardian; fee address = DAO owner (tx-sender)
- Migration functions:
migrate-create-pairandmigrate-add-liquidityfor DAO-controlled pool migration