// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereDeathSwitch — single shared circuit breaker for AERE bridges + DEX * @notice One contract every safety-critical primitive can opt into reading. * When tripped, calls to opted-in contracts revert. Foundation can * arm it manually for declared emergencies; anyone can permissionlessly * trip it when a registered condition oracle reports a breach. * * WHY a shared switch rather than per-contract pause? * - One observable signal off-chain (Foundation pager + Forta bot * can monitor a single `Tripped` event instead of N). * - Bridges that share liquidity can co-fail safely (cross-bridge * arbitrage during a partial pause is the classic exploit * vector — e.g. Multichain July 2023). * - Re-arm policy lives in one place; impossible to forget to * un-pause a sibling contract. * * WHAT trips it: * 1. Foundation calls `armForDeclaredEmergency(reason)` — emergency * declaration. Requires owner; no precondition. * 2. Anyone calls `permissionlessTrip(conditionId)` and the * registered IDeathSwitchCondition.isBreached() returns true. * Conditions are registered immutably at deploy; cannot be * added or removed post-deploy. Examples: * - bridge collateral ratio < 1.0 * - sink burn rate < floor over 24h window * - oracle staleness across all Wave 1 markets * * WHAT happens when tripped: * - `isTripped()` returns true. Opted-in contracts MUST read this * in their state-mutating entry points and revert on true. * - Optionally per-bucket: bridges and DEX may want to revert * independently. `isBucketTripped(bytes32 bucket)` allows * per-vertical kill, but `isTripped()` is the master. * * RE-ARM (post-incident recovery): * - Foundation calls `requestRearm(reason)` to start a 7-day timelock. * - After timelock, Foundation calls `finalizeRearm()` which atomically * clears `tripped`. Anyone observing the chain has 7 days to publish * evidence + escalate before Foundation can resume normal ops. * - The 7-day window is immutable; Foundation cannot shorten it. * * IMMUTABILITY: * - REARM_TIMELOCK constant. * - Condition registry frozen at constructor — cannot add or remove. * - Foundation has ARM authority but no SUPERUSER bypass on rearm. * * GOTCHA — read-only paths: * Opted-in contracts read `isTripped()` in WRITES only. View * functions (balance queries, market state) MUST continue to work * when tripped; the dashboards stay informative during incidents. */ interface IDeathSwitchCondition { /// @notice Returns true when the condition's invariant is breached. function isBreached() external view returns (bool); /// @notice Optional bucket tag (e.g. keccak256("bridge")) to scope the trip. function bucket() external view returns (bytes32); /// @notice Optional human-readable label for the trip event. function label() external view returns (string memory); } contract AereDeathSwitch { /* ================================ config ================================ */ address public immutable FOUNDATION; uint256 public constant REARM_TIMELOCK = 7 days; /// @notice immutable list of registered conditions. Index = conditionId. address[] internal _conditions; /// @notice optional per-condition cached `bucket()` to spare extra calls. mapping(uint256 => bytes32) internal _conditionBucket; /* ================================= state ================================= */ bool public tripped; bytes32 public lastTripReason; // bucket OR keccak256("declared") uint64 public lastTripAt; string public lastTripLabel; /// @notice tripped flag per bucket. Master `tripped` is union of all + manual. mapping(bytes32 => bool) public bucketTripped; /// @notice re-arm request state. uint64 public rearmReadyAt; bytes32 public rearmReason; /* ================================= events ================================= */ event ConditionRegistered(uint256 indexed conditionId, address condition, bytes32 bucket); event Tripped(bytes32 indexed bucketOrDeclared, address indexed by, uint256 indexed conditionId, string label); event RearmRequested(uint64 readyAt, bytes32 reason); event RearmCancelled(bytes32 reason); // AUDIT FIX (MED #37) event Rearmed(address indexed by); /* ================================= errors ================================= */ error NotFoundation(); error AlreadyTripped(); error NotTripped(); error UnknownCondition(); error ConditionNotBreached(); error RearmAlreadyRequested(); error RearmTimelockOpen(uint256 nowTs, uint256 readyAt); error NoRearmRequest(); error ZeroCondition(); /* =============================== modifiers ============================== */ modifier onlyFoundation() { if (msg.sender != FOUNDATION) revert NotFoundation(); _; } /* ============================== constructor ============================= */ /// @param foundation Foundation Ledger address — sole arm authority. /// @param conditions Immutable list of IDeathSwitchCondition addresses. /// Pass [] to deploy with no automatic-trip conditions /// (Foundation manual arming only). constructor(address foundation, address[] memory conditions) { require(foundation != address(0), "zero-foundation"); FOUNDATION = foundation; for (uint256 i = 0; i < conditions.length; i++) { address c = conditions[i]; if (c == address(0)) revert ZeroCondition(); bytes32 b = IDeathSwitchCondition(c).bucket(); _conditions.push(c); _conditionBucket[i] = b; emit ConditionRegistered(i, c, b); } } /* ============================= arm / trip path ============================= */ /// @notice Foundation declares an emergency. No on-chain condition needed. function armForDeclaredEmergency(string calldata reasonLabel) external onlyFoundation { if (tripped) revert AlreadyTripped(); tripped = true; bytes32 reason = keccak256("declared"); lastTripReason = reason; lastTripAt = uint64(block.timestamp); lastTripLabel = reasonLabel; emit Tripped(reason, msg.sender, type(uint256).max, reasonLabel); } /// @notice Permissionless trip when a registered condition is breached. /// Anyone can spend gas to validate + trip. No reward — being a /// Citizen is the reward. function permissionlessTrip(uint256 conditionId) external { if (tripped) revert AlreadyTripped(); if (conditionId >= _conditions.length) revert UnknownCondition(); IDeathSwitchCondition c = IDeathSwitchCondition(_conditions[conditionId]); if (!c.isBreached()) revert ConditionNotBreached(); bytes32 b = _conditionBucket[conditionId]; tripped = true; bucketTripped[b] = true; lastTripReason = b; lastTripAt = uint64(block.timestamp); lastTripLabel = c.label(); emit Tripped(b, msg.sender, conditionId, lastTripLabel); } /* ================================ re-arm ================================ */ /// @notice Foundation requests re-arm. Starts the 7-day timelock. function requestRearm(bytes32 reason) external onlyFoundation { if (!tripped) revert NotTripped(); if (rearmReadyAt != 0) revert RearmAlreadyRequested(); rearmReadyAt = uint64(block.timestamp + REARM_TIMELOCK); rearmReason = reason; emit RearmRequested(rearmReadyAt, reason); } /// @notice Cancel a pending rearm request (e.g. another incident found). /// Foundation only; no timelock needed for cancel. function cancelRearmRequest() external onlyFoundation { if (rearmReadyAt == 0) revert NoRearmRequest(); bytes32 prev = rearmReason; rearmReadyAt = 0; rearmReason = bytes32(0); emit RearmCancelled(prev); // AUDIT FIX (MED #37) } /// AUDIT FIX (HIGH #19): permissionless unarm after 24h when a DECLARED /// emergency stays armed without any registered condition currently /// breached. Prevents compromised Foundation from indefinite halt. uint64 public constant DECLARED_UNARM_DELAY = 24 hours; error NoConditionsBreached(); function permissionlessUnarmDeclared() external { if (!tripped) revert NotTripped(); // Only applies to declared (manual) trips, not condition-tripped. if (lastTripReason != keccak256("declared")) revert ConditionNotBreached(); if (block.timestamp < uint256(lastTripAt) + DECLARED_UNARM_DELAY) { revert RearmTimelockOpen(block.timestamp, uint256(lastTripAt) + DECLARED_UNARM_DELAY); } // Verify no registered condition is currently breached (else manual arm // remains justified). for (uint256 i = 0; i < _conditions.length; i++) { if (IDeathSwitchCondition(_conditions[i]).isBreached()) revert NoConditionsBreached(); } tripped = false; for (uint256 i = 0; i < _conditions.length; i++) { bucketTripped[_conditionBucket[i]] = false; } rearmReadyAt = 0; rearmReason = bytes32(0); emit Rearmed(msg.sender); } /// @notice Finalize the rearm after the timelock has elapsed. Foundation only. /// Clears tripped + all bucket flags + the rearm request. function finalizeRearm() external onlyFoundation { if (rearmReadyAt == 0) revert NoRearmRequest(); if (block.timestamp < rearmReadyAt) revert RearmTimelockOpen(block.timestamp, rearmReadyAt); tripped = false; // Clear only the buckets we know about (the registered conditions). // External callers that have set their OWN bucket must clear it via // their own redeploy. This is intentional: bucket flags persist until // EXPLICITLY cleared so reads of `bucketTripped[x]` after rearm reflect // *post-rearm state*, not stale incident memory. for (uint256 i = 0; i < _conditions.length; i++) { bucketTripped[_conditionBucket[i]] = false; } rearmReadyAt = 0; rearmReason = bytes32(0); emit Rearmed(msg.sender); } /* ================================== views ================================== */ /// @notice Master kill — opted-in contracts read this before mutating state. function isTripped() external view returns (bool) { return tripped; } /// @notice Per-vertical kill. Bridge-side reads "bridge"; DEX reads "dex". function isBucketTripped(bytes32 bucket) external view returns (bool) { return tripped || bucketTripped[bucket]; } function conditionsLength() external view returns (uint256) { return _conditions.length; } function conditionAt(uint256 i) external view returns (address condition, bytes32 bucket) { if (i >= _conditions.length) revert UnknownCondition(); return (_conditions[i], _conditionBucket[i]); } }