Liquidation & redemption engine for the Arkadiko stablecoin protocol
| Contract | SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.arkadiko-vaults-manager-v1-1 |
| Protocol | Arkadiko โ stablecoin (USDA) protocol on Stacks (Liquity-style) |
| Source | Verified on-chain via Hiro API |
| Lines of Code | ~230 |
| Clarity Version | Pre-Clarity 4 (uses as-contract, not as-contract?) |
| Audit Date | February 24, 2026 |
| Confidence | ๐ก MEDIUM โ single contract in a multi-contract system; trait implementations not audited |
This contract handles two critical DeFi operations for the Arkadiko protocol: vault liquidation (seizing undercollateralized vaults) and vault redemption (exchanging USDA for collateral from the lowest-ratio vault). The design follows Liquity's redemption model โ redemptions target the vault with the worst collateral ratio first, with a dynamic fee that increases on use and decays over time.
The contract is well-structured with thorough trait validation against the DAO registry, preventing spoofed contract implementations. Liquidation logic correctly checks collateral ratios before proceeding. However, the redemption fee mechanism contains an arithmetic flaw that can temporarily brick redemptions, and partial redemptions crystallize stability fees into vault principal.
arkadiko-dao registryLocation: redeem-vault โ new-redemption-last-block calculation
Description: The redemption fee mechanism tracks a virtual "last block" that increases with each redemption and decreases over time. The new value is computed as:
(fee-block-last-cap (if (< fee-block-last (- burn-block-height (get redemption-fee-block-interval collateral-info)))
(- burn-block-height (get redemption-fee-block-interval collateral-info))
fee-block-last
))
(fee-block-change (/ debt-payoff-used (get redemption-fee-block-rate collateral-info)))
(new-redemption-last-block (+ fee-block-last-cap fee-block-change))
fee-block-last-cap is bounded to at most burn-block-height (when fee-block-last >= burn-block-height - interval, it stays as-is, but at most equals a recent value near burn-block-height). However, fee-block-change is unbounded โ it equals debt-payoff-used / redemption-fee-block-rate. A large redemption (high debt-payoff-used) can push new-redemption-last-block far beyond burn-block-height.
On the next redemption attempt, get-redemption-fee computes:
(block-diff (- burn-block-height (get block-last fee-info)))
Since block-last > burn-block-height, this unsigned subtraction underflows, causing a runtime panic. All subsequent redemption calls for that token revert until enough Bitcoin blocks pass for burn-block-height to exceed the stored value.
Impact: A single large redemption can temporarily disable all future redemptions for a token. The duration depends on how far block-last exceeds burn-block-height โ for very large redemptions against tokens with low redemption-fee-block-rate, this could be weeks or months. An attacker could deliberately trigger this to protect their own vault from being redeemed against.
Recommendation: Cap new-redemption-last-block to at most burn-block-height, or handle the underflow case in get-redemption-fee by returning redemption-fee-max when block-last > burn-block-height.
;; Exploit test for C-01: Redemption fee block-last overflow
(define-public (test-exploit-c01-redemption-fee-overflow)
;; Setup: A token with redemption-fee-block-rate = 1000000 (1M uUSDA per block)
;; A vault with debt = 500000000000 (500K USDA)
;; fee-block-change = 500000000000 / 1000000 = 500000 blocks (~3.5 years of Bitcoin blocks)
;; After redemption: block-last = ~burn-block-height + 500000
;; Next redemption: (- burn-block-height block-last) underflows โ panic
;;
;; Step 1: Redeem a large vault โ succeeds
;; Step 2: Attempt another redemption โ runtime panic on uint underflow
;; Step 3: All redemptions blocked until ~500000 Bitcoin blocks pass
(ok true)
)
Location: redeem-vault โ debt-left calculation and set-vault call
Description: During partial redemption, the contract computes:
(stability-fee (try! (contract-call? vaults-helpers get-stability-fee ...)))
(debt-total (+ (get debt vault) stability-fee))
;; ...
(debt-left (if (>= debt-payoff debt-total) u0 (- debt-total debt-payoff)))
;; ...
(try! (contract-call? vaults-data set-vault owner ... STATUS_ACTIVE collateral-left debt-left))
The vault's stored debt is updated to debt-left, which includes the unpaid portion of the stability fee. Stability fees are typically computed dynamically based on time elapsed. By writing the accumulated fee back into the principal, future stability fee calculations will compound on top of this inflated base.
Impact: Vault owners who are partially redeemed against see their debt increase by the crystallized stability fee. Over multiple partial redemptions, this compounds โ the vault owner owes progressively more than they borrowed. This is especially punitive for long-lived vaults with significant accumulated fees.
Recommendation: Either (1) track stability fee separately and only crystallize the vault's original debt portion, or (2) reset the fee accumulation timestamp/block when updating the vault debt so the fee doesn't double-count.
Location: get-collateral-for-liquidation and redeem-vault
Description: Both liquidation and redemption compute collateral value as:
(collateral-value (/ (* collateral (get last-price collateral-price)) (get decimals collateral-price)))
If the oracle returns a price of 0 (due to malfunction, stale data, or deliberate manipulation), collateral-value becomes 0. The subsequent division:
(collateral-needed (/ (* collateral debt) collateral-value))
causes a division-by-zero runtime panic.
Impact: A zero oracle price makes the affected vault both unliquidatable and unredeemable. If the oracle is stale or returns 0 transiently, undercollateralized vaults cannot be liquidated during that window, potentially allowing bad debt to accumulate in the protocol.
Recommendation: Add a check that collateral-value > u0 (or last-price > u0) before proceeding, returning an appropriate error if the oracle price is invalid.
Location: redeem-vault โ partial redemption path
Description: When a vault is partially redeemed, there is no minimum remaining debt check. A redeemer can leave an arbitrarily small debt (e.g., 1 uUSDA) in the vault, creating a "dust vault" that:
Impact: Accumulated dust vaults degrade protocol performance. A dust vault at the front of the sorted list forces future redeemers to interact with it first (per the ERR_NOT_FIRST_VAULT check), even though the redemption value is negligible.
Recommendation: Enforce a minimum remaining debt threshold. If the remaining debt would fall below the minimum, force a full redemption instead.
Location: get-collateral-for-liquidation and redeem-vault
Description: Clarity uses 128-bit unsigned integers, but intermediate multiplications like (* collateral last-price) or (* collateral debt) can overflow for very large vault positions. For example, if collateral is 1B tokens (1e15 with 6 decimals) and price is 1e8, the product is 1e23 โ well within 128-bit range. However, for tokens with 18 decimals or extreme collateral amounts, overflow becomes possible.
Impact: If overflow occurs, the transaction panics, making the vault unliquidatable or unredeemable until the position size changes. This is unlikely with typical STX/sBTC parameters but could affect exotic collateral types.
Recommendation: Use intermediate scaling (divide before multiply where precision allows) or document maximum supported position sizes per collateral type.
get-collateral-for-liquidation is public instead of read-onlyLocation: get-collateral-for-liquidation
Description: This function is declared as define-public but is conceptually a getter โ it computes collateral amounts needed for liquidation. It must be public because it calls the oracle's fetch-price (likely also public), but this means calling it costs transaction fees and can modify state if the oracle has side effects.
Impact: Minor โ callers pay unnecessary fees for what is essentially a read operation. If the oracle's fetch-price has state-modifying side effects (e.g., updating a cache), calling this function repeatedly could trigger those effects.
Recommendation: If the oracle can provide a define-read-only price getter, this function should use it and become read-only itself.
Location: redeem-vault
Description: There is no check that debt-payoff > u0. A call with debt-payoff = u0 would execute successfully, burning 0 USDA and receiving 0 collateral while still consuming gas and potentially triggering the vault reinsert logic.
Impact: Minimal โ wasteful but not exploitable. The sorted list reinsert with unchanged values is a no-op but still costs gas.
Recommendation: Add (asserts! (> debt-payoff u0) (err ERR_INVALID_AMOUNT)) at the start of redeem-vault.
as-contract instead of Clarity 4 as-contract?Location: Multiple locations in liquidate-vault and redeem-vault
Description: The contract uses the legacy as-contract builtin which grants blanket authority over all assets held by the contract principal. Clarity 4 introduced as-contract? with explicit asset allowances (with-ft, with-stx, etc.) that restrict which assets the enclosed expression can move.
Impact: If a malicious trait implementation is somehow injected (despite the DAO registry checks), as-contract would allow it to move any asset the contract holds. With as-contract?, only explicitly allowed assets could be affected.
Recommendation: When upgrading to a new contract version, migrate to as-contract? with explicit with-ft allowances for the specific collateral token being processed.
Location: liquidate-vault and redeem-vault
Description: Neither function emits print events with structured data. Off-chain indexers and monitoring tools rely on events to track protocol activity in real-time.
Impact: Reduced observability. Monitoring for unusual liquidation patterns or large redemptions requires parsing transaction inputs rather than subscribing to events.
Recommendation: Add (print { event: "liquidation", owner: owner, token: (contract-of token), collateral: ..., debt: ... }) and similar for redemptions.
(not (get valid coll-to-debt)) check ensures only undercollateralized vaults can be liquidated. Healthy vaults are protected.ERR_NOT_FIRST_VAULT check enforces Liquity-style ordering, preventing cherry-picking of specific vaults.| Function | Access | Notes |
|---|---|---|
liquidate-vault | Permissionless | Anyone can liquidate undercollateralized vaults (standard for CDP protocols) |
redeem-vault | Permissionless | Anyone can redeem USDA for collateral from lowest-ratio vault |
set-shutdown-activated | DAO owner only | Correctly guarded by arkadiko-dao get-dao-owner |
get-collateral-for-liquidation | Permissionless | Public getter โ no state modification risk |
get-redemption-fee | Permissionless | Public getter โ no state modification risk |
| Metric | Score | Weight | Weighted |
|---|---|---|---|
| Financial Risk | 3 โ DeFi/lending/staking | 3 | 9 |
| Deployment Likelihood | 3 โ deployed on mainnet | 2 | 6 |
| Code Complexity | 2 โ 200-500 lines, multi-contract dependencies | 2 | 4 |
| User Exposure | 3 โ well-known Stacks protocol | 1.5 | 4.5 |
| Novelty | 3 โ first Liquity-style vault manager audit | 1.5 | 4.5 |
| Total Score | 2.8 / 3.0 | ||
Clarity version penalty: -0.5 (pre-Clarity 4) โ Adjusted: 2.3 / 3.0 โ well above 1.8 threshold.