|
| 1 | +## Tidal Protocol — Production Liquidation Mechanism (DEX + Keeper + Auto) |
| 2 | + |
| 3 | +### Objectives |
| 4 | +- **Safety**: Permissionless, incentive-aligned liquidations that reliably resolve undercollateralized positions. |
| 5 | +- **Coverage**: Dual paths — a direct repay-for-seize (keeper provides debt tokens) and a protocol-executed DEX path — plus an automatic DEX liquidation scheduler; no auction fallback. |
| 6 | +- **Determinism**: Predictable math, oracle guards, and invariant checks. |
| 7 | +- **Observability**: Rich events and view endpoints for keepers/frontends. |
| 8 | + |
| 9 | +### Current foundations (in contract) |
| 10 | +- **Risk math**: `healthFactor`, `effectiveCollateral`, `effectiveDebt`, per-token factors; `RiskParams` includes `liquidationBonus`. |
| 11 | +- **Position config**: `InternalPosition` has `minHealth`, `targetHealth`, `maxHealth`, optional `topUpSource` and `drawDownSink`. |
| 12 | +- **Oracle connectors**: `DeFiActions.PriceOracle` (Band Oracle impl supports staleness checks). |
| 13 | + |
| 14 | +### Out-of-scope for this phase |
| 15 | +- Multi-oracle medianization (can be added later). |
| 16 | +- Insurance module for bad debt (can be added later, we expose hooks). |
| 17 | + |
| 18 | +## Architecture |
| 19 | + |
| 20 | +### Liquidation paths |
| 21 | +- **Permissionless repay-for-seize (Keeper)**: A keeper repays the borrower’s debt and receives collateral with a bonus. Only the exact amount needed to reach the liquidation target health is used; any excess allowance is ignored. |
| 22 | +- **Protocol-executed DEX**: Protocol seizes collateral, swaps via allowlisted DEX connector into debt asset, repays debt, and returns any remainder appropriately. |
| 23 | + |
| 24 | +### Routing policy (DEX-first with keeper override) |
| 25 | +1) Attempt `topUpSource` pull if present to restore health above the trigger (≥ 1.0). |
| 26 | +2) If still unhealthy and outside warm-up: compute the DEX route and quote the effective collateral-per-debt price. |
| 27 | +3) If a keeper presents an offer that is strictly better than the DEX route (i.e., requires less collateral per unit of debt repaid), route to keeper; otherwise execute via DEX. |
| 28 | +4) Additionally, run an automatic DEX liquidation on a timer (or keeper-triggered automation) so that positions are liquidated even without explicit keeper participation, subject to oracle/DEX deviation guards. |
| 29 | + |
| 30 | +## Governance parameters |
| 31 | +- **Per-token (or global defaults)** |
| 32 | + - **collateralFactor** (exists) |
| 33 | + - **borrowFactor** (exists) |
| 34 | + - **liquidationBonus** (exists; percent added to seize quote) |
| 35 | + - **twapWindowSec**, **maxDeviationBps** (oracle guards) |
| 36 | + - **dustThresholdCredit**, **dustThresholdDebt** |
| 37 | +- **Global** |
| 38 | + - **liquidationTriggerHF** = 1.0e18 (health strictly below this triggers liquidation; constant) |
| 39 | + - **liquidationTargetHF** (e.g., 1.05e18 or 1.10e18; exact health to reach post-liquidation) |
| 40 | + - **dexMaxSlippageBps**, **dexMaxRouteHops** |
| 41 | + - **dexOracleDeviationBps** (max allowed deviation between oracle price and DEX mid/quote) |
| 42 | + - **fees**: `protocolLiquidationFeeBps`, `keeperBountyBps`, `feeSink` |
| 43 | + - **connector allowlists**: `allowedSwappers`, `allowedOracles` (by component ID) |
| 44 | + - **circuit breakers**: `liquidationsPaused`, `liquidationWarmupSec` (warm-up delay after unpause when liquidations remain disabled) |
| 45 | + - **position health spacing** (validation rule): enforce `1.0 < min < target < max` with minimum spacing (e.g., ≥ 5% between bounds) for user-configured thresholds; not used by liquidation. |
| 46 | + |
| 47 | +### Storage additions |
| 48 | +- **`liquidationParams`** struct stored in pool. |
| 49 | +- **Connector allowlists** and **fee sink** capability path. |
| 50 | +- **`lastUnpausedAt: UInt64?`** to compute warm-up window (`liquidationWarmupSec`). |
| 51 | + |
| 52 | +### Events |
| 53 | +- **LiquidationParamsUpdated(poolUUID, targetHF, warmupSec, protocolFeeBps)** |
| 54 | +- **LiquidationsPaused(by)** / **LiquidationsUnpaused(by, warmupEndsAt)** |
| 55 | +- **LiquidationExecuted**(pid, liquidator, debtType, repayAmount, seizeType, seizeAmount, bonusBps, newHF) |
| 56 | +- **LiquidationExecutedViaDex**(pid, seizeType, seized, debtType, repaid, slippageBps, newHF) |
| 57 | +- **AutoLiquidationExecuted**(pid, route, debtType, repaid, seizeType, seized, newHF) |
| 58 | +- **BadDebtWrittenOff**(pid, shortfall) |
| 59 | + |
| 60 | +## Math and quoting (pure helpers) |
| 61 | +- **Eligibility**: `health(view) < liquidationThresholdHF`. |
| 62 | +- **Required repay to reach target**: |
| 63 | + - Compute the exact repay needed to move `health` to `liquidationTargetHF` given current snapshots. |
| 64 | + - If a keeper supplies more than required, only the required amount is used. |
| 65 | +- **Seize for repay** (single collateral type): |
| 66 | + - Let `R = repayTrueAmount` in debt token, `P_d = price(debt)`, `P_c = price(collateral)`, `BF = borrowFactor(debt)`, `CF = collateralFactor(collateral)`, `LB = (1 + liquidationBonus)`. |
| 67 | + - Debt value basis = `(R * P_d) / BF`. |
| 68 | + - Seize amount = `DebtValue * LB / (P_c * CF)`. |
| 69 | +- **Collateral selection**: choose collateral with highest effective value and available balance, unless a hint is provided. |
| 70 | +- **Quote**: `quoteLiquidation(pid, debtType, seizeType?) -> {requiredRepay, seizeType, seizeAmount, newHF, route=Keeper|DEX}` including DEX-vs-keeper price comparison when keeper input is provided. |
| 71 | + |
| 72 | +## Entrypoints (new public API) |
| 73 | +- **View** |
| 74 | + - `isLiquidatable(pid) -> Bool` |
| 75 | + - `quoteLiquidation(pid, debtType, seizeType?) -> Quote` |
| 76 | + - `getLiquidationParams() -> LiquidationParams` |
| 77 | +- **Permissionless actions** |
| 78 | + - `liquidateRepayForSeize(pid, debtType, maxRepayAmount, seizeType, minSeizeAmount)` (requires `maxRepayAmount ≥ requiredRepay`) |
| 79 | + - `autoLiquidate(pid, debtType, seizeTypeHint?, routeParams?)` (DEX path; callable by any keeper/cron executor) |
| 80 | +- **Keeper/governance** |
| 81 | + - `liquidateViaDex(pid, debtType, seizeType, maxSeizeAmount, minRepayAmount, routeParams)` |
| 82 | + - `setLiquidationParams(params)` |
| 83 | + - `setConnectorAllowlists(swappers, oracles)` |
| 84 | + - `setFeeSink(sink)` |
| 85 | + - `pauseLiquidations(flag)` |
| 86 | + |
| 87 | +## Execution flows |
| 88 | + |
| 89 | +### Repay-for-seize (permissionless) |
| 90 | +- Preconditions: liquidations not paused; past warm-up if recently unpaused; fresh indices for involved tokens; oracle staleness/deviation checks incl. DEX-vs-oracle deviation; `health < 1.0e18`. |
| 91 | +- Steps: |
| 92 | + - Compute `requiredRepay` to reach `liquidationTargetHF`. |
| 93 | + - Require `maxRepayAmount ≥ requiredRepay`; transfer exactly `requiredRepay` from keeper; reduce position debt. |
| 94 | + - Compute `seizeAmount` with bonus; withdraw from position’s collateral; transfer to liquidator. |
| 95 | + - Apply fees/bounties from seized amount if configured; emit `LiquidationExecuted`. |
| 96 | +- Postconditions: health increases; no negative balances; dust rules applied. |
| 97 | + |
| 98 | +### Via DEX (protocol executes swap) |
| 99 | +- Preconditions: same as above + swapper allowlisted + slippage bounds + DEX-vs-oracle deviation within `dexOracleDeviationBps`. |
| 100 | +- Steps: |
| 101 | + - Seize up to `maxSeizeAmount` collateral to an internal temporary vault. |
| 102 | + - Swap seized collateral → debt token via `SwapConnectors.Swapper` with `minOut` based on slippage. |
| 103 | + - Repay debt with swap output; handle any leftover collateral (return to position or fees/sink per params). |
| 104 | + - Emit `LiquidationExecutedViaDex`. |
| 105 | +- Security: snapshot → seize → external call → state mutate → emit; never pass borrower’s resources directly out. |
| 106 | + |
| 107 | +### Auto liquidation (DEX timer) |
| 108 | +- A scheduled or keeper-triggered automation repeatedly scans for undercollateralized positions and calls `autoLiquidate` using the DEX path, subject to the same oracle/DEX deviation and warm-up rules. |
| 109 | +- Events: `AutoLiquidationExecuted` per position. |
| 110 | + |
| 111 | +## Oracle safety and indices |
| 112 | +- Use oracle with `staleThreshold`, per-token TWAP window, and `maxDeviationBps` guard vs last snapshot. |
| 113 | +- Additionally enforce `dexOracleDeviationBps`: the DEX spot/TWAP price used for liquidation must be within this deviation vs oracle price, else revert. |
| 114 | +- Accrue interest indices for involved tokens on-demand before quoting/execution. |
| 115 | + |
| 116 | +## Invariants and safety checks |
| 117 | +- Liquidation only when `health < threshold` and not paused. |
| 118 | +- Post-liquidation: `health ≥ pre.health`. |
| 119 | +- Balances never negative; dust thresholds applied. |
| 120 | +- Fees ≤ seized amount; fee sink receives expected amount. |
| 121 | +- Throttles: per-tx close factor, optional per-block liquidation cap, single active auction per pid. |
| 122 | + |
| 123 | +## Scripts and transactions to add |
| 124 | +- Scripts: `quote_liquidation.cdc`, `get_liquidation_params.cdc`. |
| 125 | +- Transactions: `liquidate_repay_for_seize.cdc`, `liquidate_via_dex.cdc`, `auto_liquidate.cdc`. |
| 126 | +- Governance tx: `set_liquidation_params.cdc`, `allow_swapper.cdc`, `pause_liquidations.cdc`. |
| 127 | + |
| 128 | +## Testing strategy |
| 129 | +- Unit (pure math): seize calculation vectors; HF monotonic increase on liquidation; close-factor clamping; rounding bias toward protocol. |
| 130 | +- Scenarios: price drop → repay-for-seize; DEX route vs keeper offer routing; stale oracle rejection; DEX-vs-oracle deviation rejection; warm-up blocking; top-up source prevents liquidation; bad-debt write-off path. |
| 131 | +- Fuzz/property: random portfolios and prices; repeated partial liquidations; assert invariants each step. |
| 132 | + |
| 133 | +## Rollout phases |
| 134 | +- **Phase 1**: Params (incl. warm-up, target HF), view quotes, `liquidateRepayForSeize` + tests/events. |
| 135 | +- **Phase 2**: `liquidateViaDex` with an allowlisted swapper + slippage/deviation guards + tests. |
| 136 | +- **Phase 3**: Auto liquidation scheduler + keeper example + monitoring dashboards. |
| 137 | +- **Phase 4**: Security review, gas profiling, parameter calibration; optional insurance fund integration. |
| 138 | + |
| 139 | +## Open questions |
| 140 | +- Single-asset seize per call vs multi-asset? (recommend single per call) |
| 141 | +- Default `liquidationTargetHF` (1.05 vs 1.10) and warm-up duration defaults |
| 142 | +- Keeper offer format standardization for price comparison (quote units) |
| 143 | +- Reserve/insurance module hookup for bad debt. |
| 144 | + |
| 145 | +## References |
| 146 | +- High-level design (Notion): https://www.notion.so/Liquidation-Mechanism-in-Tidal-23a9c94cfb9c8087bee9d8e99045b3d9 |
| 147 | +- Implementation doc (this branch): https://github.com/onflow/TidalProtocol/blob/feature/liquidation-mechanism/LIQUIDATION_MECHANISM_DESIGN.md |
| 148 | + |
| 149 | +## Liquidation policy (Phase 1) |
| 150 | +- **Target health factor (HF):** `liquidationTargetHF = 1.05e24`. |
| 151 | +- **Trigger condition:** Liquidation is only allowed when current HF < 1.0e24. |
| 152 | +- **Quote behavior (`quoteLiquidation`):** |
| 153 | + - **If feasible:** Return the unique pair `(requiredRepay, seizeAmount)` that moves the position to HF ≈ `liquidationTargetHF` using the minimal necessary repayment and collateral seize. |
| 154 | + - **If infeasible (insolvency):** Return the pair that maximizes HF subject to `seizeAmount ≤ availableCollateral`. If reaching the target is not possible but solvency is, the quote should move HF to ≥ 1.0e24 (as close to target as allowed). If even solvency is not achievable, the quote must strictly improve HF while remaining < 1.0e24. Do not exceed available collateral. |
| 155 | + - **No over-reward:** The quote never recommends seizing more collateral than required by the target (or insolvency boundary). Extra repayment must not increase seized collateral. |
| 156 | + - **Monotonicity:** As price worsens, `requiredRepay` must not decrease. As price improves, `requiredRepay` must not increase (for the same state). |
| 157 | + - **Rounding:** Round conservatively so post-quote execution is not below target due to rounding; small “at or above target” tolerance is acceptable. |
| 158 | +- **Execution behavior (`liquidateRepayForSeize`):** |
| 159 | + - Uses the quote and takes **exactly** `requiredRepay` from the passed-in vault; if more is provided, the excess is returned/refunded to the caller. |
| 160 | + - Sends **exactly** `seizeAmount` collateral to the liquidator; never more. |
| 161 | + - Enforce slippage guards: `maxRepayAmount ≥ requiredRepay` and `minSeizeAmount ≤ seizeAmount`, else revert. |
| 162 | + - Multiple liquidations can occur over time, but each call performs a single exact-to-quote step. No “extra repay for extra seize.” |
| 163 | + |
| 164 | +### Insolvency redemption (borrower path) |
| 165 | +- **Repay-all-and-redeem:** The borrower must always be able to repay all outstanding debt and fully redeem their collateral in one operation, regardless of HF (including when HF < 1.0e24). This closes the position and returns all collateral to the borrower. |
| 166 | +- **Partial borrower repayments:** Borrowers can partially repay debt via normal repay flows; collateral withdrawals remain gated by the health check. The effective collateral-to-debt exchange rate is determined by risk parameters and prices, not by discretionary ratios. |
| 167 | + |
| 168 | +### Typical insolvency scenarios |
| 169 | +- **Missed/late liquidation:** Automation or keepers fail to liquidate promptly after HF dips below 1.0, allowing interest accrual or price drift to deepen undercollateralization. |
| 170 | +- **Sharp price gap:** A sudden oracle price drop (or market gap) pushes HF far below 1.0 faster than liquidation can be executed. |
| 171 | +- **Route guards:** DEX-vs-oracle deviation guard or slippage limits temporarily block the DEX route; HF may worsen until conditions normalize or a keeper route executes. |
| 172 | + |
| 173 | +### Partial-to-above-one policy |
| 174 | +- When `liquidationTargetHF` cannot be reached due to constraints, but HF ≥ 1.0 is reachable, liquidations should proceed to bring HF above 1.0 immediately rather than waiting to hit 1.05 later. Subsequent liquidations can finish the move to target as conditions allow. |
| 175 | + |
| 176 | +## Acceptance criteria |
| 177 | +- **Feasible cases:** After execution, `newHF` is ≥ `liquidationTargetHF - ε` (tiny tolerance for rounding) and ≈ target. |
| 178 | +- **Insolvent cases:** After execution, `newHF` is strictly improved compared to pre-liquidation HF. If the target is unreachable but solvency is, `newHF ≥ 1.0e24`. If even solvency is unreachable, `newHF < 1.0e24` but greater than pre-HF. |
| 179 | +- **No over-repay/over-seize:** Sending a larger vault must not increase `seizeAmount`; the contract only consumes `requiredRepay`. |
| 180 | +- **Slippage respected:** Transactions revert if `maxRepayAmount` < `requiredRepay` or `minSeizeAmount` > `seizeAmount`. |
| 181 | + |
| 182 | +## What needs to be fixed/verified |
| 183 | +- **Config** |
| 184 | + - Verify `liquidationTargetHF` is 1.05e24 and exposed via `get_liquidation_params.cdc`. |
| 185 | +- **Contract** |
| 186 | + - Ensure `quoteLiquidation`: |
| 187 | + - Solves to target when feasible; otherwise returns boundary solution that maximizes HF under `seize ≤ availableCollateral`. |
| 188 | + - Rounds conservatively (post-exec HF not below target when feasible). |
| 189 | + - Respects monotonicity vs price. |
| 190 | + - Ensure `liquidateRepayForSeize`: |
| 191 | + - Consumes exactly `requiredRepay` and seizes exactly `seizeAmount`. |
| 192 | + - Refunds any excess funds passed in. |
| 193 | + - Enforces slippage guards and rejects partial repayments that do not meet the quoted requirement. |
| 194 | +- **Tests** |
| 195 | + - Update insolvency test: |
| 196 | + - Expect `requiredRepay > 0`, `seizeAmount > 0`. |
| 197 | + - Do not require “full seize” by default; instead require `newHF` > pre-HF and not above target; if the scenario is known infeasible, allow `newHF < 1.0e24`. |
| 198 | + - Update multi-liquidation test: |
| 199 | + - Ensure initial price produces HF < 1.0. |
| 200 | + - After one liquidation, assert `newHF` ≥ target (feasible case); if you want multi-step, drop price further and liquidate again. |
| 201 | + - Add an “overpay attempt” test: |
| 202 | + - Pass `maxRepayAmount` > `requiredRepay` and assert actual repay equals `requiredRepay` and `seizeAmount` unchanged. |
| 203 | + - Add a slippage failure test: |
| 204 | + - `maxRepayAmount < requiredRepay` → revert; `minSeizeAmount > seizeAmount` → revert. |
| 205 | + - Add rounding guard test: |
| 206 | + - Feasible case should not end below target due to rounding. |
| 207 | + |
0 commit comments