From b68e78ed377f16e28b07696952cc08df340c3ef0 Mon Sep 17 00:00:00 2001 From: Nazar Hussain Date: Wed, 20 Mar 2024 13:44:55 +0100 Subject: [PATCH] test: move withdrawal tests to sim tests (#6507) * Support withdrawal credentials * Fix log level for errors * Fix lint warnings * Add withdrawal assertions * Update withdrawal assertions * Update the withdrawal check * Remove old withdrawal sim test * Update code as per feedback --- .github/workflows/test-sim-merge.yml | 33 -- .../contribution/testing/integration-tests.md | 18 - packages/beacon-node/package.json | 1 - .../test/sim/withdrawal-interop.test.ts | 388 ------------------ packages/cli/test/sim/multi_fork.test.ts | 3 + .../utils/simulation/SimulationEnvironment.ts | 3 +- .../test/utils/simulation/TableReporter.ts | 4 +- .../assertions/withdrawalsAssertion.ts | 96 +++++ 8 files changed, 103 insertions(+), 443 deletions(-) delete mode 100644 packages/beacon-node/test/sim/withdrawal-interop.test.ts create mode 100644 packages/cli/test/utils/simulation/assertions/withdrawalsAssertion.ts diff --git a/.github/workflows/test-sim-merge.yml b/.github/workflows/test-sim-merge.yml index 7ae64466a45d..9ec221134c83 100644 --- a/.github/workflows/test-sim-merge.yml +++ b/.github/workflows/test-sim-merge.yml @@ -71,39 +71,6 @@ jobs: ENGINE_PORT: 8551 ETH_PORT: 8661 - - name: Pull geth withdrawals - run: docker pull $GETH_WITHDRAWALS_IMAGE - - - name: Test Lodestar <> geth withdrawals - run: yarn test:sim:withdrawals - working-directory: packages/beacon-node - env: - EL_BINARY_DIR: ${{ env.GETH_WITHDRAWALS_IMAGE }} - EL_SCRIPT_DIR: gethdocker - - - name: Pull ethereumjs withdrawals - run: docker pull $ETHEREUMJS_WITHDRAWALS_IMAGE - - - name: Test Lodestar <> ethereumjs withdrawals - run: yarn test:sim:withdrawals - working-directory: packages/beacon-node - env: - EL_BINARY_DIR: ${{ env.ETHEREUMJS_WITHDRAWALS_IMAGE }} - EL_SCRIPT_DIR: ethereumjsdocker - - # Disable nethermind build as the withdrawal test config seems to be no - # longer available, enable after grabbing a build which has one - # - # - name: Pull nethermind withdrawals - # run: docker pull $NETHERMIND_WITHDRAWALS_IMAGE - - # - name: Test Lodestar <> nethermind withdrawals - # run: yarn test:sim:withdrawals - # working-directory: packages/beacon-node - # env: - # EL_BINARY_DIR: ${{ env.NETHERMIND_WITHDRAWALS_IMAGE }} - # EL_SCRIPT_DIR: netherminddocker - # Enable the blob sims when stable images # - name: Pull ethereumjs blobs # run: docker pull $ETHEREUMJS_BLOBS_IMAGE diff --git a/docs/pages/contribution/testing/integration-tests.md b/docs/pages/contribution/testing/integration-tests.md index c93cb635afca..7e515e64390f 100644 --- a/docs/pages/contribution/testing/integration-tests.md +++ b/docs/pages/contribution/testing/integration-tests.md @@ -2,24 +2,6 @@ The following tests are found in `packages/beacon-node` -#### `test:sim:withdrawals` - -This test simulates capella blocks with withdrawals. It tests lodestar against Geth and EthereumJS. - -There are two ENV variables that are required to run this test: - -- `EL_BINARY_DIR`: the docker image setup to handle the test case -- `EL_SCRIPT_DIR`: the script that will be used to start the EL client. All of the scripts can be found in `packages/beacon-node/test/scripts/el-interop` and the `EL_SCRIPT_DIR` is the sub-directory name in that root that should be used to run the test. - -The command to run this test is: - -`EL_BINARY_DIR=g11tech/geth:withdrawals EL_SCRIPT_DIR=gethdocker yarn vitest --run test/sim/withdrawal-interop.test.ts` - -The images used by this test during CI are: - -- `GETH_WITHDRAWALS_IMAGE: g11tech/geth:withdrawalsfeb8` -- `ETHEREUMJS_WITHDRAWALS_IMAGE: g11tech/ethereumjs:blobs-b6b63` - #### `test:sim:mergemock` #### `yarn test:sim:blobs` diff --git a/packages/beacon-node/package.json b/packages/beacon-node/package.json index 329ef91f5979..c1e7d090e1ad 100644 --- a/packages/beacon-node/package.json +++ b/packages/beacon-node/package.json @@ -81,7 +81,6 @@ "test:e2e": "LODESTAR_PRESET=minimal vitest --run --segfaultRetry 3 --config vitest.e2e.config.ts --dir test/e2e", "test:sim": "vitest --run test/sim/**/*.test.ts", "test:sim:mergemock": "vitest --run test/sim/mergemock.test.ts", - "test:sim:withdrawals": "vitest --run test/sim/withdrawal-interop.test.ts", "test:sim:blobs": "vitest --run test/sim/4844-interop.test.ts", "download-spec-tests": "node --loader=ts-node/esm test/spec/downloadTests.ts", "test:spec:bls": "vitest --run --config vitest.spec.config.ts --dir test/spec/bls/", diff --git a/packages/beacon-node/test/sim/withdrawal-interop.test.ts b/packages/beacon-node/test/sim/withdrawal-interop.test.ts deleted file mode 100644 index 4ba0dc2136e3..000000000000 --- a/packages/beacon-node/test/sim/withdrawal-interop.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import fs from "node:fs"; -import {describe, it, afterAll, afterEach, vi} from "vitest"; -import {fromHexString, toHexString} from "@chainsafe/ssz"; -import {LogLevel, sleep} from "@lodestar/utils"; -import {TimestampFormatCode} from "@lodestar/logger"; -import {SLOTS_PER_EPOCH, ForkName} from "@lodestar/params"; -import {ChainConfig} from "@lodestar/config"; -import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Epoch, capella, Slot, allForks} from "@lodestar/types"; -import {ValidatorProposerConfig} from "@lodestar/validator"; - -import {ExecutionPayloadStatus, PayloadAttributes} from "../../src/execution/engine/interface.js"; -import {initializeExecutionEngine} from "../../src/execution/index.js"; -import {ClockEvent} from "../../src/util/clock.js"; -import {testLogger, TestLoggerOpts} from "../utils/logger.js"; -import {getDevBeaconNode} from "../utils/node/beacon.js"; -import {BeaconRestApiServerOpts} from "../../src/api/index.js"; -import {simTestInfoTracker} from "../utils/node/simTest.js"; -import {getAndInitDevValidators} from "../utils/node/validator.js"; -import {BeaconNode, Eth1Provider} from "../../src/index.js"; -import {ZERO_HASH} from "../../src/constants/index.js"; -import {bytesToData, dataToBytes} from "../../src/eth1/provider/utils.js"; -import {defaultExecutionEngineHttpOpts} from "../../src/execution/engine/http.js"; -import {ApiError} from "../../src/api/impl/errors.js"; -import {runEL, ELStartMode, ELClient} from "../utils/runEl.js"; -import {logFilesDir} from "./params.js"; -import {shell} from "./shell.js"; - -// NOTE: How to run -// EL_BINARY_DIR=g11tech/geth:withdrawalsfeb8 EL_SCRIPT_DIR=gethdocker yarn vitest --run test/sim/withdrawal-interop.test.ts -// ``` - -/* eslint-disable no-console, @typescript-eslint/naming-convention */ - -const jwtSecretHex = "0xdc6457099f127cf0bac78de8b297df04951281909db4f58b43def7c7151e765d"; -const retries = defaultExecutionEngineHttpOpts.retries; -const retryDelay = defaultExecutionEngineHttpOpts.retryDelay; - -describe("executionEngine / ExecutionEngineHttp", function () { - if (!process.env.EL_BINARY_DIR || !process.env.EL_SCRIPT_DIR) { - throw Error( - `EL ENV must be provided, EL_BINARY_DIR: ${process.env.EL_BINARY_DIR}, EL_SCRIPT_DIR: ${process.env.EL_SCRIPT_DIR}` - ); - } - vi.setConfig({testTimeout: 10 * 60 * 1000}); - - const dataPath = fs.mkdtempSync("lodestar-test-withdrawal"); - const elSetupConfig = { - elScriptDir: process.env.EL_SCRIPT_DIR, - elBinaryDir: process.env.EL_BINARY_DIR, - }; - const elRunOptions = { - dataPath, - jwtSecretHex, - enginePort: parseInt(process.env.ENGINE_PORT ?? "8551"), - ethPort: parseInt(process.env.ETH_PORT ?? "8545"), - }; - - const controller = new AbortController(); - afterAll(async () => { - controller?.abort(); - await shell(`sudo rm -rf ${dataPath}`); - }); - - const afterEachCallbacks: (() => Promise | void)[] = []; - afterEach(async () => { - while (afterEachCallbacks.length > 0) { - const callback = afterEachCallbacks.pop(); - if (callback) await callback(); - } - }); - - it("Send stub payloads to EL", async () => { - const {elClient, tearDownCallBack} = await runEL( - {...elSetupConfig, mode: ELStartMode.PostMerge, genesisTemplate: "genesisPostWithdraw.tmpl"}, - {...elRunOptions, ttd: BigInt(0)}, - controller.signal - ); - afterEachCallbacks.push(() => tearDownCallBack()); - const {genesisBlockHash, engineRpcUrl} = elClient; - console.log({genesisBlockHash}); - - //const controller = new AbortController(); - const executionEngine = initializeExecutionEngine( - {mode: "http", urls: [engineRpcUrl], jwtSecretHex, retries, retryDelay}, - {signal: controller.signal, logger: testLogger("executionEngine")} - ); - - const withdrawalsVector = [ - {Index: 0, Validator: 65535, Recipient: "0x0000000000000000000000000000000000000000", Amount: "0"}, - { - Index: 1, - Validator: 65536, - Recipient: "0x0100000000000000000000000000000000000000", - Amount: "04523128485832663883", - }, - { - Index: 2, - Validator: 65537, - Recipient: "0x0200000000000000000000000000000000000000", - Amount: "09046256971665327767", - }, - { - Index: 3, - Validator: 65538, - Recipient: "0x0300000000000000000000000000000000000000", - Amount: "13569385457497991651", - }, - { - Index: 4, - Validator: 65539, - Recipient: "0x0400000000000000000000000000000000000000", - Amount: "18446744073709551615", - }, - { - Index: 5, - Validator: 65540, - Recipient: "0x0500000000000000000000000000000000000000", - Amount: "02261564242916331941", - }, - { - Index: 6, - Validator: 65541, - Recipient: "0x0600000000000000000000000000000000000000", - Amount: "02713877091499598330", - }, - { - Index: 7, - Validator: 65542, - Recipient: "0x0700000000000000000000000000000000000000", - Amount: "03166189940082864718", - }, - ]; - - const withdrawals = withdrawalsVector.map((testVec) => ({ - index: testVec.Index, - validatorIndex: testVec.Validator, - address: dataToBytes(testVec.Recipient, 20), - amount: BigInt(testVec.Amount), - })); - - const preparePayloadParams: PayloadAttributes = { - // Note: this is created with a pre-defined genesis.json - timestamp: 47, - prevRandao: dataToBytes("0xff00000000000000000000000000000000000000000000000000000000000000", 32), - suggestedFeeRecipient: "0xaa00000000000000000000000000000000000000", - withdrawals, - }; - const finalizedBlockHash = "0xfe950635b1bd2a416ff6283b0bbd30176e1b1125ad06fa729da9f3f4c1c61710"; - - // 1. Prepare a payload - const payloadId = await executionEngine.notifyForkchoiceUpdate( - ForkName.capella, - genesisBlockHash, - //use finalizedBlockHash as safeBlockHash - finalizedBlockHash, - finalizedBlockHash, - preparePayloadParams - ); - if (!payloadId) throw Error("InvalidPayloadId"); - - // 2. Get the payload - const payloadWithValue = await executionEngine.getPayload(ForkName.capella, payloadId); - const payload = payloadWithValue.executionPayload; - - const stateRoot = toHexString(payload.stateRoot); - const expectedStateRoot = "0x6160c5b91ea5ded26da07f6655762deddefdbed6ddab2edc60484cfb38ef16be"; - if (stateRoot !== expectedStateRoot) { - throw Error(`Invalid stateRoot expected=${expectedStateRoot} actual=${stateRoot}`); - } - - // 3. Execute the payload - const payloadResult = await executionEngine.notifyNewPayload(ForkName.capella, payload); - if (payloadResult.status !== ExecutionPayloadStatus.VALID) { - throw Error("getPayload returned payload that notifyNewPayload deems invalid"); - } - - // 4. Update the fork choice - await executionEngine.notifyForkchoiceUpdate( - ForkName.capella, - bytesToData(payload.blockHash), - genesisBlockHash, - genesisBlockHash - ); - }); - - it("Post-merge, run for a few blocks", async function () { - console.log("\n\nPost-merge, run for a few blocks\n\n"); - const {elClient, tearDownCallBack} = await runEL( - {...elSetupConfig, mode: ELStartMode.PostMerge, genesisTemplate: "genesisPostWithdraw.tmpl"}, - {...elRunOptions, ttd: BigInt(0)}, - controller.signal - ); - afterEachCallbacks.push(() => tearDownCallBack()); - - await runNodeWithEL({ - elClient, - capellaEpoch: 0, - testName: "post-merge", - }); - }); - - async function runNodeWithEL({ - elClient, - capellaEpoch, - testName, - }: { - elClient: ELClient; - capellaEpoch: Epoch; - testName: string; - }): Promise { - const {genesisBlockHash, ttd, engineRpcUrl} = elClient; - const validatorClientCount = 1; - const validatorsPerClient = 32; - - const testParams: Pick = { - SECONDS_PER_SLOT: 2, - }; - - // Just finish the run within first epoch as we only need to test if withdrawals started - const expectedEpochsToFinish = 1; - // 1 epoch of margin of error - const epochsOfMargin = 1; - const timeoutSetupMargin = 30 * 1000; // Give extra 30 seconds of margin - - // delay a bit so regular sync sees it's up to date and sync is completed from the beginning - const genesisSlotsDelay = 8; - - // TODO for g11tech: Why 4? Provide rationale for the number - const expectedWithdrawalBlocks = 4; - - const timeout = - ((epochsOfMargin + expectedEpochsToFinish) * SLOTS_PER_EPOCH + genesisSlotsDelay) * - testParams.SECONDS_PER_SLOT * - 1000; - - vi.setConfig({testTimeout: timeout + 2 * timeoutSetupMargin}); - - const genesisTime = Math.floor(Date.now() / 1000) + genesisSlotsDelay * testParams.SECONDS_PER_SLOT; - - const testLoggerOpts: TestLoggerOpts = { - level: LogLevel.info, - file: { - filepath: `${logFilesDir}/mergemock-${testName}.log`, - level: LogLevel.debug, - }, - timestampFormat: { - format: TimestampFormatCode.EpochSlot, - genesisTime, - slotsPerEpoch: SLOTS_PER_EPOCH, - secondsPerSlot: testParams.SECONDS_PER_SLOT, - }, - }; - const loggerNodeA = testLogger("Node-A", testLoggerOpts); - - const bn = await getDevBeaconNode({ - params: { - ...testParams, - ALTAIR_FORK_EPOCH: 0, - BELLATRIX_FORK_EPOCH: 0, - CAPELLA_FORK_EPOCH: capellaEpoch, - TERMINAL_TOTAL_DIFFICULTY: ttd, - }, - options: { - api: {rest: {enabled: true} as BeaconRestApiServerOpts}, - sync: {isSingleNode: true}, - network: {allowPublishToZeroPeers: true, discv5: null}, - // Now eth deposit/merge tracker methods directly available on engine endpoints - eth1: {enabled: false, providerUrls: [engineRpcUrl], jwtSecretHex}, - executionEngine: {urls: [engineRpcUrl], jwtSecretHex}, - chain: {suggestedFeeRecipient: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, - }, - validatorCount: validatorClientCount * validatorsPerClient, - logger: loggerNodeA, - genesisTime, - eth1BlockHash: fromHexString(genesisBlockHash), - withEth1Credentials: true, - }); - - afterEachCallbacks.push(async function () { - await bn.close(); - await sleep(1000); - }); - - const stopInfoTracker = simTestInfoTracker(bn, loggerNodeA); - const valProposerConfig = { - defaultConfig: { - feeRecipient: "0xcccccccccccccccccccccccccccccccccccccccc", - }, - } as ValidatorProposerConfig; - - const {validators} = await getAndInitDevValidators({ - logPrefix: "withdrawal-interop", - node: bn, - validatorsPerClient, - validatorClientCount, - startIndex: 0, - // At least one sim test must use the REST API for beacon <-> validator comms - useRestApi: true, - testLoggerOpts, - valProposerConfig, - }); - - afterEachCallbacks.push(async function () { - await Promise.all(validators.map((v) => v.close())); - }); - - await new Promise((resolve, _reject) => { - bn.chain.clock.on(ClockEvent.epoch, (epoch) => { - // Resolve only if the finalized checkpoint includes execution payload - if (epoch >= expectedEpochsToFinish) { - console.log("\nGot event epoch, stopping validators and nodes\n"); - resolve(); - } - }); - }); - - const withdrawalsBlocks = await retrieveCanonicalWithdrawals( - bn, - computeStartSlotAtEpoch(capellaEpoch), - bn.chain.forkChoice.getHead().slot - ); - - // Stop chain and un-subscribe events so the execution engine won't update it's head - // Allow some time to broadcast finalized events and complete the importBlock routine - await Promise.all(validators.map((v) => v.close())); - await bn.close(); - await sleep(500); - - if (bn.chain.beaconProposerCache.get(1) !== "0xcccccccccccccccccccccccccccccccccccccccc") { - throw Error("Invalid feeRecipient set at BN"); - } - - // Assertions to make sure the end state is good - // 1. The proper head is set - const rpc = new Eth1Provider({DEPOSIT_CONTRACT_ADDRESS: ZERO_HASH}, {providerUrls: [engineRpcUrl], jwtSecretHex}); - const consensusHead = bn.chain.forkChoice.getHead(); - const executionHeadBlock = await rpc.getBlockByNumber("latest"); - - if (!executionHeadBlock) throw Error("Execution has not head block"); - if (consensusHead.executionPayloadBlockHash !== executionHeadBlock.hash) { - throw Error( - "Consensus head not equal to execution head: " + - JSON.stringify({ - executionHeadBlockHash: executionHeadBlock.hash, - consensusHeadExecutionPayloadBlockHash: consensusHead.executionPayloadBlockHash, - consensusHeadSlot: consensusHead.slot, - }) - ); - } - - // Simple check to confirm that withdrawals were mostly processed - if (withdrawalsBlocks < expectedWithdrawalBlocks) { - throw Error(`Withdrawals withdrawalsBlocks ${withdrawalsBlocks} < ${expectedWithdrawalBlocks}`); - } - - // wait for 1 slot to print current epoch stats - await sleep(1 * bn.config.SECONDS_PER_SLOT * 1000); - stopInfoTracker(); - console.log("\n\nDone\n\n"); - } -}); - -async function retrieveCanonicalWithdrawals(bn: BeaconNode, fromSlot: Slot, toSlot: Slot): Promise { - let withdrawalsBlocks = 0; - - for (let slot = fromSlot; slot <= toSlot; slot++) { - const block = await bn.api.beacon.getBlock(slot).catch((e) => { - if (e instanceof ApiError && e.statusCode === 404) { - // Missed slot - return null; - } else { - throw e; - } - }); - - if (block) { - if ( - ((block as {data: allForks.SignedBeaconBlock}).data as capella.SignedBeaconBlock).message.body.executionPayload - ?.withdrawals.length > 0 - ) { - withdrawalsBlocks++; - } - } - } - - return withdrawalsBlocks; -} diff --git a/packages/cli/test/sim/multi_fork.test.ts b/packages/cli/test/sim/multi_fork.test.ts index f57d3a397955..6feb6adaaf8b 100644 --- a/packages/cli/test/sim/multi_fork.test.ts +++ b/packages/cli/test/sim/multi_fork.test.ts @@ -19,6 +19,7 @@ import {mergeAssertion} from "../utils/simulation/assertions/mergeAssertion.js"; import {createForkAssertion} from "../utils/simulation/assertions/forkAssertion.js"; import {createAccountBalanceAssertion} from "../utils/simulation/assertions/accountBalanceAssertion.js"; import {createExecutionHeadAssertion} from "../utils/simulation/assertions/executionHeadAssertion.js"; +import {createWithdrawalAssertions} from "../utils/simulation/assertions/withdrawalsAssertion.js"; const altairForkEpoch = 2; const bellatrixForkEpoch = 4; @@ -153,6 +154,8 @@ env.tracker.register( }) ); +env.tracker.register(createWithdrawalAssertions(env.nodes[0].id)); + await env.start({runTimeoutMs: estimatedTimeoutMs}); await connectAllNodes(env.nodes); diff --git a/packages/cli/test/utils/simulation/SimulationEnvironment.ts b/packages/cli/test/utils/simulation/SimulationEnvironment.ts index 47ef9770d3ac..88ae03cc8686 100644 --- a/packages/cli/test/utils/simulation/SimulationEnvironment.ts +++ b/packages/cli/test/utils/simulation/SimulationEnvironment.ts @@ -108,7 +108,7 @@ export class SimulationEnvironment { this.logger.info( `Starting simulation environment "${this.options.id}". currentTime=${new Date( currentTime - ).toISOString()} simulationTimeout=${prettyMsToTime(opts.runTimeoutMs)}` + ).toISOString()} simulationTimeout=${prettyMsToTime(opts.runTimeoutMs)} rootDir=${this.options.rootDir}` ); if (opts.runTimeoutMs > 0) { @@ -326,6 +326,7 @@ export class SimulationEnvironment { const genesisState = nodeUtils.initDevState(this.forkConfig, this.keysCount, { genesisTime: this.options.genesisTime + this.forkConfig.GENESIS_DELAY, eth1BlockHash: fromHexString(eth1Genesis.hash), + withEth1Credentials: true, }).state; this.genesisState = genesisState; diff --git a/packages/cli/test/utils/simulation/TableReporter.ts b/packages/cli/test/utils/simulation/TableReporter.ts index e2c77d961e5c..3ca7569a7a4a 100644 --- a/packages/cli/test/utils/simulation/TableReporter.ts +++ b/packages/cli/test/utils/simulation/TableReporter.ts @@ -139,11 +139,11 @@ export class TableReporter extends SimulationReporter const groupBySlot = arrayGroupBy(errors, (e) => String(e.slot as number)); for (const [slot, slotErrors] of Object.entries(groupBySlot)) { - if (slotErrors.length > 0) this.options.logger.info(`├─ Slot: ${slot}`); + if (slotErrors.length > 0) this.options.logger.error(`├─ Slot: ${slot}`); const groupByAssertion = arrayGroupBy(slotErrors, (e) => e.assertionId); for (const [assertionId, assertionErrors] of Object.entries(groupByAssertion)) { - if (assertionErrors.length > 0) this.options.logger.info(`├── Assertion: ${assertionId}`); + if (assertionErrors.length > 0) this.options.logger.error(`├── Assertion: ${assertionId}`); for (const error of assertionErrors) { this.options.logger.error( diff --git a/packages/cli/test/utils/simulation/assertions/withdrawalsAssertion.ts b/packages/cli/test/utils/simulation/assertions/withdrawalsAssertion.ts new file mode 100644 index 000000000000..6f7be91bf3c7 --- /dev/null +++ b/packages/cli/test/utils/simulation/assertions/withdrawalsAssertion.ts @@ -0,0 +1,96 @@ +import {capella} from "@lodestar/types"; +import {ApiError} from "@lodestar/api"; +import {MAX_WITHDRAWALS_PER_PAYLOAD} from "@lodestar/params"; +import {AssertionMatch, AssertionResult, SimulationAssertion} from "../interfaces.js"; + +type WithdrawalsData = { + withdrawalCount: number; + withdrawalAmount: bigint; + validators: Record; +}; + +export function createWithdrawalAssertions( + nodeId: T +): SimulationAssertion<`withdrawals_${T}`, WithdrawalsData> { + return { + id: `withdrawals_${nodeId}`, + match({forkConfig, epoch, node}) { + if (nodeId === node.id && epoch === forkConfig.CAPELLA_FORK_EPOCH) + return AssertionMatch.Capture | AssertionMatch.Assert; + + return AssertionMatch.None; + }, + async capture({block, node, slot}) { + const withdrawals = (block as capella.SignedBeaconBlock).message.body.executionPayload.withdrawals; + const withdrawalCount = withdrawals.length; + let withdrawalAmount = BigInt(0); + const validators: WithdrawalsData["validators"] = {}; + + for (const withdrawal of withdrawals) { + withdrawalAmount += withdrawal.amount; + const validatorDataLastSlot = await node.beacon.api.beacon.getStateValidator( + slot - 1, + withdrawal.validatorIndex + ); + const validatorDataCurrentSlot = await node.beacon.api.beacon.getStateValidator( + slot, + withdrawal.validatorIndex + ); + ApiError.assert(validatorDataLastSlot); + ApiError.assert(validatorDataCurrentSlot); + + validators[withdrawal.validatorIndex] = { + withdrawalAmount: withdrawal.amount, + balanceInLastSlot: BigInt(validatorDataLastSlot.response.data.balance), + currentBalance: BigInt(validatorDataCurrentSlot.response.data.balance), + }; + } + + return { + withdrawalCount, + withdrawalAmount, + validators, + }; + }, + async assert({store, slot}) { + const errors: AssertionResult[] = []; + + if (store[slot].withdrawalCount < MAX_WITHDRAWALS_PER_PAYLOAD) { + errors.push( + `Not enough withdrawals found. Expected ${MAX_WITHDRAWALS_PER_PAYLOAD}, got ${store[slot].withdrawalCount}` + ); + } + + for (const {currentBalance, withdrawalAmount, balanceInLastSlot} of Object.values(store[slot].validators)) { + // A validator can get sync committee reward, so difference must be greater than zero + if (currentBalance < balanceInLastSlot - withdrawalAmount) { + errors.push( + `Withdrawal amount ${withdrawalAmount} does not match the difference between balances. balanceInLastSlot=${balanceInLastSlot}, currentBalance=${currentBalance}` + ); + } + } + return errors; + }, + async dump({slot, nodes, store}) { + /* + * | Slot | Node 1 | | + * |------|-------------------|------------------|- + * | | Withdrawal Amount | Withdrawal Count | + * |------|-------------------|------------------|- + * | 1 | 100000 | 2 | + * | 2 | 150000 | 3 | + */ + const result = [`Slot,${nodes.map((n) => n.beacon.id).join(", ,")}`]; + result.push(`,${nodes.map((_) => "Withdrawal Amount,Withdrawal Count").join(",")}`); + for (let s = 1; s <= slot; s++) { + let row = `${s}`; + for (const node of nodes) { + const {withdrawalAmount, withdrawalCount} = store[node.beacon.id][s] ?? {}; + row += `,${withdrawalAmount ?? "-"},${withdrawalCount ?? "-"}`; + } + result.push(row); + } + return {"withdrawals.csv": result.join("\n")}; + }, + }; +}