diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b8b0a145e41e..b3027425dab6 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,7 +8,22 @@ export type { IHttpClient, Metrics, } from "./utils/client/index.js"; -export {ApiResponse, HttpClient, defaultInit} from "./utils/client/index.js"; +export { + ApiResponse, + ENGINE_SSZ_ACCEPT, + ENGINE_SSZ_CONTENT_TYPE, + EngineSszNegotiationState, + HttpClient, + LODESTAR_ENGINE_METHODS_IN_USE, + LODESTAR_ENGINE_SSZ_CAPABILITIES, + buildEngineDispatchPlan, + buildEngineSszRequestInit, + defaultInit, + getEngineSszMethodDescriptor, + getUniqueEngineSszCapabilitiesForMethods, + isEngineSszUnsupportedStatus, + selectEngineTransport, +} from "./utils/client/index.js"; export type {ApiRequestInit} from "./utils/client/request.js"; export {HttpHeader, MediaType} from "./utils/headers.js"; export type {HttpErrorCodes, HttpSuccessCodes} from "./utils/httpStatusCode.js"; diff --git a/packages/api/src/utils/client/engineSszCapabilities.ts b/packages/api/src/utils/client/engineSszCapabilities.ts new file mode 100644 index 000000000000..158fc143c783 --- /dev/null +++ b/packages/api/src/utils/client/engineSszCapabilities.ts @@ -0,0 +1,49 @@ +export type EngineSszEndpoint = `${"GET" | "POST"} /engine/${string}`; + +export function isEngineSszCapability(value: string): value is EngineSszEndpoint { + if (typeof value !== "string") return false; + + const parts = value.trim().split(/\s+/); + if (parts.length < 2) return false; + + const method = parts[0].toUpperCase(); + if (method !== "GET" && method !== "POST") return false; + + const path = parts.slice(1).join(" ").toLowerCase(); + return path.startsWith("/engine/"); +} + +/** + * Given CL-supported capabilities and EL-advertised capabilities from + * engine_exchangeCapabilities, return the mutually-supported SSZ REST endpoints. + */ +export function getMutuallySupportedEngineSszCapabilities( + clCapabilities: string[], + elCapabilities: string[] +): Set { + const clSet = new Set(clCapabilities.filter(isEngineSszCapability).map(normalizeCapability)); + const supported = new Set(); + + for (const value of elCapabilities) { + if (!isEngineSszCapability(value)) continue; + const normalized = normalizeCapability(value); + if (clSet.has(normalized)) { + supported.add(normalized as EngineSszEndpoint); + } + } + + return supported; +} + +export function isEngineSszEndpointSupported( + supported: ReadonlySet, + endpoint: EngineSszEndpoint +): boolean { + return supported.has(normalizeCapability(endpoint) as EngineSszEndpoint); +} + +function normalizeCapability(value: string): string { + const [method, ...rest] = value.trim().split(/\s+/); + const path = rest.join(" ").toLowerCase(); + return `${method.toUpperCase()} ${path}`; +} diff --git a/packages/api/src/utils/client/engineSszDispatchPlan.ts b/packages/api/src/utils/client/engineSszDispatchPlan.ts new file mode 100644 index 000000000000..80db89bb2660 --- /dev/null +++ b/packages/api/src/utils/client/engineSszDispatchPlan.ts @@ -0,0 +1,52 @@ +import {type EngineSszRequestInit, buildEngineSszRequestInit} from "./engineSszHttp.js"; +import {type EngineSszMethodDescriptor} from "./engineSszMethodMap.js"; +import type {EngineSszNegotiationState} from "./engineSszNegotiation.js"; +import {selectEngineTransport} from "./engineSszTransportSelector.js"; + +export type EngineSszBodyEncoder = (args: { + method: string; + params: unknown[]; + descriptor: EngineSszMethodDescriptor; +}) => Uint8Array | undefined; + +export type EngineDispatchPlan = + | { + transport: "ssz"; + descriptor: EngineSszMethodDescriptor; + request: EngineSszRequestInit; + } + | { + transport: "json-rpc"; + reason: "method-not-mapped" | "endpoint-not-negotiated" | "ssz-body-not-encoded"; + }; + +/** + * Build an execution dispatch plan for Engine API requests. + * + * - If method is mapped and negotiated, returns an SSZ request plan. + * - Otherwise returns JSON-RPC fallback plan with explicit reason. + */ +export function buildEngineDispatchPlan( + method: string, + params: unknown[], + negotiation: EngineSszNegotiationState, + encodeBody?: EngineSszBodyEncoder +): EngineDispatchPlan { + const selection = selectEngineTransport(method, params, negotiation); + if (selection.transport === "json-rpc") { + return selection; + } + + const body = encodeBody?.({method, params, descriptor: selection.descriptor}); + + if (selection.descriptor.httpMethod === "POST" && body === undefined) { + return {transport: "json-rpc", reason: "ssz-body-not-encoded"}; + } + + const request = buildEngineSszRequestInit(selection.descriptor, body); + return { + transport: "ssz", + descriptor: selection.descriptor, + request, + }; +} diff --git a/packages/api/src/utils/client/engineSszHttp.ts b/packages/api/src/utils/client/engineSszHttp.ts new file mode 100644 index 000000000000..3c0c6c6df2aa --- /dev/null +++ b/packages/api/src/utils/client/engineSszHttp.ts @@ -0,0 +1,48 @@ +import {HttpStatusCode} from "../httpStatusCode.js"; +import type {EngineSszMethodDescriptor} from "./engineSszMethodMap.js"; + +export const ENGINE_SSZ_CONTENT_TYPE = "application/octet-stream"; +export const ENGINE_SSZ_ACCEPT = "application/octet-stream"; + +export type EngineSszRequestInit = { + urlPath: string; + method: "GET" | "POST"; + body?: Uint8Array; + headers: Record; +}; + +/** + * Build request init data for Engine API binary SSZ transport. + * + * Spec note: both request and response use application/octet-stream. + */ +export function buildEngineSszRequestInit( + descriptor: EngineSszMethodDescriptor, + body?: Uint8Array +): EngineSszRequestInit { + if (descriptor.httpMethod === "GET" && body !== undefined) { + throw Error("GET SSZ engine request must not include a body"); + } + + return { + urlPath: descriptor.path, + method: descriptor.httpMethod, + body, + headers: { + "Content-Type": ENGINE_SSZ_CONTENT_TYPE, + Accept: ENGINE_SSZ_ACCEPT, + }, + }; +} + +/** + * Returns true when HTTP status indicates EL likely does not support + * the requested SSZ endpoint. + */ +export function isEngineSszUnsupportedStatus(status: number): boolean { + return ( + status === HttpStatusCode.NOT_FOUND || + status === HttpStatusCode.NOT_IMPLEMENTED || + status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE + ); +} diff --git a/packages/api/src/utils/client/engineSszLodestarProfile.ts b/packages/api/src/utils/client/engineSszLodestarProfile.ts new file mode 100644 index 000000000000..5c476d866e48 --- /dev/null +++ b/packages/api/src/utils/client/engineSszLodestarProfile.ts @@ -0,0 +1,33 @@ +import {getUniqueEngineSszCapabilitiesForMethods} from "./engineSszMethodMap.js"; + +/** + * Engine API methods currently used by Lodestar beacon-node execution engine client. + * + * Source: packages/beacon-node/src/execution/engine/http.ts + */ +export const LODESTAR_ENGINE_METHODS_IN_USE = [ + "engine_newPayloadV1", + "engine_newPayloadV2", + "engine_newPayloadV3", + "engine_newPayloadV4", + "engine_forkchoiceUpdatedV1", + "engine_forkchoiceUpdatedV2", + "engine_forkchoiceUpdatedV3", + "engine_getPayloadV1", + "engine_getPayloadV2", + "engine_getPayloadV3", + "engine_getPayloadV4", + "engine_getPayloadV5", + "engine_getPayloadBodiesByHashV1", + "engine_getPayloadBodiesByRangeV1", + "engine_getBlobsV1", + "engine_getBlobsV2", + "engine_getClientVersionV1", +] as const; + +/** + * SSZ REST capabilities Lodestar should advertise via engine_exchangeCapabilities. + */ +export const LODESTAR_ENGINE_SSZ_CAPABILITIES = getUniqueEngineSszCapabilitiesForMethods([ + ...LODESTAR_ENGINE_METHODS_IN_USE, +]); diff --git a/packages/api/src/utils/client/engineSszMethodMap.ts b/packages/api/src/utils/client/engineSszMethodMap.ts new file mode 100644 index 000000000000..4b7c23495bff --- /dev/null +++ b/packages/api/src/utils/client/engineSszMethodMap.ts @@ -0,0 +1,151 @@ +/* biome-ignore-all lint/style/useNamingConvention: Engine API method names are protocol-defined. */ +import type {EngineSszEndpoint} from "./engineSszCapabilities.js"; + +export type EngineSszHttpMethod = "GET" | "POST"; + +export type EngineSszMethodDescriptor = { + httpMethod: EngineSszHttpMethod; + /** Concrete request path for this invocation */ + path: string; + /** Capability string used by engine_exchangeCapabilities negotiation */ + capability: EngineSszEndpoint; +}; + +const FIXED_METHOD_MAP: Record = { + engine_newPayloadV1: { + httpMethod: "POST", + path: "/engine/v1/payloads", + capability: "POST /engine/v1/payloads", + }, + engine_newPayloadV2: { + httpMethod: "POST", + path: "/engine/v2/payloads", + capability: "POST /engine/v2/payloads", + }, + engine_newPayloadV3: { + httpMethod: "POST", + path: "/engine/v3/payloads", + capability: "POST /engine/v3/payloads", + }, + engine_newPayloadV4: { + httpMethod: "POST", + path: "/engine/v4/payloads", + capability: "POST /engine/v4/payloads", + }, + + engine_forkchoiceUpdatedV1: { + httpMethod: "POST", + path: "/engine/v1/forkchoice", + capability: "POST /engine/v1/forkchoice", + }, + engine_forkchoiceUpdatedV2: { + httpMethod: "POST", + path: "/engine/v2/forkchoice", + capability: "POST /engine/v2/forkchoice", + }, + engine_forkchoiceUpdatedV3: { + httpMethod: "POST", + path: "/engine/v3/forkchoice", + capability: "POST /engine/v3/forkchoice", + }, + + engine_getPayloadBodiesByHashV1: { + httpMethod: "POST", + path: "/engine/v1/payloads/bodies/by-hash", + capability: "POST /engine/v1/payloads/bodies/by-hash", + }, + engine_getPayloadBodiesByRangeV1: { + httpMethod: "POST", + path: "/engine/v1/payloads/bodies/by-range", + capability: "POST /engine/v1/payloads/bodies/by-range", + }, + + engine_getClientVersionV1: { + httpMethod: "POST", + path: "/engine/v1/client/version", + capability: "POST /engine/v1/client/version", + }, + + engine_getBlobsV1: { + httpMethod: "POST", + path: "/engine/v1/blobs", + capability: "POST /engine/v1/blobs", + }, + engine_getBlobsV2: { + httpMethod: "POST", + path: "/engine/v2/blobs", + capability: "POST /engine/v2/blobs", + }, +}; + +const PAYLOAD_GET_METHODS: Record = { + engine_getPayloadV1: { + pathPrefix: "/engine/v1/payloads", + capability: "GET /engine/v1/payloads/{payload_id}", + }, + engine_getPayloadV2: { + pathPrefix: "/engine/v2/payloads", + capability: "GET /engine/v2/payloads/{payload_id}", + }, + engine_getPayloadV3: { + pathPrefix: "/engine/v3/payloads", + capability: "GET /engine/v3/payloads/{payload_id}", + }, + engine_getPayloadV4: { + pathPrefix: "/engine/v4/payloads", + capability: "GET /engine/v4/payloads/{payload_id}", + }, + engine_getPayloadV5: { + pathPrefix: "/engine/v5/payloads", + capability: "GET /engine/v5/payloads/{payload_id}", + }, +}; + +export function getEngineSszCapabilityForMethod(method: string): EngineSszEndpoint | null { + const fixed = FIXED_METHOD_MAP[method]; + if (fixed !== undefined) return fixed.capability; + + const payloadGet = PAYLOAD_GET_METHODS[method]; + if (payloadGet !== undefined) return payloadGet.capability; + + return null; +} + +export function getUniqueEngineSszCapabilitiesForMethods(methods: string[]): EngineSszEndpoint[] { + const set = new Set(); + for (const method of methods) { + const capability = getEngineSszCapabilityForMethod(method); + if (capability !== null) set.add(capability); + } + return [...set]; +} + +export function getUniqueEngineSszCapabilitiesFromElCapabilities(elCapabilities: string[]): EngineSszEndpoint[] { + return getUniqueEngineSszCapabilitiesForMethods( + elCapabilities.filter((value) => typeof value === "string" && value.startsWith("engine_")) + ); +} + +export function getEngineSszMethodDescriptor(method: string, params: unknown[]): EngineSszMethodDescriptor | null { + const fixed = FIXED_METHOD_MAP[method]; + if (fixed !== undefined) return fixed; + + const payloadGet = PAYLOAD_GET_METHODS[method]; + if (payloadGet !== undefined) { + const payloadId = normalizePayloadId(params[0]); + return { + httpMethod: "GET", + path: `${payloadGet.pathPrefix}/${payloadId}`, + capability: payloadGet.capability, + }; + } + + return null; +} + +function normalizePayloadId(value: unknown): string { + if (typeof value !== "string" || !value.startsWith("0x")) { + throw Error(`Invalid payloadId format: ${String(value)}`); + } + return value.toLowerCase(); +} diff --git a/packages/api/src/utils/client/engineSszNegotiation.ts b/packages/api/src/utils/client/engineSszNegotiation.ts new file mode 100644 index 000000000000..1692d920a4a2 --- /dev/null +++ b/packages/api/src/utils/client/engineSszNegotiation.ts @@ -0,0 +1,48 @@ +import { + type EngineSszEndpoint, + getMutuallySupportedEngineSszCapabilities, + isEngineSszEndpointSupported, +} from "./engineSszCapabilities.js"; +import { + type EngineSszMethodDescriptor, + getEngineSszCapabilityForMethod, + getUniqueEngineSszCapabilitiesFromElCapabilities, +} from "./engineSszMethodMap.js"; + +/** + * Tracks negotiated Engine API SSZ endpoint support based on + * engine_exchangeCapabilities response. + */ +export class EngineSszNegotiationState { + private supported = new Set(); + + constructor(private readonly clCapabilities: string[]) {} + + /** Update negotiated support from EL-advertised capabilities list. */ + updateFromElCapabilities(elCapabilities: string[]): void { + const mappedFromMethods = getUniqueEngineSszCapabilitiesFromElCapabilities(elCapabilities); + this.supported = getMutuallySupportedEngineSszCapabilities(this.clCapabilities, [ + ...elCapabilities, + ...mappedFromMethods, + ]); + } + + /** Returns true if this method is currently negotiated for SSZ transport. */ + isMethodSupported(method: string): boolean { + const capability = getEngineSszCapabilityForMethod(method); + if (capability === null) return false; + return isEngineSszEndpointSupported(this.supported, capability); + } + + /** + * Returns true if descriptor capability is negotiated for SSZ transport. + * Useful after method+params mapping has resolved concrete path. + */ + isDescriptorSupported(descriptor: EngineSszMethodDescriptor): boolean { + return isEngineSszEndpointSupported(this.supported, descriptor.capability); + } + + getSupportedCapabilities(): EngineSszEndpoint[] { + return [...this.supported]; + } +} diff --git a/packages/api/src/utils/client/engineSszTransportSelector.ts b/packages/api/src/utils/client/engineSszTransportSelector.ts new file mode 100644 index 000000000000..bd729d469046 --- /dev/null +++ b/packages/api/src/utils/client/engineSszTransportSelector.ts @@ -0,0 +1,32 @@ +import type {EngineSszMethodDescriptor} from "./engineSszMethodMap.js"; +import {getEngineSszMethodDescriptor} from "./engineSszMethodMap.js"; +import type {EngineSszNegotiationState} from "./engineSszNegotiation.js"; + +export type EngineTransportSelection = + | {transport: "ssz"; descriptor: EngineSszMethodDescriptor} + | {transport: "json-rpc"; reason: "method-not-mapped" | "endpoint-not-negotiated"}; + +/** + * Select transport for an Engine API method invocation. + * + * - Uses SSZ when method is mapped to an SSZ endpoint AND that endpoint was + * mutually advertised via engine_exchangeCapabilities. + * - Falls back to JSON-RPC otherwise. + */ +export function selectEngineTransport( + method: string, + params: unknown[], + negotiation: EngineSszNegotiationState +): EngineTransportSelection { + const descriptor = getEngineSszMethodDescriptor(method, params); + + if (descriptor === null) { + return {transport: "json-rpc", reason: "method-not-mapped"}; + } + + if (!negotiation.isDescriptorSupported(descriptor)) { + return {transport: "json-rpc", reason: "endpoint-not-negotiated"}; + } + + return {transport: "ssz", descriptor}; +} diff --git a/packages/api/src/utils/client/index.ts b/packages/api/src/utils/client/index.ts index 4f88ec061f5c..688b2a10e98f 100644 --- a/packages/api/src/utils/client/index.ts +++ b/packages/api/src/utils/client/index.ts @@ -1,3 +1,10 @@ +export * from "./engineSszCapabilities.js"; +export * from "./engineSszDispatchPlan.js"; +export * from "./engineSszHttp.js"; +export * from "./engineSszLodestarProfile.js"; +export * from "./engineSszMethodMap.js"; +export * from "./engineSszNegotiation.js"; +export * from "./engineSszTransportSelector.js"; export * from "./error.js"; export * from "./httpClient.js"; export * from "./method.js"; diff --git a/packages/api/test/unit/client/engineSszCapabilities.test.ts b/packages/api/test/unit/client/engineSszCapabilities.test.ts new file mode 100644 index 000000000000..6e0c03589502 --- /dev/null +++ b/packages/api/test/unit/client/engineSszCapabilities.test.ts @@ -0,0 +1,34 @@ +import {describe, expect, it} from "vitest"; +import { + type EngineSszEndpoint, + getMutuallySupportedEngineSszCapabilities, + isEngineSszCapability, + isEngineSszEndpointSupported, +} from "../../../src/utils/client/engineSszCapabilities.js"; + +describe("api / client / engineSszCapabilities", () => { + it("detects engine SSZ REST capability entries", () => { + expect(isEngineSszCapability("POST /engine/v5/payloads")).toBe(true); + expect(isEngineSszCapability("GET /engine/v6/payloads/{payload_id}")).toBe(true); + expect(isEngineSszCapability("engine_newPayloadV4")).toBe(false); + }); + + it("computes mutual capability set with normalization", () => { + const cl = ["POST /engine/v5/payloads", "POST /engine/v4/forkchoice", "GET /engine/v6/payloads/{payload_id}"]; + const el = ["post /engine/v5/payloads", "POST /engine/v4/forkchoice", "engine_forkchoiceUpdatedV4"]; + + const supported = getMutuallySupportedEngineSszCapabilities(cl, el); + + expect(supported.size).toBe(2); + expect(supported.has("POST /engine/v5/payloads" as EngineSszEndpoint)).toBe(true); + expect(supported.has("POST /engine/v4/forkchoice" as EngineSszEndpoint)).toBe(true); + expect(supported.has("GET /engine/v6/payloads/{payload_id}" as EngineSszEndpoint)).toBe(false); + }); + + it("checks endpoint support against negotiated set", () => { + const supported = getMutuallySupportedEngineSszCapabilities(["POST /engine/v3/blobs"], ["POST /engine/v3/blobs"]); + + expect(isEngineSszEndpointSupported(supported, "POST /engine/v3/blobs" as EngineSszEndpoint)).toBe(true); + expect(isEngineSszEndpointSupported(supported, "POST /engine/v2/blobs" as EngineSszEndpoint)).toBe(false); + }); +}); diff --git a/packages/api/test/unit/client/engineSszDispatchPlan.test.ts b/packages/api/test/unit/client/engineSszDispatchPlan.test.ts new file mode 100644 index 000000000000..2c83ad0732ab --- /dev/null +++ b/packages/api/test/unit/client/engineSszDispatchPlan.test.ts @@ -0,0 +1,57 @@ +import {describe, expect, it} from "vitest"; +import {buildEngineDispatchPlan} from "../../../src/utils/client/engineSszDispatchPlan.js"; +import {EngineSszNegotiationState} from "../../../src/utils/client/engineSszNegotiation.js"; + +describe("api / client / engineSszDispatchPlan", () => { + it("returns SSZ dispatch plan for negotiated POST endpoint", () => { + const negotiation = new EngineSszNegotiationState(["POST /engine/v3/payloads"]); + negotiation.updateFromElCapabilities(["POST /engine/v3/payloads"]); + + const plan = buildEngineDispatchPlan( + "engine_newPayloadV3", + ["0x01", [], "0x02"], + negotiation, + () => new Uint8Array([7, 8, 9]) + ); + + expect(plan.transport).toBe("ssz"); + if (plan.transport === "ssz") { + expect(plan.request.method).toBe("POST"); + expect(plan.request.urlPath).toBe("/engine/v3/payloads"); + expect(plan.request.headers["Content-Type"]).toBe("application/octet-stream"); + expect(plan.request.body).toEqual(new Uint8Array([7, 8, 9])); + } + }); + + it("returns JSON-RPC fallback plan when endpoint is not negotiated", () => { + const negotiation = new EngineSszNegotiationState(["POST /engine/v3/payloads", "POST /engine/v3/forkchoice"]); + negotiation.updateFromElCapabilities(["POST /engine/v3/payloads"]); + + const plan = buildEngineDispatchPlan("engine_forkchoiceUpdatedV3", [{}, null], negotiation); + + expect(plan).toEqual({transport: "json-rpc", reason: "endpoint-not-negotiated"}); + }); + + it("builds GET SSZ plan without body", () => { + const negotiation = new EngineSszNegotiationState(["GET /engine/v5/payloads/{payload_id}"]); + negotiation.updateFromElCapabilities(["GET /engine/v5/payloads/{payload_id}"]); + + const plan = buildEngineDispatchPlan("engine_getPayloadV5", ["0xAABBCC"], negotiation); + + expect(plan.transport).toBe("ssz"); + if (plan.transport === "ssz") { + expect(plan.request.method).toBe("GET"); + expect(plan.request.urlPath).toBe("/engine/v5/payloads/0xaabbcc"); + expect(plan.request.body).toBeUndefined(); + } + }); + + it("falls back to JSON-RPC when POST endpoint is negotiated but SSZ body is not encoded", () => { + const negotiation = new EngineSszNegotiationState(["POST /engine/v3/payloads"]); + negotiation.updateFromElCapabilities(["POST /engine/v3/payloads"]); + + const plan = buildEngineDispatchPlan("engine_newPayloadV3", ["0x01", [], "0x02"], negotiation); + + expect(plan).toEqual({transport: "json-rpc", reason: "ssz-body-not-encoded"}); + }); +}); diff --git a/packages/api/test/unit/client/engineSszHttp.test.ts b/packages/api/test/unit/client/engineSszHttp.test.ts new file mode 100644 index 000000000000..318d72e67d0d --- /dev/null +++ b/packages/api/test/unit/client/engineSszHttp.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from "vitest"; +import { + ENGINE_SSZ_ACCEPT, + ENGINE_SSZ_CONTENT_TYPE, + buildEngineSszRequestInit, + isEngineSszUnsupportedStatus, +} from "../../../src/utils/client/engineSszHttp.js"; + +describe("api / client / engineSszHttp", () => { + it("builds POST SSZ request init with octet-stream headers", () => { + const req = buildEngineSszRequestInit( + { + httpMethod: "POST", + path: "/engine/v3/payloads", + capability: "POST /engine/v3/payloads", + }, + new Uint8Array([1, 2, 3]) + ); + + expect(req.method).toBe("POST"); + expect(req.urlPath).toBe("/engine/v3/payloads"); + expect(req.headers["Content-Type"]).toBe(ENGINE_SSZ_CONTENT_TYPE); + expect(req.headers.Accept).toBe(ENGINE_SSZ_ACCEPT); + expect(req.body).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("throws if GET request includes body", () => { + expect(() => + buildEngineSszRequestInit( + { + httpMethod: "GET", + path: "/engine/v5/payloads/0x01", + capability: "GET /engine/v5/payloads/{payload_id}", + }, + new Uint8Array([1]) + ) + ).toThrow("GET SSZ engine request must not include a body"); + }); + + it("classifies unsupported SSZ statuses", () => { + expect(isEngineSszUnsupportedStatus(404)).toBe(true); + expect(isEngineSszUnsupportedStatus(415)).toBe(true); + expect(isEngineSszUnsupportedStatus(501)).toBe(true); + expect(isEngineSszUnsupportedStatus(500)).toBe(false); + }); +}); diff --git a/packages/api/test/unit/client/engineSszLodestarProfile.test.ts b/packages/api/test/unit/client/engineSszLodestarProfile.test.ts new file mode 100644 index 000000000000..705e60e0c81e --- /dev/null +++ b/packages/api/test/unit/client/engineSszLodestarProfile.test.ts @@ -0,0 +1,22 @@ +import {describe, expect, it} from "vitest"; +import { + LODESTAR_ENGINE_METHODS_IN_USE, + LODESTAR_ENGINE_SSZ_CAPABILITIES, +} from "../../../src/utils/client/engineSszLodestarProfile.js"; + +describe("api / client / engineSszLodestarProfile", () => { + it("tracks currently used beacon-node engine methods", () => { + expect(LODESTAR_ENGINE_METHODS_IN_USE).toContain("engine_getPayloadBodiesByRangeV1"); + expect(LODESTAR_ENGINE_METHODS_IN_USE).toContain("engine_getBlobsV2"); + expect(LODESTAR_ENGINE_METHODS_IN_USE).not.toContain("engine_exchangeCapabilities"); + }); + + it("derives deduplicated SSZ capability advertisement set", () => { + expect(LODESTAR_ENGINE_SSZ_CAPABILITIES).toContain("POST /engine/v1/payloads/bodies/by-range"); + expect(LODESTAR_ENGINE_SSZ_CAPABILITIES).toContain("POST /engine/v2/blobs"); + expect(LODESTAR_ENGINE_SSZ_CAPABILITIES).toContain("GET /engine/v5/payloads/{payload_id}"); + + const uniqueCount = new Set(LODESTAR_ENGINE_SSZ_CAPABILITIES).size; + expect(uniqueCount).toBe(LODESTAR_ENGINE_SSZ_CAPABILITIES.length); + }); +}); diff --git a/packages/api/test/unit/client/engineSszMethodMap.test.ts b/packages/api/test/unit/client/engineSszMethodMap.test.ts new file mode 100644 index 000000000000..8e51188ea10f --- /dev/null +++ b/packages/api/test/unit/client/engineSszMethodMap.test.ts @@ -0,0 +1,58 @@ +import {describe, expect, it} from "vitest"; +import { + getEngineSszMethodDescriptor, + getUniqueEngineSszCapabilitiesForMethods, + getUniqueEngineSszCapabilitiesFromElCapabilities, +} from "../../../src/utils/client/engineSszMethodMap.js"; + +describe("api / client / engineSszMethodMap", () => { + it("maps fixed POST methods", () => { + const d = getEngineSszMethodDescriptor("engine_forkchoiceUpdatedV3", [{}, {}]); + + expect(d).toEqual({ + httpMethod: "POST", + path: "/engine/v3/forkchoice", + capability: "POST /engine/v3/forkchoice", + }); + }); + + it("maps getPayload methods with payload_id path and capability template", () => { + const d = getEngineSszMethodDescriptor("engine_getPayloadV5", ["0xABCDEF0123"]); + + expect(d).toEqual({ + httpMethod: "GET", + path: "/engine/v5/payloads/0xabcdef0123", + capability: "GET /engine/v5/payloads/{payload_id}", + }); + }); + + it("returns null for non-mapped methods", () => { + expect(getEngineSszMethodDescriptor("engine_exchangeCapabilities", [[]])).toBeNull(); + }); + + it("throws for invalid payloadId", () => { + expect(() => getEngineSszMethodDescriptor("engine_getPayloadV1", ["abc"])).toThrow("Invalid payloadId format"); + }); + + it("extracts unique negotiated-capability set from engine method list", () => { + const caps = getUniqueEngineSszCapabilitiesForMethods([ + "engine_newPayloadV3", + "engine_getPayloadV5", + "engine_newPayloadV3", + "engine_exchangeCapabilities", + ]); + + expect(caps).toEqual(["POST /engine/v3/payloads", "GET /engine/v5/payloads/{payload_id}"]); + }); + + it("derives endpoint capabilities from EL method-name capability list", () => { + const caps = getUniqueEngineSszCapabilitiesFromElCapabilities([ + "engine_getPayloadV5", + "engine_getClientVersionV1", + "engine_exchangeCapabilities", + "POST /engine/v2/payloads", + ]); + + expect(caps).toEqual(["GET /engine/v5/payloads/{payload_id}", "POST /engine/v1/client/version"]); + }); +}); diff --git a/packages/api/test/unit/client/engineSszNegotiation.test.ts b/packages/api/test/unit/client/engineSszNegotiation.test.ts new file mode 100644 index 000000000000..2e12c2216587 --- /dev/null +++ b/packages/api/test/unit/client/engineSszNegotiation.test.ts @@ -0,0 +1,49 @@ +import {describe, expect, it} from "vitest"; +import {getEngineSszMethodDescriptor} from "../../../src/utils/client/engineSszMethodMap.js"; +import {EngineSszNegotiationState} from "../../../src/utils/client/engineSszNegotiation.js"; + +describe("api / client / engineSszNegotiation", () => { + it("only enables SSZ for mutually advertised endpoints", () => { + const clCapabilities = [ + "POST /engine/v3/payloads", + "POST /engine/v3/forkchoice", + "GET /engine/v5/payloads/{payload_id}", + ]; + + const state = new EngineSszNegotiationState(clCapabilities); + + state.updateFromElCapabilities(["POST /engine/v3/payloads", "engine_newPayloadV3"]); + + expect(state.isMethodSupported("engine_newPayloadV3")).toBe(true); + expect(state.isMethodSupported("engine_forkchoiceUpdatedV3")).toBe(false); + expect(state.isMethodSupported("engine_getPayloadV5")).toBe(false); + }); + + it("checks support using method descriptor capability", () => { + const state = new EngineSszNegotiationState(["GET /engine/v5/payloads/{payload_id}", "POST /engine/v3/forkchoice"]); + + state.updateFromElCapabilities(["GET /engine/v5/payloads/{payload_id}"]); + + const getPayloadDescriptor = getEngineSszMethodDescriptor("engine_getPayloadV5", ["0xAABB"]); + const fcuDescriptor = getEngineSszMethodDescriptor("engine_forkchoiceUpdatedV3", [{}, {}]); + + if (getPayloadDescriptor === null || fcuDescriptor === null) { + throw Error("Expected method descriptors to be present"); + } + + expect(state.isDescriptorSupported(getPayloadDescriptor)).toBe(true); + expect(state.isDescriptorSupported(fcuDescriptor)).toBe(false); + }); + + it("maps EL method-name capabilities to SSZ endpoint negotiation", () => { + const state = new EngineSszNegotiationState([ + "POST /engine/v1/client/version", + "GET /engine/v5/payloads/{payload_id}", + ]); + + state.updateFromElCapabilities(["engine_getClientVersionV1", "engine_getPayloadV5"]); + + expect(state.isMethodSupported("engine_getClientVersionV1")).toBe(true); + expect(state.isMethodSupported("engine_getPayloadV5")).toBe(true); + }); +}); diff --git a/packages/api/test/unit/client/engineSszTransportSelector.test.ts b/packages/api/test/unit/client/engineSszTransportSelector.test.ts new file mode 100644 index 000000000000..32c8fee8bc42 --- /dev/null +++ b/packages/api/test/unit/client/engineSszTransportSelector.test.ts @@ -0,0 +1,43 @@ +import {describe, expect, it} from "vitest"; +import {EngineSszNegotiationState} from "../../../src/utils/client/engineSszNegotiation.js"; +import {selectEngineTransport} from "../../../src/utils/client/engineSszTransportSelector.js"; + +describe("api / client / engineSszTransportSelector", () => { + it("returns SSZ when method is mapped and endpoint is negotiated", () => { + const negotiation = new EngineSszNegotiationState(["POST /engine/v3/payloads", "POST /engine/v3/forkchoice"]); + negotiation.updateFromElCapabilities(["POST /engine/v3/payloads"]); + + const selection = selectEngineTransport("engine_newPayloadV3", ["0x01", [], "0x02"], negotiation); + + expect(selection).toEqual({ + transport: "ssz", + descriptor: { + httpMethod: "POST", + path: "/engine/v3/payloads", + capability: "POST /engine/v3/payloads", + }, + }); + }); + + it("falls back to JSON-RPC when method has no SSZ mapping", () => { + const negotiation = new EngineSszNegotiationState(["POST /engine/v1/payloads"]); + negotiation.updateFromElCapabilities(["POST /engine/v1/payloads"]); + + const selection = selectEngineTransport("engine_exchangeCapabilities", [[]], negotiation); + + expect(selection).toEqual({transport: "json-rpc", reason: "method-not-mapped"}); + }); + + it("falls back to JSON-RPC when endpoint is not negotiated", () => { + const negotiation = new EngineSszNegotiationState(["POST /engine/v3/payloads", "POST /engine/v3/forkchoice"]); + negotiation.updateFromElCapabilities(["POST /engine/v3/payloads"]); + + const selection = selectEngineTransport( + "engine_forkchoiceUpdatedV3", + [{headBlockHash: "0x", safeBlockHash: "0x", finalizedBlockHash: "0x"}, null], + negotiation + ); + + expect(selection).toEqual({transport: "json-rpc", reason: "endpoint-not-negotiated"}); + }); +}); diff --git a/packages/beacon-node/src/execution/engine/http.ts b/packages/beacon-node/src/execution/engine/http.ts index 69094ce2fa8b..a238c1a5d7bd 100644 --- a/packages/beacon-node/src/execution/engine/http.ts +++ b/packages/beacon-node/src/execution/engine/http.ts @@ -1,12 +1,17 @@ +import { + EngineSszNegotiationState, + LODESTAR_ENGINE_SSZ_CAPABILITIES, + buildEngineDispatchPlan, + isEngineSszUnsupportedStatus, +} from "@lodestar/api"; import {Logger} from "@lodestar/logger"; import {ForkName, ForkPostFulu, ForkPreFulu, ForkSeq, SLOTS_PER_EPOCH, isForkPostFulu} from "@lodestar/params"; 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 {Metrics} from "../../metrics/index.js"; +import {ErrorAborted, TimeoutError, fetch, fromHex, retry, strip0xPrefix} from "@lodestar/utils"; +import type {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 { ClientCode, @@ -26,7 +31,9 @@ import { JsonRpcHttpClientEvent, ReqOpts, } from "./jsonRpcHttpClient.js"; +import {encodeJwtToken} from "./jwt.js"; import {PayloadIdCache} from "./payloadIdCache.js"; +import {decodeEngineSszResponse, encodeEngineSszRequest} from "./sszTransport.js"; import { BLOB_AND_PROOF_V2_RPC_BYTES, EngineApiRpcParamTypes, @@ -106,6 +113,17 @@ const QUEUE_MAX_LENGTH = EPOCHS_PER_BATCH * SLOTS_PER_EPOCH * 2; * https://github.com/ethereum/execution-apis/blob/main/src/engine/cancun.md#specification-3 */ const MAX_VERSIONED_HASHES = 128; +const REQUEST_TIMEOUT = 30 * 1000; +const MAX_ERROR_BODY_LENGTH = 500; + +function getLodestarEngineClientVersion(opts?: {version?: string; commit?: string}): ClientVersion { + return { + code: ClientCode.LS, + name: "Lodestar", + version: opts?.version ?? "", + commit: opts?.commit?.slice(0, 8) ?? "", + }; +} // Define static options once to prevent extra allocations const notifyNewPayloadOpts: ReqOpts = {routeId: "notifyNewPayload"}; @@ -116,6 +134,7 @@ 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"}; /** * based on Ethereum JSON-RPC API and inherits the following properties of this standard: @@ -138,6 +157,15 @@ export class ExecutionEngineHttp implements IExecutionEngine { /** Cached EL client version from the latest getClientVersion call */ clientVersion?: ClientVersion | null; + /** Cached EL capability advertisement from engine_exchangeCapabilities */ + engineCapabilities?: string[]; + + /** Negotiated SSZ endpoint support derived from exchangeCapabilities. */ + private readonly sszNegotiation = new EngineSszNegotiationState(LODESTAR_ENGINE_SSZ_CAPABILITIES); + + private readonly signal: AbortSignal; + private readonly jwtSecret?: Uint8Array; + readonly payloadIdCache = new PayloadIdCache(); /** * A queue to serialize the fcUs and newPayloads calls: @@ -151,10 +179,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { private readonly rpcFetchQueue: JobItemQueue<[EngineRequest], EngineResponse>; private jobQueueProcessor = async ({method, params, methodOpts}: EngineRequest): Promise => { - return this.rpc.fetchWithRetries( - {method, params}, - methodOpts - ); + return this.fetchWithSelectedTransport(method, params, methodOpts); }; constructor( @@ -169,6 +194,8 @@ export class ExecutionEngineHttp implements IExecutionEngine { ); this.logger = logger; this.metrics = metrics ?? null; + this.signal = signal; + this.jwtSecret = opts?.jwtSecretHex ? fromHex(opts.jwtSecretHex) : undefined; this.rpc.emitter.on(JsonRpcHttpClientEvent.ERROR, ({error}) => { this.updateEngineState(getExecutionEngineState({payloadError: error, oldState: this.state})); @@ -178,9 +205,11 @@ export class ExecutionEngineHttp implements IExecutionEngine { if (this.clientVersion === undefined) { this.clientVersion = null; // This statement should only be called first time receiving response after startup - this.getClientVersion(getLodestarClientVersion(this.opts)).catch((e) => { - this.logger.debug("Unable to get execution client version", {}, e); - }); + this.getClientVersion(getLodestarEngineClientVersion(this.opts)) + .then(() => this.exchangeCapabilities(LODESTAR_ENGINE_SSZ_CAPABILITIES)) + .catch((e) => { + this.logger.debug("Unable to negotiate execution engine capabilities", {}, e); + }); } this.updateEngineState(getExecutionEngineState({targetState: ExecutionEngineState.ONLINE, oldState: this.state})); }); @@ -442,16 +471,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { method = "engine_getPayloadV5"; break; } - const payloadResponse = await this.rpc.fetchWithRetries< - EngineApiRpcReturnTypes[typeof method], - EngineApiRpcParamTypes[typeof method] - >( - { - method, - params: [payloadId], - }, - getPayloadOpts - ); + const payloadResponse = await this.fetchWithSelectedTransport(method, [payloadId], getPayloadOpts); return parseExecutionPayload(fork, payloadResponse); } @@ -462,10 +482,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { async getPayloadBodiesByHash(_fork: ForkName, blockHashes: RootHex[]): Promise<(ExecutionPayloadBody | null)[]> { const method = "engine_getPayloadBodiesByHashV1"; assertReqSizeLimit(blockHashes.length, 32); - const response = await this.rpc.fetchWithRetries< - EngineApiRpcReturnTypes[typeof method], - EngineApiRpcParamTypes[typeof method] - >({method, params: [blockHashes]}, getPayloadBodiesByHashOpts); + const response = await this.fetchWithSelectedTransport(method, [blockHashes], getPayloadBodiesByHashOpts); return response.map(deserializeExecutionPayloadBody); } @@ -478,10 +495,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { assertReqSizeLimit(blockCount, 32); const start = numToQuantity(startBlockNumber); const count = numToQuantity(blockCount); - const response = await this.rpc.fetchWithRetries< - EngineApiRpcReturnTypes[typeof method], - EngineApiRpcParamTypes[typeof method] - >({method, params: [start, count]}, getPayloadBodiesByRangeOpts); + const response = await this.fetchWithSelectedTransport(method, [start, count], getPayloadBodiesByRangeOpts); return response.map(deserializeExecutionPayloadBody); } @@ -508,16 +522,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { } private async getBlobsV1(versionedHashesHex: string[]) { - const response = await this.rpc.fetchWithRetries< - EngineApiRpcReturnTypes["engine_getBlobsV1"], - EngineApiRpcParamTypes["engine_getBlobsV1"] - >( - { - method: "engine_getBlobsV1", - params: [versionedHashesHex], - }, - getBlobsV1Opts - ); + const response = await this.fetchWithSelectedTransport("engine_getBlobsV1", [versionedHashesHex], getBlobsV1Opts); const invalidLength = response.length !== versionedHashesHex.length; @@ -543,16 +548,7 @@ export class ExecutionEngineHttp implements IExecutionEngine { } } - const response = await this.rpc.fetchWithRetries< - EngineApiRpcReturnTypes["engine_getBlobsV2"], - EngineApiRpcParamTypes["engine_getBlobsV2"] - >( - { - method: "engine_getBlobsV2", - params: [versionedHashesHex], - }, - getBlobsV2Opts - ); + const response = await this.fetchWithSelectedTransport("engine_getBlobsV2", [versionedHashesHex], getBlobsV2Opts); // engine_getBlobsV2 does not return partial responses. It returns null if any blob is not found const invalidLength = !!response && response.length !== versionedHashesHex.length; @@ -578,10 +574,11 @@ 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 response = await this.fetchWithSelectedTransport( + method, + [{...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; @@ -598,6 +595,159 @@ export class ExecutionEngineHttp implements IExecutionEngine { return clientVersions; } + private async exchangeCapabilities(clCapabilities: string[]): Promise { + const method = "engine_exchangeCapabilities"; + + const capabilities = await this.rpc.fetchWithRetries< + EngineApiRpcReturnTypes[typeof method], + EngineApiRpcParamTypes[typeof method] + >({method, params: [clCapabilities]}, exchangeCapabilitiesOpts); + + this.engineCapabilities = capabilities; + this.sszNegotiation.updateFromElCapabilities(capabilities); + this.logger.debug("Execution engine capabilities updated", {capabilitiesCount: capabilities.length}); + + return capabilities; + } + + private async fetchWithSelectedTransport( + method: M, + params: EngineApiRpcParamTypes[M], + methodOpts: ReqOpts + ): Promise { + const dispatchPlan = buildEngineDispatchPlan( + method, + params as unknown[], + this.sszNegotiation, + ({method: sszMethod, params: sszParams}) => encodeEngineSszRequest(sszMethod as EngineRequestKey, sszParams) + ); + + if (dispatchPlan.transport === "json-rpc") { + this.logger.debug("Engine JSON-RPC dispatch plan selected", {method, reason: dispatchPlan.reason}); + return this.rpc.fetchWithRetries( + {method, params}, + methodOpts + ); + } + + if (this.opts?.urls.length === 0 || this.opts?.urls === undefined) { + this.logger.debug("Engine SSZ dispatch selected but HTTP URLs are unavailable, using JSON-RPC fallback", { + method, + }); + return this.rpc.fetchWithRetries( + {method, params}, + methodOpts + ); + } + + this.logger.debug("Engine SSZ dispatch plan selected", { + method, + endpoint: dispatchPlan.request.urlPath, + httpMethod: dispatchPlan.request.method, + }); + + try { + const response = await this.fetchSszWithRetries(method, dispatchPlan.request, methodOpts); + this.updateEngineState(getExecutionEngineState({targetState: ExecutionEngineState.ONLINE, oldState: this.state})); + return response; + } catch (e) { + if (e instanceof HttpRpcError && isEngineSszUnsupportedStatus(e.status)) { + this.logger.debug("Engine SSZ request unsupported by EL, falling back to JSON-RPC", { + method, + endpoint: dispatchPlan.request.urlPath, + status: e.status, + }); + return this.rpc.fetchWithRetries( + {method, params}, + methodOpts + ); + } + + this.updateEngineState(getExecutionEngineState({payloadError: e, oldState: this.state})); + throw e; + } + } + + private async fetchSszWithRetries( + method: M, + request: {urlPath: string; method: "GET" | "POST"; body?: Uint8Array; headers: Record}, + methodOpts: ReqOpts + ): Promise { + let lastError: Error | null = null; + const retries = methodOpts.retries ?? this.opts?.retries ?? 0; + const retryDelay = methodOpts.retryDelay ?? this.opts?.retryDelay; + + for (const baseUrl of this.opts?.urls ?? []) { + try { + return await retry(async () => this.fetchSszOnUrl(method, baseUrl, request, methodOpts), { + retries, + retryDelay, + shouldRetry: methodOpts.shouldRetry, + signal: this.signal, + }); + } catch (e) { + lastError = e as Error; + } + } + + throw lastError ?? Error(`No execution engine URLs available for SSZ request ${method}`); + } + + private async fetchSszOnUrl( + method: M, + baseUrl: string, + request: {urlPath: string; method: "GET" | "POST"; body?: Uint8Array; headers: Record}, + methodOpts: ReqOpts + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), methodOpts.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT); + const onParentSignalAbort = (): void => controller.abort(); + + this.signal.addEventListener("abort", onParentSignalAbort, {once: true}); + + try { + const headers: Record = {...request.headers}; + if (this.jwtSecret) { + const token = encodeJwtToken( + { + iat: Math.floor(Date.now() / 1000), + id: this.opts?.jwtId, + clv: this.opts?.jwtVersion, + }, + this.jwtSecret + ); + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(new URL(request.urlPath, baseUrl).toString(), { + method: request.method, + headers, + body: request.body ? Buffer.from(request.body) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + const bodyText = await response.text(); + throw new HttpRpcError(response.status, `${response.statusText}: ${bodyText.slice(0, MAX_ERROR_BODY_LENGTH)}`); + } + + const bodyBytes = response.status === 204 ? new Uint8Array() : new Uint8Array(await response.arrayBuffer()); + + return decodeEngineSszResponse(method, response.status, bodyBytes); + } catch (e) { + if (controller.signal.aborted) { + if (this.signal.aborted) { + throw new ErrorAborted("request"); + } + throw new TimeoutError("request"); + } + throw e; + } finally { + clearTimeout(timeout); + this.signal.removeEventListener("abort", onParentSignalAbort); + } + } + private updateEngineState(newState: ExecutionEngineState): void { const oldState = this.state; @@ -606,10 +756,12 @@ export class ExecutionEngineHttp implements IExecutionEngine { switch (newState) { case ExecutionEngineState.ONLINE: this.logger.info("Execution client became online", {oldState, newState}); - this.getClientVersion(getLodestarClientVersion(this.opts)).catch((e) => { - this.logger.debug("Unable to get execution client version", {}, e); - this.clientVersion = null; - }); + this.getClientVersion(getLodestarEngineClientVersion(this.opts)) + .then(() => this.exchangeCapabilities(LODESTAR_ENGINE_SSZ_CAPABILITIES)) + .catch((e) => { + this.logger.debug("Unable to negotiate execution engine capabilities", {}, e); + this.clientVersion = null; + }); break; case ExecutionEngineState.OFFLINE: this.logger.error("Execution client went offline", {oldState, newState}); diff --git a/packages/beacon-node/src/execution/engine/mock.ts b/packages/beacon-node/src/execution/engine/mock.ts index 2501ff031339..fde9555e8dae 100644 --- a/packages/beacon-node/src/execution/engine/mock.ts +++ b/packages/beacon-node/src/execution/engine/mock.ts @@ -145,9 +145,17 @@ export class ExecutionEngineMockBackend implements JsonRpcBackend { engine_getClientVersionV1: this.getClientVersionV1.bind(this), engine_getBlobsV1: this.getBlobs.bind(this), engine_getBlobsV2: this.getBlobsV2.bind(this), + engine_exchangeCapabilities: this.exchangeCapabilities.bind(this), }; } + private exchangeCapabilities( + capabilities: EngineApiRpcParamTypes["engine_exchangeCapabilities"][0] + ): EngineApiRpcReturnTypes["engine_exchangeCapabilities"] { + // Mock EL echoes advertised capabilities by default. + return capabilities; + } + private getPayloadBodiesByHash( _blockHex: EngineApiRpcParamTypes["engine_getPayloadBodiesByHashV1"][0] ): EngineApiRpcReturnTypes["engine_getPayloadBodiesByHashV1"] { diff --git a/packages/beacon-node/src/execution/engine/sszTransport.ts b/packages/beacon-node/src/execution/engine/sszTransport.ts new file mode 100644 index 000000000000..9ad422fe217d --- /dev/null +++ b/packages/beacon-node/src/execution/engine/sszTransport.ts @@ -0,0 +1,584 @@ +import {ByteListType, ByteVectorType, ContainerType, ListCompositeType} from "@chainsafe/ssz"; +import { + BYTES_PER_FIELD_ELEMENT, + CELLS_PER_EXT_BLOB, + FIELD_ELEMENTS_PER_BLOB, + ForkName, + MAX_BLOB_COMMITMENTS_PER_BLOCK, + MAX_BYTES_PER_TRANSACTION, + MAX_TRANSACTIONS_PER_PAYLOAD, + MAX_WITHDRAWALS_PER_PAYLOAD, +} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import {ExecutionPayloadStatus} from "./interface.js"; +import { + type EngineApiRpcParamTypes, + type EngineApiRpcReturnTypes, + deserializeWithdrawal, + serializeBlobsBundle, + serializeExecutionPayload, + serializeExecutionPayloadBody, +} from "./types.js"; +import {bytesToData, dataToBytes, numToQuantity, quantityToNum} from "./utils.js"; + +const MAX_PAYLOAD_BODIES_REQUEST = 32; +const MAX_BLOB_HASHES_REQUEST = 128; +const MAX_EXECUTION_REQUESTS = 256; +const MAX_ERROR_MESSAGE_LENGTH = 1024; +const MAX_CLIENT_CODE_LENGTH = 2; +const MAX_CLIENT_NAME_LENGTH = 64; +const MAX_CLIENT_VERSION_LENGTH = 64; +const MAX_CLIENT_VERSIONS = 4; + +const transactionByteListType = new ByteListType(MAX_BYTES_PER_TRANSACTION); +const transactionsType = new ListCompositeType(transactionByteListType, MAX_TRANSACTIONS_PER_PAYLOAD); + +const executionPayloadBodyV1Type = new ContainerType( + { + transactions: transactionsType, + withdrawals: new ListCompositeType(ssz.capella.Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD), + }, + {typeName: "EngineExecutionPayloadBodyV1"} +); + +const nullableExecutionPayloadBodyV1Type = new ListCompositeType(executionPayloadBodyV1Type, 1); + +const payloadBodiesV1ResponseType = new ContainerType( + { + payloadBodies: new ListCompositeType(nullableExecutionPayloadBodyV1Type, MAX_PAYLOAD_BODIES_REQUEST), + }, + {typeName: "EnginePayloadBodiesV1Response"} +); + +const getPayloadBodiesByHashV1RequestType = new ContainerType( + { + blockHashes: new ListCompositeType(ssz.Bytes32, MAX_PAYLOAD_BODIES_REQUEST), + }, + {typeName: "EngineGetPayloadBodiesByHashV1Request"} +); + +const getPayloadBodiesByRangeV1RequestType = new ContainerType( + { + start: ssz.UintNum64, + count: ssz.UintNum64, + }, + {typeName: "EngineGetPayloadBodiesByRangeV1Request"} +); + +const getBlobsV1RequestType = new ContainerType( + { + blobVersionedHashes: new ListCompositeType(ssz.Bytes32, MAX_BLOB_HASHES_REQUEST), + }, + {typeName: "EngineGetBlobsV1Request"} +); + +const getBlobsV2RequestType = new ContainerType( + { + blobVersionedHashes: new ListCompositeType(ssz.Bytes32, MAX_BLOB_HASHES_REQUEST), + }, + {typeName: "EngineGetBlobsV2Request"} +); + +const blobType = new ByteVectorType(BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB); + +const blobAndProofV1Type = new ContainerType( + { + blob: blobType, + proof: ssz.Bytes48, + }, + {typeName: "EngineBlobAndProofV1"} +); + +const blobAndProofV2Type = new ContainerType( + { + blob: blobType, + proofs: new ListCompositeType(ssz.Bytes48, CELLS_PER_EXT_BLOB), + }, + {typeName: "EngineBlobAndProofV2"} +); + +const getBlobsV1ResponseType = new ContainerType( + { + blobsAndProofs: new ListCompositeType(blobAndProofV1Type, MAX_BLOB_HASHES_REQUEST), + }, + {typeName: "EngineGetBlobsV1Response"} +); + +const getBlobsV2ResponseType = new ContainerType( + { + blobsAndProofs: new ListCompositeType(blobAndProofV2Type, MAX_BLOB_HASHES_REQUEST), + }, + {typeName: "EngineGetBlobsV2Response"} +); + +const payloadStatusV1Type = new ContainerType( + { + status: ssz.Uint8, + latestValidHash: ssz.Bytes32, + validationError: new ByteListType(MAX_ERROR_MESSAGE_LENGTH), + }, + {typeName: "EnginePayloadStatusV1"} +); + +const forkchoiceStateV1Type = new ContainerType( + { + headBlockHash: ssz.Bytes32, + safeBlockHash: ssz.Bytes32, + finalizedBlockHash: ssz.Bytes32, + }, + {typeName: "EngineForkchoiceStateV1"} +); + +const payloadAttributesV1Type = new ContainerType( + { + timestamp: ssz.UintNum64, + prevRandao: ssz.Bytes32, + suggestedFeeRecipient: ssz.Bytes20, + }, + {typeName: "EnginePayloadAttributesV1"} +); + +const payloadAttributesV2Type = new ContainerType( + { + timestamp: ssz.UintNum64, + prevRandao: ssz.Bytes32, + suggestedFeeRecipient: ssz.Bytes20, + withdrawals: new ListCompositeType(ssz.capella.Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD), + }, + {typeName: "EnginePayloadAttributesV2"} +); + +const payloadAttributesV3Type = new ContainerType( + { + timestamp: ssz.UintNum64, + prevRandao: ssz.Bytes32, + suggestedFeeRecipient: ssz.Bytes20, + withdrawals: new ListCompositeType(ssz.capella.Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD), + parentBeaconBlockRoot: ssz.Bytes32, + }, + {typeName: "EnginePayloadAttributesV3"} +); + +const forkchoiceUpdatedV1RequestType = new ContainerType( + { + forkchoiceState: forkchoiceStateV1Type, + payloadAttributes: new ListCompositeType(payloadAttributesV1Type, 1), + }, + {typeName: "EngineForkchoiceUpdatedV1Request"} +); + +const forkchoiceUpdatedV2RequestType = new ContainerType( + { + forkchoiceState: forkchoiceStateV1Type, + payloadAttributes: new ListCompositeType(payloadAttributesV2Type, 1), + }, + {typeName: "EngineForkchoiceUpdatedV2Request"} +); + +const forkchoiceUpdatedV3RequestType = new ContainerType( + { + forkchoiceState: forkchoiceStateV1Type, + payloadAttributes: new ListCompositeType(payloadAttributesV3Type, 1), + }, + {typeName: "EngineForkchoiceUpdatedV3Request"} +); + +const forkchoiceUpdatedResponseV1Type = new ContainerType( + { + payloadStatus: payloadStatusV1Type, + payloadId: ssz.Bytes8, + }, + {typeName: "EngineForkchoiceUpdatedResponseV1"} +); + +const newPayloadV1RequestType = new ContainerType( + { + executionPayload: ssz.bellatrix.ExecutionPayload, + }, + {typeName: "EngineNewPayloadV1Request"} +); + +const newPayloadV2RequestType = new ContainerType( + { + executionPayload: ssz.capella.ExecutionPayload, + }, + {typeName: "EngineNewPayloadV2Request"} +); + +const newPayloadV3RequestType = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + expectedBlobVersionedHashes: new ListCompositeType(ssz.Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK), + parentBeaconBlockRoot: ssz.Bytes32, + }, + {typeName: "EngineNewPayloadV3Request"} +); + +const newPayloadV4RequestType = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + expectedBlobVersionedHashes: new ListCompositeType(ssz.Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK), + parentBeaconBlockRoot: ssz.Bytes32, + executionRequests: new ListCompositeType(transactionByteListType, MAX_EXECUTION_REQUESTS), + }, + {typeName: "EngineNewPayloadV4Request"} +); + +const getPayloadResponseV2Type = new ContainerType( + { + executionPayload: ssz.capella.ExecutionPayload, + blockValue: ssz.UintBn256, + }, + {typeName: "EngineGetPayloadResponseV2"} +); + +const getPayloadResponseV3Type = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.deneb.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + }, + {typeName: "EngineGetPayloadResponseV3"} +); + +const getPayloadResponseV4Type = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.deneb.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + executionRequests: new ListCompositeType(transactionByteListType, MAX_EXECUTION_REQUESTS), + }, + {typeName: "EngineGetPayloadResponseV4"} +); + +const getPayloadResponseV5Type = new ContainerType( + { + executionPayload: ssz.deneb.ExecutionPayload, + blockValue: ssz.UintBn256, + blobsBundle: ssz.fulu.BlobsBundle, + shouldOverrideBuilder: ssz.Boolean, + executionRequests: new ListCompositeType(transactionByteListType, MAX_EXECUTION_REQUESTS), + }, + {typeName: "EngineGetPayloadResponseV5"} +); + +const clientVersionV1Type = new ContainerType( + { + code: new ByteListType(MAX_CLIENT_CODE_LENGTH), + name: new ByteListType(MAX_CLIENT_NAME_LENGTH), + version: new ByteListType(MAX_CLIENT_VERSION_LENGTH), + commit: new ByteVectorType(4), + }, + {typeName: "EngineClientVersionV1"} +); + +const getClientVersionV1RequestType = new ContainerType( + { + clientVersion: clientVersionV1Type, + }, + {typeName: "EngineGetClientVersionV1Request"} +); + +const getClientVersionV1ResponseType = new ContainerType( + { + versions: new ListCompositeType(clientVersionV1Type, MAX_CLIENT_VERSIONS), + }, + {typeName: "EngineGetClientVersionV1Response"} +); + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const payloadStatusByCode: Record = { + 0: ExecutionPayloadStatus.VALID, + 1: ExecutionPayloadStatus.INVALID, + 2: ExecutionPayloadStatus.SYNCING, + 3: ExecutionPayloadStatus.ACCEPTED, + 4: ExecutionPayloadStatus.INVALID_BLOCK_HASH, +}; + +const zeroRootHex = bytesToData(new Uint8Array(32)); +const zeroPayloadIdHex = bytesToData(new Uint8Array(8)); + +function parsePayloadStatusFromSsz(value: {status: number; latestValidHash: Uint8Array; validationError: Uint8Array}): { + status: ExecutionPayloadStatus; + latestValidHash: string | null; + validationError: string | null; +} { + return { + status: payloadStatusByCode[value.status] ?? ExecutionPayloadStatus.ELERROR, + latestValidHash: bytesToData(value.latestValidHash) === zeroRootHex ? null : bytesToData(value.latestValidHash), + validationError: value.validationError.length === 0 ? null : textDecoder.decode(value.validationError), + }; +} + +export function encodeEngineSszRequest( + method: keyof EngineApiRpcParamTypes, + params: unknown[] +): Uint8Array | undefined { + switch (method) { + case "engine_newPayloadV1": { + const [executionPayload] = params as EngineApiRpcParamTypes["engine_newPayloadV1"]; + return newPayloadV1RequestType.serialize({ + executionPayload: ssz.bellatrix.ExecutionPayload.fromJson(executionPayload), + }); + } + + case "engine_newPayloadV2": { + const [executionPayload] = params as EngineApiRpcParamTypes["engine_newPayloadV2"]; + return newPayloadV2RequestType.serialize({ + executionPayload: ssz.capella.ExecutionPayload.fromJson(executionPayload), + }); + } + + case "engine_newPayloadV3": { + const [executionPayload, expectedBlobVersionedHashes, parentBeaconBlockRoot] = + params as EngineApiRpcParamTypes["engine_newPayloadV3"]; + return newPayloadV3RequestType.serialize({ + executionPayload: ssz.deneb.ExecutionPayload.fromJson(executionPayload), + expectedBlobVersionedHashes: expectedBlobVersionedHashes.map((hash) => dataToBytes(hash, 32)), + parentBeaconBlockRoot: dataToBytes(parentBeaconBlockRoot, 32), + }); + } + + case "engine_newPayloadV4": { + const [executionPayload, expectedBlobVersionedHashes, parentBeaconBlockRoot, executionRequests] = + params as EngineApiRpcParamTypes["engine_newPayloadV4"]; + return newPayloadV4RequestType.serialize({ + executionPayload: ssz.deneb.ExecutionPayload.fromJson(executionPayload), + expectedBlobVersionedHashes: expectedBlobVersionedHashes.map((hash) => dataToBytes(hash, 32)), + parentBeaconBlockRoot: dataToBytes(parentBeaconBlockRoot, 32), + executionRequests: executionRequests.map((request) => dataToBytes(request, null)), + }); + } + + case "engine_forkchoiceUpdatedV1": { + const [forkchoiceState, payloadAttributes] = params as EngineApiRpcParamTypes["engine_forkchoiceUpdatedV1"]; + return forkchoiceUpdatedV1RequestType.serialize({ + forkchoiceState: { + headBlockHash: dataToBytes(forkchoiceState.headBlockHash, 32), + safeBlockHash: dataToBytes(forkchoiceState.safeBlockHash, 32), + finalizedBlockHash: dataToBytes(forkchoiceState.finalizedBlockHash, 32), + }, + payloadAttributes: + payloadAttributes === undefined + ? [] + : [ + { + timestamp: quantityToNum(payloadAttributes.timestamp), + prevRandao: dataToBytes(payloadAttributes.prevRandao, 32), + suggestedFeeRecipient: dataToBytes(payloadAttributes.suggestedFeeRecipient, 20), + }, + ], + }); + } + + case "engine_forkchoiceUpdatedV2": { + const [forkchoiceState, payloadAttributes] = params as EngineApiRpcParamTypes["engine_forkchoiceUpdatedV2"]; + return forkchoiceUpdatedV2RequestType.serialize({ + forkchoiceState: { + headBlockHash: dataToBytes(forkchoiceState.headBlockHash, 32), + safeBlockHash: dataToBytes(forkchoiceState.safeBlockHash, 32), + finalizedBlockHash: dataToBytes(forkchoiceState.finalizedBlockHash, 32), + }, + payloadAttributes: + payloadAttributes === undefined + ? [] + : [ + { + timestamp: quantityToNum(payloadAttributes.timestamp), + prevRandao: dataToBytes(payloadAttributes.prevRandao, 32), + suggestedFeeRecipient: dataToBytes(payloadAttributes.suggestedFeeRecipient, 20), + withdrawals: (payloadAttributes.withdrawals ?? []).map(deserializeWithdrawal), + }, + ], + }); + } + + case "engine_forkchoiceUpdatedV3": { + const [forkchoiceState, payloadAttributes] = params as EngineApiRpcParamTypes["engine_forkchoiceUpdatedV3"]; + return forkchoiceUpdatedV3RequestType.serialize({ + forkchoiceState: { + headBlockHash: dataToBytes(forkchoiceState.headBlockHash, 32), + safeBlockHash: dataToBytes(forkchoiceState.safeBlockHash, 32), + finalizedBlockHash: dataToBytes(forkchoiceState.finalizedBlockHash, 32), + }, + payloadAttributes: + payloadAttributes === undefined + ? [] + : [ + { + timestamp: quantityToNum(payloadAttributes.timestamp), + prevRandao: dataToBytes(payloadAttributes.prevRandao, 32), + suggestedFeeRecipient: dataToBytes(payloadAttributes.suggestedFeeRecipient, 20), + withdrawals: (payloadAttributes.withdrawals ?? []).map(deserializeWithdrawal), + parentBeaconBlockRoot: dataToBytes(payloadAttributes.parentBeaconBlockRoot ?? zeroRootHex, 32), + }, + ], + }); + } + + case "engine_getPayloadBodiesByHashV1": { + const [blockHashes] = params as EngineApiRpcParamTypes["engine_getPayloadBodiesByHashV1"]; + return getPayloadBodiesByHashV1RequestType.serialize({ + blockHashes: blockHashes.map((hash) => dataToBytes(hash, 32)), + }); + } + + case "engine_getPayloadBodiesByRangeV1": { + const [start, count] = params as EngineApiRpcParamTypes["engine_getPayloadBodiesByRangeV1"]; + return getPayloadBodiesByRangeV1RequestType.serialize({start: quantityToNum(start), count: quantityToNum(count)}); + } + + case "engine_getBlobsV1": { + const [blobVersionedHashes] = params as EngineApiRpcParamTypes["engine_getBlobsV1"]; + return getBlobsV1RequestType.serialize({ + blobVersionedHashes: blobVersionedHashes.map((hash) => dataToBytes(hash, 32)), + }); + } + + case "engine_getBlobsV2": { + const [blobVersionedHashes] = params as EngineApiRpcParamTypes["engine_getBlobsV2"]; + return getBlobsV2RequestType.serialize({ + blobVersionedHashes: blobVersionedHashes.map((hash) => dataToBytes(hash, 32)), + }); + } + + case "engine_getClientVersionV1": { + const [clientVersion] = params as EngineApiRpcParamTypes["engine_getClientVersionV1"]; + return getClientVersionV1RequestType.serialize({ + clientVersion: { + code: textEncoder.encode(clientVersion.code), + name: textEncoder.encode(clientVersion.name), + version: textEncoder.encode(clientVersion.version), + commit: dataToBytes(clientVersion.commit, 4), + }, + }); + } + + default: + return undefined; + } +} + +export function decodeEngineSszResponse( + method: M, + status: number, + bytes: Uint8Array +): EngineApiRpcReturnTypes[M] { + if (status === 204) { + if (method === "engine_getBlobsV2") { + return null as EngineApiRpcReturnTypes[M]; + } + + throw Error(`Unexpected 204 status for ${method}`); + } + + switch (method) { + case "engine_newPayloadV1": + case "engine_newPayloadV2": + case "engine_newPayloadV3": + case "engine_newPayloadV4": { + return parsePayloadStatusFromSsz(payloadStatusV1Type.deserialize(bytes)) as EngineApiRpcReturnTypes[M]; + } + + case "engine_forkchoiceUpdatedV1": + case "engine_forkchoiceUpdatedV2": + case "engine_forkchoiceUpdatedV3": { + const response = forkchoiceUpdatedResponseV1Type.deserialize(bytes); + return { + payloadStatus: parsePayloadStatusFromSsz(response.payloadStatus), + payloadId: bytesToData(response.payloadId) === zeroPayloadIdHex ? null : bytesToData(response.payloadId), + } as EngineApiRpcReturnTypes[M]; + } + + case "engine_getPayloadV1": { + const executionPayload = ssz.bellatrix.ExecutionPayload.deserialize(bytes); + return serializeExecutionPayload(ForkName.bellatrix, executionPayload) as EngineApiRpcReturnTypes[M]; + } + + case "engine_getPayloadV2": { + const response = getPayloadResponseV2Type.deserialize(bytes); + return { + executionPayload: serializeExecutionPayload(ForkName.capella, response.executionPayload), + blockValue: numToQuantity(response.blockValue), + } as EngineApiRpcReturnTypes[M]; + } + + case "engine_getPayloadV3": { + const response = getPayloadResponseV3Type.deserialize(bytes); + return { + executionPayload: serializeExecutionPayload(ForkName.deneb, response.executionPayload), + blockValue: numToQuantity(response.blockValue), + blobsBundle: serializeBlobsBundle(response.blobsBundle), + shouldOverrideBuilder: response.shouldOverrideBuilder, + } as EngineApiRpcReturnTypes[M]; + } + + case "engine_getPayloadV4": { + const response = getPayloadResponseV4Type.deserialize(bytes); + return { + executionPayload: serializeExecutionPayload(ForkName.deneb, response.executionPayload), + blockValue: numToQuantity(response.blockValue), + blobsBundle: serializeBlobsBundle(response.blobsBundle), + shouldOverrideBuilder: response.shouldOverrideBuilder, + executionRequests: response.executionRequests.map((request) => bytesToData(request)), + } as EngineApiRpcReturnTypes[M]; + } + + case "engine_getPayloadV5": { + const response = getPayloadResponseV5Type.deserialize(bytes); + return { + executionPayload: serializeExecutionPayload(ForkName.deneb, response.executionPayload), + blockValue: numToQuantity(response.blockValue), + blobsBundle: serializeBlobsBundle(response.blobsBundle), + shouldOverrideBuilder: response.shouldOverrideBuilder, + executionRequests: response.executionRequests.map((request) => bytesToData(request)), + } as EngineApiRpcReturnTypes[M]; + } + + case "engine_getPayloadBodiesByHashV1": + case "engine_getPayloadBodiesByRangeV1": { + const response = payloadBodiesV1ResponseType.deserialize(bytes); + return response.payloadBodies.map((nullableBody) => { + if (nullableBody.length === 0) return null; + const body = nullableBody[0]; + return serializeExecutionPayloadBody({ + transactions: body.transactions, + withdrawals: body.withdrawals, + }); + }) as EngineApiRpcReturnTypes[M]; + } + + case "engine_getBlobsV1": { + const response = getBlobsV1ResponseType.deserialize(bytes); + return response.blobsAndProofs.map((blobAndProof) => ({ + blob: bytesToData(blobAndProof.blob), + proof: bytesToData(blobAndProof.proof), + })) as EngineApiRpcReturnTypes[M]; + } + + case "engine_getBlobsV2": { + const response = getBlobsV2ResponseType.deserialize(bytes); + return response.blobsAndProofs.map((blobAndProof) => ({ + blob: bytesToData(blobAndProof.blob), + proofs: blobAndProof.proofs.map((proof) => bytesToData(proof)), + })) as EngineApiRpcReturnTypes[M]; + } + + case "engine_getClientVersionV1": { + const response = getClientVersionV1ResponseType.deserialize(bytes); + return response.versions.map((version) => ({ + code: textDecoder.decode(version.code), + name: textDecoder.decode(version.name), + version: textDecoder.decode(version.version), + commit: bytesToData(version.commit), + })) as EngineApiRpcReturnTypes[M]; + } + + default: + throw Error(`Unsupported SSZ response decoder for ${method}`); + } +} diff --git a/packages/beacon-node/src/execution/engine/types.ts b/packages/beacon-node/src/execution/engine/types.ts index cfc910d8d206..64fd75ecd877 100644 --- a/packages/beacon-node/src/execution/engine/types.ts +++ b/packages/beacon-node/src/execution/engine/types.ts @@ -94,6 +94,13 @@ export type EngineApiRpcParamTypes = { engine_getBlobsV1: [DATA[]]; engine_getBlobsV2: [DATA[]]; + + /** + * Exchange supported capability identifiers between CL and EL. + * + * Used for Engine API transport negotiation and feature discovery. + */ + engine_exchangeCapabilities: [string[]]; }; export type PayloadStatus = { @@ -140,6 +147,9 @@ export type EngineApiRpcReturnTypes = { engine_getBlobsV1: (BlobAndProofRpc | null)[]; engine_getBlobsV2: BlobAndProofV2Rpc[] | null; + + /** Returns EL-advertised capability identifiers. */ + engine_exchangeCapabilities: string[]; }; type ExecutionPayloadRpcWithValue = { diff --git a/packages/beacon-node/test/unit/execution/engine/http.sszFallback.test.ts b/packages/beacon-node/test/unit/execution/engine/http.sszFallback.test.ts new file mode 100644 index 000000000000..5948687d24c0 --- /dev/null +++ b/packages/beacon-node/test/unit/execution/engine/http.sszFallback.test.ts @@ -0,0 +1,139 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {ByteListType, ContainerType, ListCompositeType} from "@chainsafe/ssz"; +import {ForkName} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import * as lodestarUtils from "@lodestar/utils"; +import {ExecutionEngineHttp} from "../../../../src/execution/engine/http.js"; +import type {IJsonRpcHttpClient} from "../../../../src/execution/engine/jsonRpcHttpClient.js"; +import {JsonRpcHttpClientEventEmitter} from "../../../../src/execution/engine/jsonRpcHttpClient.js"; + +class StubRpcClient implements IJsonRpcHttpClient { + emitter = new JsonRpcHttpClientEventEmitter(); + fetch = vi.fn(); + fetchWithRetries = vi.fn(); + fetchBatch = vi.fn(); +} + +const executionPayloadBodyV1Type = new ContainerType( + { + transactions: new ListCompositeType(new ByteListType(1024), 16), + withdrawals: new ListCompositeType(ssz.capella.Withdrawal, 16), + }, + {typeName: "EngineExecutionPayloadBodyV1Test"} +); + +const nullableExecutionPayloadBodyV1Type = new ListCompositeType(executionPayloadBodyV1Type, 1); +const payloadBodiesV1ResponseType = new ContainerType( + { + payloadBodies: new ListCompositeType(nullableExecutionPayloadBodyV1Type, 32), + }, + {typeName: "EnginePayloadBodiesV1ResponseTest"} +); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("execution / engine / http.sszFallback", () => { + it("uses negotiated SSZ endpoint and skips JSON-RPC", async () => { + const rpc = new StubRpcClient(); + const logger = {debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any; + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://localhost:8551"], retries: 0, retryDelay: 0} + ); + + (engine as any).sszNegotiation.updateFromElCapabilities(["POST /engine/v1/payloads/bodies/by-range"]); + + const sszResponseBytes = payloadBodiesV1ResponseType.serialize({ + payloadBodies: [ + [ + { + transactions: [Uint8Array.from([0xaa, 0xbb])], + withdrawals: [], + }, + ], + ], + }); + + const fetchSpy = vi.spyOn(lodestarUtils, "fetch").mockResolvedValue( + new Response(sszResponseBytes, { + status: 200, + headers: {"content-type": "application/octet-stream"}, + }) + ); + + const res = await engine.getPayloadBodiesByRange(ForkName.deneb, 10, 1); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(rpc.fetchWithRetries).not.toHaveBeenCalled(); + expect(res).toEqual([{transactions: [Uint8Array.from([0xaa, 0xbb])], withdrawals: []}]); + }); + + it.each([404, 415, 501])("falls back to JSON-RPC on unsupported SSZ status %s", async (statusCode) => { + const rpc = new StubRpcClient(); + const logger = {debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any; + rpc.fetchWithRetries.mockResolvedValue([{transactions: ["0xaabb"], withdrawals: []}]); + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://localhost:8551"], retries: 0, retryDelay: 0} + ); + (engine as any).sszNegotiation.updateFromElCapabilities(["POST /engine/v1/payloads/bodies/by-range"]); + const fetchSpy = vi + .spyOn(lodestarUtils, "fetch") + .mockResolvedValue(new Response("unsupported", {status: statusCode, statusText: "Unsupported"})); + const res = await engine.getPayloadBodiesByRange(ForkName.deneb, 10, 1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(rpc.fetchWithRetries).toHaveBeenCalledTimes(1); + expect(res).toEqual([{transactions: [Uint8Array.from([0xaa, 0xbb])], withdrawals: []}]); + }); + + it("does not fallback to JSON-RPC on non-unsupported SSZ server status", async () => { + const rpc = new StubRpcClient(); + const logger = {debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any; + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://localhost:8551"], retries: 0, retryDelay: 0} + ); + + (engine as any).sszNegotiation.updateFromElCapabilities(["POST /engine/v1/payloads/bodies/by-range"]); + + const fetchSpy = vi.spyOn(lodestarUtils, "fetch").mockResolvedValue( + new Response("server error", { + status: 500, + statusText: "Internal Server Error", + }) + ); + + await expect(engine.getPayloadBodiesByRange(ForkName.deneb, 10, 1)).rejects.toThrow("Internal Server Error"); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(rpc.fetchWithRetries).not.toHaveBeenCalled(); + }); + + it("uses JSON-RPC directly when endpoint is not negotiated", async () => { + const rpc = new StubRpcClient(); + const logger = {debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any; + + rpc.fetchWithRetries.mockResolvedValue([{transactions: ["0x01"], withdrawals: []}]); + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://localhost:8551"], retries: 0, retryDelay: 0} + ); + + const fetchSpy = vi.spyOn(lodestarUtils, "fetch"); + + const res = await engine.getPayloadBodiesByRange(ForkName.deneb, 10, 1); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(rpc.fetchWithRetries).toHaveBeenCalledTimes(1); + expect(res).toEqual([{transactions: [Uint8Array.from([1])], withdrawals: []}]); + }); +}); diff --git a/packages/beacon-node/test/unit/execution/engine/http.sszGethE2e.test.ts b/packages/beacon-node/test/unit/execution/engine/http.sszGethE2e.test.ts new file mode 100644 index 000000000000..06220c0f88af --- /dev/null +++ b/packages/beacon-node/test/unit/execution/engine/http.sszGethE2e.test.ts @@ -0,0 +1,218 @@ +import {readFileSync} from "node:fs"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {LODESTAR_ENGINE_SSZ_CAPABILITIES} from "@lodestar/api"; +import {ForkName} from "@lodestar/params"; +import * as lodestarUtils from "@lodestar/utils"; +import {fromHex} from "@lodestar/utils"; +import {ExecutionEngineHttp} from "../../../../src/execution/engine/http.js"; +import {JsonRpcHttpClient} from "../../../../src/execution/engine/jsonRpcHttpClient.js"; + +const runE2e = process.env.ENGINE_SSZ_GETH_E2E === "1"; +const describeE2e = runE2e ? describe : describe.skip; +const realFetch = lodestarUtils.fetch; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function readJwtHex(): string { + const path = process.env.ENGINE_SSZ_GETH_JWT ?? "/tmp/geth-jwt.hex"; + return readFileSync(path, "utf8").trim(); +} + +describeE2e("execution / engine / http.sszGethE2e", () => { + const logger = {debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn()} as any; + + it("negotiates SSZ endpoint support from geth method-name capability response", async () => { + const jwtSecretHex = readJwtHex(); + const rpc = new JsonRpcHttpClient(["http://127.0.0.1:8551"], { + jwtSecret: fromHex(jwtSecretHex), + retries: 0, + retryDelay: 0, + timeout: 5000, + }); + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://127.0.0.1:8551"], retries: 0, retryDelay: 0, timeout: 5000, jwtSecretHex} + ); + + (engine as any).clientVersion = null; + await engine.exchangeCapabilities(LODESTAR_ENGINE_SSZ_CAPABILITIES); + + const supported = (engine as any).sszNegotiation.getSupportedCapabilities(); + expect(supported).toContain("POST /engine/v1/client/version"); + expect(supported).toContain("POST /engine/v1/payloads/bodies/by-range"); + expect(supported).toContain("POST /engine/v1/payloads/bodies/by-hash"); + expect(supported.length).toBeGreaterThan(0); + }); + + it("attempts SSZ path, then falls back to JSON-RPC on live geth unsupported status", async () => { + const jwtSecretHex = readJwtHex(); + const rpc = new JsonRpcHttpClient(["http://127.0.0.1:8551"], { + jwtSecret: fromHex(jwtSecretHex), + retries: 0, + retryDelay: 0, + timeout: 5000, + }); + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://127.0.0.1:8551"], retries: 0, retryDelay: 0, timeout: 5000, jwtSecretHex} + ); + + (engine as any).clientVersion = null; + await engine.exchangeCapabilities(LODESTAR_ENGINE_SSZ_CAPABILITIES); + + const sszStatuses: number[] = []; + const fetchSpy = vi.spyOn(lodestarUtils, "fetch").mockImplementation(async (...args: unknown[]) => { + const res = await (realFetch as (...a: unknown[]) => Promise)(...args); + if (String(args[0]).endsWith("/engine/v1/client/version")) { + sszStatuses.push(res.status); + } + return res; + }); + + const fetchWithRetriesSpy = vi.spyOn(rpc, "fetchWithRetries"); + + const versions = await engine.getClientVersion({ + code: "LS", + name: "Lodestar", + version: "e2e", + commit: "deadbeef", + }); + + const sszCalls = fetchSpy.mock.calls.filter(([url]) => String(url).endsWith("/engine/v1/client/version")); + const clientVersionJsonCalls = fetchWithRetriesSpy.mock.calls.filter( + ([payload]) => (payload as any).method === "engine_getClientVersionV1" + ); + + expect(versions.length).toBeGreaterThan(0); + expect(sszCalls.length).toBeGreaterThan(0); + expect(sszStatuses).toContain(404); + expect(clientVersionJsonCalls.length).toBeGreaterThan(0); + }); + + it("attempts SSZ payload-bodies endpoint, then falls back to JSON-RPC on live geth unsupported status", async () => { + const jwtSecretHex = readJwtHex(); + const rpc = new JsonRpcHttpClient(["http://127.0.0.1:8551"], { + jwtSecret: fromHex(jwtSecretHex), + retries: 0, + retryDelay: 0, + timeout: 5000, + }); + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://127.0.0.1:8551"], retries: 0, retryDelay: 0, timeout: 5000, jwtSecretHex} + ); + + (engine as any).clientVersion = null; + await engine.exchangeCapabilities(LODESTAR_ENGINE_SSZ_CAPABILITIES); + + const sszStatuses: number[] = []; + const fetchSpy = vi.spyOn(lodestarUtils, "fetch").mockImplementation(async (...args: unknown[]) => { + const res = await (realFetch as (...a: unknown[]) => Promise)(...args); + if (String(args[0]).endsWith("/engine/v1/payloads/bodies/by-range")) { + sszStatuses.push(res.status); + } + return res; + }); + + const fetchWithRetriesSpy = vi.spyOn(rpc, "fetchWithRetries"); + + const payloadBodies = await engine.getPayloadBodiesByRange(ForkName.deneb, 1, 1); + + const sszCalls = fetchSpy.mock.calls.filter(([url]) => String(url).endsWith("/engine/v1/payloads/bodies/by-range")); + const jsonCalls = fetchWithRetriesSpy.mock.calls.filter( + ([payload]) => (payload as any).method === "engine_getPayloadBodiesByRangeV1" + ); + + expect(Array.isArray(payloadBodies)).toBe(true); + expect(sszCalls.length).toBeGreaterThan(0); + expect(sszStatuses).toContain(404); + expect(jsonCalls.length).toBeGreaterThan(0); + }); + + it("attempts SSZ payload-bodies-by-hash endpoint, then falls back to JSON-RPC on live geth unsupported status", async () => { + const jwtSecretHex = readJwtHex(); + const rpc = new JsonRpcHttpClient(["http://127.0.0.1:8551"], { + jwtSecret: fromHex(jwtSecretHex), + retries: 0, + retryDelay: 0, + timeout: 5000, + }); + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://127.0.0.1:8551"], retries: 0, retryDelay: 0, timeout: 5000, jwtSecretHex} + ); + + (engine as any).clientVersion = null; + await engine.exchangeCapabilities(LODESTAR_ENGINE_SSZ_CAPABILITIES); + + const sszStatuses: number[] = []; + const fetchSpy = vi.spyOn(lodestarUtils, "fetch").mockImplementation(async (...args: unknown[]) => { + const res = await (realFetch as (...a: unknown[]) => Promise)(...args); + if (String(args[0]).endsWith("/engine/v1/payloads/bodies/by-hash")) { + sszStatuses.push(res.status); + } + return res; + }); + + const fetchWithRetriesSpy = vi.spyOn(rpc, "fetchWithRetries"); + + const payloadBodies = await engine.getPayloadBodiesByHash(ForkName.deneb, [ + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]); + + const sszCalls = fetchSpy.mock.calls.filter(([url]) => String(url).endsWith("/engine/v1/payloads/bodies/by-hash")); + const jsonCalls = fetchWithRetriesSpy.mock.calls.filter( + ([payload]) => (payload as any).method === "engine_getPayloadBodiesByHashV1" + ); + + expect(Array.isArray(payloadBodies)).toBe(true); + expect(sszCalls.length).toBeGreaterThan(0); + expect(sszStatuses).toContain(404); + expect(jsonCalls.length).toBeGreaterThan(0); + }); + + it("falls back to JSON-RPC when endpoint is not negotiated", async () => { + const jwtSecretHex = readJwtHex(); + const rpc = new JsonRpcHttpClient(["http://127.0.0.1:8551"], { + jwtSecret: fromHex(jwtSecretHex), + retries: 0, + retryDelay: 0, + timeout: 5000, + }); + + const engine = new ExecutionEngineHttp( + rpc, + {signal: new AbortController().signal, logger, metrics: null}, + {urls: ["http://127.0.0.1:8551"], retries: 0, retryDelay: 0, timeout: 5000, jwtSecretHex} + ); + + (engine as any).clientVersion = null; + (engine as any).sszNegotiation.updateFromElCapabilities([]); + + const fetchWithRetriesSpy = vi.spyOn(rpc, "fetchWithRetries"); + + const versions = await engine.getClientVersion({ + code: "LS", + name: "Lodestar", + version: "e2e", + commit: "deadbeef", + }); + + const clientVersionJsonCalls = fetchWithRetriesSpy.mock.calls.filter( + ([payload]) => (payload as any).method === "engine_getClientVersionV1" + ); + + expect(versions.length).toBeGreaterThan(0); + expect(clientVersionJsonCalls.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/beacon-node/test/unit/execution/engine/http.sszPositiveE2e.test.ts b/packages/beacon-node/test/unit/execution/engine/http.sszPositiveE2e.test.ts new file mode 100644 index 000000000000..a1f906e0326f --- /dev/null +++ b/packages/beacon-node/test/unit/execution/engine/http.sszPositiveE2e.test.ts @@ -0,0 +1,45 @@ +import {readFileSync} from "node:fs"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {fetch, fromHex} from "@lodestar/utils"; +import {encodeJwtToken} from "../../../../src/execution/engine/jwt.js"; + +const runE2e = process.env.ENGINE_SSZ_GETH_POSITIVE_E2E === "1"; +const describeE2e = runE2e ? describe : describe.skip; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function readJwtHex(): string { + const path = process.env.ENGINE_SSZ_GETH_JWT ?? "/tmp/geth-jwt.hex"; + return readFileSync(path, "utf8").trim(); +} + +describeE2e("execution / engine / http.sszPositiveE2e", () => { + it("gets 200 + binary body from live SSZ REST endpoint on geth PR33926", async () => { + const jwtSecretHex = readJwtHex(); + const sszUrl = process.env.ENGINE_SSZ_GETH_SSZ_URL ?? "http://127.0.0.1:11552"; + + const token = encodeJwtToken({iat: Math.floor(Date.now() / 1000)}, fromHex(jwtSecretHex)); + + const res = await fetch(`${sszUrl}/engine/v1/get_client_version`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/octet-stream", + Accept: "application/octet-stream", + }, + body: new Uint8Array(), + }); + + const bytes = new Uint8Array(await res.arrayBuffer()); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/octet-stream"); + expect(bytes.length).toBeGreaterThan(0); + + // first 4 bytes in this legacy geth endpoint payload are count LE uint32 + const count = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true); + expect(count).toBeGreaterThan(0); + }); +}); diff --git a/packages/beacon-node/test/unit/execution/engine/sszTransport.test.ts b/packages/beacon-node/test/unit/execution/engine/sszTransport.test.ts new file mode 100644 index 000000000000..cd59e42942e8 --- /dev/null +++ b/packages/beacon-node/test/unit/execution/engine/sszTransport.test.ts @@ -0,0 +1,116 @@ +import {describe, expect, it} from "vitest"; +import {ByteListType, ContainerType, ListCompositeType} from "@chainsafe/ssz"; +import {ssz} from "@lodestar/types"; +import {ExecutionPayloadStatus} from "../../../../src/execution/engine/interface.js"; +import {decodeEngineSszResponse, encodeEngineSszRequest} from "../../../../src/execution/engine/sszTransport.js"; + +const payloadStatusV1Type = new ContainerType( + { + status: ssz.Uint8, + latestValidHash: ssz.Bytes32, + validationError: new ByteListType(1024), + }, + {typeName: "PayloadStatusV1Test"} +); + +const forkchoiceUpdatedResponseV1Type = new ContainerType( + { + payloadStatus: payloadStatusV1Type, + payloadId: ssz.Bytes8, + }, + {typeName: "ForkchoiceUpdatedResponseV1Test"} +); + +const clientVersionV1Type = new ContainerType( + { + code: new ByteListType(2), + name: new ByteListType(64), + version: new ByteListType(64), + commit: ssz.Bytes4, + }, + {typeName: "ClientVersionV1Test"} +); + +const getClientVersionV1ResponseType = new ContainerType( + { + versions: new ListCompositeType(clientVersionV1Type, 4), + }, + {typeName: "GetClientVersionV1ResponseTest"} +); + +describe("execution / engine / sszTransport", () => { + it("encodes payload bodies by range request", () => { + const bytes = encodeEngineSszRequest("engine_getPayloadBodiesByRangeV1", ["0x10", "0x02"]); + + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes?.length).toBeGreaterThan(0); + }); + + it("returns undefined for methods without SSZ request body", () => { + const bytes = encodeEngineSszRequest("engine_getPayloadV3", ["0x0102030405060708"]); + + expect(bytes).toBeUndefined(); + }); + + it("decodes payload status response", () => { + const bytes = payloadStatusV1Type.serialize({ + status: 0, + latestValidHash: new Uint8Array(32), + validationError: new Uint8Array(), + }); + + const res = decodeEngineSszResponse("engine_newPayloadV3", 200, bytes); + + expect(res).toEqual({ + status: ExecutionPayloadStatus.VALID, + latestValidHash: null, + validationError: null, + }); + }); + + it("decodes forkchoice response payload id", () => { + const bytes = forkchoiceUpdatedResponseV1Type.serialize({ + payloadStatus: { + status: 0, + latestValidHash: new Uint8Array(32), + validationError: new Uint8Array(), + }, + payloadId: Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]), + }); + + const res = decodeEngineSszResponse("engine_forkchoiceUpdatedV3", 200, bytes); + + expect(res.payloadStatus.status).toBe(ExecutionPayloadStatus.VALID); + expect(res.payloadId).toBe("0x0102030405060708"); + }); + + it("decodes 204 blobs-v2 response as null", () => { + const res = decodeEngineSszResponse("engine_getBlobsV2", 204, new Uint8Array()); + + expect(res).toBeNull(); + }); + + it("decodes client version response", () => { + const bytes = getClientVersionV1ResponseType.serialize({ + versions: [ + { + code: new TextEncoder().encode("GE"), + name: new TextEncoder().encode("geth"), + version: new TextEncoder().encode("v1.2.3"), + commit: Uint8Array.from([0xaa, 0xbb, 0xcc, 0xdd]), + }, + ], + }); + + const res = decodeEngineSszResponse("engine_getClientVersionV1", 200, bytes); + + expect(res).toEqual([ + { + code: "GE", + name: "geth", + version: "v1.2.3", + commit: "0xaabbccdd", + }, + ]); + }); +});