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.
220 lines
10 KiB
Solidity
220 lines
10 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
|
|
/**
|
|
* @title AereSlashingInsuranceV2 — delegator slashing-loss coverage vault (native AERE)
|
|
* @notice Bug-fix redeploy of AereSlashingInsurance. Same discretion-fund model:
|
|
* reimburses a portion of the loss a DELEGATOR suffers when a validator
|
|
* they delegated to is slashed. 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.
|
|
*
|
|
* ─────────────────────────── WHY V2 ───────────────────────────
|
|
* V1 (0xAd0109A880444519DDbdbFDF3561C624787591bC) let cancelCoverage run
|
|
* at ANY time while a claim existed, including AFTER the timelock matured.
|
|
* A griefer could therefore front-run every executeCoverage of a MATURED,
|
|
* legitimate claim with cancelCoverage, deleting it before it could pay.
|
|
* Repeated on each re-proposal, this permanently blocked the payout.
|
|
*
|
|
* V2 FIX: cancelCoverage is only allowed while block.timestamp < readyAt.
|
|
* Once a claim MATURES (block.timestamp >= readyAt) it can no longer be
|
|
* cancelled — only executed. So the moment a claim survives its full
|
|
* CLAIM_DELAY window, the payout is locked in and a griefer can no longer
|
|
* delete it. The community-veto property is preserved: during the delay
|
|
* window cancel remains permissionless, so anyone can still stop a
|
|
* fraudulent proposal from a compromised Foundation key BEFORE it matures.
|
|
*
|
|
* DESIGN NOTE / RESIDUAL RISK. Cancel is intentionally kept PERMISSIONLESS
|
|
* during the pre-maturity window. That is the contract's headline safety
|
|
* property (a compromised Foundation key alone cannot pay itself, because
|
|
* the community can cancel during the delay). The unavoidable trade-off is
|
|
* that a griefer can still cancel a legitimate claim DURING its window,
|
|
* forcing the Foundation to re-propose (cheap for the Foundation, gas-cost
|
|
* to the griefer each time). What V2 eliminates is the PERMANENT block: a
|
|
* matured claim can no longer be cancelled, so a claim that clears one
|
|
* full window always becomes executable and stays executable. Restricting
|
|
* cancel to the Foundation/proposer would remove the community veto and was
|
|
* deliberately NOT done.
|
|
*
|
|
* Everything else is identical to V1: Foundation-only proposeCoverage,
|
|
* payout frozen at proposal (min(slashed*bps, cap)), per-event cap, global
|
|
* cooldown, and NO admin withdrawal of principal.
|
|
*/
|
|
contract AereSlashingInsuranceV2 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 ClaimMatured(uint64 readyAt); // V2: cancel attempted at/after 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 only.
|
|
/// V2 FIX: reverts (ClaimMatured) once block.timestamp >= readyAt, so a
|
|
/// matured claim can never be cancelled — only executed. This closes the
|
|
/// griefing hole where a matured, legitimate payout could be front-run
|
|
/// and deleted indefinitely. Before maturity, cancel stays permissionless
|
|
/// so the community can still veto a fraudulent proposal (compromised
|
|
/// Foundation key). Foundation can re-propose a legitimate claim.
|
|
function cancelCoverage(uint256 claimId, string calldata reason) external {
|
|
Claim storage c = claims[claimId];
|
|
if (!c.exists) revert NoClaim();
|
|
if (block.timestamp >= uint256(c.readyAt)) revert ClaimMatured(c.readyAt);
|
|
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;
|
|
}
|
|
}
|