From 9a3b2be8ef89ed2be6d073a4f7875b453434adcf Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Thu, 13 Aug 2026 14:45:06 +0100 Subject: [PATCH] fix: reject repeat proposals in publish block API --- .../src/api/impl/beacon/blocks/index.ts | 6 -- .../impl/beacon/blocks/publishBlock.test.ts | 78 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 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..ad347e02ad0b 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -220,12 +220,6 @@ export function getBeaconBlockApi({ // 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; } } 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..a6482b2e66ae 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,17 @@ 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"); +vi.mock("../../../../../../src/chain/validation/block.js", async (importOriginal) => { + const original = await importOriginal(); + return {...original, validateGossipBlock: vi.fn()}; +}); describe("api - beacon - publishBlockV2", () => { const config = createBeaconConfig(configDef, Buffer.alloc(32, 1)); @@ -26,6 +32,78 @@ describe("api - beacon - publishBlockV2", () => { Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); modules.network.publishBeaconBlock = vi.fn(); modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); + vi.mocked(validateGossipBlock).mockResolvedValue({skippedSlots: 0}); + }); + + describe("broadcast_validation=gossip", () => { + it("returns successfully for an already-known block", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + 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); + vi.mocked(validateGossipBlock).mockRejectedValueOnce( + new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot}) + ); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.gossip, + }) + ).resolves.toBeUndefined(); + + expect(modules.chain.persistInvalidSszValue).not.toHaveBeenCalled(); + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).not.toHaveBeenCalled(); + }); + + it("rejects a repeat proposal", async () => { + const signedBlock = ssz.phase0.SignedBeaconBlock.defaultValue(); + 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, + }); + const error = new BlockGossipError(GossipAction.IGNORE, { + code: BlockErrorCode.REPEAT_PROPOSAL, + proposerIndex: signedBlock.message.proposerIndex, + root: blockRoot, + }); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + vi.mocked(validateGossipBlock).mockRejectedValueOnce(error); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.gossip, + }) + ).rejects.toBe(error); + + expect(modules.chain.persistInvalidSszValue).toHaveBeenCalledWith( + modules.config.getForkTypes(signedBlock.message.slot).SignedBeaconBlock, + signedBlock, + "api_reject_gossip_failure" + ); + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).not.toHaveBeenCalled(); + }); }); describe("broadcast_validation=consensus_and_equivocation", () => {