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.
235 lines
11 KiB
Solidity
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;
|
|
}
|
|
}
|