diff --git a/contracts/BurnerVault.sol b/contracts/BurnerVault.sol deleted file mode 100644 index 201ea329..00000000 --- a/contracts/BurnerVault.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -interface IWstETH { - function unwrap(uint256 wstETHAmount) external returns (uint256); -} - -interface IERC20 { - function balanceOf(address owner) external view returns (uint256); -} - -interface IBurner { - function requestBurnMyStETH(uint256 _stETHAmountToBurn) external; -} - -contract BurnerVault { - address immutable BURNER; - address immutable ST_ETH; - address immutable WST_ETH; - - constructor(address burner, address stEth, address wstEth) { - BURNER = burner; - ST_ETH = stEth; - WST_ETH = wstEth; - } - - function requestBurning() public { - uint256 wstEthBalance = IERC20(WST_ETH).balanceOf(address(this)); - if (wstEthBalance > 0) { - IWstETH(WST_ETH).unwrap(wstEthBalance); - } - - uint256 stEthBalance = IERC20(ST_ETH).balanceOf(address(this)); - if (stEthBalance > 0) { - IBurner(BURNER).requestBurnMyStETH(stEthBalance); - } - } -} diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 9e19368b..58b134d5 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -28,6 +28,12 @@ contract Configuration is IConfiguration { uint256 public immutable TIE_BREAK_ACTIVATION_TIMEOUT = 365 days; + uint256 public immutable RAGE_QUIT_EXTRA_TIMELOCK = 14 days; + uint256 public immutable RAGE_QUIT_EXTENSION_DELAY = 7 days; + uint256 public immutable RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK = 60 days; + uint256 public immutable MIN_STATE_DURATION = 5 hours; + uint256 public immutable ESCROW_ASSETS_UNLOCK_DELAY = 5 hours; + // Sealables Array Representation uint256 private immutable MAX_SELABLES_COUNT = 5; diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 68602810..8e8bd608 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -1,613 +1,314 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -struct WithdrawalRequestStatus { - uint256 amountOfStETH; - uint256 amountOfShares; - address owner; - uint256 timestamp; - bool isFinalized; - bool isClaimed; -} - -interface IGovernanceState { - function activateNextState() external; -} - -interface IERC20 { - function totalSupply() external view returns (uint256); +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; - function totalShares() external view returns (uint256); +import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IConfiguration} from "./interfaces/IConfiguration.sol"; - function balanceOf(address owner) external view returns (uint256); +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; - function approve(address spender, uint256 value) external returns (bool); +import {AssetsAccounting, LockedAssetsStats, LockedAssetsTotals} from "./libraries/AssetsAccounting.sol"; - function transferFrom(address from, address to, uint256 value) external returns (bool); - - function transfer(address to, uint256 value) external returns (bool); +interface IDualGovernance { + function activateNextState() external; } -interface IStETH { - function getSharesByPooledEth(uint256 ethAmount) external view returns (uint256); - - function getPooledEthByShares(uint256 sharesAmount) external view returns (uint256); - - function transferShares(address to, uint256 amount) external; +enum EscrowState { + NotInitialized, + SignallingEscrow, + RageQuitEscrow } -interface IWstETH { - function wrap(uint256 stETHAmount) external returns (uint256); - - function unwrap(uint256 wstETHAmount) external returns (uint256); +struct VetoerState { + uint256 stETHShares; + uint256 wstETHShares; + uint256 unstETHShares; } -interface IWithdrawalQueue { - function MAX_STETH_WITHDRAWAL_AMOUNT() external view returns (uint256); - - function requestWithdrawalsWstETH(uint256[] calldata amounts, address owner) external returns (uint256[] memory); - - function claimWithdrawals(uint256[] calldata requestIds, uint256[] calldata hints) external; - - function getLastFinalizedRequestId() external view returns (uint256); - - function transferFrom(address from, address to, uint256 requestId) external; - - function getWithdrawalStatus(uint256[] calldata _requestIds) - external - view - returns (WithdrawalRequestStatus[] memory statuses); - - function balanceOf(address owner) external view returns (uint256); +contract Escrow is IEscrow { + using AssetsAccounting for AssetsAccounting.State; - function requestWithdrawals( - uint256[] calldata _amounts, - address _owner - ) external returns (uint256[] memory requestIds); -} - -/** - * A contract serving as a veto signalling and rage quit escrow. - */ -contract Escrow { - error Unauthorized(); - error InvalidState(); - error NoUnrequestedWithdrawalsLeft(); - error SenderIsNotOwner(uint256 id); - error TransferFailed(uint256 id); - error NotClaimedWQRequests(); - error FinalizedRequest(uint256); - error RequestNotFound(uint256 id); - error SenderIsNotAllowed(); - error RequestIsNotFromBatch(uint256 id); - error RequestFromBatch(uint256 id); - error AlreadyInitialized(); + error EmptyBatch(); + error ZeroWithdraw(); + error WithdrawalsTimelockNotPassed(); + error InvalidETHSender(address actual, address expected); + error NotDualGovernance(address actual, address expected); + error InvalidNextBatch(uint256 actualRequestId, uint256 expectedRequestId); error MasterCopyCallForbidden(); + error InvalidState(EscrowState actual, EscrowState expected); + error RageQuitExtraTimelockNotStarted(); - event RageQuitStarted(); - event WithdrawalsBatchRequested( - uint256 indexed firstRequestId, uint256 indexed lastRequestId, uint256 stEthLeftToRequest - ); - - enum State { - Signalling, - RageQuit - } - - struct HolderState { - uint256 stEthInEthShares; - uint256 wstEthInEthShares; - uint256 wqRequestsBalance; - uint256 finalizedWqRequestsBalance; - uint256 eth; - uint256[] wqRequestIds; - } - - struct WithdrawalRequest { - uint256 stEthInEthShares; - uint256 wstEthInEthShares; - uint256 wqRequestsBalance; - uint256 finalizedWqRequestsBalance; - } - - struct Balance { - uint256 stEth; - uint256 wstEth; - uint256 wqRequestsBalance; - uint256 finalizedWqRequestsBalance; - uint256 eth; - uint256[] wqRequestIds; - } - - // the source code from this address is used as the implementation - // to clone the Escrow contract on when escrow instance becomes an rage quit escrow + uint256 public immutable RAGE_QUIT_TIMELOCK = 30 days; address public immutable MASTER_COPY; - address internal immutable ST_ETH; - address internal immutable WST_ETH; - address internal immutable WITHDRAWAL_QUEUE; - address internal immutable BURNER_VAULT; + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; - State internal _state; - address internal _dualGovernance; + IConfiguration public immutable CONFIG; - uint256 internal _totalStEthInEthLocked; - uint256 internal _totalWstEthInEthLocked; - uint256 internal _totalWithdrawalNftsAmountLocked; - uint256 internal _totalFinalizedWithdrawalNftsAmountLocked; - uint256 internal _totalClaimedEthLocked; + EscrowState internal _escrowState; + IDualGovernance private _dualGovernance; + AssetsAccounting.State private _accounting; - uint256 internal _totalEscrowShares; - uint256 internal _claimedWQRequestsAmount; + uint256[] internal _withdrawalUnstETHIds; - uint256 internal _rageQuitAmountTotal; - uint256 internal _rageQuitAmountRequested; - uint256 internal _lastWithdrawalRequestId; + uint256 internal _rageQuitExtraTimelock; + uint256 internal _rageQuitWithdrawalsTimelock; + uint256 internal _rageQuitTimelockStartedAt; - mapping(address => HolderState) private _balances; - mapping(uint256 => WithdrawalRequestStatus) private _wqRequests; - - constructor(address stEth, address wstEth, address withdrawalQueue, address burnerVault) { - ST_ETH = stEth; - WST_ETH = wstEth; - WITHDRAWAL_QUEUE = withdrawalQueue; - BURNER_VAULT = burnerVault; + constructor(address stETH, address wstETH, address withdrawalQueue, address config) { + ST_ETH = IStETH(stETH); + WST_ETH = IWstETH(wstETH); + WITHDRAWAL_QUEUE = IWithdrawalQueue(withdrawalQueue); MASTER_COPY = address(this); + CONFIG = IConfiguration(config); } function initialize(address dualGovernance) external { if (address(this) == MASTER_COPY) { revert MasterCopyCallForbidden(); } - if (_dualGovernance != address(0)) { - revert AlreadyInitialized(); - } - _totalStEthInEthLocked = 1; - _totalEscrowShares = 1; - _dualGovernance = dualGovernance; - } + _checkEscrowState(EscrowState.NotInitialized); - /// - /// Staker interface - /// - function balanceOf(address holder) public view returns (Balance memory balance) { - HolderState memory state = _balances[holder]; - - balance.stEth = _getETHByShares(state.stEthInEthShares); - balance.wstEth = IStETH(ST_ETH).getSharesByPooledEth(_getETHByShares(state.wstEthInEthShares)); - balance.wqRequestsBalance = state.wqRequestsBalance; - balance.finalizedWqRequestsBalance = state.finalizedWqRequestsBalance; - balance.eth = state.eth; - balance.wqRequestIds = state.wqRequestIds; + _escrowState = EscrowState.SignallingEscrow; + _dualGovernance = IDualGovernance(dualGovernance); } - function lockStEth(uint256 amount) external { - if (_state != State.Signalling) { - revert InvalidState(); - } - - IERC20(ST_ETH).transferFrom(msg.sender, address(this), amount); - - uint256 shares = _getSharesByETH(amount); - - _balances[msg.sender].stEthInEthShares += shares; - _totalEscrowShares += shares; - _totalStEthInEthLocked += amount; - - _activateNextGovernanceState(); - } - - function lockWstEth(uint256 amount) external { - if (_state != State.Signalling) { - revert InvalidState(); - } - - IERC20(WST_ETH).transferFrom(msg.sender, address(this), amount); - - uint256 amountInEth = IStETH(ST_ETH).getPooledEthByShares(amount); - uint256 shares = _getSharesByETH(amountInEth); - - _balances[msg.sender].wstEthInEthShares = shares; - _totalEscrowShares += shares; - _totalWstEthInEthLocked += amountInEth; + // --- + // Lock & Unlock stETH + // --- + function lockStETH(uint256 amount) external { + uint256 shares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHLock(msg.sender, shares); + ST_ETH.transferSharesFrom(msg.sender, address(this), shares); _activateNextGovernanceState(); } - function lockWithdrawalNFT(uint256[] memory ids) external { - if (_state != State.Signalling) { - revert InvalidState(); - } - - WithdrawalRequestStatus[] memory wqRequestStatuses = IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(ids); - - uint256 wqRequestsAmount = 0; - address sender = msg.sender; - - for (uint256 i = 0; i < ids.length; ++i) { - uint256 id = ids[i]; - if (wqRequestStatuses[i].isFinalized == true) { - revert FinalizedRequest(id); - } - - IWithdrawalQueue(WITHDRAWAL_QUEUE).transferFrom(sender, address(this), id); - _wqRequests[id] = wqRequestStatuses[i]; - wqRequestsAmount += wqRequestStatuses[i].amountOfStETH; - _balances[sender].wqRequestIds.push(ids[i]); - } - - _balances[sender].wqRequestsBalance += wqRequestsAmount; - _totalWithdrawalNftsAmountLocked += wqRequestsAmount; - + function unlockStETH() external { + uint256 sharesUnlocked = _accounting.accountStETHUnlock(CONFIG.ESCROW_ASSETS_UNLOCK_DELAY(), msg.sender); + ST_ETH.transferShares(msg.sender, sharesUnlocked); _activateNextGovernanceState(); } - function unlockStEth() external { - _activateNextGovernanceState(); - if (_state != State.Signalling) { - revert InvalidState(); - } - - burnRewards(); - - address sender = msg.sender; - uint256 escrowShares = _balances[sender].stEthInEthShares; - uint256 amount = _getETHByShares(escrowShares); - - IERC20(ST_ETH).transfer(sender, amount); - - _balances[sender].stEthInEthShares = 0; - _totalEscrowShares -= escrowShares; - _totalStEthInEthLocked -= amount; + // --- + // Lock / Unlock wstETH + // --- + function lockWstETH(uint256 amount) external { + _accounting.accountWstETHLock(msg.sender, amount); + WST_ETH.transferFrom(msg.sender, address(this), amount); _activateNextGovernanceState(); } - function unlockWstEth() external { - _activateNextGovernanceState(); - if (_state != State.Signalling) { - revert InvalidState(); - } - - burnRewards(); - - address sender = msg.sender; - uint256 escrowShares = _balances[sender].wstEthInEthShares; - uint256 amount = _getETHByShares(escrowShares); - uint256 amountInShares = IStETH(ST_ETH).getSharesByPooledEth(amount); - - IERC20(WST_ETH).transfer(sender, amountInShares); - - _balances[sender].wstEthInEthShares = 0; - _totalEscrowShares -= escrowShares; - _totalWstEthInEthLocked -= amount; - + function unlockWstETH() external returns (uint256 wstETHUnlocked) { + wstETHUnlocked = _accounting.accountWstETHUnlock(CONFIG.ESCROW_ASSETS_UNLOCK_DELAY(), msg.sender); + WST_ETH.transfer(msg.sender, wstETHUnlocked); _activateNextGovernanceState(); } - function unlockWithdrawalNFT(uint256[] memory ids) external { - _activateNextGovernanceState(); - if (_state != State.Signalling) { - revert InvalidState(); - } + // --- + // Lock / Unlock unstETH + // --- + function lockUnstETH(uint256[] memory unstETHIds) external { + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); - WithdrawalRequestStatus[] memory wqRequestStatuses = IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(ids); - - uint256 wqRequestsAmount = 0; - uint256 finalizedWqRequestsAmount = 0; - address sender = msg.sender; - - for (uint256 i = 0; i < ids.length; ++i) { - uint256 id = ids[i]; - if (_wqRequests[ids[i]].owner != sender) { - revert SenderIsNotOwner(id); - } - IWithdrawalQueue(WITHDRAWAL_QUEUE).transferFrom(address(this), sender, id); - _wqRequests[id].owner = address(0); - if (_wqRequests[id].isFinalized == true) { - finalizedWqRequestsAmount += wqRequestStatuses[i].amountOfStETH; - } else { - wqRequestsAmount += wqRequestStatuses[i].amountOfStETH; - } + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); } - - _balances[sender].wqRequestsBalance -= wqRequestsAmount; - _balances[sender].finalizedWqRequestsBalance -= finalizedWqRequestsAmount; - _totalWithdrawalNftsAmountLocked -= wqRequestsAmount; - _totalFinalizedWithdrawalNftsAmountLocked -= finalizedWqRequestsAmount; - - _activateNextGovernanceState(); } - function unlockEth() public { - _activateNextGovernanceState(); - if (_state != State.Signalling) { - revert InvalidState(); - } - - address sender = msg.sender; - uint256 ethToUnlock = _balances[sender].eth; - - if (ethToUnlock > 0) { - _balances[sender].eth = 0; - _totalClaimedEthLocked -= ethToUnlock; - IERC20(WST_ETH).transfer(sender, ethToUnlock); - - _activateNextGovernanceState(); + function unlockUnstETH(uint256[] memory unstETHIds) external { + _accounting.accountUnstETHUnlock(CONFIG.ESCROW_ASSETS_UNLOCK_DELAY(), msg.sender, unstETHIds); + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstETHIds[i]); } } - function claimETH() external { - if (_state != State.RageQuit) { - revert InvalidState(); - } + function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { + _checkEscrowState(EscrowState.SignallingEscrow); - if (_claimedWQRequestsAmount < _rageQuitAmountTotal) { - revert NotClaimedWQRequests(); - } + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); + _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); + } - address sender = msg.sender; - HolderState memory state = _balances[sender]; + // --- + // State Updates + // --- - uint256 ethToClaim = _getETHByShares(state.stEthInEthShares); - ethToClaim += _getETHByShares(state.wstEthInEthShares); - ethToClaim += _balances[sender].eth; + function startRageQuit(uint256 rageQuitExtraTimelock, uint256 rageQuitWithdrawalsTimelock) external { + _checkDualGovernance(msg.sender); + _checkEscrowState(EscrowState.SignallingEscrow); - _balances[sender].stEthInEthShares = 0; - _balances[sender].wstEthInEthShares = 0; - _balances[sender].eth = 0; + _escrowState = EscrowState.RageQuitEscrow; + _rageQuitExtraTimelock = rageQuitExtraTimelock; + _rageQuitWithdrawalsTimelock = rageQuitWithdrawalsTimelock; - if (ethToClaim > 0) { - payable(sender).transfer(ethToClaim); + uint256 wstETHBalance = WST_ETH.balanceOf(address(this)); + if (wstETHBalance > 0) { + WST_ETH.unwrap(wstETHBalance); } + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); } - /// - /// State transitions - /// + function requestWithdrawalsBatch(uint256 maxWithdrawalRequestsCount) external { + _checkEscrowState(EscrowState.RageQuitEscrow); + + uint256[] memory requestAmounts = _accounting.formWithdrawalBatch( + WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(), + WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(), + ST_ETH.balanceOf(address(this)), + maxWithdrawalRequestsCount + ); + uint256[] memory unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this)); + _accounting.accountWithdrawalBatch(unstETHIds); + } - function burnRewards() public { - uint256 minRewardsAmount = 1e9; - uint256 wstEthLocked = IStETH(ST_ETH).getSharesByPooledEth(_totalWstEthInEthLocked); - uint256 wstEthBalance = IERC20(WST_ETH).balanceOf(address(this)); + function claimNextWithdrawalsBatch(uint256 offset, uint256[] calldata hints) external { + _checkEscrowState(EscrowState.RageQuitEscrow); + uint256[] memory unstETHIds = _accounting.accountWithdrawalBatchClaimed(offset, hints.length); - uint256 stEthBalance = IERC20(ST_ETH).balanceOf(address(this)); + if (unstETHIds.length > 0) { + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; - if (wstEthLocked + minRewardsAmount < wstEthBalance) { - uint256 wstEthRewards = wstEthBalance - wstEthLocked; - IWstETH(WST_ETH).unwrap(wstEthRewards); + _accounting.accountClaimedETH(ethAmountClaimed); } - if (wstEthLocked > wstEthBalance) { - _totalWstEthInEthLocked = IStETH(ST_ETH).getPooledEthByShares(wstEthBalance); - } - - uint256 stEthRewards = 0; - - if (_totalStEthInEthLocked < stEthBalance) { - stEthBalance = IERC20(ST_ETH).balanceOf(address(this)); - stEthRewards = stEthBalance - _totalStEthInEthLocked; - IERC20(ST_ETH).transfer(BURNER_VAULT, stEthRewards); - } else { - _totalStEthInEthLocked = stEthBalance; + if (_accounting.getIsWithdrawalsClaimed()) { + _rageQuitTimelockStartedAt = block.timestamp; } } - function checkForFinalization(uint256[] memory ids) public { - if (_state != State.Signalling) { - revert InvalidState(); - } + function claimWithdrawalRequests(uint256[] calldata unstETHIds, uint256[] calldata hints) external { + _checkEscrowState(EscrowState.RageQuitEscrow); + uint256[] memory claimableAmounts = WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints); - WithdrawalRequestStatus[] memory wqRequestStatuses = IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(ids); + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + uint256 ethBalanceAfter = address(this).balance; - for (uint256 i = 0; i < ids.length; ++i) { - uint256 id = ids[i]; - address requestOwner = _wqRequests[ids[i]].owner; - - if (requestOwner == address(0)) { - revert RequestNotFound(id); - } - - if (_wqRequests[id].isFinalized == false && wqRequestStatuses[i].isFinalized == true) { - _totalWithdrawalNftsAmountLocked -= _wqRequests[id].amountOfStETH; - _totalFinalizedWithdrawalNftsAmountLocked += wqRequestStatuses[i].amountOfStETH; - _balances[requestOwner].wqRequestsBalance -= _wqRequests[id].amountOfStETH; - _balances[requestOwner].finalizedWqRequestsBalance += wqRequestStatuses[i].amountOfStETH; - - _wqRequests[id].amountOfStETH = wqRequestStatuses[i].amountOfStETH; - _wqRequests[id].isFinalized = true; - } - } + uint256 totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); } - function getSignallingState() external view returns (uint256 totalSupport, uint256 rageQuitSupport) { - uint256 stEthTotalSupply = IERC20(ST_ETH).totalSupply(); + // --- + // Withdraw Logic + // --- - uint256 totalRageQuitStEthLocked = - _totalStEthInEthLocked + _totalWstEthInEthLocked + _totalWithdrawalNftsAmountLocked; - rageQuitSupport = (totalRageQuitStEthLocked * 10 ** 18) / stEthTotalSupply; - - uint256 totalStakedEthLocked = - totalRageQuitStEthLocked + _totalFinalizedWithdrawalNftsAmountLocked + _totalClaimedEthLocked; - totalSupport = (totalStakedEthLocked * 10 ** 18) / stEthTotalSupply; + function withdrawStETHAsETH() external { + _checkEscrowState(EscrowState.RageQuitEscrow); + _checkWithdrawalsTimelockPassed(); + Address.sendValue(payable(msg.sender), _accounting.accountStETHWithdraw(msg.sender)); } - function startRageQuit() external { - if (msg.sender != _dualGovernance) { - revert Unauthorized(); - } - if (_state != State.Signalling) { - revert InvalidState(); - } - - burnRewards(); - - assert(_rageQuitAmountRequested == 0); - assert(_lastWithdrawalRequestId == 0); - - _rageQuitAmountTotal = _totalStEthInEthLocked + _totalWstEthInEthLocked; - - _state = State.RageQuit; - - uint256 wstEthBalance = IERC20(WST_ETH).balanceOf(address(this)); - if (wstEthBalance != 0) { - IWstETH(WST_ETH).unwrap(wstEthBalance); - } - - IERC20(ST_ETH).approve(WITHDRAWAL_QUEUE, type(uint256).max); - - emit RageQuitStarted(); + function withdrawWstETH() external { + _checkEscrowState(EscrowState.RageQuitEscrow); + _checkWithdrawalsTimelockPassed(); + Address.sendValue(payable(msg.sender), _accounting.accountWstETHWithdraw(msg.sender)); } - function requestNextWithdrawalsBatch(uint256 maxNumRequests) external returns (uint256, uint256, uint256) { - if (_state != State.RageQuit) { - revert InvalidState(); - } - - uint256 maxStRequestAmount = IWithdrawalQueue(WITHDRAWAL_QUEUE).MAX_STETH_WITHDRAWAL_AMOUNT(); - - uint256 total = _rageQuitAmountTotal; - uint256 requested = _rageQuitAmountRequested; - - if (requested >= total) { - revert NoUnrequestedWithdrawalsLeft(); - } - - uint256 remainder = total - requested; - uint256 numFullRequests = remainder / maxStRequestAmount; - - if (numFullRequests > maxNumRequests) { - numFullRequests = maxNumRequests; - } - - requested += maxStRequestAmount * numFullRequests; - remainder = total - requested; - - uint256[] memory amounts; - - if (numFullRequests < maxNumRequests && remainder < maxStRequestAmount) { - amounts = new uint256[](numFullRequests + 1); - amounts[numFullRequests] = remainder; - requested += remainder; - remainder = 0; - } else { - amounts = new uint256[](numFullRequests); - } - - assert(requested <= total); - assert(amounts.length > 0); - - for (uint256 i = 0; i < numFullRequests; ++i) { - amounts[i] = maxStRequestAmount; - } - - _rageQuitAmountRequested = requested; - - uint256[] memory reqIds = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, address(this)); - - WithdrawalRequestStatus[] memory wqRequestStatuses = - IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(reqIds); - - for (uint256 i = 0; i < reqIds.length; ++i) { - _wqRequests[reqIds[i]] = wqRequestStatuses[i]; - } + function withdrawUnstETHAsETH(uint256[] calldata unstETHIds) external { + _checkEscrowState(EscrowState.RageQuitEscrow); + _checkWithdrawalsTimelockPassed(); + Address.sendValue(payable(msg.sender), _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds)); + } - uint256 lastRequestId = reqIds[reqIds.length - 1]; - _lastWithdrawalRequestId = lastRequestId; + // --- + // Getters + // --- - emit WithdrawalsBatchRequested(reqIds[0], lastRequestId, remainder); - return (reqIds[0], lastRequestId, remainder); + function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { + totals = _accounting.totals; } - function isRageQuitFinalized() external view returns (bool) { - return _state == State.RageQuit && _rageQuitAmountRequested == _rageQuitAmountTotal - && IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastFinalizedRequestId() >= _lastWithdrawalRequestId; + function getVetoerState(address vetoer) external view returns (VetoerState memory vetoerState) { + LockedAssetsStats memory stats = _accounting.assets[vetoer]; + vetoerState.stETHShares = stats.stETHShares; + vetoerState.wstETHShares = stats.wstETHShares; + vetoerState.unstETHShares = stats.unstETHShares; } - function claimNextETHBatch(uint256[] calldata requestIds, uint256[] calldata hints) external { - if (_state != State.RageQuit) { - revert InvalidState(); + function getNextWithdrawalBatches(uint256 limit) + external + view + returns (uint256 offset, uint256 total, uint256[] memory unstETHIds) + { + offset = _accounting.claimedBatchesCount; + total = _accounting.withdrawalBatchIds.length; + if (total == offset) { + return (offset, total, unstETHIds); + } + uint256 count = Math.min(limit, total - offset); + unstETHIds = new uint256[](count); + for (uint256 i = 0; i < count; ++i) { + unstETHIds[i] = _accounting.withdrawalBatchIds[offset + i]; } + } - IWithdrawalQueue(WITHDRAWAL_QUEUE).claimWithdrawals(requestIds, hints); - - WithdrawalRequestStatus[] memory wqRequestStatuses = - IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(requestIds); - - for (uint256 i = 0; i < requestIds.length; ++i) { - uint256 id = requestIds[i]; - address owner = _wqRequests[id].owner; + function getIsWithdrawalsClaimed() external view returns (bool) { + return _accounting.getIsWithdrawalsClaimed(); + } - if (owner != address(this)) { - revert RequestIsNotFromBatch(id); - } - _claimedWQRequestsAmount += wqRequestStatuses[i].amountOfStETH; + function getRageQuitTimelockStartedAt() external view returns (uint256) { + return _rageQuitTimelockStartedAt; + } - for (uint256 idx = 0; i < _balances[owner].wqRequestIds.length; i++) { - if (_balances[owner].wqRequestIds[idx] == requestIds[i]) { - _balances[owner].wqRequestIds[idx] = - _balances[owner].wqRequestIds[_balances[owner].wqRequestIds.length - 1]; - _balances[owner].wqRequestIds.pop(); - break; - } - } - } + function getRageQuitSupport() external view returns (uint256 rageQuitSupport) { + (uint256 rebaseableShares, uint256 finalizedAmount) = _accounting.getLocked(); + uint256 rebaseableAmount = ST_ETH.getPooledEthByShares(rebaseableShares); + rageQuitSupport = (10 ** 18 * (rebaseableAmount + finalizedAmount)) / (ST_ETH.totalSupply() + finalizedAmount); } - function claimWithdrawalRequests(uint256[] calldata requestIds, uint256[] calldata hints) external { - IWithdrawalQueue(WITHDRAWAL_QUEUE).claimWithdrawals(requestIds, hints); - - WithdrawalRequestStatus[] memory wqRequestStatuses = - IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(requestIds); - - for (uint256 i = 0; i < requestIds.length; ++i) { - uint256 id = requestIds[i]; - WithdrawalRequestStatus memory request = _wqRequests[id]; - address owner = request.owner; - - if (owner == address(this) || owner == address(0)) { - revert RequestFromBatch(id); - } - - if (request.isFinalized) { - _balances[owner].finalizedWqRequestsBalance -= request.amountOfStETH; - _totalFinalizedWithdrawalNftsAmountLocked -= request.amountOfStETH; - } else { - _balances[owner].wqRequestsBalance -= request.amountOfStETH; - _totalWithdrawalNftsAmountLocked -= request.amountOfStETH; - } - _balances[owner].eth += wqRequestStatuses[i].amountOfStETH; - _totalClaimedEthLocked += wqRequestStatuses[i].amountOfStETH; - - for (uint256 idx = 0; i < _balances[owner].wqRequestIds.length; i++) { - if (_balances[owner].wqRequestIds[idx] == requestIds[i]) { - _balances[owner].wqRequestIds[idx] = - _balances[owner].wqRequestIds[_balances[owner].wqRequestIds.length - 1]; - _balances[owner].wqRequestIds.pop(); - break; - } - } - } + function isRageQuitFinalized() external view returns (bool) { + return _escrowState == EscrowState.RageQuitEscrow && _accounting.getIsWithdrawalsClaimed() + && _rageQuitTimelockStartedAt != 0 && block.timestamp > _rageQuitTimelockStartedAt + _rageQuitExtraTimelock; } + // --- + // RECEIVE + // --- + receive() external payable { - if (msg.sender != WITHDRAWAL_QUEUE) { - revert SenderIsNotAllowed(); + if (msg.sender != address(WITHDRAWAL_QUEUE)) { + revert InvalidETHSender(msg.sender, address(WITHDRAWAL_QUEUE)); } } + // --- + // Internal Methods + // --- + function _activateNextGovernanceState() internal { - IGovernanceState(_dualGovernance).activateNextState(); + _dualGovernance.activateNextState(); } - function _getSharesByETH(uint256 eth) internal view returns (uint256 shares) { - uint256 totalEthLocked = _totalStEthInEthLocked + _totalWstEthInEthLocked; - - shares = eth * _totalEscrowShares / totalEthLocked; + function _checkEscrowState(EscrowState expected) internal view { + if (_escrowState != expected) { + revert InvalidState(_escrowState, expected); + } } - function _getETHByShares(uint256 shares) internal view returns (uint256 eth) { - uint256 totalEthLocked = _totalStEthInEthLocked + _totalWstEthInEthLocked; + function _checkDualGovernance(address account) internal view { + if (account != address(_dualGovernance)) { + revert NotDualGovernance(account, address(_dualGovernance)); + } + } - eth = shares * totalEthLocked / _totalEscrowShares; + function _checkWithdrawalsTimelockPassed() internal view { + if (_rageQuitTimelockStartedAt == 0) { + revert RageQuitExtraTimelockNotStarted(); + } + if (block.timestamp <= _rageQuitTimelockStartedAt + _rageQuitExtraTimelock + _rageQuitWithdrawalsTimelock) { + revert WithdrawalsTimelockNotPassed(); + } } } diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index b57be9fe..7c1f87b8 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -26,6 +26,13 @@ interface IDualGovernanceConfiguration { function TIE_BREAK_ACTIVATION_TIMEOUT() external view returns (uint256); + function RAGE_QUIT_EXTRA_TIMELOCK() external view returns (uint256); + function RAGE_QUIT_EXTENSION_DELAY() external view returns (uint256); + function RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK() external view returns (uint256); + + function MIN_STATE_DURATION() external view returns (uint256); + function ESCROW_ASSETS_UNLOCK_DELAY() external view returns (uint256); + function sealableWithdrawalBlockers() external view returns (address[] memory); function getSignallingThresholdData() diff --git a/contracts/interfaces/IEscrow.sol b/contracts/interfaces/IEscrow.sol index 01daa67e..d8c44087 100644 --- a/contracts/interfaces/IEscrow.sol +++ b/contracts/interfaces/IEscrow.sol @@ -2,10 +2,11 @@ pragma solidity 0.8.23; interface IEscrow { - function startRageQuit() external; function initialize(address dualGovernance) external; + function startRageQuit(uint256 rageQuitExtraTimelock, uint256 rageQuitWithdrawalsTimelock) external; + function MASTER_COPY() external view returns (address); function isRageQuitFinalized() external view returns (bool); - function getSignallingState() external view returns (uint256 totalSupport, uint256 rageQuitSupport); + function getRageQuitSupport() external view returns (uint256 rageQuitSupport); } diff --git a/contracts/interfaces/IGateSeal.sol b/contracts/interfaces/IGateSeal.sol index e8507879..2b2a121d 100644 --- a/contracts/interfaces/IGateSeal.sol +++ b/contracts/interfaces/IGateSeal.sol @@ -6,4 +6,4 @@ interface IGateSeal { function get_expiry_timestamp() external view returns (uint256); function sealed_sealables() external view returns (address[] memory); function seal(address[] calldata sealables) external; -} \ No newline at end of file +} diff --git a/contracts/interfaces/ISealable.sol b/contracts/interfaces/ISealable.sol index 6ab14d6e..12239b92 100644 --- a/contracts/interfaces/ISealable.sol +++ b/contracts/interfaces/ISealable.sol @@ -5,4 +5,4 @@ interface ISealable { function resume() external; function pauseFor(uint256 duration) external; function isPaused() external view returns (bool); -} \ No newline at end of file +} diff --git a/contracts/interfaces/IStETH.sol b/contracts/interfaces/IStETH.sol new file mode 100644 index 00000000..764dc145 --- /dev/null +++ b/contracts/interfaces/IStETH.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IStETH is IERC20 { + function getTotalShares() external view returns (uint256); + function getSharesByPooledEth(uint256 ethAmount) external view returns (uint256); + + function getPooledEthByShares(uint256 sharesAmount) external view returns (uint256); + + function transferShares(address to, uint256 amount) external; + function transferSharesFrom( + address _sender, + address _recipient, + uint256 _sharesAmount + ) external returns (uint256); +} diff --git a/contracts/interfaces/IWithdrawalQueue.sol b/contracts/interfaces/IWithdrawalQueue.sol new file mode 100644 index 00000000..2b79d759 --- /dev/null +++ b/contracts/interfaces/IWithdrawalQueue.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +struct WithdrawalRequestStatus { + uint256 amountOfStETH; + uint256 amountOfShares; + address owner; + uint256 timestamp; + bool isFinalized; + bool isClaimed; +} + +interface IWithdrawalQueue { + function MIN_STETH_WITHDRAWAL_AMOUNT() external view returns (uint256); + function MAX_STETH_WITHDRAWAL_AMOUNT() external view returns (uint256); + + function requestWithdrawalsWstETH(uint256[] calldata amounts, address owner) external returns (uint256[] memory); + + function claimWithdrawals(uint256[] calldata requestIds, uint256[] calldata hints) external; + + function getLastFinalizedRequestId() external view returns (uint256); + + function transferFrom(address from, address to, uint256 requestId) external; + + function getWithdrawalStatus(uint256[] calldata _requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses); + + function getLastRequestId() external view returns (uint256); + + /// @notice Returns amount of ether available for claim for each provided request id + /// @param _requestIds array of request ids + /// @param _hints checkpoint hints. can be found with `findCheckpointHints(_requestIds, 1, getLastCheckpointIndex())` + /// @return claimableEthValues amount of claimable ether for each request, amount is equal to 0 if request + /// is not finalized or already claimed + function getClaimableEther( + uint256[] calldata _requestIds, + uint256[] calldata _hints + ) external view returns (uint256[] memory claimableEthValues); + + function balanceOf(address owner) external view returns (uint256); + + function requestWithdrawals( + uint256[] calldata _amounts, + address _owner + ) external returns (uint256[] memory requestIds); +} diff --git a/contracts/interfaces/IWstETH.sol b/contracts/interfaces/IWstETH.sol new file mode 100644 index 00000000..2b5622e0 --- /dev/null +++ b/contracts/interfaces/IWstETH.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IWstETH is IERC20 { + function wrap(uint256 stETHAmount) external returns (uint256); + + function unwrap(uint256 wstETHAmount) external returns (uint256); +} diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol new file mode 100644 index 00000000..d067da0a --- /dev/null +++ b/contracts/libraries/AssetsAccounting.sol @@ -0,0 +1,534 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; + +import {TimeUtils} from "../utils/time.sol"; +import {ArrayUtils} from "../utils/arrays.sol"; + +enum WithdrawalRequestState { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +struct WithdrawalRequest { + address owner; + uint96 claimableAmount; + uint128 shares; + uint64 vetoerUnstETHIndexOneBased; + WithdrawalRequestState state; +} + +struct LockedAssetsStats { + uint128 stETHShares; + uint128 wstETHShares; + uint128 unstETHShares; + uint128 sharesFinalized; + uint128 amountFinalized; + uint40 lastAssetsLockTimestamp; +} + +struct LockedAssetsTotals { + uint128 shares; + uint128 sharesFinalized; + uint128 amountFinalized; + uint128 amountClaimed; +} + +library AssetsAccounting { + using SafeCast for uint256; + + event StETHLocked(address indexed vetoer, uint256 shares); + event StETHUnlocked(address indexed vetoer, uint256 shares); + event StETHWithdrawn(address indexed vetoer, uint256 stETHShares, uint256 ethAmount); + + event WstETHLocked(address indexed vetoer, uint256 shares); + event WstETHUnlocked(address indexed vetoer, uint256 shares); + event WstETHWithdrawn(address indexed vetoer, uint256 wstETHShares, uint256 ethAmount); + + event UnstETHLocked(address indexed vetoer, uint256[] ids, uint256 shares); + event UnstETHUnlocked( + address indexed vetoer, + uint256[] ids, + uint256 sharesDecrement, + uint256 finalizedSharesDecrement, + uint256 finalizedAmountDecrement + ); + event UnstETHFinalized(uint256[] ids, uint256 finalizedSharesIncrement, uint256 finalizedAmountIncrement); + event UnstETHClaimed(uint256[] ids, uint256 ethAmount); + event UnstETHWithdrawn(uint256[] ids, uint256 ethAmount); + + event WithdrawalBatchCreated(uint256[] ids); + event WithdrawalBatchesClaimed(uint256 offset, uint256 count); + + error NoBatchesToClaim(); + error EmptyWithdrawalBatch(); + error WithdrawalBatchesFormed(); + error NotWithdrawalRequestOwner(uint256 id, address actual, address expected); + error InvalidSharesLock(address vetoer, uint256 shares); + error InvalidSharesUnlock(address vetoer, uint256 shares); + error InvalidSharesWithdraw(address vetoer, uint256 shares); + error WithdrawalRequestFinalized(uint256 id); + error ClaimableAmountChanged(uint256 id, uint256 actual, uint256 expected); + error WithdrawalRequestNotClaimable(uint256 id, WithdrawalRequestState state); + error WithdrawalRequestWasNotLocked(uint256 id); + error WithdrawalRequestAlreadyLocked(uint256 id); + error InvalidUnstETHOwner(address actual, address expected); + error InvalidWithdrawlRequestState(uint256 id, WithdrawalRequestState actual, WithdrawalRequestState expected); + error InvalidWithdrawalBatchesOffset(uint256 actual, uint256 expected); + error InvalidWithdrawalBatchesCount(uint256 actual, uint256 expected); + error AssetsUnlockDelayNotPassed(uint256 unlockTimelockExpiresAt); + + struct State { + LockedAssetsTotals totals; + mapping(address vetoer => LockedAssetsStats) assets; + mapping(uint256 unstETHId => WithdrawalRequest) requests; + mapping(address vetoer => uint256[] unstETHIds) vetoersUnstETHIds; + uint256[] withdrawalBatchIds; + uint256 claimedBatchesCount; + bool isAllWithdrawalBatchesFormed; + } + + // --- + // stETH Operations Accounting + // --- + + function accountStETHLock(State storage self, address vetoer, uint256 shares) internal { + _checkNonZeroSharesLock(vetoer, shares); + uint128 sharesUint128 = shares.toUint128(); + self.assets[vetoer].stETHShares += sharesUint128; + self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.totals.shares += sharesUint128; + emit StETHLocked(vetoer, shares); + } + + function accountStETHUnlock( + State storage self, + uint256 assetsUnlockDelay, + address vetoer + ) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = self.assets[vetoer].stETHShares; + _checkNonZeroSharesUnlock(vetoer, sharesUnlocked); + _checkAssetsUnlockDelayPassed(self, assetsUnlockDelay, vetoer); + self.assets[vetoer].stETHShares = 0; + self.totals.shares -= sharesUnlocked; + emit StETHUnlocked(vetoer, sharesUnlocked); + } + + function accountStETHWithdraw(State storage self, address vetoer) internal returns (uint256 ethAmount) { + uint256 stETHShares = self.assets[vetoer].stETHShares; + _checkNonZeroSharesWithdraw(vetoer, stETHShares); + self.assets[vetoer].stETHShares = 0; + ethAmount = self.totals.amountClaimed * stETHShares / self.totals.shares; + emit StETHWithdrawn(vetoer, stETHShares, ethAmount); + } + + // --- + // wstETH Operations Accounting + // --- + + function accountWstETHLock(State storage self, address vetoer, uint256 shares) internal { + _checkNonZeroSharesLock(vetoer, shares); + uint128 sharesUint128 = shares.toUint128(); + self.assets[vetoer].wstETHShares += sharesUint128; + self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.totals.shares += sharesUint128; + emit WstETHLocked(vetoer, shares); + } + + function accountWstETHUnlock( + State storage self, + uint256 assetsUnlockDelay, + address vetoer + ) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = self.assets[vetoer].wstETHShares; + _checkNonZeroSharesUnlock(vetoer, sharesUnlocked); + _checkAssetsUnlockDelayPassed(self, assetsUnlockDelay, vetoer); + self.totals.shares -= sharesUnlocked; + self.assets[vetoer].wstETHShares = 0; + emit WstETHUnlocked(vetoer, sharesUnlocked); + } + + function accountWstETHWithdraw(State storage self, address vetoer) internal returns (uint256 ethAmount) { + uint256 wstETHShares = self.assets[vetoer].wstETHShares; + _checkNonZeroSharesWithdraw(vetoer, wstETHShares); + self.assets[vetoer].wstETHShares = 0; + ethAmount = self.totals.amountClaimed * wstETHShares / self.totals.shares; + emit WstETHWithdrawn(vetoer, wstETHShares, ethAmount); + } + + // --- + // unstETH Operations Accounting + // --- + + function accountUnstETHLock( + State storage self, + address vetoer, + uint256[] memory unstETHIds, + WithdrawalRequestStatus[] memory statuses + ) internal { + assert(unstETHIds.length == statuses.length); + + uint256 totalUnstETHSharesLocked; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + totalUnstETHSharesLocked += _addWithdrawalRequest(self, vetoer, unstETHIds[i], statuses[i]); + } + uint128 totalUnstETHSharesLockedUint128 = totalUnstETHSharesLocked.toUint128(); + self.assets[vetoer].unstETHShares += totalUnstETHSharesLockedUint128; + self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.totals.shares += totalUnstETHSharesLockedUint128; + emit UnstETHLocked(vetoer, unstETHIds, totalUnstETHSharesLocked); + } + + function accountUnstETHUnlock( + State storage self, + uint256 assetsUnlockDelay, + address vetoer, + uint256[] memory unstETHIds + ) internal { + _checkAssetsUnlockDelayPassed(self, assetsUnlockDelay, vetoer); + + uint256 totalUnstETHSharesUnlocked; + uint256 totalFinalizedSharesUnlocked; + uint256 totalFinalizedAmountUnlocked; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (uint256 sharesUnlocked, uint256 finalizedSharesUnlocked, uint256 finalizedAmountUnlocked) = + _removeWithdrawalRequest(self, vetoer, unstETHIds[i]); + totalUnstETHSharesUnlocked += sharesUnlocked; + totalFinalizedSharesUnlocked += finalizedSharesUnlocked; + totalFinalizedAmountUnlocked += finalizedAmountUnlocked; + } + + uint128 totalUnstETHSharesUnlockedUint128 = totalUnstETHSharesUnlocked.toUint128(); + uint128 totalFinalizedSharesUnlockedUint128 = totalFinalizedSharesUnlocked.toUint128(); + uint128 totalFinalizedAmountUnlockedUint128 = totalFinalizedAmountUnlocked.toUint128(); + + self.assets[vetoer].unstETHShares -= totalUnstETHSharesUnlockedUint128; + self.assets[vetoer].sharesFinalized -= totalFinalizedSharesUnlockedUint128; + self.assets[vetoer].amountFinalized -= totalFinalizedAmountUnlockedUint128; + + self.totals.shares -= totalUnstETHSharesUnlockedUint128; + self.totals.amountFinalized -= totalFinalizedSharesUnlockedUint128; + self.totals.sharesFinalized -= totalFinalizedAmountUnlockedUint128; + + emit UnstETHUnlocked( + vetoer, unstETHIds, totalUnstETHSharesUnlocked, totalFinalizedSharesUnlocked, totalFinalizedAmountUnlocked + ); + } + + function accountUnstETHFinalized( + State storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + assert(claimableAmounts.length == unstETHIds.length); + + uint256 totalSharesFinalized; + uint256 totalAmountFinalized; + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + (uint256 sharesFinalized, uint256 amountFinalized) = + _finalizeWithdrawalRequest(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized += sharesFinalized; + totalAmountFinalized += amountFinalized; + } + uint128 totalSharesFinalizedUint128 = totalSharesFinalized.toUint128(); + uint128 totalAmountFinalizedUint128 = totalAmountFinalized.toUint128(); + + self.totals.sharesFinalized += totalSharesFinalizedUint128; + self.totals.amountFinalized += totalAmountFinalizedUint128; + + emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); + } + + function accountUnstETHClaimed( + State storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal returns (uint256 totalAmountClaimed) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + totalAmountClaimed += _claimWithdrawalRequest(self, unstETHIds[i], claimableAmounts[i]); + } + self.totals.amountClaimed += totalAmountClaimed.toUint128(); + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + State storage self, + address vetoer, + uint256[] calldata unstETHIds + ) internal returns (uint256 amountWithdrawn) { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + amountWithdrawn += _withdrawWithdrawalRequest(self, vetoer, unstETHIds[i]); + } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); + } + + // --- + // Withdraw Batches + // --- + + function formWithdrawalBatch( + State storage self, + uint256 minRequestAmount, + uint256 maxRequestAmount, + uint256 stETHBalance, + uint256 requestAmountsCountLimit + ) internal returns (uint256[] memory requestAmounts) { + if (self.isAllWithdrawalBatchesFormed) { + revert WithdrawalBatchesFormed(); + } + if (requestAmountsCountLimit == 0) { + revert EmptyWithdrawalBatch(); + } + + uint256 maxAmount = maxRequestAmount * requestAmountsCountLimit; + if (stETHBalance >= maxAmount) { + return ArrayUtils.seed(requestAmountsCountLimit, maxRequestAmount); + } + + self.isAllWithdrawalBatchesFormed = true; + + uint256 requestsCount = stETHBalance / maxRequestAmount; + uint256 lastRequestAmount = stETHBalance % maxRequestAmount; + + if (lastRequestAmount < minRequestAmount) { + return ArrayUtils.seed(requestsCount, maxRequestAmount); + } + + requestAmounts = ArrayUtils.seed(requestsCount + 1, maxRequestAmount); + requestAmounts[requestsCount] = lastRequestAmount; + } + + function accountWithdrawalBatch(State storage self, uint256[] memory unstETHIds) internal { + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + self.withdrawalBatchIds.push(unstETHIds[i]); + } + emit WithdrawalBatchCreated(unstETHIds); + } + + function accountWithdrawalBatchClaimed( + State storage self, + uint256 offset, + uint256 count + ) internal returns (uint256[] memory unstETHIds) { + if (count == 0) { + return unstETHIds; + } + uint256 batchesCount = self.withdrawalBatchIds.length; + uint256 claimedBatchesCount = self.claimedBatchesCount; + if (claimedBatchesCount == batchesCount) { + revert NoBatchesToClaim(); + } + if (claimedBatchesCount != offset) { + revert InvalidWithdrawalBatchesOffset(offset, claimedBatchesCount); + } + if (count > batchesCount - claimedBatchesCount) { + revert InvalidWithdrawalBatchesCount(count, batchesCount - claimedBatchesCount); + } + + unstETHIds = new uint256[](count); + for (uint256 i = 0; i < count; ++i) { + unstETHIds[i] = self.withdrawalBatchIds[claimedBatchesCount + i]; + } + self.claimedBatchesCount += count; + emit WithdrawalBatchesClaimed(offset, count); + } + + function accountClaimedETH(State storage self, uint256 amount) internal { + self.totals.amountClaimed += amount.toUint128(); + } + + // --- + // Getters + // --- + + function getLocked(State storage self) internal view returns (uint256 rebaseableShares, uint256 finalizedAmount) { + rebaseableShares = self.totals.shares - self.totals.sharesFinalized; + finalizedAmount = self.totals.amountFinalized; + } + + function getIsWithdrawalsClaimed(State storage self) internal view returns (bool) { + return self.claimedBatchesCount == self.withdrawalBatchIds.length; + } + + function _checkWithdrawalRequestStatusOwner(WithdrawalRequestStatus memory status, address account) private pure { + if (status.owner != account) { + revert InvalidUnstETHOwner(account, status.owner); + } + } + + // --- + // Private Methods + // --- + + function _addWithdrawalRequest( + State storage self, + address vetoer, + uint256 unstETHId, + WithdrawalRequestStatus memory status + ) private returns (uint256 amountOfShares) { + amountOfShares = status.amountOfShares; + WithdrawalRequest storage request = self.requests[unstETHId]; + + _checkWithdrawalRequestNotLocked(request, unstETHId); + _checkWithdrawalRequestStatusNotFinalized(status, unstETHId); + + self.vetoersUnstETHIds[vetoer].push(unstETHId); + + request.owner = vetoer; + request.state = WithdrawalRequestState.Locked; + request.vetoerUnstETHIndexOneBased = self.vetoersUnstETHIds[vetoer].length.toUint64(); + request.shares = amountOfShares.toUint128(); + assert(request.claimableAmount == 0); + } + + function _removeWithdrawalRequest( + State storage self, + address vetoer, + uint256 unstETHId + ) private returns (uint256 sharesUnlocked, uint256 finalizedSharesUnlocked, uint256 finalizedAmountUnlocked) { + WithdrawalRequest storage request = self.requests[unstETHId]; + + _checkWithdrawalRequestOwner(request, vetoer); + _checkWithdrawalRequestWasLocked(request, unstETHId); + + sharesUnlocked = request.shares; + if (request.state == WithdrawalRequestState.Finalized) { + finalizedSharesUnlocked = sharesUnlocked; + finalizedAmountUnlocked = request.claimableAmount; + } + + uint256[] storage vetoerUnstETHIds = self.vetoersUnstETHIds[vetoer]; + uint256 unstETHIdIndex = request.vetoerUnstETHIndexOneBased - 1; + uint256 lastUnstETHIdIndex = vetoerUnstETHIds.length - 1; + if (lastUnstETHIdIndex != unstETHIdIndex) { + uint256 lastUnstETHId = vetoerUnstETHIds[lastUnstETHIdIndex]; + vetoerUnstETHIds[unstETHIdIndex] = lastUnstETHId; + self.requests[lastUnstETHId].vetoerUnstETHIndexOneBased = (unstETHIdIndex + 1).toUint64(); + } + vetoerUnstETHIds.pop(); + delete self.requests[unstETHId]; + } + + function _finalizeWithdrawalRequest( + State storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (uint256 sharesFinalized, uint256 amountFinalized) { + WithdrawalRequest storage request = self.requests[unstETHId]; + if (claimableAmount == 0 || request.state != WithdrawalRequestState.Locked) { + return (0, 0); + } + request.state = WithdrawalRequestState.Finalized; + request.claimableAmount = claimableAmount.toUint96(); + + sharesFinalized = request.shares; + amountFinalized = claimableAmount; + } + + function _claimWithdrawalRequest( + State storage self, + uint256 unstETHId, + uint256 claimableAmount + ) private returns (uint256 amountClaimed) { + WithdrawalRequest storage request = self.requests[unstETHId]; + + if (request.state != WithdrawalRequestState.Locked && request.state != WithdrawalRequestState.Finalized) { + revert WithdrawalRequestNotClaimable(unstETHId, request.state); + } + if (request.state == WithdrawalRequestState.Finalized && request.claimableAmount != claimableAmount) { + revert ClaimableAmountChanged(unstETHId, claimableAmount, request.claimableAmount); + } else { + request.claimableAmount = claimableAmount.toUint96(); + } + request.state = WithdrawalRequestState.Claimed; + amountClaimed = claimableAmount; + } + + function _withdrawWithdrawalRequest( + State storage self, + address vetoer, + uint256 unstETHId + ) private returns (uint256 amountWithdrawn) { + WithdrawalRequest storage request = self.requests[unstETHId]; + + if (request.owner != vetoer) { + revert NotWithdrawalRequestOwner(unstETHId, vetoer, request.owner); + } + if (request.state != WithdrawalRequestState.Claimed) { + revert InvalidWithdrawlRequestState(unstETHId, request.state, WithdrawalRequestState.Claimed); + } + request.state = WithdrawalRequestState.Withdrawn; + amountWithdrawn = request.claimableAmount; + } + + function _checkWithdrawalRequestOwner(WithdrawalRequest storage request, address account) private view { + if (request.owner != account) { + revert InvalidUnstETHOwner(account, request.owner); + } + } + + function _checkWithdrawalRequestStatusNotFinalized( + WithdrawalRequestStatus memory status, + uint256 id + ) private pure { + if (status.isFinalized) { + revert WithdrawalRequestFinalized(id); + } + // it can't be claimed without finalization + assert(!status.isClaimed); + } + + function _checkWithdrawalRequestNotLocked(WithdrawalRequest storage request, uint256 unstETHId) private view { + if (request.vetoerUnstETHIndexOneBased != 0) { + revert WithdrawalRequestAlreadyLocked(unstETHId); + } + } + + function _checkWithdrawalRequestWasLocked(WithdrawalRequest storage request, uint256 id) private view { + if (request.vetoerUnstETHIndexOneBased == 0) { + revert WithdrawalRequestWasNotLocked(id); + } + } + + function _checkNonZeroSharesLock(address vetoer, uint256 shares) private pure { + if (shares == 0) { + revert InvalidSharesLock(vetoer, 0); + } + } + + function _checkNonZeroSharesUnlock(address vetoer, uint256 shares) private pure { + if (shares == 0) { + revert InvalidSharesUnlock(vetoer, 0); + } + } + + function _checkNonZeroSharesWithdraw(address vetoer, uint256 shares) private pure { + if (shares == 0) { + revert InvalidSharesWithdraw(vetoer, 0); + } + } + + function _checkAssetsUnlockDelayPassed( + State storage self, + uint256 assetsUnlockDelay, + address vetoer + ) private view { + if (block.timestamp <= self.assets[vetoer].lastAssetsLockTimestamp + assetsUnlockDelay) { + revert AssetsUnlockDelayNotPassed(self.assets[vetoer].lastAssetsLockTimestamp + assetsUnlockDelay); + } + } +} diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index ef932957..f363f500 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -6,7 +6,7 @@ import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; import {IEscrow} from "../interfaces/IEscrow.sol"; import {IDualGovernanceConfiguration as IConfiguration} from "../interfaces/IConfiguration.sol"; -import {timestamp} from "../utils/time.sol"; +import {TimeUtils} from "../utils/time.sol"; interface IPausableUntil { function isPaused() external view returns (bool); @@ -29,6 +29,7 @@ library DualGovernanceState { IEscrow signallingEscrow; IEscrow rageQuitEscrow; uint40 lastProposalCreatedAt; + uint8 rageQuitRound; } error NotTie(); @@ -48,7 +49,11 @@ library DualGovernanceState { function activateNextState(Store storage self, IConfiguration config) internal returns (State newState) { State oldState = self.state; - if (oldState == State.Normal) { + // TODO: Currently doesn't match spec precisely because not only Normal or VetoSignalling states are bounded. + // But it seems like there are no states that may last shorter than MIN_STATE_DURATION + if (block.timestamp < self.enteredAt + config.MIN_STATE_DURATION()) { + newState = oldState; + } else if (oldState == State.Normal) { newState = _fromNormalState(self, config); } else if (oldState == State.VetoSignalling) { newState = _fromVetoSignallingState(self, config); @@ -64,13 +69,13 @@ library DualGovernanceState { if (oldState != newState) { _setState(self, oldState, newState); - _handleStateTransitionSideEffects(self, oldState, newState); + _handleStateTransitionSideEffects(self, config, oldState, newState); emit DualGovernanceStateChanged(oldState, newState); } } function setLastProposalCreationTimestamp(Store storage self) internal { - self.lastProposalCreatedAt = timestamp(); + self.lastProposalCreatedAt = TimeUtils.timestamp(); } function checkProposalsCreationAllowed(Store storage self) internal view { @@ -131,7 +136,7 @@ library DualGovernanceState { } function getVetoSignallingDuration(Store storage self, IConfiguration config) internal view returns (uint256) { - (uint256 totalSupport,) = self.signallingEscrow.getSignallingState(); + uint256 totalSupport = self.signallingEscrow.getRageQuitSupport(); return _calcVetoSignallingTargetDuration(config, totalSupport); } @@ -163,12 +168,12 @@ library DualGovernanceState { // --- function _fromNormalState(Store storage self, IConfiguration config) private view returns (State) { - (uint256 totalSupport,) = self.signallingEscrow.getSignallingState(); - return totalSupport >= config.FIRST_SEAL_THRESHOLD() ? State.VetoSignalling : State.Normal; + uint256 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + return rageQuitSupport >= config.FIRST_SEAL_THRESHOLD() ? State.VetoSignalling : State.Normal; } function _fromVetoSignallingState(Store storage self, IConfiguration config) private view returns (State) { - (uint256 totalSupport,) = self.signallingEscrow.getSignallingState(); + uint256 totalSupport = self.signallingEscrow.getRageQuitSupport(); if (totalSupport < config.FIRST_SEAL_THRESHOLD()) { return State.VetoSignallingDeactivation; @@ -190,15 +195,15 @@ library DualGovernanceState { ) private view returns (State) { if (_isVetoSignallingDeactivationPhasePassed(self, config)) return State.VetoCooldown; - (uint256 totalSupport, uint256 rageQuitSupport) = self.signallingEscrow.getSignallingState(); + uint256 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); uint256 currentSignallingDuration = block.timestamp - self.signallingActivatedAt; - uint256 targetSignallingDuration = _calcVetoSignallingTargetDuration(config, totalSupport); + uint256 targetSignallingDuration = _calcVetoSignallingTargetDuration(config, rageQuitSupport); if (currentSignallingDuration >= targetSignallingDuration) { if (rageQuitSupport >= config.SECOND_SEAL_THRESHOLD()) { return State.RageQuit; } - } else if (totalSupport >= config.FIRST_SEAL_THRESHOLD()) { + } else if (rageQuitSupport >= config.FIRST_SEAL_THRESHOLD()) { return State.VetoSignalling; } return State.VetoSignallingDeactivation; @@ -216,7 +221,7 @@ library DualGovernanceState { if (!self.rageQuitEscrow.isRageQuitFinalized()) { return State.RageQuit; } - return _isFirstThresholdReached(self, config) ? State.VetoSignalling : State.Normal; + return _isFirstThresholdReached(self, config) ? State.VetoSignalling : State.VetoCooldown; } function _setState(Store storage self, State oldState, State newState) private { @@ -225,25 +230,35 @@ library DualGovernanceState { self.state = newState; - uint40 currentTime = timestamp(); + uint40 currentTime = TimeUtils.timestamp(); self.enteredAt = currentTime; } - function _handleStateTransitionSideEffects(Store storage self, State oldState, State newState) private { - uint40 currentTime = timestamp(); + function _handleStateTransitionSideEffects( + Store storage self, + IConfiguration config, + State oldState, + State newState + ) private { + uint40 currentTime = TimeUtils.timestamp(); // track the time when the governance state allowed execution if (oldState == State.Normal || oldState == State.VetoCooldown) { self.lastAdoptableStateExitedAt = currentTime; } - + if (newState == State.Normal && self.rageQuitRound != 0) { + self.rageQuitRound = 0; + } if (newState == State.VetoSignalling && oldState != State.VetoSignallingDeactivation) { self.signallingActivatedAt = currentTime; } if (newState == State.RageQuit) { IEscrow signallingEscrow = self.signallingEscrow; - signallingEscrow.startRageQuit(); + signallingEscrow.startRageQuit( + config.RAGE_QUIT_EXTRA_TIMELOCK(), _calcRageQuitWithdrawalsTimelock(config, self.rageQuitRound) + ); self.rageQuitEscrow = signallingEscrow; _deployNewSignallingEscrow(self, signallingEscrow.MASTER_COPY()); + self.rageQuitRound += 1; } } @@ -252,12 +267,12 @@ library DualGovernanceState { // --- function _isFirstThresholdReached(Store storage self, IConfiguration config) private view returns (bool) { - (uint256 totalSupport,) = self.signallingEscrow.getSignallingState(); - return totalSupport >= config.FIRST_SEAL_THRESHOLD(); + uint256 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); + return rageQuitSupport >= config.FIRST_SEAL_THRESHOLD(); } function _isSecondThresholdReached(Store storage self, IConfiguration config) private view returns (bool) { - (, uint256 rageQuitSupport) = self.signallingEscrow.getSignallingState(); + uint256 rageQuitSupport = self.signallingEscrow.getRageQuitSupport(); return rageQuitSupport >= config.SECOND_SEAL_THRESHOLD(); } @@ -300,4 +315,12 @@ library DualGovernanceState { self.signallingEscrow = clone; emit NewSignallingEscrowDeployed(address(clone)); } + + function _calcRageQuitWithdrawalsTimelock( + IConfiguration config, + uint256 rageQuitRound + ) private view returns (uint256) { + // TODO: implement proper function + return config.RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK() * config.RAGE_QUIT_EXTENSION_DELAY() * rageQuitRound; + } } diff --git a/contracts/libraries/Proposals.sol b/contracts/libraries/Proposals.sol index e588befd..0cbb4317 100644 --- a/contracts/libraries/Proposals.sol +++ b/contracts/libraries/Proposals.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.23; import {IExecutor, ExecutorCall} from "../interfaces/IExecutor.sol"; -import {timestamp} from "../utils/time.sol"; +import {TimeUtils} from "../utils/time.sol"; enum Status { NotExist, @@ -70,7 +70,7 @@ library Proposals { newProposal.executor = executor; newProposal.executedAt = 0; - newProposal.submittedAt = timestamp(); + newProposal.submittedAt = TimeUtils.timestamp(); // copying of arrays of custom types from calldata to storage has not been supported by the // Solidity compiler yet, so insert item by item @@ -85,7 +85,7 @@ library Proposals { function schedule(State storage self, uint256 proposalId, uint256 afterSubmitDelay) internal { _checkProposalSubmitted(self, proposalId); _checkAfterSubmitDelayPassed(self, proposalId, afterSubmitDelay); - _packed(self, proposalId).scheduledAt = timestamp(); + _packed(self, proposalId).scheduledAt = TimeUtils.timestamp(); emit ProposalScheduled(proposalId); } @@ -137,7 +137,7 @@ library Proposals { function _executeProposal(State storage self, uint256 proposalId) private returns (bytes[] memory results) { ProposalPacked storage packed = _packed(self, proposalId); - packed.executedAt = timestamp(); + packed.executedAt = TimeUtils.timestamp(); ExecutorCall[] memory calls = packed.calls; uint256 callsCount = calls.length; diff --git a/contracts/libraries/SealableCalls.sol b/contracts/libraries/SealableCalls.sol index bb1ec9e3..72b4e331 100644 --- a/contracts/libraries/SealableCalls.sol +++ b/contracts/libraries/SealableCalls.sol @@ -42,4 +42,4 @@ library SealableCalls { lowLevelError = resumeLowLevelError; } } -} \ No newline at end of file +} diff --git a/contracts/utils/arrays.sol b/contracts/utils/arrays.sol new file mode 100644 index 00000000..c64ce2f3 --- /dev/null +++ b/contracts/utils/arrays.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +library ArrayUtils { + function seed(uint256 length, uint256 value) internal pure returns (uint256[] memory res) { + res = new uint256[](length); + for (uint256 i = 0; i < length; ++i) { + res[i] = value; + } + } +} diff --git a/contracts/utils/time.sol b/contracts/utils/time.sol index 5c93f520..05bc93b3 100644 --- a/contracts/utils/time.sol +++ b/contracts/utils/time.sol @@ -3,14 +3,16 @@ pragma solidity 0.8.23; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -function timestamp() view returns (uint40) { - return timestamp(block.timestamp); -} +library TimeUtils { + function timestamp() internal view returns (uint40) { + return timestamp(block.timestamp); + } -function timestamp(uint256 value) pure returns (uint40) { - return SafeCast.toUint40(value); -} + function timestamp(uint256 value) internal pure returns (uint40) { + return SafeCast.toUint40(value); + } -function duration(uint256 value) pure returns (uint32) { - return SafeCast.toUint32(value); + function duration(uint256 value) internal pure returns (uint32) { + return SafeCast.toUint32(value); + } } diff --git a/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol index bec38aa1..994465a4 100644 --- a/test/scenario/escrow.t.sol +++ b/test/scenario/escrow.t.sol @@ -1,17 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; - -import {IStEth, IWstETH, IWithdrawalQueue, WithdrawalRequestStatus} from "../utils/interfaces.sol"; +import {WithdrawalRequestStatus} from "../utils/interfaces.sol"; import { - Utils, Escrow, - BurnerVault, - IERC20, - ST_ETH, - WST_ETH, + Balances, + VetoerState, + LockedAssetsTotals, WITHDRAWAL_QUEUE, ScenarioTestBlueprint } from "../utils/scenario-test-blueprint.sol"; @@ -20,29 +16,27 @@ contract TestHelpers is ScenarioTestBlueprint { function rebase(int256 deltaBP) public { bytes32 CL_BALANCE_POSITION = 0xa66d35f054e68143c18f32c990ed5cb972bb68a68f500cd2dd3a16bbf3686483; // keccak256("lido.Lido.beaconBalance"); - uint256 totalSupply = IERC20(ST_ETH).totalSupply(); - uint256 clBalance = uint256(vm.load(ST_ETH, CL_BALANCE_POSITION)); + uint256 totalSupply = _ST_ETH.totalSupply(); + uint256 clBalance = uint256(vm.load(address(_ST_ETH), CL_BALANCE_POSITION)); int256 delta = (deltaBP * int256(totalSupply) / 10000); - vm.store(ST_ETH, CL_BALANCE_POSITION, bytes32(uint256(int256(clBalance) + delta))); + vm.store(address(_ST_ETH), CL_BALANCE_POSITION, bytes32(uint256(int256(clBalance) + delta))); assertEq( - uint256(int256(totalSupply) * deltaBP / 10000 + int256(totalSupply)), - IERC20(ST_ETH).totalSupply(), - "total supply" + uint256(int256(totalSupply) * deltaBP / 10000 + int256(totalSupply)), _ST_ETH.totalSupply(), "total supply" ); } function finalizeWQ() public { - uint256 lastRequestId = IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastRequestId(); + uint256 lastRequestId = _WITHDRAWAL_QUEUE.getLastRequestId(); finalizeWQ(lastRequestId); } function finalizeWQ(uint256 id) public { - uint256 finalizationShareRate = IStEth(ST_ETH).getPooledEthByShares(1e27) + 1e9; // TODO check finalization rate + uint256 finalizationShareRate = _ST_ETH.getPooledEthByShares(1e27) + 1e9; // TODO check finalization rate address lido = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; vm.prank(lido); - IWithdrawalQueue(WITHDRAWAL_QUEUE).finalize(id, finalizationShareRate); + _WITHDRAWAL_QUEUE.finalize(id, finalizationShareRate); bytes32 LOCKED_ETHER_AMOUNT_POSITION = 0x0e27eaa2e71c8572ab988fef0b54cd45bbd1740de1e22343fb6cda7536edc12f; // keccak256("lido.WithdrawalQueue.lockedEtherAmount"); @@ -52,141 +46,153 @@ contract TestHelpers is ScenarioTestBlueprint { contract EscrowHappyPath is TestHelpers { Escrow internal escrow; - BurnerVault internal burnerVault; - GovernanceState__mock internal govState; - address internal stEthHolder1; - address internal stEthHolder2; + uint256 internal immutable _RAGE_QUIT_EXTRA_TIMELOCK = 14 days; + uint256 internal immutable _RAGE_QUIT_WITHDRAWALS_TIMELOCK = 7 days; - function assertEq(Escrow.Balance memory a, Escrow.Balance memory b) internal { - assertApproxEqAbs(a.stEth, b.stEth, 2, "StEth balance missmatched"); - assertApproxEqAbs(a.wstEth, b.wstEth, 2, "WstEth balance missmatched"); - assertEq(a.wqRequestsBalance, b.wqRequestsBalance, "WQ requests balance missmatched"); - assertEq( - a.finalizedWqRequestsBalance, b.finalizedWqRequestsBalance, "Finalized WQ requests balance missmatched" - ); - } + address internal immutable _VETOER_1 = makeAddr("VETOER_1"); + address internal immutable _VETOER_2 = makeAddr("VETOER_2"); + + Balances internal _firstVetoerBalances; + Balances internal _secondVetoerBalances; function setUp() external { - Utils.selectFork(); - Utils.removeLidoStakingLimit(); + _selectFork(); + _deployDualGovernanceSetup( /* isEmergencyProtectionEnabled */ false); - _deployAdminExecutor(address(this)); - _deployConfigImpl(); - _deployConfigProxy(address(this)); - _deployEscrowMasterCopy(); + escrow = _getSignallingEscrow(); - Escrow escrowImpl; - escrowImpl = _escrowMasterCopy; - burnerVault = _burnerVault; + _setupStETHWhale(_VETOER_1); + vm.startPrank(_VETOER_1); + _ST_ETH.approve(address(_WST_ETH), type(uint256).max); + _ST_ETH.approve(address(escrow), type(uint256).max); + _ST_ETH.approve(address(_WITHDRAWAL_QUEUE), type(uint256).max); + _WST_ETH.approve(address(escrow), type(uint256).max); - escrow = - Escrow(payable(address(new TransparentUpgradeableProxy(address(escrowImpl), address(this), new bytes(0))))); + _WST_ETH.wrap(100_000 * 10 ** 18); + vm.stopPrank(); - govState = new GovernanceState__mock(); + _setupStETHWhale(_VETOER_2); + vm.startPrank(_VETOER_2); + _ST_ETH.approve(address(_WST_ETH), type(uint256).max); + _ST_ETH.approve(address(escrow), type(uint256).max); + _ST_ETH.approve(address(_WITHDRAWAL_QUEUE), type(uint256).max); + _WST_ETH.approve(address(escrow), type(uint256).max); - escrow.initialize(address(govState)); + _WST_ETH.wrap(100_000 * 10 ** 18); + vm.stopPrank(); - stEthHolder1 = makeAddr("steth_holder_1"); - Utils.setupStEthWhale(stEthHolder1); + _firstVetoerBalances = _getBalances(_VETOER_1); + _secondVetoerBalances = _getBalances(_VETOER_2); + } - vm.startPrank(stEthHolder1); - IERC20(ST_ETH).approve(WST_ETH, 1e30); + function test_lock_unlock() public { + uint256 firstVetoerStETHBalanceBefore = _ST_ETH.balanceOf(_VETOER_1); + uint256 firstVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_1); - IWstETH(WST_ETH).wrap(1e24); + uint256 secondVetoerStETHBalanceBefore = _ST_ETH.balanceOf(_VETOER_2); + uint256 secondVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_2); - IERC20(ST_ETH).approve(address(escrow), 1e30); - IERC20(WST_ETH).approve(address(escrow), 1e30); - IERC20(WST_ETH).approve(address(WITHDRAWAL_QUEUE), 1e30); - IERC20(ST_ETH).approve(address(WITHDRAWAL_QUEUE), 1e30); - IWithdrawalQueue(WITHDRAWAL_QUEUE).setApprovalForAll(address(escrow), true); - vm.stopPrank(); + _lockStETH(_VETOER_1, 10 ** 18); + _lockWstETH(_VETOER_1, 2 * 10 ** 18); - stEthHolder2 = makeAddr("steth_holder_2"); - Utils.setupStEthWhale(stEthHolder2); + _lockStETH(_VETOER_2, 3 * 10 ** 18); + _lockWstETH(_VETOER_2, 5 * 10 ** 18); - vm.startPrank(stEthHolder2); - IERC20(ST_ETH).approve(WST_ETH, 1e30); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); - IWstETH(WST_ETH).wrap(1e24); + _unlockStETH(_VETOER_1); + _unlockWstETH(_VETOER_1); + _unlockStETH(_VETOER_2); + _unlockWstETH(_VETOER_2); - IERC20(ST_ETH).approve(address(escrow), 1e30); - IERC20(WST_ETH).approve(address(escrow), 1e30); - IERC20(WST_ETH).approve(address(WITHDRAWAL_QUEUE), 1e30); - IERC20(ST_ETH).approve(address(WITHDRAWAL_QUEUE), 1e30); - IWithdrawalQueue(WITHDRAWAL_QUEUE).setApprovalForAll(address(escrow), true); - vm.stopPrank(); + assertEq(firstVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_1)); + assertApproxEqAbs(firstVetoerStETHBalanceBefore, _ST_ETH.balanceOf(_VETOER_1), 1); + + assertEq(secondVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_2)); + assertApproxEqAbs(secondVetoerStETHBalanceBefore, _ST_ETH.balanceOf(_VETOER_2), 1); } - function test_lock_unlock() public { - uint256 amountToLock = 1e18; - uint256 wstEthAmountToLock = IStEth(ST_ETH).getSharesByPooledEth(amountToLock); + function test_lock_unlock_w_rebase() public { + uint256 firstVetoerStETHAmount = 10 * 10 ** 18; + uint256 firstVetoerStETHShares = _ST_ETH.getSharesByPooledEth(firstVetoerStETHAmount); + uint256 firstVetoerWstETHAmount = 11 * 10 ** 18; - uint256 stEthBalanceBefore1 = IERC20(ST_ETH).balanceOf(stEthHolder1); - uint256 wstEthBalanceBefore1 = IERC20(WST_ETH).balanceOf(stEthHolder1); - uint256 stEthBalanceBefore2 = IERC20(ST_ETH).balanceOf(stEthHolder2); - uint256 wstEthBalanceBefore2 = IERC20(WST_ETH).balanceOf(stEthHolder2); + uint256 secondVetoerStETHAmount = 13 * 10 ** 18; + uint256 secondVetoerStETHShares = _ST_ETH.getSharesByPooledEth(secondVetoerStETHAmount); + uint256 secondVetoerWstETHAmount = 17 * 10 ** 18; - lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); - lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + _lockStETH(_VETOER_1, firstVetoerStETHAmount); + _lockWstETH(_VETOER_1, firstVetoerWstETHAmount); - unlockAssets(stEthHolder1, true, true, new uint256[](0)); - unlockAssets(stEthHolder2, true, true, new uint256[](0)); + _lockStETH(_VETOER_2, secondVetoerStETHAmount); + _lockWstETH(_VETOER_2, secondVetoerWstETHAmount); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(stEthHolder1), stEthBalanceBefore1, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(stEthHolder1), wstEthBalanceBefore1, 3); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(stEthHolder2), stEthBalanceBefore2, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(stEthHolder2), wstEthBalanceBefore2, 3); - } + rebase(100); - function test_lock_unlock_w_rebase() public { - uint256 amountToLock = 1e18; - uint256 wstEthAmountToLock = IStEth(ST_ETH).getSharesByPooledEth(amountToLock); + uint256 firstVetoerStETHSharesAfterRebase = _ST_ETH.sharesOf(_VETOER_1); + uint256 firstVetoerWstETHBalanceAfterRebase = _WST_ETH.balanceOf(_VETOER_1); - lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); - lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + uint256 secondVetoerStETHSharesAfterRebase = _ST_ETH.sharesOf(_VETOER_2); + uint256 secondVetoerWstETHBalanceAfterRebase = _WST_ETH.balanceOf(_VETOER_2); - rebase(100); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); - uint256 wstEthAmountToUnlock = IStEth(ST_ETH).getSharesByPooledEth(amountToLock); + _unlockStETH(_VETOER_1); + _unlockWstETH(_VETOER_1); - uint256 stEthBalanceBefore1 = IERC20(ST_ETH).balanceOf(stEthHolder1); - uint256 wstEthBalanceBefore1 = IERC20(WST_ETH).balanceOf(stEthHolder1); - uint256 stEthBalanceBefore2 = IERC20(ST_ETH).balanceOf(stEthHolder2); - uint256 wstEthBalanceBefore2 = IERC20(WST_ETH).balanceOf(stEthHolder2); + _unlockStETH(_VETOER_2); + _unlockWstETH(_VETOER_2); - unlockAssets(stEthHolder1, true, true, new uint256[](0)); - unlockAssets(stEthHolder2, true, true, new uint256[](0)); + assertApproxEqAbs( + _ST_ETH.getPooledEthByShares(firstVetoerStETHSharesAfterRebase + firstVetoerStETHShares), + _ST_ETH.balanceOf(_VETOER_1), + 1 + ); + assertEq(firstVetoerWstETHBalanceAfterRebase + firstVetoerWstETHAmount, _WST_ETH.balanceOf(_VETOER_1)); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(stEthHolder1), stEthBalanceBefore1 + amountToLock, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(stEthHolder1), wstEthBalanceBefore1 + wstEthAmountToUnlock, 3); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(stEthHolder2), stEthBalanceBefore2 + 2 * amountToLock, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(stEthHolder2), wstEthBalanceBefore2 + 2 * wstEthAmountToUnlock, 3); + assertApproxEqAbs( + _ST_ETH.getPooledEthByShares(secondVetoerStETHSharesAfterRebase + secondVetoerStETHShares), + _ST_ETH.balanceOf(_VETOER_2), + 1 + ); + assertEq(secondVetoerWstETHBalanceAfterRebase + secondVetoerWstETHAmount, _WST_ETH.balanceOf(_VETOER_2)); } function test_lock_unlock_w_negative_rebase() public { - int256 rebaseBP = -100; - uint256 amountToLock = 1e18; - uint256 wstEthAmountToLock = IStEth(ST_ETH).getSharesByPooledEth(amountToLock); + uint256 firstVetoerStETHAmount = 10 * 10 ** 18; + uint256 firstVetoerWstETHAmount = 11 * 10 ** 18; + + uint256 secondVetoerStETHAmount = 13 * 10 ** 18; + uint256 secondVetoerWstETHAmount = 17 * 10 ** 18; + + uint256 firstVetoerStETHSharesBefore = _ST_ETH.sharesOf(_VETOER_1); + uint256 firstVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_1); + + uint256 secondVetoerStETHSharesBefore = _ST_ETH.sharesOf(_VETOER_2); + uint256 secondVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_2); + + _lockStETH(_VETOER_1, firstVetoerStETHAmount); + _lockWstETH(_VETOER_1, firstVetoerWstETHAmount); + + _lockStETH(_VETOER_2, secondVetoerStETHAmount); + _lockWstETH(_VETOER_2, secondVetoerWstETHAmount); - uint256 stEthBalanceBefore1 = IERC20(ST_ETH).balanceOf(stEthHolder1); - uint256 wstEthBalanceBefore1 = IERC20(WST_ETH).balanceOf(stEthHolder1); - uint256 stEthBalanceBefore2 = IERC20(ST_ETH).balanceOf(stEthHolder2); - uint256 wstEthBalanceBefore2 = IERC20(WST_ETH).balanceOf(stEthHolder2); + rebase(-100); - lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); - lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); - rebase(rebaseBP); - escrow.burnRewards(); + _unlockStETH(_VETOER_1); + _unlockWstETH(_VETOER_1); - unlockAssets(stEthHolder1, true, true, new uint256[](0)); - unlockAssets(stEthHolder2, true, true, new uint256[](0)); + _unlockStETH(_VETOER_2); + _unlockWstETH(_VETOER_2); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(stEthHolder1), stEthBalanceBefore1 * 9900 / 10000, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(stEthHolder1), wstEthBalanceBefore1, 3); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(stEthHolder2), stEthBalanceBefore2 * 9900 / 10000, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(stEthHolder2), wstEthBalanceBefore2, 3); + assertApproxEqAbs(_ST_ETH.getPooledEthByShares(firstVetoerStETHSharesBefore), _ST_ETH.balanceOf(_VETOER_1), 1); + assertEq(firstVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_1)); + + assertApproxEqAbs(_ST_ETH.getPooledEthByShares(secondVetoerStETHSharesBefore), _ST_ETH.balanceOf(_VETOER_2), 1); + assertEq(secondVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_2)); } function test_lock_unlock_withdrawal_nfts() public { @@ -195,12 +201,14 @@ contract EscrowHappyPath is TestHelpers { amounts[i] = 1e18; } - vm.prank(stEthHolder1); - uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + vm.prank(_VETOER_1); + uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); + + _lockUnstETH(_VETOER_1, unstETHIds); - lockAssets(stEthHolder1, 0, 0, ids); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); - unlockAssets(stEthHolder1, false, false, ids); + _unlockUnstETH(_VETOER_1, unstETHIds); } function test_lock_withdrawal_nfts_reverts_on_finalized() public { @@ -209,70 +217,88 @@ contract EscrowHappyPath is TestHelpers { amounts[i] = 1e18; } - vm.prank(stEthHolder1); - uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + vm.prank(_VETOER_1); + uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); finalizeWQ(); - vm.prank(stEthHolder1); vm.expectRevert(); - escrow.lockWithdrawalNFT(ids); + this.externalLockUnstETH(_VETOER_1, unstETHIds); } function test_check_finalization() public { + uint256 totalSharesLocked = _ST_ETH.getSharesByPooledEth(2 * 1e18); + uint256 expectedSharesFinalized = _ST_ETH.getSharesByPooledEth(1 * 1e18); uint256[] memory amounts = new uint256[](2); for (uint256 i = 0; i < 2; ++i) { amounts[i] = 1e18; } - vm.prank(stEthHolder1); - uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + vm.prank(_VETOER_1); + uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); + + _lockUnstETH(_VETOER_1, unstETHIds); - lockAssets(stEthHolder1, 0, 0, ids); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); + assertEq(escrow.getLockedAssetsTotals().sharesFinalized, 0); - Escrow.Balance memory balance = escrow.balanceOf(stEthHolder1); - assertEq(balance.wqRequestsBalance, 2 * 1e18); - assertEq(balance.finalizedWqRequestsBalance, 0); + finalizeWQ(unstETHIds[0]); + uint256[] memory hints = + _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); + escrow.markUnstETHFinalized(unstETHIds, hints); - finalizeWQ(ids[0]); - escrow.checkForFinalization(ids); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); - balance = escrow.balanceOf(stEthHolder1); - assertEq(balance.wqRequestsBalance, 1e18); - assertEq(balance.finalizedWqRequestsBalance, 1e18); + assertApproxEqAbs(escrow.getLockedAssetsTotals().sharesFinalized, expectedSharesFinalized, 1); + uint256 ethAmountFinalized = _WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints)[0]; + assertApproxEqAbs(escrow.getLockedAssetsTotals().amountFinalized, ethAmountFinalized, 1); } - function test_get_signaling_state() public { + function test_get_rage_quit_support() public { uint256[] memory amounts = new uint256[](2); for (uint256 i = 0; i < 2; ++i) { amounts[i] = 1e18; } - uint256 totalSupply = IERC20(ST_ETH).totalSupply(); + uint256 totalSupply = _ST_ETH.totalSupply(); - vm.prank(stEthHolder1); - uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + vm.prank(_VETOER_1); + uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); + + uint256 amountToLock = 1e18; + uint256 sharesToLock = _ST_ETH.getSharesByPooledEth(amountToLock); - lockAssets(stEthHolder1, 1e18, IStEth(ST_ETH).getSharesByPooledEth(1e18), ids); + _lockStETH(_VETOER_1, amountToLock); + _lockWstETH(_VETOER_1, sharesToLock); + _lockUnstETH(_VETOER_1, unstETHIds); - Escrow.Balance memory balance = escrow.balanceOf(stEthHolder1); - assertEq(balance.wqRequestsBalance, 2 * 1e18); - assertEq(balance.finalizedWqRequestsBalance, 0); + VetoerState memory vetoerState = escrow.getVetoerState(_VETOER_1); + assertApproxEqAbs(vetoerState.stETHShares, sharesToLock, 1); + assertEq(vetoerState.wstETHShares, sharesToLock); + assertApproxEqAbs(vetoerState.unstETHShares, _ST_ETH.getSharesByPooledEth(2e18), 1); - (uint256 totalSupport, uint256 rageQuitSupport) = escrow.getSignallingState(); - assertEq(totalSupport, 4 * 1e18 * 1e18 / totalSupply); + uint256 rageQuitSupport = escrow.getRageQuitSupport(); assertEq(rageQuitSupport, 4 * 1e18 * 1e18 / totalSupply); - finalizeWQ(ids[0]); - escrow.checkForFinalization(ids); + finalizeWQ(unstETHIds[0]); + uint256[] memory hints = + _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); + escrow.markUnstETHFinalized(unstETHIds, hints); - balance = escrow.balanceOf(stEthHolder1); - assertEq(balance.wqRequestsBalance, 1e18); - assertEq(balance.finalizedWqRequestsBalance, 1e18); + LockedAssetsTotals memory totals = escrow.getLockedAssetsTotals(); - (totalSupport, rageQuitSupport) = escrow.getSignallingState(); - assertEq(totalSupport, 4 * 1e18 * 1e18 / totalSupply); - assertEq(rageQuitSupport, 3 * 1e18 * 1e18 / totalSupply); + assertApproxEqAbs(totals.sharesFinalized, sharesToLock, 1); + uint256 ethAmountFinalized = _WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints)[0]; + assertApproxEqAbs(totals.amountFinalized, ethAmountFinalized, 1); + + rageQuitSupport = escrow.getRageQuitSupport(); + assertEq( + rageQuitSupport, + 10 ** 18 * (_ST_ETH.getPooledEthByShares(3 * sharesToLock) + ethAmountFinalized) + / (_ST_ETH.totalSupply() + ethAmountFinalized) + ); } function test_rage_quit() public { @@ -282,66 +308,84 @@ contract EscrowHappyPath is TestHelpers { amounts[i] = requestAmount; } - vm.prank(stEthHolder1); - uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + vm.prank(_VETOER_1); + uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); - lockAssets(stEthHolder1, 20 * requestAmount, IStEth(ST_ETH).getSharesByPooledEth(20 * requestAmount), ids); + uint256 requestShares = _ST_ETH.getSharesByPooledEth(30 * requestAmount); + + _lockStETH(_VETOER_1, 20 * requestAmount); + _lockWstETH(_VETOER_1, requestShares); + _lockUnstETH(_VETOER_1, unstETHIds); rebase(100); vm.expectRevert(); - escrow.startRageQuit(); + escrow.startRageQuit(_RAGE_QUIT_EXTRA_TIMELOCK, _RAGE_QUIT_WITHDRAWALS_TIMELOCK); - vm.prank(address(govState)); - escrow.startRageQuit(); + vm.prank(address(_dualGovernance)); + escrow.startRageQuit(_RAGE_QUIT_EXTRA_TIMELOCK, _RAGE_QUIT_WITHDRAWALS_TIMELOCK); - assertEq(IWithdrawalQueue(WITHDRAWAL_QUEUE).balanceOf(address(escrow)), 10); + uint256 escrowStETHBalance = _ST_ETH.balanceOf(address(escrow)); + uint256 expectedWithdrawalBatchesCount = escrowStETHBalance / requestAmount + 1; + assertEq(_WITHDRAWAL_QUEUE.balanceOf(address(escrow)), 10); - escrow.requestNextWithdrawalsBatch(10); + escrow.requestWithdrawalsBatch(10); - assertEq(IWithdrawalQueue(WITHDRAWAL_QUEUE).balanceOf(address(escrow)), 20); + assertEq(_WITHDRAWAL_QUEUE.balanceOf(address(escrow)), 20); - escrow.requestNextWithdrawalsBatch(200); + escrow.requestWithdrawalsBatch(200); - assertEq(IWithdrawalQueue(WITHDRAWAL_QUEUE).balanceOf(address(escrow)), 50); + assertEq(_WITHDRAWAL_QUEUE.balanceOf(address(escrow)), 10 + expectedWithdrawalBatchesCount); assertEq(escrow.isRageQuitFinalized(), false); vm.deal(WITHDRAWAL_QUEUE, 1000 * requestAmount); finalizeWQ(); - uint256[] memory hints = IWithdrawalQueue(WITHDRAWAL_QUEUE).findCheckpointHints( - ids, - IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - 2, - IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - ); + (uint256 offset, uint256 total, uint256[] memory unstETHIdsToClaim) = + escrow.getNextWithdrawalBatches(expectedWithdrawalBatchesCount); + assertEq(total, expectedWithdrawalBatchesCount); - escrow.claimWithdrawalRequests(ids, hints); + WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIdsToClaim); - assertEq(escrow.isRageQuitFinalized(), true); + for (uint256 i = 0; i < statuses.length; ++i) { + assertTrue(statuses[i].isFinalized); + assertFalse(statuses[i].isClaimed); + } - vm.expectRevert(); - vm.prank(stEthHolder1); - escrow.claimETH(); + uint256[] memory hints = + _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIdsToClaim, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); - uint256[] memory escrowRequestIds = new uint256[](40); - for (uint256 i = 0; i < 40; ++i) { - escrowRequestIds[i] = ids[9] + i + 1; - } + escrow.claimNextWithdrawalsBatch(offset, hints); - uint256[] memory escrowRequestHints = IWithdrawalQueue(WITHDRAWAL_QUEUE).findCheckpointHints( - escrowRequestIds, - IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - 2, - IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - ); + assertEq(escrow.isRageQuitFinalized(), false); + + // --- + // unstETH holders claim their withdrawal requests + // --- + { + uint256[] memory hints = + _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); + escrow.claimWithdrawalRequests(unstETHIds, hints); + + // but it can't be withdrawn before withdrawal timelock has passed + vm.expectRevert(); + vm.prank(_VETOER_1); + escrow.withdrawUnstETHAsETH(unstETHIds); + } vm.expectRevert(); - vm.prank(stEthHolder1); - escrow.claimETH(); + vm.prank(_VETOER_1); + escrow.withdrawStETHAsETH(); - escrow.claimNextETHBatch(escrowRequestIds, escrowRequestHints); + _wait(_RAGE_QUIT_EXTRA_TIMELOCK + 1); + assertEq(escrow.isRageQuitFinalized(), true); + + _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK + 1); - vm.prank(stEthHolder1); - escrow.claimETH(); + vm.startPrank(_VETOER_1); + escrow.withdrawStETHAsETH(); + escrow.withdrawUnstETHAsETH(unstETHIds); + vm.stopPrank(); } function test_wq_requests_only_happy_path() public { @@ -352,156 +396,39 @@ contract EscrowHappyPath is TestHelpers { amounts[i] = requestAmount; } - vm.prank(stEthHolder1); - uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + vm.prank(_VETOER_1); + uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); - lockAssets(stEthHolder1, 0, 0, ids); + _lockUnstETH(_VETOER_1, unstETHIds); - vm.prank(address(govState)); - escrow.startRageQuit(); + vm.prank(address(_dualGovernance)); + escrow.startRageQuit(_RAGE_QUIT_EXTRA_TIMELOCK, _RAGE_QUIT_WITHDRAWALS_TIMELOCK); vm.deal(WITHDRAWAL_QUEUE, 100 * requestAmount); finalizeWQ(); - uint256[] memory hints = IWithdrawalQueue(WITHDRAWAL_QUEUE).findCheckpointHints( - ids, - IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - 2, - IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - ); - - escrow.claimWithdrawalRequests(ids, hints); - - assertEq(escrow.isRageQuitFinalized(), true); - - vm.prank(stEthHolder1); - escrow.claimETH(); - } - - function lockAssets( - address owner, - uint256 stEthAmountToLock, - uint256 wstEthAmountToLock, - uint256[] memory wqRequestIds - ) public { - vm.startPrank(owner); - - Escrow.Balance memory balanceBefore = escrow.balanceOf(owner); - uint256 stEthBalanceBefore = IERC20(ST_ETH).balanceOf(owner); - uint256 wstEthBalanceBefore = IERC20(WST_ETH).balanceOf(owner); - if (stEthAmountToLock > 0) { - escrow.lockStEth(stEthAmountToLock); - } - if (wstEthAmountToLock > 0) { - escrow.lockWstEth(wstEthAmountToLock); - } - - uint256 wqRequestsAmount = 0; - if (wqRequestIds.length > 0) { - WithdrawalRequestStatus[] memory statuses = - IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(wqRequestIds); - - for (uint256 i = 0; i < wqRequestIds.length; ++i) { - assertEq(statuses[i].isFinalized, false); - wqRequestsAmount += statuses[i].amountOfStETH; - } - - escrow.lockWithdrawalNFT(wqRequestIds); - } - - assertEq( - escrow.balanceOf(owner), - Escrow.Balance( - balanceBefore.stEth + stEthAmountToLock, - balanceBefore.wstEth + wstEthAmountToLock, - balanceBefore.wqRequestsBalance + wqRequestsAmount, - balanceBefore.finalizedWqRequestsBalance, - 0, - new uint256[](0) - ) - ); + escrow.claimNextWithdrawalsBatch(0, new uint256[](0)); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(owner), stEthBalanceBefore - stEthAmountToLock, 3); - assertEq(IERC20(WST_ETH).balanceOf(owner), wstEthBalanceBefore - wstEthAmountToLock); - - vm.stopPrank(); - } - - function unlockAssets(address owner, bool unlockStEth, bool unlockWstEth, uint256[] memory wqRequestIds) public { - unlockAssets(owner, unlockStEth, unlockWstEth, wqRequestIds, 0); - } - - function unlockAssets( - address owner, - bool unlockStEth, - bool unlockWstEth, - uint256[] memory wqRequestIds, - int256 rebaseBP - ) public { - vm.startPrank(owner); - - Escrow.Balance memory balanceBefore = escrow.balanceOf(owner); - uint256 stEthBalanceBefore = IERC20(ST_ETH).balanceOf(owner); - uint256 wstEthBalanceBefore = IERC20(WST_ETH).balanceOf(owner); - - if (unlockStEth) { - escrow.unlockStEth(); - } - if (unlockWstEth) { - escrow.unlockWstEth(); - } - - uint256 wqRequestsAmount = 0; - if (wqRequestIds.length > 0) { - WithdrawalRequestStatus[] memory statuses = - IWithdrawalQueue(WITHDRAWAL_QUEUE).getWithdrawalStatus(wqRequestIds); + assertEq(escrow.isRageQuitFinalized(), false); - for (uint256 i = 0; i < wqRequestIds.length; ++i) { - assertEq(statuses[i].owner, address(escrow)); - wqRequestsAmount += statuses[i].amountOfStETH; - } + uint256[] memory hints = + _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); - escrow.unlockWithdrawalNFT(wqRequestIds); - } + escrow.claimWithdrawalRequests(unstETHIds, hints); - assertEq( - escrow.balanceOf(owner), - Escrow.Balance( - unlockStEth ? 0 : balanceBefore.stEth, - unlockWstEth ? 0 : balanceBefore.wstEth, - balanceBefore.wqRequestsBalance - wqRequestsAmount, - balanceBefore.finalizedWqRequestsBalance, - 0, - new uint256[](0) - ) - ); + assertEq(escrow.isRageQuitFinalized(), false); - uint256 expectedStEthAmount = uint256(int256(balanceBefore.stEth) * (10000 + rebaseBP) / 10000); - uint256 expectedWstEthAmount = uint256(int256(balanceBefore.wstEth) * (10000 + rebaseBP) / 10000); + _wait(_RAGE_QUIT_EXTRA_TIMELOCK + 1); + assertEq(escrow.isRageQuitFinalized(), true); - assertApproxEqAbs(IERC20(ST_ETH).balanceOf(owner), stEthBalanceBefore + expectedStEthAmount, 3); - assertApproxEqAbs(IERC20(WST_ETH).balanceOf(owner), wstEthBalanceBefore + expectedWstEthAmount, 3); + _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK + 1); + vm.startPrank(_VETOER_1); + escrow.withdrawUnstETHAsETH(unstETHIds); vm.stopPrank(); } -} - -contract GovernanceState__mock { - enum State { - Normal, - VetoSignalling, - VetoSignallingDeactivation, - VetoCooldown, - RageQuitAccumulation, - RageQuit - } - - State public state = State.Normal; - - function setState(State _nextState) public { - state = _nextState; - } - function activateNextState() public returns (State) { - return state; + function externalLockUnstETH(address vetoer, uint256[] memory unstETHIds) external { + _lockUnstETH(vetoer, unstETHIds); } } diff --git a/test/scenario/gate-seal-breaker.t.sol b/test/scenario/gate-seal-breaker.t.sol index ad6f5fbf..1b427efa 100644 --- a/test/scenario/gate-seal-breaker.t.sol +++ b/test/scenario/gate-seal-breaker.t.sol @@ -42,7 +42,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { assertFalse(_WITHDRAWAL_QUEUE.isPaused()); _assertNormalState(); - _lockStEth(_VETOER, percents(10, 0)); + _lockStETH(_VETOER, percents("10.0")); _assertVetoSignalingState(); // sealing committee seals Withdrawal Queue @@ -102,7 +102,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { // wait some time, before dual governance enters veto signaling state _wait(_MIN_SEAL_DURATION / 2); - _lockStEth(_VETOER, percents(10, 0)); + _lockStETH(_VETOER, percents("10.0")); _assertVetoSignalingState(); // seal can't be released before the min sealing duration has passed @@ -125,7 +125,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { _assertVetoCooldownState(); // the stETH whale takes his funds back from Escrow - _unlockStEth(_VETOER); + _unlockStETH(_VETOER); _wait(_dualGovernance.CONFIG().SIGNALLING_COOLDOWN_DURATION() + 1); _activateNextState(); diff --git a/test/scenario/gov-state-transitions.t.sol b/test/scenario/gov-state-transitions.t.sol index 4ed30400..d3f6d079 100644 --- a/test/scenario/gov-state-transitions.t.sol +++ b/test/scenario/gov-state-transitions.t.sol @@ -1,173 +1,110 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import { - Utils, - Escrow, - IERC20, - ST_ETH, - console, - ExecutorCall, - DualGovernance, - IDangerousContract, - ExecutorCallHelpers, - GovernanceState, - ScenarioTestBlueprint -} from "../utils/scenario-test-blueprint.sol"; +import {ScenarioTestBlueprint, percents} from "../utils/scenario-test-blueprint.sol"; contract GovernanceStateTransitions is ScenarioTestBlueprint { - address internal stEthWhale; - DualGovernance internal dualGov; + address internal immutable _VETOER = makeAddr("VETOER"); function setUp() external { _selectFork(); - _deployTarget(); _deployDualGovernanceSetup( /* isEmergencyProtectionEnabled */ false); - dualGov = _dualGovernance; - - Utils.removeLidoStakingLimit(); - stEthWhale = makeAddr("steth_whale"); - Utils.setupStEthWhale(stEthWhale); } function test_signalling_state_min_duration() public { - assertEq(dualGov.currentState(), GovernanceState.Normal); - - updateVetoSupportInPercent(3 * 10 ** 16); - updateVetoSupport(1); - - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _assertNormalState(); - uint256 signallingDuration = _config.SIGNALLING_MIN_DURATION(); + _lockStETH(_VETOER, percents("3.00")); + _assertVetoSignalingState(); - vm.warp(block.timestamp + (signallingDuration / 2)); - updateVetoSupport(1); + _wait(_config.SIGNALLING_MIN_DURATION() / 2); - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _activateNextState(); + _assertVetoSignalingState(); - vm.warp(block.timestamp + signallingDuration / 2); - updateVetoSupport(1); + _wait(_config.SIGNALLING_MIN_DURATION() / 2 + 1); - assertEq(dualGov.currentState(), GovernanceState.VetoSignallingDeactivation); + _activateNextState(); + _assertVetoSignalingDeactivationState(); } function test_signalling_state_max_duration() public { - assertEq(dualGov.currentState(), GovernanceState.Normal); + _assertNormalState(); - updateVetoSupportInPercent(15 * 10 ** 16); + _lockStETH(_VETOER, percents("15.0")); - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _assertVetoSignalingState(); - uint256 signallingDuration = _config.SIGNALLING_MAX_DURATION(); + _wait(_config.SIGNALLING_MAX_DURATION() / 2); + _activateNextState(); - vm.warp(block.timestamp + (signallingDuration / 2)); - dualGov.activateNextState(); + _assertVetoSignalingState(); - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _wait(_config.SIGNALLING_MAX_DURATION() / 2 + 1); + _activateNextState(); - vm.warp(block.timestamp + signallingDuration / 2 + 1000); - dualGov.activateNextState(); - - assertEq(dualGov.currentState(), GovernanceState.RageQuit); + _assertRageQuitState(); } function test_signalling_to_normal() public { - assertEq(dualGov.currentState(), GovernanceState.Normal); - - updateVetoSupportInPercent(3 * 10 ** 16 + 1); - - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _assertNormalState(); - uint256 signallingDuration = _config.SIGNALLING_MIN_DURATION(); + _lockStETH(_VETOER, percents("3.00")); - vm.warp(block.timestamp + signallingDuration); - dualGov.activateNextState(); + _assertVetoSignalingState(); - assertEq(dualGov.currentState(), GovernanceState.VetoSignallingDeactivation); + _wait(_config.SIGNALLING_MIN_DURATION()); + _activateNextState(); - uint256 signallingDeactivationDuration = _config.SIGNALLING_DEACTIVATION_DURATION(); + _assertVetoSignalingDeactivationState(); - vm.warp(block.timestamp + signallingDeactivationDuration); - dualGov.activateNextState(); + _wait(_config.SIGNALLING_DEACTIVATION_DURATION()); + _activateNextState(); - assertEq(dualGov.currentState(), GovernanceState.VetoCooldown); + _assertVetoCooldownState(); - uint256 signallingCooldownDuration = _config.SIGNALLING_COOLDOWN_DURATION(); - - Escrow signallingEscrow = Escrow(payable(dualGov.signallingEscrow())); - vm.prank(stEthWhale); - signallingEscrow.unlockStEth(); + vm.startPrank(_VETOER); + _getSignallingEscrow().unlockStETH(); + vm.stopPrank(); - vm.warp(block.timestamp + signallingCooldownDuration); - dualGov.activateNextState(); + _wait(_config.SIGNALLING_COOLDOWN_DURATION()); + _activateNextState(); - assertEq(dualGov.currentState(), GovernanceState.Normal); + _assertNormalState(); } function test_signalling_non_stop() public { - assertEq(dualGov.currentState(), GovernanceState.Normal); - - updateVetoSupportInPercent(3 * 10 ** 16 + 1); - - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); - - uint256 signallingDuration = _config.SIGNALLING_MIN_DURATION(); + _assertNormalState(); - vm.warp(block.timestamp + signallingDuration); - dualGov.activateNextState(); + _lockStETH(_VETOER, percents("3.00")); - assertEq(dualGov.currentState(), GovernanceState.VetoSignallingDeactivation); + _assertVetoSignalingState(); - uint256 signallingDeactivationDuration = _config.SIGNALLING_DEACTIVATION_DURATION(); + _wait(_config.SIGNALLING_MIN_DURATION()); + _activateNextState(); - vm.warp(block.timestamp + signallingDeactivationDuration); - dualGov.activateNextState(); + _assertVetoSignalingDeactivationState(); - assertEq(dualGov.currentState(), GovernanceState.VetoCooldown); + _wait(_config.SIGNALLING_DEACTIVATION_DURATION()); + _activateNextState(); - uint256 signallingCooldownDuration = _config.SIGNALLING_COOLDOWN_DURATION(); + _assertVetoCooldownState(); - vm.warp(block.timestamp + signallingCooldownDuration); - dualGov.activateNextState(); + _wait(_config.SIGNALLING_COOLDOWN_DURATION()); + _activateNextState(); - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _assertVetoSignalingState(); } function test_signalling_to_rage_quit() public { - assertEq(dualGov.currentState(), GovernanceState.Normal); + _assertNormalState(); - updateVetoSupportInPercent(15 * 10 ** 16); + _lockStETH(_VETOER, percents("15.00")); + _assertVetoSignalingState(); - assertEq(dualGov.currentState(), GovernanceState.VetoSignalling); + _wait(_config.SIGNALLING_MAX_DURATION()); + _activateNextState(); - uint256 signallingDuration = _config.SIGNALLING_MAX_DURATION(); - - vm.warp(block.timestamp + signallingDuration); - dualGov.activateNextState(); - - assertEq(dualGov.currentState(), GovernanceState.RageQuit); - } - - function updateVetoSupportInPercent(uint256 supportInPercent) internal { - Escrow signallingEscrow = Escrow(payable(dualGov.signallingEscrow())); - uint256 newVetoSupport = (supportInPercent * IERC20(ST_ETH).totalSupply()) / 10 ** 18; - - vm.prank(stEthWhale); - // signallingEscrow.unlockStEth(); - - updateVetoSupport(newVetoSupport); - vm.stopPrank(); - - (uint256 totalSupport, uint256 rageQuitSupport) = signallingEscrow.getSignallingState(); - // solhint-disable-next-line - console.log("veto totalSupport %d, rageQuitSupport %d", totalSupport, rageQuitSupport); - } - - function updateVetoSupport(uint256 amount) internal { - Escrow signallingEscrow = Escrow(payable(dualGov.signallingEscrow())); - vm.startPrank(stEthWhale); - IERC20(ST_ETH).approve(address(signallingEscrow), amount); - signallingEscrow.lockStEth(amount); - vm.stopPrank(); + _assertRageQuitState(); } } diff --git a/test/scenario/last-moment-malicious-proposal.t.sol b/test/scenario/last-moment-malicious-proposal.t.sol index 45713387..630aae83 100644 --- a/test/scenario/last-moment-malicious-proposal.t.sol +++ b/test/scenario/last-moment-malicious-proposal.t.sol @@ -39,7 +39,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { // --- address maliciousActor = makeAddr("MALICIOUS_ACTOR"); { - _lockStEth(maliciousActor, percents(12, 0)); + _lockStETH(maliciousActor, percents("12.0")); _assertVetoSignalingState(); _logVetoSignallingState(); @@ -72,7 +72,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { // ACT 4. MALICIOUS ACTOR UNLOCK FUNDS FROM ESCROW // --- { - _unlockStEth(maliciousActor); + _unlockStETH(maliciousActor); _logVetoSignallingDeactivationState(); _assertVetoSignalingDeactivationState(); } @@ -82,7 +82,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { // --- address stEthWhale = makeAddr("STETH_WHALE"); { - _lockStEth(stEthWhale, percents(10, 0)); + _lockStETH(stEthWhale, percents("10.0")); _logVetoSignallingDeactivationState(); _assertVetoSignalingDeactivationState(); } @@ -104,7 +104,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { vm.warp(block.timestamp + _config.SIGNALLING_MIN_PROPOSAL_REVIEW_DURATION() / 2); // stEth holders reach the rage quit threshold - _lockStEth(maliciousActor, percents(10, 0)); + _lockStETH(maliciousActor, percents("10.0")); // the dual governance immediately transfers to the Rage Quit state _assertRageQuitState(); @@ -137,7 +137,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { { vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2); - _lockStEth(maliciousActor, percents(12, 0)); + _lockStETH(maliciousActor, percents("12.0")); _assertVetoSignalingState(); vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2 + 1); diff --git a/test/utils/interfaces.sol b/test/utils/interfaces.sol index 2e9cbb2f..2d3c56f3 100644 --- a/test/utils/interfaces.sol +++ b/test/utils/interfaces.sol @@ -47,6 +47,7 @@ interface IStEth is IERC20 { function STAKING_CONTROL_ROLE() external view returns (bytes32); function submit(address referral) external payable returns (uint256); function removeStakingLimit() external; + function sharesOf(address account) external view returns (uint256); function getSharesByPooledEth(uint256 ethAmount) external view returns (uint256); function getPooledEthByShares(uint256 sharesAmount) external view returns (uint256); function getStakeLimitFullInfo() @@ -61,6 +62,7 @@ interface IStEth is IERC20 { uint256 prevStakeLimit, uint256 prevStakeBlockNumber ); + function getTotalShares() external view returns (uint256); } interface IWstETH is IERC20 { @@ -72,6 +74,15 @@ interface IWithdrawalQueue is IERC721 { function PAUSE_ROLE() external pure returns (bytes32); function RESUME_ROLE() external pure returns (bytes32); + /// @notice Returns amount of ether available for claim for each provided request id + /// @param _requestIds array of request ids + /// @param _hints checkpoint hints. can be found with `findCheckpointHints(_requestIds, 1, getLastCheckpointIndex())` + /// @return claimableEthValues amount of claimable ether for each request, amount is equal to 0 if request + /// is not finalized or already claimed + function getClaimableEther( + uint256[] calldata _requestIds, + uint256[] calldata _hints + ) external view returns (uint256[] memory claimableEthValues); function getWithdrawalStatus(uint256[] calldata _requestIds) external view diff --git a/test/utils/percents.sol b/test/utils/percents.sol new file mode 100644 index 00000000..0b5da1b0 --- /dev/null +++ b/test/utils/percents.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +error InvalidPercentsString(string value); + +struct Percents { + uint256 value; + uint256 precision; +} + +uint256 constant PRECISION = 16; + +function percents(string memory value) pure returns (Percents memory result) { + result = percents(value, PRECISION); +} + +function percents(string memory value, uint256 precision) pure returns (Percents memory result) { + uint256 integerPart; + uint256 fractionalPart; + uint256 fractionalPartLength; + + bytes memory bvalue = bytes(value); + uint256 length = bytes(value).length; + bytes1 dot = bytes1("."); + + bool isFractionalPart = false; + for (uint256 i = 0; i < length; ++i) { + if (bytes1(bvalue[i]) == dot) { + if (isFractionalPart) { + revert InvalidPercentsString(value); + } + isFractionalPart = true; + } else if (uint8(bvalue[i]) >= 48 && uint8(bvalue[i]) <= 57) { + if (isFractionalPart) { + fractionalPartLength += 1; + fractionalPart = 10 * fractionalPart + (uint8(bvalue[i]) - 48); + } else { + integerPart = 10 * integerPart + (uint8(bvalue[i]) - 48); + } + } else { + revert InvalidPercentsString(value); + } + } + result.precision = precision; + result.value = 10 ** precision * integerPart + 10 ** (precision - fractionalPartLength) * fractionalPart; +} diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index 03187ab9..d9119dda 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -9,8 +9,7 @@ import { ProxyAdmin } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {Escrow} from "contracts/Escrow.sol"; -import {BurnerVault} from "contracts/BurnerVault.sol"; +import {Escrow, VetoerState, LockedAssetsTotals} from "contracts/Escrow.sol"; import {IConfiguration, Configuration} from "contracts/Configuration.sol"; import {OwnableExecutor} from "contracts/OwnableExecutor.sol"; @@ -26,11 +25,19 @@ import {DualGovernance, GovernanceState} from "contracts/DualGovernance.sol"; import {Proposal, Status as ProposalStatus} from "contracts/libraries/Proposals.sol"; -import {IERC20, IStEth, IWstETH, IWithdrawalQueue} from "../utils/interfaces.sol"; +import {Percents, percents} from "../utils/percents.sol"; +import {IERC20, IStEth, IWstETH, IWithdrawalQueue, WithdrawalRequestStatus} from "../utils/interfaces.sol"; import {ExecutorCallHelpers} from "../utils/executor-calls.sol"; import {Utils, TargetMock, console} from "../utils/utils.sol"; -import {DAO_VOTING, ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, BURNER} from "../utils/mainnet-addresses.sol"; +import {DAO_VOTING, ST_ETH, WST_ETH, WITHDRAWAL_QUEUE} from "../utils/mainnet-addresses.sol"; + +struct Balances { + uint256 stETHAmount; + uint256 stETHShares; + uint256 wstETHAmount; + uint256 wstETHShares; +} uint256 constant PERCENTS_PRECISION = 16; @@ -40,11 +47,6 @@ function countDigits(uint256 number) pure returns (uint256 digitsCount) { } while (number / 10 != 0); } -function percents(uint256 integerPart, uint256 fractionalPart) pure returns (uint256) { - return integerPart * 10 ** PERCENTS_PRECISION - + fractionalPart * 10 ** (PERCENTS_PRECISION - countDigits(fractionalPart)); -} - interface IDangerousContract { function doRegularStaff(uint256 magic) external; function doRugPool() external; @@ -76,7 +78,6 @@ contract ScenarioTestBlueprint is Test { TransparentUpgradeableProxy internal _configProxy; Escrow internal _escrowMasterCopy; - BurnerVault internal _burnerVault; OwnableExecutor internal _adminExecutor; @@ -112,26 +113,126 @@ contract ScenarioTestBlueprint is Test { Utils.selectFork(); } + // --- + // Balances Manipulation + // --- + + function _setupStETHWhale(address vetoer) internal { + Utils.removeLidoStakingLimit(); + Utils.setupStETHWhale(vetoer, percents("10.0").value); + } + + function _setupStETHWhale(address vetoer, Percents memory vetoPowerInPercents) internal { + Utils.removeLidoStakingLimit(); + Utils.setupStETHWhale(vetoer, vetoPowerInPercents.value); + } + + function _getBalances(address vetoer) internal view returns (Balances memory balances) { + uint256 stETHAmount = _ST_ETH.balanceOf(vetoer); + uint256 wstETHShares = _WST_ETH.balanceOf(vetoer); + balances = Balances({ + stETHAmount: stETHAmount, + stETHShares: _ST_ETH.getSharesByPooledEth(stETHAmount), + wstETHAmount: _ST_ETH.getPooledEthByShares(wstETHShares), + wstETHShares: wstETHShares + }); + } + // --- // Escrow Manipulation // --- - function _lockStEth(address vetoer, uint256 vetoPowerInPercents) internal { + function _lockStETH(address vetoer, Percents memory vetoPowerInPercents) internal { Utils.removeLidoStakingLimit(); - Utils.setupStEthWhale(vetoer, vetoPowerInPercents); - uint256 vetoerBalance = IERC20(ST_ETH).balanceOf(vetoer); + Utils.setupStETHWhale(vetoer, vetoPowerInPercents.value); + _lockStETH(vetoer, IERC20(ST_ETH).balanceOf(vetoer)); + } + + function _lockStETH(address vetoer, uint256 amount) internal { + Escrow escrow = _getSignallingEscrow(); + vm.startPrank(vetoer); + if (_ST_ETH.allowance(vetoer, address(escrow)) < amount) { + _ST_ETH.approve(address(escrow), amount); + } + escrow.lockStETH(amount); + vm.stopPrank(); + } + function _unlockStETH(address vetoer) internal { vm.startPrank(vetoer); - IERC20(ST_ETH).approve(address(_getSignallingEscrow()), vetoerBalance); - _getSignallingEscrow().lockStEth(vetoerBalance); + _getSignallingEscrow().unlockStETH(); vm.stopPrank(); } - function _unlockStEth(address vetoer) internal { + function _lockWstETH(address vetoer, uint256 amount) internal { + Escrow escrow = _getSignallingEscrow(); vm.startPrank(vetoer); - _getSignallingEscrow().unlockStEth(); + if (_WST_ETH.allowance(vetoer, address(escrow)) < amount) { + _WST_ETH.approve(address(escrow), amount); + } + escrow.lockWstETH(amount); vm.stopPrank(); } + function _unlockWstETH(address vetoer) internal { + Escrow escrow = _getSignallingEscrow(); + uint256 wstETHBalanceBefore = _WST_ETH.balanceOf(vetoer); + uint256 vetoerWstETHSharesBefore = escrow.getVetoerState(vetoer).wstETHShares; + + vm.startPrank(vetoer); + uint256 wstETHUnlocked = escrow.unlockWstETH(); + vm.stopPrank(); + + assertEq(wstETHUnlocked, vetoerWstETHSharesBefore); + assertEq(_WST_ETH.balanceOf(vetoer), wstETHBalanceBefore + vetoerWstETHSharesBefore); + } + + function _lockUnstETH(address vetoer, uint256[] memory unstETHIds) internal { + Escrow escrow = _getSignallingEscrow(); + uint256 vetoerUnstETHSharesBefore = escrow.getVetoerState(vetoer).unstETHShares; + uint256 totalSharesBefore = escrow.getLockedAssetsTotals().shares; + + uint256 unstETHTotalSharesLocked = 0; + WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + for (uint256 i = 0; i < unstETHIds.length; ++i) { + unstETHTotalSharesLocked += statuses[i].amountOfShares; + } + + vm.startPrank(vetoer); + _WITHDRAWAL_QUEUE.setApprovalForAll(address(escrow), true); + escrow.lockUnstETH(unstETHIds); + _WITHDRAWAL_QUEUE.setApprovalForAll(address(escrow), false); + vm.stopPrank(); + + for (uint256 i = 0; i < unstETHIds.length; ++i) { + assertEq(_WITHDRAWAL_QUEUE.ownerOf(unstETHIds[i]), address(escrow)); + } + + assertEq(escrow.getVetoerState(vetoer).unstETHShares, vetoerUnstETHSharesBefore + unstETHTotalSharesLocked); + assertEq(escrow.getLockedAssetsTotals().shares, totalSharesBefore + unstETHTotalSharesLocked); + } + + function _unlockUnstETH(address vetoer, uint256[] memory unstETHIds) internal { + Escrow escrow = _getSignallingEscrow(); + uint256 vetoerUnstETHSharesBefore = escrow.getVetoerState(vetoer).unstETHShares; + uint256 totalSharesBefore = escrow.getLockedAssetsTotals().shares; + + uint256 unstETHTotalSharesLocked = 0; + WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + for (uint256 i = 0; i < unstETHIds.length; ++i) { + unstETHTotalSharesLocked += statuses[i].amountOfShares; + } + + vm.prank(vetoer); + escrow.unlockUnstETH(unstETHIds); + + for (uint256 i = 0; i < unstETHIds.length; ++i) { + assertEq(_WITHDRAWAL_QUEUE.ownerOf(unstETHIds[i]), vetoer); + } + + assertEq(escrow.getVetoerState(vetoer).unstETHShares, vetoerUnstETHSharesBefore - unstETHTotalSharesLocked); + assertEq(escrow.getLockedAssetsTotals().shares, totalSharesBefore - unstETHTotalSharesLocked); + } + // --- // Dual Governance State Manipulation // --- @@ -403,8 +504,7 @@ contract ScenarioTestBlueprint is Test { } function _deployEscrowMasterCopy() internal { - _burnerVault = new BurnerVault(BURNER, ST_ETH, WST_ETH); - _escrowMasterCopy = new Escrow(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, address(_burnerVault)); + _escrowMasterCopy = new Escrow(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, address(_config)); } function _finishTimelockSetup(address governance, bool isEmergencyProtectionEnabled) internal { @@ -492,4 +592,13 @@ contract ScenarioTestBlueprint is Test { function assertEq(GovernanceState a, GovernanceState b) internal { assertEq(uint256(a), uint256(b)); } + + function assertEq(Balances memory b1, Balances memory b2, uint256 stETHSharesEpsilon) internal { + assertEq(b1.wstETHShares, b2.wstETHShares); + assertEq(b1.wstETHAmount, b2.wstETHAmount); + + uint256 stETHAmountEpsilon = _ST_ETH.getPooledEthByShares(stETHSharesEpsilon); + assertApproxEqAbs(b1.stETHShares, b2.stETHShares, stETHSharesEpsilon); + assertApproxEqAbs(b1.stETHAmount, b2.stETHAmount, stETHAmountEpsilon); + } } diff --git a/test/utils/utils.sol b/test/utils/utils.sol index e8ce5d55..15c23c60 100644 --- a/test/utils/utils.sol +++ b/test/utils/utils.sol @@ -87,14 +87,17 @@ library Utils { vm.warp(block.timestamp + 15); } - function setupStEthWhale(address addr) internal { + function setupStETHWhale(address addr) internal { // 15% of total stETH supply - setupStEthWhale(addr, 30 * 10 ** 16); + setupStETHWhale(addr, 30 * 10 ** 16); } - function setupStEthWhale(address addr, uint256 totalSupplyPercentage) internal { + function setupStETHWhale(address addr, uint256 totalSupplyPercentage) internal { + uint256 ST_ETH_TRANSFERS_SHARE_LOST_COMPENSATION = 8; // TODO: evaluate min enough value // bal / (totalSupply + bal) = percentage => bal = totalSupply * percentage / (1 - percentage) - uint256 ethBalance = IERC20(ST_ETH).totalSupply() * totalSupplyPercentage / (10 ** 18 - totalSupplyPercentage); + uint256 shares = IStEth(ST_ETH).getTotalShares() * totalSupplyPercentage / (10 ** 18 - totalSupplyPercentage); + // to compensate StETH wei lost on submit/transfers, generate slightly larger eth amount + uint256 ethBalance = IStEth(ST_ETH).getPooledEthByShares(shares + ST_ETH_TRANSFERS_SHARE_LOST_COMPENSATION); // solhint-disable-next-line console.log("setting ETH balance of address %x to %d ETH", addr, ethBalance / 10 ** 18); vm.deal(addr, ethBalance);