diff --git a/.gitignore b/.gitignore index 8c09d4d1..ece301e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ out/ !/broadcast /broadcast/*/31337/ /broadcast/**/dry-run/ + +#Certora +.certora_internal/ .vscode/ diff --git a/certora/README.md b/certora/README.md new file mode 100644 index 00000000..400c0d71 --- /dev/null +++ b/certora/README.md @@ -0,0 +1,25 @@ +# Overview and Directory Structure +This directory contains formal verification specifications for the Certora +Prover and written in the Certora Verification Language (CVL). The subdirectory +contents are as follows: + * confs -- contains configuration files to run the verification jobs + * harness -- contains test harnesses to help with verification, and mock + versions of ERC20 contracts that are relevant to but not part of this solidity project + * mutation -- contains mutation tests that we used to gain further assurance + about our specifications + * helpers -- contains a mock WithdrawalQueue and two simple contracts that inherit from Escrow + to alow us to model multiple distinct Escrow addresses +* specs -- contains our formal verification specifications + +# Run instructions +Ensure you have installed the Certora Prover. These specifications were tested with +`certora-cli 7.17.2`. Launch each of the verification jobs from the root directory of the project with +`certoraRun certora/confs/DualGovernance.conf` +`certoraRun certora/confs/EmergencyProtectedTimelock.conf` +`certoraRun certora/confs/Escrow.conf` +`certoraRun certora/confs/Escrow_solvency.conf` +`certoraRun certora/confs/Escrow_validState.conf` + +One of the rules in Escrow_solvency.conf `solvency_ETH` can have performance issues resulting in +a timeout. As a workaround, it can be run separately by running +`certoraRun certora/confs/Escrow_solvency.conf --rule solvency_ETH` in which case it should pass. \ No newline at end of file diff --git a/certora/confs/DualGovernance.conf b/certora/confs/DualGovernance.conf new file mode 100644 index 00000000..b5ba435c --- /dev/null +++ b/certora/confs/DualGovernance.conf @@ -0,0 +1,50 @@ +{ + "files": [ + "contracts/libraries/DualGovernanceStateMachine.sol", + "contracts/Executor.sol", + "contracts/EmergencyProtectedTimelock.sol", + "contracts/ResealManager.sol", + "certora/helpers/EscrowA.sol", + "certora/helpers/EscrowB.sol", + "contracts/DualGovernanceConfigProvider.sol:ImmutableDualGovernanceConfigProvider", + "certora/harnesses/ERC20Like/DummyStETH.sol", + "certora/harnesses/ERC20Like/DummyWstETH.sol", + "certora/harnesses/DualGovernanceHarness.sol", + ], + "link": [ + "DualGovernanceHarness:TIMELOCK=EmergencyProtectedTimelock", + "DualGovernanceHarness:_configProvider=ImmutableDualGovernanceConfigProvider", + "ResealManager:EMERGENCY_PROTECTED_TIMELOCK=EmergencyProtectedTimelock", + "DualGovernanceHarness:RESEAL_MANAGER=ResealManager", + "EscrowA:ST_ETH=DummyStETH", + "EscrowA:WST_ETH=DummyWstETH", + "EscrowA:DUAL_GOVERNANCE=DualGovernanceHarness", + "EscrowB:ST_ETH=DummyStETH", + "EscrowB:WST_ETH=DummyWstETH", + "EscrowB:DUAL_GOVERNANCE=DualGovernanceHarness" + ], + "struct_link": [ + "DualGovernanceHarness:resealManager=ResealManager", + "EmergencyProtectedTimelock:executor=Executor", + ], + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + "parametric_contracts": [ + "DualGovernanceHarness", + // "EmergencyProtectedTimelock", + // "ResealManager", + // "Escrow", + // // Not sure these are needed + // "DummyStETH", + // "DummyWstETH", + ], + "rule_sanity": "basic", + "process": "emv", + "solc": "solc8.26", + "optimistic_loop": true, + "loop_iter": "5", + "smt_timeout": "3600", + "build_cache": true, + "verify": "DualGovernanceHarness:certora/specs/DualGovernance.spec" +} \ No newline at end of file diff --git a/certora/confs/EmergencyProtectedTimelock.conf b/certora/confs/EmergencyProtectedTimelock.conf new file mode 100644 index 00000000..30471d7d --- /dev/null +++ b/certora/confs/EmergencyProtectedTimelock.conf @@ -0,0 +1,24 @@ +{ + "files": [ + "contracts/EmergencyProtectedTimelock.sol", + "contracts/Executor.sol", + "contracts/libraries/ExecutableProposals.sol", + "contracts/libraries/EmergencyProtection.sol", + "contracts/types/Timestamp.sol:Timestamps", + "contracts/types/Duration.sol:Durations" + ], + "struct_link": [ + "EmergencyProtectedTimelock:executor=Executor", + ], + "msg": "Emergency Protected Timelock", + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + "process": "emv", + "solc": "solc8.26", + "optimistic_loop": true, + "solc_via_ir": false, + "verify": "EmergencyProtectedTimelock:certora/specs/Timelock.spec", + "rule_sanity": "basic", + "server": "production" +} \ No newline at end of file diff --git a/certora/confs/Escrow.conf b/certora/confs/Escrow.conf new file mode 100644 index 00000000..588a28e9 --- /dev/null +++ b/certora/confs/Escrow.conf @@ -0,0 +1,32 @@ +{ + "files": [ + "contracts/Escrow.sol", + "contracts/DualGovernance.sol", + "contracts/DualGovernanceConfigProvider.sol:ImmutableDualGovernanceConfigProvider", + "certora/helpers/DummyWithdrawalQueue.sol", + "certora/harnesses/ERC20Like/DummyStETH.sol", + "certora/harnesses/ERC20Like/DummyWstETH.sol", + ], + "link": [ + "Escrow:DUAL_GOVERNANCE=DualGovernance", + "Escrow:WITHDRAWAL_QUEUE=DummyWithdrawalQueue", + "Escrow:ST_ETH=DummyStETH", + "Escrow:WST_ETH=DummyWstETH", + "DummyWstETH:stETH=DummyStETH", + "DummyWithdrawalQueue:stETH=DummyStETH", + "DualGovernance:_configProvider=ImmutableDualGovernanceConfigProvider", + ], + + "msg": "sanity", + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + + "solc": "solc8.26", + "optimistic_loop": true, + "optimistic_fallback": true, + "loop_iter": "3", + "build_cache" : true, + "rule_sanity" : "basic", + "verify": "Escrow:certora/specs/Escrow.spec" +} \ No newline at end of file diff --git a/certora/confs/Escrow_solvency.conf b/certora/confs/Escrow_solvency.conf new file mode 100644 index 00000000..f9acfa34 --- /dev/null +++ b/certora/confs/Escrow_solvency.conf @@ -0,0 +1,32 @@ +{ + "files": [ + "contracts/Escrow.sol", + "contracts/DualGovernance.sol", + "contracts/DualGovernanceConfigProvider.sol:ImmutableDualGovernanceConfigProvider", + "certora/helpers/DummyWithdrawalQueue.sol", + "certora/harnesses/ERC20Like/DummyStETH.sol", + "certora/harnesses/ERC20Like/DummyWstETH.sol", + ], + "link": [ + "Escrow:DUAL_GOVERNANCE=DualGovernance", + "Escrow:WITHDRAWAL_QUEUE=DummyWithdrawalQueue", + "Escrow:ST_ETH=DummyStETH", + "Escrow:WST_ETH=DummyWstETH", + "DummyWstETH:stETH=DummyStETH", + "DummyWithdrawalQueue:stETH=DummyStETH", + "DualGovernance:_configProvider=ImmutableDualGovernanceConfigProvider", + ], + + "msg": "sanity", + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + + "solc": "solc8.26", + "optimistic_loop": true, + "optimistic_fallback": true, + "loop_iter": "3", + "build_cache" : true, + "rule_sanity" : "basic", + "verify": "Escrow:certora/specs/Escrow_solvency.spec" +} \ No newline at end of file diff --git a/certora/confs/Escrow_validState.conf b/certora/confs/Escrow_validState.conf new file mode 100644 index 00000000..2132cfeb --- /dev/null +++ b/certora/confs/Escrow_validState.conf @@ -0,0 +1,32 @@ +{ + "files": [ + "contracts/Escrow.sol", + "contracts/DualGovernance.sol", + "contracts/DualGovernanceConfigProvider.sol:ImmutableDualGovernanceConfigProvider", + "certora/helpers/DummyWithdrawalQueue.sol", + "certora/harnesses/ERC20Like/DummyStETH.sol", + "certora/harnesses/ERC20Like/DummyWstETH.sol", + ], + "link": [ + "Escrow:DUAL_GOVERNANCE=DualGovernance", + "Escrow:WITHDRAWAL_QUEUE=DummyWithdrawalQueue", + "Escrow:ST_ETH=DummyStETH", + "Escrow:WST_ETH=DummyWstETH", + "DummyWstETH:stETH=DummyStETH", + "DummyWithdrawalQueue:stETH=DummyStETH", + "DualGovernance:_configProvider=ImmutableDualGovernanceConfigProvider", + ], + + "msg": "Escrow_validState", + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + + "solc": "solc8.26", + "optimistic_loop": true, + "optimistic_fallback": true, + "loop_iter": "3", + "build_cache" : true, + "rule_sanity" : "basic", + "verify": "Escrow:certora/specs/Escrow_validState.spec" +} \ No newline at end of file diff --git a/certora/harnesses/DualGovernanceHarness.sol b/certora/harnesses/DualGovernanceHarness.sol new file mode 100644 index 00000000..84496cbb --- /dev/null +++ b/certora/harnesses/DualGovernanceHarness.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "../../contracts/libraries/Proposers.sol"; +import "../../contracts/DualGovernance.sol"; +import {Status as ProposalStatus} from "../../contracts/libraries/ExecutableProposals.sol"; +import {Proposal} from "../../contracts/libraries/EnumerableProposals.sol"; +// This is to make a type available for a NONDET summary +import {IExternalExecutor} from "../../contracts/interfaces/IExternalExecutor.sol"; +import {State, DualGovernanceStateMachine} from "../../contracts/libraries/DualGovernanceStateMachine.sol"; + +// The following are for methods about checking if max durations have passed +import {DualGovernanceConfig} from "../../contracts/libraries/DualGovernanceConfig.sol"; +import {PercentD16} from "../../contracts/types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../../contracts/types/Timestamp.sol"; + +contract DualGovernanceHarness is DualGovernance { + using Proposers for Proposers.Context; + using Proposers for Proposers.Proposer; + using Tiebreaker for Tiebreaker.Context; + using DualGovernanceStateMachine for DualGovernanceStateMachine.Context; + using DualGovernanceConfig for DualGovernanceConfig.Context; + + // Needed because DualGovernanceStateMachine.State is not + // referrable without redeclaring this here. + enum DGHarnessState { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit + } + + constructor( + ExternalDependencies memory dependencies, + SanityCheckParams memory sanityCheckParams + ) DualGovernance(dependencies, sanityCheckParams) {} + + // Return is uint32 which is the same as IndexOneBased + function getProposerIndexFromExecutor(address proposer) external view returns (uint32) { + return IndexOneBased.unwrap(_proposers.executors[proposer].proposerIndex); + } + + function getProposalInfoHarnessed(uint256 proposalId) + external + view + returns (uint256 id, ProposalStatus status, address executor, Timestamp submittedAt, Timestamp scheduledAt) + { + return TIMELOCK.getProposalInfo(proposalId); + } + + function getProposalHarnessed(uint256 proposalId) external view returns (ITimelock.Proposal memory proposal) { + return TIMELOCK.getProposal(proposalId); + } + + function getVetoSignallingActivatedAt() external view returns (Timestamp) { + return _stateMachine.vetoSignallingActivatedAt; + } + + function asDGHarnessState(State state) public returns (DGHarnessState) { + uint256 state_underlying = uint256(state); + return DGHarnessState(state_underlying); + } + + function getState() external returns (DGHarnessState) { + return asDGHarnessState(_stateMachine.state); + } + + function getFirstSeal() external view returns (uint256) { + return PercentD16.unwrap(_configProvider.getDualGovernanceConfig().firstSealRageQuitSupport); + } + + function getSecondSeal() external view returns (uint256) { + return PercentD16.unwrap(_configProvider.getDualGovernanceConfig().secondSealRageQuitSupport); + } + + function getFirstSealRageQuitSupportCrossed() external view returns (bool) { + return _configProvider.getDualGovernanceConfig().isFirstSealRageQuitSupportCrossed( + _stateMachine.signallingEscrow.getRageQuitSupport() + ); + } + + function getSecondSealRageQuitSupportCrossed() external view returns (bool) { + return _configProvider.getDualGovernanceConfig().isSecondSealRageQuitSupportCrossed( + _stateMachine.signallingEscrow.getRageQuitSupport() + ); + } + + function getRageQuitSupportHarnessed() external view returns (PercentD16) { + return _stateMachine.signallingEscrow.getRageQuitSupport(); + } + + function isDynamicTimelockPassed(uint256 rageQuitSupport) public returns (bool) { + return _configProvider.getDualGovernanceConfig().isDynamicTimelockDurationPassed( + _stateMachine.vetoSignallingActivatedAt, PercentD16.wrap(rageQuitSupport) + ); + } + + function isVetoSignallingReactivationPassed() public returns (bool) { + return _configProvider.getDualGovernanceConfig().isVetoSignallingReactivationDurationPassed( + Timestamps.max(_stateMachine.vetoSignallingReactivationTime, _stateMachine.vetoSignallingActivatedAt) + ); + } + + function isVetoSignallingDeactivationPassed() public returns (bool) { + return _configProvider.getDualGovernanceConfig().isVetoSignallingDeactivationMaxDurationPassed( + _stateMachine.enteredAt + ); + } + + function isVetoSignallingDeactivationMaxDurationPassed() public returns (bool) { + return _configProvider.getDualGovernanceConfig().isVetoSignallingDeactivationMaxDurationPassed( + _stateMachine.enteredAt + ); + } + + function isVetoCooldownDurationPassed() public returns (bool) { + return _configProvider.getDualGovernanceConfig().isVetoCooldownDurationPassed(_stateMachine.enteredAt); + } + + function isUnset(DGHarnessState state) public returns (bool) { + return state == DGHarnessState.Unset; + } + + function isNormal(DGHarnessState state) public returns (bool) { + return state == DGHarnessState.Normal; + } + + function isVetoSignalling(DGHarnessState state) public returns (bool) { + return state == DGHarnessState.VetoSignalling; + } + + function isVetoSignallingDeactivation(DGHarnessState state) public returns (bool) { + return state == DGHarnessState.VetoSignallingDeactivation; + } + + function isVetoCooldown(DGHarnessState state) public returns (bool) { + return state == DGHarnessState.VetoCooldown; + } + + function isRageQuit(DGHarnessState state) public returns (bool) { + return state == DGHarnessState.RageQuit; + } +} diff --git a/certora/harnesses/ERC20Like/DummyERC20A.sol b/certora/harnesses/ERC20Like/DummyERC20A.sol new file mode 100644 index 00000000..679c2274 --- /dev/null +++ b/certora/harnesses/ERC20Like/DummyERC20A.sol @@ -0,0 +1,51 @@ +// Represents a symbolic/dummy ERC20 token + +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +contract DummyERC20A { + uint256 t; + mapping(address => uint256) b; + mapping(address => mapping(address => uint256)) a; + + string public name; + string public symbol; + uint256 public decimals; + + function myAddress() external view returns (address) { + return address(this); + } + + function totalSupply() external view returns (uint256) { + return t; + } + + function balanceOf(address account) external view returns (uint256) { + return b[account]; + } + + function transfer(address recipient, uint256 amount) external returns (bool) { + b[msg.sender] -= amount; + b[recipient] += amount; + + return true; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return a[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + a[msg.sender][spender] = amount; + + return true; + } + + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { + b[sender] -= amount; + b[recipient] += amount; + a[sender][msg.sender] -= amount; + + return true; + } +} diff --git a/certora/harnesses/ERC20Like/DummyERC20B.sol b/certora/harnesses/ERC20Like/DummyERC20B.sol new file mode 100644 index 00000000..7105667f --- /dev/null +++ b/certora/harnesses/ERC20Like/DummyERC20B.sol @@ -0,0 +1,51 @@ +// Represents a symbolic/dummy ERC20 token + +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +contract DummyERC20B { + uint256 t; + mapping(address => uint256) b; + mapping(address => mapping(address => uint256)) a; + + string public name; + string public symbol; + uint256 public decimals; + + function myAddress() external view returns (address) { + return address(this); + } + + function totalSupply() external view returns (uint256) { + return t; + } + + function balanceOf(address account) external view returns (uint256) { + return b[account]; + } + + function transfer(address recipient, uint256 amount) external returns (bool) { + b[msg.sender] -= amount; + b[recipient] += amount; + + return true; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return a[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + a[msg.sender][spender] = amount; + + return true; + } + + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { + b[sender] -= amount; + b[recipient] += amount; + a[sender][msg.sender] -= amount; + + return true; + } +} diff --git a/certora/harnesses/ERC20Like/DummyERC20MintBurn.sol b/certora/harnesses/ERC20Like/DummyERC20MintBurn.sol new file mode 100644 index 00000000..4e7892b5 --- /dev/null +++ b/certora/harnesses/ERC20Like/DummyERC20MintBurn.sol @@ -0,0 +1,61 @@ +// Represents a symbolic/dummy ERC20 token + +// SPDX-License-Identifier: agpl-3.0 +pragma solidity ^0.8.0; + +contract DummyERC20MintBurn { + uint256 t; + mapping(address => uint256) b; + mapping(address => mapping(address => uint256)) a; + + string public name; + string public symbol; + uint256 public decimals; + + function myAddress() external view returns (address) { + return address(this); + } + + function totalSupply() external view returns (uint256) { + return t; + } + + function balanceOf(address account) external view returns (uint256) { + return b[account]; + } + + function _mint(address to, uint256 amount) internal { + b[to] += amount; + t += amount; + } + + function _burn(address to, uint256 amount) internal { + b[to] -= amount; + t -= amount; + } + + function transfer(address recipient, uint256 amount) external returns (bool) { + b[msg.sender] -= amount; + b[recipient] += amount; + + return true; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return a[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + a[msg.sender][spender] = amount; + + return true; + } + + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { + b[sender] -= amount; + b[recipient] += amount; + a[sender][msg.sender] -= amount; + + return true; + } +} diff --git a/certora/harnesses/ERC20Like/DummyStETH.sol b/certora/harnesses/ERC20Like/DummyStETH.sol new file mode 100644 index 00000000..020b6ebd --- /dev/null +++ b/certora/harnesses/ERC20Like/DummyStETH.sol @@ -0,0 +1,114 @@ +pragma solidity >=0.8.0; + +import "../../../contracts/interfaces/IStETH.sol"; + +contract DummyStETH is IStETH { + uint256 internal totalShares; + mapping(address => uint256) private shares; + mapping(address => mapping(address => uint256)) private allowances; + + function getTotalShares() external view returns (uint256) { + return totalShares; + } + + function getSharesByPooledEth(uint256 ethAmount) external view returns (uint256) { + return ethAmount * 3 / 5; + } + + function getPooledEthByShares(uint256 sharesAmount) external view returns (uint256) { + return sharesAmount * 5 / 3; + } + + function transferShares(address to, uint256 amount) external { + _transferShares(msg.sender, to, amount); + // uint256 tokensAmount = getPooledEthByShares(amount); + // uint256 tokensAmount = amount*3/5; + // return tokensAmount; + } + + function transferSharesFrom( + address _sender, + address _recipient, + uint256 _sharesAmount + ) external returns (uint256) { + uint256 tokensAmount = _sharesAmount * 5 / 3; + _spendAllowance(_sender, msg.sender, _sharesAmount); + _transferShares(_sender, _recipient, _sharesAmount); + return tokensAmount; + } + + function transfer(address _recipient, uint256 _amount) external returns (bool) { + _transfer(msg.sender, _recipient, _amount); + return true; + } + + function transferFrom(address _sender, address _recipient, uint256 _amount) external returns (bool) { + _spendAllowance(_sender, msg.sender, _amount); + _transfer(_sender, _recipient, _amount); + return true; + } + + function increaseAllowance(address _spender, uint256 _addedValue) external returns (bool) { + _approve(msg.sender, _spender, allowances[msg.sender][_spender] + (_addedValue)); + return true; + } + + function decreaseAllowance(address _spender, uint256 _subtractedValue) external returns (bool) { + uint256 currentAllowance = allowances[msg.sender][_spender]; + require(currentAllowance >= _subtractedValue, "ALLOWANCE_BELOW_ZERO"); + _approve(msg.sender, _spender, currentAllowance - (_subtractedValue)); + return true; + } + + function totalSupply() external view returns (uint256) { + return totalShares * 5 / 3; + } + + function approve(address _spender, uint256 _amount) external returns (bool) { + _approve(msg.sender, _spender, _amount); + return true; + } + + function allowance(address _owner, address _spender) external view returns (uint256) { + return allowances[_owner][_spender]; + } + + function balanceOf(address _account) external view returns (uint256) { + // return getPooledEthByShares(_sharesOf(_account)); + return _sharesOf(_account) * 5 / 3; + } + + function _sharesOf(address account) internal view returns (uint256) { + return shares[account]; + } + + function _transfer(address sender, address recipient, uint256 amount) internal { + uint256 sharesToTransfer = amount * 3 / 5; + _transferShares(sender, recipient, sharesToTransfer); + } + + function _transferShares(address sender, address recipient, uint256 sharesAmount) internal { + require(sender != address(0), "TRANSFER_FROM_ZERO_ADDR"); + require(recipient != address(0), "TRANSFER_TO_ZERO_ADDR"); + require(recipient != address(this), "TRANSFER_TO_STETH_CONTRACT"); + + uint256 currentSenderShares = shares[sender]; + require(sharesAmount <= currentSenderShares, "BALANCE_EXCEEDED"); + + shares[sender] = currentSenderShares - (sharesAmount); + shares[recipient] = shares[recipient] + (sharesAmount); + } + + function _spendAllowance(address owner, address spender, uint256 amount) internal { + uint256 currentAllowance = allowances[owner][spender]; + require(currentAllowance >= amount, "ALLOWANCE_EXCEEDED"); + _approve(owner, spender, currentAllowance - amount); + } + + function _approve(address owner, address spender, uint256 amount) internal { + require(owner != address(0), "APPROVE_FROM_ZERO_ADDR"); + require(spender != address(0), "APPROVE_TO_ZERO_ADDR"); + + allowances[owner][spender] = amount; + } +} diff --git a/certora/harnesses/ERC20Like/DummyWeth.sol b/certora/harnesses/ERC20Like/DummyWeth.sol new file mode 100644 index 00000000..9c691504 --- /dev/null +++ b/certora/harnesses/ERC20Like/DummyWeth.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity >=0.8.0; + +/** + * Dummy Weth token. + */ +contract DummyWeth { + uint256 t; + + mapping(address => uint256) b; + mapping(address => mapping(address => uint256)) a; + + string public name; + string public symbol; + uint256 public decimals; + + function myAddress() external view returns (address) { + return address(this); + } + + function totalSupply() external view returns (uint256) { + return t; + } + + function balanceOf(address account) external view returns (uint256) { + return b[account]; + } + + function transfer(address recipient, uint256 amount) external returns (bool) { + b[msg.sender] -= amount; + b[recipient] += amount; + return true; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return a[owner][spender]; + } + + function approve(address spender, uint256 amount) external returns (bool) { + a[msg.sender][spender] = amount; + return true; + } + + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { + b[sender] -= amount; + b[recipient] += amount; + a[sender][msg.sender] -= amount; + return true; + } + + // WETH + function deposit() external payable { + b[msg.sender] += msg.value; + } + + function withdraw(uint256 amt) external { + b[msg.sender] -= amt; + payable(msg.sender).transfer(amt); // use optimistic_fallback here + } +} diff --git a/certora/harnesses/ERC20Like/DummyWstETH.sol b/certora/harnesses/ERC20Like/DummyWstETH.sol new file mode 100644 index 00000000..f8842bbc --- /dev/null +++ b/certora/harnesses/ERC20Like/DummyWstETH.sol @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2021 Lido + +// SPDX-License-Identifier: GPL-3.0 + +/* See contracts/COMPILERS.md */ +pragma solidity ^0.8.26; + +import "./DummyERC20MintBurn.sol"; + +import "../../../contracts/interfaces/IStETH.sol"; + +/** + * @title StETH token wrapper with static balances. + * @dev It's an ERC20 token that represents the account's share of the total + * supply of stETH tokens. WstETH token's balance only changes on transfers, + * unlike StETH that is also changed when oracles report staking rewards and + * penalties. It's a "power user" token for DeFi protocols which don't + * support rebasable tokens. + * + * The contract is also a trustless wrapper that accepts stETH tokens and mints + * wstETH in return. Then the user unwraps, the contract burns user's wstETH + * and sends user locked stETH in return. + * + * The contract provides the staking shortcut: user can send ETH with regular + * transfer and get wstETH in return. The contract will send ETH to Lido submit + * method, staking it and wrapping the received stETH. + * + */ +contract DummyWstETH is DummyERC20MintBurn { + IStETH public stETH; + + /** + * @param _stETH address of the StETH token to wrap + */ + + constructor(IStETH _stETH) { + stETH = _stETH; + } + + /** + * @notice Exchanges stETH to wstETH + * @param _stETHAmount amount of stETH to wrap in exchange for wstETH + * @dev Requirements: + * - `_stETHAmount` must be non-zero + * - msg.sender must approve at least `_stETHAmount` stETH to this + * contract. + * - msg.sender must have at least `_stETHAmount` of stETH. + * User should first approve _stETHAmount to the WstETH contract + * @return Amount of wstETH user receives after wrap + */ + function wrap(uint256 _stETHAmount) external returns (uint256) { + require(_stETHAmount > 0, "wstETH: can't wrap zero stETH"); + uint256 wstETHAmount = stETH.getSharesByPooledEth(_stETHAmount); + _mint(msg.sender, wstETHAmount); + stETH.transferFrom(msg.sender, address(this), _stETHAmount); + return wstETHAmount; + } + + /** + * @notice Exchanges wstETH to stETH + * @param _wstETHAmount amount of wstETH to uwrap in exchange for stETH + * @dev Requirements: + * - `_wstETHAmount` must be non-zero + * - msg.sender must have at least `_wstETHAmount` wstETH. + * @return Amount of stETH user receives after unwrap + */ + function unwrap(uint256 _wstETHAmount) external returns (uint256) { + require(_wstETHAmount > 0, "wstETH: zero amount unwrap not allowed"); + uint256 stETHAmount = stETH.getPooledEthByShares(_wstETHAmount); + _burn(msg.sender, _wstETHAmount); + stETH.transfer(msg.sender, stETHAmount); + return stETHAmount; + } +} diff --git a/certora/harnesses/Utilities.sol b/certora/harnesses/Utilities.sol new file mode 100644 index 00000000..bae8c012 --- /dev/null +++ b/certora/harnesses/Utilities.sol @@ -0,0 +1,14 @@ +pragma solidity ^0.8.0; + +contract Utilities { + function havocAll() external { + (bool success,) = address(0xdeadbeef).call(abi.encodeWithSelector(0x12345678)); + require(success); + } + + function justRevert() external { + revert(); + } + + function nop() external {} +} diff --git a/certora/helpers/DummyWithdrawalQueue.sol b/certora/helpers/DummyWithdrawalQueue.sol new file mode 100644 index 00000000..2eec13a3 --- /dev/null +++ b/certora/helpers/DummyWithdrawalQueue.sol @@ -0,0 +1,173 @@ +pragma solidity ^0.8.26; + + + +import "../../contracts/interfaces/IStETH.sol"; + +// This implementation is only mock for ESCROW contract +contract DummyWithdrawalQueue { + + // The Prover will assume a contant but random value; + uint256 public MAX_STETH_WITHDRAWAL_AMOUNT; + uint256 public MIN_STETH_WITHDRAWAL_AMOUNT; + + uint256 internal lastRequestId; + uint256 internal lastFinalizedRequestId; + + mapping(address => uint256) balances; + mapping(address => mapping(address => bool)) public allowance; + + + IStETH public stETH; + + struct WithdrawalRequestInfo { + uint256 amountOfStETH; + uint256 claimableAmount; + uint256 amountOfShares; + address owner; + uint256 timestamp; + bool isFinalized; + bool isClaimed; + } + + struct WithdrawalRequestStatus { + uint256 amountOfStETH; + uint256 amountOfShares; + address owner; + uint256 timestamp; + bool isFinalized; + bool isClaimed; + } + + + + mapping(uint256 => WithdrawalRequestInfo) public requests; + + // get the last (exsisting) requestId + function getLastRequestId() public view returns (uint256) { + return lastRequestId; + } + function getLastFinalizedRequestId() public view returns (uint256) { + return lastFinalizedRequestId; + } + + uint256 randomNumOfFinalzied; + // if reduction true we simulate reduce by half + function finalize(uint256 upToRequestId, bool reduction) external { + if (lastFinalizedRequestId == 0 ) + lastFinalizedRequestId++; + require (upToRequestId > lastFinalizedRequestId && upToRequestId <= lastRequestId); + for(uint256 i = lastFinalizedRequestId; i <= upToRequestId ; i++) { + require(!requests[i].isFinalized); + requests[i].isFinalized = true; + if (reduction) { + requests[i].claimableAmount = requests[i].amountOfStETH / 2; + } + else { + requests[i].claimableAmount = requests[i].amountOfStETH; + } + } + lastFinalizedRequestId = upToRequestId; + } + + function getWithdrawalStatus(uint256[] calldata _requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses) + { + statuses = new WithdrawalRequestStatus[](_requestIds.length); + for (uint256 i = 0; i < _requestIds.length; ++i) { + require(_requestIds[i] <= lastRequestId); + WithdrawalRequestInfo memory r = requests[_requestIds[i]]; + statuses[i] = WithdrawalRequestStatus( + r.amountOfStETH, + r.amountOfShares, + r.owner, + r.timestamp, + r.isFinalized, + r.isClaimed); + } + } + + function transferFrom(address from, address to, uint256 requestId) external { + require (requests[requestId].owner == from && balances[from] >= 1 ); + require (allowance[from][msg.sender] || msg.sender == from); + requests[requestId].owner = to; + balances[from] = balances[from] -1; + balances[to] = balances[to] -1; + } + + + function balanceOf(address owner) external view returns (uint256) { + return balances[owner]; + } + + + function getClaimableEther( + uint256[] calldata _requestIds, + uint256[] calldata _hints + ) external view returns (uint256[] memory claimableEthValues) { + claimableEthValues = new uint256[](_requestIds.length); + for (uint256 i = 0; i < _requestIds.length; ++i) { + uint256 _requestId = _requestIds[i]; + require (_requestId != 0 && _requestId <= lastRequestId) ; + if (_requestId > lastFinalizedRequestId || requests[_requestId].isClaimed) { + claimableEthValues[i] = 0; + } + else { + claimableEthValues[i] = requests[_requestIds[i]].claimableAmount; + } + } + } + + function requestWithdrawals( + uint256[] calldata _amounts, + address _owner + ) external returns (uint256[] memory requestIds) { + requestIds = new uint256[](_amounts.length); + for (uint256 i = 0; i < _amounts.length; ++i) { + stETH.transferFrom(msg.sender, address(this), _amounts[i]); + uint256 amountOfShares = stETH.getSharesByPooledEth(_amounts[i]); + require (amountOfShares > 0 ); + lastRequestId += 1; + requestIds[i] = lastRequestId; + requests[lastRequestId] = + WithdrawalRequestInfo( + _amounts[i], + 0, + amountOfShares, + _owner, + block.timestamp, + false, + false); + } + } + + function claimWithdrawals(uint256[] calldata requestIds, uint256[] calldata hints) external { + for (uint256 i = 0; i < requestIds.length; ++i) { + require( ! requests[requestIds[i]].isClaimed && requests[requestIds[i]].isFinalized); + require (requests[requestIds[i]].owner == msg.sender); + requests[requestIds[i]].isClaimed = true; + (bool success,) = msg.sender.call{value: requests[requestIds[i]].claimableAmount }(""); + require(success); + } + } + + + uint256[] internal hints; + + function findCheckpointHints( + uint256[] calldata _requestIds, + uint256 _firstIndex, + uint256 _lastIndex + ) external view returns (uint256[] memory ) { + return hints; + } + + uint256 lastCheckpointIndex; + function getLastCheckpointIndex() external view returns (uint256) { + return lastCheckpointIndex; + } + + +} diff --git a/certora/helpers/EscrowA.sol b/certora/helpers/EscrowA.sol new file mode 100644 index 00000000..abe135ff --- /dev/null +++ b/certora/helpers/EscrowA.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "../../contracts/Escrow.sol"; +import {State as EscrowStateInner} from "../../contracts/libraries/EscrowState.sol"; + +contract EscrowA is Escrow { + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) Escrow(stETH, wstETH, withdrawalQueue, dualGovernance, minWithdrawalsBatchSize) {} + + function isRageQuitState() external returns (bool) { + return _escrowState.state == EscrowStateInner.RageQuitEscrow; + } +} diff --git a/certora/helpers/EscrowB.sol b/certora/helpers/EscrowB.sol new file mode 100644 index 00000000..14d314bc --- /dev/null +++ b/certora/helpers/EscrowB.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "../../contracts/Escrow.sol"; +import {State as EscrowStateInner} from "../../contracts/libraries/EscrowState.sol"; + +contract EscrowB is Escrow { + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) Escrow(stETH, wstETH, withdrawalQueue, dualGovernance, minWithdrawalsBatchSize) {} + + function isRageQuitState() external returns (bool) { + return _escrowState.state == EscrowStateInner.RageQuitEscrow; + } +} diff --git a/certora/mutation/conf/DualGovernance.conf b/certora/mutation/conf/DualGovernance.conf new file mode 100644 index 00000000..b0faf87e --- /dev/null +++ b/certora/mutation/conf/DualGovernance.conf @@ -0,0 +1,65 @@ +{ + "files": [ + "contracts/libraries/DualGovernanceStateMachine.sol", + "contracts/Executor.sol", + "contracts/EmergencyProtectedTimelock.sol", + "contracts/ResealManager.sol", + "certora/helpers/EscrowA.sol", + "certora/helpers/EscrowB.sol", + "contracts/DualGovernanceConfigProvider.sol:ImmutableDualGovernanceConfigProvider", + "certora/harnesses/ERC20Like/DummyStETH.sol", + "certora/harnesses/ERC20Like/DummyWstETH.sol", + "certora/harnesses/DualGovernanceHarness.sol", + ], + "link": [ + "DualGovernanceHarness:TIMELOCK=EmergencyProtectedTimelock", + "DualGovernanceHarness:_configProvider=ImmutableDualGovernanceConfigProvider", + "ResealManager:EMERGENCY_PROTECTED_TIMELOCK=EmergencyProtectedTimelock", + "DualGovernanceHarness:RESEAL_MANAGER=ResealManager", + "EscrowA:ST_ETH=DummyStETH", + "EscrowA:WST_ETH=DummyWstETH", + "EscrowA:DUAL_GOVERNANCE=DualGovernanceHarness", + "EscrowB:ST_ETH=DummyStETH", + "EscrowB:WST_ETH=DummyWstETH", + "EscrowB:DUAL_GOVERNANCE=DualGovernanceHarness" + ], + "struct_link": [ + "DualGovernanceHarness:resealManager=ResealManager", + "EmergencyProtectedTimelock:executor=Executor", + ], + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + "parametric_contracts": [ + "DualGovernanceHarness", + // "EmergencyProtectedTimelock", + // "ResealManager", + // "Escrow", + // "DummyStETH", + // "DummyWstETH", + ], + "rule_sanity": "basic", + "process": "emv", + "solc": "solc8.26", + "optimistic_loop": true, + "loop_iter": "5", + "smt_timeout": "3600", + "build_cache": true, + "verify": "DualGovernanceHarness:certora/specs/DualGovernance.spec", + "mutations": { + "manual_mutants": [ + { + "file_to_mutate": "contracts/DualGovernance.sol", + "mutants_location": "certora/mutation/mutants/DualGovernance" + }, + { + "file_to_mutate": "contracts/libraries/Proposers.sol", + "mutants_location": "certora/mutation/mutants/Proposers" + }, + { + "file_to_mutate": "contracts/libraries/DualGovernanceStateMachine.sol", + "mutants_location": "certora/mutation/mutants/DualGovernanceStateMachine" + }, + ] + } +} \ No newline at end of file diff --git a/certora/mutation/conf/EmergencyProtectedTimelock.conf b/certora/mutation/conf/EmergencyProtectedTimelock.conf new file mode 100644 index 00000000..f64c6391 --- /dev/null +++ b/certora/mutation/conf/EmergencyProtectedTimelock.conf @@ -0,0 +1,40 @@ +{ + "files": [ + "contracts/EmergencyProtectedTimelock.sol", + "contracts/Executor.sol", + "contracts/libraries/ExecutableProposals.sol", + "contracts/libraries/EmergencyProtection.sol", + "contracts/types/Timestamp.sol:Timestamps", + "contracts/types/Duration.sol:Durations" + ], + "struct_link": [ + "EmergencyProtectedTimelock:executor=Executor", + ], + "msg": "Emergency Protected Timelock", + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + "process": "emv", + "solc": "solc8.26", + "optimistic_loop": true, + "solc_via_ir": false, + "verify": "EmergencyProtectedTimelock:certora/specs/Timelock.spec", + "server": "production", + // mutation options below this line + "mutations": { + "manual_mutants": [ + { + "file_to_mutate": "contracts/libraries/ExecutableProposals.sol", + "mutants_location": "certora/mutation/mutants/ExecutableProposals" + }, + { + "file_to_mutate": "contracts/libraries/EmergencyProtection.sol", + "mutants_location": "certora/mutation/mutants/EmergencyProtection" + }, + { + "file_to_mutate": "contracts/EmergencyProtectedTimelock.sol", + "mutants_location": "certora/mutation/mutants/EmergencyProtectedTimelock" + }, + ] + } +} \ No newline at end of file diff --git a/certora/mutation/conf/Escrow.conf b/certora/mutation/conf/Escrow.conf new file mode 100644 index 00000000..d8d41373 --- /dev/null +++ b/certora/mutation/conf/Escrow.conf @@ -0,0 +1,48 @@ +{ + "files": [ + "contracts/Escrow.sol", + "contracts/DualGovernance.sol", + "contracts/DualGovernanceConfigProvider.sol:ImmutableDualGovernanceConfigProvider", + "certora/helpers/DummyWithdrawalQueue.sol", + "certora/harnesses/ERC20Like/DummyStETH.sol", + "certora/harnesses/ERC20Like/DummyWstETH.sol", + ], + "link": [ + "Escrow:DUAL_GOVERNANCE=DualGovernance", + "Escrow:WITHDRAWAL_QUEUE=DummyWithdrawalQueue", + "Escrow:ST_ETH=DummyStETH", + "Escrow:WST_ETH=DummyWstETH", + "DummyWstETH:stETH=DummyStETH", + "DummyWithdrawalQueue:stETH=DummyStETH", + "DualGovernance:_configProvider=ImmutableDualGovernanceConfigProvider", + ], + + "msg": "sanity", + "packages": [ + "@openzeppelin=lib/openzeppelin-contracts" + ], + "solc": "solc8.26", + "optimistic_loop": true, + "optimistic_fallback": true, + "loop_iter": "3", + "build_cache" : true, + "verify": "Escrow:certora/specs/Escrow.spec", + "server": "production", + // mutation options below this line + "mutations": { + "manual_mutants": [ + { + "file_to_mutate": "contracts/libraries/EscrowState.sol", + "mutants_location": "certora/mutation/mutants/EscrowState" + }, + { + "file_to_mutate": "contracts/Escrow.sol", + "mutants_location": "certora/mutation/mutants/Escrow" + }, + { + "file_to_mutate": "contracts/libraries/AssetsAccounting.sol", + "mutants_location": "certora/mutation/mutants/AssetsAccounting" + }, + ] + } +} \ No newline at end of file diff --git a/certora/mutation/mutants/AssetsAccounting/AssetsAccountingDoubleStETHShares.sol b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingDoubleStETHShares.sol new file mode 100644 index 00000000..0e703137 --- /dev/null +++ b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingDoubleStETHShares.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +/// @notice Tracks the stETH and unstETH tokens associated with users +/// @param stETHLockedShares Total number of stETH shares held by the user +/// @param unstETHLockedShares Total number of shares contained in the unstETH NFTs held by the user +/// @param lastAssetsLockTimestamp Timestamp of the most recent lock of stETH shares or unstETH NFTs +/// @param unstETHIds List of unstETH ids locked by the user +struct HolderAssets { + /// @dev slot0: [0..39] + Timestamp lastAssetsLockTimestamp; + /// @dev slot0: [40..167] + SharesValue stETHLockedShares; + /// @dev slot1: [0..127] + SharesValue unstETHLockedShares; + /// @dev slot2: [0..255] - the length of the array + each item occupies 1 slot + uint256[] unstETHIds; +} + +/// @notice Tracks the unfinalized shares and finalized ETH amount of unstETH NFTs +/// @param unfinalizedShares Total number of unfinalized unstETH shares +/// @param finalizedETH Total amount of ETH claimable from finalized unstETH +struct UnstETHAccounting { + /// @dev slot0: [0..127] + SharesValue unfinalizedShares; + /// @dev slot1: [128..255] + ETHValue finalizedETH; +} + +/// @notice Tracks the locked shares and claimed ETH amounts +/// @param lockedShares Total number of accounted stETH shares +/// @param claimedETH Total amount of ETH received from claiming the locked stETH shares +struct StETHAccounting { + /// @dev slot0: [0..127] + SharesValue lockedShares; + /// @dev slot0: [128..255] + ETHValue claimedETH; +} + +/// @notice Represents the state of an accounted WithdrawalRequest +/// @param NotLocked Indicates the default value of the unstETH record, meaning it was not accounted as locked or +/// was unlocked by the account that previously locked it +/// @param Locked Indicates the unstETH record was accounted as locked +/// @param Finalized Indicates the unstETH record was marked as finalized +/// @param Claimed Indicates the unstETH record was claimed +/// @param Withdrawn Indicates the unstETH record was withdrawn after a successful claim +enum UnstETHRecordStatus { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +/// @notice Stores information about an accounted unstETH NFT +/// @param state The current state of the unstETH record. Refer to `UnstETHRecordStatus` for details. +/// @param index The one-based index of the unstETH record in the `UnstETHAccounting.unstETHIds` array +/// @param lockedBy The address of the account that locked the unstETH +/// @param shares The amount of shares contained in the unstETH +/// @param claimableAmount The amount of claimable ETH contained in the unstETH. This value is 0 +/// until the NFT is marked as finalized or claimed. +struct UnstETHRecord { + /// @dev slot 0: [0..7] + UnstETHRecordStatus status; + /// @dev slot 0: [8..39] + IndexOneBased index; + /// @dev slot 0: [40..199] + address lockedBy; + /// @dev slot 1: [0..127] + SharesValue shares; + /// @dev slot 1: [128..255] + ETHValue claimableAmount; +} + +/// @notice Provides functionality for accounting user stETH and unstETH tokens +/// locked in the Escrow contract +library AssetsAccounting { + /// @notice The context of the AssetsAccounting library + /// @param stETHTotals The total number of shares and the amount of stETH locked by users + /// @param unstETHTotals The total number of shares and the amount of unstETH locked by users + /// @param assets Mapping to store information about the assets locked by each user + /// @param unstETHRecords Mapping to track the state of the locked unstETH ids + struct Context { + /// @dev slot0: [0..255] + StETHAccounting stETHTotals; + /// @dev slot1: [0..255] + UnstETHAccounting unstETHTotals; + /// @dev slot2: [0..255] empty slot for mapping track in the storage + mapping(address account => HolderAssets) assets; + /// @dev slot3: [0..255] empty slot for mapping track in the storage + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } + + // --- + // Events + // --- + + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); + event UnstETHUnlocked( + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement + ); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); + + event ETHClaimed(ETHValue amount); + + // --- + // Errors + // --- + + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error MinAssetsLockDurationNotPassed(Timestamp unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- + + function accountStETHSharesLock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + // mutated + assets.stETHLockedShares = assets.stETHLockedShares + shares + shares; + // assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = Timestamps.now(); + emit StETHSharesLocked(holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + if (assets.stETHLockedShares < shares) { + revert InvalidSharesValue(shares); + } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); + } + + function accountStETHSharesWithdraw( + Context storage self, + address holder + ) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); + + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(Context storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); + } + + // --- + // unstETH operations accounting + // --- + + function accountUnstETHLock( + Context storage self, + address holder, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + SharesValue totalUnstETHLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); + } + self.assets[holder].lastAssetsLockTimestamp = Timestamps.now(); + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; + + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } + + function accountUnstETHUnlock(Context storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; + } + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); + + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); + } + + function accountUnstETHFinalized( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; + } + + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (ETHValue totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); + } + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + Context storage self, + address holder, + uint256[] memory unstETHIds + ) internal returns (ETHValue amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals( + Context storage self + ) internal view returns (SharesValue unfinalizedShares, ETHValue finalizedETH) { + finalizedETH = self.unstETHTotals.finalizedETH; + unfinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; + } + + function checkMinAssetsLockDurationPassed( + Context storage self, + address holder, + Duration minAssetsLockDuration + ) internal view { + Timestamp assetsUnlockAllowedAfter = minAssetsLockDuration.addTo(self.assets[holder].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert MinAssetsLockDurationNotPassed(assetsUnlockAllowedAfter); + } + } + + // --- + // Helper methods + // --- + + function _addUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); + + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } + + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); + + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); + } + + function _removeUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); + } + + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length); + + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.toZeroBasedValue()]; + assets.unstETHIds[unstETHIdIndex.toZeroBasedValue()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; + } + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; + } + + function _finalizeUnstETHRecord( + Context storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); + } + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); + + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; + + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _claimUnstETHRecord(Context storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } + } else { + unstETHRecord.claimableAmount = claimableAmount; + } + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _withdrawUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; + } + + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); + } + } +} diff --git a/certora/mutation/mutants/AssetsAccounting/AssetsAccountingStETHSharesWithdrawDoubleShares.sol b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingStETHSharesWithdrawDoubleShares.sol new file mode 100644 index 00000000..0aeb8749 --- /dev/null +++ b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingStETHSharesWithdrawDoubleShares.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +/// @notice Tracks the stETH and unstETH tokens associated with users +/// @param stETHLockedShares Total number of stETH shares held by the user +/// @param unstETHLockedShares Total number of shares contained in the unstETH NFTs held by the user +/// @param lastAssetsLockTimestamp Timestamp of the most recent lock of stETH shares or unstETH NFTs +/// @param unstETHIds List of unstETH ids locked by the user +struct HolderAssets { + /// @dev slot0: [0..39] + Timestamp lastAssetsLockTimestamp; + /// @dev slot0: [40..167] + SharesValue stETHLockedShares; + /// @dev slot1: [0..127] + SharesValue unstETHLockedShares; + /// @dev slot2: [0..255] - the length of the array + each item occupies 1 slot + uint256[] unstETHIds; +} + +/// @notice Tracks the unfinalized shares and finalized ETH amount of unstETH NFTs +/// @param unfinalizedShares Total number of unfinalized unstETH shares +/// @param finalizedETH Total amount of ETH claimable from finalized unstETH +struct UnstETHAccounting { + /// @dev slot0: [0..127] + SharesValue unfinalizedShares; + /// @dev slot1: [128..255] + ETHValue finalizedETH; +} + +/// @notice Tracks the locked shares and claimed ETH amounts +/// @param lockedShares Total number of accounted stETH shares +/// @param claimedETH Total amount of ETH received from claiming the locked stETH shares +struct StETHAccounting { + /// @dev slot0: [0..127] + SharesValue lockedShares; + /// @dev slot0: [128..255] + ETHValue claimedETH; +} + +/// @notice Represents the state of an accounted WithdrawalRequest +/// @param NotLocked Indicates the default value of the unstETH record, meaning it was not accounted as locked or +/// was unlocked by the account that previously locked it +/// @param Locked Indicates the unstETH record was accounted as locked +/// @param Finalized Indicates the unstETH record was marked as finalized +/// @param Claimed Indicates the unstETH record was claimed +/// @param Withdrawn Indicates the unstETH record was withdrawn after a successful claim +enum UnstETHRecordStatus { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +/// @notice Stores information about an accounted unstETH NFT +/// @param state The current state of the unstETH record. Refer to `UnstETHRecordStatus` for details. +/// @param index The one-based index of the unstETH record in the `UnstETHAccounting.unstETHIds` array +/// @param lockedBy The address of the account that locked the unstETH +/// @param shares The amount of shares contained in the unstETH +/// @param claimableAmount The amount of claimable ETH contained in the unstETH. This value is 0 +/// until the NFT is marked as finalized or claimed. +struct UnstETHRecord { + /// @dev slot 0: [0..7] + UnstETHRecordStatus status; + /// @dev slot 0: [8..39] + IndexOneBased index; + /// @dev slot 0: [40..199] + address lockedBy; + /// @dev slot 1: [0..127] + SharesValue shares; + /// @dev slot 1: [128..255] + ETHValue claimableAmount; +} + +/// @notice Provides functionality for accounting user stETH and unstETH tokens +/// locked in the Escrow contract +library AssetsAccounting { + /// @notice The context of the AssetsAccounting library + /// @param stETHTotals The total number of shares and the amount of stETH locked by users + /// @param unstETHTotals The total number of shares and the amount of unstETH locked by users + /// @param assets Mapping to store information about the assets locked by each user + /// @param unstETHRecords Mapping to track the state of the locked unstETH ids + struct Context { + /// @dev slot0: [0..255] + StETHAccounting stETHTotals; + /// @dev slot1: [0..255] + UnstETHAccounting unstETHTotals; + /// @dev slot2: [0..255] empty slot for mapping track in the storage + mapping(address account => HolderAssets) assets; + /// @dev slot3: [0..255] empty slot for mapping track in the storage + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } + + // --- + // Events + // --- + + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); + event UnstETHUnlocked( + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement + ); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); + + event ETHClaimed(ETHValue amount); + + // --- + // Errors + // --- + + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error MinAssetsLockDurationNotPassed(Timestamp unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- + + function accountStETHSharesLock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = Timestamps.now(); + emit StETHSharesLocked(holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + if (assets.stETHLockedShares < shares) { + revert InvalidSharesValue(shares); + } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); + } + + function accountStETHSharesWithdraw( + Context storage self, + address holder + ) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + // mutated + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares + assets.stETHLockedShares; + // SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); + + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(Context storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); + } + + // --- + // unstETH operations accounting + // --- + + function accountUnstETHLock( + Context storage self, + address holder, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + SharesValue totalUnstETHLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); + } + self.assets[holder].lastAssetsLockTimestamp = Timestamps.now(); + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; + + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } + + function accountUnstETHUnlock(Context storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; + } + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); + + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); + } + + function accountUnstETHFinalized( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; + } + + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (ETHValue totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); + } + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + Context storage self, + address holder, + uint256[] memory unstETHIds + ) internal returns (ETHValue amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals( + Context storage self + ) internal view returns (SharesValue unfinalizedShares, ETHValue finalizedETH) { + finalizedETH = self.unstETHTotals.finalizedETH; + unfinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; + } + + function checkMinAssetsLockDurationPassed( + Context storage self, + address holder, + Duration minAssetsLockDuration + ) internal view { + Timestamp assetsUnlockAllowedAfter = minAssetsLockDuration.addTo(self.assets[holder].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert MinAssetsLockDurationNotPassed(assetsUnlockAllowedAfter); + } + } + + // --- + // Helper methods + // --- + + function _addUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); + + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } + + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); + + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); + } + + function _removeUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); + } + + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length); + + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.toZeroBasedValue()]; + assets.unstETHIds[unstETHIdIndex.toZeroBasedValue()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; + } + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; + } + + function _finalizeUnstETHRecord( + Context storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); + } + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); + + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; + + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _claimUnstETHRecord(Context storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } + } else { + unstETHRecord.claimableAmount = claimableAmount; + } + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _withdrawUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; + } + + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); + } + } +} diff --git a/certora/mutation/mutants/AssetsAccounting/AssetsAccountingStEthSharesUnlockUnchecked.sol b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingStEthSharesUnlockUnchecked.sol new file mode 100644 index 00000000..848b81b5 --- /dev/null +++ b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingStEthSharesUnlockUnchecked.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +/// @notice Tracks the stETH and unstETH tokens associated with users +/// @param stETHLockedShares Total number of stETH shares held by the user +/// @param unstETHLockedShares Total number of shares contained in the unstETH NFTs held by the user +/// @param lastAssetsLockTimestamp Timestamp of the most recent lock of stETH shares or unstETH NFTs +/// @param unstETHIds List of unstETH ids locked by the user +struct HolderAssets { + /// @dev slot0: [0..39] + Timestamp lastAssetsLockTimestamp; + /// @dev slot0: [40..167] + SharesValue stETHLockedShares; + /// @dev slot1: [0..127] + SharesValue unstETHLockedShares; + /// @dev slot2: [0..255] - the length of the array + each item occupies 1 slot + uint256[] unstETHIds; +} + +/// @notice Tracks the unfinalized shares and finalized ETH amount of unstETH NFTs +/// @param unfinalizedShares Total number of unfinalized unstETH shares +/// @param finalizedETH Total amount of ETH claimable from finalized unstETH +struct UnstETHAccounting { + /// @dev slot0: [0..127] + SharesValue unfinalizedShares; + /// @dev slot1: [128..255] + ETHValue finalizedETH; +} + +/// @notice Tracks the locked shares and claimed ETH amounts +/// @param lockedShares Total number of accounted stETH shares +/// @param claimedETH Total amount of ETH received from claiming the locked stETH shares +struct StETHAccounting { + /// @dev slot0: [0..127] + SharesValue lockedShares; + /// @dev slot0: [128..255] + ETHValue claimedETH; +} + +/// @notice Represents the state of an accounted WithdrawalRequest +/// @param NotLocked Indicates the default value of the unstETH record, meaning it was not accounted as locked or +/// was unlocked by the account that previously locked it +/// @param Locked Indicates the unstETH record was accounted as locked +/// @param Finalized Indicates the unstETH record was marked as finalized +/// @param Claimed Indicates the unstETH record was claimed +/// @param Withdrawn Indicates the unstETH record was withdrawn after a successful claim +enum UnstETHRecordStatus { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +/// @notice Stores information about an accounted unstETH NFT +/// @param state The current state of the unstETH record. Refer to `UnstETHRecordStatus` for details. +/// @param index The one-based index of the unstETH record in the `UnstETHAccounting.unstETHIds` array +/// @param lockedBy The address of the account that locked the unstETH +/// @param shares The amount of shares contained in the unstETH +/// @param claimableAmount The amount of claimable ETH contained in the unstETH. This value is 0 +/// until the NFT is marked as finalized or claimed. +struct UnstETHRecord { + /// @dev slot 0: [0..7] + UnstETHRecordStatus status; + /// @dev slot 0: [8..39] + IndexOneBased index; + /// @dev slot 0: [40..199] + address lockedBy; + /// @dev slot 1: [0..127] + SharesValue shares; + /// @dev slot 1: [128..255] + ETHValue claimableAmount; +} + +/// @notice Provides functionality for accounting user stETH and unstETH tokens +/// locked in the Escrow contract +library AssetsAccounting { + /// @notice The context of the AssetsAccounting library + /// @param stETHTotals The total number of shares and the amount of stETH locked by users + /// @param unstETHTotals The total number of shares and the amount of unstETH locked by users + /// @param assets Mapping to store information about the assets locked by each user + /// @param unstETHRecords Mapping to track the state of the locked unstETH ids + struct Context { + /// @dev slot0: [0..255] + StETHAccounting stETHTotals; + /// @dev slot1: [0..255] + UnstETHAccounting unstETHTotals; + /// @dev slot2: [0..255] empty slot for mapping track in the storage + mapping(address account => HolderAssets) assets; + /// @dev slot3: [0..255] empty slot for mapping track in the storage + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } + + // --- + // Events + // --- + + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); + event UnstETHUnlocked( + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement + ); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); + + event ETHClaimed(ETHValue amount); + + // --- + // Errors + // --- + + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error MinAssetsLockDurationNotPassed(Timestamp unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- + + function accountStETHSharesLock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = Timestamps.now(); + emit StETHSharesLocked(holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + // mutated + // if (assets.stETHLockedShares < shares) { + // revert InvalidSharesValue(shares); + // } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); + } + + function accountStETHSharesWithdraw( + Context storage self, + address holder + ) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); + + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(Context storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); + } + + // --- + // unstETH operations accounting + // --- + + function accountUnstETHLock( + Context storage self, + address holder, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + SharesValue totalUnstETHLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); + } + self.assets[holder].lastAssetsLockTimestamp = Timestamps.now(); + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; + + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } + + function accountUnstETHUnlock(Context storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; + } + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); + + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); + } + + function accountUnstETHFinalized( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; + } + + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (ETHValue totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); + } + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + Context storage self, + address holder, + uint256[] memory unstETHIds + ) internal returns (ETHValue amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals( + Context storage self + ) internal view returns (SharesValue unfinalizedShares, ETHValue finalizedETH) { + finalizedETH = self.unstETHTotals.finalizedETH; + unfinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; + } + + function checkMinAssetsLockDurationPassed( + Context storage self, + address holder, + Duration minAssetsLockDuration + ) internal view { + Timestamp assetsUnlockAllowedAfter = minAssetsLockDuration.addTo(self.assets[holder].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert MinAssetsLockDurationNotPassed(assetsUnlockAllowedAfter); + } + } + + // --- + // Helper methods + // --- + + function _addUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); + + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } + + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); + + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); + } + + function _removeUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); + } + + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length); + + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.toZeroBasedValue()]; + assets.unstETHIds[unstETHIdIndex.toZeroBasedValue()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; + } + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; + } + + function _finalizeUnstETHRecord( + Context storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); + } + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); + + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; + + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _claimUnstETHRecord(Context storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } + } else { + unstETHRecord.claimableAmount = claimableAmount; + } + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _withdrawUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; + } + + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); + } + } +} diff --git a/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHFinalizedOneInsteadOfTotalAmount.sol b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHFinalizedOneInsteadOfTotalAmount.sol new file mode 100644 index 00000000..c17c9d43 --- /dev/null +++ b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHFinalizedOneInsteadOfTotalAmount.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +/// @notice Tracks the stETH and unstETH tokens associated with users +/// @param stETHLockedShares Total number of stETH shares held by the user +/// @param unstETHLockedShares Total number of shares contained in the unstETH NFTs held by the user +/// @param lastAssetsLockTimestamp Timestamp of the most recent lock of stETH shares or unstETH NFTs +/// @param unstETHIds List of unstETH ids locked by the user +struct HolderAssets { + /// @dev slot0: [0..39] + Timestamp lastAssetsLockTimestamp; + /// @dev slot0: [40..167] + SharesValue stETHLockedShares; + /// @dev slot1: [0..127] + SharesValue unstETHLockedShares; + /// @dev slot2: [0..255] - the length of the array + each item occupies 1 slot + uint256[] unstETHIds; +} + +/// @notice Tracks the unfinalized shares and finalized ETH amount of unstETH NFTs +/// @param unfinalizedShares Total number of unfinalized unstETH shares +/// @param finalizedETH Total amount of ETH claimable from finalized unstETH +struct UnstETHAccounting { + /// @dev slot0: [0..127] + SharesValue unfinalizedShares; + /// @dev slot1: [128..255] + ETHValue finalizedETH; +} + +/// @notice Tracks the locked shares and claimed ETH amounts +/// @param lockedShares Total number of accounted stETH shares +/// @param claimedETH Total amount of ETH received from claiming the locked stETH shares +struct StETHAccounting { + /// @dev slot0: [0..127] + SharesValue lockedShares; + /// @dev slot0: [128..255] + ETHValue claimedETH; +} + +/// @notice Represents the state of an accounted WithdrawalRequest +/// @param NotLocked Indicates the default value of the unstETH record, meaning it was not accounted as locked or +/// was unlocked by the account that previously locked it +/// @param Locked Indicates the unstETH record was accounted as locked +/// @param Finalized Indicates the unstETH record was marked as finalized +/// @param Claimed Indicates the unstETH record was claimed +/// @param Withdrawn Indicates the unstETH record was withdrawn after a successful claim +enum UnstETHRecordStatus { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +/// @notice Stores information about an accounted unstETH NFT +/// @param state The current state of the unstETH record. Refer to `UnstETHRecordStatus` for details. +/// @param index The one-based index of the unstETH record in the `UnstETHAccounting.unstETHIds` array +/// @param lockedBy The address of the account that locked the unstETH +/// @param shares The amount of shares contained in the unstETH +/// @param claimableAmount The amount of claimable ETH contained in the unstETH. This value is 0 +/// until the NFT is marked as finalized or claimed. +struct UnstETHRecord { + /// @dev slot 0: [0..7] + UnstETHRecordStatus status; + /// @dev slot 0: [8..39] + IndexOneBased index; + /// @dev slot 0: [40..199] + address lockedBy; + /// @dev slot 1: [0..127] + SharesValue shares; + /// @dev slot 1: [128..255] + ETHValue claimableAmount; +} + +/// @notice Provides functionality for accounting user stETH and unstETH tokens +/// locked in the Escrow contract +library AssetsAccounting { + /// @notice The context of the AssetsAccounting library + /// @param stETHTotals The total number of shares and the amount of stETH locked by users + /// @param unstETHTotals The total number of shares and the amount of unstETH locked by users + /// @param assets Mapping to store information about the assets locked by each user + /// @param unstETHRecords Mapping to track the state of the locked unstETH ids + struct Context { + /// @dev slot0: [0..255] + StETHAccounting stETHTotals; + /// @dev slot1: [0..255] + UnstETHAccounting unstETHTotals; + /// @dev slot2: [0..255] empty slot for mapping track in the storage + mapping(address account => HolderAssets) assets; + /// @dev slot3: [0..255] empty slot for mapping track in the storage + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } + + // --- + // Events + // --- + + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); + event UnstETHUnlocked( + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement + ); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); + + event ETHClaimed(ETHValue amount); + + // --- + // Errors + // --- + + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error MinAssetsLockDurationNotPassed(Timestamp unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- + + function accountStETHSharesLock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = Timestamps.now(); + emit StETHSharesLocked(holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + if (assets.stETHLockedShares < shares) { + revert InvalidSharesValue(shares); + } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); + } + + function accountStETHSharesWithdraw( + Context storage self, + address holder + ) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); + + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(Context storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); + } + + // --- + // unstETH operations accounting + // --- + + function accountUnstETHLock( + Context storage self, + address holder, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + SharesValue totalUnstETHLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); + } + self.assets[holder].lastAssetsLockTimestamp = Timestamps.now(); + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; + + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } + + function accountUnstETHUnlock(Context storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; + } + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); + + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); + } + + function accountUnstETHFinalized( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; + } + + // mutated + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + ETHValues.from(1); + // self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (ETHValue totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); + } + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + Context storage self, + address holder, + uint256[] memory unstETHIds + ) internal returns (ETHValue amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals( + Context storage self + ) internal view returns (SharesValue unfinalizedShares, ETHValue finalizedETH) { + finalizedETH = self.unstETHTotals.finalizedETH; + unfinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; + } + + function checkMinAssetsLockDurationPassed( + Context storage self, + address holder, + Duration minAssetsLockDuration + ) internal view { + Timestamp assetsUnlockAllowedAfter = minAssetsLockDuration.addTo(self.assets[holder].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert MinAssetsLockDurationNotPassed(assetsUnlockAllowedAfter); + } + } + + // --- + // Helper methods + // --- + + function _addUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); + + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } + + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); + + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); + } + + function _removeUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); + } + + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length); + + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.toZeroBasedValue()]; + assets.unstETHIds[unstETHIdIndex.toZeroBasedValue()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; + } + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; + } + + function _finalizeUnstETHRecord( + Context storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); + } + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); + + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; + + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _claimUnstETHRecord(Context storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } + } else { + unstETHRecord.claimableAmount = claimableAmount; + } + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _withdrawUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; + } + + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); + } + } +} diff --git a/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHLockDoubleAccounting.sol b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHLockDoubleAccounting.sol new file mode 100644 index 00000000..74fe6e82 --- /dev/null +++ b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHLockDoubleAccounting.sol @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +/// @notice Tracks the stETH and unstETH tokens associated with users +/// @param stETHLockedShares Total number of stETH shares held by the user +/// @param unstETHLockedShares Total number of shares contained in the unstETH NFTs held by the user +/// @param lastAssetsLockTimestamp Timestamp of the most recent lock of stETH shares or unstETH NFTs +/// @param unstETHIds List of unstETH ids locked by the user +struct HolderAssets { + /// @dev slot0: [0..39] + Timestamp lastAssetsLockTimestamp; + /// @dev slot0: [40..167] + SharesValue stETHLockedShares; + /// @dev slot1: [0..127] + SharesValue unstETHLockedShares; + /// @dev slot2: [0..255] - the length of the array + each item occupies 1 slot + uint256[] unstETHIds; +} + +/// @notice Tracks the unfinalized shares and finalized ETH amount of unstETH NFTs +/// @param unfinalizedShares Total number of unfinalized unstETH shares +/// @param finalizedETH Total amount of ETH claimable from finalized unstETH +struct UnstETHAccounting { + /// @dev slot0: [0..127] + SharesValue unfinalizedShares; + /// @dev slot1: [128..255] + ETHValue finalizedETH; +} + +/// @notice Tracks the locked shares and claimed ETH amounts +/// @param lockedShares Total number of accounted stETH shares +/// @param claimedETH Total amount of ETH received from claiming the locked stETH shares +struct StETHAccounting { + /// @dev slot0: [0..127] + SharesValue lockedShares; + /// @dev slot0: [128..255] + ETHValue claimedETH; +} + +/// @notice Represents the state of an accounted WithdrawalRequest +/// @param NotLocked Indicates the default value of the unstETH record, meaning it was not accounted as locked or +/// was unlocked by the account that previously locked it +/// @param Locked Indicates the unstETH record was accounted as locked +/// @param Finalized Indicates the unstETH record was marked as finalized +/// @param Claimed Indicates the unstETH record was claimed +/// @param Withdrawn Indicates the unstETH record was withdrawn after a successful claim +enum UnstETHRecordStatus { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +/// @notice Stores information about an accounted unstETH NFT +/// @param state The current state of the unstETH record. Refer to `UnstETHRecordStatus` for details. +/// @param index The one-based index of the unstETH record in the `UnstETHAccounting.unstETHIds` array +/// @param lockedBy The address of the account that locked the unstETH +/// @param shares The amount of shares contained in the unstETH +/// @param claimableAmount The amount of claimable ETH contained in the unstETH. This value is 0 +/// until the NFT is marked as finalized or claimed. +struct UnstETHRecord { + /// @dev slot 0: [0..7] + UnstETHRecordStatus status; + /// @dev slot 0: [8..39] + IndexOneBased index; + /// @dev slot 0: [40..199] + address lockedBy; + /// @dev slot 1: [0..127] + SharesValue shares; + /// @dev slot 1: [128..255] + ETHValue claimableAmount; +} + +/// @notice Provides functionality for accounting user stETH and unstETH tokens +/// locked in the Escrow contract +library AssetsAccounting { + /// @notice The context of the AssetsAccounting library + /// @param stETHTotals The total number of shares and the amount of stETH locked by users + /// @param unstETHTotals The total number of shares and the amount of unstETH locked by users + /// @param assets Mapping to store information about the assets locked by each user + /// @param unstETHRecords Mapping to track the state of the locked unstETH ids + struct Context { + /// @dev slot0: [0..255] + StETHAccounting stETHTotals; + /// @dev slot1: [0..255] + UnstETHAccounting unstETHTotals; + /// @dev slot2: [0..255] empty slot for mapping track in the storage + mapping(address account => HolderAssets) assets; + /// @dev slot3: [0..255] empty slot for mapping track in the storage + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } + + // --- + // Events + // --- + + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); + event UnstETHUnlocked( + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement + ); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); + + event ETHClaimed(ETHValue amount); + + // --- + // Errors + // --- + + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error MinAssetsLockDurationNotPassed(Timestamp unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- + + function accountStETHSharesLock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = Timestamps.now(); + emit StETHSharesLocked(holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + if (assets.stETHLockedShares < shares) { + revert InvalidSharesValue(shares); + } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); + } + + function accountStETHSharesWithdraw( + Context storage self, + address holder + ) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); + + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(Context storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); + } + + // --- + // unstETH operations accounting + // --- + + function accountUnstETHLock( + Context storage self, + address holder, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + SharesValue totalUnstETHLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); + } + self.assets[holder].lastAssetsLockTimestamp = Timestamps.now(); + // mutated + self.assets[holder].unstETHLockedShares = + self.assets[holder].unstETHLockedShares + totalUnstETHLocked + totalUnstETHLocked; + // self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; + + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } + + function accountUnstETHUnlock(Context storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; + } + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); + + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); + } + + function accountUnstETHFinalized( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; + } + + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (ETHValue totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); + } + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + Context storage self, + address holder, + uint256[] memory unstETHIds + ) internal returns (ETHValue amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals( + Context storage self + ) internal view returns (SharesValue unfinalizedShares, ETHValue finalizedETH) { + finalizedETH = self.unstETHTotals.finalizedETH; + unfinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; + } + + function checkMinAssetsLockDurationPassed( + Context storage self, + address holder, + Duration minAssetsLockDuration + ) internal view { + Timestamp assetsUnlockAllowedAfter = minAssetsLockDuration.addTo(self.assets[holder].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert MinAssetsLockDurationNotPassed(assetsUnlockAllowedAfter); + } + } + + // --- + // Helper methods + // --- + + function _addUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); + + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } + + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); + + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); + } + + function _removeUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); + } + + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length); + + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.toZeroBasedValue()]; + assets.unstETHIds[unstETHIdIndex.toZeroBasedValue()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; + } + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; + } + + function _finalizeUnstETHRecord( + Context storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); + } + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); + + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; + + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _claimUnstETHRecord(Context storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } + } else { + unstETHRecord.claimableAmount = claimableAmount; + } + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _withdrawUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; + } + + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); + } + } +} diff --git a/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHUnlockOnlyOneShare.sol b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHUnlockOnlyOneShare.sol new file mode 100644 index 00000000..b32498d6 --- /dev/null +++ b/certora/mutation/mutants/AssetsAccounting/AssetsAccountingUnstETHUnlockOnlyOneShare.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +/// @notice Tracks the stETH and unstETH tokens associated with users +/// @param stETHLockedShares Total number of stETH shares held by the user +/// @param unstETHLockedShares Total number of shares contained in the unstETH NFTs held by the user +/// @param lastAssetsLockTimestamp Timestamp of the most recent lock of stETH shares or unstETH NFTs +/// @param unstETHIds List of unstETH ids locked by the user +struct HolderAssets { + /// @dev slot0: [0..39] + Timestamp lastAssetsLockTimestamp; + /// @dev slot0: [40..167] + SharesValue stETHLockedShares; + /// @dev slot1: [0..127] + SharesValue unstETHLockedShares; + /// @dev slot2: [0..255] - the length of the array + each item occupies 1 slot + uint256[] unstETHIds; +} + +/// @notice Tracks the unfinalized shares and finalized ETH amount of unstETH NFTs +/// @param unfinalizedShares Total number of unfinalized unstETH shares +/// @param finalizedETH Total amount of ETH claimable from finalized unstETH +struct UnstETHAccounting { + /// @dev slot0: [0..127] + SharesValue unfinalizedShares; + /// @dev slot1: [128..255] + ETHValue finalizedETH; +} + +/// @notice Tracks the locked shares and claimed ETH amounts +/// @param lockedShares Total number of accounted stETH shares +/// @param claimedETH Total amount of ETH received from claiming the locked stETH shares +struct StETHAccounting { + /// @dev slot0: [0..127] + SharesValue lockedShares; + /// @dev slot0: [128..255] + ETHValue claimedETH; +} + +/// @notice Represents the state of an accounted WithdrawalRequest +/// @param NotLocked Indicates the default value of the unstETH record, meaning it was not accounted as locked or +/// was unlocked by the account that previously locked it +/// @param Locked Indicates the unstETH record was accounted as locked +/// @param Finalized Indicates the unstETH record was marked as finalized +/// @param Claimed Indicates the unstETH record was claimed +/// @param Withdrawn Indicates the unstETH record was withdrawn after a successful claim +enum UnstETHRecordStatus { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +/// @notice Stores information about an accounted unstETH NFT +/// @param state The current state of the unstETH record. Refer to `UnstETHRecordStatus` for details. +/// @param index The one-based index of the unstETH record in the `UnstETHAccounting.unstETHIds` array +/// @param lockedBy The address of the account that locked the unstETH +/// @param shares The amount of shares contained in the unstETH +/// @param claimableAmount The amount of claimable ETH contained in the unstETH. This value is 0 +/// until the NFT is marked as finalized or claimed. +struct UnstETHRecord { + /// @dev slot 0: [0..7] + UnstETHRecordStatus status; + /// @dev slot 0: [8..39] + IndexOneBased index; + /// @dev slot 0: [40..199] + address lockedBy; + /// @dev slot 1: [0..127] + SharesValue shares; + /// @dev slot 1: [128..255] + ETHValue claimableAmount; +} + +/// @notice Provides functionality for accounting user stETH and unstETH tokens +/// locked in the Escrow contract +library AssetsAccounting { + /// @notice The context of the AssetsAccounting library + /// @param stETHTotals The total number of shares and the amount of stETH locked by users + /// @param unstETHTotals The total number of shares and the amount of unstETH locked by users + /// @param assets Mapping to store information about the assets locked by each user + /// @param unstETHRecords Mapping to track the state of the locked unstETH ids + struct Context { + /// @dev slot0: [0..255] + StETHAccounting stETHTotals; + /// @dev slot1: [0..255] + UnstETHAccounting unstETHTotals; + /// @dev slot2: [0..255] empty slot for mapping track in the storage + mapping(address account => HolderAssets) assets; + /// @dev slot3: [0..255] empty slot for mapping track in the storage + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } + + // --- + // Events + // --- + + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); + event UnstETHUnlocked( + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement + ); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); + + event ETHClaimed(ETHValue amount); + + // --- + // Errors + // --- + + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error MinAssetsLockDurationNotPassed(Timestamp unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- + + function accountStETHSharesLock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = Timestamps.now(); + emit StETHSharesLocked(holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); + } + + function accountStETHSharesUnlock(Context storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + if (assets.stETHLockedShares < shares) { + revert InvalidSharesValue(shares); + } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); + } + + function accountStETHSharesWithdraw( + Context storage self, + address holder + ) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); + + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(Context storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); + } + + // --- + // unstETH operations accounting + // --- + + function accountUnstETHLock( + Context storage self, + address holder, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + SharesValue totalUnstETHLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); + } + self.assets[holder].lastAssetsLockTimestamp = Timestamps.now(); + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; + + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } + + function accountUnstETHUnlock(Context storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; + } + // mutated + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - SharesValues.from(1); + // self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); + + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); + } + + function accountUnstETHFinalized( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; + } + + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + Context storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (ETHValue totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); + } + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + Context storage self, + address holder, + uint256[] memory unstETHIds + ) internal returns (ETHValue amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals( + Context storage self + ) internal view returns (SharesValue unfinalizedShares, ETHValue finalizedETH) { + finalizedETH = self.unstETHTotals.finalizedETH; + unfinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; + } + + function checkMinAssetsLockDurationPassed( + Context storage self, + address holder, + Duration minAssetsLockDuration + ) internal view { + Timestamp assetsUnlockAllowedAfter = minAssetsLockDuration.addTo(self.assets[holder].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert MinAssetsLockDurationNotPassed(assetsUnlockAllowedAfter); + } + } + + // --- + // Helper methods + // --- + + function _addUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); + + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } + + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); + + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); + } + + function _removeUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); + } + + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.fromOneBasedValue(assets.unstETHIds.length); + + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.toZeroBasedValue()]; + assets.unstETHIds[unstETHIdIndex.toZeroBasedValue()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; + } + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; + } + + function _finalizeUnstETHRecord( + Context storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); + } + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); + + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; + + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _claimUnstETHRecord(Context storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } + } else { + unstETHRecord.claimableAmount = claimableAmount; + } + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; + } + + function _withdrawUnstETHRecord( + Context storage self, + address holder, + uint256 unstETHId + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); + } + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; + } + + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); + } + } +} diff --git a/certora/mutation/mutants/DualGovernance/DualGovernance-NoScheduleCheck.sol b/certora/mutation/mutants/DualGovernance/DualGovernance-NoScheduleCheck.sol new file mode 100644 index 00000000..564b898a --- /dev/null +++ b/certora/mutation/mutants/DualGovernance/DualGovernance-NoScheduleCheck.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ITimelock} from "./interfaces/ITimelock.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; + +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; + +import {Proposers} from "./libraries/Proposers.sol"; +import {Tiebreaker} from "./libraries/Tiebreaker.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +import {State, DualGovernanceStateMachine} from "./libraries/DualGovernanceStateMachine.sol"; +import {IDualGovernanceConfigProvider} from "./DualGovernanceConfigProvider.sol"; + +import {Escrow} from "./Escrow.sol"; + +contract DualGovernance is IDualGovernance { + using Proposers for Proposers.Context; + using Tiebreaker for Tiebreaker.Context; + using DualGovernanceStateMachine for DualGovernanceStateMachine.Context; + + // --- + // Errors + // --- + + error NotAdminProposer(); + error UnownedAdminExecutor(); + error CallerIsNotResealCommittee(address caller); + error CallerIsNotAdminExecutor(address caller); + error InvalidConfigProvider(IDualGovernanceConfigProvider configProvider); + error ProposalSubmissionBlocked(); + error ProposalSchedulingBlocked(uint256 proposalId); + error ResealIsNotAllowedInNormalState(); + + // --- + // Events + // --- + + event EscrowMasterCopyDeployed(address escrowMasterCopy); + event ConfigProviderSet(IDualGovernanceConfigProvider newConfigProvider); + + // --- + // Tiebreaker Sanity Check Param Immutables + // --- + + struct SanityCheckParams { + uint256 minWithdrawalsBatchSize; + Duration minTiebreakerActivationTimeout; + Duration maxTiebreakerActivationTimeout; + uint256 maxSealableWithdrawalBlockersCount; + } + + Duration public immutable MIN_TIEBREAKER_ACTIVATION_TIMEOUT; + Duration public immutable MAX_TIEBREAKER_ACTIVATION_TIMEOUT; + uint256 public immutable MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT; + + // --- + // External Parts Immutables + + struct ExternalDependencies { + IStETH stETH; + IWstETH wstETH; + IWithdrawalQueue withdrawalQueue; + ITimelock timelock; + IResealManager resealManager; + IDualGovernanceConfigProvider configProvider; + } + + ITimelock public immutable TIMELOCK; + IResealManager public immutable RESEAL_MANAGER; + address public immutable ESCROW_MASTER_COPY; + + // --- + // Aspects + // --- + + Proposers.Context internal _proposers; + Tiebreaker.Context internal _tiebreaker; + DualGovernanceStateMachine.Context internal _stateMachine; + + // --- + // Standalone State Variables + // --- + IDualGovernanceConfigProvider internal _configProvider; + address internal _resealCommittee; + + constructor(ExternalDependencies memory dependencies, SanityCheckParams memory sanityCheckParams) { + TIMELOCK = dependencies.timelock; + RESEAL_MANAGER = dependencies.resealManager; + + MIN_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.minTiebreakerActivationTimeout; + MAX_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.maxTiebreakerActivationTimeout; + MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT = sanityCheckParams.maxSealableWithdrawalBlockersCount; + + _setConfigProvider(dependencies.configProvider); + + 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); + } + + // --- + // Proposals Flow + // --- + + function submitProposal(ExternalCall[] calldata calls) external returns (uint256 proposalId) { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + if (!_stateMachine.canSubmitProposal()) { + revert ProposalSubmissionBlocked(); + } + Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); + proposalId = TIMELOCK.submit(proposer.executor, calls); + } + + function scheduleProposal(uint256 proposalId) external { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + ( /* id */ , /* status */, /* executor */, Timestamp submittedAt, /* scheduledAt */ ) = + TIMELOCK.getProposalInfo(proposalId); + // MUTATION + // Comment out following lines + // if (!_stateMachine.canScheduleProposal(submittedAt)) { + // revert ProposalSchedulingBlocked(proposalId); + // } + TIMELOCK.schedule(proposalId); + } + + function cancelAllPendingProposals() external { + Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); + if (proposer.executor != TIMELOCK.getAdminExecutor()) { + revert NotAdminProposer(); + } + TIMELOCK.cancelAllNonExecutedProposals(); + } + + function canSubmitProposal() public view returns (bool) { + return _stateMachine.canSubmitProposal(); + } + + function canScheduleProposal(uint256 proposalId) external view returns (bool) { + ( /* id */ , /* status */, /* executor */, Timestamp submittedAt, /* scheduledAt */ ) = + TIMELOCK.getProposalInfo(proposalId); + return _stateMachine.canScheduleProposal(submittedAt) && TIMELOCK.canSchedule(proposalId); + } + + // --- + // Dual Governance State + // --- + + function activateNextState() external { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + } + + 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 + ); + } + + function getConfigProvider() external view returns (IDualGovernanceConfigProvider) { + return _configProvider; + } + + function getVetoSignallingEscrow() external view returns (address) { + return address(_stateMachine.signallingEscrow); + } + + function getRageQuitEscrow() external view returns (address) { + return address(_stateMachine.rageQuitEscrow); + } + + function getCurrentState() external view returns (State currentState) { + currentState = _stateMachine.getCurrentState(); + } + + function getCurrentStateContext() external view returns (DualGovernanceStateMachine.Context memory) { + return _stateMachine.getCurrentContext(); + } + + function getDynamicDelayDuration() external view returns (Duration) { + return _stateMachine.getDynamicDelayDuration(_configProvider.getDualGovernanceConfig()); + } + + // --- + // Proposers & Executors Management + // --- + + function registerProposer(address proposer, address executor) external { + _checkCallerIsAdminExecutor(); + _proposers.register(proposer, executor); + } + + function unregisterProposer(address proposer) external { + _checkCallerIsAdminExecutor(); + _proposers.unregister(proposer); + + /// @dev after the removal of the proposer, check that admin executor still belongs to some proposer + if (!_proposers.isExecutor(TIMELOCK.getAdminExecutor())) { + revert UnownedAdminExecutor(); + } + } + + function isProposer(address account) external view returns (bool) { + return _proposers.isProposer(account); + } + + function getProposer(address account) external view returns (Proposers.Proposer memory proposer) { + proposer = _proposers.getProposer(account); + } + + function getProposers() external view returns (Proposers.Proposer[] memory proposers) { + proposers = _proposers.getAllProposers(); + } + + function isExecutor(address account) external view returns (bool) { + return _proposers.isExecutor(account); + } + + // --- + // Tiebreaker Protection + // --- + + function addTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.addSealableWithdrawalBlocker(sealableWithdrawalBlocker, MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT); + } + + function removeTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.removeSealableWithdrawalBlocker(sealableWithdrawalBlocker); + } + + function setTiebreakerCommittee(address tiebreakerCommittee) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.setTiebreakerCommittee(tiebreakerCommittee); + } + + function setTiebreakerActivationTimeout(Duration tiebreakerActivationTimeout) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.setTiebreakerActivationTimeout( + MIN_TIEBREAKER_ACTIVATION_TIMEOUT, tiebreakerActivationTimeout, MAX_TIEBREAKER_ACTIVATION_TIMEOUT + ); + } + + function tiebreakerResumeSealable(address sealable) external { + _tiebreaker.checkCallerIsTiebreakerCommittee(); + _tiebreaker.checkTie(_stateMachine.getCurrentState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + RESEAL_MANAGER.resume(sealable); + } + + function tiebreakerScheduleProposal(uint256 proposalId) external { + _tiebreaker.checkCallerIsTiebreakerCommittee(); + _tiebreaker.checkTie(_stateMachine.getCurrentState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + TIMELOCK.schedule(proposalId); + } + + struct TiebreakerState { + address tiebreakerCommittee; + Duration tiebreakerActivationTimeout; + address[] sealableWithdrawalBlockers; + } + + function getTiebreakerState() external view returns (TiebreakerState memory tiebreakerState) { + ( + tiebreakerState.tiebreakerCommittee, + tiebreakerState.tiebreakerActivationTimeout, + tiebreakerState.sealableWithdrawalBlockers + ) = _tiebreaker.getTiebreakerInfo(); + } + + // --- + // Reseal executor + // --- + + function resealSealable(address sealable) external { + if (msg.sender != _resealCommittee) { + revert CallerIsNotResealCommittee(msg.sender); + } + if (_stateMachine.getCurrentState() == State.Normal) { + revert ResealIsNotAllowedInNormalState(); + } + RESEAL_MANAGER.reseal(sealable); + } + + function setResealCommittee(address resealCommittee) external { + _checkCallerIsAdminExecutor(); + _resealCommittee = resealCommittee; + } + + // --- + // Private methods + // --- + + function _setConfigProvider(IDualGovernanceConfigProvider newConfigProvider) internal { + if (address(newConfigProvider) == address(0)) { + revert InvalidConfigProvider(newConfigProvider); + } + + if (newConfigProvider == _configProvider) { + return; + } + + _configProvider = IDualGovernanceConfigProvider(newConfigProvider); + emit ConfigProviderSet(newConfigProvider); + } + + function _checkCallerIsAdminExecutor() internal view { + if (TIMELOCK.getAdminExecutor() != msg.sender) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/DualGovernance/DualGovernance-NoSubmitCheck.sol b/certora/mutation/mutants/DualGovernance/DualGovernance-NoSubmitCheck.sol new file mode 100644 index 00000000..279e33ba --- /dev/null +++ b/certora/mutation/mutants/DualGovernance/DualGovernance-NoSubmitCheck.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ITimelock} from "./interfaces/ITimelock.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; + +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; + +import {Proposers} from "./libraries/Proposers.sol"; +import {Tiebreaker} from "./libraries/Tiebreaker.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +import {State, DualGovernanceStateMachine} from "./libraries/DualGovernanceStateMachine.sol"; +import {IDualGovernanceConfigProvider} from "./DualGovernanceConfigProvider.sol"; + +import {Escrow} from "./Escrow.sol"; + +contract DualGovernance is IDualGovernance { + using Proposers for Proposers.Context; + using Tiebreaker for Tiebreaker.Context; + using DualGovernanceStateMachine for DualGovernanceStateMachine.Context; + + // --- + // Errors + // --- + + error NotAdminProposer(); + error UnownedAdminExecutor(); + error CallerIsNotResealCommittee(address caller); + error CallerIsNotAdminExecutor(address caller); + error InvalidConfigProvider(IDualGovernanceConfigProvider configProvider); + error ProposalSubmissionBlocked(); + error ProposalSchedulingBlocked(uint256 proposalId); + error ResealIsNotAllowedInNormalState(); + + // --- + // Events + // --- + + event EscrowMasterCopyDeployed(address escrowMasterCopy); + event ConfigProviderSet(IDualGovernanceConfigProvider newConfigProvider); + + // --- + // Tiebreaker Sanity Check Param Immutables + // --- + + struct SanityCheckParams { + uint256 minWithdrawalsBatchSize; + Duration minTiebreakerActivationTimeout; + Duration maxTiebreakerActivationTimeout; + uint256 maxSealableWithdrawalBlockersCount; + } + + Duration public immutable MIN_TIEBREAKER_ACTIVATION_TIMEOUT; + Duration public immutable MAX_TIEBREAKER_ACTIVATION_TIMEOUT; + uint256 public immutable MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT; + + // --- + // External Parts Immutables + + struct ExternalDependencies { + IStETH stETH; + IWstETH wstETH; + IWithdrawalQueue withdrawalQueue; + ITimelock timelock; + IResealManager resealManager; + IDualGovernanceConfigProvider configProvider; + } + + ITimelock public immutable TIMELOCK; + IResealManager public immutable RESEAL_MANAGER; + address public immutable ESCROW_MASTER_COPY; + + // --- + // Aspects + // --- + + Proposers.Context internal _proposers; + Tiebreaker.Context internal _tiebreaker; + DualGovernanceStateMachine.Context internal _stateMachine; + + // --- + // Standalone State Variables + // --- + IDualGovernanceConfigProvider internal _configProvider; + address internal _resealCommittee; + + constructor(ExternalDependencies memory dependencies, SanityCheckParams memory sanityCheckParams) { + TIMELOCK = dependencies.timelock; + RESEAL_MANAGER = dependencies.resealManager; + + MIN_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.minTiebreakerActivationTimeout; + MAX_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.maxTiebreakerActivationTimeout; + MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT = sanityCheckParams.maxSealableWithdrawalBlockersCount; + + _setConfigProvider(dependencies.configProvider); + + 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); + } + + // --- + // Proposals Flow + // --- + + function submitProposal(ExternalCall[] calldata calls) external returns (uint256 proposalId) { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + // MUTATION: + // Comment out following lines: + // if (!_stateMachine.canSubmitProposal()) { + // revert ProposalSubmissionBlocked(); + // } + Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); + proposalId = TIMELOCK.submit(proposer.executor, calls); + } + + function scheduleProposal(uint256 proposalId) external { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + ( /* id */ , /* status */, /* executor */, Timestamp submittedAt, /* scheduledAt */ ) = + TIMELOCK.getProposalInfo(proposalId); + if (!_stateMachine.canScheduleProposal(submittedAt)) { + revert ProposalSchedulingBlocked(proposalId); + } + TIMELOCK.schedule(proposalId); + } + + function cancelAllPendingProposals() external { + Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); + if (proposer.executor != TIMELOCK.getAdminExecutor()) { + revert NotAdminProposer(); + } + TIMELOCK.cancelAllNonExecutedProposals(); + } + + function canSubmitProposal() public view returns (bool) { + return _stateMachine.canSubmitProposal(); + } + + function canScheduleProposal(uint256 proposalId) external view returns (bool) { + ( /* id */ , /* status */, /* executor */, Timestamp submittedAt, /* scheduledAt */ ) = + TIMELOCK.getProposalInfo(proposalId); + return _stateMachine.canScheduleProposal(submittedAt) && TIMELOCK.canSchedule(proposalId); + } + + // --- + // Dual Governance State + // --- + + function activateNextState() external { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + } + + 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 + ); + } + + function getConfigProvider() external view returns (IDualGovernanceConfigProvider) { + return _configProvider; + } + + function getVetoSignallingEscrow() external view returns (address) { + return address(_stateMachine.signallingEscrow); + } + + function getRageQuitEscrow() external view returns (address) { + return address(_stateMachine.rageQuitEscrow); + } + + function getCurrentState() external view returns (State currentState) { + currentState = _stateMachine.getCurrentState(); + } + + function getCurrentStateContext() external view returns (DualGovernanceStateMachine.Context memory) { + return _stateMachine.getCurrentContext(); + } + + function getDynamicDelayDuration() external view returns (Duration) { + return _stateMachine.getDynamicDelayDuration(_configProvider.getDualGovernanceConfig()); + } + + // --- + // Proposers & Executors Management + // --- + + function registerProposer(address proposer, address executor) external { + _checkCallerIsAdminExecutor(); + _proposers.register(proposer, executor); + } + + function unregisterProposer(address proposer) external { + _checkCallerIsAdminExecutor(); + _proposers.unregister(proposer); + + /// @dev after the removal of the proposer, check that admin executor still belongs to some proposer + if (!_proposers.isExecutor(TIMELOCK.getAdminExecutor())) { + revert UnownedAdminExecutor(); + } + } + + function isProposer(address account) external view returns (bool) { + return _proposers.isProposer(account); + } + + function getProposer(address account) external view returns (Proposers.Proposer memory proposer) { + proposer = _proposers.getProposer(account); + } + + function getProposers() external view returns (Proposers.Proposer[] memory proposers) { + proposers = _proposers.getAllProposers(); + } + + function isExecutor(address account) external view returns (bool) { + return _proposers.isExecutor(account); + } + + // --- + // Tiebreaker Protection + // --- + + function addTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.addSealableWithdrawalBlocker(sealableWithdrawalBlocker, MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT); + } + + function removeTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.removeSealableWithdrawalBlocker(sealableWithdrawalBlocker); + } + + function setTiebreakerCommittee(address tiebreakerCommittee) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.setTiebreakerCommittee(tiebreakerCommittee); + } + + function setTiebreakerActivationTimeout(Duration tiebreakerActivationTimeout) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.setTiebreakerActivationTimeout( + MIN_TIEBREAKER_ACTIVATION_TIMEOUT, tiebreakerActivationTimeout, MAX_TIEBREAKER_ACTIVATION_TIMEOUT + ); + } + + function tiebreakerResumeSealable(address sealable) external { + _tiebreaker.checkCallerIsTiebreakerCommittee(); + _tiebreaker.checkTie(_stateMachine.getCurrentState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + RESEAL_MANAGER.resume(sealable); + } + + function tiebreakerScheduleProposal(uint256 proposalId) external { + _tiebreaker.checkCallerIsTiebreakerCommittee(); + _tiebreaker.checkTie(_stateMachine.getCurrentState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + TIMELOCK.schedule(proposalId); + } + + struct TiebreakerState { + address tiebreakerCommittee; + Duration tiebreakerActivationTimeout; + address[] sealableWithdrawalBlockers; + } + + function getTiebreakerState() external view returns (TiebreakerState memory tiebreakerState) { + ( + tiebreakerState.tiebreakerCommittee, + tiebreakerState.tiebreakerActivationTimeout, + tiebreakerState.sealableWithdrawalBlockers + ) = _tiebreaker.getTiebreakerInfo(); + } + + // --- + // Reseal executor + // --- + + function resealSealable(address sealable) external { + if (msg.sender != _resealCommittee) { + revert CallerIsNotResealCommittee(msg.sender); + } + if (_stateMachine.getCurrentState() == State.Normal) { + revert ResealIsNotAllowedInNormalState(); + } + RESEAL_MANAGER.reseal(sealable); + } + + function setResealCommittee(address resealCommittee) external { + _checkCallerIsAdminExecutor(); + _resealCommittee = resealCommittee; + } + + // --- + // Private methods + // --- + + function _setConfigProvider(IDualGovernanceConfigProvider newConfigProvider) internal { + if (address(newConfigProvider) == address(0)) { + revert InvalidConfigProvider(newConfigProvider); + } + + if (newConfigProvider == _configProvider) { + return; + } + + _configProvider = IDualGovernanceConfigProvider(newConfigProvider); + emit ConfigProviderSet(newConfigProvider); + } + + function _checkCallerIsAdminExecutor() internal view { + if (TIMELOCK.getAdminExecutor() != msg.sender) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/DualGovernance/DualGovernanceFindingW2-1.sol b/certora/mutation/mutants/DualGovernance/DualGovernanceFindingW2-1.sol new file mode 100644 index 00000000..6899d6eb --- /dev/null +++ b/certora/mutation/mutants/DualGovernance/DualGovernanceFindingW2-1.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ITimelock} from "./interfaces/ITimelock.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; + +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; +import {IResealManager} from "./interfaces/IResealManager.sol"; + +import {Proposers} from "./libraries/Proposers.sol"; +import {Tiebreaker} from "./libraries/Tiebreaker.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +import {State, DualGovernanceStateMachine} from "./libraries/DualGovernanceStateMachine.sol"; +import {IDualGovernanceConfigProvider} from "./DualGovernanceConfigProvider.sol"; + +import {Escrow} from "./Escrow.sol"; + +// MUTATION +// At time of writing there is actually no mutation. This mutant +// is a placeholder saving version of DualGovernance when +// finding W2-1 was identified. At time of writing this +// finding has not yet been fixed. The finding is also affected +// by Proposers.sol, so a placeholder is kept for that as well. + +contract DualGovernance is IDualGovernance { + using Proposers for Proposers.Context; + using Tiebreaker for Tiebreaker.Context; + using DualGovernanceStateMachine for DualGovernanceStateMachine.Context; + + // --- + // Errors + // --- + + error NotAdminProposer(); + error UnownedAdminExecutor(); + error CallerIsNotResealCommittee(address caller); + error CallerIsNotAdminExecutor(address caller); + error InvalidConfigProvider(IDualGovernanceConfigProvider configProvider); + error ProposalSubmissionBlocked(); + error ProposalSchedulingBlocked(uint256 proposalId); + error ResealIsNotAllowedInNormalState(); + + // --- + // Events + // --- + + event EscrowMasterCopyDeployed(address escrowMasterCopy); + event ConfigProviderSet(IDualGovernanceConfigProvider newConfigProvider); + + // --- + // Tiebreaker Sanity Check Param Immutables + // --- + + struct SanityCheckParams { + uint256 minWithdrawalsBatchSize; + Duration minTiebreakerActivationTimeout; + Duration maxTiebreakerActivationTimeout; + uint256 maxSealableWithdrawalBlockersCount; + } + + Duration public immutable MIN_TIEBREAKER_ACTIVATION_TIMEOUT; + Duration public immutable MAX_TIEBREAKER_ACTIVATION_TIMEOUT; + uint256 public immutable MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT; + + // --- + // External Parts Immutables + + struct ExternalDependencies { + IStETH stETH; + IWstETH wstETH; + IWithdrawalQueue withdrawalQueue; + ITimelock timelock; + IResealManager resealManager; + IDualGovernanceConfigProvider configProvider; + } + + ITimelock public immutable TIMELOCK; + IResealManager public immutable RESEAL_MANAGER; + address public immutable ESCROW_MASTER_COPY; + + // --- + // Aspects + // --- + + Proposers.Context internal _proposers; + Tiebreaker.Context internal _tiebreaker; + DualGovernanceStateMachine.Context internal _stateMachine; + + // --- + // Standalone State Variables + // --- + IDualGovernanceConfigProvider internal _configProvider; + address internal _resealCommittee; + + constructor(ExternalDependencies memory dependencies, SanityCheckParams memory sanityCheckParams) { + TIMELOCK = dependencies.timelock; + RESEAL_MANAGER = dependencies.resealManager; + + MIN_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.minTiebreakerActivationTimeout; + MAX_TIEBREAKER_ACTIVATION_TIMEOUT = sanityCheckParams.maxTiebreakerActivationTimeout; + MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT = sanityCheckParams.maxSealableWithdrawalBlockersCount; + + _setConfigProvider(dependencies.configProvider); + + 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); + } + + // --- + // Proposals Flow + // --- + + function submitProposal(ExternalCall[] calldata calls) external returns (uint256 proposalId) { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + if (!_stateMachine.canSubmitProposal()) { + revert ProposalSubmissionBlocked(); + } + Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); + proposalId = TIMELOCK.submit(proposer.executor, calls); + } + + function scheduleProposal(uint256 proposalId) external { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + ( /* id */ , /* status */, /* executor */, Timestamp submittedAt, /* scheduledAt */ ) = + TIMELOCK.getProposalInfo(proposalId); + if (!_stateMachine.canScheduleProposal(submittedAt)) { + revert ProposalSchedulingBlocked(proposalId); + } + TIMELOCK.schedule(proposalId); + } + + function cancelAllPendingProposals() external { + Proposers.Proposer memory proposer = _proposers.getProposer(msg.sender); + if (proposer.executor != TIMELOCK.getAdminExecutor()) { + revert NotAdminProposer(); + } + TIMELOCK.cancelAllNonExecutedProposals(); + } + + function canSubmitProposal() public view returns (bool) { + return _stateMachine.canSubmitProposal(); + } + + function canScheduleProposal(uint256 proposalId) external view returns (bool) { + ( /* id */ , /* status */, /* executor */, Timestamp submittedAt, /* scheduledAt */ ) = + TIMELOCK.getProposalInfo(proposalId); + return _stateMachine.canScheduleProposal(submittedAt) && TIMELOCK.canSchedule(proposalId); + } + + // --- + // Dual Governance State + // --- + + function activateNextState() external { + _stateMachine.activateNextState(_configProvider.getDualGovernanceConfig(), ESCROW_MASTER_COPY); + } + + 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 + ); + } + + function getConfigProvider() external view returns (IDualGovernanceConfigProvider) { + return _configProvider; + } + + function getVetoSignallingEscrow() external view returns (address) { + return address(_stateMachine.signallingEscrow); + } + + function getRageQuitEscrow() external view returns (address) { + return address(_stateMachine.rageQuitEscrow); + } + + function getCurrentState() external view returns (State currentState) { + currentState = _stateMachine.getCurrentState(); + } + + function getCurrentStateContext() external view returns (DualGovernanceStateMachine.Context memory) { + return _stateMachine.getCurrentContext(); + } + + function getDynamicDelayDuration() external view returns (Duration) { + return _stateMachine.getDynamicDelayDuration(_configProvider.getDualGovernanceConfig()); + } + + // --- + // Proposers & Executors Management + // --- + + function registerProposer(address proposer, address executor) external { + _checkCallerIsAdminExecutor(); + _proposers.register(proposer, executor); + } + + function unregisterProposer(address proposer) external { + _checkCallerIsAdminExecutor(); + _proposers.unregister(proposer); + + /// @dev after the removal of the proposer, check that admin executor still belongs to some proposer + if (!_proposers.isExecutor(TIMELOCK.getAdminExecutor())) { + revert UnownedAdminExecutor(); + } + } + + function isProposer(address account) external view returns (bool) { + return _proposers.isProposer(account); + } + + function getProposer(address account) external view returns (Proposers.Proposer memory proposer) { + proposer = _proposers.getProposer(account); + } + + function getProposers() external view returns (Proposers.Proposer[] memory proposers) { + proposers = _proposers.getAllProposers(); + } + + function isExecutor(address account) external view returns (bool) { + return _proposers.isExecutor(account); + } + + // --- + // Tiebreaker Protection + // --- + + function addTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.addSealableWithdrawalBlocker(sealableWithdrawalBlocker, MAX_SEALABLE_WITHDRAWAL_BLOCKERS_COUNT); + } + + function removeTiebreakerSealableWithdrawalBlocker(address sealableWithdrawalBlocker) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.removeSealableWithdrawalBlocker(sealableWithdrawalBlocker); + } + + function setTiebreakerCommittee(address tiebreakerCommittee) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.setTiebreakerCommittee(tiebreakerCommittee); + } + + function setTiebreakerActivationTimeout(Duration tiebreakerActivationTimeout) external { + _checkCallerIsAdminExecutor(); + _tiebreaker.setTiebreakerActivationTimeout( + MIN_TIEBREAKER_ACTIVATION_TIMEOUT, tiebreakerActivationTimeout, MAX_TIEBREAKER_ACTIVATION_TIMEOUT + ); + } + + function tiebreakerResumeSealable(address sealable) external { + _tiebreaker.checkCallerIsTiebreakerCommittee(); + _tiebreaker.checkTie(_stateMachine.getCurrentState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + RESEAL_MANAGER.resume(sealable); + } + + function tiebreakerScheduleProposal(uint256 proposalId) external { + _tiebreaker.checkCallerIsTiebreakerCommittee(); + _tiebreaker.checkTie(_stateMachine.getCurrentState(), _stateMachine.getNormalOrVetoCooldownStateExitedAt()); + TIMELOCK.schedule(proposalId); + } + + struct TiebreakerState { + address tiebreakerCommittee; + Duration tiebreakerActivationTimeout; + address[] sealableWithdrawalBlockers; + } + + function getTiebreakerState() external view returns (TiebreakerState memory tiebreakerState) { + ( + tiebreakerState.tiebreakerCommittee, + tiebreakerState.tiebreakerActivationTimeout, + tiebreakerState.sealableWithdrawalBlockers + ) = _tiebreaker.getTiebreakerInfo(); + } + + // --- + // Reseal executor + // --- + + function resealSealable(address sealable) external { + if (msg.sender != _resealCommittee) { + revert CallerIsNotResealCommittee(msg.sender); + } + if (_stateMachine.getCurrentState() == State.Normal) { + revert ResealIsNotAllowedInNormalState(); + } + RESEAL_MANAGER.reseal(sealable); + } + + function setResealCommittee(address resealCommittee) external { + _checkCallerIsAdminExecutor(); + _resealCommittee = resealCommittee; + } + + // --- + // Private methods + // --- + + function _setConfigProvider(IDualGovernanceConfigProvider newConfigProvider) internal { + if (address(newConfigProvider) == address(0)) { + revert InvalidConfigProvider(newConfigProvider); + } + + if (newConfigProvider == _configProvider) { + return; + } + + _configProvider = IDualGovernanceConfigProvider(newConfigProvider); + emit ConfigProviderSet(newConfigProvider); + } + + function _checkCallerIsAdminExecutor() internal view { + if (TIMELOCK.getAdminExecutor() != msg.sender) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadScheduleCheck.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadScheduleCheck.sol new file mode 100644 index 00000000..f63d01d9 --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadScheduleCheck.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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; + // Mutation + // Add following line + if (state == State.VetoSignalling) return true; + if (state == State.VetoCooldown) { + return proposalSubmissionTime <= self.vetoSignallingActivatedAt; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadSubmitCheck.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadSubmitCheck.sol new file mode 100644 index 00000000..77b7e19e --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadSubmitCheck.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + // MUTATION + // change submit check + return state == State.VetoSignallingDeactivation && state != State.VetoCooldown; + // 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; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadVetoCooldownDurationCheck.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadVetoCooldownDurationCheck.sol new file mode 100644 index 00000000..0381672b --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-BadVetoCooldownDurationCheck.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + // MUTATION: bad cooldown duration check + if (config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + // if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + // return State.VetoCooldown; + // } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-DeactivateAndBypassParent.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-DeactivateAndBypassParent.sol new file mode 100644 index 00000000..406c7329 --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-DeactivateAndBypassParent.sol @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + // MUTATION + // do not cross back into ragequit + // if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + // return State.RageQuit; + // } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-IndefiniteWNoRagequit.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-IndefiniteWNoRagequit.sol new file mode 100644 index 00000000..63a31653 --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-IndefiniteWNoRagequit.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + // MUTATION + // Change argument passed for ragequit support + return config.isFirstSealRageQuitSupportCrossed(PercentD16.wrap(12)) ? State.VetoSignalling : State.Normal; + // return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + // ? State.VetoSignalling + // : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-MultipleRageQuitEscrows.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-MultipleRageQuitEscrows.sol new file mode 100644 index 00000000..50565aec --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-MultipleRageQuitEscrows.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + // MUTATION + // Add following lines (meant to cause both escrows to enter ragequit) + self.signallingEscrow.startRageQuit(config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(0)); + self.rageQuitEscrow.startRageQuit(config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(0)); + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-NormalFromDeactivation.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-NormalFromDeactivation.sol new file mode 100644 index 00000000..604765a3 --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-NormalFromDeactivation.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + // MUTATION + // go to ragequit always + return State.RageQuit; + // return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + // ? State.RageQuit + // : State.Normal; + // return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + // ? State.VetoSignalling + // : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + // MUTATION: always go to normal state + // if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + // return State.VetoSignalling; + // } + + // if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + // return State.RageQuit; + // } + + // if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + // return State.VetoCooldown; + // } + + // return State.VetoSignallingDeactivation; + return State.Normal; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-SubmitCheckBadTimestamp.sol b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-SubmitCheckBadTimestamp.sol new file mode 100644 index 00000000..bed17800 --- /dev/null +++ b/certora/mutation/mutants/DualGovernanceStateMachine/DualGovernanceStateMachine-SubmitCheckBadTimestamp.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +import {IEscrow} from "../interfaces/IEscrow.sol"; + +import {Duration} from "../types/Duration.sol"; +import {PercentD16} from "../types/PercentD16.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {DualGovernanceConfig} from "./DualGovernanceConfig.sol"; + +enum State { + Unset, + Normal, + VetoSignalling, + VetoSignallingDeactivation, + VetoCooldown, + RageQuit +} + +library DualGovernanceStateMachine { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + 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; + } + + error AlreadyInitialized(); + + event NewSignallingEscrowDeployed(IEscrow indexed escrow); + event DualGovernanceStateChanged(State from, State to, Context state); + + function initialize( + Context storage self, + DualGovernanceConfig.Context memory config, + address escrowMasterCopy + ) internal { + if (self.state != State.Unset) { + revert AlreadyInitialized(); + } + + self.state = State.Normal; + self.enteredAt = Timestamps.now(); + _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); + + if (currentState == newState) { + return; + } + + self.state = newState; + self.enteredAt = Timestamps.now(); + + if (currentState == State.Normal || currentState == State.VetoCooldown) { + self.normalOrVetoCooldownExitedAt = Timestamps.now(); + } + + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } else if (newState == State.VetoSignalling) { + if (currentState == State.VetoSignallingDeactivation) { + self.vetoSignallingReactivationTime = Timestamps.now(); + } else { + self.vetoSignallingActivatedAt = Timestamps.now(); + } + } else if (newState == State.RageQuit) { + IEscrow signallingEscrow = self.signallingEscrow; + uint256 rageQuitRound = Math.min(self.rageQuitRound + 1, type(uint8).max); + self.rageQuitRound = uint8(rageQuitRound); + signallingEscrow.startRageQuit( + config.rageQuitExtensionDelay, config.calcRageQuitWithdrawalsTimelock(rageQuitRound) + ); + self.rageQuitEscrow = signallingEscrow; + _deployNewSignallingEscrow(self, escrowMasterCopy, config.minAssetsLockDuration); + } + + emit DualGovernanceStateChanged(currentState, newState, self); + } + + function getCurrentContext(Context storage self) internal pure returns (Context memory) { + return self; + } + + function getCurrentState(Context storage self) internal view returns (State) { + return self.state; + } + + function getNormalOrVetoCooldownStateExitedAt(Context storage self) internal view returns (Timestamp) { + return self.normalOrVetoCooldownExitedAt; + } + + function getDynamicDelayDuration( + Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (Duration) { + return config.calcDynamicDelayDuration(self.signallingEscrow.getRageQuitSupport()); + } + + function canSubmitProposal(Context storage self) internal view returns (bool) { + State state = self.state; + 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) { + // MUTATION + // change the check on the following line + return proposalSubmissionTime <= Timestamps.now(); + // return proposalSubmissionTime <= self.vetoSignallingActivatedAt; + } + return false; + } + + function _deployNewSignallingEscrow( + Context storage self, + address escrowMasterCopy, + Duration minAssetsLockDuration + ) private { + IEscrow newSignallingEscrow = IEscrow(Clones.clone(escrowMasterCopy)); + newSignallingEscrow.initialize(minAssetsLockDuration); + self.signallingEscrow = newSignallingEscrow; + emit NewSignallingEscrowDeployed(newSignallingEscrow); + } +} + +library DualGovernanceStateTransitions { + using DualGovernanceConfig for DualGovernanceConfig.Context; + + function getStateTransition( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) internal view returns (State currentState, State nextStatus) { + currentState = self.state; + if (currentState == State.Normal) { + nextStatus = _fromNormalState(self, config); + } else if (currentState == State.VetoSignalling) { + nextStatus = _fromVetoSignallingState(self, config); + } else if (currentState == State.VetoSignallingDeactivation) { + nextStatus = _fromVetoSignallingDeactivationState(self, config); + } else if (currentState == State.VetoCooldown) { + nextStatus = _fromVetoCooldownState(self, config); + } else if (currentState == State.RageQuit) { + nextStatus = _fromRageQuitState(self, config); + } else { + assert(false); + } + } + + function _fromNormalState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromVetoSignallingState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + return config.isVetoSignallingReactivationDurationPassed( + Timestamps.max(self.vetoSignallingReactivationTime, self.vetoSignallingActivatedAt) + ) ? State.VetoSignallingDeactivation : State.VetoSignalling; + } + + function _fromVetoSignallingDeactivationState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + PercentD16 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + + if (!config.isDynamicTimelockDurationPassed(self.vetoSignallingActivatedAt, rageQuitSupport)) { + return State.VetoSignalling; + } + + if (config.isSecondSealRageQuitSupportCrossed(rageQuitSupport)) { + return State.RageQuit; + } + + if (config.isVetoSignallingDeactivationMaxDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + + return State.VetoSignallingDeactivation; + } + + function _fromVetoCooldownState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!config.isVetoCooldownDurationPassed(self.enteredAt)) { + return State.VetoCooldown; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.Normal; + } + + function _fromRageQuitState( + DualGovernanceStateMachine.Context storage self, + DualGovernanceConfig.Context memory config + ) private view returns (State) { + if (!self.rageQuitEscrow.isRageQuitFinalized()) { + return State.RageQuit; + } + return config.isFirstSealRageQuitSupportCrossed(self.signallingEscrow.getRageQuitSupport()) + ? State.VetoSignalling + : State.VetoCooldown; + } +} diff --git a/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockEmergencyExecuteGuardMissing.sol b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockEmergencyExecuteGuardMissing.sol new file mode 100644 index 00000000..79b393f4 --- /dev/null +++ b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockEmergencyExecuteGuardMissing.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; + +import {IOwnable} from "./interfaces/IOwnable.sol"; +import {ITimelock, ProposalStatus} from "./interfaces/ITimelock.sol"; + +import {TimelockState} from "./libraries/TimelockState.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +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. +contract EmergencyProtectedTimelock is ITimelock { + using TimelockState for TimelockState.Context; + using ExecutableProposals for ExecutableProposals.Context; + using EmergencyProtection for EmergencyProtection.Context; + + error CallerIsNotAdminExecutor(address value); + + // --- + // Sanity Check Params Immutables + // --- + struct SanityCheckParams { + Duration maxAfterSubmitDelay; + Duration maxAfterScheduleDelay; + Duration maxEmergencyModeDuration; + Duration maxEmergencyProtectionDuration; + } + + Duration public immutable MAX_AFTER_SUBMIT_DELAY; + Duration public immutable MAX_AFTER_SCHEDULE_DELAY; + + Duration public immutable MAX_EMERGENCY_MODE_DURATION; + Duration public immutable MAX_EMERGENCY_PROTECTION_DURATION; + + // --- + // Admin Executor Immutables + // --- + + address private immutable _ADMIN_EXECUTOR; + + // --- + // Aspects + // --- + + TimelockState.Context internal _timelockState; + ExecutableProposals.Context internal _proposals; + EmergencyProtection.Context internal _emergencyProtection; + + constructor(SanityCheckParams memory sanityCheckParams, address adminExecutor) { + _ADMIN_EXECUTOR = adminExecutor; + + MAX_AFTER_SUBMIT_DELAY = sanityCheckParams.maxAfterSubmitDelay; + MAX_AFTER_SCHEDULE_DELAY = sanityCheckParams.maxAfterScheduleDelay; + MAX_EMERGENCY_MODE_DURATION = sanityCheckParams.maxEmergencyModeDuration; + MAX_EMERGENCY_PROTECTION_DURATION = sanityCheckParams.maxEmergencyModeDuration; + } + + // --- + // 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. + /// @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. + /// @return newProposalId The ID of the newly created proposal. + function submit(address executor, ExternalCall[] calldata calls) external returns (uint256 newProposalId) { + _timelockState.checkCallerIsGovernance(); + newProposalId = _proposals.submit(executor, calls); + } + + /// @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. + 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. + 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. + function cancelAllNonExecutedProposals() external { + _timelockState.checkCallerIsGovernance(); + _proposals.cancelAll(); + } + + // --- + // Timelock Management + // --- + + function setGovernance(address newGovernance) external { + _checkCallerIsAdminExecutor(); + _timelockState.setGovernance(newGovernance); + } + + function setDelays(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. + /// @param executor The address of the executor contract. + /// @param owner The address of the new owner. + function transferExecutorOwnership(address executor, address owner) external { + _checkCallerIsAdminExecutor(); + IOwnable(executor).transferOwnership(owner); + } + + // --- + // Emergency Protection Functionality + // --- + + function setupEmergencyProtection( + address emergencyGovernance, + address emergencyActivationCommittee, + address emergencyExecutionCommittee, + Timestamp emergencyProtectionEndDate, + Duration emergencyModeDuration + ) external { + _checkCallerIsAdminExecutor(); + + _emergencyProtection.setEmergencyGovernance(emergencyGovernance); + _emergencyProtection.setEmergencyActivationCommittee(emergencyActivationCommittee); + _emergencyProtection.setEmergencyProtectionEndDate( + emergencyProtectionEndDate, MAX_EMERGENCY_PROTECTION_DURATION + ); + _emergencyProtection.setEmergencyModeDuration(emergencyModeDuration, MAX_EMERGENCY_MODE_DURATION); + _emergencyProtection.setEmergencyExecutionCommittee(emergencyExecutionCommittee); + } + + /// @dev Activates the emergency mode. + /// Only the activation committee can call this function. + 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. + function emergencyExecute(uint256 proposalId) external { + _emergencyProtection.checkEmergencyMode({isActive: true}); + // mutated + //_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. + function deactivateEmergencyMode() external { + _emergencyProtection.checkEmergencyMode({isActive: true}); + if (!_emergencyProtection.isEmergencyModeDurationPassed()) { + _checkCallerIsAdminExecutor(); + } + _emergencyProtection.deactivateEmergencyMode(); + _proposals.cancelAll(); + } + + /// @dev Resets the system after entering the emergency mode. + /// Only the execution committee can call this function. + function emergencyReset() external { + _emergencyProtection.checkCallerIsEmergencyExecutionCommittee(); + _emergencyProtection.checkEmergencyMode({isActive: true}); + _emergencyProtection.deactivateEmergencyMode(); + + _timelockState.setGovernance(_emergencyProtection.emergencyGovernance); + _proposals.cancelAll(); + } + + function getEmergencyProtectionContext() external view returns (EmergencyProtection.Context memory) { + return _emergencyProtection; + } + + function isEmergencyProtectionEnabled() public view returns (bool) { + return _emergencyProtection.isEmergencyProtectionEnabled(); + } + + function isEmergencyModeActive() public view returns (bool isActive) { + isActive = _emergencyProtection.isEmergencyModeActive(); + } + + // --- + // Timelock View Methods + // --- + + function getGovernance() external view returns (address) { + return _timelockState.governance; + } + + function getAdminExecutor() external view returns (address) { + return _ADMIN_EXECUTOR; + } + + function getAfterSubmitDelay() external view returns (Duration) { + return _timelockState.getAfterSubmitDelay(); + } + + function getAfterScheduleDelay() external view returns (Duration) { + return _timelockState.getAfterScheduleDelay(); + } + + /// @dev Retrieves the details of a proposal. + /// @param proposalId The ID of the proposal. + /// @return proposal The Proposal struct containing the details of the proposal. + function getProposal(uint256 proposalId) external view returns (Proposal memory proposal) { + proposal.id = proposalId; + (proposal.status, proposal.executor, proposal.submittedAt, proposal.scheduledAt) = + _proposals.getProposalInfo(proposalId); + proposal.calls = _proposals.getProposalCalls(proposalId); + } + + /// @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 id The ID of the proposal. + /// @return 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. + /// @return executor The address of the executor responsible for executing the proposal's external calls. + /// @return submittedAt The timestamp when the proposal was submitted. + /// @return scheduledAt The timestamp when the proposal was scheduled for execution. Equals 0 if the proposal + /// was submitted but not yet scheduled. + function getProposalInfo( + uint256 proposalId + ) + external + view + returns (uint256 id, ProposalStatus status, address executor, Timestamp submittedAt, Timestamp scheduledAt) + { + id = proposalId; + (status, executor, submittedAt, scheduledAt) = _proposals.getProposalInfo(proposalId); + } + + /// @notice Retrieves the external calls associated with the specified proposal. + /// @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. + /// @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. + /// @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. + /// @return A boolean indicating if the proposal can be scheduled. + function canSchedule(uint256 proposalId) external view returns (bool) { + return _proposals.canSchedule(proposalId, _timelockState.getAfterSubmitDelay()); + } + + function _checkCallerIsAdminExecutor() internal view { + if (msg.sender != _ADMIN_EXECUTOR) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockEmergencyExecuteWrongCheckForEmergencyMode.sol b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockEmergencyExecuteWrongCheckForEmergencyMode.sol new file mode 100644 index 00000000..a4183b43 --- /dev/null +++ b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockEmergencyExecuteWrongCheckForEmergencyMode.sol @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; + +import {IOwnable} from "./interfaces/IOwnable.sol"; +import {ITimelock, ProposalStatus} from "./interfaces/ITimelock.sol"; + +import {TimelockState} from "./libraries/TimelockState.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +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. +contract EmergencyProtectedTimelock is ITimelock { + using TimelockState for TimelockState.Context; + using ExecutableProposals for ExecutableProposals.Context; + using EmergencyProtection for EmergencyProtection.Context; + + error CallerIsNotAdminExecutor(address value); + + // --- + // Sanity Check Params Immutables + // --- + struct SanityCheckParams { + Duration maxAfterSubmitDelay; + Duration maxAfterScheduleDelay; + Duration maxEmergencyModeDuration; + Duration maxEmergencyProtectionDuration; + } + + Duration public immutable MAX_AFTER_SUBMIT_DELAY; + Duration public immutable MAX_AFTER_SCHEDULE_DELAY; + + Duration public immutable MAX_EMERGENCY_MODE_DURATION; + Duration public immutable MAX_EMERGENCY_PROTECTION_DURATION; + + // --- + // Admin Executor Immutables + // --- + + address private immutable _ADMIN_EXECUTOR; + + // --- + // Aspects + // --- + + TimelockState.Context internal _timelockState; + ExecutableProposals.Context internal _proposals; + EmergencyProtection.Context internal _emergencyProtection; + + constructor(SanityCheckParams memory sanityCheckParams, address adminExecutor) { + _ADMIN_EXECUTOR = adminExecutor; + + MAX_AFTER_SUBMIT_DELAY = sanityCheckParams.maxAfterSubmitDelay; + MAX_AFTER_SCHEDULE_DELAY = sanityCheckParams.maxAfterScheduleDelay; + MAX_EMERGENCY_MODE_DURATION = sanityCheckParams.maxEmergencyModeDuration; + MAX_EMERGENCY_PROTECTION_DURATION = sanityCheckParams.maxEmergencyModeDuration; + } + + // --- + // 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. + /// @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. + /// @return newProposalId The ID of the newly created proposal. + function submit(address executor, ExternalCall[] calldata calls) external returns (uint256 newProposalId) { + _timelockState.checkCallerIsGovernance(); + newProposalId = _proposals.submit(executor, calls); + } + + /// @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. + 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. + 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. + function cancelAllNonExecutedProposals() external { + _timelockState.checkCallerIsGovernance(); + _proposals.cancelAll(); + } + + // --- + // Timelock Management + // --- + + function setGovernance(address newGovernance) external { + _checkCallerIsAdminExecutor(); + _timelockState.setGovernance(newGovernance); + } + + function setDelays(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. + /// @param executor The address of the executor contract. + /// @param owner The address of the new owner. + function transferExecutorOwnership(address executor, address owner) external { + _checkCallerIsAdminExecutor(); + IOwnable(executor).transferOwnership(owner); + } + + // --- + // Emergency Protection Functionality + // --- + + function setupEmergencyProtection( + address emergencyGovernance, + address emergencyActivationCommittee, + address emergencyExecutionCommittee, + Timestamp emergencyProtectionEndDate, + Duration emergencyModeDuration + ) external { + _checkCallerIsAdminExecutor(); + + _emergencyProtection.setEmergencyGovernance(emergencyGovernance); + _emergencyProtection.setEmergencyActivationCommittee(emergencyActivationCommittee); + _emergencyProtection.setEmergencyProtectionEndDate( + emergencyProtectionEndDate, MAX_EMERGENCY_PROTECTION_DURATION + ); + _emergencyProtection.setEmergencyModeDuration(emergencyModeDuration, MAX_EMERGENCY_MODE_DURATION); + _emergencyProtection.setEmergencyExecutionCommittee(emergencyExecutionCommittee); + } + + /// @dev Activates the emergency mode. + /// Only the activation committee can call this function. + 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. + function emergencyExecute(uint256 proposalId) external { + // mutated + _emergencyProtection.checkEmergencyMode({isActive: false}); + //_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. + function deactivateEmergencyMode() external { + _emergencyProtection.checkEmergencyMode({isActive: true}); + if (!_emergencyProtection.isEmergencyModeDurationPassed()) { + _checkCallerIsAdminExecutor(); + } + _emergencyProtection.deactivateEmergencyMode(); + _proposals.cancelAll(); + } + + /// @dev Resets the system after entering the emergency mode. + /// Only the execution committee can call this function. + function emergencyReset() external { + _emergencyProtection.checkCallerIsEmergencyExecutionCommittee(); + _emergencyProtection.checkEmergencyMode({isActive: true}); + _emergencyProtection.deactivateEmergencyMode(); + + _timelockState.setGovernance(_emergencyProtection.emergencyGovernance); + _proposals.cancelAll(); + } + + function getEmergencyProtectionContext() external view returns (EmergencyProtection.Context memory) { + return _emergencyProtection; + } + + function isEmergencyProtectionEnabled() public view returns (bool) { + return _emergencyProtection.isEmergencyProtectionEnabled(); + } + + function isEmergencyModeActive() public view returns (bool isActive) { + isActive = _emergencyProtection.isEmergencyModeActive(); + } + + // --- + // Timelock View Methods + // --- + + function getGovernance() external view returns (address) { + return _timelockState.governance; + } + + function getAdminExecutor() external view returns (address) { + return _ADMIN_EXECUTOR; + } + + function getAfterSubmitDelay() external view returns (Duration) { + return _timelockState.getAfterSubmitDelay(); + } + + function getAfterScheduleDelay() external view returns (Duration) { + return _timelockState.getAfterScheduleDelay(); + } + + /// @dev Retrieves the details of a proposal. + /// @param proposalId The ID of the proposal. + /// @return proposal The Proposal struct containing the details of the proposal. + function getProposal(uint256 proposalId) external view returns (Proposal memory proposal) { + proposal.id = proposalId; + (proposal.status, proposal.executor, proposal.submittedAt, proposal.scheduledAt) = + _proposals.getProposalInfo(proposalId); + proposal.calls = _proposals.getProposalCalls(proposalId); + } + + /// @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 id The ID of the proposal. + /// @return 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. + /// @return executor The address of the executor responsible for executing the proposal's external calls. + /// @return submittedAt The timestamp when the proposal was submitted. + /// @return scheduledAt The timestamp when the proposal was scheduled for execution. Equals 0 if the proposal + /// was submitted but not yet scheduled. + function getProposalInfo( + uint256 proposalId + ) + external + view + returns (uint256 id, ProposalStatus status, address executor, Timestamp submittedAt, Timestamp scheduledAt) + { + id = proposalId; + (status, executor, submittedAt, scheduledAt) = _proposals.getProposalInfo(proposalId); + } + + /// @notice Retrieves the external calls associated with the specified proposal. + /// @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. + /// @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. + /// @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. + /// @return A boolean indicating if the proposal can be scheduled. + function canSchedule(uint256 proposalId) external view returns (bool) { + return _proposals.canSchedule(proposalId, _timelockState.getAfterSubmitDelay()); + } + + function _checkCallerIsAdminExecutor() internal view { + if (msg.sender != _ADMIN_EXECUTOR) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockScheduleGuardMissing.sol b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockScheduleGuardMissing.sol new file mode 100644 index 00000000..39ee0b08 --- /dev/null +++ b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockScheduleGuardMissing.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; + +import {IOwnable} from "./interfaces/IOwnable.sol"; +import {ITimelock, ProposalStatus} from "./interfaces/ITimelock.sol"; + +import {TimelockState} from "./libraries/TimelockState.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +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. +contract EmergencyProtectedTimelock is ITimelock { + using TimelockState for TimelockState.Context; + using ExecutableProposals for ExecutableProposals.Context; + using EmergencyProtection for EmergencyProtection.Context; + + error CallerIsNotAdminExecutor(address value); + + // --- + // Sanity Check Params Immutables + // --- + struct SanityCheckParams { + Duration maxAfterSubmitDelay; + Duration maxAfterScheduleDelay; + Duration maxEmergencyModeDuration; + Duration maxEmergencyProtectionDuration; + } + + Duration public immutable MAX_AFTER_SUBMIT_DELAY; + Duration public immutable MAX_AFTER_SCHEDULE_DELAY; + + Duration public immutable MAX_EMERGENCY_MODE_DURATION; + Duration public immutable MAX_EMERGENCY_PROTECTION_DURATION; + + // --- + // Admin Executor Immutables + // --- + + address private immutable _ADMIN_EXECUTOR; + + // --- + // Aspects + // --- + + TimelockState.Context internal _timelockState; + ExecutableProposals.Context internal _proposals; + EmergencyProtection.Context internal _emergencyProtection; + + constructor(SanityCheckParams memory sanityCheckParams, address adminExecutor) { + _ADMIN_EXECUTOR = adminExecutor; + + MAX_AFTER_SUBMIT_DELAY = sanityCheckParams.maxAfterSubmitDelay; + MAX_AFTER_SCHEDULE_DELAY = sanityCheckParams.maxAfterScheduleDelay; + MAX_EMERGENCY_MODE_DURATION = sanityCheckParams.maxEmergencyModeDuration; + MAX_EMERGENCY_PROTECTION_DURATION = sanityCheckParams.maxEmergencyModeDuration; + } + + // --- + // 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. + /// @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. + /// @return newProposalId The ID of the newly created proposal. + function submit(address executor, ExternalCall[] calldata calls) external returns (uint256 newProposalId) { + _timelockState.checkCallerIsGovernance(); + newProposalId = _proposals.submit(executor, calls); + } + + /// @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. + function schedule(uint256 proposalId) external { + // mutated + //_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. + 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. + function cancelAllNonExecutedProposals() external { + _timelockState.checkCallerIsGovernance(); + _proposals.cancelAll(); + } + + // --- + // Timelock Management + // --- + + function setGovernance(address newGovernance) external { + _checkCallerIsAdminExecutor(); + _timelockState.setGovernance(newGovernance); + } + + function setDelays(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. + /// @param executor The address of the executor contract. + /// @param owner The address of the new owner. + function transferExecutorOwnership(address executor, address owner) external { + _checkCallerIsAdminExecutor(); + IOwnable(executor).transferOwnership(owner); + } + + // --- + // Emergency Protection Functionality + // --- + + function setupEmergencyProtection( + address emergencyGovernance, + address emergencyActivationCommittee, + address emergencyExecutionCommittee, + Timestamp emergencyProtectionEndDate, + Duration emergencyModeDuration + ) external { + _checkCallerIsAdminExecutor(); + + _emergencyProtection.setEmergencyGovernance(emergencyGovernance); + _emergencyProtection.setEmergencyActivationCommittee(emergencyActivationCommittee); + _emergencyProtection.setEmergencyProtectionEndDate( + emergencyProtectionEndDate, MAX_EMERGENCY_PROTECTION_DURATION + ); + _emergencyProtection.setEmergencyModeDuration(emergencyModeDuration, MAX_EMERGENCY_MODE_DURATION); + _emergencyProtection.setEmergencyExecutionCommittee(emergencyExecutionCommittee); + } + + /// @dev Activates the emergency mode. + /// Only the activation committee can call this function. + 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. + 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. + function deactivateEmergencyMode() external { + _emergencyProtection.checkEmergencyMode({isActive: true}); + if (!_emergencyProtection.isEmergencyModeDurationPassed()) { + _checkCallerIsAdminExecutor(); + } + _emergencyProtection.deactivateEmergencyMode(); + _proposals.cancelAll(); + } + + /// @dev Resets the system after entering the emergency mode. + /// Only the execution committee can call this function. + function emergencyReset() external { + _emergencyProtection.checkCallerIsEmergencyExecutionCommittee(); + _emergencyProtection.checkEmergencyMode({isActive: true}); + _emergencyProtection.deactivateEmergencyMode(); + + _timelockState.setGovernance(_emergencyProtection.emergencyGovernance); + _proposals.cancelAll(); + } + + function getEmergencyProtectionContext() external view returns (EmergencyProtection.Context memory) { + return _emergencyProtection; + } + + function isEmergencyProtectionEnabled() public view returns (bool) { + return _emergencyProtection.isEmergencyProtectionEnabled(); + } + + function isEmergencyModeActive() public view returns (bool isActive) { + isActive = _emergencyProtection.isEmergencyModeActive(); + } + + // --- + // Timelock View Methods + // --- + + function getGovernance() external view returns (address) { + return _timelockState.governance; + } + + function getAdminExecutor() external view returns (address) { + return _ADMIN_EXECUTOR; + } + + function getAfterSubmitDelay() external view returns (Duration) { + return _timelockState.getAfterSubmitDelay(); + } + + function getAfterScheduleDelay() external view returns (Duration) { + return _timelockState.getAfterScheduleDelay(); + } + + /// @dev Retrieves the details of a proposal. + /// @param proposalId The ID of the proposal. + /// @return proposal The Proposal struct containing the details of the proposal. + function getProposal(uint256 proposalId) external view returns (Proposal memory proposal) { + proposal.id = proposalId; + (proposal.status, proposal.executor, proposal.submittedAt, proposal.scheduledAt) = + _proposals.getProposalInfo(proposalId); + proposal.calls = _proposals.getProposalCalls(proposalId); + } + + /// @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 id The ID of the proposal. + /// @return 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. + /// @return executor The address of the executor responsible for executing the proposal's external calls. + /// @return submittedAt The timestamp when the proposal was submitted. + /// @return scheduledAt The timestamp when the proposal was scheduled for execution. Equals 0 if the proposal + /// was submitted but not yet scheduled. + function getProposalInfo( + uint256 proposalId + ) + external + view + returns (uint256 id, ProposalStatus status, address executor, Timestamp submittedAt, Timestamp scheduledAt) + { + id = proposalId; + (status, executor, submittedAt, scheduledAt) = _proposals.getProposalInfo(proposalId); + } + + /// @notice Retrieves the external calls associated with the specified proposal. + /// @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. + /// @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. + /// @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. + /// @return A boolean indicating if the proposal can be scheduled. + function canSchedule(uint256 proposalId) external view returns (bool) { + return _proposals.canSchedule(proposalId, _timelockState.getAfterSubmitDelay()); + } + + function _checkCallerIsAdminExecutor() internal view { + if (msg.sender != _ADMIN_EXECUTOR) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockSubmitGuardMissing.sol b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockSubmitGuardMissing.sol new file mode 100644 index 00000000..5a000678 --- /dev/null +++ b/certora/mutation/mutants/EmergencyProtectedTimelock/EmergencyProtectedTimelockSubmitGuardMissing.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; + +import {IOwnable} from "./interfaces/IOwnable.sol"; +import {ITimelock, ProposalStatus} from "./interfaces/ITimelock.sol"; + +import {TimelockState} from "./libraries/TimelockState.sol"; +import {ExternalCall} from "./libraries/ExternalCalls.sol"; +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. +contract EmergencyProtectedTimelock is ITimelock { + using TimelockState for TimelockState.Context; + using ExecutableProposals for ExecutableProposals.Context; + using EmergencyProtection for EmergencyProtection.Context; + + error CallerIsNotAdminExecutor(address value); + + // --- + // Sanity Check Params Immutables + // --- + struct SanityCheckParams { + Duration maxAfterSubmitDelay; + Duration maxAfterScheduleDelay; + Duration maxEmergencyModeDuration; + Duration maxEmergencyProtectionDuration; + } + + Duration public immutable MAX_AFTER_SUBMIT_DELAY; + Duration public immutable MAX_AFTER_SCHEDULE_DELAY; + + Duration public immutable MAX_EMERGENCY_MODE_DURATION; + Duration public immutable MAX_EMERGENCY_PROTECTION_DURATION; + + // --- + // Admin Executor Immutables + // --- + + address private immutable _ADMIN_EXECUTOR; + + // --- + // Aspects + // --- + + TimelockState.Context internal _timelockState; + ExecutableProposals.Context internal _proposals; + EmergencyProtection.Context internal _emergencyProtection; + + constructor(SanityCheckParams memory sanityCheckParams, address adminExecutor) { + _ADMIN_EXECUTOR = adminExecutor; + + MAX_AFTER_SUBMIT_DELAY = sanityCheckParams.maxAfterSubmitDelay; + MAX_AFTER_SCHEDULE_DELAY = sanityCheckParams.maxAfterScheduleDelay; + MAX_EMERGENCY_MODE_DURATION = sanityCheckParams.maxEmergencyModeDuration; + MAX_EMERGENCY_PROTECTION_DURATION = sanityCheckParams.maxEmergencyModeDuration; + } + + // --- + // 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. + /// @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. + /// @return newProposalId The ID of the newly created proposal. + function submit(address executor, ExternalCall[] calldata calls) external returns (uint256 newProposalId) { + // mutated + //_timelockState.checkCallerIsGovernance(); + newProposalId = _proposals.submit(executor, calls); + } + + /// @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. + 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. + 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. + function cancelAllNonExecutedProposals() external { + _timelockState.checkCallerIsGovernance(); + _proposals.cancelAll(); + } + + // --- + // Timelock Management + // --- + + function setGovernance(address newGovernance) external { + _checkCallerIsAdminExecutor(); + _timelockState.setGovernance(newGovernance); + } + + function setDelays(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. + /// @param executor The address of the executor contract. + /// @param owner The address of the new owner. + function transferExecutorOwnership(address executor, address owner) external { + _checkCallerIsAdminExecutor(); + IOwnable(executor).transferOwnership(owner); + } + + // --- + // Emergency Protection Functionality + // --- + + function setupEmergencyProtection( + address emergencyGovernance, + address emergencyActivationCommittee, + address emergencyExecutionCommittee, + Timestamp emergencyProtectionEndDate, + Duration emergencyModeDuration + ) external { + _checkCallerIsAdminExecutor(); + + _emergencyProtection.setEmergencyGovernance(emergencyGovernance); + _emergencyProtection.setEmergencyActivationCommittee(emergencyActivationCommittee); + _emergencyProtection.setEmergencyProtectionEndDate( + emergencyProtectionEndDate, MAX_EMERGENCY_PROTECTION_DURATION + ); + _emergencyProtection.setEmergencyModeDuration(emergencyModeDuration, MAX_EMERGENCY_MODE_DURATION); + _emergencyProtection.setEmergencyExecutionCommittee(emergencyExecutionCommittee); + } + + /// @dev Activates the emergency mode. + /// Only the activation committee can call this function. + 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. + 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. + function deactivateEmergencyMode() external { + _emergencyProtection.checkEmergencyMode({isActive: true}); + if (!_emergencyProtection.isEmergencyModeDurationPassed()) { + _checkCallerIsAdminExecutor(); + } + _emergencyProtection.deactivateEmergencyMode(); + _proposals.cancelAll(); + } + + /// @dev Resets the system after entering the emergency mode. + /// Only the execution committee can call this function. + function emergencyReset() external { + _emergencyProtection.checkCallerIsEmergencyExecutionCommittee(); + _emergencyProtection.checkEmergencyMode({isActive: true}); + _emergencyProtection.deactivateEmergencyMode(); + + _timelockState.setGovernance(_emergencyProtection.emergencyGovernance); + _proposals.cancelAll(); + } + + function getEmergencyProtectionContext() external view returns (EmergencyProtection.Context memory) { + return _emergencyProtection; + } + + function isEmergencyProtectionEnabled() public view returns (bool) { + return _emergencyProtection.isEmergencyProtectionEnabled(); + } + + function isEmergencyModeActive() public view returns (bool isActive) { + isActive = _emergencyProtection.isEmergencyModeActive(); + } + + // --- + // Timelock View Methods + // --- + + function getGovernance() external view returns (address) { + return _timelockState.governance; + } + + function getAdminExecutor() external view returns (address) { + return _ADMIN_EXECUTOR; + } + + function getAfterSubmitDelay() external view returns (Duration) { + return _timelockState.getAfterSubmitDelay(); + } + + function getAfterScheduleDelay() external view returns (Duration) { + return _timelockState.getAfterScheduleDelay(); + } + + /// @dev Retrieves the details of a proposal. + /// @param proposalId The ID of the proposal. + /// @return proposal The Proposal struct containing the details of the proposal. + function getProposal(uint256 proposalId) external view returns (Proposal memory proposal) { + proposal.id = proposalId; + (proposal.status, proposal.executor, proposal.submittedAt, proposal.scheduledAt) = + _proposals.getProposalInfo(proposalId); + proposal.calls = _proposals.getProposalCalls(proposalId); + } + + /// @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 id The ID of the proposal. + /// @return 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. + /// @return executor The address of the executor responsible for executing the proposal's external calls. + /// @return submittedAt The timestamp when the proposal was submitted. + /// @return scheduledAt The timestamp when the proposal was scheduled for execution. Equals 0 if the proposal + /// was submitted but not yet scheduled. + function getProposalInfo( + uint256 proposalId + ) + external + view + returns (uint256 id, ProposalStatus status, address executor, Timestamp submittedAt, Timestamp scheduledAt) + { + id = proposalId; + (status, executor, submittedAt, scheduledAt) = _proposals.getProposalInfo(proposalId); + } + + /// @notice Retrieves the external calls associated with the specified proposal. + /// @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. + /// @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. + /// @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. + /// @return A boolean indicating if the proposal can be scheduled. + function canSchedule(uint256 proposalId) external view returns (bool) { + return _proposals.canSchedule(proposalId, _timelockState.getAfterSubmitDelay()); + } + + function _checkCallerIsAdminExecutor() internal view { + if (msg.sender != _ADMIN_EXECUTOR) { + revert CallerIsNotAdminExecutor(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/EmergencyProtection/EmergencyProtectionEmergencyModeActivationMissesProtectedModeCheck.sol b/certora/mutation/mutants/EmergencyProtection/EmergencyProtectionEmergencyModeActivationMissesProtectedModeCheck.sol new file mode 100644 index 00000000..7fbde780 --- /dev/null +++ b/certora/mutation/mutants/EmergencyProtection/EmergencyProtectionEmergencyModeActivationMissesProtectedModeCheck.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration, Durations} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +/// @title EmergencyProtection +/// @dev This library manages emergency protection functionality, allowing for +/// the activation and deactivation of emergency mode by designated committees. +library EmergencyProtection { + error CallerIsNotEmergencyActivationCommittee(address caller); + error CallerIsNotEmergencyExecutionCommittee(address caller); + error EmergencyProtectionExpired(Timestamp protectedTill); + error InvalidEmergencyModeDuration(Duration value); + error InvalidEmergencyProtectionEndDate(Timestamp value); + error UnexpectedEmergencyModeState(bool value); + + event EmergencyModeActivated(); + event EmergencyModeDeactivated(); + event EmergencyGovernanceSet(address newEmergencyGovernance); + event EmergencyActivationCommitteeSet(address newActivationCommittee); + event EmergencyExecutionCommitteeSet(address newActivationCommittee); + event EmergencyModeDurationSet(Duration newEmergencyModeDuration); + event EmergencyProtectionEndDateSet(Timestamp newEmergencyProtectionEndDate); + + struct Context { + /// @dev slot0 [0..39] + Timestamp emergencyModeEndsAfter; + /// @dev slot0 [40..199] + address emergencyActivationCommittee; + /// @dev slot0 [200..240] + Timestamp emergencyProtectionEndsAfter; + /// @dev slot1 [0..159] + address emergencyExecutionCommittee; + /// @dev slot1 [160..191] + Duration emergencyModeDuration; + /// @dev slot2 [0..160] + address emergencyGovernance; + } + + // --- + // Main functionality + // --- + + /// @dev Activates the emergency mode. + /// @param self The storage reference to the Context struct. + function activateEmergencyMode(Context storage self) internal { + Timestamp now_ = Timestamps.now(); + + // mutated + //if (now_ > self.emergencyProtectionEndsAfter) { + // revert EmergencyProtectionExpired(self.emergencyProtectionEndsAfter); + //} + + self.emergencyModeEndsAfter = self.emergencyModeDuration.addTo(now_); + + emit EmergencyModeActivated(); + } + + /// @dev Deactivates the emergency mode. + /// @param self The storage reference to the Context struct. + function deactivateEmergencyMode(Context storage self) internal { + self.emergencyActivationCommittee = address(0); + self.emergencyExecutionCommittee = address(0); + self.emergencyProtectionEndsAfter = Timestamps.ZERO; + self.emergencyModeEndsAfter = Timestamps.ZERO; + self.emergencyModeDuration = Durations.ZERO; + emit EmergencyModeDeactivated(); + } + + // --- + // Setup functionality + // --- + + function setEmergencyGovernance(Context storage self, address newEmergencyGovernance) internal { + if (newEmergencyGovernance == self.emergencyGovernance) { + return; + } + self.emergencyGovernance = newEmergencyGovernance; + emit EmergencyGovernanceSet(newEmergencyGovernance); + } + + function setEmergencyProtectionEndDate( + Context storage self, + Timestamp newEmergencyProtectionEndDate, + Duration maxEmergencyProtectionDuration + ) internal { + if (newEmergencyProtectionEndDate > maxEmergencyProtectionDuration.addTo(Timestamps.now())) { + revert InvalidEmergencyProtectionEndDate(newEmergencyProtectionEndDate); + } + + if (newEmergencyProtectionEndDate == self.emergencyProtectionEndsAfter) { + return; + } + self.emergencyProtectionEndsAfter = newEmergencyProtectionEndDate; + emit EmergencyProtectionEndDateSet(newEmergencyProtectionEndDate); + } + + function setEmergencyModeDuration( + Context storage self, + Duration newEmergencyModeDuration, + Duration maxEmergencyModeDuration + ) internal { + if (newEmergencyModeDuration > maxEmergencyModeDuration) { + revert InvalidEmergencyModeDuration(newEmergencyModeDuration); + } + if (newEmergencyModeDuration == self.emergencyModeDuration) { + return; + } + + self.emergencyModeDuration = newEmergencyModeDuration; + emit EmergencyModeDurationSet(newEmergencyModeDuration); + } + + function setEmergencyActivationCommittee(Context storage self, address newActivationCommittee) internal { + if (newActivationCommittee == self.emergencyActivationCommittee) { + return; + } + self.emergencyActivationCommittee = newActivationCommittee; + emit EmergencyActivationCommitteeSet(newActivationCommittee); + } + + function setEmergencyExecutionCommittee(Context storage self, address newExecutionCommittee) internal { + if (newExecutionCommittee == self.emergencyExecutionCommittee) { + return; + } + self.emergencyExecutionCommittee = newExecutionCommittee; + emit EmergencyExecutionCommitteeSet(newExecutionCommittee); + } + + // --- + // Checks + // --- + + /// @dev Checks if the caller is the emergency activator and reverts if not. + /// @param self The storage reference to the Context struct. + function checkCallerIsEmergencyActivationCommittee(Context storage self) internal view { + if (self.emergencyActivationCommittee != msg.sender) { + revert CallerIsNotEmergencyActivationCommittee(msg.sender); + } + } + + /// @dev Checks if the caller is the emergency enactor and reverts if not. + /// @param self The storage reference to the Context struct. + function checkCallerIsEmergencyExecutionCommittee(Context storage self) internal view { + if (self.emergencyExecutionCommittee != msg.sender) { + revert CallerIsNotEmergencyExecutionCommittee(msg.sender); + } + } + + /// @dev Checks if the emergency mode matches with expected passed value and reverts if not. + /// @param self The storage reference to the Context struct. + /// @param isActive The expected value of the emergency mode. + function checkEmergencyMode(Context storage self, bool isActive) internal view { + if (isEmergencyModeActive(self) != isActive) { + revert UnexpectedEmergencyModeState(isActive); + } + } + + // --- + // Getters + // --- + + /// @dev Checks if the emergency mode is activated + /// @param self The storage reference to the Context struct. + /// @return Whether the emergency mode is activated or not. + function isEmergencyModeActive(Context storage self) internal view returns (bool) { + return self.emergencyModeEndsAfter.isNotZero(); + } + + /// @dev Checks if the emergency mode has passed. + /// @param self The storage reference to the Context struct. + /// @return Whether the emergency mode has passed or not. + function isEmergencyModeDurationPassed(Context storage self) internal view returns (bool) { + Timestamp endsAfter = self.emergencyModeEndsAfter; + return endsAfter.isNotZero() && Timestamps.now() > endsAfter; + } + + /// @dev Checks if the emergency protection is enabled. + /// @param self The storage reference to the Context struct. + /// @return Whether the emergency protection is enabled or not. + function isEmergencyProtectionEnabled(Context storage self) internal view returns (bool) { + return Timestamps.now() <= self.emergencyProtectionEndsAfter || self.emergencyModeEndsAfter.isNotZero(); + } +} diff --git a/certora/mutation/mutants/Escrow/EscrowBuggyGetRageQuitSupport.sol b/certora/mutation/mutants/Escrow/EscrowBuggyGetRageQuitSupport.sol new file mode 100644 index 00000000..270bba60 --- /dev/null +++ b/certora/mutation/mutants/Escrow/EscrowBuggyGetRageQuitSupport.sol @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ETHValue, ETHValues} from "./types/ETHValue.sol"; +import {SharesValue, SharesValues} from "./types/SharesValue.sol"; +import {PercentD16, PercentsD16} from "./types/PercentD16.sol"; + +import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; + +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 +struct LockedAssetsTotals { + uint256 stETHLockedShares; + uint256 stETHClaimedETH; + uint256 unstETHUnfinalizedShares; + uint256 unstETHFinalizedETH; +} + +struct VetoerState { + uint256 stETHLockedShares; + uint256 unstETHLockedShares; + uint256 unstETHIdsCount; + uint256 lastAssetsLockTimestamp; +} + +contract Escrow is IEscrow { + using EscrowState for EscrowState.Context; + using AssetsAccounting for AssetsAccounting.Context; + using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.Context; + + // --- + // Errors + // --- + + error UnclaimedBatches(); + error UnexpectedUnstETHId(); + error UnfinalizedUnstETHIds(); + error NonProxyCallsForbidden(); + error BatchesQueueIsNotClosed(); + error InvalidBatchSize(uint256 size); + error CallerIsNotDualGovernance(address caller); + error InvalidHintsLength(uint256 actual, uint256 expected); + error InvalidETHSender(address actual, address expected); + + // --- + // Events + // --- + + event ConfigProviderSet(address newConfigProvider); + + // --- + // Sanity check params immutables + // --- + + uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE; + + // --- + // Dependencies immutables + // --- + + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + + // --- + // Implementation immutables + + address private immutable _SELF; + IDualGovernance public immutable DUAL_GOVERNANCE; + + // --- + // Aspects + // --- + + EscrowState.Context internal _escrowState; + AssetsAccounting.Context private _accounting; + WithdrawalsBatchesQueue.Context private _batchesQueue; + + // --- + // Construction & initializing + // --- + + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) { + _SELF = address(this); + DUAL_GOVERNANCE = dualGovernance; + + ST_ETH = stETH; + WST_ETH = wstETH; + WITHDRAWAL_QUEUE = withdrawalQueue; + + MIN_WITHDRAWALS_BATCH_SIZE = minWithdrawalsBatchSize; + } + + function initialize(Duration minAssetsLockDuration) external { + if (address(this) == _SELF) { + revert NonProxyCallsForbidden(); + } + _checkCallerIsDualGovernance(); + + _escrowState.initialize(minAssetsLockDuration); + + ST_ETH.approve(address(WST_ETH), type(uint256).max); + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); + } + + // --- + // Lock & unlock stETH + // --- + + function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + lockedStETHShares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + ST_ETH.transferSharesFrom(msg.sender, address(this), lockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockStETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + + DUAL_GOVERNANCE.activateNextState(); + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + unlockedStETHShares = _accounting.accountStETHSharesUnlock(msg.sender).toUint256(); + ST_ETH.transferShares(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock wstETH + // --- + + function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WST_ETH.transferFrom(msg.sender, address(this), amount); + lockedStETHShares = ST_ETH.getSharesByPooledEth(WST_ETH.unwrap(amount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockWstETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + unlockedStETHShares = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); + WST_ETH.transfer(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock unstETH + // --- + function lockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + _accounting.accountUnstETHUnlock(msg.sender, unstETHIds); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + _escrowState.checkSignallingEscrow(); + + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + } + + // --- + // Convert to NFT + // --- + + function requestWithdrawals(uint256[] calldata stETHAmounts) external returns (uint256[] memory unstETHIds) { + _escrowState.checkSignallingEscrow(); + + unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stETHAmounts, address(this)); + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + + uint256 sharesTotal = 0; + for (uint256 i = 0; i < statuses.length; ++i) { + sharesTotal += statuses[i].amountOfShares; + } + _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + } + + // --- + // Start rage quit + // --- + + function startRageQuit(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock) external { + _checkCallerIsDualGovernance(); + _escrowState.startRageQuit(rageQuitExtensionDelay, rageQuitWithdrawalsTimelock); + _batchesQueue.open(WITHDRAWAL_QUEUE.getLastRequestId()); + } + + // --- + // Request withdrawal batches + // --- + + function requestNextWithdrawalsBatch(uint256 batchSize) external { + _escrowState.checkRageQuitEscrow(); + + if (batchSize < MIN_WITHDRAWALS_BATCH_SIZE) { + revert InvalidBatchSize(batchSize); + } + + uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); + uint256 minStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); + + if (stETHRemaining < minStETHWithdrawalRequestAmount) { + return _batchesQueue.close(); + } + + uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ + minRequestAmount: minStETHWithdrawalRequestAmount, + maxRequestAmount: maxStETHWithdrawalRequestAmount, + remainingAmount: Math.min(stETHRemaining, maxStETHWithdrawalRequestAmount * batchSize) + }); + + _batchesQueue.addUnstETHIds(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); + } + + // --- + // Claim requested withdrawal batches + // --- + + function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkBatchesClaimingInProgress(); + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); + + _claimNextWithdrawalsBatch( + unstETHIds[0], + unstETHIds, + WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) + ); + } + + 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 + // --- + + function startRageQuitExtensionDelay() 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 RageQuitExtensionDelay 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(); + } + + if (!_batchesQueue.isAllBatchesClaimed()) { + revert UnclaimedBatches(); + } + + _escrowState.startRageQuitExtensionDelay(); + } + + // --- + // Claim locked unstETH NFTs + // --- + + function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { + _escrowState.checkRageQuitEscrow(); + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + ETHValue totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); + } + + // --- + // Escrow management + // --- + + function setMinAssetsLockDuration(Duration newMinAssetsLockDuration) external { + _checkCallerIsDualGovernance(); + _escrowState.setMinAssetsLockDuration(newMinAssetsLockDuration); + } + + // --- + // Withdraw logic + // --- + + function withdrawETH() external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountStETHSharesWithdraw(msg.sender); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + function withdrawETH(uint256[] calldata unstETHIds) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); + totals.stETHLockedShares = stETHTotals.lockedShares.toUint256(); + + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + totals.unstETHUnfinalizedShares = unstETHTotals.unfinalizedShares.toUint256(); + totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); + } + + function getVetoerState(address vetoer) external view returns (VetoerState memory state) { + HolderAssets storage assets = _accounting.assets[vetoer]; + + state.unstETHIdsCount = assets.unstETHIds.length; + state.stETHLockedShares = assets.stETHLockedShares.toUint256(); + state.unstETHLockedShares = assets.stETHLockedShares.toUint256(); + state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); + } + + function getUnclaimedUnstETHIdsCount() external view returns (uint256) { + return _batchesQueue.getTotalUnclaimedUnstETHIdsCount(); + } + + function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { + return _batchesQueue.getNextWithdrawalsBatches(limit); + } + + function isWithdrawalsBatchesFinalized() external view returns (bool) { + return _batchesQueue.isClosed(); + } + + function isRageQuitExtensionDelayStarted() external view returns (bool) { + return _escrowState.isRageQuitExtensionDelayStarted(); + } + + function getRageQuitExtensionDelayStartedAt() external view returns (Timestamp) { + return _escrowState.rageQuitExtensionDelayStartedAt; + } + + function getRageQuitSupport() external view returns (PercentD16) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + + uint256 finalizedETH = unstETHTotals.finalizedETH.toUint256(); + // mutated + uint256 unfinalizedShares = (stETHTotals.lockedShares + stETHTotals.lockedShares).toUint256(); + //uint256 unfinalizedShares = (stETHTotals.lockedShares + unstETHTotals.unfinalizedShares).toUint256(); + + return PercentsD16.fromFraction({ + numerator: ST_ETH.getPooledEthByShares(unfinalizedShares) + finalizedETH, + denominator: ST_ETH.totalSupply() + finalizedETH + }); + } + + function isRageQuitFinalized() external view returns (bool) { + return _escrowState.isRageQuitEscrow() && _escrowState.isRageQuitExtensionDelayPassed(); + } + + // --- + // Receive ETH + // --- + + receive() external payable { + if (msg.sender != address(WITHDRAWAL_QUEUE)) { + revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); + } + } + + // --- + // Internal methods + // --- + + function _claimNextWithdrawalsBatch( + uint256 fromUnstETHId, + uint256[] memory unstETHIds, + uint256[] memory hints + ) internal { + if (fromUnstETHId != unstETHIds[0]) { + revert UnexpectedUnstETHId(); + } + + if (hints.length != unstETHIds.length) { + revert InvalidHintsLength(hints.length, unstETHIds.length); + } + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + _accounting.accountClaimedStETH(ethBalanceAfter - ethBalanceBefore); + } + + function _checkCallerIsDualGovernance() internal view { + if (msg.sender != address(DUAL_GOVERNANCE)) { + revert CallerIsNotDualGovernance(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/Escrow/EscrowLockStETHMissingGuard.sol b/certora/mutation/mutants/Escrow/EscrowLockStETHMissingGuard.sol new file mode 100644 index 00000000..c8954474 --- /dev/null +++ b/certora/mutation/mutants/Escrow/EscrowLockStETHMissingGuard.sol @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ETHValue, ETHValues} from "./types/ETHValue.sol"; +import {SharesValue, SharesValues} from "./types/SharesValue.sol"; +import {PercentD16, PercentsD16} from "./types/PercentD16.sol"; + +import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; + +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 +struct LockedAssetsTotals { + uint256 stETHLockedShares; + uint256 stETHClaimedETH; + uint256 unstETHUnfinalizedShares; + uint256 unstETHFinalizedETH; +} + +struct VetoerState { + uint256 stETHLockedShares; + uint256 unstETHLockedShares; + uint256 unstETHIdsCount; + uint256 lastAssetsLockTimestamp; +} + +contract Escrow is IEscrow { + using EscrowState for EscrowState.Context; + using AssetsAccounting for AssetsAccounting.Context; + using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.Context; + + // --- + // Errors + // --- + + error UnclaimedBatches(); + error UnexpectedUnstETHId(); + error UnfinalizedUnstETHIds(); + error NonProxyCallsForbidden(); + error BatchesQueueIsNotClosed(); + error InvalidBatchSize(uint256 size); + error CallerIsNotDualGovernance(address caller); + error InvalidHintsLength(uint256 actual, uint256 expected); + error InvalidETHSender(address actual, address expected); + + // --- + // Events + // --- + + event ConfigProviderSet(address newConfigProvider); + + // --- + // Sanity check params immutables + // --- + + uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE; + + // --- + // Dependencies immutables + // --- + + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + + // --- + // Implementation immutables + + address private immutable _SELF; + IDualGovernance public immutable DUAL_GOVERNANCE; + + // --- + // Aspects + // --- + + EscrowState.Context internal _escrowState; + AssetsAccounting.Context private _accounting; + WithdrawalsBatchesQueue.Context private _batchesQueue; + + // --- + // Construction & initializing + // --- + + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) { + _SELF = address(this); + DUAL_GOVERNANCE = dualGovernance; + + ST_ETH = stETH; + WST_ETH = wstETH; + WITHDRAWAL_QUEUE = withdrawalQueue; + + MIN_WITHDRAWALS_BATCH_SIZE = minWithdrawalsBatchSize; + } + + function initialize(Duration minAssetsLockDuration) external { + if (address(this) == _SELF) { + revert NonProxyCallsForbidden(); + } + _checkCallerIsDualGovernance(); + + _escrowState.initialize(minAssetsLockDuration); + + ST_ETH.approve(address(WST_ETH), type(uint256).max); + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); + } + + // --- + // Lock & unlock stETH + // --- + + function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { + // mutated + //_escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + lockedStETHShares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + ST_ETH.transferSharesFrom(msg.sender, address(this), lockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockStETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + + DUAL_GOVERNANCE.activateNextState(); + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + unlockedStETHShares = _accounting.accountStETHSharesUnlock(msg.sender).toUint256(); + ST_ETH.transferShares(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock wstETH + // --- + + function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WST_ETH.transferFrom(msg.sender, address(this), amount); + lockedStETHShares = ST_ETH.getSharesByPooledEth(WST_ETH.unwrap(amount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockWstETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + unlockedStETHShares = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); + WST_ETH.transfer(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock unstETH + // --- + function lockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + _accounting.accountUnstETHUnlock(msg.sender, unstETHIds); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + _escrowState.checkSignallingEscrow(); + + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + } + + // --- + // Convert to NFT + // --- + + function requestWithdrawals(uint256[] calldata stETHAmounts) external returns (uint256[] memory unstETHIds) { + _escrowState.checkSignallingEscrow(); + + unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stETHAmounts, address(this)); + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + + uint256 sharesTotal = 0; + for (uint256 i = 0; i < statuses.length; ++i) { + sharesTotal += statuses[i].amountOfShares; + } + _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + } + + // --- + // Start rage quit + // --- + + function startRageQuit(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock) external { + _checkCallerIsDualGovernance(); + _escrowState.startRageQuit(rageQuitExtensionDelay, rageQuitWithdrawalsTimelock); + _batchesQueue.open(WITHDRAWAL_QUEUE.getLastRequestId()); + } + + // --- + // Request withdrawal batches + // --- + + function requestNextWithdrawalsBatch(uint256 batchSize) external { + _escrowState.checkRageQuitEscrow(); + + if (batchSize < MIN_WITHDRAWALS_BATCH_SIZE) { + revert InvalidBatchSize(batchSize); + } + + uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); + uint256 minStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); + + if (stETHRemaining < minStETHWithdrawalRequestAmount) { + return _batchesQueue.close(); + } + + uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ + minRequestAmount: minStETHWithdrawalRequestAmount, + maxRequestAmount: maxStETHWithdrawalRequestAmount, + remainingAmount: Math.min(stETHRemaining, maxStETHWithdrawalRequestAmount * batchSize) + }); + + _batchesQueue.addUnstETHIds(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); + } + + // --- + // Claim requested withdrawal batches + // --- + + function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkBatchesClaimingInProgress(); + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); + + _claimNextWithdrawalsBatch( + unstETHIds[0], + unstETHIds, + WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) + ); + } + + 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 + // --- + + function startRageQuitExtensionDelay() 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 RageQuitExtensionDelay 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(); + } + + if (!_batchesQueue.isAllBatchesClaimed()) { + revert UnclaimedBatches(); + } + + _escrowState.startRageQuitExtensionDelay(); + } + + // --- + // Claim locked unstETH NFTs + // --- + + function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { + _escrowState.checkRageQuitEscrow(); + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + ETHValue totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); + } + + // --- + // Escrow management + // --- + + function setMinAssetsLockDuration(Duration newMinAssetsLockDuration) external { + _checkCallerIsDualGovernance(); + _escrowState.setMinAssetsLockDuration(newMinAssetsLockDuration); + } + + // --- + // Withdraw logic + // --- + + function withdrawETH() external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountStETHSharesWithdraw(msg.sender); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + function withdrawETH(uint256[] calldata unstETHIds) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); + totals.stETHLockedShares = stETHTotals.lockedShares.toUint256(); + + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + totals.unstETHUnfinalizedShares = unstETHTotals.unfinalizedShares.toUint256(); + totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); + } + + function getVetoerState(address vetoer) external view returns (VetoerState memory state) { + HolderAssets storage assets = _accounting.assets[vetoer]; + + state.unstETHIdsCount = assets.unstETHIds.length; + state.stETHLockedShares = assets.stETHLockedShares.toUint256(); + state.unstETHLockedShares = assets.stETHLockedShares.toUint256(); + state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); + } + + function getUnclaimedUnstETHIdsCount() external view returns (uint256) { + return _batchesQueue.getTotalUnclaimedUnstETHIdsCount(); + } + + function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { + return _batchesQueue.getNextWithdrawalsBatches(limit); + } + + function isWithdrawalsBatchesFinalized() external view returns (bool) { + return _batchesQueue.isClosed(); + } + + function isRageQuitExtensionDelayStarted() external view returns (bool) { + return _escrowState.isRageQuitExtensionDelayStarted(); + } + + function getRageQuitExtensionDelayStartedAt() external view returns (Timestamp) { + return _escrowState.rageQuitExtensionDelayStartedAt; + } + + function getRageQuitSupport() external view returns (PercentD16) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + + uint256 finalizedETH = unstETHTotals.finalizedETH.toUint256(); + uint256 unfinalizedShares = (stETHTotals.lockedShares + unstETHTotals.unfinalizedShares).toUint256(); + + return PercentsD16.fromFraction({ + numerator: ST_ETH.getPooledEthByShares(unfinalizedShares) + finalizedETH, + denominator: ST_ETH.totalSupply() + finalizedETH + }); + } + + function isRageQuitFinalized() external view returns (bool) { + return _escrowState.isRageQuitEscrow() && _escrowState.isRageQuitExtensionDelayPassed(); + } + + // --- + // Receive ETH + // --- + + receive() external payable { + if (msg.sender != address(WITHDRAWAL_QUEUE)) { + revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); + } + } + + // --- + // Internal methods + // --- + + function _claimNextWithdrawalsBatch( + uint256 fromUnstETHId, + uint256[] memory unstETHIds, + uint256[] memory hints + ) internal { + if (fromUnstETHId != unstETHIds[0]) { + revert UnexpectedUnstETHId(); + } + + if (hints.length != unstETHIds.length) { + revert InvalidHintsLength(hints.length, unstETHIds.length); + } + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + _accounting.accountClaimedStETH(ethBalanceAfter - ethBalanceBefore); + } + + function _checkCallerIsDualGovernance() internal view { + if (msg.sender != address(DUAL_GOVERNANCE)) { + revert CallerIsNotDualGovernance(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/Escrow/EscrowStartRageQuitMissingGuard.sol b/certora/mutation/mutants/Escrow/EscrowStartRageQuitMissingGuard.sol new file mode 100644 index 00000000..8c5a846d --- /dev/null +++ b/certora/mutation/mutants/Escrow/EscrowStartRageQuitMissingGuard.sol @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ETHValue, ETHValues} from "./types/ETHValue.sol"; +import {SharesValue, SharesValues} from "./types/SharesValue.sol"; +import {PercentD16, PercentsD16} from "./types/PercentD16.sol"; + +import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; + +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 +struct LockedAssetsTotals { + uint256 stETHLockedShares; + uint256 stETHClaimedETH; + uint256 unstETHUnfinalizedShares; + uint256 unstETHFinalizedETH; +} + +struct VetoerState { + uint256 stETHLockedShares; + uint256 unstETHLockedShares; + uint256 unstETHIdsCount; + uint256 lastAssetsLockTimestamp; +} + +contract Escrow is IEscrow { + using EscrowState for EscrowState.Context; + using AssetsAccounting for AssetsAccounting.Context; + using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.Context; + + // --- + // Errors + // --- + + error UnclaimedBatches(); + error UnexpectedUnstETHId(); + error UnfinalizedUnstETHIds(); + error NonProxyCallsForbidden(); + error BatchesQueueIsNotClosed(); + error InvalidBatchSize(uint256 size); + error CallerIsNotDualGovernance(address caller); + error InvalidHintsLength(uint256 actual, uint256 expected); + error InvalidETHSender(address actual, address expected); + + // --- + // Events + // --- + + event ConfigProviderSet(address newConfigProvider); + + // --- + // Sanity check params immutables + // --- + + uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE; + + // --- + // Dependencies immutables + // --- + + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + + // --- + // Implementation immutables + + address private immutable _SELF; + IDualGovernance public immutable DUAL_GOVERNANCE; + + // --- + // Aspects + // --- + + EscrowState.Context internal _escrowState; + AssetsAccounting.Context private _accounting; + WithdrawalsBatchesQueue.Context private _batchesQueue; + + // --- + // Construction & initializing + // --- + + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) { + _SELF = address(this); + DUAL_GOVERNANCE = dualGovernance; + + ST_ETH = stETH; + WST_ETH = wstETH; + WITHDRAWAL_QUEUE = withdrawalQueue; + + MIN_WITHDRAWALS_BATCH_SIZE = minWithdrawalsBatchSize; + } + + function initialize(Duration minAssetsLockDuration) external { + if (address(this) == _SELF) { + revert NonProxyCallsForbidden(); + } + _checkCallerIsDualGovernance(); + + _escrowState.initialize(minAssetsLockDuration); + + ST_ETH.approve(address(WST_ETH), type(uint256).max); + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); + } + + // --- + // Lock & unlock stETH + // --- + + function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + lockedStETHShares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + ST_ETH.transferSharesFrom(msg.sender, address(this), lockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockStETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + + DUAL_GOVERNANCE.activateNextState(); + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + unlockedStETHShares = _accounting.accountStETHSharesUnlock(msg.sender).toUint256(); + ST_ETH.transferShares(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock wstETH + // --- + + function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WST_ETH.transferFrom(msg.sender, address(this), amount); + lockedStETHShares = ST_ETH.getSharesByPooledEth(WST_ETH.unwrap(amount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockWstETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + unlockedStETHShares = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); + WST_ETH.transfer(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock unstETH + // --- + function lockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + _accounting.accountUnstETHUnlock(msg.sender, unstETHIds); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + _escrowState.checkSignallingEscrow(); + + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + } + + // --- + // Convert to NFT + // --- + + function requestWithdrawals(uint256[] calldata stETHAmounts) external returns (uint256[] memory unstETHIds) { + _escrowState.checkSignallingEscrow(); + + unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stETHAmounts, address(this)); + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + + uint256 sharesTotal = 0; + for (uint256 i = 0; i < statuses.length; ++i) { + sharesTotal += statuses[i].amountOfShares; + } + _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + } + + // --- + // Start rage quit + // --- + + function startRageQuit(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock) external { + // mutated + //_checkCallerIsDualGovernance(); + _escrowState.startRageQuit(rageQuitExtensionDelay, rageQuitWithdrawalsTimelock); + _batchesQueue.open(WITHDRAWAL_QUEUE.getLastRequestId()); + } + + // --- + // Request withdrawal batches + // --- + + function requestNextWithdrawalsBatch(uint256 batchSize) external { + _escrowState.checkRageQuitEscrow(); + + if (batchSize < MIN_WITHDRAWALS_BATCH_SIZE) { + revert InvalidBatchSize(batchSize); + } + + uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); + uint256 minStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); + + if (stETHRemaining < minStETHWithdrawalRequestAmount) { + return _batchesQueue.close(); + } + + uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ + minRequestAmount: minStETHWithdrawalRequestAmount, + maxRequestAmount: maxStETHWithdrawalRequestAmount, + remainingAmount: Math.min(stETHRemaining, maxStETHWithdrawalRequestAmount * batchSize) + }); + + _batchesQueue.addUnstETHIds(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); + } + + // --- + // Claim requested withdrawal batches + // --- + + function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkBatchesClaimingInProgress(); + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); + + _claimNextWithdrawalsBatch( + unstETHIds[0], + unstETHIds, + WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) + ); + } + + 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 + // --- + + function startRageQuitExtensionDelay() 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 RageQuitExtensionDelay 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(); + } + + if (!_batchesQueue.isAllBatchesClaimed()) { + revert UnclaimedBatches(); + } + + _escrowState.startRageQuitExtensionDelay(); + } + + // --- + // Claim locked unstETH NFTs + // --- + + function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { + _escrowState.checkRageQuitEscrow(); + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + ETHValue totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); + } + + // --- + // Escrow management + // --- + + function setMinAssetsLockDuration(Duration newMinAssetsLockDuration) external { + _checkCallerIsDualGovernance(); + _escrowState.setMinAssetsLockDuration(newMinAssetsLockDuration); + } + + // --- + // Withdraw logic + // --- + + function withdrawETH() external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountStETHSharesWithdraw(msg.sender); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + function withdrawETH(uint256[] calldata unstETHIds) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); + totals.stETHLockedShares = stETHTotals.lockedShares.toUint256(); + + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + totals.unstETHUnfinalizedShares = unstETHTotals.unfinalizedShares.toUint256(); + totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); + } + + function getVetoerState(address vetoer) external view returns (VetoerState memory state) { + HolderAssets storage assets = _accounting.assets[vetoer]; + + state.unstETHIdsCount = assets.unstETHIds.length; + state.stETHLockedShares = assets.stETHLockedShares.toUint256(); + state.unstETHLockedShares = assets.stETHLockedShares.toUint256(); + state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); + } + + function getUnclaimedUnstETHIdsCount() external view returns (uint256) { + return _batchesQueue.getTotalUnclaimedUnstETHIdsCount(); + } + + function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { + return _batchesQueue.getNextWithdrawalsBatches(limit); + } + + function isWithdrawalsBatchesFinalized() external view returns (bool) { + return _batchesQueue.isClosed(); + } + + function isRageQuitExtensionDelayStarted() external view returns (bool) { + return _escrowState.isRageQuitExtensionDelayStarted(); + } + + function getRageQuitExtensionDelayStartedAt() external view returns (Timestamp) { + return _escrowState.rageQuitExtensionDelayStartedAt; + } + + function getRageQuitSupport() external view returns (PercentD16) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + + uint256 finalizedETH = unstETHTotals.finalizedETH.toUint256(); + uint256 unfinalizedShares = (stETHTotals.lockedShares + unstETHTotals.unfinalizedShares).toUint256(); + + return PercentsD16.fromFraction({ + numerator: ST_ETH.getPooledEthByShares(unfinalizedShares) + finalizedETH, + denominator: ST_ETH.totalSupply() + finalizedETH + }); + } + + function isRageQuitFinalized() external view returns (bool) { + return _escrowState.isRageQuitEscrow() && _escrowState.isRageQuitExtensionDelayPassed(); + } + + // --- + // Receive ETH + // --- + + receive() external payable { + if (msg.sender != address(WITHDRAWAL_QUEUE)) { + revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); + } + } + + // --- + // Internal methods + // --- + + function _claimNextWithdrawalsBatch( + uint256 fromUnstETHId, + uint256[] memory unstETHIds, + uint256[] memory hints + ) internal { + if (fromUnstETHId != unstETHIds[0]) { + revert UnexpectedUnstETHId(); + } + + if (hints.length != unstETHIds.length) { + revert InvalidHintsLength(hints.length, unstETHIds.length); + } + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + _accounting.accountClaimedStETH(ethBalanceAfter - ethBalanceBefore); + } + + function _checkCallerIsDualGovernance() internal view { + if (msg.sender != address(DUAL_GOVERNANCE)) { + revert CallerIsNotDualGovernance(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/Escrow/EscrowUnlockStETHMissingTimeGuard.sol b/certora/mutation/mutants/Escrow/EscrowUnlockStETHMissingTimeGuard.sol new file mode 100644 index 00000000..d26b4732 --- /dev/null +++ b/certora/mutation/mutants/Escrow/EscrowUnlockStETHMissingTimeGuard.sol @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ETHValue, ETHValues} from "./types/ETHValue.sol"; +import {SharesValue, SharesValues} from "./types/SharesValue.sol"; +import {PercentD16, PercentsD16} from "./types/PercentD16.sol"; + +import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; + +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 +struct LockedAssetsTotals { + uint256 stETHLockedShares; + uint256 stETHClaimedETH; + uint256 unstETHUnfinalizedShares; + uint256 unstETHFinalizedETH; +} + +struct VetoerState { + uint256 stETHLockedShares; + uint256 unstETHLockedShares; + uint256 unstETHIdsCount; + uint256 lastAssetsLockTimestamp; +} + +contract Escrow is IEscrow { + using EscrowState for EscrowState.Context; + using AssetsAccounting for AssetsAccounting.Context; + using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.Context; + + // --- + // Errors + // --- + + error UnclaimedBatches(); + error UnexpectedUnstETHId(); + error UnfinalizedUnstETHIds(); + error NonProxyCallsForbidden(); + error BatchesQueueIsNotClosed(); + error InvalidBatchSize(uint256 size); + error CallerIsNotDualGovernance(address caller); + error InvalidHintsLength(uint256 actual, uint256 expected); + error InvalidETHSender(address actual, address expected); + + // --- + // Events + // --- + + event ConfigProviderSet(address newConfigProvider); + + // --- + // Sanity check params immutables + // --- + + uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE; + + // --- + // Dependencies immutables + // --- + + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + + // --- + // Implementation immutables + + address private immutable _SELF; + IDualGovernance public immutable DUAL_GOVERNANCE; + + // --- + // Aspects + // --- + + EscrowState.Context internal _escrowState; + AssetsAccounting.Context private _accounting; + WithdrawalsBatchesQueue.Context private _batchesQueue; + + // --- + // Construction & initializing + // --- + + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) { + _SELF = address(this); + DUAL_GOVERNANCE = dualGovernance; + + ST_ETH = stETH; + WST_ETH = wstETH; + WITHDRAWAL_QUEUE = withdrawalQueue; + + MIN_WITHDRAWALS_BATCH_SIZE = minWithdrawalsBatchSize; + } + + function initialize(Duration minAssetsLockDuration) external { + if (address(this) == _SELF) { + revert NonProxyCallsForbidden(); + } + _checkCallerIsDualGovernance(); + + _escrowState.initialize(minAssetsLockDuration); + + ST_ETH.approve(address(WST_ETH), type(uint256).max); + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); + } + + // --- + // Lock & unlock stETH + // --- + + function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + lockedStETHShares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + ST_ETH.transferSharesFrom(msg.sender, address(this), lockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockStETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + + DUAL_GOVERNANCE.activateNextState(); + // mutated + //_accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + unlockedStETHShares = _accounting.accountStETHSharesUnlock(msg.sender).toUint256(); + ST_ETH.transferShares(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock wstETH + // --- + + function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WST_ETH.transferFrom(msg.sender, address(this), amount); + lockedStETHShares = ST_ETH.getSharesByPooledEth(WST_ETH.unwrap(amount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockWstETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + unlockedStETHShares = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); + WST_ETH.transfer(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock unstETH + // --- + function lockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + _accounting.accountUnstETHUnlock(msg.sender, unstETHIds); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + _escrowState.checkSignallingEscrow(); + + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + } + + // --- + // Convert to NFT + // --- + + function requestWithdrawals(uint256[] calldata stETHAmounts) external returns (uint256[] memory unstETHIds) { + _escrowState.checkSignallingEscrow(); + + unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stETHAmounts, address(this)); + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + + uint256 sharesTotal = 0; + for (uint256 i = 0; i < statuses.length; ++i) { + sharesTotal += statuses[i].amountOfShares; + } + _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + } + + // --- + // Start rage quit + // --- + + function startRageQuit(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock) external { + _checkCallerIsDualGovernance(); + _escrowState.startRageQuit(rageQuitExtensionDelay, rageQuitWithdrawalsTimelock); + _batchesQueue.open(WITHDRAWAL_QUEUE.getLastRequestId()); + } + + // --- + // Request withdrawal batches + // --- + + function requestNextWithdrawalsBatch(uint256 batchSize) external { + _escrowState.checkRageQuitEscrow(); + + if (batchSize < MIN_WITHDRAWALS_BATCH_SIZE) { + revert InvalidBatchSize(batchSize); + } + + uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); + uint256 minStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); + + if (stETHRemaining < minStETHWithdrawalRequestAmount) { + return _batchesQueue.close(); + } + + uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ + minRequestAmount: minStETHWithdrawalRequestAmount, + maxRequestAmount: maxStETHWithdrawalRequestAmount, + remainingAmount: Math.min(stETHRemaining, maxStETHWithdrawalRequestAmount * batchSize) + }); + + _batchesQueue.addUnstETHIds(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); + } + + // --- + // Claim requested withdrawal batches + // --- + + function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkBatchesClaimingInProgress(); + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); + + _claimNextWithdrawalsBatch( + unstETHIds[0], + unstETHIds, + WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) + ); + } + + 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 + // --- + + function startRageQuitExtensionDelay() 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 RageQuitExtensionDelay 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(); + } + + if (!_batchesQueue.isAllBatchesClaimed()) { + revert UnclaimedBatches(); + } + + _escrowState.startRageQuitExtensionDelay(); + } + + // --- + // Claim locked unstETH NFTs + // --- + + function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { + _escrowState.checkRageQuitEscrow(); + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + ETHValue totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); + } + + // --- + // Escrow management + // --- + + function setMinAssetsLockDuration(Duration newMinAssetsLockDuration) external { + _checkCallerIsDualGovernance(); + _escrowState.setMinAssetsLockDuration(newMinAssetsLockDuration); + } + + // --- + // Withdraw logic + // --- + + function withdrawETH() external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountStETHSharesWithdraw(msg.sender); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + function withdrawETH(uint256[] calldata unstETHIds) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); + totals.stETHLockedShares = stETHTotals.lockedShares.toUint256(); + + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + totals.unstETHUnfinalizedShares = unstETHTotals.unfinalizedShares.toUint256(); + totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); + } + + function getVetoerState(address vetoer) external view returns (VetoerState memory state) { + HolderAssets storage assets = _accounting.assets[vetoer]; + + state.unstETHIdsCount = assets.unstETHIds.length; + state.stETHLockedShares = assets.stETHLockedShares.toUint256(); + state.unstETHLockedShares = assets.stETHLockedShares.toUint256(); + state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); + } + + function getUnclaimedUnstETHIdsCount() external view returns (uint256) { + return _batchesQueue.getTotalUnclaimedUnstETHIdsCount(); + } + + function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { + return _batchesQueue.getNextWithdrawalsBatches(limit); + } + + function isWithdrawalsBatchesFinalized() external view returns (bool) { + return _batchesQueue.isClosed(); + } + + function isRageQuitExtensionDelayStarted() external view returns (bool) { + return _escrowState.isRageQuitExtensionDelayStarted(); + } + + function getRageQuitExtensionDelayStartedAt() external view returns (Timestamp) { + return _escrowState.rageQuitExtensionDelayStartedAt; + } + + function getRageQuitSupport() external view returns (PercentD16) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + + uint256 finalizedETH = unstETHTotals.finalizedETH.toUint256(); + uint256 unfinalizedShares = (stETHTotals.lockedShares + unstETHTotals.unfinalizedShares).toUint256(); + + return PercentsD16.fromFraction({ + numerator: ST_ETH.getPooledEthByShares(unfinalizedShares) + finalizedETH, + denominator: ST_ETH.totalSupply() + finalizedETH + }); + } + + function isRageQuitFinalized() external view returns (bool) { + return _escrowState.isRageQuitEscrow() && _escrowState.isRageQuitExtensionDelayPassed(); + } + + // --- + // Receive ETH + // --- + + receive() external payable { + if (msg.sender != address(WITHDRAWAL_QUEUE)) { + revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); + } + } + + // --- + // Internal methods + // --- + + function _claimNextWithdrawalsBatch( + uint256 fromUnstETHId, + uint256[] memory unstETHIds, + uint256[] memory hints + ) internal { + if (fromUnstETHId != unstETHIds[0]) { + revert UnexpectedUnstETHId(); + } + + if (hints.length != unstETHIds.length) { + revert InvalidHintsLength(hints.length, unstETHIds.length); + } + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + _accounting.accountClaimedStETH(ethBalanceAfter - ethBalanceBefore); + } + + function _checkCallerIsDualGovernance() internal view { + if (msg.sender != address(DUAL_GOVERNANCE)) { + revert CallerIsNotDualGovernance(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/Escrow/EscrowWithW2_2BugStillPresent.sol b/certora/mutation/mutants/Escrow/EscrowWithW2_2BugStillPresent.sol new file mode 100644 index 00000000..e604017c --- /dev/null +++ b/certora/mutation/mutants/Escrow/EscrowWithW2_2BugStillPresent.sol @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; +import {ETHValue, ETHValues} from "./types/ETHValue.sol"; +import {SharesValue, SharesValues} from "./types/SharesValue.sol"; +import {PercentD16, PercentsD16} from "./types/PercentD16.sol"; + +import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; +import {IDualGovernance} from "./interfaces/IDualGovernance.sol"; + +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 +struct LockedAssetsTotals { + uint256 stETHLockedShares; + uint256 stETHClaimedETH; + uint256 unstETHUnfinalizedShares; + uint256 unstETHFinalizedETH; +} + +struct VetoerState { + uint256 stETHLockedShares; + uint256 unstETHLockedShares; + uint256 unstETHIdsCount; + uint256 lastAssetsLockTimestamp; +} + +contract Escrow is IEscrow { + using EscrowState for EscrowState.Context; + using AssetsAccounting for AssetsAccounting.Context; + using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.Context; + + // --- + // Errors + // --- + + error UnclaimedBatches(); + error UnexpectedUnstETHId(); + error UnfinalizedUnstETHIds(); + error NonProxyCallsForbidden(); + error BatchesQueueIsNotClosed(); + error InvalidBatchSize(uint256 size); + error CallerIsNotDualGovernance(address caller); + error InvalidHintsLength(uint256 actual, uint256 expected); + error InvalidETHSender(address actual, address expected); + + // --- + // Events + // --- + + event ConfigProviderSet(address newConfigProvider); + + // --- + // Sanity check params immutables + // --- + + uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE; + + // --- + // Dependencies immutables + // --- + + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + + // --- + // Implementation immutables + + address private immutable _SELF; + IDualGovernance public immutable DUAL_GOVERNANCE; + + // --- + // Aspects + // --- + + EscrowState.Context internal _escrowState; + AssetsAccounting.Context private _accounting; + WithdrawalsBatchesQueue.Context private _batchesQueue; + + // --- + // Construction & initializing + // --- + + constructor( + IStETH stETH, + IWstETH wstETH, + IWithdrawalQueue withdrawalQueue, + IDualGovernance dualGovernance, + uint256 minWithdrawalsBatchSize + ) { + _SELF = address(this); + DUAL_GOVERNANCE = dualGovernance; + + ST_ETH = stETH; + WST_ETH = wstETH; + WITHDRAWAL_QUEUE = withdrawalQueue; + + MIN_WITHDRAWALS_BATCH_SIZE = minWithdrawalsBatchSize; + } + + function initialize(Duration minAssetsLockDuration) external { + if (address(this) == _SELF) { + revert NonProxyCallsForbidden(); + } + _checkCallerIsDualGovernance(); + + _escrowState.initialize(minAssetsLockDuration); + + ST_ETH.approve(address(WST_ETH), type(uint256).max); + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); + } + + // --- + // Lock & unlock stETH + // --- + + function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + lockedStETHShares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + ST_ETH.transferSharesFrom(msg.sender, address(this), lockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockStETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + + DUAL_GOVERNANCE.activateNextState(); + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + unlockedStETHShares = _accounting.accountStETHSharesUnlock(msg.sender).toUint256(); + ST_ETH.transferShares(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock wstETH + // --- + + function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WST_ETH.transferFrom(msg.sender, address(this), amount); + lockedStETHShares = ST_ETH.getSharesByPooledEth(WST_ETH.unwrap(amount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockWstETH() external returns (uint256 unlockedStETHShares) { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + unlockedStETHShares = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); + WST_ETH.transfer(msg.sender, unlockedStETHShares); + + DUAL_GOVERNANCE.activateNextState(); + } + + // --- + // Lock & unlock unstETH + // --- + function lockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function unlockUnstETH(uint256[] memory unstETHIds) external { + _escrowState.checkSignallingEscrow(); + DUAL_GOVERNANCE.activateNextState(); + + _accounting.checkMinAssetsLockDurationPassed(msg.sender, _escrowState.minAssetsLockDuration); + _accounting.accountUnstETHUnlock(msg.sender, unstETHIds); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstETHIds[i]); + } + + DUAL_GOVERNANCE.activateNextState(); + } + + function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + _escrowState.checkSignallingEscrow(); + + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + } + + // --- + // Convert to NFT + // --- + + function requestWithdrawals(uint256[] calldata stETHAmounts) external returns (uint256[] memory unstETHIds) { + _escrowState.checkSignallingEscrow(); + + unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stETHAmounts, address(this)); + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + + uint256 sharesTotal = 0; + for (uint256 i = 0; i < statuses.length; ++i) { + sharesTotal += statuses[i].amountOfShares; + } + _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + } + + // --- + // Start rage quit + // --- + + function startRageQuit(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock) external { + _checkCallerIsDualGovernance(); + _escrowState.startRageQuit(rageQuitExtensionDelay, rageQuitWithdrawalsTimelock); + _batchesQueue.open(WITHDRAWAL_QUEUE.getLastRequestId()); + } + + // --- + // Request withdrawal batches + // --- + + function requestNextWithdrawalsBatch(uint256 batchSize) external { + _escrowState.checkRageQuitEscrow(); + + if (batchSize < MIN_WITHDRAWALS_BATCH_SIZE) { + revert InvalidBatchSize(batchSize); + } + + uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); + uint256 minStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxStETHWithdrawalRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); + + if (stETHRemaining < minStETHWithdrawalRequestAmount) { + return _batchesQueue.close(); + } + + uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ + minRequestAmount: minStETHWithdrawalRequestAmount, + maxRequestAmount: maxStETHWithdrawalRequestAmount, + remainingAmount: Math.min(stETHRemaining, maxStETHWithdrawalRequestAmount * batchSize) + }); + + _batchesQueue.addUnstETHIds(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); + } + + // --- + // Claim requested withdrawal batches + // --- + + function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkBatchesClaimingInProgress(); + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); + + _claimNextWithdrawalsBatch( + unstETHIds[0], + unstETHIds, + WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) + ); + } + + 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 + // --- + + function startRageQuitExtensionDelay() 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 RageQuitExtensionDelay 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(); + } + + if (!_batchesQueue.isAllBatchesClaimed()) { + revert UnclaimedBatches(); + } + + _escrowState.startRageQuitExtensionDelay(); + } + + // --- + // Claim locked unstETH NFTs + // --- + + function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { + _escrowState.checkRageQuitEscrow(); + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + ETHValue totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); + } + + // --- + // Escrow management + // --- + + function setMinAssetsLockDuration(Duration newMinAssetsLockDuration) external { + _checkCallerIsDualGovernance(); + _escrowState.setMinAssetsLockDuration(newMinAssetsLockDuration); + } + + // --- + // Withdraw logic + // --- + + function withdrawETH() external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountStETHSharesWithdraw(msg.sender); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + function withdrawETH(uint256[] calldata unstETHIds) external { + _escrowState.checkRageQuitEscrow(); + _escrowState.checkWithdrawalsTimelockPassed(); + ETHValue ethToWithdraw = _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds); + ethToWithdraw.sendTo(payable(msg.sender)); + } + + // --- + // Getters + // --- + + function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); + totals.stETHLockedShares = stETHTotals.lockedShares.toUint256(); + + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + totals.unstETHUnfinalizedShares = unstETHTotals.unfinalizedShares.toUint256(); + totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); + } + + function getVetoerState(address vetoer) external view returns (VetoerState memory state) { + HolderAssets storage assets = _accounting.assets[vetoer]; + + state.unstETHIdsCount = assets.unstETHIds.length; + state.stETHLockedShares = assets.stETHLockedShares.toUint256(); + state.unstETHLockedShares = assets.stETHLockedShares.toUint256(); + state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); + } + + function getUnclaimedUnstETHIdsCount() external view returns (uint256) { + return _batchesQueue.getTotalUnclaimedUnstETHIdsCount(); + } + + function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { + return _batchesQueue.getNextWithdrawalsBatches(limit); + } + + function isWithdrawalsBatchesFinalized() external view returns (bool) { + return _batchesQueue.isClosed(); + } + + function isRageQuitExtensionDelayStarted() external view returns (bool) { + return _escrowState.isRageQuitExtensionDelayStarted(); + } + + function getRageQuitExtensionDelayStartedAt() external view returns (Timestamp) { + return _escrowState.rageQuitExtensionDelayStartedAt; + } + + function getRageQuitSupport() external view returns (PercentD16) { + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + + uint256 finalizedETH = unstETHTotals.finalizedETH.toUint256(); + uint256 unfinalizedShares = (stETHTotals.lockedShares + unstETHTotals.unfinalizedShares).toUint256(); + + return PercentsD16.fromFraction({ + numerator: ST_ETH.getPooledEthByShares(unfinalizedShares) + finalizedETH, + denominator: ST_ETH.totalSupply() + finalizedETH + }); + } + + function isRageQuitFinalized() external view returns (bool) { + return _escrowState.isRageQuitEscrow() && _escrowState.isRageQuitExtensionDelayPassed(); + } + + // --- + // Receive ETH + // --- + + receive() external payable { + if (msg.sender != address(WITHDRAWAL_QUEUE)) { + revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); + } + } + + // --- + // Internal methods + // --- + + function _claimNextWithdrawalsBatch( + uint256 fromUnstETHId, + uint256[] memory unstETHIds, + uint256[] memory hints + ) internal { + if (fromUnstETHId != unstETHIds[0]) { + revert UnexpectedUnstETHId(); + } + + if (hints.length != unstETHIds.length) { + revert InvalidHintsLength(hints.length, unstETHIds.length); + } + + ETHValue ethBalanceBefore = ETHValues.fromAddressBalance(address(this)); + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + ETHValue ethBalanceAfter = ETHValues.fromAddressBalance(address(this)); + + _accounting.accountClaimedStETH(ethBalanceAfter - ethBalanceBefore); + } + + function _checkCallerIsDualGovernance() internal view { + if (msg.sender != address(DUAL_GOVERNANCE)) { + revert CallerIsNotDualGovernance(msg.sender); + } + } +} diff --git a/certora/mutation/mutants/EscrowState/EscrowStateResetFromRageQuitToSignalling.sol b/certora/mutation/mutants/EscrowState/EscrowStateResetFromRageQuitToSignalling.sol new file mode 100644 index 00000000..1fcbaa9c --- /dev/null +++ b/certora/mutation/mutants/EscrowState/EscrowStateResetFromRageQuitToSignalling.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +/// @notice The state of Escrow representing the current set of actions allowed to be called +/// on the Escrow instance. +/// @param NotInitialized The default (uninitialized) state of the Escrow contract. Only the master +/// copy of the Escrow contract is expected to be in this state. +/// @param SignallingEscrow In this state, the Escrow contract functions as an on-chain oracle for measuring stakers' disagreement +/// with DAO decisions. Users are allowed to lock and unlock funds in the Escrow contract in this state. +/// @param RageQuitEscrow The final state of the Escrow contract. In this state, the Escrow instance acts as an accumulator +/// for withdrawn funds locked during the VetoSignalling phase. +enum State { + NotInitialized, + SignallingEscrow, + RageQuitEscrow +} + +/// @notice Represents the logic to manipulate the state of the Escrow +library EscrowState { + // --- + // Errors + // --- + + error ClaimingIsFinished(); + error UnexpectedState(State value); + error RageQuitExtraTimelockNotStarted(); + error WithdrawalsTimelockNotPassed(); + error BatchesCreationNotInProgress(); + + // --- + // Events + // --- + + event RageQuitTimelockStarted(); + event EscrowStateChanged(State from, State to); + event RageQuitStarted(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock); + event MinAssetsLockDurationSet(Duration newAssetsLockDuration); + + /// @notice Stores the context of the state of the Escrow instance + /// @param state The current state of the Escrow instance + /// @param minAssetsLockDuration The minimum time required to pass before tokens can be unlocked from the Escrow + /// contract instance + /// @param rageQuitExtensionDelay The period of time that starts after all withdrawal batches are formed, which delays + /// the exit from the RageQuit state of the DualGovernance. The main purpose of the rage quit extension delay is to provide + /// enough time for users who locked their unstETH to claim it. + struct Context { + /// @dev slot0: [0..7] + State state; + /// @dev slot0: [8..39] + Duration minAssetsLockDuration; + /// @dev slot0: [40..71] + Duration rageQuitExtensionDelay; + /// @dev slot0: [72..111] + Timestamp rageQuitExtensionDelayStartedAt; + /// @dev slot0: [112..143] + Duration rageQuitWithdrawalsTimelock; + } + + function initialize(Context storage self, Duration minAssetsLockDuration) internal { + _checkState(self, State.NotInitialized); + _setState(self, State.SignallingEscrow); + _setMinAssetsLockDuration(self, minAssetsLockDuration); + } + + function startRageQuit( + Context storage self, + Duration rageQuitExtensionDelay, + Duration rageQuitWithdrawalsTimelock + ) internal { + _checkState(self, State.SignallingEscrow); + _setState(self, State.RageQuitEscrow); + self.rageQuitExtensionDelay = rageQuitExtensionDelay; + self.rageQuitWithdrawalsTimelock = rageQuitWithdrawalsTimelock; + emit RageQuitStarted(rageQuitExtensionDelay, rageQuitWithdrawalsTimelock); + } + + function startRageQuitExtensionDelay(Context storage self) internal { + self.rageQuitExtensionDelayStartedAt = Timestamps.now(); + // mutated + _setState(self, State.SignallingEscrow); + emit RageQuitTimelockStarted(); + } + + function setMinAssetsLockDuration(Context storage self, Duration newMinAssetsLockDuration) internal { + if (self.minAssetsLockDuration == newMinAssetsLockDuration) { + return; + } + _setMinAssetsLockDuration(self, newMinAssetsLockDuration); + } + + // --- + // Checks + // --- + + function checkSignallingEscrow(Context storage self) internal view { + _checkState(self, State.SignallingEscrow); + } + + function checkRageQuitEscrow(Context storage self) internal view { + _checkState(self, State.RageQuitEscrow); + } + + function checkBatchesClaimingInProgress(Context storage self) internal view { + if (!self.rageQuitExtensionDelayStartedAt.isZero()) { + revert ClaimingIsFinished(); + } + } + + function checkWithdrawalsTimelockPassed(Context storage self) internal view { + if (self.rageQuitExtensionDelayStartedAt.isZero()) { + revert RageQuitExtraTimelockNotStarted(); + } + Duration withdrawalsTimelock = self.rageQuitExtensionDelay + self.rageQuitWithdrawalsTimelock; + if (Timestamps.now() <= withdrawalsTimelock.addTo(self.rageQuitExtensionDelayStartedAt)) { + revert WithdrawalsTimelockNotPassed(); + } + } + + // --- + // Getters + // --- + function isRageQuitExtensionDelayStarted(Context storage self) internal view returns (bool) { + return self.rageQuitExtensionDelayStartedAt.isNotZero(); + } + + function isRageQuitExtensionDelayPassed(Context storage self) internal view returns (bool) { + Timestamp rageQuitExtensionDelayStartedAt = self.rageQuitExtensionDelayStartedAt; + return rageQuitExtensionDelayStartedAt.isNotZero() + && Timestamps.now() > self.rageQuitExtensionDelay.addTo(rageQuitExtensionDelayStartedAt); + } + + function isRageQuitEscrow(Context storage self) internal view returns (bool) { + return self.state == State.RageQuitEscrow; + } + + // --- + // Private Methods + // --- + + function _checkState(Context storage self, State state) private view { + if (self.state != state) { + revert UnexpectedState(state); + } + } + + function _setState(Context storage self, State newState) private { + State prevState = self.state; + self.state = newState; + emit EscrowStateChanged(prevState, newState); + } + + function _setMinAssetsLockDuration(Context storage self, Duration newMinAssetsLockDuration) private { + self.minAssetsLockDuration = newMinAssetsLockDuration; + emit MinAssetsLockDurationSet(newMinAssetsLockDuration); + } +} diff --git a/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsExecuteMissingCancellationCheck.sol b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsExecuteMissingCancellationCheck.sol new file mode 100644 index 00000000..41d67bf8 --- /dev/null +++ b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsExecuteMissingCancellationCheck.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {ExternalCall, ExternalCalls, IExternalExecutor} from "./ExternalCalls.sol"; + +/// @dev Describes the lifecycle state of a proposal +enum Status { + /// Proposal has not been submitted yet + NotExist, + /// Proposal has been successfully submitted but not scheduled yet. This state is only reachable from NotExist + Submitted, + /// Proposal has been successfully scheduled after submission. This state is only reachable from Submitted + Scheduled, + /// Proposal has been successfully executed after being scheduled. This state is only reachable from Scheduled + /// and is the final state of the proposal + Executed, + /// Proposal was cancelled before execution. Cancelled proposals cannot be resubmitted or rescheduled. + /// This state is only reachable from Submitted or Scheduled and is the final state of the proposal. + /// @dev A proposal is considered cancelled if it was not executed and its ID is less than the ID of the last + /// submitted proposal at the time the cancelAll() method was called. To check if a proposal is in the Cancelled + /// state, use the _isProposalMarkedCancelled() view function. + Cancelled +} + +/// @dev Manages a collection of proposals with associated external calls stored as Proposal struct. +/// Proposals are uniquely identified by sequential IDs, starting from one. +library ExecutableProposals { + using ExternalCalls for ExternalCall[]; + + /// @dev Efficiently stores proposal data within a single EVM word. + /// This struct allows gas-efficient loading from storage using a single EVM sload operation. + struct ProposalData { + /// + /// @dev slot 0: [0..7] + /// The current status of the proposal. See Status for details. + Status status; + /// + /// @dev slot 0: [8..167] + /// The address of the associated executor used for executing the proposal's calls. + address executor; + /// + /// @dev slot 0: [168..207] + /// The timestamp when the proposal was submitted. + Timestamp submittedAt; + /// + /// @dev slot 0: [208..247] + /// The timestamp when the proposal was scheduled for execution. Equals zero if the proposal hasn't been scheduled yet. + Timestamp scheduledAt; + } + + struct Proposal { + /// @dev Proposal data packed into a struct for efficient loading into memory. + ProposalData data; + /// @dev The list of external calls associated with the proposal. + ExternalCall[] calls; + } + + error EmptyCalls(); + error ProposalNotFound(uint256 proposalId); + error ProposalNotScheduled(uint256 proposalId); + error ProposalNotSubmitted(uint256 proposalId); + error AfterSubmitDelayNotPassed(uint256 proposalId); + error AfterScheduleDelayNotPassed(uint256 proposalId); + + event ProposalSubmitted(uint256 indexed id, address indexed executor, ExternalCall[] calls); + event ProposalScheduled(uint256 indexed id); + event ProposalExecuted(uint256 indexed id, bytes[] callResults); + event ProposalsCancelledTill(uint256 proposalId); + + struct Context { + uint64 proposalsCount; + uint64 lastCancelledProposalId; + mapping(uint256 proposalId => Proposal) proposals; + } + + // --- + // Proposal lifecycle + // --- + + function submit( + Context storage self, + address executor, + ExternalCall[] memory calls + ) internal returns (uint256 newProposalId) { + if (calls.length == 0) { + revert EmptyCalls(); + } + + /// @dev: proposal ids are one-based. The first item has id = 1 + newProposalId = ++self.proposalsCount; + Proposal storage newProposal = self.proposals[newProposalId]; + + newProposal.data.executor = executor; + newProposal.data.status = Status.Submitted; + newProposal.data.submittedAt = Timestamps.now(); + + uint256 callsCount = calls.length; + for (uint256 i = 0; i < callsCount; ++i) { + newProposal.calls.push(calls[i]); + } + + emit ProposalSubmitted(newProposalId, executor, calls); + } + + function schedule(Context storage self, uint256 proposalId, Duration afterSubmitDelay) internal { + ProposalData memory proposalState = self.proposals[proposalId].data; + + if (proposalState.status != Status.Submitted || _isProposalMarkedCancelled(self, proposalId, proposalState)) { + revert ProposalNotSubmitted(proposalId); + } + + if (afterSubmitDelay.addTo(proposalState.submittedAt) > Timestamps.now()) { + revert AfterSubmitDelayNotPassed(proposalId); + } + + proposalState.status = Status.Scheduled; + proposalState.scheduledAt = Timestamps.now(); + self.proposals[proposalId].data = proposalState; + + emit ProposalScheduled(proposalId); + } + + function execute(Context storage self, uint256 proposalId, Duration afterScheduleDelay) internal { + Proposal memory proposal = self.proposals[proposalId]; + + // mutated + // if (proposal.data.status != Status.Scheduled || _isProposalMarkedCancelled(self, proposalId, proposal.data)) { + // revert ProposalNotScheduled(proposalId); + // } + + if (afterScheduleDelay.addTo(proposal.data.scheduledAt) > Timestamps.now()) { + revert AfterScheduleDelayNotPassed(proposalId); + } + + self.proposals[proposalId].data.status = Status.Executed; + + address executor = proposal.data.executor; + ExternalCall[] memory calls = proposal.calls; + + bytes[] memory results = calls.execute(IExternalExecutor(executor)); + + emit ProposalExecuted(proposalId, results); + } + + function cancelAll(Context storage self) internal { + uint64 lastCancelledProposalId = self.proposalsCount; + self.lastCancelledProposalId = lastCancelledProposalId; + emit ProposalsCancelledTill(lastCancelledProposalId); + } + + // --- + // Getters + // --- + + function canExecute( + Context storage self, + uint256 proposalId, + Duration afterScheduleDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Scheduled + && Timestamps.now() >= afterScheduleDelay.addTo(proposalState.scheduledAt); + } + + function canSchedule( + Context storage self, + uint256 proposalId, + Duration afterSubmitDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Submitted + && Timestamps.now() >= afterSubmitDelay.addTo(proposalState.submittedAt); + } + + function getProposalsCount(Context storage self) internal view returns (uint256) { + return self.proposalsCount; + } + + function getProposalInfo( + Context storage self, + uint256 proposalId + ) internal view returns (Status status, address executor, Timestamp submittedAt, Timestamp scheduledAt) { + ProposalData memory proposalData = self.proposals[proposalId].data; + _checkProposalExists(proposalId, proposalData); + + status = _isProposalMarkedCancelled(self, proposalId, proposalData) ? Status.Cancelled : proposalData.status; + executor = address(proposalData.executor); + submittedAt = proposalData.submittedAt; + scheduledAt = proposalData.scheduledAt; + } + + function getProposalCalls( + Context storage self, + uint256 proposalId + ) internal view returns (ExternalCall[] memory calls) { + Proposal memory proposal = self.proposals[proposalId]; + _checkProposalExists(proposalId, proposal.data); + calls = proposal.calls; + } + + // --- + // Private methods + // --- + + function _checkProposalExists(uint256 proposalId, ProposalData memory proposalData) private pure { + if (proposalData.status == Status.NotExist) { + revert ProposalNotFound(proposalId); + } + } + + function _isProposalMarkedCancelled( + Context storage self, + uint256 proposalId, + ProposalData memory proposalData + ) private view returns (bool) { + return proposalId <= self.lastCancelledProposalId || proposalData.status == Status.Cancelled; + } +} diff --git a/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsExecutionDelayCheckBuggy.sol b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsExecutionDelayCheckBuggy.sol new file mode 100644 index 00000000..63260867 --- /dev/null +++ b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsExecutionDelayCheckBuggy.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {ExternalCall, ExternalCalls, IExternalExecutor} from "./ExternalCalls.sol"; + +/// @dev Describes the lifecycle state of a proposal +enum Status { + /// Proposal has not been submitted yet + NotExist, + /// Proposal has been successfully submitted but not scheduled yet. This state is only reachable from NotExist + Submitted, + /// Proposal has been successfully scheduled after submission. This state is only reachable from Submitted + Scheduled, + /// Proposal has been successfully executed after being scheduled. This state is only reachable from Scheduled + /// and is the final state of the proposal + Executed, + /// Proposal was cancelled before execution. Cancelled proposals cannot be resubmitted or rescheduled. + /// This state is only reachable from Submitted or Scheduled and is the final state of the proposal. + /// @dev A proposal is considered cancelled if it was not executed and its ID is less than the ID of the last + /// submitted proposal at the time the cancelAll() method was called. To check if a proposal is in the Cancelled + /// state, use the _isProposalMarkedCancelled() view function. + Cancelled +} + +/// @dev Manages a collection of proposals with associated external calls stored as Proposal struct. +/// Proposals are uniquely identified by sequential IDs, starting from one. +library ExecutableProposals { + using ExternalCalls for ExternalCall[]; + + /// @dev Efficiently stores proposal data within a single EVM word. + /// This struct allows gas-efficient loading from storage using a single EVM sload operation. + struct ProposalData { + /// + /// @dev slot 0: [0..7] + /// The current status of the proposal. See Status for details. + Status status; + /// + /// @dev slot 0: [8..167] + /// The address of the associated executor used for executing the proposal's calls. + address executor; + /// + /// @dev slot 0: [168..207] + /// The timestamp when the proposal was submitted. + Timestamp submittedAt; + /// + /// @dev slot 0: [208..247] + /// The timestamp when the proposal was scheduled for execution. Equals zero if the proposal hasn't been scheduled yet. + Timestamp scheduledAt; + } + + struct Proposal { + /// @dev Proposal data packed into a struct for efficient loading into memory. + ProposalData data; + /// @dev The list of external calls associated with the proposal. + ExternalCall[] calls; + } + + error EmptyCalls(); + error ProposalNotFound(uint256 proposalId); + error ProposalNotScheduled(uint256 proposalId); + error ProposalNotSubmitted(uint256 proposalId); + error AfterSubmitDelayNotPassed(uint256 proposalId); + error AfterScheduleDelayNotPassed(uint256 proposalId); + + event ProposalSubmitted(uint256 indexed id, address indexed executor, ExternalCall[] calls); + event ProposalScheduled(uint256 indexed id); + event ProposalExecuted(uint256 indexed id, bytes[] callResults); + event ProposalsCancelledTill(uint256 proposalId); + + struct Context { + uint64 proposalsCount; + uint64 lastCancelledProposalId; + mapping(uint256 proposalId => Proposal) proposals; + } + + // --- + // Proposal lifecycle + // --- + + function submit( + Context storage self, + address executor, + ExternalCall[] memory calls + ) internal returns (uint256 newProposalId) { + if (calls.length == 0) { + revert EmptyCalls(); + } + + /// @dev: proposal ids are one-based. The first item has id = 1 + newProposalId = ++self.proposalsCount; + Proposal storage newProposal = self.proposals[newProposalId]; + + newProposal.data.executor = executor; + newProposal.data.status = Status.Submitted; + newProposal.data.submittedAt = Timestamps.now(); + + uint256 callsCount = calls.length; + for (uint256 i = 0; i < callsCount; ++i) { + newProposal.calls.push(calls[i]); + } + + emit ProposalSubmitted(newProposalId, executor, calls); + } + + function schedule(Context storage self, uint256 proposalId, Duration afterSubmitDelay) internal { + ProposalData memory proposalState = self.proposals[proposalId].data; + + if (proposalState.status != Status.Submitted || _isProposalMarkedCancelled(self, proposalId, proposalState)) { + revert ProposalNotSubmitted(proposalId); + } + + if (afterSubmitDelay.addTo(proposalState.submittedAt) > Timestamps.now()) { + revert AfterSubmitDelayNotPassed(proposalId); + } + + proposalState.status = Status.Scheduled; + proposalState.scheduledAt = Timestamps.now(); + self.proposals[proposalId].data = proposalState; + + emit ProposalScheduled(proposalId); + } + + function execute(Context storage self, uint256 proposalId, Duration afterScheduleDelay) internal { + Proposal memory proposal = self.proposals[proposalId]; + + if (proposal.data.status != Status.Scheduled || _isProposalMarkedCancelled(self, proposalId, proposal.data)) { + revert ProposalNotScheduled(proposalId); + } + + // mutated + if (afterScheduleDelay.addTo(proposal.data.submittedAt) > Timestamps.now()) { + //if (afterScheduleDelay.addTo(proposal.data.scheduledAt) > Timestamps.now()) { + revert AfterScheduleDelayNotPassed(proposalId); + } + + self.proposals[proposalId].data.status = Status.Executed; + + address executor = proposal.data.executor; + ExternalCall[] memory calls = proposal.calls; + + bytes[] memory results = calls.execute(IExternalExecutor(executor)); + + emit ProposalExecuted(proposalId, results); + } + + function cancelAll(Context storage self) internal { + uint64 lastCancelledProposalId = self.proposalsCount; + self.lastCancelledProposalId = lastCancelledProposalId; + emit ProposalsCancelledTill(lastCancelledProposalId); + } + + // --- + // Getters + // --- + + function canExecute( + Context storage self, + uint256 proposalId, + Duration afterScheduleDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Scheduled + && Timestamps.now() >= afterScheduleDelay.addTo(proposalState.scheduledAt); + } + + function canSchedule( + Context storage self, + uint256 proposalId, + Duration afterSubmitDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Submitted + && Timestamps.now() >= afterSubmitDelay.addTo(proposalState.submittedAt); + } + + function getProposalsCount(Context storage self) internal view returns (uint256) { + return self.proposalsCount; + } + + function getProposalInfo( + Context storage self, + uint256 proposalId + ) internal view returns (Status status, address executor, Timestamp submittedAt, Timestamp scheduledAt) { + ProposalData memory proposalData = self.proposals[proposalId].data; + _checkProposalExists(proposalId, proposalData); + + status = _isProposalMarkedCancelled(self, proposalId, proposalData) ? Status.Cancelled : proposalData.status; + executor = address(proposalData.executor); + submittedAt = proposalData.submittedAt; + scheduledAt = proposalData.scheduledAt; + } + + function getProposalCalls( + Context storage self, + uint256 proposalId + ) internal view returns (ExternalCall[] memory calls) { + Proposal memory proposal = self.proposals[proposalId]; + _checkProposalExists(proposalId, proposal.data); + calls = proposal.calls; + } + + // --- + // Private methods + // --- + + function _checkProposalExists(uint256 proposalId, ProposalData memory proposalData) private pure { + if (proposalData.status == Status.NotExist) { + revert ProposalNotFound(proposalId); + } + } + + function _isProposalMarkedCancelled( + Context storage self, + uint256 proposalId, + ProposalData memory proposalData + ) private view returns (bool) { + return proposalId <= self.lastCancelledProposalId || proposalData.status == Status.Cancelled; + } +} diff --git a/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsScheduleDelayCheckBuggy.sol b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsScheduleDelayCheckBuggy.sol new file mode 100644 index 00000000..10b263ae --- /dev/null +++ b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsScheduleDelayCheckBuggy.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {ExternalCall, ExternalCalls, IExternalExecutor} from "./ExternalCalls.sol"; + +/// @dev Describes the lifecycle state of a proposal +enum Status { + /// Proposal has not been submitted yet + NotExist, + /// Proposal has been successfully submitted but not scheduled yet. This state is only reachable from NotExist + Submitted, + /// Proposal has been successfully scheduled after submission. This state is only reachable from Submitted + Scheduled, + /// Proposal has been successfully executed after being scheduled. This state is only reachable from Scheduled + /// and is the final state of the proposal + Executed, + /// Proposal was cancelled before execution. Cancelled proposals cannot be resubmitted or rescheduled. + /// This state is only reachable from Submitted or Scheduled and is the final state of the proposal. + /// @dev A proposal is considered cancelled if it was not executed and its ID is less than the ID of the last + /// submitted proposal at the time the cancelAll() method was called. To check if a proposal is in the Cancelled + /// state, use the _isProposalMarkedCancelled() view function. + Cancelled +} + +/// @dev Manages a collection of proposals with associated external calls stored as Proposal struct. +/// Proposals are uniquely identified by sequential IDs, starting from one. +library ExecutableProposals { + using ExternalCalls for ExternalCall[]; + + /// @dev Efficiently stores proposal data within a single EVM word. + /// This struct allows gas-efficient loading from storage using a single EVM sload operation. + struct ProposalData { + /// + /// @dev slot 0: [0..7] + /// The current status of the proposal. See Status for details. + Status status; + /// + /// @dev slot 0: [8..167] + /// The address of the associated executor used for executing the proposal's calls. + address executor; + /// + /// @dev slot 0: [168..207] + /// The timestamp when the proposal was submitted. + Timestamp submittedAt; + /// + /// @dev slot 0: [208..247] + /// The timestamp when the proposal was scheduled for execution. Equals zero if the proposal hasn't been scheduled yet. + Timestamp scheduledAt; + } + + struct Proposal { + /// @dev Proposal data packed into a struct for efficient loading into memory. + ProposalData data; + /// @dev The list of external calls associated with the proposal. + ExternalCall[] calls; + } + + error EmptyCalls(); + error ProposalNotFound(uint256 proposalId); + error ProposalNotScheduled(uint256 proposalId); + error ProposalNotSubmitted(uint256 proposalId); + error AfterSubmitDelayNotPassed(uint256 proposalId); + error AfterScheduleDelayNotPassed(uint256 proposalId); + + event ProposalSubmitted(uint256 indexed id, address indexed executor, ExternalCall[] calls); + event ProposalScheduled(uint256 indexed id); + event ProposalExecuted(uint256 indexed id, bytes[] callResults); + event ProposalsCancelledTill(uint256 proposalId); + + struct Context { + uint64 proposalsCount; + uint64 lastCancelledProposalId; + mapping(uint256 proposalId => Proposal) proposals; + } + + // --- + // Proposal lifecycle + // --- + + function submit( + Context storage self, + address executor, + ExternalCall[] memory calls + ) internal returns (uint256 newProposalId) { + if (calls.length == 0) { + revert EmptyCalls(); + } + + /// @dev: proposal ids are one-based. The first item has id = 1 + newProposalId = ++self.proposalsCount; + Proposal storage newProposal = self.proposals[newProposalId]; + + newProposal.data.executor = executor; + newProposal.data.status = Status.Submitted; + newProposal.data.submittedAt = Timestamps.now(); + + uint256 callsCount = calls.length; + for (uint256 i = 0; i < callsCount; ++i) { + newProposal.calls.push(calls[i]); + } + + emit ProposalSubmitted(newProposalId, executor, calls); + } + + function schedule(Context storage self, uint256 proposalId, Duration afterSubmitDelay) internal { + ProposalData memory proposalState = self.proposals[proposalId].data; + + if (proposalState.status != Status.Submitted || _isProposalMarkedCancelled(self, proposalId, proposalState)) { + revert ProposalNotSubmitted(proposalId); + } + + // mutated + if (afterSubmitDelay.addTo(proposalState.submittedAt) <= Timestamps.now()) { + // if (afterSubmitDelay.addTo(proposalState.submittedAt) > Timestamps.now()) { + + revert AfterSubmitDelayNotPassed(proposalId); + } + + proposalState.status = Status.Scheduled; + proposalState.scheduledAt = Timestamps.now(); + self.proposals[proposalId].data = proposalState; + + emit ProposalScheduled(proposalId); + } + + function execute(Context storage self, uint256 proposalId, Duration afterScheduleDelay) internal { + Proposal memory proposal = self.proposals[proposalId]; + + if (proposal.data.status != Status.Scheduled || _isProposalMarkedCancelled(self, proposalId, proposal.data)) { + revert ProposalNotScheduled(proposalId); + } + + if (afterScheduleDelay.addTo(proposal.data.scheduledAt) > Timestamps.now()) { + revert AfterScheduleDelayNotPassed(proposalId); + } + + self.proposals[proposalId].data.status = Status.Executed; + + address executor = proposal.data.executor; + ExternalCall[] memory calls = proposal.calls; + + bytes[] memory results = calls.execute(IExternalExecutor(executor)); + + emit ProposalExecuted(proposalId, results); + } + + function cancelAll(Context storage self) internal { + uint64 lastCancelledProposalId = self.proposalsCount; + self.lastCancelledProposalId = lastCancelledProposalId; + emit ProposalsCancelledTill(lastCancelledProposalId); + } + + // --- + // Getters + // --- + + function canExecute( + Context storage self, + uint256 proposalId, + Duration afterScheduleDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Scheduled + && Timestamps.now() >= afterScheduleDelay.addTo(proposalState.scheduledAt); + } + + function canSchedule( + Context storage self, + uint256 proposalId, + Duration afterSubmitDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Submitted + && Timestamps.now() >= afterSubmitDelay.addTo(proposalState.submittedAt); + } + + function getProposalsCount(Context storage self) internal view returns (uint256) { + return self.proposalsCount; + } + + function getProposalInfo( + Context storage self, + uint256 proposalId + ) internal view returns (Status status, address executor, Timestamp submittedAt, Timestamp scheduledAt) { + ProposalData memory proposalData = self.proposals[proposalId].data; + _checkProposalExists(proposalId, proposalData); + + status = _isProposalMarkedCancelled(self, proposalId, proposalData) ? Status.Cancelled : proposalData.status; + executor = address(proposalData.executor); + submittedAt = proposalData.submittedAt; + scheduledAt = proposalData.scheduledAt; + } + + function getProposalCalls( + Context storage self, + uint256 proposalId + ) internal view returns (ExternalCall[] memory calls) { + Proposal memory proposal = self.proposals[proposalId]; + _checkProposalExists(proposalId, proposal.data); + calls = proposal.calls; + } + + // --- + // Private methods + // --- + + function _checkProposalExists(uint256 proposalId, ProposalData memory proposalData) private pure { + if (proposalData.status == Status.NotExist) { + revert ProposalNotFound(proposalId); + } + } + + function _isProposalMarkedCancelled( + Context storage self, + uint256 proposalId, + ProposalData memory proposalData + ) private view returns (bool) { + return proposalId <= self.lastCancelledProposalId || proposalData.status == Status.Cancelled; + } +} diff --git a/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsScheduleMissingCancellationCheck.sol b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsScheduleMissingCancellationCheck.sol new file mode 100644 index 00000000..8c9f0304 --- /dev/null +++ b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsScheduleMissingCancellationCheck.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {ExternalCall, ExternalCalls, IExternalExecutor} from "./ExternalCalls.sol"; + +/// @dev Describes the lifecycle state of a proposal +enum Status { + /// Proposal has not been submitted yet + NotExist, + /// Proposal has been successfully submitted but not scheduled yet. This state is only reachable from NotExist + Submitted, + /// Proposal has been successfully scheduled after submission. This state is only reachable from Submitted + Scheduled, + /// Proposal has been successfully executed after being scheduled. This state is only reachable from Scheduled + /// and is the final state of the proposal + Executed, + /// Proposal was cancelled before execution. Cancelled proposals cannot be resubmitted or rescheduled. + /// This state is only reachable from Submitted or Scheduled and is the final state of the proposal. + /// @dev A proposal is considered cancelled if it was not executed and its ID is less than the ID of the last + /// submitted proposal at the time the cancelAll() method was called. To check if a proposal is in the Cancelled + /// state, use the _isProposalMarkedCancelled() view function. + Cancelled +} + +/// @dev Manages a collection of proposals with associated external calls stored as Proposal struct. +/// Proposals are uniquely identified by sequential IDs, starting from one. +library ExecutableProposals { + using ExternalCalls for ExternalCall[]; + + /// @dev Efficiently stores proposal data within a single EVM word. + /// This struct allows gas-efficient loading from storage using a single EVM sload operation. + struct ProposalData { + /// + /// @dev slot 0: [0..7] + /// The current status of the proposal. See Status for details. + Status status; + /// + /// @dev slot 0: [8..167] + /// The address of the associated executor used for executing the proposal's calls. + address executor; + /// + /// @dev slot 0: [168..207] + /// The timestamp when the proposal was submitted. + Timestamp submittedAt; + /// + /// @dev slot 0: [208..247] + /// The timestamp when the proposal was scheduled for execution. Equals zero if the proposal hasn't been scheduled yet. + Timestamp scheduledAt; + } + + struct Proposal { + /// @dev Proposal data packed into a struct for efficient loading into memory. + ProposalData data; + /// @dev The list of external calls associated with the proposal. + ExternalCall[] calls; + } + + error EmptyCalls(); + error ProposalNotFound(uint256 proposalId); + error ProposalNotScheduled(uint256 proposalId); + error ProposalNotSubmitted(uint256 proposalId); + error AfterSubmitDelayNotPassed(uint256 proposalId); + error AfterScheduleDelayNotPassed(uint256 proposalId); + + event ProposalSubmitted(uint256 indexed id, address indexed executor, ExternalCall[] calls); + event ProposalScheduled(uint256 indexed id); + event ProposalExecuted(uint256 indexed id, bytes[] callResults); + event ProposalsCancelledTill(uint256 proposalId); + + struct Context { + uint64 proposalsCount; + uint64 lastCancelledProposalId; + mapping(uint256 proposalId => Proposal) proposals; + } + + // --- + // Proposal lifecycle + // --- + + function submit( + Context storage self, + address executor, + ExternalCall[] memory calls + ) internal returns (uint256 newProposalId) { + if (calls.length == 0) { + revert EmptyCalls(); + } + + /// @dev: proposal ids are one-based. The first item has id = 1 + newProposalId = ++self.proposalsCount; + Proposal storage newProposal = self.proposals[newProposalId]; + + newProposal.data.executor = executor; + newProposal.data.status = Status.Submitted; + newProposal.data.submittedAt = Timestamps.now(); + + uint256 callsCount = calls.length; + for (uint256 i = 0; i < callsCount; ++i) { + newProposal.calls.push(calls[i]); + } + + emit ProposalSubmitted(newProposalId, executor, calls); + } + + function schedule(Context storage self, uint256 proposalId, Duration afterSubmitDelay) internal { + ProposalData memory proposalState = self.proposals[proposalId].data; + + // mutated + // if (proposalState.status != Status.Submitted || _isProposalMarkedCancelled(self, proposalId, proposalState)) { + // revert ProposalNotSubmitted(proposalId); + // } + + if (afterSubmitDelay.addTo(proposalState.submittedAt) > Timestamps.now()) { + revert AfterSubmitDelayNotPassed(proposalId); + } + + proposalState.status = Status.Scheduled; + proposalState.scheduledAt = Timestamps.now(); + self.proposals[proposalId].data = proposalState; + + emit ProposalScheduled(proposalId); + } + + function execute(Context storage self, uint256 proposalId, Duration afterScheduleDelay) internal { + Proposal memory proposal = self.proposals[proposalId]; + + if (proposal.data.status != Status.Scheduled || _isProposalMarkedCancelled(self, proposalId, proposal.data)) { + revert ProposalNotScheduled(proposalId); + } + + if (afterScheduleDelay.addTo(proposal.data.scheduledAt) > Timestamps.now()) { + revert AfterScheduleDelayNotPassed(proposalId); + } + + self.proposals[proposalId].data.status = Status.Executed; + + address executor = proposal.data.executor; + ExternalCall[] memory calls = proposal.calls; + + bytes[] memory results = calls.execute(IExternalExecutor(executor)); + + emit ProposalExecuted(proposalId, results); + } + + function cancelAll(Context storage self) internal { + uint64 lastCancelledProposalId = self.proposalsCount; + self.lastCancelledProposalId = lastCancelledProposalId; + emit ProposalsCancelledTill(lastCancelledProposalId); + } + + // --- + // Getters + // --- + + function canExecute( + Context storage self, + uint256 proposalId, + Duration afterScheduleDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Scheduled + && Timestamps.now() >= afterScheduleDelay.addTo(proposalState.scheduledAt); + } + + function canSchedule( + Context storage self, + uint256 proposalId, + Duration afterSubmitDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Submitted + && Timestamps.now() >= afterSubmitDelay.addTo(proposalState.submittedAt); + } + + function getProposalsCount(Context storage self) internal view returns (uint256) { + return self.proposalsCount; + } + + function getProposalInfo( + Context storage self, + uint256 proposalId + ) internal view returns (Status status, address executor, Timestamp submittedAt, Timestamp scheduledAt) { + ProposalData memory proposalData = self.proposals[proposalId].data; + _checkProposalExists(proposalId, proposalData); + + status = _isProposalMarkedCancelled(self, proposalId, proposalData) ? Status.Cancelled : proposalData.status; + executor = address(proposalData.executor); + submittedAt = proposalData.submittedAt; + scheduledAt = proposalData.scheduledAt; + } + + function getProposalCalls( + Context storage self, + uint256 proposalId + ) internal view returns (ExternalCall[] memory calls) { + Proposal memory proposal = self.proposals[proposalId]; + _checkProposalExists(proposalId, proposal.data); + calls = proposal.calls; + } + + // --- + // Private methods + // --- + + function _checkProposalExists(uint256 proposalId, ProposalData memory proposalData) private pure { + if (proposalData.status == Status.NotExist) { + revert ProposalNotFound(proposalId); + } + } + + function _isProposalMarkedCancelled( + Context storage self, + uint256 proposalId, + ProposalData memory proposalData + ) private view returns (bool) { + return proposalId <= self.lastCancelledProposalId || proposalData.status == Status.Cancelled; + } +} diff --git a/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsSubmissionTimestampNotSet.sol b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsSubmissionTimestampNotSet.sol new file mode 100644 index 00000000..6b4b7bd2 --- /dev/null +++ b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsSubmissionTimestampNotSet.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {ExternalCall, ExternalCalls, IExternalExecutor} from "./ExternalCalls.sol"; + +/// @dev Describes the lifecycle state of a proposal +enum Status { + /// Proposal has not been submitted yet + NotExist, + /// Proposal has been successfully submitted but not scheduled yet. This state is only reachable from NotExist + Submitted, + /// Proposal has been successfully scheduled after submission. This state is only reachable from Submitted + Scheduled, + /// Proposal has been successfully executed after being scheduled. This state is only reachable from Scheduled + /// and is the final state of the proposal + Executed, + /// Proposal was cancelled before execution. Cancelled proposals cannot be resubmitted or rescheduled. + /// This state is only reachable from Submitted or Scheduled and is the final state of the proposal. + /// @dev A proposal is considered cancelled if it was not executed and its ID is less than the ID of the last + /// submitted proposal at the time the cancelAll() method was called. To check if a proposal is in the Cancelled + /// state, use the _isProposalMarkedCancelled() view function. + Cancelled +} + +/// @dev Manages a collection of proposals with associated external calls stored as Proposal struct. +/// Proposals are uniquely identified by sequential IDs, starting from one. +library ExecutableProposals { + using ExternalCalls for ExternalCall[]; + + /// @dev Efficiently stores proposal data within a single EVM word. + /// This struct allows gas-efficient loading from storage using a single EVM sload operation. + struct ProposalData { + /// + /// @dev slot 0: [0..7] + /// The current status of the proposal. See Status for details. + Status status; + /// + /// @dev slot 0: [8..167] + /// The address of the associated executor used for executing the proposal's calls. + address executor; + /// + /// @dev slot 0: [168..207] + /// The timestamp when the proposal was submitted. + Timestamp submittedAt; + /// + /// @dev slot 0: [208..247] + /// The timestamp when the proposal was scheduled for execution. Equals zero if the proposal hasn't been scheduled yet. + Timestamp scheduledAt; + } + + struct Proposal { + /// @dev Proposal data packed into a struct for efficient loading into memory. + ProposalData data; + /// @dev The list of external calls associated with the proposal. + ExternalCall[] calls; + } + + error EmptyCalls(); + error ProposalNotFound(uint256 proposalId); + error ProposalNotScheduled(uint256 proposalId); + error ProposalNotSubmitted(uint256 proposalId); + error AfterSubmitDelayNotPassed(uint256 proposalId); + error AfterScheduleDelayNotPassed(uint256 proposalId); + + event ProposalSubmitted(uint256 indexed id, address indexed executor, ExternalCall[] calls); + event ProposalScheduled(uint256 indexed id); + event ProposalExecuted(uint256 indexed id, bytes[] callResults); + event ProposalsCancelledTill(uint256 proposalId); + + struct Context { + uint64 proposalsCount; + uint64 lastCancelledProposalId; + mapping(uint256 proposalId => Proposal) proposals; + } + + // --- + // Proposal lifecycle + // --- + + function submit( + Context storage self, + address executor, + ExternalCall[] memory calls + ) internal returns (uint256 newProposalId) { + if (calls.length == 0) { + revert EmptyCalls(); + } + + /// @dev: proposal ids are one-based. The first item has id = 1 + newProposalId = ++self.proposalsCount; + Proposal storage newProposal = self.proposals[newProposalId]; + + newProposal.data.executor = executor; + newProposal.data.status = Status.Submitted; + // mutated + //newProposal.data.submittedAt = Timestamps.now(); + + uint256 callsCount = calls.length; + for (uint256 i = 0; i < callsCount; ++i) { + newProposal.calls.push(calls[i]); + } + + emit ProposalSubmitted(newProposalId, executor, calls); + } + + function schedule(Context storage self, uint256 proposalId, Duration afterSubmitDelay) internal { + ProposalData memory proposalState = self.proposals[proposalId].data; + + if (proposalState.status != Status.Submitted || _isProposalMarkedCancelled(self, proposalId, proposalState)) { + revert ProposalNotSubmitted(proposalId); + } + + if (afterSubmitDelay.addTo(proposalState.submittedAt) > Timestamps.now()) { + revert AfterSubmitDelayNotPassed(proposalId); + } + + proposalState.status = Status.Scheduled; + proposalState.scheduledAt = Timestamps.now(); + self.proposals[proposalId].data = proposalState; + + emit ProposalScheduled(proposalId); + } + + function execute(Context storage self, uint256 proposalId, Duration afterScheduleDelay) internal { + Proposal memory proposal = self.proposals[proposalId]; + + if (proposal.data.status != Status.Scheduled || _isProposalMarkedCancelled(self, proposalId, proposal.data)) { + revert ProposalNotScheduled(proposalId); + } + + if (afterScheduleDelay.addTo(proposal.data.scheduledAt) > Timestamps.now()) { + revert AfterScheduleDelayNotPassed(proposalId); + } + + self.proposals[proposalId].data.status = Status.Executed; + + address executor = proposal.data.executor; + ExternalCall[] memory calls = proposal.calls; + + bytes[] memory results = calls.execute(IExternalExecutor(executor)); + + emit ProposalExecuted(proposalId, results); + } + + function cancelAll(Context storage self) internal { + uint64 lastCancelledProposalId = self.proposalsCount; + self.lastCancelledProposalId = lastCancelledProposalId; + emit ProposalsCancelledTill(lastCancelledProposalId); + } + + // --- + // Getters + // --- + + function canExecute( + Context storage self, + uint256 proposalId, + Duration afterScheduleDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Scheduled + && Timestamps.now() >= afterScheduleDelay.addTo(proposalState.scheduledAt); + } + + function canSchedule( + Context storage self, + uint256 proposalId, + Duration afterSubmitDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Submitted + && Timestamps.now() >= afterSubmitDelay.addTo(proposalState.submittedAt); + } + + function getProposalsCount(Context storage self) internal view returns (uint256) { + return self.proposalsCount; + } + + function getProposalInfo( + Context storage self, + uint256 proposalId + ) internal view returns (Status status, address executor, Timestamp submittedAt, Timestamp scheduledAt) { + ProposalData memory proposalData = self.proposals[proposalId].data; + _checkProposalExists(proposalId, proposalData); + + status = _isProposalMarkedCancelled(self, proposalId, proposalData) ? Status.Cancelled : proposalData.status; + executor = address(proposalData.executor); + submittedAt = proposalData.submittedAt; + scheduledAt = proposalData.scheduledAt; + } + + function getProposalCalls( + Context storage self, + uint256 proposalId + ) internal view returns (ExternalCall[] memory calls) { + Proposal memory proposal = self.proposals[proposalId]; + _checkProposalExists(proposalId, proposal.data); + calls = proposal.calls; + } + + // --- + // Private methods + // --- + + function _checkProposalExists(uint256 proposalId, ProposalData memory proposalData) private pure { + if (proposalData.status == Status.NotExist) { + revert ProposalNotFound(proposalId); + } + } + + function _isProposalMarkedCancelled( + Context storage self, + uint256 proposalId, + ProposalData memory proposalData + ) private view returns (bool) { + return proposalId <= self.lastCancelledProposalId || proposalData.status == Status.Cancelled; + } +} diff --git a/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsWithCancellationMarkingBugStillPresent.sol b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsWithCancellationMarkingBugStillPresent.sol new file mode 100644 index 00000000..f94b0533 --- /dev/null +++ b/certora/mutation/mutants/ExecutableProposals/ExecutableProposalsWithCancellationMarkingBugStillPresent.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; + +import {ExternalCall, ExternalCalls, IExternalExecutor} from "./ExternalCalls.sol"; + +/// @dev Describes the lifecycle state of a proposal +enum Status { + /// Proposal has not been submitted yet + NotExist, + /// Proposal has been successfully submitted but not scheduled yet. This state is only reachable from NotExist + Submitted, + /// Proposal has been successfully scheduled after submission. This state is only reachable from Submitted + Scheduled, + /// Proposal has been successfully executed after being scheduled. This state is only reachable from Scheduled + /// and is the final state of the proposal + Executed, + /// Proposal was cancelled before execution. Cancelled proposals cannot be resubmitted or rescheduled. + /// This state is only reachable from Submitted or Scheduled and is the final state of the proposal. + /// @dev A proposal is considered cancelled if it was not executed and its ID is less than the ID of the last + /// submitted proposal at the time the cancelAll() method was called. To check if a proposal is in the Cancelled + /// state, use the _isProposalMarkedCancelled() view function. + Cancelled +} + +/// @dev Manages a collection of proposals with associated external calls stored as Proposal struct. +/// Proposals are uniquely identified by sequential IDs, starting from one. +library ExecutableProposals { + using ExternalCalls for ExternalCall[]; + + /// @dev Efficiently stores proposal data within a single EVM word. + /// This struct allows gas-efficient loading from storage using a single EVM sload operation. + struct ProposalData { + /// + /// @dev slot 0: [0..7] + /// The current status of the proposal. See Status for details. + Status status; + /// + /// @dev slot 0: [8..167] + /// The address of the associated executor used for executing the proposal's calls. + address executor; + /// + /// @dev slot 0: [168..207] + /// The timestamp when the proposal was submitted. + Timestamp submittedAt; + /// + /// @dev slot 0: [208..247] + /// The timestamp when the proposal was scheduled for execution. Equals zero if the proposal hasn't been scheduled yet. + Timestamp scheduledAt; + } + + struct Proposal { + /// @dev Proposal data packed into a struct for efficient loading into memory. + ProposalData data; + /// @dev The list of external calls associated with the proposal. + ExternalCall[] calls; + } + + error EmptyCalls(); + error ProposalNotFound(uint256 proposalId); + error ProposalNotScheduled(uint256 proposalId); + error ProposalNotSubmitted(uint256 proposalId); + error AfterSubmitDelayNotPassed(uint256 proposalId); + error AfterScheduleDelayNotPassed(uint256 proposalId); + + event ProposalSubmitted(uint256 indexed id, address indexed executor, ExternalCall[] calls); + event ProposalScheduled(uint256 indexed id); + event ProposalExecuted(uint256 indexed id, bytes[] callResults); + event ProposalsCancelledTill(uint256 proposalId); + + struct Context { + uint64 proposalsCount; + uint64 lastCancelledProposalId; + mapping(uint256 proposalId => Proposal) proposals; + } + + // --- + // Proposal lifecycle + // --- + + function submit( + Context storage self, + address executor, + ExternalCall[] memory calls + ) internal returns (uint256 newProposalId) { + if (calls.length == 0) { + revert EmptyCalls(); + } + + /// @dev: proposal ids are one-based. The first item has id = 1 + newProposalId = ++self.proposalsCount; + Proposal storage newProposal = self.proposals[newProposalId]; + + newProposal.data.executor = executor; + newProposal.data.status = Status.Submitted; + newProposal.data.submittedAt = Timestamps.now(); + + uint256 callsCount = calls.length; + for (uint256 i = 0; i < callsCount; ++i) { + newProposal.calls.push(calls[i]); + } + + emit ProposalSubmitted(newProposalId, executor, calls); + } + + function schedule(Context storage self, uint256 proposalId, Duration afterSubmitDelay) internal { + ProposalData memory proposalState = self.proposals[proposalId].data; + + if (proposalState.status != Status.Submitted || _isProposalMarkedCancelled(self, proposalId, proposalState)) { + revert ProposalNotSubmitted(proposalId); + } + + if (afterSubmitDelay.addTo(proposalState.submittedAt) > Timestamps.now()) { + revert AfterSubmitDelayNotPassed(proposalId); + } + + proposalState.status = Status.Scheduled; + proposalState.scheduledAt = Timestamps.now(); + self.proposals[proposalId].data = proposalState; + + emit ProposalScheduled(proposalId); + } + + function execute(Context storage self, uint256 proposalId, Duration afterScheduleDelay) internal { + Proposal memory proposal = self.proposals[proposalId]; + + if (proposal.data.status != Status.Scheduled || _isProposalMarkedCancelled(self, proposalId, proposal.data)) { + revert ProposalNotScheduled(proposalId); + } + + if (afterScheduleDelay.addTo(proposal.data.scheduledAt) > Timestamps.now()) { + revert AfterScheduleDelayNotPassed(proposalId); + } + + self.proposals[proposalId].data.status = Status.Executed; + + address executor = proposal.data.executor; + ExternalCall[] memory calls = proposal.calls; + + bytes[] memory results = calls.execute(IExternalExecutor(executor)); + + emit ProposalExecuted(proposalId, results); + } + + function cancelAll(Context storage self) internal { + uint64 lastCancelledProposalId = self.proposalsCount; + self.lastCancelledProposalId = lastCancelledProposalId; + emit ProposalsCancelledTill(lastCancelledProposalId); + } + + // --- + // Getters + // --- + + function canExecute( + Context storage self, + uint256 proposalId, + Duration afterScheduleDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Scheduled + && Timestamps.now() >= afterScheduleDelay.addTo(proposalState.scheduledAt); + } + + function canSchedule( + Context storage self, + uint256 proposalId, + Duration afterSubmitDelay + ) internal view returns (bool) { + ProposalData memory proposalState = self.proposals[proposalId].data; + if (_isProposalMarkedCancelled(self, proposalId, proposalState)) return false; + return proposalState.status == Status.Submitted + && Timestamps.now() >= afterSubmitDelay.addTo(proposalState.submittedAt); + } + + function getProposalsCount(Context storage self) internal view returns (uint256) { + return self.proposalsCount; + } + + function getProposalInfo( + Context storage self, + uint256 proposalId + ) internal view returns (Status status, address executor, Timestamp submittedAt, Timestamp scheduledAt) { + ProposalData memory proposalData = self.proposals[proposalId].data; + _checkProposalExists(proposalId, proposalData); + + status = _isProposalMarkedCancelled(self, proposalId, proposalData) ? Status.Cancelled : proposalData.status; + executor = address(proposalData.executor); + submittedAt = proposalData.submittedAt; + scheduledAt = proposalData.scheduledAt; + } + + function getProposalCalls( + Context storage self, + uint256 proposalId + ) internal view returns (ExternalCall[] memory calls) { + Proposal memory proposal = self.proposals[proposalId]; + _checkProposalExists(proposalId, proposal.data); + calls = proposal.calls; + } + + // --- + // Private methods + // --- + + function _checkProposalExists(uint256 proposalId, ProposalData memory proposalData) private pure { + if (proposalData.status == Status.NotExist) { + revert ProposalNotFound(proposalId); + } + } + + function _isProposalMarkedCancelled( + Context storage self, + uint256 proposalId, + ProposalData memory proposalData + ) private view returns (bool) { + // mutation: keeping this line from buggy version, should be changed in upcoming fix + return proposalId <= self.lastCancelledProposalId || proposalData.status == Status.Cancelled; + } +} diff --git a/certora/mutation/mutants/Proposers/ProposersFindingW2-1.sol b/certora/mutation/mutants/Proposers/ProposersFindingW2-1.sol new file mode 100644 index 00000000..1d514f53 --- /dev/null +++ b/certora/mutation/mutants/Proposers/ProposersFindingW2-1.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; + +/// @title Proposers Library +/// @dev This library manages proposers and their assigned executors in a governance system, providing functions to register, +/// unregister, and verify proposers and their roles. It ensures proper assignment and validation of proposers and executors. + +// MUTATION +// At time of writing there is actually no mutation. This mutant +// is a placeholder saving version of Proposers when +// finding W2-1 was identified. At time of writing this +// finding has not yet been fixed. +library Proposers { + // --- + // Errors + // --- + error InvalidExecutor(address executor); + error InvalidProposerAccount(address account); + error ProposerNotRegistered(address proposer); + error ProposerAlreadyRegistered(address proposer); + + // --- + // Events + // --- + + event AdminExecutorSet(address indexed adminExecutor); + event ProposerRegistered(address indexed proposer, address indexed executor); + event ProposerUnregistered(address indexed proposer, address indexed executor); + + // --- + // Data Types + // --- + + /// @notice The info about the registered proposer and associated executor + /// @param account Address of the proposer + /// @param executor Address of the executor associated with proposer. When proposer submits proposals, they execution + /// will be done with this address. + struct Proposer { + address account; + address executor; + } + + /// @notice The internal info about the proposer's executor data + /// @param proposerIndex The one-based index of the proposer associated with the `executor` from + /// the `Context.proposers` array + /// @param executor The address of the executor associated with the proposer + struct ExecutorData { + /// @dev slot0: [0..31] + IndexOneBased proposerIndex; + /// @dev slot0: [32..191] + address executor; + } + + /// @notice The context of the Proposers library + /// @param proposers The list of the registered proposers + /// @param executors The mapping with the executor info of the registered proposers + /// @param executorRefsCounts The mapping with the count of how many proposers is associated + /// with given executor address + struct Context { + address[] proposers; + mapping(address proposer => ExecutorData) executors; + mapping(address executor => uint256 usagesCount) executorRefsCounts; + } + + // --- + // Main Functionality + // --- + + /// @dev Registers a proposer with an assigned executor. + /// @param self The storage state of the Proposers library. + /// @param proposerAccount The address of the proposer to register. + /// @param executor The address of the assigned executor. + function register(Context storage self, address proposerAccount, address executor) internal { + if (proposerAccount == address(0)) { + revert InvalidProposerAccount(proposerAccount); + } + + if (executor == address(0)) { + revert InvalidExecutor(executor); + } + + if (_isRegisteredProposer(self.executors[proposerAccount])) { + revert ProposerAlreadyRegistered(proposerAccount); + } + + self.proposers.push(proposerAccount); + self.executors[proposerAccount] = + ExecutorData({proposerIndex: IndicesOneBased.fromOneBasedValue(self.proposers.length), executor: executor}); + self.executorRefsCounts[executor] += 1; + + emit ProposerRegistered(proposerAccount, executor); + } + + /// @dev Unregisters a proposer. + /// @param self The storage state of the Proposers library. + /// @param proposerAccount The address of the proposer to unregister. + function unregister(Context storage self, address proposerAccount) internal { + ExecutorData memory executorData = self.executors[proposerAccount]; + + _checkRegisteredProposer(proposerAccount, executorData); + + IndexOneBased lastProposerIndex = IndicesOneBased.fromOneBasedValue(self.proposers.length); + if (executorData.proposerIndex != lastProposerIndex) { + self.proposers[executorData.proposerIndex.toZeroBasedValue()] = + self.proposers[lastProposerIndex.toZeroBasedValue()]; + } + + self.proposers.pop(); + delete self.executors[proposerAccount]; + self.executorRefsCounts[executorData.executor] -= 1; + + emit ProposerUnregistered(proposerAccount, executorData.executor); + } + + // --- + // Getters + // --- + + /// @dev Retrieves the details of a specific proposer. + /// @param self The storage state of the Proposers library. + /// @param proposerAccount The address of the proposer. + /// @return proposer The struct representing the details of the proposer. + function getProposer( + Context storage self, + address proposerAccount + ) internal view returns (Proposer memory proposer) { + ExecutorData memory executorData = self.executors[proposerAccount]; + _checkRegisteredProposer(proposerAccount, executorData); + + proposer.account = proposerAccount; + proposer.executor = executorData.executor; + } + + /// @dev Retrieves all registered proposers. + /// @param self The storage state of the Proposers library. + /// @return proposers An array of structs representing all registered proposers. + function getAllProposers(Context storage self) internal view returns (Proposer[] memory proposers) { + proposers = new Proposer[](self.proposers.length); + for (uint256 i = 0; i < proposers.length; ++i) { + proposers[i] = getProposer(self, self.proposers[i]); + } + } + + /// @dev Checks if an account is a registered proposer. + /// @param self The storage state of the Proposers library. + /// @param account The address to check. + /// @return A boolean indicating whether the account is a registered proposer. + function isProposer(Context storage self, address account) internal view returns (bool) { + return _isRegisteredProposer(self.executors[account]); + } + + /// @dev Checks if an account is an executor. + /// @param self The storage state of the Proposers library. + /// @param account The address to check. + /// @return A boolean indicating whether the account is an executor. + function isExecutor(Context storage self, address account) internal view returns (bool) { + return self.executorRefsCounts[account] > 0; + } + + /// @dev Checks that proposer with given executorData is registered proposer + function _checkRegisteredProposer(address proposerAccount, ExecutorData memory executorData) internal pure { + if (!_isRegisteredProposer(executorData)) { + revert ProposerNotRegistered(proposerAccount); + } + } + + /// @dev Returns if the executorData belongs to registered proposer + function _isRegisteredProposer(ExecutorData memory executorData) internal pure returns (bool) { + return executorData.proposerIndex.isNotEmpty(); + } +} diff --git a/certora/specs/DualGovernance.spec b/certora/specs/DualGovernance.spec new file mode 100644 index 00000000..3a6bdda6 --- /dev/null +++ b/certora/specs/DualGovernance.spec @@ -0,0 +1,335 @@ +using EscrowA as EscrowA; +using EscrowB as EscrowB; + +methods { + // envfrees + function getProposer(address account) external returns (Proposers.Proposer memory) envfree; + function getProposerIndexFromExecutor(address proposer) external returns (uint32) envfree; + function getState() external returns (DualGovernanceHarness.DGHarnessState) envfree; + function isUnset(DualGovernanceHarness.DGHarnessState state) external returns (bool) envfree; + function isNormal(DualGovernanceHarness.DGHarnessState state) external returns (bool) envfree; + function isVetoSignalling(DualGovernanceHarness.DGHarnessState state) external returns (bool) envfree; + function isVetoSignallingDeactivation(DualGovernanceHarness.DGHarnessState state) external returns (bool) envfree; + function isVetoCooldown(DualGovernanceHarness.DGHarnessState state) external returns (bool) envfree; + function isRageQuit(DualGovernanceHarness.DGHarnessState state) external returns (bool) envfree; + function getVetoSignallingActivatedAt() external returns (DualGovernanceHarness.Timestamp) envfree; + function getRageQuitEscrow() external returns (address) envfree; + function getVetoSignallingEscrow() external returns (address) envfree; + function getFirstSeal() external returns (uint256) envfree; + function getSecondSeal() external returns (uint256) envfree; + + // envfrees escrow + function EscrowA.isRageQuitState() external returns (bool) envfree; + function EscrowB.isRageQuitState() external returns (bool) envfree; + + // route escrow functions to implementations while + // still allowing escrow addresses to vary + function _.startRageQuit(DualGovernanceHarness.Duration, DualGovernance.Duration) external => DISPATCHER(true); + function _.initialize(DualGovernanceHarness.Duration) external => DISPATCHER(true); + function _.setMinAssetsLockDuration(DualGovernanceHarness.Duration newMinAssetsLockDuration) external => DISPATCHER(true); + function _.getRageQuitSupport() external => DISPATCHER(true); + function _.isRageQuitFinalized() external => DISPATCHER(true); + + + // The NONDETs and summaries here essentially introduce an assumption + // that the summarized/NONDETed function does not influence the + // state of the contracts explicitly added to the scene. + // This is reached by Escrow.withdrawETH() and makes a lowlevel + // call on recipient causing a HAVOC. The call in Address passes + // an empty payload, so it will call the `receive` function of + // the recipient if there is one. + function Address.sendValue(address recipient, uint256 amount) internal => NONDET; + // This is reached by ResealManager.reseal and makes a low-level call + // on target which havocs all contracts. (And we can't NONDET functions + // that return bytes). The implementation of Address is meant + // to be a safer alternative to directly using call, according to its + // comments. + function Address.functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) => CVLFunctionCallWithValue(target, data, value); + // This function belongs to ISealable which we do not have an implementation + // of and it causes a havoc of all contracts. It is reached by + // ResealManager.reseal/resume. This is a view function so it must be safe. + function _.getResumeSinceTimestamp() external => CONSTANT; + // This function belongs to IOwnable which we do not have an implementation + // of and it causes a havoc of all contracts. It is reached by EPT. + // transferExecutorOwnership. It is not a view function, + // but from the description it likely only affects its own state. + function _.transferOwnership(address newOwner) external => NONDET; + // This is reached by 2 calls in EPT and reaches a call to + // functionCallWithValue. + function Executor.execute(address, uint256, bytes) external returns (bytes) => NONDET; + + // This NONDET is meant to address a timeout of dg_kp_2 + // for which EPT is a significant bottleneck but not + // really needed for verifying DG. We also have separate rules for EPT. + function EmergencyProtectedTimelock.submit(address executor, + DualGovernanceHarness.ExternalCall[] calls) external returns (uint256) => NONDET; +} + +function CVLFunctionCallWithValue(address target, bytes data, uint256 value) returns bytes { + bytes ret; + return ret; +} + +function escrowAddressIsRageQuit(address escrow) returns bool { + if (escrow == EscrowA) { + return EscrowA.isRageQuitState(); + } else if (escrow == EscrowB) { + return EscrowB.isRageQuitState(); + } + // EscrowA and EscrowB are the only ones in the scene so this should + // not be reached. + return false; +} + +function rageQuitThresholdAssumptions() returns bool { + return getFirstSeal() > 0 && getSecondSeal() > getFirstSeal(); +} + +// for any registered proposer, his index should be ≤ the length of +// the array of proposers +// “for each entry in the struct in the array, show that the index inside is +// the same as the real array index” +// NOTE: this has not yet been addressed by Lido, so this should fail now. +invariant w2_1_indexes_match(uint idx, address proposer_addr) + proposer_addr != 0 && idx > 0 && getProposerIndexFromExecutor(proposer_addr) == idx => + idx <= currentContract._proposers.proposers.length && + currentContract._proposers.proposers[require_uint256(idx - 1)] == proposer_addr + && getProposerIndexFromExecutor(currentContract._proposers.proposers[require_uint256(idx - 1)]) == idx { + preserved unregisterProposer(address a) with (env e) { + requireInvariant w2_1_indexes_match(getProposerIndexFromExecutor(a), a); + requireInvariant zero_address_is_not_valid_proposer(); + } + preserved { + // loop unrolling + require currentContract._proposers.proposers.length <= 5; + requireInvariant zero_address_is_not_valid_proposer(); + } + } + +invariant zero_address_is_not_valid_proposer() + currentContract._proposers.executors[0].proposerIndex == 0 && + (forall uint idx. (idx >= 0 && idx < currentContract._proposers.proposers.length => currentContract._proposers.proposers[idx] != 0)); + +// Proposals cannot be executed in the Veto Signaling (both parent state and +// Deactivation sub-state) and Rage Quit states. +rule dg_kp_1_proposal_execution { + env e; + uint256 proposal_id; + scheduleProposal(e, proposal_id); + DualGovernanceHarness.DGHarnessState state = getState(); + assert !isVetoSignalling(state) && !isRageQuit(state) && + !isVetoSignallingDeactivation(state); +} + +// Proposals cannot be submitted in the Veto Signaling Deactivation sub-state +// or in the Veto Cooldown state. +rule dg_kp_2_proposal_submission { + env e; + DualGovernanceHarness.ExternalCall[] calls; + submitProposal(e, calls); + DualGovernanceHarness.DGHarnessState state = getState(); + assert !isVetoSignallingDeactivation(state) && !isVetoCooldown(state); +} + +// If a proposal was submitted after the last time the Veto Signaling state was +// activated, then it cannot be executed in the Veto Cooldown state. +rule dg_kp_3_cooldown_execution { + calldataarg args; + env e; + uint256 proposalId; + uint256 id; + ExecutableProposals.Status proposal_status; + address executor; + DualGovernanceHarness.Timestamp submittedAt; + DualGovernanceHarness.Timestamp scheduledAt; + (id, proposal_status, executor, submittedAt, scheduledAt) = + getProposalInfoHarnessed(e, proposalId); + + scheduleProposal(e, proposalId); + + // This requires refers to the state that was stepped into during the + // the scheduleProposal call + require isVetoCooldown(getState()); + DualGovernanceHarness.Timestamp vetoSignallingActivatedAt = + getVetoSignallingActivatedAt(); + assert submittedAt <= vetoSignallingActivatedAt; +} + +// One rage quit cannot start until the previous rage quit has finalized. In +// other words, there can only be at most one active rage quit escrow at a time. +rule dg_kp_4_single_ragequit (method f) { + env e; + calldataarg args; + require getRageQuitEscrow() != 0 => escrowAddressIsRageQuit(getRageQuitEscrow()); + require EscrowA == EscrowB || !(EscrowA.isRageQuitState() && EscrowB.isRageQuitState()); + f(e, args); + assert EscrowA == EscrowB || !(EscrowA.isRageQuitState() && EscrowB.isRageQuitState()); +} + +rule dg_kp_4_single_ragequit_adendum (method f) { + env e; + calldataarg args; + require !escrowAddressIsRageQuit(getVetoSignallingEscrow()); + f(e, args); + assert !escrowAddressIsRageQuit(getVetoSignallingEscrow()); +} + +// PP-1: Regardless of the state in which a proposal is submitted, if the +// stakers are able to amass and maintain a certain amount of rage quit +// support before the ProposalExecutionMinTimelock expires, they can extend +// the timelock for a proportional time, according to the dynamic timelock +// calculation. +rule pp_kp_1_ragequit_extends { + env e; + // Assume not initially in VetoCooldown as we stay in this state + // unless vetoCooldownDuration has passed + require !isVetoCooldown(getState()); + + activateNextState(e); + + // Note: the only two states where execution is possible are Normal + // and VetoCooldown + // assuming there is enough ragequit support and the max timelock + // has not exceeded: + // - we do not transition into normal state + // - if timelock is extended with ragequit support, we + // cannot transition into VetoCooldown + uint256 rageQuitSupport = getRageQuitSupportHarnessed(e); + require !isDynamicTimelockPassed(e, rageQuitSupport); + require rageQuitThresholdAssumptions(); + require getFirstSealRageQuitSupportCrossed(e); + + // we cannot transition to normal state above first seal ragequit support + assert !isNormal(getState()); + assert !isVetoCooldown(getState()); +} + +// PP-2: It's not possible to prevent a proposal from being executed +// indefinitely without triggering a rage quit. +rule pp_kp_2_ragequit_trigger { + env e; + calldataarg args; + + uint256 rageQuitSupport = getRageQuitSupportHarnessed(e); + require rageQuitThresholdAssumptions(); + require getFirstSealRageQuitSupportCrossed(e); + + // Assumptions about waiting long enough: + // Assume we have waited long enough if in VetoSignalling + require isDynamicTimelockPassed(e, rageQuitSupport); + // Assume we wait enough time for deactivation if needed + require isVetoSignallingReactivationPassed(e); + // Assume we have waited enough time to exit deactivation if needed + require isVetoSignallingDeactivationPassed(e); + + DualGovernanceHarness.DGHarnessState old_state = getState(); + activateNextState(e); + DualGovernanceHarness.DGHarnessState new_state = getState(); + + // from normal we eventually make forward progress into veto signalling + assert isNormal(old_state) => isVetoSignalling(new_state); + // from veto signalling we either make forward progress + // into rageQuit or vetoSignallingDeactivation + // (and we show forward progress is eventually made + // from vetoSignallingDeactivation) + assert isVetoSignalling(old_state) => + isRageQuit(new_state) || isVetoSignallingDeactivation(new_state); + // From VetoSignallingDeactivation we make forward progress + // into rageQuit or vetoSignallingCooldown + // (and proposal execution is possible from cooldown) + assert isVetoSignallingDeactivation(old_state) => + isRageQuit(new_state) || isVetoCooldown(new_state); +} + +// PP-3: It's not possible to block proposal submission indefinitely. +rule pp_kp_3_no_indefinite_proposal_submission_block { + env e; + + uint256 rageQuitSupport = getRageQuitSupportHarnessed(e); + // Assume we have waited long enough + require rageQuitThresholdAssumptions(); + require isVetoSignallingDeactivationMaxDurationPassed(e) && isVetoCooldownDurationPassed(e); + + + DualGovernanceHarness.DGHarnessState old_state = getState(); + activateNextState(e); + DualGovernanceHarness.DGHarnessState new_state = getState(); + + // Show that from any state in which proposal submission is disallowed, we must step on given our waiting time + assert isVetoCooldown(old_state) => isNormal(new_state) || isVetoSignalling(new_state); + assert isVetoSignallingDeactivation(old_state) => isVetoCooldown(new_state) || isVetoSignalling(new_state) || isRageQuit(new_state); +} + +// PP-4: Until the Veto Signaling Deactivation sub-state transitions to Veto +// Cooldown, there is always a possibility (given enough rage quit support) of +// canceling Deactivation and returning to the parent state (possibly +// triggering a rage quit immediately afterwards). +rule pp_kp_4_veto_signalling_deactivation_cancellable() { + env e; + require isVetoSignallingDeactivation(getState()); + require rageQuitThresholdAssumptions(); + require getSecondSealRageQuitSupportCrossed(e); + activateNextState(e); + + // the only way out of veto signalling deactivation that does not go back to the parent is veto cooldown, + // so if we can't go here, there is no way to bypass the rage quit support + assert !isVetoCooldown(getState()); + // and we also don't want to be stuck in deactivation, but have a way back to the parent + satisfy isVetoSignalling(getState()); +} + +// If proposal submission succeeds, the system was in on of these states: Normal, Veto Signalling, Rage Quit +rule dg_states_1_proposal_submission_states() { + env e; + calldataarg args; + submitProposal(e, args); + // we take the state after and not before, because state transitions are + // triggered at the start of actions, not at the end of the ones that + // caused them to become possible. + DualGovernanceHarness.DGHarnessState state = getState(); + + assert isNormal(state) || isVetoSignalling(state) || isRageQuit(state); +} + +// If proposal scheduling succeeds, the system was in one of these states: +// Normal, Veto Cooldown +rule dg_states_2_proposal_scheduling_states() { + env e; + calldataarg args; + scheduleProposal(e, args); + // we take the state after and not before, because state transitions are + // triggered at the start of actions, not at the end of the ones that + // caused them to become possible. + DualGovernanceHarness.DGHarnessState state = getState(); + + assert isNormal(state) || isVetoCooldown(state); +} + +// Only specified transitions are possible +rule dg_transitions_1_only_legal_transitions() { + env e; + DualGovernanceHarness.DGHarnessState old_state = getState(); + activateNextState(e); + DualGovernanceHarness.DGHarnessState new_state = getState(); + // we are not interested in the cases where no transition happened + require old_state != new_state; + + require rageQuitThresholdAssumptions(); + + if(isNormal(old_state)) { + assert isVetoSignalling(new_state); + } else if(isVetoSignalling(old_state)) { + assert isRageQuit(new_state) || isVetoSignallingDeactivation(new_state); + } else if(isVetoSignallingDeactivation(old_state)) { + assert isVetoSignalling(new_state) || + isVetoCooldown(new_state) || + isRageQuit(new_state); + } else if(isVetoCooldown(old_state)) { + assert isNormal(new_state) || isVetoSignalling(new_state); + } else if(isRageQuit(old_state)) { + assert isVetoSignalling(new_state) || isVetoCooldown(new_state); + } else { + // unset state should not be reachable + assert false; + } +} \ No newline at end of file diff --git a/certora/specs/Escrow.spec b/certora/specs/Escrow.spec new file mode 100644 index 00000000..5dc1bd7a --- /dev/null +++ b/certora/specs/Escrow.spec @@ -0,0 +1,298 @@ +using DummyStETH as stEth; +using DummyWstETH as wst_eth; +using Escrow as escrow; +using DualGovernance as dualGovernance; +using ImmutableDualGovernanceConfigProvider as config; +using DummyWithdrawalQueue as withdrawalQueue; + +methods { + // calls to Escrow from dualGovernance + function _.getRageQuitSupport() external => DISPATCHER(true); + function _.isRageQuitFinalized() external => DISPATCHER(true); + function _.startRageQuit(Durations.Duration, Durations.Duration) external => DISPATCHER(true); + function _.initialize(Durations.Duration) external => DISPATCHER(true); + function _.setMinAssetsLockDuration(Durations.Duration newMinAssetsLockDuration) external => DISPATCHER(true); + + //envfree + function isWithdrawalsBatchesFinalized() external returns (bool) envfree; + function getRageQuitSupport() external returns (Escrow.PercentD16) envfree; + function withdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT() external returns (uint) envfree; + function getRageQuitExtensionDelayStartedAt() external returns (Escrow.Timestamp) envfree; + + + //calls to stEth and wst_eth from spec + function DummyStETH.getTotalShares() external returns(uint256) envfree; + function DummyStETH.totalSupply() external returns(uint256) envfree; + function DummyStETH.balanceOf(address) external returns(uint256) envfree; + function DummyWstETH.balanceOf(address) external returns(uint256) envfree; + function DummyStETH.getPooledEthByShares(uint256) external returns (uint256) envfree; + function DummyWithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT() external returns (uint256) envfree; + + //calls to resealManager are from dualGov are unrelated + function _.resume(address sealable) external => NONDET; + function _.reseal(address sealable) external => NONDET; + function _.reseal(address[] sealables) external => NONDET; + + //calls to timelock are from dualGov are unrelated + function _.submit(address executor, DualGovernance.ExternalCall[] calls) external => NONDET; + + function _.schedule(uint256 proposalId) external => NONDET; + function _.execute(uint256 proposalId) external => NONDET; + function _.cancelAllNonExecutedProposals() external => NONDET; + + function _.canSchedule(uint256 proposalId) external => NONDET; + function _.canExecute(uint256 proposalId) external => NONDET; + + function _.getProposalSubmissionTime(uint256 proposalId) external => NONDET; + +} + +/** +Helper functions +**/ + +function isNotInitializedState() returns bool { + return require_uint8(currentContract._escrowState.state) == 0 /*EscrowState.State.NotInitialized*/; +} + +function isSignallingState() returns bool { + return require_uint8(currentContract._escrowState.state) ==1 /*EscrowState.State.SignallingEscrow*/; +} + +function isRageQuitState() returns bool { + return require_uint8(currentContract._escrowState.state) == 2 /*EscrowState.State.RageQuitEscrow*/; +} + +function isBatchQueueStateAbset() returns bool { + return require_uint8(currentContract._batchesQueue.info. state) == 0; +} + +function isBatchQueueStateOpened() returns bool { + return require_uint8(currentContract._batchesQueue.info. state) == 1; +} + +function isBatchQueueStateClosed() returns bool { + return require_uint8(currentContract._batchesQueue.info. state) == 2; +} + +function isAllStEthNFTClaimed() returns bool { + return currentContract._batchesQueue.info.totalUnstETHIdsClaimed == currentContract._batchesQueue.info.totalUnstETHIdsCount ; +} + +/** +@title If the state of an escrow is RageQuitEscrow, we can execute any method and it will still be in the same state afterwards +**/ +rule E_State_1_rageQuitFinalState(method f) +{ + bool rageQuitStateBefore = isRageQuitState(); + + env e; + calldataarg args; + f(e,args); + + bool rageQuitStateAfter = isRageQuitState(); + + assert rageQuitStateBefore => rageQuitStateAfter ; + +} + +/** +@title only dual governance can start a rage quit +**/ +rule E_KP_5_rageQuitStarter(method f) +{ + bool rageQuitStateBefore = isRageQuitState(); + + env e; + calldataarg args; + f(e,args); + + bool rageQuitStateAfter = isRageQuitState(); + + assert !rageQuitStateBefore && rageQuitStateAfter => + e.msg.sender == dualGovernance; + +} + +/** @title It's not possible to lock funds in or unlock funds from an escrow that is already in the rage quit state. +locking/unlocking implies changing the stETHLockedShares or unstETHLockedShares of an account. +WithdrawEth (after rage quit) is the other option to change account's asset entry. +**/ + +rule E_KP_3_rageQuitNolockUnlock(method f, address holder) +{ + bool rageQuitStateBefore = isRageQuitState(); + + uint256 beforeStShares = currentContract._accounting.assets[holder].stETHLockedShares; + uint256 beforeUnStShares = currentContract._accounting.assets[holder].unstETHLockedShares; + + env e; + calldataarg args; + f(e,args); + + assert rageQuitStateBefore => + (beforeStShares == currentContract._accounting.assets[holder].stETHLockedShares && + beforeUnStShares == currentContract._accounting.assets[holder].unstETHLockedShares ) || + f.selector == sig:withdrawETH().selector; +} + +/** +@title Before rage quit An agent cannot unlock their funds until SignallingEscrowMinLockTime has passed since this user last locked funds. +funds can move between stEthLocked and unstETHLockedShares +**/ + +rule E_KP_4_unlockMinTime(method f, address holder) +{ + bool rageQuitStateBefore = isRageQuitState(); + + uint256 beforeStShares = currentContract._accounting.assets[holder].stETHLockedShares; + uint256 beforeUnStShares = currentContract._accounting.assets[holder].unstETHLockedShares; + uint256 lastTimestamp = currentContract._accounting.assets[holder].lastAssetsLockTimestamp; + + env e; + calldataarg args; + f(e,args); + + uint256 min_time = currentContract._escrowState.minAssetsLockDuration; + + assert (!rageQuitStateBefore && e.block.timestamp < lastTimestamp + min_time) + => + beforeStShares + beforeUnStShares <= currentContract._accounting.assets[holder].stETHLockedShares + currentContract._accounting.assets[holder].unstETHLockedShares; +} + +/** +@title once requestNextWithdrawalsBatch results in batchesQueue.close() all additional calls result in close(); +**/ +rule W2_2_batchesQueueCloseFinalState(method f){ + + bool startBatchesQueueStatus = isWithdrawalsBatchesFinalized(); + + env eF; + calldataarg argsF; + f(eF,argsF); + + bool nextBatchesQueueStatus = isWithdrawalsBatchesFinalized(); + assert startBatchesQueueStatus => nextBatchesQueueStatus; +} + + + +/** +@title W2-2 DOS when queues are closed, no change in batch list. + +checked with mutation on version with issue +https://prover.certora.com/output/40726/dd696d553405430aa40ae244474aa1d0/?anonymousKey=fe11fe659d51d8b9d1c1021a8ec18b9c2e6ab2a9 + + +**/ + +rule W2_2_batchesQueueCloseNoChange(method f){ + + bool finalize = isWithdrawalsBatchesFinalized(); + uint256 any; + uint256 beforeFirst = currentContract._batchesQueue.batches[any].firstUnstETHId; + uint256 beforeSecond = currentContract._batchesQueue.batches[any].lastUnstETHId; + env eF; + calldataarg argsF; + f(eF,argsF); + + assert finalize => + beforeFirst == currentContract._batchesQueue.batches[any].firstUnstETHId && + beforeSecond == currentContract._batchesQueue.batches[any].lastUnstETHId; +} + +/** +@title W2-2 In a situation where requestNextWithdrawalsBatch should close the queue, + there is no way to prevent it from being closed by first calling another function. +@notice We are filtering out some functions that are not interesting since they cannot + successfully be called in a situation where requestNextWithdrawalsBatch makes sense to call. +*/ +rule W2_2_front_running(method f) { + storage initial_storage = lastStorage; + + // set up one run in which requestNextWithdrawalsBatch closes the queue + require !isWithdrawalsBatchesFinalized(); + env e; + uint batchsize; + requestNextWithdrawalsBatch(e, batchsize); + require isWithdrawalsBatchesFinalized(); + + // if we frontrun something else, at the end it should still be closed + calldataarg args; + f@withrevert(e, args) at initial_storage; + bool fReverted = lastReverted; + requestNextWithdrawalsBatch(e, batchsize); + uint stETHRemaining = stEth.balanceOf(currentContract); + uint minStETHWithdrawalRequestAmount = withdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT(); + assert fReverted || stETHRemaining < minStETHWithdrawalRequestAmount => isWithdrawalsBatchesFinalized(); +} + +/** + @title Rage quit support value + The rage quit support of an escrow is equal to: + (S+W+U+F) / (T+F) + where: + S - is the ETH amount locked in the escrow in the form of stET + W - is the ETH amount locked in the escrow in the form of wstETH: _accounting.stETHTotals.lockedShares + U - is the ETH amount locked in the escrow in the form of unfinalized Withdrawal NFTs: _accounting.unstETHTotals.unfinalizedShares (sum of all nft deposited) + F - is the ETH amount locked in the escrow in the form of finalized Withdrawal NFTs: _accounting.unstETHTotals.unstETHFinalizedETH (out of unstETHUnfinalizedShares ) + T - is the total supply of stETH. + **/ + + rule E_KP_1_rageQuitSupportValue() { + // this mostly checks for overflow/underflow + mathint actual = getRageQuitSupport(); + uint256 S_W = currentContract._accounting.stETHTotals.lockedShares; + uint256 U = currentContract._accounting.unstETHTotals.unfinalizedShares; + mathint F = currentContract._accounting.unstETHTotals.finalizedETH; + mathint T = stEth.totalSupply(); + mathint expected = + ((100 * 10 ^ 16) *(stEth.getPooledEthByShares( assert_uint256(S_W + U) ) + F) ) + / ( T + F ); + assert actual == expected; + } + + + + + +/** @title state transition of an unsteth record +enum UnstETHRecordStatus { + NotLocked = 0 + Locked = 1 + Finalized = 2 + Claimed = 3 + Withdrawn = 4 +} +Valid transitions are: +0 -> 1 +1 -> 0 +1 -> 2 +1 -> 3 +2 -> 3 +2 -> 0 +3 -> 4 +4 final state +**/ + +rule stateTransition_unstethRecord(uint256 unstETHId, method f) { + uint8 before = require_uint8(currentContract._accounting.unstETHRecords[unstETHId].status); + require before == 3 => isRageQuitState(); + env e; + calldataarg args; + f(e,args); + + uint8 after = require_uint8(currentContract._accounting.unstETHRecords[unstETHId].status); + assert before != after => + ( ( before == 0 <=> after == 1) + && ( before == 1 => after <= 3) + && ( before == 2 => ( after == 0 || after == 3) ) + && ( before == 3 <=> after == 4 ) + && ( after == 2 => before == 1 ) + && ( after == 3 => (before == 1 || before == 2) ) + ); + assert after == 3 => isRageQuitState(); + +} + + diff --git a/certora/specs/Escrow_solvency.spec b/certora/specs/Escrow_solvency.spec new file mode 100644 index 00000000..930b766c --- /dev/null +++ b/certora/specs/Escrow_solvency.spec @@ -0,0 +1,143 @@ + +import "./escrow_validState.spec"; +/** + Verification of asset holding + +**/ +/** +@title Total holding of wst_eth is zero as all wst_eth are converted to st_eth +**/ +invariant solvency_zeroWstEthBalance() + wst_eth.balanceOf(currentContract) == 0 + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + } +} + + /** @title Total holding of stEth before rageQuit start + **/ +invariant solvency_stETH_before_rageQuit() + !isRageQuitState() => stEth.getPooledEthByShares(currentContract._accounting.stETHTotals.lockedShares + ) <= stEth.balanceOf(currentContract) + + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + } +} + + +/** @title Before rage quit eth value of escrow can not be reduced +**/ +rule solvency_ETH_before_rageQuit(method f) +{ + bool rageQuitStateBefore = isRageQuitState(); + uint256 before = nativeBalances[currentContract]; + env e; + calldataarg args; + // Escrow is the starting point, it can never call directly an arbitrary function + require (e.msg.sender != currentContract); + f(e,args); + uint256 after = nativeBalances[currentContract]; + assert !rageQuitStateBefore => after >= before; +} + + +/** @title Total holding of eth by the escrow: + 1. For the locked shares: + claimedETH * sumStETHLockedShares / stETHTotals.lockedShares + + where sumStETHLockedShares is the current holding of shares + 2. For the unstEth holding: + sumClaimedUnSTEth - sumWithdrawnUnSTEth + where: + sumClaimedUnSTEth - total amount of all claimed unstEth + sumWithdrawnUnSTEth - total amount already withdrawn + +**/ +invariant solvency_ETH() + // pool of batch queue + // if lockedShares is zero than this pool is zero (to avoid divide by zero) + (( (currentContract._batchesQueue.info.totalUnstETHIdsCount!= 0 && currentContract._accounting.stETHTotals.lockedShares!=0) ? (currentContract._accounting.stETHTotals.claimedETH * sumStETHLockedShares /currentContract._accounting.stETHTotals.lockedShares ) : 0 ) + // unstEth pool: the all claimed unsteth records minus those withdrawn already + + + (sumClaimedUnSTEth - sumWithdrawnUnSTEth) + <= nativeBalances[currentContract] + ) + && + (currentContract._batchesQueue.info.totalUnstETHIdsClaimed== 0 => currentContract._accounting.stETHTotals.claimedETH == 0) + + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + requireInvariant solvency_stETH_before_rageQuit(); + validState(); + } + preserved withdrawETH(uint256[] unstETHIds) with (env e) { + require unstETHIds.length ==1; + require sumClaimedUnSTEth >= unstETHIds[0]; + require e.msg.sender != currentContract; + requireInvariant solvency_stETH_before_rageQuit(); + validState(); + + } +} + +/** @title Those request id left to claim are indeed not claimed +**/ +invariant solvency_batchesQueue_solvent_leftToClaim(uint256 index, uint256 id) + (( index > 0 && index < currentContract._batchesQueue.batches.length && id >= currentContract._batchesQueue.batches[index].firstUnstETHId && + id <= currentContract._batchesQueue.batches[index].lastUnstETHId && (!withdrawalQueue.requests[id].isClaimed)) => + ( currentContract._batchesQueue.info.totalUnstETHIdsCount - currentContract._batchesQueue.info.totalUnstETHIdsClaimed >= + // all indexes unclaimed completely (at least one element to claim) + (countOFBatchIds[currentContract._batchesQueue.batches.length] - countOFBatchIds[index]) - + // claimed in this index + ( id - (currentContract._batchesQueue.batches[index].firstUnstETHId)) ) + ) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + requireInvariant solvency_batchesQueue_allClaimed(index, id); + assumingThreeOnly(); + } + } + +/** @title when all nft are claimed, the last one has been claimed **/ +invariant solvency_batchesQueue_allClaimed(uint256 index, uint256 id) + + // isAllBatchesClaimed and there are batches queues + (currentContract._batchesQueue.batches.length > 1 => + (isAllStEthNFTClaimed() => withdrawalQueue.requests[currentContract._batchesQueue.batches[currentContract._batchesQueue.info.lastClaimedBatchIndex].lastUnstETHId].isClaimed + )) + && + ( currentContract._batchesQueue.info.lastClaimedBatchIndex < currentContract._batchesQueue.batches.length || + (currentContract._batchesQueue.info.lastClaimedBatchIndex==0 && currentContract._batchesQueue.batches.length==0 ) + ) + && ( currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex <= currentContract._batchesQueue.batches[currentContract._batchesQueue.info.lastClaimedBatchIndex].lastUnstETHId -currentContract._batchesQueue.batches[currentContract._batchesQueue.info.lastClaimedBatchIndex].firstUnstETHId) + && + ( getRageQuitExtensionDelayStartedAt() > 0 => + (( + withdrawalQueue.requests[currentContract._batchesQueue.batches[currentContract._batchesQueue.info.lastClaimedBatchIndex].lastUnstETHId].isFinalized ) + && + ( currentContract._batchesQueue.info.totalUnstETHIdsClaimed == currentContract._batchesQueue.info.totalUnstETHIdsCount && + isBatchQueueStateClosed() ) + ) + ) + + + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + requireInvariant solvency_batchesQueue_solvent_leftToClaim(index, id); + assumingThreeOnly(); + } + } + + + + diff --git a/certora/specs/Escrow_validState.spec b/certora/specs/Escrow_validState.spec new file mode 100644 index 00000000..24a5ae4b --- /dev/null +++ b/certora/specs/Escrow_validState.spec @@ -0,0 +1,609 @@ +import "./Escrow.spec"; + + + +/****** Ghost declaration *****/ + + +/** @title Ghost sumStETHLockedShares is: + sum of _accounting.assets[a].stETHLockedShares Escrow.SharesValue + for all addresses a +**/ +ghost mathint sumStETHLockedShares { + // assuming value zero at the initial state before constructor + init_state axiom sumStETHLockedShares == 0; +} + + +/** @title Count how many IDs in each bathcIndex: + numOfIdsInBatch[batchIndex] = _batchesQueue.batches[batchIndex].lastUnstETHId - batchesQueue.batches[batchIndex].firstUnstETHId +1 +**/ +ghost mapping(mathint => mathint) numOfIdsInBatch { + init_state axiom forall mathint x. numOfIdsInBatch[x] == 0; +} + +/** @title Accumulated count of batch id in all previous index + countOFBatchIds[0] = 0; there is one that is just a start + countOFBatchIds[x] = \count_{i=1}^{x-1} batch[x].last - batch[x].first + 1 ; +**/ +ghost mapping(mathint => mathint) countOFBatchIds { + init_state axiom forall mathint x. countOFBatchIds[x] == 0; +} +///@title a mirror for batchesQueue.batches[batchIndex].length +ghost uint256 ghostLengthMirror { + init_state axiom ghostLengthMirror == 0; +} + +///@title mirror claimableETH +ghost mapping(mathint => mathint) claimableETH { + init_state axiom forall mathint x. claimableETH[x] == 0; +} + +/** @title the partial sum of all unstethRecord that is claimed status >= 3 (claimed or withdrawn) + this can increase when moving to state 3 + i.e., partialSumOfClaimedUnstETH[id] == sum of _accounting.unstETHRecords[id'].claimableAmount + where: id' <= id and id' is in state >= 3 +**/ +ghost mapping(mathint => mathint) partialSumOfClaimedUnstETH { + init_state axiom forall mathint x. partialSumOfClaimedUnstETH[x] == 0; +} + +/// @title the sum of all unstethRecord.claimableAmount in status >= 3 /* claimed or withdrawn */ +ghost mathint sumClaimedUnSTEth { + init_state axiom sumClaimedUnSTEth == 0; +} +/// @title the sum of all unstethRecord.claimableAmount that was claimed and then withdrawn form escrow, status == 4 /* withdrawn */ +ghost mathint sumWithdrawnUnSTEth { + init_state axiom sumWithdrawnUnSTEth == 0; +} +/** @title the partial sum of all unstethRecord that is claimed status >= 4 (withdrawn) + this can increase when moving to state 4 + i.e., partialSumOfWithdrawnUnstETH[id] == sum of _accounting.unstETHRecords[id'].claimableAmount + where: id' <= id and id' is in state >= 4 +**/ +ghost mapping(mathint => mathint) partialSumOfWithdrawnUnstETH + { + init_state axiom forall mathint x. partialSumOfWithdrawnUnstETH[x] == 0; +} + +/****** Hooks for ghost updates *****/ + +hook Sload uint256 length currentContract._batchesQueue.batches.length { + require length == ghostLengthMirror; +} + +hook Sstore currentContract._batchesQueue.batches.length uint256 newLength { + ghostLengthMirror = newLength; +} + +/* updated sumStETHLockedShares according to the change of a single account */ +hook Sstore currentContract._accounting.assets[KEY address a].stETHLockedShares Escrow.SharesValue new_balance +// the old value that balances[a] holds before the store + (Escrow.SharesValue old_balance) { + sumStETHLockedShares = sumStETHLockedShares + new_balance - old_balance; +} + +/* assume a sum is ge it's element */ +hook Sload Escrow.SharesValue value currentContract._accounting.assets[KEY address a].stETHLockedShares { + require value <= sumStETHLockedShares; +} + +hook Sstore currentContract._batchesQueue.batches[INDEX uint256 batchIndex].firstUnstETHId uint256 newStart (uint256 oldStart) { + // update numOFIdsInBatch for batchIndex + mathint end = currentContract._batchesQueue.batches[batchIndex].lastUnstETHId; + numOfIdsInBatch[batchIndex] = (batchIndex == 0 ? 0: end - newStart + 1); + // update partial sums for x > to_mathint(batchIndex) + countOFBatchIds[batchIndex+1] = countOFBatchIds[batchIndex] + numOfIdsInBatch[batchIndex] ; +} + +hook Sstore currentContract._batchesQueue.batches[INDEX uint256 batchIndex].lastUnstETHId uint256 newLastId (uint256 oldLastId) { + mathint start = currentContract._batchesQueue.batches[batchIndex].firstUnstETHId; + //require(numOfIdsInBatch[batchIndex] == (batchIndex == 0 ? 0: oldLastId - start + 1)); + numOfIdsInBatch[batchIndex] = (batchIndex == 0 ? 0: newLastId - start + 1); + // update partial sums for x > to_mathint(batchIndex) + countOFBatchIds[batchIndex+1] = countOFBatchIds[batchIndex] + numOfIdsInBatch[batchIndex] ; +} + +hook Sload uint256 end currentContract._batchesQueue.batches[INDEX uint256 batchIndex].lastUnstETHId { + mathint start = currentContract._batchesQueue.batches[batchIndex].firstUnstETHId; + require numOfIdsInBatch[batchIndex] == ((batchIndex == 0)? 0 : + end - start +1 ); +} + +hook Sload uint256 start currentContract._batchesQueue.batches[INDEX uint256 batchIndex].firstUnstETHId { + mathint end = currentContract._batchesQueue.batches[batchIndex].lastUnstETHId; + require numOfIdsInBatch[batchIndex] == ((batchIndex == 0)? 0 : + end - start +1 ); +} + + +hook Sstore currentContract._accounting.unstETHRecords[KEY uint256 unstETHId].status Escrow.UnstETHRecordStatus new_status (Escrow.UnstETHRecordStatus old_status ) +{ + // to claimed add to sumClaimedUnSTEth, note that it is also updated in the hook on claimableAmount as order is not known + sumClaimedUnSTEth = sumClaimedUnSTEth + + ( (to_mathint(old_status) != 3 && to_mathint(new_status) == 3 ) ? currentContract._accounting.unstETHRecords[unstETHId].claimableAmount : 0 ); + + // when chaning to state withrawn (state 4), claimableAmount must be already set, just update sumWithdrawnUnSTEth + sumWithdrawnUnSTEth = sumWithdrawnUnSTEth + + ( (to_mathint(old_status) != 4 && to_mathint(new_status) == 4 ) ? currentContract._accounting.unstETHRecords[unstETHId].claimableAmount : 0 ); + // update also partial sum of WithdrawanETH + if (to_mathint(old_status) == 3 && to_mathint(new_status) == 4) { + havoc partialSumOfWithdrawnUnstETH assuming forall uint256 id. + (partialSumOfWithdrawnUnstETH@new[id] == partialSumOfWithdrawnUnstETH@old[id] + ( id > unstETHId ? claimableETH[unstETHId] : 0)); + } + + // when claiming (state 3) update partialSumOfClaimedUnstETH for all indexes gt then unstETHId + if (to_mathint(old_status) != 3 && to_mathint(new_status) == 3 ) { + havoc partialSumOfClaimedUnstETH assuming forall uint256 id. + (partialSumOfClaimedUnstETH@new[id] == partialSumOfClaimedUnstETH@old[id] + ( id > unstETHId ? claimableETH[unstETHId] : 0 )); + } + + +} + + +hook Sstore currentContract._accounting.unstETHRecords[KEY uint256 unstETHId].claimableAmount Escrow.ETHValue new_claimableAmount (Escrow.ETHValue old_claimableAmount ) +{ + if ( to_mathint(currentContract._accounting.unstETHRecords[unstETHId].status) == 3) { + havoc partialSumOfClaimedUnstETH assuming forall uint256 id. + (partialSumOfClaimedUnstETH@new[id] == partialSumOfClaimedUnstETH@old[id] + ( id > unstETHId ? new_claimableAmount - old_claimableAmount : 0)); + sumClaimedUnSTEth = sumClaimedUnSTEth + new_claimableAmount - old_claimableAmount ; + } + claimableETH[unstETHId] = new_claimableAmount; +} + + +hook Sload Escrow.ETHValue claimableAmount currentContract._accounting.unstETHRecords[KEY uint256 unstETHId].claimableAmount +{ + + require claimableETH[unstETHId] == claimableAmount; + if ( to_mathint(currentContract._accounting.unstETHRecords[unstETHId].status) >= 3) { + require forall uint256 id. (id > unstETHId) => partialSumOfClaimedUnstETH[id] >= claimableAmount; + require claimableAmount <= sumClaimedUnSTEth; + } +} + +/** + @title CVL function to gather all valid state rules and a few other assumptions: + 1. the size of the batch queue is not close to max_uint + 2. lastRequestId zero is not valid +**/ +function validState() { + // push is an unchecked operation, safely assume array will not overflow + require currentContract._batchesQueue.batches.length < 1000; + require currentContract._batchesQueue.batches[0].lastUnstETHId < 1000000; + require withdrawalQueue.lastRequestId < 100000000 && withdrawalQueue.lastRequestId > 0 ; + requireInvariant validState_nonInitialized(); + requireInvariant validState_signalling(); + requireInvariant validState_rageQuit(); + uint256 any; + requireInvariant validState_batchesQueue_monotonicity(); + requireInvariant validState_batchesQueue_withdrawalQueue(); + requireInvariant validState_totalLockedShares(); + requireInvariant validState_batchesQueue_distinct_unstETHRecords(); + requireInvariant validState_batchesQueue_ordering(); + validState_batchesQueue_claimed_vs_actual(); + requireInvariant validState_withdrawalQueue(); + requireInvariant validState_batchQueuesSum(); + requireInvariant validState_totalETHIds() ; + requireInvariant validState_partialSumOfClaimedUnstETH(); + validState_partialSumMonotonicity(); + requireInvariant validState_claimedUnstEth(); + requireInvariant validState_withdrawnEth(); + requireInvariant valid_batchIndex(); + + + + } + + +/** @title Current sum of all locked shares is le the total lockedShares + @notice lockedShares is the total and is not reduced on withdraw **/ +invariant validState_totalLockedShares() + sumStETHLockedShares <= currentContract._accounting.stETHTotals.lockedShares && sumStETHLockedShares >= 0 ; + + +/****** Escrow State Invariants *****/ + + +/// @title Before initialization everything is zero +invariant validState_nonInitialized() + ( isNotInitializedState() => ( + isBatchQueueStateAbset() && + currentContract._accounting.stETHTotals.claimedETH == 0 && + getRageQuitExtensionDelayStartedAt() ==0 && + currentContract._batchesQueue.info.totalUnstETHIdsClaimed == 0 && + currentContract._accounting.stETHTotals.lockedShares == 0 && + sumClaimedUnSTEth == 0 && + currentContract._batchesQueue.batches.length == 0 + ) + ) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + // push is an unchecked opetation, safely assume array will not overflow + require currentContract._batchesQueue.batches.length < max_uint256; + } + } + +/// @title while in signaling no claims and no batch queues +invariant validState_signalling() + // While in signaling state, no batch queues are open and no claim + ( isSignallingState() => ( isBatchQueueStateAbset() && + currentContract._accounting.stETHTotals.claimedETH == 0 && + getRageQuitExtensionDelayStartedAt() ==0 && + currentContract._batchesQueue.info.totalUnstETHIdsClaimed == 0 && + sumClaimedUnSTEth == 0 && + currentContract._batchesQueue.batches.length == 0 + ) + ) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/// @title Once rageQuit start, batch queues are either open or closed +invariant validState_rageQuit() + (isRageQuitState() <=> ( !isBatchQueueStateAbset() ) ) + && (isRageQuitState() <=> (currentContract._batchesQueue.batches.length >= 1 && currentContract._batchesQueue.batches[0].lastUnstETHId != 0)) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + + +/****** Batch Queue Invariants *****/ + + +/** @title Monotonicity of batch queues: + 1. In each entry, first id is le than the last id + 2. Entry zero (if exist) has only one element +**/ +invariant validState_batchesQueue_monotonicity( ) + // each batch entry is monotonic, first <= last + (forall uint256 h2. (h2 >=0 && h2 < currentContract._batchesQueue.batches.length) => (currentContract._batchesQueue.batches[h2].firstUnstETHId <= currentContract._batchesQueue.batches[h2].lastUnstETHId) + ) && + ((currentContract._batchesQueue.batches.length > 0 ) => currentContract._batchesQueue.batches[0].firstUnstETHId == currentContract._batchesQueue.batches[0].lastUnstETHId) + + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/** @title Ordering of batch queues: + The first id in each entry is greater than the last in the previous entry + @dev To help verification of other rules, we prove for next index and also for all indexes gt than the current one +**/ +invariant validState_batchesQueue_ordering() + // monotonic, each batch is starting at a higher requestID than the previos one + (forall uint256 index. forall uint256 indexNext. (currentContract._batchesQueue.batches.length >= 1 && currentContract._batchesQueue.batches.length-1 >= indexNext && to_mathint(indexNext) == index+1 ) => + (currentContract._batchesQueue.batches[indexNext].firstUnstETHId > currentContract._batchesQueue.batches[index].lastUnstETHId ) + ) + && + (forall uint256 index. forall uint256 indexNext. (currentContract._batchesQueue.batches.length >= 1 && currentContract._batchesQueue.batches.length-1 >= indexNext && indexNext > index ) => + (currentContract._batchesQueue.batches[indexNext].firstUnstETHId > currentContract._batchesQueue.batches[index].lastUnstETHId ) + ) + + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/** @title Validity of batch queue ids: + 1. The last id in the last entry is le than the lastRequestId in withdrawal queue + 2. Escrow is the owner of the listed ids + @notice Given the proof that all indexes are ordered, this implies that all ids are le lastRequestId +**/ +invariant validState_batchesQueue_withdrawalQueue() + // last element is le withdrawalQueue.lastRequestId + (currentContract._batchesQueue.batches.length >= 1 => currentContract._batchesQueue.batches[require_uint256(currentContract._batchesQueue.batches.length - 1)].lastUnstETHId <= withdrawalQueue.lastRequestId) + && ( forall uint256 index. forall uint256 id. + ( ( index < ghostLengthMirror && index != 0 && + currentContract._batchesQueue.batches[index].firstUnstETHId <= id && currentContract._batchesQueue.batches[index].lastUnstETHId >= id )) => withdrawalQueue.requests[id].owner == currentContract) + && (forall address any. (!withdrawalQueue.allowance[currentContract][any])) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/// @title all unstEth are less than the lastRequestId and first batch if exists +invariant validState_batchesQueue_distinct_unstETHRecords( ) + forall uint256 id. ( + // if id is an unsetEth + ( to_mathint(currentContract._accounting.unstETHRecords[id].status) != 0 => + // then id is a valid one in withdrawalQueue + (id <= withdrawalQueue.lastRequestId && withdrawalQueue.requests[id].owner == currentContract ) + && + // and id is an unsetEth and there are batch queues + ( ( to_mathint(currentContract._accounting.unstETHRecords[id].status) != 0 && + currentContract._batchesQueue.batches.length > 0 ) => + // then id has to be le than the batch queues ids (we check the first as the rest are monotonic increasing) + id <= currentContract._batchesQueue.batches[0].firstUnstETHId ))) + + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/** @title Valid state of withdrawalQueue: + 1. an id is claimed only if it a valid requestId and was finalized + 2. an id is finalized iff it is le lastFinalizedRequestId + @dev This is only concerning withdrawlQueue but these properties are needed to prove properties of Escrow +**/ +invariant validState_withdrawalQueue() + (forall uint256 id. + ( (withdrawalQueue.requests[id].isClaimed) => ( + withdrawalQueue.requests[id].isFinalized && + id <= withdrawalQueue.lastRequestId + ) + ) + && (id!=0 => (withdrawalQueue.requests[id].isFinalized <=> id <= withdrawalQueue.lastFinalizedRequestId)) + ) + && (withdrawalQueue.lastRequestId >= withdrawalQueue.lastFinalizedRequestId) + && (!withdrawalQueue.requests[0].isClaimed && !withdrawalQueue.requests[0].isFinalized) + filtered { f -> f.contract == withdrawalQueue} + +/// @title countOFBatchIds is as expected + invariant validState_batchQueuesSum() + // batch index 0 is not relevant + countOFBatchIds[0] == 0 && numOfIdsInBatch[0] == 0 && countOFBatchIds[1] == 0 && + (forall uint256 index. (index > 0 && index < currentContract._batchesQueue.batches.length) => + (( numOfIdsInBatch[index] == currentContract._batchesQueue.batches[index].lastUnstETHId - currentContract._batchesQueue.batches[index].firstUnstETHId +1 ) && (numOfIdsInBatch[index] <= countOFBatchIds[index + 1])) + ) + && + (forall uint256 index. (index > 0 && index <= currentContract._batchesQueue.batches.length) => + countOFBatchIds[index] == countOFBatchIds[index-1] + numOfIdsInBatch[index-1] + ) + filtered { f -> f.contract == currentContract} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + + } + } + +/** @title integrity of totalEthids: + 1. if lastClaimedBatchIndex is not zero, then totalUnstETHIdsClaimed is the count of all batch indexs calimed plus the number of ids claimed in the current batch + 2. if lastClaimedBatchIndex is zero, then claimed index is also zero and so it the total claimed ids + 3. totalUnstETHIdsCount is the total ids in all batch queues + 4. total claimed le total ids +*/ +invariant validState_totalETHIds() + (( currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex + countOFBatchIds[currentContract._batchesQueue.info.lastClaimedBatchIndex] + 1 == currentContract._batchesQueue.info.totalUnstETHIdsClaimed ) || + (currentContract._batchesQueue.info.lastClaimedBatchIndex == 0 )) + && + (currentContract._batchesQueue.info.lastClaimedBatchIndex == 0 => + ( currentContract._batchesQueue.info.totalUnstETHIdsClaimed == 0 && + currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex == 0)) + && + currentContract._batchesQueue.info.totalUnstETHIdsCount == countOFBatchIds[currentContract._batchesQueue.batches.length] + && + (currentContract._batchesQueue.info.totalUnstETHIdsClaimed== 0 => currentContract._accounting.stETHTotals.claimedETH == 0) + && (currentContract._batchesQueue.info.totalUnstETHIdsClaimed <= currentContract._batchesQueue.info.totalUnstETHIdsCount) + + filtered { f -> f.contract == currentContract} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + + +/// @title Last claimed batch index is lt the length of batch queue, if exists +invariant valid_batchIndex() + ( currentContract._batchesQueue.info.lastClaimedBatchIndex < currentContract._batchesQueue.batches.length || + (currentContract._batchesQueue.info.lastClaimedBatchIndex==0 && currentContract._batchesQueue.batches.length==0 ) + ) + && + ( currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex <= currentContract._batchesQueue.batches[currentContract._batchesQueue.info.lastClaimedBatchIndex].lastUnstETHId - currentContract._batchesQueue.batches[currentContract._batchesQueue.info.lastClaimedBatchIndex].firstUnstETHId + ) + && ghostLengthMirror == currentContract._batchesQueue.batches.length + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/** @title If an id is within the claimed indexes than it is marked as claimed in the withdrawal queue +**/ +invariant validState_batchesQueue_claimed_vs_actual_1(uint256 index, uint256 id) + ( // if id is in one of the batch indexes + ( index < ghostLengthMirror && index != 0 && + currentContract._batchesQueue.batches[index].firstUnstETHId <= id && currentContract._batchesQueue.batches[index].lastUnstETHId >= id ) => + // then it is claimed iff the lastClaimedBatchIndex , lastClaimedUnstETHIdIndex say so + ( + // index is less than lastClaimedBatchIndex + ( index < currentContract._batchesQueue.info.lastClaimedBatchIndex || + ( index == currentContract._batchesQueue.info.lastClaimedBatchIndex && id <= currentContract._batchesQueue.batches[index].firstUnstETHId + currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex) + ) + <=> withdrawalQueue.requests[id].isClaimed + ) + ) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + } + } + +/** @dev since validState_batchesQueue_claimed_vs_actual_1 is proved for all index and for all id +we have a function that assume it for all values */ +function validState_batchesQueue_claimed_vs_actual() { + require ( forall uint256 index. forall uint256 id. + ( index < ghostLengthMirror && index != 0 && + currentContract._batchesQueue.batches[index].firstUnstETHId <= id && currentContract._batchesQueue.batches[index].lastUnstETHId >= id && withdrawalQueue.requests[id].isClaimed) + => + ( index < currentContract._batchesQueue.info.lastClaimedBatchIndex || + ( index == currentContract._batchesQueue.info.lastClaimedBatchIndex && id <= (currentContract._batchesQueue.batches[index].firstUnstETHId + currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex) ) ) + ) + && + ( forall uint256 index. forall uint256 id. + ( ( index < ghostLengthMirror && index != 0 && + currentContract._batchesQueue.batches[index].firstUnstETHId <= id && currentContract._batchesQueue.batches[index].lastUnstETHId >= id && + ( index < currentContract._batchesQueue.info.lastClaimedBatchIndex || + ( index == currentContract._batchesQueue.info.lastClaimedBatchIndex && id <= currentContract._batchesQueue.batches[index].firstUnstETHId + currentContract._batchesQueue.info.lastClaimedUnstETHIdIndex)) + ) + => withdrawalQueue.requests[id].isClaimed + ) + ); +} + +/** @title claimed unstETHRecords properties: + 1. if an unstETHRecord is finalized (status 2) then it is marked as finalized and not claimed in the withdrawal queue + 2. if an unstETHRecord is claimed or withdrawn (status 3 or 4) then it is marked as finalized and claimed in the withdrawal queue +**/ +invariant validState_partialSumOfClaimedUnstETH() + // batch index 0 is not relevant + partialSumOfClaimedUnstETH[0] == 0 && claimableETH[0] == 0 && partialSumOfClaimedUnstETH[1] == 0 && + ( + forall uint256 id. (id > 0 && (to_mathint(currentContract._accounting.unstETHRecords[id].status) == 2)) => (withdrawalQueue.requests[id].isFinalized && !withdrawalQueue.requests[id].isClaimed) + ) + && + ( + forall uint256 id. (id > 0 && (to_mathint(currentContract._accounting.unstETHRecords[id].status) == 3 || to_mathint(currentContract._accounting.unstETHRecords[id].status) == 4)) => (withdrawalQueue.requests[id].isClaimed && withdrawalQueue.requests[id].isFinalized) + ) + filtered { f -> f.contract == currentContract} { + preserved with (env e) { + // no dynamic call so Escrow + require e.msg.sender != currentContract; + validState(); + + } + } + +/** @title partial sum of withdrawn is le partial sum of claimed, by at least the element that is claimed but not withdrawn + @notice proving on a single element +**/ +invariant validState_partialSumMonotonicity_1(uint256 id) + partialSumOfWithdrawnUnstETH[require_uint256(id+1)] + ( to_mathint(currentContract._accounting.unstETHRecords[id].status) == 3 ? currentContract._accounting.unstETHRecords[id].claimableAmount: 0 ) <= partialSumOfClaimedUnstETH[require_uint256(id+1)] + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + validState(); + } + preserved withdrawETH(uint256[] unstETHIds) with (env e) { + require unstETHIds.length == 1; + require unstETHIds[0] == id || unstETHIds[0] == require_uint256(id +1); + require e.msg.sender != currentContract; + validState(); + } + +} + +/** @title partial sum of two ids is as expected +**/ +invariant validState_partialSumMonotonicity_2(uint256 id, uint256 id2) + id2 > id => (partialSumOfWithdrawnUnstETH[require_uint256(id2)] +( to_mathint(currentContract._accounting.unstETHRecords[id].status) == 3 ? currentContract._accounting.unstETHRecords[id].claimableAmount: 0 ) <= partialSumOfClaimedUnstETH[require_uint256(id2)]) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + validState(); + } + preserved withdrawETH(uint256[] unstETHIds) with (env e) { + require unstETHIds.length == 1; + require unstETHIds[0] == id ; + require e.msg.sender != currentContract; + validState(); + } +} + +/// @title a function for partialSumMonotonicity on all elements +function validState_partialSumMonotonicity() { + require ( forall uint256 id. forall uint256 idNext. ((idNext > id) => (partialSumOfWithdrawnUnstETH[idNext] +( to_mathint(currentContract._accounting.unstETHRecords[id].status) == 3 ? currentContract._accounting.unstETHRecords[id].claimableAmount: 0 ) ) <= partialSumOfClaimedUnstETH[idNext])); +} + +/// @title Total withdrawn unstEth is the partial sum of withdrawn of the lastFinalizedRequestId+ 1 +invariant validState_withdrawnEth() + (forall uint256 id. (id > withdrawalQueue.lastFinalizedRequestId) => + partialSumOfWithdrawnUnstETH[id] == sumWithdrawnUnSTEth ) + && + ( sumWithdrawnUnSTEth >= 0 && sumWithdrawnUnSTEth <= sumClaimedUnSTEth ) + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + // check requireInvariant solvency_stETH_before_ragequit(); + validState(); + } + preserved withdrawETH(uint256[] unstETHIds) with (env e) { + uint256 claimedId; + require unstETHIds.length == 1; + require unstETHIds[0] == claimedId ; + // help grounding: + requireInvariant validState_partialSumMonotonicity_1(claimedId); + requireInvariant validState_partialSumMonotonicity_1(withdrawalQueue.lastFinalizedRequestId); + + require partialSumOfWithdrawnUnstETH[require_uint256(withdrawalQueue.lastFinalizedRequestId+1)] + + ( to_mathint(currentContract._accounting.unstETHRecords[claimedId].status) == 3 ? currentContract._accounting.unstETHRecords[claimedId].claimableAmount: 0 ) <= partialSumOfClaimedUnstETH[require_uint256(withdrawalQueue.lastFinalizedRequestId+1)]; + + require withdrawalQueue.requests[claimedId].isFinalized <=> claimedId <= withdrawalQueue.lastFinalizedRequestId; + + require (partialSumOfClaimedUnstETH[claimedId] <= sumClaimedUnSTEth); + require (partialSumOfClaimedUnstETH[claimedId+1] <= sumClaimedUnSTEth); + require e.msg.sender != currentContract; + validState(); + +}} + +/// @title Total claimed unstEth is the partial sum of claimed of the lastFinalizedRequestId+ 1 +invariant validState_claimedUnstEth() + forall uint256 id. (id > withdrawalQueue.lastFinalizedRequestId) => + partialSumOfClaimedUnstETH[id] == sumClaimedUnSTEth + filtered { f -> f.contract != stEth && f.contract != wst_eth} { + preserved with (env e) { + require e.msg.sender != currentContract; + validState(); + } +} + + +/****** Helper Functions *****/ + +/** @dev Use this function to get smaller counter examples, not needed for verification **/ +function assumingThreeOnly() { + require currentContract._batchesQueue.batches.length <= 3; + if (currentContract._batchesQueue.batches.length > 0) { + require currentContract._batchesQueue.batches[0].firstUnstETHId == currentContract._batchesQueue.batches[0].lastUnstETHId && + currentContract._batchesQueue.batches[0].firstUnstETHId > 0; + } + if (currentContract._batchesQueue.batches.length > 1) { + require currentContract._batchesQueue.batches[1].firstUnstETHId > currentContract._batchesQueue.batches[0].lastUnstETHId && + currentContract._batchesQueue.batches[1].firstUnstETHId <= currentContract._batchesQueue.batches[1].lastUnstETHId; + } + if (currentContract._batchesQueue.batches.length > 2) { + require currentContract._batchesQueue.batches[2].firstUnstETHId > currentContract._batchesQueue.batches[1].lastUnstETHId && + currentContract._batchesQueue.batches[2].firstUnstETHId <= currentContract._batchesQueue.batches[2].lastUnstETHId; + } + // allow up to three unstEthIds + uint256 i; + uint256 j; + uint256 k; + require ( i > 0 && i < j && j < k && k <= withdrawalQueue.lastRequestId); + require (currentContract._batchesQueue.batches.length > 0) => k <= currentContract._batchesQueue.batches[0].firstUnstETHId; + require ( forall uint256 any. (any != i && any != j && any != k ) => + to_mathint(currentContract._accounting.unstETHRecords[any].status) == 0 ); +} + diff --git a/certora/specs/Timelock.spec b/certora/specs/Timelock.spec new file mode 100644 index 00000000..ae3798c8 --- /dev/null +++ b/certora/specs/Timelock.spec @@ -0,0 +1,293 @@ +methods { + function MAX_AFTER_SUBMIT_DELAY() external returns (Durations.Duration) envfree; + function MAX_AFTER_SCHEDULE_DELAY() external returns (Durations.Duration) envfree; + function MAX_EMERGENCY_MODE_DURATION() external returns (Durations.Duration) envfree; + function MAX_EMERGENCY_PROTECTION_DURATION() external returns (Durations.Duration) envfree; + + function getProposal(uint256) external returns (ITimelock.Proposal) envfree; + function getProposalsCount() external returns (uint256) envfree; + function getEmergencyProtectionContext() external returns (EmergencyProtection.Context) envfree; + function isEmergencyModeActive() external returns (bool) envfree; + function getAdminExecutor() external returns (address) envfree; + function getGovernance() external returns (address) envfree; + function getAfterSubmitDelay() external returns (Durations.Duration) envfree; + function getAfterScheduleDelay() external returns (Durations.Duration) envfree; + + // We do not model the calls executed through proposals + function _.execute(address, uint256, bytes) external => nondetBytes() expect bytes; +} + +// returns default empty bytes object, since we don't need to know anything about the returned value of execute in any of our rules +// specifying it like this instead of NONDET avoids a revert based on the returned value in EPT_9_EmergencyModeLiveness +function nondetBytes() returns bytes { + bytes b; + return b; +} + +function proposalIsExecuted(uint proposalId) returns bool { + return getProposal(proposalId).status == ExecutableProposals.Status.Executed; +} + +/** + @title Executed is a terminal state for a proposal, once executed it cannot transition to any other state + @notice Expected to fail due to an acknowledged bug whose fix is not merged yet +*/ +rule W1_4_TerminalityOfExecuted(method f) filtered { f -> f.selector != sig:Executor.execute(address, uint256, bytes).selector } { + uint proposalId; + requireInvariant outOfBoundsProposalDoesNotExist(proposalId); + require proposalIsExecuted(proposalId); + + env e; + calldataarg args; + f(e, args); + + assert proposalIsExecuted(proposalId); +} + +invariant outOfBoundsProposalDoesNotExist(uint proposalId) proposalId == 0 || proposalId > getProposalsCount() => getProposal(proposalId).status == ExecutableProposals.Status.NotExist + filtered { f -> f.selector != sig:Executor.execute(address, uint256, bytes).selector } {} + +/** + @title A proposal cannot be scheduled for execution before at least ProposalExecutionMinTimelock has passed since its submission. +*/ +rule EPT_KP_1_SubmissionToSchedulingDelay { + env e; + uint proposalId; + + schedule(e, proposalId); + + assert getProposal(proposalId).submittedAt + getAfterSubmitDelay() <= e.block.timestamp; +} + +/** + @title A proposal cannot be executed until the emergency protection timelock has passed since it was scheduled. +*/ +rule EPT_KP_2_SchedulingToExecutionDelay { + env e; + uint proposalId; + + execute(e, proposalId); + + assert getProposal(proposalId).scheduledAt + getAfterScheduleDelay() <= e.block.timestamp; +} + +// These functions model the deactivation of the committees after the emergency protection elapses, +// and are used in place of directly getting the committee addresses in our rules +// to make sure that anything we prove about the committee special actions is also guarded by the time +function effectiveEmergencyExecutionCommittee(env e) returns address { + if (e.block.timestamp <= getEmergencyProtectionContext().emergencyProtectionEndsAfter || isEmergencyModeActive()) { + return getEmergencyProtectionContext().emergencyExecutionCommittee; + } + return 0; +} + +function effectiveEmergencyActivationCommittee(env e) returns address { + if (e.block.timestamp <= getEmergencyProtectionContext().emergencyProtectionEndsAfter) { + return getEmergencyProtectionContext().emergencyActivationCommittee; + } + return 0; +} + +/** + @title Emergency protection configuration changes are guarded by committees or admin executor + We check here that the part of the state that should only be alterable by the respective emergency committees + or through an admin proposal is indeed not changed on any method call other than ones correctly authorized +*/ +rule EPT_1_EmergencyProtectionConfigurationGuarded(method f) filtered { f -> f.selector != sig:Executor.execute(address, uint256, bytes).selector } { + EmergencyProtection.Context before = getEmergencyProtectionContext(); + + env e; + require e.block.timestamp <= max_uint40; + bool isEmergencyModePassed = before.emergencyModeEndsAfter <= e.block.timestamp; + address effectiveEmergencyActivationCommittee = effectiveEmergencyActivationCommittee(e); + address effectiveEmergencyExecutionCommittee = effectiveEmergencyExecutionCommittee(e); + + calldataarg args; + f(e, args); + + EmergencyProtection.Context after = getEmergencyProtectionContext(); + + assert before == after + // emergency mode activation + || (after.emergencyModeEndsAfter != 0 && + before.emergencyActivationCommittee == after.emergencyActivationCommittee && + before.emergencyProtectionEndsAfter == after.emergencyProtectionEndsAfter && + before.emergencyExecutionCommittee == after.emergencyExecutionCommittee && + before.emergencyModeDuration == after.emergencyModeDuration && + before.emergencyGovernance == after.emergencyGovernance && + e.msg.sender == effectiveEmergencyActivationCommittee) + // emergency mode deactivation + || (after.emergencyModeEndsAfter == 0 && + after.emergencyActivationCommittee == 0 && + after.emergencyProtectionEndsAfter == 0 && + after.emergencyExecutionCommittee == 0 && + after.emergencyModeDuration == 0 && + after.emergencyGovernance == before.emergencyGovernance && + // via time passing or execution committee + (isEmergencyModePassed || e.msg.sender == effectiveEmergencyExecutionCommittee)) + // reconfiguration through proposal executed by admin executor + || e.msg.sender == getAdminExecutor(); + +} + +/** + @title Only governance can schedule proposals. +*/ +rule EPT_2a_SchedulingGovernanceOnly { + env e; + uint proposalId; + + schedule(e, proposalId); + + assert e.msg.sender == getGovernance(); +} + +/** + @title Only governance can submit proposals. +*/ +rule EPT_2b_SubmissionGovernanceOnly { + env e; + calldataarg args; + submit(e, args); + + assert e.msg.sender == getGovernance(); +} + +/** + @title If emergency mode is active, only emergency execution committee can execute proposals +*/ +rule EPT_3_EmergencyModeExecutionRestriction(method f) filtered { f -> f.selector != sig:Executor.execute(address, uint256, bytes).selector } { + uint proposalId; + requireInvariant outOfBoundsProposalDoesNotExist(proposalId); + bool executedBefore = proposalIsExecuted(proposalId); + + bool isEmergencyModeActivated = isEmergencyModeActive(); + + env e; + address effectiveEmergencyExecutionCommittee = effectiveEmergencyExecutionCommittee(e); + + calldataarg args; + f(e, args); + + bool executedAfter = proposalIsExecuted(proposalId); + + assert isEmergencyModeActivated && !executedBefore && executedAfter => e.msg.sender == effectiveEmergencyExecutionCommittee; +} + +/** + @title Emergency Protection deactivation without emergency + @notice This rule checks that our effectiveXXXCommittee functions correctly model the expected behaviour upon deactivating + emergency mode by the timelock elapsing. It cannot directly catch any bugs in the solidity code, + but checks our helper functions which in turn make sure that other rules take the elapsing of the emergency protection into account. + The usefullness of this rule depends on us using the effectiveXXXCommittee functions also in all other rules + where we check that something is guarded by a committee. +*/ +rule EPT_5_EmergencyProtectionElapsed() { + EmergencyProtection.Context context = getEmergencyProtectionContext(); + // protected deployment mode was activated, but not emergency mode + require context.emergencyProtectionEndsAfter != 0 && !isEmergencyModeActive(); + + env e; + // protection time has elapsed in our environment + require e.block.timestamp > context.emergencyProtectionEndsAfter; + + assert effectiveEmergencyActivationCommittee(e) == 0 && effectiveEmergencyExecutionCommittee(e) == 0; +} + +/** + @title When emergency mode is active, the emergency execution committee can execute proposals successfully +*/ +rule EPT_9_EmergencyModeLiveness { + require isEmergencyModeActive(); + uint proposalId; + requireInvariant outOfBoundsProposalDoesNotExist(proposalId); + require getProposal(proposalId).status == ExecutableProposals.Status.Scheduled; + env e; + require e.msg.value == 0; + require e.block.timestamp >= getProposal(proposalId).scheduledAt; + require e.block.timestamp < max_uint40; + emergencyExecute@withrevert(e, proposalId); + bool reverted = lastReverted; + + assert e.msg.sender == effectiveEmergencyExecutionCommittee(e) => !reverted; +} + +// Helper for EPT_10_ProposalTimestampConsistency because Proposal contains some other not easily comparable data +function proposalTimestampsEqual (ITimelock.Proposal a, ITimelock.Proposal b) returns bool { + return a.submittedAt == b.submittedAt && a.scheduledAt == b.scheduledAt; +} + +/** + @title Proposal timestamps reflect timelock actions +*/ +rule EPT_10_ProposalTimestampConsistency(method f) filtered { f -> f.selector != sig:Executor.execute(address, uint256, bytes).selector } { + env e; + require e.block.timestamp <= max_uint40; + + // For each function that should update a timestamp, we need to check that the correct timestamp of the correct proposal was updated, + // while any proposal that was not the one the function acted on should remain unchanged. + // For any other function, all proposals should have their timestamps unchanged. + if (f.selector == sig:submit(address, ExternalCalls.ExternalCall[]).selector) { + uint proposalId; + ITimelock.Proposal proposal_before = getProposal(proposalId); + calldataarg args; + uint submittedId = submit(e, args); + + assert proposalId != submittedId && proposalTimestampsEqual(proposal_before, getProposal(proposalId)) + || proposalId == submittedId && getProposal(submittedId).submittedAt == e.block.timestamp; + + } else if (f.selector == sig:schedule(uint).selector) { + uint proposalId; + ITimelock.Proposal proposal_before = getProposal(proposalId); + uint proposalIdToSchedule; + require proposalId != proposalIdToSchedule; + schedule(e, proposalIdToSchedule); + + assert proposalTimestampsEqual(proposal_before, getProposal(proposalId)) + && getProposal(proposalIdToSchedule).scheduledAt == e.block.timestamp; + + } else if (f.selector == sig:execute(uint).selector) { + uint proposalId; + ITimelock.Proposal proposal_before = getProposal(proposalId); + uint proposalIdToExecute; + require proposalId != proposalIdToExecute; + execute(e, proposalIdToExecute); + + // for the execution methods we also check that they update the status, since executedAt is not longer included as a timestamp, + // but EPT_3_EmergencyModeExecutionRestriction depends on the execution status being recorded correctly to be meaningful + assert proposalTimestampsEqual(proposal_before, getProposal(proposalId)) + && proposalIsExecuted(proposalIdToExecute); + } else if (f.selector == sig:emergencyExecute(uint).selector) { + uint proposalId; + ITimelock.Proposal proposal_before = getProposal(proposalId); + uint proposalIdToExecute; + require proposalId != proposalIdToExecute; + emergencyExecute(e, proposalIdToExecute); + + assert proposalTimestampsEqual(proposal_before, getProposal(proposalId)) + && proposalIsExecuted(proposalIdToExecute); + } else { + uint proposalId; + ITimelock.Proposal proposal_before = getProposal(proposalId); + + calldataarg args; + f(e, args); + + assert proposalTimestampsEqual(proposal_before, getProposal(proposalId)); + } +} + +/** + @title Cancelled is a terminal state for a proposal, once cancelled it cannot transition to any other state +*/ +rule EPT_11_TerminalityOfCancelled(method f) filtered { f -> f.selector != sig:Executor.execute(address, uint256, bytes).selector } { + uint proposalId; + requireInvariant outOfBoundsProposalDoesNotExist(proposalId); + require getProposal(proposalId).status == ExecutableProposals.Status.Cancelled; + + env e; + calldataarg args; + f(e, args); + + assert getProposal(proposalId).status == ExecutableProposals.Status.Cancelled; +} \ No newline at end of file