diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..21c8da08f --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +PRIVATE_KEY=0x....asd \ No newline at end of file diff --git a/contracts/0.4.24/nos/NodeOperatorsRegistry.sol b/contracts/0.4.24/nos/NodeOperatorsRegistry.sol index 21765d131..d491e849e 100644 --- a/contracts/0.4.24/nos/NodeOperatorsRegistry.sol +++ b/contracts/0.4.24/nos/NodeOperatorsRegistry.sol @@ -1138,7 +1138,7 @@ contract NodeOperatorsRegistry is AragonApp, Versioned { /// @return depositSignature Signature needed for a deposit_contract.deposit call /// @return used Flag indication if the key was used in the staking function getSigningKey(uint256 _nodeOperatorId, uint256 _index) - external + public view returns (bytes key, bytes depositSignature, bool used) { @@ -1449,4 +1449,9 @@ contract NodeOperatorsRegistry is AragonApp, Versioned { function _onlyNonZeroAddress(address _a) internal pure { require(_a != address(0), "ZERO_ADDRESS"); } + + function isKeyAvailableToExit(uint256 _nodeOperatorId, uint256 _index, bytes _pubkey) external view returns (bool) { + (bytes memory key, /** depositSignature */, bool used) = getSigningKey(_nodeOperatorId, _index); + return (keccak256(_pubkey) == keccak256(key) && used); + } } diff --git a/contracts/0.8.9/TriggerableExitMock.sol b/contracts/0.8.9/TriggerableExitMock.sol new file mode 100644 index 000000000..d7d9debd4 --- /dev/null +++ b/contracts/0.8.9/TriggerableExitMock.sol @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2024 Lido +// SPDX-License-Identifier: GPL-3.0 +// for testing purposes only + +/* See contracts/COMPILERS.md */ +pragma solidity 0.8.9; + +contract TriggerableExitMock { + address constant WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS = 0x00A3ca265EBcb825B45F985A16CEFB49958cE017; + + uint256 private constant EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT = 0; + uint256 private constant WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT = 1; + uint256 private constant WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT = 2; + uint256 private constant WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT = 3; + uint256 private constant WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET = 4; + + uint256 private constant MAX_WITHDRAWAL_REQUESTS_PER_BLOCK = 16; + uint256 private constant TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK = 2; + uint256 private constant MIN_WITHDRAWAL_REQUEST_FEE = 1; + uint256 private constant WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION = 17; + + struct ValidatorWithdrawalRequest { + address sourceAddress; + bytes validatorPubkey; + uint64 amount; + } + + event WithdrawalRequest(bytes indexed validatorPubkey, uint256 amount); + event WithdrawalRequestProcessed( + address sender, + bytes indexed validatorPubkey, + uint256 amount + ); + + uint256 public lastProcessedBlock; + + // @notice Add withdrawal request adds new request to the withdrawal request queue, so long as a sufficient fee is provided. + function addWithdrawalRequest(bytes memory validatorPubkey, uint256 amount) external payable { + checkExitFee(msg.value); + incrementExitCount(); + insertExitToQueue(validatorPubkey, uint64(amount)); + + emit WithdrawalRequest(validatorPubkey, amount); + } + + function insertExitToQueue(bytes memory validatorPubkey, uint64 amount) private { + require(validatorPubkey.length == 48, "Validator public key must contain 48 bytes"); + + bytes32 queueTailSlot = getSlotReference(WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT); + + uint256 queueTailIndex; + assembly { + queueTailIndex := sload(queueTailSlot) + } + + bytes32 queueStorageSlot = getSlotReference(WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + queueTailIndex * 3); + + assembly { + let offset := add(validatorPubkey, 0x20) + + // save to storage in next format + // + // A: sender + // slot1: aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa 00 00 00 00 00 00 00 00 00 00 00 00 + // + // B: pubkey[0:31] + // slot2: bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb bb + // + // C: pubkey[32:48] ++ amount[0:8] + // slot3: cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc cc dd dd dd dd dd dd dd dd 00 00 00 00 00 00 00 00 + + sstore(queueStorageSlot, caller()) + sstore(add(queueStorageSlot, 1), mload(offset)) //save 0..31 bytes + sstore(add(queueStorageSlot, 2), add(mload(add(offset, 0x20)), shl(64, amount))) //32..47 pk + 8bytes amount + sstore(queueTailSlot, add(queueTailIndex, 1)) //increase queue tail + } + } + + function getSlotReference(uint256 index) private pure returns (bytes32) { + bytes32 slotAddress = bytes32(uint256(uint160(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS))); + bytes32 slotIndex = bytes32(index); + + return keccak256(abi.encodePacked(slotAddress, slotIndex)); + } + + function checkExitFee(uint256 feeSent) internal view { + uint256 exitFee = getFee(); + require(feeSent >= exitFee, "Insufficient exit fee"); + } + + function getFee() public view returns (uint256) { + bytes32 position = getSlotReference(EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT); + + uint256 excessExits; + assembly { + excessExits := sload(position) + } + return fakeExponential( + MIN_WITHDRAWAL_REQUEST_FEE, + excessExits, + WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION); + } + + function fakeExponential(uint256 factor, uint256 numerator, uint256 denominator) private pure returns (uint256) { + uint256 i = 1; + uint256 output = 0; + + uint256 numeratorAccum = factor * denominator; + + while (numeratorAccum > 0) { + output += numeratorAccum; + numeratorAccum = (numeratorAccum * numerator) / (denominator * i); + i += 1; + } + + return output / denominator; + } + + function incrementExitCount() private { + bytes32 position = getSlotReference(WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT); + assembly { + sstore(position, add(sload(position), 1)) + } + } + + // ------------------------------ + // block processing + // ------------------------------ + error BlockAlreadyProcessed(); + + // only once in a block + function blockProcessing() public returns(ValidatorWithdrawalRequest[] memory) { + if (block.number == lastProcessedBlock) { + revert BlockAlreadyProcessed(); + } + + lastProcessedBlock = block.number; + + ValidatorWithdrawalRequest[] memory reqs = dequeueWithdrawalRequests(); + updateExcessWithdrawalRequests(); + resetWithdrawalRequestsCount(); + + return reqs; + } + + function dequeueWithdrawalRequests() internal returns(ValidatorWithdrawalRequest[] memory) { + bytes32 queueHeadIndexPosition = getSlotReference(WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT); + bytes32 queueTailIndexPosition = getSlotReference(WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT); + + uint256 queueHeadIndex; + uint256 queueTailIndex; + assembly { + queueHeadIndex := sload(queueHeadIndexPosition) + queueTailIndex := sload(queueTailIndexPosition) + } + + uint256 numInQueue = queueTailIndex - queueHeadIndex; + uint256 numDequeued = min(numInQueue, MAX_WITHDRAWAL_REQUESTS_PER_BLOCK); + + ValidatorWithdrawalRequest[] memory result = new ValidatorWithdrawalRequest[](numDequeued); + bytes32 queueStorageSlot; + address sourceAddress; + + bytes memory tmpKey = new bytes(48); + uint64 amount; + + for (uint256 i=0; i < numDequeued; i++) { + queueStorageSlot = getSlotReference(WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET + (queueHeadIndex + i) * 3); + + assembly { + // Withdrawal request record: + // + // +------+--------+--------+ + // | addr | pubkey | amount | + // +------+--------+--------+ + // 20 48 8 + + sourceAddress := sload(queueStorageSlot) + let p1 := sload(add(queueStorageSlot, 1)) //first part of pubkey + let p2 := sload(add(queueStorageSlot, 2)) //second part of pubkey + 8bytes amount + + mstore(add(tmpKey, 0x20), p1) + mstore(add(tmpKey, 0x40), p2) + + amount := and(shr(64, p2), 0xffffffffffffffff) + } + + result[i] = ValidatorWithdrawalRequest(sourceAddress, tmpKey, amount); + emit WithdrawalRequestProcessed(sourceAddress, tmpKey, amount); + } + + uint256 newQueueHeadIndex = queueHeadIndex + numDequeued; + if (newQueueHeadIndex == queueTailIndex) { + // Queue is empty, reset queue pointers + assembly { + sstore(queueHeadIndexPosition, 0) + sstore(queueTailIndexPosition, 0) + } + } else { + assembly { + sstore(queueHeadIndexPosition, newQueueHeadIndex) + } + } + + return result; + } + + function updateExcessWithdrawalRequests() internal { + bytes32 positionExceessExits = getSlotReference(EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT); + bytes32 positionExitsCount = getSlotReference(WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT); + + uint256 previousExcessExits; + uint256 exitCount; + assembly { + previousExcessExits := sload(positionExceessExits) + exitCount := sload(positionExitsCount) + } + + uint256 newExcessExits = 0; + if (previousExcessExits + exitCount > TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK) { + newExcessExits = previousExcessExits + exitCount - TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK; + assembly { + sstore(positionExceessExits, newExcessExits) + } + } + } + + function resetWithdrawalRequestsCount() internal { + bytes32 position = getSlotReference(WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT); + assembly { + sstore(position, 0) + } + } + + + // ------------------------------ + // Helpers + // ------------------------------ + function getQueueCount() external view returns(uint256 c) { + bytes32 position = getSlotReference(WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT); + assembly { + c := sload(position) + } + } + + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } +} diff --git a/contracts/0.8.9/WithdrawalVault.sol b/contracts/0.8.9/WithdrawalVault.sol index c5485b785..3245d3116 100644 --- a/contracts/0.8.9/WithdrawalVault.sol +++ b/contracts/0.8.9/WithdrawalVault.sol @@ -8,6 +8,7 @@ import "@openzeppelin/contracts-v4.4/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts-v4.4/token/ERC721/IERC721.sol"; import "@openzeppelin/contracts-v4.4/token/ERC20/utils/SafeERC20.sol"; +import {ILidoLocator} from "../common/interfaces/ILidoLocator.sol"; import {Versioned} from "./utils/Versioned.sol"; interface ILido { @@ -19,6 +20,12 @@ interface ILido { function receiveWithdrawals() external payable; } +interface ITriggerableExit { + function addWithdrawalRequest(bytes memory validatorPubkey, uint256 amount) external payable; + function getFee() external view returns (uint256); +} + + /** * @title A vault for temporary storage of withdrawals */ @@ -27,6 +34,8 @@ contract WithdrawalVault is Versioned { ILido public immutable LIDO; address public immutable TREASURY; + address public immutable VALIDATORS_EXIT_BUS; + ITriggerableExit public immutable TRIGGERABLE_EXIT; // Events /** @@ -41,35 +50,51 @@ contract WithdrawalVault is Versioned { */ event ERC721Recovered(address indexed requestedBy, address indexed token, uint256 tokenId); + event LidoContractSet(address lido); + event TreasuryContractSet(address treasury); + event ValidatorsExitBusContractSet(address validatorsExitBusOracle); + event TriggerableExitContractSet(address triggerableExit); + // Errors - error LidoZeroAddress(); - error TreasuryZeroAddress(); error NotLido(); + error SenderIsNotVEBOContract(); error NotEnoughEther(uint256 requested, uint256 balance); error ZeroAmount(); + error ZeroAddress(); + error ExitFeeNotEnought(); + error UnexpectedItemsCount(uint256 keysCount, uint256 amountsCount); /** * @param _lido the Lido token (stETH) address * @param _treasury the Lido treasury address (see ERC20/ERC721-recovery interfaces) + * @param _validatorsExitBus the ValidatorsExitBus contract + * @param _triggerableExit the address of the TriggerableExit contracts from EIP-7002 */ - constructor(ILido _lido, address _treasury) { - if (address(_lido) == address(0)) { - revert LidoZeroAddress(); - } - if (_treasury == address(0)) { - revert TreasuryZeroAddress(); - } + constructor(address _lido, address _treasury, address _validatorsExitBus, address _triggerableExit) { + _assertNonZero(_lido); + _assertNonZero(_treasury); + _assertNonZero(_validatorsExitBus); + _assertNonZero(_triggerableExit); - LIDO = _lido; + LIDO = ILido(_lido); TREASURY = _treasury; + VALIDATORS_EXIT_BUS = _validatorsExitBus; + TRIGGERABLE_EXIT = ITriggerableExit(_triggerableExit); + + emit LidoContractSet(_lido); + emit TreasuryContractSet(_treasury); + emit ValidatorsExitBusContractSet(_validatorsExitBus); + emit TriggerableExitContractSet(_triggerableExit); } - /** - * @notice Initialize the contract explicitly. - * Sets the contract version to '1'. - */ function initialize() external { _initializeContractVersionTo(1); + _updateContractVersion(2); + } + + function finalizeUpgrade_v2() external { + _checkContractVersion(1); + _updateContractVersion(2); } /** @@ -122,4 +147,43 @@ contract WithdrawalVault is Versioned { _token.transferFrom(address(this), TREASURY, _tokenId); } + + /** + * The exit request consists of two parts - the keys and the amount requested for the exit, + * i.e partial withdrawals. + * + * @notice The fee will be the same for all keys, because it is updated using a system call + * at the very end of block processing. + * + * @param _pubkeys the keys requested to exit + * @param _amounts the amounts requested to exit for each key + */ + function triggerELValidatorExit(bytes[] calldata _pubkeys, uint256[] calldata _amounts) external payable { + if (msg.sender != VALIDATORS_EXIT_BUS) { + revert SenderIsNotVEBOContract(); + } + + uint256 keysCount = _pubkeys.length; + uint256 amountsCount = _amounts.length; + if (keysCount != amountsCount) { + revert UnexpectedItemsCount(keysCount, amountsCount); + } + + if (TRIGGERABLE_EXIT.getFee() * keysCount > msg.value) { + revert ExitFeeNotEnought(); + } + + uint256 prevVaultBalance = address(this).balance - msg.value; + uint256 fee = msg.value / keysCount; + + for(uint256 i = 0; i < keysCount; ++i) { + TRIGGERABLE_EXIT.addWithdrawalRequest{value: fee}(_pubkeys[i], _amounts[i]); + } + + assert(address(this).balance == prevVaultBalance); + } + + function _assertNonZero(address _address) internal pure { + if (_address == address(0)) revert ZeroAddress(); + } } diff --git a/contracts/0.8.9/exits/Prover.sol b/contracts/0.8.9/exits/Prover.sol new file mode 100644 index 000000000..1fd4450d8 --- /dev/null +++ b/contracts/0.8.9/exits/Prover.sol @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2024 Lido +// SPDX-License-Identifier: GPL-3.0 +// for testing purposes only + +/* See contracts/COMPILERS.md */ +pragma solidity 0.8.9; + +import {AccessControlEnumerable} from "../utils/access/AccessControlEnumerable.sol"; +import {IStakingModule} from "../interfaces/IStakingModule.sol"; +import {StakingRouter} from "../StakingRouter.sol"; +import "../../common/interfaces/ILidoLocator.sol"; + +interface IStakingRouter { + function getStakingModule(uint256 _stakingModuleId) external view returns (StakingRouter.StakingModule memory); +} + +interface IValidatorsExitBusOracle { + function submitPriorityReportData(bytes32 reportHash, uint256 requestsCount) external; +} + +contract Prover is AccessControlEnumerable { + + ILidoLocator internal immutable LOCATOR; + IValidatorsExitBusOracle internal immutable ORACLE; + uint256 public immutable STAKING_MODULE_ID; + + constructor(address _lidoLocator, address _oracle, uint256 _stakingModuleId) { + LOCATOR = ILidoLocator(_lidoLocator); + ORACLE = IValidatorsExitBusOracle(_oracle); + STAKING_MODULE_ID = _stakingModuleId; + + //for test + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + error ErrorArraysLengthMismatch(uint256 _firstArrayLength, uint256 _secondArrayLength); + error ErrorKeyIsNotAvailiableToExit(); + + function reportKeysToExit( + uint256 _nodeOperatorId, + uint256[] calldata _indexes, + bytes[] calldata _pubkeys, + bytes32 reportHash + // bytes calldata data +) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_indexes.length != _pubkeys.length) { + revert ErrorArraysLengthMismatch(_indexes.length, _pubkeys.length); + } + IStakingRouter router = IStakingRouter(LOCATOR.stakingRouter()); + address moduleAddress = router.getStakingModule(STAKING_MODULE_ID).stakingModuleAddress; + + for (uint256 i = 0; i < _pubkeys.length; ++i) { + if (!IStakingModule(moduleAddress).isKeyAvailableToExit(_nodeOperatorId, _indexes[i], _pubkeys[i])) { + revert ErrorKeyIsNotAvailiableToExit(); + } + } + + //forced target limit > vetted + + ORACLE.submitPriorityReportData( + reportHash, _pubkeys.length + ); + } +} + diff --git a/contracts/0.8.9/interfaces/IStakingModule.sol b/contracts/0.8.9/interfaces/IStakingModule.sol index 416f89da4..3146ddf1e 100644 --- a/contracts/0.8.9/interfaces/IStakingModule.sol +++ b/contracts/0.8.9/interfaces/IStakingModule.sol @@ -161,6 +161,9 @@ interface IStakingModule { /// Details about error data: https://docs.soliditylang.org/en/v0.8.9/control-structures.html#error-handling-assert-require-revert-and-exceptions function onWithdrawalCredentialsChanged() external; + /// @notice checks is the key available for exit + function isKeyAvailableToExit(uint256 _nodeOperatorId, uint256 _index, bytes calldata _pubkey) external view returns (bool); + /// @dev Event to be emitted on StakingModule's nonce change event NonceChanged(uint256 nonce); } diff --git a/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol b/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol index 1937aff61..2b98bdfd6 100644 --- a/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol +++ b/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol @@ -11,10 +11,12 @@ import { UnstructuredStorage } from "../lib/UnstructuredStorage.sol"; import { BaseOracle } from "./BaseOracle.sol"; - interface IOracleReportSanityChecker { function checkExitBusOracleReport(uint256 _exitRequestsCount) external view; } +interface IWithdrawalVault { + function triggerELValidatorExit(bytes[] calldata pubkey, uint256[] calldata amounts) external payable; +} contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { @@ -35,6 +37,10 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { uint256 prevRequestedValidatorIndex, uint256 requestedValidatorIndex ); + error ErrorInvalidReport(); + error ErrorInvalidPubkeyInReport(); + error ErrorReportExists(); + error ErrorInvalidKeysRequestsCount(); event ValidatorExitRequest( uint256 indexed stakingModuleId, @@ -65,6 +71,9 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { /// @notice An ACL role granting the permission to submit the data for a committee report. bytes32 public constant SUBMIT_DATA_ROLE = keccak256("SUBMIT_DATA_ROLE"); + /// @notice An ACL role granting the permission to submit the prioritized exit requests data. + bytes32 public constant SUBMIT_PRIORITY_DATA_ROLE = keccak256("SUBMIT_PRIORITY_DATA_ROLE"); + /// @notice An ACL role granting the permission to pause accepting validator exit requests bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); @@ -84,6 +93,10 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { bytes32 internal constant DATA_PROCESSING_STATE_POSITION = keccak256("lido.ValidatorsExitBusOracle.dataProcessingState"); + /// @dev Storage slot: ReportData reports + bytes32 internal constant REPORTS_HASH_POSITION = + keccak256("lido.ValidatorsExitBusOracle.reports"); + ILidoLocator internal immutable LOCATOR; /// @@ -94,6 +107,7 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { BaseOracle(secondsPerSlot, genesisTime) { LOCATOR = ILidoLocator(lidoLocator); + _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); } function initialize( @@ -218,6 +232,7 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { _checkContractVersion(contractVersion); // it's a waste of gas to copy the whole calldata into mem but seems there's no way around _checkConsensusData(data.refSlot, data.consensusVersion, keccak256(abi.encode(data))); + _saveReportDataHash(keccak256(abi.encode(data)), data.requestsCount); _startProcessing(); _handleConsensusReportData(data); } @@ -343,7 +358,7 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { revert UnexpectedRequestsDataLength(); } - _processExitRequestsList(data.data); + _processExitRequestsList(data.data, ""); _storageDataProcessingState().value = DataProcessingState({ refSlot: data.refSlot.toUint64(), @@ -361,7 +376,7 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { ); } - function _processExitRequestsList(bytes calldata data) internal { + function _processExitRequestsList(bytes calldata data, bytes32 kCheckedKey) internal { uint256 offset; uint256 offsetPastEnd; assembly { @@ -406,30 +421,42 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { revert InvalidRequestsData(); } - uint256 nodeOpKey = _computeNodeOpKey(moduleId, nodeOpId); - if (nodeOpKey != lastNodeOpKey) { - if (lastNodeOpKey != 0) { - _storageLastRequestedValidatorIndices()[lastNodeOpKey] = lastRequestedVal; + if (kCheckedKey == "") { + uint256 nodeOpKey = _computeNodeOpKey(moduleId, nodeOpId); + if (nodeOpKey != lastNodeOpKey) { + if (lastNodeOpKey != 0) { + _storageLastRequestedValidatorIndices()[lastNodeOpKey] = lastRequestedVal; + } + lastRequestedVal = _storageLastRequestedValidatorIndices()[nodeOpKey]; + lastNodeOpKey = nodeOpKey; } - lastRequestedVal = _storageLastRequestedValidatorIndices()[nodeOpKey]; - lastNodeOpKey = nodeOpKey; - } - if (lastRequestedVal.requested && valIndex <= lastRequestedVal.index) { - revert NodeOpValidatorIndexMustIncrease( - moduleId, - nodeOpId, - lastRequestedVal.index, - valIndex - ); - } + if (lastRequestedVal.requested && valIndex <= lastRequestedVal.index) { + revert NodeOpValidatorIndexMustIncrease( + moduleId, + nodeOpId, + lastRequestedVal.index, + valIndex + ); + } - lastRequestedVal = RequestedValidator(true, valIndex); - lastDataWithoutPubkey = dataWithoutPubkey; + lastRequestedVal = RequestedValidator(true, valIndex); + lastDataWithoutPubkey = dataWithoutPubkey; + + } else { + if (keccak256(pubkey) == kCheckedKey) { + emit ValidatorExitRequest(moduleId, nodeOpId, valIndex, pubkey, timestamp); + return; + } + } emit ValidatorExitRequest(moduleId, nodeOpId, valIndex, pubkey, timestamp); } + if (kCheckedKey != "") { + revert ErrorInvalidPubkeyInReport(); + } + if (lastNodeOpKey != 0) { _storageLastRequestedValidatorIndices()[lastNodeOpKey] = lastRequestedVal; } @@ -460,4 +487,40 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { bytes32 position = DATA_PROCESSING_STATE_POSITION; assembly { r.slot := position } } + + function _saveReportDataHash(bytes32 reportHash, uint256 requestsCount) internal { + if (_getReportHashesStorage()[reportHash] != 0) { + revert ErrorReportExists(); + } + _getReportHashesStorage()[reportHash] = requestsCount; + } + + function _getReportHashesStorage() internal pure returns ( + mapping(bytes32 => uint256) storage r + ) { + bytes32 position = REPORTS_HASH_POSITION; + assembly { r.slot := position } + } + + function submitPriorityReportData(bytes32 reportHash, uint256 requestsCount) external onlyRole(SUBMIT_PRIORITY_DATA_ROLE){ + _saveReportDataHash(reportHash, requestsCount); + } + + function forcedExitPubkeys(bytes[] calldata keys, uint256[] calldata amounts, ReportData calldata data) external payable { + uint256 requestsCount = _getReportHashesStorage()[keccak256(abi.encode(data))]; + uint256 keysCount = keys.length; + + if (requestsCount == 0) { + revert ErrorInvalidReport(); + } + if (keysCount > requestsCount || requestsCount != data.requestsCount) { + revert ErrorInvalidKeysRequestsCount(); + } + + for(uint256 i = 0; i < keysCount; i++) { + _processExitRequestsList(data.data, keccak256(keys[i])); + } + + IWithdrawalVault(LOCATOR.withdrawalVault()).triggerELValidatorExit{value: msg.value}(keys, amounts); + } } diff --git a/contracts/0.8.9/test_helpers/ModuleSolo.sol b/contracts/0.8.9/test_helpers/ModuleSolo.sol index 4bbe96f06..f10c67326 100644 --- a/contracts/0.8.9/test_helpers/ModuleSolo.sol +++ b/contracts/0.8.9/test_helpers/ModuleSolo.sol @@ -149,4 +149,8 @@ contract ModuleSolo is IStakingModule { return (publicKeys, signatures); } + + function isKeyAvailableToExit(uint256 _nodeOperatorId, uint256 _index, bytes calldata _pubkey) external view returns (bool) { + + } } diff --git a/contracts/0.8.9/test_helpers/StakingModuleMock.sol b/contracts/0.8.9/test_helpers/StakingModuleMock.sol index 05ede6be6..805410b92 100644 --- a/contracts/0.8.9/test_helpers/StakingModuleMock.sol +++ b/contracts/0.8.9/test_helpers/StakingModuleMock.sol @@ -233,4 +233,8 @@ contract StakingModuleMock is IStakingModule { function setAvailableKeysCount(uint256 _newAvailableValidatorsCount) external { _availableValidatorsCount = _newAvailableValidatorsCount; } + + function isKeyAvailableToExit(uint256 _nodeOperatorId, uint256 _index, bytes calldata _pubkey) external view returns (bool) { + + } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 39ed9f5b9..c5c90d3f3 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -40,6 +40,21 @@ const config: HardhatUserConfig = { }, forking: HARDHAT_FORKING_URL ? { url: HARDHAT_FORKING_URL } : undefined, }, + sepolia: { + url: RPC_URL, + chainId: 11155111, + timeout: 60000 * 15, + // accounts: [""], + urls: { + apiURL: "https://api-sepolia.etherscan.io/api", + browserURL: "https://sepolia.etherscan.io/", + }, + }, + }, + etherscan: { + apiKey: { + sepolia: "", + }, }, solidity: { compilers: [ diff --git a/lib/state-file.ts b/lib/state-file.ts index fa5e7aae9..c46b5cb10 100644 --- a/lib/state-file.ts +++ b/lib/state-file.ts @@ -79,6 +79,7 @@ export enum Sk { lidoLocator = "lidoLocator", chainSpec = "chainSpec", scratchDeployGasUsed = "scratchDeployGasUsed", + triggerableExitMock = "triggerableExitMock", } export function getAddress(contractKey: Sk, state: DeploymentState): string { @@ -123,6 +124,7 @@ export function getAddress(contractKey: Sk, state: DeploymentState): string { case Sk.oracleReportSanityChecker: case Sk.wstETH: case Sk.depositContract: + case Sk.triggerableExitMock: return state[contractKey].address; default: throw new Error(`Unsupported contract entry key ${contractKey}`); diff --git a/scripts/scratch/dao-sepolia-deploy.sh b/scripts/scratch/dao-sepolia-deploy.sh new file mode 100755 index 000000000..494170361 --- /dev/null +++ b/scripts/scratch/dao-sepolia-deploy.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e +u +set -o pipefail + +# +export NETWORK=sepolia +export RPC_URL=https://sepolia.drpc.org + +export GENESIS_TIME=1639659600 # just some time +# export WITHDRAWAL_QUEUE_BASE_URI="<< SET IF REQUIED >>" +# export DSM_PREDEFINED_ADDRESS="<< SET IF REQUIED >>" +# +export DEPLOYER= # first acc of default mnemonic "test test ..." +export GAS_PRIORITY_FEE=2 +export GAS_MAX_FEE=100 +# +export NETWORK_STATE_FILE="deployed-${NETWORK}.json" +export NETWORK_STATE_DEFAULTS_FILE="scripts/scratch/deployed-testnet-defaults.json" + +bash scripts/scratch/dao-deploy.sh diff --git a/scripts/scratch/steps/09-deploy-non-aragon-contracts.ts b/scripts/scratch/steps/09-deploy-non-aragon-contracts.ts index fae746433..499cb7312 100644 --- a/scripts/scratch/steps/09-deploy-non-aragon-contracts.ts +++ b/scripts/scratch/steps/09-deploy-non-aragon-contracts.ts @@ -125,31 +125,6 @@ async function main() { ); logWideSplitter(); - // - // === WithdrawalVault === - // - const withdrawalVaultImpl = await deployImplementation(Sk.withdrawalVault, "WithdrawalVault", deployer, [ - lidoAddress, - treasuryAddress, - ]); - state = readNetworkState(); - const withdrawalsManagerProxyConstructorArgs = [votingAddress, withdrawalVaultImpl.address]; - const withdrawalsManagerProxy = await deployContract( - "WithdrawalsManagerProxy", - withdrawalsManagerProxyConstructorArgs, - deployer, - ); - const withdrawalVaultAddress = withdrawalsManagerProxy.address; - updateObjectInState(Sk.withdrawalVault, { - proxy: { - contract: await getContractPath("WithdrawalsManagerProxy"), - address: withdrawalsManagerProxy.address, - constructorArgs: withdrawalsManagerProxyConstructorArgs, - }, - address: withdrawalsManagerProxy.address, - }); - logWideSplitter(); - // // === LidoExecutionLayerRewardsVault === // @@ -269,6 +244,39 @@ async function main() { ); logWideSplitter(); + // + // === TriggerableExitMock === + // + const triggerableExitMock = await deployWithoutProxy(Sk.triggerableExitMock, "TriggerableExitMock", deployer); + logWideSplitter(); + + // + // === WithdrawalVault === + // + const withdrawalVaultImpl = await deployImplementation(Sk.withdrawalVault, "WithdrawalVault", deployer, [ + lidoAddress, + treasuryAddress, + validatorsExitBusOracle.address, + triggerableExitMock.address, + ]); + state = readNetworkState(); + const withdrawalsManagerProxyConstructorArgs = [votingAddress, withdrawalVaultImpl.address]; + const withdrawalsManagerProxy = await deployContract( + "WithdrawalsManagerProxy", + withdrawalsManagerProxyConstructorArgs, + deployer, + ); + const withdrawalVaultAddress = withdrawalsManagerProxy.address; + updateObjectInState(Sk.withdrawalVault, { + proxy: { + contract: await getContractPath("WithdrawalsManagerProxy"), + address: withdrawalsManagerProxy.address, + constructorArgs: withdrawalsManagerProxyConstructorArgs, + }, + address: withdrawalsManagerProxy.address, + }); + logWideSplitter(); + // // === Burner === // diff --git a/scripts/scratch/steps/13-grant-roles.ts b/scripts/scratch/steps/13-grant-roles.ts index b9c489973..db91c0681 100644 --- a/scripts/scratch/steps/13-grant-roles.ts +++ b/scripts/scratch/steps/13-grant-roles.ts @@ -10,6 +10,7 @@ async function main() { const deployer = (await ethers.provider.getSigner()).address; const state = readNetworkState({ deployer }); + const agent = state[Sk.appAgent].proxy.address; const lidoAddress = state[Sk.appLido].proxy.address; const nodeOperatorsRegistryAddress = state[Sk.appNodeOperatorsRegistry].proxy.address; const gateSealAddress = state.gateSeal.address; @@ -54,8 +55,8 @@ async function main() { // // === ValidatorsExitBusOracle // + const validatorsExitBusOracle = await getContractAt("ValidatorsExitBusOracle", validatorsExitBusOracleAddress); if (gateSealAddress) { - const validatorsExitBusOracle = await getContractAt("ValidatorsExitBusOracle", validatorsExitBusOracleAddress); await makeTx( validatorsExitBusOracle, "grantRole", @@ -66,6 +67,12 @@ async function main() { } else { log(`GateSeal is not specified or deployed: skipping assigning PAUSE_ROLE of validatorsExitBusOracle`); } + await makeTx( + validatorsExitBusOracle, + "grantRole", + [await validatorsExitBusOracle.getFunction("SUBMIT_PRIORITY_DATA_ROLE")(), agent], + { from: deployer }, + ); // // === WithdrawalQueue diff --git a/scripts/scratch/verify-contracts-code.sh b/scripts/scratch/verify-contracts-code.sh new file mode 100644 index 000000000..d18afe9eb --- /dev/null +++ b/scripts/scratch/verify-contracts-code.sh @@ -0,0 +1,92 @@ + +#!/bin/bash +set -e +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +export NETWORK=sepolia +export RPC_URL=https://sepolia.drpc.org + +if [[ -z "$NETWORK" ]]; then + echo "Must set NETWORK env variable" 1>&2 + exit 1 +fi + +NETWORK_STATE_FILE="deployed-${NETWORK}.json" +if [ ! -f $NETWORK_STATE_FILE ]; then + echo "Cannot find network state file ${NETWORK_STATE_FILE}" + exit 1 +fi +echo "Using network state file ${NETWORK_STATE_FILE}" + +function jsonGet { + node -e "const fs = require('fs'); const obj = JSON.parse(fs.readFileSync('${NETWORK_STATE_FILE}', 'utf8')); const path='$1'; let res = path.split('.').reduce(function(o, k) {return o && o[k] }, obj); console.log(res)" +} + +function verify { + contractPath="$(jsonGet ${1}.contract)" + contractName="${contractPath##*/}" + contractName="${contractName%.*}" + argsJson=$(jsonGet ${1}.constructorArgs) + echo "module.exports = $argsJson" > contract-args.js + npx hardhat --network $NETWORK verify --contract "$contractPath:$contractName" --constructor-args contract-args.js $(jsonGet ${1}.address) +} + +# NB: Although most of the contracts listed below would be verified by running +# this bash script as it is, some might require some manual tweaking. +# Sometimes first attempt to verify fails without observable reason. +# Part of the contract require a workaround see SCRATCH_DEPLOY.md section +# "Issues with verification of part of the contracts deployed from factories". + +verify dummyEmptyContract +verify burner +verify hashConsensusForAccountingOracle +verify hashConsensusForValidatorsExitBusOracle +verify accountingOracle.implementation +verify accountingOracle.proxy +verify validatorsExitBusOracle.implementation +verify validatorsExitBusOracle.proxy +verify triggerableExitMock +verify stakingRouter.implementation +verify stakingRouter.proxy +verify withdrawalQueueERC721.proxy +verify wstETH +verify executionLayerRewardsVault +verify eip712StETH +verify lidoTemplate +verify withdrawalVault.proxy +verify withdrawalVault.implementation +verify lidoLocator.proxy +verify lidoLocator.implementation +verify app:lido.implementation +verify app:oracle.implementation +verify app:node-operators-registry.implementation +verify app:aragon-voting.implementation +verify app:aragon-token-manager.implementation +verify app:aragon-finance.implementation +verify app:aragon-agent.implementation +verify oracleDaemonConfig +verify oracleReportSanityChecker +verify app:lido.proxy +verify depositSecurityModule +verify withdrawalQueueERC721.implementation +verify aragon-kernel.implementation +verify aragon-acl.implementation +verify aragon-kernel.proxy +verify ldo +verify callsScript +verify aragon-evm-script-registry.proxy +verify aragon-apm-registry.implementation +verify aragon-apm-registry.factory +verify aragon-app-repo-lido.implementation +verify aragon-app-repo-node-operators-registry.implementation +# NB: App Repos of lido, oracle, node-operators-registry, finance, agent, token-manager, voting +# share same implementation of Repo contract +verify aragon-evm-script-registry.proxy +verify aragon-evm-script-registry.implementation +verify app:simple-dvt.proxy +verify app:aragon-token-manager.proxy +verify app:oracle.proxy +verify app:node-operators-registry.proxy +verify app:aragon-voting.proxy +verify app:aragon-finance.proxy +verify app:aragon-agent.proxy diff --git a/test/0.4.24/contracts/LidoLocator__MutableMock.sol b/test/0.4.24/contracts/LidoLocator__MutableMock.sol index c50dd79e9..e3e07634c 100644 --- a/test/0.4.24/contracts/LidoLocator__MutableMock.sol +++ b/test/0.4.24/contracts/LidoLocator__MutableMock.sol @@ -87,4 +87,12 @@ contract LidoLocator__MutableMock { function mock___updatePostTokenRebaseReceiver(address newAddress) external { postTokenRebaseReceiver = newAddress; } + + function mock__updateValidatorsExitBusOracle(address newAddress) external { + validatorsExitBusOracle = newAddress; + } + + function mock__updateWithdrawalVault(address newAddress) external { + withdrawalVault = newAddress; + } } diff --git a/test/0.8.9/contracts/CuratedModuleMock.sol b/test/0.8.9/contracts/CuratedModuleMock.sol new file mode 100644 index 000000000..1360875bf --- /dev/null +++ b/test/0.8.9/contracts/CuratedModuleMock.sol @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +// for testing purposes only + +pragma solidity 0.4.24; + +import {NodeOperatorsRegistry} from "contracts/0.4.24/nos/NodeOperatorsRegistry.sol"; +import {Packed64x4} from "contracts/0.4.24/lib/Packed64x4.sol"; + +contract CuratedModuleMock is NodeOperatorsRegistry { + constructor() NodeOperatorsRegistry { + CONTRACT_VERSION_POSITION.setStorageUint256(0); + INITIALIZATION_BLOCK_POSITION.setStorageUint256(0); + } + + function initialize(address _locator, bytes32 _type, uint256 _stuckPenaltyDelay) { + LIDO_LOCATOR_POSITION.setStorageAddress(_locator); + TYPE_POSITION.setStorageBytes32(_type); + + _setContractVersion(2); + + _setStuckPenaltyDelay(_stuckPenaltyDelay); + + emit LocatorContractSet(_locator); + emit StakingModuleTypeSet(_type); + + initialized(); + } + + function _onlyNodeOperatorManager(address _sender, uint256 _nodeOperatorId) internal view { + //pass check for testing purpose + } + + function _auth(bytes32 _role) internal view { + //pass check for testing purpose + } + + function _authP(bytes32 _role, uint256[] _params) internal view { + //pass check for testing purpose + } + + + function testing_markAllKeysDeposited(uint256 _nodeOperatorId) external { + _onlyExistedNodeOperator(_nodeOperatorId); + Packed64x4.Packed memory signingKeysStats = _nodeOperators[_nodeOperatorId].signingKeysStats; + testing_setDepositedSigningKeysCount(_nodeOperatorId, signingKeysStats.get(TOTAL_VETTED_KEYS_COUNT_OFFSET)); + } + + function testing_setDepositedSigningKeysCount(uint256 _nodeOperatorId, uint256 _depositedSigningKeysCount) public { + _onlyExistedNodeOperator(_nodeOperatorId); + + NodeOperator storage nodeOperator = _nodeOperators[_nodeOperatorId]; + Packed64x4.Packed memory signingKeysStats = _loadOperatorSigningKeysStats(_nodeOperatorId); + uint256 depositedSigningKeysCountBefore = signingKeysStats.get(TOTAL_DEPOSITED_KEYS_COUNT_OFFSET); + + if (_depositedSigningKeysCount == depositedSigningKeysCountBefore) { + return; + } + + require( + _depositedSigningKeysCount <= signingKeysStats.get(TOTAL_VETTED_KEYS_COUNT_OFFSET), + "DEPOSITED_SIGNING_KEYS_COUNT_TOO_HIGH" + ); + + require( + _depositedSigningKeysCount >= signingKeysStats.get(TOTAL_EXITED_KEYS_COUNT_OFFSET), "DEPOSITED_SIGNING_KEYS_COUNT_TOO_LOW" + ); + + signingKeysStats.set(TOTAL_DEPOSITED_KEYS_COUNT_OFFSET, uint64(_depositedSigningKeysCount)); + _saveOperatorSigningKeysStats(_nodeOperatorId, signingKeysStats); + + emit DepositedSigningKeysCountChanged(_nodeOperatorId, _depositedSigningKeysCount); + _increaseValidatorsKeysNonce(); + + } +} diff --git a/test/0.8.9/contracts/HashConsensusTimeTravellable.sol b/test/0.8.9/contracts/HashConsensusTimeTravellable.sol new file mode 100644 index 000000000..2736bf64c --- /dev/null +++ b/test/0.8.9/contracts/HashConsensusTimeTravellable.sol @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; + + +import { HashConsensus } from "contracts/0.8.9/oracle/HashConsensus.sol"; + + +contract HashConsensusTimeTravellable is HashConsensus { + uint256 internal _time = 2513040315; + + constructor( + uint256 slotsPerEpoch, + uint256 secondsPerSlot, + uint256 genesisTime, + uint256 epochsPerFrame, + uint256 fastLaneLengthSlots, + address admin, + address reportProcessor + ) HashConsensus( + slotsPerEpoch, + secondsPerSlot, + genesisTime, + epochsPerFrame, + fastLaneLengthSlots, + admin, + reportProcessor + ) { + require(genesisTime <= _time, "GENESIS_TIME_CANNOT_BE_MORE_THAN_MOCK_TIME"); + } + + function _getTime() internal override view returns (uint256) { + return _time; + } + + function getTime() external view returns (uint256) { + return _time; + } + + function getTimeInSlots() external view returns (uint256) { + return _computeSlotAtTimestamp(_time); + } + + function setTime(uint256 newTime) external { + _time = newTime; + } + + function setTimeInSlots(uint256 slot) external { + _time = _computeTimestampAtSlot(slot); + } + + function setTimeInEpochs(uint256 epoch) external { + _time = _computeTimestampAtSlot(_computeStartSlotAtEpoch(epoch)); + } + + function advanceTimeBy(uint256 timeAdvance) external { + _time += timeAdvance; + } + + function advanceTimeToNextFrameStart() external { + FrameConfig memory config = _frameConfig; + uint256 epoch = _computeFrameStartEpoch(_time, config) + config.epochsPerFrame; + _time = _computeTimestampAtSlot(_computeStartSlotAtEpoch(epoch)); + } + + function advanceTimeBySlots(uint256 numSlots) external { + _time += SECONDS_PER_SLOT * numSlots; + } + + function advanceTimeByEpochs(uint256 numEpochs) external { + _time += SECONDS_PER_SLOT * SLOTS_PER_EPOCH * numEpochs; + } + + function getConsensusVersion() external view returns (uint256) { + return _getConsensusVersion(); + } +} diff --git a/test/0.8.9/contracts/OracleReportSanityCheckerMock.sol b/test/0.8.9/contracts/OracleReportSanityCheckerMock.sol new file mode 100644 index 000000000..ec04e451a --- /dev/null +++ b/test/0.8.9/contracts/OracleReportSanityCheckerMock.sol @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +// for testing purposes only + +pragma solidity 0.8.9; + +contract OracleReportSanityCheckerMock { + + function checkExitBusOracleReport(uint256 _exitRequestsCount) external view + { + } +} \ No newline at end of file diff --git a/test/0.8.9/contracts/StakingModule__Mock.sol b/test/0.8.9/contracts/StakingModule__Mock.sol index eb2e2433c..26650ad7f 100644 --- a/test/0.8.9/contracts/StakingModule__Mock.sol +++ b/test/0.8.9/contracts/StakingModule__Mock.sol @@ -240,6 +240,9 @@ contract StakingModule__Mock is IStakingModule { emit Mock__WithdrawalCredentialsChanged(); } + function isKeyAvailableToExit(uint256 _nodeOperatorId, uint256 _index, bytes calldata _pubkey) external view returns (bool) { + } + function mock__onWithdrawalCredentialsChanged(bool shouldRevert, string calldata revertMessage) external { onWithdrawalCredentialsChangedShouldRevert = shouldRevert; onWithdrawalCredentialsChangedShouldRevertWithMessage = revertMessage; diff --git a/test/0.8.9/exits/exits.test.ts b/test/0.8.9/exits/exits.test.ts new file mode 100644 index 000000000..a62215461 --- /dev/null +++ b/test/0.8.9/exits/exits.test.ts @@ -0,0 +1,740 @@ +import { expect } from "chai"; +import { AbiCoder, BigNumberish, BytesLike, keccak256 } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { + CuratedModuleMock, + CuratedModuleMock__factory, + DepositContractMock, + DepositContractMock__factory, + HashConsensusTimeTravellable, + HashConsensusTimeTravellable__factory, + Lido, + Lido__factory, + LidoLocator, + OracleReportSanityCheckerMock, + OracleReportSanityCheckerMock__factory, + Prover, + Prover__factory, + StakingRouter, + StakingRouter__factory, + TriggerableExitMock, + TriggerableExitMock__factory, + ValidatorsExitBusOracle, + ValidatorsExitBusOracle__factory, + WithdrawalVault, + WithdrawalVault__factory, +} from "typechain-types"; + +import { de0x, dummyLocator, ether, proxify, Snapshot } from "lib"; + +type Report = ValidatorsExitBusOracle.ReportDataStruct; +type ReportAsArray = ReturnType; + +type Block = { + number: number; + timestamp: number; + hash: string; +}; +type ExitRequest = { + moduleId: bigint; + nodeOpId: bigint; + valIndex: bigint; + valPubkey: string; +}; +type ExitRequests = ExitRequest[]; + +interface WithdrawalRequest { + keys: BytesLike[]; + amounts: BigNumberish[]; + data: Report; +} + +const pad = (hex: string, bytesLength: number, fill = "0") => { + const absentZeroes = bytesLength * 2 + 2 - hex.length; + if (absentZeroes > 0) hex = "0x" + fill.repeat(absentZeroes) + hex.substr(2); + return hex; +}; + +const SLOTS_PER_EPOCH = 32; +const SECONDS_PER_SLOT = 12; +const GENESIS_TIME = 100; +const EPOCHS_PER_FRAME = 37; +const INITIAL_FAST_LANE_LENGTH_SLOTS = 0; +const INITIAL_EPOCH = 1; + +const CONSENSUS_VERSION = 1; +const DATA_FORMAT_LIST = 1; + +const PENALTY_DELAY = 2 * 24 * 60 * 60; // 2 days + +function genPublicKeysArray(cnt = 1) { + const pubkeys = []; + const sigkeys = []; + + for (let i = 1; i <= cnt; i++) { + pubkeys.push(pad("0x" + i.toString(16), 48)); + sigkeys.push(pad("0x" + i.toString(16), 96)); + } + return { pubkeys, sigkeys }; +} + +function genPublicKeysCalldata(cnt = 1) { + let pubkeys = "0x"; + let sigkeys = "0x"; + + for (let i = 1; i <= cnt; i++) { + pubkeys = pubkeys + de0x(pad("0x" + i.toString(16), 48)); + sigkeys = sigkeys + de0x(pad("0x" + i.toString(16), 96)); + } + return { pubkeys, sigkeys }; +} + +async function bytes32() { + return "0x".padEnd(66, "1234"); +} + +const createAmounts = (length: number) => { + const arr = Array(length); + return arr.fill(ether("32")); +}; + +const getDefaultReportFields = (overrides: object) => + ({ + consensusVersion: CONSENSUS_VERSION, + dataFormat: DATA_FORMAT_LIST, + // required override: refSlot + // required override: requestsCount + // required override: data + ...overrides, + }) as Report; + +function calcValidatorsExitBusReportDataHash(reportItems: ReportAsArray): string { + return keccak256(new AbiCoder().encode(["(uint256,uint256,uint256,uint256,bytes)"], [reportItems])); +} + +function getValidatorsExitBusReportDataItems(r: Report) { + return [r.consensusVersion, r.refSlot, r.requestsCount, r.dataFormat, r.data]; +} +function hex(n: bigint, byteLen: number) { + const s = n.toString(16); + return byteLen === undefined ? s : s.padStart(byteLen * 2, "0"); +} +function encodeExitRequestHex({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) { + const pubkeyHex = de0x(valPubkey); + return hex(moduleId, 3) + hex(nodeOpId, 5) + hex(valIndex, 8) + pubkeyHex; +} + +function encodeExitRequestsDataList(requests: ExitRequests) { + return "0x" + requests.map(encodeExitRequestHex).join(""); +} + +async function prepareOracleReport({ + exitRequests, + ...restFields +}: { + exitRequests: ExitRequest[]; +} & Partial) { + const fields = getDefaultReportFields({ + ...restFields, + requestsCount: exitRequests.length, + data: encodeExitRequestsDataList(exitRequests), + }) as Report; + + const items = getValidatorsExitBusReportDataItems(fields); + const hash = calcValidatorsExitBusReportDataHash(items); + + return { fields, items, hash }; +} + +describe("Triggerable exits test", () => { + let deployer: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + let voting: HardhatEthersSigner; + let member1: HardhatEthersSigner; + let member2: HardhatEthersSigner; + let member3: HardhatEthersSigner; + let operator1: HardhatEthersSigner; + + let provider: typeof ethers.provider; + + let lido: Lido; + let withdrawalVault: WithdrawalVault; + let oracle: ValidatorsExitBusOracle; + let locator: LidoLocator; + let consensus: HashConsensusTimeTravellable; + let sanityChecker: OracleReportSanityCheckerMock; + let triggerableExitMock: TriggerableExitMock; + let prover: Prover; + let curatedModule: CuratedModuleMock; + let depositContract: DepositContractMock; + let stakingRouter: StakingRouter; + + let curatedModuleId: bigint; + const operator1Id = 0n; + let withdrawalRequest: WithdrawalRequest; + + async function getLatestBlock(): Promise { + const block = await provider.getBlock("latest"); + if (!block) throw new Error("Failed to retrieve latest block"); + return block as Block; + } + + async function triggerConsensusOnHash(hash: string) { + const { refSlot } = await consensus.getCurrentFrame(); + await consensus.connect(member1).submitReport(refSlot, hash, CONSENSUS_VERSION); + await consensus.connect(member3).submitReport(refSlot, hash, CONSENSUS_VERSION); + + const state = await consensus.getConsensusState(); + expect(state.consensusReport).to.be.equal(hash); + } + + before(async () => { + ({ provider } = ethers); + [deployer, stranger, voting, member1, member2, member3, operator1] = await ethers.getSigners(); + + const lidoFactory = new Lido__factory(deployer); + lido = await lidoFactory.deploy(); + + //triggerable exits mock + const triggerableExitMockFactory = new TriggerableExitMock__factory(deployer); + triggerableExitMock = await triggerableExitMockFactory.deploy(); + + //staking router + const depositContractFactory = new DepositContractMock__factory(deployer); + depositContract = await depositContractFactory.deploy(); + + const stakingRouterFactory = new StakingRouter__factory(deployer); + const stakingRouterImpl = await stakingRouterFactory.deploy(depositContract); + [stakingRouter] = await proxify({ impl: stakingRouterImpl, admin: deployer }); + await stakingRouter.initialize(deployer, lido, await bytes32()); + + //sanity checker + const sanityCheckerFactory = new OracleReportSanityCheckerMock__factory(deployer); + sanityChecker = await sanityCheckerFactory.deploy(); + + //locator + locator = await dummyLocator({ + oracleReportSanityChecker: await sanityChecker.getAddress(), + stakingRouter: await stakingRouter.getAddress(), + }); + + //module + const type = keccak256("0x01"); //0x01 + const curatedModuleFactory = new CuratedModuleMock__factory(deployer); + curatedModule = await curatedModuleFactory.deploy(); + await curatedModule.initialize(locator, type, PENALTY_DELAY); + + //oracle + const validatorsExitBusOracleFactory = new ValidatorsExitBusOracle__factory(deployer); + const oracleImpl = await validatorsExitBusOracleFactory.deploy(SECONDS_PER_SLOT, GENESIS_TIME, locator); + [oracle] = await proxify({ impl: oracleImpl, admin: deployer }); + + //withdrawal vault + const treasury = lido; + const withdrawalVaultFactory = new WithdrawalVault__factory(deployer); + const withdrawalVaultImpl = await withdrawalVaultFactory.deploy(lido, treasury, oracle, triggerableExitMock); + [withdrawalVault] = await proxify({ impl: withdrawalVaultImpl, admin: deployer }); + //initialize WC Vault + await withdrawalVault.initialize(); + + //mock update locator + // @ts-expect-error : dummyLocator() should return LidoLocator__MutableMock instead of LidoLocator + await locator.mock__updateValidatorsExitBusOracle(oracle); + // @ts-expect-error : dummyLocator() should return LidoLocator__MutableMock instead of LidoLocator + await locator.mock__updateWithdrawalVault(withdrawalVault); + + //consensus contract + const consensusFactory = new HashConsensusTimeTravellable__factory(deployer); + consensus = await consensusFactory.deploy( + SLOTS_PER_EPOCH, + SECONDS_PER_SLOT, + GENESIS_TIME, + EPOCHS_PER_FRAME, + INITIAL_FAST_LANE_LENGTH_SLOTS, + deployer, + await oracle.getAddress(), + ); + await consensus.updateInitialEpoch(INITIAL_EPOCH); + await consensus.setTime(GENESIS_TIME + INITIAL_EPOCH * SLOTS_PER_EPOCH * SECONDS_PER_SLOT); + + await consensus.grantRole(await consensus.MANAGE_MEMBERS_AND_QUORUM_ROLE(), deployer); + await consensus.grantRole(await consensus.DISABLE_CONSENSUS_ROLE(), deployer); + await consensus.grantRole(await consensus.MANAGE_FRAME_CONFIG_ROLE(), deployer); + await consensus.grantRole(await consensus.MANAGE_FAST_LANE_CONFIG_ROLE(), deployer); + await consensus.grantRole(await consensus.MANAGE_REPORT_PROCESSOR_ROLE(), deployer); + + const lastProcessingRefSlot = 0; + await oracle.initialize(deployer, await consensus.getAddress(), CONSENSUS_VERSION, lastProcessingRefSlot); + + await oracle.grantRole(await oracle.SUBMIT_PRIORITY_DATA_ROLE(), voting); + await oracle.grantRole(await oracle.SUBMIT_DATA_ROLE(), deployer); + await oracle.grantRole(await oracle.PAUSE_ROLE(), deployer); + await oracle.grantRole(await oracle.RESUME_ROLE(), deployer); + + //add consensus members + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); + + //resume after deploy + await oracle.resume(); + + //prover + // await prover.grantRole(await oracle.ONLY_MODULE(), voting); + + //add module + await stakingRouter.grantRole(await stakingRouter.STAKING_MODULE_MANAGE_ROLE(), deployer); + await stakingRouter.grantRole(await stakingRouter.UNSAFE_SET_EXITED_VALIDATORS_ROLE(), deployer); + + await stakingRouter.addStakingModule( + "Curated", + await curatedModule.getAddress(), + 10_000, // 100 % _targetShare + 1_000, // 10 % _moduleFee + 5_000, // 50 % _treasuryFee + ); + curatedModuleId = (await stakingRouter.getStakingModuleIds())[0]; + + await curatedModule.addNodeOperator("1", operator1); + + //prover + const proverFactory = new Prover__factory(deployer); + prover = await proverFactory.deploy(locator, oracle, curatedModuleId); + await oracle.grantRole(await oracle.SUBMIT_PRIORITY_DATA_ROLE(), prover); + }); + + context("stage1", () => { + let originalState: string; + + beforeEach(async () => { + originalState = await Snapshot.take(); + }); + afterEach(async () => { + await Snapshot.restore(originalState); + }); + + it("reverts if oracle report does not have valPubkeyUnknown", async () => { + const moduleId = 5n; + const moduleId2 = 1n; + const nodeOpId = 1n; + const nodeOpId2 = 1n; + const valIndex = 10n; + const valIndex2 = 11n; + const valPubkey = pad("0x010203", 48); + const valPubkey2 = pad("0x010204", 48); + + const block = await getLatestBlock(); + await consensus.setTime(block.timestamp); + + const { refSlot } = await consensus.getCurrentFrame(); + + const exitRequests = [ + { moduleId: moduleId2, nodeOpId: nodeOpId2, valIndex: valIndex2, valPubkey: valPubkey2 }, + { moduleId, nodeOpId, valIndex, valPubkey }, + ]; + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + await triggerConsensusOnHash(report.hash); + + //oracle report + const tx2 = await oracle.submitReportData(report.fields, 1); + await expect(tx2).to.be.emit(oracle, "ValidatorExitRequest"); + + const valPubkeyUnknown = pad("0x010101", 48); + + withdrawalRequest = { + keys: [valPubkeyUnknown], + amounts: [ether("32")], + data: report.fields, + }; + + await expect( + oracle.forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data), + ).to.be.revertedWithCustomError(oracle, "ErrorInvalidPubkeyInReport"); + }); + + it("forced exit with oracle report works", async () => { + const moduleId = 5n; + const moduleId2 = 1n; + const nodeOpId = 1n; + const nodeOpId2 = 1n; + const valIndex = 10n; + const valIndex2 = 11n; + const valPubkey = pad("0x010203", 48); + const valPubkey2 = pad("0x010204", 48); + + const block = await getLatestBlock(); + await consensus.setTime(block.timestamp); + + const { refSlot } = await consensus.getCurrentFrame(); + + const exitRequests = [ + { moduleId: moduleId2, nodeOpId: nodeOpId2, valIndex: valIndex2, valPubkey: valPubkey2 }, + { moduleId, nodeOpId, valIndex, valPubkey }, + ]; + + const report = await prepareOracleReport({ refSlot, exitRequests }); + await triggerConsensusOnHash(report.hash); + + //oracle report + const tx2 = await oracle.submitReportData(report.fields, 1); + await expect(tx2).to.be.emit(oracle, "ValidatorExitRequest"); + + withdrawalRequest = { + keys: [valPubkey], + amounts: [ether("32")], + data: report.fields, + }; + + //maximum to exit - 600val + const tx = await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.0"), + }); + await expect(tx).to.be.emit(oracle, "ValidatorExitRequest"); + await expect(tx).to.be.emit(triggerableExitMock, "WithdrawalRequest"); + }); + + it("governance vote without oracle.submitReportData works", async () => { + const moduleId = 5n; + const moduleId2 = 1n; + const nodeOpId = 1n; + const nodeOpId2 = 1n; + const valIndex = 10n; + const valIndex2 = 11n; + const valPubkey = pad("0x010203", 48); + const valPubkey2 = pad("0x010204", 48); + + const refSlot = 0; //await consensus.getCurrentFrame() + const exitRequests = [ + { moduleId: moduleId2, nodeOpId: nodeOpId2, valIndex: valIndex2, valPubkey: valPubkey2 }, + { moduleId, nodeOpId, valIndex, valPubkey }, + ]; + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + //priority + await oracle.connect(voting).submitPriorityReportData(report.hash, report.fields.requestsCount); + + withdrawalRequest = { + keys: [valPubkey], + amounts: [ether("32")], + data: report.fields, + }; + + const tx = await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.0"), + }); + await expect(tx).to.be.emit(oracle, "ValidatorExitRequest"); + await expect(tx).to.be.emit(triggerableExitMock, "WithdrawalRequest"); + }); + + it("exit multiple keys", async () => { + const { pubkeys: keys } = genPublicKeysArray(5); + + const refSlot = 0; //await consensus.getCurrentFrame() + const exitRequests = [ + { moduleId: 1n, nodeOpId: 1n, valIndex: 0n, valPubkey: keys[0] }, + { moduleId: 2n, nodeOpId: 2n, valIndex: 0n, valPubkey: keys[1] }, + { moduleId: 3n, nodeOpId: 3n, valIndex: 0n, valPubkey: keys[2] }, + { moduleId: 4n, nodeOpId: 4n, valIndex: 0n, valPubkey: keys[3] }, + { moduleId: 5n, nodeOpId: 5n, valIndex: 0n, valPubkey: keys[4] }, + ]; + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + //priority + await oracle.connect(voting).submitPriorityReportData(report.hash, exitRequests.length); + + //check invalid request count + const { pubkeys: keysInvalidRequestCount } = genPublicKeysArray(6); + withdrawalRequest = { + keys: keysInvalidRequestCount, + amounts: createAmounts(keysInvalidRequestCount.length), + data: report.fields, + }; + + await expect( + oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.0"), + }), + ).to.be.revertedWithCustomError(oracle, "ErrorInvalidKeysRequestsCount"); + + //check valid request count (not reverted) + const { pubkeys: validRequestLessInTheReport } = genPublicKeysArray(3); + withdrawalRequest = { + keys: validRequestLessInTheReport, + amounts: createAmounts(validRequestLessInTheReport.length), + data: report.fields, + }; + await expect( + oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.1"), + }), + ).not.to.be.revertedWithCustomError(oracle, "ErrorInvalidKeysRequestsCount"); + + //check invalid request count + const invalidKeyInRequest = [...keys]; + invalidKeyInRequest[2] = pad("0x010203", 48); + withdrawalRequest = { + keys: invalidKeyInRequest, + amounts: createAmounts(invalidKeyInRequest.length), + data: report.fields, + }; + await expect( + oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.2"), + }), + ).to.be.revertedWithCustomError(oracle, "ErrorInvalidPubkeyInReport"); + + //works + withdrawalRequest = { + keys, + amounts: createAmounts(keys.length), + data: report.fields, + }; + withdrawalRequest; + await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.0"), + }); + }); + + it("reverts module request exit - if unvetted/undeposited keys in report", async () => { + const keysAmount = 5; + const keysOperator1 = genPublicKeysCalldata(keysAmount); + + await curatedModule.addSigningKeys(operator1Id, keysAmount, keysOperator1.pubkeys, keysOperator1.sigkeys); + await curatedModule.setNodeOperatorStakingLimit(operator1Id, keysAmount - 2); + + const { pubkeys: keys } = genPublicKeysArray(keysAmount); + + const requestIndex = 1; + const requestKey = keys[requestIndex]; + + //first attempt - no deposits + await expect( + prover.reportKeysToExit(operator1Id, [requestIndex], [requestKey], await bytes32()), + ).to.be.revertedWithCustomError(prover, "ErrorKeyIsNotAvailiableToExit"); + + //set keys are deposited + await curatedModule.testing_markAllKeysDeposited(operator1Id); + + //calculate report + const refSlot = 0; //await consensus.getCurrentFrame() + const exitRequests = [ + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 0n, valPubkey: keys[0] }, + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 1n, valPubkey: keys[1] }, + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 2n, valPubkey: keys[2] }, + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 3n, valPubkey: keys[3] }, + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 4n, valPubkey: keys[4] }, + ]; + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + const reportIndexes = exitRequests.map((req) => req.valIndex); + const reportKeys = exitRequests.map((req) => req.valPubkey); + + //keys [0,1,2] - deposited + //keys [3,4] - not + await expect( + prover.reportKeysToExit(operator1Id, reportIndexes, reportKeys, report.hash), + ).to.be.revertedWithCustomError(prover, "ErrorKeyIsNotAvailiableToExit"); + }); + + it("module request exit", async () => { + const keysAmount = 5; + const keysOperator1 = genPublicKeysCalldata(keysAmount); + + await curatedModule.addSigningKeys(operator1Id, keysAmount, keysOperator1.pubkeys, keysOperator1.sigkeys); + await curatedModule.setNodeOperatorStakingLimit(operator1Id, keysAmount - 2); + + //set keys are deposited + await curatedModule.testing_markAllKeysDeposited(operator1Id); + + const { pubkeys: keys } = genPublicKeysArray(keysAmount); + + //calculate report + const refSlot = 0; //await consensus.getCurrentFrame() + const exitRequests = [ + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 0n, valPubkey: keys[0] }, + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 1n, valPubkey: keys[1] }, + { moduleId: curatedModuleId, nodeOpId: operator1Id, valIndex: 2n, valPubkey: keys[2] }, + ]; + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + const reportIndexes = exitRequests.map((req) => req.valIndex); + const reportKeys = exitRequests.map((req) => req.valPubkey); + + await prover.reportKeysToExit(operator1Id, reportIndexes, reportKeys, report.hash); + + // invalid key requested + const valPubkeyUnknown = pad("0x010101", 48); + withdrawalRequest = { + keys: [valPubkeyUnknown], + amounts: createAmounts(1), + data: report.fields, + }; + await expect( + oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data), + ).to.be.revertedWithCustomError(oracle, "ErrorInvalidPubkeyInReport"); + + //unvetted key requested + const unvettedKey = keys[4]; + withdrawalRequest = { + keys: [unvettedKey], + amounts: createAmounts(1), + data: report.fields, + }; + await expect( + oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data), + ).to.be.revertedWithCustomError(oracle, "ErrorInvalidPubkeyInReport"); + + // //requested key exit + withdrawalRequest = { + keys: [keys[0], keys[1]], + amounts: createAmounts(2), + data: report.fields, + }; + const tx = await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: ether("1.0"), + }); + await expect(tx).to.be.emit(oracle, "ValidatorExitRequest"); + await expect(tx).to.be.emit(triggerableExitMock, "WithdrawalRequest"); + }); + + it("increased exitFee", async function () { + const { pubkeys: keys } = genPublicKeysArray(5); + + const refSlot = 0; //await consensus.getCurrentFrame() + + const keysCount = 17; + const exitRequests = [...Array(keysCount).keys()].map(() => ({ + moduleId: 1n, + nodeOpId: 1n, + valIndex: 0n, + valPubkey: keys[0], + })); + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + //priority + await oracle.connect(voting).submitPriorityReportData(report.hash, report.fields.requestsCount); + + const keys1 = [...Array(keysCount).keys()].map(() => keys[0]); + + expect(await triggerableExitMock.getFee()).to.be.equal(1n); + + //works + // const gasEstimate1 = await oracle + // .connect(stranger) + // .forcedExitPubkeys.estimateGas(keys1, reportItems, { value: ether("1.0") }); + + //calculate exitFee + const exitFee1 = await triggerableExitMock.getFee(); + const keysFee1 = ether((exitFee1 * BigInt(keys1.length)).toString()); + + withdrawalRequest = { + keys: keys1, + amounts: createAmounts(keys1.length), + data: report.fields, + }; + + await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: keysFee1, + }); + + await triggerableExitMock.blockProcessing(); + //after block processing block the fee would be increased + + const exitFee2 = await triggerableExitMock.getFee(); + expect(exitFee2).to.be.equal(2n); + + const keysFee2 = ether((exitFee2 * BigInt(keys1.length)).toString()); + + // const gasEstimate2 = await oracle + // .connect(stranger) + // .forcedExitPubkeys.estimateGas(keys1, reportItems, { value: ether("1.0") }); + await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: keysFee2, + }); + }); + + it("test queue size", async function () { + const { pubkeys: keys } = genPublicKeysArray(5); + + const refSlot = 0; //await consensus.getCurrentFrame() + + const keysCount = 7; + const exitRequests = [...Array(keysCount).keys()].map(() => ({ + moduleId: 1n, + nodeOpId: 1n, + valIndex: 0n, + valPubkey: keys[0], + })); + + const report = await prepareOracleReport({ refSlot, exitRequests }); + + //priority + await oracle.connect(voting).submitPriorityReportData(report.hash, report.fields.requestsCount); + + const keys1 = [...Array(keysCount).keys()].map(() => keys[0]); + + expect(await triggerableExitMock.getFee()).to.be.equal(1n); + + //works + // const gasEstimate1 = await oracle + // .connect(stranger) + // .forcedExitPubkeys.estimateGas(keys1, reportItems, { value: ether("1.0") }); + + //calculate exitFee + const exitFee1 = await triggerableExitMock.getFee(); + const keysFee1 = ether((exitFee1 * BigInt(keys1.length)).toString()); + withdrawalRequest = { + keys: keys1, + amounts: createAmounts(keys1.length), + data: report.fields, + }; + + await oracle + .connect(stranger) + .forcedExitPubkeys(withdrawalRequest.keys, withdrawalRequest.amounts, withdrawalRequest.data, { + value: keysFee1, + }); + + const queueCountBefore = await triggerableExitMock.getQueueCount(); + expect(queueCountBefore).to.be.equal(keysCount); + + //block processing + await triggerableExitMock.blockProcessing(); + + const queueCountAfter = await triggerableExitMock.getQueueCount(); + expect(queueCountAfter).to.be.equal(0); + }); + }); +}); diff --git a/test/0.8.9/withdrawalVault.test.ts b/test/0.8.9/withdrawalVault.test.ts index 9a7c5829b..42273f1fe 100644 --- a/test/0.8.9/withdrawalVault.test.ts +++ b/test/0.8.9/withdrawalVault.test.ts @@ -12,7 +12,7 @@ import { WithdrawalVault, } from "typechain-types"; -import { MAX_UINT256, proxify, Snapshot } from "lib"; +import { certainAddress, MAX_UINT256, proxify, Snapshot } from "lib"; const PETRIFIED_VERSION = MAX_UINT256; @@ -29,6 +29,8 @@ describe("WithdrawalVault.sol", () => { let impl: WithdrawalVault; let vault: WithdrawalVault; let vaultAddress: string; + let oracleAddress: string; + let triggerableExitAddress: string; before(async () => { [owner, user, treasury] = await ethers.getSigners(); @@ -36,7 +38,15 @@ describe("WithdrawalVault.sol", () => { lido = await ethers.deployContract("Lido__MockForWithdrawalVault"); lidoAddress = await lido.getAddress(); - impl = await ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address]); + oracleAddress = certainAddress("oracleAddress"); + triggerableExitAddress = certainAddress("triggerableExitAddress"); + + impl = await ethers.deployContract("WithdrawalVault", [ + lidoAddress, + treasury.address, + oracleAddress, + triggerableExitAddress, + ]); [vault] = await proxify({ impl, admin: owner }); @@ -50,15 +60,31 @@ describe("WithdrawalVault.sol", () => { context("Constructor", () => { it("Reverts if the Lido address is zero", async () => { await expect( - ethers.deployContract("WithdrawalVault", [ZeroAddress, treasury.address]), - ).to.be.revertedWithCustomError(vault, "LidoZeroAddress"); + ethers.deployContract("WithdrawalVault", [ + ZeroAddress, + treasury.address, + oracleAddress, + triggerableExitAddress, + ]), + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); }); it("Reverts if the treasury address is zero", async () => { - await expect(ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress])).to.be.revertedWithCustomError( - vault, - "TreasuryZeroAddress", - ); + await expect( + ethers.deployContract("WithdrawalVault", [lidoAddress, ZeroAddress, oracleAddress, triggerableExitAddress]), + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Reverts if the oracle address is zero", async () => { + await expect( + ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, ZeroAddress, triggerableExitAddress]), + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); + }); + + it("Reverts if the triggerableExit address is zero", async () => { + await expect( + ethers.deployContract("WithdrawalVault", [lidoAddress, treasury.address, oracleAddress, ZeroAddress]), + ).to.be.revertedWithCustomError(vault, "ZeroAddress"); }); it("Sets initial properties", async () => {