aere-contracts/contracts/safety/AereDeathSwitch.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

260 lines
11 KiB
Solidity

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