diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index e3bcdb9a..66175aaf 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -2,12 +2,14 @@ pragma solidity 0.8.26; import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; import {IStETH} from "./interfaces/IStETH.sol"; import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue} from "./interfaces/IWithdrawalQueue.sol"; +import {IEscrow} from "./interfaces/IEscrow.sol"; import {ITimelock} from "./interfaces/ITimelock.sol"; import {ITiebreaker} from "./interfaces/ITiebreaker.sol"; -import {IWithdrawalQueue} from "./interfaces/IWithdrawalQueue.sol"; import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; import {IResealManager} from "./interfaces/IResealManager.sol"; import {IDualGovernanceConfigProvider} from "./interfaces/IDualGovernanceConfigProvider.sol"; @@ -15,15 +17,19 @@ import {IDualGovernanceConfigProvider} from "./interfaces/IDualGovernanceConfigP import {Proposers} from "./libraries/Proposers.sol"; import {Tiebreaker} from "./libraries/Tiebreaker.sol"; import {ExternalCall} from "./libraries/ExternalCalls.sol"; -import {DualGovernanceConfig} from "./libraries/DualGovernanceConfig.sol"; import {State, DualGovernanceStateMachine} from "./libraries/DualGovernanceStateMachine.sol"; import {Escrow} from "./Escrow.sol"; +/// @title Dual Governance +/// @notice Main contract for the Dual Governance system, serving as the central interface for proposal submission +/// and scheduling. The contract is organized as a state machine, managing transitions between governance states +/// and coordinating interactions between the Signalling Escrow and Rage Quit Escrow. It enables stETH, wstETH, +/// and unstETH holders to participate in the governance process and influence dynamic timelock mechanisms based +/// on their locked assets. contract DualGovernance is IDualGovernance { using Proposers for Proposers.Context; using Tiebreaker for Tiebreaker.Context; - using DualGovernanceConfig for DualGovernanceConfig.Context; using DualGovernanceStateMachine for DualGovernanceStateMachine.Context; // --- @@ -34,7 +40,6 @@ contract DualGovernance is IDualGovernance { error UnownedAdminExecutor(); error CallerIsNotResealCommittee(address caller); error CallerIsNotAdminExecutor(address caller); - error InvalidConfigProvider(IDualGovernanceConfigProvider configProvider); error ProposalSubmissionBlocked(); error ProposalSchedulingBlocked(uint256 proposalId); error ResealIsNotAllowedInNormalState(); @@ -45,14 +50,23 @@ contract DualGovernance is IDualGovernance { event CancelAllPendingProposalsSkipped(); event CancelAllPendingProposalsExecuted(); - event EscrowMasterCopyDeployed(address escrowMasterCopy); - event ConfigProviderSet(IDualGovernanceConfigProvider newConfigProvider); + event EscrowMasterCopyDeployed(IEscrow escrowMasterCopy); event ResealCommitteeSet(address resealCommittee); // --- - // Tiebreaker Sanity Check Param Immutables + // Sanity Check Parameters & Immutables // --- + /// @notice The parameters for the sanity checks. + /// @param minWithdrawalsBatchSize The minimum number of withdrawal requests allowed to create during a single call of + /// the `Escrow.requestNextWithdrawalsBatch(batchSize)` method. + /// @param minTiebreakerActivationTimeout The lower bound for the time the Dual Governance must spend in the "locked" state + /// before the tiebreaker committee is allowed to schedule proposals. + /// @param maxTiebreakerActivationTimeout The upper bound for the time the Dual Governance must spend in the "locked" state + /// before the tiebreaker committee is allowed to schedule proposals. + /// @param maxSealableWithdrawalBlockersCount The upper bound for the number of sealable withdrawal blockers allowed to be + /// registered in the Dual Governance. This parameter prevents filling the sealable withdrawal blockers + /// with so many items that tiebreaker calls would revert due to out-of-gas errors. struct SanityCheckParams { uint256 minWithdrawalsBatchSize; Duration minTiebreakerActivationTimeout; @@ -60,13 +74,30 @@ contract DualGovernance is IDualGovernance { uint256 maxSealableWithdrawalBlockersCount; } + /// @notice The lower bound for the time the Dual Governance must spend in the "locked" state + /// before the tiebreaker committee is allowed to schedule proposals. Duration public immutable MIN_TIEBREAKER_ACTIVATION_TIMEOUT; + + /// @notice The upper bound for the time the Dual Governance must spend in the "locked" state + /// before the tiebreaker committee is allowed to schedule proposals. Duration public immutable MAX_TIEBREAKER_ACTIVATION_TIMEOUT; + + /// @notice The upper bound for the number of sealable withdrawal blockers allowed to be + /// registered in the Dual Governance. This parameter prevents filling the sealable withdrawal blockers + /// with so many items that tiebreaker calls would revert due to out-of-gas errors. uint256 public immutable MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT; // --- - // External Parts Immutables + // External Dependencies + // --- + /// @notice The external dependencies of the Dual Governance system. + /// @param stETH The address of the stETH token. + /// @param wstETH The address of the wstETH token. + /// @param withdrawalQueue The address of Lido's Withdrawal Queue and the unstETH token. + /// @param timelock The address of the Timelock contract. + /// @param resealManager The address of the Reseal Manager. + /// @param configProvider The address of the Dual Governance Config Provider. struct ExternalDependencies { IStETH stETH; IWstETH wstETH; @@ -76,24 +107,41 @@ contract DualGovernance is IDualGovernance { IDualGovernanceConfigProvider configProvider; } + /// @notice The address of the Timelock contract. ITimelock public immutable TIMELOCK; + + /// @notice The address of the Reseal Manager. IResealManager public immutable RESEAL_MANAGER; - address public immutable ESCROW_MASTER_COPY; + + /// @notice The address of the Escrow contract used as the implementation for the Signalling and Rage Quit + /// instances of the Escrows managed by the DualGovernance contract. + IEscrow public immutable ESCROW_MASTER_COPY; // --- // Aspects // --- + /// @dev The functionality to manage the proposer -> executor pairs. Proposers.Context internal _proposers; + + /// @dev The functionality of the tiebreaker to handle "deadlocks" of the Dual Governance. Tiebreaker.Context internal _tiebreaker; + + /// @dev The state machine implementation controlling the state of the Dual Governance. DualGovernanceStateMachine.Context internal _stateMachine; // --- // Standalone State Variables // --- - IDualGovernanceConfigProvider internal _configProvider; + + /// @dev The address of the Reseal Committee which is allowed to "reseal" sealables paused for a limited + /// period of time when the Dual Governance proposal adoption is blocked. address internal _resealCommittee; + // --- + // Constructor + // --- + constructor(ExternalDependencies memory dependencies, SanityCheckParams memory sanityCheckParams) { TIMELOCK = dependencies.timelock; RESEAL_MANAGER = dependencies.resealManager; @@ -102,128 +150,213 @@ contract DualGovernance is IDualGovernance { MAX_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.maxTiebreakerActivationTimeout; MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT = sanityCheckParams.maxSealableWithdrawalBlockersCount; - _setConfigProvider(dependencies.configProvider); + ESCROW_MASTER_COPY = new Escrow({ + dualGovernance: this, + stETH: dependencies.stETH, + wstETH: dependencies.wstETH, + withdrawalQueue: dependencies.withdrawalQueue, + minWithdrawalsBatchSize: sanityCheckParams.minWithdrawalsBatchSize + }); - ESCROW_MASTER_COPY = address( - new Escrow({ - dualGovernance: this, - stETH: dependencies.stETH, - wstETH: dependencies.wstETH, - withdrawalQueue: dependencies.withdrawalQueue, - minWithdrawalsBatchSize: sanityCheckParams.minWithdrawalsBatchSize - }) - ); emit EscrowMasterCopyDeployed(ESCROW_MASTER_COPY); - _stateMachine.initialize(dependencies.configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + _stateMachine.initialize(dependencies.configProvider, ESCROW_MASTER_COPY); } // --- // Proposals Flow // --- + /// @notice Allows a registered proposer to submit a proposal to the Dual Governance system. Proposals can only + /// be submitted if the system is not in the `VetoSignallingDeactivation` or `VetoCooldown` state. + /// Each proposal contains a list of external calls to be executed sequentially, and will revert if + /// any call fails during execution. + /// @param calls An array of `ExternalCall` structs representing the actions to be executed sequentially when + /// the proposal is executed. Each call in the array will be executed one-by-one in the specified order. + /// If any call reverts, the entire proposal execution will revert. + /// @param metadata A string containing additional context or information about the proposal. This can be used + /// to provide a description, rationale, or other details relevant to the proposal. + /// @return proposalId The unique identifier of the submitted proposal. This ID can be used to reference the proposal + /// in future operations (scheduling and execution) with the proposal. function submitProposal( ExternalCall[] calldata calls, string calldata metadata ) external returns (uint256 proposalId) { - _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); - if (!_stateMachine.canSubmitProposal()) { + _stateMachine.activateNextState(ESCROW_MASTER_COPY); + if (!_stateMachine.canSubmitProposal({useEffectiveState: false})) { revert ProposalSubmissionBlocked(); } Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); proposalId = TIMELOCK.submit(proposer.executor, calls, metadata); } + /// @notice Schedules a previously submitted proposal for execution in the Dual Governance system. + /// The proposal can only be scheduled if the current state allows scheduling of the given proposal based on + /// the submission time, when the `Escrow.getAfterScheduleDelay()` has passed and proposal wasn't cancelled + /// or scheduled earlier. + /// @param proposalId The unique identifier of the proposal to be scheduled. This ID is obtained when the proposal + /// is initially submitted to the timelock contract. function scheduleProposal(uint256 proposalId) external { - _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); - ITimelock.ProposalDetails memory proposalDetails = TIMELOCK.getProposalDetails(proposalId); - if (!_stateMachine.canScheduleProposal(proposalDetails.submittedAt)) { + _stateMachine.activateNextState(ESCROW_MASTER_COPY); + Timestamp proposalSubmittedAt = TIMELOCK.getProposalDetails(proposalId).submittedAt; + if (!_stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt})) { revert ProposalSchedulingBlocked(proposalId); } TIMELOCK.schedule(proposalId); } - function cancelAllPendingProposals() external { - _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + /// @notice Allows a proposer associated with the admin executor to cancel all previously submitted or scheduled + /// but not yet executed proposals when the Dual Governance system is in the `VetoSignalling` + /// or `VetoSignallingDeactivation` state. + /// @dev If the Dual Governance state is not `VetoSignalling` or `VetoSignallingDeactivation`, the function will + /// exit early, emitting the `CancelAllPendingProposalsSkipped` event without canceling any proposals. + /// @return isProposalsCancelled A boolean indicating whether the proposals were successfully canceled (`true`) + /// or the cancellation was skipped due to an inappropriate state (`false`). + function cancelAllPendingProposals() external returns (bool) { + _stateMachine.activateNextState(ESCROW_MASTER_COPY); Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); if (proposer.executor != TIMELOCK.getAdminExecutor()) { revert NotAdminProposer(); } - State state = _stateMachine.getState(); - if (state != State.VetoSignalling && state != State.VetoSignallingDeactivation) { + if (!_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})) { /// @dev Some proposer contracts, like Aragon Voting, may not support canceling decisions that have already - /// reached consensus. This could lead to a situation where a proposer’s cancelAllPendingProposals() call - /// becomes unexecutable if the Dual Governance state changes. However, it might become executable again if - /// the system state shifts back to VetoSignalling or VetoSignallingDeactivation. - /// To avoid such a scenario, an early return is used instead of a revert when proposals cannot be canceled - /// due to an unsuitable Dual Governance state. + /// reached consensus. This could lead to a situation where a proposer’s cancelAllPendingProposals() call + /// becomes unexecutable if the Dual Governance state changes. However, it might become executable again if + /// the system state shifts back to VetoSignalling or VetoSignallingDeactivation. + /// To avoid such a scenario, an early return is used instead of a revert when proposals cannot be canceled + /// due to an unsuitable Dual Governance state. emit CancelAllPendingProposalsSkipped(); - return; + return false; } TIMELOCK.cancelAllNonExecutedProposals(); emit CancelAllPendingProposalsExecuted(); + return true; + } + + /// @notice Returns whether proposal submission is allowed based on the current `effective` state of the Dual Governance system. + /// @dev Proposal submission is forbidden in the `VetoSignalling` and `VetoSignallingDeactivation` states. + /// @return canSubmitProposal A boolean value indicating whether proposal submission is allowed (`true`) or not (`false`) + /// based on the current `effective` state of the Dual Governance system. + function canSubmitProposal() external view returns (bool) { + return _stateMachine.canSubmitProposal({useEffectiveState: true}); + } + + /// @notice Returns whether a previously submitted proposal can be scheduled for execution based on the `effective` + /// state of the Dual Governance system, the proposal's submission time, and its current status. + /// @dev Proposal scheduling is allowed only if all the following conditions are met: + /// - The Dual Governance system is in the `Normal` or `VetoCooldown` state. + /// - If the system is in the `VetoCooldown` state, the proposal must have been submitted before the system + /// last entered the `VetoSignalling` state. + /// - The proposal has not already been scheduled, canceled, or executed. + /// - The required delay period, as defined by `Escrow.getAfterSubmitDelay()`, has elapsed since the proposal + /// was submitted. + /// @param proposalId The unique identifier of the proposal to check. + /// @return canScheduleProposal A boolean value indicating whether the proposal can be scheduled (`true`) or + /// not (`false`) based on the current `effective` state of the Dual Governance system and the proposal's status. + function canScheduleProposal(uint256 proposalId) external view returns (bool) { + Timestamp proposalSubmittedAt = TIMELOCK.getProposalDetails(proposalId).submittedAt; + return _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + && TIMELOCK.canSchedule(proposalId); } - function canSubmitProposal() public view returns (bool) { - return _stateMachine.canSubmitProposal(); - } - - function canScheduleProposal(uint256 proposalId) external view returns (bool) { - ITimelock.ProposalDetails memory proposalDetails = TIMELOCK.getProposalDetails(proposalId); - return _stateMachine.canScheduleProposal(proposalDetails.submittedAt) && TIMELOCK.canSchedule(proposalId); + /// @notice Indicates whether the cancellation of all pending proposals is allowed based on the `effective` state + /// of the Dual Governance system, ensuring that the cancellation will not be skipped when calling the + /// `DualGovernance.cancelAllPendingProposals()` method. + /// @dev Proposal cancellation is only allowed when the Dual Governance system is in the `VetoSignalling` or + /// `VetoSignallingDeactivation` states. In any other state, the cancellation will be skipped and no proposals + /// will be canceled. + /// @return canCancelAllPendingProposals A boolean value indicating whether the pending proposals can be + /// canceled (`true`) or not (`false`) based on the current `effective` state of the Dual Governance system. + function canCancelAllPendingProposals() external view returns (bool) { + return _stateMachine.canCancelAllPendingProposals({useEffectiveState: true}); } // --- // Dual Governance State // --- + /// @notice Updates the state of the Dual Governance State Machine if a state transition is possible. + /// @dev This function should be called when the `persisted` and `effective` states of the system are not equal. + /// If the states are already synchronized, the function will complete without making any changes to the system state. function activateNextState() external { - _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + _stateMachine.activateNextState(ESCROW_MASTER_COPY); } + /// @notice Updates the address of the configuration provider for the Dual Governance system. + /// @param newConfigProvider The address of the new configuration provider contract. function setConfigProvider(IDualGovernanceConfigProvider newConfigProvider) external { _checkCallerIsAdminExecutor(); - _setConfigProvider(newConfigProvider); - - /// @dev the minAssetsLockDuration is kept as a storage variable in the signalling Escrow instance - /// to sync the new value with current signalling escrow, it's value must be manually updated - _stateMachine.signallingEscrow.setMinAssetsLockDuration( - newConfigProvider.getDualGovernanceConfig().minAssetsLockDuration - ); + _stateMachine.setConfigProvider(newConfigProvider); } + /// @notice Returns the current configuration provider address for the Dual Governance system. + /// @return configProvider The address of the current configuration provider contract. function getConfigProvider() external view returns (IDualGovernanceConfigProvider) { - return _configProvider; + return _stateMachine.configProvider; } + /// @notice Returns the address of the veto signaling escrow contract. + /// @return vetoSignallingEscrow The address of the veto signaling escrow contract. function getVetoSignallingEscrow() external view returns (address) { return address(_stateMachine.signallingEscrow); } + /// @notice Returns the address of the rage quit escrow contract used in the most recent or ongoing rage quit. + /// @dev The returned address will be the zero address if no rage quits have occurred in the system. + /// @return rageQuitEscrow The address of the rage quit escrow contract. function getRageQuitEscrow() external view returns (address) { return address(_stateMachine.rageQuitEscrow); } - function getState() external view returns (State state) { - state = _stateMachine.getState(); + /// @notice Returns the most recently stored (`persisted`) state of the Dual Governance State Machine. + /// @return persistedState The current persisted state of the system. + function getPersistedState() external view returns (State persistedState) { + persistedState = _stateMachine.getPersistedState(); } - function getStateDetails() external view returns (IDualGovernance.StateDetails memory stateDetails) { - return _stateMachine.getStateDetails(_configProvider.getDualGovernanceConfig()); + /// @notice Returns the current `effective` state of the Dual Governance State Machine. + /// @dev The effective state represents the state the system would transition to upon calling `activateNextState()`. + /// @return effectiveState The current effective state of the system. + function getEffectiveState() external view returns (State effectiveState) { + effectiveState = _stateMachine.getEffectiveState(); + } + + /// @notice Returns detailed information about the current state of the Dual Governance State Machine. + /// @return stateDetails A struct containing comprehensive details about the current state of the system, including: + /// - `effectiveState`: The `effective` state of the Dual Governance system. + /// - `persistedState`: The `persisted` state of the Dual Governance system. + /// - `persistedStateEnteredAt`: The timestamp when the system entered the current `persisted` state. + /// - `vetoSignallingActivatedAt`: The timestamp when `VetoSignalling` was last activated. + /// - `vetoSignallingReactivationTime`: The timestamp of the last transition from `VetoSignallingDeactivation` + /// to `VetoSignalling`. + /// - `normalOrVetoCooldownExitedAt`: The timestamp of the last exit from either the `Normal` or `VetoCooldown` state. + /// - `rageQuitRound`: The current count of consecutive Rage Quit rounds, starting from 0. + /// - `vetoSignallingDuration`: The expected duration of the `VetoSignalling` state, based on the support for rage quit + /// in the veto signalling escrow contract. + function getStateDetails() external view returns (StateDetails memory stateDetails) { + return _stateMachine.getStateDetails(); } // --- // Proposers & Executors Management // --- + /// @notice Registers a new proposer with the associated executor in the system. + /// @dev Multiple proposers can share the same executor contract, but each proposer must be unique. + /// @param proposer The address of the proposer to register. + /// @param executor The address of the executor contract associated with the proposer. function registerProposer(address proposer, address executor) external { _checkCallerIsAdminExecutor(); _proposers.register(proposer, executor); } + /// @notice Unregisters a proposer from the system. + /// @dev There must always be at least one proposer associated with the admin executor. If an attempt is made to + /// remove the last proposer assigned to the admin executor, the function will revert. + /// @param proposer The address of the proposer to unregister. function unregisterProposer(address proposer) external { _checkCallerIsAdminExecutor(); _proposers.unregister(proposer); @@ -234,18 +367,33 @@ contract DualGovernance is IDualGovernance { } } + /// @notice Checks whether the given `account` is a registered proposer. + /// @param account The address to check. + /// @return isProposer A boolean value indicating whether the `account` is a registered + /// proposer (`true`) or not (`false`). function isProposer(address account) external view returns (bool) { return _proposers.isProposer(account); } + /// @notice Returns the proposer data if the given `account` is a registered proposer. + /// @param account The address of the proposer to retrieve information for. + /// @return proposer A Proposer struct containing the data of the registered proposer, including: + /// - `account`: The address of the registered proposer. + /// - `executor`: The address of the executor associated with the proposer. function getProposer(address account) external view returns (Proposers.Proposer memory proposer) { proposer = _proposers.getProposer(account); } + /// @notice Returns the information about all registered proposers. + /// @return proposers An array of `Proposer` structs containing the data of all registered proposers. function getProposers() external view returns (Proposers.Proposer[] memory proposers) { proposers = _proposers.getAllProposers(); } + /// @notice Checks whether the given `account` is associated with an executor contract in the system. + /// @param account The address to check. + /// @return isExecutor A boolean value indicating whether the `account` is a registered + /// executor (`true`) or not (`false`). function isExecutor(address account) external view returns (bool) { return _proposers.isExecutor(account); } @@ -254,21 +402,35 @@ contract DualGovernance is IDualGovernance { // Tiebreaker Protection // --- + /// @notice Adds a unique address of a sealable contract that can be paused and may cause a Dual Governance tie (deadlock). + /// @dev A tie may occur when user withdrawal requests cannot be processed due to the paused state of a registered sealable + /// withdrawal blocker while the Dual Governance system is in the RageQuit state. + /// The contract being added must implement the `ISealable` interface. + /// @param sealableWithdrawalBlocker The address of the sealable contract to be added as a tiebreaker withdrawal blocker. function addTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { _checkCallerIsAdminExecutor(); _tiebreaker.addSealableWithdrawalBlocker(sealableWithdrawalBlocker, MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT); } + /// @notice Removes a previously registered sealable contract from the system. + /// @param sealableWithdrawalBlocker The address of the sealable contract to be removed. function removeTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { _checkCallerIsAdminExecutor(); _tiebreaker.removeSealableWithdrawalBlocker(sealableWithdrawalBlocker); } + /// @notice Sets the new address of the tiebreaker committee in the system. + /// @param tiebreakerCommittee The address of the new tiebreaker committee. function setTiebreakerCommittee(address tiebreakerCommittee) external { _checkCallerIsAdminExecutor(); _tiebreaker.setTiebreakerCommittee(tiebreakerCommittee); } + /// @notice Sets the new value for the tiebreaker activation timeout. + /// @dev If the Dual Governance system remains out of the `Normal` or `VetoCooldown` state for longer than + /// the `tiebreakerActivationTimeout` duration, the tiebreaker committee is allowed to schedule + /// submitted proposals. + /// @param tiebreakerActivationTimeout The new duration for the tiebreaker activation timeout. function setTiebreakerActivationTimeout(Duration tiebreakerActivationTimeout) external { _checkCallerIsAdminExecutor(); _tiebreaker.setTiebreakerActivationTimeout( @@ -276,40 +438,57 @@ contract DualGovernance is IDualGovernance { ); } + /// @notice Allows the tiebreaker committee to resume a paused sealable contract when the system is in a tie state. + /// @param sealable The address of the sealable contract to be resumed. function tiebreakerResumeSealable(address sealable) external { _tiebreaker.checkCallerIsTiebreakerCommittee(); - _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); - _tiebreaker.checkTie(_stateMachine.getState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + _stateMachine.activateNextState(ESCROW_MASTER_COPY); + _tiebreaker.checkTie(_stateMachine.getPersistedState(), _stateMachine.normalOrVetoCooldownExitedAt); RESEAL_MANAGER.resume(sealable); } + /// @notice Allows the tiebreaker committee to schedule for execution a submitted proposal when + /// the system is in a tie state. + /// @param proposalId The unique identifier of the proposal to be scheduled. function tiebreakerScheduleProposal(uint256 proposalId) external { _tiebreaker.checkCallerIsTiebreakerCommittee(); - _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); - _tiebreaker.checkTie(_stateMachine.getState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + _stateMachine.activateNextState(ESCROW_MASTER_COPY); + _tiebreaker.checkTie(_stateMachine.getPersistedState(), _stateMachine.normalOrVetoCooldownExitedAt); TIMELOCK.schedule(proposalId); } + /// @notice Returns detailed information about the current tiebreaker state based on the `effective` state of the system. + /// @return tiebreakerState A struct containing detailed information about the current state of the tiebreaker system, including: + /// - `isTie`: Indicates whether the system is in a tie state, allowing the tiebreaker committee to schedule proposals + /// or resume sealable contracts. + /// - `tiebreakerCommittee`: The address of the current tiebreaker committee. + /// - `tiebreakerActivationTimeout`: The required duration the system must remain in a "locked" state + /// (not in `Normal` or `VetoCooldown` state) before the tiebreaker committee is permitted to take actions. + /// - `sealableWithdrawalBlockers`: An array of sealable contracts registered in the system as withdrawal blockers. function getTiebreakerDetails() external view returns (ITiebreaker.TiebreakerDetails memory tiebreakerState) { - return _tiebreaker.getTiebreakerDetails( - _stateMachine.getState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt() - ); + return _tiebreaker.getTiebreakerDetails(_stateMachine.getStateDetails()); } // --- - // Reseal executor + // Sealables Resealing // --- + /// @notice Allows the reseal committee to "reseal" (pause indefinitely) an instance of a sealable contract through + /// the ResealManager contract. + /// @param sealable The address of the sealable contract to be resealed. function resealSealable(address sealable) external { + _stateMachine.activateNextState(ESCROW_MASTER_COPY); if (msg.sender != _resealCommittee) { revert CallerIsNotResealCommittee(msg.sender); } - if (_stateMachine.getState() == State.Normal) { + if (_stateMachine.getPersistedState() == State.Normal) { revert ResealIsNotAllowedInNormalState(); } RESEAL_MANAGER.reseal(sealable); } + /// @notice Sets the address of the reseal committee. + /// @param resealCommittee The address of the new reseal committee. function setResealCommittee(address resealCommittee) external { _checkCallerIsAdminExecutor(); _resealCommittee = resealCommittee; @@ -318,19 +497,9 @@ contract DualGovernance is IDualGovernance { } // --- - // Private methods + // Internal methods // --- - function _setConfigProvider(IDualGovernanceConfigProvider newConfigProvider) internal { - if (address(newConfigProvider) == address(0) || newConfigProvider == _configProvider) { - revert InvalidConfigProvider(newConfigProvider); - } - - newConfigProvider.getDualGovernanceConfig().validate(); - _configProvider = newConfigProvider; - emit ConfigProviderSet(newConfigProvider); - } - function _checkCallerIsAdminExecutor() internal view { if (TIMELOCK.getAdminExecutor() != msg.sender) { revert CallerIsNotAdminExecutor(msg.sender); diff --git a/contracts/EmergencyProtectedTimelock.sol b/contracts/EmergencyProtectedTimelock.sol index 7cf1a99b..12237025 100644 --- a/contracts/EmergencyProtectedTimelock.sol +++ b/contracts/EmergencyProtectedTimelock.sol @@ -13,20 +13,29 @@ import {ExecutableProposals} from "./libraries/ExecutableProposals.sol"; import {EmergencyProtection} from "./libraries/EmergencyProtection.sol"; /// @title EmergencyProtectedTimelock -/// @dev A timelock contract with emergency protection functionality. -/// The contract allows for submitting, scheduling, and executing proposals, -/// while providing emergency protection features to prevent unauthorized -/// execution during emergency situations. +/// @dev A timelock contract with emergency protection functionality. The contract allows for submitting, scheduling, +/// and executing proposals, while providing emergency protection features to prevent unauthorized execution during +/// emergency situations. contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { using TimelockState for TimelockState.Context; using ExecutableProposals for ExecutableProposals.Context; using EmergencyProtection for EmergencyProtection.Context; + // --- + // Errors + // --- + error CallerIsNotAdminExecutor(address value); // --- - // Sanity Check Params Immutables + // Sanity Check Parameters & Immutables // --- + + /// @notice The parameters for the sanity checks. + /// @param maxAfterSubmitDelay The maximum allowable delay before a submitted proposal can be scheduled for execution. + /// @param maxAfterScheduleDelay The maximum allowable delay before a scheduled proposal can be executed. + /// @param maxEmergencyModeDuration The maximum time the timelock can remain in emergency mode. + /// @param maxEmergencyProtectionDuration The maximum time the emergency protection mechanism can be activated. struct SanityCheckParams { Duration maxAfterSubmitDelay; Duration maxAfterScheduleDelay; @@ -34,26 +43,42 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { Duration maxEmergencyProtectionDuration; } + /// @notice The upper bound for the delay required before a submitted proposal can be scheduled for execution. Duration public immutable MAX_AFTER_SUBMIT_DELAY; + + /// @notice The upper bound for the delay required before a scheduled proposal can be executed. Duration public immutable MAX_AFTER_SCHEDULE_DELAY; + /// @notice The upper bound for the time the timelock can remain in emergency mode. Duration public immutable MAX_EMERGENCY_MODE_DURATION; + + /// @notice The upper bound for the time the emergency protection mechanism can be activated. Duration public immutable MAX_EMERGENCY_PROTECTION_DURATION; // --- // Admin Executor Immutables // --- + /// @dev The address of the admin executor, authorized to manage the EmergencyProtectedTimelock instance. address private immutable _ADMIN_EXECUTOR; // --- // Aspects // --- + /// @dev The functionality for managing the state of the timelock. TimelockState.Context internal _timelockState; + + /// @dev The functionality for managing the lifecycle of proposals. ExecutableProposals.Context internal _proposals; + + /// @dev The functionality for managing the emergency protection mechanism. EmergencyProtection.Context internal _emergencyProtection; + // --- + // Constructor + // --- + constructor(SanityCheckParams memory sanityCheckParams, address adminExecutor) { _ADMIN_EXECUTOR = adminExecutor; @@ -67,12 +92,11 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { // Main Timelock Functionality // --- - /// @dev Submits a new proposal to execute a series of calls through an executor. - /// Only the governance contract can call this function. + /// @notice Submits a new proposal to execute a series of calls through an executor. /// @param executor The address of the executor contract that will execute the calls. /// @param calls An array of `ExternalCall` structs representing the calls to be executed. /// @param metadata A string containing additional information about the proposal. - /// @return newProposalId The ID of the newly created proposal. + /// @return newProposalId The id of the newly created proposal. function submit( address executor, ExternalCall[] calldata calls, @@ -82,24 +106,21 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { newProposalId = _proposals.submit(executor, calls, metadata); } - /// @dev Schedules a proposal for execution after a specified delay. - /// Only the governance contract can call this function. - /// @param proposalId The ID of the proposal to be scheduled. + /// @notice Schedules a proposal for execution after a specified delay. + /// @param proposalId The id of the proposal to be scheduled. function schedule(uint256 proposalId) external { _timelockState.checkCallerIsGovernance(); _proposals.schedule(proposalId, _timelockState.getAfterSubmitDelay()); } - /// @dev Executes a scheduled proposal. - /// Checks if emergency mode is active and prevents execution if it is. - /// @param proposalId The ID of the proposal to be executed. + /// @notice Executes a scheduled proposal. + /// @param proposalId The id of the proposal to be executed. function execute(uint256 proposalId) external { _emergencyProtection.checkEmergencyMode({isActive: false}); _proposals.execute(proposalId, _timelockState.getAfterScheduleDelay()); } - /// @dev Cancels all non-executed proposals. - /// Only the governance contract can call this function. + /// @notice Cancels all non-executed proposals, preventing them from being executed in the future. function cancelAllNonExecutedProposals() external { _timelockState.checkCallerIsGovernance(); _proposals.cancelAll(); @@ -109,19 +130,23 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { // Timelock Management // --- + /// @notice Updates the address of the governance contract. + /// @param newGovernance The address of the new governance contract to be set. function setGovernance(address newGovernance) external { _checkCallerIsAdminExecutor(); _timelockState.setGovernance(newGovernance); } + /// @notice Configures the delays for submitting and scheduling proposals, within defined upper bounds. + /// @param afterSubmitDelay The delay required before a submitted proposal can be scheduled. + /// @param afterScheduleDelay The delay required before a scheduled proposal can be executed. function setupDelays(Duration afterSubmitDelay, Duration afterScheduleDelay) external { _checkCallerIsAdminExecutor(); _timelockState.setAfterSubmitDelay(afterSubmitDelay, MAX_AFTER_SUBMIT_DELAY); _timelockState.setAfterScheduleDelay(afterScheduleDelay, MAX_AFTER_SCHEDULE_DELAY); } - /// @dev Transfers ownership of the executor contract to a new owner. - /// Only the admin executor can call this function. + /// @notice Transfers ownership of the executor contract to a new owner. /// @param executor The address of the executor contract. /// @param owner The address of the new owner. function transferExecutorOwnership(address executor, address owner) external { @@ -133,21 +158,21 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { // Emergency Protection Functionality // --- - /// @dev Sets the emergency activation committee address. + /// @notice Sets the emergency activation committee address. /// @param emergencyActivationCommittee The address of the emergency activation committee. function setEmergencyProtectionActivationCommittee(address emergencyActivationCommittee) external { _checkCallerIsAdminExecutor(); _emergencyProtection.setEmergencyActivationCommittee(emergencyActivationCommittee); } - /// @dev Sets the emergency execution committee address. + /// @notice Sets the emergency execution committee address. /// @param emergencyExecutionCommittee The address of the emergency execution committee. function setEmergencyProtectionExecutionCommittee(address emergencyExecutionCommittee) external { _checkCallerIsAdminExecutor(); _emergencyProtection.setEmergencyExecutionCommittee(emergencyExecutionCommittee); } - /// @dev Sets the emergency protection end date. + /// @notice Sets the emergency protection end date. /// @param emergencyProtectionEndDate The timestamp of the emergency protection end date. function setEmergencyProtectionEndDate(Timestamp emergencyProtectionEndDate) external { _checkCallerIsAdminExecutor(); @@ -156,39 +181,36 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { ); } - /// @dev Sets the emergency mode duration. + /// @notice Sets the emergency mode duration. /// @param emergencyModeDuration The duration of the emergency mode. function setEmergencyModeDuration(Duration emergencyModeDuration) external { _checkCallerIsAdminExecutor(); _emergencyProtection.setEmergencyModeDuration(emergencyModeDuration, MAX_EMERGENCY_MODE_DURATION); } - /// @dev Sets the emergency governance address. + /// @notice Sets the emergency governance address. /// @param emergencyGovernance The address of the emergency governance. function setEmergencyGovernance(address emergencyGovernance) external { _checkCallerIsAdminExecutor(); _emergencyProtection.setEmergencyGovernance(emergencyGovernance); } - /// @dev Activates the emergency mode. - /// Only the activation committee can call this function. + /// @notice Activates the emergency mode. function activateEmergencyMode() external { _emergencyProtection.checkCallerIsEmergencyActivationCommittee(); _emergencyProtection.checkEmergencyMode({isActive: false}); _emergencyProtection.activateEmergencyMode(); } - /// @dev Executes a proposal during emergency mode. - /// Checks if emergency mode is active and if the caller is part of the execution committee. - /// @param proposalId The ID of the proposal to be executed. + /// @notice Executes a proposal during emergency mode. + /// @param proposalId The id of the proposal to be executed. function emergencyExecute(uint256 proposalId) external { _emergencyProtection.checkEmergencyMode({isActive: true}); _emergencyProtection.checkCallerIsEmergencyExecutionCommittee(); _proposals.execute({proposalId: proposalId, afterScheduleDelay: Duration.wrap(0)}); } - /// @dev Deactivates the emergency mode. - /// If the emergency mode has not passed, only the admin executor can call this function. + /// @notice Deactivates the emergency mode. function deactivateEmergencyMode() external { _emergencyProtection.checkEmergencyMode({isActive: true}); if (!_emergencyProtection.isEmergencyModeDurationPassed()) { @@ -198,8 +220,7 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { _proposals.cancelAll(); } - /// @dev Resets the system after entering the emergency mode. - /// Only the execution committee can call this function. + /// @notice Resets the system after entering the emergency mode. function emergencyReset() external { _emergencyProtection.checkCallerIsEmergencyExecutionCommittee(); _emergencyProtection.checkEmergencyMode({isActive: true}); @@ -209,38 +230,38 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { _proposals.cancelAll(); } - /// @dev Returns whether the emergency protection is enabled. - /// @return A boolean indicating whether the emergency protection is enabled. + /// @notice Returns whether the emergency protection is enabled. + /// @return isEmergencyProtectionEnabled A boolean indicating whether the emergency protection is enabled. function isEmergencyProtectionEnabled() public view returns (bool) { return _emergencyProtection.isEmergencyProtectionEnabled(); } - /// @dev Returns whether the emergency mode is active. - /// @return A boolean indicating whether the emergency protection is enabled. + /// @notice Returns whether the emergency mode is active. + /// @return isEmergencyModeActive A boolean indicating whether the emergency protection is enabled. function isEmergencyModeActive() public view returns (bool) { return _emergencyProtection.isEmergencyModeActive(); } - /// @dev Returns the details of the emergency protection. + /// @notice Returns the details of the emergency protection. /// @return details A struct containing the emergency mode duration, emergency mode ends after, and emergency protection ends after. function getEmergencyProtectionDetails() public view returns (EmergencyProtectionDetails memory details) { return _emergencyProtection.getEmergencyProtectionDetails(); } - /// @dev Returns the address of the emergency governance. - /// @return The address of the emergency governance. + /// @notice Returns the address of the emergency governance. + /// @return emergencyGovernance The address of the emergency governance. function getEmergencyGovernance() external view returns (address) { return _emergencyProtection.emergencyGovernance; } - /// @dev Returns the address of the emergency activation committee. - /// @return The address of the emergency activation committee. + /// @notice Returns the address of the emergency activation committee. + /// @return emergencyActivationCommittee The address of the emergency activation committee. function getEmergencyActivationCommittee() external view returns (address) { return _emergencyProtection.emergencyActivationCommittee; } - /// @dev Returns the address of the emergency execution committee. - /// @return The address of the emergency execution committee. + /// @notice Returns the address of the emergency execution committee. + /// @return emergencyExecutionCommittee The address of the emergency execution committee. function getEmergencyExecutionCommittee() external view returns (address) { return _emergencyProtection.emergencyExecutionCommittee; } @@ -249,24 +270,32 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { // Timelock View Methods // --- + /// @notice Returns the address of the current governance contract. + /// @return governance The address of the governance contract. function getGovernance() external view returns (address) { return _timelockState.governance; } + /// @notice Returns the address of the admin executor. + /// @return adminExecutor The address of the admin executor. function getAdminExecutor() external view returns (address) { return _ADMIN_EXECUTOR; } + /// @notice Returns the configured delay duration required before a submitted proposal can be scheduled. + /// @return afterSubmitDelay The duration of the after-submit delay. function getAfterSubmitDelay() external view returns (Duration) { return _timelockState.getAfterSubmitDelay(); } + /// @notice Returns the configured delay duration required before a scheduled proposal can be executed. + /// @return afterScheduleDelay The duration of the after-schedule delay. function getAfterScheduleDelay() external view returns (Duration) { return _timelockState.getAfterScheduleDelay(); } - /// @dev Retrieves the details of a proposal. - /// @param proposalId The ID of the proposal. + /// @notice Retrieves the details of a proposal. + /// @param proposalId The id of the proposal. /// @return proposalDetails The Proposal struct containing the details of the proposal. /// @return calls An array of ExternalCall structs representing the sequence of calls to be executed for the proposal. function getProposal(uint256 proposalId) @@ -279,52 +308,56 @@ contract EmergencyProtectedTimelock is IEmergencyProtectedTimelock { } /// @notice Retrieves information about a proposal, excluding the external calls associated with it. - /// @param proposalId The ID of the proposal to retrieve information for. - /// @return proposalDetails A ProposalDetails struct containing the details of the proposal. - /// id The ID of the proposal. - /// status The current status of the proposal. Possible values are: - /// 0 - The proposal does not exist. - /// 1 - The proposal was submitted but not scheduled. - /// 2 - The proposal was submitted and scheduled but not yet executed. - /// 3 - The proposal was submitted, scheduled, and executed. This is the final state of the proposal lifecycle. - /// 4 - The proposal was cancelled via cancelAllNonExecutedProposals() and cannot be scheduled or executed anymore. - /// This is the final state of the proposal. - /// executor The address of the executor responsible for executing the proposal's external calls. - /// submittedAt The timestamp when the proposal was submitted. - /// scheduledAt The timestamp when the proposal was scheduled for execution. Equals 0 if the proposal - /// was submitted but not yet scheduled. + /// @param proposalId The id of the proposal to retrieve information for. + /// @return proposalDetails A ProposalDetails struct containing the details of the proposal, with the following data: + /// - `id`: The id of the proposal. + /// - `status`: The current status of the proposal. Possible values are: + /// 0 - The proposal does not exist. + /// 1 - The proposal was submitted but not scheduled. + /// 2 - The proposal was submitted and scheduled but not yet executed. + /// 3 - The proposal was submitted, scheduled, and executed. This is the final state of the proposal lifecycle. + /// 4 - The proposal was cancelled via cancelAllNonExecutedProposals() and cannot be scheduled or executed anymore. + /// This is the final state of the proposal. + /// - `executor`: The address of the executor responsible for executing the proposal's external calls. + /// - `submittedAt`: The timestamp when the proposal was submitted. + /// - `scheduledAt`: The timestamp when the proposal was scheduled for execution. Equals 0 if the proposal + /// was submitted but not yet scheduled. function getProposalDetails(uint256 proposalId) external view returns (ProposalDetails memory proposalDetails) { return _proposals.getProposalDetails(proposalId); } /// @notice Retrieves the external calls associated with the specified proposal. - /// @param proposalId The ID of the proposal to retrieve external calls for. + /// @param proposalId The id of the proposal to retrieve external calls for. /// @return calls An array of ExternalCall structs representing the sequence of calls to be executed for the proposal. function getProposalCalls(uint256 proposalId) external view returns (ExternalCall[] memory calls) { calls = _proposals.getProposalCalls(proposalId); } - /// @dev Retrieves the total number of proposals. + /// @notice Retrieves the total number of proposals. /// @return count The total number of proposals. function getProposalsCount() external view returns (uint256 count) { count = _proposals.getProposalsCount(); } - /// @dev Checks if a proposal can be executed. - /// @param proposalId The ID of the proposal. + /// @notice Checks if a proposal can be executed. + /// @param proposalId The id of the proposal. /// @return A boolean indicating if the proposal can be executed. function canExecute(uint256 proposalId) external view returns (bool) { return !_emergencyProtection.isEmergencyModeActive() && _proposals.canExecute(proposalId, _timelockState.getAfterScheduleDelay()); } - /// @dev Checks if a proposal can be scheduled. - /// @param proposalId The ID of the proposal. + /// @notice Checks if a proposal can be scheduled. + /// @param proposalId The id of the proposal. /// @return A boolean indicating if the proposal can be scheduled. function canSchedule(uint256 proposalId) external view returns (bool) { return _proposals.canSchedule(proposalId, _timelockState.getAfterSubmitDelay()); } + // --- + // Internal Methods + // --- + function _checkCallerIsAdminExecutor() internal view { if (msg.sender != _ADMIN_EXECUTOR) { revert CallerIsNotAdminExecutor(msg.sender); diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 673eae62..24fd9130 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -19,13 +19,11 @@ import {EscrowState} from "./libraries/EscrowState.sol"; import {WithdrawalsBatchesQueue} from "./libraries/WithdrawalBatchesQueue.sol"; import {HolderAssets, StETHAccounting, UnstETHAccounting, AssetsAccounting} from "./libraries/AssetsAccounting.sol"; -/// @notice Summary of the total locked assets in the Escrow -/// @param stETHLockedShares Total number of stETH shares locked in the Escrow -/// @param stETHClaimedETH Total amount of ETH claimed from the stETH locked in the Escrow -/// @param unstETHUnfinalizedShares Total number of shares from unstETH NFTs that have not yet been -/// marked as finalized -/// @param unstETHFinalizedETH Total claimable amount of ETH from unstETH NFTs that have been marked -/// as finalized +/// @notice Summary of the total locked assets in the Escrow. +/// @param stETHLockedShares The total number of stETH shares currently locked in the Escrow. +/// @param stETHClaimedETH The total amount of ETH claimed from the stETH shares locked in the Escrow. +/// @param unstETHUnfinalizedShares The total number of shares from unstETH NFTs that have not yet been marked as finalized. +/// @param unstETHFinalizedETH The total amount of ETH claimable from unstETH NFTs that have been marked as finalized. struct LockedAssetsTotals { uint256 stETHLockedShares; uint256 stETHClaimedETH; @@ -33,6 +31,11 @@ struct LockedAssetsTotals { uint256 unstETHFinalizedETH; } +/// @notice Summary of the assets locked in the Escrow by a specific vetoer. +/// @param stETHLockedShares The total number of stETH shares currently locked in the Escrow by the vetoer. +/// @param unstETHLockedShares The total number of unstETH shares currently locked in the Escrow by the vetoer. +/// @param unstETHIdsCount The total number of unstETH NFTs locked in the Escrow by the vetoer. +/// @param lastAssetsLockTimestamp The timestamp of the last time the vetoer locked stETH, wstETH, or unstETH in the Escrow. struct VetoerState { uint256 stETHLockedShares; uint256 unstETHLockedShares; @@ -40,6 +43,9 @@ struct VetoerState { uint256 lastAssetsLockTimestamp; } +/// @notice This contract is used to accumulate stETH, wstETH, unstETH, and withdrawn ETH from vetoers during the +/// veto signalling and rage quit processes. +/// @dev This contract is intended to be used behind a minimal proxy deployed by the DualGovernance contract. contract Escrow is IEscrow { using EscrowState for EscrowState.Context; using AssetsAccounting for AssetsAccounting.Context; @@ -48,13 +54,12 @@ contract Escrow is IEscrow { // --- // Errors // --- - + error EmptyUnstETHIds(); error UnclaimedBatches(); error UnexpectedUnstETHId(); error UnfinalizedUnstETHIds(); error NonProxyCallsForbidden(); error BatchesQueueIsNotClosed(); - error EmptyUnstETHIds(); error InvalidBatchSize(uint256 size); error CallerIsNotDualGovernance(address caller); error InvalidHintsLength(uint256 actual, uint256 expected); @@ -65,40 +70,57 @@ contract Escrow is IEscrow { // --- /// @dev The lower limit for stETH transfers when requesting a withdrawal batch - /// during the Rage Quit phase. For more details, see https://github.com/lidofinance/lido-dao/issues/442. - /// The current value is chosen to ensure functionality over an extended period, spanning several decades. + /// during the Rage Quit phase. For more details, see https://github.com/lidofinance/lido-dao/issues/442. + /// The current value is chosen to ensure functionality over an extended period, spanning several decades. uint256 private constant _MIN_TRANSFERRABLE_ST_ETH_AMOUNT = 8 wei; // --- - // Sanity check params immutables + // Sanity Check Parameters & Immutables // --- + /// @notice The minimum number of withdrawal requests allowed to create during a single call of + /// the `Escrow.requestNextWithdrawalsBatch(batchSize)` method. uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE; // --- - // Dependencies immutables + // Dependencies Immutables // --- + /// @notice The address of the stETH token. IStETH public immutable ST_ETH; + + /// @notice The address of the wstETH token. IWstETH public immutable WST_ETH; + + /// @notice The address of Lido's Withdrawal Queue and the unstETH token. IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; // --- - // Implementation immutables + // Implementation Immutables + // --- + /// @dev Reference to the address of the implementation contract, used to distinguish whether the call + /// is made to the proxy or directly to the implementation. address private immutable _SELF; + + /// @dev The address of the Dual Governance contract. IDualGovernance public immutable DUAL_GOVERNANCE; // --- // Aspects // --- + /// @dev Provides the functionality to manage the state of the Escrow. EscrowState.Context internal _escrowState; + + /// @dev Handles the accounting of assets locked in the Escrow. AssetsAccounting.Context private _accounting; + + /// @dev Manages the queue of withdrawal request batches generated from the locked stETH and wstETH tokens. WithdrawalsBatchesQueue.Context private _batchesQueue; // --- - // Construction & initializing + // Construction & Initializing // --- constructor( @@ -118,6 +140,9 @@ contract Escrow is IEscrow { MIN_WITHDRAWALS_BATCH_SIZE = minWithdrawalsBatchSize; } + /// @notice Initializes the proxy instance with the specified minimum assets lock duration. + /// @param minAssetsLockDuration The minimum duration that must pass from the last stETH, wstETH, or unstETH lock + /// by the vetoer before they are allowed to unlock assets from the Escrow. function initialize(Duration minAssetsLockDuration) external { if (address(this) == _SELF) { revert NonProxyCallsForbidden(); @@ -131,9 +156,13 @@ contract Escrow is IEscrow { } // --- - // Lock & unlock stETH + // Lock & Unlock stETH // --- + /// @notice Locks the vetoer's specified `amount` of stETH in the Veto Signalling Escrow, thereby increasing + /// the rage quit support proportionally to the number of stETH shares locked. + /// @param amount The amount of stETH to be locked. + /// @return lockedStETHShares The number of stETH shares locked in the Escrow during the current invocation. function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); @@ -145,6 +174,9 @@ contract Escrow is IEscrow { DUAL_GOVERNANCE.activateNextState(); } + /// @notice Unlocks all previously locked stETH and wstETH tokens, returning them in the form of stETH tokens. + /// This action decreases the rage quit support proportionally to the number of unlocked stETH shares. + /// @return unlockedStETHShares The total number of stETH shares unlocked from the Escrow. function unlockStETH() external returns (uint256 unlockedStETHShares) { DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); @@ -157,9 +189,13 @@ contract Escrow is IEscrow { } // --- - // Lock & unlock wstETH + // Lock & Unlock wstETH // --- + /// @notice Locks the vetoer's specified `amount` of wstETH in the Veto Signalling Escrow, thereby increasing + /// the rage quit support proportionally to the number of locked wstETH shares. + /// @param amount The amount of wstETH to be locked. + /// @return lockedStETHShares The number of wstETH shares locked in the Escrow during the current invocation. function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); @@ -171,6 +207,9 @@ contract Escrow is IEscrow { DUAL_GOVERNANCE.activateNextState(); } + /// @notice Unlocks all previously locked stETH and wstETH tokens, returning them in the form of wstETH tokens. + /// This action decreases the rage quit support proportionally to the number of unlocked wstETH shares. + /// @return unlockedStETHShares The total number of wstETH shares unlocked from the Escrow. function unlockWstETH() external returns (uint256 unlockedStETHShares) { DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); @@ -184,13 +223,17 @@ contract Escrow is IEscrow { } // --- - // Lock & unlock unstETH + // Lock & Unlock unstETH // --- + + /// @notice Locks the specified unstETH NFTs, identified by their ids, in the Veto Signalling Escrow, thereby increasing + /// the rage quit support proportionally to the total number of stETH shares contained in the locked unstETH NFTs. + /// @dev Locking finalized or already claimed unstETH NFTs is prohibited. + /// @param unstETHIds An array of ids representing the unstETH NFTs to be locked. function lockUnstETH(uint256[] memory unstETHIds) external { if (unstETHIds.length == 0) { revert EmptyUnstETHIds(); } - DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); @@ -204,6 +247,9 @@ contract Escrow is IEscrow { DUAL_GOVERNANCE.activateNextState(); } + /// @notice Unlocks the specified unstETH NFTs, identified by their ids, from the Veto Signalling Escrow + /// that were previously locked by the vetoer. + /// @param unstETHIds An array of ids representing the unstETH NFTs to be unlocked. function unlockUnstETH(uint256[] memory unstETHIds) external { DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); @@ -218,18 +264,36 @@ contract Escrow is IEscrow { DUAL_GOVERNANCE.activateNextState(); } + /// @notice Marks the specified locked unstETH NFTs as finalized to update the rage quit support value + /// in the Veto Signalling Escrow. + /// @dev Finalizing a withdrawal NFT results in the following state changes: + /// - The value of the finalized unstETH NFT is no longer influenced by stETH token rebases. + /// - The total supply of stETH is adjusted according to the value of the finalized unstETH NFT. + /// These changes impact the rage quit support value. This function updates the status of the specified + /// unstETH NFTs to ensure accurate rage quit support accounting in the Veto Signalling Escrow. + /// @param unstETHIds An array of ids representing the unstETH NFTs to be marked as finalized. + /// @param hints An array of hints required by the WithdrawalQueue to efficiently retrieve + /// the claimable amounts for the unstETH NFTs. function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + DUAL_GOVERNANCE.activateNextState(); } // --- - // Convert to NFT + // Convert To NFT // --- + /// @notice Allows vetoers to convert their locked stETH or wstETH tokens into unstETH NFTs on behalf of the + /// Veto Signalling Escrow contract. + /// @param stETHAmounts An array representing the amounts of stETH to be converted into unstETH NFTs. + /// @return unstETHIds An array of ids representing the newly created unstETH NFTs corresponding to + /// the converted stETH amounts. function requestWithdrawals(uint256[] calldata stETHAmounts) external returns (uint256[] memory unstETHIds) { + DUAL_GOVERNANCE.activateNextState(); _escrowState.checkSignallingEscrow(); unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stETHAmounts, address(this)); @@ -241,12 +305,23 @@ contract Escrow is IEscrow { } _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + + /// @dev Skip calling activateNextState here to save gas, as converting stETH to unstETH NFTs + /// does not affect the RageQuit support. } // --- - // Start rage quit + // Start Rage Quit // --- + /// @notice Irreversibly converts the Signalling Escrow into the Rage Quit Escrow, allowing vetoers who have locked + /// their funds in the Signalling Escrow to withdraw them in the form of ETH after the Rage Quit process + /// is completed and the specified withdrawal delay has passed. + /// @param rageQuitExtensionPeriodDuration The duration that starts after all withdrawal batches are formed, extending + /// the Rage Quit state in Dual Governance. This extension period ensures that users who have locked their unstETH + /// have sufficient time to claim it. + /// @param rageQuitEthWithdrawalsDelay The waiting period that vetoers must observe after the Rage Quit process + /// is finalized before they can withdraw ETH from the Escrow. function startRageQuit(Duration rageQuitExtensionPeriodDuration, Duration rageQuitEthWithdrawalsDelay) external { _checkCallerIsDualGovernance(); _escrowState.startRageQuit(rageQuitExtensionPeriodDuration, rageQuitEthWithdrawalsDelay); @@ -254,9 +329,13 @@ contract Escrow is IEscrow { } // --- - // Request withdrawal batches + // Request Withdrawal Batches // --- + /// @notice Creates unstETH NFTs from the stETH held in the Rage Quit Escrow via the WithdrawalQueue contract. + /// This function can be called multiple times until the Rage Quit Escrow no longer holds enough stETH + /// to create a withdrawal request. + /// @param batchSize The number of withdrawal requests to process in this batch. function requestNextWithdrawalsBatch(uint256 batchSize) external { _escrowState.checkRageQuitEscrow(); @@ -269,7 +348,7 @@ contract Escrow is IEscrow { uint256 maxStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); /// @dev This check ensures that even if MIN_STETH_WITHDRAWAL_AMOUNT is set too low, - /// the withdrawal batch request process can still be completed successfully + /// the withdrawal batch request process can still be completed successfully if (stETHRemaining < Math.max(_MIN_TRANSFERRABLE_ST_ETH_AMOUNT, minStETHWithdrawalRequestAmount)) { return _batchesQueue.close(); } @@ -290,9 +369,28 @@ contract Escrow is IEscrow { } // --- - // Claim requested withdrawal batches + // Claim Requested Withdrawal Batches // --- + /// @notice Allows the claim of finalized withdrawal NFTs generated via the `Escrow.requestNextWithdrawalsBatch()` method. + /// The unstETH NFTs must be claimed sequentially, starting from the provided `fromUnstETHId`, which must be + /// the first unclaimed unstETH NFT. + /// @param fromUnstETHId The id of the first unclaimed unstETH NFT in the batch to be claimed. + /// @param hints An array of hints required by the `WithdrawalQueue` contract to efficiently process + /// the claiming of unstETH NFTs. + function claimNextWithdrawalsBatch(uint256 fromUnstETHId, uint256[] calldata hints) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkBatchesClaimingInProgress(); + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(hints.length); + + _claimNextWithdrawalsBatch(fromUnstETHId, unstETHIds, hints); + } + + /// @notice An overloaded version of `Escrow.claimNextWithdrawalsBatch(uint256, uint256[] calldata)` that calculates + /// hints for the WithdrawalQueue on-chain. This method provides a more convenient claiming process but is + /// less gas efficient compared to `Escrow.claimNextWithdrawalsBatch(uint256, uint256[] calldata)`. + /// @param maxUnstETHIdsCount The maximum number of unstETH NFTs to claim in this batch. function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { _escrowState.checkRageQuitEscrow(); _escrowState.checkBatchesClaimingInProgress(); @@ -306,32 +404,28 @@ contract Escrow is IEscrow { ); } - function claimNextWithdrawalsBatch(uint256 fromUnstETHId, uint256[] calldata hints) external { - _escrowState.checkRageQuitEscrow(); - _escrowState.checkBatchesClaimingInProgress(); - - uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(hints.length); - - _claimNextWithdrawalsBatch(fromUnstETHId, unstETHIds, hints); - } - // --- - // Start rage quit extension delay + // Start Rage Quit Extension Delay // --- + /// @notice Initiates the Rage Quit Extension Period once all withdrawal batches have been claimed. + /// For cases where the `Escrow` instance holds only locked unstETH NFTs, this function ensures that the last + /// unstETH NFT registered in the `WithdrawalQueue` at the time of the `Escrow.startRageQuit()` call is finalized. + /// The Rage Quit Extension Period provides additional time for vetoers who locked their unstETH NFTs in the + /// Escrow to claim them. function startRageQuitExtensionPeriod() external { if (!_batchesQueue.isClosed()) { revert BatchesQueueIsNotClosed(); } /// @dev This check is primarily required when only unstETH NFTs are locked in the Escrow - /// and there are no WithdrawalBatches. In this scenario, the RageQuitExtensionPeriod can only begin - /// when the last locked unstETH id is finalized in the WithdrawalQueue. - /// When the WithdrawalBatchesQueue is not empty, this invariant is maintained by the following: - /// - Any locked unstETH during the VetoSignalling phase has an id less than any unstETH NFT created - /// during the request for withdrawal batches. - /// - Claiming the withdrawal batches requires the finalization of the unstETH with the given id. - /// - The finalization of unstETH NFTs occurs in FIFO order. + /// and there are no WithdrawalBatches. In this scenario, the RageQuitExtensionPeriod can only begin + /// when the last locked unstETH id is finalized in the WithdrawalQueue. + /// When the WithdrawalBatchesQueue is not empty, this invariant is maintained by the following: + /// - Any locked unstETH during the VetoSignalling phase has an id less than any unstETH NFT created + /// during the request for withdrawal batches. + /// - Claiming the withdrawal batches requires the finalization of the unstETH with the given id. + /// - The finalization of unstETH NFTs occurs in FIFO order. if (_batchesQueue.getLastClaimedOrBoundaryUnstETHId() > WITHDRAWAL_QUEUE.getLastFinalizedRequestId()) { revert UnfinalizedUnstETHIds(); } @@ -344,9 +438,17 @@ contract Escrow is IEscrow { } // --- - // Claim locked unstETH NFTs + // Claim Locked unstETH NFTs // --- + /// @notice Allows users to claim finalized unstETH NFTs locked in the Rage Quit Escrow contract. + /// To safeguard the ETH associated with withdrawal NFTs, this function should be invoked while the `Escrow` + /// is in the `RageQuitEscrow` state and before the `RageQuitExtensionPeriod` ends. Any ETH corresponding to + /// unclaimed withdrawal NFTs after this period will remain controlled by code potentially influenced by pending + /// and future DAO decisions. + /// @param unstETHIds An array of ids representing the unstETH NFTs to be claimed. + /// @param hints An array of hints required by the `WithdrawalQueue` contract to efficiently process + /// the claiming of unstETH NFTs. function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { _escrowState.checkRageQuitEscrow(); uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); @@ -360,18 +462,24 @@ contract Escrow is IEscrow { } // --- - // Escrow management + // Escrow Management // --- + /// @notice Sets the minimum duration that must elapse after the last stETH, wstETH, or unstETH lock + /// by a vetoer before they are permitted to unlock their assets from the Escrow. + /// @param newMinAssetsLockDuration The new minimum lock duration to be set. function setMinAssetsLockDuration(Duration newMinAssetsLockDuration) external { _checkCallerIsDualGovernance(); _escrowState.setMinAssetsLockDuration(newMinAssetsLockDuration); } // --- - // Withdraw logic + // Withdraw Logic // --- + /// @notice Allows the caller (i.e., `msg.sender`) to withdraw all stETH and wstETH they have previously locked + /// into the contract (while it was in the Signalling state) as plain ETH, provided that + /// the Rage Quit process is completed and the Rage Quit Eth Withdrawals Delay has elapsed. function withdrawETH() external { _escrowState.checkRageQuitEscrow(); _escrowState.checkEthWithdrawalsDelayPassed(); @@ -379,6 +487,9 @@ contract Escrow is IEscrow { ethToWithdraw.sendTo(payable(msg.sender)); } + /// @notice Allows the caller (i.e., `msg.sender`) to withdraw the claimed ETH from the specified unstETH NFTs + /// that were locked by the caller in the contract while it was in the Signalling state. + /// @param unstETHIds An array of ids representing the unstETH NFTs from which the caller wants to withdraw ETH. function withdrawETH(uint256[] calldata unstETHIds) external { if (unstETHIds.length == 0) { revert EmptyUnstETHIds(); @@ -393,6 +504,12 @@ contract Escrow is IEscrow { // Getters // --- + /// @notice Returns the total amounts of locked and claimed assets in the Escrow. + /// @return totals A struct containing the total amounts of locked and claimed assets, including: + /// - `stETHClaimedETH`: The total amount of ETH claimed from locked stETH. + /// - `stETHLockedShares`: The total number of stETH shares currently locked in the Escrow. + /// - `unstETHUnfinalizedShares`: The total number of shares from unstETH NFTs that have not yet been finalized. + /// - `unstETHFinalizedETH`: The total amount of ETH from finalized unstETH NFTs. function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { StETHAccounting memory stETHTotals = _accounting.stETHTotals; totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); @@ -403,6 +520,13 @@ contract Escrow is IEscrow { totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); } + /// @notice Returns the state of locked assets for a specific vetoer. + /// @param vetoer The address of the vetoer whose locked asset state is being queried. + /// @return state A struct containing information about the vetoer's locked assets, including: + /// - `stETHLockedShares`: The total number of stETH shares locked by the vetoer. + /// - `unstETHLockedShares`: The total number of unstETH shares locked by the vetoer. + /// - `unstETHIdsCount`: The total number of unstETH NFTs locked by the vetoer. + /// - `lastAssetsLockTimestamp`: The timestamp of the last assets lock by the vetoer. function getVetoerState(address vetoer) external view returns (VetoerState memory state) { HolderAssets storage assets = _accounting.assets[vetoer]; @@ -412,26 +536,41 @@ contract Escrow is IEscrow { state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); } + /// @notice Returns the total count of unstETH NFTs that have not been claimed yet. + /// @return unclaimedUnstETHIdsCount The total number of unclaimed unstETH NFTs. function getUnclaimedUnstETHIdsCount() external view returns (uint256) { return _batchesQueue.getTotalUnclaimedUnstETHIdsCount(); } + /// @notice Retrieves the unstETH NFT ids of the next batch available for claiming. + /// @param limit The maximum number of unstETH NFTs to return in the batch. + /// @return unstETHIds An array of unstETH NFT IDs available for the next withdrawal batch. function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { return _batchesQueue.getNextWithdrawalsBatches(limit); } + /// @notice Returns whether all withdrawal batches have been finalized. + /// @return isWithdrawalsBatchesFinalized A boolean value indicating whether all withdrawal batches have been + /// finalized (`true`) or not (`false`). function isWithdrawalsBatchesFinalized() external view returns (bool) { return _batchesQueue.isClosed(); } + /// @notice Returns whether the Rage Quit Extension Period has started. + /// @return isRageQuitExtensionPeriodStarted A boolean value indicating whether the Rage Quit Extension Period + /// has started (`true`) or not (`false`). function isRageQuitExtensionPeriodStarted() external view returns (bool) { return _escrowState.isRageQuitExtensionPeriodStarted(); } + /// @notice Returns the timestamp when the Rage Quit Extension Period started. + /// @return rageQuitExtensionPeriodStartedAt The timestamp when the Rage Quit Extension Period began. function getRageQuitExtensionPeriodStartedAt() external view returns (Timestamp) { return _escrowState.rageQuitExtensionPeriodStartedAt; } + /// @notice Returns the current Rage Quit support value as a percentage. + /// @return rageQuitSupport The current Rage Quit support as a `PercentD16` value. function getRageQuitSupport() external view returns (PercentD16) { StETHAccounting memory stETHTotals = _accounting.stETHTotals; UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; @@ -445,6 +584,8 @@ contract Escrow is IEscrow { }); } + /// @notice Returns whether the Rage Quit process has been finalized. + /// @return A boolean value indicating whether the Rage Quit process has been finalized (`true`) or not (`false`). function isRageQuitFinalized() external view returns (bool) { return _escrowState.isRageQuitEscrow() && _escrowState.isRageQuitExtensionPeriodPassed(); } @@ -453,6 +594,7 @@ contract Escrow is IEscrow { // Receive ETH // --- + /// @notice Accepts ETH payments only from the `WithdrawalQueue` contract. receive() external payable { if (msg.sender != address(WITHDRAWAL_QUEUE)) { revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); @@ -460,7 +602,7 @@ contract Escrow is IEscrow { } // --- - // Internal methods + // Internal Methods // --- function _claimNextWithdrawalsBatch( diff --git a/contracts/TimelockedGovernance.sol b/contracts/TimelockedGovernance.sol index 1003b7d5..6cd4ff68 100644 --- a/contracts/TimelockedGovernance.sol +++ b/contracts/TimelockedGovernance.sol @@ -53,9 +53,10 @@ contract TimelockedGovernance is IGovernance { } /// @dev Cancels all pending proposals that have not been executed. - function cancelAllPendingProposals() external { + function cancelAllPendingProposals() external returns (bool) { _checkCallerIsGovernance(); TIMELOCK.cancelAllNonExecutedProposals(); + return true; } /// @dev Checks if the msg.sender is the governance address. diff --git a/contracts/committees/EmergencyActivationCommittee.sol b/contracts/committees/EmergencyActivationCommittee.sol index dce5ba81..f15ba95a 100644 --- a/contracts/committees/EmergencyActivationCommittee.sol +++ b/contracts/committees/EmergencyActivationCommittee.sol @@ -6,7 +6,7 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Durations} from "../types/Duration.sol"; import {Timestamp} from "../types/Timestamp.sol"; -import {ITimelock} from "../interfaces/ITimelock.sol"; +import {IEmergencyProtectedTimelock} from "../interfaces/IEmergencyProtectedTimelock.sol"; import {HashConsensus} from "./HashConsensus.sol"; @@ -54,7 +54,8 @@ contract EmergencyActivationCommittee is HashConsensus { function executeActivateEmergencyMode() external { _markUsed(EMERGENCY_ACTIVATION_HASH); Address.functionCall( - EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(ITimelock.activateEmergencyMode.selector) + EMERGENCY_PROTECTED_TIMELOCK, + abi.encodeWithSelector(IEmergencyProtectedTimelock.activateEmergencyMode.selector) ); } } diff --git a/contracts/committees/EmergencyExecutionCommittee.sol b/contracts/committees/EmergencyExecutionCommittee.sol index 72d03dc2..8f4288fa 100644 --- a/contracts/committees/EmergencyExecutionCommittee.sol +++ b/contracts/committees/EmergencyExecutionCommittee.sol @@ -6,7 +6,7 @@ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Durations} from "../types/Duration.sol"; import {Timestamp} from "../types/Timestamp.sol"; -import {ITimelock} from "../interfaces/ITimelock.sol"; +import {IEmergencyProtectedTimelock} from "../interfaces/IEmergencyProtectedTimelock.sol"; import {HashConsensus} from "./HashConsensus.sol"; import {ProposalsList} from "./ProposalsList.sol"; @@ -72,14 +72,18 @@ contract EmergencyExecutionCommittee is HashConsensus, ProposalsList { (, bytes32 key) = _encodeEmergencyExecute(proposalId); _markUsed(key); Address.functionCall( - EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(ITimelock.emergencyExecute.selector, proposalId) + EMERGENCY_PROTECTED_TIMELOCK, + abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyExecute.selector, proposalId) ); } /// @notice Checks if a proposal exists /// @param proposalId The ID of the proposal to check function _checkProposalExists(uint256 proposalId) internal view { - if (proposalId == 0 || proposalId > ITimelock(EMERGENCY_PROTECTED_TIMELOCK).getProposalsCount()) { + if ( + proposalId == 0 + || proposalId > IEmergencyProtectedTimelock(EMERGENCY_PROTECTED_TIMELOCK).getProposalsCount() + ) { revert ProposalDoesNotExist(proposalId); } } @@ -128,7 +132,9 @@ contract EmergencyExecutionCommittee is HashConsensus, ProposalsList { function executeEmergencyReset() external { bytes32 proposalKey = _encodeEmergencyResetProposalKey(); _markUsed(proposalKey); - Address.functionCall(EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(ITimelock.emergencyReset.selector)); + Address.functionCall( + EMERGENCY_PROTECTED_TIMELOCK, abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyReset.selector) + ); } /// @notice Encodes the proposal key for an emergency reset diff --git a/contracts/interfaces/IDualGovernance.sol b/contracts/interfaces/IDualGovernance.sol index 7e5b80dc..d8f0f55e 100644 --- a/contracts/interfaces/IDualGovernance.sol +++ b/contracts/interfaces/IDualGovernance.sol @@ -9,8 +9,9 @@ import {State} from "../libraries/DualGovernanceStateMachine.sol"; interface IDualGovernance is IGovernance, ITiebreaker { struct StateDetails { - State state; - Timestamp enteredAt; + State effectiveState; + State persistedState; + Timestamp persistedStateEnteredAt; Timestamp vetoSignallingActivatedAt; Timestamp vetoSignallingReactivationTime; Timestamp normalOrVetoCooldownExitedAt; diff --git a/contracts/interfaces/IEmergencyProtectedTimelock.sol b/contracts/interfaces/IEmergencyProtectedTimelock.sol index 841ca5ed..8916d298 100644 --- a/contracts/interfaces/IEmergencyProtectedTimelock.sol +++ b/contracts/interfaces/IEmergencyProtectedTimelock.sol @@ -12,6 +12,9 @@ interface IEmergencyProtectedTimelock is ITimelock { Timestamp emergencyProtectionEndsAfter; } + function activateEmergencyMode() external; + function emergencyExecute(uint256 proposalId) external; + function emergencyReset() external; function getEmergencyGovernance() external view returns (address emergencyGovernance); function getEmergencyActivationCommittee() external view returns (address committee); function getEmergencyExecutionCommittee() external view returns (address committee); diff --git a/contracts/interfaces/IGovernance.sol b/contracts/interfaces/IGovernance.sol index 0b567eea..51652b40 100644 --- a/contracts/interfaces/IGovernance.sol +++ b/contracts/interfaces/IGovernance.sol @@ -12,7 +12,7 @@ interface IGovernance { string calldata metadata ) external returns (uint256 proposalId); function scheduleProposal(uint256 proposalId) external; - function cancelAllPendingProposals() external; + function cancelAllPendingProposals() external returns (bool); function canScheduleProposal(uint256 proposalId) external view returns (bool); } diff --git a/contracts/interfaces/ITimelock.sol b/contracts/interfaces/ITimelock.sol index df474e15..017c2236 100644 --- a/contracts/interfaces/ITimelock.sol +++ b/contracts/interfaces/ITimelock.sol @@ -28,18 +28,13 @@ interface ITimelock { function canExecute(uint256 proposalId) external view returns (bool); function getAdminExecutor() external view returns (address); + function getGovernance() external view returns (address); + function setGovernance(address governance) external; function getProposal(uint256 proposalId) external view returns (ProposalDetails memory proposal, ExternalCall[] memory calls); function getProposalDetails(uint256 proposalId) external view returns (ProposalDetails memory proposalDetails); - - function getGovernance() external view returns (address); - function setGovernance(address governance) external; - - function activateEmergencyMode() external; - function emergencyExecute(uint256 proposalId) external; - function emergencyReset() external; function getProposalsCount() external view returns (uint256 count); } diff --git a/contracts/libraries/DualGovernanceStateMachine.sol b/contracts/libraries/DualGovernanceStateMachine.sol index a6d47b72..5a58879c 100644 --- a/contracts/libraries/DualGovernanceStateMachine.sol +++ b/contracts/libraries/DualGovernanceStateMachine.sol @@ -10,9 +10,25 @@ import {Timestamp, Timestamps} from "../types/Timestamp.sol"; import {IEscrow} from "../interfaces/IEscrow.sol"; import {IDualGovernance} from "../interfaces/IDualGovernance.sol"; +import {IDualGovernanceConfigProvider} from "../interfaces/IDualGovernanceConfigProvider.sol"; import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; +/// @notice Enum describing the state of the Dual Governance State Machine +/// @param Unset The initial (uninitialized) state of the Dual Governance State Machine. The state machine cannot +/// operate in this state and must be initialized before use. +/// @param Normal The default state where the system is expected to remain most of the time. In this state, proposals +/// can be both submitted and scheduled for execution. +/// @param VetoSignalling Represents active opposition to DAO decisions. In this state, the scheduling of proposals +/// is blocked, but the submission of new proposals is still allowed. +/// @param VetoSignallingDeactivation A sub-state of VetoSignalling, allowing users to observe the deactivation process +/// and react before non-cancelled proposals are scheduled for execution. Both proposal submission and scheduling +/// are prohibited in this state. +/// @param VetoCooldown A state where the DAO can execute non-cancelled proposals but is prohibited from submitting +/// new proposals. +/// @param RageQuit Represents the process where users opting to leave the protocol can withdraw their funds. This state +/// is triggered when the Second Seal Threshold is crossed. During this state, the scheduling of proposals for +/// execution is forbidden, but new proposals can still be submitted. enum State { Unset, Normal, @@ -22,55 +38,83 @@ enum State { RageQuit } +/// @title Dual Governance State Machine Library +/// @notice Library containing the core logic for managing the states of the Dual Governance system library DualGovernanceStateMachine { + using DualGovernanceStateTransitions for Context; using DualGovernanceConfig for DualGovernanceConfig.Context; + // --- + // Data types + // --- + + /// @notice Represents the context of the Dual Governance State Machine. + /// @param state The last recorded state of the Dual Governance State Machine. + /// @param enteredAt The timestamp when the current `persisted` `state` was entered. + /// @param vetoSignallingActivatedAt The timestamp when the VetoSignalling state was last activated. + /// @param signallingEscrow The address of the Escrow contract used for VetoSignalling. + /// @param rageQuitRound The number of continuous Rage Quit rounds, starting at 0 and capped at MAX_RAGE_QUIT_ROUND. + /// @param vetoSignallingReactivationTime The timestamp of the last transition from VetoSignallingDeactivation to VetoSignalling. + /// @param normalOrVetoCooldownExitedAt The timestamp of the last exit from either the Normal or VetoCooldown state. + /// @param rageQuitEscrow The address of the Escrow contract used during the most recent (or ongoing) Rage Quit process. + /// @param configProvider The address of the contract providing the current configuration for the Dual Governance State Machine. struct Context { - /// /// @dev slot 0: [0..7] - /// The current state of the Dual Governance FSM State state; - /// /// @dev slot 0: [8..47] - /// The timestamp when the Dual Governance FSM entered the current state Timestamp enteredAt; - /// /// @dev slot 0: [48..87] - /// The time the VetoSignalling FSM state was entered the last time Timestamp vetoSignallingActivatedAt; - /// /// @dev slot 0: [88..247] - /// The address of the currently used Veto Signalling Escrow IEscrow signallingEscrow; - /// /// @dev slot 0: [248..255] - /// The number of the Rage Quit round. Initial value is 0. uint8 rageQuitRound; - /// /// @dev slot 1: [0..39] - /// The last time VetoSignallingDeactivation -> VetoSignalling transition happened Timestamp vetoSignallingReactivationTime; - /// /// @dev slot 1: [40..79] - /// The last time when the Dual Governance FSM exited Normal or VetoCooldown state Timestamp normalOrVetoCooldownExitedAt; - /// /// @dev slot 1: [80..239] - /// The address of the Escrow used during the last (may be ongoing) Rage Quit process IEscrow rageQuitEscrow; + /// @dev slot 2: [0..159] + IDualGovernanceConfigProvider configProvider; } + // --- + // Errors + // --- + error AlreadyInitialized(); + error InvalidConfigProvider(IDualGovernanceConfigProvider configProvider); + + // --- + // Events + // --- event NewSignallingEscrowDeployed(IEscrow indexed escrow); event DualGovernanceStateChanged(State from, State to, Context state); + event ConfigProviderSet(IDualGovernanceConfigProvider newConfigProvider); + // --- + // Constants + // --- + + /// @dev The upper limit for the maximum possible continuous RageQuit rounds. Once this limit is reached, + /// the `rageQuitRound` value is capped at 255 until the system returns to the Normal or VetoCooldown state. uint256 internal constant MAX_RAGE_QUIT_ROUND = type(uint8).max; + // --- + // Main functionality + // --- + + /// @notice Initializes the Dual Governance State Machine context. + /// @param self The context of the Dual Governance State Machine to be initialized. + /// @param configProvider The address of the Dual Governance State Machine configuration provider. + /// @param escrowMasterCopy The address of the master copy used as the implementation for the minimal proxy deployment + /// of a Signalling Escrow instance. function initialize( Context storage self, - DualGovernanceConfig.Context memory config, - address escrowMasterCopy + IDualGovernanceConfigProvider configProvider, + IEscrow escrowMasterCopy ) internal { if (self.state != State.Unset) { revert AlreadyInitialized(); @@ -78,17 +122,26 @@ library DualGovernanceStateMachine { self.state = State.Normal; self.enteredAt = Timestamps.now(); + + _setConfigProvider(self, configProvider); + + DualGovernanceConfig.Context memory config = configProvider.getDualGovernanceConfig(); _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); emit DualGovernanceStateChanged(State.Unset, State.Normal, self); } - function activateNextState( - Context storage self, - DualGovernanceConfig.Context memory config, - address escrowMasterCopy - ) internal { - (State currentState, State newState) = DualGovernanceStateTransitions.getStateTransition(self, config); + /// @notice Executes a state transition for the Dual Governance State Machine, if applicable. + /// If no transition is possible from the current `persisted` state, no changes are applied to the context. + /// @dev If the state transitions to RageQuit, a new instance of the Signalling Escrow is deployed using + /// `escrowMasterCopy` as the implementation for the minimal proxy, while the previous Signalling Escrow + /// instance is converted into the RageQuit escrow. + /// @param self The context of the Dual Governance State Machine. + /// @param escrowMasterCopy The address of the master copy used as the implementation for the minimal proxy + /// to deploy a new instance of the Signalling Escrow. + function activateNextState(Context storage self, IEscrow escrowMasterCopy) internal { + DualGovernanceConfig.Context memory config = getDualGovernanceConfig(self); + (State currentState, State newState) = self.getStateTransition(config); if (currentState == newState) { return; @@ -115,7 +168,7 @@ library DualGovernanceStateMachine { uint256 currentRageQuitRound = self.rageQuitRound; /// @dev Limits the maximum value of the rage quit round to prevent failures due to arithmetic overflow - /// if the number of consecutive rage quits reaches MAX_RAGE_QUIT_ROUND. + /// if the number of continuous rage quits reaches MAX_RAGE_QUIT_ROUND. uint256 newRageQuitRound = Math.min(currentRageQuitRound + 1, MAX_RAGE_QUIT_ROUND); self.rageQuitRound = uint8(newRageQuitRound); @@ -129,12 +182,36 @@ library DualGovernanceStateMachine { emit DualGovernanceStateChanged(currentState, newState, self); } - function getStateDetails( - Context storage self, - DualGovernanceConfig.Context memory config - ) internal view returns (IDualGovernance.StateDetails memory stateDetails) { - stateDetails.state = self.state; - stateDetails.enteredAt = self.enteredAt; + /// @notice Updates the address of the configuration provider for the Dual Governance State Machine. + /// @param self The context of the Dual Governance State Machine. + /// @param newConfigProvider The address of the new configuration provider. + function setConfigProvider(Context storage self, IDualGovernanceConfigProvider newConfigProvider) internal { + _setConfigProvider(self, newConfigProvider); + + /// @dev minAssetsLockDuration is stored as a storage variable in the Signalling Escrow instance. + /// To synchronize the new value with the current Signalling Escrow, it must be manually updated. + self.signallingEscrow.setMinAssetsLockDuration( + newConfigProvider.getDualGovernanceConfig().minAssetsLockDuration + ); + } + + // --- + // Getters + // --- + + /// @notice Returns detailed information about the state of the Dual Governance State Machine. + /// @param self The context of the Dual Governance State Machine. + /// @return stateDetails A struct containing detailed information about the state of + /// the Dual Governance State Machine. + function getStateDetails(Context storage self) + internal + view + returns (IDualGovernance.StateDetails memory stateDetails) + { + DualGovernanceConfig.Context memory config = getDualGovernanceConfig(self); + (stateDetails.persistedState, stateDetails.effectiveState) = self.getStateTransition(config); + + stateDetails.persistedStateEnteredAt = self.enteredAt; stateDetails.vetoSignallingActivatedAt = self.vetoSignallingActivatedAt; stateDetails.vetoSignallingReactivationTime = self.vetoSignallingReactivationTime; stateDetails.normalOrVetoCooldownExitedAt = self.normalOrVetoCooldownExitedAt; @@ -143,65 +220,143 @@ library DualGovernanceStateMachine { config.calcVetoSignallingDuration(self.signallingEscrow.getRageQuitSupport()); } - function getState(Context storage self) internal view returns (State) { - return self.state; + /// @notice Returns the most recently persisted state of the Dual Governance State Machine. + /// @param self The context of the Dual Governance State Machine. + /// @return persistedState The state of the Dual Governance State Machine as last stored. + function getPersistedState(Context storage self) internal view returns (State persistedState) { + persistedState = self.state; } - function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { - return self.normalOrVetoCooldownExitedAt; + /// @notice Returns the effective state of the Dual Governance State Machine. + /// @dev The effective state refers to the state the Dual Governance State Machine would transition to + /// upon calling `activateNextState()`. + /// @param self The context of the Dual Governance State Machine. + /// @return effectiveState The state that will become active after the next state transition. + /// If the `activateNextState` call does not trigger a state transition, `effectiveState` + /// will be the same as `persistedState`. + function getEffectiveState(Context storage self) internal view returns (State effectiveState) { + ( /* persistedState */ , effectiveState) = self.getStateTransition(getDualGovernanceConfig(self)); } - function canSubmitProposal(Context storage self) internal view returns (bool) { - State state = self.state; + /// @notice Returns whether the submission of proposals is allowed based on the `persisted` or `effective` state, + /// depending on the `useEffectiveState` value. + /// @param self The context of the Dual Governance State Machine. + /// @param useEffectiveState If `true`, the check is performed against the `effective` state, which represents the state + /// the Dual Governance State Machine will enter after the next `activateNextState` call. If `false`, the check is + /// performed against the `persisted` state, which is the currently stored state of the system. + /// @return A boolean indicating whether the submission of proposals is allowed in the selected state. + function canSubmitProposal(Context storage self, bool useEffectiveState) internal view returns (bool) { + State state = useEffectiveState ? getEffectiveState(self) : getPersistedState(self); return state != State.VetoSignallingDeactivation && state != State.VetoCooldown; } - function canScheduleProposal(Context storage self, Timestamp proposalSubmissionTime) internal view returns (bool) { - State state = self.state; - if (state == State.Normal) { - return true; - } - if (state == State.VetoCooldown) { - return proposalSubmissionTime <= self.vetoSignallingActivatedAt; - } + /// @notice Determines whether scheduling a proposal for execution is allowed, based on either the `persisted` + /// or `effective` state, depending on the `useEffectiveState` flag. + /// @param self The context of the Dual Governance State Machine. + /// @param useEffectiveState If `true`, the check is performed against the `effective` state, which represents the state + /// the Dual Governance State Machine will enter after the next `activateNextState` call. If `false`, the check is + /// performed against the `persisted` state, which is the currently stored state of the system. + /// @param proposalSubmittedAt The timestamp indicating when the proposal to be scheduled was originally submitted. + /// @return A boolean indicating whether scheduling the proposal is allowed in the chosen state. + function canScheduleProposal( + Context storage self, + bool useEffectiveState, + Timestamp proposalSubmittedAt + ) internal view returns (bool) { + State state = useEffectiveState ? getEffectiveState(self) : getPersistedState(self); + if (state == State.Normal) return true; + if (state == State.VetoCooldown) return proposalSubmittedAt <= self.vetoSignallingActivatedAt; return false; } + /// @notice Returns whether the cancelling of the proposals is allowed based on the `persisted` or `effective` + /// state, depending on the `useEffectiveState` value. + /// @param self The context of the Dual Governance State Machine. + /// @param useEffectiveState If `true`, the check is performed against the `effective` state, which represents the state + /// the Dual Governance State Machine will enter after the next `activateNextState` call. If `false`, the check is + /// performed against the `persisted` state, which is the currently stored state of the system. + /// @return A boolean indicating whether the cancelling of proposals is allowed in the selected state. + function canCancelAllPendingProposals(Context storage self, bool useEffectiveState) internal view returns (bool) { + State state = useEffectiveState ? getEffectiveState(self) : getPersistedState(self); + return state == State.VetoSignalling || state == State.VetoSignallingDeactivation; + } + + /// @notice Returns the configuration of the Dual Governance State Machine as provided by + /// the Dual Governance Config Provider. + /// @param self The context of the Dual Governance State Machine. + /// @return The current configuration of the Dual Governance State + function getDualGovernanceConfig(Context storage self) + internal + view + returns (DualGovernanceConfig.Context memory) + { + return self.configProvider.getDualGovernanceConfig(); + } + + // --- + // Private Methods + // --- + + function _setConfigProvider(Context storage self, IDualGovernanceConfigProvider newConfigProvider) private { + if (address(newConfigProvider) == address(0) || newConfigProvider == self.configProvider) { + revert InvalidConfigProvider(newConfigProvider); + } + + newConfigProvider.getDualGovernanceConfig().validate(); + + self.configProvider = newConfigProvider; + emit ConfigProviderSet(newConfigProvider); + } + function _deployNewSignallingEscrow( Context storage self, - address escrowMasterCopy, + IEscrow escrowMasterCopy, Duration minAssetsLockDuration ) private { - IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + IEscrow newSignallingEscrow = IEscrow(Clones.clone(address(escrowMasterCopy))); newSignallingEscrow.initialize(minAssetsLockDuration); self.signallingEscrow = newSignallingEscrow; emit NewSignallingEscrowDeployed(newSignallingEscrow); } } +/// @title Dual Governance State Transitions Library +/// @notice Library containing the transitions logic for the Dual Governance system library DualGovernanceStateTransitions { using DualGovernanceConfig for DualGovernanceConfig.Context; + /// @notice Returns the allowed state transition for the Dual Governance State Machine. + /// If no state transition is possible, `currentState` will be equal to `nextState`. + /// @param self The context of the Dual Governance State Machine. + /// @param config The configuration of the Dual Governance State Machine to use for determining + /// state transitions. + /// @return currentState The current state of the Dual Governance State Machine. + /// @return nextState The next state of the Dual Governance State Machine if a transition + /// is possible, otherwise it will be the same as `currentState`. function getStateTransition( DualGovernanceStateMachine.Context storage self, DualGovernanceConfig.Context memory config - ) internal view returns (State currentState, State nextStatus) { + ) internal view returns (State currentState, State nextState) { currentState = self.state; if (currentState == State.Normal) { - nextStatus = _fromNormalState(self, config); + nextState = _fromNormalState(self, config); } else if (currentState == State.VetoSignalling) { - nextStatus = _fromVetoSignallingState(self, config); + nextState = _fromVetoSignallingState(self, config); } else if (currentState == State.VetoSignallingDeactivation) { - nextStatus = _fromVetoSignallingDeactivationState(self, config); + nextState = _fromVetoSignallingDeactivationState(self, config); } else if (currentState == State.VetoCooldown) { - nextStatus = _fromVetoCooldownState(self, config); + nextState = _fromVetoCooldownState(self, config); } else if (currentState == State.RageQuit) { - nextStatus = _fromRageQuitState(self, config); + nextState = _fromRageQuitState(self, config); } else { assert(false); } } + // --- + // Private Methods + // --- + function _fromNormalState( DualGovernanceStateMachine.Context storage self, DualGovernanceConfig.Context memory config diff --git a/contracts/libraries/Tiebreaker.sol b/contracts/libraries/Tiebreaker.sol index 872bde88..bf775705 100644 --- a/contracts/libraries/Tiebreaker.sol +++ b/contracts/libraries/Tiebreaker.sol @@ -8,6 +8,7 @@ import {Timestamp, Timestamps} from "../types/Duration.sol"; import {ISealable} from "../interfaces/ISealable.sol"; import {ITiebreaker} from "../interfaces/ITiebreaker.sol"; +import {IDualGovernance} from "../interfaces/IDualGovernance.sol"; import {SealableCalls} from "./SealableCalls.sol"; import {State as DualGovernanceState} from "./DualGovernanceStateMachine.sol"; @@ -190,15 +191,29 @@ library Tiebreaker { /// @dev Retrieves the tiebreaker context from the storage. /// @param self The storage context. + /// @param stateDetails A struct containing detailed information about the current state of the Dual Governance system /// @return context The tiebreaker context containing the tiebreaker committee, tiebreaker activation timeout, and sealable withdrawal blockers. function getTiebreakerDetails( Context storage self, - DualGovernanceState state, - Timestamp normalOrVetoCooldownExitedAt + IDualGovernance.StateDetails memory stateDetails ) internal view returns (ITiebreaker.TiebreakerDetails memory context) { context.tiebreakerCommittee = self.tiebreakerCommittee; context.tiebreakerActivationTimeout = self.tiebreakerActivationTimeout; - context.isTie = isTie(self, state, normalOrVetoCooldownExitedAt); + + DualGovernanceState persistedState = stateDetails.persistedState; + DualGovernanceState effectiveState = stateDetails.effectiveState; + Timestamp normalOrVetoCooldownExitedAt = stateDetails.normalOrVetoCooldownExitedAt; + + if (effectiveState != persistedState) { + if (persistedState == DualGovernanceState.Normal || persistedState == DualGovernanceState.VetoCooldown) { + /// @dev When a pending state change is expected from the `Normal` or `VetoCooldown` state, + /// the `normalOrVetoCooldownExitedAt` timestamp should be set to the current timestamp to reflect + /// the behavior of the `DualGovernanceStateMachine.activateNextState()` method. + normalOrVetoCooldownExitedAt = Timestamps.now(); + } + } + + context.isTie = isTie(self, effectiveState, normalOrVetoCooldownExitedAt); uint256 sealableWithdrawalBlockersCount = self.sealableWithdrawalBlockers.length(); context.sealableWithdrawalBlockers = new address[](sealableWithdrawalBlockersCount); diff --git a/docs/plan-b.md b/docs/plan-b.md index ceb01bae..94834a5a 100644 --- a/docs/plan-b.md +++ b/docs/plan-b.md @@ -2,7 +2,7 @@ Timelocked Governance (TG) is a governance subsystem positioned between the Lido DAO, represented by the admin voting system (defaulting to Aragon's Voting), and the protocol contracts it manages. The TG subsystem helps protect users from malicious DAO proposals by allowing the **Emergency Activation Committee** to activate a long-lasting timelock on these proposals. -> Motivation: the upcoming Ethereum upgrade *Pectra* will introduce a new [withdrawal mechanism](https://eips.ethereum.org/EIPS/eip-7002) (EIP-7002), significantly affecting the operation of the Lido protocol. This enhancement will allow withdrawal queue contract to trigger withdrawals, introducing a new attack vector for the whole protocol. This poses a threat to stETH users, as governance capture (or malicious actions) could enable an upgrade to the withdrawal queue contract, resulting in the theft of user funds. Timelocked Governance in its turn provides security assurances through the implementation of guardians (emergency committees) that can halt malicious proposals and the implementation of the timelock to ensure users and committees have sufficient time to react to potential threats. +> Motivation: the upcoming Ethereum upgrade *Pectra* will introduce a new [withdrawal mechanism](https://eips.ethereum.org/EIPS/eip-7002) (EIP-7002), significantly affecting the operation of the Lido protocol. This enhancement will allow withdrawal queue contract to trigger withdrawals, introducing a new attack vector for the whole protocol. This poses a threat to stETH users, as governance capture (or malicious actions) could enable an upgrade to the withdrawal queue contract, resulting in the theft of user funds. Timelocked Governance in its turn provides security assurances through the implementation of guardians (emergency committees) that can halt malicious proposals and the implementation of the timelock to ensure users and committees have sufficient time to react to potential threats. ## Navigation * [System overview](#system-overview) @@ -29,7 +29,7 @@ The system comprises the following primary contracts: - [**`Executor.sol`**](#contract-executor): A contract instance responsible for executing calls resulting from governance proposals. All protocol permissions or roles protected by TG, as well as the authority to manage these roles/permissions, should be assigned exclusively to instance of this contract, rather than being assigned directly to the DAO voting system. Additionally, the system uses several committee contracts that allow members to execute, acquiring quorum, a narrow set of actions: - + - [**`EmergencyActivationCommittee`**](#contract-emergencyactivationcommittee): A contract with the authority to activate Emergency Mode. Activation requires a quorum from committee members. - [**`EmergencyExecutionCommittee`**](#contract-emergencyexecutioncommittee): A contract that enables the execution of proposals during Emergency Mode by obtaining a quorum of committee members. @@ -104,14 +104,17 @@ Instructs the [`EmergencyProtectedTimelock`](#) singleton instance to execute See: [`EmergencyProtectedTimelock.execute`](#) #### Preconditions - The proposal with the given id MUST be in the `Scheduled` state. + ### Function: `TimelockedGovernance.cancelAllPendingProposals` ```solidity -function cancelAllPendingProposals() +function cancelAllPendingProposals() returns (bool) ``` Cancels all currently submitted and non-executed proposals. If a proposal was submitted but not scheduled, it becomes unschedulable. If a proposal was scheduled, it becomes unexecutable. +The function will return `true` if all proposals are successfully canceled. If the subsequent call to the `EmergencyProtectedTimelock.cancelAllNonExecutedProposals()` method fails, the function will revert with an error. + See: [`EmergencyProtectedTimelock.cancelAllNonExecutedProposals`](#) #### Preconditions * MUST be called by an [admin voting system](#) diff --git a/docs/specification.md b/docs/specification.md index 53628a42..47a55d05 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -310,18 +310,18 @@ Calls the `ResealManager.resumeSealable(address sealable)` if all preconditions ### Function: DualGovernance.cancelAllPendingProposals ```solidity -function cancelAllPendingProposals() +function cancelAllPendingProposals() returns (bool) ``` Cancels all currently submitted and non-executed proposals. If a proposal was submitted but not scheduled, it becomes unschedulable. If a proposal was scheduled, it becomes unexecutable. +If the current governance state is neither `VetoSignalling` nor `VetoSignallingDeactivation`, the function will exit early without canceling any proposals, emitting the `CancelAllPendingProposalsSkipped` event and returning `false`. If proposals are successfully canceled, the `CancelAllPendingProposalsExecuted` event will be emitted, and the function will return `true`. + Triggers a transition of the current governance state, if one is possible. #### Preconditions -* MUST be called by an [admin proposer](#Administrative-actions). -* The current governance state MUST NOT equal `Normal`, `VetoCooldown`, or `RageQuit`. - +- MUST be called by an [admin proposer](#Administrative-actions). ### Function: DualGovernance.registerProposer @@ -376,6 +376,38 @@ function activateNextState() Triggers a transition of the [global governance state](#Governance-state), if one is possible; does nothing otherwise. +### Function: DualGovernance.getPersistedState + +```solidity +function getPersistedState() view returns (State persistedState) +``` + +Returns the most recently persisted state of the DualGovernance. + +### Function: DualGovernance.getEffectiveState + +```solidity +function getEffectiveState() view returns (State persistedState) +``` + +Returns the effective state of the DualGovernance. The effective state refers to the state the DualGovernance would transition to upon calling `DualGovernance.activateNextState()`. + +### Function DualGovernance.getStateDetails + +```solidity +function getStateDetails() view returns (StateDetails) +``` + +This function returns detailed information about the current state of the `DualGovernance`, comprising the following data: + +- **`State effectiveState`**: The state that the `DualGovernance` would transition to upon calling `DualGovernance.activateNextState()`. +- **`State persistedState`**: The current stored state of the `DualGovernance`. +- **`Timestamp persistedStateEnteredAt`**: The timestamp when the `persistedState` was entered. +- **`Timestamp vetoSignallingActivatedAt`**: The timestamp when the `VetoSignalling` state was last activated. +- **`Timestamp vetoSignallingReactivationTime`**: The timestamp when the `VetoSignalling` state was last re-activated. +- **`Timestamp normalOrVetoCooldownExitedAt`**: The timestamp when the `Normal` or `VetoCooldown` state was last exited. +- **`uint256 rageQuitRound`**: The number of continuous RageQuit rounds. +- **`Duration vetoSignallingDuration`**: The duration of the `VetoSignalling` state, calculated based on the RageQuit support in the Veto Signalling `Escrow`. ## Contract: Executor.sol @@ -491,6 +523,9 @@ The rage quit support will be dynamically updated to reflect changes in the stET The method calls the `DualGovernance.activateNextState()` function at the beginning and end of the execution, which may transition the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.lockStETH()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, locking funds in the `SignallingEscrow` is no longer possible and will revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Returns The amount of stETH shares locked by the caller during the current method call. @@ -519,6 +554,9 @@ assets[msg.sender].stETHLockedShares = 0; Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.unlockStETH()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, unlocking funds in the `SignallingEscrow` is no longer possible and will revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Returns The amount of stETH shares unlocked by the caller. @@ -551,6 +589,9 @@ stETHTotals.lockedShares += stETHShares; The method calls the `DualGovernance.activateNextState()` function at the beginning and end of the execution, which may transition the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.lockWstETH()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, locking funds in the `SignallingEscrow` is no longer possible and will revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Returns The amount of stETH shares locked by the caller during the current method call. @@ -579,6 +620,10 @@ assets[msg.sender].stETHLockedShares = 0; Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.unlockWstETH()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, unlocking funds in the `SignallingEscrow` is no longer possible and will revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + + #### Returns The amount of stETH shares unlocked by the caller. @@ -611,6 +656,9 @@ unstETHTotals.unfinalizedShares += amountOfShares; The method calls the `DualGovernance.activateNextState()` function at the beginning and end of the execution, which may transition the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.lockUnstETH()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, locking funds in the `SignallingEscrow` is no longer possible and will revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Preconditions - The `Escrow` instance MUST be in the `SignallingEscrow` state. @@ -652,6 +700,9 @@ unstETHTotals.unfinalizedShares -= amountOfShares; Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.unlockUnstETH()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, unlocking funds in the `SignallingEscrow` is no longer possible and will revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Preconditions - The `Escrow` instance MUST be in the `SignallingEscrow` state. @@ -689,6 +740,11 @@ Withdrawal NFTs belonging to any of the following categories are excluded from t - Withdrawal NFTs already marked as finalized - Withdrawal NFTs not locked in the `Escrow` instance +The method calls the `DualGovernance.activateNextState()` function at the beginning and end of the execution, which may transition the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. + +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.markUnstETHFinalized()` method, it SHOULD be used alongside the `DualGovernance.getPersistedState()`/`DualGovernance.getEffectiveState()` methods or the `DualGovernance.getStateDetails()` method. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, calling methods that change Rage Quit support in the `SignallingEscrow` will no longer be possible and will result in a revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Preconditions - The `Escrow` instance MUST be in the `SignallingEscrow` state. @@ -703,6 +759,11 @@ Allows users who have locked their stETH and wstETH to convert it into unstETH N Internally, this function marks the total amount specified in `stETHAmounts` as unlocked from the `Escrow` and accounts for it in the form of a list of unstETH NFTs, with amounts corresponding to `stETHAmounts`. +The method calls the `DualGovernance.activateNextState()` function at the beginning of the execution. + +> [!IMPORTANT] +> To mitigate possible failures when calling the `Escrow.requestWithdrawals()` method, it SHOULD be used in conjunction with the `DualGovernance.getPersistedState()`, `DualGovernance.getEffectiveState()`, or `DualGovernance.getStateDetails()` methods. These methods help identify scenarios where `persistedState != RageQuit` but `effectiveState == RageQuit`. When this state is detected, further token manipulation within the `SignallingEscrow` is no longer possible and will result in a revert. In such cases, `DualGovernance.activateNextState()` MUST be called to initiate the pending `RageQuit`. + #### Preconditions - The total amount specified in `stETHAmounts` MUST NOT exceed the user's currently locked stETH and wstETH. - The `stETHAmounts` values MUST be in range [`WithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT()`, `WithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT()`]. diff --git a/test/unit/DualGovernance.t.sol b/test/unit/DualGovernance.t.sol index 1b5c9075..36a6601c 100644 --- a/test/unit/DualGovernance.t.sol +++ b/test/unit/DualGovernance.t.sol @@ -9,7 +9,7 @@ import {ExternalCall} from "contracts/libraries/ExternalCalls.sol"; import {Escrow} from "contracts/Escrow.sol"; import {Executor} from "contracts/Executor.sol"; -import {DualGovernance, State} from "contracts/DualGovernance.sol"; +import {DualGovernance, State, DualGovernanceStateMachine} from "contracts/DualGovernance.sol"; import {Tiebreaker} from "contracts/libraries/Tiebreaker.sol"; import {Status as ProposalStatus} from "contracts/libraries/ExecutableProposals.sol"; import {Proposers} from "contracts/libraries/Proposers.sol"; @@ -122,15 +122,15 @@ contract DualGovernanceUnitTests is UnitTest { vm.prank(vetoer); _escrow.lockStETH(5 ether); - State currentStateBefore = _dualGovernance.getState(); + State currentStateBefore = _dualGovernance.getPersistedState(); assertEq(currentStateBefore, State.VetoSignalling); _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); - assertEq(currentStateBefore, _dualGovernance.getState()); + assertEq(currentStateBefore, _dualGovernance.getPersistedState()); _dualGovernance.submitProposal(_generateExternalCalls(), ""); - State currentStateAfter = _dualGovernance.getState(); + State currentStateAfter = _dualGovernance.getPersistedState(); assertEq(currentStateAfter, State.RageQuit); assert(currentStateBefore != currentStateAfter); } @@ -139,11 +139,11 @@ contract DualGovernanceUnitTests is UnitTest { vm.startPrank(vetoer); _escrow.lockStETH(5 ether); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); _escrow.unlockStETH(); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); vm.expectRevert(abi.encodeWithSelector(DualGovernance.ProposalSubmissionBlocked.selector)); _dualGovernance.submitProposal(_generateExternalCalls(), ""); @@ -208,9 +208,9 @@ contract DualGovernanceUnitTests is UnitTest { ) ); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); _dualGovernance.scheduleProposal(proposalId); - assertEq(_dualGovernance.getState(), State.VetoCooldown); + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); } function test_scheduleProposal_RevertOn_CannotSchedule() external { @@ -246,13 +246,14 @@ contract DualGovernanceUnitTests is UnitTest { _submitMockProposal(); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); vm.expectEmit(); emit DualGovernance.CancelAllPendingProposalsSkipped(); - _dualGovernance.cancelAllPendingProposals(); + bool isProposalsCancelled = _dualGovernance.cancelAllPendingProposals(); + assertFalse(isProposalsCancelled); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); } @@ -261,7 +262,7 @@ contract DualGovernanceUnitTests is UnitTest { _submitMockProposal(); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); Escrow signallingEscrow = Escrow(payable(_dualGovernance.getVetoSignallingEscrow())); @@ -269,25 +270,26 @@ contract DualGovernanceUnitTests is UnitTest { signallingEscrow.lockStETH(5 ether); vm.stopPrank(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); vm.prank(vetoer); signallingEscrow.unlockStETH(); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoCooldown); + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); vm.expectEmit(); emit DualGovernance.CancelAllPendingProposalsSkipped(); - _dualGovernance.cancelAllPendingProposals(); + bool isProposalsCancelled = _dualGovernance.cancelAllPendingProposals(); + assertFalse(isProposalsCancelled); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); } @@ -296,24 +298,25 @@ contract DualGovernanceUnitTests is UnitTest { _submitMockProposal(); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); vm.startPrank(vetoer); _escrow.lockStETH(5 ether); vm.stopPrank(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.RageQuit); + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); vm.expectEmit(); emit DualGovernance.CancelAllPendingProposalsSkipped(); - _dualGovernance.cancelAllPendingProposals(); + bool isProposalsCancelled = _dualGovernance.cancelAllPendingProposals(); + assertFalse(isProposalsCancelled); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); } @@ -322,19 +325,20 @@ contract DualGovernanceUnitTests is UnitTest { _submitMockProposal(); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); vm.startPrank(vetoer); _escrow.lockStETH(5 ether); vm.stopPrank(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); vm.expectEmit(); emit DualGovernance.CancelAllPendingProposalsExecuted(); - _dualGovernance.cancelAllPendingProposals(); + bool isProposalsCancelled = _dualGovernance.cancelAllPendingProposals(); + assertTrue(isProposalsCancelled); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 1); } @@ -343,26 +347,27 @@ contract DualGovernanceUnitTests is UnitTest { _submitMockProposal(); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 0); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); vm.startPrank(vetoer); _escrow.lockStETH(5 ether); vm.stopPrank(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); vm.prank(vetoer); _escrow.unlockStETH(); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); vm.expectEmit(); emit DualGovernance.CancelAllPendingProposalsExecuted(); - _dualGovernance.cancelAllPendingProposals(); + bool isProposalsCancelled = _dualGovernance.cancelAllPendingProposals(); + assertTrue(isProposalsCancelled); assertEq(_timelock.getProposalsCount(), 1); assertEq(_timelock.lastCancelledProposalId(), 1); } @@ -392,14 +397,14 @@ contract DualGovernanceUnitTests is UnitTest { function test_canSubmitProposal_HappyPath() external { assertTrue(_dualGovernance.canSubmitProposal()); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); vm.startPrank(vetoer); _escrow.lockStETH(5 ether); _dualGovernance.activateNextState(); assertTrue(_dualGovernance.canSubmitProposal()); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); @@ -407,27 +412,108 @@ contract DualGovernanceUnitTests is UnitTest { _dualGovernance.activateNextState(); assertFalse(_dualGovernance.canSubmitProposal()); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); assertFalse(_dualGovernance.canSubmitProposal()); - assertEq(_dualGovernance.getState(), State.VetoCooldown); + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); assertTrue(_dualGovernance.canSubmitProposal()); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); _escrow.lockStETH(5 ether); _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); assertTrue(_dualGovernance.canSubmitProposal()); - assertEq(_dualGovernance.getState(), State.RageQuit); + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + } + + function test_canSubmitProposal_PersistedStateIsNotEqualToEffectiveState() external { + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertTrue(_dualGovernance.canSubmitProposal()); + + vm.startPrank(vetoer); + _escrow.lockStETH(0.5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertTrue(_dualGovernance.canSubmitProposal()); + + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().dividedBy(2)); + + // The RageQuit second seal threshold wasn't crossed, the system should enter Deactivation state + // where the proposals submission is not allowed + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_dualGovernance.canSubmitProposal()); + + // activate VetoSignallingState again + vm.startPrank(vetoer); + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertTrue(_dualGovernance.canSubmitProposal()); + + // make the EVM snapshot to return back after the RageQuit scenario is tested + uint256 snapshotId = vm.snapshot(); + + // RageQuit scenario + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().dividedBy(2).plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertTrue(_dualGovernance.canSubmitProposal()); + + _dualGovernance.activateNextState(); + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertTrue(_dualGovernance.canSubmitProposal()); + + vm.revertTo(snapshotId); + + // VetoCooldown scenario + + vm.startPrank(vetoer); + + _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); + + _escrow.unlockStETH(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_dualGovernance.canSubmitProposal()); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertFalse(_dualGovernance.canSubmitProposal()); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertFalse(_dualGovernance.canSubmitProposal()); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertTrue(_dualGovernance.canSubmitProposal()); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertTrue(_dualGovernance.canSubmitProposal()); } // --- @@ -499,7 +585,7 @@ contract DualGovernanceUnitTests is UnitTest { _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoCooldown); + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); vm.mockCall( address(_timelock), @@ -521,38 +607,309 @@ contract DualGovernanceUnitTests is UnitTest { assertFalse(_dualGovernance.canScheduleProposal(proposalId)); } - // --- - // activateNextState() & getState() - // --- + function test_canScheduleProposal_PersistedStateIsNotEqualToEffectiveState() external { + uint256 proposalIdSubmittedBeforeVetoSignalling = 1; + uint256 proposalIdSubmittedAfterVetoSignalling = 2; + + // The proposal is submitted before the VetoSignalling is active + vm.mockCall( + address(_timelock), + abi.encodeWithSelector(TimelockMock.getProposalDetails.selector, proposalIdSubmittedBeforeVetoSignalling), + abi.encode( + ITimelock.ProposalDetails({ + id: proposalIdSubmittedBeforeVetoSignalling, + status: ProposalStatus.Submitted, + executor: address(_executor), + submittedAt: Timestamps.now(), + scheduledAt: Timestamps.from(0) + }) + ) + ); + + vm.mockCall( + address(_timelock), + abi.encodeWithSelector(TimelockMock.canSchedule.selector, proposalIdSubmittedBeforeVetoSignalling), + abi.encode(true) + ); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + + vm.startPrank(vetoer); + _escrow.lockStETH(0.5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().dividedBy(2)); + + // The proposal is submitted after the VetoSignalling is active + vm.mockCall( + address(_timelock), + abi.encodeWithSelector(TimelockMock.getProposalDetails.selector, proposalIdSubmittedAfterVetoSignalling), + abi.encode( + ITimelock.ProposalDetails({ + id: proposalIdSubmittedAfterVetoSignalling, + status: ProposalStatus.Submitted, + executor: address(_executor), + submittedAt: Timestamps.now(), + scheduledAt: Timestamps.from(0) + }) + ) + ); + + vm.mockCall( + address(_timelock), + abi.encodeWithSelector(TimelockMock.canSchedule.selector, proposalIdSubmittedAfterVetoSignalling), + abi.encode(true) + ); - function test_activateNextState_getState_HappyPath() external { - assertEq(_dualGovernance.getState(), State.Normal); + // The RageQuit second seal threshold wasn't crossed, the system should enter Deactivation state + // where the proposals submission is not allowed + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + // activate VetoSignallingState again vm.startPrank(vetoer); _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + // make the EVM snapshot to return back after the RageQuit scenario is tested + uint256 snapshotId = vm.snapshot(); + + // RageQuit scenario + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().dividedBy(2).plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + // mock the calls to the escrow to simulate the RageQuit was over + vm.mockCall(address(_escrow), abi.encodeWithSelector(Escrow.isRageQuitFinalized.selector), abi.encode(true)); + vm.mockCall(address(_escrow), abi.encodeWithSelector(Escrow.getRageQuitSupport.selector), abi.encode(0)); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + + // As the veto signalling started after the proposal was submitted, proposal becomes schedulable + // when the RageQuit is finished + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + // But the proposal submitted after the VetoSignalling is started is not schedulable + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + // In the Normal state the proposal submitted after the veto signalling state was activated + // becomes executable + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + vm.revertTo(snapshotId); + + // VetoCooldown scenario + + vm.startPrank(vetoer); _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); + _escrow.unlockStETH(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertFalse(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedBeforeVetoSignalling)); + assertTrue(_dualGovernance.canScheduleProposal(proposalIdSubmittedAfterVetoSignalling)); + } + + // --- + // canCancelAllPendingProposals() + // --- + + function test_canCancelAllPendingProposals_HappyPath() external { + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); + + vm.startPrank(vetoer); + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + + assertTrue(_dualGovernance.canCancelAllPendingProposals()); + + _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); + _escrow.unlockStETH(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + + assertTrue(_dualGovernance.canCancelAllPendingProposals()); _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); + _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoCooldown); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); + _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); + + vm.startPrank(vetoer); _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + + assertTrue(_dualGovernance.canCancelAllPendingProposals()); + + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + + assertFalse(_dualGovernance.canCancelAllPendingProposals()); + } + + // --- + // activateNextState() & getPersistedState() & getEffectiveState() + // --- + + function test_activateNextState_getPersistedAndEffectiveState_HappyPath() external { + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + + vm.startPrank(vetoer); + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + + _wait(_configProvider.MIN_ASSETS_LOCK_DURATION().plusSeconds(1)); + _escrow.unlockStETH(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.RageQuit); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); } // --- @@ -583,7 +940,7 @@ contract DualGovernanceUnitTests is UnitTest { IDualGovernanceConfigProvider oldConfigProvider = _dualGovernance.getConfigProvider(); vm.expectEmit(); - emit DualGovernance.ConfigProviderSet(IDualGovernanceConfigProvider(address(newConfigProvider))); + emit DualGovernanceStateMachine.ConfigProviderSet(IDualGovernanceConfigProvider(address(newConfigProvider))); vm.expectCall( address(_escrow), @@ -630,14 +987,16 @@ contract DualGovernanceUnitTests is UnitTest { } function test_setConfigProvider_RevertOn_ConfigZeroAddress() external { - vm.expectRevert(abi.encodeWithSelector(DualGovernance.InvalidConfigProvider.selector, address(0))); + vm.expectRevert(abi.encodeWithSelector(DualGovernanceStateMachine.InvalidConfigProvider.selector, address(0))); _executor.execute( address(_dualGovernance), 0, abi.encodeWithSelector(DualGovernance.setConfigProvider.selector, address(0)) ); } function test_setConfigProvider_RevertOn_SameAddress() external { - vm.expectRevert(abi.encodeWithSelector(DualGovernance.InvalidConfigProvider.selector, address(_configProvider))); + vm.expectRevert( + abi.encodeWithSelector(DualGovernanceStateMachine.InvalidConfigProvider.selector, address(_configProvider)) + ); _executor.execute( address(_dualGovernance), 0, @@ -694,7 +1053,7 @@ contract DualGovernanceUnitTests is UnitTest { _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.RageQuit); + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); assertTrue(_dualGovernance.getVetoSignallingEscrow() != address(_escrow)); } @@ -711,7 +1070,7 @@ contract DualGovernanceUnitTests is UnitTest { _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.RageQuit); + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); assertEq(_dualGovernance.getRageQuitEscrow(), address(_escrow)); } @@ -724,8 +1083,8 @@ contract DualGovernanceUnitTests is UnitTest { Timestamp startTime = Timestamps.now(); IDualGovernance.StateDetails memory details = _dualGovernance.getStateDetails(); - assertEq(details.state, State.Normal); - assertEq(details.enteredAt, startTime); + assertEq(details.persistedState, State.Normal); + assertEq(details.persistedStateEnteredAt, startTime); assertEq(details.vetoSignallingActivatedAt, Timestamps.from(0)); assertEq(details.vetoSignallingReactivationTime, Timestamps.from(0)); assertEq(details.normalOrVetoCooldownExitedAt, Timestamps.from(0)); @@ -739,8 +1098,8 @@ contract DualGovernanceUnitTests is UnitTest { _dualGovernance.activateNextState(); details = _dualGovernance.getStateDetails(); - assertEq(details.state, State.VetoSignalling); - assertEq(details.enteredAt, vetoSignallingTime); + assertEq(details.persistedState, State.VetoSignalling); + assertEq(details.persistedStateEnteredAt, vetoSignallingTime); assertEq(details.vetoSignallingActivatedAt, vetoSignallingTime); assertEq(details.vetoSignallingReactivationTime, Timestamps.from(0)); assertEq(details.normalOrVetoCooldownExitedAt, vetoSignallingTime); @@ -754,8 +1113,8 @@ contract DualGovernanceUnitTests is UnitTest { _dualGovernance.activateNextState(); details = _dualGovernance.getStateDetails(); - assertEq(details.state, State.VetoSignallingDeactivation); - assertEq(details.enteredAt, deactivationTime); + assertEq(details.persistedState, State.VetoSignallingDeactivation); + assertEq(details.persistedStateEnteredAt, deactivationTime); assertEq(details.vetoSignallingActivatedAt, vetoSignallingTime); assertEq(details.vetoSignallingReactivationTime, Timestamps.from(0)); assertEq(details.normalOrVetoCooldownExitedAt, vetoSignallingTime); @@ -767,8 +1126,8 @@ contract DualGovernanceUnitTests is UnitTest { _dualGovernance.activateNextState(); details = _dualGovernance.getStateDetails(); - assertEq(details.state, State.VetoCooldown); - assertEq(details.enteredAt, vetoCooldownTime); + assertEq(details.persistedState, State.VetoCooldown); + assertEq(details.persistedStateEnteredAt, vetoCooldownTime); assertEq(details.vetoSignallingActivatedAt, vetoSignallingTime); assertEq(details.vetoSignallingReactivationTime, Timestamps.from(0)); assertEq(details.normalOrVetoCooldownExitedAt, vetoSignallingTime); @@ -780,8 +1139,8 @@ contract DualGovernanceUnitTests is UnitTest { _dualGovernance.activateNextState(); details = _dualGovernance.getStateDetails(); - assertEq(details.state, State.Normal); - assertEq(details.enteredAt, backToNormalTime); + assertEq(details.persistedState, State.Normal); + assertEq(details.persistedStateEnteredAt, backToNormalTime); assertEq(details.vetoSignallingActivatedAt, vetoSignallingTime); assertEq(details.vetoSignallingReactivationTime, Timestamps.from(0)); assertEq(details.normalOrVetoCooldownExitedAt, backToNormalTime); @@ -798,8 +1157,8 @@ contract DualGovernanceUnitTests is UnitTest { vm.stopPrank(); details = _dualGovernance.getStateDetails(); - assertEq(details.state, State.RageQuit); - assertEq(details.enteredAt, rageQuitTime); + assertEq(details.persistedState, State.RageQuit); + assertEq(details.persistedStateEnteredAt, rageQuitTime); assertEq(details.vetoSignallingActivatedAt, secondVetoSignallingTime); assertEq(details.vetoSignallingReactivationTime, Timestamps.from(0)); assertEq(details.normalOrVetoCooldownExitedAt, backToNormalTime); @@ -1190,7 +1549,7 @@ contract DualGovernanceUnitTests is UnitTest { _dualGovernance.activateNextState(); _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); vm.mockCall( address(_RESEAL_MANAGER_STUB), @@ -1201,9 +1560,9 @@ contract DualGovernanceUnitTests is UnitTest { vm.prank(tiebreakerCommittee); _dualGovernance.tiebreakerResumeSealable(sealable); - assertEq(_dualGovernance.getState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); IDualGovernance.StateDetails memory stateDetails = _dualGovernance.getStateDetails(); - assertEq(stateDetails.enteredAt, Timestamps.now()); + assertEq(stateDetails.persistedStateEnteredAt, Timestamps.now()); } // --- @@ -1265,7 +1624,7 @@ contract DualGovernanceUnitTests is UnitTest { _escrow.lockStETH(1 ether); vm.stopPrank(); _dualGovernance.activateNextState(); - assertEq(uint256(_dualGovernance.getState()), uint256(State.VetoSignalling)); + assertEq(uint256(_dualGovernance.getPersistedState()), uint256(State.VetoSignalling)); _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); @@ -1274,10 +1633,10 @@ contract DualGovernanceUnitTests is UnitTest { vm.prank(tiebreakerCommittee); _dualGovernance.tiebreakerScheduleProposal(proposalId); - assertEq(uint256(_dualGovernance.getState()), uint256(State.VetoSignallingDeactivation)); + assertEq(uint256(_dualGovernance.getPersistedState()), uint256(State.VetoSignallingDeactivation)); IDualGovernance.StateDetails memory stateDetails = _dualGovernance.getStateDetails(); - assertEq(stateDetails.enteredAt, Timestamps.now()); + assertEq(stateDetails.persistedStateEnteredAt, Timestamps.now()); } // --- @@ -1357,7 +1716,7 @@ contract DualGovernanceUnitTests is UnitTest { _escrow.lockStETH(5 ether); vm.stopPrank(); _dualGovernance.activateNextState(); - assertEq(uint256(_dualGovernance.getState()), uint256(State.VetoSignalling)); + assertEq(uint256(_dualGovernance.getPersistedState()), uint256(State.VetoSignalling)); _wait(newTimeout.plusSeconds(1)); @@ -1370,6 +1729,248 @@ contract DualGovernanceUnitTests is UnitTest { assertEq(details.sealableWithdrawalBlockers[0], sealable); } + function test_getTiebreakerDetails_IsTieInDifferentEffectivePersistedStates() external { + address tiebreakerCommittee = makeAddr("TIEBREAKER_COMMITTEE"); + address sealable = address(new SealableMock()); + Duration tiebreakerActivationTimeout = Durations.from(180 days); + + // for the correctness of the test, the following assumption must be true + assertTrue(tiebreakerActivationTimeout >= _configProvider.VETO_SIGNALLING_MAX_DURATION()); + + // setup tiebreaker + + _executor.execute( + address(_dualGovernance), + 0, + abi.encodeWithSelector(DualGovernance.setTiebreakerCommittee.selector, tiebreakerCommittee) + ); + _executor.execute( + address(_dualGovernance), + 0, + abi.encodeWithSelector(DualGovernance.setTiebreakerActivationTimeout.selector, tiebreakerActivationTimeout) + ); + _executor.execute( + address(_dualGovernance), + 0, + abi.encodeWithSelector(DualGovernance.addTiebreakerSealableWithdrawalBlocker.selector, sealable) + ); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + vm.prank(vetoer); + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_SIGNALLING_MIN_DURATION().dividedBy(2)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + vm.prank(vetoer); + _escrow.unlockStETH(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION()); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + vm.prank(vetoer); + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION()); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + // Simulate the sealable withdrawal blocker was paused + vm.mockCall(address(sealable), abi.encodeWithSelector(SealableMock.isPaused.selector), abi.encode(true)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + // TiebreakerDetails.isTie correctly returns true even if persisted state is outdated + assertTrue(_dualGovernance.getTiebreakerDetails().isTie); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertTrue(_dualGovernance.getTiebreakerDetails().isTie); + + // Return sealable to unpaused state for further testing + vm.mockCall(address(sealable), abi.encodeWithSelector(SealableMock.isPaused.selector), abi.encode(false)); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(tiebreakerActivationTimeout); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertTrue(_dualGovernance.getTiebreakerDetails().isTie); + + // simulate the rage quit was finalized but new veto signalling in progress + vm.mockCall( + _dualGovernance.getRageQuitEscrow(), + abi.encodeWithSelector(Escrow.isRageQuitFinalized.selector), + abi.encode(true) + ); + vm.mockCall( + _dualGovernance.getVetoSignallingEscrow(), + abi.encodeWithSelector(Escrow.getRageQuitSupport.selector), + abi.encode(_configProvider.SECOND_SEAL_RAGE_QUIT_SUPPORT() + PercentsD16.fromBasisPoints(1)) + ); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertTrue(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().dividedBy(2)); + + vm.mockCall( + _dualGovernance.getVetoSignallingEscrow(), + abi.encodeWithSelector(Escrow.getRageQuitSupport.selector), + abi.encode(PercentsD16.fromBasisPoints(1_00)) + ); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignallingDeactivation); + assertTrue(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + } + + function test_getTiebreakerDetails_NormalOrVetoCooldownExitedAtValueShouldBeUpdatedToCorrectlyCalculateIsTieValue() + external + { + address tiebreakerCommittee = makeAddr("TIEBREAKER_COMMITTEE"); + address sealable = address(new SealableMock()); + Duration tiebreakerActivationTimeout = Durations.from(180 days); + + // for the correctness of the test, the following assumption must be true + assertTrue(tiebreakerActivationTimeout >= _configProvider.VETO_SIGNALLING_MAX_DURATION()); + + // setup tiebreaker + + _executor.execute( + address(_dualGovernance), + 0, + abi.encodeWithSelector(DualGovernance.setTiebreakerCommittee.selector, tiebreakerCommittee) + ); + _executor.execute( + address(_dualGovernance), + 0, + abi.encodeWithSelector(DualGovernance.setTiebreakerActivationTimeout.selector, tiebreakerActivationTimeout) + ); + _executor.execute( + address(_dualGovernance), + 0, + abi.encodeWithSelector(DualGovernance.addTiebreakerSealableWithdrawalBlocker.selector, sealable) + ); + + assertEq(_dualGovernance.getPersistedState(), State.Normal); + assertEq(_dualGovernance.getEffectiveState(), State.Normal); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + vm.prank(vetoer); + _escrow.lockStETH(5 ether); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(_configProvider.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.RageQuit); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _wait(tiebreakerActivationTimeout); + + vm.mockCall( + _dualGovernance.getRageQuitEscrow(), + abi.encodeWithSelector(Escrow.isRageQuitFinalized.selector), + abi.encode(true) + ); + + assertEq(_dualGovernance.getPersistedState(), State.RageQuit); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _dualGovernance.activateNextState(); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoCooldown); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + // signalling accumulated rage quit support + vm.mockCall( + _dualGovernance.getVetoSignallingEscrow(), + abi.encodeWithSelector(Escrow.getRageQuitSupport.selector), + abi.encode(PercentsD16.fromBasisPoints(5_00)) + ); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_dualGovernance.getPersistedState(), State.VetoCooldown); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + + // The extra case, when the transition from the VetoCooldown should happened. + // In such case, `normalOrVetoCooldownExitedAt` will be updated and isTie value + // still will be equal to `false` + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + + _dualGovernance.activateNextState(); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); + assertEq(_dualGovernance.getEffectiveState(), State.VetoSignalling); + assertFalse(_dualGovernance.getTiebreakerDetails().isTie); + } + // --- // resealSealable() // --- @@ -1388,7 +1989,7 @@ contract DualGovernanceUnitTests is UnitTest { _escrow.lockStETH(5 ether); vm.stopPrank(); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); vm.mockCall( address(_RESEAL_MANAGER_STUB), @@ -1414,7 +2015,7 @@ contract DualGovernanceUnitTests is UnitTest { _escrow.lockStETH(5 ether); vm.stopPrank(); _dualGovernance.activateNextState(); - assertEq(_dualGovernance.getState(), State.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), State.VetoSignalling); vm.prank(notResealCommittee); vm.expectRevert(abi.encodeWithSelector(DualGovernance.CallerIsNotResealCommittee.selector, notResealCommittee)); @@ -1431,7 +2032,7 @@ contract DualGovernanceUnitTests is UnitTest { abi.encodeWithSelector(DualGovernance.setResealCommittee.selector, resealCommittee) ); - assertEq(_dualGovernance.getState(), State.Normal); + assertEq(_dualGovernance.getPersistedState(), State.Normal); vm.prank(resealCommittee); vm.expectRevert(DualGovernance.ResealIsNotAllowedInNormalState.selector); diff --git a/test/unit/TimelockedGovernance.t.sol b/test/unit/TimelockedGovernance.t.sol index 8cfa8a23..a7ad7c7b 100644 --- a/test/unit/TimelockedGovernance.t.sol +++ b/test/unit/TimelockedGovernance.t.sol @@ -85,8 +85,9 @@ contract TimelockedGovernanceUnitTests is UnitTest { _timelock.setSchedule(1); _timelockedGovernance.scheduleProposal(1); - _timelockedGovernance.cancelAllPendingProposals(); + bool isProposalsCancelled = _timelockedGovernance.cancelAllPendingProposals(); + assertTrue(isProposalsCancelled); assertEq(_timelock.getLastCancelledProposalId(), 2); } diff --git a/test/unit/committees/EmergencyActivationCommittee.t.sol b/test/unit/committees/EmergencyActivationCommittee.t.sol index 1cd2159c..de423ce2 100644 --- a/test/unit/committees/EmergencyActivationCommittee.t.sol +++ b/test/unit/committees/EmergencyActivationCommittee.t.sol @@ -5,7 +5,7 @@ import {EmergencyActivationCommittee} from "contracts/committees/EmergencyActiva import {HashConsensus} from "contracts/committees/HashConsensus.sol"; import {Durations} from "contracts/types/Duration.sol"; import {Timestamp} from "contracts/types/Timestamp.sol"; -import {ITimelock} from "contracts/interfaces/ITimelock.sol"; +import {IEmergencyProtectedTimelock} from "contracts/interfaces/IEmergencyProtectedTimelock.sol"; import {TargetMock} from "test/utils/target-mock.sol"; import {UnitTest} from "test/utils/unit-test.sol"; @@ -68,7 +68,10 @@ contract EmergencyActivationCommitteeUnitTest is UnitTest { emergencyActivationCommittee.approveActivateEmergencyMode(); vm.prank(committeeMembers[2]); - vm.expectCall(emergencyProtectedTimelock, abi.encodeWithSelector(ITimelock.activateEmergencyMode.selector)); + vm.expectCall( + emergencyProtectedTimelock, + abi.encodeWithSelector(IEmergencyProtectedTimelock.activateEmergencyMode.selector) + ); emergencyActivationCommittee.executeActivateEmergencyMode(); (uint256 support, uint256 executionQuorum, Timestamp quorumAt, bool isExecuted) = @@ -115,7 +118,10 @@ contract EmergencyActivationCommitteeUnitTest is UnitTest { assertEq(isExecuted, false); vm.prank(committeeMembers[2]); - vm.expectCall(emergencyProtectedTimelock, abi.encodeWithSelector(ITimelock.activateEmergencyMode.selector)); + vm.expectCall( + emergencyProtectedTimelock, + abi.encodeWithSelector(IEmergencyProtectedTimelock.activateEmergencyMode.selector) + ); emergencyActivationCommittee.executeActivateEmergencyMode(); (support, executionQuorum, quorumAt, isExecuted) = emergencyActivationCommittee.getActivateEmergencyModeState(); diff --git a/test/unit/committees/EmergencyExecutionCommittee.t.sol b/test/unit/committees/EmergencyExecutionCommittee.t.sol index cc047b30..2f05da8e 100644 --- a/test/unit/committees/EmergencyExecutionCommittee.t.sol +++ b/test/unit/committees/EmergencyExecutionCommittee.t.sol @@ -5,7 +5,7 @@ import {EmergencyExecutionCommittee, ProposalType} from "contracts/committees/Em import {HashConsensus} from "contracts/committees/HashConsensus.sol"; import {Durations} from "contracts/types/Duration.sol"; import {Timestamp} from "contracts/types/Timestamp.sol"; -import {ITimelock} from "contracts/interfaces/ITimelock.sol"; +import {IEmergencyProtectedTimelock} from "contracts/interfaces/IEmergencyProtectedTimelock.sol"; import {TargetMock} from "test/utils/target-mock.sol"; import {UnitTest} from "test/utils/unit-test.sol"; @@ -101,7 +101,8 @@ contract EmergencyExecutionCommitteeUnitTest is UnitTest { vm.prank(committeeMembers[2]); vm.expectCall( - emergencyProtectedTimelock, abi.encodeWithSelector(ITimelock.emergencyExecute.selector, proposalId) + emergencyProtectedTimelock, + abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyExecute.selector, proposalId) ); emergencyExecutionCommittee.executeEmergencyExecute(proposalId); @@ -182,7 +183,9 @@ contract EmergencyExecutionCommitteeUnitTest is UnitTest { emergencyExecutionCommittee.approveEmergencyReset(); vm.prank(committeeMembers[2]); - vm.expectCall(emergencyProtectedTimelock, abi.encodeWithSelector(ITimelock.emergencyReset.selector)); + vm.expectCall( + emergencyProtectedTimelock, abi.encodeWithSelector(IEmergencyProtectedTimelock.emergencyReset.selector) + ); emergencyExecutionCommittee.executeEmergencyReset(); (,,, bool isExecuted) = emergencyExecutionCommittee.getEmergencyResetState(); diff --git a/test/unit/libraries/DualGovernanceStateMachine.t.sol b/test/unit/libraries/DualGovernanceStateMachine.t.sol index 7e447cae..829d5b87 100644 --- a/test/unit/libraries/DualGovernanceStateMachine.t.sol +++ b/test/unit/libraries/DualGovernanceStateMachine.t.sol @@ -4,7 +4,8 @@ pragma solidity 0.8.26; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Durations} from "contracts/types/Duration.sol"; -import {PercentsD16} from "contracts/types/PercentD16.sol"; +import {Timestamp, Timestamps} from "contracts/types/Timestamp.sol"; +import {PercentD16, PercentsD16} from "contracts/types/PercentD16.sol"; import {DualGovernanceStateMachine, State} from "contracts/libraries/DualGovernanceStateMachine.sol"; import { @@ -13,12 +14,12 @@ import { } from "contracts/ImmutableDualGovernanceConfigProvider.sol"; import {UnitTest} from "test/utils/unit-test.sol"; -import {EscrowMock} from "test/mocks/EscrowMock.sol"; +import {IEscrow, EscrowMock} from "test/mocks/EscrowMock.sol"; contract DualGovernanceStateMachineUnitTests is UnitTest { using DualGovernanceStateMachine for DualGovernanceStateMachine.Context; - address private immutable _ESCROW_MASTER_COPY = address(new EscrowMock()); + IEscrow private immutable _ESCROW_MASTER_COPY = new EscrowMock(); ImmutableDualGovernanceConfigProvider internal immutable _CONFIG_PROVIDER = new ImmutableDualGovernanceConfigProvider( DualGovernanceConfig.Context({ firstSealRageQuitSupport: PercentsD16.fromBasisPoints(3_00), // 3% @@ -43,9 +44,18 @@ contract DualGovernanceStateMachineUnitTests is UnitTest { DualGovernanceStateMachine.Context private _stateMachine; function setUp() external { - _stateMachine.initialize(_CONFIG_PROVIDER.getDualGovernanceConfig(), _ESCROW_MASTER_COPY); + _stateMachine.initialize(_CONFIG_PROVIDER, _ESCROW_MASTER_COPY); } + function test_initialize_RevertOn_ReInitialization() external { + vm.expectRevert(DualGovernanceStateMachine.AlreadyInitialized.selector); + this.external__initialize(); + } + + // --- + // activateNextState() + // --- + function test_activateNextState_HappyPath_MaxRageQuitsRound() external { assertEq(_stateMachine.state, State.Normal); @@ -62,25 +72,473 @@ contract DualGovernanceStateMachineUnitTests is UnitTest { // wait here the full duration of the veto cooldown to make sure it's over from the previous iteration _wait(_CONFIG_PROVIDER.VETO_COOLDOWN_DURATION().plusSeconds(1)); - _stateMachine.activateNextState(_CONFIG_PROVIDER.getDualGovernanceConfig(), _ESCROW_MASTER_COPY); + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); assertEq(_stateMachine.state, State.VetoSignalling); _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); - _stateMachine.activateNextState(_CONFIG_PROVIDER.getDualGovernanceConfig(), _ESCROW_MASTER_COPY); + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); assertEq(_stateMachine.state, State.RageQuit); assertEq(_stateMachine.rageQuitRound, Math.min(i + 1, DualGovernanceStateMachine.MAX_RAGE_QUIT_ROUND)); EscrowMock(signallingEscrow).__setIsRageQuitFinalized(true); - _stateMachine.activateNextState(_CONFIG_PROVIDER.getDualGovernanceConfig(), _ESCROW_MASTER_COPY); + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); assertEq(_stateMachine.state, State.VetoCooldown); } // after the sequential rage quits chain is broken, the rage quit resets to 0 _wait(_CONFIG_PROVIDER.VETO_COOLDOWN_DURATION().plusSeconds(1)); - _stateMachine.activateNextState(_CONFIG_PROVIDER.getDualGovernanceConfig(), _ESCROW_MASTER_COPY); + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); assertEq(_stateMachine.rageQuitRound, 0); assertEq(_stateMachine.state, State.Normal); } + + // --- + // canSubmitProposal() + // --- + + function test_canSubmitProposal_HappyPath() external { + address signallingEscrow = address(_stateMachine.signallingEscrow); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + + // simulate the first threshold of veto signalling was reached + EscrowMock(signallingEscrow).__setRageQuitSupport( + _CONFIG_PROVIDER.FIRST_SEAL_RAGE_QUIT_SUPPORT() + PercentD16.wrap(1) + ); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + + _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MIN_DURATION().plusSeconds(1 minutes)); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignallingDeactivation); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + // simulate the second threshold of veto signalling was reached + EscrowMock(signallingEscrow).__setRageQuitSupport( + _CONFIG_PROVIDER.SECOND_SEAL_RAGE_QUIT_SUPPORT() + PercentsD16.fromBasisPoints(1_00) + ); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.RageQuit); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.RageQuit); + assertEq(_stateMachine.getEffectiveState(), State.RageQuit); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + EscrowMock(address(_stateMachine.rageQuitEscrow)).__setIsRageQuitFinalized(true); + + assertEq(_stateMachine.getPersistedState(), State.RageQuit); + assertEq(_stateMachine.getEffectiveState(), State.VetoCooldown); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoCooldown); + assertEq(_stateMachine.getEffectiveState(), State.VetoCooldown); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _wait(_CONFIG_PROVIDER.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_stateMachine.getPersistedState(), State.VetoCooldown); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertFalse(_stateMachine.canSubmitProposal({useEffectiveState: false})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: true})); + assertTrue(_stateMachine.canSubmitProposal({useEffectiveState: false})); + } + + // --- + // canScheduleProposal() + // --- + + function test_canScheduleProposal_HappyPath() external { + address signallingEscrow = address(_stateMachine.signallingEscrow); + Timestamp proposalSubmittedAt = Timestamps.now(); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + + // simulate the first threshold of veto signalling was reached + EscrowMock(signallingEscrow).__setRageQuitSupport( + _CONFIG_PROVIDER.FIRST_SEAL_RAGE_QUIT_SUPPORT() + PercentD16.wrap(1) + ); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MIN_DURATION().plusSeconds(1 minutes)); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignallingDeactivation); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + // simulate the second threshold of veto signalling was reached + EscrowMock(signallingEscrow).__setRageQuitSupport( + _CONFIG_PROVIDER.SECOND_SEAL_RAGE_QUIT_SUPPORT() + PercentsD16.fromBasisPoints(1_00) + ); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.RageQuit); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.RageQuit); + assertEq(_stateMachine.getEffectiveState(), State.RageQuit); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + + EscrowMock(address(_stateMachine.rageQuitEscrow)).__setIsRageQuitFinalized(true); + + assertEq(_stateMachine.getPersistedState(), State.RageQuit); + assertEq(_stateMachine.getEffectiveState(), State.VetoCooldown); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + // for proposals submitted at the same block the VetoSignalling started scheduling is allowed + assertTrue( + _stateMachine.canScheduleProposal({ + useEffectiveState: true, + proposalSubmittedAt: _stateMachine.vetoSignallingActivatedAt + }) + ); + // for proposals submitted after the VetoSignalling started scheduling is forbidden + assertFalse( + _stateMachine.canScheduleProposal({ + useEffectiveState: true, + proposalSubmittedAt: Durations.from(1 seconds).addTo(_stateMachine.vetoSignallingActivatedAt) + }) + ); + assertFalse(_stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: Timestamps.now()})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoCooldown); + assertEq(_stateMachine.getEffectiveState(), State.VetoCooldown); + + // persisted + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue( + _stateMachine.canScheduleProposal({ + useEffectiveState: false, + proposalSubmittedAt: _stateMachine.vetoSignallingActivatedAt + }) + ); + // for proposals submitted after the VetoSignalling started scheduling is forbidden + assertFalse( + _stateMachine.canScheduleProposal({ + useEffectiveState: false, + proposalSubmittedAt: Durations.from(1 seconds).addTo(_stateMachine.vetoSignallingActivatedAt) + }) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: Timestamps.now()}) + ); + + // effective + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + // for proposals submitted at the same block the VetoSignalling started scheduling is allowed + assertTrue( + _stateMachine.canScheduleProposal({ + useEffectiveState: true, + proposalSubmittedAt: _stateMachine.vetoSignallingActivatedAt + }) + ); + // for proposals submitted after the VetoSignalling started scheduling is forbidden + assertFalse( + _stateMachine.canScheduleProposal({ + useEffectiveState: true, + proposalSubmittedAt: Durations.from(1 seconds).addTo(_stateMachine.vetoSignallingActivatedAt) + }) + ); + assertFalse(_stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: Timestamps.now()})); + + _wait(_CONFIG_PROVIDER.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_stateMachine.getPersistedState(), State.VetoCooldown); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + + // persisted + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue( + _stateMachine.canScheduleProposal({ + useEffectiveState: false, + proposalSubmittedAt: _stateMachine.vetoSignallingActivatedAt + }) + ); + // for proposals submitted after the VetoSignalling started scheduling is forbidden + assertFalse( + _stateMachine.canScheduleProposal({ + useEffectiveState: false, + proposalSubmittedAt: Durations.from(1 seconds).addTo(_stateMachine.vetoSignallingActivatedAt) + }) + ); + assertFalse( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: Timestamps.now()}) + ); + + // effective + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue(_stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: Timestamps.now()})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + + // persisted + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue(_stateMachine.canScheduleProposal({useEffectiveState: false, proposalSubmittedAt: Timestamps.now()})); + + // effective + assertTrue( + _stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: proposalSubmittedAt}) + ); + assertTrue(_stateMachine.canScheduleProposal({useEffectiveState: true, proposalSubmittedAt: Timestamps.now()})); + } + + // --- + // canCancelAllPendingProposals() + // --- + + function test_canCancelAllPendingProposals_HappyPath() external { + address signallingEscrow = address(_stateMachine.signallingEscrow); + Timestamp proposalSubmittedAt = Timestamps.now(); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + + // simulate the first threshold of veto signalling was reached + EscrowMock(signallingEscrow).__setRageQuitSupport( + _CONFIG_PROVIDER.FIRST_SEAL_RAGE_QUIT_SUPPORT() + PercentD16.wrap(1) + ); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MIN_DURATION().plusSeconds(1 minutes)); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignallingDeactivation); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignallingDeactivation); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + // simulate the second threshold of veto signalling was reached + EscrowMock(signallingEscrow).__setRageQuitSupport( + _CONFIG_PROVIDER.SECOND_SEAL_RAGE_QUIT_SUPPORT() + PercentsD16.fromBasisPoints(1_00) + ); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignallingDeactivation); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.VetoSignalling); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _wait(_CONFIG_PROVIDER.VETO_SIGNALLING_MAX_DURATION().plusSeconds(1)); + + assertEq(_stateMachine.getPersistedState(), State.VetoSignalling); + assertEq(_stateMachine.getEffectiveState(), State.RageQuit); + assertTrue(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.RageQuit); + assertEq(_stateMachine.getEffectiveState(), State.RageQuit); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + EscrowMock(address(_stateMachine.rageQuitEscrow)).__setIsRageQuitFinalized(true); + + assertEq(_stateMachine.getPersistedState(), State.RageQuit); + assertEq(_stateMachine.getEffectiveState(), State.VetoCooldown); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.VetoCooldown); + assertEq(_stateMachine.getEffectiveState(), State.VetoCooldown); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _wait(_CONFIG_PROVIDER.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + assertEq(_stateMachine.getPersistedState(), State.VetoCooldown); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + + _stateMachine.activateNextState(_ESCROW_MASTER_COPY); + + assertEq(_stateMachine.getPersistedState(), State.Normal); + assertEq(_stateMachine.getEffectiveState(), State.Normal); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: false})); + assertFalse(_stateMachine.canCancelAllPendingProposals({useEffectiveState: true})); + } + + // --- + // Test helper methods + // --- + + function external__initialize() external { + _stateMachine.initialize(_CONFIG_PROVIDER, _ESCROW_MASTER_COPY); + } } diff --git a/test/unit/libraries/DualGovernanceStateTransitions.t.sol b/test/unit/libraries/DualGovernanceStateTransitions.t.sol new file mode 100644 index 00000000..a374c919 --- /dev/null +++ b/test/unit/libraries/DualGovernanceStateTransitions.t.sol @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration, Durations} from "contracts/types/Duration.sol"; +import {Timestamp, Timestamps} from "contracts/types/Timestamp.sol"; +import {PercentD16, PercentsD16} from "contracts/types/PercentD16.sol"; + +import {IEscrow} from "contracts/interfaces/IEscrow.sol"; + +import { + State, + DualGovernanceStateMachine, + DualGovernanceStateTransitions +} from "contracts/libraries/DualGovernanceStateMachine.sol"; +import { + DualGovernanceConfig, + ImmutableDualGovernanceConfigProvider +} from "contracts/ImmutableDualGovernanceConfigProvider.sol"; + +import {stdError} from "forge-std/StdError.sol"; + +import {UnitTest} from "test/utils/unit-test.sol"; + +contract DualGovernanceStateTransitionsUnitTestSuite is UnitTest { + using DualGovernanceConfig for DualGovernanceConfig.Context; + using DualGovernanceStateTransitions for DualGovernanceStateMachine.Context; + + DualGovernanceStateMachine.Context internal _stateMachine; + ImmutableDualGovernanceConfigProvider internal _configProvider; + address internal _escrowMasterCopyMock = makeAddr("ESCROW_MOCK"); + + function setUp() external { + _configProvider = new ImmutableDualGovernanceConfigProvider( + DualGovernanceConfig.Context({ + firstSealRageQuitSupport: PercentsD16.fromBasisPoints(3_00), // 3% + secondSealRageQuitSupport: PercentsD16.fromBasisPoints(15_00), // 15% + // + minAssetsLockDuration: Durations.from(5 hours), + // + vetoSignallingMinDuration: Durations.from(3 days), + vetoSignallingMaxDuration: Durations.from(30 days), + vetoSignallingMinActiveDuration: Durations.from(5 hours), + vetoSignallingDeactivationMaxDuration: Durations.from(5 days), + // + vetoCooldownDuration: Durations.from(4 days), + // + rageQuitExtensionPeriodDuration: Durations.from(7 days), + rageQuitEthWithdrawalsMinDelay: Durations.from(30 days), + rageQuitEthWithdrawalsMaxDelay: Durations.from(180 days), + rageQuitEthWithdrawalsDelayGrowth: Durations.from(15 days) + }) + ); + DualGovernanceStateMachine.initialize(_stateMachine, _configProvider, IEscrow(_escrowMasterCopyMock)); + _setMockRageQuitSupportInBP(0); + } + + // --- + // getStateTransition() + // --- + + // --- + // Normal -> Normal + // --- + + function test_getStateTransition_FromNormalToNormal() external { + assertEq(_stateMachine.state, State.Normal); + + _setMockRageQuitSupportInBP(3_00); + + (State current, State next) = _stateMachine.getStateTransition(_configProvider.getDualGovernanceConfig()); + + assertEq(current, State.Normal); + assertEq(next, State.Normal); + } + + // --- + // Normal -> VetoSignalling + // --- + + function test_getStateTransition_FromNormalToVetoSignalling() external { + assertEq(_stateMachine.state, State.Normal); + + _setMockRageQuitSupportInBP(3_01); + + (State current, State next) = _stateMachine.getStateTransition(_configProvider.getDualGovernanceConfig()); + + assertEq(current, State.Normal); + assertEq(next, State.VetoSignalling); + } + + // --- + // VetoSignalling -> VetoSignalling (veto signalling still in progress) + // --- + + function test_getStateTransition_FromVetoSignallingToVetoSignalling_VetoSignallingDurationNotPassed() external { + _setupVetoSignallingState(); + _setMockRageQuitSupportInBP(3_01); + + (State current, State next) = _stateMachine.getStateTransition(_configProvider.getDualGovernanceConfig()); + + assertEq(current, State.VetoSignalling); + assertEq(next, State.VetoSignalling); + } + + // --- + // VetoSignalling -> VetoSignalling (min veto signalling duration not passed) + // --- + + function test_getStateTransition_FromVetoSignallingToVetoSignalling_VetoSignallingReactivationNotPassed() + external + { + _setMockRageQuitSupportInBP(3_01); + + // the veto signalling state was entered + _setupVetoSignallingState(); + + // wait until the duration of the veto signalling is over + _wait(_calcVetoSignallingDuration().plusSeconds(1)); + + // when the duration is over the VetoSignallingDeactivation state must be entered + _assertStateMachineTransition({from: State.VetoSignalling, to: State.VetoSignallingDeactivation}); + + // simulate the reactivation of the VetoSignallingState + _stateMachine.vetoSignallingReactivationTime = Timestamps.now(); + + // while the min veto signalling active duration hasn't passed the VetoSignalling can't be exited + _wait(_configProvider.VETO_SIGNALLING_MIN_ACTIVE_DURATION().dividedBy(2)); + + _assertStateMachineTransition({from: State.VetoSignalling, to: State.VetoSignalling}); + + // but when the duration has passed, the next state should be VetoSignallingDeactivation + _wait(_configProvider.VETO_SIGNALLING_MIN_ACTIVE_DURATION().dividedBy(2).plusSeconds(1)); + + _assertStateMachineTransition({from: State.VetoSignalling, to: State.VetoSignallingDeactivation}); + } + + // --- + // VetoSignalling -> RageQuit + // --- + + function test_getStateTransition_FromVetoSignallingToRageQuit() external { + _setMockRageQuitSupportInBP(15_01); + + // the veto signalling state was entered + _setupVetoSignallingState(); + + // while the full duration of the veto signalling hasn't passed the state machine stays in the VetoSignalling state + _wait(_calcVetoSignallingDuration().dividedBy(2)); + + _assertStateMachineTransition({from: State.VetoSignalling, to: State.VetoSignalling}); + + // when the full duration has passed the state machine should transition to the Rage Quit + _wait(_calcVetoSignallingDuration().dividedBy(2).plusSeconds(1)); + + _assertStateMachineTransition({from: State.VetoSignalling, to: State.RageQuit}); + } + + // --- + // VetoSignallingDeactivation -> VetoSignalling + // --- + + function test_getStateTransition_FromVetoSignallingDeactivationToVetoSignalling() external { + _setMockRageQuitSupportInBP(3_01); + _setupVetoSignallingDeactivationState(); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoSignallingDeactivation}); + + _setMockRageQuitSupportInBP(15_01); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoSignalling}); + } + + // --- + // VetoSignallingDeactivation -> RageQuit + // --- + + function test_getStateTransition_FromVetoSignallingDeactivationToRageQuit() external { + _setMockRageQuitSupportInBP(3_01); + _setupVetoSignallingDeactivationState(); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoSignallingDeactivation}); + + _setMockRageQuitSupportInBP(15_01); + + _wait(_calcVetoSignallingDuration().plusSeconds(1 seconds)); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.RageQuit}); + } + + // --- + // VetoSignallingDeactivation -> VetoCooldown + // --- + + function test_getStateTransition_FromVetoSignallingDeactivationToVetoCooldown() external { + _setMockRageQuitSupportInBP(3_01); + _setupVetoSignallingDeactivationState(); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoSignallingDeactivation}); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoCooldown}); + } + + // --- + // VetoSignallingDeactivation -> VetoSignallingDeactivation + // --- + + function test_getStateTransition_FromVetoSignallingDeactivationToVetoSignallingDeactivation() external { + _setMockRageQuitSupportInBP(3_01); + _setupVetoSignallingDeactivationState(); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoSignallingDeactivation}); + + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION()); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoSignallingDeactivation}); + + _wait(Durations.from(1 seconds)); + + _assertStateMachineTransition({from: State.VetoSignallingDeactivation, to: State.VetoCooldown}); + } + + // --- + // VetoCooldown -> VetoCooldown + // --- + + function test_getStateTransition_FromVetoCooldownToVetoCooldown() external { + _setupVetoCooldownState(); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.VetoCooldown}); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().dividedBy(2)); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.VetoCooldown}); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().dividedBy(2).plusSeconds(1)); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.Normal}); + } + + // --- + // VetoCooldown -> VetoSignalling + // --- + + function test_getStateTransition_FromVetoCooldownToVetoSignalling() external { + _setupVetoCooldownState(); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.VetoCooldown}); + + _setMockRageQuitSupportInBP(3_01); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.VetoSignalling}); + } + + // --- + // VetoCooldown -> Normal + // --- + + function test_getStateTransition_FromVetoCooldownToNormal() external { + _setupVetoCooldownState(); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.VetoCooldown}); + + _wait(_configProvider.VETO_COOLDOWN_DURATION().plusSeconds(1)); + + _assertStateMachineTransition({from: State.VetoCooldown, to: State.Normal}); + } + + // --- + // RageQuit -> RageQuit + // --- + + function test_getStateTransition_FromRageQuitToRageQuit() external { + _setupRageQuitState(); + _setMockIsRageQuitFinalized(false); + + _assertStateMachineTransition({from: State.RageQuit, to: State.RageQuit}); + } + + // --- + // RageQuit -> VetoSignalling + // --- + + function test_getStateTransition_FromRageQuitToVetoSignalling() external { + _setupRageQuitState(); + _setMockIsRageQuitFinalized(false); + + _assertStateMachineTransition({from: State.RageQuit, to: State.RageQuit}); + + _setMockIsRageQuitFinalized(true); + _setMockRageQuitSupportInBP(3_01); + + _assertStateMachineTransition({from: State.RageQuit, to: State.VetoSignalling}); + } + + // --- + // RageQuit -> VetoCooldown + // --- + + function test_getStateTransition_FromRageQuitToVetoCooldown() external { + _setupRageQuitState(); + _setMockIsRageQuitFinalized(false); + + _assertStateMachineTransition({from: State.RageQuit, to: State.RageQuit}); + + _setMockIsRageQuitFinalized(true); + _setMockRageQuitSupportInBP(1_01); + + _assertStateMachineTransition({from: State.RageQuit, to: State.VetoCooldown}); + } + + // --- + // Unset -> assert(false) + // --- + + function test_getStateTransition_RevertOn_UnsetState() external { + _stateMachine.state = State.Unset; + + vm.expectRevert(stdError.assertionError); + this.external__getStateTransition(); + } + + // --- + // Helper test methods + // --- + + function _setupVetoSignallingState() internal { + _stateMachine.state = State.VetoSignalling; + _stateMachine.enteredAt = Timestamps.now(); + _stateMachine.vetoSignallingActivatedAt = Timestamps.now(); + } + + function _setupVetoSignallingDeactivationState() internal { + _setupVetoSignallingState(); + + _wait(_configProvider.VETO_SIGNALLING_MIN_DURATION().plusSeconds(1 hours)); + + _stateMachine.state = State.VetoSignallingDeactivation; + _stateMachine.enteredAt = Timestamps.now(); + } + + function _setupVetoCooldownState() internal { + _setupVetoSignallingDeactivationState(); + _wait(_configProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); + + _stateMachine.state = State.VetoCooldown; + _stateMachine.enteredAt = Timestamps.now(); + } + + function _setupRageQuitState() internal { + _stateMachine.state = State.RageQuit; + _stateMachine.enteredAt = Timestamps.now(); + _stateMachine.rageQuitEscrow = IEscrow(_escrowMasterCopyMock); + } + + function _setMockRageQuitSupportInBP(uint256 bpValue) internal { + vm.mockCall( + address(_stateMachine.signallingEscrow), + abi.encodeWithSelector(IEscrow.getRageQuitSupport.selector), + abi.encode(PercentsD16.fromBasisPoints(bpValue)) + ); + } + + function _setMockIsRageQuitFinalized(bool isRageQuitFinalized) internal { + vm.mockCall( + address(_stateMachine.rageQuitEscrow), + abi.encodeWithSelector(IEscrow.isRageQuitFinalized.selector), + abi.encode(isRageQuitFinalized) + ); + } + + function _calcVetoSignallingDuration() internal returns (Duration) { + return _configProvider.getDualGovernanceConfig().calcVetoSignallingDuration( + _stateMachine.signallingEscrow.getRageQuitSupport() + ); + } + + function _assertStateMachineTransition(State from, State to) internal { + (State current, State next) = _stateMachine.getStateTransition(_configProvider.getDualGovernanceConfig()); + + assertEq(current, from); + assertEq(next, to); + } + + function external__getStateTransition() external returns (State current, State next) { + (current, next) = _stateMachine.getStateTransition(_configProvider.getDualGovernanceConfig()); + } +} diff --git a/test/unit/libraries/Tiebreaker.t.sol b/test/unit/libraries/Tiebreaker.t.sol index 0a306841..f51fe839 100644 --- a/test/unit/libraries/Tiebreaker.t.sol +++ b/test/unit/libraries/Tiebreaker.t.sol @@ -8,6 +8,7 @@ import {Tiebreaker} from "contracts/libraries/Tiebreaker.sol"; import {Duration, Durations, Timestamp, Timestamps} from "contracts/types/Duration.sol"; import {ISealable} from "contracts/interfaces/ISealable.sol"; import {ITiebreaker} from "contracts/interfaces/ITiebreaker.sol"; +import {IDualGovernance} from "contracts/interfaces/IDualGovernance.sol"; import {UnitTest} from "test/utils/unit-test.sol"; import {SealableMock} from "../../mocks/SealableMock.sol"; @@ -190,8 +191,45 @@ contract TiebreakerTest is UnitTest { context.tiebreakerActivationTimeout = timeout; context.tiebreakerCommittee = address(0x123); - ITiebreaker.TiebreakerDetails memory details = - Tiebreaker.getTiebreakerDetails(context, DualGovernanceState.Normal, Timestamps.from(block.timestamp)); + IDualGovernance.StateDetails memory stateDetails; + stateDetails.persistedState = DualGovernanceState.Normal; + stateDetails.effectiveState = DualGovernanceState.VetoSignalling; + stateDetails.normalOrVetoCooldownExitedAt = Timestamps.now(); + + ITiebreaker.TiebreakerDetails memory details = Tiebreaker.getTiebreakerDetails(context, stateDetails); + + assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); + assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); + assertEq(details.sealableWithdrawalBlockers[0], address(mockSealable1)); + assertEq(details.sealableWithdrawalBlockers.length, 1); + assertEq(details.isTie, false); + } + + function test_getTiebreakerDetails_HappyPath_PendingTransitionFromVetoCooldown_ExpectIsTieFalse() external { + Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1); + + Duration timeout = Duration.wrap(5 days); + + context.tiebreakerActivationTimeout = timeout; + context.tiebreakerCommittee = address(0x123); + + IDualGovernance.StateDetails memory stateDetails; + + stateDetails.persistedState = DualGovernanceState.VetoCooldown; + stateDetails.effectiveState = DualGovernanceState.VetoSignalling; + stateDetails.normalOrVetoCooldownExitedAt = Timestamps.now(); + + ITiebreaker.TiebreakerDetails memory details = Tiebreaker.getTiebreakerDetails(context, stateDetails); + + assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); + assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); + assertEq(details.sealableWithdrawalBlockers[0], address(mockSealable1)); + assertEq(details.sealableWithdrawalBlockers.length, 1); + assertEq(details.isTie, false); + + _wait(timeout); + + details = Tiebreaker.getTiebreakerDetails(context, stateDetails); assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); @@ -200,6 +238,72 @@ contract TiebreakerTest is UnitTest { assertEq(details.isTie, false); } + function test_getTiebreakerDetails_HappyPath_PendingTransitionFromNormal_ExpectIsTieFalse() external { + Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1); + + Duration timeout = Duration.wrap(5 days); + + context.tiebreakerActivationTimeout = timeout; + context.tiebreakerCommittee = address(0x123); + + IDualGovernance.StateDetails memory stateDetails; + + stateDetails.persistedState = DualGovernanceState.Normal; + stateDetails.effectiveState = DualGovernanceState.VetoSignalling; + stateDetails.normalOrVetoCooldownExitedAt = Timestamps.now(); + + ITiebreaker.TiebreakerDetails memory details = Tiebreaker.getTiebreakerDetails(context, stateDetails); + + assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); + assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); + assertEq(details.sealableWithdrawalBlockers[0], address(mockSealable1)); + assertEq(details.sealableWithdrawalBlockers.length, 1); + assertEq(details.isTie, false); + + _wait(timeout); + + details = Tiebreaker.getTiebreakerDetails(context, stateDetails); + + assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); + assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); + assertEq(details.sealableWithdrawalBlockers[0], address(mockSealable1)); + assertEq(details.sealableWithdrawalBlockers.length, 1); + assertEq(details.isTie, false); + } + + function test_getTiebreakerDetails_HappyPath_PendingTransitionFromVetoSignalling_ExpectIsTieTrue() external { + Tiebreaker.addSealableWithdrawalBlocker(context, address(mockSealable1), 1); + + Duration timeout = Duration.wrap(5 days); + + context.tiebreakerActivationTimeout = timeout; + context.tiebreakerCommittee = address(0x123); + + IDualGovernance.StateDetails memory stateDetails; + + stateDetails.persistedState = DualGovernanceState.VetoSignalling; + stateDetails.effectiveState = DualGovernanceState.RageQuit; + stateDetails.normalOrVetoCooldownExitedAt = Timestamps.now(); + + ITiebreaker.TiebreakerDetails memory details = Tiebreaker.getTiebreakerDetails(context, stateDetails); + + assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); + assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); + assertEq(details.sealableWithdrawalBlockers[0], address(mockSealable1)); + assertEq(details.sealableWithdrawalBlockers.length, 1); + assertEq(details.isTie, false); + + _wait(timeout); + + details = Tiebreaker.getTiebreakerDetails(context, stateDetails); + + assertEq(details.tiebreakerCommittee, context.tiebreakerCommittee); + assertEq(details.tiebreakerActivationTimeout, context.tiebreakerActivationTimeout); + assertEq(details.sealableWithdrawalBlockers[0], address(mockSealable1)); + assertEq(details.sealableWithdrawalBlockers.length, 1); + assertEq(details.isTie, true); + } + function external__checkCallerIsTiebreakerCommittee() external view { Tiebreaker.checkCallerIsTiebreakerCommittee(context); } diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index 407c154e..66fb2fac 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -95,9 +95,9 @@ contract ScenarioTestBlueprint is TestingAssertEqExtender, SetupDeployment { returns (bool isActive, uint256 duration, uint256 activatedAt, uint256 enteredAt) { IDualGovernance.StateDetails memory stateContext = _dualGovernance.getStateDetails(); - isActive = stateContext.state == DGState.VetoSignalling; + isActive = stateContext.persistedState == DGState.VetoSignalling; duration = _dualGovernance.getStateDetails().vetoSignallingDuration.toSeconds(); - enteredAt = stateContext.enteredAt.toSeconds(); + enteredAt = stateContext.persistedStateEnteredAt.toSeconds(); activatedAt = stateContext.vetoSignallingActivatedAt.toSeconds(); } @@ -107,9 +107,9 @@ contract ScenarioTestBlueprint is TestingAssertEqExtender, SetupDeployment { returns (bool isActive, uint256 duration, uint256 enteredAt) { IDualGovernance.StateDetails memory stateContext = _dualGovernance.getStateDetails(); - isActive = stateContext.state == DGState.VetoSignallingDeactivation; + isActive = stateContext.persistedState == DGState.VetoSignallingDeactivation; duration = _dualGovernanceConfigProvider.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().toSeconds(); - enteredAt = stateContext.enteredAt.toSeconds(); + enteredAt = stateContext.persistedStateEnteredAt.toSeconds(); } // --- @@ -182,7 +182,7 @@ contract ScenarioTestBlueprint is TestingAssertEqExtender, SetupDeployment { } function _lockWstETH(address vetoer, PercentD16 tvlPercentage) internal { - _lockStETH(vetoer, _lido.calcSharesFromPercentageOfTVL(tvlPercentage)); + _lockWstETH(vetoer, _lido.calcSharesFromPercentageOfTVL(tvlPercentage)); } function _lockWstETH(address vetoer, uint256 amount) internal { @@ -449,23 +449,23 @@ contract ScenarioTestBlueprint is TestingAssertEqExtender, SetupDeployment { } function _assertNormalState() internal { - assertEq(_dualGovernance.getState(), DGState.Normal); + assertEq(_dualGovernance.getPersistedState(), DGState.Normal); } function _assertVetoSignalingState() internal { - assertEq(_dualGovernance.getState(), DGState.VetoSignalling); + assertEq(_dualGovernance.getPersistedState(), DGState.VetoSignalling); } function _assertVetoSignalingDeactivationState() internal { - assertEq(_dualGovernance.getState(), DGState.VetoSignallingDeactivation); + assertEq(_dualGovernance.getPersistedState(), DGState.VetoSignallingDeactivation); } function _assertRageQuitState() internal { - assertEq(_dualGovernance.getState(), DGState.RageQuit); + assertEq(_dualGovernance.getPersistedState(), DGState.RageQuit); } function _assertVetoCooldownState() internal { - assertEq(_dualGovernance.getState(), DGState.VetoCooldown); + assertEq(_dualGovernance.getPersistedState(), DGState.VetoCooldown); } function _assertNoTargetMockCalls() internal {