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 a0a0b58bb9b2..c04ddea3ed74 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -234,7 +234,7 @@ export function getBeaconBlockApi({ } try { - await verifyBlocksInEpoch.call(chain as BeaconChain, parentBlock, [blockForImport], { + await verifyBlocksInEpoch.call(chain as BeaconChain, parentBlock, [blockForImport], null, { ...opts, verifyOnly: true, skipVerifyBlockSignatures: true, diff --git a/packages/beacon-node/src/api/impl/lodestar/index.ts b/packages/beacon-node/src/api/impl/lodestar/index.ts index dc7e20cb06c4..287b5528052f 100644 --- a/packages/beacon-node/src/api/impl/lodestar/index.ts +++ b/packages/beacon-node/src/api/impl/lodestar/index.ts @@ -112,7 +112,7 @@ export function getLodestarApi({ return { // biome-ignore lint/complexity/useLiteralKeys: The `blockProcessor` is a protected attribute data: (chain as BeaconChain)["blockProcessor"].jobQueue.getItems().map((item) => { - const [blockInputs, opts] = item.args; + const [blockInputs, _envelopes, opts] = item.args; return { blockSlots: blockInputs.map((blockInput) => blockInput.slot), jobOpts: opts, diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index 5e947dbced32..cd586d1cb95b 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -7,6 +7,7 @@ import { ForkChoiceError, ForkChoiceErrorCode, NotReorgedReason, + PayloadStatus, getSafeExecutionBlockHash, isGloasBlock, } from "@lodestar/fork-choice"; @@ -142,7 +143,7 @@ export async function importBlock( // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import if (fork >= ForkSeq.gloas) { - const payloadInput = this.seenPayloadEnvelopeInputCache.add({ + const {payloadInput, created} = this.seenPayloadEnvelopeInputCache.add({ blockRootHex, block: block as SignedBeaconBlock, forkName: blockInput.forkName, @@ -150,28 +151,40 @@ export async function importBlock( custodyColumns: this.custodyConfig.custodyColumns, timeCreatedSec: fullyVerifiedBlock.seenTimestampSec, }); - this.logger.debug("Created PayloadEnvelopeInput for block", { - slot: blockSlot, - root: blockRootHex, - source: source.source, - ...(opts.seenTimestampSec !== undefined ? {recvToImport: Date.now() / 1000 - opts.seenTimestampSec} : {}), - }); + + // at gossip time, we usually create a PayloadEnvelopeInput here + // however, at range sync, we may have already created a PayloadEnvelopeInput + if (created) { + this.logger.debug("Created PayloadEnvelopeInput for block", { + slot: blockSlot, + root: blockRootHex, + source: source.source, + ...(opts.seenTimestampSec !== undefined ? {recvToImport: Date.now() / 1000 - opts.seenTimestampSec} : {}), + }); + } else { + this.logger.debug("PayloadEnvelopeInput already exists for block", { + slot: blockSlot, + root: blockRootHex, + source: source.source, + }); + } // Immediately attempt fetch of data columns from execution engine as the bid contains kzg commitments // which is all the information we need so there is no reason to delay until execution payload arrives // TODO GLOAS: If we want EL retries after this initial attempt, add an explicit retry policy here // (for example later in the slot). Do not couple retries to incoming gossip columns. - this.getBlobsTracker.triggerGetBlobs(payloadInput, () => { - // TODO GLOAS: come up with a better mechanism to trigger processExecutionPayload after data becomes available, - // similar to how pre-gloas uses waitForBlockAndAllData with a cutoff timeout and incompleteBlockInput event - this.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { - this.logger.debug( - "Error processing execution payload after getBlobs", - {slot: blockSlot, root: blockRootHex}, - e as Error - ); + if (!fullyVerifiedBlock.fromRangeSync) + this.getBlobsTracker.triggerGetBlobs(payloadInput, () => { + // TODO GLOAS: come up with a better mechanism to trigger processExecutionPayload after data becomes available, + // similar to how pre-gloas uses waitForBlockAndAllData with a cutoff timeout and incompleteBlockInput event + this.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { + this.logger.debug( + "Error processing execution payload after getBlobs", + {slot: blockSlot, root: blockRootHex}, + e as Error + ); + }); }); - }); } this.metrics?.importBlock.bySource.inc({source: source.source}); @@ -341,6 +354,7 @@ export async function importBlock( this.logger.verbose("New chain head", { slot: newHead.slot, root: newHead.blockRoot, + payloadStatus: newHead.payloadStatus === PayloadStatus.FULL ? "full" : "empty", delaySec, }); @@ -500,7 +514,7 @@ export async function importBlock( } if (!postBlockState.isStateValidatorsNodesPopulated()) { - this.logger.verbose("After importBlock caching postState without SSZ cache", {slot: postBlockState.slot}); + this.logger.verbose("After importBlock caching postBlockState without SSZ cache", {slot: postBlockState.slot}); } // Cache shufflings when crossing an epoch boundary diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index c50aea6face8..ed886728e016 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,11 +1,17 @@ import {routes} from "@lodestar/api"; -import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; +import {ExecutionStatus, PayloadExecutionStatus, PayloadStatus} from "@lodestar/fork-choice"; import {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; +import { + IBeaconStateViewGloas, + getExecutionPayloadEnvelopeSignatureSet, + isStatePostGloas, +} from "@lodestar/state-transition"; import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {isOptimisticBlock} from "../../util/forkChoice.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; +import {ForkchoiceCaller} from "../forkChoice/index.js"; import {RegenCaller} from "../regen/interface.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; import {ImportPayloadOpts} from "./types.js"; @@ -65,33 +71,34 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe } } +type VerifyExecutionPayloadResult = { + postPayloadState: IBeaconStateViewGloas; + execStatus: PayloadExecutionStatus; +}; + /** - * Import an execution payload envelope after all data is available. - * - * This function: - * 1. Emits `execution_payload_available` if payload is for current slot - * 2. Gets the ProtoBlock from fork choice - * 3. Applies write-queue backpressure (waitForSpace) early, before verification - * 4. Regenerates the block state - * 5. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope - * 6. Persists verified payload envelope to hot DB - * 7. Updates fork choice - * 8. Caches the post-execution payload state - * 9. Records metrics for column sources - * 10. Emits `execution_payload` for recent enough payloads after successful import + * Verify an execution payload envelope: + * 1. Emit `execution_payload_available` if payload is for current slot + * 2. Get the ProtoBlock from fork choice + * 3. Regenerate the block state + * 4. Run EL verification, signature verification, and state transition in parallel + * 5. Validate results (signature, EL status, state root) * + * Returns the verified post-payload state and fork-choice execution status. */ -export async function importExecutionPayload( +export async function verifyExecutionPayload( this: BeaconChain, payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {} -): Promise { +): Promise { const signedEnvelope = payloadInput.getPayloadEnvelope(); const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; - const blockHashHex = payloadInput.getBlockHashHex(); const fork = this.config.getForkName(envelope.slot); + // TODO GLOAS: also wait for payload data availability + // see https://github.com/ChainSafe/lodestar/issues/9150 + // 1. Emit `execution_payload_available` event at the start of import. At this point the payload input // is already complete, so the payload and required data are available for payload attestation. // This event is only about availability, not validity of the execution payload, hence we can emit @@ -112,11 +119,7 @@ export async function importExecutionPayload( }); } - // 3. Apply backpressure from the write queue early, before doing verification work. - // The actual DB write is deferred until after verification succeeds. - await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); - - // 4. Get pre-state for processExecutionPayloadEnvelope + // 3. Get pre-state for processExecutionPayloadEnvelope // We need the block state (post-block, pre-payload) to process the envelope const blockState = await this.regen.getBlockSlotState( protoBlock, @@ -131,8 +134,8 @@ export async function importExecutionPayload( }); } - // 5. Run verification steps in parallel - // Note: No data availability check needed here - importExecutionPayload is only + // 4. Run verification steps in parallel + // Note: No data availability check needed here - verifyExecutionPayload is only // called when payloadInput.isComplete() is true, so all data is already available. const [execResult, signatureValid, postPayloadResult] = await Promise.all([ this.executionEngine.notifyNewPayload( @@ -219,7 +222,43 @@ export async function importExecutionPayload( }); } - // 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + if (!isStatePostGloas(postPayloadState)) { + throw Error( + `Expected gloas+ post-envelope-state for execution payload envelope, got fork=${postPayloadState.forkName}` + ); + } + + return {postPayloadState, execStatus: toForkChoiceExecutionStatus(execResult.status)}; +} + +/** + * Import a pre-verified execution payload envelope: + * 6. Applies write-queue backpressure (waitForSpace) + * 7. Persists payload envelope to hot DB + * 8. Updates fork choice + * 9. Caches the post-payload state + * 10. Records metrics for column sources + * 11. Emits `execution_payload` for recent enough payloads + * + * Assumes verification already passed. Accepts pre-computed postPayloadState and execStatus, + * so it can be reused from both processExecutionPayload() and the pre-verified processChainSegment path. + */ +export async function importExecutionPayload( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput, + postPayloadState: IBeaconStateViewGloas, + execStatus: PayloadExecutionStatus +): Promise { + const signedEnvelope = payloadInput.getPayloadEnvelope(); + const envelope = signedEnvelope.message; + const blockRootHex = payloadInput.blockRootHex; + const blockHashHex = payloadInput.getBlockHashHex(); + const postPayloadStateRoot = postPayloadState.hashTreeRoot(); + + // 6. Apply backpressure from the write queue before doing persistence work. + await this.unfinalizedPayloadEnvelopeWrites.waitForSpace(); + + // 7. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( @@ -230,23 +269,43 @@ export async function importExecutionPayload( } }); - // 7. Update fork choice + // 8. Update fork choice + const oldHead = this.forkChoice.getHead(); this.forkChoice.onExecutionPayload( blockRootHex, blockHashHex, envelope.payload.blockNumber, toRootHex(postPayloadStateRoot), - toForkChoiceExecutionStatus(execResult.status) + execStatus ); + // TODO GLOAS: if this is called from gossip, the new head may not be correct because it does not go through + // tie-breaker rules since we're not yet at the next slot. Need to find a way to do that? + // see also https://github.com/ChainSafe/lodestar/pull/9164 + const newHead = this.recomputeForkChoiceHead(ForkchoiceCaller.importBlock); + + if (newHead.blockRoot !== oldHead.blockRoot) { + this.regen.updateHeadState(newHead, postPayloadState); + this.logger.verbose("New chain head after execution payload import", { + slot: newHead.slot, + root: newHead.blockRoot, + payloadStatus: newHead.payloadStatus === PayloadStatus.FULL ? "full" : "empty", + executionOptimistic: isOptimisticBlock(newHead), + }); + if (this.metrics) { + this.metrics.headSlot.set(newHead.slot); + } + this.onNewHead(newHead); + this.metrics?.forkChoice.changedHead.inc(); + } - // 8. Cache payload state + // 9. Cache payload state this.regen.processPayloadState(postPayloadState); if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); this.regen.addCheckpointState(checkpoint, postPayloadState, true); } - // 9. Record metrics for payload envelope and column sources + // 10. Record metrics for payload envelope and column sources this.metrics?.importPayload.bySource.inc({source: payloadInput.getPayloadEnvelopeSource().source}); for (const {source} of payloadInput.getSampledColumnsWithSource()) { this.metrics?.importPayload.columnsBySource.inc({source}); @@ -254,7 +313,7 @@ export async function importExecutionPayload( const stateRootHex = toRootHex(envelope.stateRoot); - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads + // 11. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { slot: envelope.slot, @@ -275,3 +334,19 @@ export async function importExecutionPayload( stateRoot: stateRootHex, }); } + +/** + * Process an execution payload envelope end-to-end: + * verifies it (steps 1-5) then imports it (steps 6-11). + * + * Used by PayloadEnvelopeProcessor for the normal gossip/req-resp path. + * For the pre-verified processChainSegment path, call importExecutionPayload() directly. + */ +export async function processExecutionPayload( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput, + opts: ImportPayloadOpts = {} +): Promise { + const {postPayloadState, execStatus} = await verifyExecutionPayload.call(this, payloadInput, opts); + await importExecutionPayload.call(this, payloadInput, postPayloadState, execStatus); +} diff --git a/packages/beacon-node/src/chain/blocks/index.ts b/packages/beacon-node/src/chain/blocks/index.ts index a0b5fdfe28c1..e0c53f83477a 100644 --- a/packages/beacon-node/src/chain/blocks/index.ts +++ b/packages/beacon-node/src/chain/blocks/index.ts @@ -1,4 +1,5 @@ -import {SignedBeaconBlock} from "@lodestar/types"; +import {ExecutionStatus} from "@lodestar/fork-choice"; +import {SignedBeaconBlock, Slot} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {nextEventLoop} from "../../util/eventLoop.js"; @@ -8,6 +9,8 @@ import {BlockError, BlockErrorCode, isBlockErrorAborted} from "../errors/index.j import {BlockProcessOpts} from "../options.js"; import {IBlockInput} from "./blockInput/types.js"; import {importBlock} from "./importBlock.js"; +import {importExecutionPayload} from "./importExecutionPayload.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {FullyVerifiedBlock, ImportBlockOpts} from "./types.js"; import {assertLinearChainSegment} from "./utils/chainSegment.js"; import {verifyBlocksInEpoch} from "./verifyBlock.js"; @@ -21,20 +24,24 @@ const QUEUE_MAX_LENGTH = 256; * BlockProcessor processes block jobs in a queued fashion, one after the other. */ export class BlockProcessor { - readonly jobQueue: JobItemQueue<[IBlockInput[], ImportBlockOpts], void>; + readonly jobQueue: JobItemQueue<[IBlockInput[], Map | null, ImportBlockOpts], void>; constructor(chain: BeaconChain, metrics: Metrics | null, opts: BlockProcessOpts, signal: AbortSignal) { - this.jobQueue = new JobItemQueue<[IBlockInput[], ImportBlockOpts], void>( - (job, importOpts) => { - return processBlocks.call(chain, job, {...opts, ...importOpts}); + this.jobQueue = new JobItemQueue<[IBlockInput[], Map | null, ImportBlockOpts], void>( + (job, envelopes, importOpts) => { + return processBlocks.call(chain, job, envelopes, {...opts, ...importOpts}); }, {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, metrics?.blockProcessorQueue ?? undefined ); } - async processBlocksJob(job: IBlockInput[], opts: ImportBlockOpts = {}): Promise { - await this.jobQueue.push(job, opts); + async processBlocksJob( + job: IBlockInput[], + envelopes: Map | null, + opts: ImportBlockOpts = {} + ): Promise { + await this.jobQueue.push(job, envelopes, opts); } } @@ -51,16 +58,13 @@ export class BlockProcessor { export async function processBlocks( this: BeaconChain, blocks: IBlockInput[], + payloadEnvelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise { if (blocks.length === 0) { return; // TODO: or throw? } - if (blocks.length > 1) { - assertLinearChainSegment(this.config, blocks); - } - try { const {relevantBlocks, parentSlots, parentBlock} = verifyBlocksSanityChecks(this, blocks, opts); @@ -70,10 +74,31 @@ export async function processBlocks( return; } + const {warnings: orphanedPayloads} = assertLinearChainSegment( + this.config, + relevantBlocks, + payloadEnvelopes, + parentBlock + ); + if (orphanedPayloads != null) { + for (const orphaned of orphanedPayloads) { + this.logger.debug("Orphaned payload envelope in chain segment", { + slot: orphaned.slot, + blockRoot: orphaned.payloadEnvelopeInput.blockRootHex, + }); + } + } + // Fully verify a block to be imported immediately after. Does not produce any side-effects besides adding intermediate // states in the state cache through regen. - const {postStates, dataAvailabilityStatuses, proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock} = - await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, opts); + const { + postBlockStates, + dataAvailabilityStatuses, + proposerBalanceDeltas, + segmentExecStatus, + indexedAttestationsByBlock, + postPayloadStates, + } = await verifyBlocksInEpoch.call(this, parentBlock, relevantBlocks, payloadEnvelopes, opts); // If segmentExecStatus has lvhForkchoice then, the entire segment should be invalid // and we need to further propagate @@ -85,25 +110,44 @@ export async function processBlocks( } const {executionStatuses} = segmentExecStatus; - const fullyVerifiedBlocks = relevantBlocks.map( - (block, i): FullyVerifiedBlock => ({ + const fullyVerifiedBlocks = relevantBlocks.map((block, i): FullyVerifiedBlock => { + const slot = block.getBlock().message.slot; + const payloadEnvelopeResult = postPayloadStates.get(slot) ?? null; + const executionStatus = executionStatuses[i]; + const baseFields = { blockInput: block, - postBlockState: postStates[i], + postBlockState: postBlockStates[i], postPayloadState: null, parentBlockSlot: parentSlots[i], - executionStatus: executionStatuses[i], // start supporting optimistic syncing/processing dataAvailabilityStatus: dataAvailabilityStatuses[i], proposerBalanceDelta: proposerBalanceDeltas[i], indexedAttestations: indexedAttestationsByBlock[i], // TODO: Make this param mandatory and capture in gossip seenTimestampSec: opts.seenTimestampSec ?? Math.floor(Date.now() / 1000), - }) - ); + fromRangeSync: opts.fromRangeSync, + }; + + if (payloadEnvelopeResult !== null) { + const {postPayloadState, payloadEnvelopeInput} = payloadEnvelopeResult; + if (executionStatus !== ExecutionStatus.Valid && executionStatus !== ExecutionStatus.Syncing) { + throw new Error( + `postPayloadEnvelopeState is set but executionStatus is ${executionStatus}, expected Valid or Syncing. slot=${slot} blockIndex=${i}` + ); + } + return {...baseFields, postPayloadState, payloadEnvelopeInput, executionStatus}; + } + + return {...baseFields, postPayloadState: null, executionStatus}; + }); for (const fullyVerifiedBlock of fullyVerifiedBlocks) { // TODO: Consider batching importBlock too if it takes significant time await importBlock.call(this, fullyVerifiedBlock, opts); + if (fullyVerifiedBlock.postPayloadState !== null) { + const {postPayloadState, payloadEnvelopeInput, executionStatus} = fullyVerifiedBlock; + await importExecutionPayload.call(this, payloadEnvelopeInput, postPayloadState, executionStatus); + } await nextEventLoop(); } } catch (e) { @@ -128,7 +172,7 @@ export async function processBlocks( const forkTypes = this.config.getForkTypes(blockSlot); this.persistInvalidSszValue(forkTypes.SignedBeaconBlock, signedBlock, `${blockSlot}_invalid_signature`); this.persistInvalidSszBytes("BeaconState", state.serialize(), `${state.slot}_invalid_signature`); - } else if (err.type.code === BlockErrorCode.INVALID_STATE_ROOT) { + } else if (err.type.code === BlockErrorCode.INVALID_BLOCK_STATE_ROOT) { const {signedBlock} = err; const blockSlot = signedBlock.message.slot; const {preState, postState} = err.type; diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts index e50b53bda93e..1e1edf78eecb 100644 --- a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -2,7 +2,7 @@ import {Metrics} from "../../metrics/metrics.js"; import {JobItemQueue} from "../../util/queue/index.js"; import type {BeaconChain} from "../chain.js"; import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; -import {importExecutionPayload} from "./importExecutionPayload.js"; +import {processExecutionPayload} from "./importExecutionPayload.js"; import {ImportPayloadOpts} from "./types.js"; // TODO GLOAS: Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now @@ -25,7 +25,7 @@ export class PayloadEnvelopeProcessor { this.jobQueue = new JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>( (payloadInput, opts) => { this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.importing); - return importExecutionPayload.call(chain, payloadInput, opts); + return processExecutionPayload.call(chain, payloadInput, opts); }, {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, metrics?.payloadEnvelopeProcessorQueue ?? undefined diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index e1858040a483..6b5824ae080a 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -1,9 +1,15 @@ import type {ChainForkConfig} from "@lodestar/config"; import {BlockExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; -import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; +import { + DataAvailabilityStatus, + IBeaconStateView, + IBeaconStateViewGloas, + computeEpochAtSlot, +} from "@lodestar/state-transition"; import type {IndexedAttestation, Slot, fulu} from "@lodestar/types"; import {IBlockInput} from "./blockInput/types.js"; +import type {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; export enum GossipedInputType { block = "block", @@ -98,6 +104,8 @@ type FullyVerifiedBlockBase = { indexedAttestations: IndexedAttestation[]; /** Seen timestamp seconds */ seenTimestampSec: number; + /** Whether this block came from range sync; used to skip unnecessary EL data fetches */ + fromRangeSync?: boolean; }; /** @@ -116,7 +124,8 @@ export type FullyVerifiedBlock = FullyVerifiedBlockBase & executionStatus: BlockExecutionStatus; } | { - postPayloadState: IBeaconStateView; + postPayloadState: IBeaconStateViewGloas; + payloadEnvelopeInput: PayloadEnvelopeInput; executionStatus: PayloadExecutionStatus; } ); diff --git a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts index 5c9b4d8b9d56..e3caf877f986 100644 --- a/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts +++ b/packages/beacon-node/src/chain/blocks/utils/chainSegment.ts @@ -1,29 +1,108 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ssz} from "@lodestar/types"; +import {ProtoBlock} from "@lodestar/fork-choice"; +import {Slot, isGloasBeaconBlock, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; import {BlockError, BlockErrorCode} from "../../errors/index.js"; import {IBlockInput} from "../blockInput/types.js"; +import {PayloadEnvelopeInput} from "../payloadEnvelopeInput/payloadEnvelopeInput.js"; + +export type OrphanedPayloadEnvelope = { + slot: Slot; + payloadEnvelopeInput: PayloadEnvelopeInput; +}; + +export type ChainSegmentResult = {warnings: OrphanedPayloadEnvelope[] | null}; /** - * Assert this chain segment of blocks is linear with slot numbers and hashes + * Assert this chain segment of blocks is linear with slot numbers and hashes, + * and that the provided envelopes are consistent with their respective blocks. + * + * Must be called after verifyBlocksSanityChecks so that parentBlock (from forkchoice) + * is available to seed the execution hash chain. + * + * For each block: + * - Verifies parent root + slot linearity + * - For gloas: verifies bid.parentBlockHash matches the tracked execution hash + * - If an envelope exists for this slot: verifies it references this block's root + * - Advances the tracked execution hash (FULL if envelope present, EMPTY if not) */ +export function assertLinearChainSegment( + config: ChainForkConfig, + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + parentBlock: ProtoBlock +): ChainSegmentResult { + const warnings: OrphanedPayloadEnvelope[] = []; -export function assertLinearChainSegment(config: ChainForkConfig, blocks: IBlockInput[]): void { - for (let i = 0; i < blocks.length - 1; i++) { + // Track the expected execution payload block hash through the segment. + // Starts from the known forkchoice parent's execution hash. + // - FULL variant (envelope present for slot): advances to envelope.payload.blockHash + // - EMPTY variant (no envelope for slot): execution hash is unchanged + // null only for pre-merge parents, which cannot precede gloas blocks. + let currentExecHash: string | null = parentBlock.executionPayloadBlockHash; + // Track the execution hash before the last FULL advancement so we can recover + // if the next block reveals that envelope was orphaned. + let prevExecHash: string | null = currentExecHash; + // The slot whose envelope last advanced currentExecHash (for warning context). + let lastFullSlot: Slot | null = null; + + for (let i = 0; i < blocks.length; i++) { const block = blocks[i].getBlock(); - const child = blocks[i + 1].getBlock(); - // If this block has a child in this chain segment, ensure that its parent root matches - // the root of this block. - if ( - !ssz.Root.equals( - config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message), - child.message.parentRoot - ) - ) { - throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_PARENT_ROOTS}); + const slot = block.message.slot; + + if (i > 0) { + const prevBlock = blocks[i - 1].getBlock(); + // Ensure parent root matches the previous block's root + if ( + !ssz.Root.equals( + config.getForkTypes(prevBlock.message.slot).BeaconBlock.hashTreeRoot(prevBlock.message), + block.message.parentRoot + ) + ) { + throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_PARENT_ROOTS}); + } + // Ensure slots are strictly increasing + if (slot <= prevBlock.message.slot) { + throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_SLOTS}); + } } - // Ensure that the slots are strictly increasing throughout the chain segment. - if (child.message.slot <= block.message.slot) { - throw new BlockError(block, {code: BlockErrorCode.NON_LINEAR_SLOTS}); + + if (isGloasBeaconBlock(block.message) && currentExecHash !== null) { + // Verify the bid's parentBlockHash matches the tracked execution hash. + // This ensures the block was built on the correct FULL or EMPTY variant of its parent. + const bidParentHash = toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash); + if (bidParentHash !== currentExecHash) { + // The previous slot's envelope was orphaned — fall back to prevExecHash + if (lastFullSlot !== null && payloadEnvelopes !== null) { + const orphanedInput = payloadEnvelopes.get(lastFullSlot); + if (orphanedInput != null) { + warnings.push({slot: lastFullSlot, payloadEnvelopeInput: orphanedInput}); + } + } + currentExecHash = prevExecHash; + } + + const payloadEnvelope = payloadEnvelopes?.get(slot)?.getPayloadEnvelope(); + if (payloadEnvelope != null) { + // Verify the envelope references this block's root + const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message)); + const envelopeBlockRoot = toRootHex(payloadEnvelope.message.beaconBlockRoot); + if (blockRoot !== envelopeBlockRoot) { + throw new BlockError(block, { + code: BlockErrorCode.ENVELOPE_BLOCK_ROOT_MISMATCH, + envelopeBlockRoot, + blockRoot, + }); + } + + // FULL variant: save state before advancing, then advance + prevExecHash = currentExecHash; + lastFullSlot = slot; + currentExecHash = toRootHex(payloadEnvelope.message.payload.blockHash); + } + // EMPTY variant: currentExecHash unchanged } } + + return {warnings: warnings.length > 0 ? warnings : null}; } diff --git a/packages/beacon-node/src/chain/blocks/verifyBlock.ts b/packages/beacon-node/src/chain/blocks/verifyBlock.ts index 42e0a381314c..9c88ea7f4494 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlock.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlock.ts @@ -1,12 +1,18 @@ import {ExecutionStatus, ProtoBlock} from "@lodestar/fork-choice"; import {ForkName, isForkPostFulu} from "@lodestar/params"; -import {DataAvailabilityStatus, IBeaconStateView, computeEpochAtSlot} from "@lodestar/state-transition"; -import {IndexedAttestation, deneb} from "@lodestar/types"; +import { + DataAvailabilityStatus, + IBeaconStateView, + IBeaconStateViewGloas, + computeEpochAtSlot, +} from "@lodestar/state-transition"; +import {IndexedAttestation, Slot, deneb} from "@lodestar/types"; import type {BeaconChain} from "../chain.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {RegenCaller} from "../regen/index.js"; import {DAType, IBlockInput} from "./blockInput/index.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; import {DENEB_BLOWFISH_BANNER} from "./utils/blowfishBanner.js"; import {ELECTRA_GIRAFFE_BANNER} from "./utils/giraffeBanner.js"; @@ -32,13 +38,18 @@ export async function verifyBlocksInEpoch( this: BeaconChain, parentBlock: ProtoBlock, blockInputs: IBlockInput[], + payloadEnvelopes: Map | null, opts: BlockProcessOpts & ImportBlockOpts ): Promise<{ - postStates: IBeaconStateView[]; + postBlockStates: IBeaconStateView[]; proposerBalanceDeltas: number[]; segmentExecStatus: SegmentExecStatus; dataAvailabilityStatuses: DataAvailabilityStatus[]; indexedAttestationsByBlock: IndexedAttestation[][]; + postPayloadStates: Map< + Slot, + {postPayloadState: IBeaconStateViewGloas; payloadEnvelopeInput: PayloadEnvelopeInput} | null + >; }> { const blocks = blockInputs.map((blockInput) => blockInput.getBlock()); const lastBlock = blocks.at(-1); @@ -94,7 +105,15 @@ export async function verifyBlocksInEpoch( // Start execution payload verification first (async request to execution client) const verifyExecutionPayloadsPromise = opts.skipVerifyExecutionPayload !== true - ? verifyBlocksExecutionPayload(this, parentBlock, blockInputs, preState0, abortController.signal, opts) + ? verifyBlocksExecutionPayload( + this, + parentBlock, + blockInputs, + payloadEnvelopes, + preState0, + abortController.signal, + opts + ) : Promise.resolve({ execAborted: null, executionStatuses: blocks.map((_blk) => ExecutionStatus.Syncing), @@ -114,12 +133,14 @@ export async function verifyBlocksInEpoch( const [ segmentExecStatus, {dataAvailabilityStatuses, availableTime}, - {postStates, proposerBalanceDeltas, verifyStateTime}, + {postBlockStates, proposerBalanceDeltas, verifyStateTime, postPayloadStates}, {verifySignaturesTime}, ] = await Promise.all([ verifyExecutionPayloadsPromise, // data availability for the blobs + // TODO GLOAS: modify this once we have verifyPayloadDataAvailability() + // see https://github.com/ChainSafe/lodestar/issues/9150 verifyBlocksDataAvailability(blockInputs, abortController.signal), // Run state transition only @@ -127,6 +148,7 @@ export async function verifyBlocksInEpoch( verifyBlocksStateTransitionOnly( preState0, blockInputs, + payloadEnvelopes, // hack availability for state transition eval as availability is separately determined blocks.map(() => DataAvailabilityStatus.Available), this.logger, @@ -140,11 +162,13 @@ export async function verifyBlocksInEpoch( opts.skipVerifyBlockSignatures !== true ? verifyBlocksSignatures( this.config, + this.pubkeyCache, this.bls, this.logger, this.metrics, preState0, blocks, + payloadEnvelopes, indexedAttestationsByBlock, opts ) @@ -229,7 +253,14 @@ export async function verifyBlocksInEpoch( ); } - return {postStates, dataAvailabilityStatuses, proposerBalanceDeltas, segmentExecStatus, indexedAttestationsByBlock}; + return { + postBlockStates, + dataAvailabilityStatuses, + proposerBalanceDeltas, + segmentExecStatus, + indexedAttestationsByBlock, + postPayloadStates, + }; } finally { abortController.abort(); } diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts index c1b225381076..f6ed57311c19 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksExecutionPayloads.ts @@ -9,15 +9,26 @@ import { } from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {IBeaconStateView, isExecutionBlockBodyType, isStatePostBellatrix} from "@lodestar/state-transition"; -import {bellatrix, electra} from "@lodestar/types"; +import { + ExecutionPayload, + ExecutionRequests, + Root, + Slot, + bellatrix, + electra, + gloas, + isGloasBeaconBlock, +} from "@lodestar/types"; import {ErrorAborted, Logger, toRootHex} from "@lodestar/utils"; -import {ExecutionPayloadStatus, IExecutionEngine} from "../../execution/engine/interface.js"; +import {ExecutionPayloadStatus, IExecutionEngine, VersionedHashes} from "../../execution/engine/interface.js"; import {Metrics} from "../../metrics/metrics.js"; +import {kzgCommitmentToVersionedHash} from "../../util/blobs.js"; import {IClock} from "../../util/clock.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {isBlockInputBlobs, isBlockInputColumns, isBlockInputNoData} from "./blockInput/blockInput.js"; import {IBlockInput} from "./blockInput/types.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; export type VerifyBlockExecutionPayloadModules = { @@ -58,6 +69,7 @@ export async function verifyBlocksExecutionPayload( chain: VerifyBlockExecutionPayloadModules, parentBlock: ProtoBlock, blockInputs: IBlockInput[], + payloadEnvelopes: Map | null, preState0: IBeaconStateView, signal: AbortSignal, opts: BlockProcessOpts & ImportBlockOpts @@ -96,11 +108,13 @@ export async function verifyBlocksExecutionPayload( if (signal.aborted) { throw new ErrorAborted("verifyBlockExecutionPayloads"); } - const verifyResponse = await verifyBlockExecutionPayload(chain, blockInput, preState0); + const payloadInput = payloadEnvelopes?.get(blockInput.slot) ?? null; + const signedEnvelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : null; + const verifyResponse = await verifyBlockExecutionPayload(chain, blockInput, signedEnvelope, preState0); // If execError has happened, then we need to extract the segmentExecStatus and return if (verifyResponse.execError !== null) { - return getSegmentErrorResponse({verifyResponse, blockIndex}, parentBlock, blockInputs); + return getSegmentErrorResponse({verifyResponse, blockIndex}, parentBlock, blockInputs, payloadEnvelopes); } // If we are here then its because executionStatus is one of BlockExecutionStatus @@ -141,47 +155,74 @@ export async function verifyBlocksExecutionPayload( export async function verifyBlockExecutionPayload( chain: VerifyBlockExecutionPayloadModules, blockInput: IBlockInput, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope | null, preState0: IBeaconStateView ): Promise { const block = blockInput.getBlock(); - // Gloas block doesn't have execution payload. Return right away - if (isBlockInputNoData(blockInput)) { + // Gloas block doesn't have execution payload in block body. This happens at gossip time, or range sync with no payload. + if (isBlockInputNoData(blockInput) && signedEnvelope === null) { + const clockSlot = chain.clock.currentSlot; + const logMsg = + clockSlot === blockInput.slot + ? "Skipping engine api newPayload for gloas block" + : "Skipping engine api newPayload for gloas block without payload"; + chain.logger.verbose(logMsg, {slot: blockInput.slot, root: blockInput.blockRootHex, clockSlot}); return {executionStatus: ExecutionStatus.PayloadSeparated, lvhResponse: undefined, execError: null}; } - /** Not null if execution is enabled */ - const executionPayloadEnabled = - isStatePostBellatrix(preState0) && - preState0.isExecutionStateType && - isExecutionBlockBodyType(block.message.body) && - preState0.isExecutionEnabled(block.message) - ? block.message.body.executionPayload - : null; + const fork = blockInput.forkName; - if (!executionPayloadEnabled) { - // Pre-merge block, no execution payload to verify - return {executionStatus: ExecutionStatus.PreMerge, lvhResponse: undefined, execError: null}; - } + // For gloas, execution payload and execution requests come from the signed envelope; + // blob kzg commitments come from the execution payload bid in the block body. + // For other forks, all three come from the block body. + let executionPayload: ExecutionPayload; + let versionedHashes: VersionedHashes | undefined; + let parentBlockRoot: Root | undefined; + let executionRequests: ExecutionRequests | undefined; - // TODO: Handle better notifyNewPayload() returning error is syncing - const fork = blockInput.forkName; - const versionedHashes = - isBlockInputBlobs(blockInput) || isBlockInputColumns(blockInput) ? blockInput.getVersionedHashes() : undefined; - const parentBlockRoot = ForkSeq[fork] >= ForkSeq.deneb ? block.message.parentRoot : undefined; - const executionRequests = - ForkSeq[fork] >= ForkSeq.electra ? (block.message.body as electra.BeaconBlockBody).executionRequests : undefined; + if (isBlockInputNoData(blockInput)) { + // Should not happen - we returned early above if signedEnvelope is null + if (signedEnvelope === null) + throw Error(`signedEnvelope is null for gloas block slot=${blockInput.slot} root=${blockInput.blockRootHex}`); + executionPayload = signedEnvelope.message.payload; + versionedHashes = blockInput.getBlobKzgCommitments().map(kzgCommitmentToVersionedHash); + parentBlockRoot = block.message.parentRoot; // gloas is always post-deneb + executionRequests = signedEnvelope.message.executionRequests; + } else { + /** pre-gloas, not null if execution is enabled */ + const executionPayloadEnabled = + isStatePostBellatrix(preState0) && + preState0.isExecutionStateType && + isExecutionBlockBodyType(block.message.body) && + preState0.isExecutionEnabled(block.message) + ? block.message.body.executionPayload + : null; + + if (!executionPayloadEnabled) { + // Pre-merge block, no execution payload to verify + return {executionStatus: ExecutionStatus.PreMerge, lvhResponse: undefined, execError: null}; + } + + executionPayload = executionPayloadEnabled; + // TODO GLOAs: Handle better notifyNewPayload() returning error is syncing + versionedHashes = + isBlockInputBlobs(blockInput) || isBlockInputColumns(blockInput) ? blockInput.getVersionedHashes() : undefined; + parentBlockRoot = ForkSeq[fork] >= ForkSeq.deneb ? block.message.parentRoot : undefined; + executionRequests = + ForkSeq[fork] >= ForkSeq.electra ? (block.message.body as electra.BeaconBlockBody).executionRequests : undefined; + } - const logCtx = {slot: blockInput.slot, executionBlock: executionPayloadEnabled.blockNumber}; - chain.logger.debug("Call engine api newPayload", logCtx); + const logCtx = {slot: blockInput.slot, root: blockInput.blockRootHex, executionBlock: executionPayload.blockNumber}; + chain.logger.verbose("Call engine api newPayload", logCtx); const execResult = await chain.executionEngine.notifyNewPayload( fork, - executionPayloadEnabled, + executionPayload, versionedHashes, parentBlockRoot, executionRequests ); - chain.logger.debug("Receive engine api newPayload result", {...logCtx, status: execResult.status}); + chain.logger.verbose("Receive engine api newPayload result", {...logCtx, status: execResult.status}); chain.metrics?.engineNotifyNewPayloadResult.inc({result: execResult.status}); @@ -246,7 +287,8 @@ export async function verifyBlockExecutionPayload( function getSegmentErrorResponse( {verifyResponse, blockIndex}: {verifyResponse: VerifyExecutionErrorResponse; blockIndex: number}, parentBlock: ProtoBlock, - blocks: IBlockInput[] + blocks: IBlockInput[], + payloadEnvelopes: Map | null ): SegmentExecStatus { const {executionStatus, lvhResponse, execError} = verifyResponse; let invalidSegmentLVH: LVHInvalidResponse | undefined = undefined; @@ -258,11 +300,16 @@ function getSegmentErrorResponse( ) { let lvhFound = false; for (let mayBeLVHIndex = blockIndex - 1; mayBeLVHIndex >= 0; mayBeLVHIndex--) { - const block = blocks[mayBeLVHIndex].getBlock(); - if ( - toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash) === - lvhResponse.latestValidExecHash - ) { + const blockInput = blocks[mayBeLVHIndex]; + const block = blockInput.getBlock(); + const blockHashHex = isGloasBeaconBlock(block.message) + ? toRootHex( + payloadEnvelopes?.get(blockInput.slot)?.getPayloadEnvelope()?.message.payload.blockHash ?? + // should be parentBlockHash because there is no payload in this case + block.message.body.signedExecutionPayloadBid.message.parentBlockHash + ) + : toRootHex((block.message.body as bellatrix.BeaconBlockBody).executionPayload.blockHash); + if (blockHashHex === lvhResponse.latestValidExecHash) { lvhFound = true; break; } diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts index a5613c8135ca..3f2837f9078f 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksSignatures.ts @@ -1,16 +1,19 @@ import {BeaconConfig} from "@lodestar/config"; import { IBeaconStateView, + PubkeyCache, SyncCommitteeCacheEmpty, getBlockSignatureSets, + getExecutionPayloadEnvelopeSignatureSet, isStatePostAltair, } from "@lodestar/state-transition"; -import {IndexedAttestation, SignedBeaconBlock} from "@lodestar/types"; +import {IndexedAttestation, SignedBeaconBlock, Slot} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {nextEventLoop} from "../../util/eventLoop.js"; import {IBlsVerifier} from "../bls/index.js"; import {BlockError, BlockErrorCode} from "../errors/blockError.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; /** @@ -22,11 +25,13 @@ import {ImportBlockOpts} from "./types.js"; */ export async function verifyBlocksSignatures( config: BeaconConfig, + pubkeyCache: PubkeyCache, bls: IBlsVerifier, logger: Logger, metrics: Metrics | null, preState0: IBeaconStateView, blocks: SignedBeaconBlock[], + payloadEnvelopes: Map | null, indexedAttestationsByBlock: IndexedAttestation[][], opts: ImportBlockOpts ): Promise<{verifySignaturesTime: number}> { @@ -45,13 +50,31 @@ export async function verifyBlocksSignatures( isValidPromises[i] = opts.validSignatures ? // Skip all signature verification Promise.resolve(true) - : // - // Verify signatures per block to track which block is invalid - bls.verifySignatureSets( - getBlockSignatureSets(config, currentSyncCommitteeIndexed, preState0, block, indexedAttestationsByBlock[i], { - skipProposerSignature: opts.validProposerSignature, - }) - ); + : // Verify signatures per block + envelope to track which block is invalid + (() => { + const signatureSets = getBlockSignatureSets( + config, + currentSyncCommitteeIndexed, + preState0, + block, + indexedAttestationsByBlock[i], + {skipProposerSignature: opts.validProposerSignature} + ); + const payloadInput = payloadEnvelopes?.get(block.message.slot); + const payloadEnvelope = payloadInput?.hasPayloadEnvelope() ? payloadInput.getPayloadEnvelope() : undefined; + if (payloadEnvelope) { + signatureSets.push( + getExecutionPayloadEnvelopeSignatureSet( + config, + pubkeyCache, + preState0, + payloadEnvelope, + block.message.proposerIndex + ) + ); + } + return bls.verifySignatureSets(signatureSets); + })(); // getBlockSignatureSets() takes 45ms in benchmarks for 2022Q2 mainnet blocks (100 sigs). When syncing a 32 blocks // segments it will block the event loop for 1400 ms, which is too much. This call will allow the event loop to diff --git a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts index 8a9ffa3b1fed..c65a3444485c 100644 --- a/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts +++ b/packages/beacon-node/src/chain/blocks/verifyBlocksStateTransitionOnly.ts @@ -2,49 +2,100 @@ import { DataAvailabilityStatus, ExecutionPayloadStatus, IBeaconStateView, + IBeaconStateViewGloas, StateHashTreeRootSource, + isStatePostGloas, } from "@lodestar/state-transition"; -import {ErrorAborted, Logger, byteArrayEquals} from "@lodestar/utils"; +import {Slot, isGloasBeaconBlock} from "@lodestar/types"; +import {ErrorAborted, Logger, byteArrayEquals, toRootHex} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {nextEventLoop} from "../../util/eventLoop.js"; import {BlockError, BlockErrorCode} from "../errors/index.js"; import {BlockProcessOpts} from "../options.js"; import {ValidatorMonitor} from "../validatorMonitor.js"; import {IBlockInput} from "./blockInput/index.js"; +import {PayloadEnvelopeInput} from "./payloadEnvelopeInput/payloadEnvelopeInput.js"; import {ImportBlockOpts} from "./types.js"; /** - * Verifies 1 or more blocks are fully valid running the full state transition; from a linear sequence of blocks. + * Verifies 1 or more blocks/envelopes are fully valid running the full state transition; from a linear sequence of blocks/envelopes. * * - Advance state to block's slot - per_slot_processing() * - For each block: * - STFN - per_block_processing() * - Check state root matches + * - For gloas blocks with an envelope: run processExecutionPayloadEnvelope() and check envelope state root + * - Pre-state selection for gloas: use post-envelope state of previous block if proposer built on FULL path + * (bid.parentBlockHash matches previous envelope payload.blockHash), otherwise use post-block state */ export async function verifyBlocksStateTransitionOnly( preState0: IBeaconStateView, blocks: IBlockInput[], + payloadEnvelopes: Map | null, dataAvailabilityStatuses: DataAvailabilityStatus[], logger: Logger, metrics: Metrics | null, validatorMonitor: ValidatorMonitor | null, signal: AbortSignal, opts: BlockProcessOpts & ImportBlockOpts -): Promise<{postStates: IBeaconStateView[]; proposerBalanceDeltas: number[]; verifyStateTime: number}> { - const postStates: IBeaconStateView[] = []; +): Promise<{ + postBlockStates: IBeaconStateView[]; + proposerBalanceDeltas: number[]; + verifyStateTime: number; + postPayloadStates: Map< + Slot, + {postPayloadState: IBeaconStateViewGloas; payloadEnvelopeInput: PayloadEnvelopeInput} | null + >; +}> { + const postBlockStates: IBeaconStateView[] = []; const proposerBalanceDeltas: number[] = []; + const postPayloadStates = new Map< + Slot, + {postPayloadState: IBeaconStateViewGloas; payloadEnvelopeInput: PayloadEnvelopeInput} | null + >(); const recvToValLatency = Date.now() / 1000 - (opts.seenTimestampSec ?? Date.now() / 1000); for (let i = 0; i < blocks.length; i++) { const {validProposerSignature, validSignatures} = opts; const block = blocks[i].getBlock(); - const preState = i === 0 ? preState0 : postStates[i - 1]; const dataAvailabilityStatus = dataAvailabilityStatuses[i]; + let preState: IBeaconStateView; + if (i === 0) { + preState = preState0; + } else { + const prevSlot = blocks[i - 1].getBlock().message.slot; + const prevEnvelopeResult = postPayloadStates.get(prevSlot) ?? null; + // If previous slot had an envelope and its latestBlockHash matches + // this block's bid parentBlockHash, the proposer built on the FULL path + if ( + prevEnvelopeResult != null && + isGloasBeaconBlock(block.message) && + byteArrayEquals( + prevEnvelopeResult.postPayloadState.latestBlockHash, + block.message.body.signedExecutionPayloadBid.message.parentBlockHash + ) + ) { + // gloas FULL path - use post-envelope state of previous block as pre-state for this block + preState = prevEnvelopeResult.postPayloadState; + } else { + // EMPTY path or pre-gloas block + if (prevEnvelopeResult != null && isGloasBeaconBlock(block.message)) { + // the envelope is orphaned + logger.debug("Previous block had an execution payload envelope but this block did not build on it", { + slot: block.message.slot, + prevEnvelopeBlockHash: toRootHex(prevEnvelopeResult.postPayloadState.latestBlockHash), + currentBidParentHash: toRootHex(block.message.body.signedExecutionPayloadBid.message.parentBlockHash), + }); + } + preState = postBlockStates[i - 1]; + } + } + // STFN - per_slot_processing() + per_block_processing() // NOTE: `regen.getPreState()` should have dialed forward the state already caching checkpoint states const useBlsBatchVerify = !opts?.disableBlsBatchVerify; - const postState = preState.stateTransition( + const postBlockState = preState.stateTransition( block, { // NOTE: Assume valid for now while sending payload to execution engine in parallel @@ -64,25 +115,69 @@ export async function verifyBlocksStateTransitionOnly( const hashTreeRootTimer = metrics?.stateHashTreeRootTime.startTimer({ source: StateHashTreeRootSource.blockTransition, }); - const stateRoot = postState.hashTreeRoot(); + const stateRootAfterStateTransition = postBlockState.hashTreeRoot(); hashTreeRootTimer?.(); // Check state root matches - if (!byteArrayEquals(block.message.stateRoot, stateRoot)) { + if (!byteArrayEquals(block.message.stateRoot, stateRootAfterStateTransition)) { throw new BlockError(block, { - code: BlockErrorCode.INVALID_STATE_ROOT, - root: postState.hashTreeRoot(), + code: BlockErrorCode.INVALID_BLOCK_STATE_ROOT, + root: postBlockState.hashTreeRoot(), expectedRoot: block.message.stateRoot, preState, - postState, + postState: postBlockState, }); } - postStates[i] = postState; + // If blocks are invalid in execution the main promise could resolve before this loop ends. + // In that case stop processing blocks and return early. + if (signal.aborted) { + throw new ErrorAborted("verifyBlockStateTransitionOnly"); + } + + postBlockStates[i] = postBlockState; // For metric block profitability const proposerIndex = block.message.proposerIndex; - proposerBalanceDeltas[i] = postState.getBalance(proposerIndex) - preState.getBalance(proposerIndex); + proposerBalanceDeltas[i] = postBlockState.getBalance(proposerIndex) - preState.getBalance(proposerIndex); + + const slot = block.message.slot; + const payloadEnvelopeInput = payloadEnvelopes?.get(slot) ?? null; + const payloadEnvelope = payloadEnvelopeInput?.hasPayloadEnvelope() + ? payloadEnvelopeInput.getPayloadEnvelope() + : null; + if (payloadEnvelope !== null && isStatePostGloas(postBlockState)) { + // verifyStateRoot: false — we verify manually below with BlockError for proper error typing + const postPayloadState = postBlockState.processExecutionPayloadEnvelope(payloadEnvelope, { + verifySignature: false, + verifyStateRoot: false, + }); + + const hashTreeRootTimerEnvelope = metrics?.stateHashTreeRootTime.startTimer({ + source: StateHashTreeRootSource.envelopeTransition, + }); + const stateRootAfterEnvelope = postPayloadState.hashTreeRoot(); + hashTreeRootTimerEnvelope?.(); + + if (!byteArrayEquals(payloadEnvelope.message.stateRoot, stateRootAfterEnvelope)) { + throw new BlockError(block, { + code: BlockErrorCode.INVALID_PAYLOAD_STATE_ROOT, + root: stateRootAfterEnvelope, + expectedRoot: payloadEnvelope.message.stateRoot, + preState: postBlockState, + postState: postPayloadState, + }); + } + + if (payloadEnvelopeInput === null) { + // should not happen + throw Error("Expected PayloadEnvelopeInput"); + } + + postPayloadStates.set(slot, {postPayloadState: postPayloadState as IBeaconStateViewGloas, payloadEnvelopeInput}); + } else { + postPayloadStates.set(slot, null); + } // If blocks are invalid in execution the main promise could resolve before this loop ends. // In that case stop processing blocks and return early. @@ -108,5 +203,5 @@ export async function verifyBlocksStateTransitionOnly( logger.debug("Verified block state transition", {slot, recvToValLatency, recvToValidation, validationTime}); } - return {postStates, proposerBalanceDeltas, verifyStateTime}; + return {postBlockStates, proposerBalanceDeltas, verifyStateTime, postPayloadStates}; } diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 2547bd03fac6..5ddaa38cc79c 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -1093,11 +1093,15 @@ export class BeaconChain implements IBeaconChain { } async processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob([block], opts); + return this.blockProcessor.processBlocksJob([block], null, opts); } - async processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob(blocks, opts); + async processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise { + return this.blockProcessor.processBlocksJob(blocks, payloadEnvelopes, opts); } async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 9d8e07c02a56..4aca03b659ec 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -33,7 +33,9 @@ export enum BlockErrorCode { /** A signature in the block is invalid (exactly which is unknown). */ INVALID_SIGNATURE = "BLOCK_ERROR_INVALID_SIGNATURE", /** Block transition returns invalid state root. */ - INVALID_STATE_ROOT = "BLOCK_ERROR_INVALID_STATE_ROOT", + INVALID_BLOCK_STATE_ROOT = "BLOCK_ERROR_INVALID_BLOCK_STATE_ROOT", + /** Payload transition returns invalid state root. */ + INVALID_PAYLOAD_STATE_ROOT = "BLOCK_ERROR_INVALID_PAYLOAD_STATE_ROOT", /** Block (its parent) is not a descendant of current finalized block */ NOT_FINALIZED_DESCENDANT = "BLOCK_ERROR_NOT_FINALIZED_DESCENDANT", /** The provided block is from an later slot than its parent. */ @@ -70,6 +72,10 @@ export enum BlockErrorCode { TOO_MANY_KZG_COMMITMENTS = "BLOCK_ERROR_TOO_MANY_KZG_COMMITMENTS", /** Bid parent block root does not match block parent root */ BID_PARENT_ROOT_MISMATCH = "BLOCK_ERROR_BID_PARENT_ROOT_MISMATCH", + /** Block bid's parentBlockHash does not match the expected execution hash derived from the parent chain */ + BID_PARENT_HASH_MISMATCH = "BLOCK_ERROR_BID_PARENT_HASH_MISMATCH", + /** Envelope's beacon_block_root does not match the block's hash tree root */ + ENVELOPE_BLOCK_ROOT_MISMATCH = "BLOCK_ERROR_ENVELOPE_BLOCK_ROOT_MISMATCH", /** The parent block's execution payload has been verified as invalid */ PARENT_EXECUTION_INVALID = "BLOCK_ERROR_PARENT_EXECUTION_INVALID", /** The block's parent execution payload (defined by bid.parent_block_hash) has not been seen */ @@ -97,7 +103,14 @@ export type BlockErrorType = | {code: BlockErrorCode.UNKNOWN_PROPOSER; proposerIndex: ValidatorIndex} | {code: BlockErrorCode.INVALID_SIGNATURE; state: IBeaconStateView} | { - code: BlockErrorCode.INVALID_STATE_ROOT; + code: BlockErrorCode.INVALID_BLOCK_STATE_ROOT; + root: Uint8Array; + expectedRoot: Uint8Array; + preState: IBeaconStateView; + postState: IBeaconStateView; + } + | { + code: BlockErrorCode.INVALID_PAYLOAD_STATE_ROOT; root: Uint8Array; expectedRoot: Uint8Array; preState: IBeaconStateView; @@ -119,6 +132,8 @@ export type BlockErrorType = | {code: BlockErrorCode.DATA_UNAVAILABLE} | {code: BlockErrorCode.TOO_MANY_KZG_COMMITMENTS; blobKzgCommitmentsLen: number; commitmentLimit: number} | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex} + | {code: BlockErrorCode.BID_PARENT_HASH_MISMATCH; bidParentHash: RootHex; expectedHash: RootHex} + | {code: BlockErrorCode.ENVELOPE_BLOCK_ROOT_MISMATCH; envelopeBlockRoot: RootHex; blockRoot: RootHex} | {code: BlockErrorCode.PARENT_EXECUTION_INVALID; parentRoot: RootHex} | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentBlockHash: RootHex}; @@ -161,7 +176,8 @@ export function renderBlockErrorType(type: BlockErrorType): Record; /** Process a chain of blocks until complete */ - processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise; /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; diff --git a/packages/beacon-node/src/chain/regen/queued.ts b/packages/beacon-node/src/chain/regen/queued.ts index 43852a4a8b21..2ff150870b99 100644 --- a/packages/beacon-node/src/chain/regen/queued.ts +++ b/packages/beacon-node/src/chain/regen/queued.ts @@ -190,7 +190,6 @@ export class QueuedStateRegenerator implements IStateRegenerator { this.blockStateCache.add(payloadState); } - // TODO GLOAS: This should also be called when importing execution payload after we implement it addCheckpointState(cp: phase0.Checkpoint, item: IBeaconStateView, payloadPresent: boolean): void { this.checkpointStateCache.add(cp, item, payloadPresent); } diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index e36147638061..a16c0fc495cd 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -70,14 +70,15 @@ export class SeenPayloadEnvelopeInput { this.logger?.debug("SeenPayloadEnvelopeInput.onFinalized deleted cached entries", {deletedCount}); }; - add(props: CreateFromBlockProps): PayloadEnvelopeInput { - if (this.payloadInputs.has(props.blockRootHex)) { - throw new Error(`PayloadEnvelopeInput already exists for block ${props.blockRootHex}`); + add(props: CreateFromBlockProps): {payloadInput: PayloadEnvelopeInput; created: boolean} { + const existing = this.payloadInputs.get(props.blockRootHex); + if (existing !== undefined) { + return {payloadInput: existing, created: false}; } const input = PayloadEnvelopeInput.createFromBlock(props); this.payloadInputs.set(props.blockRootHex, input); - this.metrics?.seenCache.payloadEnvelopeInput.created.inc(); - return input; + this.metrics?.seenCache.payloadEnvelopeInput.createdByBlock.inc(); + return {payloadInput: input, created: true}; } get(blockRootHex: RootHex): PayloadEnvelopeInput | undefined { diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 358054bdd8e9..be20e05f0b41 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1567,9 +1567,9 @@ export function createLodestarMetrics( name: "lodestar_seen_payload_envelope_input_cache_serialized_object_refs", help: "Number of serialized-cache object refs retained by cached PayloadEnvelopeInputs", }), - created: register.counter({ - name: "lodestar_seen_payload_envelope_input_cache_items_created_total", - help: "Number of PayloadEnvelopeInputs created", + createdByBlock: register.counter({ + name: "lodestar_seen_payload_envelope_input_cache_items_created_by_block_total", + help: "Number of PayloadEnvelopeInputs created via a block being processed first", }), }, }, diff --git a/packages/beacon-node/src/sync/range/chain.ts b/packages/beacon-node/src/sync/range/chain.ts index 911ce93b5bb0..02cadbc76e72 100644 --- a/packages/beacon-node/src/sync/range/chain.ts +++ b/packages/beacon-node/src/sync/range/chain.ts @@ -6,6 +6,7 @@ import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js"; import {IBlockInput} from "../../chain/blocks/blockInput/types.js"; import {BlobSidecarErrorCode} from "../../chain/errors/blobSidecarError.js"; import {DataColumnSidecarErrorCode} from "../../chain/errors/dataColumnSidecarError.js"; +import {PayloadEnvelopeInput} from "../../chain/seenCache/seenPayloadEnvelopeInput.js"; import {Metrics} from "../../metrics/metrics.js"; import {PeerAction, prettyPrintPeerIdStr} from "../../network/index.js"; import {PeerSyncMeta} from "../../network/peers/peersData.js"; @@ -44,7 +45,11 @@ export type SyncChainFns = { * Must return if ALL blocks are processed successfully * If SOME blocks are processed must throw BlockProcessorError() */ - processChainSegment: (blocks: IBlockInput[], syncType: RangeSyncType) => Promise; + processChainSegment: ( + blocks: IBlockInput[], + envelopes: Map | null, + syncType: RangeSyncType + ) => Promise; /** Must download blocks, and validate their range */ downloadByRange: ( peer: PeerSyncMeta, @@ -581,7 +586,7 @@ export class SyncChain { const blocks = batch.startProcessing(); // wrapError ensures to never call both batch success() and batch error() - const res = await wrapError(this.processChainSegment(blocks, this.syncType)); + const res = await wrapError(this.processChainSegment(blocks, null, this.syncType)); if (!res.err) { batch.processingSuccess(); diff --git a/packages/beacon-node/src/sync/range/range.ts b/packages/beacon-node/src/sync/range/range.ts index 64c284c65936..6ea00972fda5 100644 --- a/packages/beacon-node/src/sync/range/range.ts +++ b/packages/beacon-node/src/sync/range/range.ts @@ -172,7 +172,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { } /** Convenience method for `SyncChain` */ - private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, syncType) => { + private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, _envelopes, syncType) => { // Not trusted, verify signatures const flags: ImportBlockOpts = { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. @@ -194,7 +194,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { // Should only be used for debugging or testing for (const block of blocks) await this.chain.processBlock(block, flags); } else { - await this.chain.processChainSegment(blocks, flags); + await this.chain.processChainSegment(blocks, null, flags); } }; diff --git a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 0793b05f069b..fa6c9c291a94 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -126,7 +126,7 @@ describe.skip("verify+import blocks - range sync perf test", () => { }); }); - await chain.processChainSegment(blocksImport, { + await chain.processChainSegment(blocksImport, null, { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. // Importing attestations also triggers a head update, see https://github.com/ChainSafe/lodestar/issues/3804 // TODO: Review if this is okay, can we prevent some attacks by importing attestations? diff --git a/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts new file mode 100644 index 000000000000..93ca4a7bfe26 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/blocks/assertLinearChainSegment.test.ts @@ -0,0 +1,246 @@ +import {describe, expect, it} from "vitest"; +import {createChainForkConfig, defaultChainConfig} from "@lodestar/config"; +import {ProtoBlock} from "@lodestar/fork-choice"; +import {ForkName} from "@lodestar/params"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {Slot, gloas, ssz} from "@lodestar/types"; +import {toRootHex} from "@lodestar/utils"; +import {BlockInputNoData} from "../../../../src/chain/blocks/blockInput/blockInput.js"; +import {BlockInputSource, IBlockInput} from "../../../../src/chain/blocks/blockInput/types.js"; +import {PayloadEnvelopeInput} from "../../../../src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.js"; +import {PayloadEnvelopeInputSource} from "../../../../src/chain/blocks/payloadEnvelopeInput/types.js"; +import {assertLinearChainSegment} from "../../../../src/chain/blocks/utils/chainSegment.js"; +import {BlockErrorCode} from "../../../../src/chain/errors/index.js"; +import {expectThrowsLodestarError} from "../../../utils/errors.js"; + +const GLOAS_FORK_EPOCH = 1; +const config = createChainForkConfig({ + ...defaultChainConfig, + CAPELLA_FORK_EPOCH: 0, + DENEB_FORK_EPOCH: 0, + ELECTRA_FORK_EPOCH: 0, + FULU_FORK_EPOCH: 0, + GLOAS_FORK_EPOCH, +}); +const GLOAS_SLOT = computeStartSlotAtEpoch(GLOAS_FORK_EPOCH); + +const HASH_A = new Uint8Array(32).fill(0xaa); +const HASH_B = new Uint8Array(32).fill(0xbb); +const HASH_C = new Uint8Array(32).fill(0xcc); +const HASH_WRONG = new Uint8Array(32).fill(0xde); + +/** Build a gloas block input with specific parentRoot and bid.parentBlockHash */ +function makeBlockInput(slot: Slot, parentRoot: Uint8Array, parentBlockHash: Uint8Array): IBlockInput { + const block = ssz.gloas.SignedBeaconBlock.defaultValue(); + block.message.slot = slot; + block.message.parentRoot = parentRoot; + block.message.body.signedExecutionPayloadBid.message.parentBlockHash = parentBlockHash; + const blockRootHex = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block.message)); + return BlockInputNoData.createFromBlock({ + block, + blockRootHex, + forkName: ForkName.gloas, + daOutOfRange: false, + source: BlockInputSource.byRange, + seenTimestampSec: 0, + }); +} + +/** Compute the hash tree root of a block input's block */ +function blockRoot(blockInput: IBlockInput): Uint8Array { + const block = blockInput.getBlock(); + return config.getForkTypes(block.message.slot).BeaconBlock.hashTreeRoot(block.message); +} + +/** Build a PayloadEnvelopeInput that references the given block and carries a given payload block hash */ +function makePayloadEnvelopeInput(blockInput: IBlockInput, payloadBlockHash: Uint8Array): PayloadEnvelopeInput { + const block = blockInput.getBlock() as gloas.SignedBeaconBlock; + const input = PayloadEnvelopeInput.createFromBlock({ + block, + forkName: ForkName.gloas, + blockRootHex: toRootHex(blockRoot(blockInput)), + sampledColumns: [], + custodyColumns: [], + timeCreatedSec: 0, + }); + const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + envelope.message.beaconBlockRoot = blockRoot(blockInput); + envelope.message.slot = blockInput.getBlock().message.slot; + envelope.message.payload.blockHash = payloadBlockHash; + input.addPayloadEnvelope({envelope, source: PayloadEnvelopeInputSource.byRange, seenTimestampSec: 0}); + return input; +} + +/** Build a mock parent ProtoBlock seeded with the given execution payload block hash */ +function makeParentBlock(execHash: Uint8Array): ProtoBlock { + return {executionPayloadBlockHash: toRootHex(execHash)} as Partial as ProtoBlock; +} + +describe("chain / blocks / assertLinearChainSegment", () => { + // parentBlock represents the forkchoice-known parent of the first block in all segments below. + // Its execution hash is HASH_A, so all first blocks must have bid.parentBlockHash = HASH_A. + const parentBlock = makeParentBlock(HASH_A); + + describe("block linearity", () => { + it("ok - single block", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const {warnings} = assertLinearChainSegment(config, [block0], null, parentBlock); + expect(warnings).toBeNull(); + }); + + it("ok - two blocks with matching parent roots and increasing slots", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + const {warnings} = assertLinearChainSegment(config, [block0, block1], null, parentBlock); + expect(warnings).toBeNull(); + }); + + it("NON_LINEAR_PARENT_ROOTS - second block has wrong parentRoot", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const block1 = makeBlockInput(GLOAS_SLOT + 1, HASH_WRONG, HASH_A); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0, block1], null, parentBlock), + BlockErrorCode.NON_LINEAR_PARENT_ROOTS + ); + }); + + it("NON_LINEAR_SLOTS - second block has same slot as first", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const block1 = makeBlockInput(GLOAS_SLOT, blockRoot(block0), HASH_A); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0, block1], null, parentBlock), + BlockErrorCode.NON_LINEAR_SLOTS + ); + }); + }); + + describe("execution hash chain (bid.parentBlockHash)", () => { + it("ok - single block with correct bid.parentBlockHash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const {warnings} = assertLinearChainSegment(config, [block0], null, parentBlock); + expect(warnings).toBeNull(); + }); + + it("BID_PARENT_HASH_MISMATCH on first block - no lastFullSlot so no warning emitted", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_B); // parentBlock has HASH_A + // No prior envelope to blame, so fallback happens silently (no warning) + const {warnings} = assertLinearChainSegment(config, [block0], null, parentBlock); + expect(warnings).toBeNull(); + }); + + it("ok - EMPTY chain (no envelopes): execution hash propagates unchanged across segment", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // block1 expects HASH_A because block0 was EMPTY (no envelope → no payload hash change) + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + const {warnings} = assertLinearChainSegment(config, [block0, block1], null, parentBlock); + expect(warnings).toBeNull(); + }); + + it("ok - FULL slot (envelope present): execution hash advances to envelope payload hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // Envelope for block0 delivers HASH_B, so block1 must use HASH_B as parentBlockHash + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); + const {warnings} = assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); + expect(warnings).toBeNull(); + }); + + it("orphaned envelope recovery - second block references FULL hash but no envelope existed", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // No envelope for block0 → EMPTY → next block uses HASH_B which mismatches + // No lastFullSlot to blame, so fallback happens silently + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + const {warnings} = assertLinearChainSegment(config, [block0, block1], null, parentBlock); + expect(warnings).toBeNull(); + }); + + it("ok - FULL then EMPTY: third block reuses the envelope hash from the first slot", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // block0 FULL: delivers HASH_B + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + // block1 EMPTY: hash stays HASH_B + const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_B); + const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); + const {warnings} = assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + expect(warnings).toBeNull(); + }); + + it("ok - FULL then FULL: each block advances the execution hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // block0 FULL: delivers HASH_B + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_B); + // block1 FULL: delivers HASH_C + const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_C); + const envelopes = new Map([ + [GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)], + [GLOAS_SLOT + 1, makePayloadEnvelopeInput(block1, HASH_C)], + ]); + const {warnings} = assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + expect(warnings).toBeNull(); + }); + + it("orphaned envelope - FULL then block references EMPTY hash, warns with orphaned envelope", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelope0 = makePayloadEnvelopeInput(block0, HASH_B); + // block0 FULL: delivers HASH_B, but block1 references HASH_A (EMPTY variant) + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + const envelopes = new Map([[GLOAS_SLOT, envelope0]]); + const {warnings} = assertLinearChainSegment(config, [block0, block1], envelopes, parentBlock); + expect(warnings).toHaveLength(1); + expect(warnings?.[0].slot).toBe(GLOAS_SLOT); + expect(warnings?.[0].payloadEnvelopeInput).toBe(envelope0); + }); + + it("consecutive orphaned envelopes - two FULL slots, third block references original hash", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelope0 = makePayloadEnvelopeInput(block0, HASH_B); + // block0 FULL → HASH_B, but block1 references HASH_A → orphan envelope0, fallback to HASH_A + const block1 = makeBlockInput(GLOAS_SLOT + 1, blockRoot(block0), HASH_A); + const envelope1 = makePayloadEnvelopeInput(block1, HASH_C); + // block1 FULL → HASH_C, but block2 references HASH_A → orphan envelope1, fallback to HASH_A + const block2 = makeBlockInput(GLOAS_SLOT + 2, blockRoot(block1), HASH_A); + const envelopes = new Map([ + [GLOAS_SLOT, envelope0], + [GLOAS_SLOT + 1, envelope1], + ]); + const {warnings} = assertLinearChainSegment(config, [block0, block1, block2], envelopes, parentBlock); + expect(warnings).toHaveLength(2); + expect(warnings?.[0].slot).toBe(GLOAS_SLOT); + expect(warnings?.[0].payloadEnvelopeInput).toBe(envelope0); + expect(warnings?.[1].slot).toBe(GLOAS_SLOT + 1); + expect(warnings?.[1].payloadEnvelopeInput).toBe(envelope1); + }); + }); + + describe("envelope beacon_block_root validation", () => { + it("ok - envelope beaconBlockRoot matches the block's hash tree root", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + const envelopes = new Map([[GLOAS_SLOT, makePayloadEnvelopeInput(block0, HASH_B)]]); + const {warnings} = assertLinearChainSegment(config, [block0], envelopes, parentBlock); + expect(warnings).toBeNull(); + }); + + it("ENVELOPE_BEACON_BLOCK_ROOT_MISMATCH - envelope references a different block root", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // Build a PayloadEnvelopeInput for a *different* block (different parentRoot → different HTR) + // so its stored beaconBlockRoot won't match block0's root + const blockOther = makeBlockInput(GLOAS_SLOT, HASH_WRONG, HASH_A); + const wrongInput = makePayloadEnvelopeInput(blockOther, HASH_B); + const envelopes = new Map([[GLOAS_SLOT, wrongInput]]); + expectThrowsLodestarError( + () => assertLinearChainSegment(config, [block0], envelopes, parentBlock), + BlockErrorCode.ENVELOPE_BLOCK_ROOT_MISMATCH + ); + }); + + it("ok - envelope for a slot not in segment is silently ignored", () => { + const block0 = makeBlockInput(GLOAS_SLOT, new Uint8Array(32), HASH_A); + // Orphan PayloadEnvelopeInput for a slot outside the segment — no error expected + const orphanBlock = makeBlockInput(GLOAS_SLOT + 99, new Uint8Array(32), HASH_A); + const orphanInput = makePayloadEnvelopeInput(orphanBlock, HASH_B); + const envelopes = new Map([[GLOAS_SLOT + 99, orphanInput]]); + const {warnings} = assertLinearChainSegment(config, [block0], envelopes, parentBlock); + expect(warnings).toBeNull(); + }); + }); +}); diff --git a/packages/beacon-node/test/unit/sync/range/chain.test.ts b/packages/beacon-node/test/unit/sync/range/chain.test.ts index 246db737b897..dda40168f2f8 100644 --- a/packages/beacon-node/test/unit/sync/range/chain.test.ts +++ b/packages/beacon-node/test/unit/sync/range/chain.test.ts @@ -218,9 +218,9 @@ describe("sync / range / chain", () => { function logSyncChainFns(logger: Logger, fns: SyncChainFns): SyncChainFns { return { - processChainSegment(blocks, syncType) { + processChainSegment(blocks, envelopes, syncType) { logger.debug("mock processChainSegment", {blocks: blocks.map((b) => b.slot).join(",")}); - return fns.processChainSegment(blocks, syncType); + return fns.processChainSegment(blocks, envelopes, syncType); }, downloadByRange(peer, request, syncType) { logger.debug("mock downloadBeaconBlocksByRange", request.state.status); diff --git a/packages/state-transition/src/stateTransition.ts b/packages/state-transition/src/stateTransition.ts index e9b0ffc21c2c..895598a21e25 100644 --- a/packages/state-transition/src/stateTransition.ts +++ b/packages/state-transition/src/stateTransition.ts @@ -76,6 +76,7 @@ export enum StateHashTreeRootSource { prepareNextEpoch = "prepare_next_epoch", regenState = "regen_state", computeNewStateRoot = "compute_new_state_root", + envelopeTransition = "envelope_transition", computePayloadEnvelopeStateRoot = "compute_payload_envelope_state_root", }