// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.24; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IUSDR, IOracleHub, ISolvencyGuard, ISurplusBuffer} from "./interfaces/IUSDR.sol"; import {CDPVault} from "./CDPVault.sol"; /// @title LiquidationEngine — keeper Dutch auctions, partial/staggered under breaker /// @notice White Paper v3.2 §7 (step 2), §9. Keepers buy collateral from unhealthy /// vaults at a discount, paying USDR which is burned against the debt. /// /// - Dutch pricing: starts at oracle price minus the penalty discount and decays, /// so auctions clear as soon as a keeper is profitable. /// - Circuit breaker active ⇒ liquidations are RATE-LIMITED per window, never frozen /// ("partial and staggered", WP §9), and the un-cleared stress shortfall is /// reported to the SolvencyGuard as CDP_shortfall_risk (WP §4.6). /// - Residual bad debt (collateral exhausted) flows to the SurplusBuffer (WP §7 step 3). contract LiquidationEngine is ReentrancyGuard { error NotDeployer(); error AlreadyFinalized(); error NotTimelock(); error VaultHealthy(); error AuctionNotActive(); error RateLimited(); error ZeroAmount(); uint256 private constant BPS = 10_000; uint256 private constant WAD = 1e18; struct Auction { uint256 vaultId; uint256 startTime; uint256 startPrice; // WAD price per collateral unit at kick bool active; } address public immutable deployer; CDPVault public immutable vault; IUSDR public immutable usdr; IOracleHub public immutable oracle; ISolvencyGuard public guard; ISurplusBuffer public buffer; address public timelock; bool public finalized; // Dutch decay: price falls linearly to floorBps of start over decayDuration uint256 public decayDuration = 2 hours; uint256 public floorBps = 6000; // 60% of start // breaker rate limiting: max debt clearable per asset per window while tripped uint256 public breakerRateLimitWad = 250_000e18; uint256 public rateWindow = 1 hours; mapping(address => uint256) public windowStart; // per collateral asset mapping(address => uint256) public clearedInWindow; // WAD // stress shortfall accounting (aggregate over vaults in active auctions) uint256 public trackedShortfall; mapping(uint256 => uint256) public auctionShortfall; // vaultId => shortfall priced in mapping(uint256 => Auction) public auctions; // vaultId => auction event AuctionKicked(uint256 indexed vaultId, uint256 startPrice, uint256 shortfallPriced); event AuctionTaken(uint256 indexed vaultId, address indexed keeper, uint256 debtCleared, uint256 collateralOut); event BadDebtAbsorbed(uint256 indexed vaultId, uint256 amount); event ParamsSet(uint256 decayDuration, uint256 floorBps, uint256 rateLimit, uint256 rateWindow); constructor(address _vault, address _usdr, address _oracle) { deployer = msg.sender; vault = CDPVault(_vault); usdr = IUSDR(_usdr); oracle = IOracleHub(_oracle); } function setup(address _guard, address _buffer, address _timelock) external { if (msg.sender != deployer) revert NotDeployer(); if (finalized) revert AlreadyFinalized(); guard = ISolvencyGuard(_guard); buffer = ISurplusBuffer(_buffer); timelock = _timelock; finalized = true; } function setParams(uint256 _decay, uint256 _floorBps, uint256 _rateLimit, uint256 _rateWindow) external { if (msg.sender != timelock) revert NotTimelock(); decayDuration = _decay; floorBps = _floorBps; breakerRateLimitWad = _rateLimit; rateWindow = _rateWindow; emit ParamsSet(_decay, _floorBps, _rateLimit, _rateWindow); } /// @notice Permissionless: start an auction on an unhealthy vault. /// Prices the vault's STRESS shortfall into the invariant immediately /// (WP §4.6: "priced across the full circuit-breaker window at full /// assumed drawdown, so the invariant holds even while liquidations /// are paused"). function kick(uint256 vaultId) external nonReentrant { if (vault.isHealthy(vaultId)) revert VaultHealthy(); (,, uint256 collateralAmount, uint256 debt) = _vaultData(vaultId); if (debt == 0 || auctions[vaultId].active) revert AuctionNotActive(); (address collateral,,,) = _vaultMeta(vaultId); (uint256 price,) = oracle.getPrice(collateral); auctions[vaultId] = Auction({vaultId: vaultId, startTime: block.timestamp, startPrice: price, active: true}); // shortfall under stress: debt − stress collateral value (floored at 0) uint256 stressValue = vault.collateralValueWad(vaultId, true); uint256 shortfall = debt > stressValue ? debt - stressValue : 0; auctionShortfall[vaultId] = shortfall; trackedShortfall += shortfall; guard.reportCdpShortfall(trackedShortfall); emit AuctionKicked(vaultId, price, shortfall); collateralAmount; // silence } /// @notice Keeper takes (part of) an auction: pays USDR (burned), receives collateral /// at the current Dutch price minus the liquidation penalty. function take(uint256 vaultId, uint256 maxDebtWad) external nonReentrant { Auction storage a = auctions[vaultId]; if (!a.active) revert AuctionNotActive(); if (maxDebtWad == 0) revert ZeroAmount(); (address collateral, uint256 penaltyBps, uint8 dec,) = _vaultMeta(vaultId); (, bool breaker) = oracle.getPrice(collateral); uint256 debt = vault.vaultDebt(vaultId); uint256 debtToClear = maxDebtWad > debt ? debt : maxDebtWad; // breaker ⇒ partial + staggered (rate-limited, never frozen) — WP §9 if (breaker) { if (block.timestamp >= windowStart[collateral] + rateWindow) { windowStart[collateral] = block.timestamp; clearedInWindow[collateral] = 0; } uint256 room = breakerRateLimitWad > clearedInWindow[collateral] ? breakerRateLimitWad - clearedInWindow[collateral] : 0; if (room == 0) revert RateLimited(); if (debtToClear > room) debtToClear = room; clearedInWindow[collateral] += debtToClear; } // Dutch price: linear decay from startPrice to floorBps of startPrice uint256 price = _currentPrice(a); // keeper discount = liquidation penalty uint256 keeperPrice = price * (BPS - penaltyBps) / BPS; // collateral out (native decimals), rounded DOWN (protocol-favoring) uint256 collateralOut = debtToClear * (10 ** dec) / keeperPrice; // burn keeper's USDR against the debt, seize collateral to keeper usdr.burn(msg.sender, debtToClear); vault.seize(vaultId, debtToClear, collateralOut, msg.sender); _settleAuction(vaultId); emit AuctionTaken(vaultId, msg.sender, debtToClear, collateralOut); } /// @notice After collateral is exhausted, write off residual bad debt into the /// SurplusBuffer (WP §7 step 3). function absorbBadDebt(uint256 vaultId) external nonReentrant { Auction storage a = auctions[vaultId]; if (!a.active) revert AuctionNotActive(); (,, uint256 collateralAmount, uint256 debt) = _vaultData(vaultId); require(collateralAmount == 0 && debt > 0, "not bad debt"); uint256 badDebt = vault.writeOffBadDebt(vaultId); buffer.absorbBadDebt(badDebt); _closeAuction(vaultId); emit BadDebtAbsorbed(vaultId, badDebt); } // -- internals ------------------------------------------------------------------ function _settleAuction(uint256 vaultId) internal { // close the auction when the vault is healthy again or debt is cleared uint256 debt = vault.vaultDebt(vaultId); if (debt == 0 || vault.isHealthy(vaultId)) { _closeAuction(vaultId); } else { // refresh shortfall while the auction remains open uint256 stressValue = vault.collateralValueWad(vaultId, true); uint256 newShortfall = debt > stressValue ? debt - stressValue : 0; trackedShortfall = trackedShortfall - auctionShortfall[vaultId] + newShortfall; auctionShortfall[vaultId] = newShortfall; guard.reportCdpShortfall(trackedShortfall); } } function _closeAuction(uint256 vaultId) internal { trackedShortfall -= auctionShortfall[vaultId]; auctionShortfall[vaultId] = 0; auctions[vaultId].active = false; guard.reportCdpShortfall(trackedShortfall); } function _currentPrice(Auction memory a) internal view returns (uint256) { uint256 elapsed = block.timestamp - a.startTime; if (elapsed >= decayDuration) return a.startPrice * floorBps / BPS; uint256 span = a.startPrice * (BPS - floorBps) / BPS; return a.startPrice - span * elapsed / decayDuration; } function _vaultData(uint256 vaultId) internal view returns (address owner, address collateral, uint256 collateralAmount, uint256 debt) { (owner, collateral, collateralAmount, debt) = vault.vaults(vaultId); } function _vaultMeta(uint256 vaultId) internal view returns (address collateral, uint256 penaltyBps, uint8 dec, uint256 ratioBps) { (, collateral,,) = vault.vaults(vaultId); (, uint256 _ratio,, uint256 _penalty,, uint8 _dec) = vault.configs(collateral); return (collateral, _penalty, _dec, _ratio); } }