Skip to content

Liquidation edge cases#181

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

Liquidation edge cases#181
mts1715 wants to merge 10 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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix e5ef699

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought that automated liquidation will be implemented in the near future, and liquidateViaMockDex should be replaced then.

Copy link
Member

Choose a reason for hiding this comment

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

I would much prefer we just add a TODO for the test cases that can only be meaningfully implemented when automated liquidation is also implemented. IMO, liquidateViaMockDex does not provide any benefit right now, but might make someone in the future skimming through this code think we have tests covering dex liquidation, when we don't.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix 3d93806

}

// =============================================================================
// 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The test is validating the interaction between the interest accrual state machine and fee collection when a liquidation happens mid-way through an accrual period, so fee collection uses post-liquidation debt, not pre-liquidation debt

Copy link
Member

Choose a reason for hiding this comment

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

Thanks, that makes sense. Do you mind adjusting the test name and docs?

Test name to something like testStabilityAndInsuranceFees_notCollectedForLiquidatedFunds

And add something like the following to docs:

Insurance and stability fees are collected periodically, based on the total debit balance at the time of collection.
In practice, this means that these fees are an estimate of the actual debit income (they do not account for states between collections.
This means that, if a debit balance changes substantially between collections, we might over- or under-collect fees.
This test demonstrates this scenario in the case that a liquidation reduces the debit balance prior to a collection.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix b035740

}

// =============================================================================
// 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd agree: this test can be removed

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix e5ef699

}

// =============================================================================
// 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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The core "insolvent position, health-reducing liquidation succeeds" logic is duplicated. The fork test adds two things reduceHealth doesn't have: a second liquidation with seize=0 (voluntary bad-debt repayment), and the constraint that repaying 100% of zero-collateral debt reverts due to UFix128.max post-health.

Maybe those edge cases move to liquidation_phase1_test.cdc instead ?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, do you mind moving those additions to liquidation_phase1_test.cdc?

In general, I'm hoping to only use these separate forked Mainnet tests for scenarios where we are interacting with something deployed on Mainnet (an Oracle, DEX, or other non-mocked dependency). If we are deploying mocks to forked Mainnet and not making any other use of the fork network environment, I think those cases are better served by the basic, local test environment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fix b035740

mts1715 added 2 commits March 17, 2026 16:56
…stManualLiquidation_dexOraclePriceDivergence.* in cadence/tests/liquidation_phase1_test.cdc
@mts1715 mts1715 requested a review from jordanschalm March 17, 2026 15:56
@mts1715 mts1715 requested a review from liobrasil March 23, 2026 20:53
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

3 participants