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 005a812d19ab..a12b718fd231 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -58,7 +58,7 @@ import { ProduceFullGloas, } from "../../../../chain/produceBlock/index.js"; import {RegenCaller} from "../../../../chain/regen/index.js"; -import {validateGossipBlock} from "../../../../chain/validation/block.js"; +import {validateGossipBlock, verifyBlockProposerSignature} from "../../../../chain/validation/block.js"; import {validateApiExecutionPayloadBid} from "../../../../chain/validation/executionPayloadBid.js"; import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js"; import {OpSource} from "../../../../chain/validatorMonitor.js"; @@ -268,7 +268,6 @@ export function getBeaconBlockApi({ await verifyBlocksInEpoch.call(chain as BeaconChain, parentBlock, [blockForImport], null, { ...opts, verifyOnly: true, - skipVerifyBlockSignatures: true, skipVerifyExecutionPayload: true, seenTimestampSec, }); @@ -285,12 +284,26 @@ export function getBeaconBlockApi({ chain.logger.debug("Consensus validated while publishing block", valLogMeta); - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; - if (chain.opts.broadcastValidationStrictness === "error") { - throw Error(message); + // Non-local blocks had their proposer and block-body signatures checked by verifyBlocksInEpoch above + // Locally produced blocks already passed block production validation, so only their proposer signature is unchecked + // Verify that signature and observe the block root here + if (blockLocallyProduced) { + try { + await verifyBlockProposerSignature(chain, signedBlock, blockRoot); + chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot); + } catch (e) { + chain.logger.error( + "Proposer signature validation failed while publishing the block", + valLogMeta, + e as Error + ); + chain.persistInvalidSszValue( + chain.config.getForkTypes(slot).SignedBeaconBlock, + signedBlock, + "api_reject_consensus_failure" + ); + throw e; } - chain.logger.warn(message, valLogMeta); } break; } @@ -318,6 +331,25 @@ export function getBeaconBlockApi({ await sleep(msToBlockSlot); } + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + const conflictingRoots = chain.seenBlockProposers.getConflictingBlockRoots( + slot, + signedBlock.message.proposerIndex, + blockRoot + ); + if (conflictingRoots.length > 0) { + chain.logger.warn("Not publishing block due to proposer equivocation", { + ...valLogMeta, + conflictingRoots: conflictingRoots.join(", "), + }); + throw new ApiError( + 400, + `Block is a proposer equivocation, conflicting block roots: ${conflictingRoots.join(", ")}` + ); + } + chain.logger.debug("Equivocation validated while publishing the block", valLogMeta); + } + // TODO: Validate block const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipBlock.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); @@ -775,15 +807,6 @@ export function getBeaconBlockApi({ throw new ApiError(400, (error as Error).message); } chain.logger.debug("Consensus validated while publishing execution payload envelope", valLogMeta); - - // TODO GLOAS: check the block is not a proposer equivocation before publishing the envelope - if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { - const message = `Equivocation checks not yet implemented for broadcastValidation=${broadcastValidation}`; - if (chain.opts.broadcastValidationStrictness === "error") { - throw Error(message); - } - chain.logger.warn(message, valLogMeta); - } break; } @@ -886,6 +909,27 @@ export function getBeaconBlockApi({ await sleep(msToBlockSlot); } + // Keep this as the final async validation before publishing. A conflicting block may be observed while the + // envelope, blob data, or slot timing is being validated above. + if (broadcastValidation === routes.beacon.BroadcastValidation.consensusAndEquivocation) { + const conflictingRoots = chain.seenBlockProposers.getConflictingBlockRoots( + slot, + payloadInput.proposerIndex, + blockRootHex + ); + if (conflictingRoots.length > 0) { + chain.logger.warn("Not publishing execution payload envelope due to proposer equivocation", { + ...valLogMeta, + conflictingRoots: conflictingRoots.join(", "), + }); + throw new ApiError( + 400, + `Block of execution payload envelope is a proposer equivocation, conflicting block roots: ${conflictingRoots.join(", ")}` + ); + } + chain.logger.debug("Equivocation validated while publishing execution payload envelope", valLogMeta); + } + if (payloadInput.hasPayloadEnvelope()) { // The envelope may have been added while this request was being validated, e.g. via gossip chain.logger.debug("Execution payload envelope already added during publishing", valLogMeta); diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index c7b60d4f5a73..f3dcbda3bb6d 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -108,6 +108,13 @@ export async function processBlocks( throw segmentExecStatus.execAborted.execError; } + if (opts.skipVerifyBlockSignatures !== true) { + for (const blockInput of relevantBlocks) { + const block = blockInput.getBlock().message; + this.seenBlockProposers.add(block.slot, block.proposerIndex); + } + } + const {executionStatuses} = segmentExecStatus; const verifiedBlocksBySlot = new Map(); for (let i = 0; i < relevantBlocks.length; i++) { diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 379413265d2e..cd7ff6d5892d 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -2,6 +2,7 @@ import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, ForkSeq, isForkPostFulu} from "@lodestar/params"; import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; import {IndexedAttestation, Slot, deneb} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; import type {BeaconChain} from "../chain.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; @@ -204,6 +205,15 @@ export async function verifyBlocksInEpoch( // maybe chain with the above verifyBlocksSignatures() ]); + if (opts.skipVerifyBlockSignatures !== true) { + for (const block of blocks) { + const blockRoot = toRootHex( + this.config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message) + ); + this.seenBlockProposers.observeBlockRoot(block.message.slot, block.message.proposerIndex, blockRoot); + } + } + if (opts.verifyOnly !== true) { const fromForkBoundary = this.config.getForkBoundaryAtEpoch(computeEpochAtSlot(parentBlock.slot)); const toForkBoundary = this.config.getForkBoundaryAtEpoch(computeEpochAtSlot(lastBlock.message.slot)); diff --git a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts index 6b37015ecb88..5f3591d48a1e 100644 --- a/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts +++ b/packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts @@ -1,21 +1,57 @@ import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {Epoch, Slot, ValidatorIndex} from "@lodestar/types"; +import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; +/** Two distinct block roots signed by the same proposer for the same slot are sufficient to establish an equivocation */ +const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2; + /** * Keeps a cache to filter block proposals from the same validator in the same slot. * - * This cache is not bounded and for extremely long periods of non-finality it can grow a lot. However it's practically - * limited by the possible shufflings in those epochs, and the stored data is very cheap + * Block roots with a signature verified against the block's proposer index are tracked separately from proposals + * accepted by gossip validation or block import. A root from a block signed by its declared proposer is potential + * equivocation evidence, but the block may still fail other validation. Such a block must not mark the proposal as + * known, since that would cause a later valid block for the same slot and proposer to be ignored as a repeat proposal. + * + * The cache is pruned on finalization and bounds the number of roots stored per proposer and slot */ export class SeenBlockProposers { private readonly proposerIndexesBySlot = new MapDef>(() => new Set()); - private finalizedSlot: Epoch = 0; + private readonly blockRootsBySlot = new MapDef>>( + () => new MapDef>(() => new Set()) + ); + private finalizedSlot: Slot = 0; isKnown(blockSlot: Slot, proposerIndex: ValidatorIndex): boolean { return this.proposerIndexesBySlot.get(blockSlot)?.has(proposerIndex) === true; } + hasBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): boolean { + return this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex)?.has(blockRoot) === true; + } + + isEquivocating(blockSlot: Slot, proposerIndex: ValidatorIndex): boolean { + return (this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex)?.size ?? 0) >= MAX_BLOCK_ROOTS_PER_PROPOSAL; + } + + getConflictingBlockRoots(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): RootHex[] { + const roots = this.blockRootsBySlot.get(blockSlot)?.get(proposerIndex); + return roots === undefined ? [] : Array.from(roots).filter((root) => root !== blockRoot); + } + + /** Record a block only after its proposer signature has been verified */ + observeBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void { + if (blockSlot < this.finalizedSlot) { + throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); + } + + const blockRoots = this.blockRootsBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex); + if (blockRoots.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) { + blockRoots.add(blockRoot); + } + } + + /** Mark a block as known from gossip or another block import path */ add(blockSlot: Slot, proposerIndex: ValidatorIndex): void { if (blockSlot < this.finalizedSlot) { throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`); @@ -31,9 +67,14 @@ export class SeenBlockProposers { this.proposerIndexesBySlot.delete(slot); } } + for (const slot of this.blockRootsBySlot.keys()) { + if (slot < finalizedSlot) { + this.blockRootsBySlot.delete(slot); + } + } } - seenAtEpoch(epoch: Slot, index: ValidatorIndex): boolean { + seenAtEpoch(epoch: Epoch, index: ValidatorIndex): boolean { const fromSlot = computeStartSlotAtEpoch(epoch); const toSlot = computeStartSlotAtEpoch(epoch + 1); diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 8739c0e6d3a2..6b9f7aadaa69 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -24,7 +24,7 @@ import { isExecutionBlockBodyType, isStatePostBellatrix, } from "@lodestar/state-transition"; -import {SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; +import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types"; import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils"; import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../interface.js"; @@ -85,7 +85,12 @@ export async function validateGossipBlock( // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot. const proposerIndex = block.proposerIndex; + const hasBlockRoot = chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot); if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { + if (!hasBlockRoot && !chain.seenBlockProposers.isEquivocating(blockSlot, proposerIndex)) { + await verifyBlockProposerSignature(chain, signedBlock, blockRoot, {verifyOnMainThread: false}); + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); + } throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); } @@ -270,18 +275,8 @@ export async function validateGossipBlock( } // [REJECT] The proposer signature, signed_beacon_block.signature, is valid with respect to the proposer_index pubkey. - if (!chain.seenBlockInputCache.isVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature)) { - const signatureSet = getBlockProposerSignatureSet(chain.config, signedBlock); - // Don't batch so verification is not delayed - if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: true}))) { - throw new BlockGossipError(GossipAction.REJECT, { - code: BlockErrorCode.PROPOSAL_SIGNATURE_INVALID, - blockSlot, - }); - } - - chain.seenBlockInputCache.markVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature); - } + await verifyBlockProposerSignature(chain, signedBlock, blockRoot); + chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot); // [REJECT] The block is proposed by the expected proposer_index for the block's slot in the context of the current // shuffling (defined by parent_root/slot). If the proposer_index cannot immediately be verified against the expected @@ -291,11 +286,6 @@ export async function validateGossipBlock( throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.INCORRECT_PROPOSER, proposerIndex}); } - // Check again in case there two blocks are processed concurrently - if (chain.seenBlockProposers.isKnown(blockSlot, proposerIndex)) { - throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex}); - } - // Simple implementation of a pending block queue. Keeping the block here recycles the queue logic, and keeps the // gossip validation promise without any extra infrastructure. // Do the sleep at the end, since regen and signature validation can already take longer than `msToBlockSlot`. @@ -305,7 +295,35 @@ export async function validateGossipBlock( await sleep(msToBlockSlot); } + // 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}); + } + chain.seenBlockProposers.add(blockSlot, proposerIndex); return {skippedSlots}; } + +export async function verifyBlockProposerSignature( + chain: IBeaconChain, + signedBlock: SignedBeaconBlock, + blockRoot: RootHex, + opts: {verifyOnMainThread?: boolean} = {} +): Promise { + const blockSlot = signedBlock.message.slot; + if (chain.seenBlockInputCache.isVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature)) { + return; + } + + const signatureSet = getBlockProposerSignatureSet(chain.config, signedBlock); + // Don't batch so verification is not delayed + if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: opts.verifyOnMainThread ?? true}))) { + throw new BlockGossipError(GossipAction.REJECT, { + code: BlockErrorCode.PROPOSAL_SIGNATURE_INVALID, + blockSlot, + }); + } + + chain.seenBlockInputCache.markVerifiedProposerSignature(blockSlot, blockRoot, signedBlock.signature); +} 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 new file mode 100644 index 000000000000..8aca7865a389 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishBlock.test.ts @@ -0,0 +1,137 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {createBeaconConfig} from "@lodestar/config"; +import {config as configDef} from "@lodestar/config/default"; +import {ForkName} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +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 {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; +import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; +import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; + +vi.mock("../../../../../../src/chain/blocks/verifyBlock.js"); + +describe("api - beacon - publishBlockV2", () => { + const config = createBeaconConfig(configDef, Buffer.alloc(32, 1)); + let modules: ApiTestModules; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyBlocksInEpoch).mockResolvedValue({} as Awaited>); + modules = getApiTestModules({config}); + Object.defineProperty(modules.chain, "blockProductionCache", {value: new Map()}); + Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); + modules.network.publishBeaconBlock = vi.fn(); + modules.chain.processBlock = vi.fn().mockResolvedValue(undefined); + }); + + describe("broadcast_validation=consensus_and_equivocation", () => { + it("does not publish or import a non-local block that conflicts with an observed proposal", 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 conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); + const blockInput = BlockInputPreData.createFromBlock({ + forkName: ForkName.phase0, + block: signedBlock, + blockRootHex: blockRoot, + source: BlockInputSource.api, + seenTimestampSec: 0, + daOutOfRange: false, + }); + modules.chain.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + modules.chain.seenBlockProposers.observeBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + conflictingBlockRoot + ); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishBlockV2({ + signedBlockContents: {signedBlock}, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); + + expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); + expect(modules.network.publishBeaconBlock).not.toHaveBeenCalled(); + expect(modules.chain.processBlock).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", + async (broadcastValidation) => { + 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, + }); + vi.spyOn(modules.chain.blockProductionCache, "has").mockReturnValue(true); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({signedBlockContents: {signedBlock}, broadcastValidation}); + + expect(modules.chain.bls.verifySignatureSets).toHaveBeenCalledOnce(); + expect( + modules.chain.seenBlockProposers.hasBlockRoot( + signedBlock.message.slot, + signedBlock.message.proposerIndex, + blockRoot + ) + ).toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + expect(modules.chain.processBlock).toHaveBeenCalledWith(blockInput, {}); + } + ); + + it.each([routes.beacon.BroadcastValidation.consensus, routes.beacon.BroadcastValidation.consensusAndEquivocation])( + "verifies all signatures for a non-local block with broadcast_validation=%s", + async (broadcastValidation) => { + 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.forkChoice.getBlockDefaultStatus.mockReturnValue(generateProtoBlock({slot: 0})); + modules.chain.seenBlockInputCache.getByBlock.mockReturnValue(blockInput); + + const api = getBeaconBlockApi(modules); + await api.publishBlockV2({signedBlockContents: {signedBlock}, broadcastValidation}); + + expect(verifyBlocksInEpoch).toHaveBeenCalledOnce(); + const verifyOpts = vi.mocked(verifyBlocksInEpoch).mock.calls[0][3]; + expect(verifyOpts.skipVerifyBlockSignatures).not.toBe(true); + expect(modules.network.publishBeaconBlock).toHaveBeenCalledWith(signedBlock); + } + ); + }); +}); diff --git a/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts new file mode 100644 index 000000000000..5820ad63a615 --- /dev/null +++ b/packages/beacon-node/test/unit/api/impl/beacon/blocks/publishExecutionPayloadEnvelope.test.ts @@ -0,0 +1,83 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {routes} from "@lodestar/api"; +import {createChainForkConfig} from "@lodestar/config"; +import {config as configDef} from "@lodestar/config/default"; +import {ForkName} from "@lodestar/params"; +import {IBeaconStateView} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {fromHex, toRootHex} from "@lodestar/utils"; +import {getBeaconBlockApi} from "../../../../../../src/api/impl/beacon/blocks/index.js"; +import {PayloadEnvelopeInput} from "../../../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {SeenBlockProposers} from "../../../../../../src/chain/seenCache/seenBlockProposers.js"; +import {ApiTestModules, getApiTestModules} from "../../../../../utils/api.js"; +import {generateProtoBlock} from "../../../../../utils/typeGenerator.js"; + +vi.mock("../../../../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js", () => ({ + verifyExecutionPayloadEnvelope: vi.fn(), +})); +vi.mock("../../../../../../src/chain/validation/executionPayloadEnvelope.js", () => ({ + validateApiExecutionPayloadEnvelope: vi.fn(), +})); + +describe("api - beacon - publishExecutionPayloadEnvelope", () => { + const config = createChainForkConfig({ + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }); + let modules: ApiTestModules; + + beforeEach(() => { + modules = getApiTestModules({config}); + Object.defineProperty(modules.chain, "blockProductionCache", {value: {get: vi.fn()}}); + Object.defineProperty(modules.chain, "seenBlockProposers", {value: new SeenBlockProposers()}); + modules.network.publishSignedExecutionPayloadEnvelope = vi.fn(); + modules.chain.processExecutionPayload = vi.fn(); + }); + + describe("broadcast_validation=consensus_and_equivocation", () => { + it("rejects an envelope for an observed proposer equivocation", async () => { + const signedBlock = ssz.gloas.SignedBeaconBlock.defaultValue(); + const slot = signedBlock.message.slot; + const proposerIndex = signedBlock.message.proposerIndex; + const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(signedBlock.message)); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 1)); + const payloadInput = PayloadEnvelopeInput.createFromBlock({ + blockRootHex: blockRoot, + block: signedBlock, + forkName: ForkName.gloas, + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: 0, + daOutOfRange: false, + }); + const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + signedEnvelope.message.beaconBlockRoot = fromHex(blockRoot); + signedEnvelope.message.payload.slotNumber = slot; + + modules.forkChoice.getBlockHex.mockReturnValue(generateProtoBlock({slot})); + vi.mocked(modules.chain.seenPayloadEnvelopeInputCache.get).mockReturnValue(payloadInput); + modules.chain.regen.getBlockSlotState.mockResolvedValue({forkName: ForkName.gloas} as IBeaconStateView); + modules.chain.seenBlockProposers.add(slot, proposerIndex); + modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockRoot); + modules.chain.seenBlockProposers.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + + const api = getBeaconBlockApi(modules); + await expect( + api.publishExecutionPayloadEnvelope({ + signedEnvelopeOrContents: signedEnvelope, + broadcastValidation: routes.beacon.BroadcastValidation.consensusAndEquivocation, + }) + ).rejects.toThrow(/proposer equivocation/); + + expect(modules.network.publishSignedExecutionPayloadEnvelope).not.toHaveBeenCalled(); + expect(modules.chain.processExecutionPayload).not.toHaveBeenCalled(); + expect(payloadInput.hasPayloadEnvelope()).toBe(false); + }); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts new file mode 100644 index 000000000000..bdededb09289 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/processBlocks.test.ts @@ -0,0 +1,111 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {createChainForkConfig} from "@lodestar/config"; +import {config as configDef} from "@lodestar/config/default"; +import {ExecutionStatus} from "@lodestar/fork-choice"; +import {ForkName} from "@lodestar/params"; +import {DataAvailabilityStatus, IBeaconStateView} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {importBlock} from "../../../../src/chain/blocks/importBlock.js"; +import {processBlocks} from "../../../../src/chain/blocks/index.js"; +import {assertLinearChainSegment} from "../../../../src/chain/blocks/utils/chainSegment.js"; +import {verifyBlocksInEpoch} from "../../../../src/chain/blocks/verifyBlock.js"; +import {verifyBlocksSanityChecks} from "../../../../src/chain/blocks/verifyBlocksSanityChecks.js"; +import {BlockError, BlockErrorCode} from "../../../../src/chain/errors/index.js"; +import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; +import {ExecutionPayloadStatus} from "../../../../src/execution/engine/interface.js"; +import {MockedBeaconChain, getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; +import {MockBlockInput} from "../../../utils/blockInput.js"; +import {generateProtoBlock} from "../../../utils/typeGenerator.js"; + +vi.mock("../../../../src/chain/blocks/importBlock.js"); +vi.mock("../../../../src/chain/blocks/utils/chainSegment.js"); +vi.mock("../../../../src/chain/blocks/verifyBlock.js"); +vi.mock("../../../../src/chain/blocks/verifyBlocksSanityChecks.js"); + +describe("chain / blocks / processBlocks", () => { + const slot = 1; + const proposerIndex = 2; + const config = createChainForkConfig({ + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + }); + let chain: MockedBeaconChain; + let seenBlockProposers: SeenBlockProposers; + let blockInput: MockBlockInput; + + beforeEach(() => { + vi.clearAllMocks(); + + chain = getMockedBeaconChain({config}); + seenBlockProposers = new SeenBlockProposers(); + Object.defineProperty(chain, "seenBlockProposers", {value: seenBlockProposers}); + + const block = ssz.deneb.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(ssz.deneb.BeaconBlock.hashTreeRoot(block.message)); + blockInput = new MockBlockInput({forkName: ForkName.deneb, slot, blockRootHex: blockRoot}); + blockInput._block = block; + + vi.mocked(verifyBlocksSanityChecks).mockReturnValue({ + relevantBlocks: [blockInput], + parentSlots: [slot - 1], + parentBlock: generateProtoBlock({slot: slot - 1}), + }); + vi.mocked(assertLinearChainSegment).mockReturnValue({warnings: null}); + vi.mocked(importBlock).mockResolvedValue(undefined); + }); + + it("does not mark a proposal as known when execution verification aborts", async () => { + const block = blockInput.getBlock(); + const execError = new BlockError(block, { + code: BlockErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: ExecutionPayloadStatus.ELERROR, + errorMessage: "test execution error", + }); + vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { + seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); + return { + postStates: [{forkName: ForkName.deneb} as IBeaconStateView], + proposerBalanceDeltas: [0], + segmentExecStatus: {execAborted: {blockIndex: 0, execError}}, + blockDAStatuses: [DataAvailabilityStatus.Available], + payloadDAStatuses: new Map(), + indexedAttestationsByBlock: [[]], + }; + }); + + await expect(processBlocks.call(chain, [blockInput], null, {})).rejects.toBe(execError); + + expect(seenBlockProposers.hasBlockRoot(slot, proposerIndex, blockInput.blockRootHex)).toBe(true); + expect(seenBlockProposers.isKnown(slot, proposerIndex)).toBe(false); + expect(importBlock).not.toHaveBeenCalled(); + }); + + it("marks a proposal as known after execution verification succeeds", async () => { + vi.mocked(verifyBlocksInEpoch).mockImplementation(async () => { + seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockInput.blockRootHex); + return { + postStates: [{forkName: ForkName.deneb} as IBeaconStateView], + proposerBalanceDeltas: [0], + segmentExecStatus: { + execAborted: null, + executionStatuses: [ExecutionStatus.Valid], + executionTime: 0, + }, + blockDAStatuses: [DataAvailabilityStatus.Available], + payloadDAStatuses: new Map(), + indexedAttestationsByBlock: [[]], + }; + }); + + await processBlocks.call(chain, [blockInput], null, {}); + + expect(seenBlockProposers.isKnown(slot, proposerIndex)).toBe(true); + expect(importBlock).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts new file mode 100644 index 000000000000..5d65c34b10a4 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/seenCache/seenBlockProposers.test.ts @@ -0,0 +1,67 @@ +import {describe, expect, it} from "vitest"; +import {toRootHex} from "@lodestar/utils"; +import {SeenBlockProposers} from "../../../../src/chain/seenCache/seenBlockProposers.js"; + +describe("SeenBlockProposers", () => { + const slot = 32; + const proposerIndex = 1; + const blockRoot = toRootHex(Buffer.alloc(32, 1)); + const conflictingBlockRoot = toRootHex(Buffer.alloc(32, 2)); + const additionalBlockRoot = toRootHex(Buffer.alloc(32, 3)); + + it("tracks observed block roots separately from known proposals", () => { + const cache = new SeenBlockProposers(); + + cache.observeBlockRoot(slot, proposerIndex, blockRoot); + + expect(cache.isKnown(slot, proposerIndex)).toBe(false); + expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(true); + + cache.add(slot, proposerIndex); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + + expect(cache.isKnown(slot, proposerIndex)).toBe(true); + expect(cache.isEquivocating(slot, proposerIndex)).toBe(true); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, blockRoot)).toEqual([conflictingBlockRoot]); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([blockRoot]); + }); + + it("stores at most two roots per slot and proposer", () => { + const cache = new SeenBlockProposers(); + + cache.observeBlockRoot(slot, proposerIndex, blockRoot); + cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot); + cache.observeBlockRoot(slot, proposerIndex, additionalBlockRoot); + + expect(cache.isEquivocating(slot, proposerIndex)).toBe(true); + expect(cache.hasBlockRoot(slot, proposerIndex, additionalBlockRoot)).toBe(false); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, additionalBlockRoot)).toEqual([ + blockRoot, + conflictingBlockRoot, + ]); + }); + + it("prunes known proposals and observed roots", () => { + const cache = new SeenBlockProposers(); + cache.observeBlockRoot(slot, proposerIndex, blockRoot); + cache.add(slot, proposerIndex); + + cache.prune(slot + 1); + + expect(cache.isKnown(slot, proposerIndex)).toBe(false); + expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(false); + expect(cache.getConflictingBlockRoots(slot, proposerIndex, conflictingBlockRoot)).toEqual([]); + }); + + it("rejects updates for slots before the finalized slot", () => { + const cache = new SeenBlockProposers(); + cache.prune(slot + 1); + + expect(() => cache.add(slot, proposerIndex)).toThrow(`blockSlot ${slot} < finalizedSlot ${slot + 1}`); + expect(() => cache.observeBlockRoot(slot, proposerIndex, blockRoot)).toThrow( + `blockSlot ${slot} < finalizedSlot ${slot + 1}` + ); + expect(cache.isKnown(slot, proposerIndex)).toBe(false); + expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(false); + }); +}); 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 b9879f0c13d6..59bb0280c635 100644 --- a/packages/beacon-node/test/unit/chain/validation/block.test.ts +++ b/packages/beacon-node/test/unit/chain/validation/block.test.ts @@ -1,10 +1,11 @@ -import {Mock, Mocked, beforeEach, describe, it, vi} from "vitest"; +import {Mock, Mocked, beforeEach, describe, expect, it, vi} from "vitest"; import {createBeaconConfig, createChainForkConfig} from "@lodestar/config"; import {config as configDef} from "@lodestar/config/default"; import {ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, ForkPostDeneb, ForkPreFulu} from "@lodestar/params"; import {BeaconStateView} from "@lodestar/state-transition"; import {SignedBeaconBlock, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; import {QueuedStateRegenerator} from "../../../../src/chain/regen/index.js"; import {SeenBlockProposers} from "../../../../src/chain/seenCache/index.js"; @@ -33,9 +34,22 @@ describe("gossip block validation", () => { DENEB_FORK_EPOCH: 0, }); const config = createBeaconConfig(configDef, Buffer.alloc(32, 0xaa)); + const gloasConfig = createBeaconConfig( + { + ...configDef, + ALTAIR_FORK_EPOCH: 0, + BELLATRIX_FORK_EPOCH: 0, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH: 0, + }, + Buffer.alloc(32, 0xaa) + ); - beforeEach(() => { - chain = getMockedBeaconChain({config}); + function setupChain(chainConfig = config, genesisTime = 0): void { + chain = getMockedBeaconChain({config: chainConfig, genesisTime}); vi.spyOn(chain.clock, "currentSlotWithGossipDisparity", "get").mockReturnValue(clockSlot); forkChoice = chain.forkChoice; forkChoice.getBlockHexDefaultStatus.mockReturnValue(null); @@ -60,6 +74,10 @@ describe("gossip block validation", () => { ).seenBlockProposers = new SeenBlockProposers(); job = {signature, message: block}; + } + + beforeEach(() => { + setupChain(); }); it("FUTURE_SLOT", async () => { @@ -96,14 +114,110 @@ describe("gossip block validation", () => { ); }); - it("REPEAT_PROPOSAL", async () => { - // Register the proposer as known - chain.seenBlockProposers.add(job.message.slot, job.message.proposerIndex); + describe("repeat proposal handling", () => { + beforeEach(() => { + setupChain(gloasConfig); + }); - await expectRejectedWithLodestarError( - validateGossipBlock(config, chain, job, ForkName.phase0), - BlockErrorCode.REPEAT_PROPOSAL - ); + it("records a conflicting block root after verifying the proposer signature", 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); + 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)); + + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); + + expect(verifySignature).toHaveBeenCalledOnce(); + expect(verifySignature).toHaveBeenCalledWith(expect.any(Array), {verifyOnMainThread: false}); + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([ + 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(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + const blockRoot = toRootHex(forkTypes.BeaconBlock.hashTreeRoot(signedBlock.message)); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, blockRoot); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const conflictingBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + conflictingBlock.message.stateRoot = Buffer.alloc(32, 1); + verifySignature.mockResolvedValue(false); + + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, conflictingBlock, ForkName.gloas), + BlockErrorCode.PROPOSAL_SIGNATURE_INVALID + ); + + expect(chain.seenBlockProposers.getConflictingBlockRoots(clockSlot, proposerIndex, blockRoot)).toEqual([]); + }); + + it("skips proposer signature verification after observing an equivocation", 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); + chain.seenBlockProposers.observeBlockRoot(clockSlot, proposerIndex, toRootHex(Buffer.alloc(32, 1))); + chain.seenBlockProposers.add(clockSlot, proposerIndex); + + const additionalBlock = forkTypes.SignedBeaconBlock.clone(signedBlock); + additionalBlock.message.stateRoot = Buffer.alloc(32, 2); + + await expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, additionalBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); + + expect(verifySignature).not.toHaveBeenCalled(); + }); + + it("detects another proposal that becomes known during the early-block delay", async () => { + const now = 1_000_000; + vi.useFakeTimers({now}); + + try { + const genesisTime = now / 1000 - clockSlot * (gloasConfig.SLOT_DURATION_MS / 1000) + 0.1; + setupChain(gloasConfig, genesisTime); + const forkTypes = gloasConfig.getForkTypes(clockSlot); + const signedBlock = forkTypes.SignedBeaconBlock.defaultValue(); + signedBlock.message.slot = clockSlot; + signedBlock.message.proposerIndex = proposerIndex; + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce(null); + forkChoice.getBlockHexDefaultStatus.mockReturnValueOnce({slot: clockSlot - 1} as ProtoBlock); + forkChoice.getBlockHexAndBlockHash.mockReturnValue({slot: clockSlot - 1} as ProtoBlock); + const state = new BeaconStateView(generateCachedState()); + regen.getPreState.mockResolvedValue(state); + vi.spyOn(state.cachedState.epochCtx, "getBeaconProposer").mockReturnValue(proposerIndex); + + const validation = expectRejectedWithLodestarError( + validateGossipBlock(gloasConfig, chain, signedBlock, ForkName.gloas), + BlockErrorCode.REPEAT_PROPOSAL + ); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(1); + + chain.seenBlockProposers.add(clockSlot, proposerIndex); + await vi.advanceTimersByTimeAsync(100); + await validation; + } finally { + vi.useRealTimers(); + } + }); }); it("PARENT_BLOCK_UNKNOWN (fork-choice)", async () => {