diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index 29ae253b..0abe3b4b 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -123,10 +123,6 @@ contract DualGovernance { proposal = _proposals.adopt(proposalId, CONFIG.minProposalExecutionTimelock()); } - function _getTime() internal view virtual returns (uint256) { - return block.timestamp; - } - modifier onlyAdminExecutor() { if (msg.sender != TIMELOCK.ADMIN_EXECUTOR()) { revert Unauthorized(); diff --git a/contracts/EmergencyProtectedTimelock.sol b/contracts/EmergencyProtectedTimelock.sol index 9b429800..b665894d 100644 --- a/contracts/EmergencyProtectedTimelock.sol +++ b/contracts/EmergencyProtectedTimelock.sol @@ -1,13 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {IOwnable} from "./interfaces/IOwnable.sol"; import {ITimelock} from "./interfaces/ITimelock.sol"; -import {EmergencyProtection} from "./libraries/EmergencyProtection.sol"; +import {EmergencyProtection, EmergencyState} from "./libraries/EmergencyProtection.sol"; import {ScheduledCallsBatches, ScheduledCallsBatch, ExecutorCall} from "./libraries/ScheduledCalls.sol"; contract EmergencyProtectedTimelock is ITimelock { @@ -18,7 +17,6 @@ contract EmergencyProtectedTimelock is ITimelock { error NotGovernance(address sender); error NotAdminExecutor(address sender); - event DelaySet(uint256 delay); event GovernanceSet(address indexed governance); address public immutable ADMIN_EXECUTOR; @@ -34,19 +32,21 @@ contract EmergencyProtectedTimelock is ITimelock { EMERGENCY_GOVERNANCE = emergencyGovernance; } - // executes call immediately when the delay for scheduled calls is set to 0 + // executes call immediately when the delay is set to 0 function relay(address executor, ExecutorCall[] calldata calls) external onlyGovernance { _scheduledCalls.relay(executor, calls); } // schedules call to be executed after some delay function schedule(uint256 batchId, address executor, ExecutorCall[] calldata calls) external onlyGovernance { - _scheduledCalls.add(batchId, executor, calls); + _scheduledCalls.schedule(batchId, executor, calls); } // executes scheduled call function execute(uint256 batchId) external { - if (_emergencyProtection.isActive()) { + // Until the emergency mode is deactivated manually, the execution of the calls is allowed + // only for the emergency committee + if (_emergencyProtection.isEmergencyModeActivated()) { _emergencyProtection.validateIsCommittee(msg.sender); } _scheduledCalls.execute(batchId); @@ -56,16 +56,25 @@ contract EmergencyProtectedTimelock is ITimelock { _scheduledCalls.removeCanceled(batchId); } - function setGovernance(address governance, uint256 delay) external onlyAdminExecutor { - _setGovernance(governance, delay); + function setGovernanceAndDelay(address governance, uint256 delay) external onlyAdminExecutor { + _setGovernance(governance); + _scheduledCalls.setDelay(delay); + } + + function setDelay(uint256 delay) external onlyAdminExecutor { + _scheduledCalls.setDelay(delay); } function transferExecutorOwnership(address executor, address owner) external onlyAdminExecutor { IOwnable(executor).transferOwnership(owner); } - function setEmergencyProtection(address committee, uint256 lifetime, uint256 duration) external onlyAdminExecutor { - _emergencyProtection.setup(committee, lifetime, duration); + function setEmergencyProtection( + address committee, + uint256 protectionDuration, + uint256 emergencyModeDuration + ) external onlyAdminExecutor { + _emergencyProtection.setup(committee, protectionDuration, emergencyModeDuration); } function emergencyModeActivate() external { @@ -73,17 +82,19 @@ contract EmergencyProtectedTimelock is ITimelock { } function emergencyModeDeactivate() external { - if (_emergencyProtection.isActive()) { + if (!_emergencyProtection.isEmergencyModePassed()) { _assertAdminExecutor(); } - _scheduledCalls.cancelAll(); _emergencyProtection.deactivate(); + _scheduledCalls.cancelAll(); } function emergencyResetGovernance() external { - _scheduledCalls.cancelAll(); + _emergencyProtection.validateIsCommittee(msg.sender); _emergencyProtection.reset(); - _setGovernance(EMERGENCY_GOVERNANCE, 0); + _scheduledCalls.cancelAll(); + _scheduledCalls.setDelay(0); + _setGovernance(EMERGENCY_GOVERNANCE); } function getDelay() external view returns (uint256 delay) { @@ -107,41 +118,23 @@ contract EmergencyProtectedTimelock is ITimelock { } function getIsExecutable(uint256 batchId) external view returns (bool isExecutable) { - isExecutable = _scheduledCalls.isExecutable(batchId); + isExecutable = !_emergencyProtection.isEmergencyModeActivated() && _scheduledCalls.isExecutable(batchId); } function getIsCanceled(uint256 batchId) external view returns (bool isExecutable) { isExecutable = _scheduledCalls.isCanceled(batchId); } - struct EmergencyState { - bool isActive; - address committee; - uint256 protectedTill; - uint256 emergencyModeEndsAfter; - uint256 emergencyModeDuration; - } - function getEmergencyState() external view returns (EmergencyState memory res) { - EmergencyProtection.State memory state = _emergencyProtection; - res.isActive = _emergencyProtection.isActive(); - res.committee = state.committee; - res.protectedTill = state.protectedTill; - res.emergencyModeEndsAfter = state.emergencyModeEndsAfter; - res.emergencyModeDuration = state.emergencyModeDuration; + res = _emergencyProtection.getEmergencyState(); } - function _setGovernance(address governance, uint256 delay) internal { + function _setGovernance(address governance) internal { address prevGovernance = _governance; - uint256 prevDelay = _scheduledCalls.delay; if (prevGovernance != governance) { _governance = governance; emit GovernanceSet(governance); } - if (prevDelay != delay) { - _scheduledCalls.delay = delay.toUint32(); - emit DelaySet(delay); - } } function _assertAdminExecutor() private view { diff --git a/contracts/libraries/EmergencyProtection.sol b/contracts/libraries/EmergencyProtection.sol index 6656759f..23cf743c 100644 --- a/contracts/libraries/EmergencyProtection.sol +++ b/contracts/libraries/EmergencyProtection.sol @@ -3,21 +3,27 @@ pragma solidity 0.8.23; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +struct EmergencyState { + address committee; + uint256 protectedTill; + bool isEmergencyModeActivated; + uint256 emergencyModeDuration; + uint256 emergencyModeEndsAfter; +} + library EmergencyProtection { error NotEmergencyCommittee(address sender); - error EmergencyCommitteeExpired(); - error EmergencyModeNotEntered(); + error EmergencyModeNotActivated(); error EmergencyPeriodFinished(); - error EmergencyPeriodNotFinished(); - error EmergencyModeIsActive(); - error EmergencyModeWasActivatedPreviously(); + error EmergencyCommitteeExpired(); + error EmergencyModeAlreadyActive(); event EmergencyModeActivated(); event EmergencyModeDeactivated(); event EmergencyGovernanceReset(); event EmergencyCommitteeSet(address indexed guardian); - event EmergencyDurationSet(uint256 emergencyModeDuration); - event EmergencyCommitteeActiveTillSet(uint256 guardedTill); + event EmergencyModeDurationSet(uint256 emergencyModeDuration); + event EmergencyCommitteeProtectedTillSet(uint256 protectedTill); struct State { // has rights to activate emergency mode @@ -28,7 +34,12 @@ library EmergencyProtection { uint32 emergencyModeDuration; } - function setup(State storage self, address committee, uint256 lifetime, uint256 duration) internal { + function setup( + State storage self, + address committee, + uint256 protectionDuration, + uint256 emergencyModeDuration + ) internal { address prevCommittee = self.committee; if (prevCommittee != committee) { self.committee = committee; @@ -36,17 +47,17 @@ library EmergencyProtection { } uint256 prevProtectedTill = self.protectedTill; - uint256 protectedTill = block.timestamp + lifetime; + uint256 protectedTill = block.timestamp + protectionDuration; if (prevProtectedTill != protectedTill) { self.protectedTill = SafeCast.toUint40(protectedTill); - emit EmergencyCommitteeActiveTillSet(protectedTill); + emit EmergencyCommitteeProtectedTillSet(protectedTill); } uint256 prevDuration = self.emergencyModeDuration; - if (prevDuration != duration) { - self.emergencyModeDuration = SafeCast.toUint32(duration); - emit EmergencyDurationSet(duration); + if (prevDuration != emergencyModeDuration) { + self.emergencyModeDuration = SafeCast.toUint32(emergencyModeDuration); + emit EmergencyModeDurationSet(emergencyModeDuration); } } @@ -58,7 +69,7 @@ library EmergencyProtection { revert EmergencyCommitteeExpired(); } if (self.emergencyModeEndsAfter != 0) { - revert EmergencyModeIsActive(); + revert EmergencyModeAlreadyActive(); } self.emergencyModeEndsAfter = SafeCast.toUint40(block.timestamp + self.emergencyModeDuration); emit EmergencyModeActivated(); @@ -67,7 +78,7 @@ library EmergencyProtection { function deactivate(State storage self) internal { uint256 endsAfter = self.emergencyModeEndsAfter; if (endsAfter == 0) { - revert EmergencyModeNotEntered(); + revert EmergencyModeNotActivated(); } _reset(self); emit EmergencyModeDeactivated(); @@ -76,10 +87,7 @@ library EmergencyProtection { function reset(State storage self) internal { uint256 endsAfter = self.emergencyModeEndsAfter; if (endsAfter == 0) { - revert EmergencyModeNotEntered(); - } - if (msg.sender != self.committee) { - revert NotEmergencyCommittee(msg.sender); + revert EmergencyModeNotActivated(); } if (block.timestamp > endsAfter) { revert EmergencyPeriodFinished(); @@ -88,25 +96,31 @@ library EmergencyProtection { emit EmergencyGovernanceReset(); } - function isActive(State storage self) internal view returns (bool) { - uint256 endsAfter = self.emergencyModeEndsAfter; - if (endsAfter == 0) return false; - return endsAfter >= block.timestamp; - } - - function isCommittee(State storage self) internal view returns (bool) { - return msg.sender == self.committee; - } - function validateIsCommittee(State storage self, address account) internal view { if (self.committee != account) { revert NotEmergencyCommittee(account); } } + function getEmergencyState(State storage self) internal view returns (EmergencyState memory res) { + res.committee = self.committee; + res.protectedTill = self.protectedTill; + res.emergencyModeDuration = self.emergencyModeDuration; + res.emergencyModeEndsAfter = self.emergencyModeEndsAfter; + res.isEmergencyModeActivated = isEmergencyModeActivated(self); + } + + function isEmergencyModeActivated(State storage self) internal view returns (bool) { + return self.emergencyModeEndsAfter != 0; + } + + function isEmergencyModePassed(State storage self) internal view returns (bool) { + uint256 endsAfter = self.emergencyModeEndsAfter; + return endsAfter != 0 && block.timestamp > endsAfter; + } + function _reset(State storage self) private { self.committee = address(0); - self.protectedTill = 0; self.emergencyModeDuration = 0; self.emergencyModeEndsAfter = 0; } diff --git a/contracts/libraries/Proposals.sol b/contracts/libraries/Proposals.sol index a3c7eb57..30984982 100644 --- a/contracts/libraries/Proposals.sol +++ b/contracts/libraries/Proposals.sol @@ -17,7 +17,7 @@ struct Proposal { struct ProposalPacked { address proposer; uint40 proposedAt; - // time passed, starting from proposedAt to adopting the proposal + // Time passed, starting from the proposedAt till the adoption of the proposal uint32 adoptionTime; address executor; ExecutorCall[] calls; @@ -30,7 +30,7 @@ library Proposals { uint256 private constant FIRST_PROPOSAL_ID = 1; struct State { - // all proposals with ids less or equal than given one cannot be executed + // any proposals with ids less or equal to the given one cannot be executed uint256 lastCanceledProposalId; ProposalPacked[] proposals; } @@ -50,27 +50,27 @@ library Proposals { address proposer, address executor, ExecutorCall[] calldata calls - ) internal returns (uint256) { + ) internal returns (uint256 newProposalId) { if (calls.length == 0) { revert EmptyCalls(); } + newProposalId = self.proposals.length; self.proposals.push(); - uint256 newProposalId = self.proposals.length - FIRST_PROPOSAL_ID; + ProposalPacked storage newProposal = self.proposals[newProposalId]; newProposal.proposer = proposer; newProposal.executor = executor; newProposal.adoptionTime = 0; newProposal.proposedAt = block.timestamp.toUint40(); - // copying of arrays of custom types from calldata to storage has not supported - // by the Solidity compiler yet, so copy item by item + // copying of arrays of custom types from calldata to storage has not been supported by the + // Solidity compiler yet, so insert item by item for (uint256 i = 0; i < calls.length; ++i) { newProposal.calls.push(calls[i]); } emit Proposed(newProposalId, proposer, executor, calls); - return newProposalId; } function cancelAll(State storage self) internal { @@ -86,19 +86,18 @@ library Proposals { revert ProposalCanceled(proposalId); } uint256 proposedAt = packed.proposedAt; - uint256 adoptionTime = packed.adoptionTime; + if (packed.adoptionTime != 0) { + revert ProposalAlreadyAdopted(proposalId, proposedAt + packed.adoptionTime); + } if (block.timestamp < proposedAt + delay) { revert ProposalNotExecutable(proposalId); } - if (adoptionTime != 0) { - revert ProposalAlreadyAdopted(proposalId, proposedAt + adoptionTime); - } - uint256 adoptionDelay = block.timestamp - proposedAt; + uint256 adoptionTime = block.timestamp - proposedAt; // the proposal can't be proposed and adopted at the same transaction - if (adoptionDelay == 0) { + if (adoptionTime == 0) { revert InvalidAdoptionDelay(0); } - packed.adoptionTime = adoptionDelay.toUint32(); + packed.adoptionTime = adoptionTime.toUint32(); proposal = _unpack(proposalId, packed); } diff --git a/contracts/libraries/Proposers.sol b/contracts/libraries/Proposers.sol index b49b81c4..c3e6fe79 100644 --- a/contracts/libraries/Proposers.sol +++ b/contracts/libraries/Proposers.sol @@ -18,7 +18,7 @@ library Proposers { event ProposerUnregistered(address indexed proposer, address indexed executor); struct ExecutorData { - uint8 proposerIndexOneBased; // indexed from 1. We don't wanna have many executors + uint8 proposerIndexOneBased; // indexed from 1. The count of executors is limited address executor; } @@ -27,13 +27,13 @@ library Proposers { mapping(address proposer => ExecutorData) executors; } - function register(State storage self, address proposer, address executor_) internal { + function register(State storage self, address proposer, address executor) internal { if (self.executors[proposer].proposerIndexOneBased != 0) { revert ProposerAlreadyRegistered(proposer); } self.proposers.push(proposer); - self.executors[proposer] = ExecutorData(self.proposers.length.toUint8(), executor_); - emit ProposerRegistered(proposer, executor_); + self.executors[proposer] = ExecutorData(self.proposers.length.toUint8(), executor); + emit ProposerRegistered(proposer, executor); } function unregister(State storage self, address proposer) internal { @@ -55,12 +55,6 @@ library Proposers { emit ProposerUnregistered(proposer, executorData.executor); } - function validate(State storage self, Proposer memory proposer) internal view { - if (!_isProposer(self, proposer)) { - revert ProposerNotRegistered(proposer.account); - } - } - function all(State storage self) internal view returns (Proposer[] memory proposers) { proposers = new Proposer[](self.proposers.length); for (uint256 i = 0; i < proposers.length; ++i) { @@ -80,9 +74,4 @@ library Proposers { function isProposer(State storage self, address proposer) internal view returns (bool) { return self.executors[proposer].proposerIndexOneBased != 0; } - - function _isProposer(State storage self, Proposer memory proposer) private view returns (bool) { - Proposer memory storedProposer = get(self, proposer.account); - return storedProposer.executor == proposer.executor; - } } diff --git a/contracts/libraries/ScheduledCalls.sol b/contracts/libraries/ScheduledCalls.sol index 76c87b97..ce79d9da 100644 --- a/contracts/libraries/ScheduledCalls.sol +++ b/contracts/libraries/ScheduledCalls.sol @@ -34,12 +34,13 @@ library ScheduledCallsBatches { struct State { uint32 delay; - // all scheduled batch with executableAfter less or equal than given cannot be executed + // all scheduled batches with executableAfter less or equal than given cannot be executed uint40 canceledBeforeTimestamp; uint256[] batchIds; mapping(uint256 batchId => ScheduledCallsBatchPacked) batches; } + event DelaySet(uint256 delay); event Scheduled(uint256 indexed batchId, uint256 delay, ExecutorCall[] calls); event Executed(uint256 indexed batchId, uint256 executedAt, bytes[] results); event Relayed(address indexed executor, ExecutorCall[] calls, bytes[] results); @@ -47,16 +48,15 @@ library ScheduledCallsBatches { event CallsBatchRemoved(uint256 indexed batchId); error EmptyCallsArray(); - error CallsUnscheduled(); error RelayingDisabled(); error SchedulingDisabled(); + error DelayNotExpired(uint256 batchId); error BatchNotScheduled(uint256 batchId); + error CallsBatchCanceled(uint256 batchId); error BatchAlreadyScheduled(uint256 batchId); error CallsBatchNotCanceled(uint256 batchId); - error TimelockNotExpired(uint256 batchId); - error CallsBatchNotFound(uint256 batchId); - function add(State storage self, uint256 batchId, address executor, ExecutorCall[] calldata calls) internal { + function schedule(State storage self, uint256 batchId, address executor, ExecutorCall[] calldata calls) internal { uint32 delay = self.delay; if (delay == 0) { revert SchedulingDisabled(); @@ -78,10 +78,7 @@ library ScheduledCallsBatches { batch.scheduledAt = block.timestamp.toUint40(); for (uint256 i = 0; i < calls.length; ++i) { - ExecutorCall storage call = batch.calls.push(); - call.value = calls[i].value; - call.target = calls[i].target; - call.payload = calls[i].payload; + batch.calls.push(ExecutorCall({value: calls[i].value, target: calls[i].target, payload: calls[i].payload})); } emit Scheduled(batchId, delay, calls); @@ -103,11 +100,11 @@ library ScheduledCallsBatches { ScheduledCallsBatchPacked memory batch = _remove(self, batchId); uint256 executableAfter = batch.scheduledAt + batch.delay; if (block.timestamp <= executableAfter) { - revert TimelockNotExpired(batchId); + revert DelayNotExpired(batchId); } // check that batch wasn't unscheduled if (executableAfter <= self.canceledBeforeTimestamp) { - revert CallsUnscheduled(); + revert CallsBatchCanceled(batchId); } results = _executeCalls(batch.executor, batch.calls); emit Executed(batchId, block.timestamp, results); @@ -126,8 +123,15 @@ library ScheduledCallsBatches { emit CallsBatchRemoved(batchId); } + function setDelay(State storage self, uint256 delay) internal { + if (self.delay != delay) { + self.delay = delay.toUint32(); + emit DelaySet(delay); + } + } + function get(State storage self, uint256 batchId) internal view returns (ScheduledCallsBatch memory batch) { - return _unpack(_packed(self, batchId), self.canceledBeforeTimestamp); + return _unpack(batchId, _packed(self, batchId), self.canceledBeforeTimestamp); } function all(State storage self) internal view returns (ScheduledCallsBatch[] memory res) { @@ -136,7 +140,8 @@ library ScheduledCallsBatches { uint256 canceledBeforeTimestamp = self.canceledBeforeTimestamp; for (uint256 i = 0; i < batchIdsCount; ++i) { - res[i] = _unpack(self.batches[self.batchIds[i]], canceledBeforeTimestamp); + uint256 batchId = self.batchIds[i]; + res[i] = _unpack(batchId, self.batches[batchId], canceledBeforeTimestamp); } } @@ -182,17 +187,13 @@ library ScheduledCallsBatches { uint256 batchIndexToRemove = self.batches[batchId].indexOneBased - 1; uint256 lastBatchIndex = self.batchIds.length - 1; if (batchIndexToRemove != lastBatchIndex) { - self.batchIds[batchIndexToRemove] = lastBatchIndex; + uint256 lastBatchId = self.batchIds[lastBatchIndex]; + self.batchIds[batchIndexToRemove] = lastBatchId; + self.batches[lastBatchId].indexOneBased = batchIndexToRemove + 1; } self.batchIds.pop(); - uint256 callsCount = batch.calls.length; - // remove every item in the batch - for (uint256 i = 0; i < callsCount; ++i) { - self.batches[batchId].calls.pop(); - } - - // then remove the batch itself + // then remove the batch with calls delete self.batches[batchId]; } @@ -207,10 +208,11 @@ library ScheduledCallsBatches { } function _unpack( + uint256 batchId, ScheduledCallsBatchPacked memory packed, uint256 canceledBeforeTimestamp ) private pure returns (ScheduledCallsBatch memory batch) { - batch.id = packed.indexOneBased; + batch.id = batchId; batch.calls = packed.calls; batch.executor = packed.executor; uint256 scheduledAt = packed.scheduledAt; diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol new file mode 100644 index 00000000..6c37982f --- /dev/null +++ b/script/Deploy.s.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; + +import {Escrow} from "contracts/Escrow.sol"; +import {Configuration} from "contracts/Configuration.sol"; +import {DualGovernance} from "contracts/DualGovernance.sol"; +import {TransparentUpgradeableProxy} from "contracts/TransparentUpgradeableProxy.sol"; + +import {OwnableExecutor} from "contracts/OwnableExecutor.sol"; +import {EmergencyProtectedTimelock} from "contracts/EmergencyProtectedTimelock.sol"; + +contract DualGovernanceDeployScript { + address public immutable STETH; + address public immutable WSTETH; + address public immutable BURNER; + address public immutable VOTING; + address public immutable WITHDRAWAL_QUEUE; + + constructor(address stETH, address wstETH, address burner, address voting, address withdrawalQueue) { + STETH = stETH; + WSTETH = wstETH; + BURNER = burner; + VOTING = voting; + WITHDRAWAL_QUEUE = withdrawalQueue; + } + + function deploy( + address adminProposer, + uint256 delay, + address emergencyCommittee, + uint256 protectionDuration, + uint256 emergencyModeDuration + ) + external + returns (DualGovernance dualGovernance, EmergencyProtectedTimelock timelock, OwnableExecutor adminExecutor) + { + (timelock, adminExecutor) = + deployEmergencyProtectedTimelock(delay, emergencyCommittee, protectionDuration, emergencyModeDuration); + dualGovernance = deployDualGovernance(address(timelock), adminProposer); + } + + function deployDualGovernance( + address timelock, + address adminProposer + ) public returns (DualGovernance dualGovernance) { + // deploy initial config impl + address configImpl = address(new Configuration(adminProposer)); + + // deploy config proxy + ProxyAdmin configAdmin = new ProxyAdmin(address(this)); + TransparentUpgradeableProxy config = + new TransparentUpgradeableProxy(configImpl, address(configAdmin), new bytes(0)); + + // deploy DG + address escrowImpl = address(new Escrow(address(config), STETH, WSTETH, WITHDRAWAL_QUEUE, BURNER)); + dualGovernance = new DualGovernance(address(config), configImpl, address(configAdmin), escrowImpl, timelock); + configAdmin.transferOwnership(address(dualGovernance)); + } + + function deployEmergencyProtectedTimelock( + uint256 delay, + address emergencyCommittee, + uint256 protectionDuration, + uint256 emergencyModeDuration + ) public returns (EmergencyProtectedTimelock timelock, OwnableExecutor adminExecutor) { + adminExecutor = new OwnableExecutor(address(this)); + timelock = new EmergencyProtectedTimelock(address(adminExecutor), VOTING); + + adminExecutor.execute(address(timelock), 0, abi.encodeCall(timelock.setGovernanceAndDelay, (VOTING, delay))); + adminExecutor.execute( + address(timelock), + 0, + abi.encodeCall( + timelock.setEmergencyProtection, (emergencyCommittee, protectionDuration, emergencyModeDuration) + ) + ); + adminExecutor.transferOwnership(address(timelock)); + } +} diff --git a/test/scenario/happy-path-plan-b.t.sol b/test/scenario/happy-path-plan-b.t.sol index 3677f8ce..7b526bc2 100644 --- a/test/scenario/happy-path-plan-b.t.sol +++ b/test/scenario/happy-path-plan-b.t.sol @@ -1,393 +1,614 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {Test} from "forge-std/Test.sol"; -import {Escrow} from "contracts/Escrow.sol"; -import {Configuration} from "contracts/Configuration.sol"; -import {DualGovernance} from "contracts/DualGovernance.sol"; -import {TransparentUpgradeableProxy} from "contracts/TransparentUpgradeableProxy.sol"; +import {Utils, TargetMock} from "../utils/utils.sol"; +import {ExecutorCallHelpers, ExecutorCall} from "../utils/executor-calls.sol"; +import {DAO_VOTING, ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, BURNER} from "../utils/mainnet-addresses.sol"; -import {OwnableExecutor} from "contracts/OwnableExecutor.sol"; -import {ExecutorCall, EmergencyProtection, EmergencyProtectedTimelock} from "contracts/EmergencyProtectedTimelock.sol"; +import { + EmergencyState, + EmergencyProtection, + ScheduledCallsBatch, + ScheduledCallsBatches, + EmergencyProtectedTimelock +} from "contracts/EmergencyProtectedTimelock.sol"; -import "forge-std/Test.sol"; +import {Proposals, Proposal} from "contracts/libraries/Proposals.sol"; -import "../utils/mainnet-addresses.sol"; -import "../utils/interfaces.sol"; -import "../utils/utils.sol"; +import {DualGovernanceDeployScript, DualGovernance} from "script/Deploy.s.sol"; -contract DualGovernanceDeployFactory { - address immutable STETH; - address immutable WSTETH; - address immutable WQ; - - constructor(address stETH, address wstETH, address withdrawalQueue) { - STETH = stETH; - WSTETH = wstETH; - WQ = withdrawalQueue; - } - - function deployDualGovernance(address timelock) external returns (DualGovernance dualGov) { - // deploy initial config impl - address configImpl = address(new Configuration(DAO_VOTING)); - - // deploy config proxy - ProxyAdmin configAdmin = new ProxyAdmin(address(this)); - TransparentUpgradeableProxy config = - new TransparentUpgradeableProxy(configImpl, address(configAdmin), new bytes(0)); - - // deploy DG - address escrowImpl = address(new Escrow(address(config), ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, BURNER)); - dualGov = new DualGovernance(address(config), configImpl, address(configAdmin), escrowImpl, timelock); - - configAdmin.transferOwnership(address(dualGov)); - } -} - -abstract contract PlanBSetup is Test { - function deployPlanB( - address daoVoting, - uint256 timelockDuration, - address vetoMultisig, - uint256 vetoMultisigActiveFor, - uint256 emergencyModeDuration - ) public returns (EmergencyProtectedTimelock timelock) { - OwnableExecutor adminExecutor = new OwnableExecutor(address(this)); - - timelock = new EmergencyProtectedTimelock(address(adminExecutor), daoVoting); - - // configure Timelock - adminExecutor.execute( - address(timelock), 0, abi.encodeCall(timelock.setGovernance, (daoVoting, timelockDuration)) - ); - - adminExecutor.execute( - address(timelock), - 0, - abi.encodeCall( - timelock.setEmergencyProtection, (vetoMultisig, vetoMultisigActiveFor, emergencyModeDuration) - ) - ); - - adminExecutor.transferOwnership(address(timelock)); - } +interface IDangerousContract { + function doRegularStaff(uint256 magic) external; + function doRugPool() external; } -contract HappyPathPlanBTest is PlanBSetup { - IAragonVoting internal daoVoting; - EmergencyProtectedTimelock internal timelock; - address internal vetoMultisig; - address internal ldoWhale; - Target internal target; +contract PlanBSetup is Test { + uint256 private immutable _DELAY = 3 days; + uint256 private immutable _EMERGENCY_MODE_DURATION = 180 days; + uint256 private immutable _EMERGENCY_PROTECTION_DURATION = 90 days; + address private immutable _EMERGENCY_COMMITTEE = makeAddr("EMERGENCY_COMMITTEE"); - uint256 internal timelockDuration; - uint256 internal vetoMultisigActiveFor; - uint256 internal _emergencyModeDuration; + TargetMock private _target; + DualGovernance private _dualGovernance; + EmergencyProtectedTimelock private _timelock; + DualGovernanceDeployScript private _dualGovernanceDeployScript; function setUp() external { Utils.selectFork(); + _target = new TargetMock(); - ldoWhale = makeAddr("ldo_whale"); - Utils.setupLdoWhale(ldoWhale); + _dualGovernanceDeployScript = + new DualGovernanceDeployScript(ST_ETH, WST_ETH, BURNER, DAO_VOTING, WITHDRAWAL_QUEUE); - vetoMultisig = makeAddr("vetoMultisig"); - - timelockDuration = 1 days; - vetoMultisigActiveFor = 90 days; - _emergencyModeDuration = 180 days; - - timelock = - deployPlanB(DAO_VOTING, timelockDuration, vetoMultisig, vetoMultisigActiveFor, _emergencyModeDuration); - target = new Target(); - daoVoting = IAragonVoting(DAO_VOTING); + (_timelock,) = _dualGovernanceDeployScript.deployEmergencyProtectedTimelock( + _DELAY, _EMERGENCY_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, _EMERGENCY_MODE_DURATION + ); } - function test_proposal() external { - bytes memory targetCalldata = abi.encodeCall(target.doSmth, (42)); - - ExecutorCall[] memory calls = new ExecutorCall[](1); - calls[0].value = 0; - calls[0].target = address(target); - calls[0].payload = abi.encodeCall(target.doSmth, (42)); - - uint256 proposalId = 1; - bytes memory proposeCalldata = abi.encodeCall(timelock.schedule, (proposalId, timelock.ADMIN_EXECUTOR(), calls)); - - bytes memory script = Utils.encodeEvmCallScript(address(timelock), proposeCalldata); - - bytes memory newVoteScript = - Utils.encodeEvmCallScript(address(daoVoting), abi.encodeCall(daoVoting.newVote, (script, "", false, false))); - - uint256 voteId = daoVoting.votesLength(); - - vm.prank(ldoWhale); - IAragonForwarder(DAO_TOKEN_MANAGER).forward(newVoteScript); - Utils.supportVoteAndWaitTillDecided(voteId, ldoWhale); - - // no calls to execute before the vote is enacted - assertEq(timelock.getScheduledCallBatchesCount(), 0); - - // executing the vote - assertEq(IAragonVoting(DAO_VOTING).canExecute(voteId), true); - daoVoting.executeVote(voteId); - - // new call is scheduled but has not executable yet - assertEq(timelock.getScheduledCallBatchesCount(), 1); - assertFalse(timelock.getIsExecutable(proposalId)); - - // wait until call becomes executable - vm.warp(block.timestamp + timelockDuration + 1); - assertTrue(timelock.getIsExecutable(proposalId)); - - // call successfully executed - vm.expectCall(address(target), targetCalldata); - target.expectCalledBy(address(timelock.ADMIN_EXECUTOR())); - timelock.execute(proposalId); - - // scheduled call was removed after execution - assertEq(timelock.getScheduledCallBatchesCount(), 0); - - // malicious vote was proposed and passed - voteId = daoVoting.votesLength(); - - vm.prank(ldoWhale); - IAragonForwarder(DAO_TOKEN_MANAGER).forward(newVoteScript); - Utils.supportVoteAndWaitTillDecided(voteId, ldoWhale); - - assertEq(IAragonVoting(DAO_VOTING).canExecute(voteId), true); - daoVoting.executeVote(voteId); - - // malicious call was scheduled - assertEq(timelock.getScheduledCallBatchesCount(), 1); - - // emergency committee activates emergency mode during the timelock duration - vm.prank(vetoMultisig); - timelock.emergencyModeActivate(); - - EmergencyProtectedTimelock.EmergencyState memory emergencyState = timelock.getEmergencyState(); - assertTrue(emergencyState.isActive); - assertEq(emergencyState.emergencyModeEndsAfter, block.timestamp + emergencyState.emergencyModeDuration); - assertEq(emergencyState.emergencyModeDuration, _emergencyModeDuration); - - // now, only emergency committee may execute calls on timelock - vm.warp(block.timestamp + timelockDuration + 1); - uint256 maliciousProposalId = 1; - - // malicious proposal can be executed now, but in emergency mode, only by the committee - assertTrue(timelock.getIsExecutable(maliciousProposalId)); - - // attempt to execute malicious proposal not from committee fails - vm.expectRevert(abi.encodeWithSelector(EmergencyProtection.NotEmergencyCommittee.selector, address(this))); - timelock.execute(maliciousProposalId); - - // Some time later, the DG development was finished and may be deployed - vm.warp(block.timestamp + 30 days); - - DualGovernanceDeployFactory dgFactory = new DualGovernanceDeployFactory(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE); - - DualGovernance dualGov = dgFactory.deployDualGovernance(address(timelock)); - - // The vote to enable dual governance is prepared and launched - - ExecutorCall[] memory dualGovActivationCalls = new ExecutorCall[](3); - // call timelock.setGovernance() with deployed instance of DG - dualGovActivationCalls[0].target = address(timelock); - dualGovActivationCalls[0].payload = abi.encodeCall(timelock.setGovernance, (address(dualGov), 1 days)); - - // deactivate emergency mode - dualGovActivationCalls[1].target = address(timelock); - dualGovActivationCalls[1].payload = abi.encodeCall(timelock.emergencyModeDeactivate, ()); - - // call timelock.setEmergencyProtection() to update the emergency protection settings - dualGovActivationCalls[2].target = address(timelock); - dualGovActivationCalls[2].payload = - abi.encodeCall(timelock.setEmergencyProtection, (vetoMultisig, 90 days, 30 days)); - - uint256 newProposalId = 3; - bytes memory newProposeCalldata = - abi.encodeCall(timelock.schedule, (newProposalId, timelock.ADMIN_EXECUTOR(), dualGovActivationCalls)); + function testFork_PlanB_Scenario() external { + bytes memory regularStaffCalldata = abi.encodeCall(IDangerousContract.doRegularStaff, (42)); + ExecutorCall[] memory regularStaffCalls = ExecutorCallHelpers.create(address(_target), regularStaffCalldata); + + // --- + // ACT 1. 📈 DAO OPERATES AS USUALLY + // --- + { + uint256 proposalId = 1; + _scheduleViaVoting( + proposalId, + "DAO does regular staff on potentially dangerous contract", + _timelock.ADMIN_EXECUTOR(), + regularStaffCalls + ); + + // wait until scheduled call becomes executable + _waitFor(proposalId); + + // call successfully executed + _execute(proposalId); + _assertTargetMockCalls(_timelock.ADMIN_EXECUTOR(), regularStaffCalls); + } + + // --- + // ACT 2. 😱 DAO IS UNDER ATTACK + // --- + uint256 maliciousProposalId = 666; + EmergencyState memory emergencyState; + { + // Malicious vote was proposed by the attacker with huge LDO wad (but still not the majority) + bytes memory maliciousStaffCalldata = abi.encodeCall(IDangerousContract.doRugPool, ()); + ExecutorCall[] memory maliciousCalls = ExecutorCallHelpers.create(address(_target), maliciousStaffCalldata); + + _scheduleViaVoting(maliciousProposalId, "Rug Pool attempt", _timelock.ADMIN_EXECUTOR(), maliciousCalls); + + // the call isn't executable until the delay has passed + assertFalse(_timelock.getIsExecutable(maliciousProposalId)); + + // some time required to assemble the emergency committee and activate emergency mode + vm.warp(block.timestamp + _DELAY / 2); + + // malicious call still not executable + assertFalse(_timelock.getIsExecutable(maliciousProposalId)); + vm.expectRevert(abi.encodeWithSelector(ScheduledCallsBatches.DelayNotExpired.selector, maliciousProposalId)); + _timelock.execute(maliciousProposalId); + + // emergency committee activates emergency mode + vm.prank(_EMERGENCY_COMMITTEE); + _timelock.emergencyModeActivate(); + + // emergency mode was successfully activated + uint256 expectedEmergencyModeEndTimestamp = block.timestamp + _EMERGENCY_MODE_DURATION; + emergencyState = _timelock.getEmergencyState(); + assertTrue(emergencyState.isEmergencyModeActivated); + assertEq(emergencyState.emergencyModeEndsAfter, expectedEmergencyModeEndTimestamp); + + // now only emergency committee may execute scheduled calls + vm.warp(block.timestamp + _DELAY / 2 + 1); + assertFalse(_timelock.getIsExecutable(maliciousProposalId)); + vm.expectRevert(abi.encodeWithSelector(EmergencyProtection.NotEmergencyCommittee.selector, address(this))); + _timelock.execute(maliciousProposalId); + } + + // --- + // ACT 3. 🔫 DAO STRIKES BACK (WITH DUAL GOVERNANCE SHIPMENT) + // --- + { + // Lido contributors work hard to implement and ship the Dual Governance mechanism + // before the emergency mode is over + vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); + + // Time passes but malicious proposal still on hold + assertFalse(_timelock.getIsExecutable(maliciousProposalId)); + + // Dual Governance is deployed into mainnet + _dualGovernance = _dualGovernanceDeployScript.deployDualGovernance(address(_timelock), DAO_VOTING); + + ExecutorCall[] memory dualGovernanceLaunchCalls = ExecutorCallHelpers.create( + address(_timelock), + [ + // Only Dual Governance contract can call the Timelock contract + abi.encodeCall(_timelock.setGovernanceAndDelay, (address(_dualGovernance), _DELAY)), + // Now the emergency mode may be deactivated (all scheduled calls will be canceled) + abi.encodeCall(_timelock.emergencyModeDeactivate, ()), + // Setup emergency committee for some period of time until the Dual Governance is battle tested + abi.encodeCall( + _timelock.setEmergencyProtection, (_EMERGENCY_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, 30 days) + ) + ] + ); + + // The vote to launch Dual Governance is launched and reached the quorum (the major part of LDO holder still have power) + uint256 dualGovernanceLunchProposalId = 777; + _scheduleViaVoting( + dualGovernanceLunchProposalId, + "Launch the Dual Governance", + _timelock.ADMIN_EXECUTOR(), + dualGovernanceLaunchCalls + ); + + // Anticipated vote will be executed soon... + _waitFor(dualGovernanceLunchProposalId); + + // The malicious vote still on hold + assertFalse(_timelock.getIsExecutable(maliciousProposalId)); + + // Emergency Committee executes vote and enables Dual Governance + _execute(dualGovernanceLunchProposalId, _EMERGENCY_COMMITTEE); + + // the deployed configuration is correct + assertEq(_timelock.getGovernance(), address(_dualGovernance)); + // and the malicious call was marked as cancelled + assertTrue(_timelock.getIsCanceled(maliciousProposalId)); + // and can NEVER be executed + assertFalse(_timelock.getIsExecutable(maliciousProposalId)); + + // anyone can remove malicious calls batch now + _timelock.removeCanceledCallsBatch(maliciousProposalId); + assertEq(_timelock.getScheduledCallBatchesCount(), 0); + } + + // --- + // ACT 4. 🫡 EMERGENCY COMMITTEE DISBANDED + // --- + { + // Time passes and there were no vulnerabilities reported. Emergency Committee may be dissolved now + // Thank you for your service, sirs! + + ExecutorCall[] memory disbandEmergencyCommitteeCalls = ExecutorCallHelpers.create( + address(_timelock), + [ + // disable emergency protection + abi.encodeCall(_timelock.setEmergencyProtection, (address(0), 0, 0)), + // turn off the scheduling and allow calls relaying + abi.encodeCall(_timelock.setDelay, (0)) + ] + ); + + uint256 disbandEmergencyCommitteeProposalId = + _propose("Disband Emergency Committee & turn off the calls delaying", disbandEmergencyCommitteeCalls); + + // until the DG timelock has passed the proposal can't be scheduled + vm.expectRevert( + abi.encodeWithSelector(Proposals.ProposalNotExecutable.selector, disbandEmergencyCommitteeProposalId) + ); + _dualGovernance.schedule(disbandEmergencyCommitteeProposalId); + + // wait until the proposal is executable + vm.warp(block.timestamp + _dualGovernance.CONFIG().minProposalExecutionTimelock() + 1); + + // schedule the proposal + _scheduleViaDualGovernance( + disbandEmergencyCommitteeProposalId, _timelock.ADMIN_EXECUTOR(), disbandEmergencyCommitteeCalls + ); + + // wait until the calls batch is executable + _waitFor(disbandEmergencyCommitteeProposalId); + + // execute the proposal + _execute(disbandEmergencyCommitteeProposalId); + + // validate the proposal was applied correctly: + + // - emergency protection disabled + emergencyState = _timelock.getEmergencyState(); + assertEq(emergencyState.committee, address(0)); + assertFalse(emergencyState.isEmergencyModeActivated); + assertEq(emergencyState.emergencyModeDuration, 0); + assertEq(emergencyState.emergencyModeEndsAfter, 0); + + // - delay was set to 0 + assertEq(_timelock.getDelay(), 0); + } + + // --- + // ACT 5. 📆 DAO CONTINUES THEIR REGULAR DUTIES (PROTECTED BY DUAL GOVERNANCE) + // --- + { + uint256 regularStaffProposalId = _propose("Make regular staff with help of DG", regularStaffCalls); + + // wait until the proposal is executable + vm.warp(block.timestamp + _dualGovernance.CONFIG().minProposalExecutionTimelock() + 1); + + // scheduling is disabled after delay set to 0, so schedule call expectedly fails + vm.expectRevert(ScheduledCallsBatches.SchedulingDisabled.selector); + _dualGovernance.schedule(regularStaffProposalId); + + // Use relay method to execute the proposal + _relayViaDualGovernance(regularStaffProposalId); + + // validate the proposal was executed correctly + _assertTargetMockCalls(_timelock.ADMIN_EXECUTOR(), regularStaffCalls); + } + + // --- + // ACT 6. 🔜 NEW DUAL GOVERNANCE VERSION IS COMING + // --- + { + // some time later, the major Dual Governance update release is ready to be launched + vm.warp(block.timestamp + 365 days); + DualGovernance dualGovernanceV2 = + _dualGovernanceDeployScript.deployDualGovernance(address(_timelock), DAO_VOTING); + + ExecutorCall[] memory dualGovernanceUpdateCalls = ExecutorCallHelpers.create( + address(_timelock), + [ + // Update the governance in the Timelock + abi.encodeCall(_timelock.setGovernanceAndDelay, (address(dualGovernanceV2), _DELAY)), + // Assembly the emergency committee again, until the new version of Dual Governance is battle tested + abi.encodeCall( + _timelock.setEmergencyProtection, (_EMERGENCY_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, 30 days) + ) + ] + ); + + uint256 updateDualGovernanceProposalId = _propose("Update Dual Governance to V2", dualGovernanceUpdateCalls); + + // wait until the proposal is executable + vm.warp(block.timestamp + _dualGovernance.CONFIG().minProposalExecutionTimelock() + 1); + + // relay the proposal + _relayViaDualGovernance(updateDualGovernanceProposalId); + + // validate the proposal was applied correctly: + + // new version of dual governance attached to timelock + assertEq(_timelock.getGovernance(), address(dualGovernanceV2)); + + // - emergency protection disabled + emergencyState = _timelock.getEmergencyState(); + assertEq(emergencyState.committee, _EMERGENCY_COMMITTEE); + assertFalse(emergencyState.isEmergencyModeActivated); + assertEq(emergencyState.emergencyModeDuration, 30 days); + assertEq(emergencyState.emergencyModeEndsAfter, 0); + + // - delay was set correctly + assertEq(_timelock.getDelay(), _DELAY); + + // use the new version of the dual governance in the future calls + _dualGovernance = dualGovernanceV2; + } + + // --- + // ACT 7. 📆 DAO CONTINUES THEIR REGULAR DUTIES (PROTECTED BY DUAL GOVERNANCE V2) + // --- + { + uint256 regularStaffProposalId = _propose("Make regular staff with help of DG V2", regularStaffCalls); + + // wait until the proposal is executable + vm.warp(block.timestamp + _dualGovernance.CONFIG().minProposalExecutionTimelock() + 1); + + // the timelock emergency protection is enabled, so schedule calls instead of relaying + _scheduleViaDualGovernance(regularStaffProposalId, _timelock.ADMIN_EXECUTOR(), regularStaffCalls); + + // wait until the proposal is executable + _waitFor(regularStaffProposalId); + + // execute scheduled calls + _execute(regularStaffProposalId); + + // validate the proposal was executed correctly + _assertTargetMockCalls(_timelock.ADMIN_EXECUTOR(), regularStaffCalls); + } + } - bytes memory newScript = Utils.encodeEvmCallScript(address(timelock), newProposeCalldata); + function testFork_ScheduledCallsCantBeExecutedAfterEmergencyModeDeactivation() external { + uint256 maliciousProposalId = 666; + ExecutorCall[] memory maliciousCalls = + ExecutorCallHelpers.create(address(_target), abi.encodeCall(IDangerousContract.doRugPool, ())); + // schedule some malicious call + { + _scheduleViaVoting(maliciousProposalId, "Rug Pool attempt", _timelock.ADMIN_EXECUTOR(), maliciousCalls); + + // call can't be executed before the delay is passed + vm.expectRevert(abi.encodeWithSelector(ScheduledCallsBatches.DelayNotExpired.selector, maliciousProposalId)); + _timelock.execute(maliciousProposalId); + } + + // activate emergency mode + EmergencyState memory emergencyState; + { + vm.warp(block.timestamp + _DELAY / 2); + + vm.prank(_EMERGENCY_COMMITTEE); + _timelock.emergencyModeActivate(); + + emergencyState = _timelock.getEmergencyState(); + assertTrue(emergencyState.isEmergencyModeActivated); + } + + // delay for malicious proposal has passed, but it can't be executed because of emergency mode was activated + { + vm.warp(block.timestamp + _DELAY / 2 + 1); + ScheduledCallsBatch memory batch = _timelock.getScheduledCallsBatch(maliciousProposalId); + assertTrue(block.timestamp > batch.executableAfter); + + vm.expectRevert(abi.encodeWithSelector(EmergencyProtection.NotEmergencyCommittee.selector, address(this))); + _timelock.execute(maliciousProposalId); + } + + // another malicious call is scheduled during the emergency mode also can't be executed + uint256 maliciousProposalId2 = 667; + { + vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); + // emergency mode still active + assertTrue(emergencyState.emergencyModeEndsAfter > block.timestamp); + + _scheduleViaVoting(maliciousProposalId2, "Rug Pool attempt 2", _timelock.ADMIN_EXECUTOR(), maliciousCalls); + + vm.warp(block.timestamp + _DELAY + 1); + ScheduledCallsBatch memory batch = _timelock.getScheduledCallsBatch(maliciousProposalId2); + assertTrue(block.timestamp > batch.executableAfter); + + // new malicious proposal also can't be executed + vm.expectRevert(abi.encodeWithSelector(EmergencyProtection.NotEmergencyCommittee.selector, address(this))); + _timelock.execute(maliciousProposalId2); + } + + // emergency mode is over but proposals can't be executed until the emergency mode turned off manually + { + vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); + assertTrue(emergencyState.emergencyModeEndsAfter < block.timestamp); + + vm.expectRevert(abi.encodeWithSelector(EmergencyProtection.NotEmergencyCommittee.selector, address(this))); + _timelock.execute(maliciousProposalId); + + vm.expectRevert(abi.encodeWithSelector(EmergencyProtection.NotEmergencyCommittee.selector, address(this))); + _timelock.execute(maliciousProposalId2); + } + + // anyone can deactivate emergency mode when it's over + { + _timelock.emergencyModeDeactivate(); + + emergencyState = _timelock.getEmergencyState(); + assertFalse(emergencyState.isEmergencyModeActivated); + } + + // all malicious calls is canceled now and can't be executed + { + assertTrue(_timelock.getIsCanceled(maliciousProposalId)); + vm.expectRevert( + abi.encodeWithSelector(ScheduledCallsBatches.CallsBatchCanceled.selector, (maliciousProposalId)) + ); + _timelock.execute(maliciousProposalId); + + assertTrue(_timelock.getIsCanceled(maliciousProposalId)); + vm.expectRevert( + abi.encodeWithSelector(ScheduledCallsBatches.CallsBatchCanceled.selector, (maliciousProposalId2)) + ); + _timelock.execute(maliciousProposalId2); + } + + // but they can be removed now + { + _timelock.removeCanceledCallsBatch(maliciousProposalId); + _timelock.removeCanceledCallsBatch(maliciousProposalId2); + + assertEq(_timelock.getScheduledCallBatchesCount(), 0); + + vm.expectRevert( + abi.encodeWithSelector(ScheduledCallsBatches.BatchNotScheduled.selector, (maliciousProposalId)) + ); + _timelock.execute(maliciousProposalId); + + vm.expectRevert( + abi.encodeWithSelector(ScheduledCallsBatches.BatchNotScheduled.selector, (maliciousProposalId2)) + ); + _timelock.execute(maliciousProposalId2); + } + } - voteId = daoVoting.votesLength(); + function testFork_EmergencyResetGovernance() external { + // deploy dual governance full setup + { + (_dualGovernance, _timelock,) = _dualGovernanceDeployScript.deploy( + DAO_VOTING, _DELAY, _EMERGENCY_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, _EMERGENCY_MODE_DURATION + ); + } + + // emergency committee activates emergency mode + EmergencyState memory emergencyState; + { + vm.prank(_EMERGENCY_COMMITTEE); + _timelock.emergencyModeActivate(); + + emergencyState = _timelock.getEmergencyState(); + assertTrue(emergencyState.isEmergencyModeActivated); + } + + // before the end of the emergency mode emergency committee can reset governance to DAO + { + vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); + assertTrue(emergencyState.emergencyModeEndsAfter > block.timestamp); + + vm.prank(_EMERGENCY_COMMITTEE); + _timelock.emergencyResetGovernance(); + + assertEq(_timelock.getGovernance(), DAO_VOTING); + + emergencyState = _timelock.getEmergencyState(); + assertEq(emergencyState.committee, address(0)); + assertEq(emergencyState.emergencyModeDuration, 0); + assertEq(emergencyState.emergencyModeEndsAfter, 0); + assertFalse(emergencyState.isEmergencyModeActivated); + } + } - // The quorum to activate the DG is reached among the honest LDO holders - vm.prank(ldoWhale); - IAragonForwarder(DAO_TOKEN_MANAGER).forward( - Utils.encodeEvmCallScript( - address(daoVoting), abi.encodeCall(daoVoting.newVote, (newScript, "Activate DG", false, false)) - ) - ); - Utils.supportVoteAndWaitTillDecided(voteId, ldoWhale); + function testFork_ExpiredEmergencyCommitteeHasNoPower() external { + // deploy dual governance full setup + { + (_dualGovernance, _timelock,) = _dualGovernanceDeployScript.deploy( + DAO_VOTING, _DELAY, _EMERGENCY_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, _EMERGENCY_MODE_DURATION + ); + } + + // wait till the protection duration passes + { + vm.warp(block.timestamp + _EMERGENCY_PROTECTION_DURATION + 1); + } + + // attempt to activate emergency protection fails + { + vm.expectRevert(EmergencyProtection.EmergencyCommitteeExpired.selector); + vm.prank(_EMERGENCY_COMMITTEE); + _timelock.emergencyModeActivate(); + } + } - // The vote passed and may be enacted - assertEq(IAragonVoting(DAO_VOTING).canExecute(voteId), true); - daoVoting.executeVote(voteId); + function _execute(uint256 proposalId) internal { + _execute(proposalId, address(this)); + } - // call was scheduled successfully - assertEq(timelock.getScheduledCallBatchesCount(), 2); + function _execute(uint256 proposalId, address sender) internal { + uint256 scheduledCallBatchesCountBefore = _timelock.getScheduledCallBatchesCount(); + if (sender != address(this)) { + vm.prank(sender); + } + _timelock.execute(proposalId); - // wait timelock duration passes - vm.warp(block.timestamp + timelockDuration + 1); - assertTrue(timelock.getIsExecutable(newProposalId)); + assertEq(_timelock.getScheduledCallBatchesCount(), scheduledCallBatchesCountBefore - 1); + } - // execute new proposal by emergency committee - vm.prank(vetoMultisig); - timelock.execute(newProposalId); + function _assertTargetMockCalls(address executor, ExecutorCall[] memory calls) internal { + TargetMock.Call[] memory called = _target.getCalls(); + assertEq(called.length, calls.length); + + for (uint256 i = 0; i < calls.length; ++i) { + assertEq(called[i].sender, executor); + assertEq(called[i].value, calls[i].value); + assertEq(called[i].data, calls[i].payload); + assertEq(called[i].blockNumber, block.number); + } + _target.reset(); + } - uint256 dgDeployedTimestamp = block.timestamp; + function _propose(string memory description, ExecutorCall[] memory calls) internal returns (uint256 proposalId) { + bytes memory script = + Utils.encodeEvmCallScript(address(_dualGovernance), abi.encodeCall(_dualGovernance.propose, (calls))); + + uint256 proposalsCountBefore = _dualGovernance.getProposalsCount(); + + uint256 voteId = Utils.adoptVote(DAO_VOTING, description, script); + Utils.executeVote(DAO_VOTING, voteId); + + uint256 proposalsCountAfter = _dualGovernance.getProposalsCount(); + // proposal was created + assertEq(proposalsCountAfter, proposalsCountBefore + 1); + proposalId = proposalsCountAfter; + + // and with correct data + Proposal memory proposal = _dualGovernance.getProposal(proposalId); + assertEq(proposal.id, proposalId); + assertEq(proposal.proposer, DAO_VOTING); + assertEq(proposal.executor, _timelock.ADMIN_EXECUTOR()); + assertEq(proposal.proposedAt, block.timestamp); + assertEq(proposal.adoptedAt, 0); + + assertEq(proposal.calls.length, calls.length); + for (uint256 i = 0; i < calls.length; ++i) { + assertEq(proposal.calls[i].value, calls[i].value); + assertEq(proposal.calls[i].target, calls[i].target); + assertEq(proposal.calls[i].payload, calls[i].payload); + } + } - // validate the governance and emergency protection was set correctly - assertEq(timelock.getGovernance(), address(dualGov)); + function _relayViaDualGovernance(uint256 proposalId) internal { + _dualGovernance.relay(proposalId); + Proposal memory proposal = _dualGovernance.getProposal(proposalId); + assertEq(proposal.adoptedAt, block.timestamp); + } - // Validate the emergency mode is deactivated and emergency settings updated - emergencyState = timelock.getEmergencyState(); - assertFalse(emergencyState.isActive); - assertEq(emergencyState.committee, vetoMultisig); - assertEq(emergencyState.protectedTill, dgDeployedTimestamp + 90 days); - assertEq(emergencyState.emergencyModeDuration, 30 days); + function _scheduleViaDualGovernance(uint256 proposalId, address executor, ExecutorCall[] memory calls) internal { + uint256 scheduledCallsCountBefore = _timelock.getScheduledCallBatchesCount(); - // after execution only malicious proposal has left - assertEq(timelock.getScheduledCallBatchesCount(), 1); + _dualGovernance.schedule(proposalId); + Proposal memory proposal = _dualGovernance.getProposal(proposalId); + assertEq(proposal.adoptedAt, block.timestamp); - // malicious proposal was canceled and may be removed - timelock.getIsCanceled(maliciousProposalId); - timelock.removeCanceledCallsBatch(maliciousProposalId); + // new call is scheduled but has not executable yet + assertEq(_timelock.getScheduledCallBatchesCount(), scheduledCallsCountBefore + 1); - // now, all votings passes via dual governance - _testDualGovernanceWorks(dualGov); - _testDualGovernanceRedeploy(dualGov); + // validate the correct batch was created + _assertScheduledCallsBatch(proposalId, executor, calls); } - function _testDualGovernanceWorks(DualGovernance dualGov) internal { - ExecutorCall[] memory calls = new ExecutorCall[](1); - calls[0].value = 0; - calls[0].target = address(target); - calls[0].payload = abi.encodeCall(target.doSmth, (43)); - - bytes memory dgProposeCalldata = abi.encodeCall(dualGov.propose, calls); - - bytes memory dgProposeVoteScript = Utils.encodeEvmCallScript(address(dualGov), dgProposeCalldata); + function _scheduleViaVoting( + uint256 proposalId, + string memory description, + address executor, + ExecutorCall[] memory calls + ) internal { + uint256 scheduledCallsCountBefore = _timelock.getScheduledCallBatchesCount(); - uint256 voteId = daoVoting.votesLength(); - - // The quorum to activate the DG is reached among the honest LDO holders - vm.prank(ldoWhale); - IAragonForwarder(DAO_TOKEN_MANAGER).forward( - Utils.encodeEvmCallScript( - address(daoVoting), - abi.encodeCall(daoVoting.newVote, (dgProposeVoteScript, "Propose via DG", false, false)) - ) + bytes memory script = Utils.encodeEvmCallScript( + address(_timelock), abi.encodeCall(_timelock.schedule, (proposalId, executor, calls)) ); - Utils.supportVoteAndWaitTillDecided(voteId, ldoWhale); - - // The vote passed and may be enacted - assertEq(IAragonVoting(DAO_VOTING).canExecute(voteId), true); - daoVoting.executeVote(voteId); - - // The vote was proposed to DG - assertEq(dualGov.getProposalsCount(), 1); - - uint256 newProposalId = 1; - - // wait till the proposal may be executed - vm.warp(block.timestamp + dualGov.CONFIG().minProposalExecutionTimelock() + 1); + uint256 voteId = Utils.adoptVote(DAO_VOTING, description, script); - // execute the proposal - dualGov.schedule(newProposalId); + // The scheduled calls count is the same until the vote is enacted + assertEq(_timelock.getScheduledCallBatchesCount(), scheduledCallsCountBefore); - // the call must be scheduled to the timelock now - assertEq(timelock.getScheduledCallBatchesCount(), 1); - - // but it's not executable now - assertFalse(timelock.getIsExecutable(newProposalId)); - - // wait the timelock duration - vm.warp(block.timestamp + timelock.getDelay() + 1); - - // now call must be executable - assertTrue(timelock.getIsExecutable(newProposalId)); + // executing the vote + Utils.executeVote(DAO_VOTING, voteId); - // executing the call - vm.expectCall(address(target), calls[0].payload); - target.expectCalledBy(address(timelock.ADMIN_EXECUTOR())); - timelock.execute(newProposalId); + // new call is scheduled but has not executable yet + assertEq(_timelock.getScheduledCallBatchesCount(), scheduledCallsCountBefore + 1); - // executed calls were removed from the scheduled - assertEq(timelock.getScheduledCallBatchesCount(), 0); + // validate the correct batch was created + _assertScheduledCallsBatch(proposalId, executor, calls); } - function _testDualGovernanceRedeploy(DualGovernance dualGov) internal { - // after some significant time dual governance update is prepared - vm.warp(block.timestamp + 365 days); - DualGovernanceDeployFactory newDgFactory = new DualGovernanceDeployFactory(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE); - DualGovernance newDg = newDgFactory.deployDualGovernance(address(timelock)); - - // prepare vote to update the DG implementation and reset the emergency committee - - ExecutorCall[] memory newDualGovActivationCalls = new ExecutorCall[](2); - // call timelock.setGovernance() with deployed instance of DG - newDualGovActivationCalls[0].target = address(timelock); - newDualGovActivationCalls[0].payload = abi.encodeCall(timelock.setGovernance, (address(newDg), 1 days)); - - // call timelock.setEmergencyProtection() to update the emergency protection settings - newDualGovActivationCalls[1].target = address(timelock); - newDualGovActivationCalls[1].payload = - abi.encodeCall(timelock.setEmergencyProtection, (vetoMultisig, 90 days, 30 days)); - - uint256 newProposalId = 2; - bytes memory newProposeCalldata = abi.encodeCall(dualGov.propose, (newDualGovActivationCalls)); - - bytes memory newScript = Utils.encodeEvmCallScript(address(dualGov), newProposeCalldata); - - uint256 voteId = daoVoting.votesLength(); + function _assertScheduledCallsBatch(uint256 proposalId, address executor, ExecutorCall[] memory calls) internal { + ScheduledCallsBatch memory batch = _timelock.getScheduledCallsBatch(proposalId); + assertEq(batch.id, proposalId, "unexpected batch id"); + assertFalse(batch.isCanceled, "batch is canceled"); + assertEq(batch.executor, executor, "unexpected executor"); + assertEq(batch.scheduledAt, block.timestamp, "unexpected scheduledAt"); + assertEq(batch.executableAfter, block.timestamp + _DELAY, "unexpected executableAfter"); + assertEq(batch.calls.length, calls.length, "unexpected calls length"); + + for (uint256 i = 0; i < batch.calls.length; ++i) { + ExecutorCall memory expected = calls[i]; + ExecutorCall memory actual = batch.calls[i]; + + assertEq(actual.value, expected.value); + assertEq(actual.target, expected.target); + assertEq(actual.payload, expected.payload); + } + } - // The quorum to activate the DG is reached among the honest LDO holders - vm.prank(ldoWhale); - IAragonForwarder(DAO_TOKEN_MANAGER).forward( - Utils.encodeEvmCallScript( - address(daoVoting), abi.encodeCall(daoVoting.newVote, (newScript, "Redeploy DG", false, false)) - ) + function _waitFor(uint256 proposalId) internal { + // the call is not executable until the delay has passed + assertFalse( + _timelock.getScheduledCallsBatch(proposalId).executableAfter <= block.timestamp, "proposal is executable" ); - Utils.supportVoteAndWaitTillDecided(voteId, ldoWhale); - - // The vote passed and may be enacted - assertEq(IAragonVoting(DAO_VOTING).canExecute(voteId), true); - daoVoting.executeVote(voteId); - - // new proposal was successfully created - assertEq(dualGov.getProposalsCount(), 2); - - // wait till the proposal may be executed - vm.warp(block.timestamp + dualGov.CONFIG().minProposalExecutionTimelock() + 1); - - // execute the proposal - dualGov.schedule(newProposalId); - - // the call must be scheduled to the timelock now - assertEq(timelock.getScheduledCallBatchesCount(), 1); - // but it's not executable now - assertFalse(timelock.getIsExecutable(newProposalId)); - - // wait the timelock duration - vm.warp(block.timestamp + timelock.getDelay() + 1); - - // now call must be executable - assertTrue(timelock.getIsExecutable(newProposalId)); - timelock.execute(newProposalId); - uint256 dgDeployedTimestamp = block.timestamp; - - // new dual gov instance must be attached to timelock now - assertEq(timelock.getGovernance(), address(newDg)); - - EmergencyProtectedTimelock.EmergencyState memory emergencyState = timelock.getEmergencyState(); - // after the emergency mode deactivation, all other emergency protection settings - // stays the same - assertFalse(emergencyState.isActive); - assertEq(emergencyState.committee, vetoMultisig); - assertEq(emergencyState.protectedTill, dgDeployedTimestamp + 90 days); - assertEq(emergencyState.emergencyModeDuration, 30 days); + // wait until scheduled call becomes executable + vm.warp(block.timestamp + _DELAY + 1); + assertFalse( + _timelock.getScheduledCallsBatch(proposalId).executableAfter > block.timestamp, "proposal is executable" + ); } } diff --git a/test/scenario/setup.sol b/test/scenario/setup.sol index 638ffc32..0d96aea9 100644 --- a/test/scenario/setup.sol +++ b/test/scenario/setup.sol @@ -87,7 +87,9 @@ abstract contract DualGovernanceSetup is TestAssertions { // configure Timelock d.adminExecutor.execute( - address(d.timelock), 0, abi.encodeCall(d.timelock.setGovernance, (address(d.dualGov), timelockDuration)) + address(d.timelock), + 0, + abi.encodeCall(d.timelock.setGovernanceAndDelay, (address(d.dualGov), timelockDuration)) ); // TODO: pass this value via args diff --git a/test/utils/executor-calls.sol b/test/utils/executor-calls.sol new file mode 100644 index 00000000..2396a101 --- /dev/null +++ b/test/utils/executor-calls.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {ExecutorCall} from "contracts/libraries/ScheduledCalls.sol"; + +// Syntax sugar for more convenient creation of ExecutorCall arrays +library ExecutorCallHelpers { + // calls with explicit ExecutorCall definition + + function create(ExecutorCall[1] memory calls) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](1); + for (uint256 i = 0; i < 1; ++i) { + res[i] = calls[i]; + } + } + + function create(ExecutorCall[2] memory calls) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](2); + for (uint256 i = 0; i < 2; ++i) { + res[i] = calls[i]; + } + } + + function create(ExecutorCall[3] memory calls) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](2); + for (uint256 i = 0; i < 2; ++i) { + res[i] = calls[i]; + } + } + + // calls with value equal to 0 + + function create( + address[1] memory targets, + bytes[1] memory payloads + ) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](1); + res[0].target = targets[0]; + res[0].payload = payloads[0]; + } + + function create( + address[2] memory targets, + bytes[2] memory payloads + ) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](2); + for (uint256 i = 0; i < 2; ++i) { + res[i].target = targets[i]; + res[i].payload = payloads[i]; + } + } + + function create( + address[3] memory targets, + bytes[3] memory payloads + ) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](3); + for (uint256 i = 0; i < 3; ++i) { + res[i].target = targets[i]; + res[i].payload = payloads[i]; + } + } + + // same target different calls + + function create(address target, bytes memory payload) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](1); + res[0].target = target; + res[0].payload = payload; + } + + function create(address target, bytes[2] memory payloads) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](2); + for (uint256 i = 0; i < 2; ++i) { + res[i].target = target; + res[i].payload = payloads[i]; + } + } + + function create(address target, bytes[3] memory payloads) internal pure returns (ExecutorCall[] memory res) { + res = new ExecutorCall[](3); + for (uint256 i = 0; i < 3; ++i) { + res[i].target = target; + res[i].payload = payloads[i]; + } + } +} diff --git a/test/utils/utils.sol b/test/utils/utils.sol index 25bc3dcc..350b108d 100644 --- a/test/utils/utils.sol +++ b/test/utils/utils.sol @@ -18,6 +18,36 @@ abstract contract TestAssertions is Test { } } +// May be used as a mock contract to collect method calls +contract TargetMock { + struct Call { + uint256 value; + address sender; + uint256 blockNumber; + bytes data; + } + + Call[] public calls; + + function getCallsLength() external view returns (uint256) { + return calls.length; + } + + function getCalls() external view returns (Call[] memory calls_) { + calls_ = calls; + } + + function reset() external { + for (uint256 i = 0; i < calls.length; ++i) { + calls.pop(); + } + } + + fallback() external payable { + calls.push(Call({value: msg.value, sender: msg.sender, blockNumber: block.number, data: msg.data})); + } +} + contract Target is TestAssertions { bool internal _expectNoCalls; address internal _expectedCaller; @@ -143,4 +173,30 @@ library Utils { vm.prank(voter); IAragonVoting(DAO_VOTING).vote(voteId, support, false); } + + // Creates vote with given description and script, votes for it, and waits until it can be executed + function adoptVote( + address voting, + string memory description, + bytes memory script + ) internal returns (uint256 voteId) { + uint256 ldoWhalePrivateKey = uint256(keccak256(abi.encodePacked("LDO_WHALE"))); + address ldoWhale = vm.addr(ldoWhalePrivateKey); + if (IERC20(LDO_TOKEN).balanceOf(ldoWhale) < IAragonVoting(DAO_VOTING).minAcceptQuorumPct()) { + setupLdoWhale(ldoWhale); + } + bytes memory voteScript = Utils.encodeEvmCallScript( + voting, abi.encodeCall(IAragonVoting.newVote, (script, description, false, false)) + ); + + voteId = IAragonVoting(voting).votesLength(); + + vm.prank(ldoWhale); + IAragonForwarder(DAO_TOKEN_MANAGER).forward(voteScript); + supportVoteAndWaitTillDecided(voteId, ldoWhale); + } + + function executeVote(address voting, uint256 voteId) internal { + IAragonVoting(voting).executeVote(voteId); + } }