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.
260 lines
11 KiB
Solidity
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]);
|
|
}
|
|
}
|