diff --git a/packages/api/src/beacon/routes/beacon/block.ts b/packages/api/src/beacon/routes/beacon/block.ts index 6ac6377fa9d1..c4199f505bb2 100644 --- a/packages/api/src/beacon/routes/beacon/block.ts +++ b/packages/api/src/beacon/routes/beacon/block.ts @@ -606,7 +606,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.slot); + const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); return { body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.toJson(signedExecutionPayloadEnvelope), headers: { @@ -621,7 +621,7 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions { - const fork = config.getForkName(signedExecutionPayloadEnvelope.message.slot); + const fork = config.getForkName(signedExecutionPayloadEnvelope.message.payload.slotNumber); return { body: getPostGloasForkTypes(fork).SignedExecutionPayloadEnvelope.serialize(signedExecutionPayloadEnvelope), headers: { diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 9c2a28458684..e631f0ced8bd 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -186,7 +186,6 @@ export type EventData = { builderIndex: BuilderIndex; blockHash: RootHex; blockRoot: RootHex; - stateRoot: RootHex; executionOptimistic: boolean; }; [EventType.executionPayloadGossip]: { @@ -194,7 +193,6 @@ export type EventData = { builderIndex: BuilderIndex; blockHash: RootHex; blockRoot: RootHex; - stateRoot: RootHex; }; [EventType.executionPayloadAvailable]: { slot: Slot; @@ -376,7 +374,6 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type builderIndex: ssz.BuilderIndex, blockHash: stringType, blockRoot: stringType, - stateRoot: stringType, executionOptimistic: ssz.Boolean, }, {jsonCase: "eth2"} @@ -387,7 +384,6 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type builderIndex: ssz.BuilderIndex, blockHash: stringType, blockRoot: stringType, - stateRoot: stringType, }, {jsonCase: "eth2"} ), diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index 8de345a96411..a6c8c06ad055 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -279,7 +279,6 @@ export const eventTestData: EventData = { builderIndex: 42, blockHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", - stateRoot: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", executionOptimistic: false, }, [EventType.executionPayloadGossip]: { @@ -287,7 +286,6 @@ export const eventTestData: EventData = { builderIndex: 42, blockHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", blockRoot: "0x9a2fefd2fdb57f74993c7780ea5b9030d2897b615b89f808011ca5aebed54eaf", - stateRoot: "0x600e852a08c1200654ddf11025f1ceacb3c2e74bdd5c630cde0838b2591b69f9", }, [EventType.executionPayloadAvailable]: { slot: 10, 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 fa8e523d3f1c..4ecc158f1120 100644 --- a/packages/beacon-node/src/api/impl/beacon/blocks/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/blocks/index.ts @@ -651,11 +651,11 @@ export function getBeaconBlockApi({ async publishExecutionPayloadEnvelope({signedExecutionPayloadEnvelope}) { const seenTimestampSec = Date.now() / 1000; const envelope = signedExecutionPayloadEnvelope.message; - const slot = envelope.slot; + const slot = envelope.payload.slotNumber; const fork = config.getForkName(slot); const blockRootHex = toRootHex(envelope.beaconBlockRoot); const blockHashHex = toRootHex(envelope.payload.blockHash); - const stateRootHex = toRootHex(envelope.stateRoot); + // stateRoot removed from envelope in consensus-specs#5094 if (!isForkPostGloas(fork)) { throw new ApiError(400, `publishExecutionPayloadEnvelope not supported for pre-gloas fork=${fork}`); @@ -740,7 +740,6 @@ export function getBeaconBlockApi({ slot, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, builderIndex: envelope.builderIndex, isSelfBuild, dataColumns: dataColumnSidecars.length, @@ -768,7 +767,6 @@ export function getBeaconBlockApi({ builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, }); const sentPeersArr = await publishPromise; diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 607479db091f..52a124759272 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -1648,10 +1648,6 @@ export function getValidatorApi( executionRequests: executionRequests, builderIndex: BUILDER_INDEX_SELF_BUILD, beaconBlockRoot, - slot, - // TODO GLOAS: stateRoot is no longer computed during block production. - // This field will be removed when we implement defer payload processing - stateRoot: ZERO_HASH, }; logger.info("Produced execution payload envelope", { diff --git a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts index 613a9223f74f..851533c06596 100644 --- a/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts +++ b/packages/beacon-node/src/chain/blocks/importExecutionPayload.ts @@ -1,8 +1,8 @@ import {routes} from "@lodestar/api"; import {ExecutionStatus, PayloadExecutionStatus} from "@lodestar/fork-choice"; -import {SLOTS_PER_EPOCH} from "@lodestar/params"; -import {getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas} from "@lodestar/state-transition"; -import {byteArrayEquals, fromHex, toRootHex} from "@lodestar/utils"; +import {isStatePostGloas} from "@lodestar/state-transition"; +import {verifyExecutionPayloadEnvelope, verifyExecutionPayloadEnvelopeSignature} from "./verifyExecutionPayloadEnvelope.js"; +import {fromHex} from "@lodestar/utils"; import {ExecutionPayloadStatus} from "../../execution/index.js"; import {isQueueErrorAborted} from "../../util/queue/index.js"; import {BeaconChain} from "../chain.js"; @@ -69,18 +69,20 @@ function toForkChoiceExecutionStatus(status: ExecutionPayloadStatus): PayloadExe /** * Import an execution payload envelope after all data is available. * - * This function: - * 1. Emits `execution_payload_available` if payload is for current slot - * 2. Gets the ProtoBlock from fork choice - * 3. Applies write-queue backpressure (waitForSpace) early, before verification - * 4. Regenerates the block state - * 5. Runs EL verification (notifyNewPayload) in parallel with signature verification and processExecutionPayloadEnvelope - * 6. Persists verified payload envelope to hot DB - * 7. Updates fork choice - * 8. Caches the post-execution payload state - * 9. Records metrics for column sources - * 10. Emits `execution_payload` for recent enough payloads after successful import + * With deferred processing (consensus-specs#5094), the envelope is purely verified + * here — no state mutation. State effects are applied in the next block via + * processParentExecutionPayload. * + * Steps: + * 1. Emit `execution_payload_available` for payload attestation + * 2. Get the ProtoBlock from fork choice + * 3. Apply write-queue backpressure + * 4. Regenerate block state for envelope field validation + * 5. Run EL verification and signature verification in parallel, plus pure envelope verification + * 6. Persist verified payload envelope to hot DB + * 7. Update fork choice (no stateRoot — FULL shares PENDING's stateRoot) + * 8. Record metrics + * 9. Emit `execution_payload` event */ export async function importExecutionPayload( this: BeaconChain, @@ -92,15 +94,12 @@ export async function importExecutionPayload( const envelope = signedEnvelope.message; const blockRootHex = payloadInput.blockRootHex; const blockHashHex = payloadInput.getBlockHashHex(); - const fork = this.config.getForkName(envelope.slot); + const fork = this.config.getForkName(envelope.payload.slotNumber); - // 1. Emit `execution_payload_available` event at the start of import. At this point the payload input - // is already complete, so the payload and required data are available for payload attestation. - // This event is only about availability, not validity of the execution payload, hence we can emit - // it before getting a response from the execution client on whether the payload is valid or not. - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + // 1. Emit `execution_payload_available` event at the start of import + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayloadAvailable, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, blockRoot: blockRootHex, }); } @@ -138,7 +137,7 @@ export async function importExecutionPayload( } // 6. Run verification steps in parallel - const [execResult, signatureValid, postPayloadResult] = await Promise.all([ + const [execResult, signatureValid] = await Promise.all([ this.executionEngine.notifyNewPayload( fork, envelope.payload, @@ -149,45 +148,39 @@ export async function importExecutionPayload( opts.validSignature === true ? Promise.resolve(true) - : (async () => { - const signatureSet = getExecutionPayloadEnvelopeSignatureSet( - this.config, - this.pubkeyCache, - blockState, - signedEnvelope, - 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: blockState.processExecutionPayloadEnvelope(signedEnvelope, { - 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}` - ); - } - })(), + : verifyExecutionPayloadEnvelopeSignature( + this.config, + blockState, + this.pubkeyCache, + signedEnvelope, + payloadInput.proposerIndex, + this.bls + ), ]); - // 5a. Check signature verification result + // 5a. Verify envelope fields against state (spec: verify_execution_payload_envelope) + try { + // When validSignature is true, the envelope came from gossip/API where both + // signature and executionRequestsRoot were already verified — skip re-hashing + verifyExecutionPayloadEnvelope(this.config, blockState, envelope, { + verifyExecutionRequestsRoot: !opts.validSignature, + }); + } catch (e) { + throw new PayloadError( + { + code: PayloadErrorCode.STATE_TRANSITION_ERROR, + message: (e as Error).message, + }, + `Envelope verification error: ${(e as Error).message}` + ); + } + + // 5b. Check signature verification result if (!signatureValid) { throw new PayloadError({code: PayloadErrorCode.INVALID_SIGNATURE}); } - // 5b. Handle EL response + // 5c. Handle EL response switch (execResult.status) { case ExecutionPayloadStatus.VALID: break; @@ -213,69 +206,43 @@ export async function importExecutionPayload( }); } - // 5c. Verify envelope state root matches post-state - const postPayloadState = postPayloadResult.postPayloadState; - const postPayloadStateRoot = postPayloadState.hashTreeRoot(); - if (!byteArrayEquals(envelope.stateRoot, postPayloadStateRoot)) { - throw new PayloadError({ - code: PayloadErrorCode.STATE_TRANSITION_ERROR, - message: `Envelope state root mismatch expected=${toRootHex(envelope.stateRoot)} actual=${toRootHex(postPayloadStateRoot)}`, - }); - } - - // 6. Persist payload envelope to hot DB (performed asynchronously to avoid blocking) + // 6. Persist payload envelope to hot DB this.unfinalizedPayloadEnvelopeWrites.push(payloadInput).catch((e) => { if (!isQueueErrorAborted(e)) { this.logger.error( "Error pushing payload envelope to unfinalized write queue", - {slot: envelope.slot, blockRoot: blockRootHex}, + {slot: envelope.payload.slotNumber, blockRoot: blockRootHex}, e as Error ); } }); - // 7. Update fork choice - this.forkChoice.onExecutionPayload( - blockRootHex, - blockHashHex, - envelope.payload.blockNumber, - toRootHex(postPayloadStateRoot), - toForkChoiceExecutionStatus(execResult.status) - ); - - // 8. Cache payload state - this.regen.processState(blockRootHex, postPayloadState); - if (postPayloadState.slot % SLOTS_PER_EPOCH === 0) { - const {checkpoint} = postPayloadState.computeAnchorCheckpoint(); - this.regen.addCheckpointState(checkpoint, postPayloadState); - } + // 7. Update fork choice — no separate stateRoot since envelope doesn't produce post-state + const execStatus = toForkChoiceExecutionStatus(execResult.status); + this.forkChoice.onExecutionPayload(blockRootHex, blockHashHex, envelope.payload.blockNumber, execStatus); - // 9. Record metrics for payload envelope and column sources + // 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}); } - const stateRootHex = toRootHex(envelope.stateRoot); - - // 10. Emit event after payload is fully verified and imported to fork choice, only for recent enough payloads - if (this.clock.currentSlot - envelope.slot < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { + // 9. Emit event after payload is fully verified and imported to fork choice + if (this.clock.currentSlot - envelope.payload.slotNumber < EVENTSTREAM_EMIT_RECENT_EXECUTION_PAYLOAD_SLOTS) { this.emitter.emit(routes.events.EventType.executionPayload, { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockHash: blockHashHex, blockRoot: blockRootHex, - stateRoot: stateRootHex, // TODO GLOAS: revisit once we support optimistic import executionOptimistic: false, }); } this.logger.verbose("Execution payload imported", { - slot: envelope.slot, + slot: envelope.payload.slotNumber, builderIndex: envelope.builderIndex, blockRoot: blockRootHex, blockHash: blockHashHex, - stateRoot: stateRootHex, }); } diff --git a/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts new file mode 100644 index 000000000000..cecfbc25dc4d --- /dev/null +++ b/packages/beacon-node/src/chain/blocks/verifyExecutionPayloadEnvelope.ts @@ -0,0 +1,132 @@ +import {BeaconConfig} from "@lodestar/config"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils"; +import { + type IBeaconStateViewGloas, + type PubkeyCache, + computeTimeAtSlot, + getExecutionPayloadEnvelopeSignatureSet, +} from "@lodestar/state-transition"; +import {IBlsVerifier} from "../bls/index.js"; + +export type VerifyExecutionPayloadEnvelopeOpts = { + verifyExecutionRequestsRoot?: boolean; +}; + +/** + * Verify execution payload envelope fields against the post-block state. + * Does NOT verify signature (done separately) or call the execution engine. + * + * Spec: gloas/fork-choice.md — verify_execution_payload_envelope + */ +export function verifyExecutionPayloadEnvelope( + config: BeaconConfig, + state: IBeaconStateViewGloas, + envelope: gloas.ExecutionPayloadEnvelope, + opts?: VerifyExecutionPayloadEnvelopeOpts +): void { + const {verifyExecutionRequestsRoot = true} = opts ?? {}; + const payload = envelope.payload; + + // Compute header root without mutating state + const headerValue = {...state.latestBlockHeader}; + if (byteArrayEquals(headerValue.stateRoot, ssz.Root.defaultValue())) { + headerValue.stateRoot = state.hashTreeRoot(); + } + const headerRoot = ssz.phase0.BeaconBlockHeader.hashTreeRoot(headerValue); + + // Verify consistency with the beacon block + if (!byteArrayEquals(envelope.beaconBlockRoot, headerRoot)) { + throw new Error( + `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(headerRoot)}` + ); + } + + if (payload.slotNumber !== state.slot) { + throw new Error(`Slot mismatch between payload and state payload=${payload.slotNumber} state=${state.slot}`); + } + + // Verify consistency with the committed bid + const bid = state.latestExecutionPayloadBid; + if (envelope.builderIndex !== bid.builderIndex) { + throw new Error( + `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} bid=${bid.builderIndex}` + ); + } + + if (!byteArrayEquals(bid.prevRandao, payload.prevRandao)) { + throw new Error( + `Prev randao mismatch between bid and payload bid=${toHex(bid.prevRandao)} payload=${toHex(payload.prevRandao)}` + ); + } + + if (Number(bid.gasLimit) !== payload.gasLimit) { + throw new Error( + `Gas limit mismatch between payload and bid payload=${payload.gasLimit} bid=${Number(bid.gasLimit)}` + ); + } + + if (!byteArrayEquals(bid.blockHash, payload.blockHash)) { + throw new Error( + `Block hash mismatch between payload and bid payload=${toRootHex(payload.blockHash)} bid=${toRootHex(bid.blockHash)}` + ); + } + + // Verify execution_requests_root matches bid commitment + // Can be skipped if already verified during gossip validation + if (verifyExecutionRequestsRoot) { + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, bid.executionRequestsRoot)) { + throw new Error( + `Execution requests root mismatch envelope=${toRootHex(requestsRoot)} bid=${toRootHex(bid.executionRequestsRoot)}` + ); + } + } + + // Verify the execution payload is valid + if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { + throw new Error( + `Parent hash mismatch between payload and state payload=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` + ); + } + + if (payload.timestamp !== computeTimeAtSlot(config, state.slot, state.genesisTime)) { + throw new Error( + `Timestamp mismatch between payload and state payload=${payload.timestamp} state=${computeTimeAtSlot(config, state.slot, state.genesisTime)}` + ); + } + + // Verify consistency with expected withdrawals + const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); + const expectedWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals); + if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { + throw new Error( + `Withdrawals mismatch between payload and expected payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` + ); + } + + // Execution engine verification (verify_and_notify_new_payload) is done externally +} + +/** + * Verify the BLS signature of an execution payload envelope. + * + * Spec: gloas/fork-choice.md — verify_execution_payload_envelope_signature + */ +export async function verifyExecutionPayloadEnvelopeSignature( + config: BeaconConfig, + state: IBeaconStateViewGloas, + pubkeyCache: PubkeyCache, + signedEnvelope: gloas.SignedExecutionPayloadEnvelope, + proposerIndex: number, + bls: IBlsVerifier +): Promise { + const signatureSet = getExecutionPayloadEnvelopeSignatureSet( + config, + pubkeyCache, + state, + signedEnvelope, + proposerIndex + ); + return bls.verifySignatureSets([signatureSet]); +} diff --git a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts index 425bd83adca8..f5deccde72ee 100644 --- a/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts +++ b/packages/beacon-node/src/chain/blocks/writePayloadEnvelopeInputToDb.ts @@ -5,7 +5,7 @@ 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 + * TODO GLOAS: Persist envelope metadata (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 */ @@ -44,12 +44,5 @@ export async function persistPayloadEnvelopeInput( }, 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 2ee877c9d7f3..c2309c42ee50 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -2,7 +2,13 @@ import path from "node:path"; import {PrivateKey} from "@libp2p/interface"; import {Type} from "@chainsafe/ssz"; import {BeaconConfig} from "@lodestar/config"; -import {CheckpointWithPayloadStatus, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice"; +import { + CheckpointWithPayloadStatus, + IForkChoice, + PayloadStatus, + ProtoBlock, + UpdateHeadOpt, +} from "@lodestar/fork-choice"; import {LoggerNode} from "@lodestar/logger/node"; import { EFFECTIVE_BALANCE_INCREMENT, @@ -39,6 +45,7 @@ import { ValidatorIndex, Wei, deneb, + electra, gloas, isBlindedBeaconBlock, phase0, @@ -72,6 +79,7 @@ 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 {PayloadEnvelopeInputSource} from "./blocks/payloadEnvelopeInput/types.js"; import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js"; import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js"; import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js"; @@ -870,6 +878,47 @@ export class BeaconChain implements IBeaconChain { ); } + /** + * Get execution requests from parent's payload envelope for block production. + * Uses is_payload_verified AND should_extend_payload per spec's prepare_execution_payload. + * If parent was FULL and PTC voted timely, returns execution requests from the cached envelope + * (with DB fallback if the entry was pruned). Otherwise returns empty execution requests + * (build on EMPTY variant). + */ + async getParentExecutionRequests(parentBlockRootHex: RootHex): Promise { + if ( + !this.forkChoice.hasPayloadHexUnsafe(parentBlockRootHex) || + !this.forkChoice.shouldExtendPayload(parentBlockRootHex) + ) { + // Parent was EMPTY, payload not verified, or PTC didn't vote timely — return empty requests + return ssz.electra.ExecutionRequests.defaultValue(); + } + + // Pre-gloas parents are stored as `PayloadStatus.FULL` by default in fork choice but have no + // execution payload envelope (their payload is inlined in the block). For the fork-boundary + // case (first gloas block built on the last pre-gloas block), fall back to empty requests. + const parentBlock = this.forkChoice.getBlockHex(parentBlockRootHex, PayloadStatus.FULL); + if (parentBlock && !isForkPostGloas(this.config.getForkName(parentBlock.slot))) { + return ssz.electra.ExecutionRequests.defaultValue(); + } + + const payloadInput = this.seenPayloadEnvelopeInputCache.get(parentBlockRootHex); + if (payloadInput?.hasPayloadEnvelope()) { + return payloadInput.getPayloadEnvelope().message.executionRequests; + } + + // Cache miss (e.g., entry pruned in prepareNextSlot). Fall back to DB. + const envelopeFromDb = await this.db.executionPayloadEnvelope.get(fromHex(parentBlockRootHex)); + if (envelopeFromDb) { + return envelopeFromDb.message.executionRequests; + } + + // Invariant: fork choice says parent is FULL gloas, so envelope must exist in cache or DB. + throw new Error( + `getParentExecutionRequests: fork choice reports FULL parent ${parentBlockRootHex} but envelope is missing in cache and DB` + ); + } + async getExecutionPayloadEnvelope( blockSlot: Slot, blockRootHex: string @@ -1085,8 +1134,35 @@ export class BeaconChain implements IBeaconChain { return this.blockProcessor.processBlocksJob([block], opts); } - async processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise { - return this.blockProcessor.processBlocksJob(blocks, opts); + async processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise { + await this.blockProcessor.processBlocksJob(blocks, opts); + + // After blocks are imported, add downloaded envelopes to their PayloadEnvelopeInput + // and trigger processing. Each block's importBlock() already created a PayloadEnvelopeInput + // in seenPayloadEnvelopeInputCache. + if (payloadEnvelopes) { + for (const [slot, envelope] of payloadEnvelopes) { + const blockRootHex = toRootHex(envelope.message.beaconBlockRoot); + const payloadInput = this.seenPayloadEnvelopeInputCache.get(blockRootHex); + if (!payloadInput || payloadInput.hasPayloadEnvelope()) continue; + + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRange, + seenTimestampSec: Date.now() / 1000, + }); + + if (payloadInput.isComplete()) { + this.processExecutionPayload(payloadInput, {validSignature: false}).catch((e) => { + this.logger.debug("Error processing envelope from range sync", {slot, root: blockRootHex}, e as Error); + }); + } + } + } } async processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise { diff --git a/packages/beacon-node/src/chain/errors/blockError.ts b/packages/beacon-node/src/chain/errors/blockError.ts index 9d8e07c02a56..dccf9926d546 100644 --- a/packages/beacon-node/src/chain/errors/blockError.ts +++ b/packages/beacon-node/src/chain/errors/blockError.ts @@ -120,7 +120,7 @@ export type BlockErrorType = | {code: BlockErrorCode.TOO_MANY_KZG_COMMITMENTS; blobKzgCommitmentsLen: number; commitmentLimit: number} | {code: BlockErrorCode.BID_PARENT_ROOT_MISMATCH; bidParentRoot: RootHex; blockParentRoot: RootHex} | {code: BlockErrorCode.PARENT_EXECUTION_INVALID; parentRoot: RootHex} - | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentBlockHash: RootHex}; + | {code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN; parentRoot: RootHex; parentBlockHash: RootHex}; export class BlockGossipError extends GossipActionError {} diff --git a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts index cf7a0fe4a002..c8a8645d6dc5 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadEnvelope.ts @@ -11,6 +11,7 @@ export enum ExecutionPayloadEnvelopeErrorCode { 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", + EXECUTION_REQUESTS_ROOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_EXECUTION_REQUESTS_ROOT_MISMATCH", INVALID_SIGNATURE = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_INVALID_SIGNATURE", PAYLOAD_ENVELOPE_INPUT_MISSING = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PAYLOAD_ENVELOPE_INPUT_MISSING", } @@ -36,6 +37,11 @@ export type ExecutionPayloadEnvelopeErrorType = envelopeBlockHash: RootHex; bidBlockHash: RootHex | null; } + | { + code: ExecutionPayloadEnvelopeErrorCode.EXECUTION_REQUESTS_ROOT_MISMATCH; + envelopeRequestsRoot: RootHex; + bidRequestsRoot: RootHex; + } | {code: ExecutionPayloadEnvelopeErrorCode.INVALID_SIGNATURE} | {code: ExecutionPayloadEnvelopeErrorCode.PAYLOAD_ENVELOPE_INPUT_MISSING; blockRoot: RootHex}; diff --git a/packages/beacon-node/src/chain/forkChoice/index.ts b/packages/beacon-node/src/chain/forkChoice/index.ts index 6e274acd2175..7d1854666e75 100644 --- a/packages/beacon-node/src/chain/forkChoice/index.ts +++ b/packages/beacon-node/src/chain/forkChoice/index.ts @@ -138,6 +138,8 @@ export function initializeForkChoiceFromFinalizedState( stateRoot: toRootHex(blockHeader.stateRoot), blockRoot: toRootHex(checkpoint.root), timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), @@ -161,7 +163,7 @@ export function initializeForkChoiceFromFinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), 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? + payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? parentBlockHash: isStatePostGloas(state) ? toRootHex(state.latestBlockHash) : null, }, currentSlot @@ -235,6 +237,8 @@ export function initializeForkChoiceFromUnfinalizedState( blockRoot: headRoot, targetRoot: headRoot, timeliness: true, // Optimistically assume is timely + ptcTimeliness: true, // Spec: block_timeliness for anchor = [True, True] + proposerIndex: blockHeader.proposerIndex, justifiedEpoch: justifiedCheckpoint.epoch, justifiedRoot: toRootHex(justifiedCheckpoint.root), @@ -260,7 +264,7 @@ export function initializeForkChoiceFromUnfinalizedState( : {executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}), 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? + payloadStatus: isForkPostGloas ? PayloadStatus.EMPTY : PayloadStatus.FULL, // TODO GLOAS: Post-gloas how do we know if the checkpoint payload is FULL or EMPTY? parentBlockHash: isStatePostGloas(unfinalizedState) ? toRootHex(unfinalizedState.latestBlockHash) : null, }; diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 1bb42bfddd30..e97d436e3aa5 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -248,7 +248,11 @@ export interface IBeaconChain { /** Process a block until complete */ processBlock(block: IBlockInput, opts?: ImportBlockOpts): Promise; /** Process a chain of blocks until complete */ - processChainSegment(blocks: IBlockInput[], opts?: ImportBlockOpts): Promise; + processChainSegment( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + opts?: ImportBlockOpts + ): Promise; /** Process execution payload envelope: verify, import to fork choice, and persist to DB */ processExecutionPayload(payloadInput: PayloadEnvelopeInput, opts?: ImportPayloadOpts): Promise; diff --git a/packages/beacon-node/src/chain/prepareNextSlot.ts b/packages/beacon-node/src/chain/prepareNextSlot.ts index ec3dfc3286c0..44ccefcc7cd6 100644 --- a/packages/beacon-node/src/chain/prepareNextSlot.ts +++ b/packages/beacon-node/src/chain/prepareNextSlot.ts @@ -203,6 +203,20 @@ export class PrepareNextSlotScheduler { }); } + if (ForkSeq[fork] >= ForkSeq.gloas) { + // Cutoff = slot of the parent of the block we'll actually build on (post-reorg). + // Steady state: cache holds just 2 entries — head (parent for next-slot production) + // and head.parent (proposer-boost-reorg fallback). Anything older is evicted. + const finalHead = this.chain.forkChoice.getBlockHexDefaultStatus(updatedHeadRoot); + const finalHeadParent = finalHead && this.chain.forkChoice.getBlockHexDefaultStatus(finalHead.parentRoot); + if (finalHeadParent) { + this.chain.seenPayloadEnvelopeInputCache.pruneBelow(finalHeadParent.slot); + } + } + + if (!isStatePostBellatrix(updatedPrepareState)) { + throw new Error("Expected Bellatrix state for payload attributes"); + } this.computeStateHashTreeRoot(updatedPrepareState, isEpochTransition); // If emitPayloadAttributes is true emit a SSE payloadAttributes event diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index e8004ab94519..5df5b7de4858 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -18,6 +18,7 @@ import { G2_POINT_AT_INFINITY, IBeaconStateView, type IBeaconStateViewBellatrix, + type IBeaconStateViewGloas, computeTimeAtSlot, isStatePostBellatrix, isStatePostCapella, @@ -46,6 +47,7 @@ import { electra, fulu, gloas, + ssz, } from "@lodestar/types"; import {Logger, byteArrayEquals, fromHex, sleep, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; @@ -269,6 +271,7 @@ export async function produceBlockBody( value: 0, executionPayment: 0, blobKzgCommitments: blobsBundle.commitments, + executionRequestsRoot: ssz.electra.ExecutionRequests.hashTreeRoot(executionRequests), }; const signedBid: gloas.SignedExecutionPayloadBid = { message: bid, @@ -280,6 +283,10 @@ export async function produceBlockBody( gloasBody.signedExecutionPayloadBid = signedBid; // TODO GLOAS: Get payload attestations from pool for previous slot gloasBody.payloadAttestations = []; + // Determine parent execution requests for deferred processing (consensus-specs#5094) + // If parent was FULL: include execution requests from its envelope + // If parent was EMPTY: include empty execution requests + gloasBody.parentExecutionRequests = await this.getParentExecutionRequests(parentBlock.blockRoot); blockBody = gloasBody as AssembledBodyType; // Store execution payload data required to construct execution payload envelope later @@ -608,6 +615,8 @@ export async function prepareExecutionPayload( chain: { executionEngine: IExecutionEngine; config: ChainForkConfig; + forkChoice?: IForkChoice; + getParentExecutionRequests?: (parentBlockRootHex: RootHex) => Promise; }, logger: Logger, fork: ForkPostBellatrix, @@ -618,11 +627,43 @@ export async function prepareExecutionPayload( state: IBeaconStateViewBellatrix, suggestedFeeRecipient: string ): Promise<{prepType: PayloadPreparationType; payloadId: PayloadId}> { + let parentHash = parentBlockHash; + let withdrawalsOverride: capella.Withdrawal[] | undefined; + + // For Gloas: determine FULL vs EMPTY parent per spec's prepare_execution_payload + // If extending FULL parent: apply parent payload to get correct withdrawals and use bid.blockHash + // If EMPTY parent: use state.payloadExpectedWithdrawals and bid.parentBlockHash + if (isForkPostGloas(fork) && chain.forkChoice && chain.getParentExecutionRequests) { + const gloasState = state as unknown as { + latestExecutionPayloadBid: { + slot: number; + blockHash: Uint8Array; + parentBlockHash: Uint8Array; + builderIndex: number; + value: number; + feeRecipient: Uint8Array; + }; + }; + const parentRootHex = toRootHex(parentBlockRoot); + + if (chain.forkChoice.shouldExtendPayload(parentRootHex)) { + // Build on FULL variant: fetch parent execution requests (cache → DB fallback), + // apply parent payload to compute correct withdrawals, use bid.blockHash as EL head + // (per spec's prepare_execution_payload) + const executionRequests = await chain.getParentExecutionRequests(parentRootHex); + const gloasView = state as unknown as IBeaconStateViewGloas; + withdrawalsOverride = gloasView.getExpectedWithdrawalsForFullParent(executionRequests); + parentHash = gloasState.latestExecutionPayloadBid.blockHash; + } else { + // EMPTY parent: use bid.parentBlockHash as the EL head + parentHash = gloasState.latestExecutionPayloadBid.parentBlockHash; + } + } const timestamp = computeTimeAtSlot(chain.config, state.slot, state.genesisTime); const prevRandao = state.getRandaoMix(state.epoch); const payloadIdCached = chain.executionEngine.payloadIdCache.get({ - headBlockHash: toRootHex(parentBlockHash), + headBlockHash: toRootHex(parentHash), finalizedBlockHash, timestamp: numToQuantity(timestamp), prevRandao: toHex(prevRandao), @@ -653,11 +694,12 @@ export async function prepareExecutionPayload( parentBlockRoot, parentBlockHash, feeRecipient: suggestedFeeRecipient, + withdrawalsOverride, }); payloadId = await chain.executionEngine.notifyForkchoiceUpdate( fork, - toRootHex(parentBlockHash), + toRootHex(parentHash), safeBlockHash, finalizedBlockHash, attributes @@ -763,12 +805,14 @@ function preparePayloadAttributes( parentBlockRoot, parentBlockHash, feeRecipient, + withdrawalsOverride, }: { prepareState: IBeaconStateViewBellatrix; prepareSlot: Slot; parentBlockRoot: Root; parentBlockHash: Bytes32; feeRecipient: string; + withdrawalsOverride?: capella.Withdrawal[]; } ): SSEPayloadAttributes["payloadAttributes"] { const timestamp = computeTimeAtSlot(chain.config, prepareSlot, prepareState.genesisTime); @@ -784,7 +828,10 @@ function preparePayloadAttributes( throw new Error("Expected Capella state for withdrawals"); } - if (isStatePostGloas(prepareState)) { + if (withdrawalsOverride) { + // FULL parent: withdrawals computed from state with parent payload applied + (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = withdrawalsOverride; + } else if (isStatePostGloas(prepareState)) { const isExtendingPayload = byteArrayEquals(parentBlockHash, prepareState.latestExecutionPayloadBid.blockHash); // When the parent block is empty, state.payloadExpectedWithdrawals holds a batch // already deducted from CL balances but never credited on the EL (the envelope @@ -794,7 +841,7 @@ function preparePayloadAttributes( ? prepareState.getExpectedWithdrawals().expectedWithdrawals : prepareState.payloadExpectedWithdrawals; } else { - // withdrawals logic is now fork aware as it changes on electra fork post capella + // Pre-Gloas or Gloas with full parent but no override (shouldn't happen in normal flow) (payloadAttributes as capella.SSEPayloadAttributes["payloadAttributes"]).withdrawals = prepareState.getExpectedWithdrawals().expectedWithdrawals; } diff --git a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts index e36147638061..338fa55d0066 100644 --- a/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts +++ b/packages/beacon-node/src/chain/seenCache/seenPayloadEnvelopeInput.ts @@ -1,6 +1,6 @@ import {CheckpointWithHex} from "@lodestar/fork-choice"; import {computeStartSlotAtEpoch} from "@lodestar/state-transition"; -import {RootHex} from "@lodestar/types"; +import {RootHex, Slot} from "@lodestar/types"; import {Logger} from "@lodestar/utils"; import {Metrics} from "../../metrics/metrics.js"; import {SerializedCache} from "../../util/serializedCache.js"; @@ -21,8 +21,20 @@ export type SeenPayloadEnvelopeInputModules = { /** * 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. + * Created during block import when a block is processed. Two pruning paths: + * - `prepareNextSlot` calls `pruneBelow(headParentSlot)` every slot once the head we'll build + * on is known. + * - `onFinalized` calls `pruneBelow(finalizedSlot)` on every finalization for bulk cleanup. + * + * Steady state (linear chain, healthy progression): the cache holds ~2 entries — the head + * (parent for next-slot production) and its parent (proposer-boost-reorg fallback). It can + * transiently hold more during forks, range-sync bursts, or when `prepareNextSlot` skips + * ticks; subsequent ticks settle it back. + * + * Consumers that miss the cache fall back to DB (`chain.getParentExecutionRequests` / + * `getExecutionPayloadEnvelope`). The authoritative view of "does this block / payload exist + * in the canonical chain" is `forkChoice` — this cache is a latency optimisation for the + * synchronous fast path, not a source of truth. */ export class SeenPayloadEnvelopeInput { private readonly chainEvents: ChainEventEmitter; @@ -58,16 +70,7 @@ export class SeenPayloadEnvelopeInput { } 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}); + this.pruneBelow(computeStartSlotAtEpoch(checkpoint.epoch)); }; add(props: CreateFromBlockProps): PayloadEnvelopeInput { @@ -88,17 +91,21 @@ export class SeenPayloadEnvelopeInput { return this.payloadInputs.get(blockRootHex)?.hasPayloadEnvelope() ?? false; } - prune(blockRootHex: RootHex): void { - const payloadInput = this.payloadInputs.get(blockRootHex); - if (payloadInput) { - this.evictPayloadInput(payloadInput); - } - } - size(): number { return this.payloadInputs.size; } + pruneBelow(slot: Slot): void { + let deletedCount = 0; + for (const [, input] of this.payloadInputs) { + if (input.slot < slot) { + this.evictPayloadInput(input); + deletedCount++; + } + } + this.logger?.debug("SeenPayloadEnvelopeInput.pruneBelow deleted entries", {slot, deletedCount}); + } + private evictPayloadInput(payloadInput: PayloadEnvelopeInput): void { this.serializedCache.delete(payloadInput.getSerializedCacheKeys()); this.payloadInputs.delete(payloadInput.blockRootHex); diff --git a/packages/beacon-node/src/chain/validation/block.ts b/packages/beacon-node/src/chain/validation/block.ts index 33b9daef36ad..1c79e2717e21 100644 --- a/packages/beacon-node/src/chain/validation/block.ts +++ b/packages/beacon-node/src/chain/validation/block.ts @@ -103,6 +103,7 @@ export async function validateGossipBlock( if (chain.forkChoice.getBlockHexAndBlockHash(parentRoot, parentBlockHashHex) === null) { throw new BlockGossipError(GossipAction.IGNORE, { code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN, + parentRoot, parentBlockHash: parentBlockHashHex, }); } diff --git a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts index 256b5f567b7a..234f6521b6c1 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadEnvelope.ts @@ -4,8 +4,8 @@ import { getExecutionPayloadEnvelopeSignatureSet, isStatePostGloas, } from "@lodestar/state-transition"; -import {gloas} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {gloas, ssz} from "@lodestar/types"; +import {byteArrayEquals, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadEnvelopeError, ExecutionPayloadEnvelopeErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; @@ -47,16 +47,18 @@ async function validateExecutionPayloadEnvelope( // [IGNORE] The node has not seen another valid // `SignedExecutionPayloadEnvelope` for this block root from this builder. + // Fork choice flips to PayloadStatus.FULL during envelope import, so it is the authoritative + // duplicate-detection signal. const envelopeBlock = chain.forkChoice.getBlockHex(blockRootHex, PayloadStatus.FULL); - const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); - if (envelopeBlock || payloadInput?.hasPayloadEnvelope()) { + if (envelopeBlock) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.ENVELOPE_ALREADY_KNOWN, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); } + const payloadInput = chain.seenPayloadEnvelopeInputCache.get(blockRootHex); if (!payloadInput) { // PayloadEnvelopeInput should have been created during block import throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { @@ -65,13 +67,13 @@ async function validateExecutionPayloadEnvelope( }); } - // [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)` + // [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot -- i.e. validate that `payload.slotNumber >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)` const finalizedCheckpoint = chain.forkChoice.getFinalizedCheckpoint(); const finalizedSlot = computeStartSlotAtEpoch(finalizedCheckpoint.epoch); - if (envelope.slot < finalizedSlot) { + if (payload.slotNumber < finalizedSlot) { throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.BELONG_TO_FINALIZED_BLOCK, - envelopeSlot: envelope.slot, + envelopeSlot: payload.slotNumber, finalizedSlot, }); } @@ -80,11 +82,11 @@ async function validateExecutionPayloadEnvelope( // TODO GLOAS: implement this. Technically if we cannot get proto block from fork choice, // it is possible that the block didn't pass the validation - // [REJECT] `block.slot` equals `envelope.slot`. - if (block.slot !== envelope.slot) { + // [REJECT] `block.slot` equals `payload.slotNumber`. + if (block.slot !== payload.slotNumber) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { code: ExecutionPayloadEnvelopeErrorCode.SLOT_MISMATCH, - envelopeSlot: envelope.slot, + envelopeSlot: payload.slotNumber, blockSlot: block.slot, }); } @@ -98,6 +100,16 @@ async function validateExecutionPayloadEnvelope( }); } + // [REJECT] `hash_tree_root(envelope.execution_requests) == bid.execution_requests_root` + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(envelope.executionRequests); + if (!byteArrayEquals(requestsRoot, payloadInput.getBid().executionRequestsRoot)) { + throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { + code: ExecutionPayloadEnvelopeErrorCode.EXECUTION_REQUESTS_ROOT_MISMATCH, + envelopeRequestsRoot: toRootHex(requestsRoot), + bidRequestsRoot: toRootHex(payloadInput.getBid().executionRequestsRoot), + }); + } + // [REJECT] `payload.block_hash == bid.block_hash` if (toRootHex(payload.blockHash) !== payloadInput.getBlockHashHex()) { throw new ExecutionPayloadEnvelopeError(GossipAction.REJECT, { @@ -114,7 +126,7 @@ async function validateExecutionPayloadEnvelope( throw new ExecutionPayloadEnvelopeError(GossipAction.IGNORE, { code: ExecutionPayloadEnvelopeErrorCode.UNKNOWN_BLOCK_STATE, blockRoot: blockRootHex, - slot: envelope.slot, + slot: payload.slotNumber, }); }); if (!isStatePostGloas(blockState)) { diff --git a/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts b/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts index 5b282b1b1bd7..439f2de1f822 100644 --- a/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts +++ b/packages/beacon-node/src/db/repositories/executionPayloadEnvelopeArchive.ts @@ -19,7 +19,7 @@ export class ExecutionPayloadEnvelopeArchiveRepository extends Repository { - const method = "engine_getPayloadBodiesByHashV1"; + async getPayloadBodiesByHash(fork: ForkName, blockHashes: RootHex[]): Promise<(ExecutionPayloadBody | null)[]> { + const method = ForkSeq[fork] >= ForkSeq.gloas ? "engine_getPayloadBodiesByHashV2" : "engine_getPayloadBodiesByHashV1"; assertReqSizeLimit(blockHashes.length, 32); const response = await this.rpc.fetchWithRetries< EngineApiRpcReturnTypes[typeof method], @@ -477,11 +477,12 @@ export class ExecutionEngineHttp implements IExecutionEngine { } async getPayloadBodiesByRange( - _fork: ForkName, + fork: ForkName, startBlockNumber: number, blockCount: number ): Promise<(ExecutionPayloadBody | null)[]> { - const method = "engine_getPayloadBodiesByRangeV1"; + const method = + ForkSeq[fork] >= ForkSeq.gloas ? "engine_getPayloadBodiesByRangeV2" : "engine_getPayloadBodiesByRangeV1"; assertReqSizeLimit(blockCount, 32); const start = numToQuantity(startBlockNumber); const count = numToQuantity(blockCount); diff --git a/packages/beacon-node/src/execution/engine/mock.ts b/packages/beacon-node/src/execution/engine/mock.ts index 3b6342ae7b6e..28f394d78bc3 100644 --- a/packages/beacon-node/src/execution/engine/mock.ts +++ b/packages/beacon-node/src/execution/engine/mock.ts @@ -11,7 +11,7 @@ import { SLOTS_PER_EPOCH, } from "@lodestar/params"; import {computeTimeAtSlot} from "@lodestar/state-transition"; -import {ExecutionPayload, RootHex, bellatrix, deneb, ssz} from "@lodestar/types"; +import {ExecutionPayload, RootHex, bellatrix, deneb, gloas, ssz} from "@lodestar/types"; import {fromHex, toRootHex} from "@lodestar/utils"; import {ZERO_HASH_HEX} from "../../constants/index.js"; import {INTEROP_BLOCK_HASH} from "../../node/utils/interop/state.js"; @@ -144,7 +144,9 @@ export class ExecutionEngineMockBackend implements JsonRpcBackend { engine_getPayloadV5: this.getPayloadV5.bind(this), engine_getPayloadV6: this.getPayloadV5.bind(this), engine_getPayloadBodiesByHashV1: this.getPayloadBodiesByHash.bind(this), + engine_getPayloadBodiesByHashV2: this.getPayloadBodiesByHash.bind(this), engine_getPayloadBodiesByRangeV1: this.getPayloadBodiesByRange.bind(this), + engine_getPayloadBodiesByRangeV2: this.getPayloadBodiesByRange.bind(this), engine_getClientVersionV1: this.getClientVersionV1.bind(this), engine_getBlobsV1: this.getBlobs.bind(this), engine_getBlobsV2: this.getBlobsV2.bind(this), @@ -382,6 +384,10 @@ export class ExecutionEngineMockBackend implements JsonRpcBackend { (executionPayload as ExecutionPayload).withdrawals = ssz.capella.Withdrawals.defaultValue(); } + if (ForkSeq[fork] >= ForkSeq.gloas && payloadAttributes.slotNumber != null) { + (executionPayload as gloas.ExecutionPayload).slotNumber = payloadAttributes.slotNumber; + } + this.preparingPayloads.set(payloadId, { executionPayload: serializeExecutionPayload(fork, executionPayload), blobsBundle: serializeBlobsBundle({ diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index 36e6737f1e34..3344a02274fa 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -87,12 +87,14 @@ export type EngineApiRpcParamTypes = { * 1. Array of DATA - Array of block_hash field values of the ExecutionPayload structure * */ engine_getPayloadBodiesByHashV1: DATA[][]; + engine_getPayloadBodiesByHashV2: DATA[][]; /** * 1. start: QUANTITY, 64 bits - Starting block number * 2. count: QUANTITY, 64 bits - Number of blocks to return */ engine_getPayloadBodiesByRangeV1: [start: QUANTITY, count: QUANTITY]; + engine_getPayloadBodiesByRangeV2: [start: QUANTITY, count: QUANTITY]; /** * Object - Instance of ClientVersion @@ -146,8 +148,10 @@ export type EngineApiRpcReturnTypes = { engine_getPayloadV6: ExecutionPayloadResponse; engine_getPayloadBodiesByHashV1: (ExecutionPayloadBodyRpc | null)[]; + engine_getPayloadBodiesByHashV2: (ExecutionPayloadBodyRpc | null)[]; engine_getPayloadBodiesByRangeV1: (ExecutionPayloadBodyRpc | null)[]; + engine_getPayloadBodiesByRangeV2: (ExecutionPayloadBodyRpc | null)[]; engine_getClientVersionV1: ClientVersionRpc[]; @@ -168,11 +172,13 @@ type ExecutionPayloadResponse = ExecutionPayloadRpcWithValue; export type ExecutionPayloadBodyRpc = { transactions: DATA[]; withdrawals: WithdrawalV1[] | null | undefined; + blockAccessList?: DATA | null; // GLOAS:EIP-7928 }; export type ExecutionPayloadBody = { transactions: bellatrix.Transaction[]; withdrawals: capella.Withdrawals | null; + blockAccessList?: Uint8Array | null; // GLOAS:EIP-7928 }; export type ExecutionPayloadRpc = { @@ -424,7 +430,7 @@ export function serializePayloadAttributes(data: PayloadAttributes): PayloadAttr suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map(serializeWithdrawal), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? bytesToData(data.parentBeaconBlockRoot) : undefined, - slotNumber: data.slotNumber !== undefined ? numToQuantity(data.slotNumber) : undefined, + slotNumber: data.slotNumber != null ? numToQuantity(data.slotNumber) : undefined, }; } @@ -441,7 +447,7 @@ export function deserializePayloadAttributes(data: PayloadAttributesRpc): Payloa suggestedFeeRecipient: data.suggestedFeeRecipient, withdrawals: data.withdrawals?.map((withdrawal) => deserializeWithdrawal(withdrawal)), parentBeaconBlockRoot: data.parentBeaconBlockRoot ? dataToBytes(data.parentBeaconBlockRoot, 32) : undefined, - slotNumber: data.slotNumber !== undefined ? quantityToNum(data.slotNumber) : undefined, + slotNumber: data.slotNumber != null ? quantityToNum(data.slotNumber) : undefined, }; } @@ -605,21 +611,27 @@ export function deserializeExecutionRequests(serialized: ExecutionRequestsRpc): } export function deserializeExecutionPayloadBody(data: ExecutionPayloadBodyRpc | null): ExecutionPayloadBody | null { - return data - ? { - transactions: data.transactions.map((tran) => dataToBytes(tran, null)), - withdrawals: data.withdrawals ? data.withdrawals.map(deserializeWithdrawal) : null, - } - : null; + if (data == null) return null; + const body: ExecutionPayloadBody = { + transactions: data.transactions.map((tran) => dataToBytes(tran, null)), + withdrawals: data.withdrawals ? data.withdrawals.map(deserializeWithdrawal) : null, + }; + if (data.blockAccessList != null) { + body.blockAccessList = dataToBytes(data.blockAccessList, null); + } + return body; } export function serializeExecutionPayloadBody(data: ExecutionPayloadBody | null): ExecutionPayloadBodyRpc | null { - return data - ? { - transactions: data.transactions.map((tran) => bytesToData(tran)), - withdrawals: data.withdrawals ? data.withdrawals.map(serializeWithdrawal) : null, - } - : null; + if (data == null) return null; + const body: ExecutionPayloadBodyRpc = { + transactions: data.transactions.map((tran) => bytesToData(tran)), + withdrawals: data.withdrawals ? data.withdrawals.map(serializeWithdrawal) : null, + }; + if (data.blockAccessList != null) { + body.blockAccessList = bytesToData(data.blockAccessList); + } + return body; } export function deserializeBlobAndProofs(data: BlobAndProofRpc | null): BlobAndProof | null { diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index 358054bdd8e9..bb62ed95d535 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -680,6 +680,23 @@ export function createLodestarMetrics( labelNames: ["code", "client"], }), }, + pendingPayloads: register.gauge({ + name: "lodestar_sync_unknown_block_pending_payloads_size", + help: "Current size of BlockInputSync pending payloads cache", + }), + payloadRequests: register.gauge<{source: BlockInputSource}>({ + name: "lodestar_sync_unknown_block_payload_requests_total", + help: "Total number of payload envelope fetch requests triggered", + labelNames: ["source"], + }), + payloadFetchSuccess: register.gauge({ + name: "lodestar_sync_unknown_block_payload_fetch_success_total", + help: "Total number of successful payload envelope fetches", + }), + payloadFetchError: register.gauge({ + name: "lodestar_sync_unknown_block_payload_fetch_error_total", + help: "Total number of errored payload envelope fetches", + }), peerBalancer: { peersMetaCount: register.gauge({ name: "lodestar_sync_unknown_block_peer_balancer_peers_meta_count", diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 623fca31039c..4cd24034df98 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -505,7 +505,7 @@ export class Network implements INetwork { } async publishSignedExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope): Promise { - const epoch = computeEpochAtSlot(signedEnvelope.message.slot); + const epoch = computeEpochAtSlot(signedEnvelope.message.payload.slotNumber); const boundary = this.config.getForkBoundaryAtEpoch(epoch); return this.publishGossip( diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 32debf5db525..68fa5c29df9e 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -198,6 +198,23 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand } catch (e) { if (e instanceof BlockGossipError) { logger.debug("Gossip block has error", {slot, root: blockShortHex, code: e.type.code}); + if (e.type.code === BlockErrorCode.PARENT_PAYLOAD_UNKNOWN && blockInput) { + logger.debug("Gossip block has parent payload unknown", {slot, root: blockShortHex, code: e.type.code}); + // Track the child block for processing after parent envelope arrives + chain.emitter.emit(ChainEvent.blockUnknownParent, { + blockInput, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + // Trigger parent envelope fetch + chain.emitter.emit(ChainEvent.unknownEnvelopeBlockRoot, { + rootHex: e.type.parentRoot, + peer: peerIdStr, + source: BlockInputSource.gossip, + }); + throw e; + } + if (e.type.code === BlockErrorCode.PARENT_UNKNOWN && blockInput) { chain.emitter.emit(ChainEvent.blockUnknownParent, { blockInput, @@ -1065,7 +1082,8 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand await validateGossipExecutionPayloadEnvelope(chain, signedEnvelope); } catch (e) { if (e instanceof ExecutionPayloadEnvelopeError) { - const {slot, beaconBlockRoot} = signedEnvelope.message; + const {beaconBlockRoot} = signedEnvelope.message; + const slot = signedEnvelope.message.payload.slotNumber; logger.debug("Gossip envelope has error", {slot, root: toRootHex(beaconBlockRoot), code: e.type.code}); if (e.type.code === ExecutionPayloadEnvelopeErrorCode.BLOCK_ROOT_UNKNOWN) { // TODO GLOAS: UnknownBlockSync to handle this @@ -1088,7 +1106,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand throw e; } - const slot = envelope.slot; + const slot = envelope.payload.slotNumber; const delaySec = seenTimestampSec - computeTimeAtSlot(config, slot, chain.genesisTime); metrics?.gossipExecutionPayloadEnvelope.elapsedTimeTillReceived.observe({source: OpSource.gossip}, delaySec); chain.validatorMonitor?.registerExecutionPayloadEnvelope(OpSource.gossip, delaySec, signedEnvelope); @@ -1118,7 +1136,6 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand builderIndex: envelope.builderIndex, blockHash: toRootHex(envelope.payload.blockHash), blockRoot: blockRootHex, - stateRoot: toRootHex(envelope.stateRoot), }); chain.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => { diff --git a/packages/beacon-node/src/sync/range/batch.ts b/packages/beacon-node/src/sync/range/batch.ts index cf03667acedb..afdaacab1303 100644 --- a/packages/beacon-node/src/sync/range/batch.ts +++ b/packages/beacon-node/src/sync/range/batch.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ForkName, isForkPostDeneb, isForkPostFulu} from "@lodestar/params"; -import {Epoch, RootHex, Slot, phase0} from "@lodestar/types"; +import {ForkName, isForkPostDeneb, isForkPostFulu, isForkPostGloas} from "@lodestar/params"; +import {Epoch, RootHex, Slot, gloas, phase0} from "@lodestar/types"; import {LodestarError} from "@lodestar/utils"; import {isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {IBlockInput} from "../../chain/blocks/blockInput/types.js"; @@ -46,19 +46,21 @@ export type Attempt = { export type AwaitingDownloadState = { status: BatchStatus.AwaitingDownload; blocks: IBlockInput[]; + payloadEnvelopes: Map | null; }; export type DownloadSuccessState = { status: BatchStatus.AwaitingProcessing; blocks: IBlockInput[]; + payloadEnvelopes: Map | null; }; export type BatchState = | AwaitingDownloadState - | {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]} + | {status: BatchStatus.Downloading; peer: PeerIdStr; blocks: IBlockInput[]; payloadEnvelopes: Map | null} | DownloadSuccessState - | {status: BatchStatus.Processing; blocks: IBlockInput[]; attempt: Attempt} - | {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; attempt: Attempt}; + | {status: BatchStatus.Processing; blocks: IBlockInput[]; payloadEnvelopes: Map | null; attempt: Attempt} + | {status: BatchStatus.AwaitingValidation; blocks: IBlockInput[]; payloadEnvelopes: Map | null; attempt: Attempt}; export type BatchMetadata = { startEpoch: Epoch; @@ -85,7 +87,7 @@ export class Batch { /** Block, blob and column requests that are used to determine the best peer and are used in downloadByRange */ requests: DownloadByRangeRequests; /** State of the batch. */ - state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: []}; + state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; /** Peers that provided good data */ goodPeers: PeerIdStr[] = []; /** The `Attempts` that have been made and failed to send us this batch. */ @@ -129,6 +131,10 @@ export class Batch { count: this.count, step: 1, }; + const envelopesRequest: gloas.ExecutionPayloadEnvelopesByRangeRequest | undefined = isForkPostGloas(this.forkName) + ? {startSlot: this.startSlot, count: this.count} + : undefined; + if (isForkPostFulu(this.forkName) && withinValidRequestWindow) { return { blocksRequest, @@ -137,6 +143,7 @@ export class Batch { count: this.count, columns: this.custodyConfig.sampledColumns, }, + envelopesRequest, }; } if (isForkPostDeneb(this.forkName) && withinValidRequestWindow) { @@ -146,10 +153,12 @@ export class Batch { startSlot: this.startSlot, count: this.count, }, + envelopesRequest, }; } return { blocksRequest, + envelopesRequest, }; } @@ -186,6 +195,18 @@ export class Batch { } } + // Track envelope start slot for post-Gloas forks + let envelopeStartSlot = this.startSlot; + if (isForkPostGloas(this.forkName)) { + for (const blockInput of blocks) { + const blockSlot = blockInput.slot; + // Envelopes track separately - advance start slot for contiguous downloaded envelopes + if (envelopeStartSlot === blockSlot) { + envelopeStartSlot = blockSlot + 1; + } + } + } + // if the blockStartSlot or dataStartSlot is after the desired endSlot then no request will be made for the batch // because it is complete const endSlot = this.startSlot + this.count - 1; @@ -216,6 +237,13 @@ export class Batch { // dataSlot will still have a value but do not create a request for preDeneb forks } + if (isForkPostGloas(this.forkName) && envelopeStartSlot <= endSlot) { + requests.envelopesRequest = { + startSlot: envelopeStartSlot, + count: endSlot - envelopeStartSlot + 1, + }; + } + return requests; } @@ -263,6 +291,10 @@ export class Batch { return this.state.blocks; } + getPayloadEnvelopes(): Map | null { + return this.state.payloadEnvelopes; + } + /** * AwaitingDownload -> Downloading */ @@ -271,13 +303,13 @@ export class Batch { throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingDownload)); } - this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks}; + this.state = {status: BatchStatus.Downloading, peer, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes}; } /** * Downloading -> AwaitingProcessing */ - downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[]): DownloadSuccessState { + downloadingSuccess(peer: PeerIdStr, blocks: IBlockInput[], payloadEnvelopes: Map | null): DownloadSuccessState { if (this.state.status !== BatchStatus.Downloading) { throw new BatchError(this.wrongStatusErrorType(BatchStatus.Downloading)); } @@ -305,11 +337,13 @@ export class Batch { status: this.state.status, }); } + const newPayloadEnvelopes = payloadEnvelopes ?? this.state.payloadEnvelopes; + if (allComplete) { - this.state = {status: BatchStatus.AwaitingProcessing, blocks}; + this.state = {status: BatchStatus.AwaitingProcessing, blocks, payloadEnvelopes: newPayloadEnvelopes}; } else { this.requests = this.getRequests(blocks); - this.state = {status: BatchStatus.AwaitingDownload, blocks}; + this.state = {status: BatchStatus.AwaitingDownload, blocks, payloadEnvelopes: newPayloadEnvelopes}; } return this.state as DownloadSuccessState; @@ -328,25 +362,26 @@ export class Batch { throw new BatchError(this.errorType({code: BatchErrorCode.MAX_DOWNLOAD_ATTEMPTS})); } - this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes}; } /** * AwaitingProcessing -> Processing */ - startProcessing(): IBlockInput[] { + startProcessing(): {blocks: IBlockInput[]; payloadEnvelopes: Map | null} { if (this.state.status !== BatchStatus.AwaitingProcessing) { throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingProcessing)); } const blocks = this.state.blocks; + const payloadEnvelopes = this.state.payloadEnvelopes; const hash = hashBlocks(blocks, this.config); // tracks blocks to report peer on processing error // Reset goodPeers in case another download attempt needs to be made. When Attempt is successful or not the peers // that the data came from will be handled by the Attempt that goes for processing const peers = this.goodPeers; this.goodPeers = []; - this.state = {status: BatchStatus.Processing, blocks, attempt: {peers, hash}}; - return blocks; + this.state = {status: BatchStatus.Processing, blocks, payloadEnvelopes, attempt: {peers, hash}}; + return {blocks, payloadEnvelopes}; } /** @@ -357,7 +392,7 @@ export class Batch { throw new BatchError(this.wrongStatusErrorType(BatchStatus.Processing)); } - this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, attempt: this.state.attempt}; + this.state = {status: BatchStatus.AwaitingValidation, blocks: this.state.blocks, payloadEnvelopes: this.state.payloadEnvelopes, attempt: this.state.attempt}; } /** @@ -408,7 +443,7 @@ export class Batch { // remove any downloaded blocks and re-attempt // TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache - this.state = {status: BatchStatus.AwaitingDownload, blocks: []}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; } private onProcessingError(attempt: Attempt): void { @@ -419,7 +454,7 @@ export class Batch { // remove any downloaded blocks and re-attempt // TODO(fulu): need to remove the bad blocks from the SeenBlockInputCache - this.state = {status: BatchStatus.AwaitingDownload, blocks: []}; + this.state = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null}; } /** Helper to construct typed BatchError. Stack traces are correct as the error is thrown above */ diff --git a/packages/beacon-node/src/sync/range/chain.ts b/packages/beacon-node/src/sync/range/chain.ts index 911ce93b5bb0..dab8e6251d91 100644 --- a/packages/beacon-node/src/sync/range/chain.ts +++ b/packages/beacon-node/src/sync/range/chain.ts @@ -1,5 +1,5 @@ import {ChainForkConfig} from "@lodestar/config"; -import {Epoch, Root, Slot} from "@lodestar/types"; +import {Epoch, Root, Slot, gloas} from "@lodestar/types"; import {ErrorAborted, LodestarError, Logger, toRootHex} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../../chain/blocks/blockInput/blockInput.js"; import {BlockInputErrorCode} from "../../chain/blocks/blockInput/errors.js"; @@ -44,13 +44,19 @@ export type SyncChainFns = { * Must return if ALL blocks are processed successfully * If SOME blocks are processed must throw BlockProcessorError() */ - processChainSegment: (blocks: IBlockInput[], syncType: RangeSyncType) => Promise; + processChainSegment: ( + blocks: IBlockInput[], + payloadEnvelopes: Map | null, + syncType: RangeSyncType + ) => Promise; /** Must download blocks, and validate their range */ downloadByRange: ( peer: PeerSyncMeta, batch: Batch, syncType: RangeSyncType - ) => Promise>; + ) => Promise< + WarnResult<{blocks: IBlockInput[]; payloadEnvelopes: Map | null}, DownloadByRangeError> + >; /** Report peer for negative actions. Decouples from the full network instance */ reportPeer: (peer: PeerIdStr, action: PeerAction, actionName: string) => void; /** Gets current peer custodyColumns and earliestAvailableSlot */ @@ -516,7 +522,8 @@ export class SyncChain { }); this.metrics?.syncRange.downloadByRange.success.inc(); const {warnings, result} = res.result; - const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, result); + const {blocks: downloadedBlocks, payloadEnvelopes} = result; + const downloadSuccessOutput = batch.downloadingSuccess(peer.peerId, downloadedBlocks, payloadEnvelopes); const logMeta: Record = { blockCount: downloadSuccessOutput.blocks.length, }; @@ -578,10 +585,10 @@ export class SyncChain { * Sends `batch` to the processor. Note: batch may be empty */ private async processBatch(batch: Batch): Promise { - const blocks = batch.startProcessing(); + const {blocks, payloadEnvelopes} = batch.startProcessing(); // wrapError ensures to never call both batch success() and batch error() - const res = await wrapError(this.processChainSegment(blocks, this.syncType)); + const res = await wrapError(this.processChainSegment(blocks, payloadEnvelopes, this.syncType)); if (!res.err) { batch.processingSuccess(); diff --git a/packages/beacon-node/src/sync/range/range.ts b/packages/beacon-node/src/sync/range/range.ts index 64c284c65936..8c1fc88f90ce 100644 --- a/packages/beacon-node/src/sync/range/range.ts +++ b/packages/beacon-node/src/sync/range/range.ts @@ -172,7 +172,11 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { } /** Convenience method for `SyncChain` */ - private processChainSegment: SyncChainFns["processChainSegment"] = async (blocks, syncType) => { + private processChainSegment: SyncChainFns["processChainSegment"] = async ( + blocks, + payloadEnvelopes, + syncType + ) => { // Not trusted, verify signatures const flags: ImportBlockOpts = { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. @@ -194,7 +198,7 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { // Should only be used for debugging or testing for (const block of blocks) await this.chain.processBlock(block, flags); } else { - await this.chain.processChainSegment(blocks, flags); + await this.chain.processChainSegment(blocks, payloadEnvelopes, flags); } }; @@ -209,13 +213,14 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) { peerDasMetrics: this.chain.metrics?.peerDas, ...batch.getRequestsForPeer(peer), }); - const cached = cacheByRangeResponses({ + const {responses, payloadEnvelopes} = result; + const blocks = cacheByRangeResponses({ cache: this.chain.seenBlockInputCache, peerIdStr: peer.peerId, - responses: result, + responses, batchBlocks, }); - return {result: cached, warnings}; + return {result: {blocks, payloadEnvelopes}, warnings}; }; private pruneBlockInputs: SyncChainFns["pruneBlockInputs"] = (blocks: IBlockInput[]) => { diff --git a/packages/beacon-node/src/sync/types.ts b/packages/beacon-node/src/sync/types.ts index ca36095c5ca8..4330ec8e7c24 100644 --- a/packages/beacon-node/src/sync/types.ts +++ b/packages/beacon-node/src/sync/types.ts @@ -1,5 +1,6 @@ import {RootHex, Slot} from "@lodestar/types"; import {IBlockInput} from "../chain/blocks/blockInput/index.js"; +import {PeerIdStr} from "../util/peerId.js"; export enum PendingBlockType { /** @@ -55,3 +56,12 @@ export function getBlockInputSyncCacheItemRootHex(block: BlockInputSyncCacheItem export function getBlockInputSyncCacheItemSlot(block: BlockInputSyncCacheItem): Slot | string { return isPendingBlockInput(block) ? block.blockInput.slot : "unknown"; } + +export type PendingPayloadEnvelope = { + status: "pending" | "fetching"; + blockRootHex: RootHex; + slot: Slot; + attempts: number; + peerIdStrings: Set; + timeAddedSec: number; +}; diff --git a/packages/beacon-node/src/sync/unknownBlock.ts b/packages/beacon-node/src/sync/unknownBlock.ts index 4875911f28b6..076b1cd7c7ae 100644 --- a/packages/beacon-node/src/sync/unknownBlock.ts +++ b/packages/beacon-node/src/sync/unknownBlock.ts @@ -1,11 +1,13 @@ import {ChainForkConfig} from "@lodestar/config"; +import {PayloadStatus} from "@lodestar/fork-choice"; import {ForkSeq} from "@lodestar/params"; import {RequestError, RequestErrorCode} from "@lodestar/reqresp"; import {computeTimeAtSlot} from "@lodestar/state-transition"; import {RootHex} from "@lodestar/types"; -import {Logger, prettyPrintIndices, pruneSetToMax, sleep} from "@lodestar/utils"; +import {Logger, fromHex, prettyPrintIndices, pruneSetToMax, sleep} from "@lodestar/utils"; import {isBlockInputBlobs, isBlockInputColumns} from "../chain/blocks/blockInput/blockInput.js"; import {BlockInputSource, IBlockInput} from "../chain/blocks/blockInput/types.js"; +import {PayloadEnvelopeInputSource} from "../chain/blocks/payloadEnvelopeInput/index.js"; import {BlockError, BlockErrorCode} from "../chain/errors/index.js"; import {ChainEvent, ChainEventData, IBeaconChain} from "../chain/index.js"; import {Metrics} from "../metrics/index.js"; @@ -22,6 +24,7 @@ import { PendingBlockInput, PendingBlockInputStatus, PendingBlockType, + PendingPayloadEnvelope, getBlockInputSyncCacheItemRootHex, getBlockInputSyncCacheItemSlot, isPendingBlockInput, @@ -32,6 +35,8 @@ import {getAllDescendantBlocks, getDescendantBlocks, getUnknownAndAncestorBlocks const MAX_ATTEMPTS_PER_BLOCK = 5; const MAX_KNOWN_BAD_BLOCKS = 500; const MAX_PENDING_BLOCKS = 100; +const MAX_PENDING_PAYLOADS = 100; +const MAX_ATTEMPTS_PER_PAYLOAD = 5; enum FetchResult { SuccessResolved = "success_resolved", @@ -78,6 +83,7 @@ export class BlockInputSync { * block RootHex -> PendingBlock. To avoid finding same root at the same time */ private readonly pendingBlocks = new Map(); + private readonly pendingPayloads = new Map(); private readonly knownBadBlocks = new Set(); private readonly maxPendingBlocks; private subscribedToNetworkEvents = false; @@ -101,6 +107,9 @@ export class BlockInputSync { metrics.blockInputSync.knownBadBlocks.addCollect(() => metrics.blockInputSync.knownBadBlocks.set(this.knownBadBlocks.size) ); + metrics.blockInputSync.pendingPayloads?.addCollect(() => + metrics.blockInputSync.pendingPayloads?.set(this.pendingPayloads.size) + ); } } @@ -116,6 +125,7 @@ export class BlockInputSync { this.chain.emitter.on(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.on(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); this.chain.emitter.on(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.on(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownPayloadEnvelope); this.network.events.on(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.on(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = true; @@ -127,6 +137,7 @@ export class BlockInputSync { this.chain.emitter.off(ChainEvent.unknownBlockRoot, this.onUnknownBlockRoot); this.chain.emitter.off(ChainEvent.incompleteBlockInput, this.onIncompleteBlockInput); this.chain.emitter.off(ChainEvent.blockUnknownParent, this.onUnknownParent); + this.chain.emitter.off(ChainEvent.unknownEnvelopeBlockRoot, this.onUnknownPayloadEnvelope); this.network.events.off(NetworkEvent.peerConnected, this.onPeerConnected); this.network.events.off(NetworkEvent.peerDisconnected, this.onPeerDisconnected); this.subscribedToNetworkEvents = false; @@ -183,6 +194,21 @@ export class BlockInputSync { } }; + /** + * Process an unknownEnvelopeBlockRoot event - fetch missing payload envelope for a known block. + */ + private onUnknownPayloadEnvelope = (data: ChainEventData[ChainEvent.unknownEnvelopeBlockRoot]): void => { + try { + const {rootHex: blockRootHex, peer} = data; + const block = this.chain.forkChoice.getBlockHexDefaultStatus(blockRootHex); + if (!block) return; + this.addPendingPayload(blockRootHex, block.slot, peer); + this.metrics?.blockInputSync.payloadRequests?.inc({source: data.source}); + } catch (e) { + this.logger.debug("Error handling unknownPayloadEnvelope event", {}, e as Error); + } + }; + private addByRootHex = (rootHex: RootHex, peerIdStr?: PeerIdStr): void => { let pendingBlock = this.pendingBlocks.get(rootHex); if (!pendingBlock) { @@ -248,6 +274,7 @@ export class BlockInputSync { const peerSyncMeta = this.network.getConnectedPeerSyncMeta(peerId); this.peerBalancer.onPeerConnected(data.peer, peerSyncMeta); this.triggerUnknownBlockSearch(); + this.triggerPayloadSearch(); } catch (e) { this.logger.debug("Error handling peerConnected event", {}, e as Error); } @@ -258,6 +285,105 @@ export class BlockInputSync { this.peerBalancer.onPeerDisconnected(peerId); }; + addPendingPayload(rootHex: RootHex, slot: number, peer?: PeerIdStr): void { + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(rootHex); + if (payloadInput?.hasPayloadEnvelope()) return; + if (this.chain.forkChoice.getBlockHex(rootHex, PayloadStatus.FULL)) return; + if (this.pendingPayloads.size >= MAX_PENDING_PAYLOADS) return; + + let pending = this.pendingPayloads.get(rootHex); + if (!pending) { + pending = { + status: "pending", + blockRootHex: rootHex, + slot, + attempts: 0, + peerIdStrings: new Set(), + timeAddedSec: Date.now() / 1000, + }; + this.pendingPayloads.set(rootHex, pending); + } + if (peer) pending.peerIdStrings.add(peer); + this.triggerPayloadSearch(); + } + + private triggerPayloadSearch = (): void => { + if (this.pendingPayloads.size === 0) return; + if (this.network.getConnectedPeers().length === 0) return; + + const finalizedSlot = this.chain.forkChoice.getFinalizedBlock().slot; + + for (const [rootHex, pending] of this.pendingPayloads) { + if (pending.slot <= finalizedSlot) { + this.pendingPayloads.delete(rootHex); + continue; + } + if ( + this.chain.seenPayloadEnvelopeInputCache.get(rootHex)?.hasPayloadEnvelope() || + this.chain.forkChoice.getBlockHex(rootHex, PayloadStatus.FULL) + ) { + this.pendingPayloads.delete(rootHex); + continue; + } + if (!this.chain.forkChoice.hasBlockHexUnsafe(rootHex)) continue; + if (pending.status !== "pending") continue; + if (pending.attempts >= MAX_ATTEMPTS_PER_PAYLOAD) { + this.pendingPayloads.delete(rootHex); + continue; + } + this.fetchPayloadEnvelope(pending).catch((e) => { + this.logger.debug("Unexpected error - fetchPayloadEnvelope", {root: pending.blockRootHex}, e); + }); + } + }; + + private async fetchPayloadEnvelope(pending: PendingPayloadEnvelope): Promise { + pending.status = "fetching"; + pending.attempts++; + try { + const peerMeta = this.peerBalancer.bestPeerForPendingColumns(new Set(), new Set()); + if (!peerMeta) { + pending.status = "pending"; + return; + } + const {peerId: peer} = peerMeta; + + const envelopes = await this.network.sendExecutionPayloadEnvelopesByRoot(peer, [fromHex(pending.blockRootHex)]); + if (envelopes.length === 0) { + pending.status = "pending"; + return; + } + + const envelope = envelopes[0]; + const payloadInput = this.chain.seenPayloadEnvelopeInputCache.get(pending.blockRootHex); + if (!payloadInput) { + this.logger.debug("PayloadEnvelopeInput missing for fetched envelope", {root: pending.blockRootHex}); + pending.status = "pending"; + return; + } + + payloadInput.addPayloadEnvelope({ + envelope, + source: PayloadEnvelopeInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + peerIdStr: peer, + }); + + if (payloadInput.isComplete()) { + await this.chain.processExecutionPayload(payloadInput); + } + + this.pendingPayloads.delete(pending.blockRootHex); + this.metrics?.blockInputSync.payloadFetchSuccess?.inc(); + // Re-trigger block search since pending blocks may now be processable + this.triggerUnknownBlockSearch(); + } catch (e) { + this.logger.debug("Error fetching payload envelope", {root: pending.blockRootHex}, e as Error); + pending.status = "pending"; + this.metrics?.blockInputSync.payloadFetchError?.inc(); + } + } + /** * Gather tip parent blocks with unknown parent and do a search for all of them */ @@ -456,6 +582,12 @@ export class BlockInputSync { pendingBlock.status = PendingBlockInputStatus.downloaded; break; + case BlockErrorCode.PARENT_PAYLOAD_UNKNOWN: + this.logger.debug("Block parent payload unknown", errorData, res.err); + this.addPendingPayload(res.err.type.parentRoot, pendingBlock.blockInput.slot - 1); + pendingBlock.status = PendingBlockInputStatus.downloaded; + break; + case BlockErrorCode.EXECUTION_ENGINE_ERROR: // Removing the block(s) without penalizing the peers, hoping for EL to // recover on a latter download + verify attempt @@ -671,6 +803,7 @@ export class BlockInputSync { for (const block of badPendingBlocks) { const rootHex = getBlockInputSyncCacheItemRootHex(block); this.pendingBlocks.delete(rootHex); + this.pendingPayloads.delete(rootHex); this.chain.seenBlockInputCache.prune(rootHex); this.logger.debug("Removing bad/unknown/incomplete BlockInputSyncCacheItem", { slot, diff --git a/packages/beacon-node/src/sync/utils/downloadByRange.ts b/packages/beacon-node/src/sync/utils/downloadByRange.ts index 8d554db255bd..73047ea56b64 100644 --- a/packages/beacon-node/src/sync/utils/downloadByRange.ts +++ b/packages/beacon-node/src/sync/utils/downloadByRange.ts @@ -1,6 +1,6 @@ import {ChainForkConfig} from "@lodestar/config"; -import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu} from "@lodestar/params"; -import {SignedBeaconBlock, Slot, deneb, fulu, phase0} from "@lodestar/types"; +import {ForkPostDeneb, ForkPostFulu, ForkPreFulu, isForkPostFulu, isForkPostGloas} from "@lodestar/params"; +import {DataColumnSidecar, SignedBeaconBlock, Slot, deneb, fulu, gloas, isGloasDataColumnSidecar, phase0} from "@lodestar/types"; import {LodestarError, Logger, byteArrayEquals, fromHex, prettyPrintIndices, toRootHex} from "@lodestar/utils"; import { BlockInputSource, @@ -11,7 +11,7 @@ import { } from "../../chain/blocks/blockInput/index.js"; import {SeenBlockInput} from "../../chain/seenCache/seenGossipBlockInput.js"; import {validateBlockBlobSidecars} from "../../chain/validation/blobSidecar.js"; -import {validateFuluBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; +import {validateFuluBlockDataColumnSidecars, validateGloasBlockDataColumnSidecars} from "../../chain/validation/dataColumnSidecar.js"; import {BeaconMetrics} from "../../metrics/metrics/beacon.js"; import {INetwork} from "../../network/index.js"; import {getBlobKzgCommitments} from "../../util/dataColumns.js"; @@ -22,12 +22,14 @@ export type DownloadByRangeRequests = { blocksRequest?: phase0.BeaconBlocksByRangeRequest; blobsRequest?: deneb.BlobSidecarsByRangeRequest; columnsRequest?: fulu.DataColumnSidecarsByRangeRequest; + envelopesRequest?: gloas.ExecutionPayloadEnvelopesByRangeRequest; }; export type DownloadByRangeResponses = { blocks?: SignedBeaconBlock[]; blobSidecars?: deneb.BlobSidecars; - columnSidecars?: fulu.DataColumnSidecar[]; + columnSidecars?: DataColumnSidecar[]; + payloadEnvelopes?: gloas.SignedExecutionPayloadEnvelope[]; }; export type DownloadAndCacheByRangeProps = DownloadByRangeRequests & { @@ -58,7 +60,7 @@ export type ValidatedBlobSidecars = { export type ValidatedColumnSidecars = { blockRoot: Uint8Array; - columnSidecars: fulu.DataColumnSidecar[]; + columnSidecars: DataColumnSidecar[]; }; export type ValidatedResponses = { @@ -150,12 +152,17 @@ export function cacheByRangeResponses({ } for (const {blockRoot, columnSidecars} of responses.validatedColumnSidecars ?? []) { - const dataSlot = columnSidecars.at(0)?.signedBlockHeader.message.slot; - if (dataSlot === undefined) { + const firstColumn = columnSidecars[0]; + if (!firstColumn) { throw new Error( `Coding Error: empty columnSidecars returned for blockRoot=${toRootHex(blockRoot)} from validation functions` ); } + // Gloas columns are added to PayloadEnvelopeInput by the caller, not to IBlockInput + if (isGloasDataColumnSidecar(firstColumn)) continue; + + const fuluColumns = columnSidecars as fulu.DataColumnSidecar[]; + const dataSlot = firstColumn.signedBlockHeader.message.slot; const existing = updatedBatchBlocks.get(dataSlot); const blockRootHex = toRootHex(blockRoot); @@ -172,7 +179,7 @@ export function cacheByRangeResponses({ actual: existing.type, }); } - for (const columnSidecar of columnSidecars) { + for (const columnSidecar of fuluColumns) { // will throw if root hex does not match (meaning we are following the wrong chain) existing.addColumn( { @@ -198,8 +205,14 @@ export async function downloadByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, peerDasMetrics, -}: DownloadAndCacheByRangeProps): Promise> { +}: DownloadAndCacheByRangeProps): Promise< + WarnResult< + {responses: ValidatedResponses; payloadEnvelopes: Map | null}, + DownloadByRangeError + > +> { let response: DownloadByRangeResponses; try { response = await requestByRange({ @@ -208,6 +221,7 @@ export async function downloadByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, }); } catch (err) { throw new DownloadByRangeError({ @@ -217,17 +231,16 @@ export async function downloadByRange({ }); } - const validated = await validateResponses({ + return validateResponses({ config, batchBlocks, blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, peerDasMetrics, ...response, }); - - return validated; } /** @@ -239,13 +252,15 @@ export async function requestByRange({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, }: DownloadByRangeRequests & { network: INetwork; peerIdStr: PeerIdStr; }): Promise { let blocks: undefined | SignedBeaconBlock[]; let blobSidecars: undefined | deneb.BlobSidecars; - let columnSidecars: undefined | fulu.DataColumnSidecar[]; + let columnSidecars: undefined | DataColumnSidecar[]; + let payloadEnvelopes: undefined | gloas.SignedExecutionPayloadEnvelope[]; const requests: Promise[] = []; @@ -268,7 +283,15 @@ export async function requestByRange({ if (columnsRequest) { requests.push( network.sendDataColumnSidecarsByRange(peerIdStr, columnsRequest).then((columnResponse) => { - columnSidecars = columnResponse as fulu.DataColumnSidecar[]; + columnSidecars = columnResponse; + }) + ); + } + + if (envelopesRequest) { + requests.push( + network.sendExecutionPayloadEnvelopesByRange(peerIdStr, envelopesRequest).then((envelopeResponse) => { + payloadEnvelopes = envelopeResponse; }) ); } @@ -279,6 +302,7 @@ export async function requestByRange({ blocks, blobSidecars, columnSidecars, + payloadEnvelopes, }; } @@ -291,16 +315,23 @@ export async function validateResponses({ blocksRequest, blobsRequest, columnsRequest, + envelopesRequest, blocks, blobSidecars, columnSidecars, + payloadEnvelopes, peerDasMetrics, }: DownloadByRangeRequests & DownloadByRangeResponses & { config: ChainForkConfig; batchBlocks?: IBlockInput[]; peerDasMetrics?: BeaconMetrics["peerDas"] | null; - }): Promise> { + }): Promise< + WarnResult< + {responses: ValidatedResponses; payloadEnvelopes: Map | null}, + DownloadByRangeError + > +> { // Blocks are always required for blob/column validation // If a blocksRequest is provided, blocks have just been downloaded // If no blocksRequest is provided, batchBlocks must have been provided from cache @@ -326,8 +357,21 @@ export async function validateResponses({ } const dataRequest = blobsRequest ?? columnsRequest; + if (!dataRequest && !envelopesRequest) { + return {result: {responses: validatedResponses, payloadEnvelopes: null}, warnings}; + } + if (!dataRequest) { - return {result: validatedResponses, warnings}; + // Only envelope validation needed + let validatedPayloadEnvelopes: Map | null = null; + if (envelopesRequest) { + validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( + validatedResponses.validatedBlocks ?? [], + batchBlocks, + payloadEnvelopes ?? [] + ); + } + return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } const blocksForDataValidation = getBlocksForDataValidation( @@ -385,7 +429,17 @@ export async function validateResponses({ warnings = validatedColumnSidecarsResult.warnings; } - return {result: validatedResponses, warnings}; + // Validate envelopes if an envelopes request was made + let validatedPayloadEnvelopes: Map | null = null; + if (envelopesRequest) { + validatedPayloadEnvelopes = validateEnvelopesByRangeResponse( + validatedResponses.validatedBlocks ?? [], + batchBlocks, + payloadEnvelopes ?? [] + ); + } + + return {result: {responses: validatedResponses, payloadEnvelopes: validatedPayloadEnvelopes}, warnings}; } /** @@ -615,19 +669,19 @@ export async function validateColumnsByRangeResponse( config: ChainForkConfig, request: fulu.DataColumnSidecarsByRangeRequest, blocks: ValidatedBlock[], - columnSidecars: fulu.DataColumnSidecar[], + columnSidecars: DataColumnSidecar[], peerDasMetrics?: BeaconMetrics["peerDas"] | null ): Promise> { const warnings: DownloadByRangeError[] = []; - // TODO GLOAS: Extend by range column sync to support gloas.DataColumnSidecar and - // validate against the block bid commitments instead of the fulu signed header shape - const seenColumns = new Map>(); + const seenColumns = new Map>(); let currentSlot = -1; let currentIndex = -1; // Check for duplicates and order for (const columnSidecar of columnSidecars) { - const slot = columnSidecar.signedBlockHeader.message.slot; + const slot = isGloasDataColumnSidecar(columnSidecar) + ? columnSidecar.slot + : columnSidecar.signedBlockHeader.message.slot; let seenSlotColumns = seenColumns.get(slot); if (!seenSlotColumns) { seenSlotColumns = new Map(); @@ -686,20 +740,20 @@ export async function validateColumnsByRangeResponse( const slot = block.message.slot; const rootHex = toRootHex(blockRoot); const forkName = config.getForkName(slot); - const columnSidecarsMap: Map = seenColumns.get(slot) ?? new Map(); + const columnSidecarsMap: Map = seenColumns.get(slot) ?? new Map(); const columnSidecars = Array.from(columnSidecarsMap.values()).sort((a, b) => a.index - b.index); let blobCount: number; if (!isForkPostFulu(forkName)) { - const dataSlot = columnSidecars.at(0)?.signedBlockHeader.message.slot; throw new DownloadByRangeError({ code: DownloadByRangeErrorCode.MISMATCH_BLOCK_FORK, slot, blockFork: forkName, - dataFork: dataSlot ? config.getForkName(dataSlot) : "unknown", + dataFork: "unknown", }); } - blobCount = getBlobKzgCommitments(forkName, block as SignedBeaconBlock).length; + const kzgCommitments = getBlobKzgCommitments(forkName, block as SignedBeaconBlock); + blobCount = kzgCommitments.length; if (columnSidecars.length === 0) { if (!blobCount) { @@ -768,15 +822,25 @@ export async function validateColumnsByRangeResponse( ); } + const validatePromise = isForkPostGloas(forkName) + ? validateGloasBlockDataColumnSidecars( + slot, + blockRoot, + kzgCommitments, + columnSidecars as gloas.DataColumnSidecar[], + peerDasMetrics + ) + : validateFuluBlockDataColumnSidecars( + null, // do not pass chain here so we do not validate header signature + slot, + blockRoot, + blobCount, + columnSidecars as fulu.DataColumnSidecar[], + peerDasMetrics + ); + validationPromises.push( - validateFuluBlockDataColumnSidecars( - null, // do not pass chain here so we do not validate header signature - slot, - blockRoot, - blobCount, - columnSidecars, - peerDasMetrics - ).then(() => ({ + validatePromise.then(() => ({ blockRoot, columnSidecars, })) @@ -882,6 +946,9 @@ export enum DownloadByRangeErrorCode { /** Cached block input type mismatches new data */ MISMATCH_BLOCK_FORK = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_FORK", MISMATCH_BLOCK_INPUT_TYPE = "DOWNLOAD_BY_RANGE_ERROR_MISMATCH_BLOCK_INPUT_TYPE", + + /** Envelope beaconBlockRoot does not match the block's root */ + INVALID_ENVELOPE_BEACON_BLOCK_ROOT = "DOWNLOAD_BY_RANGE_ERROR_INVALID_ENVELOPE_BEACON_BLOCK_ROOT", } export type DownloadByRangeErrorType = @@ -973,6 +1040,61 @@ export type DownloadByRangeErrorType = blockRoot: string; expected: DAType; actual: DAType; + } + | { + code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT; + slot: Slot; + expected: string; + actual: string; }; export class DownloadByRangeError extends LodestarError {} + +/** + * Validates SignedExecutionPayloadEnvelopes received for a range request. + * For each envelope whose slot appears in the downloaded blocks, verifies that + * envelope.message.beaconBlockRoot matches the corresponding block's root. + * Envelopes for slots not in the batch (orphaned payloads) are silently ignored. + */ +export function validateEnvelopesByRangeResponse( + validatedBlocks: ValidatedBlock[], + batchBlocks: IBlockInput[] | undefined, + payloadEnvelopes: gloas.SignedExecutionPayloadEnvelope[] +): Map { + // Build a map of slot -> blockRoot for all blocks in the batch + const batchBlockRoots = new Map(); + if (batchBlocks) { + for (const blockInput of batchBlocks) { + batchBlockRoots.set(blockInput.slot, fromHex(blockInput.blockRootHex)); + } + } + for (const {block, blockRoot} of validatedBlocks) { + batchBlockRoots.set(block.message.slot, blockRoot); + } + + const payloadEnvelopeMap = new Map(); + + for (const payloadEnvelope of payloadEnvelopes) { + const slot = payloadEnvelope.message.payload.slotNumber; + const batchBlockRoot = batchBlockRoots.get(slot); + + // Envelopes for slots not in the batch are silently ignored (orphaned payloads) + if (batchBlockRoot === undefined) { + continue; + } + + // Verify beaconBlockRoot matches the block's root + if (!byteArrayEquals(payloadEnvelope.message.beaconBlockRoot, batchBlockRoot)) { + throw new DownloadByRangeError({ + code: DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT, + slot, + expected: toRootHex(batchBlockRoot), + actual: toRootHex(payloadEnvelope.message.beaconBlockRoot), + }); + } + + payloadEnvelopeMap.set(slot, payloadEnvelope); + } + + return payloadEnvelopeMap; +} diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 725d2fa6954c..fa9bf569577a 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -560,8 +560,8 @@ export function getBeaconBlockRootFromDataColumnSidecarSerialized(data: Uint8Arr * ├─ 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) + * └─ variable: payload data (starts at envelope + 48) + * └─ ExecutionPayload fixed portion includes slotNumber at offset 532 */ const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET = 4; const SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE = 96; @@ -576,8 +576,26 @@ const BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE; // 116 +// Envelope fixed portion (without slot): payload_offset(4) + requests_offset(4) + builderIndex(8) + beaconBlockRoot(32) = 48 +const EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE = + EXECUTION_PAYLOAD_ENVELOPE_PAYLOAD_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_REQUESTS_OFFSET + + EXECUTION_PAYLOAD_ENVELOPE_BUILDER_INDEX_SIZE + + ROOT_SIZE; // 48 + +// slotNumber offset within ExecutionPayload fixed portion: +// parentHash(32) + feeRecipient(20) + stateRoot(32) + receiptsRoot(32) + logsBloom(256) + +// prevRandao(32) + blockNumber(8) + gasLimit(8) + gasUsed(8) + timestamp(8) + +// extraData_offset(4) + baseFeePerGas(32) + blockHash(32) + transactions_offset(4) + +// withdrawals_offset(4) + blobGasUsed(8) + excessBlobGas(8) + blockAccessList_offset(4) = 532 +const SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD = 532; + +// Payload data starts right after the envelope's fixed portion +const ENVELOPE_START_IN_SIGNED = + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MESSAGE_OFFSET + SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SIGNATURE_SIZE; // 100 + const SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE = - BEACON_BLOCK_ROOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + ROOT_SIZE; // 148 + ENVELOPE_START_IN_SIGNED + EXECUTION_PAYLOAD_ENVELOPE_FIXED_SIZE + SLOT_NUMBER_OFFSET_IN_EXECUTION_PAYLOAD; // 100 + 48 + 532 = 680 export function getSlotFromExecutionPayloadEnvelopeSerialized(data: Uint8Array): Slot | null { if (data.length < SLOT_OFFSET_IN_SIGNED_EXECUTION_PAYLOAD_ENVELOPE + SLOT_SIZE) { diff --git a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts index 0793b05f069b..fa6c9c291a94 100644 --- a/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts +++ b/packages/beacon-node/test/perf/chain/verifyImportBlocks.test.ts @@ -126,7 +126,7 @@ describe.skip("verify+import blocks - range sync perf test", () => { }); }); - await chain.processChainSegment(blocksImport, { + await chain.processChainSegment(blocksImport, null, { // Only skip importing attestations for finalized sync. For head sync attestation are valuable. // Importing attestations also triggers a head update, see https://github.com/ChainSafe/lodestar/issues/3804 // TODO: Review if this is okay, can we prevent some attacks by importing attestations? diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index 4795a3979714..a03dab18d814 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -20,6 +20,7 @@ import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, BeaconStateView, + IBeaconStateViewGloas, createCachedBeaconState, createPubkeyCache, isExecutionStateType, @@ -47,8 +48,13 @@ import { BlockInputPreData, BlockInputSource, } from "../../../src/chain/blocks/blockInput/index.js"; +import { + verifyExecutionPayloadEnvelope, + verifyExecutionPayloadEnvelopeSignature, +} from "../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js"; import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; +import {RegenCaller} from "../../../src/chain/regen/index.js"; import {defaultChainOptions} from "../../../src/chain/options.js"; import {validateFuluBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; @@ -381,7 +387,32 @@ const forkChoiceTest = const beaconBlockRoot = toHex(envelope.message.beaconBlockRoot); const blockHash = toHex(envelope.message.payload.blockHash); const blockNumber = envelope.message.payload.blockNumber; - const stateRoot = toHex(envelope.message.stateRoot); + + // Verify envelope against the post-block state (spec: verify_execution_payload_envelope) + const protoBlock = (chain.forkChoice as ForkChoice).getBlockHexDefaultStatus(beaconBlockRoot); + if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); + const envelopeState = await chain.regen.getBlockSlotState( + protoBlock, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.restApi + ); + verifyExecutionPayloadEnvelope( + beaconConfig, + envelopeState as IBeaconStateViewGloas, + envelope.message + ); + + // Verify signature + const sigValid = await verifyExecutionPayloadEnvelopeSignature( + beaconConfig, + envelopeState as IBeaconStateViewGloas, + pubkeyCache, + envelope, + envelopeState.latestBlockHeader.proposerIndex, + chain.bls + ); + if (!sigValid) throw Error("Invalid execution payload envelope signature"); // Add predefined VALID status for the payload's block hash so the EL mock accepts it executionEngineBackend.addPredefinedPayloadStatus(blockHash, { @@ -394,7 +425,6 @@ const forkChoiceTest = beaconBlockRoot, blockHash, blockNumber, - stateRoot, ExecutionStatus.Valid ); if (!isValid) throw Error("Expect error since this is a negative test"); @@ -588,17 +618,9 @@ const forkChoiceTest = name.includes("voting_source_beyond_two_epoch") || name.includes("justified_update_always_if_better") || name.includes("justified_update_not_realized_finality") || - // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 - (name.includes("gloas") && - (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || - name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))) || - // TODO GLOAS: These two tests are affected by the wrong proposer boost cutoff time from the - // consensus-specs and thus have wrong expectation of proposer boost. Our implementation - // should pass these two tests after https://github.com/ethereum/consensus-specs/pull/5095 - // is included the spec release. - (name.includes("gloas/fork_choice/on_block") && - (name.endsWith("proposer_boost") || name.endsWith("proposer_boost_is_first_block"))), + // Spec test fixture bug in v1.7.0-alpha.5: wrong_withdrawals envelope SSZ data is + // byte-for-byte identical to the valid envelope, making it impossible to reject + name.endsWith("on_execution_payload_envelope__wrong_withdrawals"), }, }; }; diff --git a/packages/beacon-node/test/spec/presets/operations.test.ts b/packages/beacon-node/test/spec/presets/operations.test.ts index 48fd66e28c50..4f97fd26d79f 100644 --- a/packages/beacon-node/test/spec/presets/operations.test.ts +++ b/packages/beacon-node/test/spec/presets/operations.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import {getConfig} from "@lodestar/config/test-utils"; -import {ACTIVE_PRESET, ForkName, ForkSeq} from "@lodestar/params"; +import {ACTIVE_PRESET, ForkName} from "@lodestar/params"; import {InputType} from "@lodestar/spec-test-util"; import { BeaconStateAllForks, @@ -10,6 +10,7 @@ import { CachedBeaconStateElectra, CachedBeaconStateGloas, ExecutionPayloadStatus, + processSlots, getBlockRootAtSlot, } from "@lodestar/state-transition"; import * as blockFns from "@lodestar/state-transition/block"; @@ -71,18 +72,11 @@ const operationFns: Record> = execution_payload: ( state, testCase: { - body: bellatrix.BeaconBlockBody | gloas.BeaconBlockBody; - signed_envelope: gloas.SignedExecutionPayloadEnvelope; + body: bellatrix.BeaconBlockBody; execution: {execution_valid: boolean}; } - ): CachedBeaconStateAllForks | void => { + ) => { const fork = state.config.getForkSeq(state.slot); - if (fork >= ForkSeq.gloas) { - 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 ? ExecutionPayloadStatus.valid @@ -117,6 +111,13 @@ const operationFns: Record> = blockFns.processExecutionPayloadBid(state as CachedBeaconStateGloas, testCase.block); }, + parent_execution_payload: (state, testCase: {block: gloas.BeaconBlock}): CachedBeaconStateAllForks => { + // Spec test calls process_slots then process_parent_execution_payload + const postState = processSlots(state, testCase.block.slot); + blockFns.processParentExecutionPayload(postState as CachedBeaconStateGloas, testCase.block); + return postState; + }, + payload_attestation: (state, testCase: {payload_attestation: gloas.PayloadAttestation}) => { blockFns.processPayloadAttestation(state as CachedBeaconStateGloas, testCase.payload_attestation); }, @@ -144,7 +145,6 @@ const operations: TestRunnerFn = (fork, const cachedState = createCachedBeaconStateTest(state, getConfig(fork, epoch)); const postState = operationFn(cachedState, testcase); - // processExecutionPayloadEnvelope returns the postState, other operations mutate the state in-place and return void if (postState !== undefined) { postState.commit(); return postState; @@ -178,7 +178,6 @@ const operations: TestRunnerFn = (fork, deposit_request: ssz.electra.DepositRequest, consolidation_request: ssz.electra.ConsolidationRequest, payload_attestation: ssz.gloas.PayloadAttestation, - signed_envelope: ssz.gloas.SignedExecutionPayloadEnvelope, }, shouldError: (testCase) => testCase.post === undefined, getExpected: (testCase) => testCase.post, diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index 1616cdf5040b..ce6b66f452db 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -87,7 +87,7 @@ export const defaultSkipOpts: SkipOpts = { /^gloas\/fork_choice\/on_execution_payload\/.*$/, ], skippedTests: [], - skippedRunners: [], + skippedRunners: ["fast_confirmation"], }; /** diff --git a/packages/beacon-node/test/unit/sync/range/batch.test.ts b/packages/beacon-node/test/unit/sync/range/batch.test.ts index 089f0b68bec0..86af12348b01 100644 --- a/packages/beacon-node/test/unit/sync/range/batch.test.ts +++ b/packages/beacon-node/test/unit/sync/range/batch.test.ts @@ -285,16 +285,20 @@ describe("sync / range / batch", async () => { // retry download: AwaitingDownload -> Downloading // downloadingSuccess: Downloading -> AwaitingProcessing batch.startDownloading(peer); - batch.downloadingSuccess(peer, [ - BlockInputPreData.createFromBlock({ - block: ssz.capella.SignedBeaconBlock.defaultValue(), - blockRootHex: "0x1234", - source: BlockInputSource.byRoot, - seenTimestampSec: Date.now() / 1000, - forkName: ForkName.capella, - daOutOfRange: false, - }), - ]); + batch.downloadingSuccess( + peer, + [ + BlockInputPreData.createFromBlock({ + block: ssz.capella.SignedBeaconBlock.defaultValue(), + blockRootHex: "0x1234", + source: BlockInputSource.byRoot, + seenTimestampSec: Date.now() / 1000, + forkName: ForkName.capella, + daOutOfRange: false, + }), + ], + null + ); expect(batch.state.status).toBe(BatchStatus.AwaitingProcessing); // startProcessing: AwaitingProcessing -> Processing @@ -334,7 +338,7 @@ describe("sync / range / batch", async () => { const batch = new Batch(startEpoch, config, clock, custodyConfig); expectThrowsLodestarError( - () => batch.downloadingSuccess(peer, []), + () => batch.downloadingSuccess(peer, [], null), new BatchError({ code: BatchErrorCode.WRONG_STATUS, startEpoch, diff --git a/packages/beacon-node/test/unit/sync/range/chain.test.ts b/packages/beacon-node/test/unit/sync/range/chain.test.ts index 246db737b897..394ac13e1f88 100644 --- a/packages/beacon-node/test/unit/sync/range/chain.test.ts +++ b/packages/beacon-node/test/unit/sync/range/chain.test.ts @@ -117,7 +117,7 @@ describe("sync / range / chain", () => { }) ); } - return {result: blocks, warnings: null}; + return {result: {blocks, payloadEnvelopes: null}, warnings: null}; }; const target: ChainTarget = {slot: computeStartSlotAtEpoch(targetEpoch), root: ZERO_HASH}; @@ -172,7 +172,7 @@ describe("sync / range / chain", () => { }) ); } - return {result: blocks, warnings: null}; + return {result: {blocks, payloadEnvelopes: null}, warnings: null}; }; const target: ChainTarget = {slot: computeStartSlotAtEpoch(targetEpoch), root: ZERO_HASH}; @@ -218,9 +218,9 @@ describe("sync / range / chain", () => { function logSyncChainFns(logger: Logger, fns: SyncChainFns): SyncChainFns { return { - processChainSegment(blocks, syncType) { + processChainSegment(blocks, payloadEnvelopes, syncType) { logger.debug("mock processChainSegment", {blocks: blocks.map((b) => b.slot).join(",")}); - return fns.processChainSegment(blocks, syncType); + return fns.processChainSegment(blocks, payloadEnvelopes, syncType); }, downloadByRange(peer, request, syncType) { logger.debug("mock downloadBeaconBlocksByRange", request.state.status); diff --git a/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts b/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts index a33b255b1fa3..d3f5b849d8f6 100644 --- a/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts +++ b/packages/beacon-node/test/unit/sync/range/utils/batches.test.ts @@ -229,7 +229,7 @@ describe("sync / range / batches", () => { batch.startDownloading(peer); if (status === BatchStatus.Downloading) return batch; - batch.downloadingSuccess(peer, []); + batch.downloadingSuccess(peer, [], null); if (status === BatchStatus.AwaitingProcessing) return batch; batch.startProcessing(); diff --git a/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts b/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts index bcac6004b288..74bf280aebb0 100644 --- a/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts +++ b/packages/beacon-node/test/unit/sync/range/utils/peerBalancer.test.ts @@ -186,7 +186,7 @@ describe("sync / range / peerBalancer", () => { sampledColumns: [0, 1, 2, 3], }); console.log(blockInput.hasAllData()); - const x = batch0.downloadingSuccess(peer1.peerId, [blockInput]); + const x = batch0.downloadingSuccess(peer1.peerId, [blockInput], null); console.log("x", x); // peer2 and peer3 are the same but peer3 has a lower target slot than the previous download diff --git a/packages/beacon-node/test/unit/util/sszBytes.test.ts b/packages/beacon-node/test/unit/util/sszBytes.test.ts index b58562abd8cd..2fa572d9eca3 100644 --- a/packages/beacon-node/test/unit/util/sszBytes.test.ts +++ b/packages/beacon-node/test/unit/util/sszBytes.test.ts @@ -571,7 +571,7 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { for (const {slot, blockRoot} of testCases) { it(`slot=${slot}`, () => { const envelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue(); - envelope.message.slot = slot; + envelope.message.payload.slotNumber = slot; envelope.message.beaconBlockRoot = fromHex(blockRoot); const bytes = ssz.gloas.SignedExecutionPayloadEnvelope.serialize(envelope); @@ -581,8 +581,8 @@ describe("SignedExecutionPayloadEnvelope SSZ serialized picking", () => { } it("getSlotFromExecutionPayloadEnvelopeSerialized - invalid data", () => { - // Slot is at offset 148, need at least 156 bytes - const invalidSizes = [0, 50, 100, 155]; + // slotNumber is at offset 676 within the serialized payload, need at least 684 bytes + const invalidSizes = [0, 50, 100, 683]; for (const size of invalidSizes) { expect(getSlotFromExecutionPayloadEnvelopeSerialized(Buffer.alloc(size))).toBeNull(); } diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 49226737b9fe..89f2111879a1 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -516,7 +516,7 @@ export class ForkChoice implements IForkChoice { * starting from the proposerIndex */ let proposerBoost: {root: RootHex; score: number} | null = null; - if (this.opts?.proposerBoost && this.proposerBoostRoot) { + if (this.opts?.proposerBoost && this.proposerBoostRoot && this.shouldApplyProposerBoost()) { const proposerBoostScore = this.justifiedProposerBoostScore ?? getCommitteeFraction(this.fcStore.justified.totalBalance, { @@ -794,6 +794,8 @@ export class ForkChoice implements IForkChoice { targetRoot: toRootHex(targetRoot), stateRoot: toRootHex(block.stateRoot), timeliness: isTimely, + ptcTimeliness: this.isBlockPtcTimely(block, blockDelaySec), + proposerIndex: block.proposerIndex, justifiedEpoch: stateJustifiedEpoch, justifiedRoot: toRootHex(state.currentJustifiedCheckpoint.root), @@ -988,7 +990,6 @@ export class ForkChoice implements IForkChoice { blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void { this.protoArray.onExecutionPayload( @@ -996,7 +997,6 @@ export class ForkChoice implements IForkChoice { this.fcStore.currentSlot, executionPayloadBlockHash, executionPayloadNumber, - executionPayloadStateRoot, this.proposerBoostRoot, executionStatus ); @@ -1444,6 +1444,66 @@ export class ForkChoice implements IForkChoice { return this.fcStore.currentSlot === block.slot && isBeforeLateBlockCutoff; } + /** + * Check if block arrived before the PTC deadline. + * Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + */ + private isBlockPtcTimely(block: BeaconBlock, blockDelaySec: number): boolean { + const isCurrentSlot = this.fcStore.currentSlot === block.slot; + const ptcThresholdMs = this.config.getSlotComponentDurationMs(this.config.PAYLOAD_ATTESTATION_DUE_BPS); + return isCurrentSlot && blockDelaySec * 1000 < ptcThresholdMs; + } + + /** + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost + * Determines whether proposer boost should apply for gloas blocks. + * Returns true for pre-gloas blocks (unconditional boost). + */ + private shouldApplyProposerBoost(): boolean { + if (!this.proposerBoostRoot) { + return false; + } + + const boostedBlock = this.getBlockHexDefaultStatus(this.proposerBoostRoot); + if (!boostedBlock || !isGloasBlock(boostedBlock)) { + // Pre-gloas blocks always get boost + return true; + } + + const parentBlock = this.getBlockHexDefaultStatus(boostedBlock.parentRoot); + if (!parentBlock) { + return true; + } + + const slot = boostedBlock.slot; + + // Apply proposer boost if parent is not from the previous slot + if (parentBlock.slot + 1 < slot) { + return true; + } + + // Apply proposer boost if parent is not weak + const reorgThreshold = getCommitteeFraction(this.fcStore.justified.totalBalance, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: this.config.REORG_HEAD_WEIGHT_THRESHOLD, + }); + const parentNode = this.protoArray.getNode(parentBlock.blockRoot, PayloadStatus.PENDING); + if (parentNode === undefined || parentNode.weight >= reorgThreshold) { + // Parent is not weak + return true; + } + + // Parent is weak and from the previous slot: apply boost if there are no equivocations + // Look for other PTC-timely blocks at the same slot from the same proposer + const equivocations = this.protoArray.findEquivocatingBlocks( + parentBlock.proposerIndex, + parentBlock.slot, + parentBlock.blockRoot + ); + + return equivocations.length === 0; + } + /** * https://github.com/ethereum/consensus-specs/blob/v1.5.0/specs/phase0/fork-choice.md#is_proposing_on_time */ diff --git a/packages/fork-choice/src/forkChoice/interface.ts b/packages/fork-choice/src/forkChoice/interface.ts index 62bb364d776d..5b1a8f8328f7 100644 --- a/packages/fork-choice/src/forkChoice/interface.ts +++ b/packages/fork-choice/src/forkChoice/interface.ts @@ -198,13 +198,11 @@ export interface IForkChoice { * @param blockRoot - The beacon block root for which the payload arrived * @param executionPayloadBlockHash - The block hash of the execution payload * @param executionPayloadNumber - The block number of the execution payload - * @param executionPayloadStateRoot - The execution payload state root ie. the root of post-state after processExecutionPayloadEnvelope() */ onExecutionPayload( blockRoot: RootHex, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, executionStatus: PayloadExecutionStatus ): void; /** @@ -232,6 +230,12 @@ export interface IForkChoice { */ hasPayloadUnsafe(blockRoot: Root): boolean; hasPayloadHexUnsafe(blockRoot: RootHex): boolean; + /** + * Whether to extend the payload for a given block root. + * Checks PTC timeliness and data availability, with fallback logic. + * Spec: gloas/fork-choice.md#should_extend_payload + */ + shouldExtendPayload(blockRoot: RootHex): boolean; getSlotsPresent(windowStart: number): number; /** * Returns a `ProtoBlock` if the block is known **and** a descendant of the finalized root. diff --git a/packages/fork-choice/src/protoArray/interface.ts b/packages/fork-choice/src/protoArray/interface.ts index cae46fdacd55..6ab1c88f26c7 100644 --- a/packages/fork-choice/src/protoArray/interface.ts +++ b/packages/fork-choice/src/protoArray/interface.ts @@ -135,6 +135,13 @@ export type ProtoBlock = BlockExtraMeta & { // Indicate whether block arrives in a timely manner ie. before the 4 second mark timeliness: boolean; + // Indicate whether block arrives before the PTC deadline + // Spec: gloas/fork-choice.md#record_block_timeliness (block_timeliness[PTC_TIMELINESS_INDEX]) + ptcTimeliness: boolean; + + // The index of the block proposer + proposerIndex: number; + /** Payload status for this node (Gloas fork). Always FULL in pre-gloas */ payloadStatus: PayloadStatus; diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 4d239d52e8db..f9ce79e700e7 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -111,26 +111,9 @@ export class ProtoArray { // Anchor block PTC votes must be all-true per spec get_forkchoice_store: // payload_timeliness_vote={anchor_root: Vector[boolean, PTC_SIZE](True for _ in range(PTC_SIZE))} - // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.4/specs/gloas/fork-choice.md#modified-get_forkchoice_store + // Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.5/specs/gloas/fork-choice.md#modified-get_forkchoice_store if (protoArray.ptcVotes.has(block.blockRoot)) { protoArray.ptcVotes.set(block.blockRoot, BitArray.fromBoolArray(Array.from({length: PTC_SIZE}, () => true))); - - // In the spec, we have payload_states = {anchor_root: anchor_state.copy()} - // which means the anchor's "payload" is considered received - // Without FULL, blocks extending FULL from the anchor would be orphaned. - // TODO GLOAS: This is a bug in the spec. Keep this to pass the current spec test - // for now. Need to remove this when we work on v1.7.0-alpha.5 - if (block.executionPayloadBlockHash !== null) { - protoArray.onExecutionPayload( - block.blockRoot, - currentSlot, - block.executionPayloadBlockHash, - (block as {executionPayloadNumber?: number}).executionPayloadNumber ?? 0, - block.stateRoot, - null, - ExecutionStatus.Valid - ); - } } return protoArray; @@ -572,7 +555,6 @@ export class ProtoArray { currentSlot: Slot, executionPayloadBlockHash: RootHex, executionPayloadNumber: number, - executionPayloadStateRoot: RootHex, proposerBoostRoot: RootHex | null, executionStatus: PayloadExecutionStatus ): void { @@ -617,6 +599,8 @@ export class ProtoArray { } // Create FULL variant as a child of PENDING (sibling to EMPTY) + // With deferred payload processing (consensus-specs#5094), FULL shares the same + // stateRoot as PENDING since envelope no longer produces a separate post-state const fullNode: ProtoNode = { ...pendingNode, parent: pendingIndex, // Points to own PENDING (same as EMPTY) @@ -628,7 +612,6 @@ export class ProtoArray { executionStatus, executionPayloadBlockHash, executionPayloadNumber, - stateRoot: executionPayloadStateRoot, }; const fullIndex = this.nodes.length; @@ -1872,6 +1855,30 @@ export class ProtoArray { return node; } + /** + * Find blocks at the given slot from the same proposer that are PTC-timely, + * excluding the given block root. + * Spec: gloas/fork-choice.md#new-should_apply_proposer_boost (equivocations check) + */ + findEquivocatingBlocks(proposerIndex: number, slot: Slot, excludeRoot: RootHex): ProtoNode[] { + const result: ProtoNode[] = []; + for (const [root, variantOrArr] of this.indices.entries()) { + if (root === excludeRoot) continue; + const nodeIndex = Array.isArray(variantOrArr) ? variantOrArr[0] : variantOrArr; + if (nodeIndex === undefined) continue; + const node = this.nodes[nodeIndex]; + if ( + node !== undefined && + node.slot === slot && + node.proposerIndex === proposerIndex && + node.ptcTimeliness + ) { + result.push(node); + } + } + return result; + } + private getNodesBetween(upperIndex: number, lowerIndex: number): ProtoNode[] { const result = []; for (let index = upperIndex - 1; index > lowerIndex; index--) { diff --git a/packages/state-transition/src/block/index.ts b/packages/state-transition/src/block/index.ts index 2dc24d48bd5b..0822024a04ce 100644 --- a/packages/state-transition/src/block/index.ts +++ b/packages/state-transition/src/block/index.ts @@ -14,8 +14,8 @@ import {processBlockHeader} from "./processBlockHeader.js"; import {processEth1Data} from "./processEth1Data.js"; import {processExecutionPayload} from "./processExecutionPayload.js"; import {processExecutionPayloadBid} from "./processExecutionPayloadBid.js"; -import {processExecutionPayloadEnvelope} from "./processExecutionPayloadEnvelope.js"; import {processOperations} from "./processOperations.js"; +import {processParentExecutionPayload} from "./processParentExecutionPayload.js"; import {processPayloadAttestation} from "./processPayloadAttestation.js"; import {processRandao} from "./processRandao.js"; import {processSyncAggregate} from "./processSyncCommittee.js"; @@ -32,7 +32,7 @@ export { processWithdrawals, processExecutionPayloadBid, processPayloadAttestation, - processExecutionPayloadEnvelope, + processParentExecutionPayload, }; export * from "./externalData.js"; @@ -51,10 +51,16 @@ export function processBlock( ): void { const {verifySignatures = true} = opts ?? {}; + // Process parent execution payload effects first (consensus-specs#5094) + // Must run before processBlockHeader and processExecutionPayloadBid + if (fork >= ForkSeq.gloas) { + processParentExecutionPayload(state as CachedBeaconStateGloas, block as BeaconBlock); + } + processBlockHeader(state, block); if (fork >= ForkSeq.gloas) { - // After gloas, processWithdrawals does not take a payload parameter + // After consensus-specs#5094, processParentExecutionPayload has already handled parent effects processWithdrawals(fork, state as CachedBeaconStateGloas); } else if (fork >= ForkSeq.capella) { const fullOrBlindedPayload = getFullOrBlindedPayload(block); diff --git a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts b/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts deleted file mode 100644 index c7f858a908d0..000000000000 --- a/packages/state-transition/src/block/processExecutionPayloadEnvelope.ts +++ /dev/null @@ -1,175 +0,0 @@ -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 {computeTimeAtSlot} from "../util/index.js"; -import {verifySignatureSet} from "../util/signatureSets.js"; -import {processConsolidationRequest} from "./processConsolidationRequest.js"; -import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; -import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; - -export type ProcessExecutionPayloadEnvelopeOpts = { - verifySignature?: boolean; - verifyStateRoot?: boolean; - dontTransferCache?: boolean; -}; - -// Unlike other block processing functions which mutate state in-place, this function -// clones the state and returns the post-state, similar to stateTransition(). -// This function does not call execution engine to verify payload. Need to call it from other place. -export function processExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - 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 (verifySignature && !verifyExecutionPayloadEnvelopeSignature(state, signedEnvelope)) { - throw Error(`Execution payload envelope has invalid signature builderIndex=${envelope.builderIndex}`); - } - - // .clone() before mutating state, similar to stateTransition() - const postState = state.clone(opts?.dontTransferCache) as CachedBeaconStateGloas; - - validateExecutionPayloadEnvelope(postState, envelope); - - const requests = envelope.executionRequests; - - if (requests.deposits.length > 0) { - // Build cache of pending validator pubkeys once, shared across all deposit requests - const pendingValidatorPubkeys = getPendingValidatorPubkeys(postState.config, postState); - - for (const deposit of requests.deposits) { - processDepositRequest(fork, postState, deposit, pendingValidatorPubkeys); - } - } - - for (const withdrawal of requests.withdrawals) { - processWithdrawalRequest(fork, postState, withdrawal); - } - - for (const consolidation of requests.consolidations) { - processConsolidationRequest(postState, consolidation); - } - - // Queue the builder payment - const paymentIndex = SLOTS_PER_EPOCH + (postState.slot % SLOTS_PER_EPOCH); - const payment = postState.builderPendingPayments.get(paymentIndex).clone(); - const amount = payment.withdrawal.amount; - - if (amount > 0) { - postState.builderPendingWithdrawals.push(payment.withdrawal); - } - - postState.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); - - // Cache the execution payload hash - postState.executionPayloadAvailability.set(postState.slot % SLOTS_PER_HISTORICAL_ROOT, true); - postState.latestBlockHash = payload.blockHash; - - postState.commit(); - - 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())}` - ); - } - - return postState; -} - -function validateExecutionPayloadEnvelope( - state: CachedBeaconStateGloas, - envelope: gloas.ExecutionPayloadEnvelope -): void { - const payload = envelope.payload; - - // Cache latest block header state root - if (byteArrayEquals(state.latestBlockHeader.stateRoot, ssz.Root.defaultValue())) { - const previousStateRoot = state.hashTreeRoot(); - state.latestBlockHeader.stateRoot = previousStateRoot; - } - - // Verify consistency with the beacon block - if (!byteArrayEquals(envelope.beaconBlockRoot, state.latestBlockHeader.hashTreeRoot())) { - throw new Error( - `Envelope's block is not the latest block header envelope=${toRootHex(envelope.beaconBlockRoot)} latestBlockHeader=${toRootHex(state.latestBlockHeader.hashTreeRoot())}` - ); - } - - if (envelope.slot !== state.slot) { - throw new Error(`Slot mismatch between envelope and state envelope=${envelope.slot} state=${state.slot}`); - } - - // Verify consistency with the committed bid - const committedBid = state.latestExecutionPayloadBid; - if (envelope.builderIndex !== committedBid.builderIndex) { - throw new Error( - `Builder index mismatch between envelope and committed bid envelope=${envelope.builderIndex} committedBid=${committedBid.builderIndex}` - ); - } - - if (!byteArrayEquals(committedBid.prevRandao, payload.prevRandao)) { - throw new Error( - `Prev randao mismatch between committed bid and payload committedBid=${toHex(committedBid.prevRandao)} payload=${toHex(payload.prevRandao)}` - ); - } - - // Verify consistency with expected withdrawals - const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals); - const expectedWithdrawalsRoot = state.payloadExpectedWithdrawals.hashTreeRoot(); - if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) { - throw new Error( - `Withdrawals mismatch between payload and expected withdrawals payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}` - ); - } - - // Verify the gas_limit - if (Number(committedBid.gasLimit) !== payload.gasLimit) { - throw new Error( - `Gas limit mismatch between envelope's payload and committed bid envelope=${payload.gasLimit} committedBid=${Number(committedBid.gasLimit)}` - ); - } - - // Verify the block hash - if (!byteArrayEquals(committedBid.blockHash, payload.blockHash)) { - throw new Error( - `Block hash mismatch between envelope's payload and committed bid envelope=${toRootHex(payload.blockHash)} committedBid=${toRootHex(committedBid.blockHash)}` - ); - } - - // Verify consistency of the parent hash with respect to the previous execution payload - if (!byteArrayEquals(payload.parentHash, state.latestBlockHash)) { - throw new Error( - `Parent hash mismatch between envelope's payload and state envelope=${toRootHex(payload.parentHash)} state=${toRootHex(state.latestBlockHash)}` - ); - } - - // Verify timestamp - if (payload.timestamp !== computeTimeAtSlot(state.config, state.slot, state.genesisTime)) { - throw new Error( - `Timestamp mismatch between envelope's payload and state envelope=${payload.timestamp} state=${computeTimeAtSlot(state.config, state.slot, state.genesisTime)}` - ); - } - - // Skipped: Verify the execution payload is valid -} - -function verifyExecutionPayloadEnvelopeSignature( - state: CachedBeaconStateGloas, - signedEnvelope: gloas.SignedExecutionPayloadEnvelope -): boolean { - 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/block/processParentExecutionPayload.ts b/packages/state-transition/src/block/processParentExecutionPayload.ts new file mode 100644 index 000000000000..b639dca10b3d --- /dev/null +++ b/packages/state-transition/src/block/processParentExecutionPayload.ts @@ -0,0 +1,114 @@ +import {ForkPostGloas, SLOTS_PER_EPOCH, SLOTS_PER_HISTORICAL_ROOT} from "@lodestar/params"; +import {BeaconBlock, electra, ssz} from "@lodestar/types"; +import {byteArrayEquals, toRootHex} from "@lodestar/utils"; +import {CachedBeaconStateGloas} from "../types.js"; +import {computeEpochAtSlot} from "../util/epoch.js"; +import {processConsolidationRequest} from "./processConsolidationRequest.js"; +import {getPendingValidatorPubkeys, processDepositRequest} from "./processDepositRequest.js"; +import {processWithdrawalRequest} from "./processWithdrawalRequest.js"; + +/** + * Process parent execution payload effects as first step of processBlock. + * + * Spec: consensus-specs#5094 + * https://github.com/ethereum/consensus-specs/blob/26ed32e/specs/gloas/beacon-chain.md + */ +export function processParentExecutionPayload( + state: CachedBeaconStateGloas, + block: BeaconBlock +): void { + const bid = block.body.signedExecutionPayloadBid.message; + const parentBid = state.latestExecutionPayloadBid; + const requests = block.body.parentExecutionRequests; + + // True if this block built on the parent's full payload + const isParentFull = byteArrayEquals(bid.parentBlockHash, parentBid.blockHash); + + if (!isParentFull) { + // Parent was EMPTY -- no execution requests expected + assertEmptyExecutionRequests(requests); + return; + } + + // Parent was FULL -- verify the bid commitment and apply the payload + const requestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot(requests); + if (!byteArrayEquals(requestsRoot, parentBid.executionRequestsRoot)) { + throw new Error( + `Parent execution requests root mismatch actual=${toRootHex(requestsRoot)} expected=${toRootHex(parentBid.executionRequestsRoot)}` + ); + } + + applyParentExecutionPayload(state, parentBid, requests); +} + +/** + * Apply parent execution payload effects to state. + * + * Spec: apply_parent_execution_payload + */ +/** + * Settle a builder payment at the given index. + * Spec: settle_builder_payment + */ +function settleBuilderPayment(state: CachedBeaconStateGloas, paymentIndex: number): void { + const payment = state.builderPendingPayments.get(paymentIndex).clone(); + if (payment.withdrawal.amount > 0) { + state.builderPendingWithdrawals.push(payment.withdrawal); + } + state.builderPendingPayments.set(paymentIndex, ssz.gloas.BuilderPendingPayment.defaultViewDU()); +} + +export function applyParentExecutionPayload( + state: CachedBeaconStateGloas, + parentBid: {slot: number; blockHash: Uint8Array; builderIndex: number; value: number; feeRecipient: Uint8Array}, + requests: electra.ExecutionRequests +): void { + const fork = state.config.getForkSeq(state.slot); + const parentSlot = parentBid.slot; + const parentEpoch = computeEpochAtSlot(parentSlot); + const currentEpoch = computeEpochAtSlot(state.slot); + + // Process execution requests from parent's payload + // Execution requests are processed at state.slot (child's slot), not parent's slot + if (requests.deposits.length > 0) { + const pendingValidatorPubkeys = getPendingValidatorPubkeys(state.config, state); + for (const deposit of requests.deposits) { + processDepositRequest(fork, state, deposit, pendingValidatorPubkeys); + } + } + + for (const withdrawal of requests.withdrawals) { + processWithdrawalRequest(fork, state, withdrawal); + } + + for (const consolidation of requests.consolidations) { + processConsolidationRequest(state, consolidation); + } + + // Settle the builder payment + if (parentEpoch === currentEpoch) { + settleBuilderPayment(state, SLOTS_PER_EPOCH + (parentSlot % SLOTS_PER_EPOCH)); + } else if (parentEpoch === currentEpoch - 1) { + settleBuilderPayment(state, parentSlot % SLOTS_PER_EPOCH); + } else if (parentBid.value > 0) { + // Parent is older than previous epoch — payment entry already settled/evicted. + // Directly append the withdrawal to ensure the builder gets paid. + state.builderPendingWithdrawals.push( + ssz.gloas.BuilderPendingWithdrawal.toViewDU({ + feeRecipient: parentBid.feeRecipient, + amount: parentBid.value, + builderIndex: parentBid.builderIndex, + }) + ); + } + + // Update parent payload availability and latest block hash + state.executionPayloadAvailability.set(parentSlot % SLOTS_PER_HISTORICAL_ROOT, true); + state.latestBlockHash = parentBid.blockHash; +} + +function assertEmptyExecutionRequests(requests: electra.ExecutionRequests): void { + if (requests.deposits.length !== 0 || requests.withdrawals.length !== 0 || requests.consolidations.length !== 0) { + throw new Error("Parent execution requests must be empty when parent block is EMPTY"); + } +} diff --git a/packages/state-transition/src/block/processWithdrawals.ts b/packages/state-transition/src/block/processWithdrawals.ts index e2b54f5e183a..e57041d1a1c4 100644 --- a/packages/state-transition/src/block/processWithdrawals.ts +++ b/packages/state-transition/src/block/processWithdrawals.ts @@ -10,6 +10,7 @@ import { } from "@lodestar/params"; import {BuilderIndex, ValidatorIndex, capella, ssz} from "@lodestar/types"; import {byteArrayEquals, toRootHex} from "@lodestar/utils"; +import {ZERO_HASH} from "../constants/index.js"; import {CachedBeaconStateCapella, CachedBeaconStateElectra, CachedBeaconStateGloas} from "../types.js"; import { convertBuilderIndexToValidatorIndex, @@ -31,9 +32,14 @@ export function processWithdrawals( state: CachedBeaconStateCapella | CachedBeaconStateElectra | CachedBeaconStateGloas, payload?: capella.FullOrBlindedExecutionPayload ): void { - // Return early if the parent block is empty - if (fork >= ForkSeq.gloas && !isParentBlockFull(state as CachedBeaconStateGloas)) { - return; + // Return early if this is genesis block or the parent block is empty. + if (fork >= ForkSeq.gloas) { + const stateGloas = state as CachedBeaconStateGloas; + const isGenesisBlock = byteArrayEquals(stateGloas.latestBlockHash, ZERO_HASH); + const isParentBlockEmpty = !isParentBlockFull(stateGloas); + if (isGenesisBlock || isParentBlockEmpty) { + return; + } } // processedBuilderWithdrawalsCount is withdrawals coming from builder payment since gloas (EIP-7732) @@ -48,7 +54,7 @@ export function processWithdrawals( } = getExpectedWithdrawals(fork, state); const numWithdrawals = expectedWithdrawals.length; - // After gloas, withdrawals are verified later in processExecutionPayloadEnvelope + // After gloas, withdrawals are verified later in verifyExecutionPayloadEnvelope if (fork < ForkSeq.gloas) { if (payload === undefined) { throw Error("payload is required for pre-gloas processWithdrawals"); diff --git a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts index 806a18ff69f6..42855cf01c87 100644 --- a/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts +++ b/packages/state-transition/src/signatureSets/executionPayloadEnvelope.ts @@ -11,7 +11,7 @@ export function getExecutionPayloadEnvelopeSigningRoot( config: BeaconConfig, envelope: gloas.ExecutionPayloadEnvelope ): Uint8Array { - const domain = config.getDomain(envelope.slot, DOMAIN_BEACON_BUILDER); + const domain = config.getDomain(envelope.payload.slotNumber, DOMAIN_BEACON_BUILDER); return computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); } diff --git a/packages/state-transition/src/slot/upgradeStateToGloas.ts b/packages/state-transition/src/slot/upgradeStateToGloas.ts index a269dba199c1..c116bbf109af 100644 --- a/packages/state-transition/src/slot/upgradeStateToGloas.ts +++ b/packages/state-transition/src/slot/upgradeStateToGloas.ts @@ -48,6 +48,9 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.currentSyncCommittee = stateGloasCloned.currentSyncCommittee; stateGloasView.nextSyncCommittee = stateGloasCloned.nextSyncCommittee; stateGloasView.latestExecutionPayloadBid.blockHash = stateFulu.latestExecutionPayloadHeader.blockHash; + stateGloasView.latestExecutionPayloadBid.executionRequestsRoot = ssz.electra.ExecutionRequests.hashTreeRoot( + ssz.electra.ExecutionRequests.defaultValue() + ); stateGloasView.nextWithdrawalIndex = stateGloasCloned.nextWithdrawalIndex; stateGloasView.nextWithdrawalValidatorIndex = stateGloasCloned.nextWithdrawalValidatorIndex; stateGloasView.historicalSummaries = stateGloasCloned.historicalSummaries; @@ -61,7 +64,9 @@ export function upgradeStateToGloas(stateFulu: CachedBeaconStateFulu): CachedBea stateGloasView.pendingPartialWithdrawals = stateGloasCloned.pendingPartialWithdrawals; stateGloasView.pendingConsolidations = stateGloasCloned.pendingConsolidations; stateGloasView.proposerLookahead = stateGloasCloned.proposerLookahead; - stateGloasView.ptcWindow = ssz.gloas.PtcWindow.toViewDU(initializePtcWindow(stateFulu)); + stateGloasView.ptcWindow = ssz.gloas.PtcWindow.toViewDU( + initializePtcWindow(stateFulu).map((committee) => Array.from(committee)) + ); for (let i = 0; i < SLOTS_PER_HISTORICAL_ROOT; i++) { stateGloasView.executionPayloadAvailability.set(i, true); diff --git a/packages/state-transition/src/stateView/beaconStateView.ts b/packages/state-transition/src/stateView/beaconStateView.ts index 578d223741ff..67c622a0b5f0 100644 --- a/packages/state-transition/src/stateView/beaconStateView.ts +++ b/packages/state-transition/src/stateView/beaconStateView.ts @@ -28,8 +28,7 @@ import { rewards, } from "@lodestar/types"; import {Checkpoint, Fork} from "@lodestar/types/phase0"; -import {processExecutionPayloadEnvelope} from "../block/index.js"; -import {ProcessExecutionPayloadEnvelopeOpts} from "../block/processExecutionPayloadEnvelope.js"; +import {applyParentExecutionPayload} from "../block/processParentExecutionPayload.js"; import {VoluntaryExitValidity, getVoluntaryExitValidity} from "../block/processVoluntaryExit.js"; import {getExpectedWithdrawals} from "../block/processWithdrawals.js"; import {EffectiveBalanceIncrements} from "../cache/effectiveBalanceIncrements.js"; @@ -784,19 +783,14 @@ export class BeaconStateView implements IBeaconStateViewLatestFork { return new BeaconStateView(newState); } - 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}`); + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): capella.Withdrawal[] { + const fork = this.config.getForkSeq(this.cachedState.slot); + if (!isForkPostGloas(this.config.getForkName(this.cachedState.slot))) { + throw Error("getExpectedWithdrawalsForFullParent is only available for gloas+ forks"); } - const postPayloadState = processExecutionPayloadEnvelope( - this.cachedState as CachedBeaconStateGloas, - signedEnvelope, - opts - ); - return new BeaconStateView(postPayloadState); + const stateCopy = this.cachedState.clone(true) as CachedBeaconStateGloas; + applyParentExecutionPayload(stateCopy, stateCopy.latestExecutionPayloadBid, executionRequests); + const {expectedWithdrawals} = getExpectedWithdrawals(fork, stateCopy); + return expectedWithdrawals; } } diff --git a/packages/state-transition/src/stateView/interface.ts b/packages/state-transition/src/stateView/interface.ts index 5eee21e3af9a..324fc69e10a6 100644 --- a/packages/state-transition/src/stateView/interface.ts +++ b/packages/state-transition/src/stateView/interface.ts @@ -41,7 +41,6 @@ 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"; @@ -254,10 +253,12 @@ export interface IBeaconStateViewGloas extends IBeaconStateViewFulu { getBuilder(index: BuilderIndex): gloas.Builder; canBuilderCoverBid(builderIndex: BuilderIndex, bidAmount: number): boolean; getIndexInPayloadTimelinessCommittee(validatorIndex: ValidatorIndex, slot: Slot): number; - processExecutionPayloadEnvelope( - signedEnvelope: gloas.SignedExecutionPayloadEnvelope, - opts?: ProcessExecutionPayloadEnvelopeOpts - ): IBeaconStateView; + /** + * Compute expected withdrawals as if the parent was FULL. + * Clones the state, applies parent payload effects, then computes withdrawals. + * Used by prepare_execution_payload when building on FULL parent. + */ + getExpectedWithdrawalsForFullParent(executionRequests: electra.ExecutionRequests): capella.Withdrawal[]; } /** diff --git a/packages/state-transition/src/util/computeAnchorCheckpoint.ts b/packages/state-transition/src/util/computeAnchorCheckpoint.ts index 1edb2ac57ca2..8bfe9a88efea 100644 --- a/packages/state-transition/src/util/computeAnchorCheckpoint.ts +++ b/packages/state-transition/src/util/computeAnchorCheckpoint.ts @@ -1,30 +1,21 @@ import {ChainForkConfig} from "@lodestar/config"; -import {GENESIS_SLOT, ZERO_HASH} from "@lodestar/params"; +import {ZERO_HASH} from "@lodestar/params"; import {phase0, ssz} from "@lodestar/types"; import {BeaconStateAllForks} from "../types.js"; -import {blockToHeader} from "./blockRoot.js"; import {computeCheckpointEpochAtStateSlot} from "./epoch.js"; export function computeAnchorCheckpoint( - config: ChainForkConfig, + _config: ChainForkConfig, anchorState: BeaconStateAllForks ): {checkpoint: phase0.Checkpoint; blockHeader: phase0.BeaconBlockHeader} { let blockHeader: phase0.BeaconBlockHeader; let root: Uint8Array; - const blockTypes = config.getForkTypes(anchorState.latestBlockHeader.slot); - if (anchorState.latestBlockHeader.slot === GENESIS_SLOT) { - const block = blockTypes.BeaconBlock.defaultValue(); - block.stateRoot = anchorState.hashTreeRoot(); - blockHeader = blockToHeader(config, block); - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); - } else { - blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); - if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { - blockHeader.stateRoot = anchorState.hashTreeRoot(); - } - root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); + blockHeader = ssz.phase0.BeaconBlockHeader.clone(anchorState.latestBlockHeader); + if (ssz.Root.equals(blockHeader.stateRoot, ZERO_HASH)) { + blockHeader.stateRoot = anchorState.hashTreeRoot(); } + root = ssz.phase0.BeaconBlockHeader.hashTreeRoot(blockHeader); return { checkpoint: { diff --git a/packages/types/src/gloas/sszTypes.ts b/packages/types/src/gloas/sszTypes.ts index 329a359177a3..0d25f71bcaf5 100644 --- a/packages/types/src/gloas/sszTypes.ts +++ b/packages/types/src/gloas/sszTypes.ts @@ -151,6 +151,7 @@ export const ExecutionPayloadBid = new ContainerType( value: UintNum64, executionPayment: UintNum64, blobKzgCommitments: denebSsz.BlobKzgCommitments, + executionRequestsRoot: Root, // New in consensus-specs#5094 }, {typeName: "ExecutionPayloadBid", jsonCase: "eth2"} ); @@ -180,8 +181,6 @@ export const ExecutionPayloadEnvelope = new ContainerType( executionRequests: electraSsz.ExecutionRequests, builderIndex: BuilderIndex, beaconBlockRoot: Root, - slot: Slot, - stateRoot: Root, }, {typeName: "ExecutionPayloadEnvelope", jsonCase: "eth2"} ); @@ -211,6 +210,7 @@ export const BeaconBlockBody = new ContainerType( // executionRequests: ExecutionRequests, // Removed in GLOAS:EIP7732 signedExecutionPayloadBid: SignedExecutionPayloadBid, // New in GLOAS:EIP7732 payloadAttestations: new ListCompositeType(PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS), // New in GLOAS:EIP7732 + parentExecutionRequests: electraSsz.ExecutionRequests, // New in consensus-specs#5094 }, {typeName: "BeaconBlockBody", jsonCase: "eth2", cachePermanentRootStruct: true} ); @@ -268,7 +268,7 @@ export const BeaconState = new ContainerType( nextSyncCommittee: altairSsz.SyncCommittee, // Execution // latestExecutionPayloadHeader: ExecutionPayloadHeader, // Removed in GLOAS:EIP7732 - latestExecutionPayloadBid: ExecutionPayloadBid, // New in GLOAS:EIP7732 + latestBlockHash: Bytes32, // New in GLOAS:EIP7732 // Withdrawals nextWithdrawalIndex: capellaSsz.BeaconState.fields.nextWithdrawalIndex, nextWithdrawalValidatorIndex: capellaSsz.BeaconState.fields.nextWithdrawalValidatorIndex, @@ -289,7 +289,7 @@ export const BeaconState = new ContainerType( executionPayloadAvailability: new BitVectorType(SLOTS_PER_HISTORICAL_ROOT), // New in GLOAS:EIP7732 builderPendingPayments: new VectorCompositeType(BuilderPendingPayment, 2 * SLOTS_PER_EPOCH), // New in GLOAS:EIP7732 builderPendingWithdrawals: new ListCompositeType(BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT), // New in GLOAS:EIP7732 - latestBlockHash: Bytes32, // New in GLOAS:EIP7732 + latestExecutionPayloadBid: ExecutionPayloadBid, payloadExpectedWithdrawals: capellaSsz.Withdrawals, // New in GLOAS:EIP7732 ptcWindow: PtcWindow, // New in GLOAS:EIP7732 }, diff --git a/packages/validator/src/services/block.ts b/packages/validator/src/services/block.ts index a3f198273d5e..ebe04f09971a 100644 --- a/packages/validator/src/services/block.ts +++ b/packages/validator/src/services/block.ts @@ -241,9 +241,8 @@ export class BlockProposingService { beaconBlockRoot, }); const envelope = envelopeRes.value(); - const stateRootHex = toRootHex(envelope.stateRoot); - this.logger.debug("Retrieved execution payload envelope", {...debugLogCtx, stateRoot: stateRootHex}); + this.logger.debug("Retrieved execution payload envelope", debugLogCtx); // Step 4: Sign and publish the envelope const signedEnvelope = await this.validatorStore.signExecutionPayloadEnvelope(pubkey, envelope, slot, this.logger); diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 4716d3498756..ea84e9f15833 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -496,11 +496,11 @@ export class ValidatorStore { logger?: LoggerVc ): Promise { // Make sure the envelope slot is not higher than the current slot to avoid potential attacks. - if (envelope.slot > currentSlot) { - throw Error(`Not signing envelope with slot ${envelope.slot} greater than current slot ${currentSlot}`); + if (envelope.payload.slotNumber > currentSlot) { + throw Error(`Not signing envelope with slot ${envelope.payload.slotNumber} greater than current slot ${currentSlot}`); } - const signingSlot = envelope.slot; + const signingSlot = envelope.payload.slotNumber; const domain = this.config.getDomain(signingSlot, DOMAIN_BEACON_BUILDER); const signingRoot = computeSigningRoot(ssz.gloas.ExecutionPayloadEnvelope, envelope, domain); diff --git a/spec-tests-version.json b/spec-tests-version.json index 8e29f2192c02..64560a60b747 100644 --- a/spec-tests-version.json +++ b/spec-tests-version.json @@ -1,6 +1,6 @@ { "ethereumConsensusSpecsTests": { - "specVersion": "v1.7.0-alpha.4", + "specVersion": "v1.7.0-alpha.5", "specTestsRepoUrl": "https://github.com/ethereum/consensus-specs", "outputDirBase": "spec-tests", "testsToDownload": [ diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 41b05a145e51..9b773fc0fcf6 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -1,4 +1,4 @@ -version: v1.7.0-alpha.4 +version: v1.7.0-alpha.5 style: full specrefs: