Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
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 owner account (an externally owned account today, not a multisig contract) 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 (partner application 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;
|
|
}
|
|
}
|