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
7 changes: 6 additions & 1 deletion packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,12 @@ export function getBeaconBlockApi({
if (blockLocallyProduced) {
try {
await verifyBlockProposerSignature(chain, signedBlock, blockRoot);
chain.seenBlockProposers.observeBlockRoot(slot, signedBlock.message.proposerIndex, blockRoot);
chain.seenBlockProposers.observeBlockRoot(
slot,
signedBlock.message.proposerIndex,
blockRoot,
signedBlockToSignedHeader(config, signedBlock)
);
} catch (e) {
chain.logger.error(
"Proposer signature validation failed while publishing the block",
Expand Down
22 changes: 16 additions & 6 deletions packages/beacon-node/src/chain/blocks/verifyBlock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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 {
DataAvailabilityStatus,
IBeaconStateView,
computeEpochAtSlot,
signedBlockToSignedHeader,
} from "@lodestar/state-transition";
import {IndexedAttestation, Slot, deneb, ssz} from "@lodestar/types";
import {toRootHex} from "@lodestar/utils";
import {getBlobKzgCommitments} from "../../util/dataColumns.js";
import type {BeaconChain} from "../chain.js";
Expand Down Expand Up @@ -207,10 +212,15 @@ export async function verifyBlocksInEpoch(

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);
const {slot, proposerIndex} = block.message;
const signedBlockHeader = signedBlockToSignedHeader(this.config, block);
const blockRoot = toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(signedBlockHeader.message));
this.seenBlockProposers.observeBlockRoot(slot, proposerIndex, blockRoot, signedBlockHeader);
// Only produce a slashing while importing the block. A block that is verified before it is published
// must not be treated as equivocation evidence since it may never be seen by the network
if (opts.verifyOnly !== true && this.seenBlockProposers.isEquivocating(slot, proposerIndex)) {
this.processProposerEquivocation(slot, proposerIndex);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap in callInNextEventLoop()?
this feels like a follow up task, not the main thing of this verifyBlock flow

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this might be good, I don't think it's a lot of work to create the slashing but it doesn't need to be produced timely at all, ideally the next proposer receives it in-time but that should be the case most of the time, wrapped inside of callInNextEventLoop() in #9795

}
}
}

Expand Down
56 changes: 56 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ import {CPStateDatastore} from "./stateCache/datastore/types.js";
import {FIFOBlockStateCache} from "./stateCache/fifoBlockStateCache.js";
import {PersistentCheckpointStateCache} from "./stateCache/persistentCheckpointsCache.js";
import {CheckpointStateCache} from "./stateCache/types.js";
import {validateApiProposerSlashing} from "./validation/proposerSlashing.js";
import {ValidatorMonitor} from "./validatorMonitor.js";

/**
Expand Down Expand Up @@ -200,6 +201,8 @@ export class BeaconChain implements IBeaconChain {
readonly seenAggregatedAttestations: SeenAggregatedAttestations;
readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids();
readonly seenBlockProposers = new SeenBlockProposers();
/** Proposer indexes with an in-flight proposer slashing production */
private readonly producingProposerSlashing = new Set<ValidatorIndex>();
readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages();
readonly seenContributionAndProof: SeenContributionAndProof;
readonly seenAttestationDatas: SeenAttestationDatas;
Expand Down Expand Up @@ -1163,6 +1166,59 @@ export class BeaconChain implements IBeaconChain {
return this.payloadEnvelopeProcessor.processPayloadEnvelopeJob(payloadInput, opts);
}

processProposerEquivocation(blockSlot: Slot, proposerIndex: ValidatorIndex): void {
this.produceProposerSlashing(blockSlot, proposerIndex).catch((e) => {
this.logger.debug("Error producing proposer slashing", {slot: blockSlot, proposerIndex}, e as Error);
});
}

/** Produce a proposer slashing from two conflicting signed block headers observed for the same slot and proposer */
private async produceProposerSlashing(blockSlot: Slot, proposerIndex: ValidatorIndex): Promise<void> {
if (
this.opts.disableProposerSlashings === true ||
this.opPool.hasSeenProposerSlashing(proposerIndex) ||
this.producingProposerSlashing.has(proposerIndex)
) {
return;
}

const headers = this.seenBlockProposers.getEquivocationHeaders(blockSlot, proposerIndex);
if (headers === null) {
return;
}

this.producingProposerSlashing.add(proposerIndex);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be great to log header roots here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try {
const [header1, header2] = headers;
// ProposerSlashing uses the bigint variant of the signed block header, see types package for details
const proposerSlashing: phase0.ProposerSlashing = {
signedHeader1: {
message: {...header1.message, slot: BigInt(header1.message.slot)},
signature: header1.signature,
},
signedHeader2: {
message: {...header2.message, slot: BigInt(header2.message.slot)},
signature: header2.signature,
},
};

try {
await validateApiProposerSlashing(this, proposerSlashing);
} catch (e) {
this.logger.debug("Produced proposer slashing is not valid", {slot: blockSlot, proposerIndex}, e as Error);
return;
}

this.opPool.insertProposerSlashing(proposerSlashing);
this.emitter.emit(routes.events.EventType.proposerSlashing, proposerSlashing);
this.emitter.emit(ChainEvent.publishProposerSlashing, proposerSlashing);
this.metrics?.opPool.proposerSlashingsProduced.inc();
this.logger.info("Produced proposer slashing from observed equivocation", {slot: blockSlot, proposerIndex});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could consider using verbose here, but this is a really rare event anyways

} finally {
this.producingProposerSlashing.delete(proposerIndex);
}
}

getStatus(): Status {
const head = this.forkChoice.getHead();
const finalizedCheckpoint = this.forkChoice.getFinalizedCheckpoint();
Expand Down
7 changes: 7 additions & 0 deletions packages/beacon-node/src/chain/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export enum ChainEvent {
* and are ready to be published.
*/
publishBlobSidecars = "publishBlobSidecars",
/**
* This event signals that a proposer slashing has been produced from an observed equivocation
* and is ready to be published.
*/
publishProposerSlashing = "publishProposerSlashing",
/**
* Trigger an update of status so reqresp by peers have current earliestAvailableSlot
*/
Expand Down Expand Up @@ -110,6 +115,8 @@ export type IChainEvents = ApiEvents & {

[ChainEvent.publishBlobSidecars]: (sidecars: deneb.BlobSidecar[]) => void;

[ChainEvent.publishProposerSlashing]: (proposerSlashing: phase0.ProposerSlashing) => void;

[ChainEvent.updateStatus]: () => void;

// Sync events that are chain->chain. Initiated from network requests but do not cross the network
Expand Down
3 changes: 3 additions & 0 deletions packages/beacon-node/src/chain/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ export interface IBeaconChain {
/** Process execution payload envelope: verify, import to fork choice, and persist to DB */
processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise<void>;

/** Produce and publish a proposer slashing from an observed equivocation. Does not throw, only logs errors */
processProposerEquivocation(blockSlot: Slot, proposerIndex: ValidatorIndex): void;

getStatus(): Status;

recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock;
Expand Down
3 changes: 3 additions & 0 deletions packages/beacon-node/src/chain/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export type IChainOptions = BlockProcessOpts &
suggestedFeeRecipient: string;
graffitiAppend?: boolean;
maxSkipSlots?: number;
/** Do not produce proposer slashings from observed equivocations and do not include proposer slashings in produced blocks */
disableProposerSlashings?: boolean;
/** Ensure blobs returned by the execution engine are valid */
sanityCheckExecutionEngineBlobs?: boolean;
/** Max number of produced blobs by local validators to cache */
Expand Down Expand Up @@ -122,6 +124,7 @@ export const defaultChainOptions: IChainOptions = {
// for gossip block validation, it's unlikely we see a reorg with 32 slots
// for attestation validation, having this value ensures we don't have to regen states most of the time
maxSkipSlots: 32,
disableProposerSlashings: false,
broadcastValidationStrictness: "warn",
// should be less than or equal to MIN_SIGNATURE_SETS_TO_BATCH_VERIFY
// batching too much may block the I/O thread so if useWorker=false, suggest this value to be 32
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,7 @@ export async function produceCommonBlockBody<T extends BlockType>(
graffiti,
// Eth1 data voting is no longer required since electra
eth1Data: currentState.eth1Data,
proposerSlashings,
proposerSlashings: this.opts.disableProposerSlashings === true ? [] : proposerSlashings,
attesterSlashings,
attestations,
// Since electra, deposits are processed by the execution layer,
Expand Down
54 changes: 40 additions & 14 deletions packages/beacon-node/src/chain/seenCache/seenBlockProposers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {computeStartSlotAtEpoch} from "@lodestar/state-transition";
import {Epoch, RootHex, Slot, ValidatorIndex} from "@lodestar/types";
import {Epoch, RootHex, Slot, ValidatorIndex, phase0} 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 */
Expand All @@ -13,41 +13,67 @@ const MAX_BLOCK_ROOTS_PER_PROPOSAL = 2;
* 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 signed block header of each observed root is kept so a proposer slashing can be produced from the two
* conflicting headers once an equivocation is established.
*
* 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 blockRootsBySlot = new MapDef<Slot, MapDef<ValidatorIndex, Set<RootHex>>>(
() => new MapDef<ValidatorIndex, Set<RootHex>>(() => new Set<RootHex>())
);
private readonly signedBlockHeadersBySlot = new MapDef<
Slot,
MapDef<ValidatorIndex, Map<RootHex, phase0.SignedBeaconBlockHeader>>
>(() => new MapDef<ValidatorIndex, Map<RootHex, phase0.SignedBeaconBlockHeader>>(() => new Map()));
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;
return this.signedBlockHeadersBySlot.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;
return (
(this.signedBlockHeadersBySlot.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);
const signedBlockHeaderByRoot = this.signedBlockHeadersBySlot.get(blockSlot)?.get(proposerIndex);
return signedBlockHeaderByRoot === undefined
? []
: Array.from(signedBlockHeaderByRoot.keys()).filter((root) => root !== blockRoot);
}

/** Return the two signed block headers that establish an equivocation, or null if there is none */
getEquivocationHeaders(
blockSlot: Slot,
proposerIndex: ValidatorIndex
): [phase0.SignedBeaconBlockHeader, phase0.SignedBeaconBlockHeader] | null {
const signedBlockHeaderByRoot = this.signedBlockHeadersBySlot.get(blockSlot)?.get(proposerIndex);
if (signedBlockHeaderByRoot === undefined || signedBlockHeaderByRoot.size < MAX_BLOCK_ROOTS_PER_PROPOSAL) {
return null;
}
const [signedHeader1, signedHeader2] = signedBlockHeaderByRoot.values();
return [signedHeader1, signedHeader2];
}

/** Record a block only after its proposer signature has been verified */
observeBlockRoot(blockSlot: Slot, proposerIndex: ValidatorIndex, blockRoot: RootHex): void {
observeBlockRoot(
blockSlot: Slot,
proposerIndex: ValidatorIndex,
blockRoot: RootHex,
signedBlockHeader: phase0.SignedBeaconBlockHeader
): 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);
const signedBlockHeaderByRoot = this.signedBlockHeadersBySlot.getOrDefault(blockSlot).getOrDefault(proposerIndex);
if (signedBlockHeaderByRoot.size < MAX_BLOCK_ROOTS_PER_PROPOSAL && !signedBlockHeaderByRoot.has(blockRoot)) {
signedBlockHeaderByRoot.set(blockRoot, signedBlockHeader);
}
}

Expand All @@ -67,9 +93,9 @@ export class SeenBlockProposers {
this.proposerIndexesBySlot.delete(slot);
}
}
for (const slot of this.blockRootsBySlot.keys()) {
for (const slot of this.signedBlockHeadersBySlot.keys()) {
if (slot < finalizedSlot) {
this.blockRootsBySlot.delete(slot);
this.signedBlockHeadersBySlot.delete(slot);
}
}
}
Expand Down
12 changes: 8 additions & 4 deletions packages/beacon-node/src/chain/validation/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import {
getBlockProposerSignatureSet,
isExecutionBlockBodyType,
isStatePostBellatrix,
signedBlockToSignedHeader,
} from "@lodestar/state-transition";
import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock} from "@lodestar/types";
import {RootHex, SignedBeaconBlock, deneb, gloas, isGloasBeaconBlock, ssz} from "@lodestar/types";
import {byteArrayEquals, sleep, toRootHex} from "@lodestar/utils";
import {BlockErrorCode, BlockGossipError, GossipAction} from "../errors/index.js";
import {IBeaconChain} from "../interface.js";
Expand Down Expand Up @@ -75,7 +76,10 @@ export async function validateGossipBlock(
// reboot if the `observed_block_producers` cache is empty. In that case, without this
// check, we will load the parent and state from disk only to find out later that we
// already know this block.
const blockRoot = toRootHex(config.getForkTypes(blockSlot).BeaconBlock.hashTreeRoot(block));
// A block's hash tree root is identical to its header's, so the root is derived from the header
// which is also used as potential equivocation evidence
const signedBlockHeader = signedBlockToSignedHeader(config, signedBlock);
const blockRoot = toRootHex(ssz.phase0.BeaconBlockHeader.hashTreeRoot(signedBlockHeader.message));
if (chain.forkChoice.getBlockHexDefaultStatus(blockRoot) !== null) {
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.ALREADY_KNOWN, root: blockRoot});
}
Expand All @@ -89,7 +93,7 @@ export async function validateGossipBlock(
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);
chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot, signedBlockHeader);
}
throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.REPEAT_PROPOSAL, proposerIndex});
}
Expand Down Expand Up @@ -276,7 +280,7 @@ export async function validateGossipBlock(

// [REJECT] The proposer signature, signed_beacon_block.signature, is valid with respect to the proposer_index pubkey.
await verifyBlockProposerSignature(chain, signedBlock, blockRoot);
chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot);
chain.seenBlockProposers.observeBlockRoot(blockSlot, proposerIndex, blockRoot, signedBlockHeader);

// [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
Expand Down
4 changes: 4 additions & 0 deletions packages/beacon-node/src/metrics/metrics/lodestar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,10 @@ export function createLodestarMetrics(
name: "lodestar_oppool_proposer_slashing_pool_size",
help: "Current size of the ProposerSlashingPool",
}),
proposerSlashingsProduced: register.counter({
name: "lodestar_oppool_proposer_slashings_produced_total",
help: "Total number of proposer slashings produced from observed equivocations",
}),
voluntaryExitPoolSize: register.gauge({
name: "lodestar_oppool_voluntary_exit_pool_size",
help: "Current size of the VoluntaryExitPool",
Expand Down
17 changes: 16 additions & 1 deletion packages/beacon-node/src/network/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export class Network implements INetwork {
this.chain.emitter.on(ChainEvent.updateTargetCustodyGroupCount, this.onTargetGroupCountUpdated);
this.chain.emitter.on(ChainEvent.publishDataColumns, this.onPublishDataColumns);
this.chain.emitter.on(ChainEvent.publishBlobSidecars, this.onPublishBlobSidecars);
this.chain.emitter.on(ChainEvent.publishProposerSlashing, this.onPublishProposerSlashing);
this.chain.emitter.on(ChainEvent.updateStatus, this.onUpdateStatus);
}

Expand Down Expand Up @@ -244,6 +245,7 @@ export class Network implements INetwork {
this.chain.emitter.off(ChainEvent.updateTargetCustodyGroupCount, this.onTargetGroupCountUpdated);
this.chain.emitter.off(ChainEvent.publishDataColumns, this.onPublishDataColumns);
this.chain.emitter.off(ChainEvent.publishBlobSidecars, this.onPublishBlobSidecars);
this.chain.emitter.off(ChainEvent.publishProposerSlashing, this.onPublishProposerSlashing);
this.chain.emitter.off(ChainEvent.updateStatus, this.onUpdateStatus);
await this.core.close();

Expand Down Expand Up @@ -455,7 +457,8 @@ export class Network implements INetwork {

return this.publishGossip<GossipType.proposer_slashing>(
{type: GossipType.proposer_slashing, boundary},
proposerSlashing
proposerSlashing,
{ignoreDuplicatePublishError: true}
);
}

Expand Down Expand Up @@ -877,6 +880,18 @@ export class Network implements INetwork {
return promiseAllMaybeAsync(sidecars.map((sidecar) => () => this.publishBlobSidecar(sidecar)));
};

private onPublishProposerSlashing = async (proposerSlashing: phase0.ProposerSlashing): Promise<void> => {
try {
await this.publishProposerSlashing(proposerSlashing);
} catch (e) {
this.logger.debug(
"Error publishing proposer slashing",
{proposerIndex: proposerSlashing.signedHeader1.message.proposerIndex},
e as Error
);
}
};

private onUpdateStatus = async (): Promise<void> => {
await this.core.updateStatus(this.chain.getStatus());
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,13 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
chain.seenPayloadEnvelopeInputCache.prune(blockRootHex);
}
throw e;
} finally {
// The block received from the network may have established an equivocation, either by conflicting
// with a previously observed block root (REPEAT_PROPOSAL) or with a root observed during validation
const proposerIndex = signedBlock.message.proposerIndex;
if (chain.seenBlockProposers.isEquivocating(slot, proposerIndex)) {
chain.processProposerEquivocation(slot, proposerIndex);
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/test/mocks/mockedBeaconChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ vi.mock("../../src/chain/chain.js", async (importActual) => {
getStateBySlot: vi.fn(),
updateBuilderStatus: vi.fn(),
processBlock: vi.fn(),
processProposerEquivocation: vi.fn(),
persistInvalidSszValue: vi.fn(),
regenStateForAttestationVerification: vi.fn(),
close: vi.fn(),
Expand Down
Loading
Loading