From aff7f38830e6707cf2f3e4b045483db2a0c6c10b Mon Sep 17 00:00:00 2001 From: twoeths Date: Tue, 11 Aug 2026 16:06:36 +0700 Subject: [PATCH 1/6] fix: process REPEAT_PROPOSAL block --- .../src/api/impl/beacon/blocks/index.ts | 39 +++--- .../src/chain/errors/blockError.ts | 2 +- .../beacon-node/src/chain/validation/block.ts | 12 +- .../src/network/processor/gossipHandlers.ts | 27 ++++- .../impl/beacon/blocks/publishBlock.test.ts | 43 +++++++ .../test/unit/chain/validation/block.test.ts | 26 ++++ .../network/processor/gossipHandlers.test.ts | 114 ++++++++++++++++++ 7 files changed, 243 insertions(+), 20 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 6b0fb515c9e0..db9ed6556202 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -208,6 +208,10 @@ export function getBeaconBlockApi({ const blockLocallyProduced = chain.blockProductionCache.has(blockRoot); const valLogMeta = {slot, blockRoot, bodyRoot, broadcastValidation, blockLocallyProduced}; + // For a REPEAT_PROPOSAL (equivocating) block we still import it into fork choice but must not + // re-publish it (or its sidecars) to the network; this flag gates the publish thunks below. + let skipPublish = false; + switch (broadcastValidation) { case routes.beacon.BroadcastValidation.gossip: { if (!blockLocallyProduced) { @@ -215,17 +219,18 @@ export function getBeaconBlockApi({ await validateGossipBlock(config, chain, signedBlock, fork); } catch (error) { if (error instanceof BlockGossipError) { - switch (error.type.code) { - case BlockErrorCode.ALREADY_KNOWN: - // Block has already been seen, e.g. via gossip racing the publish API. Benign. - chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); - return; - case BlockErrorCode.REPEAT_PROPOSAL: - // The proposer already produced a block for this slot. For a solo setup this is a - // notable signal (duplicate-proposal attempt). For fallback / DVT setups it is - // expected on every block where another node published first. - chain.logger.warn("Ignoring repeat-proposal block during publishing", valLogMeta); - return; + if (error.type.code === BlockErrorCode.ALREADY_KNOWN) { + // Block has already been seen, e.g. via gossip racing the publish API. Benign. + chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); + return; + } + if (error.type.code === BlockErrorCode.REPEAT_PROPOSAL) { + // the proposer already produced a block for this slot + // Spec: do NOT re-publish it, but we should still import because each node may receive different block + // and this block may become canonical + chain.logger.warn("Importing repeat-proposal block without publishing", valLogMeta); + skipPublish = true; + break; } } @@ -371,9 +376,15 @@ export function getBeaconBlockApi({ // b) getting block first allows nodes to use getBlobs from local ELs and save // import latency and hopefully bandwidth // - () => network.publishBeaconBlock(signedBlock), - ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), - ...blobSidecars.map((blobSidecar) => () => network.publishBlobSidecar(blobSidecar)), + // For a REPEAT_PROPOSAL (equivocating) block we still import it (below) but do not re-publish + // it or its sidecars to the network. + ...(skipPublish + ? [] + : [ + () => network.publishBeaconBlock(signedBlock), + ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), + ...blobSidecars.map((blobSidecar) => () => network.publishBlobSidecar(blobSidecar)), + ]), () => // there is no rush to persist block since we published it to gossip anyway chain diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index f9cd342c328c..6efa93a60504 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -118,7 +118,7 @@ export type BlockErrorType = | {code: BlockErrorCode.GENESIS_BLOCK} | {code: BlockErrorCode.WOULD_REVERT_FINALIZED_SLOT; blockSlot: Slot; finalizedSlot: Slot} | {code: BlockErrorCode.ALREADY_KNOWN; root: RootHex} - | {code: BlockErrorCode.REPEAT_PROPOSAL; proposerIndex: ValidatorIndex} + | {code: BlockErrorCode.REPEAT_PROPOSAL; proposerIndex: ValidatorIndex; root: RootHex} | {code: BlockErrorCode.BLOCK_SLOT_LIMIT_REACHED} | {code: BlockErrorCode.INCORRECT_PROPOSER; proposerIndex: ValidatorIndex} | {code: BlockErrorCode.PROPOSAL_SIGNATURE_INVALID; blockSlot: Slot} diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 11ec46526e55..c168340c7551 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -95,7 +95,11 @@ export async function validateGossipBlock( await verifyBlockProposerSignature(chain, signedBlock, blockRoot, {verifyOnMainThread: false}); chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot, signedBlockHeader); } - throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); + throw new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.REPEAT_PROPOSAL, + proposerIndex, + root: blockRoot, + }); } // [REJECT] The current finalized_checkpoint is an ancestor of block -- i.e. @@ -301,7 +305,11 @@ export async function validateGossipBlock( // Check again after all async validation and the early-block delay so concurrent proposals cannot both pass if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); + throw new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.REPEAT_PROPOSAL, + proposerIndex, + root: blockRoot, + }); } chain.seenBlockProposers.add(blockSlot, proposerIndex); diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 6f79da94b2db..d4aad1af0221 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -713,9 +713,30 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand const {serializedData} = gossipData; const signedBlock = sszDeserialize(topic, serializedData); - const blockInput = await validateBeaconBlock(signedBlock, topic.boundary.fork, peerIdStr, seenTimestampSec); - chain.serializedCache.set(signedBlock, serializedData); - handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); + try { + const blockInput = await validateBeaconBlock(signedBlock, topic.boundary.fork, peerIdStr, seenTimestampSec); + chain.serializedCache.set(signedBlock, serializedData); + handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); + } catch (e) { + // Spec: IGNORE the block, ie not to re-publish to peers + // but we should still import an equivocating (REPEAT_PROPOSAL) block into fork choice because we don't + // know if this block (or the 1st known block with same slot) will become canonical yet + if ( + e instanceof BlockGossipError && + e.type.code === BlockErrorCode.REPEAT_PROPOSAL && + // this is make sure the block's proposer signature was verified, it should be true anyway + chain.seenBlockProposers.hasBlockRoot(signedBlock.message.slot, e.type.proposerIndex, e.type.root) + ) { + // blockInput was optimistically seeded in validateBeaconBlock and retained on IGNORE + const blockInput = chain.seenBlockInputCache.get(e.type.root); + if (blockInput) { + chain.serializedCache.set(signedBlock, serializedData); + handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); + } + } + // rethrow so gossipValidatorFn maps IGNORE -> TopicValidatorResult.Ignore (message not forwarded) + throw e; + } }, [GossipType.blob_sidecar]: async ({ diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index 7a4ddbdd312f..d59ca497eb4a 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -8,11 +8,18 @@ import {toRootHex} from "@lodestar/utils"; import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; import {BlockInputPreData, BlockInputSource} from "../../../../../../src/chain/blocks/blockInput/index.js"; import {verifyBlocksInEpoch} from "../../../../../../src/chain/blocks/verifyBlock.js"; +import {BlockErrorCode, BlockGossipError, GossipAction} from "../../../../../../src/chain/errors/index.js"; import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; +import {validateGossipBlock} from "../../../../../../src/chain/validation/block.js"; import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; vi.mock("../../../../../../src/chain/blocks/verifyBlock.js"); +// Partial mock: keep verifyBlockProposerSignature (used by the consensus paths), override validateGossipBlock +vi.mock("../../../../../../src/chain/validation/block.js", async (importOriginal) => ({ + ...(await importOriginal()), + validateGossipBlock: vi.fn(), +})); describe("api - beacon - publishBlockV2", () => { const config = createBeaconConfig(configDef, Buffer.alloc(32, 1)); @@ -68,6 +75,42 @@ describe("api - beacon - publishBlockV2", () => { }); }); + describe("broadcast_validation=gossip", () => { + it("imports a REPEAT_PROPOSAL (equivocating) block into fork choice but does not re-publish it", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 2; + const blockRoot = toRootHex( + modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) + ); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + // Default (gossip) broadcast validation runs validateGossipBlock; simulate an equivocating proposal + vi.mocked(validateGossipBlock).mockRejectedValue( + new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.REPEAT_PROPOSAL, + proposerIndex: signedBlock.message.proposerIndex, + root: blockRoot, + }) + ); + + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({signedBlockContents: {signedBlock}}); + + // Imported into fork choice so LMD-GHOST can weigh it ... + expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); + // ... but not re-published to the network + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + }); + }); + describe("consensus validation strategies", () => { it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( "verifies the proposer signature and records the root before publishing a local block with broadcast_validation=%s", diff --git a/packages/beacon-node/test/unit/chain/validation/block.test.ts b/packages/beacon-node/test/unit/chain/validation/block.test.ts index 60b499ce583d..2e799b36abcb 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -153,6 +153,32 @@ describe("gossip block validation", () => { ).toEqual([blockRoot, conflictingBlockRoot]); }); + it("attaches the conflicting block root to the REPEAT_PROPOSAL error", async () => { + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot( + clockSlot, + proposerIndex, + blockRoot, + signedBlockToSignedHeader(gloasConfig, signedBlock) + ); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); + + const error = (await validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas).catch( + (e) => e + )) as {type: {code: BlockErrorCode; root?: string}}; + expect(error.type.code).toBe(BlockErrorCode.REPEAT_PROPOSAL); + // The root lets the gossip/API handlers fetch the seeded blockInput and import the equivocating block + expect(error.type.root).toBe(conflictingBlockRoot); + }); + it("does not record a conflicting block root when the proposer signature is invalid", async () => { const forkTypes = gloasConfig.getForkTypes(clockSlot); const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); diff --git a/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts b/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts index 2b059b733e84..b4ca97747ae4 100644 --- a/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts +++ b/packages/beacon-node/test/unit/network/processor/gossipHandlers.test.ts @@ -8,6 +8,7 @@ import {toRootHex} from "@lodestar/utils"; import {BlockInputBlobs} from "../../../../src/chain/blocks/blockInput/blockInput.js"; import {BlockInputSource} from "../../../../src/chain/blocks/blockInput/types.js"; import {BlockError, BlockErrorCode} from "../../../../src/chain/errors/blockError.js"; +import {BlockGossipError, GossipAction} from "../../../../src/chain/errors/index.js"; import {ChainEventEmitter, IBeaconChain} from "../../../../src/chain/index.js"; import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; import {SeenBlockInput} from "../../../../src/chain/seenCache/seenGossipBlockInput.js"; @@ -62,6 +63,22 @@ describe("getGossipHandlers", () => { expect(core.reportPeer).toHaveBeenCalledOnce(); expect(core.reportPeer).toHaveBeenCalledWith(peerIdStr, PeerAction.LowToleranceError, "ExecutionEngineInvalid"); }); + + it("imports a signature-verified REPEAT_PROPOSAL (equivocating) block into fork choice but keeps IGNORE", async () => { + const {processBlock, threw} = await runBeaconBlockRepeatProposal(denebConfig, {recorded: true}); + + // imported so LMD-GHOST can weigh it ... + expect(processBlock).toHaveBeenCalledOnce(); + // ... but the gossip result stays IGNORE (handler re-throws), so the message is not forwarded + expect(threw).toBe(true); + }); + + it("does not import a REPEAT_PROPOSAL block whose root was not recorded (unverified 3rd+ proposal)", async () => { + const {processBlock, threw} = await runBeaconBlockRepeatProposal(denebConfig, {recorded: false}); + + expect(processBlock).not.toHaveBeenCalled(); + expect(threw).toBe(true); + }); }); async function runBeaconBlockProcessingError( @@ -138,6 +155,103 @@ async function runBeaconBlockProcessingError( return {core, peerIdStr}; } +async function runBeaconBlockRepeatProposal( + config: BeaconConfig, + {recorded}: {recorded: boolean} +): Promise<{processBlock: ReturnType; threw: boolean}> { + const logger = testLogger(); + const peerIdStr = "16Uiu2HAmTestGossipPeer" as PeerIdStr; + const signedBlock = ssz.deneb.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = 1; + signedBlock.message.proposerIndex = 3; + const blockRootHex = toRootHex(ssz.deneb.BeaconBlock.hashTreeRoot(signedBlock.message)); + const blockInput = BlockInputBlobs.createFromBlock({ + block: signedBlock, + blockRootHex, + forkName: ForkName.deneb, + daOutOfRange: false, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + peerIdStr, + }); + + // gossip validation rejects the 2nd distinct block for this (proposer, slot) with REPEAT_PROPOSAL + vi.mocked(validateGossipBlock).mockRejectedValue( + new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.REPEAT_PROPOSAL, + proposerIndex: signedBlock.message.proposerIndex, + root: blockRootHex, + }) + ); + + const seenBlockProposers = new SeenBlockProposers(); + if (recorded) { + // observeBlockRoot runs only after the proposer signature is verified, so hasBlockRoot(root) + // being true is the handler's proof the signature was checked (the 2nd distinct block) + seenBlockProposers.observeBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + blockRootHex, + ssz.phase0.SignedBeaconBlockHeader.defaultValue() + ); + } + + const processBlock = vi.fn().mockResolvedValue(undefined); + const chain = { + clock: new ClockStopped(1), + custodyConfig: {sampledColumns: [], custodyColumns: []} as unknown as CustodyConfig, + emitter: new ChainEventEmitter(), + getBlobsTracker: {triggerGetBlobs: vi.fn()}, + logger, + processBlock, + processProposerEquivocation: vi.fn(), + seenBlockProposers, + seenBlockInputCache: { + getByBlock: vi.fn().mockReturnValue(blockInput), + get: vi.fn().mockReturnValue(blockInput), + prune: vi.fn(), + } as unknown as SeenBlockInput, + seenPayloadEnvelopeInputCache: { + add: vi.fn(), + get: vi.fn().mockReturnValue(undefined), + prune: vi.fn(), + } as unknown as IBeaconChain["seenPayloadEnvelopeInputCache"], + serializedCache: {set: vi.fn()}, + } as unknown as IBeaconChain; + + const handlers = getGossipHandlers( + { + aggregatorTracker: {} as AggregatorTracker, + chain, + config, + core: {reportPeer: vi.fn()} as unknown as INetworkCore, + events: new NetworkEventBus(), + logger, + metrics: null, + }, + {} + ); + const beaconBlockHandler = handlers[GossipType.beacon_block] as SequentialGossipHandler; + + let threw = false; + try { + await beaconBlockHandler({ + gossipData: {serializedData: ssz.deneb.SignedBeaconBlock.serialize(signedBlock)}, + peerIdStr, + seenTimestampSec: 0, + topic: { + boundary: {fork: ForkName.deneb, epoch: 0}, + type: GossipType.beacon_block, + }, + }); + } catch { + threw = true; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + + return {processBlock, threw}; +} + function getExecutionBlockError( signedBlock: SignedBeaconBlock, code: BlockErrorCode.EXECUTION_ENGINE_ERROR | BlockErrorCode.EXECUTION_ENGINE_INVALID From 4ac0456f16a96075e44d74162ac14f5ff646c815 Mon Sep 17 00:00:00 2001 From: twoeths Date: Wed, 12 Aug 2026 10:32:04 +0700 Subject: [PATCH 2/6] fix: do not record sentPeers metrics if skipPublish --- packages/beacon-node/src/api/impl/beacon/blocks/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index db9ed6556202..957a575969bf 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -408,7 +408,8 @@ export function getBeaconBlockApi({ publishPromises ); - if (isForkPostGloas(fork)) { + if (skipPublish || isForkPostGloas(fork)) { + // For a REPEAT_PROPOSAL block we published nothing, so there are no sent-peers metrics to record. // After gloas, data columns are not published with the block but when publishing the execution payload envelope } else if (isForkPostFulu(fork)) { let columnsPublishedWithZeroPeers = 0; From f5b2b9b787aadc30154a62770dcf03265c99f4a4 Mon Sep 17 00:00:00 2001 From: twoeths Date: Thu, 13 Aug 2026 18:22:55 +0700 Subject: [PATCH 3/6] fix: revert api change --- .../src/api/impl/beacon/blocks/index.ts | 42 +++++++----------- .../impl/beacon/blocks/publishBlock.test.ts | 43 ------------------- 2 files changed, 15 insertions(+), 70 deletions(-) diff --git a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts index 957a575969bf..6b0fb515c9e0 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -208,10 +208,6 @@ export function getBeaconBlockApi({ const blockLocallyProduced = chain.blockProductionCache.has(blockRoot); const valLogMeta = {slot, blockRoot, bodyRoot, broadcastValidation, blockLocallyProduced}; - // For a REPEAT_PROPOSAL (equivocating) block we still import it into fork choice but must not - // re-publish it (or its sidecars) to the network; this flag gates the publish thunks below. - let skipPublish = false; - switch (broadcastValidation) { case routes.beacon.BroadcastValidation.gossip: { if (!blockLocallyProduced) { @@ -219,18 +215,17 @@ export function getBeaconBlockApi({ await validateGossipBlock(config, chain, signedBlock, fork); } catch (error) { if (error instanceof BlockGossipError) { - if (error.type.code === BlockErrorCode.ALREADY_KNOWN) { - // Block has already been seen, e.g. via gossip racing the publish API. Benign. - chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); - return; - } - if (error.type.code === BlockErrorCode.REPEAT_PROPOSAL) { - // the proposer already produced a block for this slot - // Spec: do NOT re-publish it, but we should still import because each node may receive different block - // and this block may become canonical - chain.logger.warn("Importing repeat-proposal block without publishing", valLogMeta); - skipPublish = true; - break; + switch (error.type.code) { + case BlockErrorCode.ALREADY_KNOWN: + // Block has already been seen, e.g. via gossip racing the publish API. Benign. + chain.logger.debug("Ignoring already-known block during publishing", valLogMeta); + return; + case BlockErrorCode.REPEAT_PROPOSAL: + // The proposer already produced a block for this slot. For a solo setup this is a + // notable signal (duplicate-proposal attempt). For fallback / DVT setups it is + // expected on every block where another node published first. + chain.logger.warn("Ignoring repeat-proposal block during publishing", valLogMeta); + return; } } @@ -376,15 +371,9 @@ export function getBeaconBlockApi({ // b) getting block first allows nodes to use getBlobs from local ELs and save // import latency and hopefully bandwidth // - // For a REPEAT_PROPOSAL (equivocating) block we still import it (below) but do not re-publish - // it or its sidecars to the network. - ...(skipPublish - ? [] - : [ - () => network.publishBeaconBlock(signedBlock), - ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), - ...blobSidecars.map((blobSidecar) => () => network.publishBlobSidecar(blobSidecar)), - ]), + () => network.publishBeaconBlock(signedBlock), + ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), + ...blobSidecars.map((blobSidecar) => () => network.publishBlobSidecar(blobSidecar)), () => // there is no rush to persist block since we published it to gossip anyway chain @@ -408,8 +397,7 @@ export function getBeaconBlockApi({ publishPromises ); - if (skipPublish || isForkPostGloas(fork)) { - // For a REPEAT_PROPOSAL block we published nothing, so there are no sent-peers metrics to record. + if (isForkPostGloas(fork)) { // After gloas, data columns are not published with the block but when publishing the execution payload envelope } else if (isForkPostFulu(fork)) { let columnsPublishedWithZeroPeers = 0; diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts index d59ca497eb4a..7a4ddbdd312f 100644 --- a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -8,18 +8,11 @@ import {toRootHex} from "@lodestar/utils"; import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; import {BlockInputPreData, BlockInputSource} from "../../../../../../src/chain/blocks/blockInput/index.js"; import {verifyBlocksInEpoch} from "../../../../../../src/chain/blocks/verifyBlock.js"; -import {BlockErrorCode, BlockGossipError, GossipAction} from "../../../../../../src/chain/errors/index.js"; import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; -import {validateGossipBlock} from "../../../../../../src/chain/validation/block.js"; import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; vi.mock("../../../../../../src/chain/blocks/verifyBlock.js"); -// Partial mock: keep verifyBlockProposerSignature (used by the consensus paths), override validateGossipBlock -vi.mock("../../../../../../src/chain/validation/block.js", async (importOriginal) => ({ - ...(await importOriginal()), - validateGossipBlock: vi.fn(), -})); describe("api - beacon - publishBlockV2", () => { const config = createBeaconConfig(configDef, Buffer.alloc(32, 1)); @@ -75,42 +68,6 @@ describe("api - beacon - publishBlockV2", () => { }); }); - describe("broadcast_validation=gossip", () => { - it("imports a REPEAT_PROPOSAL (equivocating) block into fork choice but does not re-publish it", async () => { - const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = 1; - signedBlock.message.proposerIndex = 2; - const blockRoot = toRootHex( - modules.config.getForkTypes(signedBlock.message.slot).BeaconBlock.hashTreeRoot(signedBlock.message) - ); - const blockInput = BlockInputPreData.createFromBlock({ - forkName: ForkName.phase0, - block: signedBlock, - blockRootHex: blockRoot, - source: BlockInputSource.api, - seenTimestampSec: 0, - daOutOfRange: false, - }); - modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); - // Default (gossip) broadcast validation runs validateGossipBlock; simulate an equivocating proposal - vi.mocked(validateGossipBlock).mockRejectedValue( - new BlockGossipError(GossipAction.IGNORE, { - code: BlockErrorCode.REPEAT_PROPOSAL, - proposerIndex: signedBlock.message.proposerIndex, - root: blockRoot, - }) - ); - - const api = getBeaconBlockApi(modules); - await api.publishBlockV2({signedBlockContents: {signedBlock}}); - - // Imported into fork choice so LMD-GHOST can weigh it ... - expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); - // ... but not re-published to the network - expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); - }); - }); - describe("consensus validation strategies", () => { it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( "verifies the proposer signature and records the root before publishing a local block with broadcast_validation=%s", From c7d0c64c4f5ea384a70feb9a231f0adcb70eddaf Mon Sep 17 00:00:00 2001 From: twoeths Date: Thu, 13 Aug 2026 18:30:58 +0700 Subject: [PATCH 4/6] chore: add more comments --- packages/beacon-node/src/network/processor/gossipHandlers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index d4aad1af0221..a9bbcce6ca16 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -731,6 +731,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand const blockInput = chain.seenBlockInputCache.get(e.type.root); if (blockInput) { chain.serializedCache.set(signedBlock, serializedData); + // this is technically not a valid gossip block but gossip validation is a cheap subset of checks + // this runs the full state transition, so importing an equivocating-but-valid block here is safe. handleValidBeaconBlock(blockInput, peerIdStr, seenTimestampSec); } } From d87bb3acd2899c4a76fc73e80d925ae5ef777ab4 Mon Sep 17 00:00:00 2001 From: twoeths Date: Thu, 13 Aug 2026 18:47:13 +0700 Subject: [PATCH 5/6] fix: merge issue --- packages/beacon-node/test/unit/chain/validation/block.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/beacon-node/test/unit/chain/validation/block.test.ts b/packages/beacon-node/test/unit/chain/validation/block.test.ts index bc4a23c65e9d..e849d874c2af 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -186,7 +186,7 @@ describe("gossip block validation", () => { blockRoot, signedBlockToSignedHeader(gloasConfig, signedBlock) ); - chain.seenBlockProposers.add(clockSlot, proposerIndex); + chain.seenBlockProposers.add(clockSlot, proposerIndex, blockRoot); const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); From 09c50372f171d9e3fc2b8c35495b55481c92c05b Mon Sep 17 00:00:00 2001 From: twoeths Date: Thu, 13 Aug 2026 18:53:40 +0700 Subject: [PATCH 6/6] fix: revert unnecessary unit test --- .../test/unit/chain/validation/block.test.ts | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/packages/beacon-node/test/unit/chain/validation/block.test.ts b/packages/beacon-node/test/unit/chain/validation/block.test.ts index e849d874c2af..86b93a89ae3b 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -174,32 +174,6 @@ describe("gossip block validation", () => { ).toEqual([blockRoot, conflictingBlockRoot]); }); - it("attaches the conflicting block root to the REPEAT_PROPOSAL error", async () => { - const forkTypes = gloasConfig.getForkTypes(clockSlot); - const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); - signedBlock.message.slot = clockSlot; - signedBlock.message.proposerIndex = proposerIndex; - const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); - chain.seenBlockProposers.observeBlockRoot( - clockSlot, - proposerIndex, - blockRoot, - signedBlockToSignedHeader(gloasConfig, signedBlock) - ); - chain.seenBlockProposers.add(clockSlot, proposerIndex, blockRoot); - - const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); - conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); - const conflictingBlockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(conflictingBlock.message)); - - const error = (await validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas).catch( - (e) => e - )) as {type: {code: BlockErrorCode; root?: string}}; - expect(error.type.code).toBe(BlockErrorCode.REPEAT_PROPOSAL); - // The root lets the gossip/API handlers fetch the seeded blockInput and import the equivocating block - expect(error.type.root).toBe(conflictingBlockRoot); - }); - it("does not record a conflicting block root when the proposer signature is invalid", async () => { const forkTypes = gloasConfig.getForkTypes(clockSlot); const signedBlock = forkTypes.SignedBeaconBlock.defaultValue();