From 70377ddd06de91e568395984c23faaaaa90f4db5 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Sat, 23 Mar 2024 04:07:39 +0400 Subject: [PATCH 1/7] The first draft of the Escrow with changed mechanics --- .../EscrowWithChangedMechanics_draft.sol | 429 ++++++++++++++++++ contracts/interfaces/IStETH.sol | 18 + contracts/interfaces/IWithdrawalQueue.sol | 48 ++ contracts/interfaces/IWstETH.sol | 10 + 4 files changed, 505 insertions(+) create mode 100644 contracts/EscrowWithChangedMechanics_draft.sol create mode 100644 contracts/interfaces/IStETH.sol create mode 100644 contracts/interfaces/IWithdrawalQueue.sol create mode 100644 contracts/interfaces/IWstETH.sol diff --git a/contracts/EscrowWithChangedMechanics_draft.sol b/contracts/EscrowWithChangedMechanics_draft.sol new file mode 100644 index 00000000..f513c0a9 --- /dev/null +++ b/contracts/EscrowWithChangedMechanics_draft.sol @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {IStETH} from "./interfaces/IStETH.sol"; +import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; + +interface IDualGovernance { + function activateNextState() external; +} + +struct LockedAssetsStats { + uint128 stEthShares; + uint128 wstEthShares; + uint128 unstEthShares; + uint128 finalizedShares; + uint128 finalizedAmount; +} + +struct LockedAssetsTotals { + uint128 shares; + uint128 unstEthShares; + uint128 finalizedShares; + uint128 finalizedAmount; + uint128 claimedEthAmount; +} + +struct WithdrawalRequestState { + bool isFinalized; + bool isClaimed; + bool isWithdrawn; + address owner; + // index of the unstEth NFT associated with WithdrawalRequestState in the + // array _vetoersUnstEthIds[owner] + uint64 vetoerUnstEthIndexOneBased; + uint128 ethAmount; +} + +enum EscrowState { + SignallingEscrow, + RageQuitEscrow +} + +/** + * A contract serving as a veto signalling and rage quit escrow. + */ +contract Escrow { + using SafeERC20 for IERC20; + using SafeCast for uint256; + + error ZeroWithdraw(); + error NoRequestsToClaim(); + error InvalidEscrowState(); + error NoBatchesToWithdraw(); + error WithdrawalRequestFinalized(uint256 requestId); + error WithdrawalRequestNotClaimed(uint256 requestId); + error WithdrawalRequestAlreadyLocked(uint256 requestId); + error WithdrawalRequestAlreadyWithdrawn(uint256 requestId); + error InvalidOwner(uint256 unstEthId, address actualOwner, address expectedOwner); + + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + + EscrowState internal _escrowState; + IDualGovernance private _dualGovernance; + LockedAssetsTotals internal _totalLockedAssets; + + // Count + + uint128 internal _claimRequestsCount; + uint128 internal _claimedRequestsCount; + uint128 internal _lastRequestedIdToWaitClaimed; + + mapping(address vetoer => LockedAssetsStats) internal _lockedAssetsStats; + mapping(address vetoer => uint256[] unstEthIds) internal _vetoersUnstEthIds; + mapping(uint256 unstEthId => WithdrawalRequestState) internal _withdrawalRequestStates; + + // --- + // Lock / Unlock stETH + // --- + + function lockStEth(uint256 amount) external { + uint256 shares = ST_ETH.getSharesByPooledEth(amount); + ST_ETH.transferSharesFrom(msg.sender, address(this), shares); + _accountStEthLock(_lockedAssetsStats[msg.sender], shares); + _activateNextGovernanceState(); + } + + function unlockStEth() external { + uint256 sharesUnlocked = _accountStEthUnlock(_lockedAssetsStats[msg.sender]); + ST_ETH.transferShares(msg.sender, sharesUnlocked); + _activateNextGovernanceState(); + } + + // --- + // Lock / Unlock wstETH + // --- + + function lockWstEth(uint256 amount) external { + WST_ETH.transferFrom(msg.sender, address(this), amount); + _accountWstEthLock(_lockedAssetsStats[msg.sender], amount); + _activateNextGovernanceState(); + } + + function unlockWstEth() external { + uint256 sharesUnlocked = _accountWstEthUnlock(_lockedAssetsStats[msg.sender]); + WST_ETH.transfer(msg.sender, sharesUnlocked); + _activateNextGovernanceState(); + } + + // --- + // Lock / Unlock unstETH + // --- + + function lockUnstEth(uint256[] memory unstEthIds) external { + WithdrawalRequestStatus[] memory wrStatuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstEthIds); + + uint256 unstEthId; + uint256 sharesToLock; + for (uint256 i = 0; i < unstEthIds.length; ++i) { + unstEthId = unstEthIds[i]; + WithdrawalRequestStatus memory wrStatus = wrStatuses[unstEthId]; + WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstEthId); + + if (wrStatus.isFinalized) { + revert WithdrawalRequestFinalized(unstEthId); + } + assert(!wrStatus.isClaimed); + + WithdrawalRequestState memory withdrawalRequestState = _withdrawalRequestStates[unstEthId]; + + if (withdrawalRequestState.owner != address(0)) { + revert WithdrawalRequestAlreadyLocked(unstEthId); + } + assert(!withdrawalRequestState.isClaimed); + assert(!withdrawalRequestState.isFinalized); + + _withdrawalRequestStates[unstEthId].owner = wrStatus.owner; + sharesToLock += wrStatus.amountOfShares; + } + _accountUnstEthLock(_lockedAssetsStats[msg.sender], sharesToLock); + _activateNextGovernanceState(); + } + + function unlockUnstEth(uint256[] memory unstEthIds) external { + WithdrawalRequestStatus[] memory wrStatuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstEthIds); + + uint256 unstEthId; + uint256 sharesToUnlock; + uint256 finalizedAmountToUnlock; + uint256 finalizedSharesToUnlock; + + for (uint256 i = 0; i < unstEthIds.length; ++i) { + unstEthId = unstEthIds[i]; + WithdrawalRequestState memory state = _withdrawalRequestStates[unstEthId]; + + if (state.owner != msg.sender) { + revert InvalidOwner(unstEthId, msg.sender, state.owner); + } + WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstEthId); + + WithdrawalRequestStatus memory status = wrStatuses[i]; + + if (status.isFinalized) { + finalizedSharesToUnlock += status.amountOfShares; + finalizedAmountToUnlock += _withdrawalRequestStates[unstEthId].ethAmount; + } + + delete _withdrawalRequestStates[unstEthId]; + uint256[] storage vetoerUnstEthIds = _vetoersUnstEthIds[msg.sender]; + // todo: add underflow checks + uint256 unstEthIdIndex = state.vetoerUnstEthIndexOneBased - 1; + uint256 lastUnstEthIdIndex = vetoerUnstEthIds.length - 1; + if (lastUnstEthIdIndex != unstEthIdIndex) { + vetoerUnstEthIds[unstEthIdIndex] = vetoerUnstEthIds[lastUnstEthIdIndex]; + } + vetoerUnstEthIds.pop(); + } + + LockedAssetsStats storage vetoerLockedAssets = _lockedAssetsStats[msg.sender]; + vetoerLockedAssets.unstEthShares -= sharesToUnlock.toUint128(); + vetoerLockedAssets.finalizedAmount -= finalizedAmountToUnlock.toUint128(); + vetoerLockedAssets.finalizedShares -= finalizedSharesToUnlock.toUint128(); + + _totalLockedAssets.unstEthShares -= sharesToUnlock.toUint128(); + _totalLockedAssets.finalizedAmount -= finalizedAmountToUnlock.toUint128(); + _totalLockedAssets.finalizedShares -= finalizedSharesToUnlock.toUint128(); + + _activateNextGovernanceState(); + } + + // --- + // State Updates + // --- + function markFinalized(uint256[] memory unstEthIds, uint256[] calldata hints) external { + if (_escrowState != EscrowState.SignallingEscrow) { + revert InvalidEscrowState(); + } + + uint256[] memory claimableEthValues = WITHDRAWAL_QUEUE.getClaimableEther(unstEthIds, hints); + WithdrawalRequestStatus[] memory wrStatuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstEthIds); + + uint256 unstEthId; + uint256 totalAmountFinalized; + uint256 totalSharesFinalized; + for (uint256 i = 0; i < unstEthIds.length; ++i) { + unstEthId = unstEthIds[i]; + WithdrawalRequestState memory state = _withdrawalRequestStates[unstEthId]; + if (state.isFinalized || state.owner == address(0) || claimableEthValues[i] == 0) { + // skip the NFTs which were not locked or not finalized or already locked + continue; + } + assert(!state.isClaimed); + + totalAmountFinalized += claimableEthValues[i]; + totalSharesFinalized += wrStatuses[i].amountOfShares; + + _withdrawalRequestStates[unstEthId].isFinalized = true; + _withdrawalRequestStates[unstEthId].ethAmount = claimableEthValues[i].toUint128(); + } + _totalLockedAssets.finalizedAmount += totalAmountFinalized.toUint128(); + _totalLockedAssets.finalizedShares += totalSharesFinalized.toUint128(); + } + + function startRageQuit() external { + _escrowState = EscrowState.RageQuitEscrow; + + uint256 wstEthBalance = WST_ETH.balanceOf(address(this)); + if (wstEthBalance > 0) { + WST_ETH.unwrap(wstEthBalance); + } + + ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); + } + + function requestWithdrawalsBatch(uint256 maxWithdrawalRequestsCount) external { + if (_escrowState != EscrowState.RageQuitEscrow) { + revert InvalidEscrowState(); + } + + if (_lastRequestedIdToWaitClaimed != 0) { + revert NoBatchesToWithdraw(); + } + + uint256 minRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); + + uint256 currentBalance = ST_ETH.balanceOf(address(this)); + uint256 requestsCount = Math.min(maxWithdrawalRequestsCount, currentBalance / maxRequestAmount + 1); + + uint256[] memory requestAmounts = new uint256[](requestsCount); + for (uint256 i = 0; i < requestsCount; ++i) { + requestAmounts[i] = maxRequestAmount; + } + + // if we preparing the final batch, last withdrawal request will contain less + // stETH than maxRequestAmount + if (currentBalance < requestsCount * maxRequestAmount) { + uint256 lastRequestAmount = currentBalance % maxRequestAmount; + requestAmounts[requestsCount - 1] = lastRequestAmount; + // completely remove the last item if it's less than the minimal withdrawal amount + if (lastRequestAmount < minRequestAmount) { + assembly { + mstore(requestAmounts, sub(requestsCount, 1)) + } + } + } + + if (requestAmounts.length > 0) { + WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this)); + _claimRequestsCount += requestAmounts.length.toUint128(); + } + + if (ST_ETH.balanceOf(address(this)) < minRequestAmount) { + _lastRequestedIdToWaitClaimed = WITHDRAWAL_QUEUE.getLastRequestId().toUint128(); + } + } + + function claimWithdrawalsBatch(uint256[] calldata requestIds, uint256[] calldata hints) external { + if (_escrowState != EscrowState.RageQuitEscrow) { + revert InvalidEscrowState(); + } + + if (_claimRequestsCount == _claimedRequestsCount) { + revert NoRequestsToClaim(); + } + + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(requestIds, hints); + uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + + _totalLockedAssets.claimedEthAmount += ethAmountClaimed.toUint128(); + _claimedRequestsCount += requestIds.length.toUint128(); + + if (_claimedRequestsCount == _claimRequestsCount) { + // TODO: start the `RageQuitExtraTimelock` + } + } + + function claimWithdrawalRequests(uint256[] calldata requestIds, uint256[] calldata hints) external { + if (_escrowState != EscrowState.RageQuitEscrow) { + revert InvalidEscrowState(); + } + uint256[] memory claimedAmounts = WITHDRAWAL_QUEUE.getClaimableEther(requestIds, hints); + + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(requestIds, hints); + uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + + for (uint256 i = 0; i < requestIds.length; ++i) { + WithdrawalRequestState storage wq = _withdrawalRequestStates[requestIds[i]]; + wq.isClaimed = true; + wq.ethAmount = claimedAmounts[i].toUint128(); + _lockedAssetsStats[wq.owner].finalizedAmount += claimedAmounts[i].toUint128(); + } + + _totalLockedAssets.claimedEthAmount += ethAmountClaimed.toUint128(); + } + + // --- + // Withdraw Logic + // --- + + function withdrawStEth() external { + LockedAssetsTotals memory totals = _totalLockedAssets; + LockedAssetsStats memory stats = _lockedAssetsStats[msg.sender]; + + uint256 ethAmount = totals.claimedEthAmount * stats.stEthShares / totals.shares; + + _lockedAssetsStats[msg.sender].stEthShares = 0; + + if (ethAmount == 0) { + revert ZeroWithdraw(); + } + + Address.sendValue(payable(msg.sender), ethAmount); + } + + function withdrawWstEth() external { + LockedAssetsTotals memory totals = _totalLockedAssets; + LockedAssetsStats memory stats = _lockedAssetsStats[msg.sender]; + + uint256 ethAmount = totals.claimedEthAmount * stats.wstEthShares / totals.shares; + + _lockedAssetsStats[msg.sender].wstEthShares = 0; + + if (ethAmount == 0) { + revert ZeroWithdraw(); + } + + Address.sendValue(payable(msg.sender), ethAmount); + } + + function withdrawUnstEth(uint256[] calldata requestIds) external { + uint256 requestId; + for (uint256 i = 0; i < requestIds.length; ++i) { + requestId = requestIds[i]; + WithdrawalRequestState memory state = _withdrawalRequestStates[requestIds[i]]; + if (state.owner != msg.sender) { + revert InvalidOwner(requestId, msg.sender, state.owner); + } + if (!state.isClaimed) { + revert WithdrawalRequestNotClaimed(requestId); + } + if (state.isWithdrawn) { + revert WithdrawalRequestAlreadyWithdrawn(requestId); + } + state.isWithdrawn = true; + Address.sendValue(payable(msg.sender), state.ethAmount); + } + } + + // --- + // Getters + // --- + + function getRageQuitSupport() external view returns (uint256) { + LockedAssetsTotals memory totals = _totalLockedAssets; + + uint256 rebaseableAmount = ST_ETH.getPooledEthByShares(totals.shares - totals.finalizedShares); + return 10 ** 18 * (rebaseableAmount + totals.finalizedAmount) / (ST_ETH.totalSupply() + totals.finalizedAmount); + } + + // --- + // Internal Methods + // --- + + function _accountStEthLock(LockedAssetsStats storage assets, uint256 shares) internal { + uint128 sharesUint128 = shares.toUint128(); + assets.stEthShares += sharesUint128; + _totalLockedAssets.shares += sharesUint128; + } + + function _accountStEthUnlock(LockedAssetsStats storage assets) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = assets.stEthShares; + assets.stEthShares = 0; + _totalLockedAssets.shares -= sharesUnlocked; + } + + function _accountWstEthLock(LockedAssetsStats storage assets, uint256 shares) internal { + uint128 sharesUint128 = shares.toUint128(); + assets.wstEthShares += sharesUint128; + _totalLockedAssets.shares += sharesUint128; + } + + function _accountWstEthUnlock(LockedAssetsStats storage assets) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = assets.wstEthShares; + assets.wstEthShares = 0; + _totalLockedAssets.shares -= sharesUnlocked; + } + + function _accountUnstEthLock(LockedAssetsStats storage assets, uint256 shares) internal { + uint128 sharesUint128 = shares.toUint128(); + assets.unstEthShares += sharesUint128; + _totalLockedAssets.unstEthShares += sharesUint128; + } + + function _activateNextGovernanceState() internal { + _dualGovernance.activateNextState(); + } +} 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); +} From 1836fbb92e5816561d26d2fff75874735b733d32 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 3 Apr 2024 01:34:29 +0400 Subject: [PATCH 2/7] Change Escrow methods logic --- contracts/BurnerVault.sol | 38 - contracts/Configuration.sol | 4 + contracts/Escrow.sol | 717 +++++------------- .../EscrowWithChangedMechanics_draft.sol | 429 ----------- contracts/interfaces/IConfiguration.sol | 4 + contracts/interfaces/IEscrow.sol | 5 +- contracts/libraries/AssetsAccounting.sol | 472 ++++++++++++ contracts/libraries/DualGovernanceState.sol | 47 +- test/scenario/escrow.t.sol | 564 ++++++-------- test/scenario/gov-state-transitions.t.sol | 169 ++--- .../last-moment-malicious-proposal.t.sol | 10 +- test/utils/interfaces.sol | 18 +- test/utils/percents.sol | 46 ++ test/utils/scenario-test-blueprint.sol | 155 +++- test/utils/utils.sol | 11 +- 15 files changed, 1226 insertions(+), 1463 deletions(-) delete mode 100644 contracts/BurnerVault.sol delete mode 100644 contracts/EscrowWithChangedMechanics_draft.sol create mode 100644 contracts/libraries/AssetsAccounting.sol create mode 100644 test/utils/percents.sol 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..9b3fbed8 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -28,6 +28,10 @@ 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; + // Sealables Array Representation uint256 private immutable MAX_SELABLES_COUNT = 5; diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 68602810..317b0939 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -1,157 +1,67 @@ // 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"; - 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; - - State internal _state; - address internal _dualGovernance; - - uint256 internal _totalStEthInEthLocked; - uint256 internal _totalWstEthInEthLocked; - uint256 internal _totalWithdrawalNftsAmountLocked; - uint256 internal _totalFinalizedWithdrawalNftsAmountLocked; - uint256 internal _totalClaimedEthLocked; + IStETH public immutable ST_ETH; + IWstETH public immutable WST_ETH; + IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; - uint256 internal _totalEscrowShares; - uint256 internal _claimedWQRequestsAmount; + EscrowState internal _escrowState; + IDualGovernance private _dualGovernance; + AssetsAccounting.State private _accounting; - uint256 internal _rageQuitAmountTotal; - uint256 internal _rageQuitAmountRequested; - uint256 internal _lastWithdrawalRequestId; + uint256[] internal _withdrawalUnstETHIds; - mapping(address => HolderState) private _balances; - mapping(uint256 => WithdrawalRequestStatus) private _wqRequests; + uint256 internal _rageQuitExtraTimelock; + uint256 internal _rageQuitWithdrawalsTimelock; + uint256 internal _rageQuitTimelockStartedAt; - 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) { + ST_ETH = IStETH(stETH); + WST_ETH = IWstETH(wstETH); + WITHDRAWAL_QUEUE = IWithdrawalQueue(withdrawalQueue); MASTER_COPY = address(this); } @@ -159,455 +69,242 @@ contract Escrow { if (address(this) == MASTER_COPY) { revert MasterCopyCallForbidden(); } - if (_dualGovernance != address(0)) { - revert AlreadyInitialized(); - } - _totalStEthInEthLocked = 1; - _totalEscrowShares = 1; - _dualGovernance = dualGovernance; - } - - /// - /// 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; - } - - 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; + _checkEscrowState(EscrowState.NotInitialized); - _activateNextGovernanceState(); + _escrowState = EscrowState.SignallingEscrow; + _dualGovernance = IDualGovernance(dualGovernance); } - 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(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(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(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/EscrowWithChangedMechanics_draft.sol b/contracts/EscrowWithChangedMechanics_draft.sol deleted file mode 100644 index f513c0a9..00000000 --- a/contracts/EscrowWithChangedMechanics_draft.sol +++ /dev/null @@ -1,429 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; - -import {Address} from "@openzeppelin/contracts/utils/Address.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import {IStETH} from "./interfaces/IStETH.sol"; -import {IWstETH} from "./interfaces/IWstETH.sol"; -import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; - -interface IDualGovernance { - function activateNextState() external; -} - -struct LockedAssetsStats { - uint128 stEthShares; - uint128 wstEthShares; - uint128 unstEthShares; - uint128 finalizedShares; - uint128 finalizedAmount; -} - -struct LockedAssetsTotals { - uint128 shares; - uint128 unstEthShares; - uint128 finalizedShares; - uint128 finalizedAmount; - uint128 claimedEthAmount; -} - -struct WithdrawalRequestState { - bool isFinalized; - bool isClaimed; - bool isWithdrawn; - address owner; - // index of the unstEth NFT associated with WithdrawalRequestState in the - // array _vetoersUnstEthIds[owner] - uint64 vetoerUnstEthIndexOneBased; - uint128 ethAmount; -} - -enum EscrowState { - SignallingEscrow, - RageQuitEscrow -} - -/** - * A contract serving as a veto signalling and rage quit escrow. - */ -contract Escrow { - using SafeERC20 for IERC20; - using SafeCast for uint256; - - error ZeroWithdraw(); - error NoRequestsToClaim(); - error InvalidEscrowState(); - error NoBatchesToWithdraw(); - error WithdrawalRequestFinalized(uint256 requestId); - error WithdrawalRequestNotClaimed(uint256 requestId); - error WithdrawalRequestAlreadyLocked(uint256 requestId); - error WithdrawalRequestAlreadyWithdrawn(uint256 requestId); - error InvalidOwner(uint256 unstEthId, address actualOwner, address expectedOwner); - - IStETH public immutable ST_ETH; - IWstETH public immutable WST_ETH; - IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; - - EscrowState internal _escrowState; - IDualGovernance private _dualGovernance; - LockedAssetsTotals internal _totalLockedAssets; - - // Count - - uint128 internal _claimRequestsCount; - uint128 internal _claimedRequestsCount; - uint128 internal _lastRequestedIdToWaitClaimed; - - mapping(address vetoer => LockedAssetsStats) internal _lockedAssetsStats; - mapping(address vetoer => uint256[] unstEthIds) internal _vetoersUnstEthIds; - mapping(uint256 unstEthId => WithdrawalRequestState) internal _withdrawalRequestStates; - - // --- - // Lock / Unlock stETH - // --- - - function lockStEth(uint256 amount) external { - uint256 shares = ST_ETH.getSharesByPooledEth(amount); - ST_ETH.transferSharesFrom(msg.sender, address(this), shares); - _accountStEthLock(_lockedAssetsStats[msg.sender], shares); - _activateNextGovernanceState(); - } - - function unlockStEth() external { - uint256 sharesUnlocked = _accountStEthUnlock(_lockedAssetsStats[msg.sender]); - ST_ETH.transferShares(msg.sender, sharesUnlocked); - _activateNextGovernanceState(); - } - - // --- - // Lock / Unlock wstETH - // --- - - function lockWstEth(uint256 amount) external { - WST_ETH.transferFrom(msg.sender, address(this), amount); - _accountWstEthLock(_lockedAssetsStats[msg.sender], amount); - _activateNextGovernanceState(); - } - - function unlockWstEth() external { - uint256 sharesUnlocked = _accountWstEthUnlock(_lockedAssetsStats[msg.sender]); - WST_ETH.transfer(msg.sender, sharesUnlocked); - _activateNextGovernanceState(); - } - - // --- - // Lock / Unlock unstETH - // --- - - function lockUnstEth(uint256[] memory unstEthIds) external { - WithdrawalRequestStatus[] memory wrStatuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstEthIds); - - uint256 unstEthId; - uint256 sharesToLock; - for (uint256 i = 0; i < unstEthIds.length; ++i) { - unstEthId = unstEthIds[i]; - WithdrawalRequestStatus memory wrStatus = wrStatuses[unstEthId]; - WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstEthId); - - if (wrStatus.isFinalized) { - revert WithdrawalRequestFinalized(unstEthId); - } - assert(!wrStatus.isClaimed); - - WithdrawalRequestState memory withdrawalRequestState = _withdrawalRequestStates[unstEthId]; - - if (withdrawalRequestState.owner != address(0)) { - revert WithdrawalRequestAlreadyLocked(unstEthId); - } - assert(!withdrawalRequestState.isClaimed); - assert(!withdrawalRequestState.isFinalized); - - _withdrawalRequestStates[unstEthId].owner = wrStatus.owner; - sharesToLock += wrStatus.amountOfShares; - } - _accountUnstEthLock(_lockedAssetsStats[msg.sender], sharesToLock); - _activateNextGovernanceState(); - } - - function unlockUnstEth(uint256[] memory unstEthIds) external { - WithdrawalRequestStatus[] memory wrStatuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstEthIds); - - uint256 unstEthId; - uint256 sharesToUnlock; - uint256 finalizedAmountToUnlock; - uint256 finalizedSharesToUnlock; - - for (uint256 i = 0; i < unstEthIds.length; ++i) { - unstEthId = unstEthIds[i]; - WithdrawalRequestState memory state = _withdrawalRequestStates[unstEthId]; - - if (state.owner != msg.sender) { - revert InvalidOwner(unstEthId, msg.sender, state.owner); - } - WITHDRAWAL_QUEUE.transferFrom(address(this), msg.sender, unstEthId); - - WithdrawalRequestStatus memory status = wrStatuses[i]; - - if (status.isFinalized) { - finalizedSharesToUnlock += status.amountOfShares; - finalizedAmountToUnlock += _withdrawalRequestStates[unstEthId].ethAmount; - } - - delete _withdrawalRequestStates[unstEthId]; - uint256[] storage vetoerUnstEthIds = _vetoersUnstEthIds[msg.sender]; - // todo: add underflow checks - uint256 unstEthIdIndex = state.vetoerUnstEthIndexOneBased - 1; - uint256 lastUnstEthIdIndex = vetoerUnstEthIds.length - 1; - if (lastUnstEthIdIndex != unstEthIdIndex) { - vetoerUnstEthIds[unstEthIdIndex] = vetoerUnstEthIds[lastUnstEthIdIndex]; - } - vetoerUnstEthIds.pop(); - } - - LockedAssetsStats storage vetoerLockedAssets = _lockedAssetsStats[msg.sender]; - vetoerLockedAssets.unstEthShares -= sharesToUnlock.toUint128(); - vetoerLockedAssets.finalizedAmount -= finalizedAmountToUnlock.toUint128(); - vetoerLockedAssets.finalizedShares -= finalizedSharesToUnlock.toUint128(); - - _totalLockedAssets.unstEthShares -= sharesToUnlock.toUint128(); - _totalLockedAssets.finalizedAmount -= finalizedAmountToUnlock.toUint128(); - _totalLockedAssets.finalizedShares -= finalizedSharesToUnlock.toUint128(); - - _activateNextGovernanceState(); - } - - // --- - // State Updates - // --- - function markFinalized(uint256[] memory unstEthIds, uint256[] calldata hints) external { - if (_escrowState != EscrowState.SignallingEscrow) { - revert InvalidEscrowState(); - } - - uint256[] memory claimableEthValues = WITHDRAWAL_QUEUE.getClaimableEther(unstEthIds, hints); - WithdrawalRequestStatus[] memory wrStatuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstEthIds); - - uint256 unstEthId; - uint256 totalAmountFinalized; - uint256 totalSharesFinalized; - for (uint256 i = 0; i < unstEthIds.length; ++i) { - unstEthId = unstEthIds[i]; - WithdrawalRequestState memory state = _withdrawalRequestStates[unstEthId]; - if (state.isFinalized || state.owner == address(0) || claimableEthValues[i] == 0) { - // skip the NFTs which were not locked or not finalized or already locked - continue; - } - assert(!state.isClaimed); - - totalAmountFinalized += claimableEthValues[i]; - totalSharesFinalized += wrStatuses[i].amountOfShares; - - _withdrawalRequestStates[unstEthId].isFinalized = true; - _withdrawalRequestStates[unstEthId].ethAmount = claimableEthValues[i].toUint128(); - } - _totalLockedAssets.finalizedAmount += totalAmountFinalized.toUint128(); - _totalLockedAssets.finalizedShares += totalSharesFinalized.toUint128(); - } - - function startRageQuit() external { - _escrowState = EscrowState.RageQuitEscrow; - - uint256 wstEthBalance = WST_ETH.balanceOf(address(this)); - if (wstEthBalance > 0) { - WST_ETH.unwrap(wstEthBalance); - } - - ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); - } - - function requestWithdrawalsBatch(uint256 maxWithdrawalRequestsCount) external { - if (_escrowState != EscrowState.RageQuitEscrow) { - revert InvalidEscrowState(); - } - - if (_lastRequestedIdToWaitClaimed != 0) { - revert NoBatchesToWithdraw(); - } - - uint256 minRequestAmount = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); - uint256 maxRequestAmount = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); - - uint256 currentBalance = ST_ETH.balanceOf(address(this)); - uint256 requestsCount = Math.min(maxWithdrawalRequestsCount, currentBalance / maxRequestAmount + 1); - - uint256[] memory requestAmounts = new uint256[](requestsCount); - for (uint256 i = 0; i < requestsCount; ++i) { - requestAmounts[i] = maxRequestAmount; - } - - // if we preparing the final batch, last withdrawal request will contain less - // stETH than maxRequestAmount - if (currentBalance < requestsCount * maxRequestAmount) { - uint256 lastRequestAmount = currentBalance % maxRequestAmount; - requestAmounts[requestsCount - 1] = lastRequestAmount; - // completely remove the last item if it's less than the minimal withdrawal amount - if (lastRequestAmount < minRequestAmount) { - assembly { - mstore(requestAmounts, sub(requestsCount, 1)) - } - } - } - - if (requestAmounts.length > 0) { - WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this)); - _claimRequestsCount += requestAmounts.length.toUint128(); - } - - if (ST_ETH.balanceOf(address(this)) < minRequestAmount) { - _lastRequestedIdToWaitClaimed = WITHDRAWAL_QUEUE.getLastRequestId().toUint128(); - } - } - - function claimWithdrawalsBatch(uint256[] calldata requestIds, uint256[] calldata hints) external { - if (_escrowState != EscrowState.RageQuitEscrow) { - revert InvalidEscrowState(); - } - - if (_claimRequestsCount == _claimedRequestsCount) { - revert NoRequestsToClaim(); - } - - uint256 ethBalanceBefore = address(this).balance; - WITHDRAWAL_QUEUE.claimWithdrawals(requestIds, hints); - uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; - - _totalLockedAssets.claimedEthAmount += ethAmountClaimed.toUint128(); - _claimedRequestsCount += requestIds.length.toUint128(); - - if (_claimedRequestsCount == _claimRequestsCount) { - // TODO: start the `RageQuitExtraTimelock` - } - } - - function claimWithdrawalRequests(uint256[] calldata requestIds, uint256[] calldata hints) external { - if (_escrowState != EscrowState.RageQuitEscrow) { - revert InvalidEscrowState(); - } - uint256[] memory claimedAmounts = WITHDRAWAL_QUEUE.getClaimableEther(requestIds, hints); - - uint256 ethBalanceBefore = address(this).balance; - WITHDRAWAL_QUEUE.claimWithdrawals(requestIds, hints); - uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; - - for (uint256 i = 0; i < requestIds.length; ++i) { - WithdrawalRequestState storage wq = _withdrawalRequestStates[requestIds[i]]; - wq.isClaimed = true; - wq.ethAmount = claimedAmounts[i].toUint128(); - _lockedAssetsStats[wq.owner].finalizedAmount += claimedAmounts[i].toUint128(); - } - - _totalLockedAssets.claimedEthAmount += ethAmountClaimed.toUint128(); - } - - // --- - // Withdraw Logic - // --- - - function withdrawStEth() external { - LockedAssetsTotals memory totals = _totalLockedAssets; - LockedAssetsStats memory stats = _lockedAssetsStats[msg.sender]; - - uint256 ethAmount = totals.claimedEthAmount * stats.stEthShares / totals.shares; - - _lockedAssetsStats[msg.sender].stEthShares = 0; - - if (ethAmount == 0) { - revert ZeroWithdraw(); - } - - Address.sendValue(payable(msg.sender), ethAmount); - } - - function withdrawWstEth() external { - LockedAssetsTotals memory totals = _totalLockedAssets; - LockedAssetsStats memory stats = _lockedAssetsStats[msg.sender]; - - uint256 ethAmount = totals.claimedEthAmount * stats.wstEthShares / totals.shares; - - _lockedAssetsStats[msg.sender].wstEthShares = 0; - - if (ethAmount == 0) { - revert ZeroWithdraw(); - } - - Address.sendValue(payable(msg.sender), ethAmount); - } - - function withdrawUnstEth(uint256[] calldata requestIds) external { - uint256 requestId; - for (uint256 i = 0; i < requestIds.length; ++i) { - requestId = requestIds[i]; - WithdrawalRequestState memory state = _withdrawalRequestStates[requestIds[i]]; - if (state.owner != msg.sender) { - revert InvalidOwner(requestId, msg.sender, state.owner); - } - if (!state.isClaimed) { - revert WithdrawalRequestNotClaimed(requestId); - } - if (state.isWithdrawn) { - revert WithdrawalRequestAlreadyWithdrawn(requestId); - } - state.isWithdrawn = true; - Address.sendValue(payable(msg.sender), state.ethAmount); - } - } - - // --- - // Getters - // --- - - function getRageQuitSupport() external view returns (uint256) { - LockedAssetsTotals memory totals = _totalLockedAssets; - - uint256 rebaseableAmount = ST_ETH.getPooledEthByShares(totals.shares - totals.finalizedShares); - return 10 ** 18 * (rebaseableAmount + totals.finalizedAmount) / (ST_ETH.totalSupply() + totals.finalizedAmount); - } - - // --- - // Internal Methods - // --- - - function _accountStEthLock(LockedAssetsStats storage assets, uint256 shares) internal { - uint128 sharesUint128 = shares.toUint128(); - assets.stEthShares += sharesUint128; - _totalLockedAssets.shares += sharesUint128; - } - - function _accountStEthUnlock(LockedAssetsStats storage assets) internal returns (uint128 sharesUnlocked) { - sharesUnlocked = assets.stEthShares; - assets.stEthShares = 0; - _totalLockedAssets.shares -= sharesUnlocked; - } - - function _accountWstEthLock(LockedAssetsStats storage assets, uint256 shares) internal { - uint128 sharesUint128 = shares.toUint128(); - assets.wstEthShares += sharesUint128; - _totalLockedAssets.shares += sharesUint128; - } - - function _accountWstEthUnlock(LockedAssetsStats storage assets) internal returns (uint128 sharesUnlocked) { - sharesUnlocked = assets.wstEthShares; - assets.wstEthShares = 0; - _totalLockedAssets.shares -= sharesUnlocked; - } - - function _accountUnstEthLock(LockedAssetsStats storage assets, uint256 shares) internal { - uint128 sharesUint128 = shares.toUint128(); - assets.unstEthShares += sharesUint128; - _totalLockedAssets.unstEthShares += sharesUint128; - } - - function _activateNextGovernanceState() internal { - _dualGovernance.activateNextState(); - } -} diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index b57be9fe..638b7c2c 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -26,6 +26,10 @@ 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 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/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol new file mode 100644 index 00000000..73785153 --- /dev/null +++ b/contracts/libraries/AssetsAccounting.sol @@ -0,0 +1,472 @@ +// 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 {ArrayUtils} from "../utils/arrays.sol"; + +enum WithdrawalRequestState { + NotLocked, + Locked, + Finalized, + Claimed, + Withdrawn +} + +struct WithdrawalRequest { + address owner; + uint96 claimableAmount; + WithdrawalRequestState state; + uint64 vetoerUnstETHIndexOneBased; + uint128 shares; +} + +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 WithdrawalBatchCreated(uint256[] ids); + event WithdrawalBatchesClaimed(uint256 offset, uint256 count); + event WithdrawalRequestWithdrawn(uint256 indexed id, uint256 ethAmount); + + 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); + + 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.totals.shares += sharesUint128; + emit StETHLocked(vetoer, shares); + } + + function accountStETHUnlock(State storage self, address vetoer) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = self.assets[vetoer].stETHShares; + _checkNonZeroSharesUnlock(vetoer, sharesUnlocked); + 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.totals.shares += sharesUint128; + emit WstETHLocked(vetoer, shares); + } + + function accountWstETHUnlock(State storage self, address vetoer) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = self.assets[vetoer].wstETHShares; + _checkNonZeroSharesUnlock(vetoer, sharesUnlocked); + 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 unstETHId; + uint256 amountOfShares; + uint256 totalUnstETHSharesLocked; + WithdrawalRequest storage request; + uint256 unstETHcount = unstETHIds.length; + for (uint256 i = 0; i < unstETHcount; ++i) { + unstETHId = unstETHIds[i]; + request = self.requests[unstETHId]; + + _checkWithdrawalRequestNotLocked(self, unstETHId); + _checkWithdrawalRequestStatusNotFinalized(statuses[i], unstETHId); + + self.vetoersUnstETHIds[vetoer].push(unstETHId); + + request.owner = vetoer; + request.state = WithdrawalRequestState.Locked; + request.vetoerUnstETHIndexOneBased = self.vetoersUnstETHIds[vetoer].length.toUint64(); + amountOfShares = statuses[i].amountOfShares; + request.shares = amountOfShares.toUint128(); + assert(request.claimableAmount == 0); + + totalUnstETHSharesLocked += amountOfShares; + } + uint128 totalUnstETHSharesLockedUint128 = totalUnstETHSharesLocked.toUint128(); + self.assets[vetoer].unstETHShares += totalUnstETHSharesLockedUint128; + self.totals.shares += totalUnstETHSharesLockedUint128; + emit UnstETHLocked(vetoer, unstETHIds, totalUnstETHSharesLocked); + } + + function accountUnstETHUnlock(State storage self, address vetoer, uint256[] memory unstETHIds) internal { + uint256 unstETHId; + uint256 sharesToUnlock; + uint256 totalUnstETHSharesToUnlock; + uint256 totalFinalizedSharesToUnlock; + uint256 totalFinalizedAmountToUnlock; + WithdrawalRequest storage request; + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + unstETHId = unstETHIds[i]; + request = self.requests[unstETHId]; + + _checkWithdrawalRequestOwner(request, vetoer); + _checkWithdrawalRequestWasLocked(request, unstETHId); + + sharesToUnlock = request.shares; + if (request.state == WithdrawalRequestState.Finalized) { + totalFinalizedSharesToUnlock += sharesToUnlock; + totalFinalizedAmountToUnlock += 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]; + totalUnstETHSharesToUnlock += sharesToUnlock; + } + + uint128 totalUnstETHSharesToUnlockUint128 = totalUnstETHSharesToUnlock.toUint128(); + uint128 totalFinalizedSharesToUnlockUint128 = totalFinalizedSharesToUnlock.toUint128(); + uint128 totalFinalizedAmountToUnlockUint128 = totalFinalizedAmountToUnlock.toUint128(); + + self.assets[vetoer].unstETHShares -= totalUnstETHSharesToUnlockUint128; + self.assets[vetoer].sharesFinalized -= totalFinalizedSharesToUnlockUint128; + self.assets[vetoer].amountFinalized -= totalFinalizedAmountToUnlockUint128; + + self.totals.shares -= totalUnstETHSharesToUnlockUint128; + self.totals.amountFinalized -= totalFinalizedSharesToUnlockUint128; + self.totals.sharesFinalized -= totalFinalizedAmountToUnlockUint128; + emit UnstETHUnlocked( + vetoer, unstETHIds, totalUnstETHSharesToUnlock, totalFinalizedSharesToUnlock, totalFinalizedAmountToUnlock + ); + } + + function accountUnstETHFinalized( + State storage self, + uint256[] memory unstETHIds, + uint256[] memory claimableAmounts + ) internal { + uint256 claimableAmount; + uint256 totalSharesFinalized; + uint256 totalAmountFinalized; + WithdrawalRequest storage request; + + assert(claimableAmounts.length == unstETHIds.length); + + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + request = self.requests[unstETHIds[i]]; + claimableAmount = claimableAmounts[i]; + if (claimableAmount == 0 || request.state != WithdrawalRequestState.Locked) { + continue; + } + request.state = WithdrawalRequestState.Finalized; + request.claimableAmount = claimableAmount.toUint96(); + totalSharesFinalized += request.shares; + totalAmountFinalized += claimableAmount; + } + 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 unstETHId; + uint256 claimableAmount; + WithdrawalRequest storage request; + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + unstETHId = unstETHIds[i]; + claimableAmount = claimableAmounts[i]; + 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; + totalAmountClaimed += claimableAmount; + } + self.totals.amountClaimed += totalAmountClaimed.toUint128(); + emit UnstETHClaimed(unstETHIds, totalAmountClaimed); + } + + function accountUnstETHWithdraw( + State storage self, + address vetoer, + uint256[] calldata unstETHIds + ) internal returns (uint256 ethAmount) { + uint256 unstETHId; + WithdrawalRequest storage request; + uint256 unstETHIdsCount = unstETHIds.length; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + unstETHId = unstETHIds[i]; + 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; + ethAmount += request.claimableAmount; + emit WithdrawalRequestWithdrawn(unstETHId, ethAmount); + } + } + + // --- + // 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 _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(State storage self, uint256 unstETHId) private view { + if (self.requests[unstETHId].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); + } + } +} diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index ef932957..690d9f14 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -29,6 +29,7 @@ library DualGovernanceState { IEscrow signallingEscrow; IEscrow rageQuitEscrow; uint40 lastProposalCreatedAt; + uint8 rageQuitRound; } error NotTie(); @@ -64,7 +65,7 @@ library DualGovernanceState { if (oldState != newState) { _setState(self, oldState, newState); - _handleStateTransitionSideEffects(self, oldState, newState); + _handleStateTransitionSideEffects(self, config, oldState, newState); emit DualGovernanceStateChanged(oldState, newState); } } @@ -131,7 +132,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 +164,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 +191,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; @@ -229,21 +230,31 @@ library DualGovernanceState { self.enteredAt = currentTime; } - function _handleStateTransitionSideEffects(Store storage self, State oldState, State newState) private { + function _handleStateTransitionSideEffects( + Store storage self, + IConfiguration config, + State oldState, + State newState + ) private { uint40 currentTime = 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 +263,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 +311,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/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol index bec38aa1..48bdc5fe 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,147 @@ 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); + _unlockStETH(_VETOER_1); + _unlockWstETH(_VETOER_1); + _unlockStETH(_VETOER_2); + _unlockWstETH(_VETOER_2); - IWstETH(WST_ETH).wrap(1e24); + assertEq(firstVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_1)); + assertApproxEqAbs(firstVetoerStETHBalanceBefore, _ST_ETH.balanceOf(_VETOER_1), 1); - 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(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); - - 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); + function test_lock_unlock_w_rebase() public { + uint256 firstVetoerStETHAmount = 10 * 10 ** 18; + uint256 firstVetoerStETHShares = _ST_ETH.getSharesByPooledEth(firstVetoerStETHAmount); + uint256 firstVetoerWstETHAmount = 11 * 10 ** 18; - lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); - lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + uint256 secondVetoerStETHAmount = 13 * 10 ** 18; + uint256 secondVetoerStETHShares = _ST_ETH.getSharesByPooledEth(secondVetoerStETHAmount); + uint256 secondVetoerWstETHAmount = 17 * 10 ** 18; - unlockAssets(stEthHolder1, true, true, new uint256[](0)); - unlockAssets(stEthHolder2, true, true, new uint256[](0)); + _lockStETH(_VETOER_1, firstVetoerStETHAmount); + _lockWstETH(_VETOER_1, firstVetoerWstETHAmount); - 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); - } + _lockStETH(_VETOER_2, secondVetoerStETHAmount); + _lockWstETH(_VETOER_2, secondVetoerWstETHAmount); - function test_lock_unlock_w_rebase() public { - uint256 amountToLock = 1e18; - uint256 wstEthAmountToLock = IStEth(ST_ETH).getSharesByPooledEth(amountToLock); + rebase(100); - lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); - lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + uint256 firstVetoerStETHSharesAfterRebase = _ST_ETH.sharesOf(_VETOER_1); + uint256 firstVetoerWstETHBalanceAfterRebase = _WST_ETH.balanceOf(_VETOER_1); - rebase(100); + uint256 secondVetoerStETHSharesAfterRebase = _ST_ETH.sharesOf(_VETOER_2); + uint256 secondVetoerWstETHBalanceAfterRebase = _WST_ETH.balanceOf(_VETOER_2); - 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)); + _unlockStETH(_VETOER_1); + _unlockWstETH(_VETOER_1); - rebase(rebaseBP); - escrow.burnRewards(); + _unlockStETH(_VETOER_2); + _unlockWstETH(_VETOER_2); - unlockAssets(stEthHolder1, true, true, new uint256[](0)); - unlockAssets(stEthHolder2, true, true, new uint256[](0)); + assertApproxEqAbs(_ST_ETH.getPooledEthByShares(firstVetoerStETHSharesBefore), _ST_ETH.balanceOf(_VETOER_1), 1); + assertEq(firstVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_1)); - 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(secondVetoerStETHSharesBefore), _ST_ETH.balanceOf(_VETOER_2), 1); + assertEq(secondVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_2)); } function test_lock_unlock_withdrawal_nfts() public { @@ -195,12 +195,11 @@ 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); - lockAssets(stEthHolder1, 0, 0, ids); - - unlockAssets(stEthHolder1, false, false, ids); + _lockUnstETH(_VETOER_1, unstETHIds); + _unlockUnstETH(_VETOER_1, unstETHIds); } function test_lock_withdrawal_nfts_reverts_on_finalized() public { @@ -209,70 +208,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); - lockAssets(stEthHolder1, 1e18, IStEth(ST_ETH).getSharesByPooledEth(1e18), ids); + uint256 amountToLock = 1e18; + uint256 sharesToLock = _ST_ETH.getSharesByPooledEth(amountToLock); - Escrow.Balance memory balance = escrow.balanceOf(stEthHolder1); - assertEq(balance.wqRequestsBalance, 2 * 1e18); - assertEq(balance.finalizedWqRequestsBalance, 0); + _lockStETH(_VETOER_1, amountToLock); + _lockWstETH(_VETOER_1, sharesToLock); + _lockUnstETH(_VETOER_1, unstETHIds); - (uint256 totalSupport, uint256 rageQuitSupport) = escrow.getSignallingState(); - assertEq(totalSupport, 4 * 1e18 * 1e18 / totalSupply); + 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 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); + + LockedAssetsTotals memory totals = escrow.getLockedAssetsTotals(); - balance = escrow.balanceOf(stEthHolder1); - assertEq(balance.wqRequestsBalance, 1e18); - assertEq(balance.finalizedWqRequestsBalance, 1e18); + assertApproxEqAbs(totals.sharesFinalized, sharesToLock, 1); + uint256 ethAmountFinalized = _WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints)[0]; + assertApproxEqAbs(totals.amountFinalized, ethAmountFinalized, 1); - (totalSupport, rageQuitSupport) = escrow.getSignallingState(); - assertEq(totalSupport, 4 * 1e18 * 1e18 / totalSupply); - assertEq(rageQuitSupport, 3 * 1e18 * 1e18 / totalSupply); + rageQuitSupport = escrow.getRageQuitSupport(); + assertEq( + rageQuitSupport, + 10 ** 18 * (_ST_ETH.getPooledEthByShares(3 * sharesToLock) + ethAmountFinalized) + / (_ST_ETH.totalSupply() + ethAmountFinalized) + ); } function test_rage_quit() public { @@ -282,66 +299,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); + + uint256 requestShares = _ST_ETH.getSharesByPooledEth(30 * requestAmount); - lockAssets(stEthHolder1, 20 * requestAmount, IStEth(ST_ETH).getSharesByPooledEth(20 * requestAmount), ids); + _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(); + + _wait(_RAGE_QUIT_EXTRA_TIMELOCK + 1); + assertEq(escrow.isRageQuitFinalized(), true); - escrow.claimNextETHBatch(escrowRequestIds, escrowRequestHints); + _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 +387,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) - ) - ); - - 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); - } + escrow.claimNextWithdrawalsBatch(0, new uint256[](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/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 38b79b49..2d3c56f3 100644 --- a/test/utils/interfaces.sol +++ b/test/utils/interfaces.sol @@ -1,6 +1,7 @@ pragma solidity 0.8.23; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol"; struct WithdrawalRequestStatus { uint256 amountOfStETH; @@ -42,10 +43,11 @@ interface IAragonForwarder { function forward(bytes memory evmScript) external; } -interface IStEth { +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() @@ -60,17 +62,27 @@ interface IStEth { uint256 prevStakeLimit, uint256 prevStakeBlockNumber ); + function getTotalShares() external view returns (uint256); } -interface IWstETH { +interface IWstETH is IERC20 { function wrap(uint256 stETHAmount) external returns (uint256); function unwrap(uint256 wstETHAmount) external returns (uint256); } -interface IWithdrawalQueue { +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 db9c481b..0129ce2b 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} 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; @@ -63,6 +65,10 @@ contract ScenarioTestBlueprint is Test { address internal immutable _TIEBREAK_COMMITTEE = makeAddr("TIEBREAK_COMMITTEE"); + IStEth public immutable _ST_ETH = IStEth(ST_ETH); + IWstETH public immutable _WST_ETH = IWstETH(WST_ETH); + IWithdrawalQueue public immutable _WITHDRAWAL_QUEUE = IWithdrawalQueue(WITHDRAWAL_QUEUE); + TargetMock internal _target; IConfiguration internal _config; @@ -71,7 +77,6 @@ contract ScenarioTestBlueprint is Test { TransparentUpgradeableProxy internal _configProxy; Escrow internal _escrowMasterCopy; - BurnerVault internal _burnerVault; OwnableExecutor internal _adminExecutor; @@ -107,24 +112,124 @@ 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); + _getSignallingEscrow().unlockStETH(); + vm.stopPrank(); + } + function _lockWstETH(address vetoer, uint256 amount) internal { + Escrow escrow = _getSignallingEscrow(); vm.startPrank(vetoer); - IERC20(ST_ETH).approve(address(_getSignallingEscrow()), vetoerBalance); - _getSignallingEscrow().lockStEth(vetoerBalance); + if (_WST_ETH.allowance(vetoer, address(escrow)) < amount) { + _WST_ETH.approve(address(escrow), amount); + } + escrow.lockWstETH(amount); vm.stopPrank(); } - function _unlockStEth(address vetoer) internal { + function _unlockWstETH(address vetoer) internal { + Escrow escrow = _getSignallingEscrow(); + uint256 wstETHBalanceBefore = _WST_ETH.balanceOf(vetoer); + uint256 vetoerWstETHSharesBefore = escrow.getVetoerState(vetoer).wstETHShares; + vm.startPrank(vetoer); - _getSignallingEscrow().unlockStEth(); + 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); } // --- @@ -262,6 +367,10 @@ contract ScenarioTestBlueprint is Test { assertEq(_timelock.getProposal(proposalId).status, ProposalStatus.Canceled, "Proposal not in 'Canceled' state"); } + function _assertNormalState() internal { + assertEq(uint256(_dualGovernance.currentState()), uint256(GovernanceState.Normal)); + } + function _assertVetoSignalingState() internal { assertEq(uint256(_dualGovernance.currentState()), uint256(GovernanceState.VetoSignalling)); } @@ -394,8 +503,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); } function _finishTimelockSetup(address governance, bool isEmergencyProtectionEnabled) internal { @@ -478,4 +586,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); From b9376d09fd37f59451cfc31c33a9bc865d8999a7 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 3 Apr 2024 01:51:08 +0400 Subject: [PATCH 3/7] Add min state duration to DualGovernanceState --- contracts/Configuration.sol | 1 + contracts/interfaces/IConfiguration.sol | 2 ++ contracts/libraries/DualGovernanceState.sol | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 9b3fbed8..49065045 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -31,6 +31,7 @@ contract Configuration is IConfiguration { 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; // Sealables Array Representation uint256 private immutable MAX_SELABLES_COUNT = 5; diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index 638b7c2c..14b25ee9 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -30,6 +30,8 @@ interface IDualGovernanceConfiguration { 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 sealableWithdrawalBlockers() external view returns (address[] memory); function getSignallingThresholdData() diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index 690d9f14..d49b842a 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -49,7 +49,9 @@ library DualGovernanceState { function activateNextState(Store storage self, IConfiguration config) internal returns (State newState) { State oldState = self.state; - if (oldState == State.Normal) { + 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); From 2ddb54e2c180fc0987fe65fc584b8de94d25ea03 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 3 Apr 2024 03:39:51 +0400 Subject: [PATCH 4/7] Add assets unlock delay to Escrow contract --- contracts/Configuration.sol | 1 + contracts/Escrow.sol | 12 ++-- contracts/interfaces/IConfiguration.sol | 1 + contracts/libraries/AssetsAccounting.sol | 66 ++++++++++++++------- contracts/libraries/DualGovernanceState.sol | 8 +-- contracts/libraries/Proposals.sol | 8 +-- contracts/utils/time.sol | 18 +++--- test/scenario/escrow.t.sol | 9 +++ test/utils/scenario-test-blueprint.sol | 2 +- 9 files changed, 83 insertions(+), 42 deletions(-) diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 49065045..58b134d5 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -32,6 +32,7 @@ contract Configuration is IConfiguration { 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 317b0939..8e8bd608 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -5,6 +5,7 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {IEscrow} from "./interfaces/IEscrow.sol"; +import {IConfiguration} from "./interfaces/IConfiguration.sol"; import {IStETH} from "./interfaces/IStETH.sol"; import {IWstETH} from "./interfaces/IWstETH.sol"; @@ -48,6 +49,8 @@ contract Escrow is IEscrow { IWstETH public immutable WST_ETH; IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; + IConfiguration public immutable CONFIG; + EscrowState internal _escrowState; IDualGovernance private _dualGovernance; AssetsAccounting.State private _accounting; @@ -58,11 +61,12 @@ contract Escrow is IEscrow { uint256 internal _rageQuitWithdrawalsTimelock; uint256 internal _rageQuitTimelockStartedAt; - constructor(address stETH, address wstETH, address withdrawalQueue) { + 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 { @@ -87,7 +91,7 @@ contract Escrow is IEscrow { } function unlockStETH() external { - uint256 sharesUnlocked = _accounting.accountStETHUnlock(msg.sender); + uint256 sharesUnlocked = _accounting.accountStETHUnlock(CONFIG.ESCROW_ASSETS_UNLOCK_DELAY(), msg.sender); ST_ETH.transferShares(msg.sender, sharesUnlocked); _activateNextGovernanceState(); } @@ -103,7 +107,7 @@ contract Escrow is IEscrow { } function unlockWstETH() external returns (uint256 wstETHUnlocked) { - wstETHUnlocked = _accounting.accountWstETHUnlock(msg.sender); + wstETHUnlocked = _accounting.accountWstETHUnlock(CONFIG.ESCROW_ASSETS_UNLOCK_DELAY(), msg.sender); WST_ETH.transfer(msg.sender, wstETHUnlocked); _activateNextGovernanceState(); } @@ -122,7 +126,7 @@ contract Escrow is IEscrow { } function unlockUnstETH(uint256[] memory unstETHIds) external { - _accounting.accountUnstETHUnlock(msg.sender, unstETHIds); + _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]); diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index 14b25ee9..7c1f87b8 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -31,6 +31,7 @@ interface IDualGovernanceConfiguration { 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); diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol index 73785153..2c878d78 100644 --- a/contracts/libraries/AssetsAccounting.sol +++ b/contracts/libraries/AssetsAccounting.sol @@ -5,6 +5,7 @@ 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 { @@ -81,6 +82,7 @@ library AssetsAccounting { 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; @@ -100,13 +102,19 @@ library AssetsAccounting { _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, address vetoer) internal returns (uint128 sharesUnlocked) { + 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); @@ -128,13 +136,19 @@ library AssetsAccounting { _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, address vetoer) internal returns (uint128 sharesUnlocked) { + 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); @@ -185,26 +199,30 @@ library AssetsAccounting { } 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, address vetoer, uint256[] memory unstETHIds) internal { - uint256 unstETHId; - uint256 sharesToUnlock; + function accountUnstETHUnlock( + State storage self, + uint256 assetsUnlockDelay, + address vetoer, + uint256[] memory unstETHIds + ) internal { + _checkAssetsUnlockDelayPassed(self, assetsUnlockDelay, vetoer); + uint256 totalUnstETHSharesToUnlock; uint256 totalFinalizedSharesToUnlock; uint256 totalFinalizedAmountToUnlock; - WithdrawalRequest storage request; - uint256 unstETHIdsCount = unstETHIds.length; - for (uint256 i = 0; i < unstETHIdsCount; ++i) { - unstETHId = unstETHIds[i]; - request = self.requests[unstETHId]; + for (uint256 i = 0; i < unstETHIds.length; ++i) { + uint256 unstETHId = unstETHIds[i]; + WithdrawalRequest storage request = self.requests[unstETHId]; _checkWithdrawalRequestOwner(request, vetoer); _checkWithdrawalRequestWasLocked(request, unstETHId); - sharesToUnlock = request.shares; + uint256 sharesToUnlock = request.shares; if (request.state == WithdrawalRequestState.Finalized) { totalFinalizedSharesToUnlock += sharesToUnlock; totalFinalizedAmountToUnlock += request.claimableAmount; @@ -223,17 +241,13 @@ library AssetsAccounting { totalUnstETHSharesToUnlock += sharesToUnlock; } - uint128 totalUnstETHSharesToUnlockUint128 = totalUnstETHSharesToUnlock.toUint128(); - uint128 totalFinalizedSharesToUnlockUint128 = totalFinalizedSharesToUnlock.toUint128(); - uint128 totalFinalizedAmountToUnlockUint128 = totalFinalizedAmountToUnlock.toUint128(); - - self.assets[vetoer].unstETHShares -= totalUnstETHSharesToUnlockUint128; - self.assets[vetoer].sharesFinalized -= totalFinalizedSharesToUnlockUint128; - self.assets[vetoer].amountFinalized -= totalFinalizedAmountToUnlockUint128; + self.assets[vetoer].unstETHShares -= totalUnstETHSharesToUnlock.toUint128(); + self.assets[vetoer].sharesFinalized -= totalFinalizedSharesToUnlock.toUint128(); + self.assets[vetoer].amountFinalized -= totalFinalizedAmountToUnlock.toUint128(); - self.totals.shares -= totalUnstETHSharesToUnlockUint128; - self.totals.amountFinalized -= totalFinalizedSharesToUnlockUint128; - self.totals.sharesFinalized -= totalFinalizedAmountToUnlockUint128; + self.totals.shares -= totalUnstETHSharesToUnlock.toUint128(); + self.totals.amountFinalized -= totalFinalizedSharesToUnlock.toUint128(); + self.totals.sharesFinalized -= totalFinalizedAmountToUnlock.toUint128(); emit UnstETHUnlocked( vetoer, unstETHIds, totalUnstETHSharesToUnlock, totalFinalizedSharesToUnlock, totalFinalizedAmountToUnlock ); @@ -469,4 +483,14 @@ library AssetsAccounting { 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 d49b842a..a5949c9a 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); @@ -73,7 +73,7 @@ library DualGovernanceState { } function setLastProposalCreationTimestamp(Store storage self) internal { - self.lastProposalCreatedAt = timestamp(); + self.lastProposalCreatedAt = TimeUtils.timestamp(); } function checkProposalsCreationAllowed(Store storage self) internal view { @@ -228,7 +228,7 @@ library DualGovernanceState { self.state = newState; - uint40 currentTime = timestamp(); + uint40 currentTime = TimeUtils.timestamp(); self.enteredAt = currentTime; } @@ -238,7 +238,7 @@ library DualGovernanceState { State oldState, State newState ) private { - uint40 currentTime = timestamp(); + uint40 currentTime = TimeUtils.timestamp(); // track the time when the governance state allowed execution if (oldState == State.Normal || oldState == State.VetoCooldown) { self.lastAdoptableStateExitedAt = currentTime; 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/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 48bdc5fe..994465a4 100644 --- a/test/scenario/escrow.t.sol +++ b/test/scenario/escrow.t.sol @@ -99,6 +99,8 @@ contract EscrowHappyPath is TestHelpers { _lockStETH(_VETOER_2, 3 * 10 ** 18); _lockWstETH(_VETOER_2, 5 * 10 ** 18); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); + _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); _unlockStETH(_VETOER_2); @@ -134,6 +136,8 @@ contract EscrowHappyPath is TestHelpers { uint256 secondVetoerStETHSharesAfterRebase = _ST_ETH.sharesOf(_VETOER_2); uint256 secondVetoerWstETHBalanceAfterRebase = _WST_ETH.balanceOf(_VETOER_2); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); + _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); @@ -176,6 +180,8 @@ contract EscrowHappyPath is TestHelpers { rebase(-100); + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); + _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); @@ -199,6 +205,9 @@ contract EscrowHappyPath is TestHelpers { uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); _lockUnstETH(_VETOER_1, unstETHIds); + + _wait(_config.ESCROW_ASSETS_UNLOCK_DELAY() + 1); + _unlockUnstETH(_VETOER_1, unstETHIds); } diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index 0129ce2b..4145265a 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -503,7 +503,7 @@ contract ScenarioTestBlueprint is Test { } function _deployEscrowMasterCopy() internal { - _escrowMasterCopy = new Escrow(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE); + _escrowMasterCopy = new Escrow(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, address(_config)); } function _finishTimelockSetup(address governance, bool isEmergencyProtectionEnabled) internal { From 8be737ce5660761cfa43abf66d062bb7186a3107 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 3 Apr 2024 12:40:57 +0400 Subject: [PATCH 5/7] After RageQuit move into VetoCooldown instead of Normal --- contracts/libraries/DualGovernanceState.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index a5949c9a..b32ed983 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -219,7 +219,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 { From 208f5a4631bcf01e656c9cce28503f16ed6d658e Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 3 Apr 2024 12:41:53 +0400 Subject: [PATCH 6/7] Add missing ArrayUtils lib --- contracts/utils/arrays.sol | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 contracts/utils/arrays.sol 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; + } + } +} From a48431cd9cac19e059c2507026b47fac113698e9 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Thu, 4 Apr 2024 02:33:46 +0400 Subject: [PATCH 7/7] Use standalone methods for withdrawal requests in AssetsAccounting lib --- contracts/libraries/AssetsAccounting.sol | 244 +++++++++++++---------- 1 file changed, 141 insertions(+), 103 deletions(-) diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol index 2c878d78..d067da0a 100644 --- a/contracts/libraries/AssetsAccounting.sol +++ b/contracts/libraries/AssetsAccounting.sol @@ -19,9 +19,9 @@ enum WithdrawalRequestState { struct WithdrawalRequest { address owner; uint96 claimableAmount; - WithdrawalRequestState state; - uint64 vetoerUnstETHIndexOneBased; uint128 shares; + uint64 vetoerUnstETHIndexOneBased; + WithdrawalRequestState state; } struct LockedAssetsStats { @@ -61,10 +61,10 @@ library AssetsAccounting { ); 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); - event WithdrawalRequestWithdrawn(uint256 indexed id, uint256 ethAmount); error NoBatchesToClaim(); error EmptyWithdrawalBatch(); @@ -174,28 +174,10 @@ library AssetsAccounting { ) internal { assert(unstETHIds.length == statuses.length); - uint256 unstETHId; - uint256 amountOfShares; uint256 totalUnstETHSharesLocked; - WithdrawalRequest storage request; uint256 unstETHcount = unstETHIds.length; for (uint256 i = 0; i < unstETHcount; ++i) { - unstETHId = unstETHIds[i]; - request = self.requests[unstETHId]; - - _checkWithdrawalRequestNotLocked(self, unstETHId); - _checkWithdrawalRequestStatusNotFinalized(statuses[i], unstETHId); - - self.vetoersUnstETHIds[vetoer].push(unstETHId); - - request.owner = vetoer; - request.state = WithdrawalRequestState.Locked; - request.vetoerUnstETHIndexOneBased = self.vetoersUnstETHIds[vetoer].length.toUint64(); - amountOfShares = statuses[i].amountOfShares; - request.shares = amountOfShares.toUint128(); - assert(request.claimableAmount == 0); - - totalUnstETHSharesLocked += amountOfShares; + totalUnstETHSharesLocked += _addWithdrawalRequest(self, vetoer, unstETHIds[i], statuses[i]); } uint128 totalUnstETHSharesLockedUint128 = totalUnstETHSharesLocked.toUint128(); self.assets[vetoer].unstETHShares += totalUnstETHSharesLockedUint128; @@ -212,44 +194,33 @@ library AssetsAccounting { ) internal { _checkAssetsUnlockDelayPassed(self, assetsUnlockDelay, vetoer); - uint256 totalUnstETHSharesToUnlock; - uint256 totalFinalizedSharesToUnlock; - uint256 totalFinalizedAmountToUnlock; - for (uint256 i = 0; i < unstETHIds.length; ++i) { - uint256 unstETHId = unstETHIds[i]; - WithdrawalRequest storage request = self.requests[unstETHId]; - - _checkWithdrawalRequestOwner(request, vetoer); - _checkWithdrawalRequestWasLocked(request, unstETHId); - - uint256 sharesToUnlock = request.shares; - if (request.state == WithdrawalRequestState.Finalized) { - totalFinalizedSharesToUnlock += sharesToUnlock; - totalFinalizedAmountToUnlock += 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]; - totalUnstETHSharesToUnlock += sharesToUnlock; + 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; } - self.assets[vetoer].unstETHShares -= totalUnstETHSharesToUnlock.toUint128(); - self.assets[vetoer].sharesFinalized -= totalFinalizedSharesToUnlock.toUint128(); - self.assets[vetoer].amountFinalized -= totalFinalizedAmountToUnlock.toUint128(); + 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; - self.totals.shares -= totalUnstETHSharesToUnlock.toUint128(); - self.totals.amountFinalized -= totalFinalizedSharesToUnlock.toUint128(); - self.totals.sharesFinalized -= totalFinalizedAmountToUnlock.toUint128(); emit UnstETHUnlocked( - vetoer, unstETHIds, totalUnstETHSharesToUnlock, totalFinalizedSharesToUnlock, totalFinalizedAmountToUnlock + vetoer, unstETHIds, totalUnstETHSharesUnlocked, totalFinalizedSharesUnlocked, totalFinalizedAmountUnlocked ); } @@ -258,30 +229,24 @@ library AssetsAccounting { uint256[] memory unstETHIds, uint256[] memory claimableAmounts ) internal { - uint256 claimableAmount; + assert(claimableAmounts.length == unstETHIds.length); + uint256 totalSharesFinalized; uint256 totalAmountFinalized; - WithdrawalRequest storage request; - - assert(claimableAmounts.length == unstETHIds.length); uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - request = self.requests[unstETHIds[i]]; - claimableAmount = claimableAmounts[i]; - if (claimableAmount == 0 || request.state != WithdrawalRequestState.Locked) { - continue; - } - request.state = WithdrawalRequestState.Finalized; - request.claimableAmount = claimableAmount.toUint96(); - totalSharesFinalized += request.shares; - totalAmountFinalized += claimableAmount; + (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); } @@ -290,25 +255,9 @@ library AssetsAccounting { uint256[] memory unstETHIds, uint256[] memory claimableAmounts ) internal returns (uint256 totalAmountClaimed) { - uint256 unstETHId; - uint256 claimableAmount; - WithdrawalRequest storage request; uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - unstETHId = unstETHIds[i]; - claimableAmount = claimableAmounts[i]; - 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; - totalAmountClaimed += claimableAmount; + totalAmountClaimed += _claimWithdrawalRequest(self, unstETHIds[i], claimableAmounts[i]); } self.totals.amountClaimed += totalAmountClaimed.toUint128(); emit UnstETHClaimed(unstETHIds, totalAmountClaimed); @@ -318,24 +267,12 @@ library AssetsAccounting { State storage self, address vetoer, uint256[] calldata unstETHIds - ) internal returns (uint256 ethAmount) { - uint256 unstETHId; - WithdrawalRequest storage request; + ) internal returns (uint256 amountWithdrawn) { uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - unstETHId = unstETHIds[i]; - 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; - ethAmount += request.claimableAmount; - emit WithdrawalRequestWithdrawn(unstETHId, ethAmount); + amountWithdrawn += _withdrawWithdrawalRequest(self, vetoer, unstETHIds[i]); } + emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); } // --- @@ -437,6 +374,107 @@ library AssetsAccounting { // 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); @@ -454,8 +492,8 @@ library AssetsAccounting { assert(!status.isClaimed); } - function _checkWithdrawalRequestNotLocked(State storage self, uint256 unstETHId) private view { - if (self.requests[unstETHId].vetoerUnstETHIndexOneBased != 0) { + function _checkWithdrawalRequestNotLocked(WithdrawalRequest storage request, uint256 unstETHId) private view { + if (request.vetoerUnstETHIndexOneBased != 0) { revert WithdrawalRequestAlreadyLocked(unstETHId); } }