From 07fb29bc9c652efef43709a1dde23a9e395303fe Mon Sep 17 00:00:00 2001 From: flexocode442 Date: Mon, 3 Aug 2026 22:22:44 +0000 Subject: [PATCH] feat: implement emergency pause & circuit breaker framework (#301) Add multi-level protocol pause with governance-controlled recovery: [ADD] contracts/governance/EmergencyController.sol - 4-level pause: Normal (0), HighRisk (1), Financial (2), Shutdown (3) - Role-based access: EMERGENCY_COUNCIL, DAO_GOVERNANCE, TIMELOCK_CONTROLLER with per-level authorisation matrix - Emergency Council can pause but CANNOT unpause (separation of powers) - DAO Governance required for lifting any pause - Timelock controller cooldown enforcement (1 hour default) - Staged recovery procedure (3 sequential steps) - On-chain audit trail with EmergencyRecord history - isOperationAllowed() for protocol modules to query pause state - AccessControlEnumerable for role enumeration [ADD] contracts/governance/EmergencyProtected.sol - Abstract whenNotPaused modifier for protocol contracts - Fail-safe: assumes paused if EmergencyController call reverts [ADD] test/EmergencyController.t.sol - 21 unit tests covering: initialisation, activation at all levels, authorisation enforcement, timelock cooldown, pause lifting, recovery flow, audit trail, read interface, and events --- contracts/governance/EmergencyController.sol | 409 +++++++++++++++++++ contracts/governance/EmergencyProtected.sol | 53 +++ test/EmergencyController.t.sol | 264 ++++++++++++ 3 files changed, 726 insertions(+) create mode 100644 contracts/governance/EmergencyController.sol create mode 100644 contracts/governance/EmergencyProtected.sol create mode 100644 test/EmergencyController.t.sol diff --git a/contracts/governance/EmergencyController.sol b/contracts/governance/EmergencyController.sol new file mode 100644 index 0000000..44c2a24 --- /dev/null +++ b/contracts/governance/EmergencyController.sol @@ -0,0 +1,409 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/** + * @title EmergencyController + * @notice Emergency Pause & Circuit Breaker Framework for TruthBounty Protocol + * @dev Implements multi-level protocol pause with governance-controlled recovery. + * + * ## Pause Levels + * + * | Level | Name | Effect | + * |-------|----------|--------| + * | 0 | Normal | Full protocol operation | + * | 1 | HighRisk | Pause new claim creation, staking, verification submission. Read-only + governance still active. | + * | 2 | Financial| Pause reward distribution, treasury transfers, withdrawals. Read-only + governance still active. | + * | 3 | Shutdown | Global emergency shutdown. Only governance recovery operations remain. | + * + * ## Roles + * + * | Role | Can Activate | Can Lift | Notes | + * |----------------------|-------------|----------|-------| + * | EMERGENCY_COUNCIL | Yes (L1-L3) | No | Rapid response — cannot unilaterally lift | + * | DAO_GOVERNANCE | Yes (L1-L2) | Yes | Full governance control | + * | TIMELOCK_CONTROLLER | Yes (L1) | No | Narrow scope, time-delayed | + * + * ## Security Properties + * + * - Emergency Council can pause but CANNOT unpause (separation of powers) + * - DAO Governance is required for recovery (no unilateral unpause) + * - All emergency actions emit immutable audit events + * - Protected functions query this contract's pause state + * - Read operations remain available at all levels + */ +contract EmergencyController is AccessControlEnumerable, ReentrancyGuard { + // ─── Custom Errors ──────────────────────────────────────────────── + error NotAuthorizedForLevel(address caller, uint8 currentLevel); + error InvalidPauseLevel(uint8 level); + error AlreadyAtLevel(uint8 level); + error CannotLiftBelowCurrent(uint8 current, uint8 attempted); + error ProtocolNotPaused(); + error RecoveryNotComplete(); + error InvalidRecoveryStep(uint8 step); + error ZeroAddress(); + error NoChangeRequested(); + + // ─── Constants ──────────────────────────────────────────────────── + + /// @notice Normal operation — no restrictions + uint8 public constant LEVEL_NORMAL = 0; + /// @notice Pause high-risk operations (claims, staking, verification) + uint8 public constant LEVEL_HIGH_RISK = 1; + /// @notice Pause financial operations (rewards, treasury, withdrawals) + uint8 public constant LEVEL_FINANCIAL = 2; + /// @notice Global emergency shutdown + uint8 public constant LEVEL_SHUTDOWN = 3; + + uint16 public constant EVENT_SCHEMA_VERSION = 1; + uint8 public constant MAX_PAUSE_LEVEL = 3; + + // ─── Roles ──────────────────────────────────────────────────────── + + /// @notice Can activate any pause level (rapid response) + bytes32 public constant EMERGENCY_COUNCIL = keccak256("EMERGENCY_COUNCIL"); + /// @notice Can activate L1-L2 and lift any pause (full governance) + bytes32 public constant DAO_GOVERNANCE = keccak256("DAO_GOVERNANCE"); + /// @notice Can activate L1 only with time delay (narrow scope) + bytes32 public constant TIMELOCK_CONTROLLER = keccak256("TIMELOCK_CONTROLLER"); + /// @notice Can execute recovery steps after pause is lifted + bytes32 public constant RECOVERY_EXECUTOR = keccak256("RECOVERY_EXECUTOR"); + + // ─── State ──────────────────────────────────────────────────────── + + /// @notice Current pause level (0 = normal) + uint8 public currentPauseLevel = LEVEL_NORMAL; + + /// @notice Whether recovery procedure has been completed after the last pause + bool public recoveryComplete = true; + + /// @notice Timestamp of the most recent pause activation + uint256 public lastPauseTimestamp; + + /// @notice Timestamp when the current pause was lifted (0 if still active) + uint256 public lastLiftTimestamp; + + /// @notice Timelock controller's cooldown between activations + uint256 public timelockCooldown = 1 hours; + + /// @notice Last time the timelock controller activated a pause + uint256 public lastTimelockActivation; + + // ─── Audit Trail ────────────────────────────────────────────────── + + struct EmergencyRecord { + uint8 level; + uint256 timestamp; + address initiator; + string reason; + bytes32 proposalRef; + uint256 recoveryTimestamp; + } + + /// @notice All emergency actions, indexed chronologically + EmergencyRecord[] public emergencyHistory; + + // ─── Events ─────────────────────────────────────────────────────── + + event EmergencyPauseActivated( + uint8 indexed level, + address indexed executor, + string reason, + bytes32 indexed proposalRef + ); + + event EmergencyPauseLifted( + uint8 indexed previousLevel, + address indexed executor, + bytes32 indexed proposalRef + ); + + event EmergencyActionRecorded(bytes32 indexed actionId); + + event RecoveryStepCompleted( + uint8 indexed step, + address indexed executor, + string description + ); + + event RecoveryFinalised(address indexed executor, uint256 timestamp); + + // ─── Constructor ────────────────────────────────────────────────── + + /** + * @param emergencyCouncil Address authorised for rapid emergency response + * @param daoGovernance Address of the DAO governance contract or multisig + * @param timelockController Address of the timelock controller + */ + constructor( + address emergencyCouncil, + address daoGovernance, + address timelockController + ) { + if (emergencyCouncil == address(0)) revert ZeroAddress(); + if (daoGovernance == address(0)) revert ZeroAddress(); + if (timelockController == address(0)) revert ZeroAddress(); + + _grantRole(DEFAULT_ADMIN_ROLE, daoGovernance); + _grantRole(EMERGENCY_COUNCIL, emergencyCouncil); + _grantRole(DAO_GOVERNANCE, daoGovernance); + _grantRole(TIMELOCK_CONTROLLER, timelockController); + _grantRole(RECOVERY_EXECUTOR, daoGovernance); + } + + // ─── Pause Activation ───────────────────────────────────────────── + + /** + * @notice Activate an emergency pause at the specified level. + * @dev Can only increase the pause level (not decrease). Use `liftPause` to lower. + * @param level The pause level to activate (must be > current level) + * @param reason Human-readable reason for the pause (stored on-chain) + * @param proposalRef Optional governance proposal reference (bytes32(0) if none) + */ + function activatePause( + uint8 level, + string calldata reason, + bytes32 proposalRef + ) external nonReentrant { + if (level > MAX_PAUSE_LEVEL) revert InvalidPauseLevel(level); + if (level <= currentPauseLevel) revert AlreadyAtLevel(currentPauseLevel); + if (level == LEVEL_NORMAL) revert InvalidPauseLevel(level); + + // Authorisation check based on level + if (level == LEVEL_SHUTDOWN) { + // Only EMERGENCY_COUNCIL or DAO_GOVERNANCE can trigger full shutdown + if ( + !hasRole(EMERGENCY_COUNCIL, msg.sender) && + !hasRole(DAO_GOVERNANCE, msg.sender) + ) revert NotAuthorizedForLevel(msg.sender, level); + } else if (level == LEVEL_FINANCIAL) { + if ( + !hasRole(EMERGENCY_COUNCIL, msg.sender) && + !hasRole(DAO_GOVERNANCE, msg.sender) + ) revert NotAuthorizedForLevel(msg.sender, level); + } else if (level == LEVEL_HIGH_RISK) { + // All three roles can activate L1 + if (hasRole(TIMELOCK_CONTROLLER, msg.sender)) { + // Timelock cooldown enforcement + if (block.timestamp < lastTimelockActivation + timelockCooldown) { + revert("Timelock cooldown not elapsed"); + } + lastTimelockActivation = block.timestamp; + } else if ( + !hasRole(EMERGENCY_COUNCIL, msg.sender) && + !hasRole(DAO_GOVERNANCE, msg.sender) + ) { + revert NotAuthorizedForLevel(msg.sender, level); + } + } + + currentPauseLevel = level; + lastPauseTimestamp = block.timestamp; + lastLiftTimestamp = 0; + recoveryComplete = false; + + emergencyHistory.push( + EmergencyRecord({ + level: level, + timestamp: block.timestamp, + initiator: msg.sender, + reason: reason, + proposalRef: proposalRef, + recoveryTimestamp: 0 + }) + ); + + bytes32 actionId = keccak256( + abi.encode(level, msg.sender, reason, block.timestamp) + ); + + emit EmergencyPauseActivated(level, msg.sender, reason, proposalRef); + emit EmergencyActionRecorded(actionId); + } + + // ─── Pause Lifting ───────────────────────────────────────────────── + + /** + * @notice Lift the emergency pause entirely, returning to LEVEL_NORMAL. + * @dev Only DAO_GOVERNANCE can lift a pause. Emergency Council cannot unilaterally lift. + * @param proposalRef Governance proposal reference authorising the lift + */ + function liftPause(bytes32 proposalRef) external nonReentrant { + if (currentPauseLevel == LEVEL_NORMAL) revert ProtocolNotPaused(); + + // Only DAO governance can lift — Emergency Council CANNOT + if (!hasRole(DAO_GOVERNANCE, msg.sender)) { + revert("Only DAO governance can lift pause"); + } + + uint8 previousLevel = currentPauseLevel; + currentPauseLevel = LEVEL_NORMAL; + lastLiftTimestamp = block.timestamp; + recoveryComplete = false; // Recovery must be performed after lifting + + // Update the last emergency record with recovery timestamp + if (emergencyHistory.length > 0) { + emergencyHistory[emergencyHistory.length - 1].recoveryTimestamp = block.timestamp; + } + + emit EmergencyPauseLifted(previousLevel, msg.sender, proposalRef); + emit EmergencyActionRecorded( + keccak256(abi.encode("lift", previousLevel, msg.sender, block.timestamp)) + ); + } + + // ─── Recovery Procedure ──────────────────────────────────────────── + + uint8 public recoveryStep = 0; + uint8 public constant MAX_RECOVERY_STEP = 3; + + /** + * @notice Complete a step of the staged recovery procedure. + * @dev Recovery must be performed sequentially (step 1 → 2 → 3). + * Protocol must be at LEVEL_NORMAL before recovery begins. + * @param description Description of the recovery action taken + */ + function completeRecoveryStep(string calldata description) external { + if (currentPauseLevel != LEVEL_NORMAL) revert("Protocol is still paused"); + if (recoveryComplete) revert("Recovery already complete"); + if (!hasRole(RECOVERY_EXECUTOR, msg.sender)) { + revert("Not authorised for recovery"); + } + + uint8 nextStep = recoveryStep + 1; + if (nextStep > MAX_RECOVERY_STEP) revert InvalidRecoveryStep(nextStep); + + recoveryStep = nextStep; + + emit RecoveryStepCompleted(nextStep, msg.sender, description); + + // Finalise after last step + if (recoveryStep == MAX_RECOVERY_STEP) { + recoveryComplete = true; + recoveryStep = 0; + emit RecoveryFinalised(msg.sender, block.timestamp); + } + } + + // ─── Read Interface ──────────────────────────────────────────────── + + /** + * @notice Check if a specific operation type is currently allowed. + * @dev Called by protocol modules before executing restricted operations. + * @param operationType The operation category to check + * @return True if the operation is allowed at the current pause level + */ + function isOperationAllowed(bytes32 operationType) external view returns (bool) { + uint8 level = currentPauseLevel; + + if (level == LEVEL_NORMAL) return true; + if (level == LEVEL_SHUTDOWN) { + // Only governance recovery operations are allowed at shutdown + return operationType == keccak256("governance_recovery"); + } + + if (level == LEVEL_FINANCIAL) { + // Financial operations are blocked at L2+ + if ( + operationType == keccak256("reward_distribution") || + operationType == keccak256("treasury_transfer") || + operationType == keccak256("withdrawal") + ) return false; + } + + if (level >= LEVEL_HIGH_RISK) { + // High-risk operations are blocked at L1+ + if ( + operationType == keccak256("claim_creation") || + operationType == keccak256("staking") || + operationType == keccak256("verification_submission") + ) return false; + } + + // Read operations and governance are always allowed + return true; + } + + /** + * @notice Returns the current pause level. + */ + function getPauseLevel() external view returns (uint8) { + return currentPauseLevel; + } + + /** + * @notice Returns the number of emergency actions in the audit trail. + */ + function getEmergencyHistoryCount() external view returns (uint256) { + return emergencyHistory.length; + } + + /** + * @notice Returns a page of emergency history records. + * @param start Index to start from + * @param count Maximum number of records to return + */ + function getEmergencyHistory( + uint256 start, + uint256 count + ) external view returns (EmergencyRecord[] memory) { + uint256 end = start + count; + if (end > emergencyHistory.length) { + end = emergencyHistory.length; + } + if (start >= end) return new EmergencyRecord[](0); + + EmergencyRecord[] memory page = new EmergencyRecord[](end - start); + for (uint256 i = start; i < end; i++) { + page[i - start] = emergencyHistory[i]; + } + return page; + } + + /** + * @notice Returns recovery status details. + */ + function getRecoveryStatus() + external + view + returns ( + bool isComplete, + uint8 currentStep, + bool isPaused, + uint8 pauseLevel + ) + { + return (recoveryComplete, recoveryStep, currentPauseLevel != LEVEL_NORMAL, currentPauseLevel); + } + + /** + * @notice Returns the addresses authorised for each emergency role. + */ + function getAuthorisedRoles() + external + view + returns ( + uint256 emergencyCouncilCount, + uint256 daoGovernanceCount, + uint256 timelockControllerCount + ) + { + return ( + getRoleMemberCount(EMERGENCY_COUNCIL), + getRoleMemberCount(DAO_GOVERNANCE), + getRoleMemberCount(TIMELOCK_CONTROLLER) + ); + } + + // ─── Admin ───────────────────────────────────────────────────────── + + /** + * @notice Update the timelock controller's cooldown period. + * @dev Only DAO_GOVERNANCE can change this. + */ + function setTimelockCooldown(uint256 newCooldown) external onlyRole(DAO_GOVERNANCE) { + timelockCooldown = newCooldown; + } +} diff --git a/contracts/governance/EmergencyProtected.sol b/contracts/governance/EmergencyProtected.sol new file mode 100644 index 0000000..caa687a --- /dev/null +++ b/contracts/governance/EmergencyProtected.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/** + * @title EmergencyProtected + * @notice Abstract contract providing a `whenNotPaused` modifier that queries + * the EmergencyController for the current pause level. + * @dev Protocol modules should inherit this contract and apply the modifier + * to restricted functions. The EmergencyController address is set once + * during initialisation. + * + * Usage: + * contract ClaimRegistry is EmergencyProtected { + * function createClaim(...) external whenNotPaused(keccak256("claim_creation")) { + * // ... + * } + * } + */ +abstract contract EmergencyProtected { + /// @notice The EmergencyController that owns the pause state + address public emergencyController; + + error EmergencyControllerNotSet(); + error OperationPaused(bytes32 operationType, uint8 pauseLevel); + + /** + * @notice Initialise the emergency controller reference. + * @param _controller Address of the deployed EmergencyController + */ + function _setEmergencyController(address _controller) internal { + emergencyController = _controller; + } + + /** + * @notice Reverts if the given operation type is paused. + * @param operationType The operation to check (e.g. keccak256("claim_creation")) + */ + modifier whenNotPaused(bytes32 operationType) { + if (emergencyController == address(0)) revert EmergencyControllerNotSet(); + (bool success, bytes memory data) = emergencyController.staticcall( + abi.encodeWithSignature("isOperationAllowed(bytes32)", operationType) + ); + if (success && data.length >= 32) { + bool allowed = abi.decode(data, (bool)); + if (!allowed) revert OperationPaused(operationType, 0); + } + // If the call fails, assume paused (fail-safe) + else { + revert OperationPaused(operationType, 0); + } + _; + } +} diff --git a/test/EmergencyController.t.sol b/test/EmergencyController.t.sol new file mode 100644 index 0000000..f2f0a37 --- /dev/null +++ b/test/EmergencyController.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test, console} from "forge-std/Test.sol"; +import "../contracts/governance/EmergencyController.sol"; + +/** + * @title EmergencyControllerTest + * @notice Unit tests for the Emergency Pause & Circuit Breaker Framework + */ +contract EmergencyControllerTest is Test { + EmergencyController public controller; + + address public emergencyCouncil = makeAddr("emergencyCouncil"); + address public daoGovernance = makeAddr("daoGovernance"); + address public timelockController = makeAddr("timelockController"); + address public unauthorisedUser = makeAddr("unauthorisedUser"); + address public recoveryExecutor = makeAddr("recoveryExecutor"); + + function setUp() public { + controller = new EmergencyController( + emergencyCouncil, + daoGovernance, + timelockController + ); + vm.prank(daoGovernance); + controller.grantRole(controller.RECOVERY_EXECUTOR(), recoveryExecutor); + } + + // ─── Initialisation ─────────────────────────────────────────────── + + function test_initialState() public view { + assertEq(controller.currentPauseLevel(), controller.LEVEL_NORMAL()); + assertEq(controller.recoveryComplete(), true); + assertEq(controller.getEmergencyHistoryCount(), 0); + } + + function test_constructor_revertsZeroAddress() public { + vm.expectRevert(EmergencyController.ZeroAddress.selector); + new EmergencyController(address(0), daoGovernance, timelockController); + } + + // ─── Pause Activation ───────────────────────────────────────────── + + function test_emergencyCouncil_canActivateLevel1() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Security incident", bytes32(0)); + assertEq(controller.currentPauseLevel(), controller.LEVEL_HIGH_RISK()); + } + + function test_emergencyCouncil_canActivateLevel3() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_SHUTDOWN(), "Critical exploit", bytes32(0)); + assertEq(controller.currentPauseLevel(), controller.LEVEL_SHUTDOWN()); + } + + function test_daoGovernance_canActivateLevel2() public { + vm.prank(daoGovernance); + controller.activatePause(controller.LEVEL_FINANCIAL(), "Oracle failure", bytes32(0)); + assertEq(controller.currentPauseLevel(), controller.LEVEL_FINANCIAL()); + } + + function test_timelock_canActivateLevel1() public { + vm.prank(timelockController); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Scheduled maintenance", bytes32(0)); + assertEq(controller.currentPauseLevel(), controller.LEVEL_HIGH_RISK()); + } + + function test_timelock_cannotActivateLevel2() public { + vm.prank(timelockController); + vm.expectRevert( + abi.encodeWithSelector( + EmergencyController.NotAuthorizedForLevel.selector, + timelockController, + controller.LEVEL_FINANCIAL() + ) + ); + controller.activatePause(controller.LEVEL_FINANCIAL(), "Not allowed", bytes32(0)); + } + + function test_unauthorised_cannotActivate() public { + vm.prank(unauthorisedUser); + vm.expectRevert( + abi.encodeWithSelector( + EmergencyController.NotAuthorizedForLevel.selector, + unauthorisedUser, + controller.LEVEL_HIGH_RISK() + ) + ); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Hack attempt", bytes32(0)); + } + + function test_cannotActivateSameOrLowerLevel() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "First", bytes32(0)); + + vm.prank(emergencyCouncil); + vm.expectRevert( + abi.encodeWithSelector( + EmergencyController.AlreadyAtLevel.selector, + controller.LEVEL_HIGH_RISK() + ) + ); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Duplicate", bytes32(0)); + } + + function test_cannotActivateLevel0() public { + vm.prank(emergencyCouncil); + vm.expectRevert( + abi.encodeWithSelector(EmergencyController.InvalidPauseLevel.selector, 0) + ); + controller.activatePause(0, "Invalid", bytes32(0)); + } + + function test_cannotActivateAboveMaxLevel() public { + vm.prank(emergencyCouncil); + vm.expectRevert( + abi.encodeWithSelector(EmergencyController.InvalidPauseLevel.selector, 99) + ); + controller.activatePause(99, "Invalid", bytes32(0)); + } + + function test_timelockCooldown_enforced() public { + vm.prank(timelockController); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "First", bytes32(0)); + + // Lift via governance + vm.prank(daoGovernance); + controller.liftPause(bytes32(0)); + + // Timelock tries again immediately — should fail + vm.prank(timelockController); + vm.expectRevert("Timelock cooldown not elapsed"); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Too soon", bytes32(0)); + + // After cooldown + vm.warp(block.timestamp + controller.timelockCooldown() + 1); + vm.prank(timelockController); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "After cooldown", bytes32(0)); + assertEq(controller.currentPauseLevel(), controller.LEVEL_HIGH_RISK()); + } + + // ─── Pause Lifting ───────────────────────────────────────────────── + + function test_daoGovernance_canLiftPause() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Test", bytes32(0)); + + vm.prank(daoGovernance); + controller.liftPause(bytes32(0)); + + assertEq(controller.currentPauseLevel(), controller.LEVEL_NORMAL()); + } + + function test_emergencyCouncil_cannotLiftPause() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Test", bytes32(0)); + + vm.prank(emergencyCouncil); + vm.expectRevert("Only DAO governance can lift pause"); + controller.liftPause(bytes32(0)); + } + + function test_cannotLiftWhenNotPaused() public { + vm.prank(daoGovernance); + vm.expectRevert(EmergencyController.ProtocolNotPaused.selector); + controller.liftPause(bytes32(0)); + } + + // ─── Recovery ───────────────────────────────────────────────────── + + function test_recoveryFlow_completes() public { + // Activate and lift + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Test", bytes32(0)); + vm.prank(daoGovernance); + controller.liftPause(bytes32(0)); + + // Complete recovery steps + vm.startPrank(recoveryExecutor); + controller.completeRecoveryStep("Validation complete"); + controller.completeRecoveryStep("State verified"); + controller.completeRecoveryStep("All systems operational"); + vm.stopPrank(); + + (bool complete, uint8 step, bool paused, uint8 level) = controller.getRecoveryStatus(); + assertTrue(complete); + assertEq(step, 0); + assertFalse(paused); + } + + function test_recovery_mustBePaused() public { + vm.prank(recoveryExecutor); + vm.expectRevert("Recovery already complete"); + controller.completeRecoveryStep("Should fail"); + } + + // ─── Audit Trail ────────────────────────────────────────────────── + + function test_auditTrail_recordsActions() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "First incident", bytes32(0)); + + assertEq(controller.getEmergencyHistoryCount(), 1); + + EmergencyController.EmergencyRecord[] memory history = controller.getEmergencyHistory(0, 10); + assertEq(history[0].level, controller.LEVEL_HIGH_RISK()); + assertEq(history[0].initiator, emergencyCouncil); + assertEq(history[0].reason, "First incident"); + } + + // ─── Read Interface ─────────────────────────────────────────────── + + function test_isOperationAllowed_normalState() public view { + assertTrue(controller.isOperationAllowed(keccak256("claim_creation"))); + assertTrue(controller.isOperationAllowed(keccak256("reward_distribution"))); + } + + function test_isOperationAllowed_level1_blocksHighRisk() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Test", bytes32(0)); + + assertFalse(controller.isOperationAllowed(keccak256("claim_creation"))); + assertFalse(controller.isOperationAllowed(keccak256("staking"))); + assertTrue(controller.isOperationAllowed(keccak256("reward_distribution"))); + } + + function test_isOperationAllowed_level3_onlyGovernance() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_SHUTDOWN(), "Critical", bytes32(0)); + + assertFalse(controller.isOperationAllowed(keccak256("claim_creation"))); + assertFalse(controller.isOperationAllowed(keccak256("reward_distribution"))); + assertTrue(controller.isOperationAllowed(keccak256("governance_recovery"))); + } + + // ─── Events ─────────────────────────────────────────────────────── + + function test_emitsEmergencyPauseActivated() public { + vm.prank(emergencyCouncil); + vm.expectEmit(true, true, true, true); + emit EmergencyController.EmergencyPauseActivated( + controller.LEVEL_HIGH_RISK(), + emergencyCouncil, + "Test reason", + bytes32(0) + ); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Test reason", bytes32(0)); + } + + function test_emitsEmergencyPauseLifted() public { + vm.prank(emergencyCouncil); + controller.activatePause(controller.LEVEL_HIGH_RISK(), "Test", bytes32(0)); + + vm.prank(daoGovernance); + vm.expectEmit(true, true, true, true); + emit EmergencyController.EmergencyPauseLifted( + controller.LEVEL_HIGH_RISK(), + daoGovernance, + bytes32(0) + ); + controller.liftPause(bytes32(0)); + } +}