diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index dd81ff7b..fffecf44 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -4,30 +4,33 @@ pragma solidity 0.8.23; import {Duration} from "./types/Duration.sol"; import {Timestamp} from "./types/Timestamp.sol"; import {ITimelock, IGovernance} from "./interfaces/ITimelock.sol"; +import {ISealable} from "./interfaces/ISealable.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; import {ConfigurationProvider} from "./ConfigurationProvider.sol"; import {Proposers, Proposer} from "./libraries/Proposers.sol"; import {ExecutorCall} from "./libraries/Proposals.sol"; import {EmergencyProtection} from "./libraries/EmergencyProtection.sol"; import {State, DualGovernanceState} from "./libraries/DualGovernanceState.sol"; +import {TiebreakerProtection} from "./libraries/TiebreakerProtection.sol"; contract DualGovernance is IGovernance, ConfigurationProvider { using Proposers for Proposers.State; using DualGovernanceState for DualGovernanceState.Store; + using TiebreakerProtection for TiebreakerProtection.Tiebreaker; - event TiebreakerSet(address tiebreakCommittee); event ProposalScheduled(uint256 proposalId); - error ProposalNotExecutable(uint256 proposalId); - error NotTiebreaker(address account, address tiebreakCommittee); + error NotResealCommitttee(address account); ITimelock public immutable TIMELOCK; - address internal _tiebreaker; - + TiebreakerProtection.Tiebreaker internal _tiebreaker; Proposers.State internal _proposers; DualGovernanceState.Store internal _dgState; EmergencyProtection.State internal _emergencyProtection; + address internal _resealCommittee; + IResealManager internal _resealManager; constructor( address config, @@ -143,29 +146,38 @@ contract DualGovernance is IGovernance, ConfigurationProvider { // Tiebreaker Protection // --- + function tiebreakerResumeSealable(address sealable) external { + _tiebreaker.checkTiebreakerCommittee(msg.sender); + _dgState.checkTiebreak(CONFIG); + _tiebreaker.resumeSealable(sealable); + } + function tiebreakerScheduleProposal(uint256 proposalId) external { - _checkTiebreakerCommittee(msg.sender); - _dgState.activateNextState(CONFIG.getDualGovernanceConfig()); + _tiebreaker.checkTiebreakerCommittee(msg.sender); _dgState.checkTiebreak(CONFIG); TIMELOCK.schedule(proposalId); } - function setTiebreakerCommittee(address newTiebreaker) external { + function setTiebreakerProtection(address newTiebreaker, address resealManager) external { _checkAdminExecutor(msg.sender); - address oldTiebreaker = _tiebreaker; - if (newTiebreaker != oldTiebreaker) { - _tiebreaker = newTiebreaker; - emit TiebreakerSet(newTiebreaker); - } + _tiebreaker.setTiebreaker(newTiebreaker, resealManager); } // --- - // Internal Helper Methods + // Reseal executor // --- - function _checkTiebreakerCommittee(address account) internal view { - if (account != _tiebreaker) { - revert NotTiebreaker(account, _tiebreaker); + function resealSealables(address[] memory sealables) external { + if (msg.sender != _resealCommittee) { + revert NotResealCommitttee(msg.sender); } + _dgState.checkResealState(); + _resealManager.reseal(sealables); + } + + function setReseal(address resealManager, address resealCommittee) external { + _checkAdminExecutor(msg.sender); + _resealCommittee = resealCommittee; + _resealManager = IResealManager(resealManager); } } diff --git a/contracts/GateSealBreaker.sol b/contracts/GateSealBreaker.sol deleted file mode 100644 index 58c75587..00000000 --- a/contracts/GateSealBreaker.sol +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; - -import {IGateSeal} from "./interfaces/IGateSeal.sol"; -import {ISealable} from "./interfaces/ISealable.sol"; -import {SealableCalls} from "./libraries/SealableCalls.sol"; - -interface IDualGovernance { - function isSchedulingEnabled() external view returns (bool); -} - -contract GateSealBreaker is Ownable { - using SafeCast for uint256; - using SealableCalls for ISealable; - - struct GateSealState { - uint40 registeredAt; - uint40 releaseStartedAt; - uint40 releaseEnactedAt; - } - - error GovernanceLocked(); - error ReleaseNotStarted(); - error GateSealNotActivated(); - error ReleaseDelayNotPassed(); - error DualGovernanceIsLocked(); - error GateSealAlreadyReleased(); - error MinSealDurationNotPassed(); - error GateSealIsNotRegistered(IGateSeal gateSeal); - error GateSealAlreadyRegistered(IGateSeal gateSeal, uint256 registeredAt); - - event ReleaseIsPausedConditionNotMet(ISealable sealable); - event ReleaseResumeCallFailed(ISealable sealable, bytes lowLevelError); - event ReleaseIsPausedCheckFailed(ISealable sealable, bytes lowLevelError); - - uint256 public immutable RELEASE_DELAY; - IDualGovernance public immutable DUAL_GOVERNANCE; - - constructor(uint256 releaseDelay, address owner, address dualGovernance) Ownable(owner) { - RELEASE_DELAY = releaseDelay; - DUAL_GOVERNANCE = IDualGovernance(dualGovernance); - } - - mapping(IGateSeal gateSeal => GateSealState) internal _gateSeals; - - function registerGateSeal(IGateSeal gateSeal) external { - _checkOwner(); - if (_gateSeals[gateSeal].registeredAt != 0) { - revert GateSealAlreadyRegistered(gateSeal, _gateSeals[gateSeal].registeredAt); - } - _gateSeals[gateSeal].registeredAt = block.timestamp.toUint40(); - } - - function startRelease(IGateSeal gateSeal) external { - _checkGateSealRegistered(gateSeal); - _checkGateSealActivated(gateSeal); - _checkMinSealDurationPassed(gateSeal); - _checkGateSealNotReleased(gateSeal); - _checkGovernanceNotLocked(); - - _gateSeals[gateSeal].releaseStartedAt = block.timestamp.toUint40(); - } - - function enactRelease(IGateSeal gateSeal) external { - _checkGateSealRegistered(gateSeal); - GateSealState memory gateSealState = _gateSeals[gateSeal]; - if (gateSealState.releaseStartedAt == 0) { - revert ReleaseNotStarted(); - } - if (block.timestamp <= gateSealState.releaseStartedAt + RELEASE_DELAY) { - revert ReleaseDelayNotPassed(); - } - - _gateSeals[gateSeal].releaseEnactedAt = block.timestamp.toUint40(); - - address[] memory sealed_ = gateSeal.sealed_sealables(); - - for (uint256 i = 0; i < sealed_.length; ++i) { - ISealable sealable = ISealable(sealed_[i]); - (bool isPausedCallSuccess, bytes memory isPausedLowLevelError, bool isPaused) = sealable.callIsPaused(); - if (!isPausedCallSuccess) { - emit ReleaseIsPausedCheckFailed(sealable, isPausedLowLevelError); - } - if (!isPaused) { - emit ReleaseIsPausedConditionNotMet(sealable); - continue; - } - (bool resumeCallSuccess, bytes memory lowLevelError) = sealable.callResume(); - if (!resumeCallSuccess) { - emit ReleaseResumeCallFailed(sealable, lowLevelError); - } - } - } - - function _checkGateSealRegistered(IGateSeal gateSeal) internal view { - if (_gateSeals[gateSeal].registeredAt == 0) { - revert GateSealIsNotRegistered(gateSeal); - } - } - - function _checkGateSealActivated(IGateSeal gateSeal) internal view { - address[] memory sealed_ = gateSeal.sealed_sealables(); - if (sealed_.length == 0) { - revert GateSealNotActivated(); - } - } - - function _checkMinSealDurationPassed(IGateSeal gateSeal) internal view { - if (block.timestamp < gateSeal.get_expiry_timestamp() + gateSeal.get_min_seal_duration()) { - revert MinSealDurationNotPassed(); - } - } - - function _checkGateSealNotReleased(IGateSeal gateSeal) internal view { - if (_gateSeals[gateSeal].releaseStartedAt != 0) { - revert GateSealAlreadyReleased(); - } - } - - function _checkGovernanceNotLocked() internal view { - if (!DUAL_GOVERNANCE.isSchedulingEnabled()) { - revert GovernanceLocked(); - } - } -} diff --git a/contracts/ResealManager.sol b/contracts/ResealManager.sol new file mode 100644 index 00000000..5fdb2137 --- /dev/null +++ b/contracts/ResealManager.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {ISealable} from "./interfaces/ISealable.sol"; + +interface IEmergencyProtectedTimelock { + function getGovernance() external view returns (address); +} + +contract ResealManager { + error SealableWrongPauseState(); + error SenderIsNotGovernance(); + error NotAllowed(); + + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + address public immutable EMERGENCY_PROTECTED_TIMELOCK; + + constructor(address emergencyProtectedTimelock) { + EMERGENCY_PROTECTED_TIMELOCK = emergencyProtectedTimelock; + } + + function reseal(address[] memory sealables) public onlyGovernance { + 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)); + } + } + + function resume(address sealable) public onlyGovernance { + uint256 sealableResumeSinceTimestamp = ISealable(sealable).getResumeSinceTimestamp(); + if (sealableResumeSinceTimestamp < block.timestamp) { + revert SealableWrongPauseState(); + } + Address.functionCall(sealable, abi.encodeWithSelector(ISealable.resume.selector)); + } + + modifier onlyGovernance() { + address governance = IEmergencyProtectedTimelock(EMERGENCY_PROTECTED_TIMELOCK).getGovernance(); + if (msg.sender != governance) { + revert SenderIsNotGovernance(); + } + _; + } +} diff --git a/contracts/committees/EmergencyActivationCommittee.sol b/contracts/committees/EmergencyActivationCommittee.sol new file mode 100644 index 00000000..038dd46b --- /dev/null +++ b/contracts/committees/EmergencyActivationCommittee.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {HashConsensus} from "./HashConsensus.sol"; + +interface IEmergencyProtectedTimelock { + function emergencyActivate() external; +} + +contract EmergencyActivationCommittee is HashConsensus { + address public immutable EMERGENCY_PROTECTED_TIMELOCK; + + bytes32 private constant EMERGENCY_ACTIVATION_HASH = keccak256("EMERGENCY_ACTIVATE"); + + constructor( + address owner, + address[] memory committeeMembers, + uint256 executionQuorum, + address emergencyProtectedTimelock + ) HashConsensus(owner, committeeMembers, executionQuorum, 0) { + EMERGENCY_PROTECTED_TIMELOCK = emergencyProtectedTimelock; + } + + function approveEmergencyActivate() public onlyMember { + _vote(EMERGENCY_ACTIVATION_HASH, true); + } + + function getEmergencyActivateState() + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + return _getHashState(EMERGENCY_ACTIVATION_HASH); + } + + function executeEmergencyActivate() external { + _markUsed(EMERGENCY_ACTIVATION_HASH); + Address.functionCall( + EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyActivate.selector) + ); + } +} diff --git a/contracts/committees/EmergencyExecutionCommittee.sol b/contracts/committees/EmergencyExecutionCommittee.sol new file mode 100644 index 00000000..1c670227 --- /dev/null +++ b/contracts/committees/EmergencyExecutionCommittee.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +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; +} + +enum ProposalType { + EmergencyExecute, + EmergencyReset +} + +contract EmergencyExecutionCommittee is HashConsensus, ProposalsList { + address public immutable EMERGENCY_PROTECTED_TIMELOCK; + + constructor( + address owner, + address[] memory committeeMembers, + uint256 executionQuorum, + address emergencyProtectedTimelock + ) HashConsensus(owner, committeeMembers, executionQuorum, 0) { + EMERGENCY_PROTECTED_TIMELOCK = emergencyProtectedTimelock; + } + + // Emergency Execution + + function voteEmergencyExecute(uint256 proposalId, bool _supports) public onlyMember { + (bytes memory proposalData, bytes32 key) = _encodeEmergencyExecute(proposalId); + _vote(key, _supports); + _pushProposal(key, uint256(ProposalType.EmergencyExecute), proposalData); + } + + function getEmergencyExecuteState(uint256 proposalId) + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + (, bytes32 key) = _encodeEmergencyExecute(proposalId); + return _getHashState(key); + } + + function executeEmergencyExecute(uint256 proposalId) public { + (, bytes32 key) = _encodeEmergencyExecute(proposalId); + _markUsed(key); + Address.functionCall( + EMERGENCY_PROTECTED_TIMELOCK, + abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyExecute.selector, proposalId) + ); + } + + function _encodeEmergencyExecute(uint256 proposalId) + private + pure + returns (bytes memory proposalData, bytes32 key) + { + proposalData = abi.encode(ProposalType.EmergencyExecute, proposalId); + key = keccak256(proposalData); + } + + // Governance reset + + function approveEmergencyReset() public onlyMember { + bytes32 proposalKey = _encodeEmergencyResetProposalKey(); + _vote(proposalKey, true); + _pushProposal(proposalKey, uint256(ProposalType.EmergencyReset), bytes("")); + } + + function getEmergencyResetState() + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + bytes32 proposalKey = _encodeEmergencyResetProposalKey(); + return _getHashState(proposalKey); + } + + function executeEmergencyReset() external { + bytes32 proposalKey = _encodeEmergencyResetProposalKey(); + _markUsed(proposalKey); + Address.functionCall( + EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyReset.selector) + ); + } + + function _encodeEmergencyResetProposalKey() internal pure returns (bytes32) { + return keccak256(abi.encode(ProposalType.EmergencyReset, bytes32(0))); + } +} diff --git a/contracts/committees/HashConsensus.sol b/contracts/committees/HashConsensus.sol new file mode 100644 index 00000000..fb4a2d65 --- /dev/null +++ b/contracts/committees/HashConsensus.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +abstract contract HashConsensus is Ownable { + using EnumerableSet for EnumerableSet.AddressSet; + + event MemberAdded(address indexed member); + event MemberRemoved(address indexed member); + event QuorumSet(uint256 quorum); + event HashUsed(bytes32 hash); + event Voted(address indexed signer, bytes32 hash, bool support); + event TimelockDurationSet(uint256 timelockDuration); + + error IsNotMember(); + error SenderIsNotMember(); + error HashAlreadyUsed(); + error QuorumIsNotReached(); + error InvalidQuorum(); + error DuplicatedMember(address member); + error TimelockNotPassed(); + + struct HashState { + uint40 quorumAt; + uint40 usedAt; + } + + uint256 public quorum; + uint256 public timelockDuration; + + mapping(bytes32 => HashState) private _hashStates; + EnumerableSet.AddressSet private _members; + mapping(address signer => mapping(bytes32 => bool)) public approves; + + constructor(address owner, address[] memory newMembers, uint256 executionQuorum, uint256 timelock) Ownable(owner) { + if (executionQuorum == 0) { + revert InvalidQuorum(); + } + quorum = executionQuorum; + emit QuorumSet(executionQuorum); + + timelockDuration = timelock; + emit TimelockDurationSet(timelock); + + for (uint256 i = 0; i < newMembers.length; ++i) { + _addMember(newMembers[i]); + } + } + + function _vote(bytes32 hash, bool support) internal { + if (_hashStates[hash].usedAt > 0) { + revert HashAlreadyUsed(); + } + + if (approves[msg.sender][hash] == support) { + return; + } + + uint256 heads = _getSupport(hash); + if (heads == quorum - 1 && support == true) { + _hashStates[hash].quorumAt = uint40(block.timestamp); + } + + approves[msg.sender][hash] = support; + emit Voted(msg.sender, hash, support); + } + + function _markUsed(bytes32 hash) internal { + if (_hashStates[hash].usedAt > 0) { + revert HashAlreadyUsed(); + } + if (_getSupport(hash) < quorum) { + revert QuorumIsNotReached(); + } + if (block.timestamp < _hashStates[hash].quorumAt + timelockDuration) { + revert TimelockNotPassed(); + } + + _hashStates[hash].usedAt = uint40(block.timestamp); + + emit HashUsed(hash); + } + + function _getHashState(bytes32 hash) + internal + view + returns (uint256 support, uint256 execuitionQuorum, bool isUsed) + { + support = _getSupport(hash); + execuitionQuorum = quorum; + isUsed = _hashStates[hash].usedAt > 0; + } + + function addMember(address newMember, uint256 newQuorum) public onlyOwner { + _addMember(newMember); + + if (newQuorum == 0 || newQuorum > _members.length()) { + revert InvalidQuorum(); + } + quorum = newQuorum; + emit QuorumSet(newQuorum); + } + + function removeMember(address memberToRemove, uint256 newQuorum) public onlyOwner { + if (!_members.contains(memberToRemove)) { + revert IsNotMember(); + } + _members.remove(memberToRemove); + emit MemberRemoved(memberToRemove); + + if (newQuorum == 0 || newQuorum > _members.length()) { + revert InvalidQuorum(); + } + quorum = newQuorum; + emit QuorumSet(newQuorum); + } + + function getMembers() public view returns (address[] memory) { + return _members.values(); + } + + function isMember(address member) public view returns (bool) { + return _members.contains(member); + } + + function setTimelockDuration(uint256 timelock) public onlyOwner { + timelockDuration = timelock; + emit TimelockDurationSet(timelock); + } + + function setQuorum(uint256 newQuorum) public onlyOwner { + if (newQuorum == 0 || newQuorum > _members.length()) { + revert InvalidQuorum(); + } + + quorum = newQuorum; + emit QuorumSet(newQuorum); + } + + function _addMember(address newMember) internal { + if (_members.contains(newMember)) { + revert DuplicatedMember(newMember); + } + _members.add(newMember); + emit MemberAdded(newMember); + } + + function _getSupport(bytes32 hash) internal view returns (uint256 support) { + for (uint256 i = 0; i < _members.length(); ++i) { + if (approves[_members.at(i)][hash]) { + support++; + } + } + } + + modifier onlyMember() { + if (!_members.contains(msg.sender)) { + revert SenderIsNotMember(); + } + _; + } +} diff --git a/contracts/committees/ProposalsList.sol b/contracts/committees/ProposalsList.sol new file mode 100644 index 00000000..625a5841 --- /dev/null +++ b/contracts/committees/ProposalsList.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {EnumerableProposals, Proposal} from "../libraries/EnumerableProposals.sol"; + +contract ProposalsList { + using EnumerableProposals for EnumerableProposals.Bytes32ToProposalMap; + + EnumerableProposals.Bytes32ToProposalMap internal _proposals; + + function getProposals(uint256 offset, uint256 limit) public view returns (Proposal[] memory proposals) { + bytes32[] memory keys = _proposals.orederedKeys(offset, limit); + + uint256 length = keys.length; + proposals = new Proposal[](length); + + for (uint256 i = 0; i < length; ++i) { + proposals[i] = _proposals.get(keys[i]); + } + } + + function getProposalAt(uint256 index) public view returns (Proposal memory) { + return _proposals.at(index); + } + + function getProposal(bytes32 key) public view returns (Proposal memory) { + return _proposals.get(key); + } + + function proposalsLength() public view returns (uint256) { + return _proposals.length(); + } + + function orederedKeys(uint256 offset, uint256 limit) public view returns (bytes32[] memory) { + return _proposals.orederedKeys(offset, limit); + } + + function _pushProposal(bytes32 key, uint256 proposalType, bytes memory data) internal { + _proposals.push(key, proposalType, data); + } +} diff --git a/contracts/committees/ResealCommittee.sol b/contracts/committees/ResealCommittee.sol new file mode 100644 index 00000000..1b40a5d6 --- /dev/null +++ b/contracts/committees/ResealCommittee.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {HashConsensus} from "./HashConsensus.sol"; +import {ProposalsList} from "./ProposalsList.sol"; + +interface IDualGovernance { + function reseal(address[] memory sealables) external; +} + +contract ResealCommittee is HashConsensus, ProposalsList { + address public immutable DUAL_GOVERNANCE; + + mapping(bytes32 => uint256) private _resealNonces; + + constructor( + address owner, + address[] memory committeeMembers, + uint256 executionQuorum, + address dualGovernance, + uint256 timelock + ) HashConsensus(owner, committeeMembers, executionQuorum, timelock) { + DUAL_GOVERNANCE = dualGovernance; + } + + function voteReseal(address[] memory sealables, bool support) public onlyMember { + (bytes memory proposalData, bytes32 key) = _encodeResealProposal(sealables); + _vote(key, support); + _pushProposal(key, 0, proposalData); + } + + function getResealState(address[] memory sealables) + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + (, bytes32 key) = _encodeResealProposal(sealables); + return _getHashState(key); + } + + function executeReseal(address[] memory sealables) external { + (, bytes32 key) = _encodeResealProposal(sealables); + _markUsed(key); + + Address.functionCall(DUAL_GOVERNANCE, abi.encodeWithSelector(IDualGovernance.reseal.selector, sealables)); + + bytes32 resealNonceHash = keccak256(abi.encode(sealables)); + _resealNonces[resealNonceHash]++; + } + + 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]); + key = keccak256(data); + } +} diff --git a/contracts/committees/TiebreakerCore.sol b/contracts/committees/TiebreakerCore.sol new file mode 100644 index 00000000..a380036a --- /dev/null +++ b/contracts/committees/TiebreakerCore.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {HashConsensus} from "./HashConsensus.sol"; +import {ProposalsList} from "./ProposalsList.sol"; + +interface IDualGovernance { + function tiebreakerScheduleProposal(uint256 proposalId) external; + function tiebreakerResumeSealable(address sealable) external; +} + +enum ProposalType { + ScheduleProposal, + ResumeSelable +} + +contract TiebreakerCore is HashConsensus, ProposalsList { + error ResumeSealableNonceMismatch(); + + address immutable DUAL_GOVERNANCE; + + mapping(address => uint256) private _sealableResumeNonces; + + constructor( + address owner, + address[] memory committeeMembers, + uint256 executionQuorum, + address dualGovernance, + uint256 timelock + ) HashConsensus(owner, committeeMembers, executionQuorum, timelock) { + DUAL_GOVERNANCE = dualGovernance; + } + + // Schedule proposal + + function scheduleProposal(uint256 proposalId) public onlyMember { + (bytes memory proposalData, bytes32 key) = _encodeScheduleProposal(proposalId); + _vote(key, true); + _pushProposal(key, uint256(ProposalType.ScheduleProposal), proposalData); + } + + function getScheduleProposalState(uint256 proposalId) + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + (, bytes32 key) = _encodeScheduleProposal(proposalId); + return _getHashState(key); + } + + function executeScheduleProposal(uint256 proposalId) public { + (, bytes32 key) = _encodeScheduleProposal(proposalId); + _markUsed(key); + Address.functionCall( + DUAL_GOVERNANCE, abi.encodeWithSelector(IDualGovernance.tiebreakerScheduleProposal.selector, proposalId) + ); + } + + function _encodeScheduleProposal(uint256 proposalId) internal pure returns (bytes memory data, bytes32 key) { + data = abi.encode(ProposalType.ScheduleProposal, proposalId); + key = keccak256(data); + } + + // Resume sealable + + function getSealableResumeNonce(address sealable) public view returns (uint256) { + return _sealableResumeNonces[sealable]; + } + + function sealableResume(address sealable, uint256 nonce) public onlyMember { + if (nonce != _sealableResumeNonces[sealable]) { + revert ResumeSealableNonceMismatch(); + } + (bytes memory proposalData, bytes32 key) = _encodeSealableResume(sealable, nonce); + _vote(key, true); + _pushProposal(key, uint256(ProposalType.ResumeSelable), proposalData); + } + + function getSealableResumeState( + address sealable, + uint256 nonce + ) public view returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) { + (, bytes32 key) = _encodeSealableResume(sealable, nonce); + return _getHashState(key); + } + + function executeSealableResume(address sealable) external { + (, bytes32 key) = _encodeSealableResume(sealable, _sealableResumeNonces[sealable]); + _markUsed(key); + _sealableResumeNonces[sealable]++; + Address.functionCall( + DUAL_GOVERNANCE, abi.encodeWithSelector(IDualGovernance.tiebreakerResumeSealable.selector, sealable) + ); + } + + function _encodeSealableResume( + address sealable, + uint256 nonce + ) private pure returns (bytes memory data, bytes32 key) { + data = abi.encode(ProposalType.ResumeSelable, sealable, nonce); + key = keccak256(data); + } +} diff --git a/contracts/committees/TiebreakerSubCommittee.sol b/contracts/committees/TiebreakerSubCommittee.sol new file mode 100644 index 00000000..1ceb40fc --- /dev/null +++ b/contracts/committees/TiebreakerSubCommittee.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {HashConsensus} from "./HashConsensus.sol"; +import {ProposalsList} from "./ProposalsList.sol"; + +interface ITiebreakerCore { + function getSealableResumeNonce(address sealable) external view returns (uint256 nonce); + function scheduleProposal(uint256 _proposalId) external; + function sealableResume(address sealable, uint256 nonce) external; +} + +enum ProposalType { + ScheduleProposal, + ResumeSelable +} + +contract TiebreakerSubCommittee is HashConsensus, ProposalsList { + address immutable TIEBREAKER_CORE; + + constructor( + address owner, + address[] memory committeeMembers, + uint256 executionQuorum, + address tiebreakerCore + ) HashConsensus(owner, committeeMembers, executionQuorum, 0) { + TIEBREAKER_CORE = tiebreakerCore; + } + + // Schedule proposal + + function scheduleProposal(uint256 proposalId) public onlyMember { + (bytes memory proposalData, bytes32 key) = _encodeAproveProposal(proposalId); + _vote(key, true); + _pushProposal(key, uint256(ProposalType.ScheduleProposal), proposalData); + } + + function getScheduleProposalState(uint256 proposalId) + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + (, bytes32 key) = _encodeAproveProposal(proposalId); + return _getHashState(key); + } + + function executeScheduleProposal(uint256 proposalId) public { + (, bytes32 key) = _encodeAproveProposal(proposalId); + _markUsed(key); + Address.functionCall( + TIEBREAKER_CORE, abi.encodeWithSelector(ITiebreakerCore.scheduleProposal.selector, proposalId) + ); + } + + function _encodeAproveProposal(uint256 proposalId) internal pure returns (bytes memory data, bytes32 key) { + data = abi.encode(ProposalType.ScheduleProposal, data); + key = keccak256(data); + } + + // Sealable resume + + function sealableResume(address sealable) public { + (bytes memory proposalData, bytes32 key,) = _encodeSealableResume(sealable); + _vote(key, true); + _pushProposal(key, uint256(ProposalType.ResumeSelable), proposalData); + } + + function getSealableResumeState(address sealable) + public + view + returns (uint256 support, uint256 execuitionQuorum, bool isExecuted) + { + (, bytes32 key,) = _encodeSealableResume(sealable); + return _getHashState(key); + } + + function executeSealableResume(address sealable) public { + (, bytes32 key, uint256 nonce) = _encodeSealableResume(sealable); + _markUsed(key); + Address.functionCall( + TIEBREAKER_CORE, abi.encodeWithSelector(ITiebreakerCore.sealableResume.selector, sealable, nonce) + ); + } + + function _encodeSealableResume(address sealable) + internal + view + returns (bytes memory data, bytes32 key, uint256 nonce) + { + nonce = ITiebreakerCore(TIEBREAKER_CORE).getSealableResumeNonce(sealable); + data = abi.encode(sealable, nonce); + key = keccak256(data); + } +} diff --git a/contracts/interfaces/IGateSeal.sol b/contracts/interfaces/IGateSeal.sol index 2b2a121d..9dd6d07e 100644 --- a/contracts/interfaces/IGateSeal.sol +++ b/contracts/interfaces/IGateSeal.sol @@ -2,8 +2,5 @@ pragma solidity 0.8.23; interface IGateSeal { - function get_min_seal_duration() external view returns (uint256); - function get_expiry_timestamp() external view returns (uint256); - function sealed_sealables() external view returns (address[] memory); function seal(address[] calldata sealables) external; } diff --git a/contracts/interfaces/IResealManager.sol b/contracts/interfaces/IResealManager.sol new file mode 100644 index 00000000..ca1f2ee4 --- /dev/null +++ b/contracts/interfaces/IResealManager.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +interface IResealManager { + function resume(address sealable) external; + function reseal(address[] memory sealables) external; +} diff --git a/contracts/interfaces/ISealable.sol b/contracts/interfaces/ISealable.sol index 12239b92..df924ec5 100644 --- a/contracts/interfaces/ISealable.sol +++ b/contracts/interfaces/ISealable.sol @@ -5,4 +5,5 @@ interface ISealable { function resume() external; function pauseFor(uint256 duration) external; function isPaused() external view returns (bool); + function getResumeSinceTimestamp() external view returns (uint256); } diff --git a/contracts/interfaces/IWithdrawalQueue.sol b/contracts/interfaces/IWithdrawalQueue.sol index f6ae3a6c..0d2ac472 100644 --- a/contracts/interfaces/IWithdrawalQueue.sol +++ b/contracts/interfaces/IWithdrawalQueue.sol @@ -55,4 +55,8 @@ interface IWithdrawalQueue { uint256[] calldata _amounts, address _owner ) external returns (uint256[] memory requestIds); + + function grantRole(bytes32 role, address account) external; + function pauseFor(uint256 duration) external; + function isPaused() external returns (bool); } diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index aaec71db..5b030899 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -38,6 +38,7 @@ library DualGovernanceState { error AlreadyInitialized(); error ProposalsCreationSuspended(); error ProposalsAdoptionSuspended(); + error ResealIsNotAllowedInNormalState(); event NewSignallingEscrowDeployed(address indexed escrow); event DualGovernanceStateChanged(State oldState, State newState); @@ -103,6 +104,12 @@ library DualGovernanceState { } } + function checkResealState(Store storage self) internal view { + if (self.state == State.Normal) { + revert ResealIsNotAllowedInNormalState(); + } + } + function currentState(Store storage self) internal view returns (State) { return self.state; } diff --git a/contracts/libraries/EnumerableProposals.sol b/contracts/libraries/EnumerableProposals.sol new file mode 100644 index 00000000..d5954f9e --- /dev/null +++ b/contracts/libraries/EnumerableProposals.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +struct Proposal { + uint40 submittedAt; + uint256 proposalType; + bytes data; +} + +library EnumerableProposals { + using EnumerableSet for EnumerableSet.Bytes32Set; + + error ProposalDoesNotExist(bytes32 key); + error OffsetOutOfBounds(); + + struct Bytes32ToProposalMap { + bytes32[] _orderedKeys; + EnumerableSet.Bytes32Set _keys; + mapping(bytes32 key => Proposal) _proposals; + } + + function push( + Bytes32ToProposalMap storage map, + bytes32 key, + uint256 proposalType, + bytes memory data + ) internal returns (bool) { + if (!contains(map, key)) { + Proposal memory proposal = Proposal(uint40(block.timestamp), proposalType, data); + map._proposals[key] = proposal; + map._orderedKeys.push(key); + map._keys.add(key); + return true; + } + return false; + } + + function contains(Bytes32ToProposalMap storage map, bytes32 key) internal view returns (bool) { + return map._keys.contains(key); + } + + function length(Bytes32ToProposalMap storage map) internal view returns (uint256) { + return map._orderedKeys.length; + } + + function at(Bytes32ToProposalMap storage map, uint256 index) internal view returns (Proposal memory) { + bytes32 key = map._orderedKeys[index]; + return map._proposals[key]; + } + + function get(Bytes32ToProposalMap storage map, bytes32 key) internal view returns (Proposal memory value) { + if (!contains(map, key)) { + revert ProposalDoesNotExist(key); + } + value = map._proposals[key]; + } + + function orederedKeys(Bytes32ToProposalMap storage map) internal view returns (bytes32[] memory) { + return map._orderedKeys; + } + + function orederedKeys( + Bytes32ToProposalMap storage map, + uint256 offset, + uint256 limit + ) internal view returns (bytes32[] memory keys) { + if (offset >= map._orderedKeys.length) { + revert OffsetOutOfBounds(); + } + + uint256 keysLength = limit; + if (keysLength > map._orderedKeys.length - offset) { + keysLength = map._orderedKeys.length - offset; + } + + keys = new bytes32[](keysLength); + for (uint256 i = 0; i < keysLength; ++i) { + keys[i] = map._orderedKeys[offset + i]; + } + } +} diff --git a/contracts/libraries/TiebreakerProtection.sol b/contracts/libraries/TiebreakerProtection.sol new file mode 100644 index 00000000..fb30649a --- /dev/null +++ b/contracts/libraries/TiebreakerProtection.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +interface IResealManger { + function resume(address sealable) external; +} + +library TiebreakerProtection { + struct Tiebreaker { + address tiebreaker; + IResealManger resealManager; + } + + event TiebreakerSet(address tiebreakCommittee, address resealManager); + event SealableResumed(address sealable); + + error ProposalNotExecutable(uint256 proposalId); + error NotTiebreaker(address account, address tiebreakCommittee); + error TieBreakerAddressIsSame(); + + function resumeSealable(Tiebreaker storage self, address sealable) internal { + self.resealManager.resume(sealable); + emit SealableResumed(sealable); + } + + function setTiebreaker(Tiebreaker storage self, address tiebreaker, address resealManager) internal { + if (self.tiebreaker == tiebreaker) { + revert TieBreakerAddressIsSame(); + } + + self.tiebreaker = tiebreaker; + self.resealManager = IResealManger(resealManager); + emit TiebreakerSet(tiebreaker, resealManager); + } + + function checkTiebreakerCommittee(Tiebreaker storage self, address account) internal view { + if (account != self.tiebreaker) { + revert NotTiebreaker(account, self.tiebreaker); + } + } +} diff --git a/contracts/model/DualGovernance.sol b/contracts/model/DualGovernance.sol index 730c272c..c5a192f0 100644 --- a/contracts/model/DualGovernance.sol +++ b/contracts/model/DualGovernance.sol @@ -86,7 +86,7 @@ contract DualGovernance { "Proposals can only be scheduled in Normal or Veto Cooldown states." ); if (currentState == State.VetoCooldown) { - (,,,uint256 submissionTime,) = emergencyProtectedTimelock.proposals(proposalId); + (,,, uint256 submissionTime,) = emergencyProtectedTimelock.proposals(proposalId); require( submissionTime < lastVetoSignallingTime, "Proposal submitted after the last time Veto Signalling state was entered." diff --git a/docs/specification.md b/docs/specification.md index 8e402254..9a99fe73 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -41,7 +41,13 @@ The system is composed of the following main contracts: * [`DualGovernance.sol`](#Contract-DualGovernancesol) is a singleton that provides an interface for submitting governance proposals and scheduling their execution, as well as managing the list of supported proposers (DAO voting systems). Implements a state machine tracking the current global governance state which, in turn, determines whether proposal submission and execution is currently allowed. * [`EmergencyProtectedTimelock.sol`](#Contract-EmergencyProtectedTimelocksol) is a singleton that stores submitted proposals and provides an interface for their execution. In addition, it implements an optional temporary protection from a zero-day vulnerability in the dual governance contracts following the initial deployment or upgrade of the system. The protection is implemented as a timelock on proposal execution combined with two emergency committees that have the right to cooperate and disable the dual governance. * [`Executor.sol`](#Contract-Executorsol) contract instances make calls resulting from governance proposals' execution. Every protocol permission or role protected by the DG, as well as the permission to manage this role/permission, should be assigned exclusively to one of the instances of this contract (in contrast with being assigned directly to a DAO voting system). +* [`ResealExecutor.sol`](#Contract-ResealExecutorsol) contract instances make calls to extend protocol withdrawals pause in case of contracts were put into an emergency pause by the [GateSeal emergency protection mechanism](https://github.com/lidofinance/gate-seals) and the DAO governance is currently blocked by the DG system. Has pause and resume roles for all protocols withdrawals contracts. * [`Escrow.sol`](#Contract-Escrowsol) is a contract that can hold stETH, wstETH, withdrawal NFTs, and plain ETH. It can exist in two states, each serving a different purpose: either an oracle for users' opposition to DAO proposals or an immutable and ungoverned accumulator for the ETH withdrawn as a result of the [rage quit](#Rage-quit). +* [`TiebreakerCore.sol`](#contract-tiebreakercoresol) allows to approve proposals for execution and release protocol withdrawals in case of DAO execution ability is locked by `DualGovernance`. Consists of set of `TiebreakerSubCommittee` appointed by the DAO. +* [`TiebreakerSubCommittee.sol`](#contract-tiebreakersubcommitteesol) provides ability to participate in `TiebreakerCore` for external actors. +* [`EmergencyActivationCommittee`](#contract-emergencyactivationcommitteesol) contract that can activate the Emergency Mode, while only `EmergencyExecutionCommittee` can perform proposal execution. Requires to get quorum from committee members. +* [`EmergencyExecutionCommittee`](#contract-emergencyexecutioncommitteesol) contract provides ability to execute proposals in case of the Emergency Mode or renounce renounce further execution rights, by getting quorum of committee members. +* [`ResealExecutor`] ## Proposal flow @@ -205,7 +211,7 @@ The main entry point to the dual governance system. * Implements a state machine tracking the current [global governance state](#Governance-state) which, in turn, determines whether proposal submission and execution is currently allowed. * Deploys and tracks the [`Escrow`](#Contract-Escrowsol) contract instances. Tracks the current signalling escrow. -This contract is a singleton, meaning that any DG deployment includes exectly one instance of this contract. +This contract is a singleton, meaning that any DG deployment includes exactly one instance of this contract. ### Enum: DualGovernance.State @@ -439,6 +445,46 @@ The result of the call. * MUST be called by the contract owner (which SHOULD be the [`EmergencyProtectedTimelock`](#Contract-EmergencyProtectedTimelocksol) singleton instance). +## Contract: ResealExecutor.sol + +In the Lido protocol, specific critical components (`WithdrawalQueue` and `ValidatorsExitBus`) are safeguarded by the `GateSeal` contract instance. According to the gate seals [documentation](https://github.com/lidofinance/gate-seals?tab=readme-ov-file#what-is-a-gateseal): + +>*"A GateSeal is a contract that allows the designated account to instantly put a set of contracts on pause (i.e. seal) for a limited duration. This will give the Lido DAO the time to come up with a solution, hold a vote, implement changes, etc.".* + +However, the effectiveness of this approach is contingent upon the predictability of the DAO's solution adoption timeframe. With the dual governance system, proposal execution may experience significant delays based on the current state of the `DualGovernance` contract. There's a risk that `GateSeal`'s pause period may expire before the Lido DAO can implement the necessary fixes. + +To address this compatibility challenge between gate seals and dual governance, the `ResealExecutor` contract is introduced. The `ResealExecutor` allows to extend pause of temporarily paused contracts to permanent pause, if conditions are met: +- `ResealExecutor` has `PAUSE_ROLE` and `RESUME_ROLE` for target contracts. +- Contracts are paused until timestamp after current timestamp and not for infinite time. +- The DAO governance is blocked by `DualGovernance`. + +It inherits `OwnableExecutor` and provides ability to extend contracts pause for committee set by DAO. + +### Function ResealExecutor.reseal + +```solidity +function reseal(address[] memory sealables) +``` + +This function extends pause of `sealables`. Can be called by committee address. + +#### Preconditions + +- `ResealExecutor` has `PAUSE_ROLE` and `RESUME_ROLE` for target contracts. +- Contracts are paused until timestamp after current timestamp and not for infinite time. +- The DAO governance is blocked by `DualGovernance`. + +### Function ResealExecutor.setResealCommittee + +```solidity +function setResealCommittee(address newResealCommittee) +``` + +This function set `resealCommittee` address to `newResealCommittee`. Can be called by owner. + +#### Preconditions + +- Can be called by `OWNER`. ## Contract: Escrow.sol @@ -479,8 +525,8 @@ The total rage quit support is updated proportionally to the number of shares co ```solidity amountInShares = stETH.getSharesByPooledEther(amount); -_assets[msg.sender].stETHLockedShares += amountInShares; -_stETHTotals.lockedShares += amountInShares; +assets[msg.sender].stETHLockedShares += amountInShares; +stETHTotals.lockedShares += amountInShares; ``` The rage quit support will be dynamically updated to reflect changes in the stETH balance due to protocol rewards or validators slashing. @@ -508,8 +554,8 @@ Allows the caller (i.e., `msg.sender`) to unlock all previously locked stETH and For accurate rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: ```solidity -_stETHTotals.lockedShares -= _assets[msg.sender].stETHLockedShares; -_assets[msg.sender].stETHLockedShares = 0; +stETHTotals.lockedShares -= _assets[msg.sender].stETHLockedShares; +assets[msg.sender].stETHLockedShares = 0; ``` Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. @@ -539,8 +585,8 @@ stETHAmount = WST_ETH.unwrap(amount); // Use getSharesByPooledEther(), because unwrap() method may transfer 1 wei less amount of stETH stETHShares = ST_ETH.getSharesByPooledEth(stETHAmount); -_assets[msg.sender].stETHLockedShares += stETHShares; -_stETHTotals.lockedShares += stETHShares; +assets[msg.sender].stETHLockedShares += stETHShares; +stETHTotals.lockedShares += stETHShares; ``` Finally, the function calls the `DualGovernance.activateNextState()`. This action may transition the `Escrow` instance from the `SignallingEscrow` state into the `RageQuitEscrow` state. @@ -566,8 +612,8 @@ Allows the caller (i.e. `msg.sender`) to unlock previously locked wstETH and stE For the correct rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: ```solidity -_stETHTotals.lockedShares -= _assets[msg.sender].stETHLockedShares; -_assets[msg.sender].stETHLockedShares = 0; +stETHTotals.lockedShares -= _assets[msg.sender].stETHLockedShares; +assets[msg.sender].stETHLockedShares = 0; ``` Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. @@ -597,8 +643,8 @@ To correctly calculate the rage quit support (see the `Escrow.getRageQuitSupport ```solidity uint256 amountOfShares = withdrawalRequests[id].amountOfShares; -_assets[msg.sender].unstETHLockedShares += amountOfShares; -_unstETHTotals.unfinalizedShares += amountOfShares; +assets[msg.sender].unstETHLockedShares += amountOfShares; +unstETHTotals.unfinalizedShares += amountOfShares; ``` Finally, calls the `DualGovernance.activateNextState()` function. This action may transition the `Escrow` instance from the `SignallingEscrow` state into the `RageQuitEscrow` state. @@ -696,7 +742,7 @@ The returned value represents the total rage quit support expressed as a percent ```solidity uint256 finalizedETH = unstETHTotals.finalizedETH; -uint256 ufinalizedShares = stETHTotals.lockedShares + unstETHTotals.unfinalizedShares; +uint256 unfinalizedShares = stETHTotals.lockedShares + unstETHTotals.unfinalizedShares; return 10 ** 18 * ( ST_ETH.getPooledEtherByShares(unfinalizedShares) + finalizedETH @@ -767,7 +813,7 @@ function claimUnstETH(uint256[] unstETHIds, uint256[] hints) Allows users to claim the ETH associated with finalized withdrawal NFTs with ids `unstETHIds` locked in the `Escrow` contract. Upon calling this function, the claimed ETH is transferred to the `Escrow` contract instance. -To safeguard the ETH associated with withdrawal NFTs, this function should be invoked when the `Escrow` is in the `RageQuitEscrow` state and before the `RageQuitExtensionDelay` period ends. The ETH corresponding to unclaimed withdrawal NFTs after this period ends would still be controlled by the code potentially afftected by pending and future DAO decisions. +To safeguard the ETH associated with withdrawal NFTs, this function should be invoked when the `Escrow` is in the `RageQuitEscrow` state and before the `RageQuitExtensionDelay` period ends. The ETH corresponding to unclaimed withdrawal NFTs after this period ends would still be controlled by the code potentially affected by pending and future DAO decisions. #### Preconditions @@ -839,7 +885,7 @@ For a proposal to be executed, the following steps have to be performed in order The contract only allows proposal submission and scheduling by the `governance` address. Normally, this address points to the [`DualGovernance`](#Contract-DualGovernancesol) singleton instance. Proposal execution is permissionless, unless Emergency Mode is activated. -If the Emergency Committees are set up and active, the governance proposal gets a separate emergency protection delay between submitting and scheduling. This additional timelock is implemented in the `EmergencyProtectedTimelock` contract to protect from zero-day vulnerability in the logic of `DualGovenance.sol` and other core DG contracts. If the Emergency Committees aren't set, the proposal flow is the same, but the timelock duration is zero. +If the Emergency Committees are set up and active, the governance proposal gets a separate emergency protection delay between submitting and scheduling. This additional timelock is implemented in the `EmergencyProtectedTimelock` contract to protect from zero-day vulnerability in the logic of `DualGovernance.sol` and other core DG contracts. If the Emergency Committees aren't set, the proposal flow is the same, but the timelock duration is zero. Emergency Activation Committee, while active, can enable the Emergency Mode. This mode prohibits anyone but the Emergency Execution Committee from executing proposals. It also allows the Emergency Execution Committee to reset the governance, effectively disabling the Dual Governance subsystem. @@ -968,6 +1014,11 @@ The contract has the interface for managing the configuration related to emergen `Configuration.sol` is the smart contract encompassing all the constants in the Dual Governance design & providing the interfaces for getting access to them. It implements interfaces `IAdminExecutorConfiguration`, `ITimelockConfiguration`, `IDualGovernanceConfiguration` covering for relevant "parameters domains". +## Contract: TiebreakerCore.sol +## Contract: TiebreakerSubCommittee.sol +## Contract: EmergencyActivationCommittee.sol +## Contract: EmergencyExecutionCommittee.sol +## Contract: ResealCommittee.sol ## Upgrade flow description diff --git a/test/mocks/GateSealMock.sol b/test/mocks/GateSealMock.sol index fbd8e069..632c2269 100644 --- a/test/mocks/GateSealMock.sol +++ b/test/mocks/GateSealMock.sol @@ -12,11 +12,11 @@ contract GateSealMock is IGateSeal { uint256 internal constant _INFINITE_DURATION = type(uint256).max; uint256 internal _expiryTimestamp; - uint256 internal _minSealDuration; + uint256 internal _seal_duration_seconds; address[] internal _sealedSealables; - constructor(uint256 minSealDuration, uint256 lifetime) { - _minSealDuration = minSealDuration; + constructor(uint256 sealDurationSeconds, uint256 lifetime) { + _seal_duration_seconds = sealDurationSeconds; _expiryTimestamp = block.timestamp + lifetime; } @@ -28,22 +28,10 @@ contract GateSealMock is IGateSeal { _expiryTimestamp = block.timestamp; for (uint256 i = 0; i < sealables.length; ++i) { - ISealable(sealables[i]).pauseFor(_INFINITE_DURATION); + ISealable(sealables[i]).pauseFor(_seal_duration_seconds); assert(ISealable(sealables[i]).isPaused()); } emit SealablesSealed(sealables); } - - function sealed_sealables() external view returns (address[] memory) { - return _sealedSealables; - } - - function get_min_seal_duration() external view returns (uint256) { - return _minSealDuration; - } - - function get_expiry_timestamp() external view returns (uint256) { - return _expiryTimestamp; - } } diff --git a/test/scenario/agent-timelock.t.sol b/test/scenario/agent-timelock.t.sol index bfac8585..75e365c8 100644 --- a/test/scenario/agent-timelock.t.sol +++ b/test/scenario/agent-timelock.t.sol @@ -98,10 +98,10 @@ contract AgentTimelockTest is ScenarioTestBlueprint { _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); // committee resets governance - vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); + vm.prank(address(_emergencyActivationCommittee)); _timelock.activateEmergencyMode(); - vm.prank(_EMERGENCY_EXECUTION_COMMITTEE); + vm.prank(address(_emergencyExecutionCommittee)); _timelock.emergencyReset(); // proposal is canceled now diff --git a/test/scenario/gate-seal-breaker.t.sol b/test/scenario/gate-seal-breaker.t.sol deleted file mode 100644 index ee925d80..00000000 --- a/test/scenario/gate-seal-breaker.t.sol +++ /dev/null @@ -1,204 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import { - percents, ScenarioTestBlueprint, DurationType, Timestamps, Durations -} from "../utils/scenario-test-blueprint.sol"; - -import {GateSealMock} from "../mocks/GateSealMock.sol"; -import {GateSealBreaker, IGateSeal} from "contracts/GateSealBreaker.sol"; - -import {DAO_AGENT} from "../utils/mainnet-addresses.sol"; - -contract SealBreakerScenarioTest is ScenarioTestBlueprint { - DurationType private immutable _RELEASE_DELAY = Durations.from(5 days); - DurationType private immutable _MIN_SEAL_DURATION = Durations.from(14 days); - - address private immutable _VETOER = makeAddr("VETOER"); - - IGateSeal private _gateSeal; - address[] private _sealables; - GateSealBreaker private _sealBreaker; - - function setUp() external { - _selectFork(); - _deployTarget(); - _deployDualGovernanceSetup( /* isEmergencyProtectionEnabled */ false); - - _sealables.push(address(_WITHDRAWAL_QUEUE)); - - _gateSeal = new GateSealMock(_MIN_SEAL_DURATION.toSeconds(), _SEALING_COMMITTEE_LIFETIME.toSeconds()); - - _sealBreaker = new GateSealBreaker(_RELEASE_DELAY.toSeconds(), address(this), address(_dualGovernance)); - - _sealBreaker.registerGateSeal(_gateSeal); - - // grant rights to gate seal to pause/resume the withdrawal queue - vm.startPrank(DAO_AGENT); - _WITHDRAWAL_QUEUE.grantRole(_WITHDRAWAL_QUEUE.PAUSE_ROLE(), address(_gateSeal)); - _WITHDRAWAL_QUEUE.grantRole(_WITHDRAWAL_QUEUE.RESUME_ROLE(), address(_sealBreaker)); - vm.stopPrank(); - } - - function testFork_DualGovernanceLockedThenSeal() external { - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - _assertNormalState(); - - _lockStETH(_VETOER, percents("10.0")); - _assertVetoSignalingState(); - - // sealing committee seals Withdrawal Queue - vm.prank(_SEALING_COMMITTEE); - _gateSeal.seal(_sealables); - - // seal can't be released before the min sealing duration has passed - vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); - _sealBreaker.startRelease(_gateSeal); - - // validate Withdrawal Queue was paused - assertTrue(_WITHDRAWAL_QUEUE.isPaused()); - - _wait(_MIN_SEAL_DURATION.plusSeconds(1)); - - // validate the dual governance still in the veto signaling state - _assertVetoSignalingState(); - - // seal can't be released before the governance returns to Normal state - vm.expectRevert(GateSealBreaker.GovernanceLocked.selector); - _sealBreaker.startRelease(_gateSeal); - - // wait the governance returns to normal state - _wait(Durations.from(14 days)); - _activateNextState(); - _assertVetoSignalingDeactivationState(); - - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); - _activateNextState(); - _assertVetoCooldownState(); - - // anyone may start release the seal - _sealBreaker.startRelease(_gateSeal); - - // reverts until timelock - vm.expectRevert(GateSealBreaker.ReleaseDelayNotPassed.selector); - _sealBreaker.enactRelease(_gateSeal); - - // anyone may release the seal after timelock - _wait(_RELEASE_DELAY.plusSeconds(1)); - _sealBreaker.enactRelease(_gateSeal); - - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - } - - function testFork_SealThenDualGovernanceLocked() external { - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - _assertNormalState(); - - // sealing committee seals the Withdrawal Queue - vm.prank(_SEALING_COMMITTEE); - _gateSeal.seal(_sealables); - - // validate Withdrawal Queue was paused - assertTrue(_WITHDRAWAL_QUEUE.isPaused()); - - // wait some time, before dual governance enters veto signaling state - _wait(_MIN_SEAL_DURATION.dividedBy(2)); - - _lockStETH(_VETOER, percents("10.0")); - _assertVetoSignalingState(); - - // seal can't be released before the min sealing duration has passed - vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); - _sealBreaker.startRelease(_gateSeal); - - _wait(_MIN_SEAL_DURATION.dividedBy(2).plusSeconds(1)); - - // seal can't be released before the governance returns to Normal state - vm.expectRevert(GateSealBreaker.GovernanceLocked.selector); - _sealBreaker.startRelease(_gateSeal); - - // wait the governance returns to normal state - _wait(Durations.from(14 days)); - _activateNextState(); - _assertVetoSignalingDeactivationState(); - - _wait(_dualGovernance.CONFIG().VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); - _activateNextState(); - _assertVetoCooldownState(); - - // the stETH whale takes his funds back from Escrow - _unlockStETH(_VETOER); - - _wait(_dualGovernance.CONFIG().VETO_COOLDOWN_DURATION().plusSeconds(1)); - _activateNextState(); - _assertNormalState(); - - // now seal may be released - _sealBreaker.startRelease(_gateSeal); - - // reverts until timelock - vm.expectRevert(GateSealBreaker.ReleaseDelayNotPassed.selector); - _sealBreaker.enactRelease(_gateSeal); - - // anyone may release the seal after timelock - _wait(_RELEASE_DELAY.plusSeconds(1)); - _sealBreaker.enactRelease(_gateSeal); - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - } - - function testFork_SealWhenDualGovernanceNotLocked() external { - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - _assertNormalState(); - - // sealing committee seals the Withdrawal Queue - vm.prank(_SEALING_COMMITTEE); - _gateSeal.seal(_sealables); - - // validate Withdrawal Queue was paused - assertTrue(_WITHDRAWAL_QUEUE.isPaused()); - - // seal can't be released before the min sealing duration has passed - vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); - _sealBreaker.startRelease(_gateSeal); - - _wait(_MIN_SEAL_DURATION.plusSeconds(1)); - - // now seal may be released - _sealBreaker.startRelease(_gateSeal); - - // reverts until timelock - vm.expectRevert(GateSealBreaker.ReleaseDelayNotPassed.selector); - _sealBreaker.enactRelease(_gateSeal); - - // anyone may release the seal after timelock - _wait(_RELEASE_DELAY.plusSeconds(1)); - _sealBreaker.enactRelease(_gateSeal); - - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - } - - function testFork_GateSealMayBeReleasedOnlyOnce() external { - assertFalse(_WITHDRAWAL_QUEUE.isPaused()); - _assertNormalState(); - - // sealing committee seals the Withdrawal Queue - vm.prank(_SEALING_COMMITTEE); - _gateSeal.seal(_sealables); - - // validate Withdrawal Queue was paused - assertTrue(_WITHDRAWAL_QUEUE.isPaused()); - - // seal can't be released before the min sealing duration has passed - vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); - _sealBreaker.startRelease(_gateSeal); - - _wait(_MIN_SEAL_DURATION.plusSeconds(1)); - - // now seal may be released - _sealBreaker.startRelease(_gateSeal); - - // An attempt to release same gate seal the second time fails - vm.expectRevert(GateSealBreaker.GateSealAlreadyReleased.selector); - _sealBreaker.startRelease(_gateSeal); - } -} diff --git a/test/scenario/happy-path-plan-b.t.sol b/test/scenario/happy-path-plan-b.t.sol index 3083c0c3..ac52bc20 100644 --- a/test/scenario/happy-path-plan-b.t.sol +++ b/test/scenario/happy-path-plan-b.t.sol @@ -75,7 +75,7 @@ contract PlanBSetup is ScenarioTestBlueprint { _assertCanSchedule(_singleGovernance, maliciousProposalId, false); // emergency committee activates emergency mode - vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); + vm.prank(address(_emergencyActivationCommittee)); _timelock.activateEmergencyMode(); // emergency mode was successfully activated @@ -127,8 +127,8 @@ contract PlanBSetup is ScenarioTestBlueprint { abi.encodeCall( _timelock.setEmergencyProtection, ( - _EMERGENCY_ACTIVATION_COMMITTEE, - _EMERGENCY_EXECUTION_COMMITTEE, + address(_emergencyActivationCommittee), + address(_emergencyExecutionCommittee), _EMERGENCY_PROTECTION_DURATION, Durations.from(30 days) ) @@ -150,8 +150,7 @@ contract PlanBSetup is ScenarioTestBlueprint { _waitAfterScheduleDelayPassed(); // now emergency committee may execute the proposal - vm.prank(_EMERGENCY_EXECUTION_COMMITTEE); - _timelock.emergencyExecute(dualGovernanceLunchProposalId); + _executeEmergencyExecute(dualGovernanceLunchProposalId); assertEq(_timelock.getGovernance(), address(_dualGovernance)); // TODO: check emergency protection also was applied @@ -205,8 +204,8 @@ contract PlanBSetup is ScenarioTestBlueprint { abi.encodeCall( _timelock.setEmergencyProtection, ( - _EMERGENCY_ACTIVATION_COMMITTEE, - _EMERGENCY_EXECUTION_COMMITTEE, + address(_emergencyActivationCommittee), + address(_emergencyExecutionCommittee), _EMERGENCY_PROTECTION_DURATION, Durations.from(30 days) ) @@ -235,8 +234,8 @@ contract PlanBSetup is ScenarioTestBlueprint { assertTrue(_timelock.isEmergencyProtectionEnabled()); emergencyState = _timelock.getEmergencyState(); - assertEq(emergencyState.activationCommittee, _EMERGENCY_ACTIVATION_COMMITTEE); - assertEq(emergencyState.executionCommittee, _EMERGENCY_EXECUTION_COMMITTEE); + assertEq(emergencyState.activationCommittee, address(_emergencyActivationCommittee)); + assertEq(emergencyState.executionCommittee, address(_emergencyExecutionCommittee)); assertFalse(emergencyState.isEmergencyModeActivated); assertEq(emergencyState.emergencyModeDuration, Durations.from(30 days)); assertEq(emergencyState.emergencyModeEndsAfter, Timestamps.ZERO); @@ -292,7 +291,7 @@ contract PlanBSetup is ScenarioTestBlueprint { { _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); - vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); + vm.prank(address(_emergencyActivationCommittee)); _timelock.activateEmergencyMode(); emergencyState = _timelock.getEmergencyState(); @@ -391,7 +390,7 @@ contract PlanBSetup is ScenarioTestBlueprint { // emergency committee activates emergency mode EmergencyState memory emergencyState; { - vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); + vm.prank(address(_emergencyActivationCommittee)); _timelock.activateEmergencyMode(); emergencyState = _timelock.getEmergencyState(); @@ -404,8 +403,7 @@ contract PlanBSetup is ScenarioTestBlueprint { _wait(_EMERGENCY_MODE_DURATION.dividedBy(2)); assertTrue(emergencyState.emergencyModeEndsAfter > Timestamps.now()); - vm.prank(_EMERGENCY_EXECUTION_COMMITTEE); - _timelock.emergencyReset(); + _executeEmergencyReset(); assertEq(_timelock.getGovernance(), _config.EMERGENCY_GOVERNANCE()); @@ -441,7 +439,7 @@ contract PlanBSetup is ScenarioTestBlueprint { emergencyState.protectedTill ) ); - vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); + vm.prank(address(_emergencyActivationCommittee)); _timelock.activateEmergencyMode(); } } diff --git a/test/scenario/tiebraker.t.sol b/test/scenario/tiebraker.t.sol new file mode 100644 index 00000000..4af73b90 --- /dev/null +++ b/test/scenario/tiebraker.t.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import { + ScenarioTestBlueprint, percents, ExecutorCall, ExecutorCallHelpers +} from "../utils/scenario-test-blueprint.sol"; + +import {EmergencyProtectedTimelock} from "contracts/EmergencyProtectedTimelock.sol"; + +import {DAO_AGENT} from "../utils/mainnet-addresses.sol"; + +contract TiebreakerScenarioTest is ScenarioTestBlueprint { + address internal immutable _VETOER = makeAddr("VETOER"); + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + + function setUp() external { + _selectFork(); + _deployDualGovernanceSetup( /* isEmergencyProtectionEnabled */ false); + _depositStETH(_VETOER, 1 ether); + } + + function test_proposal_approval() external { + uint256 quorum; + uint256 support; + bool isExecuted; + + address[] memory members; + + // Tiebreak activation + _assertNormalState(); + _lockStETH(_VETOER, percents(_config.SECOND_SEAL_RAGE_QUIT_SUPPORT())); + _lockStETH(_VETOER, 1 gwei); + _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION().plusSeconds(1)); + _activateNextState(); + _assertRageQuitState(); + _wait(_config.TIE_BREAK_ACTIVATION_TIMEOUT()); + _activateNextState(); + + ExecutorCall[] memory proposalCalls = ExecutorCallHelpers.create(address(0), new bytes(0)); + uint256 proposalIdToExecute = _submitProposal(_dualGovernance, "Proposal for execution", proposalCalls); + + // Tiebreaker subcommittee 0 + members = _tiebreakerSubCommittees[0].getMembers(); + for (uint256 i = 0; i < _tiebreakerSubCommittees[0].quorum() - 1; i++) { + vm.prank(members[i]); + _tiebreakerSubCommittees[0].scheduleProposal(proposalIdToExecute); + (support, quorum, isExecuted) = _tiebreakerSubCommittees[0].getScheduleProposalState(proposalIdToExecute); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _tiebreakerSubCommittees[0].scheduleProposal(proposalIdToExecute); + (support, quorum, isExecuted) = _tiebreakerSubCommittees[0].getScheduleProposalState(proposalIdToExecute); + assert(support == quorum); + assert(isExecuted == false); + + _tiebreakerSubCommittees[0].executeScheduleProposal(proposalIdToExecute); + (support, quorum, isExecuted) = _tiebreakerCommittee.getScheduleProposalState(proposalIdToExecute); + assert(support < quorum); + + // Tiebreaker subcommittee 1 + members = _tiebreakerSubCommittees[1].getMembers(); + for (uint256 i = 0; i < _tiebreakerSubCommittees[1].quorum() - 1; i++) { + vm.prank(members[i]); + _tiebreakerSubCommittees[1].scheduleProposal(proposalIdToExecute); + (support, quorum, isExecuted) = _tiebreakerSubCommittees[1].getScheduleProposalState(proposalIdToExecute); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _tiebreakerSubCommittees[1].scheduleProposal(proposalIdToExecute); + (support, quorum, isExecuted) = _tiebreakerSubCommittees[1].getScheduleProposalState(proposalIdToExecute); + assert(support == quorum); + assert(isExecuted == false); + + // Approve proposal for scheduling + _tiebreakerSubCommittees[1].executeScheduleProposal(proposalIdToExecute); + (support, quorum, isExecuted) = _tiebreakerCommittee.getScheduleProposalState(proposalIdToExecute); + assert(support == quorum); + + // Waiting for submit delay pass + _wait(_config.AFTER_SUBMIT_DELAY()); + + _tiebreakerCommittee.executeScheduleProposal(proposalIdToExecute); + } + + function test_resume_withdrawals() external { + uint256 quorum; + uint256 support; + bool isExecuted; + + address[] memory members; + + vm.prank(DAO_AGENT); + _WITHDRAWAL_QUEUE.grantRole( + 0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d, address(DAO_AGENT) + ); + vm.prank(DAO_AGENT); + _WITHDRAWAL_QUEUE.pauseFor(type(uint256).max); + assertEq(_WITHDRAWAL_QUEUE.isPaused(), true); + + // Tiebreak activation + _assertNormalState(); + _lockStETH(_VETOER, percents(_config.SECOND_SEAL_RAGE_QUIT_SUPPORT())); + _lockStETH(_VETOER, 1 gwei); + _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION().plusSeconds(1)); + _activateNextState(); + _assertRageQuitState(); + _wait(_config.TIE_BREAK_ACTIVATION_TIMEOUT()); + _activateNextState(); + + // Tiebreaker subcommittee 0 + members = _tiebreakerSubCommittees[0].getMembers(); + for (uint256 i = 0; i < _tiebreakerSubCommittees[0].quorum() - 1; i++) { + vm.prank(members[i]); + _tiebreakerSubCommittees[0].sealableResume(address(_WITHDRAWAL_QUEUE)); + (support, quorum, isExecuted) = + _tiebreakerSubCommittees[0].getSealableResumeState(address(_WITHDRAWAL_QUEUE)); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _tiebreakerSubCommittees[0].sealableResume(address(_WITHDRAWAL_QUEUE)); + (support, quorum, isExecuted) = _tiebreakerSubCommittees[0].getSealableResumeState(address(_WITHDRAWAL_QUEUE)); + assert(support == quorum); + assert(isExecuted == false); + + _tiebreakerSubCommittees[0].executeSealableResume(address(_WITHDRAWAL_QUEUE)); + (support, quorum, isExecuted) = _tiebreakerCommittee.getSealableResumeState( + address(_WITHDRAWAL_QUEUE), _tiebreakerCommittee.getSealableResumeNonce(address(_WITHDRAWAL_QUEUE)) + ); + assert(support < quorum); + + // Tiebreaker subcommittee 1 + members = _tiebreakerSubCommittees[1].getMembers(); + for (uint256 i = 0; i < _tiebreakerSubCommittees[1].quorum() - 1; i++) { + vm.prank(members[i]); + _tiebreakerSubCommittees[1].sealableResume(address(_WITHDRAWAL_QUEUE)); + (support, quorum, isExecuted) = + _tiebreakerSubCommittees[1].getSealableResumeState(address(_WITHDRAWAL_QUEUE)); + assert(support < quorum); + assert(isExecuted == false); + } + + vm.prank(members[members.length - 1]); + _tiebreakerSubCommittees[1].sealableResume(address(_WITHDRAWAL_QUEUE)); + (support, quorum, isExecuted) = _tiebreakerSubCommittees[1].getSealableResumeState(address(_WITHDRAWAL_QUEUE)); + assert(support == quorum); + assert(isExecuted == false); + + _tiebreakerSubCommittees[1].executeSealableResume(address(_WITHDRAWAL_QUEUE)); + (support, quorum, isExecuted) = _tiebreakerCommittee.getSealableResumeState( + address(_WITHDRAWAL_QUEUE), _tiebreakerCommittee.getSealableResumeNonce(address(_WITHDRAWAL_QUEUE)) + ); + assert(support == quorum); + + _tiebreakerCommittee.executeSealableResume(address(_WITHDRAWAL_QUEUE)); + + assertEq(_WITHDRAWAL_QUEUE.isPaused(), false); + } +} diff --git a/test/unit/SingleGovernance.t.sol b/test/unit/SingleGovernance.t.sol index 489370bf..50619594 100644 --- a/test/unit/SingleGovernance.t.sol +++ b/test/unit/SingleGovernance.t.sol @@ -50,9 +50,7 @@ contract SingleGovernanceUnitTests is UnitTest { assertEq(_timelock.getSubmittedProposals().length, 0); vm.startPrank(stranger); - vm.expectRevert( - abi.encodeWithSelector(SingleGovernance.NotGovernance.selector, [stranger]) - ); + vm.expectRevert(abi.encodeWithSelector(SingleGovernance.NotGovernance.selector, [stranger])); _singleGovernance.submitProposal(_getTargetRegularStaffCalls(address(0x1))); assertEq(_timelock.getSubmittedProposals().length, 0); @@ -93,7 +91,7 @@ contract SingleGovernanceUnitTests is UnitTest { _timelock.setSchedule(1); _singleGovernance.scheduleProposal(1); - + _singleGovernance.cancelAllPendingProposals(); assertEq(_timelock.getLastCancelledProposalId(), 2); @@ -105,9 +103,7 @@ contract SingleGovernanceUnitTests is UnitTest { assertEq(_timelock.getLastCancelledProposalId(), 0); vm.startPrank(stranger); - vm.expectRevert( - abi.encodeWithSelector(SingleGovernance.NotGovernance.selector, [stranger]) - ); + vm.expectRevert(abi.encodeWithSelector(SingleGovernance.NotGovernance.selector, [stranger])); _singleGovernance.cancelAllPendingProposals(); assertEq(_timelock.getLastCancelledProposalId(), 0); @@ -123,4 +119,4 @@ contract SingleGovernanceUnitTests is UnitTest { assertTrue(_singleGovernance.canSchedule(1)); } -} \ No newline at end of file +} diff --git a/test/utils/interfaces.sol b/test/utils/interfaces.sol index 40cd495f..5a2e90f3 100644 --- a/test/utils/interfaces.sol +++ b/test/utils/interfaces.sol @@ -105,10 +105,13 @@ interface IWithdrawalQueue is IERC721 { function grantRole(bytes32 role, address account) external; function hasRole(bytes32 role, address account) external view returns (bool); function isPaused() external view returns (bool); + function resume() external; + function pauseFor(uint256 duration) external; + function getResumeSinceTimestamp() external view returns (uint256); } interface IDangerousContract { function doRegularStaff(uint256 magic) external; function doRugPool() external; function doControversialStaff() external; -} \ No newline at end of file +} diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index d1e53f2c..6354155d 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -15,6 +15,13 @@ import {Escrow, VetoerState, LockedAssetsTotals} from "contracts/Escrow.sol"; import {IConfiguration, Configuration} from "contracts/Configuration.sol"; import {Executor} from "contracts/Executor.sol"; +import {EmergencyActivationCommittee} from "contracts/committees/EmergencyActivationCommittee.sol"; +import {EmergencyExecutionCommittee} from "contracts/committees/EmergencyExecutionCommittee.sol"; +import {TiebreakerCore} from "contracts/committees/TiebreakerCore.sol"; +import {TiebreakerSubCommittee} from "contracts/committees/TiebreakerSubCommittee.sol"; + +import {ResealManager} from "contracts/ResealManager.sol"; + import { ExecutorCall, EmergencyState, @@ -39,7 +46,7 @@ import { import {ExecutorCallHelpers} from "../utils/executor-calls.sol"; import {Utils, TargetMock, console} from "../utils/utils.sol"; -import {DAO_VOTING, ST_ETH, WST_ETH, WITHDRAWAL_QUEUE} from "../utils/mainnet-addresses.sol"; +import {DAO_VOTING, ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, DAO_AGENT} from "../utils/mainnet-addresses.sol"; struct Balances { uint256 stETHAmount; @@ -69,12 +76,15 @@ contract ScenarioTestBlueprint is Test { DurationType internal immutable _SEALING_COMMITTEE_LIFETIME = Durations.from(365 days); address internal immutable _SEALING_COMMITTEE = makeAddr("SEALING_COMMITTEE"); - address internal immutable _TIEBREAK_COMMITTEE = makeAddr("TIEBREAK_COMMITTEE"); - IStEth public immutable _ST_ETH = IStEth(ST_ETH); IWstETH public immutable _WST_ETH = IWstETH(WST_ETH); IWithdrawalQueue public immutable _WITHDRAWAL_QUEUE = IWithdrawalQueue(WITHDRAWAL_QUEUE); + EmergencyActivationCommittee internal _emergencyActivationCommittee; + EmergencyExecutionCommittee internal _emergencyExecutionCommittee; + TiebreakerCore internal _tiebreakerCommittee; + TiebreakerSubCommittee[] internal _tiebreakerSubCommittees; + TargetMock internal _target; IConfiguration internal _config; @@ -90,6 +100,8 @@ contract ScenarioTestBlueprint is Test { SingleGovernance internal _singleGovernance; DualGovernance internal _dualGovernance; + ResealManager internal _resealManager; + address[] internal _sealableWithdrawalBlockers = [WITHDRAWAL_QUEUE]; // --- @@ -510,6 +522,9 @@ contract ScenarioTestBlueprint is Test { _deployEscrowMasterCopy(); _deployUngovernedTimelock(); _deployDualGovernance(); + _deployEmergencyActivationCommittee(); + _deployEmergencyExecutionCommittee(); + _deployTiebreaker(); _finishTimelockSetup(address(_dualGovernance), isEmergencyProtectionEnabled); } @@ -520,6 +535,9 @@ contract ScenarioTestBlueprint is Test { _deployEscrowMasterCopy(); _deployUngovernedTimelock(); _deploySingleGovernance(); + _deployEmergencyActivationCommittee(); + _deployEmergencyExecutionCommittee(); + _deployTiebreaker(); _finishTimelockSetup(address(_singleGovernance), isEmergencyProtectionEnabled); } @@ -558,6 +576,52 @@ contract ScenarioTestBlueprint is Test { _escrowMasterCopy = new Escrow(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, address(_config)); } + function _deployTiebreaker() internal { + uint256 subCommitteeMembersCount = 5; + uint256 subCommitteeQuorum = 5; + uint256 subCommitteesCount = 2; + + _tiebreakerCommittee = + new TiebreakerCore(address(_adminExecutor), new address[](0), 1, address(_dualGovernance), 0); + + for (uint256 i = 0; i < subCommitteesCount; ++i) { + address[] memory committeeMembers = new address[](subCommitteeMembersCount); + for (uint256 j = 0; j < subCommitteeMembersCount; j++) { + committeeMembers[j] = makeAddr(string(abi.encode(i + j * subCommitteeMembersCount + 65))); + } + _tiebreakerSubCommittees.push( + new TiebreakerSubCommittee( + address(_adminExecutor), committeeMembers, subCommitteeQuorum, address(_tiebreakerCommittee) + ) + ); + + vm.prank(address(_adminExecutor)); + _tiebreakerCommittee.addMember(address(_tiebreakerSubCommittees[i]), i + 1); + } + } + + function _deployEmergencyActivationCommittee() internal { + 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(0xFE + i * membersCount + 65))); + } + _emergencyActivationCommittee = + new EmergencyActivationCommittee(address(_adminExecutor), committeeMembers, quorum, address(_timelock)); + } + + function _deployEmergencyExecutionCommittee() internal { + 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(0xFD + i * membersCount + 65))); + } + _emergencyExecutionCommittee = + new EmergencyExecutionCommittee(address(_adminExecutor), committeeMembers, quorum, address(_timelock)); + } + function _finishTimelockSetup(address governance, bool isEmergencyProtectionEnabled) internal { if (isEmergencyProtectionEnabled) { _adminExecutor.execute( @@ -566,8 +630,8 @@ contract ScenarioTestBlueprint is Test { abi.encodeCall( _timelock.setEmergencyProtection, ( - _EMERGENCY_ACTIVATION_COMMITTEE, - _EMERGENCY_EXECUTION_COMMITTEE, + address(_emergencyActivationCommittee), + address(_emergencyExecutionCommittee), _EMERGENCY_PROTECTION_DURATION, _EMERGENCY_MODE_DURATION ) @@ -575,11 +639,24 @@ contract ScenarioTestBlueprint is Test { ); } + _resealManager = new ResealManager(address(_timelock)); + + vm.prank(DAO_AGENT); + _WITHDRAWAL_QUEUE.grantRole( + 0x139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d, address(_resealManager) + ); + vm.prank(DAO_AGENT); + _WITHDRAWAL_QUEUE.grantRole( + 0x2fc10cc8ae19568712f7a176fb4978616a610650813c9d05326c34abb62749c7, address(_resealManager) + ); + if (governance == address(_dualGovernance)) { _adminExecutor.execute( address(_dualGovernance), 0, - abi.encodeCall(_dualGovernance.setTiebreakerCommittee, (_TIEBREAK_COMMITTEE)) + abi.encodeCall( + _dualGovernance.setTiebreakerProtection, (address(_tiebreakerCommittee), address(_resealManager)) + ) ); } _adminExecutor.execute(address(_timelock), 0, abi.encodeCall(_timelock.setGovernance, (governance))); @@ -607,6 +684,33 @@ contract ScenarioTestBlueprint is Test { _wait(_config.AFTER_SCHEDULE_DELAY() + ONE_SECOND); } + function _executeEmergencyActivate() internal { + address[] memory members = _emergencyActivationCommittee.getMembers(); + for (uint256 i = 0; i < _emergencyActivationCommittee.quorum(); ++i) { + vm.prank(members[i]); + _emergencyActivationCommittee.approveEmergencyActivate(); + } + _emergencyActivationCommittee.executeEmergencyActivate(); + } + + function _executeEmergencyExecute(uint256 proposalId) internal { + address[] memory members = _emergencyExecutionCommittee.getMembers(); + for (uint256 i = 0; i < _emergencyExecutionCommittee.quorum(); ++i) { + vm.prank(members[i]); + _emergencyExecutionCommittee.voteEmergencyExecute(proposalId, true); + } + _emergencyExecutionCommittee.executeEmergencyExecute(proposalId); + } + + function _executeEmergencyReset() internal { + address[] memory members = _emergencyExecutionCommittee.getMembers(); + for (uint256 i = 0; i < _emergencyExecutionCommittee.quorum(); ++i) { + vm.prank(members[i]); + _emergencyExecutionCommittee.approveEmergencyReset(); + } + _emergencyExecutionCommittee.executeEmergencyReset(); + } + struct Duration { uint256 _days; uint256 _hours; diff --git a/test/utils/utils.sol b/test/utils/utils.sol index 9072e0ce..15224876 100644 --- a/test/utils/utils.sol +++ b/test/utils/utils.sol @@ -54,7 +54,7 @@ library Utils { function selectFork() internal { vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); - vm.rollFork(18984396); + vm.rollFork(20218312); } function encodeEvmCallScript(address target, bytes memory data) internal pure returns (bytes memory) {