diff --git a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts index ec853bdb6e2b..c32f92628176 100644 --- a/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts +++ b/packages/beacon-node/src/chain/produceBlock/produceBlockBody.ts @@ -692,6 +692,9 @@ export async function prepareExecutionPayload( parentBlockHash, feeRecipient: suggestedFeeRecipient, }); + if (ForkSeq[fork] >= ForkSeq.gloas) { + attributes.targetGasLimit = getTargetGasLimit(chain, state, state.slot, parentBlockRoot, parentBlockHash); + } payloadId = await chain.executionEngine.notifyForkchoiceUpdate( fork, @@ -795,6 +798,43 @@ export function getPayloadAttributesForSSE( return ssePayloadAttributes; } +function getTargetGasLimit( + chain: { + forkChoice: IForkChoice; + proposerPreferencesPool: { + get(slot: Slot, dependentRootHex: RootHex): gloas.SignedProposerPreferences | null; + }; + }, + prepareState: IBeaconStateViewBellatrix, + prepareSlot: Slot, + parentBlockRoot: Root, + parentBlockHash: Bytes32 +): number { + const fallback = isStatePostGloas(prepareState) + ? Number(prepareState.latestExecutionPayloadBid.gasLimit) + : prepareState.latestExecutionPayloadHeader.gasLimit; + const parentBlockRootHex = toRootHex(parentBlockRoot); + const parentBlock = + chain.forkChoice.getBlockHexAndBlockHash(parentBlockRootHex, toRootHex(parentBlockHash)) ?? + chain.forkChoice.getBlockHexDefaultStatus(parentBlockRootHex); + + if (parentBlock === null) { + return fallback; + } + + try { + const dependentRootHex = getShufflingDependentRoot( + chain.forkChoice, + computeEpochAtSlot(prepareSlot), + computeEpochAtSlot(parentBlock.slot), + parentBlock + ); + return chain.proposerPreferencesPool.get(prepareSlot, dependentRootHex)?.message.targetGasLimit ?? fallback; + } catch { + return fallback; + } +} + function preparePayloadAttributes( fork: ForkPostBellatrix, chain: { diff --git a/packages/beacon-node/src/execution/engine/http.ts b/packages/beacon-node/src/execution/engine/http.ts index 8a82b8f42d22..5cd8c4e87c79 100644 --- a/packages/beacon-node/src/execution/engine/http.ts +++ b/packages/beacon-node/src/execution/engine/http.ts @@ -3,11 +3,11 @@ import {ForkName, ForkPostFulu, ForkPreFulu, ForkSeq, SLOTS_PER_EPOCH, isForkPos import {BlobsBundle, ExecutionPayload, ExecutionRequests, Root, RootHex, Wei} from "@lodestar/types"; import {BlobAndProof} from "@lodestar/types/deneb"; import {BlobAndProofV2} from "@lodestar/types/fulu"; -import {strip0xPrefix} from "@lodestar/utils"; +import {fromHex, strip0xPrefix} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {EPOCHS_PER_BATCH} from "../../sync/constants.js"; import {getLodestarClientVersion} from "../../util/metadata.js"; -import {JobItemQueue} from "../../util/queue/index.js"; +import {JobFnQueue} from "../../util/queue/index.js"; import { ClientCode, ClientVersion, @@ -27,6 +27,26 @@ import { ReqOpts, } from "./jsonRpcHttpClient.js"; import {PayloadIdCache} from "./payloadIdCache.js"; +import {SszRestClient, isSszRestNetworkError} from "./sszRestClient.js"; +import { + decodeForkchoiceUpdatedResponse, + decodeGetBlobsV1Response, + decodeGetBlobsV2Response, + decodeGetClientVersionResponse, + decodeGetPayloadResponse, + decodePayloadBodiesV1Response, + decodePayloadStatus, + encodeForkchoiceUpdatedRequest, + encodeGetBlobsRequest, + encodeGetClientVersionRequest, + encodeGetPayloadBodiesByHashRequest, + encodeGetPayloadBodiesByRangeRequest, + encodeNewPayloadRequest, + forkchoiceUpdatedVersion, + getBlobsVersion, + getPayloadVersion, + newPayloadVersion, +} from "./sszRestEncoding.js"; import { BLOB_AND_PROOF_V2_RPC_BYTES, EngineApiRpcParamTypes, @@ -80,6 +100,17 @@ export type ExecutionEngineHttpOpts = { * Lodestar commit to be used for `ClientVersion` */ commit?: string; + /** + * EIP-8161 / ethereum/execution-apis#764: opt-in to the binary SSZ-REST + * Engine API transport. + * + * When enabled, the CL negotiates SSZ-REST endpoint support through + * engine_exchangeCapabilities, uses binary SSZ for mutually advertised + * endpoints, and falls back to JSON-RPC on network errors. Off by default + * until the spec stabilises and ELs we test against advertise support + * consistently. + */ + sszRest?: boolean; }; export const defaultExecutionEngineHttpOpts: ExecutionEngineHttpOpts = { @@ -116,6 +147,30 @@ const getPayloadBodiesByRangeOpts: ReqOpts = {routeId: "getPayloadBodiesByRange" const getBlobsV1Opts: ReqOpts = {routeId: "getBlobsV1"}; const getBlobsV2Opts: ReqOpts = {routeId: "getBlobsV2"}; const getClientVersionOpts: ReqOpts = {routeId: "getClientVersion"}; +const exchangeCapabilitiesOpts: ReqOpts = {routeId: "exchangeCapabilities", retries: 1}; + +const supportedSszRestEndpoints = [ + "POST /engine/v1/payloads", + "POST /engine/v2/payloads", + "POST /engine/v3/payloads", + "POST /engine/v4/payloads", + "POST /engine/v5/payloads", + "GET /engine/v1/payloads/{payload_id}", + "GET /engine/v2/payloads/{payload_id}", + "GET /engine/v3/payloads/{payload_id}", + "GET /engine/v4/payloads/{payload_id}", + "GET /engine/v5/payloads/{payload_id}", + "GET /engine/v6/payloads/{payload_id}", + "POST /engine/v1/payloads/bodies/by-hash", + "POST /engine/v1/payloads/bodies/by-range", + "POST /engine/v1/forkchoice", + "POST /engine/v2/forkchoice", + "POST /engine/v3/forkchoice", + "POST /engine/v4/forkchoice", + "POST /engine/v1/blobs", + "POST /engine/v2/blobs", + "POST /engine/v1/client/version", +]; /** * based on Ethereum JSON-RPC API and inherits the following properties of this standard: @@ -139,6 +194,11 @@ export class ExecutionEngineHttp implements IExecutionEngine { clientVersion?: ClientVersion | null; readonly payloadIdCache = new PayloadIdCache(); + + /** EIP-8161: SSZ-REST client, null if not configured */ + private readonly sszRestClient: SszRestClient | null; + private readonly sszRestCapabilities: Promise> | null; + /** * A queue to serialize the fcUs and newPayloads calls: * @@ -148,28 +208,39 @@ export class ExecutionEngineHttp implements IExecutionEngine { * the order of new payloads and fcUs is pretty important to EL, this queue will serialize the calls in the * order with which we make them. */ - private readonly rpcFetchQueue: JobItemQueue<[EngineRequest], EngineResponse>; - - private jobQueueProcessor = async ({method, params, methodOpts}: EngineRequest): Promise => { - return this.rpc.fetchWithRetries( - {method, params}, - methodOpts - ); - }; + private readonly rpcFetchQueue: JobFnQueue; constructor( private readonly rpc: IJsonRpcHttpClient, {metrics, signal, logger}: ExecutionEngineModules, private readonly opts?: ExecutionEngineHttpOpts ) { - this.rpcFetchQueue = new JobItemQueue<[EngineRequest], EngineResponse>( - this.jobQueueProcessor, + this.rpcFetchQueue = new JobFnQueue( {maxLength: QUEUE_MAX_LENGTH, maxConcurrency: 1, noYieldIfOneItem: true, signal}, metrics?.engineHttpProcessorQueue ); this.logger = logger; this.metrics = metrics ?? null; + // EIP-8161: Initialize SSZ-REST client only when the flag is set. + // SSZ-REST routes are served on the same port under /engine/* paths. + if (opts?.sszRest) { + const engineUrl = opts.urls?.[0] ?? "http://localhost:8551"; + const baseUrl = stripTrailingSlashes(engineUrl); + this.sszRestClient = new SszRestClient({ + baseUrl, + jwtSecretHex: opts.jwtSecretHex, + jwtId: opts.jwtId, + jwtVersion: opts.jwtVersion, + timeout: opts.timeout, + }); + this.sszRestCapabilities = this.exchangeSszRestCapabilities(); + this.logger.info("SSZ-REST Engine API transport enabled (EIP-8161)", {url: baseUrl}); + } else { + this.sszRestClient = null; + this.sszRestCapabilities = null; + } + this.rpc.emitter.on(JsonRpcHttpClientEvent.ERROR, ({error}) => { this.updateEngineState(getExecutionEngineState({payloadError: error, oldState: this.state})); }); @@ -186,6 +257,36 @@ export class ExecutionEngineHttp implements IExecutionEngine { }); } + private async exchangeSszRestCapabilities(): Promise> { + const method = "engine_exchangeCapabilities"; + try { + const response = await this.rpc.fetchWithRetries< + EngineApiRpcReturnTypes[typeof method], + EngineApiRpcParamTypes[typeof method] + >({method, params: [supportedSszRestEndpoints]}, exchangeCapabilitiesOpts); + + return new Set(response.filter((capability) => supportedSszRestEndpoints.includes(capability))); + } catch (e) { + this.logger.debug("Unable to exchange SSZ-REST Engine API capabilities", {}, e as Error); + return new Set(); + } + } + + private async supportsSszRestEndpoint(endpoint: string): Promise { + return (await this.sszRestCapabilities)?.has(endpoint) ?? false; + } + + private async fetchQueued( + request: EngineRequest + ): Promise { + return this.rpcFetchQueue.push(() => + this.rpc.fetchWithRetries( + {method: request.method, params: request.params}, + request.methodOpts + ) + ); + } + /** * `engine_newPayloadV1` * From: https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.6/src/engine/specification.md#engine_newpayloadv1 @@ -215,16 +316,52 @@ export class ExecutionEngineHttp implements IExecutionEngine { parentBlockRoot?: Root, executionRequests?: ExecutionRequests ): Promise { - const method = - ForkSeq[fork] >= ForkSeq.gloas - ? "engine_newPayloadV5" - : ForkSeq[fork] >= ForkSeq.electra - ? "engine_newPayloadV4" - : ForkSeq[fork] >= ForkSeq.deneb - ? "engine_newPayloadV3" - : ForkSeq[fork] >= ForkSeq.capella - ? "engine_newPayloadV2" - : "engine_newPayloadV1"; + // EIP-8161: Try SSZ-REST first, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + const path = `/engine/v${newPayloadVersion(fork)}/payloads`; + const endpoint = `POST ${path}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST newPayload endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const body = encodeNewPayloadRequest( + fork, + executionPayload, + versionedHashes, + parentBlockRoot, + executionRequests + ); + const resp = await this.rpcFetchQueue.push(async () => { + if (!this.sszRestClient) throw Error("SSZ-REST client not configured"); + return this.sszRestClient.doRequest(path, body); + }); + const {status, latestValidHash, validationError} = decodePayloadStatus(resp); + this.updateEngineState(getExecutionEngineState({payloadStatus: status, oldState: this.state})); + + switch (status) { + case ExecutionPayloadStatus.VALID: + return {status, latestValidHash: latestValidHash ?? "0x0", validationError: null}; + case ExecutionPayloadStatus.INVALID: + return {status, latestValidHash, validationError}; + case ExecutionPayloadStatus.SYNCING: + case ExecutionPayloadStatus.ACCEPTED: + return {status, latestValidHash: null, validationError: null}; + default: + return { + status: ExecutionPayloadStatus.ELERROR, + latestValidHash: null, + validationError: `Invalid EL status on executePayload: ${status}`, + }; + } + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST newPayload failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + } const serializedExecutionPayload = serializeExecutionPayload(fork, executionPayload); @@ -272,7 +409,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { } const {status, latestValidHash, validationError} = await ( - this.rpcFetchQueue.push(engineRequest) as Promise + this.fetchQueued(engineRequest) as Promise ).catch((e: Error) => { if (e instanceof HttpRpcError || e instanceof ErrorJsonRpcResponse) { return {status: ExecutionPayloadStatus.ELERROR, latestValidHash: null, validationError: e.message}; @@ -347,6 +484,75 @@ export class ExecutionEngineHttp implements IExecutionEngine { finalizedBlockHash: RootHex, payloadAttributes?: PayloadAttributes ): Promise { + // EIP-8161: Try SSZ-REST first, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + const path = `/engine/v${forkchoiceUpdatedVersion(fork)}/forkchoice`; + const endpoint = `POST ${path}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST forkchoiceUpdate endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const body = encodeForkchoiceUpdatedRequest( + fork, + fromHex(headBlockHash), + fromHex(safeBlockHash), + fromHex(finalizedBlockHash), + payloadAttributes + ); + const resp = await this.rpcFetchQueue.push(async () => { + if (!this.sszRestClient) throw Error("SSZ-REST client not configured"); + return this.sszRestClient.doRequest(path, body); + }); + const decoded = decodeForkchoiceUpdatedResponse(resp); + const {status, validationError} = decoded.payloadStatus; + + this.updateEngineState(getExecutionEngineState({payloadStatus: status, oldState: this.state})); + this.metrics?.engineNotifyForkchoiceUpdateResult.inc({result: status}); + + const payloadAttributesRpc = payloadAttributes ? serializePayloadAttributes(payloadAttributes) : undefined; + + switch (status) { + case ExecutionPayloadStatus.VALID: + if (payloadAttributesRpc) { + if (decoded.payloadId === null) { + throw Error("Received null payloadId when payload attributes were provided"); + } + this.payloadIdCache.add( + {headBlockHash, finalizedBlockHash, ...payloadAttributesRpc}, + decoded.payloadId + ); + void this.prunePayloadIdCache(); + } + return decoded.payloadId; + + case ExecutionPayloadStatus.SYNCING: + if (payloadAttributes) { + throw Error("Execution Layer Syncing"); + } + return null; + + case ExecutionPayloadStatus.INVALID: + throw Error( + `Invalid ${payloadAttributes ? "prepare payload" : "forkchoice request"}, validationError=${ + validationError ?? "" + }` + ); + + default: + throw Error(`Unknown status ${status}`); + } + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST forkchoiceUpdate failed, falling back to JSON-RPC", { + error: (e as Error).message, + }); + } else { + throw e; + } + } + } + } + // Once on capella, should this need to be permanently switched to v2 when payload attrs // not provided const method = @@ -363,7 +569,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { const fcUReqOpts = payloadAttributes !== undefined ? forkchoiceUpdatedV1Opts : {...forkchoiceUpdatedV1Opts, retries: 0}; - const request = this.rpcFetchQueue.push({ + const request = this.fetchQueued({ method, params: [{headBlockHash, safeBlockHash, finalizedBlockHash}, payloadAttributesRpc], methodOpts: fcUReqOpts, @@ -426,6 +632,33 @@ export class ExecutionEngineHttp implements IExecutionEngine { executionRequests?: ExecutionRequests; shouldOverrideBuilder?: boolean; }> { + // EIP-8161: Try SSZ-REST first, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + const pathPrefix = `/engine/v${getPayloadVersion(fork)}/payloads`; + const endpoint = `GET ${pathPrefix}/{payload_id}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST getPayload endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const resp = await this.sszRestClient.doGetRequest(`${pathPrefix}/${payloadId}`); + const decoded = decodeGetPayloadResponse(fork, resp); + return { + executionPayload: decoded.executionPayload, + executionPayloadValue: decoded.blockValue, + blobsBundle: decoded.blobsBundle, + executionRequests: decoded.executionRequests, + shouldOverrideBuilder: decoded.shouldOverrideBuilder, + }; + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getPayload failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + } + let method: keyof EngineApiRpcReturnTypes; switch (fork) { case ForkName.phase0: @@ -467,8 +700,31 @@ export class ExecutionEngineHttp implements IExecutionEngine { } async getPayloadBodiesByHash(_fork: ForkName, blockHashes: RootHex[]): Promise<(ExecutionPayloadBody | null)[]> { - const method = "engine_getPayloadBodiesByHashV1"; assertReqSizeLimit(blockHashes.length, 32); + + if (this.sszRestClient) { + const path = "/engine/v1/payloads/bodies/by-hash"; + const endpoint = `POST ${path}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST getPayloadBodiesByHash endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const body = encodeGetPayloadBodiesByHashRequest(blockHashes.map((h) => fromHex(h))); + const resp = await this.sszRestClient.doRequest(path, body); + return decodePayloadBodiesV1Response(resp); + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getPayloadBodiesByHash failed, falling back to JSON-RPC", { + error: (e as Error).message, + }); + } else { + throw e; + } + } + } + } + + const method = "engine_getPayloadBodiesByHashV1"; const response = await this.rpc.fetchWithRetries< EngineApiRpcReturnTypes[typeof method], EngineApiRpcParamTypes[typeof method] @@ -481,8 +737,31 @@ export class ExecutionEngineHttp implements IExecutionEngine { startBlockNumber: number, blockCount: number ): Promise<(ExecutionPayloadBody | null)[]> { - const method = "engine_getPayloadBodiesByRangeV1"; assertReqSizeLimit(blockCount, 32); + + if (this.sszRestClient) { + const path = "/engine/v1/payloads/bodies/by-range"; + const endpoint = `POST ${path}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST getPayloadBodiesByRange endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const body = encodeGetPayloadBodiesByRangeRequest(startBlockNumber, blockCount); + const resp = await this.sszRestClient.doRequest(path, body); + return decodePayloadBodiesV1Response(resp); + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getPayloadBodiesByRange failed, falling back to JSON-RPC", { + error: (e as Error).message, + }); + } else { + throw e; + } + } + } + } + + const method = "engine_getPayloadBodiesByRangeV1"; const start = numToQuantity(startBlockNumber); const count = numToQuantity(blockCount); const response = await this.rpc.fetchWithRetries< @@ -507,6 +786,42 @@ export class ExecutionEngineHttp implements IExecutionEngine { versionedHashes: VersionedHashes ): Promise { assertReqSizeLimit(versionedHashes.length, MAX_VERSIONED_HASHES); + + // EIP-8161: Try SSZ-REST first for getBlobs, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + const version = getBlobsVersion(fork); + const path = `/engine/v${version}/blobs`; + const endpoint = `POST ${path}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST getBlobs endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const body = encodeGetBlobsRequest(versionedHashes); + const resp = await this.sszRestClient.doRequest(path, body); + // HTTP 204 (syncing, or any missing blob in v2) maps to null per spec. + if (resp.length === 0) { + return null; + } + if (version === 1) { + // Spec v1 returns a flat list of found blobs with no per-element + // nullability — potentially shorter than the request. Map missing + // slots to null to keep the result indexable by request position + // (matches the JSON-RPC v1 contract). This assumes ELs return + // results in request order with trailing missing entries. + const found = decodeGetBlobsV1Response(resp); + return versionedHashes.map((_, i) => found[i] ?? null); + } + return decodeGetBlobsV2Response(resp); + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getBlobs failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + } + const versionedHashesHex = versionedHashes.map(bytesToData); if (isForkPostFulu(fork)) { return await this.getBlobsV2(versionedHashesHex); @@ -583,17 +898,12 @@ export class ExecutionEngineHttp implements IExecutionEngine { } private async getClientVersion(clientVersion: ClientVersion): Promise { - const method = "engine_getClientVersionV1"; - - const response = await this.rpc.fetchWithRetries< - EngineApiRpcReturnTypes[typeof method], - EngineApiRpcParamTypes[typeof method] - >({method, params: [{...clientVersion, commit: `0x${clientVersion.commit}`}]}, getClientVersionOpts); - - const clientVersions = response.map((cv) => { - const code = cv.code in ClientCode ? ClientCode[cv.code as keyof typeof ClientCode] : ClientCode.XX; - return {code, name: cv.name, version: cv.version, commit: strip0xPrefix(cv.commit)}; - }); + const clientVersions = (await this.fetchClientVersions(clientVersion)).map((cv) => ({ + code: cv.code in ClientCode ? ClientCode[cv.code as keyof typeof ClientCode] : ClientCode.XX, + name: cv.name, + version: cv.version, + commit: strip0xPrefix(cv.commit), + })); if (clientVersions.length === 0) { throw Error("Received empty client versions array"); @@ -605,6 +915,38 @@ export class ExecutionEngineHttp implements IExecutionEngine { return clientVersions; } + private async fetchClientVersions( + clientVersion: ClientVersion + ): Promise<{code: string; name: string; version: string; commit: string}[]> { + if (this.sszRestClient) { + const path = "/engine/v1/client/version"; + const endpoint = `POST ${path}`; + if (!(await this.supportsSszRestEndpoint(endpoint))) { + this.logger.debug("SSZ-REST getClientVersion endpoint not advertised, using JSON-RPC", {endpoint}); + } else { + try { + const body = encodeGetClientVersionRequest(clientVersion); + const resp = await this.sszRestClient.doRequest(path, body); + return decodeGetClientVersionResponse(resp); + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getClientVersion failed, falling back to JSON-RPC", { + error: (e as Error).message, + }); + } else { + throw e; + } + } + } + } + + const method = "engine_getClientVersionV1"; + return this.rpc.fetchWithRetries( + {method, params: [{...clientVersion, commit: `0x${clientVersion.commit}`}]}, + getClientVersionOpts + ); + } + private updateEngineState(newState: ExecutionEngineState): void { const oldState = this.state; @@ -636,10 +978,16 @@ export class ExecutionEngineHttp implements IExecutionEngine { } } +// Linear-time trailing-slash strip. Avoids /\/+$/ which CodeQL flags as a +// polynomial-backtracking regex (js/polynomial-redos). +function stripTrailingSlashes(s: string): string { + let end = s.length; + while (end > 0 && s.charCodeAt(end - 1) === 0x2f /* '/' */) end--; + return end === s.length ? s : s.slice(0, end); +} + type EngineRequestKey = keyof EngineApiRpcParamTypes; type EngineRequestByKey = { [K in EngineRequestKey]: {method: K; params: EngineApiRpcParamTypes[K]; methodOpts: ReqOpts}; }; -type EngineRequest = EngineRequestByKey[EngineRequestKey]; -type EngineResponseByKey = {[K in EngineRequestKey]: EngineApiRpcReturnTypes[K]}; -type EngineResponse = EngineResponseByKey[EngineRequestKey]; +type EngineRequest = EngineRequestByKey[K]; diff --git a/packages/beacon-node/src/execution/engine/mock.ts b/packages/beacon-node/src/execution/engine/mock.ts index d452b8c189c8..887d64b8650d 100644 --- a/packages/beacon-node/src/execution/engine/mock.ts +++ b/packages/beacon-node/src/execution/engine/mock.ts @@ -146,11 +146,18 @@ export class ExecutionEngineMockBackend implements JsonRpcBackend { engine_getPayloadBodiesByHashV1: this.getPayloadBodiesByHash.bind(this), engine_getPayloadBodiesByRangeV1: this.getPayloadBodiesByRange.bind(this), engine_getClientVersionV1: this.getClientVersionV1.bind(this), + engine_exchangeCapabilities: this.exchangeCapabilities.bind(this), engine_getBlobsV1: this.getBlobs.bind(this), engine_getBlobsV2: this.getBlobsV2.bind(this), }; } + private exchangeCapabilities( + capabilities: EngineApiRpcParamTypes["engine_exchangeCapabilities"][0] + ): EngineApiRpcReturnTypes["engine_exchangeCapabilities"] { + return capabilities; + } + private getPayloadBodiesByHash( _blockHex: EngineApiRpcParamTypes["engine_getPayloadBodiesByHashV1"][0] ): EngineApiRpcReturnTypes["engine_getPayloadBodiesByHashV1"] { diff --git a/packages/beacon-node/src/execution/engine/sszRestClient.ts b/packages/beacon-node/src/execution/engine/sszRestClient.ts new file mode 100644 index 000000000000..1d44d452ec7f --- /dev/null +++ b/packages/beacon-node/src/execution/engine/sszRestClient.ts @@ -0,0 +1,142 @@ +import {fetch, fromHex, isFetchError} from "@lodestar/utils"; +import {JwtClaim, encodeJwtToken} from "./jwt.js"; +import {HTTP_CONNECTION_ERROR_CODES, HTTP_FATAL_ERROR_CODES} from "./utils.js"; + +export interface SszRestClientOpts { + baseUrl: string; + jwtSecretHex?: string; + jwtId?: string; + jwtVersion?: string; + /** Request timeout in milliseconds. Defaults to 12000 */ + timeout?: number; +} + +/** + * Error thrown when the SSZ-REST endpoint returns a non-200 response. + * The EL returns JSON error bodies of the form {"code": N, "message": "..."}. + */ +export class SszRestError extends Error { + readonly code: number; + constructor(code: number, message: string) { + super(`SSZ-REST error ${code}: ${message}`); + this.code = code; + } +} + +/** + * Determines whether an error is a network-level error (DNS failure, connection + * refused, timeout, etc.) that should trigger a fallback to JSON-RPC rather than + * being propagated directly to the caller. + */ +export function isSszRestNetworkError(e: unknown): boolean { + if (e instanceof SszRestError) { + // HTTP responses carry Engine API semantics in the SSZ-REST spec. Once an + // endpoint has been negotiated, do not hide malformed SSZ, auth failures, + // unknown payload IDs, invalid forkchoice state, or EL errors by retrying + // the same operation through JSON-RPC. + return false; + } + // Node.js fetch errors (ECONNREFUSED, ENOTFOUND, etc.) + if (isFetchError(e)) { + const allCodes = [...HTTP_FATAL_ERROR_CODES, ...HTTP_CONNECTION_ERROR_CODES]; + return allCodes.includes((e as {code: string}).code) || (e as {code: string}).code === "ERR_ABORTED"; + } + // TypeError is thrown by fetch on DNS resolution failure in some runtimes + if (e instanceof TypeError) { + return true; + } + // AbortError from timeout + if (e instanceof DOMException && e.name === "AbortError") { + return true; + } + return false; +} + +const DEFAULT_TIMEOUT = 12_000; + +/** + * SSZ-REST HTTP client for EIP-8161 Engine API transport. + * + * Sends binary (application/octet-stream) POST requests and receives binary + * responses. JWT authentication is supported identically to the JSON-RPC client. + */ +export class SszRestClient { + private readonly baseUrl: string; + private readonly jwtSecret: Uint8Array | undefined; + private readonly jwtId: string | undefined; + private readonly jwtVersion: string | undefined; + private readonly timeout: number; + + constructor(opts: SszRestClientOpts) { + // Trailing slash normalisation is the caller's responsibility. + this.baseUrl = opts.baseUrl; + this.jwtSecret = opts.jwtSecretHex ? fromHex(opts.jwtSecretHex) : undefined; + this.jwtId = opts.jwtId; + this.jwtVersion = opts.jwtVersion; + this.timeout = opts.timeout ?? DEFAULT_TIMEOUT; + } + + /** + * POST binary body to `baseUrl + path` and return the response as Uint8Array. + * + * - Content-Type: application/octet-stream + * - Authorization: Bearer (when jwtSecret is configured) + * - On 200: returns response body as Uint8Array + * - On non-200: attempts to parse JSON error body and throws SszRestError + */ + async doRequest(path: string, body: Uint8Array): Promise { + return this._fetch(path, "POST", body); + } + + /** + * GET request (no body) to `baseUrl + path` and return the response as Uint8Array. + * Used for getPayload where payload_id is in the URL path. + */ + async doGetRequest(path: string): Promise { + return this._fetch(path, "GET", undefined); + } + + private async _fetch(path: string, method: string, body: Uint8Array | undefined): Promise { + const url = `${this.baseUrl}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeout); + + try { + const headers: Record = { + Accept: "application/octet-stream", + }; + if (body) { + headers["Content-Type"] = "application/octet-stream"; + } + + if (this.jwtSecret) { + const jwtClaim: JwtClaim = { + iat: Math.floor(Date.now() / 1000), + id: this.jwtId, + clv: this.jwtVersion, + }; + const token = encodeJwtToken(jwtClaim, this.jwtSecret); + headers.Authorization = `Bearer ${token}`; + } + + const res = await fetch(url, { + method, + body: body ? (body as unknown as BodyInit) : undefined, + headers, + signal: controller.signal, + }); + + if (!res.ok) { + // Error responses use text/plain per execution-apis SSZ spec + const code = res.status; + const message = await res.text().catch(() => res.statusText); + throw new SszRestError(code, message); + } + + const arrayBuf = await res.arrayBuffer(); + return new Uint8Array(arrayBuf); + } finally { + clearTimeout(timeout); + } + } +} diff --git a/packages/beacon-node/src/execution/engine/sszRestEncoding.ts b/packages/beacon-node/src/execution/engine/sszRestEncoding.ts new file mode 100644 index 000000000000..6d909b87111d --- /dev/null +++ b/packages/beacon-node/src/execution/engine/sszRestEncoding.ts @@ -0,0 +1,753 @@ +import {ByteListType, ByteVectorType, ContainerType, ListCompositeType, UintNumberType} from "@chainsafe/ssz"; +import { + CELLS_PER_EXT_BLOB, + CONSOLIDATION_REQUEST_TYPE, + DEPOSIT_REQUEST_TYPE, + ForkName, + ForkSeq, + MAX_BLOB_COMMITMENTS_PER_BLOCK, + MAX_BYTES_PER_TRANSACTION, + MAX_TRANSACTIONS_PER_PAYLOAD, + WITHDRAWAL_REQUEST_TYPE, +} from "@lodestar/params"; +import {ExecutionPayload, ExecutionRequests, RootHex, ssz} from "@lodestar/types"; +import type {BlobAndProof} from "@lodestar/types/deneb"; +import type {BlobAndProofV2} from "@lodestar/types/fulu"; +import {fromHex, toHex} from "@lodestar/utils"; +import {ExecutionPayloadStatus, PayloadAttributes, VersionedHashes} from "./interface.js"; +import {PayloadId} from "./payloadIdCache.js"; + +// Spec constants from ethereum/execution-apis#764 not exported by @lodestar/params. +const MAX_BLOB_HASHES_REQUEST = 128; +const MAX_EXECUTION_REQUESTS = 256; +const MAX_ERROR_MESSAGE_LENGTH = 1024; +const MAX_CAPABILITY_NAME_LENGTH = 64; +const MAX_CAPABILITIES = 64; +const BLOB_SIZE = 131072; + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +const Uint8 = new UintNumberType(1); +const Bytes8 = new ByteVectorType(8); +const Bytes20 = new ByteVectorType(20); +const Bytes32 = new ByteVectorType(32); +const Bytes48 = new ByteVectorType(48); + +// Nullable wrapper: spec encodes `T or null` as `List[T, 1]` — 0 = absent, 1 = present. +const NullableHash = new ListCompositeType(Bytes32, 1); +const NullablePayloadId = new ListCompositeType(Bytes8, 1); + +const ValidationErrorBytes = new ByteListType(MAX_ERROR_MESSAGE_LENGTH); +const TransactionBytes = new ByteListType(MAX_BYTES_PER_TRANSACTION); + +const VersionedHashesList = new ListCompositeType(Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK); +const BlobHashesRequest = new ListCompositeType(Bytes32, MAX_BLOB_HASHES_REQUEST); + +// `execution_requests` is a flat list of opaque byte-lists; each element is +// `type_byte || ssz_bytes`. CL forwards them to the EL without parsing. +const ExecutionRequestsList = new ListCompositeType(TransactionBytes, MAX_EXECUTION_REQUESTS); + +// --------------------------------------------------------------------------- +// Fork-independent containers +// --------------------------------------------------------------------------- + +const PayloadStatusV1 = new ContainerType( + {status: Uint8, latestValidHash: NullableHash, validationError: ValidationErrorBytes}, + {typeName: "PayloadStatusV1"} +); + +const ForkchoiceStateV1 = new ContainerType( + {headBlockHash: Bytes32, safeBlockHash: Bytes32, finalizedBlockHash: Bytes32}, + {typeName: "ForkchoiceStateV1"} +); + +const ForkchoiceUpdatedResponseV1 = new ContainerType( + {payloadStatus: PayloadStatusV1, payloadId: NullablePayloadId}, + {typeName: "ForkchoiceUpdatedResponseV1"} +); + +const ExchangeCapabilitiesContainer = new ContainerType( + {capabilities: new ListCompositeType(new ByteListType(MAX_CAPABILITY_NAME_LENGTH), MAX_CAPABILITIES)}, + {typeName: "ExchangeCapabilities"} +); + +// --------------------------------------------------------------------------- +// PayloadAttributes (one container per fork) +// +// We cannot reuse `ssz.{fork}.PayloadAttributes` from @lodestar/types because +// they declare `suggestedFeeRecipient: stringType` — a JSON-only marker that +// throws on SSZ serialization. +// --------------------------------------------------------------------------- + +const PayloadAttributesV1Container = new ContainerType( + {timestamp: ssz.UintNum64, prevRandao: Bytes32, suggestedFeeRecipient: Bytes20}, + {typeName: "PayloadAttributesV1"} +); + +const PayloadAttributesV2Container = new ContainerType( + {...PayloadAttributesV1Container.fields, withdrawals: ssz.capella.Withdrawals}, + {typeName: "PayloadAttributesV2"} +); + +const PayloadAttributesV3Container = new ContainerType( + {...PayloadAttributesV2Container.fields, parentBeaconBlockRoot: Bytes32}, + {typeName: "PayloadAttributesV3"} +); + +const PayloadAttributesV4Container = new ContainerType( + {...PayloadAttributesV3Container.fields, slotNumber: ssz.UintNum64, targetGasLimit: ssz.UintNum64}, + {typeName: "PayloadAttributesV4"} +); + +const PayloadAttributesV1Optional = new ListCompositeType(PayloadAttributesV1Container, 1); +const PayloadAttributesV2Optional = new ListCompositeType(PayloadAttributesV2Container, 1); +const PayloadAttributesV3Optional = new ListCompositeType(PayloadAttributesV3Container, 1); +const PayloadAttributesV4Optional = new ListCompositeType(PayloadAttributesV4Container, 1); + +// --------------------------------------------------------------------------- +// NewPayload request containers (per version) +// --------------------------------------------------------------------------- + +const NewPayloadV1Request = new ContainerType( + {executionPayload: ssz.bellatrix.ExecutionPayload}, + {typeName: "NewPayloadV1Request"} +); + +const NewPayloadV2Request = new ContainerType( + {executionPayload: ssz.capella.ExecutionPayload}, + {typeName: "NewPayloadV2Request"} +); + +const NewPayloadV3Request = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + expectedBlobVersionedHashes: VersionedHashesList, + parentBeaconBlockRoot: Bytes32, + }, + {typeName: "NewPayloadV3Request"} +); + +const NewPayloadV4Request = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + expectedBlobVersionedHashes: VersionedHashesList, + parentBeaconBlockRoot: Bytes32, + executionRequests: ExecutionRequestsList, + }, + {typeName: "NewPayloadV4Request"} +); + +const NewPayloadV5Request = new ContainerType( + { + executionPayload: ssz.gloas.ExecutionPayload, + expectedBlobVersionedHashes: VersionedHashesList, + parentBeaconBlockRoot: Bytes32, + executionRequests: ExecutionRequestsList, + }, + {typeName: "NewPayloadV5Request"} +); + +// --------------------------------------------------------------------------- +// ForkchoiceUpdated request containers +// --------------------------------------------------------------------------- + +const ForkchoiceUpdatedV1Request = new ContainerType( + {forkchoiceState: ForkchoiceStateV1, payloadAttributes: PayloadAttributesV1Optional}, + {typeName: "ForkchoiceUpdatedV1Request"} +); + +const ForkchoiceUpdatedV2Request = new ContainerType( + {forkchoiceState: ForkchoiceStateV1, payloadAttributes: PayloadAttributesV2Optional}, + {typeName: "ForkchoiceUpdatedV2Request"} +); + +const ForkchoiceUpdatedV3Request = new ContainerType( + {forkchoiceState: ForkchoiceStateV1, payloadAttributes: PayloadAttributesV3Optional}, + {typeName: "ForkchoiceUpdatedV3Request"} +); + +const ForkchoiceUpdatedV4Request = new ContainerType( + {forkchoiceState: ForkchoiceStateV1, payloadAttributes: PayloadAttributesV4Optional}, + {typeName: "ForkchoiceUpdatedV4Request"} +); + +// --------------------------------------------------------------------------- +// GetPayload response containers +// --------------------------------------------------------------------------- + +const GetPayloadResponseV2 = new ContainerType( + {executionPayload: ssz.capella.ExecutionPayload, blockValue: ssz.UintBn256}, + {typeName: "GetPayloadResponseV2"} +); + +const GetPayloadResponseV3 = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.deneb.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + }, + {typeName: "GetPayloadResponseV3"} +); + +const GetPayloadResponseV4 = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.deneb.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + executionRequests: ExecutionRequestsList, + }, + {typeName: "GetPayloadResponseV4"} +); + +const GetPayloadResponseV5 = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.fulu.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + executionRequests: ExecutionRequestsList, + }, + {typeName: "GetPayloadResponseV5"} +); + +const GetPayloadResponseV6 = new ContainerType( + { + executionPayload: ssz.gloas.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.fulu.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + executionRequests: ExecutionRequestsList, + }, + {typeName: "GetPayloadResponseV6"} +); + +// --------------------------------------------------------------------------- +// GetBlobs request / response containers +// --------------------------------------------------------------------------- + +const GetBlobsRequest = new ContainerType({blobVersionedHashes: BlobHashesRequest}, {typeName: "GetBlobsRequest"}); + +const BlobBytes = new ByteVectorType(BLOB_SIZE); + +const BlobAndProofV1Container = new ContainerType({blob: BlobBytes, proof: Bytes48}, {typeName: "BlobAndProofV1"}); + +const BlobAndProofV2Container = new ContainerType( + {blob: BlobBytes, proofs: new ListCompositeType(Bytes48, CELLS_PER_EXT_BLOB)}, + {typeName: "BlobAndProofV2"} +); + +const GetBlobsV1Response = new ContainerType( + {blobsAndProofs: new ListCompositeType(BlobAndProofV1Container, MAX_BLOB_HASHES_REQUEST)}, + {typeName: "GetBlobsV1Response"} +); + +const GetBlobsV2Response = new ContainerType( + {blobsAndProofs: new ListCompositeType(BlobAndProofV2Container, MAX_BLOB_HASHES_REQUEST)}, + {typeName: "GetBlobsV2Response"} +); + +// --------------------------------------------------------------------------- +// Payload bodies (Shanghai) +// --------------------------------------------------------------------------- + +const MAX_PAYLOAD_BODIES_REQUEST = 32; +const TransactionsList = new ListCompositeType(TransactionBytes, MAX_TRANSACTIONS_PER_PAYLOAD); + +const ExecutionPayloadBodyV1Container = new ContainerType( + {transactions: TransactionsList, withdrawals: ssz.capella.Withdrawals}, + {typeName: "ExecutionPayloadBodyV1"} +); +// Nullable wrapper: 0 elements = unknown block, 1 element = known block. +const ExecutionPayloadBodyV1Optional = new ListCompositeType(ExecutionPayloadBodyV1Container, 1); + +const PayloadBodiesV1Response = new ContainerType( + {payloadBodies: new ListCompositeType(ExecutionPayloadBodyV1Optional, MAX_PAYLOAD_BODIES_REQUEST)}, + {typeName: "PayloadBodiesV1Response"} +); + +const GetPayloadBodiesByHashV1Request = new ContainerType( + {blockHashes: new ListCompositeType(Bytes32, MAX_PAYLOAD_BODIES_REQUEST)}, + {typeName: "GetPayloadBodiesByHashV1Request"} +); + +const GetPayloadBodiesByRangeV1Request = new ContainerType( + {start: ssz.UintNum64, count: ssz.UintNum64}, + {typeName: "GetPayloadBodiesByRangeV1Request"} +); + +// --------------------------------------------------------------------------- +// Client version (identification.md) +// --------------------------------------------------------------------------- + +const MAX_CLIENT_CODE_LENGTH = 2; +const MAX_CLIENT_NAME_LENGTH = 64; +const MAX_CLIENT_VERSION_LENGTH = 64; +const MAX_CLIENT_VERSIONS = 4; +const Bytes4 = new ByteVectorType(4); + +const ClientVersionV1Container = new ContainerType( + { + code: new ByteListType(MAX_CLIENT_CODE_LENGTH), + name: new ByteListType(MAX_CLIENT_NAME_LENGTH), + version: new ByteListType(MAX_CLIENT_VERSION_LENGTH), + commit: Bytes4, + }, + {typeName: "ClientVersionV1"} +); + +const GetClientVersionV1Request = new ContainerType( + {clientVersion: ClientVersionV1Container}, + {typeName: "GetClientVersionV1Request"} +); + +const GetClientVersionV1Response = new ContainerType( + {versions: new ListCompositeType(ClientVersionV1Container, MAX_CLIENT_VERSIONS)}, + {typeName: "GetClientVersionV1Response"} +); + +// --------------------------------------------------------------------------- +// Fork → version mapping +// --------------------------------------------------------------------------- + +/** + * REST endpoint version for `engine_newPayload`. + * Spec: Paris=v1, Shanghai=v2, Cancun=v3, Prague=v4, Amsterdam=v5. + * Osaka (Fulu) does not bump the newPayload version. + */ +export function newPayloadVersion(fork: ForkName): 1 | 2 | 3 | 4 | 5 { + const seq = ForkSeq[fork]; + if (seq >= ForkSeq.gloas) return 5; + if (seq >= ForkSeq.electra) return 4; + if (seq >= ForkSeq.deneb) return 3; + if (seq >= ForkSeq.capella) return 2; + return 1; +} + +/** + * REST endpoint version for `engine_getPayload`. + * Spec: Paris=v1, Shanghai=v2, Cancun=v3, Prague=v4, Osaka=v5, Amsterdam=v6. + */ +export function getPayloadVersion(fork: ForkName): 1 | 2 | 3 | 4 | 5 | 6 { + const seq = ForkSeq[fork]; + if (seq >= ForkSeq.gloas) return 6; + if (seq >= ForkSeq.fulu) return 5; + if (seq >= ForkSeq.electra) return 4; + if (seq >= ForkSeq.deneb) return 3; + if (seq >= ForkSeq.capella) return 2; + return 1; +} + +/** + * REST endpoint version for `engine_forkchoiceUpdated`. + * Spec: Paris=v1, Shanghai=v2, Cancun=v3, Amsterdam=v4. + */ +export function forkchoiceUpdatedVersion(fork: ForkName): 1 | 2 | 3 | 4 { + const seq = ForkSeq[fork]; + if (seq >= ForkSeq.gloas) return 4; + if (seq >= ForkSeq.deneb) return 3; + if (seq >= ForkSeq.capella) return 2; + return 1; +} + +/** + * REST endpoint version for `engine_getBlobs`. + * + * Cancun (deneb) → v1 + * Osaka (fulu) → v2 + * + * Osaka also defines v3 (`List[List[BlobAndProofV2, 1], MAX]`, per-element + * nullable) alongside v2 (all-or-nothing — HTTP 204 when any blob is + * missing). Lodestar picks v2 deliberately: + * + * 1. `IExecutionEngine.getBlobs` post-Fulu returns + * `BlobAndProofV2[] | null` — all-or-nothing by design. v2 maps onto + * it directly; v3 would require either changing the interface to + * `(BlobAndProofV2 | null)[]` or a collapse step that throws v3's + * per-element information away. + * 2. The JSON-RPC path uses `engine_getBlobsV2`. Keeping the same version + * on SSZ-REST avoids transport-asymmetric semantics for the same + * logical operation. + * 3. Spec guidance (osaka.md `engine_getBlobsV3`): "For an all-or-nothing + * query style, refer to `engine_getBlobsV2`." + * 4. `deserializeBlobAndProofsV2IntoBytes` reuses caller-supplied buffers + * on the block-production hot path; that optimisation assumes + * all-or-nothing semantics. + * + * Revisit if Lodestar grows a consumer that benefits from per-element + * granularity (e.g. parallel EL+gossip blob fetching). Nethermind and + * Erigon ELs both serve v2 alongside v3, so picking v2 has no interop cost. + */ +export function getBlobsVersion(fork: ForkName): 1 | 2 { + return ForkSeq[fork] >= ForkSeq.fulu ? 2 : 1; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildExecutionRequestsList(executionRequests: ExecutionRequests): Uint8Array[] { + const items: Uint8Array[] = []; + const prefix = (typeByte: number, body: Uint8Array): Uint8Array => { + const out = new Uint8Array(1 + body.length); + out[0] = typeByte; + out.set(body, 1); + return out; + }; + if (executionRequests.deposits.length > 0) { + items.push(prefix(DEPOSIT_REQUEST_TYPE, ssz.electra.DepositRequests.serialize(executionRequests.deposits))); + } + if (executionRequests.withdrawals.length > 0) { + items.push( + prefix(WITHDRAWAL_REQUEST_TYPE, ssz.electra.WithdrawalRequests.serialize(executionRequests.withdrawals)) + ); + } + if (executionRequests.consolidations.length > 0) { + items.push( + prefix(CONSOLIDATION_REQUEST_TYPE, ssz.electra.ConsolidationRequests.serialize(executionRequests.consolidations)) + ); + } + return items; +} + +function parseExecutionRequestsList(items: Uint8Array[]): ExecutionRequests { + const result: ExecutionRequests = {deposits: [], withdrawals: [], consolidations: []}; + for (const item of items) { + if (item.length === 0) throw Error("Execution request with empty data"); + const type = item[0]; + const body = item.subarray(1); + switch (type) { + case DEPOSIT_REQUEST_TYPE: + result.deposits = ssz.electra.DepositRequests.deserialize(body); + break; + case WITHDRAWAL_REQUEST_TYPE: + result.withdrawals = ssz.electra.WithdrawalRequests.deserialize(body); + break; + case CONSOLIDATION_REQUEST_TYPE: + result.consolidations = ssz.electra.ConsolidationRequests.deserialize(body); + break; + default: + throw Error(`Unknown execution request type=${type}`); + } + } + return result; +} + +function buildPayloadAttributesValue(fork: ForkName, attrs: PayloadAttributes): Record { + const seq = ForkSeq[fork]; + const base = { + timestamp: attrs.timestamp, + prevRandao: attrs.prevRandao, + suggestedFeeRecipient: fromHex(attrs.suggestedFeeRecipient), + }; + if (seq < ForkSeq.capella) return base; + const v2 = {...base, withdrawals: attrs.withdrawals ?? []}; + if (seq < ForkSeq.deneb) return v2; + if (attrs.parentBeaconBlockRoot === undefined) { + throw Error(`parentBeaconBlockRoot required in PayloadAttributes for fork=${fork}`); + } + const v3 = {...v2, parentBeaconBlockRoot: attrs.parentBeaconBlockRoot}; + if (seq < ForkSeq.gloas) return v3; + if (attrs.slotNumber === undefined) { + throw Error(`slotNumber required in PayloadAttributes for fork=${fork}`); + } + if (attrs.targetGasLimit === undefined) { + throw Error(`targetGasLimit required in PayloadAttributes for fork=${fork}`); + } + return {...v3, slotNumber: attrs.slotNumber, targetGasLimit: attrs.targetGasLimit}; +} + +function statusByteToEnum(byte: number): ExecutionPayloadStatus { + switch (byte) { + case 0: + return ExecutionPayloadStatus.VALID; + case 1: + return ExecutionPayloadStatus.INVALID; + case 2: + return ExecutionPayloadStatus.SYNCING; + case 3: + return ExecutionPayloadStatus.ACCEPTED; + default: + throw Error(`Unknown payload status byte=${byte}`); + } +} + +// --------------------------------------------------------------------------- +// Public encoders +// --------------------------------------------------------------------------- + +export function encodeNewPayloadRequest( + fork: ForkName, + executionPayload: ExecutionPayload, + versionedHashes?: VersionedHashes, + parentBeaconBlockRoot?: Uint8Array, + executionRequests?: ExecutionRequests +): Uint8Array { + const version = newPayloadVersion(fork); + + if (version === 1) { + return NewPayloadV1Request.serialize({executionPayload} as never); + } + if (version === 2) { + return NewPayloadV2Request.serialize({executionPayload} as never); + } + + if (versionedHashes === undefined || parentBeaconBlockRoot === undefined) { + throw Error(`versionedHashes and parentBeaconBlockRoot required for newPayload v${version}`); + } + + if (version === 3) { + return NewPayloadV3Request.serialize({ + executionPayload, + expectedBlobVersionedHashes: versionedHashes, + parentBeaconBlockRoot, + } as never); + } + + if (executionRequests === undefined) { + throw Error(`executionRequests required for newPayload v${version}`); + } + const requestsList = buildExecutionRequestsList(executionRequests); + + if (version === 4) { + return NewPayloadV4Request.serialize({ + executionPayload, + expectedBlobVersionedHashes: versionedHashes, + parentBeaconBlockRoot, + executionRequests: requestsList, + } as never); + } + + return NewPayloadV5Request.serialize({ + executionPayload, + expectedBlobVersionedHashes: versionedHashes, + parentBeaconBlockRoot, + executionRequests: requestsList, + } as never); +} + +export function encodeForkchoiceUpdatedRequest( + fork: ForkName, + headBlockHash: Uint8Array, + safeBlockHash: Uint8Array, + finalizedBlockHash: Uint8Array, + attributes?: PayloadAttributes +): Uint8Array { + const version = forkchoiceUpdatedVersion(fork); + const forkchoiceState = {headBlockHash, safeBlockHash, finalizedBlockHash}; + const payloadAttributes = attributes ? [buildPayloadAttributesValue(fork, attributes)] : []; + + switch (version) { + case 1: + return ForkchoiceUpdatedV1Request.serialize({forkchoiceState, payloadAttributes} as never); + case 2: + return ForkchoiceUpdatedV2Request.serialize({forkchoiceState, payloadAttributes} as never); + case 3: + return ForkchoiceUpdatedV3Request.serialize({forkchoiceState, payloadAttributes} as never); + case 4: + return ForkchoiceUpdatedV4Request.serialize({forkchoiceState, payloadAttributes} as never); + } +} + +export function encodeGetBlobsRequest(versionedHashes: VersionedHashes): Uint8Array { + return GetBlobsRequest.serialize({blobVersionedHashes: versionedHashes}); +} + +export function encodeGetPayloadBodiesByHashRequest(blockHashes: Uint8Array[]): Uint8Array { + return GetPayloadBodiesByHashV1Request.serialize({blockHashes}); +} + +export function encodeGetPayloadBodiesByRangeRequest(start: number, count: number): Uint8Array { + return GetPayloadBodiesByRangeV1Request.serialize({start, count}); +} + +export interface SszClientVersion { + code: string; + name: string; + version: string; + /** Hex string (with or without 0x prefix) — 4 bytes. */ + commit: string; +} + +export function encodeGetClientVersionRequest(clientVersion: SszClientVersion): Uint8Array { + const encoder = new TextEncoder(); + return GetClientVersionV1Request.serialize({ + clientVersion: { + code: encoder.encode(clientVersion.code), + name: encoder.encode(clientVersion.name), + version: encoder.encode(clientVersion.version), + commit: fromHex(clientVersion.commit), + }, + }); +} + +export function decodeGetClientVersionResponse(data: Uint8Array): SszClientVersion[] { + const parsed = GetClientVersionV1Response.deserialize(data); + const decoder = new TextDecoder(); + return parsed.versions.map((cv) => ({ + code: decoder.decode(cv.code), + name: decoder.decode(cv.name), + version: decoder.decode(cv.version), + commit: toHex(cv.commit).slice(2), // drop the `0x` prefix to match JSON-RPC path + })); +} + +export function encodeExchangeCapabilities(capabilities: string[]): Uint8Array { + const encoder = new TextEncoder(); + return ExchangeCapabilitiesContainer.serialize({ + capabilities: capabilities.map((s) => encoder.encode(s)), + }); +} + +// --------------------------------------------------------------------------- +// Public decoders +// --------------------------------------------------------------------------- + +export interface DecodedPayloadStatus { + status: ExecutionPayloadStatus; + latestValidHash: RootHex | null; + validationError: string | null; +} + +export function decodePayloadStatus(data: Uint8Array): DecodedPayloadStatus { + const parsed = PayloadStatusV1.deserialize(data); + const validationError = parsed.validationError.length > 0 ? new TextDecoder().decode(parsed.validationError) : null; + return { + status: statusByteToEnum(parsed.status), + latestValidHash: parsed.latestValidHash.length === 1 ? toHex(parsed.latestValidHash[0]) : null, + validationError, + }; +} + +export interface DecodedForkchoiceUpdatedResponse { + payloadStatus: DecodedPayloadStatus; + payloadId: PayloadId | null; +} + +export function decodeForkchoiceUpdatedResponse(data: Uint8Array): DecodedForkchoiceUpdatedResponse { + const parsed = ForkchoiceUpdatedResponseV1.deserialize(data); + const validationError = + parsed.payloadStatus.validationError.length > 0 + ? new TextDecoder().decode(parsed.payloadStatus.validationError) + : null; + return { + payloadStatus: { + status: statusByteToEnum(parsed.payloadStatus.status), + latestValidHash: + parsed.payloadStatus.latestValidHash.length === 1 ? toHex(parsed.payloadStatus.latestValidHash[0]) : null, + validationError, + }, + payloadId: parsed.payloadId.length === 1 ? toHex(parsed.payloadId[0]) : null, + }; +} + +export interface DecodedGetPayloadResponse { + executionPayload: ExecutionPayload; + blockValue: bigint; + blobsBundle?: import("@lodestar/types").BlobsBundle; + shouldOverrideBuilder: boolean; + executionRequests?: ExecutionRequests; +} + +export function decodeGetPayloadResponse(fork: ForkName, data: Uint8Array): DecodedGetPayloadResponse { + const version = getPayloadVersion(fork); + + // v1 is the raw ExecutionPayloadV1 with no wrapping container — no block + // value, no blobs bundle. Lodestar does not produce v1 traffic but we keep + // the branch for completeness. + if (version === 1) { + return { + executionPayload: ssz.bellatrix.ExecutionPayload.deserialize(data) as ExecutionPayload, + blockValue: 0n, + shouldOverrideBuilder: false, + }; + } + + if (version === 2) { + const parsed = GetPayloadResponseV2.deserialize(data); + return { + executionPayload: parsed.executionPayload as ExecutionPayload, + blockValue: parsed.blockValue, + shouldOverrideBuilder: false, + }; + } + + if (version === 3) { + const parsed = GetPayloadResponseV3.deserialize(data); + return { + executionPayload: parsed.executionPayload as ExecutionPayload, + blockValue: parsed.blockValue, + blobsBundle: parsed.blobsBundle, + shouldOverrideBuilder: parsed.shouldOverrideBuilder, + }; + } + + if (version === 4) { + const parsed = GetPayloadResponseV4.deserialize(data); + return { + executionPayload: parsed.executionPayload as ExecutionPayload, + blockValue: parsed.blockValue, + blobsBundle: parsed.blobsBundle, + shouldOverrideBuilder: parsed.shouldOverrideBuilder, + executionRequests: parseExecutionRequestsList(parsed.executionRequests), + }; + } + + if (version === 5) { + const parsed = GetPayloadResponseV5.deserialize(data); + return { + executionPayload: parsed.executionPayload as ExecutionPayload, + blockValue: parsed.blockValue, + blobsBundle: parsed.blobsBundle, + shouldOverrideBuilder: parsed.shouldOverrideBuilder, + executionRequests: parseExecutionRequestsList(parsed.executionRequests), + }; + } + + // v6 + const parsed = GetPayloadResponseV6.deserialize(data); + return { + executionPayload: parsed.executionPayload as ExecutionPayload, + blockValue: parsed.blockValue, + blobsBundle: parsed.blobsBundle, + shouldOverrideBuilder: parsed.shouldOverrideBuilder, + executionRequests: parseExecutionRequestsList(parsed.executionRequests), + }; +} + +export function decodeGetBlobsV1Response(data: Uint8Array): BlobAndProof[] { + const parsed = GetBlobsV1Response.deserialize(data); + return parsed.blobsAndProofs.map((item) => ({blob: item.blob, proof: item.proof})); +} + +export function decodeGetBlobsV2Response(data: Uint8Array): BlobAndProofV2[] { + const parsed = GetBlobsV2Response.deserialize(data); + return parsed.blobsAndProofs.map((item) => ({blob: item.blob, proofs: item.proofs})); +} + +export interface DecodedExecutionPayloadBody { + transactions: Uint8Array[]; + withdrawals: import("@lodestar/types").capella.Withdrawals; +} + +/** + * Spec PayloadBodiesV1Response wraps each entry in List[Body, 1] for per-element + * nullability: 0 elements = unknown block, 1 element = known block. + */ +export function decodePayloadBodiesV1Response(data: Uint8Array): (DecodedExecutionPayloadBody | null)[] { + const parsed = PayloadBodiesV1Response.deserialize(data); + return parsed.payloadBodies.map((wrapper) => { + if (wrapper.length === 0) return null; + const body = wrapper[0]; + return {transactions: body.transactions, withdrawals: body.withdrawals}; + }); +} + +export function decodeExchangeCapabilities(data: Uint8Array): string[] { + const parsed = ExchangeCapabilitiesContainer.deserialize(data); + const decoder = new TextDecoder(); + return parsed.capabilities.map((bytes) => decoder.decode(bytes)); +} diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index 896a9766cbb2..3b1e0f144099 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -98,6 +98,7 @@ export type EngineApiRpcParamTypes = { * Object - Instance of ClientVersion */ engine_getClientVersionV1: [ClientVersionRpc]; + engine_exchangeCapabilities: [string[]]; engine_getBlobsV1: [DATA[]]; engine_getBlobsV2: [DATA[]]; @@ -150,6 +151,7 @@ export type EngineApiRpcReturnTypes = { engine_getPayloadBodiesByRangeV1: (ExecutionPayloadBodyRpc | null)[]; engine_getClientVersionV1: ClientVersionRpc[]; + engine_exchangeCapabilities: string[]; engine_getBlobsV1: (BlobAndProofRpc | null)[]; engine_getBlobsV2: BlobAndProofV2Rpc[] | null; diff --git a/packages/beacon-node/test/unit/executionEngine/httpSszRest.test.ts b/packages/beacon-node/test/unit/executionEngine/httpSszRest.test.ts new file mode 100644 index 000000000000..4be533d4ab3d --- /dev/null +++ b/packages/beacon-node/test/unit/executionEngine/httpSszRest.test.ts @@ -0,0 +1,359 @@ +import {FastifyReply, FastifyRequest, fastify} from "fastify"; +import {afterEach, describe, expect, it} from "vitest"; +import {ByteListType, ByteVectorType, ContainerType, ListCompositeType, UintNumberType} from "@chainsafe/ssz"; +import {Logger} from "@lodestar/logger"; +import {ForkName} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import {defaultExecutionEngineHttpOpts} from "../../../src/execution/engine/http.js"; +import {encodeForkchoiceUpdatedRequest} from "../../../src/execution/engine/sszRestEncoding.js"; +import {parseExecutionPayload} from "../../../src/execution/engine/types.js"; +import {RpcPayload} from "../../../src/execution/engine/utils.js"; +import {IExecutionEngine, initializeExecutionEngine} from "../../../src/execution/index.js"; + +const Uint8 = new UintNumberType(1); +const Bytes8 = new ByteVectorType(8); +const Bytes20 = new ByteVectorType(20); +const Bytes32 = new ByteVectorType(32); +const NullableHash = new ListCompositeType(Bytes32, 1); +const NullablePayloadId = new ListCompositeType(Bytes8, 1); +const ValidationErrorBytes = new ByteListType(1024); + +const PayloadStatusV1 = new ContainerType( + {status: Uint8, latestValidHash: NullableHash, validationError: ValidationErrorBytes}, + {typeName: "PayloadStatusV1"} +); + +const ForkchoiceUpdatedResponseV1 = new ContainerType( + { + payloadStatus: PayloadStatusV1, + payloadId: NullablePayloadId, + }, + {typeName: "ForkchoiceUpdatedResponseV1"} +); + +const ForkchoiceStateV1 = new ContainerType( + {headBlockHash: Bytes32, safeBlockHash: Bytes32, finalizedBlockHash: Bytes32}, + {typeName: "ForkchoiceStateV1"} +); + +const PayloadAttributesV4 = new ContainerType( + { + timestamp: ssz.UintNum64, + prevRandao: Bytes32, + suggestedFeeRecipient: Bytes20, + withdrawals: ssz.capella.Withdrawals, + parentBeaconBlockRoot: Bytes32, + slotNumber: ssz.UintNum64, + targetGasLimit: ssz.UintNum64, + }, + {typeName: "PayloadAttributesV4"} +); + +const ForkchoiceUpdatedV4Request = new ContainerType( + { + forkchoiceState: ForkchoiceStateV1, + payloadAttributes: new ListCompositeType(PayloadAttributesV4, 1), + }, + {typeName: "ForkchoiceUpdatedV4Request"} +); + +const executionPayloadRpc = { + blockHash: "0xb084c10440f05f5a23a55d1d7ebcb1b3892935fb56f23cdc9a7f42c348eed174", + parentHash: "0xa0513a503d5bd6e89a144c3268e5b7e9da9dbf63df125a360e3950a7d0d67131", + feeRecipient: "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + stateRoot: "0xca3149fa9e37db08d1cd49c9061db1002ef1cd58db2210f2115c8c989b2bdf45", + receiptsRoot: "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + logsBloom: + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + prevRandao: "0x0000000000000000000000000000000000000000000000000000000000000000", + blockNumber: "0x1", + gasLimit: "0x989680", + gasUsed: "0x0", + timestamp: "0x5", + extraData: "0x", + baseFeePerGas: "0x7", + transactions: [], +}; + +const validExecutionPayloadRpc = {...executionPayloadRpc, logsBloom: `0x${"00".repeat(256)}`}; + +const forkChoiceHeadData = { + headBlockHash: "0xb084c10440f05f5a23a55d1d7ebcb1b3892935fb56f23cdc9a7f42c348eed174", + safeBlockHash: "0xb084c10440f05f5a23a55d1d7ebcb1b3892935fb56f23cdc9a7f42c348eed174", + finalizedBlockHash: "0xb084c10440f05f5a23a55d1d7ebcb1b3892935fb56f23cdc9a7f42c348eed174", +}; + +describe("ExecutionEngine / SSZ-REST", () => { + const afterCallbacks: (() => Promise | void)[] = []; + + afterEach(async () => { + while (afterCallbacks.length > 0) { + const callback = afterCallbacks.pop(); + if (callback) await callback(); + } + }); + + it("encodes targetGasLimit in forkchoiceUpdated v4 payload attributes", () => { + const root = new Uint8Array(32); + const body = encodeForkchoiceUpdatedRequest(ForkName.gloas, root, root, root, { + timestamp: 1, + prevRandao: root, + suggestedFeeRecipient: `0x${"11".repeat(20)}`, + withdrawals: [], + parentBeaconBlockRoot: root, + slotNumber: 2, + targetGasLimit: 30_000_000, + }); + + const parsed = ForkchoiceUpdatedV4Request.deserialize(body); + + expect(parsed.payloadAttributes[0].targetGasLimit).toBe(30_000_000); + }); + + it("does not call SSZ endpoint unless it is advertised by engine_exchangeCapabilities", async () => { + let sszNewPayloadRequests = 0; + let jsonRpcNewPayloadRequests = 0; + + const executionEngine = await startExecutionEngine( + { + capabilities: ["engine_newPayloadV1"], + async onJsonRpc(payload) { + if (payload.method === "engine_newPayloadV1") { + jsonRpcNewPayloadRequests++; + return {status: "VALID", latestValidHash: executionPayloadRpc.blockHash, validationError: null}; + } + return []; + }, + sszRoutes: { + "/engine/v1/payloads": async (_req, reply) => { + sszNewPayloadRequests++; + reply.code(500).send("SSZ endpoint should not be called"); + }, + }, + }, + afterCallbacks + ); + + await executionEngine.notifyNewPayload( + ForkName.bellatrix, + parseExecutionPayload(ForkName.bellatrix, validExecutionPayloadRpc).executionPayload + ); + + expect(sszNewPayloadRequests).toBe(0); + expect(jsonRpcNewPayloadRequests).toBe(1); + }); + + it("does not fall back to JSON-RPC when an advertised SSZ endpoint returns a semantic HTTP error", async () => { + let jsonRpcNewPayloadRequests = 0; + + const executionEngine = await startExecutionEngine( + { + capabilities: ["POST /engine/v1/payloads"], + async onJsonRpc(payload) { + if (payload.method === "engine_newPayloadV1") { + jsonRpcNewPayloadRequests++; + return {status: "VALID", latestValidHash: executionPayloadRpc.blockHash, validationError: null}; + } + return []; + }, + sszRoutes: { + "/engine/v1/payloads": async (_req, reply) => { + reply.code(400).send("Malformed SSZ"); + }, + }, + }, + afterCallbacks + ); + + await expect( + executionEngine.notifyNewPayload( + ForkName.bellatrix, + parseExecutionPayload(ForkName.bellatrix, validExecutionPayloadRpc).executionPayload + ) + ).rejects.toThrow("SSZ-REST error 400: Malformed SSZ"); + + expect(jsonRpcNewPayloadRequests).toBe(0); + }); + + it("pads SSZ getBlobsV1 response with null when the EL returns fewer blobs than requested", async () => { + let sszGetBlobsRequests = 0; + let jsonRpcGetBlobsRequests = 0; + + const executionEngine = await startExecutionEngine( + { + capabilities: ["POST /engine/v1/blobs"], + async onJsonRpc(payload) { + if (payload.method === "engine_getBlobsV1") { + jsonRpcGetBlobsRequests++; + return [null]; + } + return []; + }, + sszRoutes: { + "/engine/v1/blobs": async (_req, reply) => { + sszGetBlobsRequests++; + // Spec v1 has no per-element nullability; we return an empty list + // to simulate "no blobs found" and rely on the http.ts path to pad + // back up to the request length. + sendSsz(reply, emptyGetBlobsV1Response()); + }, + }, + }, + afterCallbacks + ); + + const response = await executionEngine.getBlobs(ForkName.deneb, [new Uint8Array(32)]); + + expect(response).toEqual([null]); + expect(sszGetBlobsRequests).toBe(1); + expect(jsonRpcGetBlobsRequests).toBe(0); + }); + + it("serializes SSZ newPayload and forkchoiceUpdated through the Engine queue", async () => { + const events: string[] = []; + let releaseNewPayload = (): void => { + throw Error("releaseNewPayload called before request started"); + }; + + const executionEngine = await startExecutionEngine( + { + capabilities: ["POST /engine/v1/payloads", "POST /engine/v1/forkchoice"], + sszRoutes: { + "/engine/v1/payloads": async (_req, reply) => { + events.push("newPayload:start"); + await new Promise((resolve) => { + releaseNewPayload = resolve; + }); + events.push("newPayload:end"); + sendSsz(reply, validPayloadStatus()); + }, + "/engine/v1/forkchoice": async (_req, reply) => { + events.push("forkchoice:start"); + sendSsz(reply, validForkchoiceUpdatedResponse()); + }, + }, + }, + afterCallbacks + ); + + const newPayloadPromise = executionEngine.notifyNewPayload( + ForkName.bellatrix, + parseExecutionPayload(ForkName.bellatrix, validExecutionPayloadRpc).executionPayload + ); + + await waitUntil(() => events.includes("newPayload:start")); + + const forkchoicePromise = executionEngine.notifyForkchoiceUpdate( + ForkName.bellatrix, + forkChoiceHeadData.headBlockHash, + forkChoiceHeadData.safeBlockHash, + forkChoiceHeadData.finalizedBlockHash + ); + + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(events).toEqual(["newPayload:start"]); + + releaseNewPayload(); + await Promise.all([newPayloadPromise, forkchoicePromise]); + + expect(events).toEqual(["newPayload:start", "newPayload:end", "forkchoice:start"]); + }); +}); + +type SszRouteHandler = (req: FastifyRequest, reply: FastifyReply) => Promise | void; + +type EngineStubOpts = { + capabilities: string[]; + onJsonRpc?: (payload: RpcPayload) => Promise | unknown; + sszRoutes?: Partial>; +}; + +async function startExecutionEngine( + opts: EngineStubOpts, + afterCallbacks: (() => Promise | void)[] +): Promise { + const controller = new AbortController(); + const server = fastify({logger: false}); + + server.addContentTypeParser("application/octet-stream", {parseAs: "buffer"}, (_req, body, done) => { + done(null, body); + }); + + server.post("/", async (req) => { + const payload = req.body as RpcPayload; + if (payload.method === "engine_exchangeCapabilities") { + return {jsonrpc: "2.0", id: 1, result: opts.capabilities}; + } + if (payload.method === "engine_getClientVersionV1") { + return {jsonrpc: "2.0", id: 1, result: [{code: "GE", name: "geth", version: "test", commit: "0x00000000"}]}; + } + return {jsonrpc: "2.0", id: 1, result: await opts.onJsonRpc?.(payload)}; + }); + + for (const [path, handler] of Object.entries(opts.sszRoutes ?? {})) { + if (!handler) continue; + server.post(path, handler); + } + + afterCallbacks.push(async () => { + controller.abort(); + await server.close(); + }); + + const baseUrl = await server.listen({host: "127.0.0.1", port: 0}); + + return initializeExecutionEngine( + { + mode: "http", + urls: [baseUrl], + retries: defaultExecutionEngineHttpOpts.retries, + retryDelay: defaultExecutionEngineHttpOpts.retryDelay, + sszRest: true, + }, + {signal: controller.signal, logger: console as unknown as Logger} + ); +} + +function validPayloadStatus(): Uint8Array { + return PayloadStatusV1.serialize({ + status: 0, + latestValidHash: [new Uint8Array(32)], + validationError: new Uint8Array(), + }); +} + +function validForkchoiceUpdatedResponse(): Uint8Array { + return ForkchoiceUpdatedResponseV1.serialize({ + payloadStatus: { + status: 0, + latestValidHash: [new Uint8Array(32)], + validationError: new Uint8Array(), + }, + payloadId: [], + }); +} + +const Bytes48 = new ByteVectorType(48); +const BlobBytes = new ByteVectorType(131072); +const BlobAndProofV1 = new ContainerType({blob: BlobBytes, proof: Bytes48}, {typeName: "BlobAndProofV1"}); +const GetBlobsV1Response = new ContainerType( + {blobsAndProofs: new ListCompositeType(BlobAndProofV1, 128)}, + {typeName: "GetBlobsV1Response"} +); + +function emptyGetBlobsV1Response(): Uint8Array { + return GetBlobsV1Response.serialize({blobsAndProofs: []}); +} + +function sendSsz(reply: FastifyReply, data: Uint8Array): void { + reply.header("Content-Type", "application/octet-stream"); + reply.send(Buffer.from(data)); +} + +async function waitUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 50; i++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw Error("Timed out waiting for condition"); +} diff --git a/packages/cli/src/options/beaconNodeOptions/execution.ts b/packages/cli/src/options/beaconNodeOptions/execution.ts index 4f2317383c52..604b37362b4b 100644 --- a/packages/cli/src/options/beaconNodeOptions/execution.ts +++ b/packages/cli/src/options/beaconNodeOptions/execution.ts @@ -9,6 +9,7 @@ export type ExecutionEngineArgs = { "execution.retries": number; "execution.retryDelay": number; "execution.engineMock"?: boolean; + "execution.sszRest"?: boolean; jwtSecret?: string; jwtId?: string; }; @@ -32,6 +33,7 @@ export function parseArgs(args: ExecutionEngineArgs): IBeaconNodeOptions["execut */ jwtSecretHex: args.jwtSecret ? extractJwtHexSecret(fs.readFileSync(args.jwtSecret, "utf-8").trim()) : undefined, jwtId: args.jwtId, + sszRest: args["execution.sszRest"], }; } @@ -76,6 +78,14 @@ export const options: CliCommandOptions = { group: "execution", }, + "execution.sszRest": { + description: + "Enable the experimental SSZ-REST Engine API transport (EIP-8161 / ethereum/execution-apis#764). When enabled, mutually advertised Engine API endpoints use binary SSZ over REST on the same Engine port, falling back to JSON-RPC on network errors. Off by default until the spec stabilises.", + type: "boolean", + hidden: true, + group: "execution", + }, + jwtSecret: { description: "File path to a shared hex-encoded jwt secret which will be used to generate and bundle HS256 encoded jwt tokens for authentication with the EL client's rpc server hosting engine apis. Secret to be exactly same as the one used by the corresponding EL client.",