Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions packages/builder/src/services/executionPayloadEnvelope.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutionPayloadEnvelopeErrorType> {}

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,
};
}
179 changes: 179 additions & 0 deletions packages/builder/src/services/payloadSource.ts
Original file line number Diff line number Diff line change
@@ -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<F extends ForkPostGloas = ForkPostGloas> = SSEPayloadAttributes<F>["payloadAttributes"];

export type BuildRequest<F extends ForkPostGloas = ForkPostGloas> = F extends ForkPostGloas
? {
fork: F;
forkchoiceState: ForkchoiceState;
payloadAttributes: PayloadAttributes<F>;
/** Logical custody set. The transport serializes it for Engine API; null means no custody service. */
custodyColumns: ColumnIndex[] | null;
}
: never;

export type BuildHandle<F extends ForkPostGloas = ForkPostGloas> = {
sourceId: string;
fork: F;
payloadId: PayloadId;
};

export type BuiltPayload<F extends ForkPostGloas = ForkPostGloas> = {
sourceId: string;
fork: F;
executionPayload: ExecutionPayload<F>;
executionRequests: ExecutionRequests<F>;
blobsBundle: BlobsBundle<F>;
executionPayloadValue: bigint;
};

export type EnginePayloadResult<F extends ForkPostGloas = ForkPostGloas> = {
executionPayload: ExecutionPayload<F>;
executionPayloadValue: bigint;
blobsBundle?: BlobsBundle<F>;
executionRequests?: ExecutionRequests<F>;
};

/** Narrow Engine boundary whose transport owns serialization, retries, and request execution. */
export interface PayloadSourceEngine {
notifyForkchoiceUpdate<F extends ForkPostGloas>(
fork: F,
headBlockHash: RootHex,
safeBlockHash: RootHex,
finalizedBlockHash: RootHex,
payloadAttributes: PayloadAttributes<F>,
custodyColumns: ColumnIndex[] | null,
signal: AbortSignal
): Promise<PayloadId | null>;
getPayload<F extends ForkPostGloas>(
fork: F,
payloadId: PayloadId,
signal: AbortSignal
): Promise<EnginePayloadResult<F>>;
}

/** Source that prepares and retrieves complete execution payloads without owning build scheduling policy. */
export interface PayloadSource {
readonly id: string;
prepare<R extends BuildRequest>(request: R, signal: AbortSignal): Promise<BuildHandle<R["fork"]>>;
getPayload<F extends ForkPostGloas>(handle: BuildHandle<F>, signal: AbortSignal): Promise<BuiltPayload<F>>;
}

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<PayloadSourceErrorType> {}

/** 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<R extends BuildRequest>(request: R, signal: AbortSignal): Promise<BuildHandle<R["fork"]>> {
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<F extends ForkPostGloas>(handle: BuildHandle<F>, signal: AbortSignal): Promise<BuiltPayload<F>> {
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,
};
}
}
Loading