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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
49 changes: 49 additions & 0 deletions packages/api/src/utils/client/engineSszCapabilities.ts
Original file line number Diff line number Diff line change
@@ -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<EngineSszEndpoint> {
const clSet = new Set(clCapabilities.filter(isEngineSszCapability).map(normalizeCapability));
const supported = new Set<EngineSszEndpoint>();

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<EngineSszEndpoint>,
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}`;
}
52 changes: 52 additions & 0 deletions packages/api/src/utils/client/engineSszDispatchPlan.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
48 changes: 48 additions & 0 deletions packages/api/src/utils/client/engineSszHttp.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

/**
* 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
);
}
33 changes: 33 additions & 0 deletions packages/api/src/utils/client/engineSszLodestarProfile.ts
Original file line number Diff line number Diff line change
@@ -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,
]);
151 changes: 151 additions & 0 deletions packages/api/src/utils/client/engineSszMethodMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, EngineSszMethodDescriptor> = {
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<string, {pathPrefix: string; capability: EngineSszEndpoint}> = {
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<EngineSszEndpoint>();
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();
}
Loading
Loading