From 691032433dba5e000abfd126868e525c3f896582 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Thu, 20 Jun 2024 21:04:56 +0400 Subject: [PATCH 01/13] Accounting in Escrow using only stETH --- contracts/Escrow.sol | 62 +++++------- contracts/libraries/AssetsAccounting.sol | 48 +-------- test/scenario/escrow.t.sol | 124 +++++++++++++---------- test/utils/scenario-test-blueprint.sol | 17 +++- 4 files changed, 114 insertions(+), 137 deletions(-) diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 2488acb5..7b967e9b 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -27,7 +27,6 @@ enum EscrowState { struct VetoerState { uint256 stETHShares; - uint256 wstETHShares; uint256 unstETHShares; } @@ -80,6 +79,7 @@ contract Escrow is IEscrow { _escrowState = EscrowState.SignallingEscrow; _dualGovernance = IDualGovernance(dualGovernance); + ST_ETH.approve(address(WST_ETH), type(uint256).max); ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); WST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); } @@ -90,54 +90,37 @@ contract Escrow is IEscrow { function lockStETH(uint256 amount) external { uint256 shares = ST_ETH.getSharesByPooledEth(amount); - _accounting.accountStETHLock(msg.sender, shares); + _accounting.accountStETHSharesLock(msg.sender, shares); ST_ETH.transferSharesFrom(msg.sender, address(this), shares); _activateNextGovernanceState(); } function unlockStETH() external { _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); - uint256 sharesUnlocked = _accounting.accountStETHUnlock(msg.sender); + uint256 sharesUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); ST_ETH.transferShares(msg.sender, sharesUnlocked); _activateNextGovernanceState(); } - function requestWithdrawalsStETH(uint256[] calldata amounts) external returns (uint256[] memory unstETHIds) { - unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(amounts, address(this)); - WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); - - uint256 sharesTotal = 0; - for (uint256 i = 0; i < statuses.length; ++i) { - sharesTotal += statuses[i].amountOfShares; - } - _accounting.accountStETHUnlock(msg.sender, sharesTotal); - _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); - } - // --- // Lock / Unlock wstETH // --- function lockWstETH(uint256 amount) external { - _accounting.accountWstETHLock(msg.sender, amount); WST_ETH.transferFrom(msg.sender, address(this), amount); + uint256 stETHAmount = WST_ETH.unwrap(amount); + _accounting.accountStETHSharesLock(msg.sender, ST_ETH.getSharesByPooledEth(stETHAmount)); _activateNextGovernanceState(); } function unlockWstETH() external returns (uint256 wstETHUnlocked) { _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); - wstETHUnlocked = _accounting.accountWstETHUnlock(msg.sender); - WST_ETH.transfer(msg.sender, wstETHUnlocked); + wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + uint256 wstETHAmount = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked)); + WST_ETH.transfer(msg.sender, wstETHAmount); _activateNextGovernanceState(); } - function requestWithdrawalsWstETH(uint256[] calldata amounts) external returns (uint256[] memory unstETHIds) { - uint256 totalAmount = ArrayUtils.sum(amounts); - _accounting.accountWstETHUnlock(msg.sender, totalAmount); - unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawalsWstETH(amounts, address(this)); - _accounting.accountUnstETHLock(msg.sender, unstETHIds, WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds)); - } - // --- // Lock / Unlock unstETH // --- @@ -166,6 +149,22 @@ contract Escrow is IEscrow { _accounting.accountUnstETHFinalized(unstETHIds, claimableAmounts); } + // --- + // Convert to NFT + // --- + + function requestWithdrawals(uint256[] calldata stEthAmounts) external returns (uint256[] memory unstETHIds) { + unstETHIds = WITHDRAWAL_QUEUE.requestWithdrawals(stEthAmounts, address(this)); + WithdrawalRequestStatus[] memory statuses = WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + + uint256 sharesTotal = 0; + for (uint256 i = 0; i < statuses.length; ++i) { + sharesTotal += statuses[i].amountOfShares; + } + _accounting.accountStETHSharesUnlock(msg.sender, sharesTotal); + _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); + } + // --- // State Updates // --- @@ -230,19 +229,13 @@ contract Escrow is IEscrow { // Withdraw Logic // --- - function withdrawStETHAsETH() external { - _checkEscrowState(EscrowState.RageQuitEscrow); - _checkWithdrawalsTimelockPassed(); - Address.sendValue(payable(msg.sender), _accounting.accountStETHWithdraw(msg.sender)); - } - - function withdrawWstETHAsETH() external { + function withdrawETH() external { _checkEscrowState(EscrowState.RageQuitEscrow); _checkWithdrawalsTimelockPassed(); - Address.sendValue(payable(msg.sender), _accounting.accountWstETHWithdraw(msg.sender)); + Address.sendValue(payable(msg.sender), _accounting.accountStETHSharesWithdraw(msg.sender)); } - function withdrawUnstETHAsETH(uint256[] calldata unstETHIds) external { + function withdrawETH(uint256[] calldata unstETHIds) external { _checkEscrowState(EscrowState.RageQuitEscrow); _checkWithdrawalsTimelockPassed(); Address.sendValue(payable(msg.sender), _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds)); @@ -259,7 +252,6 @@ contract Escrow is IEscrow { 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; } diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol index afee69ec..acbfefc5 100644 --- a/contracts/libraries/AssetsAccounting.sol +++ b/contracts/libraries/AssetsAccounting.sol @@ -26,7 +26,6 @@ struct WithdrawalRequest { struct LockedAssetsStats { uint128 stETHShares; - uint128 wstETHShares; uint128 unstETHShares; uint128 sharesFinalized; uint128 amountFinalized; @@ -47,10 +46,6 @@ library AssetsAccounting { 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, @@ -99,7 +94,7 @@ library AssetsAccounting { // stETH Operations Accounting // --- - function accountStETHLock(State storage self, address vetoer, uint256 shares) internal { + function accountStETHSharesLock(State storage self, address vetoer, uint256 shares) internal { _checkNonZeroSharesLock(vetoer, shares); uint128 sharesUint128 = shares.toUint128(); self.assets[vetoer].stETHShares += sharesUint128; @@ -108,11 +103,11 @@ library AssetsAccounting { emit StETHLocked(vetoer, shares); } - function accountStETHUnlock(State storage self, address vetoer) internal returns (uint128 sharesUnlocked) { - sharesUnlocked = accountStETHUnlock(self, vetoer, self.assets[vetoer].stETHShares); + function accountStETHSharesUnlock(State storage self, address vetoer) internal returns (uint128 sharesUnlocked) { + sharesUnlocked = accountStETHSharesUnlock(self, vetoer, self.assets[vetoer].stETHShares); } - function accountStETHUnlock( + function accountStETHSharesUnlock( State storage self, address vetoer, uint256 shares @@ -124,7 +119,7 @@ library AssetsAccounting { emit StETHUnlocked(vetoer, sharesUnlocked); } - function accountStETHWithdraw(State storage self, address vetoer) internal returns (uint256 ethAmount) { + function accountStETHSharesWithdraw(State storage self, address vetoer) internal returns (uint256 ethAmount) { uint256 stETHShares = self.assets[vetoer].stETHShares; _checkNonZeroSharesWithdraw(vetoer, stETHShares); self.assets[vetoer].stETHShares = 0; @@ -140,39 +135,6 @@ library AssetsAccounting { _checkAssetsUnlockDelayPassed(self, delay, vetoer); } - function accountWstETHLock(State storage self, address vetoer, uint256 shares) internal { - _checkNonZeroSharesLock(vetoer, shares); - uint128 sharesUint128 = shares.toUint128(); - self.assets[vetoer].wstETHShares += sharesUint128; - self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); - self.totals.shares += sharesUint128; - emit WstETHLocked(vetoer, shares); - } - - function accountWstETHUnlock(State storage self, address vetoer) internal returns (uint128 sharesUnlocked) { - sharesUnlocked = accountWstETHUnlock(self, vetoer, self.assets[vetoer].wstETHShares); - } - - function accountWstETHUnlock( - State storage self, - address vetoer, - uint256 shares - ) internal returns (uint128 sharesUnlocked) { - _checkNonZeroSharesUnlock(vetoer, shares); - sharesUnlocked = shares.toUint128(); - self.totals.shares -= sharesUnlocked; - self.assets[vetoer].wstETHShares -= sharesUnlocked; - 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 // --- diff --git a/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol index 66a07544..10d590dd 100644 --- a/test/scenario/escrow.t.sol +++ b/test/scenario/escrow.t.sol @@ -88,29 +88,35 @@ contract EscrowHappyPath is TestHelpers { function test_lock_unlock() public { uint256 firstVetoerStETHBalanceBefore = _ST_ETH.balanceOf(_VETOER_1); - uint256 firstVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_1); - - uint256 secondVetoerStETHBalanceBefore = _ST_ETH.balanceOf(_VETOER_2); uint256 secondVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_2); - _lockStETH(_VETOER_1, 10 ** 18); - _lockWstETH(_VETOER_1, 2 * 10 ** 18); + uint256 firstVetoerLockStETHAmount = 1 ether; + uint256 firstVetoerLockWstETHAmount = 2 ether; + + uint256 secondVetoerLockStETHAmount = 3 ether; + uint256 secondVetoerLockWstETHAmount = 5 ether; + + _lockStETH(_VETOER_1, firstVetoerLockStETHAmount); + _lockWstETH(_VETOER_1, firstVetoerLockWstETHAmount); - _lockStETH(_VETOER_2, 3 * 10 ** 18); - _lockWstETH(_VETOER_2, 5 * 10 ** 18); + _lockStETH(_VETOER_2, secondVetoerLockStETHAmount); + _lockWstETH(_VETOER_2, secondVetoerLockWstETHAmount); _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); _unlockStETH(_VETOER_1); - _unlockWstETH(_VETOER_1); - _unlockStETH(_VETOER_2); - _unlockWstETH(_VETOER_2); - - assertEq(firstVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_1)); - assertApproxEqAbs(firstVetoerStETHBalanceBefore, _ST_ETH.balanceOf(_VETOER_1), 1); + assertApproxEqAbs( + _ST_ETH.balanceOf(_VETOER_1), + firstVetoerStETHBalanceBefore + _ST_ETH.getPooledEthByShares(firstVetoerLockWstETHAmount), + 1 + ); - assertEq(secondVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_2)); - assertApproxEqAbs(secondVetoerStETHBalanceBefore, _ST_ETH.balanceOf(_VETOER_2), 1); + _unlockWstETH(_VETOER_2); + assertApproxEqAbs( + secondVetoerWstETHBalanceBefore, + _WST_ETH.balanceOf(_VETOER_2), + secondVetoerWstETHBalanceBefore + _ST_ETH.getSharesByPooledEth(secondVetoerLockWstETHAmount) + ); } function test_lock_unlock_w_rebase() public { @@ -122,6 +128,9 @@ contract EscrowHappyPath is TestHelpers { uint256 secondVetoerStETHShares = _ST_ETH.getSharesByPooledEth(secondVetoerStETHAmount); uint256 secondVetoerWstETHAmount = 17 * 10 ** 18; + uint256 firstVetoerWstETHBalanceBefore = _WST_ETH.balanceOf(_VETOER_1); + uint256 secondVetoerStETHSharesBefore = _ST_ETH.sharesOf(_VETOER_2); + _lockStETH(_VETOER_1, firstVetoerStETHAmount); _lockWstETH(_VETOER_1, firstVetoerWstETHAmount); @@ -138,25 +147,24 @@ contract EscrowHappyPath is TestHelpers { _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); - _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); - - _unlockStETH(_VETOER_2); - _unlockWstETH(_VETOER_2); - assertApproxEqAbs( - _ST_ETH.getPooledEthByShares(firstVetoerStETHSharesAfterRebase + firstVetoerStETHShares), - _ST_ETH.balanceOf(_VETOER_1), - 1 + firstVetoerWstETHBalanceBefore + firstVetoerStETHShares, + _WST_ETH.balanceOf(_VETOER_1), + // Even though the wstETH itself doesn't have rounding issues, the Escrow contract wraps stETH into wstETH + // so the the rounding issue may happen because of it. Another rounding may happen on the converting stETH amount + // into shares via _ST_ETH.getSharesByPooledEth(secondVetoerStETHAmount) + 2 ); - assertEq(firstVetoerWstETHBalanceAfterRebase + firstVetoerWstETHAmount, _WST_ETH.balanceOf(_VETOER_1)); + + _unlockStETH(_VETOER_2); assertApproxEqAbs( - _ST_ETH.getPooledEthByShares(secondVetoerStETHSharesAfterRebase + secondVetoerStETHShares), + // all locked stETH and wstETH was withdrawn as stETH + _ST_ETH.getPooledEthByShares(secondVetoerStETHSharesBefore + secondVetoerWstETHAmount), _ST_ETH.balanceOf(_VETOER_2), 1 ); - assertEq(secondVetoerWstETHBalanceAfterRebase + secondVetoerWstETHAmount, _WST_ETH.balanceOf(_VETOER_2)); } function test_lock_unlock_w_negative_rebase() public { @@ -165,11 +173,9 @@ contract EscrowHappyPath is TestHelpers { uint256 secondVetoerStETHAmount = 13 * 10 ** 18; uint256 secondVetoerWstETHAmount = 17 * 10 ** 18; + uint256 secondVetoerStETHShares = _ST_ETH.getSharesByPooledEth(secondVetoerStETHAmount); 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); @@ -183,16 +189,23 @@ contract EscrowHappyPath is TestHelpers { _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); _unlockStETH(_VETOER_1); - _unlockWstETH(_VETOER_1); + assertApproxEqAbs( + // all locked stETH and wstETH was withdrawn as stETH + _ST_ETH.getPooledEthByShares(firstVetoerStETHSharesBefore + firstVetoerWstETHAmount), + _ST_ETH.balanceOf(_VETOER_1), + 1 + ); - _unlockStETH(_VETOER_2); _unlockWstETH(_VETOER_2); - assertApproxEqAbs(_ST_ETH.getPooledEthByShares(firstVetoerStETHSharesBefore), _ST_ETH.balanceOf(_VETOER_1), 1); - assertEq(firstVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_1)); - - assertApproxEqAbs(_ST_ETH.getPooledEthByShares(secondVetoerStETHSharesBefore), _ST_ETH.balanceOf(_VETOER_2), 1); - assertEq(secondVetoerWstETHBalanceBefore, _WST_ETH.balanceOf(_VETOER_2)); + assertApproxEqAbs( + secondVetoerWstETHBalanceBefore + secondVetoerStETHShares, + _WST_ETH.balanceOf(_VETOER_2), + // Even though the wstETH itself doesn't have rounding issues, the Escrow contract wraps stETH into wstETH + // so the the rounding issue may happen because of it. Another rounding may happen on the converting stETH amount + // into shares via _ST_ETH.getSharesByPooledEth(secondVetoerStETHAmount) + 2 + ); } function test_lock_unlock_withdrawal_nfts() public { @@ -275,8 +288,7 @@ contract EscrowHappyPath is TestHelpers { _lockUnstETH(_VETOER_1, unstETHIds); VetoerState memory vetoerState = escrow.getVetoerState(_VETOER_1); - assertApproxEqAbs(vetoerState.stETHShares, sharesToLock, 1); - assertEq(vetoerState.wstETHShares, sharesToLock); + assertApproxEqAbs(vetoerState.stETHShares, 2 * sharesToLock, 1); assertApproxEqAbs(vetoerState.unstETHShares, _ST_ETH.getSharesByPooledEth(2e18), 1); uint256 rageQuitSupport = escrow.getRageQuitSupport(); @@ -370,12 +382,12 @@ contract EscrowHappyPath is TestHelpers { // but it can't be withdrawn before withdrawal timelock has passed vm.expectRevert(); vm.prank(_VETOER_1); - escrow.withdrawUnstETHAsETH(unstETHIds); + escrow.withdrawETH(unstETHIds); } vm.expectRevert(); vm.prank(_VETOER_1); - escrow.withdrawStETHAsETH(); + escrow.withdrawETH(); _wait(_RAGE_QUIT_EXTRA_TIMELOCK + 1); assertEq(escrow.isRageQuitFinalized(), true); @@ -383,8 +395,8 @@ contract EscrowHappyPath is TestHelpers { _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK + 1); vm.startPrank(_VETOER_1); - escrow.withdrawStETHAsETH(); - escrow.withdrawUnstETHAsETH(unstETHIds); + escrow.withdrawETH(); + escrow.withdrawETH(unstETHIds); vm.stopPrank(); } @@ -424,7 +436,7 @@ contract EscrowHappyPath is TestHelpers { _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK + 1); vm.startPrank(_VETOER_1); - escrow.withdrawUnstETHAsETH(unstETHIds); + escrow.withdrawETH(unstETHIds); vm.stopPrank(); } @@ -440,8 +452,10 @@ contract EscrowHappyPath is TestHelpers { assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, firstVetoerStETHShares, 1); _lockWstETH(_VETOER_1, firstVetoerWstETHAmount); - assertEq(escrow.getVetoerState(_VETOER_1).wstETHShares, firstVetoerWstETHAmount); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); + assertApproxEqAbs( + escrow.getVetoerState(_VETOER_1).stETHShares, firstVetoerWstETHAmount + firstVetoerStETHShares, 2 + ); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); @@ -449,19 +463,19 @@ contract EscrowHappyPath is TestHelpers { stETHWithdrawalRequestAmounts[0] = firstVetoerStETHAmount; vm.prank(_VETOER_1); - uint256[] memory stETHWithdrawalRequestIds = escrow.requestWithdrawalsStETH(stETHWithdrawalRequestAmounts); + uint256[] memory stETHWithdrawalRequestIds = escrow.requestWithdrawals(stETHWithdrawalRequestAmounts); assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, firstVetoerStETHShares, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); uint256[] memory wstETHWithdrawalRequestAmounts = new uint256[](1); - wstETHWithdrawalRequestAmounts[0] = firstVetoerWstETHAmount; + wstETHWithdrawalRequestAmounts[0] = _ST_ETH.getPooledEthByShares(firstVetoerWstETHAmount); vm.prank(_VETOER_1); - uint256[] memory wstETHWithdrawalRequestIds = escrow.requestWithdrawalsWstETH(wstETHWithdrawalRequestAmounts); + uint256[] memory wstETHWithdrawalRequestIds = escrow.requestWithdrawals(wstETHWithdrawalRequestAmounts); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); finalizeWQ(wstETHWithdrawalRequestIds[0]); @@ -471,8 +485,8 @@ contract EscrowHappyPath is TestHelpers { stETHWithdrawalRequestIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex() ) ); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); escrow.markUnstETHFinalized( wstETHWithdrawalRequestIds, @@ -480,8 +494,8 @@ contract EscrowHappyPath is TestHelpers { wstETHWithdrawalRequestIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex() ) ); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index fabbb898..629551ff 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -26,7 +26,14 @@ import {DualGovernance, DualGovernanceState, State} from "contracts/DualGovernan import {Proposal, Status as ProposalStatus} from "contracts/libraries/Proposals.sol"; import {Percents, percents} from "../utils/percents.sol"; -import {IERC20, IStEth, IWstETH, IWithdrawalQueue, WithdrawalRequestStatus, IDangerousContract} from "../utils/interfaces.sol"; +import { + IERC20, + IStEth, + IWstETH, + IWithdrawalQueue, + WithdrawalRequestStatus, + IDangerousContract +} from "../utils/interfaces.sol"; import {ExecutorCallHelpers} from "../utils/executor-calls.sol"; import {Utils, TargetMock, console} from "../utils/utils.sol"; @@ -184,14 +191,16 @@ contract ScenarioTestBlueprint is Test { function _unlockWstETH(address vetoer) internal { Escrow escrow = _getVetoSignallingEscrow(); uint256 wstETHBalanceBefore = _WST_ETH.balanceOf(vetoer); - uint256 vetoerWstETHSharesBefore = escrow.getVetoerState(vetoer).wstETHShares; + uint256 vetoerWstETHSharesBefore = escrow.getVetoerState(vetoer).stETHShares; vm.startPrank(vetoer); uint256 wstETHUnlocked = escrow.unlockWstETH(); vm.stopPrank(); - assertEq(wstETHUnlocked, vetoerWstETHSharesBefore); - assertEq(_WST_ETH.balanceOf(vetoer), wstETHBalanceBefore + vetoerWstETHSharesBefore); + // 1 wei rounding issue may arise because of the wrapping stETH into wstETH before + // sending funds to the user + assertApproxEqAbs(wstETHUnlocked, vetoerWstETHSharesBefore, 1); + assertApproxEqAbs(_WST_ETH.balanceOf(vetoer), wstETHBalanceBefore + vetoerWstETHSharesBefore, 1); } function _lockUnstETH(address vetoer, uint256[] memory unstETHIds) internal { From 3e95f3e0f583dcb6e9b0337fe7257eb5c6a5f342 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Tue, 25 Jun 2024 13:08:14 +0400 Subject: [PATCH 02/13] Use custom types for timestamps and durations --- contracts/Configuration.sol | 29 +-- contracts/DualGovernance.sol | 10 +- contracts/EmergencyProtectedTimelock.sol | 11 +- contracts/Escrow.sol | 26 ++- contracts/interfaces/IConfiguration.sol | 46 ++-- contracts/interfaces/IEscrow.sol | 4 +- contracts/interfaces/ITimelock.sol | 3 +- contracts/libraries/AssetsAccounting.sol | 25 ++- contracts/libraries/DualGovernanceState.sol | 78 ++++--- contracts/libraries/EmergencyProtection.sol | 68 +++--- contracts/libraries/Proposals.sol | 51 ++--- contracts/types/Duration.sol | 121 +++++++++++ contracts/types/Timestamp.sol | 84 ++++++++ contracts/utils/time.sol | 18 -- test/scenario/agent-timelock.t.sol | 6 +- test/scenario/escrow.t.sol | 29 +-- test/scenario/gate-seal-breaker.t.sol | 38 ++-- test/scenario/gov-state-transitions.t.sol | 26 +-- test/scenario/happy-path-plan-b.t.sol | 54 ++--- test/scenario/happy-path.t.sol | 8 +- .../last-moment-malicious-proposal.t.sol | 39 ++-- test/scenario/veto-cooldown-mechanics.t.sol | 4 +- test/unit/EmergencyProtectedTimelock.t.sol | 70 +++--- test/unit/libraries/EmergencyProtection.t.sol | 200 +++++++++++------- test/unit/libraries/Proposals.t.sol | 144 ++++++------- test/unit/mocks/TimelockMock.sol | 4 +- test/utils/scenario-test-blueprint.sol | 70 ++++-- test/utils/unit-test.sol | 19 +- 28 files changed, 804 insertions(+), 481 deletions(-) create mode 100644 contracts/types/Duration.sol create mode 100644 contracts/types/Timestamp.sol delete mode 100644 contracts/utils/time.sol diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 44cfc9bd..aae54c51 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Durations, Duration} from "./types/Duration.sol"; import {IConfiguration, DualGovernanceConfig} from "./interfaces/IConfiguration.sol"; uint256 constant PERCENT = 10 ** 16; @@ -14,17 +15,17 @@ contract Configuration is IConfiguration { uint256 public immutable FIRST_SEAL_RAGE_QUIT_SUPPORT = 3 * PERCENT; uint256 public immutable SECOND_SEAL_RAGE_QUIT_SUPPORT = 15 * PERCENT; - uint256 public immutable DYNAMIC_TIMELOCK_MIN_DURATION = 3 days; - uint256 public immutable DYNAMIC_TIMELOCK_MAX_DURATION = 30 days; + Duration public immutable DYNAMIC_TIMELOCK_MIN_DURATION = Durations.from(3 days); + Duration public immutable DYNAMIC_TIMELOCK_MAX_DURATION = Durations.from(30 days); - uint256 public immutable VETO_SIGNALLING_MIN_ACTIVE_DURATION = 5 hours; - uint256 public immutable VETO_SIGNALLING_DEACTIVATION_MAX_DURATION = 5 days; - uint256 public immutable RAGE_QUIT_ACCUMULATION_MAX_DURATION = 3 days; + Duration public immutable VETO_SIGNALLING_MIN_ACTIVE_DURATION = Durations.from(5 hours); + Duration public immutable VETO_SIGNALLING_DEACTIVATION_MAX_DURATION = Durations.from(5 days); + Duration public immutable RAGE_QUIT_ACCUMULATION_MAX_DURATION = Durations.from(3 days); - uint256 public immutable VETO_COOLDOWN_DURATION = 4 days; + Duration public immutable VETO_COOLDOWN_DURATION = Durations.from(4 days); - uint256 public immutable RAGE_QUIT_EXTENSION_DELAY = 7 days; - uint256 public immutable RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK = 60 days; + Duration public immutable RAGE_QUIT_EXTENSION_DELAY = Durations.from(7 days); + Duration public immutable RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK = Durations.from(60 days); uint256 public immutable RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_START_SEQ_NUMBER = 2; uint256 public immutable RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_A = 0; @@ -35,12 +36,12 @@ contract Configuration is IConfiguration { address public immutable ADMIN_EXECUTOR; address public immutable EMERGENCY_GOVERNANCE; - uint256 public immutable AFTER_SUBMIT_DELAY = 3 days; - uint256 public immutable AFTER_SCHEDULE_DELAY = 2 days; + Duration public immutable AFTER_SUBMIT_DELAY = Durations.from(3 days); + Duration public immutable AFTER_SCHEDULE_DELAY = Durations.from(2 days); - uint256 public immutable SIGNALLING_ESCROW_MIN_LOCK_TIME = 5 hours; + Duration public immutable SIGNALLING_ESCROW_MIN_LOCK_TIME = Durations.from(5 hours); - uint256 public immutable TIE_BREAK_ACTIVATION_TIMEOUT = 365 days; + Duration public immutable TIE_BREAK_ACTIVATION_TIMEOUT = Durations.from(365 days); // Sealables Array Representation uint256 private immutable MAX_SELABLES_COUNT = 5; @@ -84,8 +85,8 @@ contract Configuration is IConfiguration { returns ( uint256 firstSealRageQuitSupport, uint256 secondSealRageQuitSupport, - uint256 dynamicTimelockMinDuration, - uint256 dynamicTimelockMaxDuration + Duration dynamicTimelockMinDuration, + Duration dynamicTimelockMaxDuration ) { firstSealRageQuitSupport = FIRST_SEAL_RAGE_QUIT_SUPPORT; diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index 3a6c7a5a..7e82e1d6 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; import {ITimelock, IGovernance} from "./interfaces/ITimelock.sol"; import {ConfigurationProvider} from "./ConfigurationProvider.sol"; @@ -49,7 +51,7 @@ contract DualGovernance is IGovernance, ConfigurationProvider { function scheduleProposal(uint256 proposalId) external { _dgState.activateNextState(CONFIG.getDualGovernanceConfig()); - uint256 proposalSubmissionTime = TIMELOCK.schedule(proposalId); + Timestamp proposalSubmissionTime = TIMELOCK.schedule(proposalId); _dgState.checkCanScheduleProposal(proposalSubmissionTime); emit ProposalScheduled(proposalId); } @@ -86,7 +88,7 @@ contract DualGovernance is IGovernance, ConfigurationProvider { function getVetoSignallingState() external view - returns (bool isActive, uint256 duration, uint256 activatedAt, uint256 enteredAt) + returns (bool isActive, Duration duration, Timestamp activatedAt, Timestamp enteredAt) { (isActive, duration, activatedAt, enteredAt) = _dgState.getVetoSignallingState(CONFIG.getDualGovernanceConfig()); } @@ -94,12 +96,12 @@ contract DualGovernance is IGovernance, ConfigurationProvider { function getVetoSignallingDeactivationState() external view - returns (bool isActive, uint256 duration, uint256 enteredAt) + returns (bool isActive, Duration duration, Timestamp enteredAt) { (isActive, duration, enteredAt) = _dgState.getVetoSignallingDeactivationState(CONFIG.getDualGovernanceConfig()); } - function getVetoSignallingDuration() external view returns (uint256) { + function getVetoSignallingDuration() external view returns (Duration) { return _dgState.getVetoSignallingDuration(CONFIG.getDualGovernanceConfig()); } diff --git a/contracts/EmergencyProtectedTimelock.sol b/contracts/EmergencyProtectedTimelock.sol index 019feb72..1069fea3 100644 --- a/contracts/EmergencyProtectedTimelock.sol +++ b/contracts/EmergencyProtectedTimelock.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Duration} from "./types/Duration.sol"; +import {Timestamp} from "./types/Timestamp.sol"; + import {IOwnable} from "./interfaces/IOwnable.sol"; import {ITimelock} from "./interfaces/ITimelock.sol"; @@ -30,7 +33,7 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { newProposalId = _proposals.submit(executor, calls); } - function schedule(uint256 proposalId) external returns (uint256 submittedAt) { + function schedule(uint256 proposalId) external returns (Timestamp submittedAt) { _checkGovernance(msg.sender); submittedAt = _proposals.schedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); } @@ -68,7 +71,7 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { function emergencyExecute(uint256 proposalId) external { _emergencyProtection.checkEmergencyModeActive(true); _emergencyProtection.checkExecutionCommittee(msg.sender); - _proposals.execute(proposalId, /* afterScheduleDelay */ 0); + _proposals.execute(proposalId, /* afterScheduleDelay */ Duration.wrap(0)); } function deactivateEmergencyMode() external { @@ -91,8 +94,8 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { function setEmergencyProtection( address activator, address enactor, - uint256 protectionDuration, - uint256 emergencyModeDuration + Duration protectionDuration, + Duration emergencyModeDuration ) external { _checkAdminExecutor(msg.sender); _emergencyProtection.setup(activator, enactor, protectionDuration, emergencyModeDuration); diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 2488acb5..5075598f 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -4,6 +4,9 @@ pragma solidity 0.8.23; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Duration} from "./types/Duration.sol"; +import {Timestamp, Timestamps} from "./types/Timestamp.sol"; + import {IEscrow} from "./interfaces/IEscrow.sol"; import {IConfiguration} from "./interfaces/IConfiguration.sol"; @@ -59,9 +62,9 @@ contract Escrow is IEscrow { uint256[] internal _withdrawalUnstETHIds; - uint256 internal _rageQuitExtraTimelock; - uint256 internal _rageQuitWithdrawalsTimelock; - uint256 internal _rageQuitTimelockStartedAt; + Duration internal _rageQuitExtraTimelock; + Duration internal _rageQuitWithdrawalsTimelock; + Timestamp internal _rageQuitTimelockStartedAt; constructor(address stETH, address wstETH, address withdrawalQueue, address config) { ST_ETH = IStETH(stETH); @@ -170,7 +173,7 @@ contract Escrow is IEscrow { // State Updates // --- - function startRageQuit(uint256 rageQuitExtraTimelock, uint256 rageQuitWithdrawalsTimelock) external { + function startRageQuit(Duration rageQuitExtraTimelock, Duration rageQuitWithdrawalsTimelock) external { _checkDualGovernance(msg.sender); _checkEscrowState(EscrowState.SignallingEscrow); @@ -210,7 +213,7 @@ contract Escrow is IEscrow { _accounting.accountClaimedETH(ethAmountClaimed); } if (_accounting.getIsWithdrawalsClaimed()) { - _rageQuitTimelockStartedAt = block.timestamp; + _rageQuitTimelockStartedAt = Timestamps.now(); } } @@ -284,7 +287,7 @@ contract Escrow is IEscrow { return _accounting.getIsWithdrawalsClaimed(); } - function getRageQuitTimelockStartedAt() external view returns (uint256) { + function getRageQuitTimelockStartedAt() external view returns (Timestamp) { return _rageQuitTimelockStartedAt; } @@ -295,8 +298,10 @@ contract Escrow is IEscrow { } function isRageQuitFinalized() external view returns (bool) { - return _escrowState == EscrowState.RageQuitEscrow && _accounting.getIsWithdrawalsClaimed() - && _rageQuitTimelockStartedAt != 0 && block.timestamp > _rageQuitTimelockStartedAt + _rageQuitExtraTimelock; + if (_escrowState != EscrowState.RageQuitEscrow) return false; + if (!_accounting.getIsWithdrawalsClaimed()) return false; + if (_rageQuitTimelockStartedAt.isZero()) return false; + return Timestamps.now() > _rageQuitExtraTimelock.addTo(_rageQuitTimelockStartedAt); } // --- @@ -330,10 +335,11 @@ contract Escrow is IEscrow { } function _checkWithdrawalsTimelockPassed() internal view { - if (_rageQuitTimelockStartedAt == 0) { + if (_rageQuitTimelockStartedAt.isZero()) { revert RageQuitExtraTimelockNotStarted(); } - if (block.timestamp <= _rageQuitTimelockStartedAt + _rageQuitExtraTimelock + _rageQuitWithdrawalsTimelock) { + Duration withdrawalsTimelock = _rageQuitExtraTimelock + _rageQuitWithdrawalsTimelock; + if (Timestamps.now() <= withdrawalsTimelock.addTo(_rageQuitTimelockStartedAt)) { revert WithdrawalsTimelockNotPassed(); } } diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index 04aa9f14..26826dd5 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -1,18 +1,20 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Duration} from "../types/Duration.sol"; + struct DualGovernanceConfig { uint256 firstSealRageQuitSupport; uint256 secondSealRageQuitSupport; // TODO: consider dynamicDelayMaxDuration - uint256 dynamicTimelockMaxDuration; - uint256 dynamicTimelockMinDuration; - uint256 vetoSignallingMinActiveDuration; - uint256 vetoSignallingDeactivationMaxDuration; - uint256 vetoCooldownDuration; - uint256 rageQuitExtraTimelock; - uint256 rageQuitExtensionDelay; - uint256 rageQuitEthClaimMinTimelock; + Duration dynamicTimelockMaxDuration; + Duration dynamicTimelockMinDuration; + Duration vetoSignallingMinActiveDuration; + Duration vetoSignallingDeactivationMaxDuration; + Duration vetoCooldownDuration; + Duration rageQuitExtraTimelock; + Duration rageQuitExtensionDelay; + Duration rageQuitEthClaimMinTimelock; uint256 rageQuitEthClaimTimelockGrowthStartSeqNumber; uint256[3] rageQuitEthClaimTimelockGrowthCoeffs; } @@ -22,35 +24,35 @@ interface IAdminExecutorConfiguration { } interface ITimelockConfiguration { - function AFTER_SUBMIT_DELAY() external view returns (uint256); - function AFTER_SCHEDULE_DELAY() external view returns (uint256); + function AFTER_SUBMIT_DELAY() external view returns (Duration); + function AFTER_SCHEDULE_DELAY() external view returns (Duration); function EMERGENCY_GOVERNANCE() external view returns (address); } interface IDualGovernanceConfiguration { - function TIE_BREAK_ACTIVATION_TIMEOUT() external view returns (uint256); + function TIE_BREAK_ACTIVATION_TIMEOUT() external view returns (Duration); - function VETO_COOLDOWN_DURATION() external view returns (uint256); - function VETO_SIGNALLING_MIN_ACTIVE_DURATION() external view returns (uint256); + function VETO_COOLDOWN_DURATION() external view returns (Duration); + function VETO_SIGNALLING_MIN_ACTIVE_DURATION() external view returns (Duration); - function VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() external view returns (uint256); + function VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() external view returns (Duration); - function DYNAMIC_TIMELOCK_MIN_DURATION() external view returns (uint256); - function DYNAMIC_TIMELOCK_MAX_DURATION() external view returns (uint256); + function DYNAMIC_TIMELOCK_MIN_DURATION() external view returns (Duration); + function DYNAMIC_TIMELOCK_MAX_DURATION() external view returns (Duration); function FIRST_SEAL_RAGE_QUIT_SUPPORT() external view returns (uint256); function SECOND_SEAL_RAGE_QUIT_SUPPORT() 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 RAGE_QUIT_ACCUMULATION_MAX_DURATION() external view returns (uint256); + function RAGE_QUIT_EXTENSION_DELAY() external view returns (Duration); + function RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK() external view returns (Duration); + function RAGE_QUIT_ACCUMULATION_MAX_DURATION() external view returns (Duration); function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_START_SEQ_NUMBER() external view returns (uint256); function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_A() external view returns (uint256); function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_B() external view returns (uint256); function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_C() external view returns (uint256); - function SIGNALLING_ESCROW_MIN_LOCK_TIME() external view returns (uint256); + function SIGNALLING_ESCROW_MIN_LOCK_TIME() external view returns (Duration); function sealableWithdrawalBlockers() external view returns (address[] memory); @@ -60,8 +62,8 @@ interface IDualGovernanceConfiguration { returns ( uint256 firstSealThreshold, uint256 secondSealThreshold, - uint256 signallingMinDuration, - uint256 signallingMaxDuration + Duration signallingMinDuration, + Duration signallingMaxDuration ); function getDualGovernanceConfig() external view returns (DualGovernanceConfig memory config); diff --git a/contracts/interfaces/IEscrow.sol b/contracts/interfaces/IEscrow.sol index d8c44087..3586d311 100644 --- a/contracts/interfaces/IEscrow.sol +++ b/contracts/interfaces/IEscrow.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Duration} from "../types/Duration.sol"; + interface IEscrow { function initialize(address dualGovernance) external; - function startRageQuit(uint256 rageQuitExtraTimelock, uint256 rageQuitWithdrawalsTimelock) external; + function startRageQuit(Duration rageQuitExtraTimelock, Duration rageQuitWithdrawalsTimelock) external; function MASTER_COPY() external view returns (address); function isRageQuitFinalized() external view returns (bool); diff --git a/contracts/interfaces/ITimelock.sol b/contracts/interfaces/ITimelock.sol index 0f080e8d..9ca71745 100644 --- a/contracts/interfaces/ITimelock.sol +++ b/contracts/interfaces/ITimelock.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Timestamp} from "../types/Timestamp.sol"; import {ExecutorCall} from "./IExecutor.sol"; interface IGovernance { @@ -13,7 +14,7 @@ interface IGovernance { interface ITimelock { function submit(address executor, ExecutorCall[] calldata calls) external returns (uint256 newProposalId); - function schedule(uint256 proposalId) external returns (uint256 submittedAt); + function schedule(uint256 proposalId) external returns (Timestamp submittedAt); function execute(uint256 proposalId) external; function cancelAllNonExecutedProposals() external; diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol index afee69ec..2a695620 100644 --- a/contracts/libraries/AssetsAccounting.sol +++ b/contracts/libraries/AssetsAccounting.sol @@ -5,7 +5,9 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; -import {TimeUtils} from "../utils/time.sol"; +import {Duration} from "../types/Duration.sol"; +import {Timestamps, Timestamp} from "../types/Timestamp.sol"; + import {ArrayUtils} from "../utils/arrays.sol"; enum WithdrawalRequestState { @@ -30,7 +32,7 @@ struct LockedAssetsStats { uint128 unstETHShares; uint128 sharesFinalized; uint128 amountFinalized; - uint40 lastAssetsLockTimestamp; + Timestamp lastAssetsLockTimestamp; } struct LockedAssetsTotals { @@ -82,7 +84,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); + error AssetsUnlockDelayNotPassed(Timestamp unlockTimelockExpiresAt); error NotEnoughStETHToUnlock(uint256 requested, uint256 sharesBalance); struct State { @@ -103,7 +105,7 @@ library AssetsAccounting { _checkNonZeroSharesLock(vetoer, shares); uint128 sharesUint128 = shares.toUint128(); self.assets[vetoer].stETHShares += sharesUint128; - self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.assets[vetoer].lastAssetsLockTimestamp = Timestamps.now(); self.totals.shares += sharesUint128; emit StETHLocked(vetoer, shares); } @@ -136,7 +138,7 @@ library AssetsAccounting { // wstETH Operations Accounting // --- - function checkAssetsUnlockDelayPassed(State storage self, address vetoer, uint256 delay) internal view { + function checkAssetsUnlockDelayPassed(State storage self, address vetoer, Duration delay) internal view { _checkAssetsUnlockDelayPassed(self, delay, vetoer); } @@ -144,7 +146,7 @@ library AssetsAccounting { _checkNonZeroSharesLock(vetoer, shares); uint128 sharesUint128 = shares.toUint128(); self.assets[vetoer].wstETHShares += sharesUint128; - self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.assets[vetoer].lastAssetsLockTimestamp = Timestamps.now(); self.totals.shares += sharesUint128; emit WstETHLocked(vetoer, shares); } @@ -192,14 +194,14 @@ library AssetsAccounting { } uint128 totalUnstETHSharesLockedUint128 = totalUnstETHSharesLocked.toUint128(); self.assets[vetoer].unstETHShares += totalUnstETHSharesLockedUint128; - self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.assets[vetoer].lastAssetsLockTimestamp = Timestamps.now(); self.totals.shares += totalUnstETHSharesLockedUint128; emit UnstETHLocked(vetoer, unstETHIds, totalUnstETHSharesLocked); } function accountUnstETHUnlock( State storage self, - uint256 assetsUnlockDelay, + Duration assetsUnlockDelay, address vetoer, uint256[] memory unstETHIds ) internal { @@ -551,11 +553,12 @@ library AssetsAccounting { function _checkAssetsUnlockDelayPassed( State storage self, - uint256 assetsUnlockDelay, + Duration assetsUnlockDelay, address vetoer ) private view { - if (block.timestamp <= self.assets[vetoer].lastAssetsLockTimestamp + assetsUnlockDelay) { - revert AssetsUnlockDelayNotPassed(self.assets[vetoer].lastAssetsLockTimestamp + assetsUnlockDelay); + Timestamp assetsUnlockAllowedAfter = assetsUnlockDelay.addTo(self.assets[vetoer].lastAssetsLockTimestamp); + if (Timestamps.now() <= assetsUnlockAllowedAfter) { + revert AssetsUnlockDelayNotPassed(assetsUnlockAllowedAfter); } } } diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index b55499f4..6aa9255b 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -7,7 +7,8 @@ import {IEscrow} from "../interfaces/IEscrow.sol"; import {ISealable} from "../interfaces/ISealable.sol"; import {IDualGovernanceConfiguration as IConfiguration, DualGovernanceConfig} from "../interfaces/IConfiguration.sol"; -import {TimeUtils} from "../utils/time.sol"; +import {Duration, Durations} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; enum State { Normal, @@ -21,14 +22,14 @@ library DualGovernanceState { // TODO: Optimize storage layout efficiency struct Store { State state; - uint40 enteredAt; + Timestamp enteredAt; // the time the veto signalling state was entered - uint40 vetoSignallingActivationTime; - IEscrow signallingEscrow; + Timestamp vetoSignallingActivationTime; + IEscrow signallingEscrow; // 248 // the time the Deactivation sub-state was last exited without exiting the parent Veto Signalling state - uint40 vetoSignallingReactivationTime; + Timestamp vetoSignallingReactivationTime; // the last time a proposal was submitted to the DG subsystem - uint40 lastAdoptableStateExitedAt; + Timestamp lastAdoptableStateExitedAt; IEscrow rageQuitEscrow; uint8 rageQuitRound; } @@ -90,7 +91,7 @@ library DualGovernanceState { } } - function checkCanScheduleProposal(Store storage self, uint256 proposalSubmittedAt) internal view { + function checkCanScheduleProposal(Store storage self, Timestamp proposalSubmittedAt) internal view { if (!canScheduleProposal(self, proposalSubmittedAt)) { revert ProposalsAdoptionSuspended(); } @@ -106,7 +107,7 @@ library DualGovernanceState { return self.state; } - function canScheduleProposal(Store storage self, uint256 proposalSubmissionTime) internal view returns (bool) { + function canScheduleProposal(Store storage self, Timestamp proposalSubmissionTime) internal view returns (bool) { State state = self.state; if (state == State.Normal) return true; if (state == State.VetoCooldown) { @@ -129,7 +130,9 @@ library DualGovernanceState { if (isProposalsAdoptionAllowed(self)) return false; // for the governance is locked for long period of time - if (block.timestamp - self.lastAdoptableStateExitedAt >= config.TIE_BREAK_ACTIVATION_TIMEOUT()) return true; + if (Timestamps.now() >= config.TIE_BREAK_ACTIVATION_TIMEOUT().addTo(self.lastAdoptableStateExitedAt)) { + return true; + } if (self.state != State.RageQuit) return false; @@ -143,17 +146,17 @@ library DualGovernanceState { function getVetoSignallingState( Store storage self, DualGovernanceConfig memory config - ) internal view returns (bool isActive, uint256 duration, uint256 activatedAt, uint256 enteredAt) { + ) internal view returns (bool isActive, Duration duration, Timestamp activatedAt, Timestamp enteredAt) { isActive = self.state == State.VetoSignalling; - duration = isActive ? getVetoSignallingDuration(self, config) : 0; - enteredAt = isActive ? self.enteredAt : 0; - activatedAt = isActive ? self.vetoSignallingActivationTime : 0; + duration = isActive ? getVetoSignallingDuration(self, config) : Duration.wrap(0); + enteredAt = isActive ? self.enteredAt : Timestamps.ZERO; + activatedAt = isActive ? self.vetoSignallingActivationTime : Timestamps.ZERO; } function getVetoSignallingDuration( Store storage self, DualGovernanceConfig memory config - ) internal view returns (uint256) { + ) internal view returns (Duration) { uint256 totalSupport = self.signallingEscrow.getRageQuitSupport(); return _calcDynamicTimelockDuration(config, totalSupport); } @@ -166,10 +169,10 @@ library DualGovernanceState { function getVetoSignallingDeactivationState( Store storage self, DualGovernanceConfig memory config - ) internal view returns (bool isActive, uint256 duration, uint256 enteredAt) { + ) internal view returns (bool isActive, Duration duration, Timestamp enteredAt) { isActive = self.state == State.VetoSignallingDeactivation; duration = config.vetoSignallingDeactivationMaxDuration; - enteredAt = isActive ? self.enteredAt : 0; + enteredAt = isActive ? self.enteredAt : Timestamps.ZERO; } // --- @@ -253,7 +256,7 @@ library DualGovernanceState { State oldState, State newState ) private { - uint40 timestamp = TimeUtils.timestamp(); + Timestamp timestamp = Timestamps.now(); self.enteredAt = timestamp; // track the time when the governance state allowed execution if (oldState == State.Normal || oldState == State.VetoCooldown) { @@ -301,7 +304,7 @@ library DualGovernanceState { Store storage self, DualGovernanceConfig memory config ) private view returns (bool) { - return block.timestamp - self.vetoSignallingActivationTime > config.dynamicTimelockMaxDuration; + return Timestamps.now() > config.dynamicTimelockMaxDuration.addTo(self.vetoSignallingActivationTime); } function _isDynamicTimelockDurationPassed( @@ -309,29 +312,29 @@ library DualGovernanceState { DualGovernanceConfig memory config, uint256 rageQuitSupport ) private view returns (bool) { - uint256 vetoSignallingDurationPassed = block.timestamp - self.vetoSignallingActivationTime; - return vetoSignallingDurationPassed > _calcDynamicTimelockDuration(config, rageQuitSupport); + Duration dynamicTimelock = _calcDynamicTimelockDuration(config, rageQuitSupport); + return Timestamps.now() > dynamicTimelock.addTo(self.vetoSignallingActivationTime); } function _isVetoSignallingReactivationDurationPassed( Store storage self, DualGovernanceConfig memory config ) private view returns (bool) { - return block.timestamp - self.vetoSignallingReactivationTime > config.vetoSignallingMinActiveDuration; + return Timestamps.now() > config.vetoSignallingMinActiveDuration.addTo(self.vetoSignallingReactivationTime); } function _isVetoSignallingDeactivationMaxDurationPassed( Store storage self, DualGovernanceConfig memory config ) private view returns (bool) { - return block.timestamp - self.enteredAt > config.vetoSignallingDeactivationMaxDuration; + return Timestamps.now() > config.vetoSignallingDeactivationMaxDuration.addTo(self.enteredAt); } function _isVetoCooldownDurationPassed( Store storage self, DualGovernanceConfig memory config ) private view returns (bool) { - return block.timestamp - self.enteredAt > config.vetoCooldownDuration; + return Timestamps.now() > config.vetoCooldownDuration.addTo(self.enteredAt); } function _deployNewSignallingEscrow(Store storage self, address escrowMasterCopy) private { @@ -344,29 +347,31 @@ library DualGovernanceState { function _calcRageQuitWithdrawalsTimelock( DualGovernanceConfig memory config, uint256 rageQuitRound - ) private pure returns (uint256) { + ) private pure returns (Duration) { if (rageQuitRound < config.rageQuitEthClaimTimelockGrowthStartSeqNumber) { return config.rageQuitEthClaimMinTimelock; } return config.rageQuitEthClaimMinTimelock - + ( - config.rageQuitEthClaimTimelockGrowthCoeffs[0] * rageQuitRound * rageQuitRound - + config.rageQuitEthClaimTimelockGrowthCoeffs[1] * rageQuitRound - + config.rageQuitEthClaimTimelockGrowthCoeffs[2] - ) / 10 ** 18; // TODO: rewrite in a prettier way + + Durations.from( + ( + config.rageQuitEthClaimTimelockGrowthCoeffs[0] * rageQuitRound * rageQuitRound + + config.rageQuitEthClaimTimelockGrowthCoeffs[1] * rageQuitRound + + config.rageQuitEthClaimTimelockGrowthCoeffs[2] + ) / 10 ** 18 + ); // TODO: rewrite in a prettier way } function _calcDynamicTimelockDuration( DualGovernanceConfig memory config, uint256 rageQuitSupport - ) internal pure returns (uint256 duration_) { + ) internal pure returns (Duration duration_) { uint256 firstSealRageQuitSupport = config.firstSealRageQuitSupport; uint256 secondSealRageQuitSupport = config.secondSealRageQuitSupport; - uint256 dynamicTimelockMinDuration = config.dynamicTimelockMinDuration; - uint256 dynamicTimelockMaxDuration = config.dynamicTimelockMaxDuration; + Duration dynamicTimelockMinDuration = config.dynamicTimelockMinDuration; + Duration dynamicTimelockMaxDuration = config.dynamicTimelockMaxDuration; if (rageQuitSupport < firstSealRageQuitSupport) { - return 0; + return Durations.ZERO; } if (rageQuitSupport >= secondSealRageQuitSupport) { @@ -374,7 +379,10 @@ library DualGovernanceState { } duration_ = dynamicTimelockMinDuration - + (rageQuitSupport - firstSealRageQuitSupport) * (dynamicTimelockMaxDuration - dynamicTimelockMinDuration) - / (secondSealRageQuitSupport - firstSealRageQuitSupport); + + Durations.from( + (rageQuitSupport - firstSealRageQuitSupport) + * (dynamicTimelockMaxDuration - dynamicTimelockMinDuration).toSeconds() + / (secondSealRageQuitSupport - firstSealRageQuitSupport) + ); } } diff --git a/contracts/libraries/EmergencyProtection.sol b/contracts/libraries/EmergencyProtection.sol index bbbee390..f52bb4f3 100644 --- a/contracts/libraries/EmergencyProtection.sol +++ b/contracts/libraries/EmergencyProtection.sol @@ -1,37 +1,38 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Duration, Durations} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; struct EmergencyState { address executionCommittee; address activationCommittee; - uint256 protectedTill; + Timestamp protectedTill; bool isEmergencyModeActivated; - uint256 emergencyModeDuration; - uint256 emergencyModeEndsAfter; + Duration emergencyModeDuration; + Timestamp emergencyModeEndsAfter; } library EmergencyProtection { error NotEmergencyActivator(address account); error NotEmergencyEnactor(address account); - error EmergencyCommitteeExpired(uint256 timestamp, uint256 protectedTill); + error EmergencyCommitteeExpired(Timestamp timestamp, Timestamp protectedTill); error InvalidEmergencyModeActiveValue(bool actual, bool expected); - event EmergencyModeActivated(uint256 timestamp); - event EmergencyModeDeactivated(uint256 timestamp); + event EmergencyModeActivated(Timestamp timestamp); + event EmergencyModeDeactivated(Timestamp timestamp); event EmergencyActivationCommitteeSet(address indexed activationCommittee); event EmergencyExecutionCommitteeSet(address indexed executionCommittee); - event EmergencyModeDurationSet(uint256 emergencyModeDuration); - event EmergencyCommitteeProtectedTillSet(uint256 protectedTill); + event EmergencyModeDurationSet(Duration emergencyModeDuration); + event EmergencyCommitteeProtectedTillSet(Timestamp newProtectedTill); struct State { // has rights to activate emergency mode address activationCommittee; - uint40 protectedTill; + Timestamp protectedTill; // till this time, the committee may activate the emergency mode - uint40 emergencyModeEndsAfter; - uint32 emergencyModeDuration; + Timestamp emergencyModeEndsAfter; + Duration emergencyModeDuration; // has rights to execute proposals in emergency mode address executionCommittee; } @@ -40,8 +41,8 @@ library EmergencyProtection { State storage self, address activationCommittee, address executionCommittee, - uint256 protectionDuration, - uint256 emergencyModeDuration + Duration protectionDuration, + Duration emergencyModeDuration ) internal { address prevActivationCommittee = self.activationCommittee; if (activationCommittee != prevActivationCommittee) { @@ -55,36 +56,37 @@ library EmergencyProtection { emit EmergencyExecutionCommitteeSet(executionCommittee); } - uint256 prevProtectedTill = self.protectedTill; - uint256 protectedTill = block.timestamp + protectionDuration; + Timestamp prevProtectedTill = self.protectedTill; + Timestamp newProtectedTill = protectionDuration.addTo(Timestamps.now()); - if (protectedTill != prevProtectedTill) { - self.protectedTill = SafeCast.toUint40(protectedTill); - emit EmergencyCommitteeProtectedTillSet(protectedTill); + if (newProtectedTill != prevProtectedTill) { + self.protectedTill = newProtectedTill; + emit EmergencyCommitteeProtectedTillSet(newProtectedTill); } - uint256 prevEmergencyModeDuration = self.emergencyModeDuration; + Duration prevEmergencyModeDuration = self.emergencyModeDuration; if (emergencyModeDuration != prevEmergencyModeDuration) { - self.emergencyModeDuration = SafeCast.toUint32(emergencyModeDuration); + self.emergencyModeDuration = emergencyModeDuration; emit EmergencyModeDurationSet(emergencyModeDuration); } } function activate(State storage self) internal { - if (block.timestamp > self.protectedTill) { - revert EmergencyCommitteeExpired(block.timestamp, self.protectedTill); + Timestamp timestamp = Timestamps.now(); + if (timestamp > self.protectedTill) { + revert EmergencyCommitteeExpired(timestamp, self.protectedTill); } - self.emergencyModeEndsAfter = SafeCast.toUint40(block.timestamp + self.emergencyModeDuration); - emit EmergencyModeActivated(block.timestamp); + self.emergencyModeEndsAfter = self.emergencyModeDuration.addTo(timestamp); + emit EmergencyModeActivated(timestamp); } function deactivate(State storage self) internal { self.activationCommittee = address(0); self.executionCommittee = address(0); - self.protectedTill = 0; - self.emergencyModeDuration = 0; - self.emergencyModeEndsAfter = 0; - emit EmergencyModeDeactivated(block.timestamp); + self.protectedTill = Timestamps.ZERO; + self.emergencyModeEndsAfter = Timestamps.ZERO; + self.emergencyModeDuration = Durations.ZERO; + emit EmergencyModeDeactivated(Timestamps.now()); } function getEmergencyState(State storage self) internal view returns (EmergencyState memory res) { @@ -97,16 +99,16 @@ library EmergencyProtection { } function isEmergencyModeActivated(State storage self) internal view returns (bool) { - return self.emergencyModeEndsAfter != 0; + return self.emergencyModeEndsAfter.isNotZero(); } function isEmergencyModePassed(State storage self) internal view returns (bool) { - uint256 endsAfter = self.emergencyModeEndsAfter; - return endsAfter != 0 && block.timestamp > endsAfter; + Timestamp endsAfter = self.emergencyModeEndsAfter; + return endsAfter.isNotZero() && Timestamps.now() > endsAfter; } function isEmergencyProtectionEnabled(State storage self) internal view returns (bool) { - return block.timestamp <= self.protectedTill || self.emergencyModeEndsAfter != 0; + return Timestamps.now() <= self.protectedTill || self.emergencyModeEndsAfter.isNotZero(); } function checkActivationCommittee(State storage self, address account) internal view { diff --git a/contracts/libraries/Proposals.sol b/contracts/libraries/Proposals.sol index e404a93f..3771c183 100644 --- a/contracts/libraries/Proposals.sol +++ b/contracts/libraries/Proposals.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {IExecutor, ExecutorCall} from "../interfaces/IExecutor.sol"; +import {Duration} from "../types/Duration.sol"; +import {Timestamp, Timestamps} from "../types/Timestamp.sol"; -import {TimeUtils} from "../utils/time.sol"; +import {IExecutor, ExecutorCall} from "../interfaces/IExecutor.sol"; enum Status { NotExist, @@ -17,18 +18,18 @@ struct Proposal { uint256 id; Status status; address executor; - uint256 submittedAt; - uint256 scheduledAt; - uint256 executedAt; + Timestamp submittedAt; + Timestamp scheduledAt; + Timestamp executedAt; ExecutorCall[] calls; } library Proposals { struct ProposalPacked { address executor; - uint40 submittedAt; - uint40 scheduledAt; - uint40 executedAt; + Timestamp submittedAt; + Timestamp scheduledAt; + Timestamp executedAt; ExecutorCall[] calls; } @@ -69,7 +70,7 @@ library Proposals { ProposalPacked storage newProposal = self.proposals[newProposalIndex]; newProposal.executor = executor; - newProposal.submittedAt = TimeUtils.timestamp(); + newProposal.submittedAt = Timestamps.now(); // copying of arrays of custom types from calldata to storage has not been supported by the // Solidity compiler yet, so insert item by item @@ -84,19 +85,19 @@ library Proposals { function schedule( State storage self, uint256 proposalId, - uint256 afterSubmitDelay - ) internal returns (uint256 submittedAt) { + Duration afterSubmitDelay + ) internal returns (Timestamp submittedAt) { _checkProposalSubmitted(self, proposalId); _checkAfterSubmitDelayPassed(self, proposalId, afterSubmitDelay); ProposalPacked storage proposal = _packed(self, proposalId); submittedAt = proposal.submittedAt; - proposal.scheduledAt = TimeUtils.timestamp(); + proposal.scheduledAt = Timestamps.now(); emit ProposalScheduled(proposalId); } - function execute(State storage self, uint256 proposalId, uint256 afterScheduleDelay) internal { + function execute(State storage self, uint256 proposalId, Duration afterScheduleDelay) internal { _checkProposalScheduled(self, proposalId); _checkAfterScheduleDelayPassed(self, proposalId, afterScheduleDelay); _executeProposal(self, proposalId); @@ -128,24 +129,24 @@ library Proposals { function canExecute( State storage self, uint256 proposalId, - uint256 afterScheduleDelay + Duration afterScheduleDelay ) internal view returns (bool) { return _getProposalStatus(self, proposalId) == Status.Scheduled - && block.timestamp >= _packed(self, proposalId).scheduledAt + afterScheduleDelay; + && Timestamps.now() >= afterScheduleDelay.addTo(_packed(self, proposalId).scheduledAt); } function canSchedule( State storage self, uint256 proposalId, - uint256 afterSubmitDelay + Duration afterSubmitDelay ) internal view returns (bool) { return _getProposalStatus(self, proposalId) == Status.Submitted - && block.timestamp >= _packed(self, proposalId).submittedAt + afterSubmitDelay; + && Timestamps.now() >= afterSubmitDelay.addTo(_packed(self, proposalId).submittedAt); } function _executeProposal(State storage self, uint256 proposalId) private { ProposalPacked storage packed = _packed(self, proposalId); - packed.executedAt = TimeUtils.timestamp(); + packed.executedAt = Timestamps.now(); ExecutorCall[] memory calls = packed.calls; uint256 callsCount = calls.length; @@ -187,9 +188,9 @@ library Proposals { function _checkAfterSubmitDelayPassed( State storage self, uint256 proposalId, - uint256 afterSubmitDelay + Duration afterSubmitDelay ) private view { - if (block.timestamp < _packed(self, proposalId).submittedAt + afterSubmitDelay) { + if (Timestamps.now() < afterSubmitDelay.addTo(_packed(self, proposalId).submittedAt)) { revert AfterSubmitDelayNotPassed(proposalId); } } @@ -197,9 +198,9 @@ library Proposals { function _checkAfterScheduleDelayPassed( State storage self, uint256 proposalId, - uint256 afterScheduleDelay + Duration afterScheduleDelay ) private view { - if (block.timestamp < _packed(self, proposalId).scheduledAt + afterScheduleDelay) { + if (Timestamps.now() < afterScheduleDelay.addTo(_packed(self, proposalId).scheduledAt)) { revert AfterScheduleDelayNotPassed(proposalId); } } @@ -209,10 +210,10 @@ library Proposals { ProposalPacked storage packed = _packed(self, proposalId); - if (packed.executedAt != 0) return Status.Executed; + if (packed.executedAt.isNotZero()) return Status.Executed; if (proposalId <= self.lastCancelledProposalId) return Status.Cancelled; - if (packed.scheduledAt != 0) return Status.Scheduled; - if (packed.submittedAt != 0) return Status.Submitted; + if (packed.scheduledAt.isNotZero()) return Status.Scheduled; + if (packed.submittedAt.isNotZero()) return Status.Submitted; assert(false); } } diff --git a/contracts/types/Duration.sol b/contracts/types/Duration.sol new file mode 100644 index 00000000..0a599061 --- /dev/null +++ b/contracts/types/Duration.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Timestamp, Timestamps} from "./Timestamp.sol"; + +type Duration is uint32; + +error DurationOverflow(); +error DurationUnderflow(); + +// the max possible duration is ~ 106 years +uint256 constant MAX_VALUE = type(uint32).max; + +using {lt as <} for Duration global; +using {lte as <=} for Duration global; +using {gt as >} for Duration global; +using {eq as ==} for Duration global; +using {notEq as !=} for Duration global; + +using {plus as +} for Duration global; +using {minus as -} for Duration global; + +using {addTo} for Duration global; +using {plusSeconds} for Duration global; +using {minusSeconds} for Duration global; +using {multipliedBy} for Duration global; +using {dividedBy} for Duration global; +using {toSeconds} for Duration global; + +// --- +// Comparison Ops +// --- + +function lt(Duration d1, Duration d2) pure returns (bool) { + return Duration.unwrap(d1) < Duration.unwrap(d2); +} + +function lte(Duration d1, Duration d2) pure returns (bool) { + return Duration.unwrap(d1) <= Duration.unwrap(d2); +} + +function gt(Duration d1, Duration d2) pure returns (bool) { + return Duration.unwrap(d1) > Duration.unwrap(d2); +} + +function eq(Duration d1, Duration d2) pure returns (bool) { + return Duration.unwrap(d1) == Duration.unwrap(d2); +} + +function notEq(Duration d1, Duration d2) pure returns (bool) { + return !(d1 == d2); +} + +// --- +// Arithmetic Operations +// --- + +function plus(Duration d1, Duration d2) pure returns (Duration) { + return toDuration(Duration.unwrap(d1) + Duration.unwrap(d2)); +} + +function minus(Duration d1, Duration d2) pure returns (Duration) { + if (d1 < d2) { + revert DurationUnderflow(); + } + return Duration.wrap(Duration.unwrap(d1) - Duration.unwrap(d2)); +} + +function plusSeconds(Duration d, uint256 seconds_) pure returns (Duration) { + return toDuration(Duration.unwrap(d) + seconds_); +} + +function minusSeconds(Duration d, uint256 seconds_) pure returns (Duration) { + uint256 durationValue = Duration.unwrap(d); + if (durationValue < seconds_) { + revert DurationUnderflow(); + } + return Duration.wrap(uint32(durationValue - seconds_)); +} + +function dividedBy(Duration d, uint256 divisor) pure returns (Duration) { + return Duration.wrap(uint32(Duration.unwrap(d) / divisor)); +} + +function multipliedBy(Duration d, uint256 multiplicand) pure returns (Duration) { + return toDuration(Duration.unwrap(d) * multiplicand); +} + +function addTo(Duration d, Timestamp t) pure returns (Timestamp) { + return Timestamps.from(t.toSeconds() + d.toSeconds()); +} + +// --- +// Conversion Ops +// --- + +function toDuration(uint256 value) pure returns (Duration) { + if (value > MAX_VALUE) { + revert DurationOverflow(); + } + return Duration.wrap(uint32(value)); +} + +function toSeconds(Duration d) pure returns (uint256) { + return Duration.unwrap(d); +} + +library Durations { + Duration internal constant ZERO = Duration.wrap(0); + + Duration internal constant MIN = ZERO; + Duration internal constant MAX = Duration.wrap(uint32(MAX_VALUE)); + + function from(uint256 seconds_) internal pure returns (Duration res) { + res = toDuration(seconds_); + } + + function between(Timestamp t1, Timestamp t2) internal pure returns (Duration res) { + res = toDuration(t1.toSeconds() - t2.toSeconds()); + } +} diff --git a/contracts/types/Timestamp.sol b/contracts/types/Timestamp.sol new file mode 100644 index 00000000..ab06379f --- /dev/null +++ b/contracts/types/Timestamp.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +type Timestamp is uint40; + +error TimestampOverflow(); +error TimestampUnderflow(); + +uint256 constant MAX_TIMESTAMP_VALUE = type(uint40).max; + +using {lt as <} for Timestamp global; +using {gt as >} for Timestamp global; +using {gte as >=} for Timestamp global; +using {lte as <=} for Timestamp global; +using {eq as ==} for Timestamp global; +using {notEq as !=} for Timestamp global; + +using {isZero} for Timestamp global; +using {isNotZero} for Timestamp global; +using {toSeconds} for Timestamp global; + +// --- +// Comparison Ops +// --- + +function lt(Timestamp t1, Timestamp t2) pure returns (bool) { + return Timestamp.unwrap(t1) < Timestamp.unwrap(t2); +} + +function gt(Timestamp t1, Timestamp t2) pure returns (bool) { + return Timestamp.unwrap(t1) > Timestamp.unwrap(t2); +} + +function gte(Timestamp t1, Timestamp t2) pure returns (bool) { + return Timestamp.unwrap(t1) >= Timestamp.unwrap(t2); +} + +function lte(Timestamp t1, Timestamp t2) pure returns (bool) { + return Timestamp.unwrap(t1) <= Timestamp.unwrap(t2); +} + +function eq(Timestamp t1, Timestamp t2) pure returns (bool) { + return Timestamp.unwrap(t1) == Timestamp.unwrap(t2); +} + +function notEq(Timestamp t1, Timestamp t2) pure returns (bool) { + return !(t1 == t2); +} + +function isZero(Timestamp t) pure returns (bool) { + return Timestamp.unwrap(t) == 0; +} + +function isNotZero(Timestamp t) pure returns (bool) { + return Timestamp.unwrap(t) != 0; +} + +// --- +// Conversion Ops +// --- + +function toSeconds(Timestamp t) pure returns (uint256) { + return Timestamp.unwrap(t); +} + +uint256 constant MAX_VALUE = type(uint40).max; + +library Timestamps { + Timestamp internal constant ZERO = Timestamp.wrap(0); + + Timestamp internal constant MIN = ZERO; + Timestamp internal constant MAX = Timestamp.wrap(uint40(MAX_TIMESTAMP_VALUE)); + + function now() internal view returns (Timestamp res) { + res = Timestamp.wrap(uint40(block.timestamp)); + } + + function from(uint256 value) internal pure returns (Timestamp res) { + if (value > MAX_TIMESTAMP_VALUE) { + revert TimestampOverflow(); + } + return Timestamp.wrap(uint40(value)); + } +} diff --git a/contracts/utils/time.sol b/contracts/utils/time.sol deleted file mode 100644 index 05bc93b3..00000000 --- a/contracts/utils/time.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; - -library TimeUtils { - function timestamp() internal view returns (uint40) { - return timestamp(block.timestamp); - } - - function timestamp(uint256 value) internal pure returns (uint40) { - return SafeCast.toUint40(value); - } - - function duration(uint256 value) internal pure returns (uint32) { - return SafeCast.toUint32(value); - } -} diff --git a/test/scenario/agent-timelock.t.sol b/test/scenario/agent-timelock.t.sol index f209a1e9..bfac8585 100644 --- a/test/scenario/agent-timelock.t.sol +++ b/test/scenario/agent-timelock.t.sol @@ -76,7 +76,7 @@ contract AgentTimelockTest is ScenarioTestBlueprint { // --- { // wait until the delay has passed - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() + 1); + _wait(_config.AFTER_SUBMIT_DELAY().plusSeconds(1)); // when the first delay is passed and the is no opposition from the stETH holders // the proposal can be scheduled @@ -95,7 +95,7 @@ contract AgentTimelockTest is ScenarioTestBlueprint { { // some time passes and emergency committee activates emergency mode // and resets the controller - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); // committee resets governance vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); @@ -105,7 +105,7 @@ contract AgentTimelockTest is ScenarioTestBlueprint { _timelock.emergencyReset(); // proposal is canceled now - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2 + 1); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2).plusSeconds(1)); // remove canceled call from the timelock _assertCanExecute(proposalId, false); diff --git a/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol index 66a07544..99ca0fca 100644 --- a/test/scenario/escrow.t.sol +++ b/test/scenario/escrow.t.sol @@ -2,14 +2,15 @@ pragma solidity 0.8.23; import {WithdrawalRequestStatus} from "../utils/interfaces.sol"; - +import {Duration as DurationType} from "contracts/types/Duration.sol"; import { Escrow, Balances, VetoerState, LockedAssetsTotals, WITHDRAWAL_QUEUE, - ScenarioTestBlueprint + ScenarioTestBlueprint, + Durations } from "../utils/scenario-test-blueprint.sol"; contract TestHelpers is ScenarioTestBlueprint { @@ -47,8 +48,8 @@ contract TestHelpers is ScenarioTestBlueprint { contract EscrowHappyPath is TestHelpers { Escrow internal escrow; - uint256 internal immutable _RAGE_QUIT_EXTRA_TIMELOCK = 14 days; - uint256 internal immutable _RAGE_QUIT_WITHDRAWALS_TIMELOCK = 7 days; + DurationType internal immutable _RAGE_QUIT_EXTRA_TIMELOCK = Durations.from(14 days); + DurationType internal immutable _RAGE_QUIT_WITHDRAWALS_TIMELOCK = Durations.from(7 days); address internal immutable _VETOER_1 = makeAddr("VETOER_1"); address internal immutable _VETOER_2 = makeAddr("VETOER_2"); @@ -99,7 +100,7 @@ contract EscrowHappyPath is TestHelpers { _lockStETH(_VETOER_2, 3 * 10 ** 18); _lockWstETH(_VETOER_2, 5 * 10 ** 18); - _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); + _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME().plusSeconds(1)); _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); @@ -136,7 +137,7 @@ contract EscrowHappyPath is TestHelpers { uint256 secondVetoerStETHSharesAfterRebase = _ST_ETH.sharesOf(_VETOER_2); uint256 secondVetoerWstETHBalanceAfterRebase = _WST_ETH.balanceOf(_VETOER_2); - _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); + _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME().plusSeconds(1)); _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); @@ -180,7 +181,7 @@ contract EscrowHappyPath is TestHelpers { rebase(-100); - _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); + _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME().plusSeconds(1)); _unlockStETH(_VETOER_1); _unlockWstETH(_VETOER_1); @@ -206,7 +207,7 @@ contract EscrowHappyPath is TestHelpers { _lockUnstETH(_VETOER_1, unstETHIds); - _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); + _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME().plusSeconds(1)); _unlockUnstETH(_VETOER_1, unstETHIds); } @@ -377,10 +378,10 @@ contract EscrowHappyPath is TestHelpers { vm.prank(_VETOER_1); escrow.withdrawStETHAsETH(); - _wait(_RAGE_QUIT_EXTRA_TIMELOCK + 1); + _wait(_RAGE_QUIT_EXTRA_TIMELOCK.plusSeconds(1)); assertEq(escrow.isRageQuitFinalized(), true); - _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK + 1); + _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK.plusSeconds(1)); vm.startPrank(_VETOER_1); escrow.withdrawStETHAsETH(); @@ -418,10 +419,10 @@ contract EscrowHappyPath is TestHelpers { assertEq(escrow.isRageQuitFinalized(), false); - _wait(_RAGE_QUIT_EXTRA_TIMELOCK + 1); + _wait(_RAGE_QUIT_EXTRA_TIMELOCK.plusSeconds(1)); assertEq(escrow.isRageQuitFinalized(), true); - _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK + 1); + _wait(_RAGE_QUIT_WITHDRAWALS_TIMELOCK.plusSeconds(1)); vm.startPrank(_VETOER_1); escrow.withdrawUnstETHAsETH(unstETHIds); @@ -443,7 +444,7 @@ contract EscrowHappyPath is TestHelpers { assertEq(escrow.getVetoerState(_VETOER_1).wstETHShares, firstVetoerWstETHAmount); assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); - _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); + _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME().plusSeconds(1)); uint256[] memory stETHWithdrawalRequestAmounts = new uint256[](1); stETHWithdrawalRequestAmounts[0] = firstVetoerStETHAmount; @@ -483,7 +484,7 @@ contract EscrowHappyPath is TestHelpers { assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); - _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); + _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME().plusSeconds(1)); vm.prank(_VETOER_1); escrow.unlockUnstETH(stETHWithdrawalRequestIds); diff --git a/test/scenario/gate-seal-breaker.t.sol b/test/scenario/gate-seal-breaker.t.sol index 4f0dea97..ee925d80 100644 --- a/test/scenario/gate-seal-breaker.t.sol +++ b/test/scenario/gate-seal-breaker.t.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {percents, ScenarioTestBlueprint} from "../utils/scenario-test-blueprint.sol"; +import { + percents, ScenarioTestBlueprint, DurationType, Timestamps, Durations +} from "../utils/scenario-test-blueprint.sol"; import {GateSealMock} from "../mocks/GateSealMock.sol"; import {GateSealBreaker, IGateSeal} from "contracts/GateSealBreaker.sol"; @@ -9,8 +11,8 @@ import {GateSealBreaker, IGateSeal} from "contracts/GateSealBreaker.sol"; import {DAO_AGENT} from "../utils/mainnet-addresses.sol"; contract SealBreakerScenarioTest is ScenarioTestBlueprint { - uint256 private immutable _RELEASE_DELAY = 5 days; - uint256 private immutable _MIN_SEAL_DURATION = 14 days; + DurationType private immutable _RELEASE_DELAY = Durations.from(5 days); + DurationType private immutable _MIN_SEAL_DURATION = Durations.from(14 days); address private immutable _VETOER = makeAddr("VETOER"); @@ -25,9 +27,9 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { _sealables.push(address(_WITHDRAWAL_QUEUE)); - _gateSeal = new GateSealMock(_MIN_SEAL_DURATION, _SEALING_COMMITTEE_LIFETIME); + _gateSeal = new GateSealMock(_MIN_SEAL_DURATION.toSeconds(), _SEALING_COMMITTEE_LIFETIME.toSeconds()); - _sealBreaker = new GateSealBreaker(_RELEASE_DELAY, address(this), address(_dualGovernance)); + _sealBreaker = new GateSealBreaker(_RELEASE_DELAY.toSeconds(), address(this), address(_dualGovernance)); _sealBreaker.registerGateSeal(_gateSeal); @@ -56,7 +58,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { // validate Withdrawal Queue was paused assertTrue(_WITHDRAWAL_QUEUE.isPaused()); - _wait(_MIN_SEAL_DURATION + 1); + _wait(_MIN_SEAL_DURATION.plusSeconds(1)); // validate the dual governance still in the veto signaling state _assertVetoSignalingState(); @@ -66,11 +68,11 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { _sealBreaker.startRelease(_gateSeal); // wait the governance returns to normal state - _wait(14 days); + _wait(Durations.from(14 days)); _activateNextState(); _assertVetoSignalingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); @@ -82,7 +84,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { _sealBreaker.enactRelease(_gateSeal); // anyone may release the seal after timelock - _wait(_RELEASE_DELAY + 1); + _wait(_RELEASE_DELAY.plusSeconds(1)); _sealBreaker.enactRelease(_gateSeal); assertFalse(_WITHDRAWAL_QUEUE.isPaused()); @@ -100,7 +102,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { assertTrue(_WITHDRAWAL_QUEUE.isPaused()); // wait some time, before dual governance enters veto signaling state - _wait(_MIN_SEAL_DURATION / 2); + _wait(_MIN_SEAL_DURATION.dividedBy(2)); _lockStETH(_VETOER, percents("10.0")); _assertVetoSignalingState(); @@ -109,25 +111,25 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); _sealBreaker.startRelease(_gateSeal); - _wait(_MIN_SEAL_DURATION / 2 + 1); + _wait(_MIN_SEAL_DURATION.dividedBy(2).plusSeconds(1)); // seal can't be released before the governance returns to Normal state vm.expectRevert(GateSealBreaker.GovernanceLocked.selector); _sealBreaker.startRelease(_gateSeal); // wait the governance returns to normal state - _wait(14 days); + _wait(Durations.from(14 days)); _activateNextState(); _assertVetoSignalingDeactivationState(); - _wait(_dualGovernance.CONFIG().VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_dualGovernance.CONFIG().VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); // the stETH whale takes his funds back from Escrow _unlockStETH(_VETOER); - _wait(_dualGovernance.CONFIG().VETO_COOLDOWN_DURATION() + 1); + _wait(_dualGovernance.CONFIG().VETO_COOLDOWN_DURATION().plusSeconds(1)); _activateNextState(); _assertNormalState(); @@ -139,7 +141,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { _sealBreaker.enactRelease(_gateSeal); // anyone may release the seal after timelock - _wait(_RELEASE_DELAY + 1); + _wait(_RELEASE_DELAY.plusSeconds(1)); _sealBreaker.enactRelease(_gateSeal); assertFalse(_WITHDRAWAL_QUEUE.isPaused()); } @@ -159,7 +161,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); _sealBreaker.startRelease(_gateSeal); - vm.warp(block.timestamp + _MIN_SEAL_DURATION + 1); + _wait(_MIN_SEAL_DURATION.plusSeconds(1)); // now seal may be released _sealBreaker.startRelease(_gateSeal); @@ -169,7 +171,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { _sealBreaker.enactRelease(_gateSeal); // anyone may release the seal after timelock - _wait(_RELEASE_DELAY + 1); + _wait(_RELEASE_DELAY.plusSeconds(1)); _sealBreaker.enactRelease(_gateSeal); assertFalse(_WITHDRAWAL_QUEUE.isPaused()); @@ -190,7 +192,7 @@ contract SealBreakerScenarioTest is ScenarioTestBlueprint { vm.expectRevert(GateSealBreaker.MinSealDurationNotPassed.selector); _sealBreaker.startRelease(_gateSeal); - _wait(_MIN_SEAL_DURATION + 1); + _wait(_MIN_SEAL_DURATION.plusSeconds(1)); // now seal may be released _sealBreaker.startRelease(_gateSeal); diff --git a/test/scenario/gov-state-transitions.t.sol b/test/scenario/gov-state-transitions.t.sol index 90b35b4e..efb12696 100644 --- a/test/scenario/gov-state-transitions.t.sol +++ b/test/scenario/gov-state-transitions.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {ScenarioTestBlueprint, percents} from "../utils/scenario-test-blueprint.sol"; +import {ScenarioTestBlueprint, percents, Durations} from "../utils/scenario-test-blueprint.sol"; contract GovernanceStateTransitions is ScenarioTestBlueprint { address internal immutable _VETOER = makeAddr("VETOER"); @@ -21,12 +21,12 @@ contract GovernanceStateTransitions is ScenarioTestBlueprint { _lockStETH(_VETOER, 1 gwei); _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION() / 2); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().dividedBy(2)); _activateNextState(); _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION() / 2 + 1); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().dividedBy(2).plusSeconds(1)); _activateNextState(); _assertVetoSignalingDeactivationState(); @@ -39,19 +39,19 @@ contract GovernanceStateTransitions is ScenarioTestBlueprint { _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION() / 2); + _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION().dividedBy(2)); _activateNextState(); _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION() / 2); + _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION().dividedBy(2)); _activateNextState(); _assertVetoSignalingState(); _lockStETH(_VETOER, 1 gwei); - _wait(1 seconds); + _wait(Durations.from(1 seconds)); _activateNextState(); _assertRageQuitState(); @@ -67,12 +67,12 @@ contract GovernanceStateTransitions is ScenarioTestBlueprint { _lockStETH(_VETOER, 1 gwei); _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION() + 1); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); @@ -81,7 +81,7 @@ contract GovernanceStateTransitions is ScenarioTestBlueprint { _getVetoSignallingEscrow().unlockStETH(); vm.stopPrank(); - _wait(_config.VETO_COOLDOWN_DURATION() + 1); + _wait(_config.VETO_COOLDOWN_DURATION().plusSeconds(1)); _activateNextState(); _assertNormalState(); @@ -96,17 +96,17 @@ contract GovernanceStateTransitions is ScenarioTestBlueprint { _lockStETH(_VETOER, 1 gwei); _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION() + 1); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); - _wait(_config.VETO_COOLDOWN_DURATION() + 1); + _wait(_config.VETO_COOLDOWN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingState(); @@ -126,7 +126,7 @@ contract GovernanceStateTransitions is ScenarioTestBlueprint { _lockStETH(_VETOER, 1 gwei); _assertVetoSignalingState(); - _wait(1 seconds); + _wait(Durations.from(1 seconds)); _activateNextState(); _assertRageQuitState(); } diff --git a/test/scenario/happy-path-plan-b.t.sol b/test/scenario/happy-path-plan-b.t.sol index 33f7fef9..3083c0c3 100644 --- a/test/scenario/happy-path-plan-b.t.sol +++ b/test/scenario/happy-path-plan-b.t.sol @@ -8,7 +8,10 @@ import { ScenarioTestBlueprint, ExecutorCall, ExecutorCallHelpers, - DualGovernance + DualGovernance, + Timestamp, + Timestamps, + Durations } from "../utils/scenario-test-blueprint.sol"; import {Proposals} from "contracts/libraries/Proposals.sol"; @@ -66,7 +69,7 @@ contract PlanBSetup is ScenarioTestBlueprint { _assertCanSchedule(_singleGovernance, maliciousProposalId, false); // some time required to assemble the emergency committee and activate emergency mode - _wait(_config.AFTER_SUBMIT_DELAY() / 2); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); // malicious call still can't be scheduled _assertCanSchedule(_singleGovernance, maliciousProposalId, false); @@ -76,14 +79,14 @@ contract PlanBSetup is ScenarioTestBlueprint { _timelock.activateEmergencyMode(); // emergency mode was successfully activated - uint256 expectedEmergencyModeEndTimestamp = block.timestamp + _EMERGENCY_MODE_DURATION; + Timestamp expectedEmergencyModeEndTimestamp = _EMERGENCY_MODE_DURATION.addTo(Timestamps.now()); emergencyState = _timelock.getEmergencyState(); assertTrue(emergencyState.isEmergencyModeActivated); assertEq(emergencyState.emergencyModeEndsAfter, expectedEmergencyModeEndTimestamp); // after the submit delay has passed, the call still may be scheduled, but executed // only the emergency committee - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2 + 1); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2).plusSeconds(1)); _assertCanSchedule(_singleGovernance, maliciousProposalId, true); _scheduleProposal(_singleGovernance, maliciousProposalId); @@ -105,7 +108,7 @@ contract PlanBSetup is ScenarioTestBlueprint { { // Lido contributors work hard to implement and ship the Dual Governance mechanism // before the emergency mode is over - vm.warp(block.timestamp + _EMERGENCY_PROTECTION_DURATION / 2); + _wait(_EMERGENCY_PROTECTION_DURATION.dividedBy(2)); // Time passes but malicious proposal still on hold _assertCanExecute(maliciousProposalId, false); @@ -127,7 +130,7 @@ contract PlanBSetup is ScenarioTestBlueprint { _EMERGENCY_ACTIVATION_COMMITTEE, _EMERGENCY_EXECUTION_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, - 30 days + Durations.from(30 days) ) ) ] @@ -161,7 +164,7 @@ contract PlanBSetup is ScenarioTestBlueprint { // ACT 4. 🫡 EMERGENCY COMMITTEE LIFETIME IS ENDED // --- { - vm.warp(block.timestamp + _EMERGENCY_PROTECTION_DURATION + 1); + _wait(_EMERGENCY_PROTECTION_DURATION.plusSeconds(1)); assertFalse(_timelock.isEmergencyProtectionEnabled()); uint256 proposalId = _submitProposal( @@ -189,7 +192,7 @@ contract PlanBSetup is ScenarioTestBlueprint { // --- { // some time later, the major Dual Governance update release is ready to be launched - vm.warp(block.timestamp + 365 days); + _wait(Durations.from(365 days)); DualGovernance dualGovernanceV2 = new DualGovernance(address(_config), address(_timelock), address(_escrowMasterCopy), _ADMIN_PROPOSER); @@ -205,7 +208,7 @@ contract PlanBSetup is ScenarioTestBlueprint { _EMERGENCY_ACTIVATION_COMMITTEE, _EMERGENCY_EXECUTION_COMMITTEE, _EMERGENCY_PROTECTION_DURATION, - 30 days + Durations.from(30 days) ) ) ] @@ -235,8 +238,8 @@ contract PlanBSetup is ScenarioTestBlueprint { assertEq(emergencyState.activationCommittee, _EMERGENCY_ACTIVATION_COMMITTEE); assertEq(emergencyState.executionCommittee, _EMERGENCY_EXECUTION_COMMITTEE); assertFalse(emergencyState.isEmergencyModeActivated); - assertEq(emergencyState.emergencyModeDuration, 30 days); - assertEq(emergencyState.emergencyModeEndsAfter, 0); + assertEq(emergencyState.emergencyModeDuration, Durations.from(30 days)); + assertEq(emergencyState.emergencyModeEndsAfter, Timestamps.ZERO); // use the new version of the dual governance in the future calls _dualGovernance = dualGovernanceV2; @@ -287,7 +290,7 @@ contract PlanBSetup is ScenarioTestBlueprint { // activate emergency mode EmergencyState memory emergencyState; { - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); vm.prank(_EMERGENCY_ACTIVATION_COMMITTEE); _timelock.activateEmergencyMode(); @@ -299,12 +302,13 @@ contract PlanBSetup is ScenarioTestBlueprint { // delay for malicious proposal has passed, but it can't be executed because of emergency mode was activated { // the after submit delay has passed, and proposal can be scheduled, but not executed - _wait(_config.AFTER_SUBMIT_DELAY() + 1); + _wait(_config.AFTER_SCHEDULE_DELAY() + Durations.from(1 seconds)); + _wait(_config.AFTER_SUBMIT_DELAY().plusSeconds(1)); _assertCanSchedule(_singleGovernance, maliciousProposalId, true); _scheduleProposal(_singleGovernance, maliciousProposalId); - _wait(_config.AFTER_SCHEDULE_DELAY() + 1); + _wait(_config.AFTER_SCHEDULE_DELAY().plusSeconds(1)); _assertCanExecute(maliciousProposalId, false); vm.expectRevert( @@ -316,10 +320,10 @@ contract PlanBSetup is ScenarioTestBlueprint { // another malicious call is scheduled during the emergency mode also can't be executed uint256 anotherMaliciousProposalId; { - vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); + _wait(_EMERGENCY_MODE_DURATION.dividedBy(2)); // emergency mode still active - assertTrue(emergencyState.emergencyModeEndsAfter > block.timestamp); + assertTrue(emergencyState.emergencyModeEndsAfter > Timestamps.now()); anotherMaliciousProposalId = _submitProposal(_singleGovernance, "Another Rug Pool attempt", maliciousCalls); @@ -327,10 +331,10 @@ contract PlanBSetup is ScenarioTestBlueprint { _assertCanExecute(anotherMaliciousProposalId, false); // the after submit delay has passed, and proposal can not be executed - _wait(_config.AFTER_SUBMIT_DELAY() + 1); + _wait(_config.AFTER_SUBMIT_DELAY().plusSeconds(1)); _assertCanSchedule(_singleGovernance, anotherMaliciousProposalId, true); - _wait(_config.AFTER_SCHEDULE_DELAY() + 1); + _wait(_config.AFTER_SCHEDULE_DELAY().plusSeconds(1)); _assertCanExecute(anotherMaliciousProposalId, false); vm.expectRevert( @@ -341,8 +345,8 @@ contract PlanBSetup is ScenarioTestBlueprint { // emergency mode is over but proposals can't be executed until the emergency mode turned off manually { - vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); - assertTrue(emergencyState.emergencyModeEndsAfter < block.timestamp); + _wait(_EMERGENCY_MODE_DURATION.dividedBy(2)); + assertTrue(emergencyState.emergencyModeEndsAfter < Timestamps.now()); vm.expectRevert( abi.encodeWithSelector(EmergencyProtection.InvalidEmergencyModeActiveValue.selector, true, false) @@ -397,8 +401,8 @@ contract PlanBSetup is ScenarioTestBlueprint { // before the end of the emergency mode emergency committee can reset the controller to // disable dual governance { - vm.warp(block.timestamp + _EMERGENCY_MODE_DURATION / 2); - assertTrue(emergencyState.emergencyModeEndsAfter > block.timestamp); + _wait(_EMERGENCY_MODE_DURATION.dividedBy(2)); + assertTrue(emergencyState.emergencyModeEndsAfter > Timestamps.now()); vm.prank(_EMERGENCY_EXECUTION_COMMITTEE); _timelock.emergencyReset(); @@ -408,8 +412,8 @@ contract PlanBSetup is ScenarioTestBlueprint { emergencyState = _timelock.getEmergencyState(); assertEq(emergencyState.activationCommittee, address(0)); assertEq(emergencyState.executionCommittee, address(0)); - assertEq(emergencyState.emergencyModeDuration, 0); - assertEq(emergencyState.emergencyModeEndsAfter, 0); + assertEq(emergencyState.emergencyModeDuration, Durations.ZERO); + assertEq(emergencyState.emergencyModeEndsAfter, Timestamps.ZERO); assertFalse(emergencyState.isEmergencyModeActivated); } } @@ -423,7 +427,7 @@ contract PlanBSetup is ScenarioTestBlueprint { // wait till the protection duration passes { - vm.warp(block.timestamp + _EMERGENCY_PROTECTION_DURATION + 1); + _wait(_EMERGENCY_PROTECTION_DURATION.plusSeconds(1)); } EmergencyState memory emergencyState = _timelock.getEmergencyState(); diff --git a/test/scenario/happy-path.t.sol b/test/scenario/happy-path.t.sol index 3cde7bd0..fd55104b 100644 --- a/test/scenario/happy-path.t.sol +++ b/test/scenario/happy-path.t.sol @@ -29,14 +29,14 @@ contract HappyPathTest is ScenarioTestBlueprint { _assertProposalSubmitted(proposalId); _assertSubmittedProposalData(proposalId, regularStaffCalls); - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); // the min execution delay hasn't elapsed yet vm.expectRevert(abi.encodeWithSelector(Proposals.AfterSubmitDelayNotPassed.selector, (proposalId))); _scheduleProposal(_dualGovernance, proposalId); // wait till the first phase of timelock passes - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2 + 1); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2).plusSeconds(1)); _assertCanSchedule(_dualGovernance, proposalId, true); _scheduleProposal(_dualGovernance, proposalId); @@ -67,7 +67,7 @@ contract HappyPathTest is ScenarioTestBlueprint { uint256 proposalId = _submitProposal(_dualGovernance, "Multiple items", multipleCalls); - _wait(_config.AFTER_SUBMIT_DELAY() / 2); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); // proposal can't be scheduled before the after submit delay has passed _assertCanSchedule(_dualGovernance, proposalId, false); @@ -77,7 +77,7 @@ contract HappyPathTest is ScenarioTestBlueprint { _scheduleProposal(_dualGovernance, proposalId); // wait till the DG-enforced timelock elapses - _wait(_config.AFTER_SUBMIT_DELAY() / 2 + 1); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2).plusSeconds(1)); _assertCanSchedule(_dualGovernance, proposalId, true); _scheduleProposal(_dualGovernance, proposalId); diff --git a/test/scenario/last-moment-malicious-proposal.t.sol b/test/scenario/last-moment-malicious-proposal.t.sol index d40e4df3..99bf4e47 100644 --- a/test/scenario/last-moment-malicious-proposal.t.sol +++ b/test/scenario/last-moment-malicious-proposal.t.sol @@ -6,7 +6,8 @@ import { ScenarioTestBlueprint, ExecutorCall, ExecutorCallHelpers, - DualGovernanceState + DualGovernanceState, + Durations } from "../utils/scenario-test-blueprint.sol"; interface IDangerousContract { @@ -44,7 +45,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _logVetoSignallingState(); // almost all veto signalling period has passed - _wait(20 days); + _wait(Durations.from(20 days)); _activateNextState(); _assertVetoSignalingState(); _logVetoSignallingState(); @@ -68,7 +69,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("4. MALICIOUS ACTOR UNLOCK FUNDS FROM ESCROW"); { - _wait(12 seconds); + _wait(Durations.from(12 seconds)); _unlockStETH(maliciousActor); _assertVetoSignalingDeactivationState(); _logVetoSignallingDeactivationState(); @@ -77,12 +78,12 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { address stEthHolders = makeAddr("STETH_WHALE"); _step("5. STETH HOLDERS ACQUIRING QUORUM TO VETO MALICIOUS PROPOSAL"); { - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() / 2); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().dividedBy(2)); _lockStETH(stEthHolders, percents(_config.FIRST_SEAL_RAGE_QUIT_SUPPORT() + 1)); _assertVetoSignalingDeactivationState(); _logVetoSignallingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() / 2 + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().dividedBy(2).plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); } @@ -104,7 +105,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("7. NEW VETO SIGNALLING ROUND FOR MALICIOUS PROPOSAL IS STARTED"); { - _wait(_config.VETO_COOLDOWN_DURATION() + 1); + _wait(_config.VETO_COOLDOWN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingState(); _logVetoSignallingState(); @@ -114,7 +115,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _assertVetoSignalingState(); _logVetoSignallingState(); - _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION() + 1); + _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION().plusSeconds(1)); _logVetoSignallingState(); _activateNextState(); _assertRageQuitState(); @@ -145,17 +146,17 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { // --- address maliciousActor = makeAddr("MALICIOUS_ACTOR"); { - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2)); _lockStETH(maliciousActor, percents("12.0")); _assertVetoSignalingState(); - vm.warp(block.timestamp + _config.AFTER_SUBMIT_DELAY() / 2 + 1); + _wait(_config.AFTER_SUBMIT_DELAY().dividedBy(2).plusSeconds(1)); _assertProposalSubmitted(proposalId); (, uint256 currentVetoSignallingDuration,,) = _getVetoSignallingState(); - vm.warp(block.timestamp + currentVetoSignallingDuration + 1); + _wait(Durations.from(currentVetoSignallingDuration + 1)); _activateNextState(); _assertVetoSignalingDeactivationState(); @@ -165,7 +166,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { // ACT 3. THE VETO SIGNALLING DEACTIVATION DURATION EQUALS TO "VETO_SIGNALLING_DEACTIVATION_MAX_DURATION" DAYS // --- { - vm.warp(block.timestamp + _config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); @@ -206,12 +207,12 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("3. THE VETO SIGNALLING & DEACTIVATION PASSED BUT PROPOSAL STILL NOT EXECUTABLE"); { - _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION() + 1); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingDeactivationState(); _logVetoSignallingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); @@ -222,7 +223,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("4. AFTER THE VETO COOLDOWN GOVERNANCE TRANSITIONS INTO NORMAL STATE"); { _unlockStETH(maliciousActor); - _wait(_config.VETO_COOLDOWN_DURATION() + 1); + _wait(_config.VETO_COOLDOWN_DURATION().plusSeconds(1)); _activateNextState(); _assertNormalState(); } @@ -260,12 +261,12 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("3. THE VETO SIGNALLING & DEACTIVATION PASSED BUT PROPOSAL STILL NOT EXECUTABLE"); { - _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION() + 1); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingDeactivationState(); _logVetoSignallingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); @@ -275,7 +276,7 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("4. AFTER THE VETO COOLDOWN NEW VETO SIGNALLING ROUND STARTED"); { - _wait(_config.VETO_COOLDOWN_DURATION() + 1); + _wait(_config.VETO_COOLDOWN_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoSignalingState(); _logVetoSignallingState(); @@ -286,12 +287,12 @@ contract LastMomentMaliciousProposalSuccessor is ScenarioTestBlueprint { _step("5. PROPOSAL EXECUTABLE IN THE NEXT VETO COOLDOWN"); { - _wait(2 * _config.DYNAMIC_TIMELOCK_MIN_DURATION()); + _wait(_config.DYNAMIC_TIMELOCK_MIN_DURATION().multipliedBy(2)); _activateNextState(); _assertVetoSignalingDeactivationState(); _logVetoSignallingDeactivationState(); - _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION() + 1); + _wait(_config.VETO_SIGNALLING_DEACTIVATION_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertVetoCooldownState(); diff --git a/test/scenario/veto-cooldown-mechanics.t.sol b/test/scenario/veto-cooldown-mechanics.t.sol index 38e75c0e..9ad3eca0 100644 --- a/test/scenario/veto-cooldown-mechanics.t.sol +++ b/test/scenario/veto-cooldown-mechanics.t.sol @@ -44,7 +44,7 @@ contract VetoCooldownMechanicsTest is ScenarioTestBlueprint { vetoedStETHAmount = _lockStETH(vetoer, percents(_config.SECOND_SEAL_RAGE_QUIT_SUPPORT() + 1)); _assertVetoSignalingState(); - _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION() + 1); + _wait(_config.DYNAMIC_TIMELOCK_MAX_DURATION().plusSeconds(1)); _activateNextState(); _assertRageQuitState(); } @@ -87,7 +87,7 @@ contract VetoCooldownMechanicsTest is ScenarioTestBlueprint { rageQuitEscrow.claimNextWithdrawalsBatch(offset, hints); } - _wait(_config.RAGE_QUIT_EXTENSION_DELAY() + 1); + _wait(_config.RAGE_QUIT_EXTENSION_DELAY().plusSeconds(1)); assertTrue(rageQuitEscrow.isRageQuitFinalized()); } diff --git a/test/unit/EmergencyProtectedTimelock.t.sol b/test/unit/EmergencyProtectedTimelock.t.sol index 7216c248..55ad0a24 100644 --- a/test/unit/EmergencyProtectedTimelock.t.sol +++ b/test/unit/EmergencyProtectedTimelock.t.sol @@ -12,7 +12,7 @@ import {Executor} from "contracts/Executor.sol"; import {Proposal, Proposals, ExecutorCall, Status} from "contracts/libraries/Proposals.sol"; import {EmergencyProtection, EmergencyState} from "contracts/libraries/EmergencyProtection.sol"; -import {UnitTest} from "test/utils/unit-test.sol"; +import {UnitTest, Duration, Timestamp, Timestamps, Durations, console} from "test/utils/unit-test.sol"; import {TargetMock} from "test/utils/utils.sol"; import {ExecutorCallHelpers} from "test/utils/executor-calls.sol"; import {IDangerousContract} from "test/utils/interfaces.sol"; @@ -25,8 +25,8 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { address private _emergencyActivator = makeAddr("EMERGENCY_ACTIVATION_COMMITTEE"); address private _emergencyEnactor = makeAddr("EMERGENCY_EXECUTION_COMMITTEE"); - uint256 private _emergencyModeDuration = 180 days; - uint256 private _emergencyProtectionDuration = 90 days; + Duration private _emergencyModeDuration = Durations.from(180 days); + Duration private _emergencyProtectionDuration = Durations.from(90 days); address private _emergencyGovernance = makeAddr("EMERGENCY_GOVERNANCE"); address private _dualGovernance = makeAddr("DUAL_GOVERNANCE"); @@ -395,7 +395,7 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { EmergencyState memory state = _timelock.getEmergencyState(); assertEq(_isEmergencyStateActivated(), true); - _wait(state.emergencyModeDuration + 1); + _wait(state.emergencyModeDuration.plusSeconds(1)); vm.prank(stranger); _timelock.deactivateEmergencyMode(); @@ -449,9 +449,9 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(newState.activationCommittee, address(0)); assertEq(newState.executionCommittee, address(0)); - assertEq(newState.protectedTill, 0); - assertEq(newState.emergencyModeDuration, 0); - assertEq(newState.emergencyModeEndsAfter, 0); + assertEq(newState.protectedTill, Timestamps.ZERO); + assertEq(newState.emergencyModeDuration, Durations.ZERO); + assertEq(newState.emergencyModeEndsAfter, Timestamps.ZERO); } function test_after_emergency_reset_all_proposals_are_cancelled() external { @@ -518,9 +518,9 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(state.activationCommittee, _emergencyActivator); assertEq(state.executionCommittee, _emergencyEnactor); - assertEq(state.protectedTill, block.timestamp + _emergencyProtectionDuration); + assertEq(state.protectedTill, _emergencyProtectionDuration.addTo(Timestamps.now())); assertEq(state.emergencyModeDuration, _emergencyModeDuration); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); assertEq(state.isEmergencyModeActivated, false); } @@ -540,9 +540,9 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(state.activationCommittee, address(0)); assertEq(state.executionCommittee, address(0)); - assertEq(state.protectedTill, 0); - assertEq(state.emergencyModeDuration, 0); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, Timestamps.ZERO); + assertEq(state.emergencyModeDuration, Durations.ZERO); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); assertEq(state.isEmergencyModeActivated, false); } @@ -604,9 +604,9 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(state.isEmergencyModeActivated, false); assertEq(state.activationCommittee, address(0)); assertEq(state.executionCommittee, address(0)); - assertEq(state.protectedTill, 0); - assertEq(state.emergencyModeDuration, 0); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, Timestamps.ZERO); + assertEq(state.emergencyModeDuration, Durations.ZERO); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); vm.prank(_adminExecutor); _localTimelock.setEmergencyProtection( @@ -618,9 +618,9 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(_localTimelock.getEmergencyState().isEmergencyModeActivated, false); assertEq(state.activationCommittee, _emergencyActivator); assertEq(state.executionCommittee, _emergencyEnactor); - assertEq(state.protectedTill, block.timestamp + _emergencyProtectionDuration); + assertEq(state.protectedTill, _emergencyProtectionDuration.addTo(Timestamps.now())); assertEq(state.emergencyModeDuration, _emergencyModeDuration); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); vm.prank(_emergencyActivator); _localTimelock.activateEmergencyMode(); @@ -628,11 +628,11 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { state = _localTimelock.getEmergencyState(); assertEq(_localTimelock.getEmergencyState().isEmergencyModeActivated, true); - assertEq(state.activationCommittee, _emergencyActivator); assertEq(state.executionCommittee, _emergencyEnactor); - assertEq(state.protectedTill, block.timestamp + _emergencyProtectionDuration); + assertEq(state.activationCommittee, _emergencyActivator); assertEq(state.emergencyModeDuration, _emergencyModeDuration); - assertEq(state.emergencyModeEndsAfter, block.timestamp + _emergencyModeDuration); + assertEq(state.protectedTill, _emergencyProtectionDuration.addTo(Timestamps.now())); + assertEq(state.emergencyModeEndsAfter, _emergencyModeDuration.addTo(Timestamps.now())); vm.prank(_adminExecutor); _localTimelock.deactivateEmergencyMode(); @@ -642,9 +642,9 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(state.isEmergencyModeActivated, false); assertEq(state.activationCommittee, address(0)); assertEq(state.executionCommittee, address(0)); - assertEq(state.protectedTill, 0); - assertEq(state.emergencyModeDuration, 0); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, Timestamps.ZERO); + assertEq(state.emergencyModeDuration, Durations.ZERO); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); } function test_get_emergency_state_reset() external { @@ -666,15 +666,15 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(state.isEmergencyModeActivated, false); assertEq(state.activationCommittee, address(0)); assertEq(state.executionCommittee, address(0)); - assertEq(state.protectedTill, 0); - assertEq(state.emergencyModeDuration, 0); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, Timestamps.ZERO); + assertEq(state.emergencyModeDuration, Durations.ZERO); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); } // EmergencyProtectedTimelock.getGovernance() function testFuzz_get_governance(address governance) external { - vm.assume(governance != address(0)); + vm.assume(governance != address(0) && governance != _timelock.getGovernance()); vm.prank(_adminExecutor); _timelock.setGovernance(governance); assertEq(_timelock.getGovernance(), governance); @@ -692,13 +692,13 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { Proposal memory submittedProposal = _timelock.getProposal(1); - uint256 submitTimestamp = block.timestamp; + Timestamp submitTimestamp = Timestamps.now(); assertEq(submittedProposal.id, 1); assertEq(submittedProposal.executor, _adminExecutor); assertEq(submittedProposal.submittedAt, submitTimestamp); - assertEq(submittedProposal.scheduledAt, 0); - assertEq(submittedProposal.executedAt, 0); + assertEq(submittedProposal.scheduledAt, Timestamps.ZERO); + assertEq(submittedProposal.executedAt, Timestamps.ZERO); // assertEq doesn't support comparing enumerables so far assert(submittedProposal.status == Status.Submitted); assertEq(submittedProposal.calls.length, 1); @@ -709,7 +709,7 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { _wait(_config.AFTER_SUBMIT_DELAY()); _timelock.schedule(1); - uint256 scheduleTimestamp = block.timestamp; + Timestamp scheduleTimestamp = Timestamps.now(); Proposal memory scheduledProposal = _timelock.getProposal(1); @@ -717,7 +717,7 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(scheduledProposal.executor, _adminExecutor); assertEq(scheduledProposal.submittedAt, submitTimestamp); assertEq(scheduledProposal.scheduledAt, scheduleTimestamp); - assertEq(scheduledProposal.executedAt, 0); + assertEq(scheduledProposal.executedAt, Timestamps.ZERO); // // assertEq doesn't support comparing enumerables so far assert(scheduledProposal.status == Status.Scheduled); assertEq(scheduledProposal.calls.length, 1); @@ -730,7 +730,7 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { _timelock.execute(1); Proposal memory executedProposal = _timelock.getProposal(1); - uint256 executeTimestamp = block.timestamp; + Timestamp executeTimestamp = Timestamps.now(); assertEq(executedProposal.id, 1); assertEq(executedProposal.executor, _adminExecutor); @@ -751,8 +751,8 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(cancelledProposal.id, 2); assertEq(cancelledProposal.executor, _adminExecutor); assertEq(cancelledProposal.submittedAt, submitTimestamp); - assertEq(cancelledProposal.scheduledAt, 0); - assertEq(cancelledProposal.executedAt, 0); + assertEq(cancelledProposal.scheduledAt, Timestamps.ZERO); + assertEq(cancelledProposal.executedAt, Timestamps.ZERO); // assertEq doesn't support comparing enumerables so far assert(cancelledProposal.status == Status.Cancelled); assertEq(cancelledProposal.calls.length, 1); diff --git a/test/unit/libraries/EmergencyProtection.t.sol b/test/unit/libraries/EmergencyProtection.t.sol index 442c708f..d3681e08 100644 --- a/test/unit/libraries/EmergencyProtection.t.sol +++ b/test/unit/libraries/EmergencyProtection.t.sol @@ -5,7 +5,7 @@ import {Test, Vm} from "forge-std/Test.sol"; import {EmergencyProtection, EmergencyState} from "contracts/libraries/EmergencyProtection.sol"; -import {UnitTest} from "test/utils/unit-test.sol"; +import {UnitTest, Duration, Durations, Timestamp, Timestamps} from "test/utils/unit-test.sol"; contract EmergencyProtectionUnitTests is UnitTest { using EmergencyProtection for EmergencyProtection.State; @@ -15,11 +15,11 @@ contract EmergencyProtectionUnitTests is UnitTest { function testFuzz_setup_emergency_protection( address activationCommittee, address executionCommittee, - uint256 protectedTill, - uint256 duration + Duration protectionDuration, + Duration duration ) external { - vm.assume(protectedTill > 0 && protectedTill < type(uint40).max); - vm.assume(duration > 0 && duration < type(uint32).max); + vm.assume(protectionDuration > Durations.ZERO); + vm.assume(duration > Durations.ZERO); vm.assume(activationCommittee != address(0)); vm.assume(executionCommittee != address(0)); @@ -28,125 +28,150 @@ contract EmergencyProtectionUnitTests is UnitTest { vm.expectEmit(); emit EmergencyProtection.EmergencyExecutionCommitteeSet(executionCommittee); vm.expectEmit(); - emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(block.timestamp + protectedTill); + emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(protectionDuration.addTo(Timestamps.now())); vm.expectEmit(); emit EmergencyProtection.EmergencyModeDurationSet(duration); vm.recordLogs(); - _emergencyProtection.setup(activationCommittee, executionCommittee, protectedTill, duration); + _emergencyProtection.setup(activationCommittee, executionCommittee, protectionDuration, duration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 4); assertEq(_emergencyProtection.activationCommittee, activationCommittee); assertEq(_emergencyProtection.executionCommittee, executionCommittee); - assertEq(_emergencyProtection.protectedTill, block.timestamp + protectedTill); + assertEq(_emergencyProtection.protectedTill, protectionDuration.addTo(Timestamps.now())); assertEq(_emergencyProtection.emergencyModeDuration, duration); - assertEq(_emergencyProtection.emergencyModeEndsAfter, 0); + assertEq(_emergencyProtection.emergencyModeEndsAfter, Timestamps.ZERO); } function test_setup_same_activation_committee() external { + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); address activationCommittee = makeAddr("activationCommittee"); - _emergencyProtection.setup(activationCommittee, address(0x2), 100, 100); + _emergencyProtection.setup(activationCommittee, address(0x2), protectionDuration, emergencyModeDuration); + + Duration newProtectionDuration = Durations.from(200 seconds); + Duration newEmergencyModeDuration = Durations.from(300 seconds); vm.expectEmit(); emit EmergencyProtection.EmergencyExecutionCommitteeSet(address(0x3)); vm.expectEmit(); - emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(block.timestamp + 200); + emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(newProtectionDuration.addTo(Timestamps.now())); vm.expectEmit(); - emit EmergencyProtection.EmergencyModeDurationSet(300); + emit EmergencyProtection.EmergencyModeDurationSet(newEmergencyModeDuration); vm.recordLogs(); - _emergencyProtection.setup(activationCommittee, address(0x3), 200, 300); + _emergencyProtection.setup(activationCommittee, address(0x3), newProtectionDuration, newEmergencyModeDuration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 3); assertEq(_emergencyProtection.activationCommittee, activationCommittee); assertEq(_emergencyProtection.executionCommittee, address(0x3)); - assertEq(_emergencyProtection.protectedTill, block.timestamp + 200); - assertEq(_emergencyProtection.emergencyModeDuration, 300); - assertEq(_emergencyProtection.emergencyModeEndsAfter, 0); + assertEq(_emergencyProtection.protectedTill, newProtectionDuration.addTo(Timestamps.now())); + assertEq(_emergencyProtection.emergencyModeDuration, newEmergencyModeDuration); + assertEq(_emergencyProtection.emergencyModeEndsAfter, Timestamps.ZERO); } function test_setup_same_execution_committee() external { + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); address executionCommittee = makeAddr("executionCommittee"); - _emergencyProtection.setup(address(0x1), executionCommittee, 100, 100); + _emergencyProtection.setup(address(0x1), executionCommittee, protectionDuration, emergencyModeDuration); + + Duration newProtectionDuration = Durations.from(200 seconds); + Duration newEmergencyModeDuration = Durations.from(300 seconds); vm.expectEmit(); emit EmergencyProtection.EmergencyActivationCommitteeSet(address(0x2)); vm.expectEmit(); - emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(block.timestamp + 200); + emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(newProtectionDuration.addTo(Timestamps.now())); vm.expectEmit(); - emit EmergencyProtection.EmergencyModeDurationSet(300); + emit EmergencyProtection.EmergencyModeDurationSet(newEmergencyModeDuration); vm.recordLogs(); - _emergencyProtection.setup(address(0x2), executionCommittee, 200, 300); + _emergencyProtection.setup(address(0x2), executionCommittee, newProtectionDuration, newEmergencyModeDuration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 3); assertEq(_emergencyProtection.activationCommittee, address(0x2)); assertEq(_emergencyProtection.executionCommittee, executionCommittee); - assertEq(_emergencyProtection.protectedTill, block.timestamp + 200); - assertEq(_emergencyProtection.emergencyModeDuration, 300); - assertEq(_emergencyProtection.emergencyModeEndsAfter, 0); + assertEq(_emergencyProtection.protectedTill, newProtectionDuration.addTo(Timestamps.now())); + assertEq(_emergencyProtection.emergencyModeDuration, newEmergencyModeDuration); + assertEq(_emergencyProtection.emergencyModeEndsAfter, Timestamps.ZERO); } function test_setup_same_protected_till() external { - _emergencyProtection.setup(address(0x1), address(0x2), 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); + + Duration newProtectionDuration = protectionDuration; // the new value is the same as previous one + Duration newEmergencyModeDuration = Durations.from(200 seconds); vm.expectEmit(); emit EmergencyProtection.EmergencyActivationCommitteeSet(address(0x3)); vm.expectEmit(); emit EmergencyProtection.EmergencyExecutionCommitteeSet(address(0x4)); vm.expectEmit(); - emit EmergencyProtection.EmergencyModeDurationSet(200); + emit EmergencyProtection.EmergencyModeDurationSet(newEmergencyModeDuration); vm.recordLogs(); - _emergencyProtection.setup(address(0x3), address(0x4), 100, 200); + _emergencyProtection.setup(address(0x3), address(0x4), newProtectionDuration, newEmergencyModeDuration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 3); assertEq(_emergencyProtection.activationCommittee, address(0x3)); assertEq(_emergencyProtection.executionCommittee, address(0x4)); - assertEq(_emergencyProtection.protectedTill, block.timestamp + 100); - assertEq(_emergencyProtection.emergencyModeDuration, 200); - assertEq(_emergencyProtection.emergencyModeEndsAfter, 0); + assertEq(_emergencyProtection.protectedTill, protectionDuration.addTo(Timestamps.now())); + assertEq(_emergencyProtection.emergencyModeDuration, newEmergencyModeDuration); + assertEq(_emergencyProtection.emergencyModeEndsAfter, Timestamps.ZERO); } function test_setup_same_emergency_mode_duration() external { - _emergencyProtection.setup(address(0x1), address(0x2), 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); + + Duration newProtectionDuration = Durations.from(200 seconds); + Duration newEmergencyModeDuration = emergencyModeDuration; // the new value is the same as previous one vm.expectEmit(); emit EmergencyProtection.EmergencyActivationCommitteeSet(address(0x3)); vm.expectEmit(); emit EmergencyProtection.EmergencyExecutionCommitteeSet(address(0x4)); vm.expectEmit(); - emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(block.timestamp + 200); + emit EmergencyProtection.EmergencyCommitteeProtectedTillSet(newProtectionDuration.addTo(Timestamps.now())); vm.recordLogs(); - _emergencyProtection.setup(address(0x3), address(0x4), 200, 100); + _emergencyProtection.setup(address(0x3), address(0x4), newProtectionDuration, newEmergencyModeDuration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 3); assertEq(_emergencyProtection.activationCommittee, address(0x3)); assertEq(_emergencyProtection.executionCommittee, address(0x4)); - assertEq(_emergencyProtection.protectedTill, block.timestamp + 200); - assertEq(_emergencyProtection.emergencyModeDuration, 100); - assertEq(_emergencyProtection.emergencyModeEndsAfter, 0); + assertEq(_emergencyProtection.protectedTill, newProtectionDuration.addTo(Timestamps.now())); + assertEq(_emergencyProtection.emergencyModeDuration, newEmergencyModeDuration); + assertEq(_emergencyProtection.emergencyModeEndsAfter, Timestamps.ZERO); } function test_activate_emergency_mode() external { - _emergencyProtection.setup(address(0x1), address(0x2), 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); vm.expectEmit(); - emit EmergencyProtection.EmergencyModeActivated(block.timestamp); + emit EmergencyProtection.EmergencyModeActivated(Timestamps.now()); vm.recordLogs(); @@ -155,19 +180,22 @@ contract EmergencyProtectionUnitTests is UnitTest { Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 1); - assertEq(_emergencyProtection.emergencyModeEndsAfter, block.timestamp + 100); + assertEq(_emergencyProtection.emergencyModeEndsAfter, emergencyModeDuration.addTo(Timestamps.now())); } function test_cannot_activate_emergency_mode_if_protected_till_expired() external { - uint256 protectedTill = 100; - _emergencyProtection.setup(address(0x1), address(0x2), protectedTill, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); - _wait(protectedTill + 1); + _wait(protectionDuration.plusSeconds(1)); vm.expectRevert( abi.encodeWithSelector( EmergencyProtection.EmergencyCommitteeExpired.selector, - [block.timestamp, _emergencyProtection.protectedTill] + Timestamps.now(), + _emergencyProtection.protectedTill ) ); _emergencyProtection.activate(); @@ -176,19 +204,17 @@ contract EmergencyProtectionUnitTests is UnitTest { function testFuzz_deactivate_emergency_mode( address activationCommittee, address executionCommittee, - uint256 protectedTill, - uint256 duration + Duration protectionDuration, + Duration emergencyModeDuration ) external { - vm.assume(protectedTill > 0 && protectedTill < type(uint40).max); - vm.assume(duration > 0 && duration < type(uint32).max); vm.assume(activationCommittee != address(0)); vm.assume(executionCommittee != address(0)); - _emergencyProtection.setup(activationCommittee, executionCommittee, protectedTill, duration); + _emergencyProtection.setup(activationCommittee, executionCommittee, protectionDuration, emergencyModeDuration); _emergencyProtection.activate(); vm.expectEmit(); - emit EmergencyProtection.EmergencyModeDeactivated(block.timestamp); + emit EmergencyProtection.EmergencyModeDeactivated(Timestamps.now()); vm.recordLogs(); @@ -199,9 +225,9 @@ contract EmergencyProtectionUnitTests is UnitTest { assertEq(_emergencyProtection.activationCommittee, address(0)); assertEq(_emergencyProtection.executionCommittee, address(0)); - assertEq(_emergencyProtection.protectedTill, 0); - assertEq(_emergencyProtection.emergencyModeDuration, 0); - assertEq(_emergencyProtection.emergencyModeEndsAfter, 0); + assertEq(_emergencyProtection.protectedTill, Timestamps.ZERO); + assertEq(_emergencyProtection.emergencyModeDuration, Durations.ZERO); + assertEq(_emergencyProtection.emergencyModeEndsAfter, Timestamps.ZERO); } function test_get_emergency_state() external { @@ -209,20 +235,23 @@ contract EmergencyProtectionUnitTests is UnitTest { assertEq(state.activationCommittee, address(0)); assertEq(state.executionCommittee, address(0)); - assertEq(state.protectedTill, 0); - assertEq(state.emergencyModeDuration, 0); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, Timestamps.ZERO); + assertEq(state.emergencyModeDuration, Durations.ZERO); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); assertEq(state.isEmergencyModeActivated, false); - _emergencyProtection.setup(address(0x1), address(0x2), 100, 200); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(200 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); state = _emergencyProtection.getEmergencyState(); assertEq(state.activationCommittee, address(0x1)); assertEq(state.executionCommittee, address(0x2)); - assertEq(state.protectedTill, block.timestamp + 100); - assertEq(state.emergencyModeDuration, 200); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, protectionDuration.addTo(Timestamps.now())); + assertEq(state.emergencyModeDuration, emergencyModeDuration); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); assertEq(state.isEmergencyModeActivated, false); _emergencyProtection.activate(); @@ -231,9 +260,9 @@ contract EmergencyProtectionUnitTests is UnitTest { assertEq(state.activationCommittee, address(0x1)); assertEq(state.executionCommittee, address(0x2)); - assertEq(state.protectedTill, block.timestamp + 100); - assertEq(state.emergencyModeDuration, 200); - assertEq(state.emergencyModeEndsAfter, block.timestamp + 200); + assertEq(state.protectedTill, protectionDuration.addTo(Timestamps.now())); + assertEq(state.emergencyModeDuration, emergencyModeDuration); + assertEq(state.emergencyModeEndsAfter, emergencyModeDuration.addTo(Timestamps.now())); assertEq(state.isEmergencyModeActivated, true); _emergencyProtection.deactivate(); @@ -242,16 +271,19 @@ contract EmergencyProtectionUnitTests is UnitTest { assertEq(state.activationCommittee, address(0)); assertEq(state.executionCommittee, address(0)); - assertEq(state.protectedTill, 0); - assertEq(state.emergencyModeDuration, 0); - assertEq(state.emergencyModeEndsAfter, 0); + assertEq(state.protectedTill, Timestamps.ZERO); + assertEq(state.emergencyModeDuration, Durations.ZERO); + assertEq(state.emergencyModeEndsAfter, Timestamps.ZERO); assertEq(state.isEmergencyModeActivated, false); } function test_is_emergency_mode_activated() external { assertEq(_emergencyProtection.isEmergencyModeActivated(), false); - _emergencyProtection.setup(address(0x1), address(0x2), 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); assertEq(_emergencyProtection.isEmergencyModeActivated(), false); @@ -267,9 +299,10 @@ contract EmergencyProtectionUnitTests is UnitTest { function test_is_emergency_mode_passed() external { assertEq(_emergencyProtection.isEmergencyModePassed(), false); - uint256 duration = 200; + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(200 seconds); - _emergencyProtection.setup(address(0x1), address(0x2), 100, duration); + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); assertEq(_emergencyProtection.isEmergencyModePassed(), false); @@ -277,7 +310,7 @@ contract EmergencyProtectionUnitTests is UnitTest { assertEq(_emergencyProtection.isEmergencyModePassed(), false); - _wait(duration + 1); + _wait(emergencyModeDuration.plusSeconds(1)); assertEq(_emergencyProtection.isEmergencyModePassed(), true); @@ -287,24 +320,28 @@ contract EmergencyProtectionUnitTests is UnitTest { } function test_is_emergency_protection_enabled() external { - uint256 protectedTill = 100; - uint256 duration = 200; + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(200 seconds); assertEq(_emergencyProtection.isEmergencyProtectionEnabled(), false); - _emergencyProtection.setup(address(0x1), address(0x2), protectedTill, duration); + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); assertEq(_emergencyProtection.isEmergencyProtectionEnabled(), true); - _wait(protectedTill - block.timestamp); + EmergencyState memory emergencyState = _emergencyProtection.getEmergencyState(); + + _wait(Durations.between(emergencyState.protectedTill, Timestamps.now())); + + // _wait(emergencyState.protectedTill.absDiff(Timestamps.now())); EmergencyProtection.activate(_emergencyProtection); - _wait(duration); + _wait(emergencyModeDuration); assertEq(_emergencyProtection.isEmergencyProtectionEnabled(), true); - _wait(100); + _wait(protectionDuration); assertEq(_emergencyProtection.isEmergencyProtectionEnabled(), true); @@ -321,7 +358,10 @@ contract EmergencyProtectionUnitTests is UnitTest { _emergencyProtection.checkActivationCommittee(stranger); _emergencyProtection.checkActivationCommittee(address(0)); - _emergencyProtection.setup(committee, address(0x2), 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(committee, address(0x2), protectionDuration, emergencyModeDuration); _emergencyProtection.checkActivationCommittee(committee); @@ -337,7 +377,10 @@ contract EmergencyProtectionUnitTests is UnitTest { _emergencyProtection.checkExecutionCommittee(stranger); _emergencyProtection.checkExecutionCommittee(address(0)); - _emergencyProtection.setup(address(0x1), committee, 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), committee, protectionDuration, emergencyModeDuration); _emergencyProtection.checkExecutionCommittee(committee); @@ -352,7 +395,10 @@ contract EmergencyProtectionUnitTests is UnitTest { _emergencyProtection.checkEmergencyModeActive(true); _emergencyProtection.checkEmergencyModeActive(false); - _emergencyProtection.setup(address(0x1), address(0x2), 100, 100); + Duration protectionDuration = Durations.from(100 seconds); + Duration emergencyModeDuration = Durations.from(100 seconds); + + _emergencyProtection.setup(address(0x1), address(0x2), protectionDuration, emergencyModeDuration); _emergencyProtection.activate(); _emergencyProtection.checkEmergencyModeActive(true); diff --git a/test/unit/libraries/Proposals.t.sol b/test/unit/libraries/Proposals.t.sol index 3b4ed280..0b70209a 100644 --- a/test/unit/libraries/Proposals.t.sol +++ b/test/unit/libraries/Proposals.t.sol @@ -7,9 +7,7 @@ import {Executor} from "contracts/Executor.sol"; import {Proposals, ExecutorCall, Proposal, Status} from "contracts/libraries/Proposals.sol"; import {TargetMock} from "test/utils/utils.sol"; -import {UnitTest} from "test/utils/unit-test.sol"; -import {IDangerousContract} from "test/utils/interfaces.sol"; -import {ExecutorCallHelpers} from "test/utils/executor-calls.sol"; +import {UnitTest, Timestamps, Timestamp, Durations, Duration} from "test/utils/unit-test.sol"; contract ProposalsUnitTests is UnitTest { using Proposals for Proposals.State; @@ -50,9 +48,9 @@ contract ProposalsUnitTests is UnitTest { Proposals.ProposalPacked memory proposal = _proposals.proposals[proposalsCount - PROPOSAL_ID_OFFSET]; assertEq(proposal.executor, address(_executor)); - assertEq(proposal.submittedAt, block.timestamp); - assertEq(proposal.executedAt, 0); - assertEq(proposal.scheduledAt, 0); + assertEq(proposal.submittedAt, Timestamps.now()); + assertEq(proposal.executedAt, Timestamps.ZERO); + assertEq(proposal.scheduledAt, Timestamps.ZERO); assertEq(proposal.calls.length, 1); for (uint256 i = 0; i < proposal.calls.length; i++) { @@ -62,17 +60,17 @@ contract ProposalsUnitTests is UnitTest { } } - function testFuzz_schedule_proposal(uint256 delay) external { - vm.assume(delay > 0 && delay < type(uint40).max); + function testFuzz_schedule_proposal(Duration delay) external { + vm.assume(delay > Durations.ZERO && delay <= Durations.MAX); Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); Proposals.ProposalPacked memory proposal = _proposals.proposals[0]; - uint256 submittedAt = block.timestamp; + Timestamp submittedAt = Timestamps.now(); assertEq(proposal.submittedAt, submittedAt); - assertEq(proposal.scheduledAt, 0); - assertEq(proposal.executedAt, 0); + assertEq(proposal.scheduledAt, Timestamps.ZERO); + assertEq(proposal.executedAt, Timestamps.ZERO); uint256 proposalId = _proposals.count(); @@ -85,32 +83,32 @@ contract ProposalsUnitTests is UnitTest { proposal = _proposals.proposals[0]; assertEq(proposal.submittedAt, submittedAt); - assertEq(proposal.scheduledAt, block.timestamp); - assertEq(proposal.executedAt, 0); + assertEq(proposal.scheduledAt, Timestamps.now()); + assertEq(proposal.executedAt, Timestamps.ZERO); } function testFuzz_cannot_schedule_unsubmitted_proposal(uint256 proposalId) external { vm.assume(proposalId > 0); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotSubmitted.selector, proposalId)); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); } function test_cannot_schedule_proposal_twice() external { Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = 1; - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotSubmitted.selector, proposalId)); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); } - function testFuzz_cannot_schedule_proposal_before_delay_passed(uint256 delay) external { - vm.assume(delay > 0 && delay < type(uint40).max); + function testFuzz_cannot_schedule_proposal_before_delay_passed(Duration delay) external { + vm.assume(delay > Durations.ZERO && delay <= Durations.MAX); Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); - _wait(delay - 1); + _wait(delay.minusSeconds(1 seconds)); vm.expectRevert(abi.encodeWithSelector(Proposals.AfterSubmitDelayNotPassed.selector, PROPOSAL_ID_OFFSET)); Proposals.schedule(_proposals, PROPOSAL_ID_OFFSET, delay); @@ -123,21 +121,21 @@ contract ProposalsUnitTests is UnitTest { uint256 proposalId = _proposals.count(); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotSubmitted.selector, proposalId)); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); } - function testFuzz_execute_proposal(uint256 delay) external { - vm.assume(delay > 0 && delay < type(uint40).max); + function testFuzz_execute_proposal(Duration delay) external { + vm.assume(delay > Durations.ZERO && delay <= Durations.MAX); Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); - uint256 submittedAndScheduledAt = block.timestamp; + Timestamp submittedAndScheduledAt = Timestamps.now(); assertEq(_proposals.proposals[0].submittedAt, submittedAndScheduledAt); assertEq(_proposals.proposals[0].scheduledAt, submittedAndScheduledAt); - assertEq(_proposals.proposals[0].executedAt, 0); + assertEq(_proposals.proposals[0].executedAt, Timestamps.ZERO); _wait(delay); @@ -153,13 +151,13 @@ contract ProposalsUnitTests is UnitTest { assertEq(_proposals.proposals[0].submittedAt, submittedAndScheduledAt); assertEq(_proposals.proposals[0].scheduledAt, submittedAndScheduledAt); - assertEq(proposal.executedAt, block.timestamp); + assertEq(proposal.executedAt, Timestamps.now()); } function testFuzz_cannot_execute_unsubmitted_proposal(uint256 proposalId) external { vm.assume(proposalId > 0); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotScheduled.selector, proposalId)); - Proposals.execute(_proposals, proposalId, 0); + Proposals.execute(_proposals, proposalId, Durations.ZERO); } function test_cannot_execute_unscheduled_proposal() external { @@ -167,36 +165,36 @@ contract ProposalsUnitTests is UnitTest { uint256 proposalId = _proposals.count(); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotScheduled.selector, proposalId)); - Proposals.execute(_proposals, proposalId, 0); + Proposals.execute(_proposals, proposalId, Durations.ZERO); } function test_cannot_execute_twice() external { Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - Proposals.schedule(_proposals, proposalId, 0); - Proposals.execute(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); + Proposals.execute(_proposals, proposalId, Durations.ZERO); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotScheduled.selector, proposalId)); - Proposals.execute(_proposals, proposalId, 0); + Proposals.execute(_proposals, proposalId, Durations.ZERO); } function test_cannot_execute_cancelled_proposal() external { Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); Proposals.cancelAll(_proposals); vm.expectRevert(abi.encodeWithSelector(Proposals.ProposalNotScheduled.selector, proposalId)); - Proposals.execute(_proposals, proposalId, 0); + Proposals.execute(_proposals, proposalId, Durations.ZERO); } - function testFuzz_cannot_execute_before_delay_passed(uint256 delay) external { - vm.assume(delay > 0 && delay < type(uint40).max); + function testFuzz_cannot_execute_before_delay_passed(Duration delay) external { + vm.assume(delay > Durations.ZERO && delay <= Durations.MAX); Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); - _wait(delay - 1); + _wait(delay.minusSeconds(1 seconds)); vm.expectRevert(abi.encodeWithSelector(Proposals.AfterScheduleDelayNotPassed.selector, proposalId)); Proposals.execute(_proposals, proposalId, delay); @@ -208,7 +206,7 @@ contract ProposalsUnitTests is UnitTest { uint256 proposalsCount = _proposals.count(); - Proposals.schedule(_proposals, proposalsCount, 0); + Proposals.schedule(_proposals, proposalsCount, Durations.ZERO); vm.expectEmit(); emit Proposals.ProposalsCancelledTill(proposalsCount); @@ -224,13 +222,13 @@ contract ProposalsUnitTests is UnitTest { Proposal memory proposal = _proposals.get(proposalId); - uint256 submittedAt = block.timestamp; + Timestamp submittedAt = Timestamps.now(); assertEq(proposal.id, proposalId); assertEq(proposal.executor, address(_executor)); assertEq(proposal.submittedAt, submittedAt); - assertEq(proposal.scheduledAt, 0); - assertEq(proposal.executedAt, 0); + assertEq(proposal.scheduledAt, Timestamps.ZERO); + assertEq(proposal.executedAt, Timestamps.ZERO); assertEq(proposal.calls.length, 1); assert(proposal.status == Status.Submitted); @@ -240,9 +238,9 @@ contract ProposalsUnitTests is UnitTest { assertEq(proposal.calls[i].payload, calls[i].payload); } - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); - uint256 scheduledAt = block.timestamp; + Timestamp scheduledAt = Timestamps.now(); proposal = _proposals.get(proposalId); @@ -250,7 +248,7 @@ contract ProposalsUnitTests is UnitTest { assertEq(proposal.executor, address(_executor)); assertEq(proposal.submittedAt, submittedAt); assertEq(proposal.scheduledAt, scheduledAt); - assertEq(proposal.executedAt, 0); + assertEq(proposal.executedAt, Timestamps.ZERO); assertEq(proposal.calls.length, 1); assert(proposal.status == Status.Scheduled); @@ -260,9 +258,9 @@ contract ProposalsUnitTests is UnitTest { assertEq(proposal.calls[i].payload, calls[i].payload); } - Proposals.execute(_proposals, proposalId, 0); + Proposals.execute(_proposals, proposalId, Durations.ZERO); - uint256 executedAt = block.timestamp; + Timestamp executedAt = Timestamps.now(); proposal = _proposals.get(proposalId); @@ -288,13 +286,13 @@ contract ProposalsUnitTests is UnitTest { Proposal memory proposal = _proposals.get(proposalId); - uint256 submittedAt = block.timestamp; + Timestamp submittedAt = Timestamps.now(); assertEq(proposal.id, proposalId); assertEq(proposal.executor, address(_executor)); assertEq(proposal.submittedAt, submittedAt); - assertEq(proposal.scheduledAt, 0); - assertEq(proposal.executedAt, 0); + assertEq(proposal.scheduledAt, Timestamps.ZERO); + assertEq(proposal.executedAt, Timestamps.ZERO); assertEq(proposal.calls.length, 1); assert(proposal.status == Status.Submitted); @@ -311,8 +309,8 @@ contract ProposalsUnitTests is UnitTest { assertEq(proposal.id, proposalId); assertEq(proposal.executor, address(_executor)); assertEq(proposal.submittedAt, submittedAt); - assertEq(proposal.scheduledAt, 0); - assertEq(proposal.executedAt, 0); + assertEq(proposal.scheduledAt, Timestamps.ZERO); + assertEq(proposal.executedAt, Timestamps.ZERO); assertEq(proposal.calls.length, 1); assert(proposal.status == Status.Cancelled); @@ -343,13 +341,13 @@ contract ProposalsUnitTests is UnitTest { Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); assertEq(_proposals.count(), 4); - Proposals.schedule(_proposals, 1, 0); + Proposals.schedule(_proposals, 1, Durations.ZERO); assertEq(_proposals.count(), 4); - Proposals.schedule(_proposals, 2, 0); + Proposals.schedule(_proposals, 2, Durations.ZERO); assertEq(_proposals.count(), 4); - Proposals.execute(_proposals, 1, 0); + Proposals.execute(_proposals, 1, Durations.ZERO); assertEq(_proposals.count(), 4); Proposals.cancelAll(_proposals); @@ -357,57 +355,59 @@ contract ProposalsUnitTests is UnitTest { } function test_can_execute_proposal() external { + Duration delay = Durations.from(100 seconds); Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - assert(!_proposals.canExecute(proposalId, 0)); + assert(!_proposals.canExecute(proposalId, Durations.ZERO)); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); - assert(!_proposals.canExecute(proposalId, 100)); + assert(!_proposals.canExecute(proposalId, delay)); - _wait(100); + _wait(delay); - assert(_proposals.canExecute(proposalId, 100)); + assert(_proposals.canExecute(proposalId, delay)); - Proposals.execute(_proposals, proposalId, 0); + Proposals.execute(_proposals, proposalId, Durations.ZERO); - assert(!_proposals.canExecute(proposalId, 100)); + assert(!_proposals.canExecute(proposalId, delay)); } function test_can_not_execute_cancelled_proposal() external { Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - Proposals.schedule(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, Durations.ZERO); - assert(_proposals.canExecute(proposalId, 0)); + assert(_proposals.canExecute(proposalId, Durations.ZERO)); Proposals.cancelAll(_proposals); - assert(!_proposals.canExecute(proposalId, 0)); + assert(!_proposals.canExecute(proposalId, Durations.ZERO)); } function test_can_schedule_proposal() external { + Duration delay = Durations.from(100 seconds); Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - assert(!_proposals.canSchedule(proposalId, 100)); + assert(!_proposals.canSchedule(proposalId, delay)); - _wait(100); + _wait(delay); - assert(_proposals.canSchedule(proposalId, 100)); + assert(_proposals.canSchedule(proposalId, delay)); - Proposals.schedule(_proposals, proposalId, 100); - Proposals.execute(_proposals, proposalId, 0); + Proposals.schedule(_proposals, proposalId, delay); + Proposals.execute(_proposals, proposalId, Durations.ZERO); - assert(!_proposals.canSchedule(proposalId, 100)); + assert(!_proposals.canSchedule(proposalId, delay)); } function test_can_not_schedule_cancelled_proposal() external { Proposals.submit(_proposals, address(_executor), _getTargetRegularStaffCalls(address(_targetMock))); uint256 proposalId = _proposals.count(); - assert(_proposals.canSchedule(proposalId, 0)); + assert(_proposals.canSchedule(proposalId, Durations.ZERO)); Proposals.cancelAll(_proposals); - assert(!_proposals.canSchedule(proposalId, 0)); + assert(!_proposals.canSchedule(proposalId, Durations.ZERO)); } } diff --git a/test/unit/mocks/TimelockMock.sol b/test/unit/mocks/TimelockMock.sol index 63f58cc9..39838ca4 100644 --- a/test/unit/mocks/TimelockMock.sol +++ b/test/unit/mocks/TimelockMock.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Timestamp} from "contracts/types/Timestamp.sol"; import {ITimelock} from "contracts/interfaces/ITimelock.sol"; import {ExecutorCall} from "contracts/libraries/Proposals.sol"; @@ -22,13 +23,12 @@ contract TimelockMock is ITimelock { return newProposalId; } - function schedule(uint256 proposalId) external returns (uint256 submittedAt) { + function schedule(uint256 proposalId) external returns (Timestamp submittedAt) { if (canScheduleProposal[proposalId] == false) { revert(); } scheduledProposals.push(proposalId); - return 0; } function execute(uint256 proposalId) external { diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index fabbb898..b1cb9103 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -2,6 +2,8 @@ pragma solidity 0.8.23; import {Test} from "forge-std/Test.sol"; +import {Timestamp, Timestamps} from "contracts/types/Timestamp.sol"; +import {Durations, Duration as DurationType} from "contracts/types/Duration.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import { @@ -26,7 +28,14 @@ import {DualGovernance, DualGovernanceState, State} from "contracts/DualGovernan import {Proposal, Status as ProposalStatus} from "contracts/libraries/Proposals.sol"; import {Percents, percents} from "../utils/percents.sol"; -import {IERC20, IStEth, IWstETH, IWithdrawalQueue, WithdrawalRequestStatus, IDangerousContract} from "../utils/interfaces.sol"; +import { + IERC20, + IStEth, + IWstETH, + IWithdrawalQueue, + WithdrawalRequestStatus, + IDangerousContract +} from "../utils/interfaces.sol"; import {ExecutorCallHelpers} from "../utils/executor-calls.sol"; import {Utils, TargetMock, console} from "../utils/utils.sol"; @@ -47,15 +56,17 @@ function countDigits(uint256 number) pure returns (uint256 digitsCount) { } while (number / 10 != 0); } +DurationType constant ONE_SECOND = DurationType.wrap(1); + contract ScenarioTestBlueprint is Test { address internal immutable _ADMIN_PROPOSER = DAO_VOTING; - uint256 internal immutable _EMERGENCY_MODE_DURATION = 180 days; - uint256 internal immutable _EMERGENCY_PROTECTION_DURATION = 90 days; + DurationType internal immutable _EMERGENCY_MODE_DURATION = Durations.from(180 days); + DurationType internal immutable _EMERGENCY_PROTECTION_DURATION = Durations.from(90 days); address internal immutable _EMERGENCY_ACTIVATION_COMMITTEE = makeAddr("EMERGENCY_ACTIVATION_COMMITTEE"); address internal immutable _EMERGENCY_EXECUTION_COMMITTEE = makeAddr("EMERGENCY_EXECUTION_COMMITTEE"); - uint256 internal immutable _SEALING_DURATION = 14 days; - uint256 internal immutable _SEALING_COMMITTEE_LIFETIME = 365 days; + DurationType internal immutable _SEALING_DURATION = Durations.from(14 days); + DurationType internal immutable _SEALING_COMMITTEE_LIFETIME = Durations.from(365 days); address internal immutable _SEALING_COMMITTEE = makeAddr("SEALING_COMMITTEE"); address internal immutable _TIEBREAK_COMMITTEE = makeAddr("TIEBREAK_COMMITTEE"); @@ -102,7 +113,25 @@ contract ScenarioTestBlueprint is Test { view returns (bool isActive, uint256 duration, uint256 activatedAt, uint256 enteredAt) { - return _dualGovernance.getVetoSignallingState(); + DurationType duration_; + Timestamp activatedAt_; + Timestamp enteredAt_; + (isActive, duration_, activatedAt_, enteredAt_) = _dualGovernance.getVetoSignallingState(); + duration = DurationType.unwrap(duration_); + enteredAt = Timestamp.unwrap(enteredAt_); + activatedAt = Timestamp.unwrap(activatedAt_); + } + + function _getVetoSignallingDeactivationState() + internal + view + returns (bool isActive, uint256 duration, uint256 enteredAt) + { + Timestamp enteredAt_; + DurationType duration_; + (isActive, duration_, enteredAt_) = _dualGovernance.getVetoSignallingDeactivationState(); + duration = DurationType.unwrap(duration_); + enteredAt = Timestamp.unwrap(enteredAt_); } // --- @@ -299,8 +328,8 @@ contract ScenarioTestBlueprint is Test { assertEq(proposal.id, proposalId, "unexpected proposal id"); assertEq(uint256(proposal.status), uint256(ProposalStatus.Submitted), "unexpected status value"); assertEq(proposal.executor, executor, "unexpected executor"); - assertEq(proposal.submittedAt, block.timestamp, "unexpected scheduledAt"); - assertEq(proposal.executedAt, 0, "unexpected executedAt"); + assertEq(Timestamp.unwrap(proposal.submittedAt), block.timestamp, "unexpected scheduledAt"); + assertEq(Timestamp.unwrap(proposal.executedAt), 0, "unexpected executedAt"); assertEq(proposal.calls.length, calls.length, "unexpected calls length"); for (uint256 i = 0; i < proposal.calls.length; ++i) { @@ -405,8 +434,7 @@ contract ScenarioTestBlueprint is Test { // --- function _logVetoSignallingState() internal { /* solhint-disable no-console */ - (bool isActive, uint256 duration, uint256 activatedAt, uint256 enteredAt) = - _dualGovernance.getVetoSignallingState(); + (bool isActive, uint256 duration, uint256 activatedAt, uint256 enteredAt) = _getVetoSignallingState(); if (!isActive) { console.log("VetoSignalling state is not active\n"); @@ -431,7 +459,7 @@ contract ScenarioTestBlueprint is Test { function _logVetoSignallingDeactivationState() internal { /* solhint-disable no-console */ - (bool isActive, uint256 duration, uint256 enteredAt) = _dualGovernance.getVetoSignallingDeactivationState(); + (bool isActive, uint256 duration, uint256 enteredAt) = _getVetoSignallingDeactivationState(); if (!isActive) { console.log("VetoSignallingDeactivation state is not active\n"); @@ -552,16 +580,16 @@ contract ScenarioTestBlueprint is Test { console.log(string.concat(">>> ", text, " <<<")); } - function _wait(uint256 duration) internal { - vm.warp(block.timestamp + duration); + function _wait(DurationType duration) internal { + vm.warp(duration.addTo(Timestamps.now()).toSeconds()); } function _waitAfterSubmitDelayPassed() internal { - _wait(_config.AFTER_SUBMIT_DELAY() + 1); + _wait(_config.AFTER_SUBMIT_DELAY() + ONE_SECOND); } function _waitAfterScheduleDelayPassed() internal { - _wait(_config.AFTER_SCHEDULE_DELAY() + 1); + _wait(_config.AFTER_SCHEDULE_DELAY() + ONE_SECOND); } struct Duration { @@ -594,6 +622,18 @@ contract ScenarioTestBlueprint is Test { ); } + function assertEq(uint40 a, uint40 b) internal { + assertEq(uint256(a), uint256(b)); + } + + function assertEq(Timestamp a, Timestamp b) internal { + assertEq(uint256(Timestamp.unwrap(a)), uint256(Timestamp.unwrap(b))); + } + + function assertEq(DurationType a, DurationType b) internal { + assertEq(uint256(DurationType.unwrap(a)), uint256(DurationType.unwrap(b))); + } + function assertEq(ProposalStatus a, ProposalStatus b) internal { assertEq(uint256(a), uint256(b)); } diff --git a/test/utils/unit-test.sol b/test/utils/unit-test.sol index 6ff279a4..3ec9b59c 100644 --- a/test/utils/unit-test.sol +++ b/test/utils/unit-test.sol @@ -1,18 +1,29 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; +import {Timestamp, Timestamps} from "contracts/types/Timestamp.sol"; +import {Duration, Durations} from "contracts/types/Duration.sol"; + // solhint-disable-next-line -import {Test} from "forge-std/Test.sol"; +import {Test, console} from "forge-std/Test.sol"; import {ExecutorCall} from "contracts/libraries/Proposals.sol"; import {ExecutorCallHelpers} from "test/utils/executor-calls.sol"; import {IDangerousContract} from "test/utils/interfaces.sol"; contract UnitTest is Test { - function _wait(uint256 duration) internal { - vm.warp(block.timestamp + duration); + function _wait(Duration duration) internal { + vm.warp(block.timestamp + Duration.unwrap(duration)); } function _getTargetRegularStaffCalls(address targetMock) internal pure returns (ExecutorCall[] memory) { return ExecutorCallHelpers.create(address(targetMock), abi.encodeCall(IDangerousContract.doRegularStaff, (42))); } -} \ No newline at end of file + + function assertEq(Timestamp a, Timestamp b) internal { + assertEq(uint256(Timestamp.unwrap(a)), uint256(Timestamp.unwrap(b))); + } + + function assertEq(Duration a, Duration b) internal { + assertEq(uint256(Duration.unwrap(a)), uint256(Duration.unwrap(b))); + } +} From 1a5e43abbd1fa652f02f38553ddfea8ed8837ccb Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Tue, 25 Jun 2024 14:44:40 +0400 Subject: [PATCH 03/13] Use unstETH as Withdrawal NFT shortcut instead of wNFT --- docs/mechanism.md | 3 +++ docs/specification.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/mechanism.md b/docs/mechanism.md index f6e23f4a..ea805dc6 100644 --- a/docs/mechanism.md +++ b/docs/mechanism.md @@ -395,6 +395,9 @@ Dual governance should not cover: ## Changelog +### 2024-06-25 +- Instead of using the "wNFT" shortcut for the "Lido: stETH Withdrawal NFT" token, the official symbol "unstETH" is now used. + ### 2024-04-24 * Removed the logic with the extension of the Veto Signalling duration upon new proposal submission. diff --git a/docs/specification.md b/docs/specification.md index dc43cc78..51199dd6 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -64,7 +64,7 @@ The general proposal flow is the following: Each submitted proposal requires a minimum timelock before it can be scheduled for execution. -At any time, including while a proposal's timelock is lasting, stakers can signal their opposition to the DAO by locking their (w)stETH or withdrawal NFTs (wNFTs) into the [signalling escrow contract](#Contract-Escrowsol). If the opposition exceeds some minimum threshold, the [global governance state](#Governance-state) gets changed, blocking any DAO execution and thus effectively extending the timelock of all pending (i.e. submitted but not scheduled for execution) proposals. +At any time, including while a proposal's timelock is lasting, stakers can signal their opposition to the DAO by locking their (w)stETH or stETH Withdrawal NFTs (unstETH) into the [signalling escrow contract](#Contract-Escrowsol). If the opposition exceeds some minimum threshold, the [global governance state](#Governance-state) gets changed, blocking any DAO execution and thus effectively extending the timelock of all pending (i.e. submitted but not scheduled for execution) proposals. ![image](https://github.com/lidofinance/dual-governance/assets/1699593/98273df0-f3fd-4149-929d-3315a8e81aa8) From de758a89074a65acb8ff841eee9919c6b357dcda Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Tue, 25 Jun 2024 22:52:54 +0400 Subject: [PATCH 04/13] Use timelock instead of delay with emergency protection. Note config's constants --- docs/specification.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/specification.md b/docs/specification.md index 51199dd6..de31e0c8 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -86,11 +86,11 @@ The proposal execution flow comes after the dynamic timelock elapses and the pro #### Regular deployment mode -In the regular deployment mode, the emergency protection delay is set to zero and all calls from scheduled proposals are immediately executable by anyone via calling the [`EmergencyProtectedTimelock.execute`](#Function-EmergencyProtectedTimelockexecute) function. +In the regular deployment mode, the **emergency protection delay** is set to zero and all calls from scheduled proposals are immediately executable by anyone via calling the [`EmergencyProtectedTimelock.execute`](#Function-EmergencyProtectedTimelockexecute) function. #### Protected deployment mode -The protected deployment mode is a temporary mode designed to be active during an initial period after the deployment or upgrade of the DG contracts. In this mode, scheduled proposals cannot be executed immediately; instead, before calling [`EmergencyProtectedTimelock.execute`](#Funtion-EmergencyProtectedTimelockexecute), one has to wait until an **emergency protection timelock** elapses since the proposal scheduling time. +The protected deployment mode is a temporary mode designed to be active during an initial period after the deployment or upgrade of the DG contracts. In this mode, scheduled proposals cannot be executed immediately; instead, before calling [`EmergencyProtectedTimelock.execute`](#Funtion-EmergencyProtectedTimelockexecute), one has to wait until an emergency protection delay elapses since the proposal scheduling time. ![image](https://github.com/lidofinance/dual-governance/assets/1699593/38cb2371-bdb0-4681-9dfd-356fa1ed7959) @@ -373,7 +373,7 @@ Registers the `proposer` address in the system as a valid proposer and associate #### Preconditions -* MUST be called by the admin executor contract (see `Config.sol`). +* MUST be called by the admin executor contract (see `Configuration.sol`). * The `proposer` address MUST NOT be already registered in the system. * The `executor` instance SHOULD be owned by the [`EmergencyProtectedTimelock`](#Contract-EmergencyProtectedTimelocksol) singleton instance. @@ -837,14 +837,14 @@ Allows the caller (i.e. `msg.sender`) to withdraw the claimed ETH from the Withd For a proposal to be executed, the following steps have to be performed in order: 1. The proposal must be submitted using the `EmergencyProtectedTimelock.submit` function. -2. The configured post-submit timelock must elapse. +2. The configured post-submit timelock (`Configuration.AFTER_SUBMIT_DELAY()`) must elapse. 3. The proposal must be scheduled using the `EmergencyProtectedTimelock.schedule` function. -4. The configured emergency protection timelock must elapse (can be zero, see below). +4. The configured emergency protection delay (`Configuration.AFTER_SCHEDULE_DELAY()`) must elapse (can be zero, see below). 5. The proposal must be executed using the `EmergencyProtectedTimelock.execute` function. The contract only allows proposal submission and scheduling by the `governance` address. Normally, this address points to the [`DualGovernance`](#Contract-DualGovernancesol) singleton instance. Proposal execution is permissionless, unless Emergency Mode is activated. -If the Emergency Committees are set up and active, the governance proposal gets a separate emergency protection timelock between submitting and scheduling. This additional timelock is implemented in the `EmergencyProtectedTimelock` contract to protect from zero-day vulnerability in the logic of `DualGovenance.sol` and other core DG contracts. If the Emergency Committees aren't set, the proposal flow is the same, but the timelock duration is zero. +If the Emergency Committees are set up and active, the governance proposal gets a separate emergency protection delay between submitting and scheduling. This additional timelock is implemented in the `EmergencyProtectedTimelock` contract to protect from zero-day vulnerability in the logic of `DualGovenance.sol` and other core DG contracts. If the Emergency Committees aren't set, the proposal flow is the same, but the timelock duration is zero. Emergency Activation Committee, while active, can enable the Emergency Mode. This mode prohibits anyone but the Emergency Execution Committee from executing proposals. It also allows the Emergency Execution Committee to reset the governance, effectively disabling the Dual Governance subsystem. From a57cf8cbfcfafd5c63aacf589f58b51f778dc626 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Tue, 25 Jun 2024 22:53:57 +0400 Subject: [PATCH 05/13] Rage Quit withdrawals clarification --- docs/mechanism.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mechanism.md b/docs/mechanism.md index ea805dc6..adba5e1f 100644 --- a/docs/mechanism.md +++ b/docs/mechanism.md @@ -271,7 +271,7 @@ The Rage Quit state allows all stakers who elected to leave the protocol via rag Upon entry into the Rage Quit state, three things happen: 1. The veto signalling escrow is irreversibly transformed into the **rage quit escrow**, an immutable smart contract that holds all tokens that are part of the rage quit withdrawal process, i.e. stETH, wstETH, withdrawal NFTs, and the withdrawn ETH, and allows stakers to claim the withdrawn ETH after a certain timelock following the completion of the withdrawal process (with the timelock being determined at the moment of the Rage Quit state entry). -2. All stETH and wstETH held by the rage quit escrow are sent for withdrawal via the regular Lido Withdrawal Queue mechanism, generating a set of batch withdrawal NFTs held by the rage quit escrow. +2. All stETH and wstETH held by the rage quit escrow will be processed for withdrawals through the regular Lido Withdrawal Queue mechanism, generating a set of batch withdrawal NFTs held by the rage quit escrow. 3. A new instance of the veto signalling escrow smart contract is deployed. This way, at any point in time, there is only one veto signalling escrow but there may be multiple rage quit escrows from previous rage quits. In this state, the DAO is allowed to submit proposals to the DG but cannot execute any pending proposals. Stakers are not allowed to lock (w)stETH or withdrawal NFTs into the rage quit escrow so joining the ongoing rage quit is not possible. However, they can lock their tokens that are not part of the ongoing rage quit process to the newly-deployed veto signalling escrow to potentially trigger a new rage quit later. From ed3e9e4f64b5a252e0ee268b2610425cc47fb30c Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 26 Jun 2024 01:36:54 +0400 Subject: [PATCH 06/13] Rename params RageQuitEthClaim* -> RageQuitEthWithdrawals* --- contracts/Configuration.sol | 23 +++++++++++---------- contracts/interfaces/IConfiguration.sol | 17 +++++++-------- contracts/libraries/DualGovernanceState.sol | 12 +++++------ docs/mechanism.md | 13 ++++++------ docs/specification.md | 18 ++++++++-------- 5 files changed, 42 insertions(+), 41 deletions(-) diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index 44cfc9bd..9aa03156 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -24,12 +24,12 @@ contract Configuration is IConfiguration { uint256 public immutable VETO_COOLDOWN_DURATION = 4 days; uint256 public immutable RAGE_QUIT_EXTENSION_DELAY = 7 days; - uint256 public immutable RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK = 60 days; - uint256 public immutable RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_START_SEQ_NUMBER = 2; + uint256 public immutable RAGE_QUIT_ETH_WITHDRAWALS_MIN_TIMELOCK = 60 days; + uint256 public immutable RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_START_SEQ_NUMBER = 2; - uint256 public immutable RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_A = 0; - uint256 public immutable RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_B = 0; - uint256 public immutable RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_C = 0; + uint256 public immutable RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_A = 0; + uint256 public immutable RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_B = 0; + uint256 public immutable RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_C = 0; // --- address public immutable ADMIN_EXECUTOR; @@ -103,12 +103,13 @@ contract Configuration is IConfiguration { config.vetoSignallingDeactivationMaxDuration = VETO_SIGNALLING_DEACTIVATION_MAX_DURATION; config.vetoCooldownDuration = VETO_COOLDOWN_DURATION; config.rageQuitExtensionDelay = RAGE_QUIT_EXTENSION_DELAY; - config.rageQuitEthClaimMinTimelock = RAGE_QUIT_ETH_CLAIM_MIN_TIMELOCK; - config.rageQuitEthClaimTimelockGrowthStartSeqNumber = RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_START_SEQ_NUMBER; - config.rageQuitEthClaimTimelockGrowthCoeffs = [ - RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_A, - RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_B, - RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_C + config.rageQuitEthWithdrawalsMinTimelock = RAGE_QUIT_ETH_WITHDRAWALS_MIN_TIMELOCK; + config.rageQuitEthWithdrawalsTimelockGrowthStartSeqNumber = + RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_START_SEQ_NUMBER; + config.rageQuitEthWithdrawalsTimelockGrowthCoeffs = [ + RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_A, + RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_B, + RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_C ]; } } diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index 04aa9f14..e307f21d 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.23; struct DualGovernanceConfig { uint256 firstSealRageQuitSupport; uint256 secondSealRageQuitSupport; - // TODO: consider dynamicDelayMaxDuration uint256 dynamicTimelockMaxDuration; uint256 dynamicTimelockMinDuration; uint256 vetoSignallingMinActiveDuration; @@ -12,9 +11,9 @@ struct DualGovernanceConfig { uint256 vetoCooldownDuration; uint256 rageQuitExtraTimelock; uint256 rageQuitExtensionDelay; - uint256 rageQuitEthClaimMinTimelock; - uint256 rageQuitEthClaimTimelockGrowthStartSeqNumber; - uint256[3] rageQuitEthClaimTimelockGrowthCoeffs; + uint256 rageQuitEthWithdrawalsMinTimelock; + uint256 rageQuitEthWithdrawalsTimelockGrowthStartSeqNumber; + uint256[3] rageQuitEthWithdrawalsTimelockGrowthCoeffs; } interface IAdminExecutorConfiguration { @@ -42,13 +41,13 @@ interface IDualGovernanceConfiguration { function SECOND_SEAL_RAGE_QUIT_SUPPORT() 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 RAGE_QUIT_ETH_WITHDRAWALS_MIN_TIMELOCK() external view returns (uint256); function RAGE_QUIT_ACCUMULATION_MAX_DURATION() external view returns (uint256); - function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_START_SEQ_NUMBER() external view returns (uint256); + function RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_START_SEQ_NUMBER() external view returns (uint256); - function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_A() external view returns (uint256); - function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_B() external view returns (uint256); - function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_C() external view returns (uint256); + function RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_A() external view returns (uint256); + function RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_B() external view returns (uint256); + function RAGE_QUIT_ETH_WITHDRAWALS_TIMELOCK_GROWTH_COEFF_C() external view returns (uint256); function SIGNALLING_ESCROW_MIN_LOCK_TIME() external view returns (uint256); diff --git a/contracts/libraries/DualGovernanceState.sol b/contracts/libraries/DualGovernanceState.sol index b55499f4..084bbe97 100644 --- a/contracts/libraries/DualGovernanceState.sol +++ b/contracts/libraries/DualGovernanceState.sol @@ -345,14 +345,14 @@ library DualGovernanceState { DualGovernanceConfig memory config, uint256 rageQuitRound ) private pure returns (uint256) { - if (rageQuitRound < config.rageQuitEthClaimTimelockGrowthStartSeqNumber) { - return config.rageQuitEthClaimMinTimelock; + if (rageQuitRound < config.rageQuitEthWithdrawalsTimelockGrowthStartSeqNumber) { + return config.rageQuitEthWithdrawalsMinTimelock; } - return config.rageQuitEthClaimMinTimelock + return config.rageQuitEthWithdrawalsMinTimelock + ( - config.rageQuitEthClaimTimelockGrowthCoeffs[0] * rageQuitRound * rageQuitRound - + config.rageQuitEthClaimTimelockGrowthCoeffs[1] * rageQuitRound - + config.rageQuitEthClaimTimelockGrowthCoeffs[2] + config.rageQuitEthWithdrawalsTimelockGrowthCoeffs[0] * rageQuitRound * rageQuitRound + + config.rageQuitEthWithdrawalsTimelockGrowthCoeffs[1] * rageQuitRound + + config.rageQuitEthWithdrawalsTimelockGrowthCoeffs[2] ) / 10 ** 18; // TODO: rewrite in a prettier way } diff --git a/docs/mechanism.md b/docs/mechanism.md index adba5e1f..af81e266 100644 --- a/docs/mechanism.md +++ b/docs/mechanism.md @@ -270,7 +270,7 @@ The Rage Quit state allows all stakers who elected to leave the protocol via rag Upon entry into the Rage Quit state, three things happen: -1. The veto signalling escrow is irreversibly transformed into the **rage quit escrow**, an immutable smart contract that holds all tokens that are part of the rage quit withdrawal process, i.e. stETH, wstETH, withdrawal NFTs, and the withdrawn ETH, and allows stakers to claim the withdrawn ETH after a certain timelock following the completion of the withdrawal process (with the timelock being determined at the moment of the Rage Quit state entry). +1. The veto signalling escrow is irreversibly transformed into the **rage quit escrow**, an immutable smart contract that holds all tokens that are part of the rage quit withdrawal process, i.e. stETH, wstETH, withdrawal NFTs, and the withdrawn ETH, and allows stakers to retrieve the withdrawn ETH after a certain timelock following the completion of the withdrawal process (with the timelock being determined at the moment of the Rage Quit state entry). 2. All stETH and wstETH held by the rage quit escrow will be processed for withdrawals through the regular Lido Withdrawal Queue mechanism, generating a set of batch withdrawal NFTs held by the rage quit escrow. 3. A new instance of the veto signalling escrow smart contract is deployed. This way, at any point in time, there is only one veto signalling escrow but there may be multiple rage quit escrows from previous rage quits. @@ -291,7 +291,7 @@ When the withdrawal is complete and the extension delay elapses, two things happ **Transition to Veto Cooldown**. If, at the moment of the Rage Quit state exit, $R(t) \leq R_1$, the Veto Cooldown state is entered. -The duration of the ETH claim timelock $W(i)$ is a non-linear function that depends on the rage quit sequence number $i$ (see below): +The duration of the ETH withdraw timelock $W(i)$ is a non-linear function that depends on the rage quit sequence number $i$ (see below): ```math W(i) = W_{min} + @@ -301,16 +301,16 @@ W(i) = W_{min} + \end{array} \right. ``` -where $W_{min}$ is `RageQuitEthClaimMinTimelock`, $i_{min}$ is `RageQuitEthClaimTimelockGrowthStartSeqNumber`, and $g_W(x)$ is a quadratic polynomial function with coefficients `RageQuitEthClaimTimelockGrowthCoeffs` (a list of length 3). +where $W_{min}$ is `RageQuitEthWithdrawalsMinTimelock`, $i_{min}$ is `RageQuitEthWithdrawalsTimelockGrowthStartSeqNumber`, and $g_W(x)$ is a quadratic polynomial function with coefficients `RageQuitEthWithdrawalsTimelockGrowthCoeffs` (a list of length 3). The rage quit sequence number is calculated as follows: each time the Normal state is entered, the sequence number is set to 0; each time the Rage Quit state is entered, the number is incremented by 1. ```env # Proposed values, to be modeled and refined RageQuitExtensionDelay = 7 days -RageQuitEthClaimMinTimelock = 60 days -RageQuitEthClaimTimelockGrowthStartSeqNumber = 2 -RageQuitEthClaimTimelockGrowthCoeffs = (0, TODO, TODO) +RageQuitEthWithdrawalsMinTimelock = 60 days +RageQuitEthWithdrawalsTimelockGrowthStartSeqNumber = 2 +RageQuitEthWithdrawalsTimelockGrowthCoeffs = (0, TODO, TODO) ``` @@ -397,6 +397,7 @@ Dual governance should not cover: ### 2024-06-25 - Instead of using the "wNFT" shortcut for the "Lido: stETH Withdrawal NFT" token, the official symbol "unstETH" is now used. +- For the consistency with the codebase, the `RageQuitEthClaimMinTimelock`, `RageQuitEthClaimTimelockGrowthStartSeqNumber`, `RageQuitEthClaimTimelockGrowthCoeffs` parameters were renamed into `RageQuitEthWithdrawalsMinTimelock`, `RageQuitEthWithdrawalsTimelockGrowthStartSeqNumber`, `RageQuitEthWithdrawalsTimelockGrowthCoeffs`. ### 2024-04-24 diff --git a/docs/specification.md b/docs/specification.md index de31e0c8..8152a4fd 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -96,7 +96,7 @@ The protected deployment mode is a temporary mode designed to be active during a In this mode, an **emergency activation committee** has the one-off and time-limited right to activate an adversarial **emergency mode** if they see a scheduled proposal that was created or altered due to a vulnerability in the DG contracts or if governance execution is prevented by such a vulnerability. Once the emergency mode is activated, the emergency activation committee is disabled, i.e. loses the ability to activate the emergency mode again. If the emergency activation committee doesn't activate the emergency mode within the duration of the **emergency protection duration** since the committee was configured by the DAO, it gets automatically disabled as well. -The emergency mode lasts up to the **emergency mode max duration** counting from the moment of its activation. While it's active, 1) only the **emergency execution committee** has the right to execute scheduled proposals, and 2) the same committee has the one-off right to **disable the DG subsystem**, i.e. disconnect executor contracts from the DG contracts and reconnect them to the Lido DAO Voting/Agent contract. The latter also disables the emergency mode and the emergency execution committee, so any proposal can be executed by the DAO without cooperation from any other actors. +The emergency mode lasts up to the **emergency mode max duration** counting from the moment of its activation. While it's active, 1) only the **emergency execution committee** has the right to execute scheduled proposals, and 2) the same committee has the one-off right to **disable the DG subsystem**, i.e. disconnect the `EmergencyProtectedTimelock` contract and its associated executor contracts from the DG contracts and reconnect it to the Lido DAO Voting/Agent contract. The latter also disables the emergency mode and the emergency execution committee, so any proposal can be executed by the DAO without cooperation from any other actors. If the emergency execution committee doesn't disable the DG until the emergency mode max duration elapses, anyone gets the right to deactivate the emergency mode, switching the system back to the protected mode and disabling the emergency committee. @@ -462,9 +462,9 @@ Once all funds locked in the `Escrow` instance are converted into withdrawal NFT The purpose of the `RageQuitExtensionDelay` phase is to provide sufficient time to participants who locked withdrawal NFTs to claim them before Lido DAO's proposal execution is unblocked. As soon as a withdrawal NFT is claimed, the user's ETH is no longer affected by any code controlled by the DAO. -When the `RageQuitExtensionDelay` period elapses, the `DualGovernance.activateNextState()` function exits the `RageQuit` state and initiates the `RageQuitEthClaimTimelock`. Throughout this timelock, tokens remain locked within the `Escrow` instance and are inaccessible for withdrawal. Once the timelock expires, participants in the rage quit process can retrieve their ETH by withdrawing it from the `Escrow` instance. +When the `RageQuitExtensionDelay` period elapses, the `DualGovernance.activateNextState()` function exits the `RageQuit` state and initiates the `RageQuitEthWithdrawalsTimelock`. Throughout this timelock, tokens remain locked within the `Escrow` instance and are inaccessible for withdrawal. Once the timelock expires, participants in the rage quit process can retrieve their ETH by withdrawing it from the `Escrow` instance. -The duration of the `RageQuitEthClaimTimelock` is dynamic and varies based on the number of "continuous" rage quits. A pair of rage quits is considered continuous when `DualGovernance` has not transitioned to the `Normal` or `VetoCooldown` state between them. +The duration of the `RageQuitEthWithdrawalsTimelock` is dynamic and varies based on the number of "continuous" rage quits. A pair of rage quits is considered continuous when `DualGovernance` has not transitioned to the `Normal` or `VetoCooldown` state between them. ### Function: Escrow.lockStETH @@ -701,7 +701,7 @@ return 10 ** 18 * ( function startRageQuit() ``` -Transits the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. Following this transition, locked funds become unwithdrawable and are accessible to users only as plain ETH after the completion of the full `RageQuit` process, including the `RageQuitExtensionDelay` and `RageQuitEthClaimTimelock` stages. +Transits the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. Following this transition, locked funds become unwithdrawable and are accessible to users only as plain ETH after the completion of the full `RageQuit` process, including the `RageQuitExtensionDelay` and `RageQuitEthWithdrawalsTimelock` stages. As the initial step of transitioning to the `RageQuitEscrow` state, all locked wstETH is converted into stETH, and the maximum stETH allowance is granted to the `WithdrawalQueue` contract for the upcoming creation of Withdrawal NFTs. @@ -772,7 +772,7 @@ Returns whether the rage quit process has been finalized. The rage quit process function withdrawStEthAsEth() ``` -Allows the caller (i.e. `msg.sender`) to withdraw all stETH they have previouusly locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthClaimTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding stETH as withdrawn for the caller. +Allows the caller (i.e. `msg.sender`) to withdraw all stETH they have previouusly locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthWithdrawalsTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding stETH as withdrawn for the caller. The amount of ETH sent to the caller is determined by the proportion of the user's stETH shares compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows: @@ -785,7 +785,7 @@ return _totalClaimedEthAmount * _vetoersLockedAssets[msg.sender].stETHShares - The `Escrow` instance MUST be in the `RageQuitEscrow` state. - The rage quit process MUST be completed, including the expiration of the `RageQuitExtensionDelay` duration. -- The `RageQuitEthClaimTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. +- The `RageQuitEthWithdrawalsTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. - The caller MUST have a non-zero amount of stETH to withdraw. - The caller MUST NOT have previously withdrawn stETH. @@ -795,7 +795,7 @@ return _totalClaimedEthAmount * _vetoersLockedAssets[msg.sender].stETHShares function withdrawWstEthAsEth() external ``` -Allows the caller (i.e. `msg.sender`) to withdraw all wstETH they have previouusly locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthClaimTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding wstETH as withdrawn for the caller. +Allows the caller (i.e. `msg.sender`) to withdraw all wstETH they have previouusly locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthWithdrawalsTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding wstETH as withdrawn for the caller. The amount of ETH sent to the caller is determined by the proportion of the user's wstETH funds compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows: @@ -808,7 +808,7 @@ return _totalClaimedEthAmount * #### Preconditions - The `Escrow` instance MUST be in the `RageQuitEscrow` state. - The rage quit process MUST be completed, including the expiration of the `RageQuitExtensionDelay` duration. -- The `RageQuitEthClaimTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. +- The `RageQuitEthWithdrawalsTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. - The caller MUST have a non-zero amount of wstETH to withdraw. - The caller MUST NOT have previously withdrawn wstETH. @@ -824,7 +824,7 @@ Allows the caller (i.e. `msg.sender`) to withdraw the claimed ETH from the Withd - The `Escrow` instance MUST be in the `RageQuitEscrow` state. - The rage quit process MUST be completed, including the expiration of the `RageQuitExtensionDelay` duration. -- The `RageQuitEthClaimTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. +- The `RageQuitEthWithdrawalsTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. - The caller MUST be set as the owner of the provided NFTs. - Each Withdrawal NFT MUST have been claimed using the `Escrow.claimUnstETH()` function. - Withdrawal NFTs must not have been withdrawn previously. From 9d0f355abf2e015896f86844dddfa8609bcd22cb Mon Sep 17 00:00:00 2001 From: Roman Kolpakov Date: Tue, 2 Jul 2024 15:04:40 +0300 Subject: [PATCH 07/13] refactor: new proposals getter --- contracts/DualGovernance.sol | 6 +++++- contracts/EmergencyProtectedTimelock.sol | 8 ++++++-- contracts/interfaces/ITimelock.sol | 4 +++- contracts/libraries/Proposals.sol | 10 ++++++++-- test/unit/EmergencyProtectedTimelock.t.sol | 9 +++++++++ test/unit/mocks/TimelockMock.sol | 7 +++++-- 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index 3a6c7a5a..8223330b 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -49,8 +49,12 @@ contract DualGovernance is IGovernance, ConfigurationProvider { function scheduleProposal(uint256 proposalId) external { _dgState.activateNextState(CONFIG.getDualGovernanceConfig()); - uint256 proposalSubmissionTime = TIMELOCK.schedule(proposalId); + + uint256 proposalSubmissionTime = TIMELOCK.getProposalSubmissionTime(proposalId); _dgState.checkCanScheduleProposal(proposalSubmissionTime); + + TIMELOCK.schedule(proposalId); + emit ProposalScheduled(proposalId); } diff --git a/contracts/EmergencyProtectedTimelock.sol b/contracts/EmergencyProtectedTimelock.sol index 019feb72..0f9ca933 100644 --- a/contracts/EmergencyProtectedTimelock.sol +++ b/contracts/EmergencyProtectedTimelock.sol @@ -30,9 +30,9 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { newProposalId = _proposals.submit(executor, calls); } - function schedule(uint256 proposalId) external returns (uint256 submittedAt) { + function schedule(uint256 proposalId) external { _checkGovernance(msg.sender); - submittedAt = _proposals.schedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); + _proposals.schedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); } function execute(uint256 proposalId) external { @@ -122,6 +122,10 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { count = _proposals.count(); } + function getProposalSubmissionTime(uint256 proposalId) external view returns (uint256 submittedAt) { + submittedAt = _proposals.getProposalSubmissionTime(proposalId); + } + // --- // Proposals Lifecycle View Methods // --- diff --git a/contracts/interfaces/ITimelock.sol b/contracts/interfaces/ITimelock.sol index 0f080e8d..bf8cbae7 100644 --- a/contracts/interfaces/ITimelock.sol +++ b/contracts/interfaces/ITimelock.sol @@ -13,10 +13,12 @@ interface IGovernance { interface ITimelock { function submit(address executor, ExecutorCall[] calldata calls) external returns (uint256 newProposalId); - function schedule(uint256 proposalId) external returns (uint256 submittedAt); + function schedule(uint256 proposalId) external; function execute(uint256 proposalId) external; function cancelAllNonExecutedProposals() external; function canSchedule(uint256 proposalId) external view returns (bool); function canExecute(uint256 proposalId) external view returns (bool); + + function getProposalSubmissionTime(uint256 proposalId) external view returns (uint256 submittedAt); } diff --git a/contracts/libraries/Proposals.sol b/contracts/libraries/Proposals.sol index e404a93f..56a8a589 100644 --- a/contracts/libraries/Proposals.sol +++ b/contracts/libraries/Proposals.sol @@ -85,12 +85,11 @@ library Proposals { State storage self, uint256 proposalId, uint256 afterSubmitDelay - ) internal returns (uint256 submittedAt) { + ) internal { _checkProposalSubmitted(self, proposalId); _checkAfterSubmitDelayPassed(self, proposalId, afterSubmitDelay); ProposalPacked storage proposal = _packed(self, proposalId); - submittedAt = proposal.submittedAt; proposal.scheduledAt = TimeUtils.timestamp(); emit ProposalScheduled(proposalId); @@ -121,6 +120,13 @@ library Proposals { proposal.calls = packed.calls; } + function getProposalSubmissionTime(State storage self, uint256 proposalId) internal view returns (uint256 submittedAt) { + _checkProposalExists(self, proposalId); + ProposalPacked storage packed = _packed(self, proposalId); + + submittedAt = packed.submittedAt; + } + function count(State storage self) internal view returns (uint256 count_) { count_ = self.proposals.length; } diff --git a/test/unit/EmergencyProtectedTimelock.t.sol b/test/unit/EmergencyProtectedTimelock.t.sol index 7216c248..987f8fb5 100644 --- a/test/unit/EmergencyProtectedTimelock.t.sol +++ b/test/unit/EmergencyProtectedTimelock.t.sol @@ -825,6 +825,15 @@ contract EmergencyProtectedTimelockUnitTests is UnitTest { assertEq(_timelock.canSchedule(1), false); } + // EmergencyProtectedTimelock.getProposalSubmissionTime() + + function test_get_proposal_submission_time() external { + _submitProposal(); + uint256 submitTimestamp = block.timestamp; + + assertEq(_timelock.getProposalSubmissionTime(1), submitTimestamp); + } + // Utils function _submitProposal() internal { diff --git a/test/unit/mocks/TimelockMock.sol b/test/unit/mocks/TimelockMock.sol index 63f58cc9..467a02b1 100644 --- a/test/unit/mocks/TimelockMock.sol +++ b/test/unit/mocks/TimelockMock.sol @@ -22,13 +22,12 @@ contract TimelockMock is ITimelock { return newProposalId; } - function schedule(uint256 proposalId) external returns (uint256 submittedAt) { + function schedule(uint256 proposalId) external { if (canScheduleProposal[proposalId] == false) { revert(); } scheduledProposals.push(proposalId); - return 0; } function execute(uint256 proposalId) external { @@ -66,4 +65,8 @@ contract TimelockMock is ITimelock { function getLastCancelledProposalId() external view returns (uint256) { return lastCancelledProposalId; } + + function getProposalSubmissionTime(uint256 proposalId) external view returns (uint256 submittedAt) { + revert("Not Implemented"); + } } From 4ca353613959858f1664ab5e6848758fdaa148c6 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Wed, 3 Jul 2024 13:19:44 +0400 Subject: [PATCH 08/13] AssetsAccounting refactoring --- contracts/Escrow.sol | 202 ++++-- contracts/interfaces/IWithdrawalQueue.sol | 7 + contracts/libraries/AssetsAccounting.sol | 618 +++++++----------- .../libraries/WithdrawalBatchesQueue.sol | 179 +++++ contracts/types/ETHValue.sol | 59 ++ contracts/types/IndexOneBased.sol | 32 + contracts/types/SharesValue.sol | 50 ++ test/scenario/escrow.t.sol | 91 +-- test/scenario/veto-cooldown-mechanics.t.sol | 16 +- test/utils/scenario-test-blueprint.sol | 39 +- 10 files changed, 787 insertions(+), 506 deletions(-) create mode 100644 contracts/libraries/WithdrawalBatchesQueue.sol create mode 100644 contracts/types/ETHValue.sol create mode 100644 contracts/types/IndexOneBased.sol create mode 100644 contracts/types/SharesValue.sol diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 7b967e9b..97aeafef 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -2,7 +2,6 @@ pragma solidity 0.8.23; 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"; @@ -11,9 +10,17 @@ import {IStETH} from "./interfaces/IStETH.sol"; import {IWstETH} from "./interfaces/IWstETH.sol"; import {IWithdrawalQueue, WithdrawalRequestStatus} from "./interfaces/IWithdrawalQueue.sol"; -import {AssetsAccounting, LockedAssetsStats, LockedAssetsTotals} from "./libraries/AssetsAccounting.sol"; - -import {ArrayUtils} from "./utils/arrays.sol"; +import { + ETHValue, + ETHValues, + SharesValue, + SharesValues, + HolderAssets, + StETHAccounting, + UnstETHAccounting, + AssetsAccounting +} from "./libraries/AssetsAccounting.sol"; +import {WithdrawalsBatchesQueue} from "./libraries/WithdrawalBatchesQueue.sol"; interface IDualGovernance { function activateNextState() external; @@ -25,16 +32,27 @@ enum EscrowState { RageQuitEscrow } +struct LockedAssetsTotals { + uint256 stETHLockedShares; + uint256 stETHClaimedETH; + uint256 unstETHUnfinalizedShares; + uint256 unstETHFinalizedETH; +} + struct VetoerState { - uint256 stETHShares; - uint256 unstETHShares; + uint256 stETHLockedShares; + uint256 unstETHLockedShares; + uint256 unstETHIdsCount; + uint256 lastAssetsLockTimestamp; } contract Escrow is IEscrow { using AssetsAccounting for AssetsAccounting.State; + using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.State; error EmptyBatch(); error ZeroWithdraw(); + error InvalidBatchSize(uint256 size); error WithdrawalsTimelockNotPassed(); error InvalidETHSender(address actual, address expected); error NotDualGovernance(address actual, address expected); @@ -43,6 +61,13 @@ contract Escrow is IEscrow { error InvalidState(EscrowState actual, EscrowState expected); error RageQuitExtraTimelockNotStarted(); + // TODO: move to config + uint256 public immutable MIN_BATCH_SIZE = 8; + uint256 public immutable MAX_BATCH_SIZE = 128; + + uint256 public immutable MIN_WITHDRAWAL_REQUEST_AMOUNT; + uint256 public immutable MAX_WITHDRAWAL_REQUEST_AMOUNT; + uint256 public immutable RAGE_QUIT_TIMELOCK = 30 days; address public immutable MASTER_COPY; @@ -55,6 +80,7 @@ contract Escrow is IEscrow { EscrowState internal _escrowState; IDualGovernance private _dualGovernance; AssetsAccounting.State private _accounting; + WithdrawalsBatchesQueue.State private _batchesQueue; uint256[] internal _withdrawalUnstETHIds; @@ -65,9 +91,11 @@ contract Escrow is IEscrow { 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); + WITHDRAWAL_QUEUE = IWithdrawalQueue(withdrawalQueue); + MIN_WITHDRAWAL_REQUEST_AMOUNT = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); + MAX_WITHDRAWAL_REQUEST_AMOUNT = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); } function initialize(address dualGovernance) external { @@ -90,15 +118,15 @@ contract Escrow is IEscrow { function lockStETH(uint256 amount) external { uint256 shares = ST_ETH.getSharesByPooledEth(amount); - _accounting.accountStETHSharesLock(msg.sender, shares); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(shares)); ST_ETH.transferSharesFrom(msg.sender, address(this), shares); _activateNextGovernanceState(); } function unlockStETH() external { _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); - uint256 sharesUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); - ST_ETH.transferShares(msg.sender, sharesUnlocked); + SharesValue sharesUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + ST_ETH.transferShares(msg.sender, sharesUnlocked.toUint256()); _activateNextGovernanceState(); } @@ -109,16 +137,17 @@ contract Escrow is IEscrow { function lockWstETH(uint256 amount) external { WST_ETH.transferFrom(msg.sender, address(this), amount); uint256 stETHAmount = WST_ETH.unwrap(amount); - _accounting.accountStETHSharesLock(msg.sender, ST_ETH.getSharesByPooledEth(stETHAmount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(ST_ETH.getSharesByPooledEth(stETHAmount))); _activateNextGovernanceState(); } - function unlockWstETH() external returns (uint256 wstETHUnlocked) { + function unlockWstETH() external returns (uint256) { _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); - wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); - uint256 wstETHAmount = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked)); + SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); + uint256 wstETHAmount = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); WST_ETH.transfer(msg.sender, wstETHAmount); _activateNextGovernanceState(); + return wstETHAmount; } // --- @@ -135,7 +164,8 @@ contract Escrow is IEscrow { } function unlockUnstETH(uint256[] memory unstETHIds) external { - _accounting.accountUnstETHUnlock(CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME(), msg.sender, unstETHIds); + _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); + _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]); @@ -161,7 +191,7 @@ contract Escrow is IEscrow { for (uint256 i = 0; i < statuses.length; ++i) { sharesTotal += statuses[i].amountOfShares; } - _accounting.accountStETHSharesUnlock(msg.sender, sharesTotal); + _accounting.accountStETHSharesUnlock(msg.sender, SharesValues.from(sharesTotal)); _accounting.accountUnstETHLock(msg.sender, unstETHIds, statuses); } @@ -184,31 +214,67 @@ contract Escrow is IEscrow { ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); } - function requestNextWithdrawalsBatch(uint256 maxWithdrawalRequestsCount) external { - _checkEscrowState(EscrowState.RageQuitEscrow); + function requestNextWithdrawalsBatch(uint256 maxBatchSize) external { + _batchesQueue.checkNotFinalized(); - 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); + if (maxBatchSize < MIN_BATCH_SIZE || maxBatchSize > MAX_BATCH_SIZE) { + revert InvalidBatchSize(maxBatchSize); + } + + uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); + if (stETHRemaining < MIN_WITHDRAWAL_REQUEST_AMOUNT) { + return _batchesQueue.finalize(); + } + + uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ + minRequestAmount: MIN_WITHDRAWAL_REQUEST_AMOUNT, + requestAmount: MAX_WITHDRAWAL_REQUEST_AMOUNT, + amount: Math.min(stETHRemaining, MAX_WITHDRAWAL_REQUEST_AMOUNT * maxBatchSize) + }); + + _batchesQueue.add(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); } - function claimNextWithdrawalsBatch(uint256 offset, uint256[] calldata hints) external { + function claimWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { _checkEscrowState(EscrowState.RageQuitEscrow); - uint256[] memory unstETHIds = _accounting.accountWithdrawalBatchClaimed(offset, hints.length); + _batchesQueue.checkClaimingInProgress(); + + if (_batchesQueue.isClaimingFinished()) { + _rageQuitTimelockStartedAt = block.timestamp; + return; + } + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); + uint256[] memory hints = + WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()); + + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + + _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); + if (_batchesQueue.isClaimingFinished()) { + _rageQuitTimelockStartedAt = block.timestamp; + } + } - if (unstETHIds.length > 0) { - uint256 ethBalanceBefore = address(this).balance; - WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); - uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + function claimWithdrawalsBatch(uint256 unstETHId, uint256[] calldata hints) external { + _checkEscrowState(EscrowState.RageQuitEscrow); + _batchesQueue.checkClaimingInProgress(); - _accounting.accountClaimedETH(ethAmountClaimed); + if (_batchesQueue.isClaimingFinished()) { + _rageQuitTimelockStartedAt = block.timestamp; + return; } - if (_accounting.getIsWithdrawalsClaimed()) { + + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(unstETHId, hints.length); + + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + + _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); + if (_batchesQueue.isClaimingFinished()) { _rageQuitTimelockStartedAt = block.timestamp; } } @@ -221,8 +287,8 @@ contract Escrow is IEscrow { WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); uint256 ethBalanceAfter = address(this).balance; - uint256 totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); - assert(totalAmountClaimed == ethBalanceAfter - ethBalanceBefore); + ETHValue totalAmountClaimed = _accounting.accountUnstETHClaimed(unstETHIds, claimableAmounts); + assert(totalAmountClaimed == ETHValues.from(ethBalanceAfter - ethBalanceBefore)); } // --- @@ -232,13 +298,15 @@ contract Escrow is IEscrow { function withdrawETH() external { _checkEscrowState(EscrowState.RageQuitEscrow); _checkWithdrawalsTimelockPassed(); - Address.sendValue(payable(msg.sender), _accounting.accountStETHSharesWithdraw(msg.sender)); + ETHValue ethToWithdraw = _accounting.accountStETHSharesWithdraw(msg.sender); + ethToWithdraw.sendTo(payable(msg.sender)); } function withdrawETH(uint256[] calldata unstETHIds) external { _checkEscrowState(EscrowState.RageQuitEscrow); _checkWithdrawalsTimelockPassed(); - Address.sendValue(payable(msg.sender), _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds)); + ETHValue ethToWithdraw = _accounting.accountUnstETHWithdraw(msg.sender, unstETHIds); + ethToWithdraw.sendTo(payable(msg.sender)); } // --- @@ -246,34 +314,35 @@ contract Escrow is IEscrow { // --- function getLockedAssetsTotals() external view returns (LockedAssetsTotals memory totals) { - totals = _accounting.totals; + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + totals.stETHClaimedETH = stETHTotals.claimedETH.toUint256(); + totals.stETHLockedShares = stETHTotals.lockedShares.toUint256(); + + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + totals.unstETHUnfinalizedShares = unstETHTotals.unfinalizedShares.toUint256(); + totals.unstETHFinalizedETH = unstETHTotals.finalizedETH.toUint256(); } - function getVetoerState(address vetoer) external view returns (VetoerState memory vetoerState) { - LockedAssetsStats memory stats = _accounting.assets[vetoer]; - vetoerState.stETHShares = stats.stETHShares; - vetoerState.unstETHShares = stats.unstETHShares; + function getVetoerState(address vetoer) external view returns (VetoerState memory state) { + HolderAssets storage assets = _accounting.assets[vetoer]; + + state.unstETHIdsCount = assets.unstETHIds.length; + state.stETHLockedShares = assets.stETHLockedShares.toUint256(); + state.unstETHLockedShares = assets.stETHLockedShares.toUint256(); + state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp; } - 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]; - } + function getNextWithdrawalBatches(uint256 limit) external view returns (uint256[] memory unstETHIds) { + _batchesQueue.checkClaimingInProgress(); + return _batchesQueue.getNextWithdrawalsBatches(limit); + } + + function getIsWithdrawalsBatchesFinalized() external view returns (bool) { + return _batchesQueue.isFinalized; } function getIsWithdrawalsClaimed() external view returns (bool) { - return _accounting.getIsWithdrawalsClaimed(); + return _batchesQueue.isClaimingFinished(); } function getRageQuitTimelockStartedAt() external view returns (uint256) { @@ -281,13 +350,20 @@ contract Escrow is IEscrow { } 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); + StETHAccounting memory stETHTotals = _accounting.stETHTotals; + UnstETHAccounting memory unstETHTotals = _accounting.unstETHTotals; + + uint256 finalizedETH = unstETHTotals.finalizedETH.toUint256(); + uint256 ufinalizedShares = (stETHTotals.lockedShares + unstETHTotals.unfinalizedShares).toUint256(); + + rageQuitSupport = ( + 10 ** 18 * (ST_ETH.getPooledEthByShares(ufinalizedShares) + finalizedETH) + / (ST_ETH.totalSupply() + finalizedETH) + ); } function isRageQuitFinalized() external view returns (bool) { - return _escrowState == EscrowState.RageQuitEscrow && _accounting.getIsWithdrawalsClaimed() + return _escrowState == EscrowState.RageQuitEscrow && _batchesQueue.isClaimingFinished() && _rageQuitTimelockStartedAt != 0 && block.timestamp > _rageQuitTimelockStartedAt + _rageQuitExtraTimelock; } diff --git a/contracts/interfaces/IWithdrawalQueue.sol b/contracts/interfaces/IWithdrawalQueue.sol index e3beea17..f6ae3a6c 100644 --- a/contracts/interfaces/IWithdrawalQueue.sol +++ b/contracts/interfaces/IWithdrawalQueue.sol @@ -37,6 +37,13 @@ interface IWithdrawalQueue { uint256[] calldata _hints ) external view returns (uint256[] memory claimableEthValues); + function findCheckpointHints( + uint256[] calldata _requestIds, + uint256 _firstIndex, + uint256 _lastIndex + ) external view returns (uint256[] memory hintIds); + function getLastCheckpointIndex() external view returns (uint256); + function balanceOf(address owner) external view returns (uint256); function requestWithdrawals( diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol index acbfefc5..5737b155 100644 --- a/contracts/libraries/AssetsAccounting.sol +++ b/contracts/libraries/AssetsAccounting.sol @@ -1,14 +1,40 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {ETHValue, ETHValues} from "../types/ETHValue.sol"; +import {SharesValue, SharesValues} from "../types/SharesValue.sol"; +import {IndexOneBased, IndicesOneBased} from "../types/IndexOneBased.sol"; import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; import {TimeUtils} from "../utils/time.sol"; -import {ArrayUtils} from "../utils/arrays.sol"; -enum WithdrawalRequestState { +struct HolderAssets { + // The total shares amount of stETH/wstETH accounted to the holder + SharesValue stETHLockedShares; + // The total shares amount of unstETH NFTs accounted to the holder + SharesValue unstETHLockedShares; + // The timestamp when the last time was accounted lock of shares or unstETHs + uint40 lastAssetsLockTimestamp; + // The ids of the unstETH NFTs accounted to the holder + uint256[] unstETHIds; +} + +struct UnstETHAccounting { + // The cumulative amount of unfinalized unstETH shares locked in the Escrow + SharesValue unfinalizedShares; + // The total amount of ETH claimable from the finalized unstETH locked in the Escrow + ETHValue finalizedETH; +} + +struct StETHAccounting { + // The total amount of shares of locked stETH and wstETH tokens + SharesValue lockedShares; + // The total amount of ETH received during the claiming of the locked stETH + ETHValue claimedETH; +} + +enum UnstETHRecordStatus { NotLocked, Locked, Finalized, @@ -16,186 +42,148 @@ enum WithdrawalRequestState { Withdrawn } -struct WithdrawalRequest { - address owner; - uint96 claimableAmount; - uint128 shares; - uint64 vetoerUnstETHIndexOneBased; - WithdrawalRequestState state; -} - -struct LockedAssetsStats { - uint128 stETHShares; - uint128 unstETHShares; - uint128 sharesFinalized; - uint128 amountFinalized; - uint40 lastAssetsLockTimestamp; -} - -struct LockedAssetsTotals { - uint128 shares; - uint128 sharesFinalized; - uint128 amountFinalized; - uint128 amountClaimed; +struct UnstETHRecord { + // The one based index of the unstETH record in the UnstETHAccounting.unstETHIds list + IndexOneBased index; + // The address of the holder who locked unstETH + address lockedBy; + // The current status of the unstETH + UnstETHRecordStatus status; + // The amount of shares contained in the unstETH + SharesValue shares; + // The amount of ETH contained in the unstETH (this value equals to 0 until NFT is mark as finalized or claimed) + ETHValue claimableAmount; } library AssetsAccounting { - using SafeCast for uint256; + struct State { + StETHAccounting stETHTotals; + UnstETHAccounting unstETHTotals; + mapping(address account => HolderAssets) assets; + mapping(uint256 unstETHId => UnstETHRecord) unstETHRecords; + } - event StETHLocked(address indexed vetoer, uint256 shares); - event StETHUnlocked(address indexed vetoer, uint256 shares); - event StETHWithdrawn(address indexed vetoer, uint256 stETHShares, uint256 ethAmount); + // --- + // Events + // --- - event UnstETHLocked(address indexed vetoer, uint256[] ids, uint256 shares); + event ETHWithdrawn(address indexed holder, SharesValue shares, ETHValue value); + event StETHSharesLocked(address indexed holder, SharesValue shares); + event StETHSharesUnlocked(address indexed holder, SharesValue shares); + event UnstETHFinalized(uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement); event UnstETHUnlocked( - address indexed vetoer, - uint256[] ids, - uint256 sharesDecrement, - uint256 finalizedSharesDecrement, - uint256 finalizedAmountDecrement + address indexed holder, uint256[] ids, SharesValue finalizedSharesIncrement, ETHValue finalizedAmountIncrement ); - event UnstETHFinalized(uint256[] ids, uint256 finalizedSharesIncrement, uint256 finalizedAmountIncrement); - event UnstETHClaimed(uint256[] ids, uint256 ethAmount); - event UnstETHWithdrawn(uint256[] ids, uint256 ethAmount); - - event WithdrawalBatchCreated(uint256[] ids); - event WithdrawalBatchesClaimed(uint256 offset, uint256 count); - - error NoBatchesToClaim(); - error EmptyWithdrawalBatch(); - error WithdrawalBatchesFormed(); - error NotWithdrawalRequestOwner(uint256 id, address actual, address expected); - error InvalidSharesLock(address vetoer, uint256 shares); - error InvalidSharesUnlock(address vetoer, uint256 shares); - error InvalidSharesWithdraw(address vetoer, uint256 shares); - error WithdrawalRequestFinalized(uint256 id); - error ClaimableAmountChanged(uint256 id, uint256 actual, uint256 expected); - error WithdrawalRequestNotClaimable(uint256 id, WithdrawalRequestState state); - error WithdrawalRequestWasNotLocked(uint256 id); - error WithdrawalRequestAlreadyLocked(uint256 id); - error InvalidUnstETHOwner(address actual, address expected); - error InvalidWithdrawlRequestState(uint256 id, WithdrawalRequestState actual, WithdrawalRequestState expected); - error InvalidWithdrawalBatchesOffset(uint256 actual, uint256 expected); - error InvalidWithdrawalBatchesCount(uint256 actual, uint256 expected); - error AssetsUnlockDelayNotPassed(uint256 unlockTimelockExpiresAt); - error NotEnoughStETHToUnlock(uint256 requested, uint256 sharesBalance); + event UnstETHLocked(address indexed holder, uint256[] ids, SharesValue shares); + event UnstETHClaimed(uint256[] unstETHIds, ETHValue totalAmountClaimed); + event UnstETHWithdrawn(uint256[] unstETHIds, ETHValue amountWithdrawn); - 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; - } + event ETHClaimed(ETHValue amount); // --- - // stETH Operations Accounting + // Errors // --- - function accountStETHSharesLock(State storage self, address vetoer, uint256 shares) internal { - _checkNonZeroSharesLock(vetoer, shares); - uint128 sharesUint128 = shares.toUint128(); - self.assets[vetoer].stETHShares += sharesUint128; - self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); - self.totals.shares += sharesUint128; - emit StETHLocked(vetoer, shares); - } + error InvalidSharesValue(SharesValue value); + error InvalidUnstETHStatus(uint256 unstETHId, UnstETHRecordStatus status); + error InvalidUnstETHHolder(uint256 unstETHId, address actual, address expected); + error AssetsUnlockDelayNotPassed(uint256 unlockTimelockExpiresAt); + error InvalidClaimableAmount(uint256 unstETHId, ETHValue expected, ETHValue actual); + + // --- + // stETH shares operations accounting + // --- - function accountStETHSharesUnlock(State storage self, address vetoer) internal returns (uint128 sharesUnlocked) { - sharesUnlocked = accountStETHSharesUnlock(self, vetoer, self.assets[vetoer].stETHShares); + function accountStETHSharesLock(State storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares + shares; + HolderAssets storage assets = self.assets[holder]; + assets.stETHLockedShares = assets.stETHLockedShares + shares; + assets.lastAssetsLockTimestamp = TimeUtils.timestamp(); + emit StETHSharesLocked(holder, shares); } - function accountStETHSharesUnlock( - State storage self, - address vetoer, - uint256 shares - ) internal returns (uint128 sharesUnlocked) { - _checkStETHSharesUnlock(self, vetoer, shares); - sharesUnlocked = shares.toUint128(); - self.totals.shares -= sharesUnlocked; - self.assets[vetoer].stETHShares -= sharesUnlocked; - emit StETHUnlocked(vetoer, sharesUnlocked); + function accountStETHSharesUnlock(State storage self, address holder) internal returns (SharesValue shares) { + shares = self.assets[holder].stETHLockedShares; + accountStETHSharesUnlock(self, holder, shares); } - function accountStETHSharesWithdraw(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); + function accountStETHSharesUnlock(State storage self, address holder, SharesValue shares) internal { + _checkNonZeroShares(shares); + + HolderAssets storage assets = self.assets[holder]; + if (assets.stETHLockedShares < shares) { + revert InvalidSharesValue(shares); + } + + self.stETHTotals.lockedShares = self.stETHTotals.lockedShares - shares; + assets.stETHLockedShares = assets.stETHLockedShares - shares; + emit StETHSharesUnlocked(holder, shares); } - // --- - // wstETH Operations Accounting - // --- + function accountStETHSharesWithdraw(State storage self, address holder) internal returns (ETHValue ethWithdrawn) { + HolderAssets storage assets = self.assets[holder]; + SharesValue stETHSharesToWithdraw = assets.stETHLockedShares; + + _checkNonZeroShares(stETHSharesToWithdraw); - function checkAssetsUnlockDelayPassed(State storage self, address vetoer, uint256 delay) internal view { - _checkAssetsUnlockDelayPassed(self, delay, vetoer); + assets.stETHLockedShares = SharesValues.ZERO; + ethWithdrawn = + SharesValues.calcETHValue(self.stETHTotals.claimedETH, stETHSharesToWithdraw, self.stETHTotals.lockedShares); + + emit ETHWithdrawn(holder, stETHSharesToWithdraw, ethWithdrawn); + } + + function accountClaimedStETH(State storage self, ETHValue amount) internal { + self.stETHTotals.claimedETH = self.stETHTotals.claimedETH + amount; + emit ETHClaimed(amount); } // --- - // unstETH Operations Accounting + // unstETH operations accounting // --- function accountUnstETHLock( State storage self, - address vetoer, + address holder, uint256[] memory unstETHIds, WithdrawalRequestStatus[] memory statuses ) internal { assert(unstETHIds.length == statuses.length); - uint256 totalUnstETHSharesLocked; + SharesValue totalUnstETHLocked; uint256 unstETHcount = unstETHIds.length; for (uint256 i = 0; i < unstETHcount; ++i) { - totalUnstETHSharesLocked += _addWithdrawalRequest(self, vetoer, unstETHIds[i], statuses[i]); + totalUnstETHLocked = totalUnstETHLocked + _addUnstETHRecord(self, holder, unstETHIds[i], statuses[i]); } - uint128 totalUnstETHSharesLockedUint128 = totalUnstETHSharesLocked.toUint128(); - self.assets[vetoer].unstETHShares += totalUnstETHSharesLockedUint128; - self.assets[vetoer].lastAssetsLockTimestamp = TimeUtils.timestamp(); - self.totals.shares += totalUnstETHSharesLockedUint128; - emit UnstETHLocked(vetoer, unstETHIds, totalUnstETHSharesLocked); - } + self.assets[holder].lastAssetsLockTimestamp = TimeUtils.timestamp(); + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares + totalUnstETHLocked; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares + totalUnstETHLocked; - function accountUnstETHUnlock( - State storage self, - uint256 assetsUnlockDelay, - address vetoer, - uint256[] memory unstETHIds - ) internal { - _checkAssetsUnlockDelayPassed(self, assetsUnlockDelay, vetoer); + emit UnstETHLocked(holder, unstETHIds, totalUnstETHLocked); + } - uint256 totalUnstETHSharesUnlocked; - uint256 totalFinalizedSharesUnlocked; - uint256 totalFinalizedAmountUnlocked; + function accountUnstETHUnlock(State storage self, address holder, uint256[] memory unstETHIds) internal { + SharesValue totalSharesUnlocked; + SharesValue totalFinalizedSharesUnlocked; + ETHValue totalFinalizedAmountUnlocked; uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - (uint256 sharesUnlocked, uint256 finalizedSharesUnlocked, uint256 finalizedAmountUnlocked) = - _removeWithdrawalRequest(self, vetoer, unstETHIds[i]); - - totalUnstETHSharesUnlocked += sharesUnlocked; - totalFinalizedSharesUnlocked += finalizedSharesUnlocked; - totalFinalizedAmountUnlocked += finalizedAmountUnlocked; + (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) = + _removeUnstETHRecord(self, holder, unstETHIds[i]); + if (finalizedAmountUnlocked > ETHValues.ZERO) { + totalFinalizedAmountUnlocked = totalFinalizedAmountUnlocked + finalizedAmountUnlocked; + totalFinalizedSharesUnlocked = totalFinalizedSharesUnlocked + sharesUnlocked; + } + totalSharesUnlocked = totalSharesUnlocked + sharesUnlocked; } + self.assets[holder].unstETHLockedShares = self.assets[holder].unstETHLockedShares - totalSharesUnlocked; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH - totalFinalizedAmountUnlocked; + self.unstETHTotals.unfinalizedShares = + self.unstETHTotals.unfinalizedShares - (totalSharesUnlocked - totalFinalizedSharesUnlocked); - 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.sharesFinalized -= totalFinalizedSharesUnlockedUint128; - self.totals.amountFinalized -= totalFinalizedAmountUnlockedUint128; - - emit UnstETHUnlocked( - vetoer, unstETHIds, totalUnstETHSharesUnlocked, totalFinalizedSharesUnlocked, totalFinalizedAmountUnlocked - ); + emit UnstETHUnlocked(holder, unstETHIds, totalSharesUnlocked, totalFinalizedAmountUnlocked); } function accountUnstETHFinalized( @@ -205,26 +193,19 @@ library AssetsAccounting { ) internal { assert(claimableAmounts.length == unstETHIds.length); - uint256 totalSharesFinalized; - uint256 totalAmountFinalized; + ETHValue totalAmountFinalized; + SharesValue totalSharesFinalized; uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - (address owner, uint256 sharesFinalized, uint256 amountFinalized) = - _finalizeWithdrawalRequest(self, unstETHIds[i], claimableAmounts[i]); - - self.assets[owner].sharesFinalized += sharesFinalized.toUint128(); - self.assets[owner].amountFinalized += amountFinalized.toUint128(); - - totalSharesFinalized += sharesFinalized; - totalAmountFinalized += amountFinalized; + (SharesValue sharesFinalized, ETHValue amountFinalized) = + _finalizeUnstETHRecord(self, unstETHIds[i], claimableAmounts[i]); + totalSharesFinalized = totalSharesFinalized + sharesFinalized; + totalAmountFinalized = totalAmountFinalized + amountFinalized; } - uint128 totalSharesFinalizedUint128 = totalSharesFinalized.toUint128(); - uint128 totalAmountFinalizedUint128 = totalAmountFinalized.toUint128(); - - self.totals.sharesFinalized += totalSharesFinalizedUint128; - self.totals.amountFinalized += totalAmountFinalizedUint128; + self.unstETHTotals.finalizedETH = self.unstETHTotals.finalizedETH + totalAmountFinalized; + self.unstETHTotals.unfinalizedShares = self.unstETHTotals.unfinalizedShares - totalSharesFinalized; emit UnstETHFinalized(unstETHIds, totalSharesFinalized, totalAmountFinalized); } @@ -232,292 +213,173 @@ library AssetsAccounting { State storage self, uint256[] memory unstETHIds, uint256[] memory claimableAmounts - ) internal returns (uint256 totalAmountClaimed) { + ) internal returns (ETHValue totalAmountClaimed) { uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - totalAmountClaimed += _claimWithdrawalRequest(self, unstETHIds[i], claimableAmounts[i]); + ETHValue claimableAmount = ETHValues.from(claimableAmounts[i]); + totalAmountClaimed = totalAmountClaimed + claimableAmount; + _claimUnstETHRecord(self, unstETHIds[i], claimableAmount); } - self.totals.amountClaimed += totalAmountClaimed.toUint128(); emit UnstETHClaimed(unstETHIds, totalAmountClaimed); } function accountUnstETHWithdraw( State storage self, - address vetoer, + address holder, uint256[] calldata unstETHIds - ) internal returns (uint256 amountWithdrawn) { + ) internal returns (ETHValue amountWithdrawn) { uint256 unstETHIdsCount = unstETHIds.length; for (uint256 i = 0; i < unstETHIdsCount; ++i) { - amountWithdrawn += _withdrawWithdrawalRequest(self, vetoer, unstETHIds[i]); + amountWithdrawn = amountWithdrawn + _withdrawUnstETHRecord(self, holder, unstETHIds[i]); } emit UnstETHWithdrawn(unstETHIds, amountWithdrawn); } - // --- - // Withdraw Batches - // --- - - function formWithdrawalBatch( - State storage self, - uint256 minRequestAmount, - uint256 maxRequestAmount, - uint256 stETHBalance, - uint256 requestAmountsCountLimit - ) internal returns (uint256[] memory requestAmounts) { - if (self.isAllWithdrawalBatchesFormed) { - revert WithdrawalBatchesFormed(); - } - if (requestAmountsCountLimit == 0) { - revert EmptyWithdrawalBatch(); - } - - uint256 maxAmount = maxRequestAmount * requestAmountsCountLimit; - if (stETHBalance >= maxAmount) { - return ArrayUtils.seed(requestAmountsCountLimit, maxRequestAmount); - } - - self.isAllWithdrawalBatchesFormed = true; - - uint256 requestsCount = stETHBalance / maxRequestAmount; - uint256 lastRequestAmount = stETHBalance % maxRequestAmount; - - if (lastRequestAmount < minRequestAmount) { - return ArrayUtils.seed(requestsCount, maxRequestAmount); - } - - requestAmounts = ArrayUtils.seed(requestsCount + 1, maxRequestAmount); - requestAmounts[requestsCount] = lastRequestAmount; - } - - function accountWithdrawalBatch(State storage self, uint256[] memory unstETHIds) internal { - uint256 unstETHIdsCount = unstETHIds.length; - for (uint256 i = 0; i < unstETHIdsCount; ++i) { - self.withdrawalBatchIds.push(unstETHIds[i]); - } - emit WithdrawalBatchCreated(unstETHIds); - } - - function accountWithdrawalBatchClaimed( - State storage self, - uint256 offset, - uint256 count - ) internal returns (uint256[] memory unstETHIds) { - if (count == 0) { - return unstETHIds; - } - uint256 batchesCount = self.withdrawalBatchIds.length; - uint256 claimedBatchesCount = self.claimedBatchesCount; - if (claimedBatchesCount == batchesCount) { - revert NoBatchesToClaim(); - } - if (claimedBatchesCount != offset) { - revert InvalidWithdrawalBatchesOffset(offset, claimedBatchesCount); - } - if (count > batchesCount - claimedBatchesCount) { - revert InvalidWithdrawalBatchesCount(count, batchesCount - claimedBatchesCount); - } - - unstETHIds = new uint256[](count); - for (uint256 i = 0; i < count; ++i) { - unstETHIds[i] = self.withdrawalBatchIds[claimedBatchesCount + i]; - } - self.claimedBatchesCount += count; - emit WithdrawalBatchesClaimed(offset, count); - } - - function accountClaimedETH(State storage self, uint256 amount) internal { - self.totals.amountClaimed += amount.toUint128(); - } - // --- // Getters // --- - function getLocked(State storage self) internal view returns (uint256 rebaseableShares, uint256 finalizedAmount) { - rebaseableShares = self.totals.shares - self.totals.sharesFinalized; - finalizedAmount = self.totals.amountFinalized; + function getLockedAssetsTotals(State storage self) + internal + view + returns (SharesValue ufinalizedShares, ETHValue finalizedETH) + { + finalizedETH = self.unstETHTotals.finalizedETH; + ufinalizedShares = self.stETHTotals.lockedShares + self.unstETHTotals.unfinalizedShares; } - 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); + function checkAssetsUnlockDelayPassed( + State storage self, + address holder, + uint256 assetsUnlockDelay + ) internal view { + if (block.timestamp <= self.assets[holder].lastAssetsLockTimestamp + assetsUnlockDelay) { + revert AssetsUnlockDelayNotPassed(self.assets[holder].lastAssetsLockTimestamp + assetsUnlockDelay); } } // --- - // Private Methods + // Helper methods // --- - function _addWithdrawalRequest( + function _addUnstETHRecord( State storage self, - address vetoer, + address holder, uint256 unstETHId, WithdrawalRequestStatus memory status - ) private returns (uint256 amountOfShares) { - amountOfShares = status.amountOfShares; - WithdrawalRequest storage request = self.requests[unstETHId]; + ) private returns (SharesValue shares) { + if (status.isFinalized) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.Finalized); + } + // must never be true, for unfinalized requests + assert(!status.isClaimed); - _checkWithdrawalRequestNotLocked(request, unstETHId); - _checkWithdrawalRequestStatusNotFinalized(status, unstETHId); + if (self.unstETHRecords[unstETHId].status != UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, self.unstETHRecords[unstETHId].status); + } - self.vetoersUnstETHIds[vetoer].push(unstETHId); + HolderAssets storage assets = self.assets[holder]; + assets.unstETHIds.push(unstETHId); - request.owner = vetoer; - request.state = WithdrawalRequestState.Locked; - request.vetoerUnstETHIndexOneBased = self.vetoersUnstETHIds[vetoer].length.toUint64(); - request.shares = amountOfShares.toUint128(); - assert(request.claimableAmount == 0); + shares = SharesValues.from(status.amountOfShares); + self.unstETHRecords[unstETHId] = UnstETHRecord({ + lockedBy: holder, + status: UnstETHRecordStatus.Locked, + index: IndicesOneBased.from(assets.unstETHIds.length), + shares: shares, + claimableAmount: ETHValues.ZERO + }); } - function _removeWithdrawalRequest( + function _removeUnstETHRecord( State storage self, - address vetoer, + address holder, uint256 unstETHId - ) private returns (uint256 sharesUnlocked, uint256 finalizedSharesUnlocked, uint256 finalizedAmountUnlocked) { - WithdrawalRequest storage request = self.requests[unstETHId]; + ) private returns (SharesValue sharesUnlocked, ETHValue finalizedAmountUnlocked) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; - _checkWithdrawalRequestOwner(request, vetoer); - _checkWithdrawalRequestWasLocked(request, unstETHId); + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); + } - sharesUnlocked = request.shares; - if (request.state == WithdrawalRequestState.Finalized) { - finalizedSharesUnlocked = sharesUnlocked; - finalizedAmountUnlocked = request.claimableAmount; + if (unstETHRecord.status == UnstETHRecordStatus.NotLocked) { + revert InvalidUnstETHStatus(unstETHId, UnstETHRecordStatus.NotLocked); } - uint256[] storage vetoerUnstETHIds = self.vetoersUnstETHIds[vetoer]; - uint256 unstETHIdIndex = request.vetoerUnstETHIndexOneBased - 1; - uint256 lastUnstETHIdIndex = vetoerUnstETHIds.length - 1; + sharesUnlocked = unstETHRecord.shares; + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + finalizedAmountUnlocked = unstETHRecord.claimableAmount; + } + + HolderAssets storage assets = self.assets[holder]; + IndexOneBased unstETHIdIndex = unstETHRecord.index; + IndexOneBased lastUnstETHIdIndex = IndicesOneBased.from(assets.unstETHIds.length); + if (lastUnstETHIdIndex != unstETHIdIndex) { - uint256 lastUnstETHId = vetoerUnstETHIds[lastUnstETHIdIndex]; - vetoerUnstETHIds[unstETHIdIndex] = lastUnstETHId; - self.requests[lastUnstETHId].vetoerUnstETHIndexOneBased = (unstETHIdIndex + 1).toUint64(); + uint256 lastUnstETHId = assets.unstETHIds[lastUnstETHIdIndex.value()]; + assets.unstETHIds[unstETHIdIndex.value()] = lastUnstETHId; + self.unstETHRecords[lastUnstETHId].index = unstETHIdIndex; } - vetoerUnstETHIds.pop(); - delete self.requests[unstETHId]; + assets.unstETHIds.pop(); + delete self.unstETHRecords[unstETHId]; } - function _finalizeWithdrawalRequest( + function _finalizeUnstETHRecord( State storage self, uint256 unstETHId, uint256 claimableAmount - ) private returns (address owner, uint256 sharesFinalized, uint256 amountFinalized) { - WithdrawalRequest storage request = self.requests[unstETHId]; - if (claimableAmount == 0 || request.state != WithdrawalRequestState.Locked) { - return (request.owner, 0, 0); + ) private returns (SharesValue sharesFinalized, ETHValue amountFinalized) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (claimableAmount == 0 || unstETHRecord.status != UnstETHRecordStatus.Locked) { + return (sharesFinalized, amountFinalized); } - owner = request.owner; - request.state = WithdrawalRequestState.Finalized; - request.claimableAmount = claimableAmount.toUint96(); + sharesFinalized = unstETHRecord.shares; + amountFinalized = ETHValues.from(claimableAmount); - sharesFinalized = request.shares; - amountFinalized = claimableAmount; - } + unstETHRecord.status = UnstETHRecordStatus.Finalized; + unstETHRecord.claimableAmount = amountFinalized; - function _claimWithdrawalRequest( - State storage self, - uint256 unstETHId, - uint256 claimableAmount - ) private returns (uint256 amountClaimed) { - WithdrawalRequest storage request = self.requests[unstETHId]; + self.unstETHRecords[unstETHId] = unstETHRecord; + } - if (request.state != WithdrawalRequestState.Locked && request.state != WithdrawalRequestState.Finalized) { - revert WithdrawalRequestNotClaimable(unstETHId, request.state); + function _claimUnstETHRecord(State storage self, uint256 unstETHId, ETHValue claimableAmount) private { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; + if (unstETHRecord.status != UnstETHRecordStatus.Locked && unstETHRecord.status != UnstETHRecordStatus.Finalized) + { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); } - if (request.state == WithdrawalRequestState.Finalized && request.claimableAmount != claimableAmount) { - revert ClaimableAmountChanged(unstETHId, claimableAmount, request.claimableAmount); + if (unstETHRecord.status == UnstETHRecordStatus.Finalized) { + // if the unstETH was marked finalized earlier, it's claimable amount must stay the same + if (unstETHRecord.claimableAmount != claimableAmount) { + revert InvalidClaimableAmount(unstETHId, claimableAmount, unstETHRecord.claimableAmount); + } } else { - request.claimableAmount = claimableAmount.toUint96(); + unstETHRecord.claimableAmount = claimableAmount; } - request.state = WithdrawalRequestState.Claimed; - amountClaimed = claimableAmount; + unstETHRecord.status = UnstETHRecordStatus.Claimed; + self.unstETHRecords[unstETHId] = unstETHRecord; } - function _withdrawWithdrawalRequest( + function _withdrawUnstETHRecord( State storage self, - address vetoer, + address holder, uint256 unstETHId - ) private returns (uint256 amountWithdrawn) { - WithdrawalRequest storage request = self.requests[unstETHId]; - - if (request.owner != vetoer) { - revert NotWithdrawalRequestOwner(unstETHId, vetoer, request.owner); - } - if (request.state != WithdrawalRequestState.Claimed) { - revert InvalidWithdrawlRequestState(unstETHId, request.state, WithdrawalRequestState.Claimed); - } - request.state = WithdrawalRequestState.Withdrawn; - amountWithdrawn = request.claimableAmount; - } - - function _checkWithdrawalRequestOwner(WithdrawalRequest storage request, address account) private view { - if (request.owner != account) { - revert InvalidUnstETHOwner(account, request.owner); - } - } - - function _checkWithdrawalRequestStatusNotFinalized( - WithdrawalRequestStatus memory status, - uint256 id - ) private pure { - if (status.isFinalized) { - revert WithdrawalRequestFinalized(id); - } - // it can't be claimed without finalization - assert(!status.isClaimed); - } - - function _checkWithdrawalRequestNotLocked(WithdrawalRequest storage request, uint256 unstETHId) private view { - if (request.vetoerUnstETHIndexOneBased != 0) { - revert WithdrawalRequestAlreadyLocked(unstETHId); - } - } - - function _checkWithdrawalRequestWasLocked(WithdrawalRequest storage request, uint256 id) private view { - if (request.vetoerUnstETHIndexOneBased == 0) { - revert WithdrawalRequestWasNotLocked(id); - } - } + ) private returns (ETHValue amountWithdrawn) { + UnstETHRecord storage unstETHRecord = self.unstETHRecords[unstETHId]; - function _checkNonZeroSharesLock(address vetoer, uint256 shares) private pure { - if (shares == 0) { - revert InvalidSharesLock(vetoer, 0); + if (unstETHRecord.status != UnstETHRecordStatus.Claimed) { + revert InvalidUnstETHStatus(unstETHId, unstETHRecord.status); } - } - - function _checkNonZeroSharesUnlock(address vetoer, uint256 shares) private pure { - if (shares == 0) { - revert InvalidSharesUnlock(vetoer, 0); + if (unstETHRecord.lockedBy != holder) { + revert InvalidUnstETHHolder(unstETHId, holder, unstETHRecord.lockedBy); } + unstETHRecord.status = UnstETHRecordStatus.Withdrawn; + amountWithdrawn = unstETHRecord.claimableAmount; } - function _checkStETHSharesUnlock(State storage self, address vetoer, uint256 shares) private view { - if (shares == 0) { - revert InvalidSharesUnlock(vetoer, 0); - } - - if (self.assets[vetoer].stETHShares < shares) { - revert NotEnoughStETHToUnlock(shares, self.assets[vetoer].stETHShares); - } - } - - function _checkNonZeroSharesWithdraw(address vetoer, uint256 shares) private pure { - if (shares == 0) { - revert InvalidSharesWithdraw(vetoer, 0); - } - } - - function _checkAssetsUnlockDelayPassed( - State storage self, - uint256 assetsUnlockDelay, - address vetoer - ) private view { - if (block.timestamp <= self.assets[vetoer].lastAssetsLockTimestamp + assetsUnlockDelay) { - revert AssetsUnlockDelayNotPassed(self.assets[vetoer].lastAssetsLockTimestamp + assetsUnlockDelay); + function _checkNonZeroShares(SharesValue shares) private pure { + if (shares == SharesValues.ZERO) { + revert InvalidSharesValue(SharesValues.ZERO); } } } diff --git a/contracts/libraries/WithdrawalBatchesQueue.sol b/contracts/libraries/WithdrawalBatchesQueue.sol new file mode 100644 index 00000000..dee11a1d --- /dev/null +++ b/contracts/libraries/WithdrawalBatchesQueue.sol @@ -0,0 +1,179 @@ +// 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 {ArrayUtils} from "../utils/arrays.sol"; + +enum WithdrawalsBatchesQueueStatus { + // The default status of the WithdrawalsBatchesQueue. In the closed state the only action allowed + // to be called is open(), which transfers it into Opened state. + Closed, + // In the Opened state WithdrawalsBatchesQueue allows to add batches into the queue + Opened, + // When the WithdrawalsBatchesQueue enters Filled queue - it's not allowed to add batches and + // only allowed to mark batches claimed + Filled, + // The final state of the WithdrawalsBatchesQueue. This state means that all withdrawal batches + // were claimed + Claimed +} + +struct WithdrawalsBatch { + uint16 size; + uint240 fromUnstETHId; +} + +library WithdrawalsBatchesQueue { + using SafeCast for uint256; + + struct State { + bool isFinalized; + uint16 batchIndex; + uint16 unstETHIndex; + uint48 totalUnstETHCount; + uint48 totalUnstETHClaimed; + WithdrawalsBatch[] batches; + } + + error AllBatchesAlreadyFormed(); + error InvalidUnstETHId(uint256 unstETHId); + error NotFinalizable(uint256 stETHBalance); + error ClaimingNotStarted(); + error ClaimingIsFinished(); + error EmptyWithdrawalsBatch(); + + function calcRequestAmounts( + uint256 minRequestAmount, + uint256 requestAmount, + uint256 amount + ) internal pure returns (uint256[] memory requestAmounts) { + uint256 requestsCount = amount / requestAmount; + // last request amount will be equal to zero when it's multiple requestAmount + // when it's in the range [0, minRequestAmount) - it will not be included in the result + uint256 lastRequestAmount = amount - requestsCount * requestAmount; + if (lastRequestAmount >= minRequestAmount) { + requestsCount += 1; + } + requestAmounts = ArrayUtils.seed(requestsCount, requestAmount); + if (lastRequestAmount >= minRequestAmount) { + requestAmounts[requestsCount - 1] = lastRequestAmount; + } + } + + function add(State storage self, uint256[] memory unstETHIds) internal { + uint256 newUnstETHIdsCount = unstETHIds.length; + if (newUnstETHIdsCount == 0) { + revert EmptyWithdrawalsBatch(); + } + + uint256 firstAddedUnstETHId = unstETHIds[0]; + if (self.batches.length == 0) { + self.batches.push( + WithdrawalsBatch({fromUnstETHId: firstAddedUnstETHId.toUint240(), size: newUnstETHIdsCount.toUint16()}) + ); + return; + } + + WithdrawalsBatch memory lastBatch = self.batches[self.batches.length - 1]; + uint256 lastCreatedUnstETHId = lastBatch.fromUnstETHId + lastBatch.size; + // when there is no gap between the lastly added unstETHId and the new one + // then the batch may not be created, and added to the last one + if (firstAddedUnstETHId == lastCreatedUnstETHId) { + // but it may be done only when the batch max capacity is allowed to do it + if (lastBatch.size + newUnstETHIdsCount <= type(uint16).max) { + self.batches[self.batches.length - 1].size = (lastBatch.size + newUnstETHIdsCount).toUint16(); + } + } else { + self.batches.push( + WithdrawalsBatch({fromUnstETHId: firstAddedUnstETHId.toUint240(), size: newUnstETHIdsCount.toUint16()}) + ); + } + lastBatch = self.batches[self.batches.length - 1]; + self.totalUnstETHCount += newUnstETHIdsCount.toUint48(); + } + + function claimNextBatch(State storage self, uint256 maxUnstETHIdsCount) internal returns (uint256[] memory) { + uint256 batchId = self.batchIndex; + WithdrawalsBatch memory batch = self.batches[batchId]; + uint256 unstETHId = batch.fromUnstETHId + self.unstETHIndex; + return claimNextBatch(self, unstETHId, maxUnstETHIdsCount); + } + + function claimNextBatch( + State storage self, + uint256 unstETHId, + uint256 maxUnstETHIdsCount + ) internal returns (uint256[] memory result) { + uint256 expectedUnstETHId = self.batches[self.batchIndex].fromUnstETHId + self.unstETHIndex; + if (expectedUnstETHId != unstETHId) { + revert InvalidUnstETHId(unstETHId); + } + + uint256 unclaimedUnstETHIdsCount = self.totalUnstETHCount - self.totalUnstETHClaimed; + uint256 unstETHIdsCountToClaim = Math.min(unclaimedUnstETHIdsCount, maxUnstETHIdsCount); + + uint256 batchIndex = self.batchIndex; + uint256 unstETHIndex = self.unstETHIndex; + result = new uint256[](unstETHIdsCountToClaim); + self.totalUnstETHClaimed += unstETHIdsCountToClaim.toUint48(); + + uint256 index = 0; + while (unstETHIdsCountToClaim > 0) { + WithdrawalsBatch memory batch = self.batches[batchIndex]; + uint256 unstETHIdsToClaimInBatch = Math.min(unstETHIdsCountToClaim, batch.size - unstETHIndex); + for (uint256 i = 0; i < unstETHIdsToClaimInBatch; ++i) { + result[i] = batch.fromUnstETHId + unstETHIndex + i; + } + index += unstETHIdsToClaimInBatch; + unstETHIndex += unstETHIdsToClaimInBatch; + unstETHIdsCountToClaim -= unstETHIdsToClaimInBatch; + if (unstETHIndex == batch.size) { + batchIndex += 1; + unstETHIndex = 0; + } + } + self.batchIndex = batchIndex.toUint16(); + self.unstETHIndex = unstETHIndex.toUint16(); + } + + function getNextWithdrawalsBatches( + State storage self, + uint256 limit + ) internal view returns (uint256[] memory unstETHIds) { + uint256 batchId = self.batchIndex; + uint256 unstETHindex = self.unstETHIndex; + WithdrawalsBatch memory batch = self.batches[batchId]; + uint256 unstETHId = batch.fromUnstETHId + self.unstETHIndex; + uint256 unstETHIdsCount = Math.min(batch.size - unstETHindex, limit); + + unstETHIds = new uint256[](unstETHIdsCount); + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + unstETHIds[i] = unstETHId + i; + } + } + + function checkNotFinalized(State storage self) internal view { + if (self.isFinalized) { + revert AllBatchesAlreadyFormed(); + } + } + + function finalize(State storage self) internal { + self.isFinalized = true; + } + + function isClaimingFinished(State storage self) internal view returns (bool) { + return self.totalUnstETHClaimed == self.totalUnstETHCount; + } + + function checkClaimingInProgress(State storage self) internal view { + if (!self.isFinalized) { + revert ClaimingNotStarted(); + } + if (self.totalUnstETHCount > 0 && self.totalUnstETHCount == self.totalUnstETHClaimed) { + revert ClaimingIsFinished(); + } + } +} diff --git a/contracts/types/ETHValue.sol b/contracts/types/ETHValue.sol new file mode 100644 index 00000000..3fb11785 --- /dev/null +++ b/contracts/types/ETHValue.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +type ETHValue is uint128; + +error ETHValueOverflow(); +error ETHValueUnderflow(); + +using {plus as +, minus as -, lt as <, gt as >, eq as ==, neq as !=} for ETHValue global; +using {toUint256} for ETHValue global; +using {sendTo} for ETHValue global; + +function sendTo(ETHValue value, address payable recipient) { + Address.sendValue(recipient, value.toUint256()); +} + +function plus(ETHValue v1, ETHValue v2) pure returns (ETHValue) { + return ETHValues.from(ETHValue.unwrap(v1) + ETHValue.unwrap(v2)); +} + +function minus(ETHValue v1, ETHValue v2) pure returns (ETHValue) { + if (v1 < v2) { + revert ETHValueUnderflow(); + } + return ETHValues.from(ETHValue.unwrap(v1) + ETHValue.unwrap(v2)); +} + +function lt(ETHValue v1, ETHValue v2) pure returns (bool) { + return ETHValue.unwrap(v1) < ETHValue.unwrap(v2); +} + +function gt(ETHValue v1, ETHValue v2) pure returns (bool) { + return ETHValue.unwrap(v1) > ETHValue.unwrap(v2); +} + +function eq(ETHValue v1, ETHValue v2) pure returns (bool) { + return ETHValue.unwrap(v1) == ETHValue.unwrap(v2); +} + +function neq(ETHValue v1, ETHValue v2) pure returns (bool) { + return ETHValue.unwrap(v1) != ETHValue.unwrap(v2); +} + +function toUint256(ETHValue value) pure returns (uint256) { + return ETHValue.unwrap(value); +} + +library ETHValues { + ETHValue internal constant ZERO = ETHValue.wrap(0); + + function from(uint256 value) internal pure returns (ETHValue) { + if (value > type(uint128).max) { + revert ETHValueOverflow(); + } + return ETHValue.wrap(uint128(value)); + } +} diff --git a/contracts/types/IndexOneBased.sol b/contracts/types/IndexOneBased.sol new file mode 100644 index 00000000..201ce080 --- /dev/null +++ b/contracts/types/IndexOneBased.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +type IndexOneBased is uint32; + +error IndexOneBasedOverflow(); +error IndexOneBasedUnderflow(); + +using {neq as !=} for IndexOneBased global; +using {value} for IndexOneBased global; + +function neq(IndexOneBased i1, IndexOneBased i2) pure returns (bool) { + return IndexOneBased.unwrap(i1) != IndexOneBased.unwrap(i2); +} + +function value(IndexOneBased index) pure returns (uint256) { + if (IndexOneBased.unwrap(index) == 0) { + revert IndexOneBasedUnderflow(); + } + unchecked { + return IndexOneBased.unwrap(index) - 1; + } +} + +library IndicesOneBased { + function from(uint256 value) internal pure returns (IndexOneBased) { + if (value > type(uint32).max) { + revert IndexOneBasedOverflow(); + } + return IndexOneBased.wrap(uint32(value)); + } +} diff --git a/contracts/types/SharesValue.sol b/contracts/types/SharesValue.sol new file mode 100644 index 00000000..f5048477 --- /dev/null +++ b/contracts/types/SharesValue.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {ETHValue, ETHValues} from "./ETHValue.sol"; + +type SharesValue is uint128; + +error SharesValueOverflow(); + +using {plus as +, minus as -, eq as ==, lt as <} for SharesValue global; +using {toUint256} for SharesValue global; + +function plus(SharesValue v1, SharesValue v2) pure returns (SharesValue) { + return SharesValue.wrap(SharesValue.unwrap(v1) + SharesValue.unwrap(v2)); +} + +function minus(SharesValue v1, SharesValue v2) pure returns (SharesValue) { + return SharesValue.wrap(SharesValue.unwrap(v1) - SharesValue.unwrap(v2)); +} + +function lt(SharesValue v1, SharesValue v2) pure returns (bool) { + return SharesValue.unwrap(v1) < SharesValue.unwrap(v2); +} + +function eq(SharesValue v1, SharesValue v2) pure returns (bool) { + return SharesValue.unwrap(v1) == SharesValue.unwrap(v2); +} + +function toUint256(SharesValue v) pure returns (uint256) { + return SharesValue.unwrap(v); +} + +library SharesValues { + SharesValue internal constant ZERO = SharesValue.wrap(0); + + function from(uint256 value) internal pure returns (SharesValue) { + if (value > type(uint128).max) { + revert SharesValueOverflow(); + } + return SharesValue.wrap(uint128(value)); + } + + function calcETHValue( + ETHValue totalPooled, + SharesValue share, + SharesValue total + ) internal pure returns (ETHValue) { + return ETHValues.from(totalPooled.toUint256() * share.toUint256() / total.toUint256()); + } +} diff --git a/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol index 10d590dd..3b34656b 100644 --- a/test/scenario/escrow.t.sol +++ b/test/scenario/escrow.t.sol @@ -6,10 +6,10 @@ import {WithdrawalRequestStatus} from "../utils/interfaces.sol"; import { Escrow, Balances, - VetoerState, - LockedAssetsTotals, WITHDRAWAL_QUEUE, - ScenarioTestBlueprint + ScenarioTestBlueprint, + VetoerState, + LockedAssetsTotals } from "../utils/scenario-test-blueprint.sol"; contract TestHelpers is ScenarioTestBlueprint { @@ -240,33 +240,39 @@ contract EscrowHappyPath is TestHelpers { } function test_check_finalization() public { - uint256 totalSharesLocked = _ST_ETH.getSharesByPooledEth(2 * 1e18); - uint256 expectedSharesFinalized = _ST_ETH.getSharesByPooledEth(1 * 1e18); + uint256 totalAmountLocked = 2 ether; uint256[] memory amounts = new uint256[](2); for (uint256 i = 0; i < 2; ++i) { - amounts[i] = 1e18; + amounts[i] = 1 ether; } vm.prank(_VETOER_1); uint256[] memory unstETHIds = _WITHDRAWAL_QUEUE.requestWithdrawals(amounts, _VETOER_1); + uint256 totalSharesLocked; + WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); + for (uint256 i = 0; i < unstETHIds.length; ++i) { + totalSharesLocked += statuses[i].amountOfShares; + } + _lockUnstETH(_VETOER_1, unstETHIds); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); - assertEq(escrow.getLockedAssetsTotals().sharesFinalized, 0); + VetoerState memory vetoerState = escrow.getVetoerState(_VETOER_1); + assertEq(vetoerState.unstETHIdsCount, 2); + + LockedAssetsTotals memory totals = escrow.getLockedAssetsTotals(); + assertEq(totals.unstETHFinalizedETH, 0); + assertEq(totals.unstETHUnfinalizedShares, totalSharesLocked); finalizeWQ(unstETHIds[0]); uint256[] memory hints = _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); escrow.markUnstETHFinalized(unstETHIds, hints); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 1); - - assertApproxEqAbs(escrow.getLockedAssetsTotals().sharesFinalized, expectedSharesFinalized, 1); + totals = escrow.getLockedAssetsTotals(); + assertEq(totals.unstETHUnfinalizedShares, statuses[0].amountOfShares); uint256 ethAmountFinalized = _WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints)[0]; - assertApproxEqAbs(escrow.getLockedAssetsTotals().amountFinalized, ethAmountFinalized, 1); + assertApproxEqAbs(totals.unstETHFinalizedETH, ethAmountFinalized, 1); } function test_get_rage_quit_support() public { @@ -287,9 +293,8 @@ contract EscrowHappyPath is TestHelpers { _lockWstETH(_VETOER_1, sharesToLock); _lockUnstETH(_VETOER_1, unstETHIds); - VetoerState memory vetoerState = escrow.getVetoerState(_VETOER_1); - assertApproxEqAbs(vetoerState.stETHShares, 2 * sharesToLock, 1); - assertApproxEqAbs(vetoerState.unstETHShares, _ST_ETH.getSharesByPooledEth(2e18), 1); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).stETHLockedShares, 2 * sharesToLock, 1); + assertEq(escrow.getVetoerState(_VETOER_1).unstETHIdsCount, 2); uint256 rageQuitSupport = escrow.getRageQuitSupport(); assertEq(rageQuitSupport, 4 * 1e18 * 1e18 / totalSupply); @@ -299,11 +304,9 @@ contract EscrowHappyPath is TestHelpers { _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); escrow.markUnstETHFinalized(unstETHIds, hints); - LockedAssetsTotals memory totals = escrow.getLockedAssetsTotals(); - - assertApproxEqAbs(totals.sharesFinalized, sharesToLock, 1); + assertEq(escrow.getLockedAssetsTotals().unstETHUnfinalizedShares, sharesToLock); uint256 ethAmountFinalized = _WITHDRAWAL_QUEUE.getClaimableEther(unstETHIds, hints)[0]; - assertApproxEqAbs(totals.amountFinalized, ethAmountFinalized, 1); + assertApproxEqAbs(escrow.getLockedAssetsTotals().unstETHFinalizedETH, ethAmountFinalized, 1); rageQuitSupport = escrow.getRageQuitSupport(); assertEq( @@ -345,7 +348,9 @@ contract EscrowHappyPath is TestHelpers { assertEq(_WITHDRAWAL_QUEUE.balanceOf(address(escrow)), 20); - escrow.requestNextWithdrawalsBatch(200); + while (!escrow.getIsWithdrawalsBatchesFinalized()) { + escrow.requestNextWithdrawalsBatch(96); + } assertEq(_WITHDRAWAL_QUEUE.balanceOf(address(escrow)), 10 + expectedWithdrawalBatchesCount); assertEq(escrow.isRageQuitFinalized(), false); @@ -353,9 +358,8 @@ contract EscrowHappyPath is TestHelpers { vm.deal(WITHDRAWAL_QUEUE, 1000 * requestAmount); finalizeWQ(); - (uint256 offset, uint256 total, uint256[] memory unstETHIdsToClaim) = - escrow.getNextWithdrawalBatches(expectedWithdrawalBatchesCount); - assertEq(total, expectedWithdrawalBatchesCount); + uint256[] memory unstETHIdsToClaim = escrow.getNextWithdrawalBatches(expectedWithdrawalBatchesCount); + // assertEq(total, expectedWithdrawalBatchesCount); WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIdsToClaim); @@ -367,7 +371,9 @@ contract EscrowHappyPath is TestHelpers { uint256[] memory hints = _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIdsToClaim, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); - escrow.claimNextWithdrawalsBatch(offset, hints); + while (!escrow.getIsWithdrawalsClaimed()) { + escrow.claimWithdrawalsBatch(128); + } assertEq(escrow.isRageQuitFinalized(), false); @@ -419,7 +425,9 @@ contract EscrowHappyPath is TestHelpers { vm.deal(WITHDRAWAL_QUEUE, 100 * requestAmount); finalizeWQ(); - escrow.claimNextWithdrawalsBatch(0, new uint256[](0)); + escrow.requestNextWithdrawalsBatch(96); + + escrow.claimWithdrawalsBatch(0, new uint256[](0)); assertEq(escrow.isRageQuitFinalized(), false); @@ -448,14 +456,14 @@ contract EscrowHappyPath is TestHelpers { uint256 totalSharesLocked = firstVetoerWstETHAmount + firstVetoerStETHShares; _lockStETH(_VETOER_1, firstVetoerStETHAmount); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).stETHShares, firstVetoerStETHShares, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, firstVetoerStETHShares, 1); + assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).stETHLockedShares, firstVetoerStETHShares, 1); + assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, firstVetoerStETHShares, 1); _lockWstETH(_VETOER_1, firstVetoerWstETHAmount); assertApproxEqAbs( - escrow.getVetoerState(_VETOER_1).stETHShares, firstVetoerWstETHAmount + firstVetoerStETHShares, 2 + escrow.getVetoerState(_VETOER_1).stETHLockedShares, firstVetoerWstETHAmount + firstVetoerStETHShares, 2 ); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, totalSharesLocked, 2); _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); @@ -465,8 +473,8 @@ contract EscrowHappyPath is TestHelpers { vm.prank(_VETOER_1); uint256[] memory stETHWithdrawalRequestIds = escrow.requestWithdrawals(stETHWithdrawalRequestAmounts); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, firstVetoerStETHShares, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, firstVetoerWstETHAmount, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().unstETHUnfinalizedShares, firstVetoerStETHShares, 2); uint256[] memory wstETHWithdrawalRequestAmounts = new uint256[](1); wstETHWithdrawalRequestAmounts[0] = _ST_ETH.getPooledEthByShares(firstVetoerWstETHAmount); @@ -474,8 +482,8 @@ contract EscrowHappyPath is TestHelpers { vm.prank(_VETOER_1); uint256[] memory wstETHWithdrawalRequestIds = escrow.requestWithdrawals(wstETHWithdrawalRequestAmounts); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 2); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, 0, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().unstETHUnfinalizedShares, totalSharesLocked, 2); finalizeWQ(wstETHWithdrawalRequestIds[0]); @@ -485,8 +493,9 @@ contract EscrowHappyPath is TestHelpers { stETHWithdrawalRequestIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex() ) ); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 2); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, 0, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().unstETHUnfinalizedShares, firstVetoerWstETHAmount, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().unstETHFinalizedETH, firstVetoerStETHAmount, 2); escrow.markUnstETHFinalized( wstETHWithdrawalRequestIds, @@ -494,16 +503,16 @@ contract EscrowHappyPath is TestHelpers { wstETHWithdrawalRequestIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex() ) ); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, totalSharesLocked, 2); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, totalSharesLocked, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, 0, 2); + assertApproxEqAbs(escrow.getLockedAssetsTotals().unstETHUnfinalizedShares, 0, 2); _wait(_config.SIGNALLING_ESCROW_MIN_LOCK_TIME() + 1); vm.prank(_VETOER_1); escrow.unlockUnstETH(stETHWithdrawalRequestIds); - assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, firstVetoerWstETHAmount, 1); - assertApproxEqAbs(escrow.getLockedAssetsTotals().shares, firstVetoerWstETHAmount, 1); + // // assertApproxEqAbs(escrow.getVetoerState(_VETOER_1).unstETHShares, firstVetoerWstETHAmount, 1); + // assertApproxEqAbs(escrow.getLockedAssetsTotals().stETHLockedShares, firstVetoerWstETHAmount, 1); vm.prank(_VETOER_1); escrow.unlockUnstETH(wstETHWithdrawalRequestIds); diff --git a/test/scenario/veto-cooldown-mechanics.t.sol b/test/scenario/veto-cooldown-mechanics.t.sol index 38e75c0e..e1612568 100644 --- a/test/scenario/veto-cooldown-mechanics.t.sol +++ b/test/scenario/veto-cooldown-mechanics.t.sol @@ -68,23 +68,17 @@ contract VetoCooldownMechanicsTest is ScenarioTestBlueprint { uint256 requestAmount = _WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); uint256 maxRequestsCount = vetoedStETHAmount / requestAmount + 1; - rageQuitEscrow.requestNextWithdrawalsBatch(maxRequestsCount); + while (!rageQuitEscrow.getIsWithdrawalsBatchesFinalized()) { + rageQuitEscrow.requestNextWithdrawalsBatch(96); + } vm.deal(address(_WITHDRAWAL_QUEUE), vetoedStETHAmount); _finalizeWQ(); uint256 batchSizeLimit = 200; - while (true) { - (uint256 offset, uint256 total, uint256[] memory unstETHIds) = - rageQuitEscrow.getNextWithdrawalBatches(batchSizeLimit); - if (offset == total) { - break; - } - uint256[] memory hints = - _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); - - rageQuitEscrow.claimNextWithdrawalsBatch(offset, hints); + while (!rageQuitEscrow.getIsWithdrawalsClaimed()) { + rageQuitEscrow.claimWithdrawalsBatch(128); } _wait(_config.RAGE_QUIT_EXTENSION_DELAY() + 1); diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index 629551ff..a955d28a 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -191,7 +191,7 @@ contract ScenarioTestBlueprint is Test { function _unlockWstETH(address vetoer) internal { Escrow escrow = _getVetoSignallingEscrow(); uint256 wstETHBalanceBefore = _WST_ETH.balanceOf(vetoer); - uint256 vetoerWstETHSharesBefore = escrow.getVetoerState(vetoer).stETHShares; + VetoerState memory vetoerStateBefore = escrow.getVetoerState(vetoer); vm.startPrank(vetoer); uint256 wstETHUnlocked = escrow.unlockWstETH(); @@ -199,14 +199,14 @@ contract ScenarioTestBlueprint is Test { // 1 wei rounding issue may arise because of the wrapping stETH into wstETH before // sending funds to the user - assertApproxEqAbs(wstETHUnlocked, vetoerWstETHSharesBefore, 1); - assertApproxEqAbs(_WST_ETH.balanceOf(vetoer), wstETHBalanceBefore + vetoerWstETHSharesBefore, 1); + assertApproxEqAbs(wstETHUnlocked, vetoerStateBefore.stETHLockedShares, 1); + assertApproxEqAbs(_WST_ETH.balanceOf(vetoer), wstETHBalanceBefore + vetoerStateBefore.stETHLockedShares, 1); } function _lockUnstETH(address vetoer, uint256[] memory unstETHIds) internal { Escrow escrow = _getVetoSignallingEscrow(); - uint256 vetoerUnstETHSharesBefore = escrow.getVetoerState(vetoer).unstETHShares; - uint256 totalSharesBefore = escrow.getLockedAssetsTotals().shares; + VetoerState memory vetoerStateBefore = escrow.getVetoerState(vetoer); + LockedAssetsTotals memory lockedAssetsTotalsBefore = escrow.getLockedAssetsTotals(); uint256 unstETHTotalSharesLocked = 0; WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); @@ -224,19 +224,25 @@ contract ScenarioTestBlueprint is Test { assertEq(_WITHDRAWAL_QUEUE.ownerOf(unstETHIds[i]), address(escrow)); } - assertEq(escrow.getVetoerState(vetoer).unstETHShares, vetoerUnstETHSharesBefore + unstETHTotalSharesLocked); - assertEq(escrow.getLockedAssetsTotals().shares, totalSharesBefore + unstETHTotalSharesLocked); + VetoerState memory vetoerStateAfter = escrow.getVetoerState(vetoer); + assertEq(vetoerStateAfter.unstETHIdsCount, vetoerStateBefore.unstETHIdsCount + unstETHIds.length); + + LockedAssetsTotals memory lockedAssetsTotalsAfter = escrow.getLockedAssetsTotals(); + assertEq( + lockedAssetsTotalsAfter.unstETHUnfinalizedShares, + lockedAssetsTotalsBefore.unstETHUnfinalizedShares + unstETHTotalSharesLocked + ); } function _unlockUnstETH(address vetoer, uint256[] memory unstETHIds) internal { Escrow escrow = _getVetoSignallingEscrow(); - uint256 vetoerUnstETHSharesBefore = escrow.getVetoerState(vetoer).unstETHShares; - uint256 totalSharesBefore = escrow.getLockedAssetsTotals().shares; + VetoerState memory vetoerStateBefore = escrow.getVetoerState(vetoer); + LockedAssetsTotals memory lockedAssetsTotalsBefore = escrow.getLockedAssetsTotals(); - uint256 unstETHTotalSharesLocked = 0; + uint256 unstETHTotalSharesUnlocked = 0; WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIds); for (uint256 i = 0; i < unstETHIds.length; ++i) { - unstETHTotalSharesLocked += statuses[i].amountOfShares; + unstETHTotalSharesUnlocked += statuses[i].amountOfShares; } vm.prank(vetoer); @@ -246,8 +252,15 @@ contract ScenarioTestBlueprint is Test { assertEq(_WITHDRAWAL_QUEUE.ownerOf(unstETHIds[i]), vetoer); } - assertEq(escrow.getVetoerState(vetoer).unstETHShares, vetoerUnstETHSharesBefore - unstETHTotalSharesLocked); - assertEq(escrow.getLockedAssetsTotals().shares, totalSharesBefore - unstETHTotalSharesLocked); + VetoerState memory vetoerStateAfter = escrow.getVetoerState(vetoer); + assertEq(vetoerStateAfter.unstETHIdsCount, vetoerStateBefore.unstETHIdsCount - unstETHIds.length); + + // TODO: implement correct assert. It must consider was unstETH finalized or not + LockedAssetsTotals memory lockedAssetsTotalsAfter = escrow.getLockedAssetsTotals(); + assertEq( + lockedAssetsTotalsAfter.unstETHUnfinalizedShares, + lockedAssetsTotalsBefore.unstETHUnfinalizedShares - unstETHTotalSharesUnlocked + ); } // --- From 986299c331e516ed0ac61f966160d17d39f669da Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Fri, 5 Jul 2024 13:33:59 +0400 Subject: [PATCH 09/13] Optimized WithdrawalsBatches logic --- contracts/Escrow.sol | 94 ++++----- .../libraries/WithdrawalBatchesQueue.sol | 184 ++++++++---------- contracts/types/SequentialBatches.sol | 78 ++++++++ test/scenario/veto-cooldown-mechanics.t.sol | 4 +- 4 files changed, 199 insertions(+), 161 deletions(-) create mode 100644 contracts/types/SequentialBatches.sol diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 97aeafef..46abb629 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -50,17 +50,19 @@ contract Escrow is IEscrow { using AssetsAccounting for AssetsAccounting.State; using WithdrawalsBatchesQueue for WithdrawalsBatchesQueue.State; - error EmptyBatch(); - error ZeroWithdraw(); + error UnexpectedUnstETHId(); + error InvalidHintsLength(uint256 actual, uint256 expected); + error ClaimingIsFinished(); error InvalidBatchSize(uint256 size); 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(); + address public immutable MASTER_COPY; + // TODO: move to config uint256 public immutable MIN_BATCH_SIZE = 8; uint256 public immutable MAX_BATCH_SIZE = 128; @@ -68,9 +70,6 @@ contract Escrow is IEscrow { uint256 public immutable MIN_WITHDRAWAL_REQUEST_AMOUNT; uint256 public immutable MAX_WITHDRAWAL_REQUEST_AMOUNT; - uint256 public immutable RAGE_QUIT_TIMELOCK = 30 days; - address public immutable MASTER_COPY; - IStETH public immutable ST_ETH; IWstETH public immutable WST_ETH; IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; @@ -82,11 +81,9 @@ contract Escrow is IEscrow { AssetsAccounting.State private _accounting; WithdrawalsBatchesQueue.State private _batchesQueue; - uint256[] internal _withdrawalUnstETHIds; - uint256 internal _rageQuitExtraTimelock; - uint256 internal _rageQuitWithdrawalsTimelock; uint256 internal _rageQuitTimelockStartedAt; + uint256 internal _rageQuitWithdrawalsTimelock; constructor(address stETH, address wstETH, address withdrawalQueue, address config) { ST_ETH = IStETH(stETH); @@ -109,7 +106,6 @@ contract Escrow is IEscrow { ST_ETH.approve(address(WST_ETH), type(uint256).max); ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); - WST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); } // --- @@ -203,19 +199,15 @@ contract Escrow is IEscrow { _checkDualGovernance(msg.sender); _checkEscrowState(EscrowState.SignallingEscrow); + _batchesQueue.open(); _escrowState = EscrowState.RageQuitEscrow; _rageQuitExtraTimelock = rageQuitExtraTimelock; _rageQuitWithdrawalsTimelock = rageQuitWithdrawalsTimelock; - - uint256 wstETHBalance = WST_ETH.balanceOf(address(this)); - if (wstETHBalance > 0) { - WST_ETH.unwrap(wstETHBalance); - } - ST_ETH.approve(address(WITHDRAWAL_QUEUE), type(uint256).max); } function requestNextWithdrawalsBatch(uint256 maxBatchSize) external { - _batchesQueue.checkNotFinalized(); + _checkEscrowState(EscrowState.RageQuitEscrow); + _batchesQueue.checkOpened(); if (maxBatchSize < MIN_BATCH_SIZE || maxBatchSize > MAX_BATCH_SIZE) { revert InvalidBatchSize(maxBatchSize); @@ -223,7 +215,7 @@ contract Escrow is IEscrow { uint256 stETHRemaining = ST_ETH.balanceOf(address(this)); if (stETHRemaining < MIN_WITHDRAWAL_REQUEST_AMOUNT) { - return _batchesQueue.finalize(); + return _batchesQueue.close(); } uint256[] memory requestAmounts = WithdrawalsBatchesQueue.calcRequestAmounts({ @@ -237,46 +229,33 @@ contract Escrow is IEscrow { function claimWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { _checkEscrowState(EscrowState.RageQuitEscrow); - _batchesQueue.checkClaimingInProgress(); - - if (_batchesQueue.isClaimingFinished()) { - _rageQuitTimelockStartedAt = block.timestamp; - return; + if (_rageQuitTimelockStartedAt != 0) { + revert ClaimingIsFinished(); } uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); - uint256[] memory hints = - WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()); - - uint256 ethBalanceBefore = address(this).balance; - WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); - uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; - _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); - if (_batchesQueue.isClaimingFinished()) { - _rageQuitTimelockStartedAt = block.timestamp; - } + _claimWithdrawalsBatch( + unstETHIds, WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) + ); } - function claimWithdrawalsBatch(uint256 unstETHId, uint256[] calldata hints) external { + function claimWithdrawalsBatch(uint256 fromUnstETHIds, uint256[] calldata hints) external { _checkEscrowState(EscrowState.RageQuitEscrow); - _batchesQueue.checkClaimingInProgress(); - - if (_batchesQueue.isClaimingFinished()) { - _rageQuitTimelockStartedAt = block.timestamp; - return; + if (_rageQuitTimelockStartedAt != 0) { + revert ClaimingIsFinished(); } - uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(unstETHId, hints.length); - - uint256 ethBalanceBefore = address(this).balance; - WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); - uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(hints.length); - _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); - if (_batchesQueue.isClaimingFinished()) { - _rageQuitTimelockStartedAt = block.timestamp; + if (unstETHIds.length > 0 && fromUnstETHIds != unstETHIds[0]) { + revert UnexpectedUnstETHId(); } + if (hints.length != unstETHIds.length) { + revert InvalidHintsLength(hints.length, unstETHIds.length); + } + + _claimWithdrawalsBatch(unstETHIds, hints); } function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { @@ -333,16 +312,15 @@ contract Escrow is IEscrow { } function getNextWithdrawalBatches(uint256 limit) external view returns (uint256[] memory unstETHIds) { - _batchesQueue.checkClaimingInProgress(); return _batchesQueue.getNextWithdrawalsBatches(limit); } function getIsWithdrawalsBatchesFinalized() external view returns (bool) { - return _batchesQueue.isFinalized; + return _batchesQueue.isClosed(); } function getIsWithdrawalsClaimed() external view returns (bool) { - return _batchesQueue.isClaimingFinished(); + return _rageQuitTimelockStartedAt != 0; } function getRageQuitTimelockStartedAt() external view returns (uint256) { @@ -363,8 +341,8 @@ contract Escrow is IEscrow { } function isRageQuitFinalized() external view returns (bool) { - return _escrowState == EscrowState.RageQuitEscrow && _batchesQueue.isClaimingFinished() - && _rageQuitTimelockStartedAt != 0 && block.timestamp > _rageQuitTimelockStartedAt + _rageQuitExtraTimelock; + return _escrowState == EscrowState.RageQuitEscrow && _batchesQueue.isClosed() && _rageQuitTimelockStartedAt != 0 + && block.timestamp > _rageQuitTimelockStartedAt + _rageQuitExtraTimelock; } // --- @@ -381,6 +359,18 @@ contract Escrow is IEscrow { // Internal Methods // --- + function _claimWithdrawalsBatch(uint256[] memory unstETHIds, uint256[] memory hints) internal { + uint256 ethBalanceBefore = address(this).balance; + WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); + uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; + + _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); + + if (_batchesQueue.isClosed() && _batchesQueue.isAllUnstETHClaimed()) { + _rageQuitTimelockStartedAt = block.timestamp; + } + } + function _activateNextGovernanceState() internal { _dualGovernance.activateNextState(); } diff --git a/contracts/libraries/WithdrawalBatchesQueue.sol b/contracts/libraries/WithdrawalBatchesQueue.sol index dee11a1d..6c096be8 100644 --- a/contracts/libraries/WithdrawalBatchesQueue.sol +++ b/contracts/libraries/WithdrawalBatchesQueue.sol @@ -5,44 +5,39 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {ArrayUtils} from "../utils/arrays.sol"; +import {SequentialBatch, SequentialBatches} from "../types/SequentialBatches.sol"; -enum WithdrawalsBatchesQueueStatus { +enum Status { // The default status of the WithdrawalsBatchesQueue. In the closed state the only action allowed // to be called is open(), which transfers it into Opened state. - Closed, + Empty, // In the Opened state WithdrawalsBatchesQueue allows to add batches into the queue Opened, // When the WithdrawalsBatchesQueue enters Filled queue - it's not allowed to add batches and // only allowed to mark batches claimed - Filled, - // The final state of the WithdrawalsBatchesQueue. This state means that all withdrawal batches - // were claimed - Claimed + Closed } -struct WithdrawalsBatch { - uint16 size; - uint240 fromUnstETHId; +struct QueueIndex { + uint32 batchIndex; + uint16 valueIndex; } library WithdrawalsBatchesQueue { using SafeCast for uint256; struct State { - bool isFinalized; - uint16 batchIndex; - uint16 unstETHIndex; + Status status; + QueueIndex lastClaimedUnstETHIdIndex; uint48 totalUnstETHCount; uint48 totalUnstETHClaimed; - WithdrawalsBatch[] batches; + SequentialBatch[] batches; } - error AllBatchesAlreadyFormed(); - error InvalidUnstETHId(uint256 unstETHId); - error NotFinalizable(uint256 stETHBalance); - error ClaimingNotStarted(); - error ClaimingIsFinished(); - error EmptyWithdrawalsBatch(); + event UnstETHIdsAdded(uint256[] unstETHIds); + event UnstETHIdsClaimed(uint256[] unstETHIds); + + error InvalidWithdrawalsBatchesQueueStatus(Status status); function calcRequestAmounts( uint256 minRequestAmount, @@ -62,118 +57,95 @@ library WithdrawalsBatchesQueue { } } + function open(State storage self) internal { + _checkStatus(self, Status.Empty); + // insert empty batch as a stub for first item + self.batches.push(SequentialBatches.create({seed: 0, count: 1})); + self.status = Status.Opened; + } + + function close(State storage self) internal { + _checkStatus(self, Status.Opened); + self.status = Status.Closed; + } + + function isClosed(State storage self) internal view returns (bool) { + return self.status == Status.Closed; + } + + function isAllUnstETHClaimed(State storage self) internal view returns (bool) { + return self.totalUnstETHClaimed == self.totalUnstETHCount; + } + + function checkOpened(State storage self) internal view { + _checkStatus(self, Status.Opened); + } + function add(State storage self, uint256[] memory unstETHIds) internal { - uint256 newUnstETHIdsCount = unstETHIds.length; - if (newUnstETHIdsCount == 0) { - revert EmptyWithdrawalsBatch(); + uint256 unstETHIdsCount = unstETHIds.length; + if (unstETHIdsCount == 0) { + return; } - uint256 firstAddedUnstETHId = unstETHIds[0]; - if (self.batches.length == 0) { - self.batches.push( - WithdrawalsBatch({fromUnstETHId: firstAddedUnstETHId.toUint240(), size: newUnstETHIdsCount.toUint16()}) - ); - return; + // before creating the batch, assert that the unstETHIds is sequential + for (uint256 i = 0; i < unstETHIdsCount - 1; ++i) { + assert(unstETHIds[i + 1] == unstETHIds[i] + 1); } - WithdrawalsBatch memory lastBatch = self.batches[self.batches.length - 1]; - uint256 lastCreatedUnstETHId = lastBatch.fromUnstETHId + lastBatch.size; - // when there is no gap between the lastly added unstETHId and the new one - // then the batch may not be created, and added to the last one - if (firstAddedUnstETHId == lastCreatedUnstETHId) { - // but it may be done only when the batch max capacity is allowed to do it - if (lastBatch.size + newUnstETHIdsCount <= type(uint16).max) { - self.batches[self.batches.length - 1].size = (lastBatch.size + newUnstETHIdsCount).toUint16(); - } + uint256 lastBatchIndex = self.batches.length - 1; + SequentialBatch lastWithdrawalsBatch = self.batches[lastBatchIndex]; + SequentialBatch newWithdrawalsBatch = SequentialBatches.create({seed: unstETHIds[0], count: unstETHIdsCount}); + + if (SequentialBatches.canMerge(lastWithdrawalsBatch, newWithdrawalsBatch)) { + self.batches[lastBatchIndex] = SequentialBatches.merge(lastWithdrawalsBatch, newWithdrawalsBatch); } else { - self.batches.push( - WithdrawalsBatch({fromUnstETHId: firstAddedUnstETHId.toUint240(), size: newUnstETHIdsCount.toUint16()}) - ); + self.batches.push(newWithdrawalsBatch); } - lastBatch = self.batches[self.batches.length - 1]; - self.totalUnstETHCount += newUnstETHIdsCount.toUint48(); - } - function claimNextBatch(State storage self, uint256 maxUnstETHIdsCount) internal returns (uint256[] memory) { - uint256 batchId = self.batchIndex; - WithdrawalsBatch memory batch = self.batches[batchId]; - uint256 unstETHId = batch.fromUnstETHId + self.unstETHIndex; - return claimNextBatch(self, unstETHId, maxUnstETHIdsCount); + self.totalUnstETHCount += newWithdrawalsBatch.size().toUint48(); + emit UnstETHIdsAdded(unstETHIds); } function claimNextBatch( State storage self, - uint256 unstETHId, uint256 maxUnstETHIdsCount - ) internal returns (uint256[] memory result) { - uint256 expectedUnstETHId = self.batches[self.batchIndex].fromUnstETHId + self.unstETHIndex; - if (expectedUnstETHId != unstETHId) { - revert InvalidUnstETHId(unstETHId); - } - - uint256 unclaimedUnstETHIdsCount = self.totalUnstETHCount - self.totalUnstETHClaimed; - uint256 unstETHIdsCountToClaim = Math.min(unclaimedUnstETHIdsCount, maxUnstETHIdsCount); - - uint256 batchIndex = self.batchIndex; - uint256 unstETHIndex = self.unstETHIndex; - result = new uint256[](unstETHIdsCountToClaim); - self.totalUnstETHClaimed += unstETHIdsCountToClaim.toUint48(); - - uint256 index = 0; - while (unstETHIdsCountToClaim > 0) { - WithdrawalsBatch memory batch = self.batches[batchIndex]; - uint256 unstETHIdsToClaimInBatch = Math.min(unstETHIdsCountToClaim, batch.size - unstETHIndex); - for (uint256 i = 0; i < unstETHIdsToClaimInBatch; ++i) { - result[i] = batch.fromUnstETHId + unstETHIndex + i; - } - index += unstETHIdsToClaimInBatch; - unstETHIndex += unstETHIdsToClaimInBatch; - unstETHIdsCountToClaim -= unstETHIdsToClaimInBatch; - if (unstETHIndex == batch.size) { - batchIndex += 1; - unstETHIndex = 0; - } - } - self.batchIndex = batchIndex.toUint16(); - self.unstETHIndex = unstETHIndex.toUint16(); + ) internal returns (uint256[] memory unstETHIds) { + (unstETHIds, self.lastClaimedUnstETHIdIndex) = _getNextClaimableUnstETHIds(self, maxUnstETHIdsCount); + self.totalUnstETHClaimed += unstETHIds.length.toUint48(); + emit UnstETHIdsClaimed(unstETHIds); } function getNextWithdrawalsBatches( State storage self, uint256 limit ) internal view returns (uint256[] memory unstETHIds) { - uint256 batchId = self.batchIndex; - uint256 unstETHindex = self.unstETHIndex; - WithdrawalsBatch memory batch = self.batches[batchId]; - uint256 unstETHId = batch.fromUnstETHId + self.unstETHIndex; - uint256 unstETHIdsCount = Math.min(batch.size - unstETHindex, limit); - - unstETHIds = new uint256[](unstETHIdsCount); - for (uint256 i = 0; i < unstETHIdsCount; ++i) { - unstETHIds[i] = unstETHId + i; - } + (unstETHIds,) = _getNextClaimableUnstETHIds(self, limit); } - function checkNotFinalized(State storage self) internal view { - if (self.isFinalized) { - revert AllBatchesAlreadyFormed(); - } - } + function _getNextClaimableUnstETHIds( + State storage self, + uint256 maxUnstETHIdsCount + ) private view returns (uint256[] memory unstETHIds, QueueIndex memory lastClaimedUnstETHIdIndex) { + uint256 unstETHIdsCount = Math.min(self.totalUnstETHCount - self.totalUnstETHClaimed, maxUnstETHIdsCount); - function finalize(State storage self) internal { - self.isFinalized = true; - } + unstETHIds = new uint256[](unstETHIdsCount); + lastClaimedUnstETHIdIndex = self.lastClaimedUnstETHIdIndex; + SequentialBatch currentBatch = self.batches[lastClaimedUnstETHIdIndex.batchIndex]; - function isClaimingFinished(State storage self) internal view returns (bool) { - return self.totalUnstETHClaimed == self.totalUnstETHCount; + for (uint256 i = 0; i < unstETHIdsCount; ++i) { + lastClaimedUnstETHIdIndex.valueIndex += 1; + if (currentBatch.size() == lastClaimedUnstETHIdIndex.valueIndex) { + lastClaimedUnstETHIdIndex.batchIndex += 1; + lastClaimedUnstETHIdIndex.valueIndex = 0; + currentBatch = self.batches[lastClaimedUnstETHIdIndex.batchIndex]; + } + unstETHIds[i] = currentBatch.valueAt(lastClaimedUnstETHIdIndex.valueIndex); + } } - function checkClaimingInProgress(State storage self) internal view { - if (!self.isFinalized) { - revert ClaimingNotStarted(); - } - if (self.totalUnstETHCount > 0 && self.totalUnstETHCount == self.totalUnstETHClaimed) { - revert ClaimingIsFinished(); + function _checkStatus(State storage self, Status expectedStatus) private view { + if (self.status != expectedStatus) { + revert InvalidWithdrawalsBatchesQueueStatus(self.status); } } } diff --git a/contracts/types/SequentialBatches.sol b/contracts/types/SequentialBatches.sol new file mode 100644 index 00000000..6d944497 --- /dev/null +++ b/contracts/types/SequentialBatches.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +uint256 constant BATCH_SIZE_LENGTH = 16; +uint256 constant BATCH_SIZE_MASK = 2 ** BATCH_SIZE_LENGTH - 1; + +uint256 constant MAX_BATCH_SIZE = BATCH_SIZE_MASK; +uint256 constant MAX_BATCH_VALUE = 2 ** (256 - BATCH_SIZE_LENGTH) - 1; + +// Stores the info about the withdrawals batch encoded as single uint256 +// The 230 MST bits stores the id of the UnstETH id +// the 16 LST bits stores the size of the batch (max size is 2 ^ 16 - 1= 65535) +type SequentialBatch is uint256; + +error BatchValueOverflow(); +error InvalidBatchSize(uint256 size); +error IndexOutOfBounds(uint256 index); + +using {size} for SequentialBatch global; +using {last} for SequentialBatch global; +using {first} for SequentialBatch global; +using {valueAt} for SequentialBatch global; +using {capacity} for SequentialBatch global; + +function capacity(SequentialBatch) pure returns (uint256) { + return MAX_BATCH_SIZE; +} + +function size(SequentialBatch batch) pure returns (uint256) { + unchecked { + return SequentialBatch.unwrap(batch) & BATCH_SIZE_MASK; + } +} + +function first(SequentialBatch batch) pure returns (uint256) { + unchecked { + return SequentialBatch.unwrap(batch) >> BATCH_SIZE_LENGTH; + } +} + +function last(SequentialBatch batch) pure returns (uint256) { + unchecked { + return batch.first() + batch.size() - 1; + } +} + +function valueAt(SequentialBatch batch, uint256 index) pure returns (uint256) { + if (index >= batch.size()) { + revert IndexOutOfBounds(index); + } + unchecked { + return batch.first() + index; + } +} + +library SequentialBatches { + function create(uint256 seed, uint256 count) internal pure returns (SequentialBatch) { + if (seed > MAX_BATCH_VALUE) { + revert BatchValueOverflow(); + } + if (count == 0 || count > MAX_BATCH_SIZE) { + revert InvalidBatchSize(count); + } + unchecked { + return SequentialBatch.wrap(seed << BATCH_SIZE_LENGTH | count); + } + } + + function canMerge(SequentialBatch b1, SequentialBatch b2) internal pure returns (bool) { + unchecked { + return b1.last() == b2.first() && b1.capacity() - b1.size() > 0; + } + } + + function merge(SequentialBatch b1, SequentialBatch b2) internal pure returns (SequentialBatch b3) { + return create(b1.first(), b1.size() + b2.size()); + } +} diff --git a/test/scenario/veto-cooldown-mechanics.t.sol b/test/scenario/veto-cooldown-mechanics.t.sol index e1612568..2a19a373 100644 --- a/test/scenario/veto-cooldown-mechanics.t.sol +++ b/test/scenario/veto-cooldown-mechanics.t.sol @@ -72,11 +72,9 @@ contract VetoCooldownMechanicsTest is ScenarioTestBlueprint { rageQuitEscrow.requestNextWithdrawalsBatch(96); } - vm.deal(address(_WITHDRAWAL_QUEUE), vetoedStETHAmount); + vm.deal(address(_WITHDRAWAL_QUEUE), 2 * vetoedStETHAmount); _finalizeWQ(); - uint256 batchSizeLimit = 200; - while (!rageQuitEscrow.getIsWithdrawalsClaimed()) { rageQuitEscrow.claimWithdrawalsBatch(128); } From 09faaa3b107d14d05e2857f15b7193d7cdb490df Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Fri, 5 Jul 2024 17:22:12 +0400 Subject: [PATCH 10/13] Update config for Escrow contract --- contracts/Configuration.sol | 3 +++ contracts/Escrow.sol | 12 ++++-------- contracts/interfaces/IConfiguration.sol | 15 ++++++++++++--- contracts/libraries/AssetsAccounting.sol | 2 -- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/contracts/Configuration.sol b/contracts/Configuration.sol index aae54c51..a6ca9e89 100644 --- a/contracts/Configuration.sol +++ b/contracts/Configuration.sol @@ -9,6 +9,9 @@ uint256 constant PERCENT = 10 ** 16; contract Configuration is IConfiguration { error MaxSealablesLimitOverflow(uint256 count, uint256 limit); + uint256 public immutable MIN_WITHDRAWALS_BATCH_SIZE = 8; + uint256 public immutable MAX_WITHDRAWALS_BATCH_SIZE = 128; + // --- // Dual Governance State Properties // --- diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index e5d30f71..3ab11198 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -7,7 +7,7 @@ import {Duration} from "./types/Duration.sol"; import {Timestamp, Timestamps} from "./types/Timestamp.sol"; import {IEscrow} from "./interfaces/IEscrow.sol"; -import {IConfiguration} from "./interfaces/IConfiguration.sol"; +import {IEscrowConfigration} from "./interfaces/IConfiguration.sol"; import {IStETH} from "./interfaces/IStETH.sol"; import {IWstETH} from "./interfaces/IWstETH.sol"; @@ -66,10 +66,6 @@ contract Escrow is IEscrow { address public immutable MASTER_COPY; - // TODO: move to config - uint256 public immutable MIN_BATCH_SIZE = 8; - uint256 public immutable MAX_BATCH_SIZE = 128; - uint256 public immutable MIN_WITHDRAWAL_REQUEST_AMOUNT; uint256 public immutable MAX_WITHDRAWAL_REQUEST_AMOUNT; @@ -77,7 +73,7 @@ contract Escrow is IEscrow { IWstETH public immutable WST_ETH; IWithdrawalQueue public immutable WITHDRAWAL_QUEUE; - IConfiguration public immutable CONFIG; + IEscrowConfigration public immutable CONFIG; EscrowState internal _escrowState; IDualGovernance private _dualGovernance; @@ -92,7 +88,7 @@ contract Escrow is IEscrow { ST_ETH = IStETH(stETH); WST_ETH = IWstETH(wstETH); MASTER_COPY = address(this); - CONFIG = IConfiguration(config); + CONFIG = IEscrowConfigration(config); WITHDRAWAL_QUEUE = IWithdrawalQueue(withdrawalQueue); MIN_WITHDRAWAL_REQUEST_AMOUNT = WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT(); MAX_WITHDRAWAL_REQUEST_AMOUNT = WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); @@ -212,7 +208,7 @@ contract Escrow is IEscrow { _checkEscrowState(EscrowState.RageQuitEscrow); _batchesQueue.checkOpened(); - if (maxBatchSize < MIN_BATCH_SIZE || maxBatchSize > MAX_BATCH_SIZE) { + if (maxBatchSize < CONFIG.MIN_WITHDRAWALS_BATCH_SIZE() || maxBatchSize > CONFIG.MAX_WITHDRAWALS_BATCH_SIZE()) { revert InvalidBatchSize(maxBatchSize); } diff --git a/contracts/interfaces/IConfiguration.sol b/contracts/interfaces/IConfiguration.sol index 26826dd5..14d65504 100644 --- a/contracts/interfaces/IConfiguration.sol +++ b/contracts/interfaces/IConfiguration.sol @@ -19,6 +19,12 @@ struct DualGovernanceConfig { uint256[3] rageQuitEthClaimTimelockGrowthCoeffs; } +interface IEscrowConfigration { + function MIN_WITHDRAWALS_BATCH_SIZE() external view returns (uint256); + function MAX_WITHDRAWALS_BATCH_SIZE() external view returns (uint256); + function SIGNALLING_ESCROW_MIN_LOCK_TIME() external view returns (Duration); +} + interface IAdminExecutorConfiguration { function ADMIN_EXECUTOR() external view returns (address); } @@ -52,8 +58,6 @@ interface IDualGovernanceConfiguration { function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_B() external view returns (uint256); function RAGE_QUIT_ETH_CLAIM_TIMELOCK_GROWTH_COEFF_C() external view returns (uint256); - function SIGNALLING_ESCROW_MIN_LOCK_TIME() external view returns (Duration); - function sealableWithdrawalBlockers() external view returns (address[] memory); function getSignallingThresholdData() @@ -69,4 +73,9 @@ interface IDualGovernanceConfiguration { function getDualGovernanceConfig() external view returns (DualGovernanceConfig memory config); } -interface IConfiguration is IAdminExecutorConfiguration, ITimelockConfiguration, IDualGovernanceConfiguration {} +interface IConfiguration is + IEscrowConfigration, + ITimelockConfiguration, + IAdminExecutorConfiguration, + IDualGovernanceConfiguration +{} diff --git a/contracts/libraries/AssetsAccounting.sol b/contracts/libraries/AssetsAccounting.sol index bdceef3d..2de7288d 100644 --- a/contracts/libraries/AssetsAccounting.sol +++ b/contracts/libraries/AssetsAccounting.sol @@ -10,8 +10,6 @@ import {WithdrawalRequestStatus} from "../interfaces/IWithdrawalQueue.sol"; import {Duration} from "../types/Duration.sol"; import {Timestamps, Timestamp} from "../types/Timestamp.sol"; -import {ArrayUtils} from "../utils/arrays.sol"; - struct HolderAssets { // The total shares amount of stETH/wstETH accounted to the holder SharesValue stETHLockedShares; From fa1b3f12403f9040ebab67b285c5856276ac96ea Mon Sep 17 00:00:00 2001 From: Roman Kolpakov Date: Thu, 4 Jul 2024 13:48:10 +0300 Subject: [PATCH 11/13] feat: add natspec for timelock --- contracts/EmergencyProtectedTimelock.sol | 125 ++++++++++++++++++++--- 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/contracts/EmergencyProtectedTimelock.sol b/contracts/EmergencyProtectedTimelock.sol index 0f9ca933..d68a3e96 100644 --- a/contracts/EmergencyProtectedTimelock.sol +++ b/contracts/EmergencyProtectedTimelock.sol @@ -9,6 +9,14 @@ import {EmergencyProtection, EmergencyState} from "./libraries/EmergencyProtecti import {ConfigurationProvider} from "./ConfigurationProvider.sol"; +/** + * @title EmergencyProtectedTimelock + * @dev A timelock contract with emergency protection functionality. + * The contract allows for submitting, scheduling, and executing proposals, + * while providing emergency protection features to prevent unauthorized + * execution during emergency situations. + */ + contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { using Proposals for Proposals.State; using EmergencyProtection for EmergencyProtection.State; @@ -25,52 +33,93 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { constructor(address config) ConfigurationProvider(config) {} + /** + * @dev Submits a new proposal to execute a series of calls through an executor. + * Only the governance contract can call this function. + * @param executor The address of the executor contract that will execute the calls. + * @param calls An array of `ExecutorCall` structs representing the calls to be executed. + * @return newProposalId The ID of the newly created proposal. + */ function submit(address executor, ExecutorCall[] calldata calls) external returns (uint256 newProposalId) { _checkGovernance(msg.sender); newProposalId = _proposals.submit(executor, calls); } + /** + * @dev Schedules a proposal for execution after a specified delay. + * Only the governance contract can call this function. + * @param proposalId The ID of the proposal to be scheduled. + */ function schedule(uint256 proposalId) external { _checkGovernance(msg.sender); _proposals.schedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); } + /** + * @dev Executes a scheduled proposal. + * Checks if emergency mode is active and prevents execution if it is. + * @param proposalId The ID of the proposal to be executed. + */ function execute(uint256 proposalId) external { _emergencyProtection.checkEmergencyModeActive(false); _proposals.execute(proposalId, CONFIG.AFTER_SCHEDULE_DELAY()); } + /** + * @dev Cancels all non-executed proposals. + * Only the governance contract can call this function. + */ function cancelAllNonExecutedProposals() external { _checkGovernance(msg.sender); _proposals.cancelAll(); } + /** + * @dev Transfers ownership of the executor contract to a new owner. + * Only the admin executor can call this function. + * @param executor The address of the executor contract. + * @param owner The address of the new owner. + */ function transferExecutorOwnership(address executor, address owner) external { _checkAdminExecutor(msg.sender); IOwnable(executor).transferOwnership(owner); } + /** + * @dev Sets a new governance contract address. + * Only the admin executor can call this function. + * @param newGovernance The address of the new governance contract. + */ function setGovernance(address newGovernance) external { _checkAdminExecutor(msg.sender); _setGovernance(newGovernance); } - // --- - // Emergency Protection Functionality - // --- - + /** + * @dev Activates the emergency mode. + * Only the activation committee can call this function. + */ function activateEmergencyMode() external { _emergencyProtection.checkActivationCommittee(msg.sender); _emergencyProtection.checkEmergencyModeActive(false); _emergencyProtection.activate(); } + /** + * @dev Executes a proposal during emergency mode. + * Checks if emergency mode is active and if the caller is part of the execution committee. + * @param proposalId The ID of the proposal to be executed. + */ function emergencyExecute(uint256 proposalId) external { _emergencyProtection.checkEmergencyModeActive(true); _emergencyProtection.checkExecutionCommittee(msg.sender); _proposals.execute(proposalId, /* afterScheduleDelay */ 0); } + /** + * @dev Deactivates the emergency mode. + * If the emergency mode has not passed, only the admin executor can call this function. + */ function deactivateEmergencyMode() external { _emergencyProtection.checkEmergencyModeActive(true); if (!_emergencyProtection.isEmergencyModePassed()) { @@ -80,6 +129,10 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { _proposals.cancelAll(); } + /** + * @dev Resets the system after entering the emergency mode. + * Only the execution committee can call this function. + */ function emergencyReset() external { _emergencyProtection.checkEmergencyModeActive(true); _emergencyProtection.checkExecutionCommittee(msg.sender); @@ -88,6 +141,14 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { _proposals.cancelAll(); } + /** + * @dev Sets the parameters for the emergency protection functionality. + * Only the admin executor can call this function. + * @param activator The address of the activation committee. + * @param enactor The address of the execution committee. + * @param protectionDuration The duration of the protection period. + * @param emergencyModeDuration The duration of the emergency mode. + */ function setEmergencyProtection( address activator, address enactor, @@ -98,51 +159,79 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { _emergencyProtection.setup(activator, enactor, protectionDuration, emergencyModeDuration); } + /** + * @dev Checks if the emergency protection functionality is enabled. + * @return A boolean indicating if the emergency protection is enabled. + */ function isEmergencyProtectionEnabled() external view returns (bool) { return _emergencyProtection.isEmergencyProtectionEnabled(); } + /** + * @dev Retrieves the current emergency state. + * @return res The EmergencyState struct containing the current emergency state. + */ function getEmergencyState() external view returns (EmergencyState memory res) { res = _emergencyProtection.getEmergencyState(); } - // --- - // Timelock View Methods - // --- - + /** + * @dev Retrieves the address of the current governance contract. + * @return The address of the current governance contract. + */ function getGovernance() external view returns (address) { return _governance; } + /** + * @dev Retrieves the details of a proposal. + * @param proposalId The ID of the proposal. + * @return proposal The Proposal struct containing the details of the proposal. + */ function getProposal(uint256 proposalId) external view returns (Proposal memory proposal) { proposal = _proposals.get(proposalId); } + /** + * @dev Retrieves the total number of proposals. + * @return count The total number of proposals. + */ function getProposalsCount() external view returns (uint256 count) { count = _proposals.count(); } + /** + * @dev Retrieves the submission time of a proposal. + * @param proposalId The ID of the proposal. + * @return submittedAt The submission time of the proposal. + */ function getProposalSubmissionTime(uint256 proposalId) external view returns (uint256 submittedAt) { submittedAt = _proposals.getProposalSubmissionTime(proposalId); } - // --- - // Proposals Lifecycle View Methods - // --- - + /** + * @dev Checks if a proposal can be executed. + * @param proposalId The ID of the proposal. + * @return A boolean indicating if the proposal can be executed. + */ function canExecute(uint256 proposalId) external view returns (bool) { return !_emergencyProtection.isEmergencyModeActivated() && _proposals.canExecute(proposalId, CONFIG.AFTER_SCHEDULE_DELAY()); } + /** + * @dev Checks if a proposal can be scheduled. + * @param proposalId The ID of the proposal. + * @return A boolean indicating if the proposal can be scheduled. + */ function canSchedule(uint256 proposalId) external view returns (bool) { return _proposals.canSchedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); } - // --- - // Internal Methods - // --- - + /** + * @dev Internal function to set the governance contract address. + * @param newGovernance The address of the new governance contract. + */ function _setGovernance(address newGovernance) internal { address prevGovernance = _governance; if (newGovernance == prevGovernance || newGovernance == address(0)) { @@ -152,6 +241,10 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { emit GovernanceSet(newGovernance); } + /** + * @dev Internal function to check if the caller is the governance contract. + * @param account The address to check. + */ function _checkGovernance(address account) internal view { if (_governance != account) { revert NotGovernance(account, _governance); From 2f967c9dc49416d04725ca749bbb94813e8e3883 Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Mon, 8 Jul 2024 02:25:21 +0400 Subject: [PATCH 12/13] Update the Escrow section in the spec. Tweak vars and methods names --- contracts/DualGovernance.sol | 6 +- contracts/Escrow.sol | 66 +++--- docs/specification.md | 235 ++++++++++---------- test/scenario/escrow.t.sol | 10 +- test/scenario/veto-cooldown-mechanics.t.sol | 6 +- test/utils/scenario-test-blueprint.sol | 14 +- 6 files changed, 169 insertions(+), 168 deletions(-) diff --git a/contracts/DualGovernance.sol b/contracts/DualGovernance.sol index 7e82e1d6..dd81ff7b 100644 --- a/contracts/DualGovernance.sol +++ b/contracts/DualGovernance.sol @@ -61,11 +61,11 @@ contract DualGovernance is IGovernance, ConfigurationProvider { TIMELOCK.cancelAllNonExecutedProposals(); } - function vetoSignallingEscrow() external view returns (address) { + function getVetoSignallingEscrow() external view returns (address) { return address(_dgState.signallingEscrow); } - function rageQuitEscrow() external view returns (address) { + function getRageQuitEscrow() external view returns (address) { return address(_dgState.rageQuitEscrow); } @@ -81,7 +81,7 @@ contract DualGovernance is IGovernance, ConfigurationProvider { _dgState.activateNextState(CONFIG.getDualGovernanceConfig()); } - function currentState() external view returns (State) { + function getCurrentState() external view returns (State) { return _dgState.currentState(); } diff --git a/contracts/Escrow.sol b/contracts/Escrow.sol index 3ab11198..a0d274b1 100644 --- a/contracts/Escrow.sol +++ b/contracts/Escrow.sol @@ -80,7 +80,7 @@ contract Escrow is IEscrow { AssetsAccounting.State private _accounting; WithdrawalsBatchesQueue.State private _batchesQueue; - Duration internal _rageQuitExtraTimelock; + Duration internal _rageQuitExtensionDelay; Duration internal _rageQuitWithdrawalsTimelock; Timestamp internal _rageQuitTimelockStartedAt; @@ -111,17 +111,18 @@ contract Escrow is IEscrow { // Lock & Unlock stETH // --- - function lockStETH(uint256 amount) external { - uint256 shares = ST_ETH.getSharesByPooledEth(amount); - _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(shares)); - ST_ETH.transferSharesFrom(msg.sender, address(this), shares); + function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) { + lockedStETHShares = ST_ETH.getSharesByPooledEth(amount); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); + ST_ETH.transferSharesFrom(msg.sender, address(this), lockedStETHShares); _activateNextGovernanceState(); } - function unlockStETH() external { + function unlockStETH() external returns (uint256 unlockedStETHShares) { + _activateNextGovernanceState(); _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); - SharesValue sharesUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); - ST_ETH.transferShares(msg.sender, sharesUnlocked.toUint256()); + unlockedStETHShares = _accounting.accountStETHSharesUnlock(msg.sender).toUint256(); + ST_ETH.transferShares(msg.sender, unlockedStETHShares); _activateNextGovernanceState(); } @@ -129,20 +130,20 @@ contract Escrow is IEscrow { // Lock / Unlock wstETH // --- - function lockWstETH(uint256 amount) external { + function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) { WST_ETH.transferFrom(msg.sender, address(this), amount); - uint256 stETHAmount = WST_ETH.unwrap(amount); - _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(ST_ETH.getSharesByPooledEth(stETHAmount))); + lockedStETHShares = ST_ETH.getSharesByPooledEth(WST_ETH.unwrap(amount)); + _accounting.accountStETHSharesLock(msg.sender, SharesValues.from(lockedStETHShares)); _activateNextGovernanceState(); } - function unlockWstETH() external returns (uint256) { + function unlockWstETH() external returns (uint256 unlockedStETHShares) { + _activateNextGovernanceState(); _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); SharesValue wstETHUnlocked = _accounting.accountStETHSharesUnlock(msg.sender); - uint256 wstETHAmount = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); - WST_ETH.transfer(msg.sender, wstETHAmount); + unlockedStETHShares = WST_ETH.wrap(ST_ETH.getPooledEthByShares(wstETHUnlocked.toUint256())); + WST_ETH.transfer(msg.sender, unlockedStETHShares); _activateNextGovernanceState(); - return wstETHAmount; } // --- @@ -156,15 +157,18 @@ contract Escrow is IEscrow { for (uint256 i = 0; i < unstETHIdsCount; ++i) { WITHDRAWAL_QUEUE.transferFrom(msg.sender, address(this), unstETHIds[i]); } + _activateNextGovernanceState(); } function unlockUnstETH(uint256[] memory unstETHIds) external { + _activateNextGovernanceState(); _accounting.checkAssetsUnlockDelayPassed(msg.sender, CONFIG.SIGNALLING_ESCROW_MIN_LOCK_TIME()); _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]); } + _activateNextGovernanceState(); } function markUnstETHFinalized(uint256[] memory unstETHIds, uint256[] calldata hints) external { @@ -194,13 +198,13 @@ contract Escrow is IEscrow { // State Updates // --- - function startRageQuit(Duration rageQuitExtraTimelock, Duration rageQuitWithdrawalsTimelock) external { + function startRageQuit(Duration rageQuitExtensionDelay, Duration rageQuitWithdrawalsTimelock) external { _checkDualGovernance(msg.sender); _checkEscrowState(EscrowState.SignallingEscrow); _batchesQueue.open(); _escrowState = EscrowState.RageQuitEscrow; - _rageQuitExtraTimelock = rageQuitExtraTimelock; + _rageQuitExtensionDelay = rageQuitExtensionDelay; _rageQuitWithdrawalsTimelock = rageQuitWithdrawalsTimelock; } @@ -226,7 +230,7 @@ contract Escrow is IEscrow { _batchesQueue.add(WITHDRAWAL_QUEUE.requestWithdrawals(requestAmounts, address(this))); } - function claimWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { + function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) external { _checkEscrowState(EscrowState.RageQuitEscrow); if (!_rageQuitTimelockStartedAt.isZero()) { revert ClaimingIsFinished(); @@ -234,12 +238,12 @@ contract Escrow is IEscrow { uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(maxUnstETHIdsCount); - _claimWithdrawalsBatch( + _claimNextWithdrawalsBatch( unstETHIds, WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIds, 1, WITHDRAWAL_QUEUE.getLastCheckpointIndex()) ); } - function claimWithdrawalsBatch(uint256 fromUnstETHIds, uint256[] calldata hints) external { + function claimNextWithdrawalsBatch(uint256 fromUnstETHId, uint256[] calldata hints) external { _checkEscrowState(EscrowState.RageQuitEscrow); if (!_rageQuitTimelockStartedAt.isZero()) { revert ClaimingIsFinished(); @@ -247,14 +251,14 @@ contract Escrow is IEscrow { uint256[] memory unstETHIds = _batchesQueue.claimNextBatch(hints.length); - if (unstETHIds.length > 0 && fromUnstETHIds != unstETHIds[0]) { + if (unstETHIds.length > 0 && fromUnstETHId != unstETHIds[0]) { revert UnexpectedUnstETHId(); } if (hints.length != unstETHIds.length) { revert InvalidHintsLength(hints.length, unstETHIds.length); } - _claimWithdrawalsBatch(unstETHIds, hints); + _claimNextWithdrawalsBatch(unstETHIds, hints); } function claimUnstETH(uint256[] calldata unstETHIds, uint256[] calldata hints) external { @@ -310,15 +314,15 @@ contract Escrow is IEscrow { state.lastAssetsLockTimestamp = assets.lastAssetsLockTimestamp.toSeconds(); } - function getNextWithdrawalBatches(uint256 limit) external view returns (uint256[] memory unstETHIds) { + function getNextWithdrawalBatch(uint256 limit) external view returns (uint256[] memory unstETHIds) { return _batchesQueue.getNextWithdrawalsBatches(limit); } - function getIsWithdrawalsBatchesFinalized() external view returns (bool) { + function isWithdrawalsBatchesFinalized() external view returns (bool) { return _batchesQueue.isClosed(); } - function getIsWithdrawalsClaimed() external view returns (bool) { + function isWithdrawalsClaimed() external view returns (bool) { return !_rageQuitTimelockStartedAt.isZero(); } @@ -343,12 +347,12 @@ contract Escrow is IEscrow { return ( _escrowState == EscrowState.RageQuitEscrow && _batchesQueue.isClosed() && !_rageQuitTimelockStartedAt.isZero() - && Timestamps.now() > _rageQuitExtraTimelock.addTo(_rageQuitTimelockStartedAt) + && Timestamps.now() > _rageQuitExtensionDelay.addTo(_rageQuitTimelockStartedAt) ); } // --- - // RECEIVE + // Receive ETH // --- receive() external payable { @@ -361,12 +365,14 @@ contract Escrow is IEscrow { // Internal Methods // --- - function _claimWithdrawalsBatch(uint256[] memory unstETHIds, uint256[] memory hints) internal { + function _claimNextWithdrawalsBatch(uint256[] memory unstETHIds, uint256[] memory hints) internal { uint256 ethBalanceBefore = address(this).balance; WITHDRAWAL_QUEUE.claimWithdrawals(unstETHIds, hints); uint256 ethAmountClaimed = address(this).balance - ethBalanceBefore; - _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); + if (ethAmountClaimed > 0) { + _accounting.accountClaimedStETH(ETHValues.from(ethAmountClaimed)); + } if (_batchesQueue.isClosed() && _batchesQueue.isAllUnstETHClaimed()) { _rageQuitTimelockStartedAt = Timestamps.now(); @@ -393,7 +399,7 @@ contract Escrow is IEscrow { if (_rageQuitTimelockStartedAt.isZero()) { revert RageQuitExtraTimelockNotStarted(); } - Duration withdrawalsTimelock = _rageQuitExtraTimelock + _rageQuitWithdrawalsTimelock; + Duration withdrawalsTimelock = _rageQuitExtensionDelay + _rageQuitWithdrawalsTimelock; if (Timestamps.now() <= withdrawalsTimelock.addTo(_rageQuitTimelockStartedAt)) { revert WithdrawalsTimelockNotPassed(); } diff --git a/docs/specification.md b/docs/specification.md index 8152a4fd..8e402254 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -64,7 +64,7 @@ The general proposal flow is the following: Each submitted proposal requires a minimum timelock before it can be scheduled for execution. -At any time, including while a proposal's timelock is lasting, stakers can signal their opposition to the DAO by locking their (w)stETH or stETH Withdrawal NFTs (unstETH) into the [signalling escrow contract](#Contract-Escrowsol). If the opposition exceeds some minimum threshold, the [global governance state](#Governance-state) gets changed, blocking any DAO execution and thus effectively extending the timelock of all pending (i.e. submitted but not scheduled for execution) proposals. +At any time, including while a proposal's timelock is lasting, stakers can signal their opposition to the DAO by locking their (w)stETH or stETH withdrawal NFTs (unstETH) into the [signalling escrow contract](#Contract-Escrowsol). If the opposition exceeds some minimum threshold, the [global governance state](#Governance-state) gets changed, blocking any DAO execution and thus effectively extending the timelock of all pending (i.e. submitted but not scheduled for execution) proposals. ![image](https://github.com/lidofinance/dual-governance/assets/1699593/98273df0-f3fd-4149-929d-3315a8e81aa8) @@ -442,17 +442,17 @@ The result of the call. ## Contract: Escrow.sol -The `Escrow` contract serves as an accumulator of users' (w)stETH, withdrawal NFTs, and ETH. It has two internal states and serves a different purpose depending on its state: +The `Escrow` contract serves as an accumulator of users' (w)stETH, withdrawal NFTs (unstETH), and ETH. It has two internal states and serves a different purpose depending on its state: * The initial state is the `SignallingEscrow` state. In this state, the contract serves as an oracle for users' opposition to DAO proposals. It allows users to lock and unlock (unlocking is permitted only for the caller after the `SignallingEscrowMinLockTime` duration has passed since their last funds locking operation) stETH, wstETH, and withdrawal NFTs, potentially changing the global governance state. The `SignallingEscrowMinLockTime` duration, measured in hours, safeguards against manipulating the dual governance state through instant lock/unlock actions within the `Escrow` contract instance. * The final state is the `RageQuitEscrow` state. In this state, the contract serves as an immutable and ungoverned accumulator for the ETH withdrawn as a result of the [rage quit](#Rage-quit) and enforces a timelock on reclaiming this ETH by users. -The `DualGovernance` contract tracks the current signalling escrow contract using the `DualGovernance.signallingEscrow` pointer. Upon the initial deployment of the system, an instance of `Escrow` is deployed in the `SignallingEscrow` state by the `DualGovernance` contract and the `DualGovernance.signallingEscrow` pointer is set to this contract. +The `DualGovernance` contract tracks the current signalling escrow contract using the `DualGovernance.getVetoSignallingEscrow()` pointer. Upon the initial deployment of the system, an instance of `Escrow` is deployed in the `SignallingEscrow` state by the `DualGovernance` contract and the `DualGovernance.getVetoSignallingEscrow()` pointer is set to this contract. Each time the governance enters the global `RageQuit` state, two things happen simultaneously: -1. The `Escrow` instance currently stored in the `DualGovernance.signallingEscrow` pointer changes its state from `SignallingEscrow` to `RageQuitEscrow`. This is the only possible (and thus irreversible) state transition. -2. The `DualGovernance` contract deploys a new instance of `Escrow` in the `SignallingEscrow` state and resets the `DualGovernance.signallingEscrow` pointer to this newly-deployed contract. +1. The `Escrow` instance currently stored in the `DualGovernance.getVetoSignallingEscrow()` pointer changes its state from `SignallingEscrow` to `RageQuitEscrow`. This is the only possible (and thus irreversible) state transition. +2. The `DualGovernance` contract deploys a new instance of `Escrow` in the `SignallingEscrow` state and resets the `DualGovernance.getVetoSignallingEscrow()` pointer to this newly-deployed contract. At any point in time, there can be only one instance of the contract in the `SignallingEscrow` state (so the contract in this state is a singleton) but multiple instances of the contract in the `RageQuitEscrow` state. @@ -469,7 +469,7 @@ The duration of the `RageQuitEthWithdrawalsTimelock` is dynamic and varies based ### Function: Escrow.lockStETH ```solidity! -function lockStETH(uint256 amount) +function lockStETH(uint256 amount) external returns (uint256 lockedStETHShares) ``` Transfers the specified `amount` of stETH from the caller's (i.e., `msg.sender`) account into the `SignallingEscrow` instance of the `Escrow` contract. @@ -477,15 +477,19 @@ Transfers the specified `amount` of stETH from the caller's (i.e., `msg.sender`) The total rage quit support is updated proportionally to the number of shares corresponding to the locked stETH (see the `Escrow.getRageQuitSupport()` function for the details). For the correct rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: ```solidity -uint256 amountInShares = stETH.getSharesByPooledEther(amount); +amountInShares = stETH.getSharesByPooledEther(amount); -_vetoersLockedAssets[msg.sender].stETHShares += amountInShares; -_totalStEthSharesLocked += amountInShares; +_assets[msg.sender].stETHLockedShares += amountInShares; +_stETHTotals.lockedShares += amountInShares; ``` The rage quit support will be dynamically updated to reflect changes in the stETH balance due to protocol rewards or validators slashing. -Finally, calls the `DualGovernance.activateNextState()` function. This action may transit the `Escrow` instance from the `SignallingEscrow` state into the `RageQuitEscrow` state. +Finally, the function calls `DualGovernance.activateNextState()`, which may transition the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. + +#### Returns + +The amount of stETH shares locked by the caller during the current method call. #### Preconditions @@ -496,42 +500,54 @@ Finally, calls the `DualGovernance.activateNextState()` function. This action ma ### Function: Escrow.unlockStETH ```solidity -function unlockStETH() +function unlockStETH() external returns (uint256 unlockedStETHShares) ``` -Allows the caller (i.e. `msg.sender`) to unlock the previously locked stETH in the `SignallingEscrow` instance of the `Escrow` contract. The locked stETH balance may change due to protocol rewards or validators slashing, potentially altering the original locked amount. The total unlocked stETH equals the sum of all previously locked stETH by the caller, accounting for any changes during the locking period. +Allows the caller (i.e., `msg.sender`) to unlock all previously locked stETH and wstETH in the `SignallingEscrow` instance of the `Escrow` contract as stETH. The locked balance may change due to protocol rewards or validator slashing, potentially altering the original locked amount. The total unlocked stETH amount equals the sum of all previously locked stETH and wstETH by the caller, accounting for any changes during the locking period. -For the correct rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: +For accurate rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: ```solidity -_totalStEthSharesLocked -= _vetoersLockedAssets[msg.sender].stETHShares; -_vetoersLockedAssets[msg.sender].stETHShares = 0; +_stETHTotals.lockedShares -= _assets[msg.sender].stETHLockedShares; +_assets[msg.sender].stETHLockedShares = 0; ``` Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. +#### Returns + +The amount of stETH shares unlocked by the caller. + #### Preconditions - The `Escrow` instance MUST be in the `SignallingEscrow` state. - The caller MUST have a non-zero amount of previously locked stETH in the `Escrow` instance using the `Escrow.lockStETH` function. -- At least the duration of the `SignallingEscrowMinLockTime` MUST have passed since the caller last invoked any of the methods `Escrow.lockStETH`, `Escrow.lockWstETH`, or `Escrow.lockUnstETH`. +- The duration of the `SignallingEscrowMinLockTime` MUST have passed since the caller last invoked any of the methods `Escrow.lockStETH`, `Escrow.lockWstETH`, or `Escrow.lockUnstETH`. ### Function: Escrow.lockWstETH ```solidity -function lockWstETH(uint256 amount) +function lockWstETH(uint256 amount) external returns (uint256 lockedStETHShares) ``` -Transfers the specified `amount` of wstETH from the caller's (i.e., `msg.sender`) account into the `SignallingEscrow` instance of the `Escrow` contract. +Transfers the specified `amount` of wstETH from the caller's (i.e., `msg.sender`) account into the `SignallingEscrow` instance of the `Escrow` contract and unwraps it into the stETH. -The total rage quit support is updated proportionally to the `amount` of locked wstETH (see the `Escrow.getRageQuitSupport()` function for the details). For the correct rage quit support calculation, the function updates the number of locked wstETH in the protocol as follows: +The total rage quit support is updated proportionally to the `amount` of locked wstETH (see the `Escrow.getRageQuitSupport()` function for details). For accurate rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: ```solidity -_vetoersLockedAssets[msg.sender].wstETHShares += amount; -_totalStEthSharesLocked += amount; +stETHAmount = WST_ETH.unwrap(amount); +// Use getSharesByPooledEther(), because unwrap() method may transfer 1 wei less amount of stETH +stETHShares = ST_ETH.getSharesByPooledEth(stETHAmount); + +_assets[msg.sender].stETHLockedShares += stETHShares; +_stETHTotals.lockedShares += stETHShares; ``` -Finally, calls the `DualGovernance.activateNextState()` function. This action may transit the `Escrow` instance from the `SignallingEscrow` state into the `RageQuitEscrow` state. +Finally, the function calls the `DualGovernance.activateNextState()`. This action may transition the `Escrow` instance from the `SignallingEscrow` state into the `RageQuitEscrow` state. + +#### Returns + +The amount of stETH shares locked by the caller during the current method call. #### Preconditions @@ -542,20 +558,24 @@ Finally, calls the `DualGovernance.activateNextState()` function. This action ma ### Function: Escrow.unlockWstETH ```solidity -function unlockWstETH() +function unlockWstETH() external returns (uint256 unlockedStETHShares) ``` -Allows the caller (i.e. `msg.sender`) to unlock previously locked wstETH from the `SignallingEscrow` instance of the `Escrow` contract. The total unlocked wstETH equals the sum of all previously locked wstETH by the caller. +Allows the caller (i.e. `msg.sender`) to unlock previously locked wstETH and stETH from the `SignallingEscrow` instance of the `Escrow` contract as wstETH. The locked balance may change due to protocol rewards or validator slashing, potentially altering the original locked amount. The total unlocked wstETH equals the sum of all previously locked wstETH and stETH by the caller. -For the correct rage quit support calculation, the function updates the number of locked wstETH shares in the protocol as follows: +For the correct rage quit support calculation, the function updates the number of locked stETH shares in the protocol as follows: ```solidity -_totalStEthSharesLocked -= _vetoersLockedAssets[msg.sender].wstETHShares; -_vetoersLockedAssets[msg.sender].wstETHShares = 0; +_stETHTotals.lockedShares -= _assets[msg.sender].stETHLockedShares; +_assets[msg.sender].stETHLockedShares = 0; ``` Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. +#### Returns + +The amount of stETH shares unlocked by the caller. + #### Preconditions - The `Escrow` instance MUST be in the `SignallingEscrow` state. @@ -569,15 +589,16 @@ Additionally, the function triggers the `DualGovernance.activateNextState()` fun function lockUnstETH(uint256[] unstETHIds) ``` -Transfers the WIthdrawal NFTs with ids contained in the `unstETHIds` from the caller's (i.e. `msg.sender`) account into the `SignallingEscrow` instance of the `Escrow` contract. +Transfers the withdrawal NFTs with ids contained in the `unstETHIds` from the caller's (i.e. `msg.sender`) account into the `SignallingEscrow` instance of the `Escrow` contract. + -To correctly calculate the rage quit support (see the `Escrow.getRageQuitSupport()` function for the details), updates the number of locked Withdrawal NFT shares in the protocol for each withdrawal NFT in the `unstETHIds`, as follows: +To correctly calculate the rage quit support (see the `Escrow.getRageQuitSupport()` function for the details), updates the number of locked withdrawal NFT shares in the protocol for each withdrawal NFT in the `unstETHIds`, as follows: ```solidity -uint256 amountOfShares = WithdrawalRequest[id].amountOfShares; +uint256 amountOfShares = withdrawalRequests[id].amountOfShares; -_vetoersLockedAssets[msg.sender].withdrawalNFTShares += amountOfShares; -_totalWithdrawlNFTSharesLocked += amountOfShares; +_assets[msg.sender].unstETHLockedShares += amountOfShares; +_unstETHTotals.unfinalizedShares += amountOfShares; ``` Finally, calls the `DualGovernance.activateNextState()` function. This action may transition the `Escrow` instance from the `SignallingEscrow` state into the `RageQuitEscrow` state. @@ -587,7 +608,7 @@ Finally, calls the `DualGovernance.activateNextState()` function. This action ma - The `Escrow` instance MUST be in the `SignallingEscrow` state. - The caller MUST be the owner of all withdrawal NFTs with the given ids. - The caller MUST grant permission to the `SignallingEscrow` instance to transfer tokens with the given ids (`approve()` or `setApprovalForAll()`). -- The passed ids MUST NOT contain the finalized or claimed Withdrawal NFTs. +- The passed ids MUST NOT contain the finalized or claimed withdrawal NFTs. - The passed ids MUST NOT contain duplicates. ### Function: Escrow.unlockUnstETH @@ -596,32 +617,28 @@ Finally, calls the `DualGovernance.activateNextState()` function. This action ma function unlockUnstETH(uint256[] unstETHIds) ``` -Allows the caller (i.e. `msg.sender`) to unlock a set of previously locked Withdrawal NFTs with ids `unstETHIds` from the `SignallingEscrow` instance of the `Escrow` contract. +Allows the caller (i.e. `msg.sender`) to unlock a set of previously locked withdrawal NFTs with ids `unstETHIds` from the `SignallingEscrow` instance of the `Escrow` contract. -To correctly calculate the rage quit support (see the `Escrow.getRageQuitSupport()` function for details), updates the number of locked Withdrawal NFT shares in the protocol for each withdrawal NFT in the `unstETHIds`, as follows: +To correctly calculate the rage quit support (see the `Escrow.getRageQuitSupport()` function for details), updates the number of locked withdrawal NFT shares in the protocol for each withdrawal NFT in the `unstETHIds`, as follows: -- If the Withdrawal NFT was marked as finalized (see the `Escrow.markUnstETHFinalized()` function for details): +- If the withdrawal NFT was marked as finalized (see the `Escrow.markUnstETHFinalized()` function for details): ```solidity -uint256 amountOfShares = WithdrawalRequest[id].amountOfShares; +uint256 amountOfShares = withdrawalRequests[id].amountOfShares; uint256 claimableAmount = _getClaimableEther(id); -_totalWithdrawlNFTSharesLocked -= amountOfShares; -_totalFinalizedWithdrawlNFTSharesLocked -= amountOfShares; -_totalFinalizedWithdrawlNFTAmountLocked -= claimableAmount; - -_vetoersLockedAssets[msg.sender].withdrawalNFTShares -= amountOfShares; -_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTShares -= amountOfShares; -_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTAmount -= claimableAmount; +assets[msg.sender].unstETHLockedShares -= amountOfShares; +unstETHTotals.finalizedETH -= claimableAmount; +unstETHTotals.unfinalizedShares -= amountOfShares; ``` - if the Withdrawal NFT wasn't marked as finalized: ```solidity -uint256 amountOfShares = WithdrawalRequest[id].amountOfShares; +uint256 amountOfShares = withdrawalRequests[id].amountOfShares; -_totalWithdrawlNFTSharesLocked -= amountOfShares; -_vetoersLockedAssets[msg.sender].withdrawalNFTShares -= amountOfShares; +assets[msg.sender].unstETHLockedShares -= amountOfShares; +unstETHTotals.unfinalizedShares -= amountOfShares; ``` Additionally, the function triggers the `DualGovernance.activateNextState()` function at the beginning and end of the execution. @@ -629,7 +646,7 @@ Additionally, the function triggers the `DualGovernance.activateNextState()` fun #### Preconditions - The `Escrow` instance MUST be in the `SignallingEscrow` state. -- Each provided Withdrawal NFT MUST have been previously locked by the caller. +- Each provided withdrawal NFT MUST have been previously locked by the caller. - At least the duration of the `SignallingEscrowMinLockTime` MUST have passed since the caller last invoked any of the methods `Escrow.lockStETH`, `Escrow.lockWstETH`, or `Escrow.lockUnstETH`. ### Function Escrow.markUnstETHFinalized @@ -638,31 +655,28 @@ Additionally, the function triggers the `DualGovernance.activateNextState()` fun function markUnstETHFinalized(uint256[] unstETHIds, uint256[] hints) ``` -Marks the provided Withdrawal NFTs with ids `unstETHIds` as finalized to accurately calculate their rage quit support. +Marks the provided withdrawal NFTs with ids `unstETHIds` as finalized to accurately calculate their rage quit support. -The finalization of the Withdrawal NFT leads to the following events: +The finalization of the withdrawal NFT leads to the following events: -- The value of the Withdrawal NFT is no longer affected by stETH token rebases. -- The total supply of stETH is adjusted based on the value of the finalized Withdrawal NFT. +- The value of the withdrawal NFT is no longer affected by stETH token rebases. +- The total supply of stETH is adjusted based on the value of the finalized withdrawal NFT. -As both of these events affect the rage quit support value, this function updates the number of finalized Withdrawal NFTs for the correct rage quit support accounting. +As both of these events affect the rage quit support value, this function updates the number of finalized withdrawal NFTs for the correct rage quit support accounting. -For each Withdrawal NFT in the `unstETHIds`: +For each withdrawal NFT in the `unstETHIds`: ```solidity uint256 claimableAmount = _getClaimableEther(id); -uint256 amountOfShares = WithdrawalRequest[id].amountOfShares; +uint256 amountOfShares = withdrawalRequests[id].amountOfShares; -_totalFinalizedWithdrawlNFTSharesLocked += amountOfShares; -_totalFinalizedWithdrawlNFTAmountLocked += claimableAmount; - -_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTShares += amountOfShares; -_vetoersLockedAssets[msg.sender].finalizedWithdrawalNFTAmount += claimableAmount; +unstETHTotals.finalizedETH += claimableAmount; +unstETHTotals.unfinalizedShares -= amountOfShares; ``` Withdrawal NFTs belonging to any of the following categories are excluded from the rage quit support update: -- Claimed or unfinalized Withdrawal NFTs +- Claimed or unfinalized withdrawal NFTs - Withdrawal NFTs already marked as finalized - Withdrawal NFTs not locked in the `Escrow` instance @@ -676,35 +690,32 @@ Withdrawal NFTs belonging to any of the following categories are excluded from t function getRageQuitSupport() view returns (uint256) ``` -Calculates and returns the total rage quit support as a percentage of the stETH total supply locked in the instance of the `Escrow` contract. It considers contributions from stETH, wstETH, and non-finalized Withdrawal NFTs while adjusting for the impact of locked finalized Withdrawal NFTs. +Calculates and returns the total rage quit support as a percentage of the stETH total supply locked in the instance of the `Escrow` contract. It considers contributions from stETH, wstETH, and non-finalized withdrawal NFTs while adjusting for the impact of locked finalized withdrawal NFTs. The returned value represents the total rage quit support expressed as a percentage with a precision of 16 decimals. It is computed using the following formula: ```solidity -uint256 rebaseableAmount = stETH.getPooledEthByShares( - _totalStEthSharesLocked + - _totalWstEthSharesLocked + - _totalWithdrawalNFTSharesLocked - - _totalFinalizedWithdrawalNFTSharesLocked -); +uint256 finalizedETH = unstETHTotals.finalizedETH; +uint256 ufinalizedShares = stETHTotals.lockedShares + unstETHTotals.unfinalizedShares; return 10 ** 18 * ( - rebaseableAmount + _totalFinalizedWithdrawalNFTAmountLocked + ST_ETH.getPooledEtherByShares(unfinalizedShares) + finalizedETH ) / ( - stETH.totalSupply() + _totalFinalizedWithdrawalNFTAmountLocked + stETH.totalSupply() + finalizedETH ); ``` ### Function Escrow.startRageQuit ```solidity -function startRageQuit() +function startRageQuit( + Duration rageQuitExtensionDelay, + Duration rageQuitWithdrawalsTimelock +) ``` Transits the `Escrow` instance from the `SignallingEscrow` state to the `RageQuitEscrow` state. Following this transition, locked funds become unwithdrawable and are accessible to users only as plain ETH after the completion of the full `RageQuit` process, including the `RageQuitExtensionDelay` and `RageQuitEthWithdrawalsTimelock` stages. -As the initial step of transitioning to the `RageQuitEscrow` state, all locked wstETH is converted into stETH, and the maximum stETH allowance is granted to the `WithdrawalQueue` contract for the upcoming creation of Withdrawal NFTs. - #### Preconditions - Method MUST be called by the `DualGovernance` contract. @@ -713,32 +724,40 @@ As the initial step of transitioning to the `RageQuitEscrow` state, all locked w ### Function Escrow.requestNextWithdrawalsBatch ```solidity -function requestNextWithdrawalsBatch(uint256 maxWithdrawalRequestsCount) +function requestNextWithdrawalsBatch(uint256 maxBatchSize) ``` -Transfers stETH held in the `RageQuitEscrow` instance into the `WithdrawalQueue`. The function may be invoked multiple times until all stETH is converted into Withdrawal NFTs. For each Withdrawal NFT, the owner is set to `Escrow` contract instance. Each call creates up to `maxWithdrawalRequestsCount` withdrawal requests, where each withdrawal request size equals `WithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT()`, except for potentially the last batch, which may have a smaller size. +Transfers stETH held in the `RageQuitEscrow` instance into the `WithdrawalQueue`. The function may be invoked multiple times until all stETH is converted into withdrawal NFTs. For each withdrawal NFT, the owner is set to `Escrow` contract instance. Each call creates up to `maxBatchSize` withdrawal requests, where each withdrawal request size equals `WithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT()`, except for potentially the last batch, which may have a smaller size. -Upon execution, the function updates the count of withdrawal requests generated by all invocations. When the remaining stETH balance on the contract falls below `WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT()`, the generation of withdrawal batches is concluded, and subsequent function calls will revert. +Upon execution, the function tracks the ids of the withdrawal requests generated by all invocations. When the remaining stETH balance on the contract falls below `WITHDRAWAL_QUEUE.MIN_STETH_WITHDRAWAL_AMOUNT()`, the generation of withdrawal batches is concluded, and subsequent function calls will revert. #### Preconditions - The `Escrow` instance MUST be in the `RageQuitEscrow` state. -- The `maxWithdrawalRequestsCount` MUST be greater than 0 -- The generation of WithdrawalRequest batches MUST not be concluded +- The `maxBatchSize` MUST be greater than or equal to `CONFIG.MIN_WITHDRAWALS_BATCH_SIZE()` and less than or equal to `CONFIG.MAX_WITHDRAWALS_BATCH_SIZE()`. +- The generation of withdrawal request batches MUST not be concluded -### Function Escrow.claimNextWithdrawalsBatch +### Function Escrow.claimNextWithdrawalsBatch(uint256, uint256[]) ```solidity -function claimNextWithdrawalsBatch(uint256[] withdrawalRequestIds, uint256[] hints) +function claimNextWithdrawalsBatch(uint256 fromUnstETHId, uint256[] hints) ``` -Allows users to claim finalized Withdrawal NFTs generated by the `Escrow.requestNextWithdrawalsBatch()` function. -Tracks the total amount of claimed ETH updating the `_totalClaimedEthAmount` variable. Upon claiming the last batch, the `RageQuitExtensionDelay` period commences. +Allows users to claim finalized withdrawal NFTs generated by the `Escrow.requestNextWithdrawalsBatch()` function. +Tracks the total amount of claimed ETH updating the `stETHTotals.claimedETH` variable. Upon claiming the last batch, the `RageQuitExtensionDelay` period commences. #### Preconditions - The `Escrow` instance MUST be in the `RageQuitEscrow` state. -- The `withdrawalRequestIds` array MUST contain only the ids of finalized but unclaimed withdrawal requests generated by the `Escrow.requestNextWithdrawalsBatch()` function. +- The `fromUnstETHId` MUST be equal to the id of the first unclaimed withdrawal NFT locked in the `Escrow`. The ids of the unclaimed withdrawal NFTs can be retrieved via the `getNextWithdrawalBatch()` method. + +### Function Escrow.claimNextWithdrawalsBatch(uint256) + +```solidity +function claimNextWithdrawalsBatch(uint256 maxUnstETHIdsCount) +``` + +This is an overload version of `Escrow.claimNextWithdrawalsBatch(uint256, uint256[])`. It retrieves hints for processing the withdrawal NFTs on-chain. ### Function Escrow.claimUnstETH @@ -746,9 +765,9 @@ Tracks the total amount of claimed ETH updating the `_totalClaimedEthAmount` var function claimUnstETH(uint256[] unstETHIds, uint256[] hints) ``` -Allows users to claim the ETH associated with finalized Withdrawal NFTs with ids `unstETHIds` locked in the `Escrow` contract. Upon calling this function, the claimed ETH is transferred to the `Escrow` contract instance. +Allows users to claim the ETH associated with finalized withdrawal NFTs with ids `unstETHIds` locked in the `Escrow` contract. Upon calling this function, the claimed ETH is transferred to the `Escrow` contract instance. -To safeguard the ETH associated with Withdrawal NFTs, this function should be invoked when the `Escrow` is in the `RageQuitEscrow` state and before the `RageQuitExtensionDelay` period ends. The ETH corresponding to unclaimed Withdrawal NFTs after this period ends would still be controlled by the code potentially afftected by pending and future DAO decisions. +To safeguard the ETH associated with withdrawal NFTs, this function should be invoked when the `Escrow` is in the `RageQuitEscrow` state and before the `RageQuitExtensionDelay` period ends. The ETH corresponding to unclaimed withdrawal NFTs after this period ends would still be controlled by the code potentially afftected by pending and future DAO decisions. #### Preconditions @@ -766,19 +785,19 @@ Returns whether the rage quit process has been finalized. The rage quit process - All withdrawal request batches have been claimed. - The duration of the `RageQuitExtensionDelay` has elapsed. -### Function Escrow.withdrawStEthAsEth +### Function Escrow.withdrawETH ```solidity -function withdrawStEthAsEth() +function withdrawETH() ``` -Allows the caller (i.e. `msg.sender`) to withdraw all stETH they have previouusly locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthWithdrawalsTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding stETH as withdrawn for the caller. +Allows the caller (i.e. `msg.sender`) to withdraw all stETH and wstETH they have previously locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthWithdrawalsTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding stETH and wstETH as withdrawn for the caller. -The amount of ETH sent to the caller is determined by the proportion of the user's stETH shares compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows: +The amount of ETH sent to the caller is determined by the proportion of the user's stETH and wstETH shares compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows: ```solidity -return _totalClaimedEthAmount * _vetoersLockedAssets[msg.sender].stETHShares - / (_totalStEthSharesLocked + _totalWstEthSharesLocked); +return stETHTotals.claimedETH * assets[msg.sender].stETHLockedShares + / stETHTotals.lockedShares; ``` #### Preconditions @@ -786,39 +805,15 @@ return _totalClaimedEthAmount * _vetoersLockedAssets[msg.sender].stETHShares - The `Escrow` instance MUST be in the `RageQuitEscrow` state. - The rage quit process MUST be completed, including the expiration of the `RageQuitExtensionDelay` duration. - The `RageQuitEthWithdrawalsTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. -- The caller MUST have a non-zero amount of stETH to withdraw. -- The caller MUST NOT have previously withdrawn stETH. - -### Function Escrow.withdrawWstEthAsEth - -```solidity -function withdrawWstEthAsEth() external -``` - -Allows the caller (i.e. `msg.sender`) to withdraw all wstETH they have previouusly locked into `Escrow` contract instance (while it was in the `SignallingEscrow` state) as plain ETH, given that the `RageQuit` process is completed and that the `RageQuitEthWithdrawalsTimelock` has elapsed. Upon execution, the function transfers ETH to the caller's account and marks the corresponding wstETH as withdrawn for the caller. - -The amount of ETH sent to the caller is determined by the proportion of the user's wstETH funds compared to the total amount of locked stETH and wstETH shares in the Escrow instance, calculated as follows: - -```solidity -return _totalClaimedEthAmount * - _vetoersLockedAssets[msg.sender].wstETHShares / - (_totalStEthSharesLocked + _totalWstEthSharesLocked); -``` - -#### Preconditions -- The `Escrow` instance MUST be in the `RageQuitEscrow` state. -- The rage quit process MUST be completed, including the expiration of the `RageQuitExtensionDelay` duration. -- The `RageQuitEthWithdrawalsTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. -- The caller MUST have a non-zero amount of wstETH to withdraw. -- The caller MUST NOT have previously withdrawn wstETH. +- The caller MUST have a non-zero amount of stETH shares to withdraw. -### Function Escrow.withdrawUnstETHAsEth +### Function Escrow.withdrawETH() ```solidity -function withdrawUnstETHAsEth(uint256[] unstETHIds) +function withdrawETH(uint256[] unstETHIds) ``` -Allows the caller (i.e. `msg.sender`) to withdraw the claimed ETH from the Withdrawal NFTs with ids `unstETHIds` locked by the caller in the `Escrow` contract while the latter was in the `SignallingEscrow` state. Upon execution, all ETH previously claimed from the NFTs is transferred to the caller's account, and the NFTs are marked as withdrawn. +Allows the caller (i.e. `msg.sender`) to withdraw the claimed ETH from the withdrawal NFTs with ids `unstETHIds` locked by the caller in the `Escrow` contract while the latter was in the `SignallingEscrow` state. Upon execution, all ETH previously claimed from the NFTs is transferred to the caller's account, and the NFTs are marked as withdrawn. #### Preconditions @@ -826,7 +821,7 @@ Allows the caller (i.e. `msg.sender`) to withdraw the claimed ETH from the Withd - The rage quit process MUST be completed, including the expiration of the `RageQuitExtensionDelay` duration. - The `RageQuitEthWithdrawalsTimelock` period MUST be elapsed after the expiration of the `RageQuitExtensionDelay` duration. - The caller MUST be set as the owner of the provided NFTs. -- Each Withdrawal NFT MUST have been claimed using the `Escrow.claimUnstETH()` function. +- Each withdrawal NFT MUST have been claimed using the `Escrow.claimUnstETH()` function. - Withdrawal NFTs must not have been withdrawn previously. diff --git a/test/scenario/escrow.t.sol b/test/scenario/escrow.t.sol index 4bd9a3c2..77ea81e0 100644 --- a/test/scenario/escrow.t.sol +++ b/test/scenario/escrow.t.sol @@ -349,7 +349,7 @@ contract EscrowHappyPath is TestHelpers { assertEq(_WITHDRAWAL_QUEUE.balanceOf(address(escrow)), 20); - while (!escrow.getIsWithdrawalsBatchesFinalized()) { + while (!escrow.isWithdrawalsBatchesFinalized()) { escrow.requestNextWithdrawalsBatch(96); } @@ -359,7 +359,7 @@ contract EscrowHappyPath is TestHelpers { vm.deal(WITHDRAWAL_QUEUE, 1000 * requestAmount); finalizeWQ(); - uint256[] memory unstETHIdsToClaim = escrow.getNextWithdrawalBatches(expectedWithdrawalBatchesCount); + uint256[] memory unstETHIdsToClaim = escrow.getNextWithdrawalBatch(expectedWithdrawalBatchesCount); // assertEq(total, expectedWithdrawalBatchesCount); WithdrawalRequestStatus[] memory statuses = _WITHDRAWAL_QUEUE.getWithdrawalStatus(unstETHIdsToClaim); @@ -372,8 +372,8 @@ contract EscrowHappyPath is TestHelpers { uint256[] memory hints = _WITHDRAWAL_QUEUE.findCheckpointHints(unstETHIdsToClaim, 1, _WITHDRAWAL_QUEUE.getLastCheckpointIndex()); - while (!escrow.getIsWithdrawalsClaimed()) { - escrow.claimWithdrawalsBatch(128); + while (!escrow.isWithdrawalsClaimed()) { + escrow.claimNextWithdrawalsBatch(128); } assertEq(escrow.isRageQuitFinalized(), false); @@ -428,7 +428,7 @@ contract EscrowHappyPath is TestHelpers { escrow.requestNextWithdrawalsBatch(96); - escrow.claimWithdrawalsBatch(0, new uint256[](0)); + escrow.claimNextWithdrawalsBatch(0, new uint256[](0)); assertEq(escrow.isRageQuitFinalized(), false); diff --git a/test/scenario/veto-cooldown-mechanics.t.sol b/test/scenario/veto-cooldown-mechanics.t.sol index 288a2dee..e7ae5a69 100644 --- a/test/scenario/veto-cooldown-mechanics.t.sol +++ b/test/scenario/veto-cooldown-mechanics.t.sol @@ -68,15 +68,15 @@ contract VetoCooldownMechanicsTest is ScenarioTestBlueprint { uint256 requestAmount = _WITHDRAWAL_QUEUE.MAX_STETH_WITHDRAWAL_AMOUNT(); uint256 maxRequestsCount = vetoedStETHAmount / requestAmount + 1; - while (!rageQuitEscrow.getIsWithdrawalsBatchesFinalized()) { + while (!rageQuitEscrow.isWithdrawalsBatchesFinalized()) { rageQuitEscrow.requestNextWithdrawalsBatch(96); } vm.deal(address(_WITHDRAWAL_QUEUE), 2 * vetoedStETHAmount); _finalizeWQ(); - while (!rageQuitEscrow.getIsWithdrawalsClaimed()) { - rageQuitEscrow.claimWithdrawalsBatch(128); + while (!rageQuitEscrow.isWithdrawalsClaimed()) { + rageQuitEscrow.claimNextWithdrawalsBatch(128); } _wait(_config.RAGE_QUIT_EXTENSION_DELAY().plusSeconds(1)); diff --git a/test/utils/scenario-test-blueprint.sol b/test/utils/scenario-test-blueprint.sol index d0de04a2..d1e53f2c 100644 --- a/test/utils/scenario-test-blueprint.sol +++ b/test/utils/scenario-test-blueprint.sol @@ -96,11 +96,11 @@ contract ScenarioTestBlueprint is Test { // Helper Getters // --- function _getVetoSignallingEscrow() internal view returns (Escrow) { - return Escrow(payable(_dualGovernance.vetoSignallingEscrow())); + return Escrow(payable(_dualGovernance.getVetoSignallingEscrow())); } function _getRageQuitEscrow() internal view returns (Escrow) { - address rageQuitEscrow = _dualGovernance.rageQuitEscrow(); + address rageQuitEscrow = _dualGovernance.getRageQuitEscrow(); return Escrow(payable(rageQuitEscrow)); } @@ -421,23 +421,23 @@ contract ScenarioTestBlueprint is Test { } function _assertNormalState() internal { - assertEq(uint256(_dualGovernance.currentState()), uint256(State.Normal)); + assertEq(uint256(_dualGovernance.getCurrentState()), uint256(State.Normal)); } function _assertVetoSignalingState() internal { - assertEq(uint256(_dualGovernance.currentState()), uint256(State.VetoSignalling)); + assertEq(uint256(_dualGovernance.getCurrentState()), uint256(State.VetoSignalling)); } function _assertVetoSignalingDeactivationState() internal { - assertEq(uint256(_dualGovernance.currentState()), uint256(State.VetoSignallingDeactivation)); + assertEq(uint256(_dualGovernance.getCurrentState()), uint256(State.VetoSignallingDeactivation)); } function _assertRageQuitState() internal { - assertEq(uint256(_dualGovernance.currentState()), uint256(State.RageQuit)); + assertEq(uint256(_dualGovernance.getCurrentState()), uint256(State.RageQuit)); } function _assertVetoCooldownState() internal { - assertEq(uint256(_dualGovernance.currentState()), uint256(State.VetoCooldown)); + assertEq(uint256(_dualGovernance.getCurrentState()), uint256(State.VetoCooldown)); } function _assertNoTargetMockCalls() internal { From cca2785b37ab7d1053a1cbefed85624a31f4213b Mon Sep 17 00:00:00 2001 From: Bogdan Kovtun Date: Mon, 8 Jul 2024 03:13:10 +0400 Subject: [PATCH 13/13] Update the NatSpec comments format in the EmergencyProtectedTimelock --- contracts/EmergencyProtectedTimelock.sol | 188 ++++++++++------------- 1 file changed, 80 insertions(+), 108 deletions(-) diff --git a/contracts/EmergencyProtectedTimelock.sol b/contracts/EmergencyProtectedTimelock.sol index b4be7020..e8a84dc3 100644 --- a/contracts/EmergencyProtectedTimelock.sol +++ b/contracts/EmergencyProtectedTimelock.sol @@ -12,13 +12,11 @@ import {EmergencyProtection, EmergencyState} from "./libraries/EmergencyProtecti import {ConfigurationProvider} from "./ConfigurationProvider.sol"; -/** - * @title EmergencyProtectedTimelock - * @dev A timelock contract with emergency protection functionality. - * The contract allows for submitting, scheduling, and executing proposals, - * while providing emergency protection features to prevent unauthorized - * execution during emergency situations. - */ +/// @title EmergencyProtectedTimelock +/// @dev A timelock contract with emergency protection functionality. +/// The contract allows for submitting, scheduling, and executing proposals, +/// while providing emergency protection features to prevent unauthorized +/// execution during emergency situations. contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { using Proposals for Proposals.State; using EmergencyProtection for EmergencyProtection.State; @@ -35,93 +33,83 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { constructor(address config) ConfigurationProvider(config) {} - /** - * @dev Submits a new proposal to execute a series of calls through an executor. - * Only the governance contract can call this function. - * @param executor The address of the executor contract that will execute the calls. - * @param calls An array of `ExecutorCall` structs representing the calls to be executed. - * @return newProposalId The ID of the newly created proposal. - */ + // --- + // Main Timelock Functionality + // --- + + /// @dev Submits a new proposal to execute a series of calls through an executor. + /// Only the governance contract can call this function. + /// @param executor The address of the executor contract that will execute the calls. + /// @param calls An array of `ExecutorCall` structs representing the calls to be executed. + /// @return newProposalId The ID of the newly created proposal. function submit(address executor, ExecutorCall[] calldata calls) external returns (uint256 newProposalId) { _checkGovernance(msg.sender); newProposalId = _proposals.submit(executor, calls); } - /** - * @dev Schedules a proposal for execution after a specified delay. - * Only the governance contract can call this function. - * @param proposalId The ID of the proposal to be scheduled. - */ + /// @dev Schedules a proposal for execution after a specified delay. + /// Only the governance contract can call this function. + /// @param proposalId The ID of the proposal to be scheduled. function schedule(uint256 proposalId) external { _checkGovernance(msg.sender); _proposals.schedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); } - /** - * @dev Executes a scheduled proposal. - * Checks if emergency mode is active and prevents execution if it is. - * @param proposalId The ID of the proposal to be executed. - */ + /// @dev Executes a scheduled proposal. + /// Checks if emergency mode is active and prevents execution if it is. + /// @param proposalId The ID of the proposal to be executed. function execute(uint256 proposalId) external { _emergencyProtection.checkEmergencyModeActive(false); _proposals.execute(proposalId, CONFIG.AFTER_SCHEDULE_DELAY()); } - /** - * @dev Cancels all non-executed proposals. - * Only the governance contract can call this function. - */ + /// @dev Cancels all non-executed proposals. + /// Only the governance contract can call this function. function cancelAllNonExecutedProposals() external { _checkGovernance(msg.sender); _proposals.cancelAll(); } - /** - * @dev Transfers ownership of the executor contract to a new owner. - * Only the admin executor can call this function. - * @param executor The address of the executor contract. - * @param owner The address of the new owner. - */ + /// @dev Transfers ownership of the executor contract to a new owner. + /// Only the admin executor can call this function. + /// @param executor The address of the executor contract. + /// @param owner The address of the new owner. function transferExecutorOwnership(address executor, address owner) external { _checkAdminExecutor(msg.sender); IOwnable(executor).transferOwnership(owner); } - /** - * @dev Sets a new governance contract address. - * Only the admin executor can call this function. - * @param newGovernance The address of the new governance contract. - */ + /// @dev Sets a new governance contract address. + /// Only the admin executor can call this function. + /// @param newGovernance The address of the new governance contract. function setGovernance(address newGovernance) external { _checkAdminExecutor(msg.sender); _setGovernance(newGovernance); } - /** - * @dev Activates the emergency mode. - * Only the activation committee can call this function. - */ + // --- + // Emergency Protection Functionality + // --- + + /// @dev Activates the emergency mode. + /// Only the activation committee can call this function. function activateEmergencyMode() external { _emergencyProtection.checkActivationCommittee(msg.sender); _emergencyProtection.checkEmergencyModeActive(false); _emergencyProtection.activate(); } - /** - * @dev Executes a proposal during emergency mode. - * Checks if emergency mode is active and if the caller is part of the execution committee. - * @param proposalId The ID of the proposal to be executed. - */ + /// @dev Executes a proposal during emergency mode. + /// Checks if emergency mode is active and if the caller is part of the execution committee. + /// @param proposalId The ID of the proposal to be executed. function emergencyExecute(uint256 proposalId) external { _emergencyProtection.checkEmergencyModeActive(true); _emergencyProtection.checkExecutionCommittee(msg.sender); _proposals.execute(proposalId, /* afterScheduleDelay */ Duration.wrap(0)); } - /** - * @dev Deactivates the emergency mode. - * If the emergency mode has not passed, only the admin executor can call this function. - */ + /// @dev Deactivates the emergency mode. + /// If the emergency mode has not passed, only the admin executor can call this function. function deactivateEmergencyMode() external { _emergencyProtection.checkEmergencyModeActive(true); if (!_emergencyProtection.isEmergencyModePassed()) { @@ -131,10 +119,8 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { _proposals.cancelAll(); } - /** - * @dev Resets the system after entering the emergency mode. - * Only the execution committee can call this function. - */ + /// @dev Resets the system after entering the emergency mode. + /// Only the execution committee can call this function. function emergencyReset() external { _emergencyProtection.checkEmergencyModeActive(true); _emergencyProtection.checkExecutionCommittee(msg.sender); @@ -143,14 +129,12 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { _proposals.cancelAll(); } - /** - * @dev Sets the parameters for the emergency protection functionality. - * Only the admin executor can call this function. - * @param activator The address of the activation committee. - * @param enactor The address of the execution committee. - * @param protectionDuration The duration of the protection period. - * @param emergencyModeDuration The duration of the emergency mode. - */ + /// @dev Sets the parameters for the emergency protection functionality. + /// Only the admin executor can call this function. + /// @param activator The address of the activation committee. + /// @param enactor The address of the execution committee. + /// @param protectionDuration The duration of the protection period. + /// @param emergencyModeDuration The duration of the emergency mode. function setEmergencyProtection( address activator, address enactor, @@ -161,79 +145,69 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { _emergencyProtection.setup(activator, enactor, protectionDuration, emergencyModeDuration); } - /** - * @dev Checks if the emergency protection functionality is enabled. - * @return A boolean indicating if the emergency protection is enabled. - */ + /// @dev Checks if the emergency protection functionality is enabled. + /// @return A boolean indicating if the emergency protection is enabled. function isEmergencyProtectionEnabled() external view returns (bool) { return _emergencyProtection.isEmergencyProtectionEnabled(); } - /** - * @dev Retrieves the current emergency state. - * @return res The EmergencyState struct containing the current emergency state. - */ + /// @dev Retrieves the current emergency state. + /// @return res The EmergencyState struct containing the current emergency state. function getEmergencyState() external view returns (EmergencyState memory res) { res = _emergencyProtection.getEmergencyState(); } - /** - * @dev Retrieves the address of the current governance contract. - * @return The address of the current governance contract. - */ + // --- + // Timelock View Methods + // --- + + /// @dev Retrieves the address of the current governance contract. + /// @return The address of the current governance contract. function getGovernance() external view returns (address) { return _governance; } - /** - * @dev Retrieves the details of a proposal. - * @param proposalId The ID of the proposal. - * @return proposal The Proposal struct containing the details of the proposal. - */ + /// @dev Retrieves the details of a proposal. + /// @param proposalId The ID of the proposal. + /// @return proposal The Proposal struct containing the details of the proposal. function getProposal(uint256 proposalId) external view returns (Proposal memory proposal) { proposal = _proposals.get(proposalId); } - /** - * @dev Retrieves the total number of proposals. - * @return count The total number of proposals. - */ + /// @dev Retrieves the total number of proposals. + /// @return count The total number of proposals. function getProposalsCount() external view returns (uint256 count) { count = _proposals.count(); } - /** - * @dev Retrieves the submission time of a proposal. - * @param proposalId The ID of the proposal. - * @return submittedAt The submission time of the proposal. - */ + /// @dev Retrieves the submission time of a proposal. + /// @param proposalId The ID of the proposal. + /// @return submittedAt The submission time of the proposal. function getProposalSubmissionTime(uint256 proposalId) external view returns (Timestamp submittedAt) { submittedAt = _proposals.getProposalSubmissionTime(proposalId); } - /** - * @dev Checks if a proposal can be executed. - * @param proposalId The ID of the proposal. - * @return A boolean indicating if the proposal can be executed. - */ + /// @dev Checks if a proposal can be executed. + /// @param proposalId The ID of the proposal. + /// @return A boolean indicating if the proposal can be executed. function canExecute(uint256 proposalId) external view returns (bool) { return !_emergencyProtection.isEmergencyModeActivated() && _proposals.canExecute(proposalId, CONFIG.AFTER_SCHEDULE_DELAY()); } - /** - * @dev Checks if a proposal can be scheduled. - * @param proposalId The ID of the proposal. - * @return A boolean indicating if the proposal can be scheduled. - */ + /// @dev Checks if a proposal can be scheduled. + /// @param proposalId The ID of the proposal. + /// @return A boolean indicating if the proposal can be scheduled. function canSchedule(uint256 proposalId) external view returns (bool) { return _proposals.canSchedule(proposalId, CONFIG.AFTER_SUBMIT_DELAY()); } - /** - * @dev Internal function to set the governance contract address. - * @param newGovernance The address of the new governance contract. - */ + // --- + // Internal Methods + // --- + + /// @dev Internal function to set the governance contract address. + /// @param newGovernance The address of the new governance contract. function _setGovernance(address newGovernance) internal { address prevGovernance = _governance; if (newGovernance == prevGovernance || newGovernance == address(0)) { @@ -243,10 +217,8 @@ contract EmergencyProtectedTimelock is ITimelock, ConfigurationProvider { emit GovernanceSet(newGovernance); } - /** - * @dev Internal function to check if the caller is the governance contract. - * @param account The address to check. - */ + /// @dev Internal function to check if the caller is the governance contract. + /// @param account The address to check. function _checkGovernance(address account) internal view { if (_governance != account) { revert NotGovernance(account, _governance);