aere-contracts/contracts/staking/AereSlashingInsurance.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

214 lines
9.5 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title AereSlashingInsurance — delegator slashing-loss coverage vault (native AERE)
* @notice A discretion fund that reimburses a portion of the loss a DELEGATOR
* suffers when a validator they delegated to is slashed. It custodies
* NATIVE AERE only. It is NOT a token: it mints nothing, has no balances
* of its own units, and issues no transferable claim on itself. It simply
* holds native AERE and can pay some of it out, under strict rules, to a
* slashed delegator.
*
* MECHANICS
* 1. Anyone can fund it (permissionless `donate`, or a plain transfer
* caught by `receive`). Funds are sticky.
* 2. Coverage is a 2-step, timelocked process so a single key cannot
* instantly move money:
* a. Foundation calls `proposeCoverage(delegator, slashedAmount,
* evidenceHash)`. Payout is computed at proposal time as
* min(slashedAmount * COVERAGE_BPS / 10000, PER_EVENT_CAP) and
* frozen into the claim. A claim becomes executable only after
* CLAIM_DELAY.
* b. During the delay, ANYONE may `cancelCoverage` a claim they
* believe is fraudulent (e.g. a compromised Foundation key
* proposing a payout to itself). Cancellation is permissionless.
* c. After the delay, `executeCoverage` pays the FROZEN payout to
* the FROZEN delegator address. The recipient cannot be
* redirected after proposal.
* 3. Per-event cap (PER_EVENT_CAP) bounds any single payout; a global
* COOLDOWN_SECONDS between executed coverages bounds the drain RATE.
* 4. NO admin withdrawal. There is no function that lets the Foundation
* (or anyone) pull principal to an arbitrary address. The ONLY outflow
* is `executeCoverage` paying the delegator named in a claim that
* survived the public delay window.
*
* TRUST MODEL (same shape as AereInsuranceFund)
* - Foundation key compromise alone = NO loss (community cancels the
* pending claim during CLAIM_DELAY).
* - Foundation key compromise + an undetected full CLAIM_DELAY window
* = at most PER_EVENT_CAP per COOLDOWN_SECONDS can be drained.
* This is materially weaker than "cannot rug" but materially stronger
* than "can rug at will".
*/
contract AereSlashingInsurance is ReentrancyGuard {
/* ------------------------------- immutable ------------------------------ */
address public immutable FOUNDATION;
uint16 public immutable COVERAGE_BPS; // fraction of a slash covered, <= 10000
uint256 public immutable PER_EVENT_CAP; // max native AERE paid per coverage
uint64 public immutable COOLDOWN_SECONDS; // min seconds between executed coverages
uint64 public immutable CLAIM_DELAY; // timelock between propose and execute
uint16 public constant BPS_DENOM = 10000;
/* --------------------------------- state -------------------------------- */
struct Claim {
bool exists;
address delegator; // frozen recipient
uint256 slashedAmount; // reported loss (informational)
uint256 payout; // frozen native AERE payout
uint64 readyAt; // executable at/after this timestamp
bytes32 evidenceHash; // off-chain evidence pointer (slashing tx / report)
}
mapping(uint256 => Claim) public claims;
uint256 public nextClaimId;
uint256 public totalDonated;
uint256 public totalCovered;
uint64 public lastCoverageAt;
/* --------------------------------- events ------------------------------- */
event Donated(address indexed donor, uint256 amount, uint256 newBalance);
event CoverageProposed(
uint256 indexed claimId, address indexed delegator, uint256 slashedAmount,
uint256 payout, uint64 readyAt, bytes32 evidenceHash
);
event CoverageCancelled(uint256 indexed claimId, address indexed canceller, string reason);
event CoveragePaid(uint256 indexed claimId, address indexed delegator, uint256 payout);
/* --------------------------------- errors ------------------------------- */
error NotFoundation();
error ZeroAddress();
error ZeroAmount();
error BadBps();
error NoClaim();
error TimelockNotElapsed(uint64 readyAt);
error CooldownActive(uint256 readyAt);
error InsufficientBalance(uint256 have, uint256 want);
error PayFailed();
/* ------------------------------- modifiers ------------------------------ */
modifier onlyFoundation() {
if (msg.sender != FOUNDATION) revert NotFoundation();
_;
}
/* ------------------------------ constructor ----------------------------- */
constructor(
address foundation,
uint16 coverageBps,
uint256 perEventCap,
uint64 cooldownSeconds,
uint64 claimDelay
) {
if (foundation == address(0)) revert ZeroAddress();
if (coverageBps == 0 || coverageBps > BPS_DENOM) revert BadBps();
if (perEventCap == 0) revert ZeroAmount();
FOUNDATION = foundation;
COVERAGE_BPS = coverageBps;
PER_EVENT_CAP = perEventCap;
COOLDOWN_SECONDS = cooldownSeconds;
CLAIM_DELAY = claimDelay;
}
/* -------------------------------- funding ------------------------------- */
receive() external payable {
totalDonated += msg.value;
emit Donated(msg.sender, msg.value, address(this).balance);
}
function donate() external payable {
if (msg.value == 0) revert ZeroAmount();
totalDonated += msg.value;
emit Donated(msg.sender, msg.value, address(this).balance);
}
/* ------------------------------- coverage ------------------------------- */
/// @notice Foundation proposes coverage for a slashed delegator. Payout is
/// frozen now and only becomes executable after CLAIM_DELAY, during
/// which anyone may cancel a suspicious claim.
function proposeCoverage(address delegator, uint256 slashedAmount, bytes32 evidenceHash)
external onlyFoundation returns (uint256 claimId)
{
if (delegator == address(0)) revert ZeroAddress();
if (slashedAmount == 0) revert ZeroAmount();
uint256 payout = (slashedAmount * COVERAGE_BPS) / BPS_DENOM;
if (payout > PER_EVENT_CAP) payout = PER_EVENT_CAP;
if (payout == 0) revert ZeroAmount();
uint64 readyAt = uint64(block.timestamp) + CLAIM_DELAY;
claimId = nextClaimId++;
claims[claimId] = Claim({
exists: true,
delegator: delegator,
slashedAmount: slashedAmount,
payout: payout,
readyAt: readyAt,
evidenceHash: evidenceHash
});
emit CoverageProposed(claimId, delegator, slashedAmount, payout, readyAt, evidenceHash);
}
/// @notice Permissionless: cancel a pending claim during its delay window.
/// Lets community observers block a fraudulent proposal before it can
/// be executed. Foundation can re-propose a legitimate claim.
function cancelCoverage(uint256 claimId, string calldata reason) external {
if (!claims[claimId].exists) revert NoClaim();
delete claims[claimId];
emit CoverageCancelled(claimId, msg.sender, reason);
}
/// @notice Execute a matured claim: pay the frozen payout to the frozen
/// delegator. Callable by anyone once the timelock has elapsed (the
/// recipient and amount are fixed, so a third-party trigger cannot
/// redirect funds). Enforces the global cooldown between payouts.
function executeCoverage(uint256 claimId) external nonReentrant {
Claim memory c = claims[claimId];
if (!c.exists) revert NoClaim();
if (block.timestamp < uint256(c.readyAt)) revert TimelockNotElapsed(c.readyAt);
if (lastCoverageAt != 0 && block.timestamp < uint256(lastCoverageAt) + COOLDOWN_SECONDS) {
revert CooldownActive(uint256(lastCoverageAt) + COOLDOWN_SECONDS);
}
uint256 bal = address(this).balance;
if (bal < c.payout) revert InsufficientBalance(bal, c.payout);
// effects before interaction
delete claims[claimId];
totalCovered += c.payout;
lastCoverageAt = uint64(block.timestamp);
(bool ok, ) = payable(c.delegator).call{value: c.payout}("");
if (!ok) revert PayFailed();
emit CoveragePaid(claimId, c.delegator, c.payout);
}
/* ---------------------------------- views ------------------------------- */
/// @notice Preview the frozen payout for a given reported slash amount.
function coverageFor(uint256 slashedAmount) external view returns (uint256 payout) {
payout = (slashedAmount * COVERAGE_BPS) / BPS_DENOM;
if (payout > PER_EVENT_CAP) payout = PER_EVENT_CAP;
}
/// @notice Native AERE currently available to pay coverage.
function available() external view returns (uint256) {
return address(this).balance;
}
/// @notice True if the global cooldown permits an execution right now.
function cooldownReady() external view returns (bool) {
if (lastCoverageAt == 0) return true;
return block.timestamp >= uint256(lastCoverageAt) + COOLDOWN_SECONDS;
}
}