Arkadiko Vaults Manager v1-1

Liquidation & redemption engine for the Arkadiko stablecoin protocol

ContractSP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.arkadiko-vaults-manager-v1-1
ProtocolArkadiko โ€” stablecoin (USDA) protocol on Stacks (Liquity-style)
SourceVerified on-chain via Hiro API
Lines of Code~230
Clarity VersionPre-Clarity 4 (uses as-contract, not as-contract?)
Audit DateFebruary 24, 2026
Confidence๐ŸŸก MEDIUM โ€” single contract in a multi-contract system; trait implementations not audited
1
Critical
1
High
3
Medium
2
Low
2
Info

Overview

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.

Architecture

Findings

CRITICAL

C-01: Redemption fee block-last overflow bricks future redemptions

Location: 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

;; 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)
)
HIGH

H-01: Stability fee crystallization inflates vault debt on partial redemption

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.

MEDIUM

M-01: Zero oracle price causes division by zero, making vaults unliquidatable

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.

MEDIUM

M-02: No minimum debt check after partial redemption creates dust vaults

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.

MEDIUM

M-03: Multiplication overflow in collateral calculations for large positions

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.

LOW

L-01: get-collateral-for-liquidation is public instead of read-only

Location: 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.

LOW

L-02: No zero-amount validation on redemption

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.

INFO

I-01: Uses 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.

INFO

I-02: No event emission for liquidation and redemption

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.

Positive Observations

Access Control Summary

FunctionAccessNotes
liquidate-vaultPermissionlessAnyone can liquidate undercollateralized vaults (standard for CDP protocols)
redeem-vaultPermissionlessAnyone can redeem USDA for collateral from lowest-ratio vault
set-shutdown-activatedDAO owner onlyCorrectly guarded by arkadiko-dao get-dao-owner
get-collateral-for-liquidationPermissionlessPublic getter โ€” no state modification risk
get-redemption-feePermissionlessPublic getter โ€” no state modification risk

Priority Score

MetricScoreWeightWeighted
Financial Risk3 โ€” DeFi/lending/staking39
Deployment Likelihood3 โ€” deployed on mainnet26
Code Complexity2 โ€” 200-500 lines, multi-contract dependencies24
User Exposure3 โ€” well-known Stacks protocol1.54.5
Novelty3 โ€” first Liquity-style vault manager audit1.54.5
Total Score2.8 / 3.0

Clarity version penalty: -0.5 (pre-Clarity 4) โ†’ Adjusted: 2.3 / 3.0 โ€” well above 1.8 threshold.