diff --git a/contracts/0.8.9/oracle/IWithdrawalVault.sol b/contracts/0.8.9/oracle/IWithdrawalVault.sol new file mode 100644 index 000000000..7df3e01c5 --- /dev/null +++ b/contracts/0.8.9/oracle/IWithdrawalVault.sol @@ -0,0 +1,5 @@ +pragma solidity 0.8.9; + +interface IWithdrawalVault { + function addFullWithdrawalRequests(bytes[] calldata pubkeys) external; +} \ No newline at end of file diff --git a/contracts/0.8.9/oracle/ValidatorsExitBus.sol b/contracts/0.8.9/oracle/ValidatorsExitBus.sol new file mode 100644 index 000000000..d914b98d9 --- /dev/null +++ b/contracts/0.8.9/oracle/ValidatorsExitBus.sol @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2023 Lido +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.9; + +import { AccessControlEnumerable } from "../utils/access/AccessControlEnumerable.sol"; +import { UnstructuredStorage } from "../lib/UnstructuredStorage.sol"; +import { IWithdrawalVault } from "./IWithdrawalVault.sol"; + +contract ValidatorsExitBus is AccessControlEnumerable { + using UnstructuredStorage for bytes32; + + /// @dev Errors + // error DuplicateExitRequest(); + error KeyWasNotUnpacked(uint256 keyIndex, uint256 lastUnpackedKeyIndex); + error ZeroAddress(); + + /// Part of report data + struct ExitRequestData { + /// @dev Total number of validator exit requests in this report. Must not be greater + /// than limit checked in OracleReportSanityChecker.checkExitBusOracleReport. + uint256 requestsCount; + + /// @dev Format of the validator exit requests data. Currently, only the + /// DATA_FORMAT_LIST=1 is supported. + uint256 dataFormat; + + /// @dev Validator exit requests data. Can differ based on the data format, + /// see the constant defining a specific data format below for more info. + bytes data; + } + + // TODO: make type optimization + struct DeliveryHistory { + uint256 blockNumber; + /// @dev Key index in exit request array + uint256 lastDeliveredKeyIndex; + } + // TODO: make type optimization + struct RequestStatus { + // Total items count in report (by default type(uint32).max, update on first report unpack) + uint256 totalItemsCount; + // Total processed items in report (by default 0) + uint256 deliveredItemsCount; + // Vebo contract version at the time of hash submittion + uint256 contractVersion; + DeliveryHistory[] deliverHistory; + } + + /// @notice The list format of the validator exit requests data. Used when all + /// requests fit into a single transaction. + /// + /// Each validator exit request is described by the following 64-byte array: + /// + /// MSB <------------------------------------------------------- LSB + /// | 3 bytes | 5 bytes | 8 bytes | 48 bytes | + /// | moduleId | nodeOpId | validatorIndex | validatorPubkey | + /// + /// All requests are tightly packed into a byte array where requests follow + /// one another without any separator or padding, and passed to the `data` + /// field of the report structure. + /// + /// Requests must be sorted in the ascending order by the following compound + /// key: (moduleId, nodeOpId, validatorIndex). + /// + uint256 public constant DATA_FORMAT_LIST = 1; + + /// Length in bytes of packed request + uint256 internal constant PACKED_REQUEST_LENGTH = 64; + + /// Hash constant for mapping exit requests storage + bytes32 internal constant EXIT_REQUESTS_HASHES_POSITION = + keccak256("lido.ValidatorsExitBus.reportHashes"); + + /// @dev Storage slot: address withdrawalVaultContract + bytes32 internal constant WITHDRAWAL_VAULT_CONTRACT_POSITION = + keccak256("lido.ValidatorsExitBus.withdrawalVaultContract"); + + // ILidoLocator internal immutable LOCATOR; + + // TODO: read WV via locator + function _initialize_v2(address withdrawalVaultAddr) internal { + _setWithdrawalVault(withdrawalVaultAddr); + } + + function _setWithdrawalVault(address addr) internal { + if (addr == address(0)) revert ZeroAddress(); + + WITHDRAWAL_VAULT_CONTRACT_POSITION.setStorageAddress(addr); + } + + function triggerExitHashVerify(ExitRequestData calldata exitRequestData, uint256[] calldata keyIndexes) external payable { + bytes32 dataHash = keccak256(abi.encode(exitRequestData)); + RequestStatus storage requestStatus = _storageExitRequestsHashes()[dataHash]; + + uint256 lastDeliveredKeyIndex = requestStatus.deliveredItemsCount - 1; + + uint256 offset; + bytes calldata data = exitRequestData.data; + bytes[] memory pubkeys = new bytes[](keyIndexes.length); + + assembly { + offset := data.offset + } + + for (uint256 i = 0; i < keyIndexes.length; i++) { + if (keyIndexes[i] > lastDeliveredKeyIndex) { + revert KeyWasNotUnpacked(keyIndexes[i], lastDeliveredKeyIndex); + } + uint256 requestOffset = offset + keyIndexes[i] * 64; + + bytes calldata pubkey; + + assembly { + pubkey.offset := add(requestOffset, 16) + pubkey.length := 48 + } + pubkeys[i] = pubkey; + + } + + address withdrawalVaultAddr = WITHDRAWAL_VAULT_CONTRACT_POSITION.getStorageAddress(); + IWithdrawalVault(withdrawalVaultAddr).addFullWithdrawalRequests(pubkeys); + } + + /// Storage helpers + function _storageExitRequestsHashes() internal pure returns ( + mapping(bytes32 => RequestStatus) storage r + ) { + bytes32 position = EXIT_REQUESTS_HASHES_POSITION; + assembly { + r.slot := position + } + } +} \ No newline at end of file diff --git a/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol b/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol index 1937aff61..8754839eb 100644 --- a/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol +++ b/contracts/0.8.9/oracle/ValidatorsExitBusOracle.sol @@ -10,6 +10,7 @@ import { PausableUntil } from "../utils/PausableUntil.sol"; import { UnstructuredStorage } from "../lib/UnstructuredStorage.sol"; import { BaseOracle } from "./BaseOracle.sol"; +import { ValidatorsExitBus } from "./ValidatorsExitBus.sol"; interface IOracleReportSanityChecker { @@ -17,7 +18,7 @@ interface IOracleReportSanityChecker { } -contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { +contract ValidatorsExitBusOracle is BaseOracle, PausableUntil, ValidatorsExitBus { using UnstructuredStorage for bytes32; using SafeCast for uint256; @@ -109,6 +110,12 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { _initialize(consensusContract, consensusVersion, lastProcessingRefSlot); } + // TODO: replace with locator + function finalizeUpgrade_v2(address withdrawalVaultAddress) external { + _updateContractVersion(2); + _initialize_v2(withdrawalVaultAddress); + } + /// @notice Resume accepting validator exit requests /// /// @dev Reverts with `PausedExpected()` if contract is already resumed @@ -161,40 +168,9 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { /// Requests data /// - /// @dev Total number of validator exit requests in this report. Must not be greater - /// than limit checked in OracleReportSanityChecker.checkExitBusOracleReport. - uint256 requestsCount; - - /// @dev Format of the validator exit requests data. Currently, only the - /// DATA_FORMAT_LIST=1 is supported. - uint256 dataFormat; - - /// @dev Validator exit requests data. Can differ based on the data format, - /// see the constant defining a specific data format below for more info. - bytes data; + ExitRequestData exitRequestData; } - /// @notice The list format of the validator exit requests data. Used when all - /// requests fit into a single transaction. - /// - /// Each validator exit request is described by the following 64-byte array: - /// - /// MSB <------------------------------------------------------- LSB - /// | 3 bytes | 5 bytes | 8 bytes | 48 bytes | - /// | moduleId | nodeOpId | validatorIndex | validatorPubkey | - /// - /// All requests are tightly packed into a byte array where requests follow - /// one another without any separator or padding, and passed to the `data` - /// field of the report structure. - /// - /// Requests must be sorted in the ascending order by the following compound - /// key: (moduleId, nodeOpId, validatorIndex). - /// - uint256 public constant DATA_FORMAT_LIST = 1; - - /// Length in bytes of packed request - uint256 internal constant PACKED_REQUEST_LENGTH = 64; - /// @notice Submits report data for processing. /// /// @param data The data. See the `ReportData` structure's docs for details. @@ -216,10 +192,12 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { { _checkMsgSenderIsAllowedToSubmitData(); _checkContractVersion(contractVersion); + bytes32 exitRequestDataHash = keccak256(abi.encode(data.exitRequestData)); // 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))); + _checkConsensusData(data.refSlot, data.consensusVersion, keccak256(abi.encode(data.consensusVersion, data.refSlot, exitRequestDataHash))); _startProcessing(); _handleConsensusReportData(data); + _storeOracleExitRequestHash(exitRequestDataHash, data, contractVersion); } /// @notice Returns the total number of validator exit requests ever processed @@ -328,36 +306,37 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { } function _handleConsensusReportData(ReportData calldata data) internal { - if (data.dataFormat != DATA_FORMAT_LIST) { - revert UnsupportedRequestsDataFormat(data.dataFormat); + if (data.exitRequestData.dataFormat != DATA_FORMAT_LIST) { + revert UnsupportedRequestsDataFormat(data.exitRequestData.dataFormat); } - if (data.data.length % PACKED_REQUEST_LENGTH != 0) { + if (data.exitRequestData.data.length % PACKED_REQUEST_LENGTH != 0) { revert InvalidRequestsDataLength(); } + // TODO: next iteration will check ref slot deliveredReportAmount IOracleReportSanityChecker(LOCATOR.oracleReportSanityChecker()) - .checkExitBusOracleReport(data.requestsCount); + .checkExitBusOracleReport(data.exitRequestData.requestsCount); - if (data.data.length / PACKED_REQUEST_LENGTH != data.requestsCount) { + if (data.exitRequestData.data.length / PACKED_REQUEST_LENGTH != data.exitRequestData.requestsCount) { revert UnexpectedRequestsDataLength(); } - _processExitRequestsList(data.data); + _processExitRequestsList(data.exitRequestData.data); _storageDataProcessingState().value = DataProcessingState({ refSlot: data.refSlot.toUint64(), - requestsCount: data.requestsCount.toUint64(), - requestsProcessed: data.requestsCount.toUint64(), + requestsCount: data.exitRequestData.requestsCount.toUint64(), + requestsProcessed: data.exitRequestData.requestsCount.toUint64(), dataFormat: uint16(DATA_FORMAT_LIST) }); - if (data.requestsCount == 0) { + if (data.exitRequestData.requestsCount == 0) { return; } TOTAL_REQUESTS_PROCESSED_POSITION.setStorageUint256( - TOTAL_REQUESTS_PROCESSED_POSITION.getStorageUint256() + data.requestsCount + TOTAL_REQUESTS_PROCESSED_POSITION.getStorageUint256() + data.exitRequestData.requestsCount ); } @@ -439,6 +418,17 @@ contract ValidatorsExitBusOracle is BaseOracle, PausableUntil { return (moduleId << 40) | nodeOpId; } + function _storeOracleExitRequestHash(bytes32 exitRequestHash, ReportData calldata report, uint256 contractVersion) internal { + mapping(bytes32 => RequestStatus) storage hashes = _storageExitRequestsHashes(); + // if (hashes[hash].itemsCount > 0 ) revert DuplicateExitRequest(); + + RequestStatus storage request = hashes[exitRequestHash]; + request.totalItemsCount = report.exitRequestData.requestsCount; + request.deliveredItemsCount = report.exitRequestData.requestsCount; + request.contractVersion = contractVersion; + request.deliverHistory.push(DeliveryHistory({blockNumber: block.number, lastDeliveredKeyIndex: report.exitRequestData.requestsCount - 1})); + } + /// /// Storage helpers /// diff --git a/test/0.8.9/contracts/WithdrawalValut_MockForVebo.sol b/test/0.8.9/contracts/WithdrawalValut_MockForVebo.sol new file mode 100644 index 000000000..9d60ad048 --- /dev/null +++ b/test/0.8.9/contracts/WithdrawalValut_MockForVebo.sol @@ -0,0 +1,10 @@ +pragma solidity 0.8.9; + +contract WithdrawalVault__MockForVebo { + + event AddFullWithdrawalRequestsCalled(bytes[] pubkeys); + + function addFullWithdrawalRequests(bytes[] calldata pubkeys) external { + emit AddFullWithdrawalRequestsCalled(pubkeys); + } +} \ No newline at end of file diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts index 615050cf4..8cab0dac2 100644 --- a/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.happyPath.test.ts @@ -4,7 +4,7 @@ import { ethers } from "hardhat"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; -import { HashConsensus__Harness, ValidatorsExitBus__Harness } from "typechain-types"; +import { HashConsensus__Harness, ValidatorsExitBus__Harness, WithdrawalVault__MockForVebo } from "typechain-types"; import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; @@ -29,11 +29,11 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { let consensus: HashConsensus__Harness; let oracle: ValidatorsExitBus__Harness; let admin: HardhatEthersSigner; + let withdrawalVault: WithdrawalVault__MockForVebo; let oracleVersion: bigint; let exitRequests: ExitRequest[]; let reportFields: ReportFields; - let reportItems: ReturnType; let reportHash: string; let member1: HardhatEthersSigner; @@ -50,21 +50,29 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { valPubkey: string; } - interface ReportFields { - consensusVersion: bigint; - refSlot: bigint; + interface ExitRequestData { requestsCount: number; dataFormat: number; data: string; } - const calcValidatorsExitBusReportDataHash = (items: ReturnType) => { - const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,uint256,uint256,bytes)"], [items]); - return ethers.keccak256(data); - }; + interface ReportFields { + consensusVersion: bigint; + refSlot: bigint; + exitRequestData: ExitRequestData; + } - const getValidatorsExitBusReportDataItems = (r: ReportFields) => { - return [r.consensusVersion, r.refSlot, r.requestsCount, r.dataFormat, r.data]; + const calcValidatorsExitBusReportDataHash = (items: ReportFields) => { + const exitRequestItems = [ + items.exitRequestData.requestsCount, + items.exitRequestData.dataFormat, + items.exitRequestData.data, + ]; + const exitRequestData = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,bytes)"], [exitRequestItems]); + const dataHash = ethers.keccak256(exitRequestData); + const oracleReportItems = [items.consensusVersion, items.refSlot, dataHash]; + const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,bytes32)"], [oracleReportItems]); + return ethers.keccak256(data); }; const encodeExitRequestHex = ({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) => { @@ -81,11 +89,13 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { const deployed = await deployVEBO(admin.address); oracle = deployed.oracle; consensus = deployed.consensus; + withdrawalVault = deployed.withdrawalVault; await initVEBO({ admin: admin.address, oracle, consensus, + withdrawalVault, resumeAfterDeploy: true, lastProcessingRefSlot: LAST_PROCESSING_REF_SLOT, }); @@ -146,13 +156,14 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { reportFields = { consensusVersion: CONSENSUS_VERSION, refSlot: refSlot, - requestsCount: exitRequests.length, - dataFormat: DATA_FORMAT_LIST, - data: encodeExitRequestsDataList(exitRequests), + exitRequestData: { + requestsCount: exitRequests.length, + dataFormat: DATA_FORMAT_LIST, + data: encodeExitRequestsDataList(exitRequests), + }, }; - reportItems = getValidatorsExitBusReportDataItems(reportFields); - reportHash = calcValidatorsExitBusReportDataHash(reportItems); + reportHash = calcValidatorsExitBusReportDataHash(reportFields); await triggerConsensusOnHash(reportHash); }); @@ -202,9 +213,14 @@ describe("ValidatorsExitBusOracle.sol:happyPath", () => { }); it("a data not matching the consensus hash cannot be submitted", async () => { - const invalidReport = { ...reportFields, requestsCount: reportFields.requestsCount + 1 }; - const invalidReportItems = getValidatorsExitBusReportDataItems(invalidReport); - const invalidReportHash = calcValidatorsExitBusReportDataHash(invalidReportItems); + const invalidReport = { + ...reportFields, + exitRequestData: { + ...reportFields.exitRequestData, + requestsCount: reportFields.exitRequestData.requestsCount + 1, + }, + }; + const invalidReportHash = calcValidatorsExitBusReportDataHash(invalidReport); await expect(oracle.connect(member1).submitReportData(invalidReport, oracleVersion)) .to.be.revertedWithCustomError(oracle, "UnexpectedDataHash") diff --git a/test/0.8.9/oracle/validator-exit-bus-oracle.triggerExitHashVerify.test.ts b/test/0.8.9/oracle/validator-exit-bus-oracle.triggerExitHashVerify.test.ts new file mode 100644 index 000000000..a8e701b77 --- /dev/null +++ b/test/0.8.9/oracle/validator-exit-bus-oracle.triggerExitHashVerify.test.ts @@ -0,0 +1,238 @@ +import { expect } from "chai"; +import { ZeroHash } from "ethers"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +import { HashConsensus__Harness, ValidatorsExitBus__Harness, WithdrawalVault__MockForVebo } from "typechain-types"; + +import { CONSENSUS_VERSION, de0x, numberToHex } from "lib"; + +import { + computeTimestampAtSlot, + DATA_FORMAT_LIST, + deployVEBO, + initVEBO, + SECONDS_PER_FRAME, + SLOTS_PER_FRAME, +} from "test/deploy"; + +const PUBKEYS = [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", +]; + +describe("ValidatorsExitBusOracle.sol:triggerExitHashVerify", () => { + let consensus: HashConsensus__Harness; + let oracle: ValidatorsExitBus__Harness; + let admin: HardhatEthersSigner; + let withdrawalVault: WithdrawalVault__MockForVebo; + + let oracleVersion: bigint; + let exitRequests: ExitRequest[]; + let reportFields: ReportFields; + let reportHash: string; + + let member1: HardhatEthersSigner; + let member2: HardhatEthersSigner; + let member3: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + const LAST_PROCESSING_REF_SLOT = 1; + + interface ExitRequest { + moduleId: number; + nodeOpId: number; + valIndex: number; + valPubkey: string; + } + + interface ExitRequestData { + requestsCount: number; + dataFormat: number; + data: string; + } + + interface ReportFields { + consensusVersion: bigint; + refSlot: bigint; + exitRequestData: ExitRequestData; + } + + const calcValidatorsExitBusReportDataHash = (items: ReportFields) => { + const exitRequestItems = [ + items.exitRequestData.requestsCount, + items.exitRequestData.dataFormat, + items.exitRequestData.data, + ]; + const exitRequestData = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,bytes)"], [exitRequestItems]); + const dataHash = ethers.keccak256(exitRequestData); + const oracleReportItems = [items.consensusVersion, items.refSlot, dataHash]; + const data = ethers.AbiCoder.defaultAbiCoder().encode(["(uint256,uint256,bytes32)"], [oracleReportItems]); + return ethers.keccak256(data); + }; + + const encodeExitRequestHex = ({ moduleId, nodeOpId, valIndex, valPubkey }: ExitRequest) => { + const pubkeyHex = de0x(valPubkey); + expect(pubkeyHex.length).to.equal(48 * 2); + return numberToHex(moduleId, 3) + numberToHex(nodeOpId, 5) + numberToHex(valIndex, 8) + pubkeyHex; + }; + + const encodeExitRequestsDataList = (requests: ExitRequest[]) => { + return "0x" + requests.map(encodeExitRequestHex).join(""); + }; + + const deploy = async () => { + const deployed = await deployVEBO(admin.address); + oracle = deployed.oracle; + consensus = deployed.consensus; + withdrawalVault = deployed.withdrawalVault; + + await initVEBO({ + admin: admin.address, + oracle, + consensus, + withdrawalVault, + resumeAfterDeploy: true, + lastProcessingRefSlot: LAST_PROCESSING_REF_SLOT, + }); + + oracleVersion = await oracle.getContractVersion(); + + await consensus.addMember(member1, 1); + await consensus.addMember(member2, 2); + await consensus.addMember(member3, 2); + }; + + before(async () => { + [admin, member1, member2, member3, stranger] = await ethers.getSigners(); + + await deploy(); + }); + + const triggerConsensusOnHash = async (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); + expect((await consensus.getConsensusState()).consensusReport).to.equal(hash); + }; + + it("initially, consensus report is empty and is not being processed", async () => { + const report = await oracle.getConsensusReport(); + expect(report.hash).to.equal(ZeroHash); + + expect(report.processingDeadlineTime).to.equal(0); + expect(report.processingStarted).to.equal(false); + + const frame = await consensus.getCurrentFrame(); + const procState = await oracle.getProcessingState(); + + expect(procState.currentFrameRefSlot).to.equal(frame.refSlot); + expect(procState.dataHash).to.equal(ZeroHash); + expect(procState.processingDeadlineTime).to.equal(0); + expect(procState.dataSubmitted).to.equal(false); + expect(procState.dataFormat).to.equal(0); + expect(procState.requestsCount).to.equal(0); + expect(procState.requestsSubmitted).to.equal(0); + }); + + it("reference slot of the empty initial consensus report is set to the last processing slot passed to the initialize function", async () => { + const report = await oracle.getConsensusReport(); + expect(report.refSlot).to.equal(LAST_PROCESSING_REF_SLOT); + }); + + it("committee reaches consensus on a report hash", async () => { + const { refSlot } = await consensus.getCurrentFrame(); + + exitRequests = [ + { moduleId: 1, nodeOpId: 0, valIndex: 0, valPubkey: PUBKEYS[0] }, + { moduleId: 1, nodeOpId: 0, valIndex: 2, valPubkey: PUBKEYS[1] }, + { moduleId: 2, nodeOpId: 0, valIndex: 1, valPubkey: PUBKEYS[2] }, + ]; + + reportFields = { + consensusVersion: CONSENSUS_VERSION, + refSlot: refSlot, + exitRequestData: { + requestsCount: exitRequests.length, + dataFormat: DATA_FORMAT_LIST, + data: encodeExitRequestsDataList(exitRequests), + }, + }; + + reportHash = calcValidatorsExitBusReportDataHash(reportFields); + + await triggerConsensusOnHash(reportHash); + }); + + it("oracle gets the report hash", async () => { + const report = await oracle.getConsensusReport(); + expect(report.hash).to.equal(reportHash); + expect(report.refSlot).to.equal(reportFields.refSlot); + expect(report.processingDeadlineTime).to.equal(computeTimestampAtSlot(report.refSlot + SLOTS_PER_FRAME)); + + expect(report.processingStarted).to.equal(false); + + const frame = await consensus.getCurrentFrame(); + const procState = await oracle.getProcessingState(); + + expect(procState.currentFrameRefSlot).to.equal(frame.refSlot); + expect(procState.dataHash).to.equal(reportHash); + expect(procState.processingDeadlineTime).to.equal(computeTimestampAtSlot(frame.reportProcessingDeadlineSlot)); + expect(procState.dataSubmitted).to.equal(false); + expect(procState.dataFormat).to.equal(0); + expect(procState.requestsCount).to.equal(0); + expect(procState.requestsSubmitted).to.equal(0); + }); + + it("some time passes", async () => { + await consensus.advanceTimeBy(SECONDS_PER_FRAME / 3n); + }); + + it("a committee member submits the report data, exit requests are emitted", async () => { + const tx = await oracle.connect(member1).submitReportData(reportFields, oracleVersion); + + await expect(tx).to.emit(oracle, "ProcessingStarted").withArgs(reportFields.refSlot, reportHash); + expect((await oracle.getConsensusReport()).processingStarted).to.equal(true); + + const timestamp = await oracle.getTime(); + + for (const request of exitRequests) { + await expect(tx) + .to.emit(oracle, "ValidatorExitRequest") + .withArgs(request.moduleId, request.nodeOpId, request.valIndex, request.valPubkey, timestamp); + } + }); + + it("reports are marked as processed", async () => { + const frame = await consensus.getCurrentFrame(); + const procState = await oracle.getProcessingState(); + + expect(procState.currentFrameRefSlot).to.equal(frame.refSlot); + expect(procState.dataHash).to.equal(reportHash); + expect(procState.processingDeadlineTime).to.equal(computeTimestampAtSlot(frame.reportProcessingDeadlineSlot)); + expect(procState.dataSubmitted).to.equal(true); + expect(procState.dataFormat).to.equal(DATA_FORMAT_LIST); + expect(procState.requestsCount).to.equal(exitRequests.length); + expect(procState.requestsSubmitted).to.equal(exitRequests.length); + }); + + it("last requested validator indices are updated", async () => { + const indices1 = await oracle.getLastRequestedValidatorIndices(1n, [0n, 1n, 2n]); + const indices2 = await oracle.getLastRequestedValidatorIndices(2n, [0n, 1n, 2n]); + + expect([...indices1]).to.have.ordered.members([2n, -1n, -1n]); + expect([...indices2]).to.have.ordered.members([1n, -1n, -1n]); + }); + + it("someone submitted exit report data and triggered exit", async () => { + const tx = await oracle.triggerExitHashVerify(reportFields.exitRequestData, [0, 1, 2]); + + await expect(tx) + .to.emit(withdrawalVault, "AddFullWithdrawalRequestsCalled") + .withArgs([PUBKEYS[0], PUBKEYS[1], PUBKEYS[2]]); + }); +}); diff --git a/test/deploy/validatorExitBusOracle.ts b/test/deploy/validatorExitBusOracle.ts index 1b5e0e280..7acca3e91 100644 --- a/test/deploy/validatorExitBusOracle.ts +++ b/test/deploy/validatorExitBusOracle.ts @@ -1,7 +1,12 @@ import { expect } from "chai"; import { ethers } from "hardhat"; -import { HashConsensus__Harness, ReportProcessor__Mock, ValidatorsExitBusOracle } from "typechain-types"; +import { + HashConsensus__Harness, + ReportProcessor__Mock, + ValidatorsExitBusOracle, + WithdrawalVault__MockForVebo, +} from "typechain-types"; import { CONSENSUS_VERSION, @@ -34,6 +39,10 @@ async function deployOracleReportSanityCheckerForExitBus(lidoLocator: string, ad return await ethers.deployContract("OracleReportSanityChecker", [lidoLocator, admin, limitsList]); } +async function deployWithdrawalVault() { + return await ethers.deployContract("WithdrawalVault__MockForVebo"); +} + export async function deployVEBO( admin: string, { @@ -72,11 +81,14 @@ export async function deployVEBO( await consensus.setTime(genesisTime + initialEpoch * slotsPerEpoch * secondsPerSlot); + const withdrawalVault = await deployWithdrawalVault(); + return { locatorAddr, oracle, consensus, oracleReportSanityChecker, + withdrawalVault, }; } @@ -84,6 +96,7 @@ interface VEBOConfig { admin: string; oracle: ValidatorsExitBusOracle; consensus: HashConsensus__Harness; + withdrawalVault: WithdrawalVault__MockForVebo; dataSubmitter?: string; consensusVersion?: bigint; lastProcessingRefSlot?: number; @@ -94,6 +107,7 @@ export async function initVEBO({ admin, oracle, consensus, + withdrawalVault, dataSubmitter = undefined, consensusVersion = CONSENSUS_VERSION, lastProcessingRefSlot = 0, @@ -101,6 +115,8 @@ export async function initVEBO({ }: VEBOConfig) { const initTx = await oracle.initialize(admin, await consensus.getAddress(), consensusVersion, lastProcessingRefSlot); + await oracle.finalizeUpgrade_v2(withdrawalVault); + await oracle.grantRole(await oracle.MANAGE_CONSENSUS_CONTRACT_ROLE(), admin); await oracle.grantRole(await oracle.MANAGE_CONSENSUS_VERSION_ROLE(), admin); await oracle.grantRole(await oracle.PAUSE_ROLE(), admin);