Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/beacon-node/src/chain/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export async function processBlocks(

for (const blockInput of relevantBlocks) {
const block = blockInput.getBlock().message;
this.seenBlockProposers.add(block.slot, block.proposerIndex);
this.seenBlockProposers.add(block.slot, block.proposerIndex, blockInput.blockRootHex);
}

const {executionStatuses} = segmentExecStatus;
Expand Down
2 changes: 1 addition & 1 deletion packages/beacon-node/src/chain/errors/blockError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
16 changes: 13 additions & 3 deletions packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const MIN_EQUIVOCATION_BLOCK_ROOTS_PER_PROPOSAL = 2;
* 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<Slot, Set<ValidatorIndex>>(() => new Set<ValidatorIndex>());
private readonly proposerIndexesBySlot = new MapDef<Slot, Map<ValidatorIndex, RootHex>>(
() => new Map<ValidatorIndex, RootHex>()
);
private readonly signedBlockHeadersBySlot = new MapDef<
Slot,
MapDef<ValidatorIndex, Map<RootHex, phase0.SignedBeaconBlockHeader>>
Expand All @@ -30,6 +32,14 @@ export class SeenBlockProposers {
return this.proposerIndexesBySlot.get(blockSlot)?.has(proposerIndex) === true;
}

/**
* The block proposer is known at slot with a different root.
*/
isRepeatProposal(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): boolean {
const knownRoot = this.proposerIndexesBySlot.get(blockSlot)?.get(proposerIndex);
return knownRoot !== undefined && knownRoot !== blockRoot;
}

hasBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): boolean {
return this.signedBlockHeadersBySlot.get(blockSlot)?.get(proposerIndex)?.has(blockRoot) === true;
}
Expand Down Expand Up @@ -85,12 +95,12 @@ export class SeenBlockProposers {
}

/** Mark a block as known from gossip or another block import path */
add(blockSlot: Slot, proposerIndex: ValidatorIndex): void {
add(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void {
if (blockSlot < this.finalizedSlot) {
throw Error(`blockSlot ${blockSlot} < finalizedSlot ${this.finalizedSlot}`);
}

this.proposerIndexesBySlot.getOrDefault(blockSlot).add(proposerIndex);
this.proposerIndexesBySlot.getOrDefault(blockSlot).set(proposerIndex, blockRoot);
}

prune(finalizedSlot: Slot): void {
Expand Down
28 changes: 21 additions & 7 deletions packages/beacon-node/src/chain/validation/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,20 @@ 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, signedBlockHeader);
if (chain.seenBlockProposers.isRepeatProposal(blockSlot, proposerIndex, blockRoot)) {
const hasBlockRoot = chain.seenBlockProposers.hasBlockRoot(blockSlot, proposerIndex, blockRoot);
if (!hasBlockRoot && !chain.seenBlockProposers.isEquivocating(blockSlot, proposerIndex)) {
await verifyBlockProposerSignature(chain, signedBlock, blockRoot, {verifyOnMainThread: false});
chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot, signedBlockHeader);
}
throw new BlockGossipError(GossipAction.IGNORE, {
code: BlockErrorCode.REPEAT_PROPOSAL,
proposerIndex,
root: blockRoot,
});
}
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex});
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot});
}

// [REJECT] The current finalized_checkpoint is an ancestor of block -- i.e.
Expand Down Expand Up @@ -301,10 +308,17 @@ 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});
if (chain.seenBlockProposers.isRepeatProposal(blockSlot, proposerIndex, blockRoot)) {
throw new BlockGossipError(GossipAction.IGNORE, {
code: BlockErrorCode.REPEAT_PROPOSAL,
proposerIndex,
root: blockRoot,
});
}
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot});
}

chain.seenBlockProposers.add(blockSlot, proposerIndex);
chain.seenBlockProposers.add(blockSlot, proposerIndex, blockRoot);

return {skippedSlots};
}
Expand Down
7 changes: 4 additions & 3 deletions packages/beacon-node/test/e2e/api/lodestar/lodestar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {chainConfig as chainConfigDef} from "@lodestar/config/default";
import {LogLevel, TestLoggerOpts, testLogger} from "@lodestar/logger/test-utils";
import {SLOTS_PER_EPOCH} from "@lodestar/params";
import {phase0} from "@lodestar/types";
import {toRootHex} from "@lodestar/utils";
import {BeaconNode} from "../../../../src/index.js";
import {ClockEvent} from "../../../../src/util/clock.js";
import {waitForEvent} from "../../../utils/events/resolver.js";
Expand Down Expand Up @@ -61,12 +62,12 @@ describe("api / impl / validator", () => {
});

// live indices at epoch of consideration, epoch 0
bn.chain.seenBlockProposers.add(0, 1);
bn.chain.seenBlockProposers.add(0, 1, toRootHex(Buffer.alloc(32)));
bn.chain.seenBlockAttesters.add(0, 2);
bn.chain.seenAttesters.add(0, 3);
bn.chain.seenAggregators.add(0, 4);
// live indices at other epochs, epoch 10
bn.chain.seenBlockProposers.add(10, 1000);
bn.chain.seenBlockProposers.add(10, 1000, toRootHex(Buffer.alloc(32)));
bn.chain.seenAttesters.add(10, 2000);
bn.chain.seenAggregators.add(10, 3000);

Expand Down Expand Up @@ -105,7 +106,7 @@ describe("api / impl / validator", () => {
await waitForEvent<phase0.Checkpoint>(bn.chain.clock, ClockEvent.epoch, timeout); // wait for epoch 1
await waitForEvent<phase0.Checkpoint>(bn.chain.clock, ClockEvent.epoch, timeout); // wait for epoch 2

bn.chain.seenBlockProposers.add(bn.chain.clock.currentEpoch, 1);
bn.chain.seenBlockProposers.add(bn.chain.clock.currentEpoch, 1, toRootHex(Buffer.alloc(32)));

const client = getClient({baseUrl: `http://127.0.0.1:${restPort}`}, {config});

Expand Down
6 changes: 5 additions & 1 deletion packages/beacon-node/test/spec/utils/gossipValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,11 @@ async function validateMessageForTopic(
}

await validateGossipBlock(chain.config, chain, signedBlock, fork);
chain.seenBlockProposers.add(signedBlock.message.slot, signedBlock.message.proposerIndex);
chain.seenBlockProposers.add(
signedBlock.message.slot,
signedBlock.message.proposerIndex,
toRootHex(sszTypesFor(fork).BeaconBlock.hashTreeRoot(signedBlock.message))
);
break;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("api - beacon - publishExecutionPayloadEnvelope", () => {
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.add(slot, proposerIndex, blockRoot);
modules.chain.seenBlockProposers.observeBlockRoot(
slot,
proposerIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("SeenBlockProposers", () => {
expect(cache.hasBlockRoot(slot, proposerIndex, blockRoot)).toBe(true);
expect(cache.getEquivocationHeaders(slot, proposerIndex)).toBe(null);

cache.add(slot, proposerIndex);
cache.add(slot, proposerIndex, blockRoot);
cache.observeBlockRoot(slot, proposerIndex, conflictingBlockRoot, header2);

expect(cache.isKnown(slot, proposerIndex)).toBe(true);
Expand All @@ -41,6 +41,20 @@ describe("SeenBlockProposers", () => {
expect(cache.getEquivocationHeaders(slot, proposerIndex)).toEqual([header1, header2]);
});

it("flags a repeat proposal only for a different block root", () => {
const cache = new SeenBlockProposers();

// Not known yet: never a repeat, regardless of root
expect(cache.isRepeatProposal(slot, proposerIndex, blockRoot)).toBe(false);

cache.add(slot, proposerIndex, blockRoot);

// Known with the same root: a benign duplicate, not a repeat
expect(cache.isRepeatProposal(slot, proposerIndex, blockRoot)).toBe(false);
// Known with a different root: a genuine equivocation
expect(cache.isRepeatProposal(slot, proposerIndex, conflictingBlockRoot)).toBe(true);
});

it("stores at most two roots per slot and proposer", () => {
const cache = new SeenBlockProposers();

Expand Down Expand Up @@ -70,7 +84,7 @@ describe("SeenBlockProposers", () => {
it("prunes known proposals and observed roots", () => {
const cache = new SeenBlockProposers();
cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1);
cache.add(slot, proposerIndex);
cache.add(slot, proposerIndex, blockRoot);

cache.prune(slot + 1);

Expand All @@ -84,7 +98,7 @@ describe("SeenBlockProposers", () => {
const cache = new SeenBlockProposers();
cache.prune(slot + 1);

expect(() => cache.add(slot, proposerIndex)).toThrow(`blockSlot ${slot} < finalizedSlot ${slot + 1}`);
expect(() => cache.add(slot, proposerIndex, blockRoot)).toThrow(`blockSlot ${slot} < finalizedSlot ${slot + 1}`);
expect(() => cache.observeBlockRoot(slot, proposerIndex, blockRoot, header1)).toThrow(
`blockSlot ${slot} < finalizedSlot ${slot + 1}`
);
Expand Down
30 changes: 26 additions & 4 deletions packages/beacon-node/test/unit/chain/validation/block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ describe("gossip block validation", () => {
setupChain(gloasConfig);
});

it("ignores a same-root duplicate as ALREADY_KNOWN, not REPEAT_PROPOSAL", 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);

// Re-submitting the SAME block (same root) is a benign duplicate, not an equivocation
await expectRejectedWithLodestarError(
validateGossipBlock(gloasConfig, chain, signedBlock, ForkName.gloas),
BlockErrorCode.ALREADY_KNOWN
);
});

it("records a conflicting block root after verifying the proposer signature", async () => {
const forkTypes = gloasConfig.getForkTypes(clockSlot);
const signedBlock = forkTypes.SignedBeaconBlock.defaultValue();
Expand All @@ -131,7 +152,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);
Expand Down Expand Up @@ -165,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);
Expand Down Expand Up @@ -197,7 +218,7 @@ describe("gossip block validation", () => {
toRootHex(Buffer.alloc(32, 1)),
ssz.phase0.SignedBeaconBlockHeader.defaultValue()
);
chain.seenBlockProposers.add(clockSlot, proposerIndex);
chain.seenBlockProposers.add(clockSlot, proposerIndex, blockRoot);

const additionalBlock = forkTypes.SignedBeaconBlock.clone(signedBlock);
additionalBlock.message.stateRoot = Buffer.alloc(32, 2);
Expand Down Expand Up @@ -235,7 +256,8 @@ describe("gossip block validation", () => {
await vi.advanceTimersByTimeAsync(0);
expect(vi.getTimerCount()).toBe(1);

chain.seenBlockProposers.add(clockSlot, proposerIndex);
// A different proposal (different root) becomes known during the delay -> genuine repeat proposal
chain.seenBlockProposers.add(clockSlot, proposerIndex, toRootHex(Buffer.alloc(32, 0xff)));
await vi.advanceTimersByTimeAsync(100);
await validation;
} finally {
Expand Down
Loading