diff --git a/test/kontrol/ProposalOperations.t.sol b/test/kontrol/ProposalOperations.t.sol new file mode 100644 index 00000000..de55d1f3 --- /dev/null +++ b/test/kontrol/ProposalOperations.t.sol @@ -0,0 +1,256 @@ +pragma solidity 0.8.23; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import "contracts/Configuration.sol"; +import "contracts/DualGovernance.sol"; +import "contracts/EmergencyProtectedTimelock.sol"; +import "contracts/Escrow.sol"; + +import {Status, Proposal} from "contracts/libraries/Proposals.sol"; +import {State} from "contracts/libraries/DualGovernanceState.sol"; +import {addTo, Duration, Durations} from "contracts/types/Duration.sol"; +import {Timestamp, Timestamps} from "contracts/types/Timestamp.sol"; + +import {ProposalOperationsSetup} from "test/kontrol/ProposalOperationsSetup.sol"; + +contract ProposalOperationsTest is ProposalOperationsSetup { + function _proposalOperationsInitializeStorage( + DualGovernance _dualGovernance, + EmergencyProtectedTimelock _timelock, + uint256 _proposalId + ) public { + _timelockStorageSetup(_dualGovernance, _timelock); + _proposalStorageSetup(_timelock, _proposalId); + uint256 baseSlot = _getProposalsSlot(_proposalId); + uint256 numCalls = _getCallsCount(_timelock, _proposalId); + _storeExecutorCalls(_timelock, baseSlot, numCalls); + } + + struct ProposalRecord { + State state; + uint256 id; + uint256 lastCancelledProposalId; + Timestamp submittedAt; + Timestamp scheduledAt; + Timestamp executedAt; + Timestamp vetoSignallingActivationTime; + } + + // Record a proposal's details with the current governance state. + function _recordProposal( + DualGovernance _dualGovernance, + EmergencyProtectedTimelock _timelock, + uint256 proposalId + ) internal returns (ProposalRecord memory pr) { + uint256 baseSlot = _getProposalsSlot(proposalId); + pr.id = proposalId; + pr.state = _dualGovernance.getCurrentState(); + pr.lastCancelledProposalId = _getLastCancelledProposalId(timelock); + pr.submittedAt = Timestamp.wrap(_getSubmittedAt(_timelock, baseSlot)); + pr.scheduledAt = Timestamp.wrap(_getScheduledAt(_timelock, baseSlot)); + pr.executedAt = Timestamp.wrap(_getExecutedAt(_timelock, baseSlot)); + (,, pr.vetoSignallingActivationTime,) = _dualGovernance.getVetoSignallingState(); + } + + // Validate that a pending proposal meets the criteria. + function _validPendingProposal(Mode mode, ProposalRecord memory pr) internal pure { + _establish(mode, pr.lastCancelledProposalId < pr.id); + _establish(mode, pr.submittedAt != Timestamp.wrap(0)); + _establish(mode, pr.scheduledAt == Timestamp.wrap(0)); + _establish(mode, pr.executedAt == Timestamp.wrap(0)); + } + + // Validate that a scheduled proposal meets the criteria. + function _validScheduledProposal(Mode mode, ProposalRecord memory pr) internal { + _establish(mode, pr.lastCancelledProposalId < pr.id); + _establish(mode, pr.submittedAt != Timestamp.wrap(0)); + _establish(mode, pr.scheduledAt != Timestamp.wrap(0)); + _establish(mode, pr.executedAt == Timestamp.wrap(0)); + _assumeNoOverflow(config.AFTER_SUBMIT_DELAY().toSeconds(), pr.submittedAt.toSeconds()); + _establish(mode, config.AFTER_SUBMIT_DELAY().toSeconds() + pr.submittedAt.toSeconds() <= type(uint40).max); + _establish(mode, config.AFTER_SUBMIT_DELAY().addTo(pr.submittedAt) <= Timestamps.now()); + } + + function _validExecutedProposal(Mode mode, ProposalRecord memory pr) internal { + _establish(mode, pr.lastCancelledProposalId < pr.id); + _establish(mode, pr.submittedAt != Timestamp.wrap(0)); + _establish(mode, pr.scheduledAt != Timestamp.wrap(0)); + _establish(mode, pr.executedAt != Timestamp.wrap(0)); + _assumeNoOverflow(config.AFTER_SUBMIT_DELAY().toSeconds(), pr.submittedAt.toSeconds()); + _assumeNoOverflow(config.AFTER_SCHEDULE_DELAY().toSeconds(), pr.scheduledAt.toSeconds()); + _establish(mode, config.AFTER_SUBMIT_DELAY().addTo(pr.submittedAt) <= Timestamps.now()); + _establish(mode, config.AFTER_SCHEDULE_DELAY().addTo(pr.scheduledAt) <= Timestamps.now()); + } + + function _validCanceledProposal(Mode mode, ProposalRecord memory pr) internal pure { + _establish(mode, pr.id <= pr.lastCancelledProposalId); + _establish(mode, pr.submittedAt != Timestamp.wrap(0)); + _establish(mode, pr.executedAt == Timestamp.wrap(0)); + } + + function _isExecuted(ProposalRecord memory pr) internal pure returns (bool) { + return pr.executedAt != Timestamp.wrap(0); + } + + function _isCancelled(ProposalRecord memory pr) internal pure returns (bool) { + return pr.lastCancelledProposalId >= pr.id; + } + + function testCannotProposeInInvalidState() external { + _timelockStorageSetup(dualGovernance, timelock); + uint256 newProposalIndex = timelock.getProposalsCount(); + + vm.assume( + dualGovernance.getCurrentState() == State.VetoSignallingDeactivation + || dualGovernance.getCurrentState() == State.VetoCooldown + ); + + address proposer = address(uint160(uint256(keccak256("proposer")))); + vm.assume(dualGovernance.isProposer(proposer)); + + vm.prank(proposer); + vm.expectRevert(DualGovernanceState.ProposalsCreationSuspended.selector); + dualGovernance.submitProposal(new ExecutorCall[](1)); + + assert(timelock.getProposalsCount() == newProposalIndex); + } + + /** + * Test that a proposal cannot be scheduled for execution if the Dual Governance state is not Normal or VetoCooldown. + */ + function testCannotScheduleInInvalidStates(uint256 proposalId) external { + _timelockStorageSetup(dualGovernance, timelock); + _proposalIdAssumeBound(proposalId); + _proposalStorageSetup(timelock, proposalId); + + ProposalRecord memory pre = _recordProposal(dualGovernance, timelock, proposalId); + _validPendingProposal(Mode.Assume, pre); + vm.assume(timelock.canSchedule(proposalId)); + vm.assume(!dualGovernance.isSchedulingEnabled()); + + vm.expectRevert(DualGovernanceState.ProposalsAdoptionSuspended.selector); + dualGovernance.scheduleProposal(proposalId); + + ProposalRecord memory post = _recordProposal(dualGovernance, timelock, proposalId); + _validPendingProposal(Mode.Assert, post); + } + + /** + * Test that a proposal cannot be scheduled for execution if it was submitted after the last time the VetoSignalling state was entered. + */ + function testCannotScheduleSubmissionAfterLastVetoSignalling(uint256 proposalId) external { + _timelockStorageSetup(dualGovernance, timelock); + _proposalOperationsInitializeStorage(dualGovernance, timelock, proposalId); + + ProposalRecord memory pre = _recordProposal(dualGovernance, timelock, proposalId); + _validPendingProposal(Mode.Assume, pre); + vm.assume(pre.state == State.VetoCooldown); + vm.assume(pre.submittedAt > pre.vetoSignallingActivationTime); + + vm.expectRevert(DualGovernanceState.ProposalsAdoptionSuspended.selector); + dualGovernance.scheduleProposal(proposalId); + + ProposalRecord memory post = _recordProposal(dualGovernance, timelock, proposalId); + _validPendingProposal(Mode.Assert, post); + } + + // Test that actions that are canceled or executed cannot be rescheduled + function testCanceledOrExecutedActionsCannotBeRescheduled(uint256 proposalId) external { + _proposalIdAssumeBound(proposalId); + _proposalOperationsInitializeStorage(dualGovernance, timelock, proposalId); + + ProposalRecord memory pre = _recordProposal(dualGovernance, timelock, proposalId); + vm.assume(pre.submittedAt != Timestamp.wrap(0)); + vm.assume(dualGovernance.isSchedulingEnabled()); + if (pre.state == State.VetoCooldown) { + vm.assume(pre.submittedAt <= pre.vetoSignallingActivationTime); + } + + // Check if the proposal has been executed + if (pre.executedAt != Timestamp.wrap(0)) { + _validExecutedProposal(Mode.Assume, pre); + + vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotSubmitted.selector, proposalId)); + dualGovernance.scheduleProposal(proposalId); + + ProposalRecord memory post = _recordProposal(dualGovernance, timelock, proposalId); + _validExecutedProposal(Mode.Assert, post); + } else if (pre.lastCancelledProposalId >= proposalId) { + // Check if the proposal has been cancelled + _validCanceledProposal(Mode.Assume, pre); + + vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotSubmitted.selector, proposalId)); + dualGovernance.scheduleProposal(proposalId); + + ProposalRecord memory post = _recordProposal(dualGovernance, timelock, proposalId); + _validCanceledProposal(Mode.Assert, post); + } + } + + /** + * Test that a proposal cannot be scheduled for execution before ProposalExecutionMinTimelock has passed since its submission. + */ + function testCannotScheduleBeforeMinTimelock(uint256 proposalId) external { + _proposalIdAssumeBound(proposalId); + _proposalOperationsInitializeStorage(dualGovernance, timelock, proposalId); + + ProposalRecord memory pre = _recordProposal(dualGovernance, timelock, proposalId); + _validPendingProposal(Mode.Assume, pre); + + vm.assume(dualGovernance.isSchedulingEnabled()); + if (pre.state == State.VetoCooldown) { + vm.assume(pre.submittedAt <= pre.vetoSignallingActivationTime); + } + vm.assume(Timestamps.now() < addTo(config.AFTER_SUBMIT_DELAY(), pre.submittedAt)); + + vm.expectRevert(abi.encodeWithSelector(Proposals.AfterSubmitDelayNotPassed.selector, proposalId)); + dualGovernance.scheduleProposal(proposalId); + + ProposalRecord memory post = _recordProposal(dualGovernance, timelock, proposalId); + _validPendingProposal(Mode.Assert, post); + } + + /** + * Test that a proposal cannot be executed until the emergency protection timelock has passed since it was scheduled. + */ + function testCannotExecuteBeforeEmergencyProtectionTimelock(uint256 proposalId) external { + _proposalIdAssumeBound(proposalId); + _proposalOperationsInitializeStorage(dualGovernance, timelock, proposalId); + + ProposalRecord memory pre = _recordProposal(dualGovernance, timelock, proposalId); + _validScheduledProposal(Mode.Assume, pre); + vm.assume(_getEmergencyModeEndsAfter(timelock) == 0); + vm.assume(Timestamps.now() < addTo(config.AFTER_SCHEDULE_DELAY(), pre.scheduledAt)); + + vm.expectRevert(abi.encodeWithSelector(Proposals.AfterScheduleDelayNotPassed.selector, proposalId)); + timelock.execute(proposalId); + + ProposalRecord memory post = _recordProposal(dualGovernance, timelock, proposalId); + _validScheduledProposal(Mode.Assert, post); + } + + /** + * Test that only admin proposers can cancel proposals. + */ + function testOnlyAdminProposersCanCancelProposals() external { + _timelockStorageSetup(dualGovernance, timelock); + + // Cancel as a non-admin proposer + address proposer = address(uint160(uint256(keccak256("proposer")))); + vm.assume(dualGovernance.isProposer(proposer)); + vm.assume(dualGovernance.getProposer(proposer).executor != config.ADMIN_EXECUTOR()); + + vm.prank(proposer); + vm.expectRevert(abi.encodeWithSelector(Proposers.NotAdminProposer.selector, proposer)); + dualGovernance.cancelAllPendingProposals(); + + // Cancel as an admin proposer + address adminProposer = address(uint160(uint256(keccak256("adminProposer")))); + vm.assume(dualGovernance.isProposer(adminProposer)); + vm.assume(dualGovernance.getProposer(adminProposer).executor == config.ADMIN_EXECUTOR()); + + vm.prank(adminProposer); + dualGovernance.cancelAllPendingProposals(); + } +} diff --git a/test/kontrol/ProposalOperationsSetup.sol b/test/kontrol/ProposalOperationsSetup.sol new file mode 100644 index 00000000..30bbee98 --- /dev/null +++ b/test/kontrol/ProposalOperationsSetup.sol @@ -0,0 +1,157 @@ +pragma solidity 0.8.23; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import "contracts/Configuration.sol"; +import "contracts/DualGovernance.sol"; +import "contracts/EmergencyProtectedTimelock.sol"; +import "contracts/Escrow.sol"; + +import {Status, Proposal} from "contracts/libraries/Proposals.sol"; +import {State} from "contracts/libraries/DualGovernanceState.sol"; +import {addTo, Duration, Durations} from "contracts/types/Duration.sol"; +import {Timestamp, Timestamps} from "contracts/types/Timestamp.sol"; + +import {DualGovernanceSetUp} from "test/kontrol/DualGovernanceSetUp.sol"; + +contract ProposalOperationsSetup is DualGovernanceSetUp { + // ?STORAGE3 + // ?WORD21: lastCancelledProposalId + // ?WROD22: proposalsLength + // ?WORD23: protectedTill + // ?WORD24: emergencyModeEndsAfter + function _timelockStorageSetup(DualGovernance _dualGovernance, EmergencyProtectedTimelock _timelock) public { + // Slot 0 + _storeAddress(address(_timelock), 0, address(_dualGovernance)); + // Slot 1 + uint256 lastCancelledProposalId = kevm.freshUInt(32); + vm.assume(lastCancelledProposalId < type(uint256).max); + _storeUInt256(address(timelock), 1, lastCancelledProposalId); + // Slot 2 + uint256 proposalsLength = kevm.freshUInt(32); + vm.assume(proposalsLength < type(uint256).max); + vm.assume(lastCancelledProposalId <= proposalsLength); + _storeUInt256(address(_timelock), 2, proposalsLength); + // Slot 3 + { + address activationCommittee = address(uint160(uint256(keccak256("activationCommittee")))); + uint40 protectedTill = uint40(kevm.freshUInt(5)); + vm.assume(protectedTill < timeUpperBound); + vm.assume(protectedTill <= block.timestamp); + bytes memory slot3Abi = abi.encodePacked(uint56(0), uint40(protectedTill), uint160(activationCommittee)); + bytes32 slot3; + assembly { + slot3 := mload(add(slot3Abi, 0x20)) + } + _storeBytes32(address(_timelock), 3, slot3); + } + // Slot 4 + uint40 emergencyModeEndsAfter = uint40(kevm.freshUInt(5)); + vm.assume(emergencyModeEndsAfter < timeUpperBound); + vm.assume(emergencyModeEndsAfter <= block.timestamp); + _storeUInt256(address(_timelock), 4, emergencyModeEndsAfter); + } + + // Set up the storage for a proposal. + // ?WORD25: submittedAt + // ?WROD26: scheduledAt + // ?WORD27: executedAt + // ?WORD28: numCalls + function _proposalStorageSetup(EmergencyProtectedTimelock _timelock, uint256 _proposalId) public { + uint256 baseSlot = _getProposalsSlot(_proposalId); + // slot 1 + { + address executor = address(uint160(uint256(keccak256("executor")))); + uint40 submittedAt = uint40(kevm.freshUInt(5)); + vm.assume(submittedAt < timeUpperBound); + vm.assume(submittedAt <= block.timestamp); + uint40 scheduledAt = uint40(kevm.freshUInt(5)); + vm.assume(scheduledAt < timeUpperBound); + vm.assume(scheduledAt <= block.timestamp); + bytes memory slot1Abi = + abi.encodePacked(uint16(0), uint40(scheduledAt), uint40(submittedAt), uint160(executor)); + bytes32 slot1; + assembly { + slot1 := mload(add(slot1Abi, 0x20)) + } + _storeBytes32(address(_timelock), baseSlot, slot1); + } + // slot 2 + { + uint40 executedAt = uint40(kevm.freshUInt(5)); + vm.assume(executedAt < timeUpperBound); + vm.assume(executedAt <= block.timestamp); + _storeUInt256(address(_timelock), baseSlot + 1, executedAt); + } + // slot 3 + { + uint256 numCalls = kevm.freshUInt(32); + vm.assume(numCalls < type(uint256).max); + vm.assume(numCalls > 0); + uint256 callsSlot = uint256(keccak256(abi.encodePacked(baseSlot + 2))); + vm.assume(numCalls <= (type(uint256).max - callsSlot) / 3); + _storeUInt256(address(_timelock), baseSlot + 2, numCalls); + } + } + + function _storeExecutorCalls(EmergencyProtectedTimelock _timelock, uint256 baseSlot, uint256 numCalls) public { + uint256 callsSlot = uint256(keccak256(abi.encodePacked(baseSlot + 2))); + + for (uint256 j = 0; j < numCalls; j++) { + uint256 callSlot = callsSlot + j * 3; + vm.assume(callSlot < type(uint256).max); + address target = address(uint160(uint256(keccak256(abi.encodePacked(j, "target"))))); + _storeAddress(address(_timelock), callSlot, target); + uint96 value = uint96(kevm.freshUInt(12)); + vm.assume(value != 0); + _storeUInt256(address(_timelock), callSlot + 1, uint256(value)); + bytes memory payload = abi.encodePacked(j, "payload"); + _storeBytes32(address(_timelock), callSlot + 2, keccak256(payload)); + } + } + + function _proposalIdAssumeBound(uint256 _proposalId) internal view { + vm.assume(_proposalId > 0); + vm.assume(_proposalId < _getProposalsCount(timelock)); + uint256 slot2 = uint256(keccak256(abi.encodePacked(uint256(2)))); + vm.assume((_proposalId - 1) <= ((type(uint256).max - 3 - slot2) / 3)); + } + + function _getProposalsSlot(uint256 _proposalId) internal returns (uint256 baseSlot) { + uint256 startSlot = uint256(keccak256(abi.encodePacked(uint256(2)))); + uint256 offset = 3 * (_proposalId - 1); + baseSlot = startSlot + offset; + } + + function _getProtectedTill(EmergencyProtectedTimelock _timelock) internal view returns (uint40) { + return uint40(_loadUInt256(address(_timelock), 3) >> 160); + } + + function _getLastCancelledProposalId(EmergencyProtectedTimelock _timelock) internal view returns (uint256) { + return _loadUInt256(address(_timelock), 1); + } + + function _getProposalsCount(EmergencyProtectedTimelock _timelock) internal view returns (uint256) { + return _loadUInt256(address(_timelock), 2); + } + + function _getEmergencyModeEndsAfter(EmergencyProtectedTimelock _timelock) internal view returns (uint40) { + return uint40(_loadUInt256(address(_timelock), 4)); + } + + function _getSubmittedAt(EmergencyProtectedTimelock _timelock, uint256 baseSlot) internal view returns (uint40) { + return uint40(_loadUInt256(address(_timelock), baseSlot) >> 160); + } + + function _getScheduledAt(EmergencyProtectedTimelock _timelock, uint256 baseSlot) internal view returns (uint40) { + return uint40(_loadUInt256(address(_timelock), baseSlot) >> 200); + } + + function _getExecutedAt(EmergencyProtectedTimelock _timelock, uint256 baseSlot) internal view returns (uint40) { + return uint40(_loadUInt256(address(_timelock), baseSlot + 1)); + } + + function _getCallsCount(EmergencyProtectedTimelock _timelock, uint256 baseSlot) internal view returns (uint256) { + return _loadUInt256(address(_timelock), baseSlot + 2); + } +}