diff --git a/packages/beacon-node/src/chain/validation/attesterSlashing.ts b/packages/beacon-node/src/chain/validation/attesterSlashing.ts index 3ddfac6166de..dd08627d1593 100644 --- a/packages/beacon-node/src/chain/validation/attesterSlashing.ts +++ b/packages/beacon-node/src/chain/validation/attesterSlashing.ts @@ -2,6 +2,7 @@ import { assertValidAttesterSlashing, getAttesterSlashableIndices, getAttesterSlashingSignatureSets, + isSlashableValidator, } from "@lodestar/state-transition"; import {AttesterSlashing} from "@lodestar/types"; import {AttesterSlashingError, AttesterSlashingErrorCode, GossipAction} from "../errors/index.js"; @@ -58,6 +59,18 @@ export async function validateAttesterSlashing( }); } + // Additional gossip-side check: assertValidAttesterSlashing() validates slashable data/signatures, + // but it does not enforce that any intersecting validator is currently slashable. + // Spec reference: process_attester_slashing() requires slashed_any == True after iterating intersecting indices. + const currentEpoch = state.epochCtx.epoch; + const validators = state.validators; + if (!intersectingIndices.some((index) => isSlashableValidator(validators.getReadonly(index), currentEpoch))) { + throw new AttesterSlashingError(GossipAction.REJECT, { + code: AttesterSlashingErrorCode.INVALID, + error: Error("AttesterSlashing has no slashable validators"), + }); + } + const signatureSets = getAttesterSlashingSignatureSets(chain.config, state.slot, attesterSlashing); if (!(await chain.bls.verifySignatureSets(signatureSets, {batchable: true, priority: prioritizeBls}))) { throw new AttesterSlashingError(GossipAction.REJECT, { diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 73e6e2944097..dd699caf25fa 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -104,7 +104,7 @@ export async function validateGossipBlock( // [REJECT] The block is from a higher slot than its parent. if (parentBlock.slot >= blockSlot) { - throw new BlockGossipError(GossipAction.IGNORE, { + throw new BlockGossipError(GossipAction.REJECT, { code: BlockErrorCode.NOT_LATER_THAN_PARENT, parentSlot: parentBlock.slot, slot: blockSlot, diff --git a/packages/beacon-node/test/spec/presets/networking.test.ts b/packages/beacon-node/test/spec/presets/networking.test.ts index eb5c550f143c..aa4acc74e8ec 100644 --- a/packages/beacon-node/test/spec/presets/networking.test.ts +++ b/packages/beacon-node/test/spec/presets/networking.test.ts @@ -1,12 +1,14 @@ import path from "node:path"; +import {it} from "vitest"; import {config} from "@lodestar/config/default"; -import {ACTIVE_PRESET} from "@lodestar/params"; -import {InputType} from "@lodestar/spec-test-util"; +import {ACTIVE_PRESET, ForkName} from "@lodestar/params"; +import {InputType, describeDirectorySpecTest} from "@lodestar/spec-test-util"; import {bigIntToBytes} from "@lodestar/utils"; import {computeColumnsForCustodyGroup, getCustodyGroups} from "../../../src/util/dataColumns.js"; import {ethereumConsensusSpecsTests} from "../specTestVersioning.js"; -import {specTestIterator} from "../utils/specTestIterator.js"; -import {RunnerType, TestRunnerFn} from "../utils/types.js"; +import {readdirSyncSpec, specTestIterator} from "../utils/specTestIterator.js"; +import {runGossipValidationTest} from "../utils/gossipValidation.js"; +import {RunnerType, TestRunnerCustom} from "../utils/types.js"; type ComputeColumnForCustodyGroupInput = { custody_group: number; @@ -28,30 +30,63 @@ const networkingFns: Record = { }, }; -const networking: TestRunnerFn = (_fork, testName) => { - return { - testFunction: (testcase) => { - const networkingFn = networkingFns[testName]; - if (networkingFn === undefined) { - throw Error(`No networkingFn for ${testName}`); - } - - return networkingFn(testcase.meta); - }, - options: { - inputTypes: {meta: InputType.YAML}, - getExpected: (testCase) => testCase.meta.result.map(Number), - // Do not manually skip tests here, do it in packages/beacon-node/test/spec/presets/index.test.ts - }, - }; -}; - type NetworkingTestCase = { meta: { result: number[]; }; }; +// Tests that may need to be skipped because the checks are performed +// outside gossip validation in Lodestar (same pattern as Teku). +// Each skip must have a documented reason. +const SKIPPED_GOSSIP_TESTS = new Set([ + // Lodestar currently classifies invalid attestation signature on gossip as IGNORE. + // Spec fixture expects REJECT. + "gossip_beacon_attestation__reject_invalid_signature", +]); + +const GOSSIP_HANDLERS = new Set([ + "gossip_beacon_block", + "gossip_beacon_aggregate_and_proof", + "gossip_beacon_attestation", + "gossip_proposer_slashing", + "gossip_attester_slashing", + "gossip_voluntary_exit", +]); + +const networking: TestRunnerCustom = (fork, testHandler, testSuite, testSuiteDirpath) => { + if (GOSSIP_HANDLERS.has(testHandler)) { + // Gossip validation test — iterate test cases ourselves + for (const testCaseName of readdirSyncSpec(testSuiteDirpath)) { + if (SKIPPED_GOSSIP_TESTS.has(testCaseName)) { + it.skip(`${testCaseName} (skipped — check done outside gossip validation)`, () => {}); + continue; + } + + const testCaseDir = path.join(testSuiteDirpath, testCaseName); + it(testCaseName, async () => { + await runGossipValidationTest(fork as ForkName, testHandler, testCaseDir); + }, 30_000); + } + } else { + // Existing networking function tests (compute_columns_for_custody_group, etc.) + const networkingFn = networkingFns[testHandler]; + if (networkingFn === undefined) { + throw Error(`No networkingFn for ${testHandler}`); + } + + describeDirectorySpecTest( + `${fork}/${testHandler}/${testSuite}`, + testSuiteDirpath, + (testcase) => networkingFn(testcase.meta), + { + inputTypes: {meta: InputType.YAML}, + getExpected: (testCase) => testCase.meta.result.map(Number), + } + ); + } +}; + specTestIterator(path.join(ethereumConsensusSpecsTests.outputDir, "tests", ACTIVE_PRESET), { - networking: {type: RunnerType.default, fn: networking}, + networking: {type: RunnerType.custom, fn: networking}, }); diff --git a/packages/beacon-node/test/spec/utils/gossipValidation.ts b/packages/beacon-node/test/spec/utils/gossipValidation.ts new file mode 100644 index 000000000000..bbfbfeeec392 --- /dev/null +++ b/packages/beacon-node/test/spec/utils/gossipValidation.ts @@ -0,0 +1,511 @@ +import fs from "node:fs"; +import path from "node:path"; +import {EventEmitter} from "node:events"; +import {generateKeyPair} from "@libp2p/crypto/keys"; +import {createBeaconConfig} from "@lodestar/config"; +import {ForkName} from "@lodestar/params"; +import {RootHex, SubnetID, sszTypesFor} from "@lodestar/types"; +import { + BeaconStateAllForks, + createCachedBeaconState, + createPubkeyCache, + computeEpochAtSlot, + computeStartSlotAtEpoch, + isExecutionStateType, + syncPubkeys, +} from "@lodestar/state-transition"; +import {fromHex, toHex, toHexString, toRootHex} from "@lodestar/utils"; +import snappy from "snappy"; +import {expect} from "vitest"; +import {load as loadYaml} from "js-yaml"; + +import {BlockInputPreData, BlockInputSource} from "../../../src/chain/blocks/blockInput/index.js"; +import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; +import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; +import {defaultChainOptions} from "../../../src/chain/options.js"; +import {GossipAction, GossipActionError} from "../../../src/chain/errors/gossipValidation.js"; +import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; +import {ExecutionEngineMockBackend} from "../../../src/execution/engine/mock.js"; +import {getExecutionEngineFromBackend} from "../../../src/execution/index.js"; +import {validateGossipBlock} from "../../../src/chain/validation/block.js"; +import {validateGossipProposerSlashing} from "../../../src/chain/validation/proposerSlashing.js"; +import {validateGossipAttesterSlashing} from "../../../src/chain/validation/attesterSlashing.js"; +import {validateGossipVoluntaryExit} from "../../../src/chain/validation/voluntaryExit.js"; +import {validateGossipAggregateAndProof} from "../../../src/chain/validation/aggregateAndProof.js"; +import {validateGossipAttestationsSameAttData, GossipAttestation} from "../../../src/chain/validation/attestation.js"; +import {getBeaconAttestationGossipIndex, getSlotFromBeaconAttestationSerialized} from "../../../src/util/sszBytes.js"; +import type {IClock} from "../../../src/util/clock.js"; +import {getMockedBeaconDb} from "../../mocks/mockedBeaconDb.js"; +import {getConfig} from "../../utils/config.js"; +import {testLogger} from "../../utils/logger.js"; +import {assertCorrectProgressiveBalances} from "../config.js"; + +/** + * A test clock that models gossip clock disparity from a millisecond timestamp. + * Unlike ClockStopped which returns exact slot values, this clock computes + * currentSlotWithGossipDisparity correctly for spec conformance tests. + */ +class GossipTestClock extends EventEmitter implements IClock { + genesisTime: number; + private currentTimeMs: number; + private secondsPerSlot: number; + private maxDisparityMs: number; + + constructor(genesisTimeSec: number, secondsPerSlot: number, maxDisparityMs: number) { + super(); + this.genesisTime = genesisTimeSec; + this.currentTimeMs = genesisTimeSec * 1000; + this.secondsPerSlot = secondsPerSlot; + this.maxDisparityMs = maxDisparityMs; + } + + get currentSlot(): number { + return Math.floor((this.currentTimeMs / 1000 - this.genesisTime) / this.secondsPerSlot); + } + + get currentSlotWithGossipDisparity(): number { + // Model: if we're within maxDisparityMs of next slot, return next slot + // Spec: current_time_ms + MAXIMUM_GOSSIP_CLOCK_DISPARITY >= block_time_ms + // This means: nextSlotTimeMs - currentTimeMs <= maxDisparityMs + const slot = this.currentSlot; + const nextSlotTimeMs = (this.genesisTime + (slot + 1) * this.secondsPerSlot) * 1000; + if (nextSlotTimeMs - this.currentTimeMs <= this.maxDisparityMs) { + return slot + 1; + } + return slot; + } + + get currentEpoch(): number { + return computeEpochAtSlot(this.currentSlot); + } + + slotWithFutureTolerance(toleranceSec: number): number { + return Math.floor(((this.currentTimeMs / 1000 + toleranceSec) - this.genesisTime) / this.secondsPerSlot); + } + + slotWithPastTolerance(toleranceSec: number): number { + return Math.floor(((this.currentTimeMs / 1000 - toleranceSec) - this.genesisTime) / this.secondsPerSlot); + } + + isCurrentSlotGivenGossipDisparity(slot: number): boolean { + const current = this.currentSlot; + if (slot === current) return true; + const nextSlotTimeMs = (this.genesisTime + (current + 1) * this.secondsPerSlot) * 1000; + if (nextSlotTimeMs - this.currentTimeMs <= this.maxDisparityMs) { + return slot === current + 1; + } + const currentSlotTimeMs = (this.genesisTime + current * this.secondsPerSlot) * 1000; + if (this.currentTimeMs - currentSlotTimeMs <= this.maxDisparityMs) { + return slot === current - 1; + } + return false; + } + + async waitForSlot(): Promise { + // Not used in tests + } + + secFromSlot(slot: number, toSec?: number): number { + const slotTimeSec = this.genesisTime + slot * this.secondsPerSlot; + return (toSec ?? this.currentTimeMs / 1000) - slotTimeSec; + } + + msFromSlot(slot: number, toMs?: number): number { + const slotTimeMs = (this.genesisTime + slot * this.secondsPerSlot) * 1000; + return (toMs ?? this.currentTimeMs) - slotTimeMs; + } + + /** Set the current time in milliseconds since genesis */ + setCurrentTimeMs(ms: number): void { + this.currentTimeMs = this.genesisTime * 1000 + ms; + } + + /** Also support setSlot for block import phases */ + setSlot(slot: number): void { + this.currentTimeMs = (this.genesisTime + slot * this.secondsPerSlot) * 1000; + } +} + +interface MetaYaml { + topic: string; + blocks?: {block: string; failed?: boolean}[]; + finalized_checkpoint?: {epoch: number; root?: string; block?: string}; + current_time_ms?: number; + messages: { + offset_ms?: number; + subnet_id?: number; + message: string; + expected: "valid" | "ignore" | "reject"; + reason?: string; + }[]; +} + +function loadMeta(testCaseDir: string): MetaYaml { + const raw = fs.readFileSync(path.join(testCaseDir, "meta.yaml"), "utf8"); + return loadYaml(raw) as MetaYaml; +} + +function loadSszSnappy(testCaseDir: string, name: string): Uint8Array { + const compressed = fs.readFileSync(path.join(testCaseDir, `${name}.ssz_snappy`)); + const decompressed = snappy.uncompressSync(compressed); + return typeof decompressed === "string" ? Buffer.from(decompressed) : decompressed; +} + +function loadState(testCaseDir: string, fork: ForkName): BeaconStateAllForks { + const bytes = loadSszSnappy(testCaseDir, "state"); + return sszTypesFor(fork).BeaconState.deserializeToViewDU(bytes) as BeaconStateAllForks; +} + +type FinalizedCheckpoint = {epoch: number; rootHex: RootHex}; + +function loadBlockRootHex(testCaseDir: string, fork: ForkName, name: string): RootHex { + const signedBlock = sszTypesFor(fork).SignedBeaconBlock.deserialize(loadSszSnappy(testCaseDir, name)) as any; + return toHex(sszTypesFor(fork).BeaconBlock.hashTreeRoot(signedBlock.message)); +} + +function resolveFinalizedCheckpoint( + meta: MetaYaml, + testCaseDir: string, + fork: ForkName, + blockRootsByName: Map +): FinalizedCheckpoint | null { + const cp = meta.finalized_checkpoint; + if (!cp) return null; + + let rootHex: RootHex | null = null; + if (cp.root) { + rootHex = toRootHex(fromHex(cp.root)); + } + if (cp.block) { + const blockRootHex = blockRootsByName.get(cp.block) ?? loadBlockRootHex(testCaseDir, fork, cp.block); + blockRootsByName.set(cp.block, blockRootHex); + if (rootHex !== null && rootHex !== blockRootHex) { + throw new Error(`finalized_checkpoint.root does not match root of ${cp.block}`); + } + rootHex = blockRootHex; + } + + if (rootHex === null) { + throw new Error("finalized_checkpoint must include either root or block"); + } + + return {epoch: cp.epoch, rootHex}; +} + +function setFinalizedCheckpoint(chain: BeaconChain, checkpoint: FinalizedCheckpoint): void { + const checkpointWithHex = { + epoch: checkpoint.epoch, + root: fromHex(checkpoint.rootHex), + rootHex: checkpoint.rootHex, + }; + + const forkChoice = chain.forkChoice as unknown as { + fcStore: { + finalizedCheckpoint: typeof checkpointWithHex; + unrealizedFinalizedCheckpoint: typeof checkpointWithHex; + }; + protoArray: { + finalizedEpoch: number; + finalizedRoot: RootHex; + }; + updateHead?: () => unknown; + }; + + forkChoice.fcStore.finalizedCheckpoint = checkpointWithHex; + forkChoice.fcStore.unrealizedFinalizedCheckpoint = checkpointWithHex; + forkChoice.protoArray.finalizedEpoch = checkpoint.epoch; + forkChoice.protoArray.finalizedRoot = checkpoint.rootHex; + forkChoice.updateHead?.(); +} + +function isDescendantAtFinalizedCheckpoint(chain: BeaconChain, blockRootHex: RootHex, checkpoint: FinalizedCheckpoint): boolean { + try { + const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); + return chain.forkChoice.getAncestor(blockRootHex, finalizedSlot) === checkpoint.rootHex; + } catch { + return false; + } +} + +function mapErrorToResult(e: unknown): "valid" | "ignore" | "reject" { + if (e instanceof GossipActionError) { + return e.action === GossipAction.IGNORE ? "ignore" : "reject"; + } + // Some validation paths throw raw errors instead of GossipActionError + // (e.g., validator index out of range → TypeError on undefined access). + // The spec expects these to be REJECT. + if (e instanceof TypeError || e instanceof RangeError || e instanceof Error) { + return "reject"; + } + throw e; +} + +export async function runGossipValidationTest( + fork: ForkName, + _topicHandler: string, + testCaseDir: string +): Promise { + const meta = loadMeta(testCaseDir); + const anchorState = loadState(testCaseDir, fork); + const config = getConfig(fork); + const beaconConfig = createBeaconConfig(config, anchorState.genesisValidatorsRoot); + + const genesisTimeSec = anchorState.genesisTime; + const clock = new GossipTestClock( + genesisTimeSec, + beaconConfig.SECONDS_PER_SLOT, + beaconConfig.MAXIMUM_GOSSIP_CLOCK_DISPARITY + ); + + const controller = new AbortController(); + const executionEngineBackend = new ExecutionEngineMockBackend({ + onlyPredefinedResponses: false, + genesisBlockHash: isExecutionStateType(anchorState) + ? toHexString(anchorState.latestExecutionPayloadHeader.blockHash) + : ZERO_HASH_HEX, + }); + const executionEngine = getExecutionEngineFromBackend(executionEngineBackend, { + signal: controller.signal, + logger: testLogger("executionEngine"), + }); + + const pubkeyCache = createPubkeyCache(); + syncPubkeys(pubkeyCache, anchorState.validators.getAllReadonlyValues()); + const cachedState = createCachedBeaconState( + anchorState, + {config: beaconConfig, pubkeyCache}, + {skipSyncPubkeys: true} + ); + + const chain = new BeaconChain( + { + ...defaultChainOptions, + // Disable non-spec maxSkipSlots check for conformance tests + maxSkipSlots: undefined, + blsVerifyAllMainThread: true, + disableArchiveOnCheckpoint: true, + disableLightClientServerOnImportBlockHead: true, + disableOnBlockError: true, + disablePrepareNextSlot: true, + assertCorrectProgressiveBalances, + proposerBoost: true, + proposerBoostReorg: true, + }, + { + privateKey: await generateKeyPair("secp256k1"), + config: beaconConfig, + pubkeyCache, + db: getMockedBeaconDb(), + dataDir: ".", + dbName: ",", + logger: testLogger("spec-gossip"), + processShutdownCallback: () => {}, + clock, + metrics: null, + validatorMonitor: null, + anchorState: cachedState, + isAnchorStateFinalized: true, + executionEngine, + executionBuilder: undefined, + } + ); + + chain.emitter.removeAllListeners(ChainEvent.forkChoiceFinalized); + + try { + const blockRootsByName = new Map(); + + if (meta.blocks) { + for (const blockEntry of meta.blocks) { + const signedBlock = sszTypesFor(fork).SignedBeaconBlock.deserialize( + loadSszSnappy(testCaseDir, blockEntry.block) + ) as any; + const slot = signedBlock.message.slot; + const blockRootHex = toHex(beaconConfig.getForkTypes(slot).BeaconBlock.hashTreeRoot(signedBlock.message)); + blockRootsByName.set(blockEntry.block, blockRootHex); + + if (blockEntry.failed) continue; + + // Skip genesis block — it's already the anchor state + if (slot === 0) continue; + + clock.setSlot(slot); + chain.forkChoice.updateTime(slot); + + const blockImport = BlockInputPreData.createFromBlock({ + forkName: fork, + block: signedBlock, + blockRootHex, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + daOutOfRange: false, + }); + + await chain.processBlock(blockImport, { + seenTimestampSec: 0, + validBlobSidecars: BlobSidecarValidation.Full, + importAttestations: AttestationImportOpt.Force, + validSignatures: false, + }); + } + } + + const finalizedCheckpoint = resolveFinalizedCheckpoint(meta, testCaseDir, fork, blockRootsByName); + if (finalizedCheckpoint) { + setFinalizedCheckpoint(chain, finalizedCheckpoint); + } + + const failedBlockRoots = new Set( + (meta.blocks ?? []) + .filter((blockEntry) => blockEntry.failed === true) + .map((blockEntry) => { + const rootHex = blockRootsByName.get(blockEntry.block); + if (!rootHex) throw new Error(`Missing cached root for block ${blockEntry.block}`); + return rootHex; + }) + ); + + const baseCurrentTimeMs = meta.current_time_ms ?? 0; + for (const message of meta.messages) { + const messageTimeMs = baseCurrentTimeMs + (message.offset_ms ?? 0); + clock.setCurrentTimeMs(messageTimeMs); + + let result: "valid" | "ignore" | "reject"; + try { + await validateMessageForTopic( + chain, + fork, + meta.topic, + testCaseDir, + message, + failedBlockRoots, + finalizedCheckpoint + ); + result = "valid"; + } catch (e) { + result = mapErrorToResult(e); + } + + expect(result).toBe(message.expected); + } + } finally { + controller.abort(); + await chain.close(); + } +} + +async function validateMessageForTopic( + chain: BeaconChain, + fork: ForkName, + topic: string, + testCaseDir: string, + message: MetaYaml["messages"][number], + failedBlockRoots: Set, + finalizedCheckpoint: FinalizedCheckpoint | null +): Promise { + const bytes = loadSszSnappy(testCaseDir, message.message); + + switch (topic) { + case "beacon_block": { + const signedBlock = sszTypesFor(fork).SignedBeaconBlock.deserialize(bytes) as any; + const parentRootHex = toRootHex(signedBlock.message.parentRoot); + + if (failedBlockRoots.has(parentRootHex)) { + throw new Error("Block parent failed validation"); + } + + if ( + finalizedCheckpoint !== null && + !isDescendantAtFinalizedCheckpoint(chain, parentRootHex, finalizedCheckpoint) + ) { + throw new Error("Block is not a descendant of finalized checkpoint"); + } + + await validateGossipBlock(chain.config, chain, signedBlock, fork); + chain.seenBlockProposers.add(signedBlock.message.slot, signedBlock.message.proposerIndex); + break; + } + + case "beacon_aggregate_and_proof": { + const aggregate = sszTypesFor(fork).SignedAggregateAndProof.deserialize(bytes) as any; + const beaconBlockRootHex = toRootHex(aggregate.message.aggregate.data.beaconBlockRoot); + + if (failedBlockRoots.has(beaconBlockRootHex)) { + throw new Error("Aggregate votes for block that failed validation"); + } + + if ( + finalizedCheckpoint !== null && + !isDescendantAtFinalizedCheckpoint(chain, beaconBlockRootHex, finalizedCheckpoint) + ) { + throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_FINALIZED_NOT_ANCESTOR"}); + } + + await validateGossipAggregateAndProof(fork, chain, aggregate, bytes); + break; + } + + case "beacon_attestation": { + const attestation = sszTypesFor(fork).Attestation.deserialize(bytes) as any; + const beaconBlockRootHex = toRootHex(attestation.data.beaconBlockRoot); + + if (failedBlockRoots.has(beaconBlockRootHex)) { + throw new Error("Attestation votes for block that failed validation"); + } + + if ( + finalizedCheckpoint !== null && + !isDescendantAtFinalizedCheckpoint(chain, beaconBlockRootHex, finalizedCheckpoint) + ) { + throw new GossipActionError(GossipAction.IGNORE, {code: "SPEC_FINALIZED_NOT_ANCESTOR"}); + } + + const attDataBase64 = getBeaconAttestationGossipIndex(fork, bytes); + const attSlot = getSlotFromBeaconAttestationSerialized(fork, bytes); + if (attDataBase64 == null || attSlot == null) { + throw new Error("Could not extract attestation gossip index/slot from bytes"); + } + + const gossipAttestation: GossipAttestation = { + attestation: null, + serializedData: bytes, + attSlot, + attDataBase64, + subnet: (message.subnet_id ?? 0) as SubnetID, + }; + + const batchResult = await validateGossipAttestationsSameAttData(fork, chain, [gossipAttestation]); + const first = batchResult.results[0]; + if (first?.err) throw first.err; + break; + } + + case "proposer_slashing": { + const slashing = sszTypesFor(fork).ProposerSlashing.deserialize(bytes) as any; + await validateGossipProposerSlashing(chain, slashing); + // Mirror gossip handler: insert into opPool so duplicate detection works + chain.opPool.insertProposerSlashing(slashing); + break; + } + + case "attester_slashing": { + const slashing = sszTypesFor(fork).AttesterSlashing.deserialize(bytes) as any; + await validateGossipAttesterSlashing(chain, slashing); + // Mirror gossip handler: insert into opPool + fork choice + chain.opPool.insertAttesterSlashing(fork, slashing); + chain.forkChoice.onAttesterSlashing(slashing); + break; + } + + case "voluntary_exit": { + const exit = sszTypesFor(fork).SignedVoluntaryExit.deserialize(bytes) as any; + await validateGossipVoluntaryExit(chain, exit); + // Mirror gossip handler: insert into opPool so duplicate detection works + chain.opPool.insertVoluntaryExit(exit); + break; + } + + default: + throw new Error(`Unknown gossip topic: ${topic}`); + } +}