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