diff --git a/.gitignore b/.gitignore index c5c69f9b..b75e4b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .env coverage/ coverage.json +.DS_Store # Hardhat files cache/ diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 272f2126..c139ff2c 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -23,6 +23,8 @@ interface IERC20 { function approve(address spender, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool); + + function transfer(address to, uint256 value) external returns (bool); } interface IStETH { @@ -48,7 +50,7 @@ interface IWithdrawalQueue { function getLastFinalizedRequestId() external view returns (uint256); - function safeTransferFrom(address from, address to, uint256 requestId) external; + function transferFrom(address from, address to, uint256 requestId) external; function getWithdrawalStatus(uint256[] calldata _requestIds) external @@ -75,11 +77,13 @@ contract Escrow { error NotClaimedWQRequests(); error FinalizedRequest(uint256); error RequestNotFound(uint256 id); + error SenderIsNotAllowed(); + error RequestIsNotFromBatch(uint256 id); + error RequestFromBatch(uint256 id); - event RageQuitAccumulationStarted(); event RageQuitStarted(); event WithdrawalsBatchRequested( - uint256 indexed firstRequestId, uint256 indexed lastRequestId, uint256 wstEthLeftToRequest + uint256 indexed firstRequestId, uint256 indexed lastRequestId, uint256 stEthLeftToRequest ); enum State { @@ -88,12 +92,30 @@ contract Escrow { } 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; + } + Configuration internal immutable CONFIG; address internal immutable ST_ETH; address internal immutable WST_ETH; @@ -107,15 +129,17 @@ contract Escrow { uint256 internal _totalWstEthInEthLocked; uint256 internal _totalWithdrawalNftsAmountLocked; uint256 internal _totalFinalizedWithdrawalNftsAmountLocked; - uint256 internal _escrowTotalShares; + uint256 internal _totalClaimedEthLocked; + + uint256 internal _totalEscrowShares; uint256 internal _claimedWQRequestsAmount; uint256 internal _rageQuitAmountTotal; uint256 internal _rageQuitAmountRequested; uint256 internal _lastWithdrawalRequestId; - mapping(address => HolderState) private balances; - mapping(uint256 => WithdrawalRequestStatus) private wqRequests; + mapping(address => HolderState) private _balances; + mapping(uint256 => WithdrawalRequestStatus) private _wqRequests; constructor(address config, address stEth, address wstEth, address withdrawalQueue, address burnerVault) { CONFIG = Configuration(config); @@ -123,18 +147,33 @@ contract Escrow { WST_ETH = wstEth; WITHDRAWAL_QUEUE = withdrawalQueue; BURNER_VAULT = burnerVault; + + _govState = address(this); } function initialize(address governanceState) external { if (_govState != address(0)) { revert Unauthorized(); } + _totalStEthInEthLocked = 1; + _totalEscrowShares = 1; _govState = governanceState; } /// /// 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(); @@ -142,7 +181,10 @@ contract Escrow { IERC20(ST_ETH).transferFrom(msg.sender, address(this), amount); - balances[msg.sender].stEthInEthShares += amount; + uint256 shares = _getSharesByETH(amount); + + _balances[msg.sender].stEthInEthShares += shares; + _totalEscrowShares += shares; _totalStEthInEthLocked += amount; _activateNextGovernanceState(); @@ -156,8 +198,10 @@ contract Escrow { IERC20(WST_ETH).transferFrom(msg.sender, address(this), amount); uint256 amountInEth = IStETH(ST_ETH).getPooledEthByShares(amount); + uint256 shares = _getSharesByETH(amountInEth); - balances[msg.sender].wstEthInEthShares = amountInEth; + _balances[msg.sender].wstEthInEthShares = shares; + _totalEscrowShares += shares; _totalWstEthInEthLocked += amountInEth; _activateNextGovernanceState(); @@ -179,12 +223,13 @@ contract Escrow { revert FinalizedRequest(id); } - IWithdrawalQueue(WITHDRAWAL_QUEUE).safeTransferFrom(sender, address(this), id); - wqRequests[id] = wqRequestStatuses[i]; + 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; + _balances[sender].wqRequestsBalance += wqRequestsAmount; _totalWithdrawalNftsAmountLocked += wqRequestsAmount; _activateNextGovernanceState(); @@ -196,12 +241,16 @@ contract Escrow { revert InvalidState(); } + burnRewards(); + address sender = msg.sender; - uint256 amount = balances[sender].stEthInEthShares; + uint256 escrowShares = _balances[sender].stEthInEthShares; + uint256 amount = _getETHByShares(escrowShares); - IERC20(ST_ETH).transferFrom(address(this), sender, amount); + IERC20(ST_ETH).transfer(sender, amount); - balances[sender].stEthInEthShares = 0; + _balances[sender].stEthInEthShares = 0; + _totalEscrowShares -= escrowShares; _totalStEthInEthLocked -= amount; _activateNextGovernanceState(); @@ -213,13 +262,17 @@ contract Escrow { revert InvalidState(); } + burnRewards(); + address sender = msg.sender; - uint256 amount = balances[sender].wstEthInEthShares; + uint256 escrowShares = _balances[sender].wstEthInEthShares; + uint256 amount = _getETHByShares(escrowShares); uint256 amountInShares = IStETH(ST_ETH).getSharesByPooledEth(amount); - IERC20(WST_ETH).transferFrom(address(this), sender, amountInShares); + IERC20(WST_ETH).transfer(sender, amountInShares); - balances[sender].wstEthInEthShares = 0; + _balances[sender].wstEthInEthShares = 0; + _totalEscrowShares -= escrowShares; _totalWstEthInEthLocked -= amount; _activateNextGovernanceState(); @@ -239,45 +292,67 @@ contract Escrow { for (uint256 i = 0; i < ids.length; ++i) { uint256 id = ids[i]; - if (wqRequests[ids[i]].owner == sender) { + if (_wqRequests[ids[i]].owner != sender) { revert SenderIsNotOwner(id); } - IWithdrawalQueue(WITHDRAWAL_QUEUE).safeTransferFrom(address(this), sender, id); - wqRequests[id].owner = address(0); - if (wqRequests[id].isFinalized == true) { + 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; } } - balances[sender].wqRequestsBalance -= wqRequestsAmount; - balances[sender].finalizedWqRequestsBalance -= finalizedWqRequestsAmount; + _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 claimETH() external { if (_state != State.RageQuit) { revert InvalidState(); } - if (_claimedWQRequestsAmount < (_totalStEthInEthLocked + _totalWstEthInEthLocked)) { + if (_claimedWQRequestsAmount < _rageQuitAmountTotal) { revert NotClaimedWQRequests(); } address sender = msg.sender; - HolderState memory state = balances[sender]; + HolderState memory state = _balances[sender]; - uint256 ethToClaim = - (state.stEthInEthShares + state.wqRequestsBalance) * _rageQuitAmountTotal / _escrowTotalShares; + uint256 ethToClaim = _getETHByShares(state.stEthInEthShares); + ethToClaim += _getETHByShares(state.wstEthInEthShares); + ethToClaim += _balances[sender].eth; - balances[sender].stEthInEthShares = 0; - balances[sender].wstEthInEthShares = 0; + _balances[sender].stEthInEthShares = 0; + _balances[sender].wstEthInEthShares = 0; + _balances[sender].eth = 0; - payable(sender).transfer(ethToClaim); + if (ethToClaim > 0) { + payable(sender).transfer(ethToClaim); + } } /// @@ -285,18 +360,29 @@ contract Escrow { /// function burnRewards() public { - if (_state != State.RageQuit) { - revert InvalidState(); - } - + uint256 minRewardsAmount = 1e9; uint256 wstEthLocked = IStETH(ST_ETH).getSharesByPooledEth(_totalWstEthInEthLocked); - uint256 wstEthRewards = IERC20(WST_ETH).balanceOf(address(this)) - wstEthLocked; + uint256 wstEthBalance = IERC20(WST_ETH).balanceOf(address(this)); - IWstETH(WST_ETH).unwrap(wstEthRewards); + uint256 stEthBalance = IERC20(ST_ETH).balanceOf(address(this)); - uint256 stEthRewards = IERC20(ST_ETH).balanceOf(address(this)) - _totalStEthInEthLocked; + if (wstEthLocked + minRewardsAmount < wstEthBalance) { + uint256 wstEthRewards = wstEthBalance - wstEthLocked; + IWstETH(WST_ETH).unwrap(wstEthRewards); + } + if (wstEthLocked > wstEthBalance) { + _totalWstEthInEthLocked = IStETH(ST_ETH).getPooledEthByShares(wstEthBalance); + } + + uint256 stEthRewards = 0; - IERC20(ST_ETH).transferFrom(address(this), BURNER_VAULT, stEthRewards); + if (_totalStEthInEthLocked < stEthBalance) { + stEthBalance = IERC20(ST_ETH).balanceOf(address(this)); + stEthRewards = stEthBalance - _totalStEthInEthLocked; + IERC20(ST_ETH).transfer(BURNER_VAULT, stEthRewards); + } else { + _totalStEthInEthLocked = stEthBalance; + } } function checkForFinalization(uint256[] memory ids) public { @@ -308,20 +394,20 @@ contract Escrow { for (uint256 i = 0; i < ids.length; ++i) { uint256 id = ids[i]; - address requestOwner = wqRequests[ids[i]].owner; + 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; + 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; + _balances[requestOwner].wqRequestsBalance -= _wqRequests[id].amountOfStETH; + _balances[requestOwner].finalizedWqRequestsBalance += wqRequestStatuses[i].amountOfStETH; - wqRequests[id].amountOfStETH = wqRequestStatuses[i].amountOfStETH; - wqRequests[id].isFinalized = true; + _wqRequests[id].amountOfStETH = wqRequestStatuses[i].amountOfStETH; + _wqRequests[id].isFinalized = true; } } } @@ -333,7 +419,8 @@ contract Escrow { _totalStEthInEthLocked + _totalWstEthInEthLocked + _totalWithdrawalNftsAmountLocked; rageQuitSupport = (totalRageQuitStEthLocked * 10 ** 18) / stEthTotalSupply; - uint256 totalStakedEthLocked = totalRageQuitStEthLocked + _totalWithdrawalNftsAmountLocked; + uint256 totalStakedEthLocked = + totalRageQuitStEthLocked + _totalFinalizedWithdrawalNftsAmountLocked + _totalClaimedEthLocked; totalSupport = (totalStakedEthLocked * 10 ** 18) / stEthTotalSupply; } @@ -350,7 +437,7 @@ contract Escrow { assert(_rageQuitAmountRequested == 0); assert(_lastWithdrawalRequestId == 0); - _rageQuitAmountTotal = _totalWstEthInEthLocked + _totalStEthInEthLocked; + _rageQuitAmountTotal = _totalStEthInEthLocked + _totalWstEthInEthLocked; _state = State.RageQuit; @@ -365,7 +452,7 @@ contract Escrow { } function requestNextWithdrawalsBatch(uint256 maxNumRequests) external returns (uint256, uint256, uint256) { - if (_state == State.RageQuit) { + if (_state != State.RageQuit) { revert InvalidState(); } @@ -410,6 +497,13 @@ contract Escrow { 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]; + } + uint256 lastRequestId = reqIds[reqIds.length - 1]; _lastWithdrawalRequestId = lastRequestId; @@ -434,20 +528,79 @@ contract Escrow { for (uint256 i = 0; i < requestIds.length; ++i) { uint256 id = requestIds[i]; - address owner = wqRequests[id].owner; - if (owner == address(this)) { - _claimedWQRequestsAmount += wqRequestStatuses[i].amountOfStETH; - } else { - balances[owner].finalizedWqRequestsBalance += wqRequestStatuses[i].amountOfStETH; - balances[owner].wqRequestsBalance -= wqRequests[id].amountOfStETH; + address owner = _wqRequests[id].owner; - _totalFinalizedWithdrawalNftsAmountLocked += wqRequestStatuses[i].amountOfStETH; - _totalWithdrawalNftsAmountLocked -= wqRequests[id].amountOfStETH; + if (owner != address(this)) { + revert RequestIsNotFromBatch(id); + } + _claimedWQRequestsAmount += 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 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; + } + } + } + } + + receive() external payable { + if (msg.sender != WITHDRAWAL_QUEUE) { + revert SenderIsNotAllowed(); + } + } + function _activateNextGovernanceState() internal { GovernanceState(_govState).activateNextState(); } + + function _getSharesByETH(uint256 eth) internal view returns (uint256 shares) { + uint256 totalEthLocked = _totalStEthInEthLocked + _totalWstEthInEthLocked; + + shares = eth * _totalEscrowShares / totalEthLocked; + } + + function _getETHByShares(uint256 shares) internal view returns (uint256 eth) { + uint256 totalEthLocked = _totalStEthInEthLocked + _totalWstEthInEthLocked; + + eth = shares * totalEthLocked / _totalEscrowShares; + } } diff --git a/contracts/GovernanceState.sol b/contracts/GovernanceState.sol index 42f537ff..6b334238 100644 --- a/contracts/GovernanceState.sol +++ b/contracts/GovernanceState.sol @@ -111,7 +111,7 @@ contract GovernanceState { function _deployNewSignallingEscrow() internal { uint256 escrowIndex = _escrowIndex++; - Escrow escrow = Escrow(Clones.cloneDeterministic(ESCROW_IMPL, bytes32(escrowIndex))); + Escrow escrow = Escrow(payable(Clones.cloneDeterministic(ESCROW_IMPL, bytes32(escrowIndex)))); escrow.initialize(address(this)); _signallingEscrow = escrow; emit NewSignallingEscrowDeployed(address(escrow), escrowIndex); diff --git a/test/scenario/agent-timelock.t.sol b/test/scenario/agent-timelock.t.sol index 430d41e2..9deb4a3f 100644 --- a/test/scenario/agent-timelock.t.sol +++ b/test/scenario/agent-timelock.t.sol @@ -34,6 +34,7 @@ contract AgentTimelockTest is DualGovernanceSetup { WST_ETH, BURNER, WITHDRAWAL_QUEUE, + DAO_VOTING, AGENT_TIMELOCK_DURATION, emergencyMultisig, EMERGENCY_MULTISIG_ACTIVE_FOR diff --git a/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol new file mode 100644 index 00000000..3dcbefc2 --- /dev/null +++ b/test/scenario/escrow.t.sol @@ -0,0 +1,506 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {TransparentUpgradeableProxy} from "contracts/TransparentUpgradeableProxy.sol"; +import {Configuration} from "contracts/Configuration.sol"; + +import {Escrow} from "contracts/Escrow.sol"; +import {BurnerVault} from "contracts/BurnerVault.sol"; + +import "forge-std/Test.sol"; + +import "../utils/mainnet-addresses.sol"; +import "../utils/utils.sol"; + +import {DualGovernanceSetup} from "./setup.sol"; + +contract TestHelpers is DualGovernanceSetup { + 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)); + + int256 delta = (deltaBP * int256(totalSupply) / 10000); + vm.store(ST_ETH, CL_BALANCE_POSITION, bytes32(uint256(int256(clBalance) + delta))); + + assertEq( + uint256(int256(totalSupply) * deltaBP / 10000 + int256(totalSupply)), + IERC20(ST_ETH).totalSupply(), + "total supply" + ); + } + + function finalizeWQ() public { + uint256 lastRequestId = IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastRequestId(); + finalizeWQ(lastRequestId); + } + + function finalizeWQ(uint256 id) public { + uint256 finalizationShareRate = IStEth(ST_ETH).getPooledEthByShares(1e27) + 1e9; // TODO check finalization rate + address lido = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + vm.prank(lido); + IWithdrawalQueue(WITHDRAWAL_QUEUE).finalize(id, finalizationShareRate); + + bytes32 LOCKED_ETHER_AMOUNT_POSITION = 0x0e27eaa2e71c8572ab988fef0b54cd45bbd1740de1e22343fb6cda7536edc12f; // keccak256("lido.WithdrawalQueue.lockedEtherAmount"); + + vm.store(WITHDRAWAL_QUEUE, LOCKED_ETHER_AMOUNT_POSITION, bytes32(address(WITHDRAWAL_QUEUE).balance)); + } +} + +contract EscrowHappyPath is TestHelpers { + Escrow internal escrow; + BurnerVault internal burnerVault; + GovernanceState__mock internal govState; + + address internal stEthHolder1; + address internal stEthHolder2; + + address internal proxyAdmin = makeAddr("proxy_admin"); + + 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" + ); + } + + function setUp() external { + Utils.selectFork(); + Utils.removeLidoStakingLimit(); + + TransparentUpgradeableProxy config; + (, config,) = deployConfig(DAO_VOTING); + + Escrow escrowImpl; + (escrowImpl, burnerVault) = + deployEscrowImplementation(ST_ETH, WST_ETH, WITHDRAWAL_QUEUE, BURNER, address(config)); + + escrow = + Escrow(payable(address(new TransparentUpgradeableProxy(address(escrowImpl), proxyAdmin, new bytes(0))))); + + govState = new GovernanceState__mock(); + + escrow.initialize(address(govState)); + + stEthHolder1 = makeAddr("steth_holder_1"); + Utils.setupStEthWhale(stEthHolder1); + + vm.startPrank(stEthHolder1); + IERC20(ST_ETH).approve(WST_ETH, 1e30); + + IWstETH(WST_ETH).wrap(1e24); + + 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(); + + stEthHolder2 = makeAddr("steth_holder_2"); + Utils.setupStEthWhale(stEthHolder2); + + vm.startPrank(stEthHolder2); + IERC20(ST_ETH).approve(WST_ETH, 1e30); + + IWstETH(WST_ETH).wrap(1e24); + + 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(); + } + + 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); + + lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); + lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + + unlockAssets(stEthHolder1, true, true, new uint256[](0)); + unlockAssets(stEthHolder2, true, true, new uint256[](0)); + + 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); + } + + function test_lock_unlock_w_rebase() public { + uint256 amountToLock = 1e18; + uint256 wstEthAmountToLock = IStEth(ST_ETH).getSharesByPooledEth(amountToLock); + + lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); + lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + + rebase(100); + + uint256 wstEthAmountToUnlock = 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); + + unlockAssets(stEthHolder1, true, true, new uint256[](0)); + unlockAssets(stEthHolder2, true, true, new uint256[](0)); + + 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); + } + + function test_lock_unlock_w_negative_rebase() public { + int256 rebaseBP = -100; + 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); + + lockAssets(stEthHolder1, amountToLock, wstEthAmountToLock, new uint256[](0)); + lockAssets(stEthHolder2, 2 * amountToLock, 2 * wstEthAmountToLock, new uint256[](0)); + + rebase(rebaseBP); + escrow.burnRewards(); + + unlockAssets(stEthHolder1, true, true, new uint256[](0)); + unlockAssets(stEthHolder2, true, true, new uint256[](0)); + + 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); + } + + function test_lock_unlock_withdrawal_nfts() public { + 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); + + lockAssets(stEthHolder1, 0, 0, ids); + + unlockAssets(stEthHolder1, false, false, ids); + } + + function test_lock_withdrawal_nfts_reverts_on_finalized() public { + uint256[] memory amounts = new uint256[](3); + for (uint256 i = 0; i < 3; ++i) { + amounts[i] = 1e18; + } + + vm.prank(stEthHolder1); + uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + + finalizeWQ(); + + vm.prank(stEthHolder1); + vm.expectRevert(); + escrow.lockWithdrawalNFT(ids); + } + + function test_check_finalization() public { + 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); + + lockAssets(stEthHolder1, 0, 0, ids); + + Escrow.Balance memory balance = escrow.balanceOf(stEthHolder1); + assertEq(balance.wqRequestsBalance, 2 * 1e18); + assertEq(balance.finalizedWqRequestsBalance, 0); + + finalizeWQ(ids[0]); + escrow.checkForFinalization(ids); + + balance = escrow.balanceOf(stEthHolder1); + assertEq(balance.wqRequestsBalance, 1e18); + assertEq(balance.finalizedWqRequestsBalance, 1e18); + } + + function test_get_signaling_state() public { + uint256[] memory amounts = new uint256[](2); + for (uint256 i = 0; i < 2; ++i) { + amounts[i] = 1e18; + } + + uint256 totalSupply = IERC20(ST_ETH).totalSupply(); + + vm.prank(stEthHolder1); + uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + + lockAssets(stEthHolder1, 1e18, IStEth(ST_ETH).getSharesByPooledEth(1e18), ids); + + Escrow.Balance memory balance = escrow.balanceOf(stEthHolder1); + assertEq(balance.wqRequestsBalance, 2 * 1e18); + assertEq(balance.finalizedWqRequestsBalance, 0); + + (uint256 totalSupport, uint256 rageQuitSupport) = escrow.getSignallingState(); + assertEq(totalSupport, 4 * 1e18 * 1e18 / totalSupply); + assertEq(rageQuitSupport, 4 * 1e18 * 1e18 / totalSupply); + + finalizeWQ(ids[0]); + escrow.checkForFinalization(ids); + + balance = escrow.balanceOf(stEthHolder1); + assertEq(balance.wqRequestsBalance, 1e18); + assertEq(balance.finalizedWqRequestsBalance, 1e18); + + (totalSupport, rageQuitSupport) = escrow.getSignallingState(); + assertEq(totalSupport, 4 * 1e18 * 1e18 / totalSupply); + assertEq(rageQuitSupport, 3 * 1e18 * 1e18 / totalSupply); + } + + function test_rage_quit() public { + uint256 requestAmount = 1000 * 1e18; + uint256[] memory amounts = new uint256[](10); + for (uint256 i = 0; i < 10; ++i) { + amounts[i] = requestAmount; + } + + vm.prank(stEthHolder1); + uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + + lockAssets(stEthHolder1, 20 * requestAmount, IStEth(ST_ETH).getSharesByPooledEth(20 * requestAmount), ids); + + rebase(100); + + vm.expectRevert(); + escrow.startRageQuit(); + + vm.prank(address(govState)); + escrow.startRageQuit(); + + assertEq(IWithdrawalQueue(WITHDRAWAL_QUEUE).balanceOf(address(escrow)), 10); + + escrow.requestNextWithdrawalsBatch(10); + + assertEq(IWithdrawalQueue(WITHDRAWAL_QUEUE).balanceOf(address(escrow)), 20); + + escrow.requestNextWithdrawalsBatch(200); + + assertEq(IWithdrawalQueue(WITHDRAWAL_QUEUE).balanceOf(address(escrow)), 50); + 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() + ); + + escrow.claimWithdrawalRequests(ids, hints); + + assertEq(escrow.isRageQuitFinalized(), true); + + vm.expectRevert(); + vm.prank(stEthHolder1); + escrow.claimETH(); + + uint256[] memory escrowRequestIds = new uint256[](40); + for (uint256 i = 0; i < 40; ++i) { + escrowRequestIds[i] = ids[9] + i + 1; + } + + uint256[] memory escrowRequestHints = IWithdrawalQueue(WITHDRAWAL_QUEUE).findCheckpointHints( + escrowRequestIds, + IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() - 2, + IWithdrawalQueue(WITHDRAWAL_QUEUE).getLastCheckpointIndex() + ); + + vm.expectRevert(); + vm.prank(stEthHolder1); + escrow.claimETH(); + + escrow.claimNextETHBatch(escrowRequestIds, escrowRequestHints); + + vm.prank(stEthHolder1); + escrow.claimETH(); + } + + function test_wq_requests_only_happy_path() public { + uint256 requestAmount = 10 * 1e18; + uint256 requestsCount = 10; + uint256[] memory amounts = new uint256[](requestsCount); + for (uint256 i = 0; i < requestsCount; ++i) { + amounts[i] = requestAmount; + } + + vm.prank(stEthHolder1); + uint256[] memory ids = IWithdrawalQueue(WITHDRAWAL_QUEUE).requestWithdrawals(amounts, stEthHolder1); + + lockAssets(stEthHolder1, 0, 0, ids); + + vm.prank(address(govState)); + escrow.startRageQuit(); + + 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); + } + + 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); + + for (uint256 i = 0; i < wqRequestIds.length; ++i) { + assertEq(statuses[i].owner, address(escrow)); + wqRequestsAmount += statuses[i].amountOfStETH; + } + + escrow.unlockWithdrawalNFT(wqRequestIds); + } + + assertEq( + escrow.balanceOf(owner), + Escrow.Balance( + unlockStEth ? 0 : balanceBefore.stEth, + unlockWstEth ? 0 : balanceBefore.wstEth, + balanceBefore.wqRequestsBalance - wqRequestsAmount, + balanceBefore.finalizedWqRequestsBalance, + 0, + new uint256[](0) + ) + ); + + uint256 expectedStEthAmount = uint256(int256(balanceBefore.stEth) * (10000 + rebaseBP) / 10000); + uint256 expectedWstEthAmount = uint256(int256(balanceBefore.wstEth) * (10000 + rebaseBP) / 10000); + + assertApproxEqAbs(IERC20(ST_ETH).balanceOf(owner), stEthBalanceBefore + expectedStEthAmount, 3); + assertApproxEqAbs(IERC20(WST_ETH).balanceOf(owner), wstEthBalanceBefore + expectedWstEthAmount, 3); + + 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; + } +} diff --git a/test/scenario/happy-path.t.sol b/test/scenario/happy-path.t.sol index 111f4a5e..a90d7930 100644 --- a/test/scenario/happy-path.t.sol +++ b/test/scenario/happy-path.t.sol @@ -15,7 +15,7 @@ import {DualGovernanceSetup} from "./setup.sol"; abstract contract DualGovernanceUtils is TestAssertions { function updateVetoSupport(DualGovernance dualGov, uint256 supportPercentage) internal { - Escrow signallingEscrow = Escrow(dualGov.signallingEscrow()); + Escrow signallingEscrow = Escrow(payable(dualGov.signallingEscrow())); uint256 newVetoSupport = (supportPercentage * IERC20(ST_ETH).totalSupply()) / 10 ** 18; // uint256 currentVetoSupport = signallingEscrow.totalStEthLocked(); @@ -59,6 +59,7 @@ contract HappyPathTest is DualGovernanceSetup, DualGovernanceUtils { WST_ETH, WITHDRAWAL_QUEUE, BURNER, + DAO_VOTING, timelockDuration, timelockEmergencyMultisig, timelockEmergencyMultisigActiveFor diff --git a/test/scenario/setup.sol b/test/scenario/setup.sol index 794bad3a..638ffc32 100644 --- a/test/scenario/setup.sol +++ b/test/scenario/setup.sol @@ -22,26 +22,46 @@ abstract contract DualGovernanceSetup is TestAssertions { ProxyAdmin configAdmin; EmergencyProtectedTimelock timelock; OwnableExecutor adminExecutor; - BurnerVault burnerVault; } uint256 internal constant ARAGON_VOTING_SYSTEM_ID = 1; + function deployEscrowImplementation( + address stEth, + address wstEth, + address withdrawalQueue, + address burner, + address config + ) public returns (Escrow escrowImpl, BurnerVault burnerVault) { + burnerVault = new BurnerVault(burner, stEth, wstEth); + escrowImpl = new Escrow(config, stEth, wstEth, withdrawalQueue, address(burnerVault)); + } + + function deployConfig(address voting) + public + returns (ProxyAdmin configAdmin, TransparentUpgradeableProxy configProxy, Configuration configImpl) + { + // deploy initial config impl + configImpl = new Configuration(voting); + + // deploy config proxy + configAdmin = new ProxyAdmin(address(this)); + configProxy = new TransparentUpgradeableProxy(address(configImpl), address(configAdmin), new bytes(0)); + } + function deployDG( address stEth, address wstEth, address withdrawalQueue, address burner, + address voting, uint256 timelockDuration, address timelockEmergencyMultisig, uint256 timelockEmergencyMultisigActiveFor ) public returns (Deployed memory d) { - // deploy initial config impl - address configImpl = address(new Configuration(DAO_VOTING)); + Configuration configImpl; - // deploy config proxy - d.configAdmin = new ProxyAdmin(address(this)); - d.config = new TransparentUpgradeableProxy(configImpl, address(d.configAdmin), new bytes(0)); + (d.configAdmin, d.config, configImpl) = deployConfig(voting); // initially owner of the admin is set to the deployer // to configure setup @@ -49,18 +69,18 @@ abstract contract DualGovernanceSetup is TestAssertions { d.timelock = new EmergencyProtectedTimelock( address(d.adminExecutor), - DAO_VOTING // maybe emergency governance should be Agent + voting // maybe emergency governance should be Agent ); // solhint-disable-next-line console.log("Timelock deployed to %x", address(d.timelock)); - address burner_vault = address(new BurnerVault(burner, stEth, wstEth)); - // deploy DG - address escrowImpl = address(new Escrow(address(d.config), stEth, wstEth, withdrawalQueue, burner_vault)); - d.dualGov = - new DualGovernance(address(d.config), configImpl, address(d.configAdmin), escrowImpl, address(d.timelock)); + (Escrow escrowImpl,) = deployEscrowImplementation(stEth, wstEth, withdrawalQueue, burner, address(d.config)); + + d.dualGov = new DualGovernance( + address(d.config), address(configImpl), address(d.configAdmin), address(escrowImpl), address(d.timelock) + ); // solhint-disable-next-line console.log("DG deployed to %x", address(d.dualGov)); diff --git a/test/utils/interfaces.sol b/test/utils/interfaces.sol index 7426d787..625c8e3d 100644 --- a/test/utils/interfaces.sol +++ b/test/utils/interfaces.sol @@ -2,6 +2,15 @@ pragma solidity 0.8.23; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +struct WithdrawalRequestStatus { + uint256 amountOfStETH; + uint256 amountOfShares; + address owner; + uint256 timestamp; + bool isFinalized; + bool isClaimed; +} + interface IAragonAgent { function RUN_SCRIPT_ROLE() external pure returns (bytes32); } @@ -37,4 +46,33 @@ interface IStEth { function STAKING_CONTROL_ROLE() external view returns (bytes32); function submit(address referral) external payable returns (uint256); function removeStakingLimit() external; + function getSharesByPooledEth(uint256 ethAmount) external view returns (uint256); + function getPooledEthByShares(uint256 sharesAmount) external view returns (uint256); +} + +interface IWstETH { + function wrap(uint256 stETHAmount) external returns (uint256); + function unwrap(uint256 wstETHAmount) external returns (uint256); +} + +interface IWithdrawalQueue { + function getWithdrawalStatus(uint256[] calldata _requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses); + function requestWithdrawalsWstETH(uint256[] calldata amounts, address owner) external returns (uint256[] memory); + function requestWithdrawals(uint256[] calldata amounts, address owner) external returns (uint256[] memory); + function setApprovalForAll(address _operator, bool _approved) external; + function balanceOf(address owner) external view returns (uint256); + function MAX_STETH_WITHDRAWAL_AMOUNT() external view returns (uint256); + function getLastRequestId() external view returns (uint256); + function findCheckpointHints( + uint256[] calldata _requestIds, + uint256 _firstIndex, + uint256 _lastIndex + ) external view returns (uint256[] memory hintIds); + function getLastCheckpointIndex() external view returns (uint256); + function claimWithdrawals(uint256[] calldata requestIds, uint256[] calldata hints) external; + function getLastFinalizedRequestId() external view returns (uint256); + function finalize(uint256 _lastRequestIdToBeFinalized, uint256 _maxShareRate) external payable; }