diff --git a/src/misc/PauseController.sol b/src/misc/PauseController.sol index 54192918..d09ea496 100644 --- a/src/misc/PauseController.sol +++ b/src/misc/PauseController.sol @@ -23,6 +23,11 @@ contract PauseController is OwnableUpgradeable { /// @param component The component that is unpaused. event Unpause(address indexed component); + /// @notice Emitted when a component's pause time is extended. + /// @param component The component that is paused. + /// @param timestamp The new pause expiry timestamp. + event SetPauseExpiry(address indexed component, uint256 timestamp); + /// @notice Emitted when the pause cooldown period of a component is reset. /// @param component The component that has its pause cooldown period reset. event ResetPauseCooldownPeriod(address indexed component); @@ -51,6 +56,9 @@ contract PauseController is OwnableUpgradeable { /// @dev Thrown when the execution of `ScrollOwner` contract fails. error ErrorExecuteUnpauseFailed(); + /// @dev Thrown when the provided pause expiry timestamp is invalid. + error ErrorInvalidPauseExpiry(); + /************* * Constants * *************/ @@ -58,6 +66,9 @@ contract PauseController is OwnableUpgradeable { /// @notice The role for pause controller in `ScrollOwner` contract. bytes32 public constant PAUSE_CONTROLLER_ROLE = keccak256("PAUSE_CONTROLLER_ROLE"); + /// @notice The default pause expiry duration, after which anyone can unpause the component. + uint256 public constant DEFAULT_PAUSE_EXPIRY = 7 days; + /*********************** * Immutable Variables * ***********************/ @@ -75,6 +86,9 @@ contract PauseController is OwnableUpgradeable { /// @notice The last unpause time of each component. mapping(address => uint256) private lastUnpauseTime; + /// @notice The last unpause time of each component. + mapping(address => uint256) private pauseExpiry; + /*************** * Constructor * ***************/ @@ -128,12 +142,21 @@ contract PauseController is OwnableUpgradeable { revert ErrorExecutePauseFailed(); } + uint256 timestamp = block.timestamp + DEFAULT_PAUSE_EXPIRY; + pauseExpiry[address(component)] = timestamp; + emit Pause(address(component)); + emit SetPauseExpiry(address(component), timestamp); } /// @notice Unpause a component. /// @param component The component to unpause. - function unpause(IPausable component) external onlyOwner { + function unpause(IPausable component) external { + // Skip owner check after the pause expiry time + if (pauseExpiry[address(component)] == 0 || pauseExpiry[address(component)] > block.timestamp) { + _checkOwner(); + } + if (!component.paused()) { revert ErrorComponentNotPaused(); } @@ -145,12 +168,13 @@ contract PauseController is OwnableUpgradeable { PAUSE_CONTROLLER_ROLE ); - lastUnpauseTime[address(component)] = block.timestamp; - if (component.paused()) { revert ErrorExecuteUnpauseFailed(); } + lastUnpauseTime[address(component)] = block.timestamp; + pauseExpiry[address(component)] = 0; + emit Unpause(address(component)); } @@ -168,6 +192,36 @@ contract PauseController is OwnableUpgradeable { _updatePauseCooldownPeriod(newPauseCooldownPeriod); } + /// @notice Extend the pause expiry time of a component. + /// @param component The component to pause. + /// @param newTimestamp The new pause expiry timestamp. + function extendPause(IPausable component, uint256 newTimestamp) external onlyOwner { + if (newTimestamp <= block.timestamp || newTimestamp <= pauseExpiry[address(component)]) { + revert ErrorInvalidPauseExpiry(); + } + + // Re-pause if needed, in case there is a race between signing the + // extendPause transaction and the permissionless unpause. + if (!component.paused()) { + ScrollOwner(payable(SCROLL_OWNER)).execute( + address(component), + 0, + abi.encodeWithSelector(IPausable.setPause.selector, true), + PAUSE_CONTROLLER_ROLE + ); + + emit Pause(address(component)); + } + + if (!component.paused()) { + revert ErrorComponentNotPaused(); + } + + pauseExpiry[address(component)] = newTimestamp; + + emit SetPauseExpiry(address(component), newTimestamp); + } + /********************** * Internal Functions * **********************/ diff --git a/src/test/misc/PauseController.t.sol b/src/test/misc/PauseController.t.sol index e7a018b6..8e8f33a5 100644 --- a/src/test/misc/PauseController.t.sol +++ b/src/test/misc/PauseController.t.sol @@ -190,4 +190,76 @@ contract PauseControllerTest is Test { vm.stopPrank(); } + + function test_Unpause_Permissionless() public { + vm.startPrank(owner); + pauseController.pause(mockPausable); + assertTrue(mockPausable.paused()); + uint256 pauseExpiry = block.timestamp + pauseController.DEFAULT_PAUSE_EXPIRY(); + vm.stopPrank(); + + address notOwner = makeAddr("notOwner"); + vm.startPrank(notOwner); + + vm.warp(pauseExpiry - 1); + vm.expectRevert("Ownable: caller is not the owner"); + pauseController.unpause(mockPausable); + assertTrue(mockPausable.paused()); + + vm.warp(pauseExpiry); + pauseController.unpause(mockPausable); + assertFalse(mockPausable.paused()); + + vm.stopPrank(); + } + + function test_Pause_Extend() public { + vm.startPrank(owner); + + pauseController.pause(mockPausable); + assertTrue(mockPausable.paused()); + uint256 pauseExpiry = block.timestamp + pauseController.DEFAULT_PAUSE_EXPIRY(); + + vm.expectRevert(PauseController.ErrorInvalidPauseExpiry.selector); + pauseController.extendPause(mockPausable, pauseExpiry); + + pauseController.extendPause(mockPausable, pauseExpiry + 1 days); + + vm.stopPrank(); + + address notOwner = makeAddr("notOwner"); + vm.startPrank(notOwner); + + vm.warp(pauseExpiry); + vm.expectRevert("Ownable: caller is not the owner"); + pauseController.unpause(mockPausable); + assertTrue(mockPausable.paused()); + + vm.warp(pauseExpiry + 1 days); + pauseController.unpause(mockPausable); + assertFalse(mockPausable.paused()); + + vm.stopPrank(); + } + + function test_Pause_Unpause_Extend() public { + vm.startPrank(owner); + pauseController.pause(mockPausable); + uint256 pauseExpiry = block.timestamp + pauseController.DEFAULT_PAUSE_EXPIRY(); + assertTrue(mockPausable.paused()); + vm.stopPrank(); + + vm.warp(pauseExpiry); + + address notOwner = makeAddr("notOwner"); + vm.startPrank(notOwner); + pauseController.unpause(mockPausable); + assertFalse(mockPausable.paused()); + vm.stopPrank(); + + vm.startPrank(owner); + pauseController.extendPause(mockPausable, pauseExpiry + 1 days); + assertTrue(mockPausable.paused()); // auto re-pause contract + vm.stopPrank(); + } }