// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IAereLendingMarketForFund { function repayOnBehalfOf(address account, uint256 amount) external; function debtBalance(address user) external view returns (uint256); function DEBT_TOKEN() external view returns (address); } /** * @title AereInsuranceFund — sweeps BadDebt from AereLendingMarket * insolvent liquidations * @notice Foundation-discretion fund that absorbs residual debt left on * insolvent borrowers after the lending market's liquidation clamp. * The fund: * 1. Accepts permissionless donations (any ERC-20). * 2. Per-market registration with an immutable lifetime coverage * cap — once the cap is hit, no more coverage for that market * (prevents a single misbehaving market from draining the * entire fund). * 3. Per-market cooldown between coverages — gives governance * time to react if a pattern of insolvent positions emerges * (e.g. oracle attack). * 4. Foundation-only `coverBadDebt` — Foundation evaluates each * BadDebt event off-chain (was it a legit market move, an * oracle exploit, or an attack?) then chooses whether to cover. * 5. NO admin withdrawal. Funds are sticky. The only path out is * `coverBadDebt` calling the market's `repayOnBehalfOf`. * * FOUNDATION CANNOT DIRECT-WITHDRAW. The only way tokens leave this * contract is `coverBadDebt` paying down debt on a REGISTERED market. * However, market registration IS a Foundation-trust operation: a * compromised Foundation multisig could register a malicious * "market" whose `repayOnBehalfOf` impl drains the approved * tokens. We mitigate this with a 7-day REGISTRATION_DELAY between * `proposeMarket` and `registerMarket` — long enough for community * observers + bots to call `cancelMarket` if a registration looks * malicious. Cancellation is permissionless. * * So the real trust model is: * - Foundation key compromise + 7-day undetected delay * = potential drip drain * - Foundation key compromise alone * = NO loss (community cancels the pending registration) * This is materially weaker than "Foundation cannot rug" but * materially stronger than "Foundation can rug anytime". * * FUNDING STREAMS: * - Direct donations (anyone) * - AereSink optional 5% slice (Foundation can route a portion * of buyback-and-burn to this fund instead, via a separate * route — out of scope for this contract) * - Protocol revenue dedications (Bank28 fees, etc.) */ contract AereInsuranceFund is ReentrancyGuard { /* ------------------------------- immutable ------------------------------- */ address public immutable FOUNDATION; uint64 public immutable COOLDOWN_SECONDS; // min seconds between coverages per market uint64 public constant REGISTRATION_DELAY = 7 days; /* --------------------------------- state -------------------------------- */ struct Market { bool registered; uint256 lifetimeCap; // max total coverage for this market over its lifetime uint256 covered; // cumulative coverage paid for this market uint64 lastCoverageAt; } /// @notice market address → config + accounting mapping(address => Market) public markets; /// @notice token → balance held by the fund mapping(address => uint256) public balances; struct PendingRegistration { bool exists; uint256 lifetimeCap; uint64 readyAt; } /// @notice market → pending registration (proposed but not yet executed) mapping(address => PendingRegistration) public pending; /* --------------------------------- events ------------------------------- */ event MarketProposed(address indexed market, uint256 lifetimeCap, uint64 readyAt); event MarketRegistered(address indexed market, uint256 lifetimeCap); event MarketCancelled(address indexed market, address indexed canceller, string reason); event Donated(address indexed donor, address indexed token, uint256 amount); event BadDebtCovered(address indexed market, address indexed borrower, uint256 amount); /* --------------------------------- errors ------------------------------- */ error NotFoundation(); error AlreadyRegistered(); error NoPendingRegistration(); error TimelockNotElapsed(uint64 readyAt); error UnknownMarket(); error CapExceeded(uint256 wouldBe, uint256 cap); error CooldownActive(uint256 readyAt); error InsufficientBalance(uint256 have, uint256 want); error ZeroAddress(); error ZeroAmount(); error TransferFailed(); error NoDebtToCover(); /* ------------------------------ modifiers ------------------------------- */ modifier onlyFoundation() { if (msg.sender != FOUNDATION) revert NotFoundation(); _; } /* ----------------------------- constructor ------------------------------ */ constructor(address foundation, uint64 cooldownSeconds) { if (foundation == address(0)) revert ZeroAddress(); FOUNDATION = foundation; COOLDOWN_SECONDS = cooldownSeconds; } /* ---------------------------- market registry --------------------------- */ /// @notice H4 fix: 2-step registration with 7-day delay. function proposeMarket(address market, uint256 lifetimeCap) external onlyFoundation { if (market == address(0)) revert ZeroAddress(); if (markets[market].registered) revert AlreadyRegistered(); uint64 readyAt = uint64(block.timestamp + REGISTRATION_DELAY); pending[market] = PendingRegistration({ exists: true, lifetimeCap: lifetimeCap, readyAt: readyAt }); emit MarketProposed(market, lifetimeCap, readyAt); } /// @notice Execute a pending registration after timelock elapsed. /// Foundation OR anyone may call (Foundation will normally /// do it; permissionless lets anyone unblock if Foundation /// stalls). function registerMarket(address market) external { PendingRegistration memory p = pending[market]; if (!p.exists) revert NoPendingRegistration(); if (block.timestamp < uint256(p.readyAt)) revert TimelockNotElapsed(p.readyAt); if (markets[market].registered) revert AlreadyRegistered(); markets[market] = Market({ registered: true, lifetimeCap: p.lifetimeCap, covered: 0, lastCoverageAt: 0 }); delete pending[market]; emit MarketRegistered(market, p.lifetimeCap); } /// @notice Permissionless: cancel a pending registration. Use case: /// community observers spot a malicious / wrong-address /// registration during the 7-day delay and abort it before /// it can be executed. Foundation can re-propose if it was /// a false-positive cancellation. function cancelPendingMarket(address market, string calldata reason) external { if (!pending[market].exists) revert NoPendingRegistration(); delete pending[market]; emit MarketCancelled(market, msg.sender, reason); } /* -------------------------------- funding ------------------------------- */ function donate(address token, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); if (token == address(0)) revert ZeroAddress(); if (!IERC20(token).transferFrom(msg.sender, address(this), amount)) revert TransferFailed(); balances[token] += amount; emit Donated(msg.sender, token, amount); } /* ------------------------------ coverage -------------------------------- */ /// @notice Foundation-only: clear `amount` of `borrower`'s debt on /// `market` by calling `market.repayOnBehalfOf`. Enforces /// lifetime cap + cooldown. Reverts if borrower has no debt. function coverBadDebt(address market, address borrower, uint256 amount) external onlyFoundation nonReentrant { Market storage m = markets[market]; if (!m.registered) revert UnknownMarket(); if (amount == 0) revert ZeroAmount(); if (borrower == address(0)) revert ZeroAddress(); if (m.lastCoverageAt != 0 && block.timestamp < uint256(m.lastCoverageAt) + COOLDOWN_SECONDS) { revert CooldownActive(uint256(m.lastCoverageAt) + COOLDOWN_SECONDS); } // Verify the borrower actually has outstanding debt (defensive — protects // the fund from being asked to "cover" a healthy account by mistake) and // clamp the request down to what is actually owed. uint256 owed = IAereLendingMarketForFund(market).debtBalance(borrower); if (owed == 0) revert NoDebtToCover(); if (amount > owed) amount = owed; // Cap check runs on the CLAMPED amount (what will really be paid), not the // pre-clamp request. Otherwise a legit coverage that clamps under the // remaining cap would be spuriously blocked. Semantics are unchanged: // cumulative lifetime coverage per market still stays <= lifetimeCap. if (m.covered + amount > m.lifetimeCap) { revert CapExceeded(m.covered + amount, m.lifetimeCap); } address debtToken = IAereLendingMarketForFund(market).DEBT_TOKEN(); if (balances[debtToken] < amount) revert InsufficientBalance(balances[debtToken], amount); balances[debtToken] -= amount; // Approve the market to pull tokens via repayOnBehalfOf, then call. IERC20(debtToken).approve(market, 0); IERC20(debtToken).approve(market, amount); IAereLendingMarketForFund(market).repayOnBehalfOf(borrower, amount); m.covered += amount; m.lastCoverageAt = uint64(block.timestamp); emit BadDebtCovered(market, borrower, amount); } /* ---------------------------------- views ------------------------------- */ function coverageRemaining(address market) external view returns (uint256) { Market memory m = markets[market]; if (!m.registered) return 0; return m.covered >= m.lifetimeCap ? 0 : m.lifetimeCap - m.covered; } function cooldownReady(address market) external view returns (bool) { Market memory m = markets[market]; if (!m.registered) return false; if (m.lastCoverageAt == 0) return true; return block.timestamp >= uint256(m.lastCoverageAt) + COOLDOWN_SECONDS; } }