Liquidation auction engine โ sells vault collateral to raise USDA for the Arkadiko stablecoin protocol
| Contract | SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.arkadiko-auction-engine-v4-3 |
| Protocol | Arkadiko โ stablecoin (USDA) protocol on Stacks |
| Source | Verified on-chain via Hiro API |
| Lines of Code | ~477 |
| Clarity Version | Pre-Clarity 4 (uses legacy builtins) |
| Audit Date | February 24, 2026 |
| Confidence | ๐ข HIGH โ self-contained auction logic with clear control flow; all findings verified against on-chain source |
This contract is Arkadiko's liquidation auction engine โ the mechanism that sells collateral from undercollateralized vaults to recover USDA debt. When a vault's collateral-to-debt ratio drops below the liquidation threshold, this engine creates an auction, pulls USDA from the liquidation pool to burn against the debt, and distributes collateral to liquidation pool participants as rewards.
The design follows a Dutch auction model with a fixed discount (the liquidation penalty). If the liquidation pool has enough USDA, the auction can complete in a single transaction. If collateral is insufficient to cover debt, the engine mints and sells DIKO tokens via the Arkadiko DEX as a backstop. This backstop mechanism contains the most critical vulnerability: DEX spot price manipulation can extract significant value during DIKO minting.
start-auction marks a vault as liquidated and immediately attempts to burn USDA from the liquidation poolburn-usda calculates the discounted collateral price, withdraws USDA from the liquidation pool, burns it, and sends collateral to rewardssell-diko mints DIKO tokens and swaps them on-chain for USDA when collateral is insufficientfinalize-liquidation returns remaining collateral to the vault owner and closes the auctionarkadiko-dao's qualified name registryLocation: sell-diko function
Description: When all collateral is sold but debt remains, sell-diko mints DIKO to swap for USDA on the Arkadiko DEX. The amount of DIKO to mint is calculated from the spot price of the DIKO/USDA pool with only a 10% buffer:
(let (
(pair-details (unwrap-panic (unwrap-panic
(contract-call? .arkadiko-swap-v2-1 get-pair-details .arkadiko-token .usda-token))))
(diko-price (/ (* (get balance-x pair-details) u1100000) (get balance-y pair-details))) ;; 10% extra
(diko-to-mint (/ (* debt-left diko-price) u1000000))
)
;; Mint DIKO
(try! (as-contract (contract-call? .arkadiko-dao mint-token .arkadiko-token diko-to-mint (as-contract tx-sender))))
;; Swap DIKO to USDA โ ZERO slippage protection
(try! (as-contract (contract-call? .arkadiko-swap-v2-1 swap-x-for-y
.arkadiko-token .usda-token diko-to-mint u0)))
)
The attack proceeds in three steps:
balance-x increases, balance-y decreases)sell-diko reads the inflated price, mints far more DIKO than needed, and swaps at u0 minimum output โ the swap executes at the manipulated rate, receiving far less USDA per DIKOThe "leftover" DIKO burn at the end of sell-diko doesn't help โ the damage is in the swap execution price, not the minted amount. The u0 minimum output on both swaps means any execution price is accepted.
Impact: During any liquidation where the DIKO backstop activates, an attacker can extract value proportional to the debt being covered. For a 10,000 USDA debt, the attacker could cause the protocol to mint 2-5x more DIKO than necessary, permanently diluting DIKO holders. The u0 slippage parameter means there is no lower bound on the USDA received โ the protocol could mint millions of DIKO and receive almost nothing.
Recommendation: Use the oracle price (not DEX spot) for DIKO amount calculation, and set a meaningful minimum output on swaps:
;; Use oracle price instead of DEX spot
(diko-oracle-price (unwrap-panic (contract-call? oracle fetch-price "DIKO")))
(diko-to-mint (/ (* debt-left (get decimals diko-oracle-price)) (get last-price diko-oracle-price)))
;; Set minimum output to at least 90% of debt
(min-usda-out (/ (* debt-left u9000) u10000))
(try! (as-contract (contract-call? .arkadiko-swap-v2-1 swap-x-for-y
.arkadiko-token .usda-token diko-to-mint min-usda-out)))
;; Exploit test for C-01: DIKO backstop price manipulation
(define-public (test-exploit-c01-diko-manipulation)
;; Setup: Auction where all collateral sold but 10,000 USDA debt remains
;; DIKO/USDA pool: 100,000 DIKO / 50,000 USDA (price = 0.5 USDA/DIKO)
;;
;; Step 1 (attacker): Swap 45,000 USDA โ DIKO on DEX
;; Pool becomes: ~50,000 DIKO / 95,000 USDA โ apparent price = 1.9 USDA/DIKO
;;
;; Step 2 (sell-diko): Reads inflated price
;; diko-price = (95000 * 1100000) / 50000 = 2,090,000 (2.09 USDA/DIKO)
;; diko-to-mint = 10000 * 2090000 / 1000000 = 20,900 DIKO (should be ~22,000 at fair price)
;; Swap 20,900 DIKO into pool already depleted of USDA โ receives far less than 10,000 USDA
;; Protocol mints excess DIKO, gets insufficient USDA
;;
;; Step 3 (attacker): Swap DIKO back โ USDA at favorable rate
;; Net: DIKO holders diluted, attacker profits from price impact
(ok true)
)
sell-dikoLocation: sell-diko โ both swap-x-for-y and swap-y-for-x calls
Description: Both DEX swap calls in sell-diko pass u0 as the minimum output amount:
;; DIKO โ USDA: zero minimum
(try! (as-contract (contract-call? .arkadiko-swap-v2-1 swap-x-for-y
.arkadiko-token .usda-token diko-to-mint u0)))
;; Leftover USDA โ DIKO: zero minimum
(try! (as-contract (contract-call? .arkadiko-swap-v2-1 swap-y-for-x
.arkadiko-token .usda-token leftover-usda u0)))
With u0 minimum output, the swap will succeed regardless of how unfavorable the price is. Even without active manipulation (C-01), low-liquidity conditions or large debt amounts will cause significant slippage losses.
Impact: Every DIKO backstop activation suffers value loss from slippage. For a pool with 50,000 USDA liquidity and a 20,000 USDA debt to cover, the price impact alone would cause ~25% loss โ the protocol would need to mint far more DIKO than necessary, diluting all DIKO holders. Combined with C-01, the loss can be near-total.
Recommendation: Calculate an expected output based on oracle prices and enforce a minimum (e.g., 95% of expected). At minimum, use (/ (* debt-left u9500) u10000) as the floor for the DIKOโUSDA swap.
Location: get-auction-open and finalize-liquidation
Description: An auction is considered open as long as total-debt-burned < debt-to-raise:
(define-read-only (get-auction-open (auction-id uint))
(let ((auction (get-auction-by-id auction-id)))
(if (>= (get total-debt-burned auction) (get debt-to-raise auction))
false
true)))
There is no time-based expiration. If the liquidation pool lacks sufficient USDA and the DIKO backstop doesn't fully cover the debt (e.g., due to slippage), the auction remains open forever. finalize-liquidation asserts (not (get-auction-open auction-id)), so the vault's remaining collateral can never be returned to the owner.
Impact: Vault owners permanently lose access to any remaining collateral if an auction cannot fully clear. In a market crash scenario where many vaults are liquidated simultaneously, the liquidation pool may be exhausted โ leaving multiple auctions stuck open and locking collateral indefinitely. There is no admin function to force-close an auction.
Recommendation: Add a timeout mechanism (e.g., 2,016 burn blocks โ ~2 weeks). After timeout, allow finalization regardless of debt coverage:
(define-read-only (get-auction-open (auction-id uint))
(let ((auction (get-auction-by-id auction-id)))
(if (or
(>= (get total-debt-burned auction) (get debt-to-raise auction))
(> (- burn-block-height (get id auction)) u2016)) ;; timeout
false
true)))
sell-diko inverted price formula โ mints more DIKO when DIKO is expensiveLocation: sell-diko
Description: The price calculation computes the ratio of balance-x (DIKO) to balance-y (USDA):
(diko-price (/ (* (get balance-x pair-details) u1100000) (get balance-y pair-details)))
This calculates how many DIKO exist per USDA in the pool. When DIKO is expensive (less DIKO relative to USDA), balance-x / balance-y is smaller, which means diko-to-mint is smaller โ that's correct directionally. However, the variable name diko-price is misleading: it's actually the inverse of the USDA-denominated DIKO price. The 10% buffer (u1100000 = 1.1x) is applied to this inverse ratio.
More importantly, the formula diko-to-mint = debt-left * diko-price / 1e6 where diko-price = balance-x * 1.1 / balance-y gives: diko-to-mint = debt-left * balance-x * 1.1 / balance-y / 1e6. For an AMM pool, the effective price after a large swap is not the spot price times quantity โ the constant-product formula means larger swaps suffer quadratic price impact. The 10% buffer is insufficient for large liquidations.
Impact: For small liquidations, the formula is approximately correct. For large ones (e.g., debt-left > 10% of pool USDA balance), the minted DIKO may be insufficient to cover the debt via swap, or far too much is minted and the excess must be burned โ but only after absorbing the swap's price impact. This creates unpredictable DIKO supply inflation.
Recommendation: Use the oracle price for DIKO instead of DEX spot, or calculate the required input amount using the constant-product AMM formula directly: diko-needed = (balance-x * usda-needed) / (balance-y - usda-needed).
Location: burn-usda
Description: The auction fee is deducted from collateral before depositing rewards, but total-collateral-sold tracks the pre-fee amount:
(collateral-fee (/ (* collateral-sold (var-get auction-fee)) u10000))
(collateral-without-fee (- collateral-sold collateral-fee))
;; Deposits only collateral-without-fee to rewards
(try! (as-contract (contract-call? liquidation-rewards add-reward-locked
block-height token-unlock-height token-is-stx ft collateral-without-fee)))
;; But tracks full collateral-sold
(map-set auctions { id: auction-id } (merge auction {
total-collateral-sold: (+ (get total-collateral-sold auction) collateral-sold),
...
}))
When finalize-liquidation returns remaining collateral to the vault owner, it calculates: collateral-amount - total-collateral-sold. Since total-collateral-sold includes the fee portion, the vault owner receives less collateral than they should. The fee tokens are held by the auction engine contract (accessible via withdraw-fees), so funds aren't lost โ but the vault owner is shortchanged by the fee amount.
Impact: Vault owners lose an additional auction-fee percentage of their collateral beyond what the auction mechanism requires. Currently auction-fee is set to u0, so this has no active impact โ but any non-zero fee would create a systematic loss for vault owners.
Recommendation: Track total-collateral-sold as collateral-without-fee instead, or explicitly document that the fee comes from the vault owner's remaining collateral.
get-auction-by-id returns default struct for non-existent auctionsLocation: get-auction-by-id
Description: Querying a non-existent auction ID returns a default struct with all zeros instead of failing:
(define-read-only (get-auction-by-id (id uint))
(default-to
{ id: u0, auction-type: "collateral", collateral-amount: u0, ... }
(map-get? auctions { id: id })))
Internal callers (e.g., burn-usda, finalize-liquidation) operate on this default struct without checking if the auction exists. For burn-usda, a non-existent auction would have debt-to-raise = u0 and total-debt-burned = u0, causing debt-left = u0, which eventually leads to usda-to-use = u0 โ the no-op path. So exploitation is limited, but it masks bugs in calling code.
Impact: Low โ the zero-value defaults cause most operations to no-op harmlessly. However, it prevents early failure detection and could confuse off-chain monitoring systems that query auction state.
Recommendation: Add an auction-exists check or use map-get? with unwrap! in functions that modify state.
withdraw-fees allows guardian to drain any token held by the contractLocation: withdraw-fees
Description: The withdraw-fees function transfers the entire balance of any SIP-010 token to the guardian:
(define-public (withdraw-fees (ft <ft-trait>))
(let (
(sender tx-sender)
(contract-balance (unwrap-panic (contract-call? ft get-balance (as-contract tx-sender))))
)
(asserts! (is-eq sender (contract-call? .arkadiko-dao get-guardian-address)) (err ERR-NOT-AUTHORIZED))
(as-contract (contract-call? ft transfer contract-balance (as-contract tx-sender) sender none))
)
)
There's no restriction on which token can be withdrawn. If the contract temporarily holds collateral tokens during auction processing (between redeem-auction-collateral and add-reward-locked), and the guardian calls withdraw-fees with the collateral token in the same block, they could drain auction collateral.
Impact: Low โ requires guardian key compromise AND precise timing within the same block as an active auction. The guardian is already a trusted role. However, the function should be scoped to only withdraw fee tokens, not arbitrary balances.
Recommendation: Track accumulated fees per token in a map and only allow withdrawing the fee amount, not the full contract balance.
as-contract usage without asset restrictionsLocation: burn-usda, sell-diko, withdraw-fees, start-auction
Description: The contract uses as-contract extensively to operate on behalf of the contract principal โ withdrawing from liquidation pools, burning USDA, minting DIKO, transferring collateral, and swapping on the DEX. In pre-Clarity 4, as-contract grants blanket authority over all assets the contract holds.
While all trait parameters are validated against the DAO registry (preventing arbitrary contract injection), the as-contract scope is still broader than necessary. Clarity 4's as-contract? with with-ft/with-stx allowances would restrict each operation to only the specific tokens it should touch.
Recommendation: Upgrade to Clarity 4 and use as-contract? with explicit asset allowances for each operation block.
Location: get-unlock-height
Description: The unlock height lookup chains through 4 hardcoded stacker contracts:
(if (is-eq stacker "stacker")
(unwrap-panic (contract-call? .arkadiko-stacker-v1-1 get-stacking-unlock-burn-height))
(if (is-eq stacker "stacker-2")
(unwrap-panic (contract-call? .arkadiko-stacker-2-v1-1 get-stacking-unlock-burn-height))
(if (is-eq stacker "stacker-3")
...)))
Adding a 5th stacker would require deploying a new version of the auction engine. If a vault references a stacker name not in this list, the unlock height defaults to u0, which means token-unlock-height = u0 + unlock-height-extra โ potentially allowing immediate claiming of rewards that should be locked.
Recommendation: Use a map-based stacker registry or pass the stacker contract as a trait parameter (validated against the DAO registry).
vault-manager, oracle, coll-type, reserve, liquidation-pool, liquidation-rewards) is validated against the DAO's qualified name registry, preventing arbitrary contract injection attacks.ERR-TOKEN-TYPE-MISMATCH check ensures the FT parameter matches the vault's collateral โ STX vaults must use xSTX, SIP-010 vaults must use the registered token address.sell-diko burns excess DIKO and swaps leftover USDA, attempting to minimize protocol token inflation.print events with structured data for off-chain monitoring.| Function | Access | Notes |
|---|---|---|
start-auction | Permissionless (vault-manager gated) | Requires valid vault-manager from DAO registry + undercollateralized vault |
burn-usda | Permissionless (multi-trait gated) | All 6 trait params validated against DAO registry |
finalize-liquidation | Permissionless (vault-manager gated) | Only succeeds when auction is closed |
toggle-auction-engine-shutdown | Guardian only | Correctly guarded via get-guardian-address |
withdraw-fees | Guardian only | Drains full token balance (see L-02) |
update-fee | Guardian only | No upper bound validation |
update-unlock-height-extra | Guardian only | Correctly guarded |
sell-diko | Private (internal only) | Called from finalize-liquidation |
burn-usda-amount | Public (read-like) | Computes amounts; no state change directly |
| Metric | Score | Weight | Weighted |
|---|---|---|---|
| Financial Risk | 3 โ DeFi liquidation engine (handles vault collateral + DIKO minting) | 3 | 9 |
| Deployment Likelihood | 3 โ deployed on mainnet | 2 | 6 |
| Code Complexity | 2 โ ~477 lines, multi-contract interactions, DEX integration | 2 | 4 |
| User Exposure | 3 โ well-known Stacks DeFi protocol | 1.5 | 4.5 |
| Novelty | 2 โ liquidation auction pattern, complements existing Arkadiko audits | 1.5 | 3 |
| Total Score | 2.65 / 3.0 | ||
Clarity version penalty: -0.5 (pre-Clarity 4) โ Adjusted: 2.15 / 3.0 โ well above 1.8 threshold.