diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 4b1dfbe33d60..06ae34700eb9 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -88,6 +88,8 @@ export enum EventType { blobSidecar = "blob_sidecar", /** The node has received a valid DataColumnSidecar (from P2P or API) */ dataColumnSidecar = "data_column_sidecar", + /** The node has verified that the execution payload and blobs for a block are available */ + executionPayloadAvailable = "execution_payload_available", } export const eventTypes: {[K in EventType]: K} = { @@ -108,6 +110,7 @@ export const eventTypes: {[K in EventType]: K} = { [EventType.payloadAttributes]: EventType.payloadAttributes, [EventType.blobSidecar]: EventType.blobSidecar, [EventType.dataColumnSidecar]: EventType.dataColumnSidecar, + [EventType.executionPayloadAvailable]: EventType.executionPayloadAvailable, }; export type EventData = { @@ -157,6 +160,10 @@ export type EventData = { [EventType.payloadAttributes]: {version: ForkName; data: SSEPayloadAttributes}; [EventType.blobSidecar]: BlobSidecarSSE; [EventType.dataColumnSidecar]: DataColumnSidecarSSE; + [EventType.executionPayloadAvailable]: { + slot: Slot; + blockRoot: RootHex; + }; }; export type BeaconEvent = {[K in EventType]: {type: K; message: EventData[K]}}[EventType]; @@ -311,6 +318,13 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type [EventType.payloadAttributes]: WithVersion((fork) => getPostBellatrixForkTypes(fork).SSEPayloadAttributes), [EventType.blobSidecar]: blobSidecarSSE, [EventType.dataColumnSidecar]: dataColumnSidecarSSE, + [EventType.executionPayloadAvailable]: new ContainerType( + { + slot: ssz.Slot, + blockRoot: stringType, + }, + {jsonCase: "eth2"} + ), [EventType.lightClientOptimisticUpdate]: WithVersion( (fork) => getPostAltairForkTypes(fork).LightClientOptimisticUpdate diff --git a/packages/api/test/unit/beacon/oapiSpec.test.ts b/packages/api/test/unit/beacon/oapiSpec.test.ts index 590942f8f992..1e72ddf585cf 100644 --- a/packages/api/test/unit/beacon/oapiSpec.test.ts +++ b/packages/api/test/unit/beacon/oapiSpec.test.ts @@ -78,7 +78,6 @@ runTestCheckAgainstSpec(openApiJson, definitions, testDatas, ignoredOperations, const ignoredTopics: string[] = [ // TODO GLOAS: required by v5.0.0-alpha.0 - "execution_payload_available", "execution_payload_bid", "payload_attestation_message", ]; diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index d9da1503107c..dbab2401a8f8 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -274,4 +274,8 @@ export const eventTestData: EventData = { "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", ], }), + [EventType.executionPayloadAvailable]: { + slot: 10, + blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", + }, }; 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 1b45b9457033..ad216e5a3b72 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -35,6 +35,7 @@ import { } from "@lodestar/types"; import {fromHex, sleep, toHex, toRootHex} from "@lodestar/utils"; import {BlockInputSource, isBlockInputBlobs, isBlockInputColumns} from "../../../../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInputSource} from "../../../../chain/blocks/payloadEnvelopeInput/index.js"; import {ImportBlockOpts} from "../../../../chain/blocks/types.js"; import {verifyBlocksInEpoch} from "../../../../chain/blocks/verifyBlock.js"; import {BeaconChain} from "../../../../chain/chain.js"; @@ -48,6 +49,7 @@ import { ProduceFullGloas, } from "../../../../chain/produceBlock/index.js"; import {validateGossipBlock} from "../../../../chain/validation/block.js"; +import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js"; import {OpSource} from "../../../../chain/validatorMonitor.js"; import { computePreFuluKzgCommitmentsInclusionProof, @@ -659,6 +661,8 @@ export function getBeaconBlockApi({ throw new ApiError(400, `Envelope slot ${slot} does not match block slot ${block.slot}`); } + await validateApiExecutionPayloadEnvelope(chain, signedExecutionPayloadEnvelope); + const isSelfBuild = envelope.builderIndex === BUILDER_INDEX_SELF_BUILD; let dataColumnSidecars: gloas.DataColumnSidecars = []; @@ -676,6 +680,7 @@ export function getBeaconBlockApi({ } if (cachedResult.cells && cachedResult.blobsBundle.commitments.length > 0) { + const timer = metrics?.peerDas.dataColumnSidecarComputationTime.startTimer(); const cellsAndProofs = cachedResult.cells.map((rowCells, rowIndex) => ({ cells: rowCells, proofs: cachedResult.blobsBundle.proofs.slice( @@ -685,24 +690,42 @@ export function getBeaconBlockApi({ })); dataColumnSidecars = getDataColumnSidecarsForGloas(slot, envelope.beaconBlockRoot, cellsAndProofs); + timer?.(); } } else { // TODO GLOAS: will this api be used by builders or only for self-building? } - // TODO GLOAS: Verify execution payload envelope signature - // For self-builds, the proposer signs with their own validator key - // For external builders, verify using the builder's registered pubkey - // Use verify_execution_payload_envelope_signature(state, signed_envelope) + // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. + const msToBlockSlot = computeTimeAtSlot(config, slot, chain.genesisTime) * 1000 - Date.now(); + if (msToBlockSlot <= MAX_API_CLOCK_DISPARITY_MS && msToBlockSlot > 0) { + await sleep(msToBlockSlot); + } - // TODO GLOAS: Process execution payload via state transition - // Call process_execution_payload(state, signed_envelope, execution_engine) + // TODO GLOAS: if block and payload are submitted in parallel, payloadInput may not yet exist. + // A queuing mechanism is needed to handle this case. See https://github.com/ChainSafe/lodestar/issues/8915 + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (!payloadInput) { + throw new ApiError(404, `PayloadEnvelopeInput not found for block root ${blockRootHex}`); + } - // TODO GLOAS: Update fork choice with the execution payload - // Call on_execution_payload(store, signed_envelope) to update fork choice state + payloadInput.addPayloadEnvelope({ + envelope: signedExecutionPayloadEnvelope, + source: PayloadEnvelopeInputSource.api, + seenTimestampSec, + peerIdStr: undefined, + }); - // TODO GLOAS: Add envelope and data columns to block input via seenBlockInputCache - // and trigger block import (Gloas block import requires both beacon block and envelope) + if (dataColumnSidecars.length > 0) { + for (const columnSidecar of dataColumnSidecars) { + payloadInput.addColumn({ + columnSidecar, + source: PayloadEnvelopeInputSource.api, + seenTimestampSec, + peerIdStr: undefined, + }); + } + } const valLogMeta = { slot, @@ -712,23 +735,19 @@ export function getBeaconBlockApi({ dataColumns: dataColumnSidecars.length, }; - // If called near a slot boundary (e.g. late in slot N-1), hold briefly so gossip aligns with slot N. - const msToBlockSlot = computeTimeAtSlot(config, slot, chain.genesisTime) * 1000 - Date.now(); - if (msToBlockSlot <= MAX_API_CLOCK_DISPARITY_MS && msToBlockSlot > 0) { - await sleep(msToBlockSlot); - } - const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.api}, delaySec); + chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.api, delaySec, signedExecutionPayloadEnvelope); chain.logger.info("Publishing execution payload envelope", valLogMeta); - // Publish envelope and data columns const publishPromises = [ // Gossip the signed execution payload envelope first () => network.publishSignedExecutionPayloadEnvelope(signedExecutionPayloadEnvelope), // For self-builds, publish all data column sidecars ...dataColumnSidecars.map((dataColumnSidecar) => () => network.publishDataColumnSidecar(dataColumnSidecar)), + // Import execution payload. Signature already verified above + () => chain.processExecutionPayload(payloadInput, {validSignature: true}), ]; const sentPeersArr = await promiseAllMaybeAsync(publishPromises); diff --git a/packages/beacon-node/src/chain/blocks/blockInput/types.ts b/packages/beacon-node/src/chain/blocks/blockInput/types.ts index e932360a9269..06ab97acb45a 100644 --- a/packages/beacon-node/src/chain/blocks/blockInput/types.ts +++ b/packages/beacon-node/src/chain/blocks/blockInput/types.ts @@ -1,5 +1,5 @@ import {ForkName} from "@lodestar/params"; -import {ColumnIndex, RootHex, SignedBeaconBlock, Slot, deneb, fulu} from "@lodestar/types"; +import {ColumnIndex, DataColumnSidecars, RootHex, SignedBeaconBlock, Slot, deneb, fulu} from "@lodestar/types"; import {VersionedHashes} from "../../../execution/index.js"; export enum DAType { @@ -107,9 +107,9 @@ export type MissingColumnMeta = { export interface IDataColumnsInput { readonly slot: Slot; readonly blockRootHex: string; - getCustodyColumns(): fulu.DataColumnSidecars; + getCustodyColumns(): DataColumnSidecars; hasComputedAllData(): boolean; - waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise; + waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise; } /** diff --git a/packages/beacon-node/src/chain/blocks/importBlock.ts b/packages/beacon-node/src/chain/blocks/importBlock.ts index c8df51f91f3a..ef6980e374fe 100644 --- a/packages/beacon-node/src/chain/blocks/importBlock.ts +++ b/packages/beacon-node/src/chain/blocks/importBlock.ts @@ -9,7 +9,14 @@ import { getSafeExecutionBlockHash, isGloasBlock, } from "@lodestar/fork-choice"; -import {ForkPostAltair, ForkPostElectra, ForkSeq, MAX_SEED_LOOKAHEAD, SLOTS_PER_EPOCH} from "@lodestar/params"; +import { + ForkPostAltair, + ForkPostElectra, + ForkPostGloas, + ForkSeq, + MAX_SEED_LOOKAHEAD, + SLOTS_PER_EPOCH, +} from "@lodestar/params"; import { CachedBeaconStateAltair, EpochCache, @@ -21,7 +28,17 @@ import { isStartSlotOfEpoch, isStateValidatorsNodesPopulated, } from "@lodestar/state-transition"; -import {Attestation, BeaconBlock, altair, capella, electra, isGloasBeaconBlock, phase0, ssz} from "@lodestar/types"; +import { + Attestation, + BeaconBlock, + SignedBeaconBlock, + altair, + capella, + electra, + isGloasBeaconBlock, + phase0, + ssz, +} from "@lodestar/types"; import {isErrorAborted, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {callInNextEventLoop} from "../../util/eventLoop.js"; @@ -123,6 +140,23 @@ export async function importBlock( // processState manages both block state and payload state variants together for memory/disk management this.regen.processBlockState(blockRootHex, postState); + // For Gloas blocks, create PayloadEnvelopeInput so it's available for later payload import + if (fork >= ForkSeq.gloas) { + this.seenPayloadEnvelopeInputCache.add({ + blockRootHex, + block: block as SignedBeaconBlock, + sampledColumns: this.custodyConfig.sampledColumns, + 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} : {}), + }); + } + this.metrics?.importBlock.bySource.inc({source: source.source}); this.logger.verbose("Added block to forkchoice and state cache", {slot: blockSlot, root: blockRootHex}); diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts new file mode 100644 index 000000000000..c7c1234cac24 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -0,0 +1,241 @@ +import {routes} from "@lodestar/api"; +import {ForkName} from "@lodestar/params"; +import { + BeaconStateView, + CachedBeaconStateGloas, + getExecutionPayloadEnvelopeSignatureSet, +} from "@lodestar/state-transition"; +import {processExecutionPayloadEnvelope} from "@lodestar/state-transition/block"; +import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; +import {ExecutionPayloadStatus} from "../../execution/index.js"; +import {isQueueErrorAborted} from "../../util/queue/index.js"; +import {BeaconChain} from "../chain.js"; +import {RegenCaller} from "../regen/interface.js"; +import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; +import {ImportPayloadOpts} from "./types.js"; + +const EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS = 64; + +export enum PayloadErrorCode { + EXECUTION_ENGINE_INVALID = "PAYLOAD_ERROR_EXECUTION_ENGINE_INVALID", + EXECUTION_ENGINE_ERROR = "PAYLOAD_ERROR_EXECUTION_ENGINE_ERROR", + BLOCK_NOT_IN_FORK_CHOICE = "PAYLOAD_ERROR_BLOCK_NOT_IN_FORK_CHOICE", + STATE_TRANSITION_ERROR = "PAYLOAD_ERROR_STATE_TRANSITION_ERROR", + INVALID_SIGNATURE = "PAYLOAD_ERROR_INVALID_SIGNATURE", +} + +export type PayloadErrorType = + | { + code: PayloadErrorCode.EXECUTION_ENGINE_INVALID; + execStatus: ExecutionPayloadStatus; + errorMessage: string; + } + | { + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR; + execStatus: ExecutionPayloadStatus; + errorMessage: string; + } + | { + code: PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE; + blockRootHex: string; + } + | { + code: PayloadErrorCode.STATE_TRANSITION_ERROR; + message: string; + } + | { + code: PayloadErrorCode.INVALID_SIGNATURE; + }; + +export class PayloadError extends Error { + type: PayloadErrorType; + + constructor(type: PayloadErrorType, message?: string) { + super(message ?? type.code); + this.type = type; + } +} + +/** + * Import an execution payload envelope after all data is available. + * + * This function: + * 1. Gets the ProtoBlock from fork choice + * 2. Applies write-queue backpressure (waitForSpace) early, before verification + * 3. Regenerates the block state + * 4. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope + * 5. Persists verified payload envelope to hot DB + * 6. Updates fork choice + * 7. Caches the post-execution payload state + * 8. Records metrics for column sources + * + */ +export async function importExecutionPayload( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput, + opts: ImportPayloadOpts = {} +): Promise { + const envelope = payloadInput.getPayloadEnvelope(); + const blockRootHex = payloadInput.blockRootHex; + + // 1. Get ProtoBlock for parent root lookup + const protoBlock = this.forkChoice.getBlockHexDefaultStatus(blockRootHex); + if (!protoBlock) { + throw new PayloadError({ + code: PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE, + blockRootHex, + }); + } + + // 2. 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(); + + // 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, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.processBlock + )) as CachedBeaconStateGloas; + + // 4. Run verification steps in parallel + // Note: No data availability check needed here - importExecutionPayload is only + // called when payloadInput.isComplete() is true, so all data is already available. + const [execResult, signatureValid, postPayloadResult] = await Promise.all([ + this.executionEngine.notifyNewPayload( + ForkName.gloas, + envelope.message.payload, + payloadInput.getVersionedHashes(), + fromHex(protoBlock.parentRoot), + envelope.message.executionRequests + ), + + opts.validSignature === true + ? Promise.resolve(true) + : (async () => { + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + this.config, + blockState.epochCtx.pubkeyCache, + new BeaconStateView(blockState), + envelope, + payloadInput.proposerIndex + ); + return this.bls.verifySignatureSets([signatureSet]); + })(), + + // Signature verified separately above. + // State root check is done separately below with better error typing (matching block pipeline pattern). + (async () => { + try { + return { + postPayloadState: processExecutionPayloadEnvelope(blockState, envelope, { + verifySignature: false, + verifyStateRoot: false, + }), + }; + } catch (e) { + throw new PayloadError( + { + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: (e as Error).message, + }, + `State transition error: ${(e as Error).message}` + ); + } + })(), + ]); + + // 4b. Check signature verification result + if (!signatureValid) { + throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); + } + + // 5. Handle EL response + switch (execResult.status) { + case ExecutionPayloadStatus.VALID: + break; + + case ExecutionPayloadStatus.INVALID: + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_INVALID, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "", + }); + + case ExecutionPayloadStatus.ACCEPTED: + case ExecutionPayloadStatus.SYNCING: + // TODO GLOAS: Handle optimistic import for payload - for now treat as error + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "EL syncing, payload not yet validated", + }); + + case ExecutionPayloadStatus.INVALID_BLOCK_HASH: + case ExecutionPayloadStatus.ELERROR: + case ExecutionPayloadStatus.UNAVAILABLE: + throw new PayloadError({ + code: PayloadErrorCode.EXECUTION_ENGINE_ERROR, + execStatus: execResult.status, + errorMessage: execResult.validationError ?? "", + }); + } + + // 5b. Verify envelope state root matches post-state + const postPayloadState = postPayloadResult.postPayloadState; + const postPayloadStateRoot = postPayloadState.hashTreeRoot(); + if (!byteArrayEquals(envelope.message.stateRoot, postPayloadStateRoot)) { + throw new PayloadError({ + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: `Envelope state root mismatch expected=${toRootHex(envelope.message.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, + }); + } + + // 5c. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { + if (!isQueueErrorAborted(e)) { + this.logger.error( + "Error pushing payload envelope to unfinalized write queue", + {slot: payloadInput.slot, root: blockRootHex}, + e as Error + ); + } + }); + + // 6. Update fork choice + this.forkChoice.onExecutionPayload( + blockRootHex, + payloadInput.getBlockHashHex(), + envelope.message.payload.blockNumber, + toRootHex(postPayloadStateRoot) + ); + + // 7. Cache payload state + // TODO GLOAS: Enable when PR #8868 merged (adds processPayloadState) + // this.regen.processPayloadState(postPayloadState); + // if epoch boundary also call + // this.regen.addCheckpointState(cp, checkpointState, true); + + // 8. 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}); + } + + this.logger.verbose("Execution payload imported", { + slot: payloadInput.slot, + root: blockRootHex, + blockHash: payloadInput.getBlockHashHex(), + }); + + // 9. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads + const currentSlot = this.clock.currentSlot; + if (currentSlot - payloadInput.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { + slot: payloadInput.slot, + blockRoot: blockRootHex, + }); + } +} diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts new file mode 100644 index 000000000000..368f98324f22 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/index.ts @@ -0,0 +1,2 @@ +export * from "./payloadEnvelopeInput.js"; +export * from "./types.js"; diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts new file mode 100644 index 000000000000..313b7dce8f74 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/payloadEnvelopeInput.ts @@ -0,0 +1,336 @@ +import {NUMBER_OF_COLUMNS} from "@lodestar/params"; +import {ColumnIndex, DataColumnSidecars, RootHex, Slot, ValidatorIndex, deneb, gloas} from "@lodestar/types"; +import {toRootHex, withTimeout} from "@lodestar/utils"; +import {VersionedHashes} from "../../../execution/index.js"; +import {kzgCommitmentToVersionedHash} from "../../../util/blobs.js"; +import {AddPayloadEnvelopeProps, ColumnWithSource, CreateFromBlockProps, SourceMeta} from "./types.js"; + +export type PayloadEnvelopeInputState = + | { + hasPayload: false; + hasAllData: false; + hasComputedAllData: false; + } + | { + hasPayload: false; + hasAllData: true; + hasComputedAllData: boolean; + } + | { + hasPayload: true; + hasAllData: false; + hasComputedAllData: false; + payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; + payloadEnvelopeSource: SourceMeta; + } + | { + hasPayload: true; + hasAllData: true; + hasComputedAllData: boolean; + payloadEnvelope: gloas.SignedExecutionPayloadEnvelope; + payloadEnvelopeSource: SourceMeta; + timeCompleteSec: number; + }; + +type PromiseParts = { + promise: Promise; + resolve: (value: T) => void; + reject: (e: Error) => void; +}; + +function createPromise(): PromiseParts { + let resolve!: (value: T) => void; + let reject!: (e: Error) => void; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return {promise, resolve, reject}; +} + +/** + * Tracks bid + payload envelope + data columns for a Gloas block. + * + * Created during block import from signedExecutionPayloadBid in block body. + * Always has bid (required for creation). + * + * Completion requires: payload envelope + all sampled columns + */ +export class PayloadEnvelopeInput { + readonly blockRootHex: RootHex; + readonly slot: Slot; + readonly proposerIndex: ValidatorIndex; + readonly bid: gloas.ExecutionPayloadBid; + readonly versionedHashes: VersionedHashes; + + private columnsCache = new Map(); + + private readonly sampledColumns: ColumnIndex[]; + private readonly custodyColumns: ColumnIndex[]; + + private timeCreatedSec: number; + + private readonly payloadEnvelopeDataPromise: PromiseParts; + private readonly columnsDataPromise: PromiseParts; + + state: PayloadEnvelopeInputState; + + private constructor(props: { + blockRootHex: RootHex; + slot: Slot; + proposerIndex: ValidatorIndex; + bid: gloas.ExecutionPayloadBid; + sampledColumns: ColumnIndex[]; + custodyColumns: ColumnIndex[]; + timeCreatedSec: number; + }) { + this.blockRootHex = props.blockRootHex; + this.slot = props.slot; + this.proposerIndex = props.proposerIndex; + this.bid = props.bid; + this.versionedHashes = props.bid.blobKzgCommitments.map(kzgCommitmentToVersionedHash); + this.sampledColumns = props.sampledColumns; + this.custodyColumns = props.custodyColumns; + this.timeCreatedSec = props.timeCreatedSec; + this.payloadEnvelopeDataPromise = createPromise(); + this.columnsDataPromise = createPromise(); + + const noBlobs = props.bid.blobKzgCommitments.length === 0; + const noSampledColumns = props.sampledColumns.length === 0; + const hasAllData = noBlobs || noSampledColumns; + + if (hasAllData) { + this.state = {hasPayload: false, hasAllData: true, hasComputedAllData: true}; + this.columnsDataPromise.resolve(this.getSampledColumns()); + } else { + this.state = {hasPayload: false, hasAllData: false, hasComputedAllData: false}; + } + } + + static createFromBlock(props: CreateFromBlockProps): PayloadEnvelopeInput { + const bid = (props.block.message.body as gloas.BeaconBlockBody).signedExecutionPayloadBid.message; + return new PayloadEnvelopeInput({ + blockRootHex: props.blockRootHex, + slot: props.block.message.slot, + proposerIndex: props.block.message.proposerIndex, + bid, + sampledColumns: props.sampledColumns, + custodyColumns: props.custodyColumns, + timeCreatedSec: props.timeCreatedSec, + }); + } + + getBid(): gloas.ExecutionPayloadBid { + return this.bid; + } + + getBuilderIndex(): ValidatorIndex { + return this.bid.builderIndex; + } + + getBlockHashHex(): RootHex { + return toRootHex(this.bid.blockHash); + } + + getBlobKzgCommitments(): deneb.BlobKzgCommitments { + return this.bid.blobKzgCommitments; + } + + addPayloadEnvelope(props: AddPayloadEnvelopeProps): void { + if (this.state.hasPayload) { + throw new Error(`Payload envelope already set for block ${this.blockRootHex}`); + } + if (toRootHex(props.envelope.message.beaconBlockRoot) !== this.blockRootHex) { + throw new Error("Payload envelope beacon_block_root mismatch"); + } + + const source: SourceMeta = { + source: props.source, + seenTimestampSec: props.seenTimestampSec, + peerIdStr: props.peerIdStr, + }; + + if (this.state.hasAllData) { + // Complete state + this.state = { + hasPayload: true, + hasAllData: true, + hasComputedAllData: this.state.hasComputedAllData, + payloadEnvelope: props.envelope, + payloadEnvelopeSource: source, + timeCompleteSec: props.seenTimestampSec, + }; + this.payloadEnvelopeDataPromise.resolve(props.envelope); + } else { + // Has payload, waiting for columns + this.state = { + hasPayload: true, + hasAllData: false, + hasComputedAllData: false, + payloadEnvelope: props.envelope, + payloadEnvelopeSource: source, + }; + } + } + + addColumn(columnWithSource: ColumnWithSource): void { + const {columnSidecar, seenTimestampSec} = columnWithSource; + this.columnsCache.set(columnSidecar.index, columnWithSource); + + const sampledColumns = this.getSampledColumns(); + const hasAllData = + // already hasAllData + this.state.hasAllData || + // has all sampled columns + sampledColumns.length === this.sampledColumns.length || + // has enough columns to reconstruct the rest + this.columnsCache.size >= NUMBER_OF_COLUMNS / 2; + + const hasComputedAllData = + // has all sampled columns + sampledColumns.length === this.sampledColumns.length; + + if (!hasAllData) { + return; + } + + if (hasComputedAllData) { + this.columnsDataPromise.resolve(sampledColumns); + } + + if (this.state.hasPayload) { + // Complete state + this.state = { + hasPayload: true, + hasAllData: true, + hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData, + payloadEnvelope: this.state.payloadEnvelope, + payloadEnvelopeSource: this.state.payloadEnvelopeSource, + timeCompleteSec: seenTimestampSec, + }; + this.payloadEnvelopeDataPromise.resolve(this.state.payloadEnvelope); + } else { + // No payload yet, all data ready + this.state = { + hasPayload: false, + hasAllData: true, + hasComputedAllData: hasComputedAllData || this.state.hasComputedAllData, + }; + } + } + + getVersionedHashes(): VersionedHashes { + return this.versionedHashes; + } + + hasPayloadEnvelope(): boolean { + return this.state.hasPayload; + } + + getPayloadEnvelope(): gloas.SignedExecutionPayloadEnvelope { + if (!this.state.hasPayload) throw new Error("Payload envelope not set"); + return this.state.payloadEnvelope; + } + + getPayloadEnvelopeSource(): SourceMeta { + if (!this.state.hasPayload) throw new Error("Payload envelope source not set"); + return this.state.payloadEnvelopeSource; + } + + getSampledColumns(): gloas.DataColumnSidecars { + const columns: gloas.DataColumnSidecars = []; + for (const index of this.sampledColumns) { + const column = this.columnsCache.get(index); + if (column) { + columns.push(column.columnSidecar); + } + } + return columns; + } + + getSampledColumnsWithSource(): ColumnWithSource[] { + const columns: ColumnWithSource[] = []; + for (const index of this.sampledColumns) { + const column = this.columnsCache.get(index); + if (column) { + columns.push(column); + } + } + return columns; + } + + getCustodyColumns(): gloas.DataColumnSidecars { + const columns: gloas.DataColumnSidecars = []; + for (const index of this.custodyColumns) { + const column = this.columnsCache.get(index); + if (column) { + columns.push(column.columnSidecar); + } + } + return columns; + } + + hasComputedAllData(): boolean { + return this.state.hasComputedAllData; + } + + waitForComputedAllData(timeout: number, signal?: AbortSignal): Promise { + if (this.state.hasComputedAllData) { + return Promise.resolve(this.getSampledColumns()); + } + return withTimeout(() => this.columnsDataPromise.promise, timeout, signal); + } + + getTimeCreated(): number { + return this.timeCreatedSec; + } + + getTimeComplete(): number { + if (!this.state.hasPayload || !this.state.hasAllData) throw new Error("Not yet complete"); + return this.state.timeCompleteSec; + } + + isComplete(): boolean { + return this.state.hasPayload && this.state.hasAllData; + } + + async waitForData(): Promise { + return this.payloadEnvelopeDataPromise.promise; + } + + getSerializedCacheKeys(): object[] { + const objects: object[] = []; + + if (this.state.hasPayload) { + objects.push(this.state.payloadEnvelope); + } + + for (const {columnSidecar} of this.columnsCache.values()) { + objects.push(columnSidecar); + } + + return objects; + } + + getLogMeta(): { + slot: number; + blockRoot: string; + hasPayload: boolean; + hasAllData: boolean; + hasComputedAllData: boolean; + isComplete: boolean; + columnsCount: number; + sampledColumnsCount: number; + } { + return { + slot: this.slot, + blockRoot: this.blockRootHex, + hasPayload: this.state.hasPayload, + hasAllData: this.state.hasAllData, + hasComputedAllData: this.state.hasComputedAllData, + isComplete: this.isComplete(), + columnsCount: this.columnsCache.size, + sampledColumnsCount: this.sampledColumns.length, + }; + } +} diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts new file mode 100644 index 000000000000..b4683a245b1f --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeInput/types.ts @@ -0,0 +1,33 @@ +import {ForkPostGloas} from "@lodestar/params"; +import {ColumnIndex, RootHex, SignedBeaconBlock, gloas} from "@lodestar/types"; + +export enum PayloadEnvelopeInputSource { + gossip = "gossip", + api = "api", + engine = "engine", + byRange = "req_resp_by_range", + byRoot = "req_resp_by_root", + recovery = "recovery", +} + +export type SourceMeta = { + source: PayloadEnvelopeInputSource; + seenTimestampSec: number; + peerIdStr?: string; +}; + +export type ColumnWithSource = SourceMeta & { + columnSidecar: gloas.DataColumnSidecar; +}; + +export type CreateFromBlockProps = { + blockRootHex: RootHex; + block: SignedBeaconBlock; + sampledColumns: ColumnIndex[]; + custodyColumns: ColumnIndex[]; + timeCreatedSec: number; +}; + +export type AddPayloadEnvelopeProps = SourceMeta & { + envelope: gloas.SignedExecutionPayloadEnvelope; +}; diff --git a/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts new file mode 100644 index 000000000000..e50b53bda93e --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/payloadEnvelopeProcessor.ts @@ -0,0 +1,61 @@ +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 {ImportPayloadOpts} from "./types.js"; + +// TODO GLOAS: Set to be equal to DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES for now +const QUEUE_MAX_LENGTH = 16; + +enum PayloadEnvelopeImportStatus { + queued = "queued", + importing = "importing", + imported = "imported", +} + +/** + * PayloadEnvelopeProcessor processes payload envelope jobs in a queued fashion, one after the other. + */ +export class PayloadEnvelopeProcessor { + readonly jobQueue: JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>; + private readonly importStatus = new WeakMap(); + + constructor(chain: BeaconChain, metrics: Metrics | null, signal: AbortSignal) { + this.jobQueue = new JobItemQueue<[PayloadEnvelopeInput, ImportPayloadOpts], void>( + (payloadInput, opts) => { + this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.importing); + return importExecutionPayload.call(chain, payloadInput, opts); + }, + {maxLength: QUEUE_MAX_LENGTH, noYieldIfOneItem: true, signal}, + metrics?.payloadEnvelopeProcessorQueue ?? undefined + ); + } + + async processPayloadEnvelopeJob(payloadInput: PayloadEnvelopeInput, opts: ImportPayloadOpts = {}): Promise { + if (!payloadInput.isComplete()) { + return; + } + + if (this.importStatus.get(payloadInput) !== undefined) { + return; + } + + await this.jobQueue.waitForSpace(); + + // Re-check after await, as another call may have queued this payload. + if (this.importStatus.get(payloadInput) !== undefined) { + return; + } + + this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.queued); + + try { + await this.jobQueue.push(payloadInput, opts); + this.importStatus.set(payloadInput, PayloadEnvelopeImportStatus.imported); + } catch (e) { + this.importStatus.delete(payloadInput); + throw e; + } + } +} diff --git a/packages/beacon-node/src/chain/blocks/types.ts b/packages/beacon-node/src/chain/blocks/types.ts index 50ed0076515c..52a6741d29c6 100644 --- a/packages/beacon-node/src/chain/blocks/types.ts +++ b/packages/beacon-node/src/chain/blocks/types.ts @@ -41,6 +41,14 @@ export enum BlobSidecarValidation { Full, } +export type ImportPayloadOpts = { + /** + * Set to true if envelope signature was already verified (e.g., during gossip/API validation). + * When false/undefined, signature will be verified during import. + */ + validSignature?: boolean; +}; + export type ImportBlockOpts = { /** * TEMP: Review if this is safe, Lighthouse always imports attestations even in finalized sync. diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts new file mode 100644 index 000000000000..425bd83adca8 --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -0,0 +1,55 @@ +import {BeaconChain} from "../chain.js"; +import {PayloadEnvelopeInput} from "../seenCache/seenPayloadEnvelopeInput.js"; +import {writeDataColumnsToDb} from "./writeBlockInputToDb.js"; + +/** + * Persists payload envelope data to DB. This operation must be eventually completed if a payload is imported. + * + * TODO GLOAS: Persist envelope metadata (stateRoot, executionRequests, builderIndex, etc.) without the full + * execution payload body — only keep the blockHash reference. The EL already stores the payload. + * See https://github.com/ChainSafe/lodestar/issues/5671 + */ +export async function writePayloadEnvelopeInputToDb( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput +): Promise { + const envelope = payloadInput.getPayloadEnvelope(); + const blockRootHex = payloadInput.blockRootHex; + + const envelopeBytes = this.serializedCache.get(envelope); + const envelopePromise = envelopeBytes + ? this.db.executionPayloadEnvelope.putBinary(this.db.executionPayloadEnvelope.getId(envelope), envelopeBytes) + : this.db.executionPayloadEnvelope.add(envelope); + + // Write envelope and data columns in parallel (reuses shared column writing logic) + await Promise.all([envelopePromise, writeDataColumnsToDb.call(this, payloadInput)]); + this.logger.debug("Persisted payload envelope to db", { + slot: payloadInput.slot, + root: blockRootHex, + }); +} + +export async function persistPayloadEnvelopeInput( + this: BeaconChain, + payloadInput: PayloadEnvelopeInput +): Promise { + await writePayloadEnvelopeInputToDb + .call(this, payloadInput) + .catch((e) => { + this.logger.error( + "Error persisting payload envelope in hot db", + { + slot: payloadInput.slot, + root: payloadInput.blockRootHex, + }, + e + ); + }) + .finally(() => { + this.seenPayloadEnvelopeInputCache.prune(payloadInput.blockRootHex); + this.logger.debug("Pruned payload envelope input", { + slot: payloadInput.slot, + root: payloadInput.blockRootHex, + }); + }); +} diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 4c41afd33aa6..fc6d683c197c 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -84,7 +84,10 @@ import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache} from "./beaconProposerCache.js"; import {IBlockInput, isBlockInputBlobs, isBlockInputColumns} from "./blocks/blockInput/index.js"; import {BlockProcessor, ImportBlockOpts} from "./blocks/index.js"; +import {PayloadEnvelopeProcessor} from "./blocks/payloadEnvelopeProcessor.js"; +import {ImportPayloadOpts} from "./blocks/types.js"; import {persistBlockInput} from "./blocks/writeBlockInputToDb.js"; +import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; import {ChainEvent, ChainEventEmitter} from "./emitter.js"; @@ -109,13 +112,14 @@ import {BlockAttributes, produceBlockBody, produceCommonBlockBody} from "./produ import {QueuedStateRegenerator, RegenCaller} from "./regen/index.js"; import {ReprocessController} from "./reprocess.js"; import { + PayloadEnvelopeInput, SeenAggregators, SeenAttesters, SeenBlockProposers, SeenContributionAndProof, SeenExecutionPayloadBids, - SeenExecutionPayloadEnvelopes, SeenPayloadAttesters, + SeenPayloadEnvelopeInput, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; @@ -148,6 +152,13 @@ const DEFAULT_MAX_CACHED_PRODUCED_RESULTS = 4; */ const DEFAULT_MAX_PENDING_UNFINALIZED_BLOCK_WRITES = 16; +/** + * The maximum number of pending unfinalized payload envelope writes to the database before backpressure is applied. + * Payload envelope write queue entries hold references to payload inputs (including columns), + * keeping them in memory. Keep moderate to avoid OOM during sync. + */ +const DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES = 16; + export class BeaconChain implements IBeaconChain { readonly genesisTime: UintNum64; readonly genesisValidatorsRoot: Root; @@ -172,6 +183,7 @@ export class BeaconChain implements IBeaconChain { readonly reprocessController: ReprocessController; readonly archiveStore: ArchiveStore; readonly unfinalizedBlockWrites: JobItemQueue<[IBlockInput], void>; + readonly unfinalizedPayloadEnvelopeWrites: JobItemQueue<[PayloadEnvelopeInput], void>; // Ops pool readonly attestationPool: AttestationPool; @@ -187,13 +199,13 @@ export class BeaconChain implements IBeaconChain { readonly seenAggregators = new SeenAggregators(); readonly seenPayloadAttesters = new SeenPayloadAttesters(); readonly seenAggregatedAttestations: SeenAggregatedAttestations; - readonly seenExecutionPayloadEnvelopes = new SeenExecutionPayloadEnvelopes(); readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids(); readonly seenBlockProposers = new SeenBlockProposers(); readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages(); readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; readonly seenBlockInputCache: SeenBlockInput; + readonly seenPayloadEnvelopeInputCache: SeenPayloadEnvelopeInput; // Seen cache for liveness checks readonly seenBlockAttesters = new SeenBlockAttesters(); @@ -221,6 +233,7 @@ export class BeaconChain implements IBeaconChain { readonly opts: IChainOptions; protected readonly blockProcessor: BlockProcessor; + protected readonly payloadEnvelopeProcessor: PayloadEnvelopeProcessor; protected readonly db: IBeaconDb; // this is only available if nHistoricalStates is enabled private readonly cpStateDatastore?: CPStateDatastore; @@ -334,6 +347,13 @@ export class BeaconChain implements IBeaconChain { metrics, logger, }); + this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({ + chainEvents: emitter, + signal, + serializedCache: this.serializedCache, + metrics, + logger, + }); this._earliestAvailableSlot = anchorState.slot; @@ -411,6 +431,7 @@ export class BeaconChain implements IBeaconChain { this.reprocessController = new ReprocessController(this.metrics); this.blockProcessor = new BlockProcessor(this, metrics, opts, signal); + this.payloadEnvelopeProcessor = new PayloadEnvelopeProcessor(this, metrics, signal); this.forkChoice = forkChoice; this.clock = clock; @@ -447,6 +468,15 @@ export class BeaconChain implements IBeaconChain { metrics?.unfinalizedBlockWritesQueue ); + this.unfinalizedPayloadEnvelopeWrites = new JobItemQueue( + persistPayloadEnvelopeInput.bind(this), + { + maxLength: DEFAULT_MAX_PENDING_UNFINALIZED_PAYLOAD_ENVELOPE_WRITES, + signal, + }, + metrics?.unfinalizedPayloadEnvelopeWritesQueue + ); + // always run PrepareNextSlotScheduler except for fork_choice spec tests if (!opts?.disablePrepareNextSlot) { new PrepareNextSlotScheduler(this, this.config, metrics, this.logger, signal); @@ -477,6 +507,7 @@ export class BeaconChain implements IBeaconChain { // we can abort any ongoing unfinalized block writes. // TODO: persist fork choice to disk and allow unfinalized block writes to complete. this.unfinalizedBlockWrites.dropAllJobs(); + this.unfinalizedPayloadEnvelopeWrites.dropAllJobs(); this.abortController.abort(); } @@ -1010,6 +1041,10 @@ export class BeaconChain implements IBeaconChain { return this.blockProcessor.processBlocksJob(blocks, opts); } + async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { + return this.payloadEnvelopeProcessor.processPayloadEnvelopeJob(payloadInput, opts); + } + getStatus(): Status { const head = this.forkChoice.getHead(); const finalizedCheckpoint = this.forkChoice.getFinalizedCheckpoint(); @@ -1402,7 +1437,6 @@ export class BeaconChain implements IBeaconChain { this.logger.verbose("Fork choice finalized", {epoch: cp.epoch, root: cp.rootHex}); const finalizedSlot = computeStartSlotAtEpoch(cp.epoch); this.seenBlockProposers.prune(finalizedSlot); - this.seenExecutionPayloadEnvelopes.prune(finalizedSlot); // Update validator custody to account for effective balance changes await this.updateValidatorsCustodyRequirement(cp); diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index af3a040a8d17..cf7a0fe4a002 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -4,17 +4,21 @@ import {GossipActionError} from "./gossipValidation.js"; export enum ExecutionPayloadEnvelopeErrorCode { BELONG_TO_FINALIZED_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BELONG_TO_FINALIZED_BLOCK", BLOCK_ROOT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_ROOT_UNKNOWN", + PARENT_UNKNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PARENT_UNKNOWN", + UNKNOWN_BLOCK_STATE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_UNKNOWN_BLOCK_STATE", ENVELOPE_ALREADY_KNOWN = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_ALREADY_KNOWN", INVALID_BLOCK = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_BLOCK", SLOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_SLOT_MISMATCH", BUILDER_INDEX_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BUILDER_INDEX_MISMATCH", BLOCK_HASH_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_HASH_MISMATCH", INVALID_SIGNATURE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_SIGNATURE", - CACHE_FAIL = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_CACHE_FAIL", + PAYLOAD_ENVELOPE_INPUT_MISSING = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PAYLOAD_ENVELOPE_INPUT_MISSING", } export type ExecutionPayloadEnvelopeErrorType = | {code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK; envelopeSlot: Slot; finalizedSlot: Slot} | {code: ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN; blockRoot: RootHex} + | {code: ExecutionPayloadEnvelopeErrorCode.PARENT_UNKNOWN; parentRoot: RootHex; slot: Slot} + | {code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE; blockRoot: RootHex; slot: Slot} | { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN; blockRoot: RootHex; @@ -33,6 +37,6 @@ export type ExecutionPayloadEnvelopeErrorType = bidBlockHash: RootHex | null; } | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} - | {code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL; blockRoot: RootHex}; + | {code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING; blockRoot: RootHex}; export class ExecutionPayloadEnvelopeError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index f37721a270cb..20b654bd1472 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -158,10 +158,6 @@ export function initializeForkChoiceFromFinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? - builderIndex: isForkPostGloas ? (state as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex : null, - blockHashFromBid: isForkPostGloas - ? toRootHex((state as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) - : null, parentBlockHash: isForkPostGloas ? toRootHex((state as CachedBeaconStateGloas).latestBlockHash) : null, }, currentSlot @@ -255,12 +251,6 @@ export function initializeForkChoiceFromUnfinalizedState( dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: isForkPostGloas ? PayloadStatus.PENDING : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? - builderIndex: isForkPostGloas - ? (unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.builderIndex - : null, - blockHashFromBid: isForkPostGloas - ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestExecutionPayloadBid.blockHash) - : null, parentBlockHash: isForkPostGloas ? toRootHex((unfinalizedState as CachedBeaconStateGloas).latestBlockHash) : null, }; diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index d15c9951bfbb..f9d78cb8d51a 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -32,7 +32,7 @@ import {IArchiveStore} from "./archiveStore/interface.js"; import {CheckpointBalancesCache} from "./balancesCache.js"; import {BeaconProposerCache, ProposerPreparationData} from "./beaconProposerCache.js"; import {IBlockInput} from "./blocks/blockInput/index.js"; -import {ImportBlockOpts} from "./blocks/types.js"; +import {ImportBlockOpts, ImportPayloadOpts} from "./blocks/types.js"; import {IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; import {ChainEventEmitter} from "./emitter.js"; @@ -58,7 +58,6 @@ import { SeenBlockProposers, SeenContributionAndProof, SeenExecutionPayloadBids, - SeenExecutionPayloadEnvelopes, SeenPayloadAttesters, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; @@ -66,6 +65,7 @@ import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; import {SeenAttestationDatas} from "./seenCache/seenAttestationData.js"; import {SeenBlockAttesters} from "./seenCache/seenBlockAttesters.js"; import {SeenBlockInput} from "./seenCache/seenGossipBlockInput.js"; +import {PayloadEnvelopeInput, SeenPayloadEnvelopeInput} from "./seenCache/seenPayloadEnvelopeInput.js"; import {ShufflingCache} from "./shufflingCache.js"; import {ValidatorMonitor} from "./validatorMonitor.js"; @@ -128,13 +128,13 @@ export interface IBeaconChain { readonly seenAggregators: SeenAggregators; readonly seenPayloadAttesters: SeenPayloadAttesters; readonly seenAggregatedAttestations: SeenAggregatedAttestations; - readonly seenExecutionPayloadEnvelopes: SeenExecutionPayloadEnvelopes; readonly seenExecutionPayloadBids: SeenExecutionPayloadBids; readonly seenBlockProposers: SeenBlockProposers; readonly seenSyncCommitteeMessages: SeenSyncCommitteeMessages; readonly seenContributionAndProof: SeenContributionAndProof; readonly seenAttestationDatas: SeenAttestationDatas; readonly seenBlockInputCache: SeenBlockInput; + readonly seenPayloadEnvelopeInputCache: SeenPayloadEnvelopeInput; // Seen cache for liveness checks readonly seenBlockAttesters: SeenBlockAttesters; @@ -242,6 +242,9 @@ export interface IBeaconChain { /** Process a chain of blocks until complete */ processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ + processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; + getStatus(): Status; recomputeForkChoiceHead(caller: ForkchoiceCaller): ProtoBlock; diff --git a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts index 466b42d6e0f9..f150c0275f54 100644 --- a/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts +++ b/packages/beacon-node/src/chain/produceBlock/computeNewStateRoot.ts @@ -73,7 +73,12 @@ export function computeEnvelopeStateRoot( }; const processEnvelopeTimer = metrics?.blockPayload.executionPayloadEnvelopeProcessingTime.startTimer(); - const postEnvelopeState = processExecutionPayloadEnvelope(postBlockState, signedEnvelope, false, { + const postEnvelopeState = processExecutionPayloadEnvelope(postBlockState, signedEnvelope, { + // Signature is zero-ed (G2_POINT_AT_INFINITY), skip verification + verifySignature: false, + // State root is being computed here, the envelope doesn't have it yet + verifyStateRoot: false, + // Preserve cache in source state, since the resulting state is not added to the state cache dontTransferCache: true, }); processEnvelopeTimer?.(); diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index 019523a8139f..d3f41e638f2b 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -9,8 +9,10 @@ export enum RegenCaller { processBlock = "processBlock", produceBlock = "produceBlock", validateGossipBlock = "validateGossipBlock", + validateGossipPayloadEnvelope = "validateGossipPayloadEnvelope", validateGossipBlob = "validateGossipBlob", validateGossipDataColumn = "validateGossipDataColumn", + validateGossipExecutionPayloadEnvelope = "validateGossipExecutionPayloadEnvelope", precomputeEpoch = "precomputeEpoch", predictProposerHead = "predictProposerHead", produceAttestationData = "produceAttestationData", diff --git a/packages/beacon-node/src/chain/seenCache/index.ts b/packages/beacon-node/src/chain/seenCache/index.ts index f16ae79f7f2e..23782e14385b 100644 --- a/packages/beacon-node/src/chain/seenCache/index.ts +++ b/packages/beacon-node/src/chain/seenCache/index.ts @@ -3,5 +3,5 @@ export {SeenBlockProposers} from "./seenBlockProposers.js"; export {SeenSyncCommitteeMessages} from "./seenCommittee.js"; export {SeenContributionAndProof} from "./seenCommitteeContribution.js"; export {SeenExecutionPayloadBids} from "./seenExecutionPayloadBids.js"; -export {SeenExecutionPayloadEnvelopes} from "./seenExecutionPayloadEnvelope.js"; export {SeenBlockInput} from "./seenGossipBlockInput.js"; +export {PayloadEnvelopeInput, SeenPayloadEnvelopeInput} from "./seenPayloadEnvelopeInput.js"; diff --git a/packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts deleted file mode 100644 index cbd389d29449..000000000000 --- a/packages/beacon-node/src/chain/seenCache/seenExecutionPayloadEnvelope.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {RootHex, Slot} from "@lodestar/types"; - -/** - * Cache to prevent processing multiple execution payload envelopes for the same block root. - * Only one builder qualifies to submit an execution payload for a given slot. - * We only keep track of envelopes of unfinalized slots. - * [IGNORE] The node has not seen another valid `SignedExecutionPayloadEnvelope` for this block root. - */ -export class SeenExecutionPayloadEnvelopes { - private readonly slotByBlockRoot = new Map(); - private finalizedSlot: Slot = 0; - - isKnown(blockRoot: RootHex): boolean { - return this.slotByBlockRoot.has(blockRoot); - } - - add(blockRoot: RootHex, slot: Slot): void { - if (slot < this.finalizedSlot) { - throw Error(`slot ${slot} < finalizedSlot ${this.finalizedSlot}`); - } - - this.slotByBlockRoot.set(blockRoot, slot); - } - - prune(finalizedSlot: Slot): void { - this.finalizedSlot = finalizedSlot; - - for (const [blockRoot, slot] of this.slotByBlockRoot.entries()) { - if (slot < finalizedSlot) { - this.slotByBlockRoot.delete(blockRoot); - } - } - } -} diff --git a/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts b/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts index 7570c2a03ffd..336256678962 100644 --- a/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenGossipBlockInput.ts @@ -180,7 +180,7 @@ export class SeenBlockInput { blockInput = this.blockInputs.get(parentRootHex ?? ""); parentRootHex = blockInput?.parentRootHex; } - this.logger?.debug(`BlockInputCache.prune deleted ${deletedCount} cached BlockInputs`); + this.logger?.debug("BlockInputCache.prune deleted cached BlockInputs", {deletedCount}); this.pruneToMaxSize(); } @@ -193,7 +193,7 @@ export class SeenBlockInput { this.evictBlockInput(blockInput); } } - this.logger?.debug(`BlockInputCache.onFinalized deleted ${deletedCount} cached BlockInputs`); + this.logger?.debug("BlockInputCache.onFinalized deleted cached BlockInputs", {deletedCount}); this.pruneToMaxSize(); }; diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts new file mode 100644 index 000000000000..1932a4c38f42 --- /dev/null +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -0,0 +1,106 @@ +import {CheckpointWithHex} from "@lodestar/fork-choice"; +import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {RootHex} from "@lodestar/types"; +import {Logger} from "@lodestar/utils"; +import {Metrics} from "../../metrics/metrics.js"; +import {SerializedCache} from "../../util/serializedCache.js"; +import {CreateFromBlockProps, PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; +import {ChainEvent, ChainEventEmitter} from "../emitter.js"; + +export type {PayloadEnvelopeInputState} from "../blocks/payloadEnvelopeInput/index.js"; +export {PayloadEnvelopeInput} from "../blocks/payloadEnvelopeInput/index.js"; + +export type SeenPayloadEnvelopeInputModules = { + chainEvents: ChainEventEmitter; + signal: AbortSignal; + serializedCache: SerializedCache; + metrics: Metrics | null; + logger?: Logger; +}; + +/** + * Cache for tracking PayloadEnvelopeInput instances, keyed by beacon block root. + * + * Created during block import when a block is processed. + * Pruned on finalization and after payload is written to DB. + */ +export class SeenPayloadEnvelopeInput { + private readonly chainEvents: ChainEventEmitter; + private readonly signal: AbortSignal; + private readonly serializedCache: SerializedCache; + private readonly metrics: Metrics | null; + private readonly logger?: Logger; + private payloadInputs = new Map(); + + constructor({chainEvents, signal, serializedCache, metrics, logger}: SeenPayloadEnvelopeInputModules) { + this.chainEvents = chainEvents; + this.signal = signal; + this.serializedCache = serializedCache; + this.metrics = metrics; + this.logger = logger; + + if (metrics) { + metrics.seenCache.payloadEnvelopeInput.count.addCollect(() => { + metrics.seenCache.payloadEnvelopeInput.count.set(this.payloadInputs.size); + metrics.seenCache.payloadEnvelopeInput.serializedObjectRefs.set( + Array.from(this.payloadInputs.values()).reduce( + (count, payloadInput) => count + payloadInput.getSerializedCacheKeys().length, + 0 + ) + ); + }); + } + + this.chainEvents.on(ChainEvent.forkChoiceFinalized, this.onFinalized); + this.signal.addEventListener("abort", () => { + this.chainEvents.off(ChainEvent.forkChoiceFinalized, this.onFinalized); + }); + } + + private onFinalized = (checkpoint: CheckpointWithHex): void => { + // Prune all entries with slot < finalized slot + const finalizedSlot = computeStartSlotAtEpoch(checkpoint.epoch); + let deletedCount = 0; + for (const [, input] of this.payloadInputs) { + if (input.slot < finalizedSlot) { + this.evictPayloadInput(input); + deletedCount++; + } + } + 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}`); + } + const input = PayloadEnvelopeInput.createFromBlock(props); + this.payloadInputs.set(props.blockRootHex, input); + this.metrics?.seenCache.payloadEnvelopeInput.created.inc(); + return input; + } + + get(blockRootHex: RootHex): PayloadEnvelopeInput | undefined { + return this.payloadInputs.get(blockRootHex); + } + + has(blockRootHex: RootHex): boolean { + return this.payloadInputs.has(blockRootHex); + } + + prune(blockRootHex: RootHex): void { + const payloadInput = this.payloadInputs.get(blockRootHex); + if (payloadInput) { + this.evictPayloadInput(payloadInput); + } + } + + size(): number { + return this.payloadInputs.size; + } + + private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { + this.serializedCache.delete(payloadInput.getSerializedCacheKeys()); + this.payloadInputs.delete(payloadInput.blockRootHex); + } +} diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 467a9676f2a3..c9715d59cb68 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -1,14 +1,15 @@ -import {PublicKey} from "@chainsafe/blst"; +import {PayloadStatus} from "@lodestar/fork-choice"; import { + BeaconStateView, CachedBeaconStateGloas, computeStartSlotAtEpoch, - createSingleSignatureSetFromComponents, - getExecutionPayloadEnvelopeSigningRoot, + getExecutionPayloadEnvelopeSignatureSet, } from "@lodestar/state-transition"; import {gloas} from "@lodestar/types"; import {toRootHex} from "@lodestar/utils"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; +import {RegenCaller} from "../regen/index.js"; export async function validateApiExecutionPayloadEnvelope( chain: IBeaconChain, @@ -47,7 +48,9 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. - if (chain.seenExecutionPayloadEnvelopes.isKnown(blockRootHex)) { + const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, @@ -55,6 +58,14 @@ async function validateExecutionPayloadEnvelope( }); } + if (!payloadInput) { + // PayloadEnvelopeInput should have been created during block import + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, + blockRoot: blockRootHex, + }); + } + // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `envelope.slot >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); const finalizedSlot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); @@ -79,45 +90,47 @@ async function validateExecutionPayloadEnvelope( }); } - if (block.builderIndex == null || block.blockHashFromBid == null) { - // This indicates this block is a pre-gloas block which is wrong - throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { - code: ExecutionPayloadEnvelopeErrorCode.CACHE_FAIL, - blockRoot: blockRootHex, - }); - } - // [REJECT] `envelope.builder_index == bid.builder_index` - if (envelope.builderIndex !== block.builderIndex) { + if (envelope.builderIndex !== payloadInput.getBuilderIndex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.BUILDER_INDEX_MISMATCH, envelopeBuilderIndex: envelope.builderIndex, - bidBuilderIndex: block.builderIndex, + bidBuilderIndex: payloadInput.getBuilderIndex(), }); } // [REJECT] `payload.block_hash == bid.block_hash` - if (toRootHex(payload.blockHash) !== block.blockHashFromBid) { + if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, envelopeBlockHash: toRootHex(payload.blockHash), - bidBlockHash: block.blockHashFromBid, + bidBlockHash: payloadInput.getBlockHashHex(), }); } - // [REJECT] `signed_execution_payload_envelope.signature` is valid with respect to the builder's public key. - const state = chain.getHeadState() as CachedBeaconStateGloas; - const signatureSet = createSingleSignatureSetFromComponents( - PublicKey.fromBytes(state.builders.getReadonly(envelope.builderIndex).pubkey), - getExecutionPayloadEnvelopeSigningRoot(chain.config, envelope), - executionPayloadEnvelope.signature + // Get the post block state which is the pre-payload state to verify the builder's signature. + const blockState = await chain.regen + .getState(block.stateRoot, RegenCaller.validateGossipPayloadEnvelope) + .catch(() => { + throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { + code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE, + blockRoot: blockRootHex, + slot: envelope.slot, + }); + }); + + const state = blockState as CachedBeaconStateGloas; + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + chain.config, + chain.pubkeyCache, + new BeaconStateView(state), + executionPayloadEnvelope, + payloadInput.proposerIndex ); - if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + if (!(await chain.bls.verifySignatureSets([signatureSet], {verifyOnMainThread: true}))) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE, }); } - - chain.seenExecutionPayloadEnvelopes.add(blockRootHex, envelope.slot); } diff --git a/packages/beacon-node/src/chain/validatorMonitor.ts b/packages/beacon-node/src/chain/validatorMonitor.ts index 0a2689ad5e70..45c88e42a99b 100644 --- a/packages/beacon-node/src/chain/validatorMonitor.ts +++ b/packages/beacon-node/src/chain/validatorMonitor.ts @@ -23,6 +23,7 @@ import { ValidatorIndex, altair, deneb, + gloas, } from "@lodestar/types"; import {LogData, LogHandler, LogLevel, Logger, MapDef, MapDefMax, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import {GENESIS_SLOT} from "../constants/constants.js"; @@ -61,6 +62,11 @@ export type ValidatorMonitor = { ): void; registerBeaconBlock(src: OpSource, delaySec: Seconds, block: BeaconBlock): void; registerBlobSidecar(src: OpSource, seenTimestampSec: Seconds, blob: deneb.BlobSidecar): void; + registerExecutionPayloadEnvelope( + src: OpSource, + delaySec: Seconds, + envelope: gloas.SignedExecutionPayloadEnvelope + ): void; registerImportedBlock(block: BeaconBlock, data: {proposerBalanceDelta: number}): void; onPoolSubmitUnaggregatedAttestation( seenTimestampSec: number, @@ -450,6 +456,10 @@ export function createValidatorMonitor( //TODO: freetheblobs }, + registerExecutionPayloadEnvelope(_src, _delaySec, _envelope) { + // TODO GLOAS: implement execution payload envelope monitoring + }, + registerImportedBlock(block, {proposerBalanceDelta}) { const validator = validators.get(block.proposerIndex); if (validator) { diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 32955d5a9e4d..f08a46608956 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -3,6 +3,7 @@ import {NotReorgedReason} from "@lodestar/fork-choice"; import {ArchiveStoreTask} from "../../chain/archiveStore/archiveStore.js"; import {FrequencyStateArchiveStep} from "../../chain/archiveStore/strategies/frequencyStateArchiveStrategy.js"; import {BlockInputSource} from "../../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {JobQueueItemType} from "../../chain/bls/index.js"; import {AttestationErrorCode, BlockErrorCode} from "../../chain/errors/index.js"; import { @@ -237,6 +238,56 @@ export function createLodestarMetrics( }), }, + payloadEnvelopeProcessorQueue: { + length: register.gauge({ + name: "lodestar_payload_envelope_processor_queue_length", + help: "Count of total payload envelope processor queue length", + }), + droppedJobs: register.gauge({ + name: "lodestar_payload_envelope_processor_queue_dropped_jobs_total", + help: "Count of total payload envelope processor queue dropped jobs", + }), + jobTime: register.histogram({ + name: "lodestar_payload_envelope_processor_queue_job_time_seconds", + help: "Time to process payload envelope processor queue job in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + jobWaitTime: register.histogram({ + name: "lodestar_payload_envelope_processor_queue_job_wait_time_seconds", + help: "Time from job added to the payload envelope processor queue to starting in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + concurrency: register.gauge({ + name: "lodestar_payload_envelope_processor_queue_concurrency", + help: "Current concurrency of payload envelope processor queue", + }), + }, + + unfinalizedPayloadEnvelopeWritesQueue: { + length: register.gauge({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_length", + help: "Count of total unfinalized payload envelope writes queue length", + }), + droppedJobs: register.gauge({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_dropped_jobs_total", + help: "Count of total unfinalized payload envelope writes queue dropped jobs", + }), + jobTime: register.histogram({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_job_time_seconds", + help: "Time to process unfinalized payload envelope writes queue job in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + jobWaitTime: register.histogram({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_job_wait_time_seconds", + help: "Time from job added to the unfinalized payload envelope writes queue to starting in seconds", + buckets: [0.01, 0.1, 1, 4, 12], + }), + concurrency: register.gauge({ + name: "lodestar_unfinalized_payload_envelope_writes_queue_concurrency", + help: "Current concurrency of unfinalized payload envelope writes queue", + }), + }, + engineHttpProcessorQueue: { length: register.gauge({ name: "lodestar_engine_http_processor_queue_length", @@ -925,6 +976,18 @@ export function createLodestarMetrics( labelNames: ["reason"], }), }, + importPayload: { + bySource: register.gauge<{source: PayloadEnvelopeInputSource}>({ + name: "lodestar_import_payload_by_source_total", + help: "Total number of imported execution payload envelopes by source", + labelNames: ["source"], + }), + columnsBySource: register.gauge<{source: PayloadEnvelopeInputSource}>({ + name: "lodestar_import_payload_columns_by_source_total", + help: "Total number of payload-attached columns (sampled columns for Gloas) by source", + labelNames: ["source"], + }), + }, engineNotifyNewPayloadResult: register.gauge<{result: ExecutionPayloadStatus}>({ name: "lodestar_execution_engine_notify_new_payload_result_total", help: "The total result of calling notifyNewPayload execution engine api", @@ -1495,6 +1558,20 @@ export function createLodestarMetrics( help: "Number of BlockInputs created via a data column being seen first", }), }, + payloadEnvelopeInput: { + count: register.gauge({ + name: "lodestar_seen_payload_envelope_input_cache_size", + help: "Number of cached PayloadEnvelopeInputs", + }), + serializedObjectRefs: register.gauge({ + 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", + }), + }, }, processFinalizedCheckpoint: { diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 1cf09716bbc6..304e2c54f236 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -1,11 +1,14 @@ -import {ForkName} from "@lodestar/params"; +import {ForkName, isForkPostGloas} from "@lodestar/params"; import {SlotOptionalRoot, SlotRootHex} from "@lodestar/types"; import { + getBeaconBlockRootFromDataColumnSidecarSerialized, + getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlockRootFromBeaconAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, getSlotFromBeaconAttestationSerialized, getSlotFromBlobSidecarSerialized, getSlotFromDataColumnSidecarSerialized, + getSlotFromExecutionPayloadEnvelopeSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, } from "../../util/sszBytes.js"; @@ -52,13 +55,23 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { } return {slot}; }, - [GossipType.data_column_sidecar]: (data: Uint8Array): SlotOptionalRoot | null => { - const slot = getSlotFromDataColumnSidecarSerialized(data); - + [GossipType.data_column_sidecar]: (data: Uint8Array, fork: ForkName): SlotOptionalRoot | null => { + const slot = getSlotFromDataColumnSidecarSerialized(data, fork); if (slot === null) { return null; } - return {slot}; + + const root = isForkPostGloas(fork) ? getBeaconBlockRootFromDataColumnSidecarSerialized(data) : null; + return root !== null ? {slot, root} : {slot}; + }, + [GossipType.execution_payload]: (data: Uint8Array): SlotRootHex | null => { + const slot = getSlotFromExecutionPayloadEnvelopeSerialized(data); + const root = getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(data); + + if (slot === null || root === null) { + return null; + } + return {slot, root}; }, }; } diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index b022a1866470..2dc0aab0f262 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -30,6 +30,7 @@ import { IBlockInput, isBlockInputColumns, } from "../../chain/blocks/blockInput/index.js"; +import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js"; import {BlobSidecarValidation} from "../../chain/blocks/types.js"; import {ChainEvent} from "../../chain/emitter.js"; import { @@ -42,6 +43,8 @@ import { BlockGossipError, DataColumnSidecarErrorCode, DataColumnSidecarGossipError, + ExecutionPayloadEnvelopeError, + ExecutionPayloadEnvelopeErrorCode, GossipAction, GossipActionError, SyncCommitteeError, @@ -616,6 +619,13 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand }); }); } + + // TODO GLOAS: In Gloas, also add column to PayloadEnvelopeInput and notify the payload processor: + // const payloadInput = chain.seenPayloadEnvelopeInput.get(blockRootHex); + // if (payloadInput) { + // payloadInput.addColumn({columnSidecar, source: BlockInputSource.gossip, seenTimestampSec, peerIdStr}); + // chain.processExecutionPayload(payloadInput, {validSignature: true}); + // } }, [GossipType.beacon_aggregate_and_proof]: async ({ @@ -826,17 +836,43 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand [GossipType.execution_payload]: async ({ gossipData, topic, + peerIdStr, seenTimestampSec, }: GossipHandlerParamGeneric) => { const {serializedData} = gossipData; const executionPayloadEnvelope = sszDeserialize(topic, serializedData); + // TODO GLOAS: handle BLOCK_ROOT_UNKNOWN error to trigger sync await validateGossipExecutionPayloadEnvelope(chain, executionPayloadEnvelope); const slot = executionPayloadEnvelope.message.slot; const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.gossip}, delaySec); + chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, executionPayloadEnvelope); + + const blockRootHex = toRootHex(executionPayloadEnvelope.message.beaconBlockRoot); + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); + + if (!payloadInput) { + // This shouldn't happen because beacon block should have been imported and thus payload input should have been created. + throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { + code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING, + blockRoot: blockRootHex, + }); + } - // TODO GLOAS: Handle valid envelope. Need an import flow that calls `processExecutionPayloadEnvelope` and fork choice + chain.serializedCache.set(executionPayloadEnvelope, serializedData); + + payloadInput.addPayloadEnvelope({ + envelope: executionPayloadEnvelope, + source: PayloadEnvelopeInputSource.gossip, + seenTimestampSec, + peerIdStr, + }); + + // TODO GLOAS: Emit execution_payload_gossip event for gossip receipt. + chain.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { + chain.logger.debug("Error processing execution payload from gossip", {slot, root: blockRootHex}, e as Error); + }); }, [GossipType.payload_attestation_message]: async ({ gossipData, diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 8ba5ddf3291e..d33bd898033d 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -8,6 +8,7 @@ import { ForkSeq, MAX_COMMITTEES_PER_SLOT, isForkPostElectra, + isForkPostGloas, } from "@lodestar/params"; import {BLSSignature, CommitteeIndex, RootHex, Slot, ValidatorIndex, ssz} from "@lodestar/types"; @@ -398,23 +399,102 @@ export function getSlotFromBlobSidecarSerialized(data: Uint8Array): Slot | null } /** + * Pre-Gloas DataColumnSidecar: * { - index: ColumnIndex [ fixed - 8 bytes], - column: DataColumn BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_CELL * , - kzgCommitments: denebSsz.BlobKzgCommitments, - kzgProofs: denebSsz.KZGProofs, - signedBlockHeader: phase0Ssz.SignedBeaconBlockHeader, - kzgCommitmentsInclusionProof: KzgCommitmentsInclusionProof, + * index: ColumnIndex [fixed - 8 bytes], + * column: DataColumn (offset - 4 bytes), + * kzgCommitments: (offset - 4 bytes), + * kzgProofs: (offset - 4 bytes), + * signedBlockHeader: (offset - 4 bytes) -> slot at variable offset after fixed header + * kzgCommitmentsInclusionProof: (offset - 4 bytes), + * } + * Post-Gloas DataColumnSidecar: + * { + * index: ColumnIndex [8 bytes], + * column: DataColumn (offset - 4 bytes), + * kzgProofs: (offset - 4 bytes), + * slot: Slot [8 bytes] - at offset 16, + * beaconBlockRoot: Root [32 bytes] - at offset 24, + * } + */ +const SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_PRE_GLOAS = 20; +const SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_POST_GLOAS = 16; +const BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR = 24; + +export function getSlotFromDataColumnSidecarSerialized(data: Uint8Array, fork: ForkName): Slot | null { + const offset = isForkPostGloas(fork) + ? SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_POST_GLOAS + : SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR_PRE_GLOAS; + + if (data.length < offset + SLOT_SIZE) { + return null; } + + return getSlotFromOffset(data, offset); +} + +export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Array): RootHex | null { + if (data.length < BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR + ROOT_SIZE) { + return null; + } + + blockRootBuf.set( + data.subarray( + BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR, + BEACON_BLOCK_ROOT_POSITION_IN_GLOAS_DATA_COLUMN_SIDECAR + ROOT_SIZE + ) + ); + return "0x" + blockRootBuf.toString("hex"); +} + +/** + * SignedExecutionPayloadEnvelope SSZ Layout: + * ├─ 4 bytes: message offset (points to byte 100) + * ├─ 96 bytes: signature + * └─ ExecutionPayloadEnvelope (starts at byte 100): + * ├─ 4 bytes: payload offset + * ├─ 4 bytes: executionRequests offset + * ├─ 8 bytes: builderIndex (offset 108-115) + * ├─ 32 bytes: beaconBlockRoot (offset 116-147) + * ├─ 8 bytes: slot (offset 148-155) + * └─ 32 bytes: stateRoot (offset 156-187) */ +const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4; +const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE = 96; +const EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET = 4; +const EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET = 4; +const EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE = 8; + +const BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE + + EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE; // 116 + +const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = + BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE; // 148 + +export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { + if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { + return null; + } -const SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR = 20; -export function getSlotFromDataColumnSidecarSerialized(data: Uint8Array): Slot | null { - if (data.length < SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR + SLOT_SIZE) { + return getSlotFromOffset(data, SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE); +} + +export function getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): RootHex | null { + if (data.length < BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE) { return null; } - return getSlotFromOffset(data, SLOT_BYTES_POSITION_IN_SIGNED_DATA_COLUMN_SIDECAR); + blockRootBuf.set( + data.subarray( + BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE, + BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE + ) + ); + return "0x" + blockRootBuf.toString("hex"); } /** diff --git a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts index 1136d16ab590..192d96f29d7a 100644 --- a/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts +++ b/packages/beacon-node/test/perf/chain/opPools/aggregatedAttestationPool.test.ts @@ -71,8 +71,6 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL - builderIndex: null, - blockHashFromBid: null, }, originalState.slot ); @@ -101,8 +99,6 @@ describe(`getAttestationsForBlock vc=${vc}`, () => { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL - builderIndex: null, - blockHashFromBid: null, }, slot, null diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 6b8d7a90300a..48fd66e28c50 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -78,7 +78,10 @@ const operationFns: Record> = ): CachedBeaconStateAllForks | void => { const fork = state.config.getForkSeq(state.slot); if (fork >= ForkSeq.gloas) { - return blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, true); + return blockFns.processExecutionPayloadEnvelope(state as CachedBeaconStateGloas, testCase.signed_envelope, { + verifySignature: true, + verifyStateRoot: true, + }); } blockFns.processExecutionPayload(fork, state as CachedBeaconStateBellatrix, testCase.body, { executionPayloadStatus: testCase.execution.execution_valid diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index 141aa2eca196..e30bde3735ff 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -26,6 +26,8 @@ import { getAttDataFromSignedAggregateAndProofPhase0, getAttDataFromSingleAttestationSerialized, getAttesterIndexFromSingleAttestationSerialized, + getBeaconBlockRootFromDataColumnSidecarSerialized, + getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized, getBlobKzgCommitmentsCountFromSignedBeaconBlockSerialized, getBlockRootFromAttestationSerialized, getBlockRootFromSignedAggregateAndProofSerialized, @@ -38,6 +40,8 @@ import { getSlotFromAttestationSerialized, getSlotFromBeaconStateSerialized, getSlotFromBlobSidecarSerialized, + getSlotFromDataColumnSidecarSerialized, + getSlotFromExecutionPayloadEnvelopeSerialized, getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, getSlotFromSingleAttestationSerialized, @@ -478,3 +482,99 @@ function blobSidecarFromValues(slot: Slot): deneb.BlobSidecar { blobSidecar.signedBlockHeader.message.slot = slot; return blobSidecar; } + +describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { + const testCases: {slot: Slot; blockRoot: RootHex}[] = [ + {slot: 0, blockRoot: "0x" + "00".repeat(32)}, + {slot: 1_000_000, blockRoot: "0x" + "aa".repeat(32)}, + {slot: 4_294_967_295, blockRoot: "0x" + "ff".repeat(32)}, // max uint32 + ]; + + for (const {slot, blockRoot} of testCases) { + it(`slot=${slot}`, () => { + const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); + envelope.message.slot = slot; + envelope.message.beaconBlockRoot = fromHex(blockRoot); + const bytes = ssz.gloas.SignedExecutionPayloadEnvelope.serialize(envelope); + + expect(getSlotFromExecutionPayloadEnvelopeSerialized(bytes)).toBe(slot); + expect(getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(bytes)).toBe(blockRoot); + }); + } + + it("getSlotFromExecutionPayloadEnvelopeSerialized - invalid data", () => { + // Slot is at offset 148, need at least 156 bytes + const invalidSizes = [0, 50, 100, 155]; + for (const size of invalidSizes) { + expect(getSlotFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + + it("getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized - invalid data", () => { + // Block root is at offset 116, need at least 148 bytes + const invalidSizes = [0, 50, 100, 147]; + for (const size of invalidSizes) { + expect(getBeaconBlockRootFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); + } + }); +}); + +describe("DataColumnSidecar SSZ serialized picking (fork-aware)", () => { + describe("Fulu (pre-Gloas)", () => { + const testCases: {slot: Slot}[] = [{slot: 0}, {slot: 500_000}, {slot: 4_294_967_295}]; + + for (const {slot} of testCases) { + it(`slot=${slot}`, () => { + const sidecar = ssz.fulu.DataColumnSidecar.defaultValue(); + sidecar.signedBlockHeader.message.slot = slot; + const bytes = ssz.fulu.DataColumnSidecar.serialize(sidecar); + + expect(getSlotFromDataColumnSidecarSerialized(bytes, ForkName.fulu)).toBe(slot); + }); + } + + it("getSlotFromDataColumnSidecarSerialized - invalid data", () => { + // Slot is at offset 20 for pre-Gloas, need at least 28 bytes + const invalidSizes = [0, 10, 27]; + for (const size of invalidSizes) { + expect(getSlotFromDataColumnSidecarSerialized(Buffer.alloc(size), ForkName.fulu)).toBeNull(); + } + }); + }); + + describe("Gloas", () => { + const testCases: {slot: Slot; blockRoot: RootHex}[] = [ + {slot: 0, blockRoot: "0x" + "00".repeat(32)}, + {slot: 600_000, blockRoot: "0x" + "bb".repeat(32)}, + {slot: 4_294_967_295, blockRoot: "0x" + "ff".repeat(32)}, + ]; + + for (const {slot, blockRoot} of testCases) { + it(`slot=${slot}`, () => { + const sidecar = ssz.gloas.DataColumnSidecar.defaultValue(); + sidecar.slot = slot; + sidecar.beaconBlockRoot = fromHex(blockRoot); + const bytes = ssz.gloas.DataColumnSidecar.serialize(sidecar); + + expect(getSlotFromDataColumnSidecarSerialized(bytes, ForkName.gloas)).toBe(slot); + expect(getBeaconBlockRootFromDataColumnSidecarSerialized(bytes)).toBe(blockRoot); + }); + } + + it("getSlotFromDataColumnSidecarSerialized - invalid data", () => { + // Slot is at offset 16 for Gloas, need at least 24 bytes + const invalidSizes = [0, 10, 23]; + for (const size of invalidSizes) { + expect(getSlotFromDataColumnSidecarSerialized(Buffer.alloc(size), ForkName.gloas)).toBeNull(); + } + }); + + it("getBeaconBlockRootFromDataColumnSidecarSerialized - invalid data", () => { + // Block root is at offset 24 for Gloas, need at least 56 bytes + const invalidSizes = [0, 20, 55]; + for (const size of invalidSizes) { + expect(getBeaconBlockRootFromDataColumnSidecarSerialized(Buffer.alloc(size))).toBeNull(); + } + }); + }); +}); diff --git a/packages/beacon-node/test/utils/state.ts b/packages/beacon-node/test/utils/state.ts index af7791611087..d5ee1595e637 100644 --- a/packages/beacon-node/test/utils/state.ts +++ b/packages/beacon-node/test/utils/state.ts @@ -176,7 +176,5 @@ export const zeroProtoBlock: ProtoBlock = { ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, dataAvailabilityStatus: DataAvailabilityStatus.PreData, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, parentBlockHash: null, }; diff --git a/packages/beacon-node/test/utils/validationData/attestation.ts b/packages/beacon-node/test/utils/validationData/attestation.ts index 1e57551cd8e8..f54ea3748d9b 100644 --- a/packages/beacon-node/test/utils/validationData/attestation.ts +++ b/packages/beacon-node/test/utils/validationData/attestation.ts @@ -82,8 +82,6 @@ export function getAttestationValidData(opts: AttestationValidDataOpts): { parentBlockHash: null, payloadStatus: 2, // PayloadStatus.FULL - builderIndex: null, - blockHashFromBid: null, }; const shufflingCache = new ShufflingCache(null, null, {}, [ diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 34416d97066a..44f717003753 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -834,10 +834,6 @@ export class ForkChoice implements IForkChoice { }), payloadStatus: isGloasBeaconBlock(block) ? PayloadStatus.PENDING : PayloadStatus.FULL, - builderIndex: isGloasBeaconBlock(block) ? block.body.signedExecutionPayloadBid.message.builderIndex : null, - blockHashFromBid: isGloasBeaconBlock(block) - ? toRootHex(block.body.signedExecutionPayloadBid.message.blockHash) - : null, parentBlockHash: parentHashHex, }; diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index 5c42e6f33c03..02e9890297fc 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -127,12 +127,6 @@ export type ProtoBlock = BlockExtraMeta & { /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; - // GLOAS: The followings are from bids. They are null in pre-gloas - // Used for execution payload gossip validation - builderIndex: number | null; - // Used for execution payload gossip validation. Not to be confused with executionPayloadBlockHash - blockHashFromBid: RootHex | null; - // Used to determine if this block extends EMPTY or FULL parent variant // Spec: gloas/fork-choice.md#new-get_parent_payload_status parentBlockHash: RootHex | null; diff --git a/packages/fork-choice/test/perf/forkChoice/util.ts b/packages/fork-choice/test/perf/forkChoice/util.ts index 9cc0cfe9a1fe..85d1918abc4a 100644 --- a/packages/fork-choice/test/perf/forkChoice/util.ts +++ b/packages/fork-choice/test/perf/forkChoice/util.ts @@ -110,8 +110,6 @@ export function initializeForkChoice(opts: Opts): ForkChoice { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; protoArr.onBlock(block, block.slot, null); diff --git a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts index c2ab249d3093..df68c53632dd 100644 --- a/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/forkChoice.test.ts @@ -133,8 +133,6 @@ describe("Forkchoice", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; }; diff --git a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts index 0d556343e5b7..8c89b5d35dba 100644 --- a/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/getProposerHead.test.ts @@ -52,8 +52,6 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -82,8 +80,6 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -111,8 +107,6 @@ describe("Forkchoice / GetProposerHead", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const fcStore: IForkChoiceStore = { diff --git a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts index a613962a0740..4cd4544c0240 100644 --- a/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts +++ b/packages/fork-choice/test/unit/forkChoice/shouldOverrideForkChoiceUpdate.test.ts @@ -52,8 +52,6 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseHeadBlock: ProtoBlockWithWeight = { @@ -82,8 +80,6 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const baseParentHeadBlock: ProtoBlockWithWeight = { @@ -111,8 +107,6 @@ describe("Forkchoice / shouldOverrideForkChoiceUpdate", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; const fcStore: IForkChoiceStore = { diff --git a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts index 9e8b79261ad0..8a0a544baeb4 100644 --- a/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts +++ b/packages/fork-choice/test/unit/protoArray/executionStatusUpdates.test.ts @@ -124,8 +124,6 @@ function setupForkChoice(): ProtoArray { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, block.slot, null diff --git a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts index c72f97ebf65a..e8349f4ea426 100644 --- a/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts +++ b/packages/fork-choice/test/unit/protoArray/getCommonAncestor.test.ts @@ -48,8 +48,6 @@ describe("getCommonAncestor", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, 0 ); @@ -79,8 +77,6 @@ describe("getCommonAncestor", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, block.slot, null diff --git a/packages/fork-choice/test/unit/protoArray/gloas.test.ts b/packages/fork-choice/test/unit/protoArray/gloas.test.ts index b1e5bb84d684..8648633e725d 100644 --- a/packages/fork-choice/test/unit/protoArray/gloas.test.ts +++ b/packages/fork-choice/test/unit/protoArray/gloas.test.ts @@ -53,8 +53,6 @@ describe("Gloas Fork Choice", () => { dataAvailabilityStatus: DataAvailabilityStatus.Available, parentBlockHash: parentBlockHash === undefined ? null : parentBlockHash, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }; } diff --git a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts index 61567ea73204..f23332f7a292 100644 --- a/packages/fork-choice/test/unit/protoArray/protoArray.test.ts +++ b/packages/fork-choice/test/unit/protoArray/protoArray.test.ts @@ -37,8 +37,6 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, genesisSlot ); @@ -68,8 +66,6 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, genesisSlot + 1, null @@ -100,8 +96,6 @@ describe("ProtoArray", () => { parentBlockHash: null, payloadStatus: PayloadStatus.FULL, - builderIndex: null, - blockHashFromBid: null, }, genesisSlot + 1, null diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts index 50fd56edcc7b..5330e41129b9 100644 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts @@ -1,19 +1,18 @@ -import {PublicKey, Signature, verify} from "@chainsafe/blst"; -import { - BUILDER_INDEX_SELF_BUILD, - DOMAIN_BEACON_BUILDER, - SLOTS_PER_EPOCH, - SLOTS_PER_HISTORICAL_ROOT, -} from "@lodestar/params"; +import {SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; import {gloas, ssz} from "@lodestar/types"; import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import {getExecutionPayloadEnvelopeSignatureSet} from "../signatureSets/executionPayloadEnvelope.js"; +import {BeaconStateView} from "../stateView/beaconStateView.js"; import {CachedBeaconStateGloas} from "../types.js"; -import {computeSigningRoot, computeTimeAtSlot} from "../util/index.js"; +import {computeTimeAtSlot} from "../util/index.js"; +import {verifySignatureSet} from "../util/signatureSets.js"; import {processConsolidationRequest} from "./processConsolidationRequest.js"; import {processDepositRequest} from "./processDepositRequest.js"; import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; export type ProcessExecutionPayloadEnvelopeOpts = { + verifySignature?: boolean; + verifyStateRoot?: boolean; dontTransferCache?: boolean; }; @@ -23,14 +22,14 @@ export type ProcessExecutionPayloadEnvelopeOpts = { export function processExecutionPayloadEnvelope( state: CachedBeaconStateGloas, signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - verify: boolean, opts?: ProcessExecutionPayloadEnvelopeOpts ): CachedBeaconStateGloas { + const {verifySignature = true, verifyStateRoot = true} = opts ?? {}; const envelope = signedEnvelope.message; const payload = envelope.payload; const fork = state.config.getForkSeq(envelope.slot); - if (verify && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { + if (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); } @@ -70,7 +69,7 @@ export function processExecutionPayloadEnvelope( postState.commit(); - if (verify && !byteArrayEquals(envelope.stateRoot, postState.hashTreeRoot())) { + if (verifyStateRoot && !byteArrayEquals(envelope.stateRoot, postState.hashTreeRoot())) { throw new Error( `Envelope's state root does not match state envelope=${toRootHex(envelope.stateRoot)} state=${toRootHex(postState.hashTreeRoot())}` ); @@ -160,28 +159,12 @@ function verifyExecutionPayloadEnvelopeSignature( state: CachedBeaconStateGloas, signedEnvelope: gloas.SignedExecutionPayloadEnvelope ): boolean { - const builderIndex = signedEnvelope.message.builderIndex; - - const domain = state.config.getDomain(state.slot, DOMAIN_BEACON_BUILDER); - const signingRoot = computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, signedEnvelope.message, domain); - - try { - let publicKey: PublicKey; - - if (builderIndex === BUILDER_INDEX_SELF_BUILD) { - const validatorIndex = state.latestBlockHeader.proposerIndex; - const proposerPubkey = state.epochCtx.pubkeyCache.get(validatorIndex); - if (!proposerPubkey) { - return false; - } - publicKey = proposerPubkey; - } else { - publicKey = PublicKey.fromBytes(state.builders.getReadonly(builderIndex).pubkey); - } - const signature = Signature.fromBytes(signedEnvelope.signature, true); - - return verify(signingRoot, publicKey, signature); - } catch (_e) { - return false; // Catch all BLS errors: failed key validation, failed signature validation, invalid signature - } + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + state.config, + state.epochCtx.pubkeyCache, + new BeaconStateView(state), + signedEnvelope, + state.latestBlockHeader.proposerIndex + ); + return verifySignatureSet(signatureSet); } diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index 40c21f25fd77..6107ef8eceae 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -1,7 +1,11 @@ +import {PublicKey} from "@chainsafe/blst"; import {BeaconConfig} from "@lodestar/config"; -import {DOMAIN_BEACON_BUILDER} from "@lodestar/params"; -import {gloas, ssz} from "@lodestar/types"; +import {BUILDER_INDEX_SELF_BUILD, DOMAIN_BEACON_BUILDER} from "@lodestar/params"; +import {ValidatorIndex, gloas, ssz} from "@lodestar/types"; +import {PubkeyCache} from "../cache/pubkeyCache.js"; +import {IBeaconStateView} from "../stateView/interface.js"; import {computeSigningRoot} from "../util/index.js"; +import {type SingleSignatureSet, createSingleSignatureSetFromComponents} from "../util/signatureSets.js"; export function getExecutionPayloadEnvelopeSigningRoot( config: BeaconConfig, @@ -11,3 +15,23 @@ export function getExecutionPayloadEnvelopeSigningRoot( return computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); } + +export function getExecutionPayloadEnvelopeSignatureSet( + config: BeaconConfig, + pubkeyCache: PubkeyCache, + state: IBeaconStateView, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + proposerIndex: ValidatorIndex +): SingleSignatureSet { + const envelope = signedEnvelope.message; + const pubkey = + envelope.builderIndex === BUILDER_INDEX_SELF_BUILD + ? pubkeyCache.getOrThrow(proposerIndex) + : PublicKey.fromBytes(state.getBuilder(envelope.builderIndex).pubkey); + + return createSingleSignatureSetFromComponents( + pubkey, + getExecutionPayloadEnvelopeSigningRoot(config, envelope), + signedEnvelope.signature + ); +} diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 47f7f772c111..12ce6ce0457f 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -28,6 +28,7 @@ import { } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; import {processExecutionPayloadEnvelope} from "../block/index.js"; +import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; @@ -761,12 +762,20 @@ export class BeaconStateView implements IBeaconStateView { return new BeaconStateView(newState); } - processExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope, verify: boolean): void { + processExecutionPayloadEnvelope( + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + opts?: ProcessExecutionPayloadEnvelopeOpts + ): BeaconStateView { const fork = this.config.getForkName(this.cachedState.slot); if (!isForkPostGloas(fork)) { throw Error(`processExecutionPayloadEnvelope is only available for gloas+ forks, got fork=${fork}`); } - processExecutionPayloadEnvelope(this.cachedState as CachedBeaconStateGloas, signedEnvelope, verify); + const postPayloadState = processExecutionPayloadEnvelope( + this.cachedState as CachedBeaconStateGloas, + signedEnvelope, + opts + ); + return new BeaconStateView(postPayloadState); } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index bef62b4bcfdb..9ab6b56ff0e1 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -23,6 +23,7 @@ import { rewards, } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; +import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; import {VoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; import {EpochTransitionCacheOpts} from "../cache/epochTransitionCache.js"; @@ -209,5 +210,8 @@ export interface IBeaconStateView { epochTransitionCacheOpts?: EpochTransitionCacheOpts & {dontTransferCache?: boolean}, modules?: StateTransitionModules ): IBeaconStateView; - processExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope, verify: boolean): void; + processExecutionPayloadEnvelope( + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + opts?: ProcessExecutionPayloadEnvelopeOpts + ): IBeaconStateView; }