aere-contracts/contracts/lending/AereInsuranceFund.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

235 lines
11 KiB
Solidity

// 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;
}
}