diff --git a/src/misc/IPausable.sol b/src/misc/IPausable.sol new file mode 100644 index 00000000..3e8a644c --- /dev/null +++ b/src/misc/IPausable.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IPausable { + /// @notice Returns true if the contract is paused, and false otherwise. + function paused() external view returns (bool); + + /// @notice Pause or unpause this contract. + /// @param _status Pause this contract if it is true, otherwise unpause this contract. + function setPause(bool _status) external; +} diff --git a/src/misc/PauseController.sol b/src/misc/PauseController.sol new file mode 100644 index 00000000..29e3815f --- /dev/null +++ b/src/misc/PauseController.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import {IPausable} from "./IPausable.sol"; + +import {ScrollOwner} from "./ScrollOwner.sol"; + +/// @title PauseController +/// @notice This contract is used to pause and unpause components in Scroll. +/// @dev The owner of this contract should be `ScrollOwner` contract to allow fine-grained control over the pause and unpause of components. +contract PauseController is OwnableUpgradeable { + /********** + * Events * + **********/ + + /// @notice Emitted when a component is paused. + /// @param component The component that is paused. + event Pause(address indexed component); + + /// @notice Emitted when a component is unpaused. + /// @param component The component that is unpaused. + event Unpause(address indexed component); + + /// @notice Emitted when the pause cooldown period is updated. + /// @param oldPauseCooldownPeriod The old pause cooldown period. + /// @param newPauseCooldownPeriod The new pause cooldown period. + event UpdatePauseCooldownPeriod(uint256 oldPauseCooldownPeriod, uint256 newPauseCooldownPeriod); + + /********** + * Errors * + **********/ + + /// @dev Thrown when the cooldown period is not passed. + error ErrorCooldownPeriodNotPassed(); + + /// @dev Thrown when the component is already paused. + error ErrorComponentAlreadyPaused(); + + /// @dev Thrown when the component is not paused. + error ErrorComponentNotPaused(); + + /// @dev Thrown when the execution of `ScrollOwner` contract fails. + error ErrorExecutePauseFailed(); + + /// @dev Thrown when the execution of `ScrollOwner` contract fails. + error ErrorExecuteUnpauseFailed(); + + /************* + * Constants * + *************/ + + /// @notice The role for pause controller in `ScrollOwner` contract. + bytes32 public constant PAUSE_CONTROLLER_ROLE = keccak256("PAUSE_CONTROLLER_ROLE"); + + /*********************** + * Immutable Variables * + ***********************/ + + /// @notice The address of the ScrollOwner contract. + address public immutable SCROLL_OWNER; + + /********************* + * Storage Variables * + *********************/ + + /// @notice The pause cooldown period. That is the minimum time between two consecutive pauses. + uint256 public pauseCooldownPeriod; + + /// @notice The last unpause time of each component. + mapping(address => uint256) private lastUnpauseTime; + + /*************** + * Constructor * + ***************/ + + constructor(address _scrollOwner) { + SCROLL_OWNER = _scrollOwner; + + _disableInitializers(); + } + + function initialize(uint256 _pauseCooldownPeriod) external initializer { + __Ownable_init(); + + _updatePauseCooldownPeriod(_pauseCooldownPeriod); + } + + /************************* + * Public View Functions * + *************************/ + + /// @notice Get the last unpause timestamp of a component. + /// @param component The component to get the last unpause timestamp. + /// @return The last unpause timestamp of the component. + function getLastUnpauseTime(IPausable component) external view returns (uint256) { + return lastUnpauseTime[address(component)]; + } + + /************************ + * Restricted Functions * + ************************/ + + /// @notice Pause a component. + /// @param component The component to pause. + function pause(IPausable component) external onlyOwner { + if (component.paused()) { + revert ErrorComponentAlreadyPaused(); + } + + if (lastUnpauseTime[address(component)] + pauseCooldownPeriod >= block.timestamp) { + revert ErrorCooldownPeriodNotPassed(); + } + + ScrollOwner(payable(SCROLL_OWNER)).execute( + address(component), + 0, + abi.encodeWithSelector(IPausable.setPause.selector, true), + PAUSE_CONTROLLER_ROLE + ); + + if (!component.paused()) { + revert ErrorExecutePauseFailed(); + } + + emit Pause(address(component)); + } + + /// @notice Unpause a component. + /// @param component The component to unpause. + function unpause(IPausable component) external onlyOwner { + if (!component.paused()) { + revert ErrorComponentNotPaused(); + } + + ScrollOwner(payable(SCROLL_OWNER)).execute( + address(component), + 0, + abi.encodeWithSelector(IPausable.setPause.selector, false), + PAUSE_CONTROLLER_ROLE + ); + + lastUnpauseTime[address(component)] = block.timestamp; + + if (component.paused()) { + revert ErrorExecuteUnpauseFailed(); + } + + emit Unpause(address(component)); + } + + /// @notice Set the pause cooldown period. + /// @param newPauseCooldownPeriod The new pause cooldown period. + function updatePauseCooldownPeriod(uint256 newPauseCooldownPeriod) external onlyOwner { + _updatePauseCooldownPeriod(newPauseCooldownPeriod); + } + + /********************** + * Internal Functions * + **********************/ + + /// @dev Internal function to set the pause cooldown period. + /// @param newPauseCooldownPeriod The new pause cooldown period. + function _updatePauseCooldownPeriod(uint256 newPauseCooldownPeriod) internal { + uint256 oldPauseCooldownPeriod = pauseCooldownPeriod; + pauseCooldownPeriod = newPauseCooldownPeriod; + + emit UpdatePauseCooldownPeriod(oldPauseCooldownPeriod, newPauseCooldownPeriod); + } +} diff --git a/src/test/misc/PauseController.t.sol b/src/test/misc/PauseController.t.sol new file mode 100644 index 00000000..19077537 --- /dev/null +++ b/src/test/misc/PauseController.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; + +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {PauseController} from "../../misc/PauseController.sol"; +import {IPausable} from "../../misc/IPausable.sol"; +import {ScrollOwner} from "../../misc/ScrollOwner.sol"; + +contract MockPausable is IPausable { + bool private _paused; + + function setPause(bool _status) external { + _paused = _status; + } + + function paused() external view returns (bool) { + return _paused; + } +} + +contract PauseControllerTest is Test { + event Pause(address indexed component); + event Unpause(address indexed component); + event UpdatePauseCooldownPeriod(uint256 oldPauseCooldownPeriod, uint256 newPauseCooldownPeriod); + + uint256 public constant PAUSE_COOLDOWN_PERIOD = 1 days; + + ProxyAdmin public admin; + PauseController public pauseController; + MockPausable public mockPausable; + ScrollOwner public scrollOwner; + address public owner; + + function setUp() public { + owner = makeAddr("owner"); + vm.startPrank(owner); + + admin = new ProxyAdmin(); + scrollOwner = new ScrollOwner(); + PauseController impl = new PauseController(address(scrollOwner)); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), + address(admin), + abi.encodeCall(PauseController.initialize, (PAUSE_COOLDOWN_PERIOD)) + ); + pauseController = PauseController(address(proxy)); + mockPausable = new MockPausable(); + + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = IPausable.setPause.selector; + scrollOwner.updateAccess(address(mockPausable), selectors, pauseController.PAUSE_CONTROLLER_ROLE(), true); + scrollOwner.grantRole(pauseController.PAUSE_CONTROLLER_ROLE(), address(pauseController)); + + vm.stopPrank(); + + vm.warp(1e9); + } + + function test_Pause() public { + vm.startPrank(owner); + + vm.expectEmit(true, false, false, true); + emit Pause(address(mockPausable)); + pauseController.pause(mockPausable); + assertTrue(mockPausable.paused()); + + vm.stopPrank(); + } + + function test_Pause_AlreadyPaused() public { + vm.startPrank(owner); + + pauseController.pause(mockPausable); + + vm.expectRevert(PauseController.ErrorComponentAlreadyPaused.selector); + pauseController.pause(mockPausable); + + vm.stopPrank(); + } + + function test_Pause_CooldownPeriodNotPassed() public { + vm.startPrank(owner); + + pauseController.pause(mockPausable); + pauseController.unpause(mockPausable); + uint256 lastUnpauseTime = pauseController.getLastUnpauseTime(mockPausable); + assertEq(lastUnpauseTime, block.timestamp); + + vm.warp(lastUnpauseTime + PAUSE_COOLDOWN_PERIOD - 1); + vm.expectRevert(PauseController.ErrorCooldownPeriodNotPassed.selector); + pauseController.pause(mockPausable); + assertFalse(mockPausable.paused()); + + vm.warp(lastUnpauseTime + PAUSE_COOLDOWN_PERIOD); + vm.expectRevert(PauseController.ErrorCooldownPeriodNotPassed.selector); + pauseController.pause(mockPausable); + assertFalse(mockPausable.paused()); + + vm.warp(lastUnpauseTime + PAUSE_COOLDOWN_PERIOD + 1); + pauseController.pause(mockPausable); + assertTrue(mockPausable.paused()); + + vm.stopPrank(); + } + + function test_Unpause() public { + vm.startPrank(owner); + + pauseController.pause(mockPausable); + + assertEq(pauseController.getLastUnpauseTime(mockPausable), 0); + vm.expectEmit(true, false, false, true); + emit Unpause(address(mockPausable)); + pauseController.unpause(mockPausable); + assertEq(pauseController.getLastUnpauseTime(mockPausable), block.timestamp); + + assertFalse(mockPausable.paused()); + + vm.stopPrank(); + } + + function test_Unpause_NotPaused() public { + vm.startPrank(owner); + + vm.expectRevert(PauseController.ErrorComponentNotPaused.selector); + pauseController.unpause(mockPausable); + + vm.stopPrank(); + } + + function test_UpdatePauseCooldownPeriod() public { + vm.startPrank(owner); + + uint256 newCooldownPeriod = 2 days; + + vm.expectEmit(false, false, false, true); + emit UpdatePauseCooldownPeriod(PAUSE_COOLDOWN_PERIOD, newCooldownPeriod); + pauseController.updatePauseCooldownPeriod(newCooldownPeriod); + + assertEq(pauseController.pauseCooldownPeriod(), newCooldownPeriod); + + vm.stopPrank(); + } + + function test_UpdatePauseCooldownPeriod_NotOwner() public { + address notOwner = makeAddr("notOwner"); + vm.startPrank(notOwner); + + vm.expectRevert("Ownable: caller is not the owner"); + pauseController.updatePauseCooldownPeriod(2 days); + + vm.stopPrank(); + } + + function test_Pause_NotOwner() public { + address notOwner = makeAddr("notOwner"); + vm.startPrank(notOwner); + + vm.expectRevert("Ownable: caller is not the owner"); + pauseController.pause(mockPausable); + + vm.stopPrank(); + } + + function test_Unpause_NotOwner() public { + address notOwner = makeAddr("notOwner"); + vm.startPrank(notOwner); + + vm.expectRevert("Ownable: caller is not the owner"); + pauseController.unpause(mockPausable); + + vm.stopPrank(); + } +}