-
-
Notifications
You must be signed in to change notification settings - Fork 479
feat: implement consensus_and_equivocation for blocks and payload envelopes #9757
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
317e04b
b4fad04
348df93
4b289d0
9d018e8
198bdf5
2cb6ade
5702042
8902551
10522f4
c448521
589d073
f80e52d
f3805fb
5ba5e9a
84329fa
fcca661
5a1661d
14f8b44
8607cf0
3fd4d10
6654ca6
8a55739
64a632f
76461b1
2c2d723
7360da2
308d3af
0154c58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
nflaig marked this conversation as resolved.
|
||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a validator calls Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is pointless for blinded publishing, we submit the block to the builder anyways and |
||
| 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should be able to get block root from BlockInput instead
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| ); | ||
| 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)); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this name is confusing to me, it feels like allowed block roots per proposal
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| /** | ||
| * 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<Slot, Set<ValidatorIndex>>(() => new Set<ValidatorIndex>()); | ||
| private finalizedSlot: Epoch = 0; | ||
| private readonly blockRootsBySlot = new MapDef<Slot, MapDef<ValidatorIndex, Set<RootHex>>>( | ||
|
nflaig marked this conversation as resolved.
|
||
| () => new MapDef<ValidatorIndex, Set<RootHex>>(() => new Set<RootHex>()) | ||
| ); | ||
| 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); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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}); | ||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This returns immediately after verifying the proposer signature, so a conflicting block never reaches normal processing or fork choice. #9233's (written by codex)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can handle that either in #9233 if this PR is merged first, or completely separate as a follow-up |
||||
| } | ||||
|
|
||||
|
|
@@ -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( | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
No need to change it I guess but it is good to know.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh that other one is in the other function also takes If I would change anything, I would probably delete that other function, or rename it. I do think keeping as is for now is fine. good catch though |
||||
| chain: IBeaconChain, | ||||
| signedBlock: SignedBeaconBlock, | ||||
| blockRoot: RootHex, | ||||
| opts: {verifyOnMainThread?: boolean} = {} | ||||
| ): Promise<void> { | ||||
| 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); | ||||
| } | ||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
consensusvalidation should make sure the block has valid signatures, this seems like a bug to skip verification in this caseThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this becomes a dead flag after this PR, noone specify it anymore, maybe we should remove it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what you mean by dead? passing
broadcast_validation=consensusis still valid option, the additional equivocation check is mostly used by builders, I don't see a reason a proposer itself would use it, I remember we have usedconsensusin the past to avoid broadcasting a invalid block, although I think usinggossip(the default) should be best for most proposers so there is low overheadThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah I think I misunderstood what you mean, it's about removing
skipVerifyBlockSignatures, let's do that, I am not sure why it was addedThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
seems like it was introduced in #6149 but this doesn't seem right, based on what other clients do, when
broadcast_validation=consensusis passed, we should validate these as wellthe spec explicitly says this too
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.