diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index e336eb03..61a78958 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -266,14 +266,19 @@ contract DualGovernance is IDualGovernance { // Reseal executor // --- - function resealSealables(address[] memory sealables) external { + function resealSealable(address sealable) external { if (msg.sender != _resealCommittee) { revert NotResealCommittee(msg.sender); } if (_stateMachine.getCurrentState() == State.Normal) { revert ResealIsNotAllowedInNormalState(); } - RESEAL_MANAGER.reseal(sealables); + RESEAL_MANAGER.reseal(sealable); + } + + function setResealCommittee(address resealCommittee) external { + _checkSenderIsAdminExecutor(); + _resealCommittee = resealCommittee; } // --- diff --git a/contracts/ResealManager.sol b/contracts/ResealManager.sol index efac9bd1..6bbfe9f6 100644 --- a/contracts/ResealManager.sol +++ b/contracts/ResealManager.sol @@ -7,6 +7,8 @@ import {ISealable} from "./interfaces/ISealable.sol"; import {ITimelock} from "./interfaces/ITimelock.sol"; import {IResealManager} from "./interfaces/IResealManager.sol"; +/// @title ResealManager +/// @dev Allows to extend pause of temporarily paused contracts to permanent pause or resume it. contract ResealManager is IResealManager { error SealableWrongPauseState(); error SenderIsNotGovernance(); @@ -15,24 +17,39 @@ contract ResealManager is IResealManager { uint256 public constant PAUSE_INFINITELY = type(uint256).max; ITimelock public immutable EMERGENCY_PROTECTED_TIMELOCK; + /// @notice Initializes the ResealManager contract. + /// @param emergencyProtectedTimelock The address of the EmergencyProtectedTimelock contract. constructor(ITimelock emergencyProtectedTimelock) { - EMERGENCY_PROTECTED_TIMELOCK = emergencyProtectedTimelock; + EMERGENCY_PROTECTED_TIMELOCK = ITimelock(emergencyProtectedTimelock); } - function reseal(address[] memory sealables) public { + /// @notice Extends the pause of the specified sealable contract. + /// @dev Works only if conditions are met: + /// - ResealManager has PAUSE_ROLE for target contract; + /// - Contract are paused until timestamp after current timestamp and not for infinite time; + /// - The DAO governance is blocked by DualGovernance; + /// - Function is called by the governance contract. + /// @param sealable The address of the sealable contract. + function reseal(address sealable) public { _checkSenderIsGovernance(); - for (uint256 i = 0; i < sealables.length; ++i) { - uint256 sealableResumeSinceTimestamp = ISealable(sealables[i]).getResumeSinceTimestamp(); - if (sealableResumeSinceTimestamp < block.timestamp || sealableResumeSinceTimestamp == PAUSE_INFINITELY) { - revert SealableWrongPauseState(); - } - Address.functionCall(sealables[i], abi.encodeWithSelector(ISealable.resume.selector)); - Address.functionCall(sealables[i], abi.encodeWithSelector(ISealable.pauseFor.selector, PAUSE_INFINITELY)); + + uint256 sealableResumeSinceTimestamp = ISealable(sealable).getResumeSinceTimestamp(); + if (sealableResumeSinceTimestamp < block.timestamp || sealableResumeSinceTimestamp == PAUSE_INFINITELY) { + revert SealableWrongPauseState(); } + Address.functionCall(sealable, abi.encodeWithSelector(ISealable.resume.selector)); + Address.functionCall(sealable, abi.encodeWithSelector(ISealable.pauseFor.selector, PAUSE_INFINITELY)); } + /// @notice Resumes the specified sealable contract if it is scheduled to resume in the future. + /// @dev Works only if conditions are met: + /// - ResealManager has RESUME_ROLE for target contract; + /// - Contract are paused until timestamp after current timestamp; + /// - Function is called by the governance contract. + /// @param sealable The address of the sealable contract. function resume(address sealable) public { _checkSenderIsGovernance(); + uint256 sealableResumeSinceTimestamp = ISealable(sealable).getResumeSinceTimestamp(); if (sealableResumeSinceTimestamp < block.timestamp) { revert SealableWrongPauseState(); @@ -40,6 +57,8 @@ contract ResealManager is IResealManager { Address.functionCall(sealable, abi.encodeWithSelector(ISealable.resume.selector)); } + /// @notice Ensures that the function can only be called by the governance address. + /// @dev Reverts if the sender is not the governance address. function _checkSenderIsGovernance() internal view { address governance = EMERGENCY_PROTECTED_TIMELOCK.getGovernance(); if (msg.sender != governance) { diff --git a/contracts/committees/EmergencyActivationCommittee.sol b/contracts/committees/EmergencyActivationCommittee.sol index b73724d2..c1441e9b 100644 --- a/contracts/committees/EmergencyActivationCommittee.sol +++ b/contracts/committees/EmergencyActivationCommittee.sol @@ -2,11 +2,9 @@ pragma solidity 0.8.26; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; -import {HashConsensus} from "./HashConsensus.sol"; -interface IEmergencyProtectedTimelock { - function emergencyActivate() external; -} +import {HashConsensus} from "./HashConsensus.sol"; +import {ITimelock} from "../interfaces/ITimelock.sol"; /// @title Emergency Activation Committee Contract /// @notice This contract allows a committee to approve and execute an emergency activation @@ -27,7 +25,7 @@ contract EmergencyActivationCommittee is HashConsensus { /// @notice Approves the emergency activation by casting a vote /// @dev Only callable by committee members - function approveEmergencyActivate() public { + function approveActivateEmergencyMode() public { _checkSenderIsMember(); _vote(EMERGENCY_ACTIVATION_HASH, true); } @@ -36,7 +34,7 @@ contract EmergencyActivationCommittee is HashConsensus { /// @return support The number of votes in support of the activation /// @return execuitionQuorum The required number of votes for execution /// @return isExecuted Whether the activation has been executed - function getEmergencyActivateState() + function getActivateEmergencyModeState() public view returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) @@ -46,10 +44,10 @@ contract EmergencyActivationCommittee is HashConsensus { /// @notice Executes the emergency activation if the quorum is reached /// @dev Calls the emergencyActivate function on the Emergency Protected Timelock contract - function executeEmergencyActivate() external { + function executeActivateEmergencyMode() external { _markUsed(EMERGENCY_ACTIVATION_HASH); Address.functionCall( - EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyActivate.selector) + EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(ITimelock.activateEmergencyMode.selector) ); } } diff --git a/contracts/committees/EmergencyExecutionCommittee.sol b/contracts/committees/EmergencyExecutionCommittee.sol index d52c09f1..f715b3b8 100644 --- a/contracts/committees/EmergencyExecutionCommittee.sol +++ b/contracts/committees/EmergencyExecutionCommittee.sol @@ -4,11 +4,7 @@ pragma solidity 0.8.26; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {HashConsensus} from "./HashConsensus.sol"; import {ProposalsList} from "./ProposalsList.sol"; - -interface IEmergencyProtectedTimelock { - function emergencyExecute(uint256 proposalId) external; - function emergencyReset() external; -} +import {ITimelock} from "../interfaces/ITimelock.sol"; enum ProposalType { EmergencyExecute, @@ -65,8 +61,7 @@ contract EmergencyExecutionCommittee is HashConsensus, ProposalsList { (, bytes32 key) = _encodeEmergencyExecute(proposalId); _markUsed(key); Address.functionCall( - EMERGENCY_PROTECTED_TIMELOCK, - abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyExecute.selector, proposalId) + EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(ITimelock.emergencyExecute.selector, proposalId) ); } @@ -113,9 +108,7 @@ contract EmergencyExecutionCommittee is HashConsensus, ProposalsList { function executeEmergencyReset() external { bytes32 proposalKey = _encodeEmergencyResetProposalKey(); _markUsed(proposalKey); - Address.functionCall( - EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyReset.selector) - ); + Address.functionCall(EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(ITimelock.emergencyReset.selector)); } /// @notice Encodes the proposal key for an emergency reset diff --git a/contracts/committees/ResealCommittee.sol b/contracts/committees/ResealCommittee.sol index 5284c9b2..8cc8490e 100644 --- a/contracts/committees/ResealCommittee.sol +++ b/contracts/committees/ResealCommittee.sol @@ -6,7 +6,7 @@ import {HashConsensus} from "./HashConsensus.sol"; import {ProposalsList} from "./ProposalsList.sol"; interface IDualGovernance { - function reseal(address[] memory sealables) external; + function resealSealable(address sealables) external; } /// @title Reseal Committee Contract @@ -28,52 +28,52 @@ contract ResealCommittee is HashConsensus, ProposalsList { } /// @notice Votes on a reseal proposal - /// @dev Allows committee members to vote on resealing a set of addresses - /// @param sealables The addresses to reseal + /// @dev Allows committee members to vote on resealing a sealed address + /// @param sealable The address to reseal /// @param support Indicates whether the member supports the proposal - function voteReseal(address[] memory sealables, bool support) public { + function voteReseal(address sealable, bool support) public { _checkSenderIsMember(); - (bytes memory proposalData, bytes32 key) = _encodeResealProposal(sealables); + (bytes memory proposalData, bytes32 key) = _encodeResealProposal(sealable); _vote(key, support); _pushProposal(key, 0, proposalData); } /// @notice Gets the current state of a reseal proposal - /// @dev Retrieves the state of the reseal proposal for a set of addresses - /// @param sealables The addresses for the reseal proposal + /// @dev Retrieves the state of the reseal proposal for a sealed address + /// @param sealable The addresses for the reseal proposal /// @return support The number of votes in support of the proposal /// @return execuitionQuorum The required number of votes for execution /// @return isExecuted Whether the proposal has been executed - function getResealState(address[] memory sealables) + function getResealState(address sealable) public view returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) { - (, bytes32 key) = _encodeResealProposal(sealables); + (, bytes32 key) = _encodeResealProposal(sealable); return _getHashState(key); } /// @notice Executes an approved reseal proposal /// @dev Executes the reseal proposal by calling the reseal function on the Dual Governance contract - /// @param sealables The addresses to reseal - function executeReseal(address[] memory sealables) external { - (, bytes32 key) = _encodeResealProposal(sealables); + /// @param sealable The address to reseal + function executeReseal(address sealable) external { + (, bytes32 key) = _encodeResealProposal(sealable); _markUsed(key); - Address.functionCall(DUAL_GOVERNANCE, abi.encodeWithSelector(IDualGovernance.reseal.selector, sealables)); + Address.functionCall(DUAL_GOVERNANCE, abi.encodeWithSelector(IDualGovernance.resealSealable.selector, sealable)); - bytes32 resealNonceHash = keccak256(abi.encode(sealables)); + bytes32 resealNonceHash = keccak256(abi.encode(sealable)); _resealNonces[resealNonceHash]++; } /// @notice Encodes a reseal proposal /// @dev Internal function to encode the proposal data and generate the proposal key - /// @param sealables The addresses to reseal + /// @param sealable The address to reseal /// @return data The encoded proposal data /// @return key The generated proposal key - function _encodeResealProposal(address[] memory sealables) internal view returns (bytes memory data, bytes32 key) { - bytes32 resealNonceHash = keccak256(abi.encode(sealables)); - data = abi.encode(sealables, _resealNonces[resealNonceHash]); + function _encodeResealProposal(address sealable) internal view returns (bytes memory data, bytes32 key) { + bytes32 resealNonceHash = keccak256(abi.encode(sealable)); + data = abi.encode(sealable, _resealNonces[resealNonceHash]); key = keccak256(data); } } diff --git a/contracts/interfaces/IResealManager.sol b/contracts/interfaces/IResealManager.sol index bea725ef..844bd8e1 100644 --- a/contracts/interfaces/IResealManager.sol +++ b/contracts/interfaces/IResealManager.sol @@ -3,5 +3,5 @@ pragma solidity 0.8.26; interface IResealManager { function resume(address sealable) external; - function reseal(address[] memory sealables) external; + function reseal(address sealable) external; } diff --git a/contracts/interfaces/ITimelock.sol b/contracts/interfaces/ITimelock.sol index 6f452a6a..f3c192f0 100644 --- a/contracts/interfaces/ITimelock.sol +++ b/contracts/interfaces/ITimelock.sol @@ -24,7 +24,6 @@ interface ITimelock { function canSchedule(uint256 proposalId) external view returns (bool); function canExecute(uint256 proposalId) external view returns (bool); - function getGovernance() external view returns (address); function getAdminExecutor() external view returns (address); function getProposal(uint256 proposalId) external view returns (Proposal memory proposal); @@ -32,4 +31,11 @@ interface ITimelock { external view returns (uint256 id, ProposalStatus status, address executor, Timestamp submittedAt, Timestamp scheduledAt); + + function getGovernance() external view returns (address); + function setGovernance(address governance) external; + + function activateEmergencyMode() external; + function emergencyExecute(uint256 proposalId) external; + function emergencyReset() external; } diff --git a/docs/plan-b.md b/docs/plan-b.md index adc88c1a..1cd93be9 100644 --- a/docs/plan-b.md +++ b/docs/plan-b.md @@ -333,27 +333,27 @@ Initializes the contract with an owner, committee members, a quorum, and the add #### Preconditions - `executionQuorum` MUST be greater than 0. -### Function: `EmergencyActivationCommittee.approveEmergencyActivate` +### Function: `EmergencyActivationCommittee.approveActivateEmergencyMode` ```solidity -function approveEmergencyActivate() public +function approveActivateEmergencyMode() public onlyMember ``` Approves the emergency activation by voting on the `EMERGENCY_ACTIVATION_HASH`. #### Preconditions - MUST be called by a committee member. -### Function: `EmergencyActivationCommittee.getEmergencyActivateState` +### Function: `EmergencyActivationCommittee.getActivateEmergencyModeState` ```solidity -function getEmergencyActivateState() +function getActivateEmergencyModeState() public view returns (uint256 support, uint256 executionQuorum, bool isExecuted) ``` Returns the state of the emergency activation proposal, including the support count, quorum, and execution status. -### Function: `EmergencyActivationCommittee.executeEmergencyActivate` +### Function: `EmergencyActivationCommittee.executeActivateEmergencyMode` ```solidity -function executeEmergencyActivate() external +function executeActivateEmergencyMode() external ``` Executes the emergency activation by calling the `emergencyActivate` function on the `EmergencyProtectedTimelock` contract. diff --git a/docs/specification.md b/docs/specification.md index 421d1111..ca5ee1f4 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -417,15 +417,15 @@ Initializes the contract with the address of the EmergencyProtectedTimelock cont ### Function ResealManager.reseal ```solidity -function reseal(address[] memory sealables) public +function reseal(address sealable) public ``` -Extends the pause of the specified `sealables` contracts. This function can be called by the governance address defined in the `EmergencyProtectedTimelock`. +Extends the pause of the specified `sealable` contract. This function can be called by the governance address defined in the `EmergencyProtectedTimelock`. #### Preconditions -- The `ResealManager` MUST have `PAUSE_ROLE` and `RESUME_ROLE` for the target contracts. -- The target contracts MUST be paused until a future timestamp and not indefinitely. +- The `ResealManager` MUST have `PAUSE_ROLE` and `RESUME_ROLE` for the target contract. +- The target contract MUST be paused until a future timestamp and not indefinitely. - The function MUST be called by the governance address defined in `EmergencyProtectedTimelock`. #### Errors @@ -1318,10 +1318,10 @@ executionQuorum MUST be greater than 0. emergencyProtectedTimelock MUST be a valid address. -### Function: EmergencyActivationCommittee.approveEmergencyActivate +### Function: EmergencyActivationCommittee.approveActivateEmergencyMode ```solidity -function approveEmergencyActivate() public +function approveActivateEmergencyMode() public ``` Approves the emergency activation by voting on the EMERGENCY_ACTIVATION_HASH. @@ -1330,10 +1330,10 @@ Approves the emergency activation by voting on the EMERGENCY_ACTIVATION_HASH. * MUST be called by a member. -### Function: EmergencyActivationCommittee.getEmergencyActivateState +### Function: EmergencyActivationCommittee.getActivateEmergencyModeState ```solidity -function getEmergencyActivateState() +function getActivateEmergencyModeState() public view returns (uint256 support, uint256 executionQuorum, bool isExecuted) @@ -1341,10 +1341,10 @@ function getEmergencyActivateState() Returns the state of the emergency activation proposal including support count, quorum, and execution status. -### Function: EmergencyActivationCommittee.executeEmergencyActivate +### Function: EmergencyActivationCommittee.executeActivateEmergencyMode ```solidity -function executeEmergencyActivate() external +function executeActivateEmergencyMode() external ``` Executes the emergency activation by calling the emergencyActivate function on the EmergencyProtectedTimelock contract. diff --git a/test/unit/mocks/TimelockMock.sol b/test/mocks/TimelockMock.sol similarity index 87% rename from test/unit/mocks/TimelockMock.sol rename to test/mocks/TimelockMock.sol index 9ed3c8da..314caed3 100644 --- a/test/unit/mocks/TimelockMock.sol +++ b/test/mocks/TimelockMock.sol @@ -22,6 +22,8 @@ contract TimelockMock is ITimelock { uint256 public lastCancelledProposalId; + address internal governance; + function submit(address, ExternalCall[] calldata) external returns (uint256 newProposalId) { newProposalId = submittedProposals.length + OFFSET; submittedProposals.push(newProposalId); @@ -77,6 +79,26 @@ contract TimelockMock is ITimelock { revert("Not Implemented"); } + function setGovernance(address newGovernance) external { + governance = newGovernance; + } + + function getGovernance() external view returns (address) { + return governance; + } + + function emergencyReset() external { + revert("Not Implemented"); + } + + function emergencyExecute(uint256 proposalId) external { + revert("Not Implemented"); + } + + function activateEmergencyMode() external { + revert("Not Implemented"); + } + function getProposalInfo(uint256 proposalId) external view @@ -85,10 +107,6 @@ contract TimelockMock is ITimelock { revert("Not Implemented"); } - function getGovernance() external view returns (address) { - revert("Not Implemented"); - } - function getAdminExecutor() external view returns (address) { return _ADMIN_EXECUTOR; } diff --git a/test/scenario/emergency-committee.t.sol b/test/scenario/emergency-committee.t.sol new file mode 100644 index 00000000..c1962a37 --- /dev/null +++ b/test/scenario/emergency-committee.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {EmergencyProtectedTimelock} from "contracts/EmergencyProtectedTimelock.sol"; +import {PercentsD16} from "contracts/types/PercentD16.sol"; + +import {IPotentiallyDangerousContract} from "../utils/interfaces/IPotentiallyDangerousContract.sol"; +import {ScenarioTestBlueprint, ExternalCall, ExternalCallHelpers} from "../utils/scenario-test-blueprint.sol"; +import {DAO_AGENT} from "../utils/mainnet-addresses.sol"; + +contract EmergencyCommitteeTest is ScenarioTestBlueprint { + address internal immutable _VETOER = makeAddr("VETOER"); + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + + function setUp() external { + _deployDualGovernanceSetup({isEmergencyProtectionEnabled: true}); + _setupStETHBalance(_VETOER, PercentsD16.fromBasisPoints(10_00)); + _lockStETH(_VETOER, 1 ether); + } + + function test_emergency_committees_happy_path() external { + uint256 quorum; + uint256 support; + bool isExecuted; + + address[] memory members; + + ExternalCall[] memory proposalCalls = ExternalCallHelpers.create( + address(_targetMock), abi.encodeCall(IPotentiallyDangerousContract.doRegularStaff, (0)) + ); + uint256 proposalIdToExecute = _submitProposal(_dualGovernance, "Proposal for execution", proposalCalls); + + _wait(_timelock.getAfterSubmitDelay().plusSeconds(1)); + _assertCanSchedule(_dualGovernance, proposalIdToExecute, true); + _scheduleProposal(_dualGovernance, proposalIdToExecute); + + // Emergency Activation + members = _emergencyActivationCommittee.getMembers(); + for (uint256 i = 0; i < _emergencyActivationCommittee.quorum() - 1; i++) { + vm.prank(members[i]); + _emergencyActivationCommittee.approveActivateEmergencyMode(); + (support, quorum, isExecuted) = _emergencyActivationCommittee.getActivateEmergencyModeState(); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _emergencyActivationCommittee.approveActivateEmergencyMode(); + (support, quorum, isExecuted) = _emergencyActivationCommittee.getActivateEmergencyModeState(); + assert(support == quorum); + assert(isExecuted == false); + + _emergencyActivationCommittee.executeActivateEmergencyMode(); + (support, quorum, isExecuted) = _emergencyActivationCommittee.getActivateEmergencyModeState(); + assert(isExecuted == true); + + // Emergency Execute + members = _emergencyExecutionCommittee.getMembers(); + for (uint256 i = 0; i < _emergencyExecutionCommittee.quorum() - 1; i++) { + vm.prank(members[i]); + _emergencyExecutionCommittee.voteEmergencyExecute(proposalIdToExecute, true); + (support, quorum, isExecuted) = _emergencyExecutionCommittee.getEmergencyExecuteState(proposalIdToExecute); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _emergencyExecutionCommittee.voteEmergencyExecute(proposalIdToExecute, true); + (support, quorum, isExecuted) = _emergencyExecutionCommittee.getEmergencyExecuteState(proposalIdToExecute); + assert(support == quorum); + assert(isExecuted == false); + + _emergencyExecutionCommittee.executeEmergencyExecute(proposalIdToExecute); + (support, quorum, isExecuted) = _emergencyExecutionCommittee.getEmergencyExecuteState(proposalIdToExecute); + assert(isExecuted == true); + } +} diff --git a/test/scenario/reseal-committee.t.sol b/test/scenario/reseal-committee.t.sol new file mode 100644 index 00000000..15414182 --- /dev/null +++ b/test/scenario/reseal-committee.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {DualGovernance} from "contracts/DualGovernance.sol"; +import {ResealManager} from "contracts/ResealManager.sol"; +import {PercentsD16} from "contracts/types/PercentD16.sol"; +import {IWithdrawalQueue} from "contracts/interfaces/IWithdrawalQueue.sol"; + +import {ScenarioTestBlueprint, ExternalCall} from "../utils/scenario-test-blueprint.sol"; +import {DAO_AGENT} from "../utils/mainnet-addresses.sol"; + +contract ResealCommitteeTest is ScenarioTestBlueprint { + address internal immutable _VETOER = makeAddr("VETOER"); + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + + function setUp() external { + _deployDualGovernanceSetup({isEmergencyProtectionEnabled: true}); + _setupStETHBalance(_VETOER, PercentsD16.fromBasisPoints(10_00)); + _lockStETH(_VETOER, 1 ether); + } + + function test_reseal_committees_happy_path() external { + uint256 quorum; + uint256 support; + bool isExecuted; + + address[] memory members; + + address sealable = address(_lido.withdrawalQueue); + + vm.prank(DAO_AGENT); + _lido.withdrawalQueue.grantRole( + 0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d, address(this) + ); + + // Reseal + members = _resealCommittee.getMembers(); + for (uint256 i = 0; i < _resealCommittee.quorum() - 1; i++) { + vm.prank(members[i]); + _resealCommittee.voteReseal(sealable, true); + (support, quorum, isExecuted) = _resealCommittee.getResealState(sealable); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _resealCommittee.voteReseal(sealable, true); + (support, quorum, isExecuted) = _resealCommittee.getResealState(sealable); + assert(support == quorum); + assert(isExecuted == false); + + _assertNormalState(); + + vm.expectRevert(abi.encodeWithSelector(DualGovernance.ResealIsNotAllowedInNormalState.selector)); + _resealCommittee.executeReseal(sealable); + + _lockStETH(_VETOER, _dualGovernanceConfigProvider.FIRST_SEAL_RAGE_QUIT_SUPPORT()); + _lockStETH(_VETOER, 1 gwei); + _assertVetoSignalingState(); + + assertEq(_lido.withdrawalQueue.isPaused(), false); + vm.expectRevert(abi.encodeWithSelector(ResealManager.SealableWrongPauseState.selector)); + _resealCommittee.executeReseal(sealable); + + _lido.withdrawalQueue.pauseFor(3600 * 24 * 6); + assertEq(_lido.withdrawalQueue.isPaused(), true); + + _resealCommittee.executeReseal(sealable); + } +} diff --git a/test/unit/HashConsensus.t.sol b/test/unit/HashConsensus.t.sol new file mode 100644 index 00000000..74328891 --- /dev/null +++ b/test/unit/HashConsensus.t.sol @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {UnitTest} from "test/utils/unit-test.sol"; + +import {Vm} from "forge-std/Test.sol"; + +import {HashConsensus} from "../../contracts/committees/HashConsensus.sol"; +import {Duration} from "../../contracts/types/Duration.sol"; + +abstract contract HashConsensusUnitTest is UnitTest { + HashConsensus internal _hashConsensus; + + address internal _owner = makeAddr("COMMITTEE_OWNER"); + + address internal _stranger = makeAddr("STRANGER"); + + uint256 internal _membersCount = 13; + uint256 internal _quorum = 7; + address[] internal _committeeMembers = new address[](_membersCount); + + constructor() { + for (uint256 i = 0; i < _membersCount; ++i) { + _committeeMembers[i] = makeAddr(string(abi.encode(0xFE + i * _membersCount + 65))); + } + } + + function test_isMember() public { + for (uint256 i = 0; i < _membersCount; ++i) { + assertEq(_hashConsensus.isMember(_committeeMembers[i]), true); + } + + assertEq(_hashConsensus.isMember(_owner), false); + assertEq(_hashConsensus.isMember(_stranger), false); + } + + function test_getMembers() public { + address[] memory committeeMembers = _hashConsensus.getMembers(); + + assertEq(committeeMembers.length, _committeeMembers.length); + + for (uint256 i = 0; i < _membersCount; ++i) { + assertEq(committeeMembers[i], _committeeMembers[i]); + } + } + + function test_addMember_stranger_call() public { + address newMember = makeAddr("NEW_MEMBER"); + assertEq(_hashConsensus.isMember(newMember), false); + + vm.prank(_stranger); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", _stranger)); + _hashConsensus.addMember(newMember, _quorum); + + for (uint256 i = 0; i < _membersCount; ++i) { + vm.prank(_committeeMembers[i]); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", _committeeMembers[i])); + _hashConsensus.addMember(newMember, _quorum); + } + } + + function test_addMember_reverts_on_duplicate() public { + address existedMember = _committeeMembers[0]; + assertEq(_hashConsensus.isMember(existedMember), true); + + vm.prank(_owner); + vm.expectRevert(abi.encodeWithSignature("DuplicatedMember(address)", existedMember)); + _hashConsensus.addMember(existedMember, _quorum); + } + + function test_addMember_reverts_on_invalid_quorum() public { + address newMember = makeAddr("NEW_MEMBER"); + assertEq(_hashConsensus.isMember(newMember), false); + + vm.prank(_owner); + vm.expectRevert(abi.encodeWithSignature("InvalidQuorum()")); + _hashConsensus.addMember(newMember, 0); + + vm.prank(_owner); + vm.expectRevert(abi.encodeWithSignature("InvalidQuorum()")); + _hashConsensus.addMember(newMember, _membersCount + 2); + } + + function test_addMember() public { + address newMember = makeAddr("NEW_MEMBER"); + uint256 newQuorum = _quorum + 1; + + assertEq(_hashConsensus.isMember(newMember), false); + + vm.prank(_owner); + vm.expectEmit(address(_hashConsensus)); + emit HashConsensus.MemberAdded(newMember); + vm.expectEmit(address(_hashConsensus)); + emit HashConsensus.QuorumSet(newQuorum); + _hashConsensus.addMember(newMember, newQuorum); + + assertEq(_hashConsensus.isMember(newMember), true); + + address[] memory committeeMembers = _hashConsensus.getMembers(); + + assertEq(committeeMembers.length, _membersCount + 1); + assertEq(committeeMembers[committeeMembers.length - 1], newMember); + } + + function test_removeMember_stranger_call() public { + address memberToRemove = _committeeMembers[0]; + assertEq(_hashConsensus.isMember(memberToRemove), true); + + vm.prank(_stranger); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", _stranger)); + _hashConsensus.removeMember(memberToRemove, _quorum); + + for (uint256 i = 0; i < _membersCount; ++i) { + vm.prank(_committeeMembers[i]); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", _committeeMembers[i])); + _hashConsensus.removeMember(memberToRemove, _quorum); + } + } + + function test_removeMember_reverts_on_member_is_not_exist() public { + assertEq(_hashConsensus.isMember(_stranger), false); + + vm.prank(_owner); + vm.expectRevert(abi.encodeWithSignature("IsNotMember()")); + _hashConsensus.removeMember(_stranger, _quorum); + } + + function test_removeMember_reverts_on_invalid_quorum() public { + address memberToRemove = _committeeMembers[0]; + assertEq(_hashConsensus.isMember(memberToRemove), true); + + vm.prank(_owner); + vm.expectRevert(abi.encodeWithSignature("InvalidQuorum()")); + _hashConsensus.removeMember(memberToRemove, 0); + + vm.prank(_owner); + vm.expectRevert(abi.encodeWithSignature("InvalidQuorum()")); + _hashConsensus.removeMember(memberToRemove, _membersCount); + } + + function test_removeMember() public { + address memberToRemove = _committeeMembers[0]; + uint256 newQuorum = _quorum - 1; + + assertEq(_hashConsensus.isMember(memberToRemove), true); + + vm.prank(_owner); + vm.expectEmit(address(_hashConsensus)); + emit HashConsensus.MemberRemoved(memberToRemove); + vm.expectEmit(address(_hashConsensus)); + emit HashConsensus.QuorumSet(newQuorum); + _hashConsensus.removeMember(memberToRemove, newQuorum); + + assertEq(_hashConsensus.isMember(memberToRemove), false); + + address[] memory committeeMembers = _hashConsensus.getMembers(); + + assertEq(committeeMembers.length, _membersCount - 1); + for (uint256 i = 0; i < committeeMembers.length; ++i) { + assertNotEq(committeeMembers[i], memberToRemove); + } + } +} + +contract Target { + event Executed(); + + function trigger() public { + emit Executed(); + } +} + +contract HashConsensusWrapper is HashConsensus { + event OnlyMemberModifierPassed(); + + Target internal _target; + + constructor( + address owner, + address[] memory newMembers, + uint256 executionQuorum, + uint256 timelock, + Target target + ) HashConsensus(owner, newMembers, executionQuorum, timelock) { + _target = target; + } + + function vote(bytes32 hash, bool support) public { + _vote(hash, support); + } + + function execute(bytes32 hash) public { + _markUsed(hash); + _target.trigger(); + } + + function getHashState(bytes32 hash) + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + return _getHashState(hash); + } + + function getSupport(bytes32 hash) public view returns (uint256 support) { + return _getSupport(hash); + } + + function onlyMemberProtected() public { + _checkSenderIsMember(); + emit OnlyMemberModifierPassed(); + } +} + +contract HashConsensusInternalUnitTest is HashConsensusUnitTest { + HashConsensusWrapper internal _hashConsensusWrapper; + Target internal _target; + Duration internal _timelock = Duration.wrap(3600); + + bytes internal data; + bytes32 internal dataHash; + + function setUp() public { + _target = new Target(); + _hashConsensusWrapper = + new HashConsensusWrapper(_owner, _committeeMembers, _quorum, _timelock.toSeconds(), _target); + _hashConsensus = HashConsensus(_hashConsensusWrapper); + data = abi.encode(address(_target)); + dataHash = keccak256(data); + } + + function test_getSupport() public { + assertEq(_hashConsensusWrapper.getSupport(dataHash), 0); + + for (uint256 i = 0; i < _membersCount; ++i) { + assertEq(_hashConsensusWrapper.getSupport(dataHash), i); + vm.prank(_committeeMembers[i]); + _hashConsensusWrapper.vote(dataHash, true); + assertEq(_hashConsensusWrapper.getSupport(dataHash), i + 1); + } + + assertEq(_hashConsensusWrapper.getSupport(dataHash), _membersCount); + + for (uint256 i = 0; i < _membersCount; ++i) { + assertEq(_hashConsensusWrapper.getSupport(dataHash), _membersCount - i); + vm.prank(_committeeMembers[i]); + _hashConsensusWrapper.vote(dataHash, false); + assertEq(_hashConsensusWrapper.getSupport(dataHash), _membersCount - i - 1); + } + + assertEq(_hashConsensusWrapper.getSupport(dataHash), 0); + } + + function test_getHashState() public { + uint256 support; + uint256 execuitionQuorum; + bool isExecuted; + + (support, execuitionQuorum, isExecuted) = _hashConsensusWrapper.getHashState(dataHash); + assertEq(support, 0); + assertEq(execuitionQuorum, _quorum); + assertEq(isExecuted, false); + + for (uint256 i = 0; i < _membersCount; ++i) { + (support, execuitionQuorum, isExecuted) = _hashConsensusWrapper.getHashState(dataHash); + assertEq(support, i); + assertEq(execuitionQuorum, _quorum); + assertEq(isExecuted, false); + + vm.prank(_committeeMembers[i]); + _hashConsensusWrapper.vote(dataHash, true); + + (support, execuitionQuorum, isExecuted) = _hashConsensusWrapper.getHashState(dataHash); + assertEq(support, i + 1); + assertEq(execuitionQuorum, _quorum); + assertEq(isExecuted, false); + } + + (support, execuitionQuorum, isExecuted) = _hashConsensusWrapper.getHashState(dataHash); + assertEq(support, _membersCount); + assertEq(execuitionQuorum, _quorum); + assertEq(isExecuted, false); + + _wait(_timelock); + + _hashConsensusWrapper.execute(dataHash); + + (support, execuitionQuorum, isExecuted) = _hashConsensusWrapper.getHashState(dataHash); + assertEq(support, _membersCount); + assertEq(execuitionQuorum, _quorum); + assertEq(isExecuted, true); + } + + function test_vote() public { + assertEq(_hashConsensusWrapper.approves(_committeeMembers[0], dataHash), false); + + vm.prank(_committeeMembers[0]); + vm.expectEmit(address(_hashConsensusWrapper)); + emit HashConsensus.Voted(_committeeMembers[0], dataHash, true); + _hashConsensusWrapper.vote(dataHash, true); + assertEq(_hashConsensusWrapper.approves(_committeeMembers[0], dataHash), true); + + vm.prank(_committeeMembers[0]); + vm.recordLogs(); + _hashConsensusWrapper.vote(dataHash, true); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0); + assertEq(_hashConsensusWrapper.approves(_committeeMembers[0], dataHash), true); + + vm.prank(_committeeMembers[0]); + vm.expectEmit(address(_hashConsensusWrapper)); + emit HashConsensus.Voted(_committeeMembers[0], dataHash, false); + _hashConsensusWrapper.vote(dataHash, false); + assertEq(_hashConsensusWrapper.approves(_committeeMembers[0], dataHash), false); + + vm.prank(_committeeMembers[0]); + vm.recordLogs(); + _hashConsensusWrapper.vote(dataHash, false); + logs = vm.getRecordedLogs(); + assertEq(logs.length, 0); + assertEq(_hashConsensusWrapper.approves(_committeeMembers[0], dataHash), false); + } + + function test_vote_reverts_on_executed() public { + for (uint256 i = 0; i < _quorum; ++i) { + vm.prank(_committeeMembers[i]); + _hashConsensusWrapper.vote(dataHash, true); + } + + _wait(_timelock); + + _hashConsensusWrapper.execute(dataHash); + + vm.prank(_committeeMembers[0]); + vm.expectRevert(abi.encodeWithSignature("HashAlreadyUsed()")); + _hashConsensusWrapper.vote(dataHash, true); + } + + function test_execute_events() public { + vm.prank(_stranger); + vm.expectRevert(abi.encodeWithSignature("QuorumIsNotReached()")); + _hashConsensusWrapper.execute(dataHash); + + for (uint256 i = 0; i < _quorum; ++i) { + vm.prank(_committeeMembers[i]); + _hashConsensusWrapper.vote(dataHash, true); + } + + vm.prank(_stranger); + vm.expectRevert(abi.encodeWithSignature("TimelockNotPassed()")); + _hashConsensusWrapper.execute(dataHash); + + _wait(_timelock); + vm.prank(_stranger); + vm.expectEmit(address(_hashConsensusWrapper)); + emit HashConsensus.HashUsed(dataHash); + vm.expectEmit(address(_target)); + emit Target.Executed(); + _hashConsensusWrapper.execute(dataHash); + + vm.prank(_stranger); + vm.expectRevert(abi.encodeWithSignature("HashAlreadyUsed()")); + _hashConsensusWrapper.execute(dataHash); + } + + function test_onlyMemberModifier() public { + vm.prank(_stranger); + vm.expectRevert(abi.encodeWithSignature("SenderIsNotMember()")); + _hashConsensusWrapper.onlyMemberProtected(); + + vm.prank(_committeeMembers[0]); + vm.expectEmit(address(_hashConsensus)); + emit HashConsensusWrapper.OnlyMemberModifierPassed(); + _hashConsensusWrapper.onlyMemberProtected(); + } +} diff --git a/test/unit/ResealManager.t.sol b/test/unit/ResealManager.t.sol new file mode 100644 index 00000000..0326d638 --- /dev/null +++ b/test/unit/ResealManager.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {ResealManager} from "contracts/ResealManager.sol"; +import {ITimelock} from "contracts/interfaces/ITimelock.sol"; +import {ISealable} from "contracts/interfaces/ISealable.sol"; +import {Durations} from "contracts/types/Duration.sol"; + +import {UnitTest} from "test/utils/unit-test.sol"; + +contract ResealManagerUnitTests is UnitTest { + ResealManager internal resealManager; + + address timelock = makeAddr("timelock"); + address sealable = makeAddr("sealable"); + address private governance = makeAddr("governance"); + + function setUp() external { + vm.mockCall(timelock, abi.encodeWithSelector(ITimelock.getGovernance.selector), abi.encode(governance)); + + resealManager = new ResealManager(ITimelock(timelock)); + } + + function test_resealSuccess() public { + uint256 futureTimestamp = block.timestamp + 1000; + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(futureTimestamp) + ); + + vm.expectCall(sealable, abi.encodeWithSelector(ISealable.resume.selector)); + vm.expectCall(sealable, abi.encodeWithSelector(ISealable.pauseFor.selector, type(uint256).max)); + + vm.prank(governance); + resealManager.reseal(sealable); + } + + function test_resealFailsForPastTimestamp() public { + uint256 pastTimestamp = block.timestamp; + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(pastTimestamp) + ); + + _wait(Durations.from(1)); + + vm.prank(governance); + vm.expectRevert(ResealManager.SealableWrongPauseState.selector); + resealManager.reseal(sealable); + } + + function test_resealFailsForInfinitePause() public { + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(type(uint256).max) + ); + + vm.prank(governance); + vm.expectRevert(ResealManager.SealableWrongPauseState.selector); + resealManager.reseal(sealable); + } + + function test_resumeSuccess() public { + uint256 futureTimestamp = block.timestamp + 1000; + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(futureTimestamp) + ); + + vm.expectCall(sealable, abi.encodeWithSelector(ISealable.resume.selector)); + + vm.prank(governance); + resealManager.resume(sealable); + } + + function test_resumeFailsForPastTimestamp() public { + uint256 pastTimestamp = block.timestamp; + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(pastTimestamp) + ); + + _wait(Durations.from(1)); + + vm.prank(governance); + vm.expectRevert(ResealManager.SealableWrongPauseState.selector); + resealManager.resume(sealable); + } + + function test_revertWhenSenderIsNotGovernanceOnReseal() public { + uint256 futureTimestamp = block.timestamp + 1000; + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(futureTimestamp) + ); + + vm.prank(address(0x123)); + vm.expectRevert(ResealManager.SenderIsNotGovernance.selector); + resealManager.reseal(sealable); + } + + function test_revertWhenSenderIsNotGovernanceOnResume() public { + uint256 futureTimestamp = block.timestamp + 1000; + vm.mockCall( + sealable, abi.encodeWithSelector(ISealable.getResumeSinceTimestamp.selector), abi.encode(futureTimestamp) + ); + + vm.prank(address(0x123)); + vm.expectRevert(ResealManager.SenderIsNotGovernance.selector); + resealManager.resume(sealable); + } +} diff --git a/test/unit/TimelockedGovernance.t.sol b/test/unit/TimelockedGovernance.t.sol index adf0fe17..52e57f48 100644 --- a/test/unit/TimelockedGovernance.t.sol +++ b/test/unit/TimelockedGovernance.t.sol @@ -6,7 +6,7 @@ import {TimelockedGovernance} from "contracts/TimelockedGovernance.sol"; import {UnitTest} from "test/utils/unit-test.sol"; -import {TimelockMock} from "./mocks/TimelockMock.sol"; +import {TimelockMock} from "../mocks/TimelockMock.sol"; contract TimelockedGovernanceUnitTests is UnitTest { TimelockMock private _timelock; diff --git a/test/utils/SetupDeployment.sol b/test/utils/SetupDeployment.sol index 3377acb5..de474803 100644 --- a/test/utils/SetupDeployment.sol +++ b/test/utils/SetupDeployment.sol @@ -185,6 +185,8 @@ abstract contract SetupDeployment is Test { _tiebreakerCoreCommittee.transferOwnership(address(_adminExecutor)); + _resealCommittee = _deployResealCommittee(); + // --- // Finalize Setup // --- @@ -208,6 +210,9 @@ abstract contract SetupDeployment is Test { 0, abi.encodeCall(_dualGovernance.addTiebreakerSealableWithdrawalBlocker, address(_lido.withdrawalQueue)) ); + _adminExecutor.execute( + address(_dualGovernance), 0, abi.encodeCall(_dualGovernance.setResealCommittee, address(_resealCommittee)) + ); _finalizeEmergencyProtectedTimelockDeploy(_dualGovernance); @@ -307,6 +312,17 @@ abstract contract SetupDeployment is Test { return new EmergencyExecutionCommittee(owner, members, quorum, address(timelock)); } + function _deployResealCommittee() internal returns (ResealCommittee) { + uint256 quorum = 3; + uint256 membersCount = 5; + address[] memory committeeMembers = new address[](membersCount); + for (uint256 i = 0; i < membersCount; ++i) { + committeeMembers[i] = makeAddr(string(abi.encode(0xFA + i * membersCount + 65))); + } + + return new ResealCommittee(address(_adminExecutor), committeeMembers, quorum, address(_dualGovernance), 0); + } + function _deployTimelockedGovernance( address governance, ITimelock timelock diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index aaf74fdb..5bb066dc 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -542,13 +542,13 @@ contract ScenarioTestBlueprint is TestingAssertEqExtender, SetupDeployment { _wait(_timelock.getAfterScheduleDelay() + Durations.from(1 seconds)); } - function _executeEmergencyActivate() internal { + function _executeActivateEmergencyMode() internal { address[] memory members = _emergencyActivationCommittee.getMembers(); for (uint256 i = 0; i < _emergencyActivationCommittee.quorum(); ++i) { vm.prank(members[i]); - _emergencyActivationCommittee.approveEmergencyActivate(); + _emergencyActivationCommittee.approveActivateEmergencyMode(); } - _emergencyActivationCommittee.executeEmergencyActivate(); + _emergencyActivationCommittee.executeActivateEmergencyMode(); } function _executeEmergencyExecute(uint256 proposalId) internal {