Skip to content

Liquidation edge cases#181

Open
mts1715 wants to merge 4 commits intomainfrom
taras/173-liquidation-edge-cases
Open

Liquidation edge cases#181
mts1715 wants to merge 4 commits intomainfrom
taras/173-liquidation-edge-cases

Conversation

@mts1715
Copy link
Contributor

@mts1715 mts1715 commented Feb 25, 2026

Closes: #173

Note: should be reviewed after merging #172
Some of proposed test in task weren't implemented because Flow doesn't have gas-price-based ordering at all.

Summary

Adds mainnet fork tests for liquidation edge cases: partial sequences, multi-collateral seizure, DEX liquidity failures, oracle-deviation circuit breaker, fee accrual over time, and bad debt handling.

Tests added (fork_liquidation_edge_cases.cdc)

  1. testPartialLiquidationSequences

Five positions with distinct collateral types (FLOW, USDF, USDC, WETH, WBTC), each borrowing MOET at health ≈ 1.1. After a FLOW price crash, only the FLOW position becomes unhealthy (health 0.95). A liquidator partially restores it across three sequential calls (seize 10 / repay 20 MOET each), stepping health through 0.967 → 0.985 → 1.005. A fourth call and a second liquidator's attempt both revert once the position is healthy. Verifies that positions backed by unaffected collateral are untouched.

  1. testLiquidateMultiCollateralChooseUSDC

A single position holds FLOW + USDC + WETH as collateral with USDF debt (health ≈ 1.109). After a FLOW crash ($1.00 → $0.75), the position drops to health ≈ 0.935. The liquidator selectively seizes USDC (seize 40 USDC, repay 55 USDF) while FLOW and WETH balances remain untouched. Validates that a liquidator can choose the optimal collateral without disturbing other assets.

  1. testDexLiquidityConstraints

A MockDexSwapper vault is seeded with only 50% of the required repayment tokens (23 USDF instead of 46). The batch DEX liquidation reverts atomically, leaving position state unchanged. After topping up the DEX vault to 53 USDF, the identical parameters succeed. Verifies that insufficient DEX liquidity causes a clean, state-preserving failure rather than a partial execution.

  1. testLiquidationSlippageConstraints

Governance tightens dexOracleDeviationBps from the default 300 bps to 200 bps. Two manual liquidation attempts use the same seize/repay amounts but different DEX price ratios:

Scenario 1 (priceRatio 0.7275, deviation ≈ 309 bps > 200 bps max) → reverts, position unchanged.
Scenario 2 (priceRatio 0.7425, deviation ≈ 101 bps < 200 bps max) → succeeds, post-health ≈ 1.036.
Validates the oracle-deviation guard that prevents liquidators from extracting value at stale DEX prices.

  1. testStabilityAndInsuranceFeeAccrual

Sets a 10% annual fixed interest rate on USDF with 10% stability fee and 10% insurance fee. An LP deposits 5000 USDF; a borrower takes 130 USDF against 200 FLOW. After Test.moveTime(by: ONE_YEAR), debt compounds to ≈ 143.67 USDF (continuous compounding: 130 × e^0.10). A liquidator then partially restores health. Fee collection is verified:

Stability fund receives ≈ 0.705 USDF (debitBalance 67 × (e^0.10 − 1) × 10%)
Insurance fund receives ≈ 0.705 MOET (swapped 1:1 via MockDex)
LP earns ≈ 416.435 USDF credit income (5000 × (e^0.08 − 1) — FixedRate applies creditRate to the full LP deposit, not just outstanding debt)

- Partial liquidation sequences across multi-collateral positions
- Selective collateral seizure in multi-asset positions
- Atomic DEX liquidation failure on insufficient vault liquidity
- Oracle-deviation circuit breaker (slippage > dexOracleDeviationBps reverts)
- Stability and insurance fee accrual over 1 year with continuous compounding
- Bad debt handling: zombie position after complete collateral seizure
@mts1715 mts1715 requested a review from a team as a code owner February 25, 2026 19:48
@mts1715 mts1715 self-assigned this Feb 25, 2026

createAndStorePool(signer: MAINNET_PROTOCOL_ACCOUNT, defaultTokenIdentifier: MAINNET_MOET_TOKEN_ID, beFailed: false)

// Setup pool with real mainnet token prices
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

real mainnet token prices

Kind of a nitpick but I wouldn't say the prices are "real" when we're hard-coding them in a mock oracle 😅

Suggested change
// Setup pool with real mainnet token prices
// Setup pool with plausible mainnet token prices

Comment on lines +449 to +465
// DEX Liquidity Constraints
//
// Scenario: The DEX vault holds only 50% of the debt tokens needed to repay
// the liquidation. A batch DEX liquidation fails atomically, leaving the
// position unchanged and still unhealthy. After topping up the DEX vault,
// the same liquidation parameters succeed.
//
// Position: 200 FLOW @ $1.00 (CF=0.80), borrow 130 USDF
// health = 200*1.0*0.80 / 130 = 160/130 ≈ 1.2308
// FLOW crash: $1.00 -> $0.75
// health = 200*0.75*0.80 / 130 = 120/130 ≈ 0.9231 (unhealthy)
// Liquidation params: seize 55 FLOW, repay 46 USDF
// DEX priceRatio (FLOW->USDF) = 0.75
// seize 55 < repay/ratio = 46/0.75 = 61.33 (passes DEX check)
// post-health = (200-55)*0.75*0.80 / (130-46) = 87/84 ≈ 1.036 (within 1.05 target)
// Scenario 1: DEX vault funded with 23 USDF (50% of 46 needed) -> liquidation reverts
// Scenario 2: top up to 53 USDF (>=46) -> liquidation succeeds
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure where the list of test cases from the issue came from, but I suspect this test case was proposed under the assumption that we have automated liquidation. When automated liquidation is implemented, there will be a code path in FlowALP which swaps using a DEX. When that is true, it will be useful to test that code path while simulating various conditions for the DEX, including liquidity constraints. However, in this test case:

  • The interaction with FlowALP is an invocation of the manualLiquidation function, where we pass in debt repayment funds
  • We happen to get the repayment funds by swapping through a (mock) DEX, but FlowALP doesn't see any of that -- it just gets the funds. Conceptually it's behaviour can't be different depending on where the funds originated.
  • All the DEX interactions before manualLiquidation are using mocks and test-only code. There is no additional production code beyond manualLiquidation that we are validating.

Essentially, I don't think this test case is applicable with the current liquidation logic. Let me know what you think.

}

// =============================================================================
// Stability and Insurance Fee Accrual — 1 year before liquidation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the idea behind this test case? What kinds of interactions between liquidation and fee collection are we trying to validate?

}

// =============================================================================
// Liquidation Slippage Constraints
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test seems to mostly duplicate the coverage area of the tests matching testManualLiquidation_dexOraclePriceDivergence.* in cadence/tests/liquidation_phase1_test.cdc.

}

// =============================================================================
// Bad Debt Handling
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to mostly duplicate the coverage area of testManualLiquidation_reduceHealth in cadence/tests/liquidation_phase1_test.cdc

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Liquidation Edge Cases

2 participants