diff --git a/packages/builder/src/services/executionPayloadEnvelope.ts b/packages/builder/src/services/executionPayloadEnvelope.ts new file mode 100644 index 000000000000..8b74c35a3854 --- /dev/null +++ b/packages/builder/src/services/executionPayloadEnvelope.ts @@ -0,0 +1,121 @@ +import type {BuilderIndex, Root, RootHex, Slot, gloas} from "@lodestar/types"; +import {LodestarError, fromHex, toRootHex} from "@lodestar/utils"; +import type {BuiltPayload} from "./payloadSource.js"; + +export type SelectedBidIdentity = { + slot: Slot; + parentBlockHash: RootHex; + parentBlockRoot: RootHex; + blockHash: RootHex; +}; + +export type ExecutionPayloadEnvelopeInput = { + blockRoot: RootHex; + builderIndex: BuilderIndex; + selectedBid: SelectedBidIdentity; + storedPayload: { + parentBlockRoot: Root; + payload: BuiltPayload; + }; +}; + +export type ExecutionPayloadEnvelopeMaterial = { + envelope: gloas.ExecutionPayloadEnvelope; + kzgProofs: BuiltPayload["blobsBundle"]["proofs"]; + blobs: BuiltPayload["blobsBundle"]["blobs"]; +}; + +export enum ExecutionPayloadEnvelopeErrorCode { + SLOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_SLOT_MISMATCH", + PARENT_BLOCK_ROOT_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PARENT_BLOCK_ROOT_MISMATCH", + PARENT_BLOCK_HASH_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_PARENT_BLOCK_HASH_MISMATCH", + BLOCK_HASH_MISMATCH = "EXECUTION_PAYLOAD_ENVELOPE_ERROR_BLOCK_HASH_MISMATCH", +} + +export type ExecutionPayloadEnvelopeErrorType = + | { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_BLOCK_ROOT_MISMATCH; + bidParentBlockRoot: RootHex; + storedParentBlockRoot: RootHex; + } + | { + code: ExecutionPayloadEnvelopeErrorCode.SLOT_MISMATCH; + bidSlot: Slot; + payloadSlot: Slot; + } + | { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_BLOCK_HASH_MISMATCH; + bidParentBlockHash: RootHex; + payloadParentBlockHash: RootHex; + } + | { + code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH; + bidBlockHash: RootHex; + payloadBlockHash: RootHex; + }; + +export class ExecutionPayloadEnvelopeError extends LodestarError {} + +export function createExecutionPayloadEnvelopeMaterial({ + blockRoot, + builderIndex, + selectedBid, + storedPayload, +}: ExecutionPayloadEnvelopeInput): ExecutionPayloadEnvelopeMaterial { + const storedParentBlockRoot = toRootHex(storedPayload.parentBlockRoot); + if (storedParentBlockRoot !== selectedBid.parentBlockRoot) { + throw new ExecutionPayloadEnvelopeError( + { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_BLOCK_ROOT_MISMATCH, + bidParentBlockRoot: selectedBid.parentBlockRoot, + storedParentBlockRoot, + }, + `Selected bid beacon parent does not match retained payload bidParentBlockRoot=${selectedBid.parentBlockRoot} storedParentBlockRoot=${storedParentBlockRoot}` + ); + } + + const {payload} = storedPayload; + const payloadSlot = payload.executionPayload.slotNumber; + if (payloadSlot !== selectedBid.slot) { + throw new ExecutionPayloadEnvelopeError( + {code: ExecutionPayloadEnvelopeErrorCode.SLOT_MISMATCH, bidSlot: selectedBid.slot, payloadSlot}, + `Selected bid slot does not match payload slot bidSlot=${selectedBid.slot} payloadSlot=${payloadSlot}` + ); + } + + const payloadParentBlockHash = toRootHex(payload.executionPayload.parentHash); + if (payloadParentBlockHash !== selectedBid.parentBlockHash) { + throw new ExecutionPayloadEnvelopeError( + { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_BLOCK_HASH_MISMATCH, + bidParentBlockHash: selectedBid.parentBlockHash, + payloadParentBlockHash, + }, + `Selected bid parent does not match payload parent bidParentBlockHash=${selectedBid.parentBlockHash} payloadParentBlockHash=${payloadParentBlockHash}` + ); + } + + const payloadBlockHash = toRootHex(payload.executionPayload.blockHash); + if (payloadBlockHash !== selectedBid.blockHash) { + throw new ExecutionPayloadEnvelopeError( + { + code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, + bidBlockHash: selectedBid.blockHash, + payloadBlockHash, + }, + `Selected bid block hash does not match payload bidBlockHash=${selectedBid.blockHash} payloadBlockHash=${payloadBlockHash}` + ); + } + + return { + envelope: { + payload: payload.executionPayload, + executionRequests: payload.executionRequests, + builderIndex, + beaconBlockRoot: fromHex(blockRoot), + parentBeaconBlockRoot: fromHex(selectedBid.parentBlockRoot), + }, + kzgProofs: payload.blobsBundle.proofs, + blobs: payload.blobsBundle.blobs, + }; +} diff --git a/packages/builder/src/services/payloadSource.ts b/packages/builder/src/services/payloadSource.ts new file mode 100644 index 000000000000..1de219e1be0b --- /dev/null +++ b/packages/builder/src/services/payloadSource.ts @@ -0,0 +1,179 @@ +import type {ForkPostGloas} from "@lodestar/params"; +import type { + BlobsBundle, + ColumnIndex, + ExecutionPayload, + ExecutionRequests, + RootHex, + SSEPayloadAttributes, +} from "@lodestar/types"; +import {LodestarError} from "@lodestar/utils"; + +export type PayloadId = string; + +export type ForkchoiceState = { + headBlockHash: RootHex; + safeBlockHash: RootHex; + finalizedBlockHash: RootHex; +}; + +export type PayloadAttributes = SSEPayloadAttributes["payloadAttributes"]; + +export type BuildRequest = F extends ForkPostGloas + ? { + fork: F; + forkchoiceState: ForkchoiceState; + payloadAttributes: PayloadAttributes; + /** Logical custody set. The transport serializes it for Engine API; null means no custody service. */ + custodyColumns: ColumnIndex[] | null; + } + : never; + +export type BuildHandle = { + sourceId: string; + fork: F; + payloadId: PayloadId; +}; + +export type BuiltPayload = { + sourceId: string; + fork: F; + executionPayload: ExecutionPayload; + executionRequests: ExecutionRequests; + blobsBundle: BlobsBundle; + executionPayloadValue: bigint; +}; + +export type EnginePayloadResult = { + executionPayload: ExecutionPayload; + executionPayloadValue: bigint; + blobsBundle?: BlobsBundle; + executionRequests?: ExecutionRequests; +}; + +/** Narrow Engine boundary whose transport owns serialization, retries, and request execution. */ +export interface PayloadSourceEngine { + notifyForkchoiceUpdate( + fork: F, + headBlockHash: RootHex, + safeBlockHash: RootHex, + finalizedBlockHash: RootHex, + payloadAttributes: PayloadAttributes, + custodyColumns: ColumnIndex[] | null, + signal: AbortSignal + ): Promise; + getPayload( + fork: F, + payloadId: PayloadId, + signal: AbortSignal + ): Promise>; +} + +/** Source that prepares and retrieves complete execution payloads without owning build scheduling policy. */ +export interface PayloadSource { + readonly id: string; + prepare(request: R, signal: AbortSignal): Promise>; + getPayload(handle: BuildHandle, signal: AbortSignal): Promise>; +} + +export enum PayloadSourceErrorCode { + NO_PAYLOAD_ID = "PAYLOAD_SOURCE_ERROR_NO_PAYLOAD_ID", + SOURCE_MISMATCH = "PAYLOAD_SOURCE_ERROR_SOURCE_MISMATCH", + MISSING_BLOBS_BUNDLE = "PAYLOAD_SOURCE_ERROR_MISSING_BLOBS_BUNDLE", + MISSING_EXECUTION_REQUESTS = "PAYLOAD_SOURCE_ERROR_MISSING_EXECUTION_REQUESTS", +} + +export type PayloadSourceErrorType = + | {code: PayloadSourceErrorCode.NO_PAYLOAD_ID; sourceId: string} + | { + code: PayloadSourceErrorCode.SOURCE_MISMATCH; + sourceId: string; + handleSourceId: string; + } + | { + code: PayloadSourceErrorCode.MISSING_BLOBS_BUNDLE | PayloadSourceErrorCode.MISSING_EXECUTION_REQUESTS; + sourceId: string; + payloadId: PayloadId; + }; + +export class PayloadSourceError extends LodestarError {} + +/** Payload source backed by an injected Engine boundary. Engine ownership and lifecycle remain caller policy. */ +export class EnginePayloadSource implements PayloadSource { + constructor( + readonly id: string, + private readonly engine: PayloadSourceEngine + ) {} + + async prepare(request: R, signal: AbortSignal): Promise> { + const {headBlockHash, safeBlockHash, finalizedBlockHash} = request.forkchoiceState; + const payloadId = await this.engine.notifyForkchoiceUpdate( + request.fork, + headBlockHash, + safeBlockHash, + finalizedBlockHash, + request.payloadAttributes, + request.custodyColumns, + signal + ); + + if (payloadId === null) { + throw new PayloadSourceError( + {code: PayloadSourceErrorCode.NO_PAYLOAD_ID, sourceId: this.id}, + `Execution client did not return a payload ID sourceId=${this.id}` + ); + } + + return {sourceId: this.id, fork: request.fork, payloadId}; + } + + async getPayload(handle: BuildHandle, signal: AbortSignal): Promise> { + if (handle.sourceId !== this.id) { + throw new PayloadSourceError( + { + code: PayloadSourceErrorCode.SOURCE_MISMATCH, + sourceId: this.id, + handleSourceId: handle.sourceId, + }, + `Payload handle belongs to another source sourceId=${this.id} handleSourceId=${handle.sourceId}` + ); + } + + const {executionPayload, executionPayloadValue, blobsBundle, executionRequests} = await this.engine.getPayload( + handle.fork, + handle.payloadId, + signal + ); + + if (blobsBundle === undefined) { + throw new PayloadSourceError( + { + code: PayloadSourceErrorCode.MISSING_BLOBS_BUNDLE, + sourceId: this.id, + payloadId: handle.payloadId, + }, + `Execution client did not return a blobs bundle sourceId=${this.id} payloadId=${handle.payloadId}` + ); + } + + if (executionRequests === undefined) { + throw new PayloadSourceError( + { + code: PayloadSourceErrorCode.MISSING_EXECUTION_REQUESTS, + sourceId: this.id, + payloadId: handle.payloadId, + }, + `Execution client did not return execution requests sourceId=${this.id} payloadId=${handle.payloadId}` + ); + } + + return { + sourceId: this.id, + fork: handle.fork, + executionPayload, + executionRequests, + blobsBundle, + executionPayloadValue, + }; + } +} diff --git a/packages/builder/test/unit/services/executionPayloadEnvelope.test.ts b/packages/builder/test/unit/services/executionPayloadEnvelope.test.ts new file mode 100644 index 000000000000..d9826e2b00a9 --- /dev/null +++ b/packages/builder/test/unit/services/executionPayloadEnvelope.test.ts @@ -0,0 +1,148 @@ +import {describe, expect, it} from "vitest"; +import {ForkName, type ForkPostGloas} from "@lodestar/params"; +import type {RootHex} from "@lodestar/types"; +import {ssz} from "@lodestar/types"; +import {fromHex, toRootHex} from "@lodestar/utils"; +import { + ExecutionPayloadEnvelopeError, + ExecutionPayloadEnvelopeErrorCode, + type ExecutionPayloadEnvelopeInput, + type SelectedBidIdentity, + createExecutionPayloadEnvelopeMaterial, +} from "../../../src/services/executionPayloadEnvelope.js"; +import type {BuiltPayload} from "../../../src/services/payloadSource.js"; + +const builderIndex = 7; +const blockRoot = root(8); + +describe("createExecutionPayloadEnvelopeMaterial", () => { + for (const fork of [ForkName.gloas, ForkName.heze] as const) { + it(`assembles exact ${fork} stateless envelope material`, () => { + const payload = createBuiltPayload(fork); + const selectedBid = bidIdentity(payload); + const storedPayload = retain(payload, selectedBid.parentBlockRoot); + + const material = createExecutionPayloadEnvelopeMaterial({blockRoot, builderIndex, selectedBid, storedPayload}); + + expect(material.envelope).toEqual({ + payload: payload.executionPayload, + executionRequests: payload.executionRequests, + builderIndex, + beaconBlockRoot: fromHex(blockRoot), + parentBeaconBlockRoot: fromHex(selectedBid.parentBlockRoot), + }); + expect(material.kzgProofs).toBe(payload.blobsBundle.proofs); + expect(material.blobs).toBe(payload.blobsBundle.blobs); + }); + } + + it("rejects retained material for a different slot", () => { + const payload = createBuiltPayload(ForkName.gloas); + const selectedBid = {...bidIdentity(payload), slot: 11}; + const storedPayload = retain(payload, selectedBid.parentBlockRoot); + + expectEnvelopeError( + () => createExecutionPayloadEnvelopeMaterial({blockRoot, builderIndex, selectedBid, storedPayload}), + { + code: ExecutionPayloadEnvelopeErrorCode.SLOT_MISMATCH, + bidSlot: 11, + payloadSlot: payload.executionPayload.slotNumber, + } + ); + }); + + it("rejects retained material for a different parent block root", () => { + const payload = createBuiltPayload(ForkName.gloas); + const selectedBid = bidIdentity(payload); + const storedPayload = retain(payload, root(9)); + + expectEnvelopeError( + () => createExecutionPayloadEnvelopeMaterial({blockRoot, builderIndex, selectedBid, storedPayload}), + { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_BLOCK_ROOT_MISMATCH, + bidParentBlockRoot: selectedBid.parentBlockRoot, + storedParentBlockRoot: root(9), + } + ); + }); + + it("rejects retained material for a different parent block hash", () => { + const payload = createBuiltPayload(ForkName.gloas); + const selectedBid = {...bidIdentity(payload), parentBlockHash: root(9)}; + const storedPayload = retain(payload, selectedBid.parentBlockRoot); + + expectEnvelopeError( + () => createExecutionPayloadEnvelopeMaterial({blockRoot, builderIndex, selectedBid, storedPayload}), + { + code: ExecutionPayloadEnvelopeErrorCode.PARENT_BLOCK_HASH_MISMATCH, + bidParentBlockHash: selectedBid.parentBlockHash, + payloadParentBlockHash: toRootHex(payload.executionPayload.parentHash), + } + ); + }); + + it("rejects retained material for a different execution block hash", () => { + const payload = createBuiltPayload(ForkName.gloas); + const selectedBid = {...bidIdentity(payload), blockHash: root(9)}; + const storedPayload = retain(payload, selectedBid.parentBlockRoot); + + expectEnvelopeError( + () => createExecutionPayloadEnvelopeMaterial({blockRoot, builderIndex, selectedBid, storedPayload}), + { + code: ExecutionPayloadEnvelopeErrorCode.BLOCK_HASH_MISMATCH, + bidBlockHash: selectedBid.blockHash, + payloadBlockHash: toRootHex(payload.executionPayload.blockHash), + } + ); + }); +}); + +function createBuiltPayload(fork: F): BuiltPayload { + const forkTypes = fork === ForkName.heze ? ssz.heze : ssz.gloas; + const executionPayload = forkTypes.ExecutionPayload.defaultValue(); + executionPayload.slotNumber = 10; + executionPayload.parentHash = Buffer.alloc(32, 2); + executionPayload.blockHash = Buffer.alloc(32, 4); + const blobsBundle = forkTypes.BlobsBundle.defaultValue(); + blobsBundle.proofs.push(Buffer.alloc(48, 5)); + blobsBundle.blobs.push(Buffer.alloc(0)); + + return { + sourceId: "engine", + fork, + executionPayload, + executionRequests: forkTypes.ExecutionRequests.defaultValue(), + blobsBundle, + executionPayloadValue: 1n, + } as BuiltPayload; +} + +function bidIdentity(payload: BuiltPayload): SelectedBidIdentity { + return { + slot: payload.executionPayload.slotNumber, + parentBlockHash: toRootHex(payload.executionPayload.parentHash), + parentBlockRoot: root(3), + blockHash: toRootHex(payload.executionPayload.blockHash), + }; +} + +function retain(payload: BuiltPayload, parentBlockRoot: RootHex): ExecutionPayloadEnvelopeInput["storedPayload"] { + return {parentBlockRoot: fromHex(parentBlockRoot), payload}; +} + +function root(byte: number): RootHex { + return toRootHex(Buffer.alloc(32, byte)); +} + +function expectEnvelopeError(fn: () => unknown, type: ExecutionPayloadEnvelopeError["type"]): void { + expect(fn).toThrowError(ExecutionPayloadEnvelopeError); + try { + fn(); + throw Error("Expected ExecutionPayloadEnvelopeError"); + } catch (error) { + if (!(error instanceof ExecutionPayloadEnvelopeError)) { + throw error; + } + expect(error.type).toEqual(type); + } +} diff --git a/packages/builder/test/unit/services/payloadSource.test.ts b/packages/builder/test/unit/services/payloadSource.test.ts new file mode 100644 index 000000000000..dca24cc7b0da --- /dev/null +++ b/packages/builder/test/unit/services/payloadSource.test.ts @@ -0,0 +1,222 @@ +import {type Mock, beforeEach, describe, expect, it, vi} from "vitest"; +import {ForkName, type ForkPostGloas} from "@lodestar/params"; +import {type ColumnIndex, type RootHex, ssz} from "@lodestar/types"; +import {ErrorAborted, TimeoutError, toRootHex} from "@lodestar/utils"; +import { + BuildHandle, + BuildRequest, + EnginePayloadResult, + EnginePayloadSource, + PayloadAttributes, + PayloadId, + PayloadSourceEngine, + PayloadSourceError, + PayloadSourceErrorCode, +} from "../../../src/services/payloadSource.js"; + +describe("EnginePayloadSource", () => { + const sourceId = "engine-0"; + const payloadId = "0x0102030405060708"; + const forkchoiceState = { + headBlockHash: toRootHex(Uint8Array.from({length: 32}, () => 1)), + safeBlockHash: toRootHex(Uint8Array.from({length: 32}, () => 2)), + finalizedBlockHash: toRootHex(Uint8Array.from({length: 32}, () => 3)), + }; + const payloadAttributes = ssz.gloas.PayloadAttributes.defaultValue(); + const custodyColumns = [0, 3, 127]; + const request: BuildRequest = { + fork: ForkName.gloas, + forkchoiceState, + payloadAttributes, + custodyColumns, + }; + + // @ts-expect-error Heze requests cannot use Gloas payload attributes. + const mismatchedRequest: BuildRequest = {...request, fork: ForkName.heze}; + void mismatchedRequest; + + const handle: BuildHandle = {sourceId, fork: ForkName.gloas, payloadId}; + const signal = new AbortController().signal; + + let notifyForkchoiceUpdate: Mock; + let getPayload: Mock; + let source: EnginePayloadSource; + + beforeEach(() => { + notifyForkchoiceUpdate = vi.fn(); + getPayload = vi.fn(); + const engine = {notifyForkchoiceUpdate, getPayload} as unknown as PayloadSourceEngine; + source = new EnginePayloadSource(sourceId, engine); + }); + + it("prepares a payload and returns a source-bound handle", async () => { + notifyForkchoiceUpdate.mockResolvedValue(payloadId); + + const result = await source.prepare(request, signal); + + expect(notifyForkchoiceUpdate).toHaveBeenCalledWith( + ForkName.gloas, + forkchoiceState.headBlockHash, + forkchoiceState.safeBlockHash, + forkchoiceState.finalizedBlockHash, + payloadAttributes, + custodyColumns, + signal + ); + expect(result).toEqual(handle); + }); + + it("preserves a null custody set", async () => { + notifyForkchoiceUpdate.mockResolvedValue(payloadId); + + await source.prepare({...request, custodyColumns: null}, signal); + + expect(notifyForkchoiceUpdate).toHaveBeenCalledWith( + ForkName.gloas, + forkchoiceState.headBlockHash, + forkchoiceState.safeBlockHash, + forkchoiceState.finalizedBlockHash, + payloadAttributes, + null, + signal + ); + }); + + it("supports post-Gloas forks without narrowing the fork", async () => { + const hezePayloadAttributes = ssz.heze.PayloadAttributes.defaultValue(); + hezePayloadAttributes.inclusionListTransactions = [Uint8Array.from([1, 2, 3])]; + notifyForkchoiceUpdate.mockResolvedValue(payloadId); + getPayload.mockResolvedValue({ + executionPayload: ssz.heze.ExecutionPayload.defaultValue(), + blobsBundle: ssz.heze.BlobsBundle.defaultValue(), + executionRequests: ssz.heze.ExecutionRequests.defaultValue(), + executionPayloadValue: 12_345_678_901_234_567_890n, + }); + + const result = await source.prepare( + { + fork: ForkName.heze, + forkchoiceState, + payloadAttributes: hezePayloadAttributes, + custodyColumns, + }, + signal + ); + const builtPayload = await source.getPayload(result, signal); + + expect(result.fork).toBe(ForkName.heze); + expect(notifyForkchoiceUpdate).toHaveBeenCalledWith( + ForkName.heze, + forkchoiceState.headBlockHash, + forkchoiceState.safeBlockHash, + forkchoiceState.finalizedBlockHash, + hezePayloadAttributes, + custodyColumns, + signal + ); + expect(getPayload).toHaveBeenCalledWith(ForkName.heze, payloadId, signal); + expect(builtPayload.fork).toBe(ForkName.heze); + expect(hezePayloadAttributes.inclusionListTransactions).toHaveLength(1); + }); + + it("rejects a missing payload ID with a structured error", async () => { + notifyForkchoiceUpdate.mockResolvedValue(null); + + const error = await getPayloadSourceError(source.prepare(request, signal)); + + expect(error.type).toEqual({code: PayloadSourceErrorCode.NO_PAYLOAD_ID, sourceId}); + }); + + it.each([ + ["transport", new Error("connection reset")], + ["unsupported Engine response", new Error("Method not found")], + ["timeout", new TimeoutError("engine_forkchoiceUpdatedV4")], + ["cancellation", new ErrorAborted("engine_forkchoiceUpdatedV4")], + ])("propagates %s errors from payload preparation", async (_name, error) => { + notifyForkchoiceUpdate.mockRejectedValue(error); + + await expect(source.prepare(request, signal)).rejects.toBe(error); + }); + + it("retrieves a complete payload without rebuilding exact-width values", async () => { + const result = getEnginePayloadResult(); + getPayload.mockResolvedValue(result); + + const builtPayload = await source.getPayload(handle, signal); + + expect(getPayload).toHaveBeenCalledWith(ForkName.gloas, payloadId, signal); + expect(builtPayload.sourceId).toBe(sourceId); + expect(builtPayload.fork).toBe(ForkName.gloas); + expect(builtPayload.executionPayload).toBe(result.executionPayload); + expect(builtPayload.blobsBundle).toBe(result.blobsBundle); + expect(builtPayload.executionRequests).toBe(result.executionRequests); + expect(builtPayload.executionPayloadValue).toBe(result.executionPayloadValue); + }); + + it("rejects a handle belonging to another source before calling the Engine API", async () => { + const error = await getPayloadSourceError(source.getPayload({...handle, sourceId: "engine-1"}, signal)); + + expect(error.type).toEqual({ + code: PayloadSourceErrorCode.SOURCE_MISMATCH, + sourceId, + handleSourceId: "engine-1", + }); + expect(getPayload).not.toHaveBeenCalled(); + }); + + it("rejects a response without a blobs bundle", async () => { + getPayload.mockResolvedValue({...getEnginePayloadResult(), blobsBundle: undefined}); + + const error = await getPayloadSourceError(source.getPayload(handle, signal)); + + expect(error.type).toEqual({code: PayloadSourceErrorCode.MISSING_BLOBS_BUNDLE, sourceId, payloadId}); + }); + + it("rejects a response without execution requests", async () => { + getPayload.mockResolvedValue({...getEnginePayloadResult(), executionRequests: undefined}); + + const error = await getPayloadSourceError(source.getPayload(handle, signal)); + + expect(error.type).toEqual({code: PayloadSourceErrorCode.MISSING_EXECUTION_REQUESTS, sourceId, payloadId}); + }); + + it("propagates retrieval errors without replacing their type", async () => { + const error = new TimeoutError("engine_getPayloadV6"); + getPayload.mockRejectedValue(error); + + await expect(source.getPayload(handle, signal)).rejects.toBe(error); + }); +}); + +function getEnginePayloadResult(): EnginePayloadResult { + return { + executionPayload: ssz.gloas.ExecutionPayload.defaultValue(), + blobsBundle: ssz.gloas.BlobsBundle.defaultValue(), + executionRequests: ssz.gloas.ExecutionRequests.defaultValue(), + executionPayloadValue: 12_345_678_901_234_567_890n, + }; +} + +type NotifyForkchoiceUpdate = ( + fork: ForkPostGloas, + headBlockHash: RootHex, + safeBlockHash: RootHex, + finalizedBlockHash: RootHex, + payloadAttributes: PayloadAttributes, + custodyColumns: ColumnIndex[] | null, + signal: AbortSignal +) => Promise; + +type GetPayload = (fork: ForkPostGloas, payloadId: PayloadId, signal: AbortSignal) => Promise; + +async function getPayloadSourceError(promise: Promise): Promise { + try { + await promise; + throw Error("Expected PayloadSourceError"); + } catch (error) { + if (!(error instanceof PayloadSourceError)) { + throw error; + } + return error; + } +}