diff --git a/packages/beacon-node/src/execution/engine/http.ts b/packages/beacon-node/src/execution/engine/http.ts index 69094ce2fa8b..11db265c2601 100644 --- a/packages/beacon-node/src/execution/engine/http.ts +++ b/packages/beacon-node/src/execution/engine/http.ts @@ -1,9 +1,9 @@ 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 {BlobsBundle, ExecutionPayload, ExecutionRequests, Root, RootHex, Wei, ssz as sszCodecs} from "@lodestar/types"; import {BlobAndProof} from "@lodestar/types/deneb"; import {BlobAndProofV2} from "@lodestar/types/fulu"; -import {strip0xPrefix} from "@lodestar/utils"; +import {fromHex, strip0xPrefix} from "@lodestar/utils"; import {Metrics} from "../../metrics/index.js"; import {EPOCHS_PER_BATCH} from "../../sync/constants.js"; import {getLodestarClientVersion} from "../../util/metadata.js"; @@ -27,6 +27,17 @@ import { ReqOpts, } from "./jsonRpcHttpClient.js"; import {PayloadIdCache} from "./payloadIdCache.js"; +import {SszRestClient, isSszRestNetworkError} from "./sszRestClient.js"; +import { + decodeForkchoiceUpdatedResponse, + decodeGetBlobsResponse, + decodeGetPayloadResponse, + decodePayloadStatus, + encodeForkchoiceUpdatedRequest, + encodeGetBlobsRequest, + encodeGetPayloadRequest, + encodeNewPayloadRequest, +} from "./sszRestEncoding.js"; import { BLOB_AND_PROOF_V2_RPC_BYTES, EngineApiRpcParamTypes, @@ -139,6 +150,10 @@ export class ExecutionEngineHttp implements IExecutionEngine { clientVersion?: ClientVersion | null; readonly payloadIdCache = new PayloadIdCache(); + + /** EIP-8161: SSZ-REST client, null if not configured */ + private readonly sszRestClient: SszRestClient | null; + /** * A queue to serialize the fcUs and newPayloads calls: * @@ -170,6 +185,21 @@ export class ExecutionEngineHttp implements IExecutionEngine { this.logger = logger; this.metrics = metrics ?? null; + // EIP-8161: Initialize SSZ-REST client using the same Engine API URL. + // SSZ-REST routes are served on the same port under /engine/* paths. + { + const engineUrl = opts?.urls?.[0] ?? "http://localhost:8551"; + const baseUrl = engineUrl.replace(/\/+$/, ""); + this.sszRestClient = new SszRestClient({ + baseUrl, + jwtSecretHex: opts?.jwtSecretHex, + jwtId: opts?.jwtId, + jwtVersion: opts?.jwtVersion, + timeout: opts?.timeout, + }); + this.logger.info("SSZ-REST Engine API transport enabled (EIP-8161)", {url: baseUrl}); + } + this.rpc.emitter.on(JsonRpcHttpClientEvent.ERROR, ({error}) => { this.updateEngineState(getExecutionEngineState({payloadError: error, oldState: this.state})); }); @@ -215,6 +245,44 @@ export class ExecutionEngineHttp implements IExecutionEngine { parentBlockRoot?: Root, executionRequests?: ExecutionRequests ): Promise { + // EIP-8161: Try SSZ-REST first, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + try { + const version = + ForkSeq[fork] >= ForkSeq.fulu ? 5 : ForkSeq[fork] >= ForkSeq.electra ? 4 : ForkSeq[fork] >= ForkSeq.deneb ? 3 : ForkSeq[fork] >= ForkSeq.capella ? 2 : 1; + const path = `/engine/v${version}/payloads`; + const body = encodeNewPayloadRequest(fork, executionPayload, versionedHashes, parentBlockRoot, executionRequests); + const resp = await this.sszRestClient.doRequest(path, body); + const decoded = decodePayloadStatus(resp); + const status = decoded.status as ExecutionPayloadStatus; + this.updateEngineState(getExecutionEngineState({payloadStatus: status, oldState: this.state})); + + switch (status) { + case ExecutionPayloadStatus.VALID: + return {status, latestValidHash: decoded.latestValidHash ?? "0x0", validationError: null}; + case ExecutionPayloadStatus.INVALID: + return {status, latestValidHash: decoded.latestValidHash, validationError: decoded.validationError}; + case ExecutionPayloadStatus.SYNCING: + case ExecutionPayloadStatus.ACCEPTED: + return {status, latestValidHash: null, validationError: null}; + case ExecutionPayloadStatus.INVALID_BLOCK_HASH: + return {status, latestValidHash: null, validationError: decoded.validationError ?? "Malformed block"}; + default: + return { + status: ExecutionPayloadStatus.ELERROR, + latestValidHash: null, + validationError: `Invalid EL status on executePayload: ${status}`, + }; + } + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST newPayload failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + const method = ForkSeq[fork] >= ForkSeq.electra ? "engine_newPayloadV4" @@ -345,6 +413,60 @@ export class ExecutionEngineHttp implements IExecutionEngine { finalizedBlockHash: RootHex, payloadAttributes?: PayloadAttributes ): Promise { + // EIP-8161: Try SSZ-REST first, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + try { + const version = ForkSeq[fork] >= ForkSeq.deneb ? 3 : ForkSeq[fork] >= ForkSeq.capella ? 2 : 1; + const path = `/engine/v${version}/forkchoice`; + const headBytes = fromHex(headBlockHash); + const safeBytes = fromHex(safeBlockHash); + const finalizedBytes = fromHex(finalizedBlockHash); + const body = encodeForkchoiceUpdatedRequest(headBytes, safeBytes, finalizedBytes, payloadAttributes); + const resp = await this.sszRestClient.doRequest(path, body); + const decoded = decodeForkchoiceUpdatedResponse(resp); + const status = decoded.payloadStatus.status as ExecutionPayloadStatus; + + this.updateEngineState(getExecutionEngineState({payloadStatus: status, oldState: this.state})); + this.metrics?.engineNotifyForkchoiceUpdateResult.inc({result: status}); + + const payloadAttributesRpc = payloadAttributes ? serializePayloadAttributes(payloadAttributes) : undefined; + + switch (status) { + case ExecutionPayloadStatus.VALID: + if (payloadAttributesRpc) { + if (!decoded.payloadId || decoded.payloadId === "0x") { + throw Error(`Received invalid payloadId=${decoded.payloadId}`); + } + this.payloadIdCache.add({headBlockHash, finalizedBlockHash, ...payloadAttributesRpc}, decoded.payloadId); + void this.prunePayloadIdCache(); + } + return decoded.payloadId !== "0x" ? decoded.payloadId : null; + + case ExecutionPayloadStatus.SYNCING: + if (payloadAttributes) { + throw Error("Execution Layer Syncing"); + } + return null; + + case ExecutionPayloadStatus.INVALID: + throw Error( + `Invalid ${payloadAttributes ? "prepare payload" : "forkchoice request"}, validationError=${ + decoded.payloadStatus.validationError ?? "" + }` + ); + + default: + throw Error(`Unknown status ${status}`); + } + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST forkchoiceUpdate failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + // Once on capella, should this need to be permanently switched to v2 when payload attrs // not provided const method = @@ -422,6 +544,42 @@ export class ExecutionEngineHttp implements IExecutionEngine { executionRequests?: ExecutionRequests; shouldOverrideBuilder?: boolean; }> { + // EIP-8161: Try SSZ-REST first, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + try { + const version = + ForkSeq[fork] >= ForkSeq.fulu ? 5 : ForkSeq[fork] >= ForkSeq.electra ? 4 : ForkSeq[fork] >= ForkSeq.deneb ? 3 : ForkSeq[fork] >= ForkSeq.capella ? 2 : 1; + const path = `/engine/v${version}/payloads/${payloadId}`; + const resp = await this.sszRestClient.doGetRequest(path); + const decoded = decodeGetPayloadResponse(resp); + + // The executionPayloadSsz needs to be parsed back through the JSON-RPC parseExecutionPayload path + // For now, we return the raw SSZ and let the caller handle it. + // Actually, we can deserialize the SSZ payload using @lodestar/types + const executionPayload = deserializeExecutionPayloadSsz(fork, decoded.executionPayloadSsz); + const executionPayloadValue = decoded.blockValue; + const blobsBundle = + decoded.blobsBundleSsz.length > 0 ? deserializeBlobsBundleSsz(decoded.blobsBundleSsz) : undefined; + const executionRequests = + decoded.executionRequestsSsz.length > 0 + ? deserializeExecutionRequestsSsz(decoded.executionRequestsSsz) + : undefined; + return { + executionPayload, + executionPayloadValue, + blobsBundle, + executionRequests, + shouldOverrideBuilder: decoded.shouldOverrideBuilder, + }; + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getPayload failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + let method: keyof EngineApiRpcReturnTypes; switch (fork) { case ForkName.phase0: @@ -500,6 +658,28 @@ export class ExecutionEngineHttp implements IExecutionEngine { versionedHashes: VersionedHashes ): Promise { assertReqSizeLimit(versionedHashes.length, MAX_VERSIONED_HASHES); + + // EIP-8161: Try SSZ-REST first for getBlobs, fall back to JSON-RPC on network errors + if (this.sszRestClient) { + try { + const version = isForkPostFulu(fork) ? 2 : 1; + const path = `/engine/v${version}/blobs`; + const body = encodeGetBlobsRequest(versionedHashes); + const resp = await this.sszRestClient.doRequest(path, body); + const decoded = decodeGetBlobsResponse(resp); + return decoded.map((item) => ({ + blob: item.blob, + proof: item.kzgProof, + })); + } catch (e) { + if (isSszRestNetworkError(e)) { + this.logger.debug("SSZ-REST getBlobs failed, falling back to JSON-RPC", {error: (e as Error).message}); + } else { + throw e; + } + } + } + const versionedHashesHex = versionedHashes.map(bytesToData); if (isForkPostFulu(fork)) { return await this.getBlobsV2(versionedHashesHex); @@ -629,6 +809,32 @@ export class ExecutionEngineHttp implements IExecutionEngine { } } +/** + * Deserialize an ExecutionPayload from SSZ bytes using the appropriate + * @lodestar/types codec for the given fork. Used by the SSZ-REST getPayload path. + */ +function deserializeExecutionPayloadSsz(fork: ForkName, data: Uint8Array): ExecutionPayload { + const forkSeq = ForkSeq[fork]; + if (forkSeq >= ForkSeq.electra) { + return sszCodecs.electra.ExecutionPayload.deserialize(data); + } + if (forkSeq >= ForkSeq.deneb) { + return sszCodecs.deneb.ExecutionPayload.deserialize(data); + } + if (forkSeq >= ForkSeq.capella) { + return sszCodecs.capella.ExecutionPayload.deserialize(data); + } + return sszCodecs.bellatrix.ExecutionPayload.deserialize(data); +} + +function deserializeBlobsBundleSsz(data: Uint8Array): BlobsBundle { + return sszCodecs.deneb.BlobsBundle.deserialize(data); +} + +function deserializeExecutionRequestsSsz(data: Uint8Array): ExecutionRequests { + return sszCodecs.electra.ExecutionRequests.deserialize(data); +} + type EngineRequestKey = keyof EngineApiRpcParamTypes; type EngineRequestByKey = { [K in EngineRequestKey]: {method: K; params: EngineApiRpcParamTypes[K]; methodOpts: ReqOpts}; diff --git a/packages/beacon-node/src/execution/engine/sszRestClient.ts b/packages/beacon-node/src/execution/engine/sszRestClient.ts new file mode 100644 index 000000000000..1b5475eae494 --- /dev/null +++ b/packages/beacon-node/src/execution/engine/sszRestClient.ts @@ -0,0 +1,150 @@ +import {fetch, isFetchError} from "@lodestar/utils"; +import {JwtClaim, encodeJwtToken} from "./jwt.js"; +import {HTTP_CONNECTION_ERROR_CODES, HTTP_FATAL_ERROR_CODES} from "./utils.js"; + +export interface SszRestClientOpts { + baseUrl: string; + jwtSecretHex?: string; + jwtId?: string; + jwtVersion?: string; + /** Request timeout in milliseconds. Defaults to 12000 */ + timeout?: number; +} + +/** + * Error thrown when the SSZ-REST endpoint returns a non-200 response. + * The EL returns JSON error bodies of the form {"code": N, "message": "..."}. + */ +export class SszRestError extends Error { + readonly code: number; + constructor(code: number, message: string) { + super(`SSZ-REST error ${code}: ${message}`); + this.code = code; + } +} + +/** + * Determines whether an error is a network-level error (DNS failure, connection + * refused, timeout, etc.) that should trigger a fallback to JSON-RPC rather than + * being propagated directly to the caller. + */ +export function isSszRestNetworkError(e: unknown): boolean { + if (e instanceof SszRestError) { + // HTTP errors from the SSZ-REST endpoint (e.g. 404) indicate the + // endpoint doesn't support this path — fall back to JSON-RPC. + return true; + } + // Node.js fetch errors (ECONNREFUSED, ENOTFOUND, etc.) + if (isFetchError(e)) { + const allCodes = [...HTTP_FATAL_ERROR_CODES, ...HTTP_CONNECTION_ERROR_CODES]; + return allCodes.includes((e as {code: string}).code); + } + // TypeError is thrown by fetch on DNS resolution failure in some runtimes + if (e instanceof TypeError) { + return true; + } + // AbortError from timeout + if (e instanceof DOMException && e.name === "AbortError") { + return true; + } + return false; +} + +const DEFAULT_TIMEOUT = 12_000; + +/** + * SSZ-REST HTTP client for EIP-8161 Engine API transport. + * + * Sends binary (application/octet-stream) POST requests and receives binary + * responses. JWT authentication is supported identically to the JSON-RPC client. + */ +export class SszRestClient { + private readonly baseUrl: string; + private readonly jwtSecret: Uint8Array | undefined; + private readonly jwtId: string | undefined; + private readonly jwtVersion: string | undefined; + private readonly timeout: number; + + constructor(opts: SszRestClientOpts) { + // Strip trailing slash for consistent path joining + this.baseUrl = opts.baseUrl.replace(/\/+$/, ""); + this.jwtSecret = opts.jwtSecretHex ? hexToBytes(opts.jwtSecretHex) : undefined; + this.jwtId = opts.jwtId; + this.jwtVersion = opts.jwtVersion; + this.timeout = opts.timeout ?? DEFAULT_TIMEOUT; + } + + /** + * POST binary body to `baseUrl + path` and return the response as Uint8Array. + * + * - Content-Type: application/octet-stream + * - Authorization: Bearer (when jwtSecret is configured) + * - On 200: returns response body as Uint8Array + * - On non-200: attempts to parse JSON error body and throws SszRestError + */ + async doRequest(path: string, body: Uint8Array): Promise { + return this._fetch(path, "POST", body); + } + + /** + * GET request (no body) to `baseUrl + path` and return the response as Uint8Array. + * Used for getPayload where payload_id is in the URL path. + */ + async doGetRequest(path: string): Promise { + return this._fetch(path, "GET", undefined); + } + + private async _fetch(path: string, method: string, body: Uint8Array | undefined): Promise { + const url = `${this.baseUrl}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeout); + + try { + const headers: Record = { + Accept: "application/octet-stream", + }; + if (body) { + headers["Content-Type"] = "application/octet-stream"; + } + + if (this.jwtSecret) { + const jwtClaim: JwtClaim = { + iat: Math.floor(Date.now() / 1000), + id: this.jwtId, + clv: this.jwtVersion, + }; + const token = encodeJwtToken(jwtClaim, this.jwtSecret); + headers.Authorization = `Bearer ${token}`; + } + + const res = await fetch(url, { + method, + body: body ? (body as unknown as BodyInit) : undefined, + headers, + signal: controller.signal, + }); + + if (!res.ok) { + // Error responses use text/plain per execution-apis SSZ spec + const code = res.status; + const message = await res.text().catch(() => res.statusText); + throw new SszRestError(code, message); + } + + const arrayBuf = await res.arrayBuffer(); + return new Uint8Array(arrayBuf); + } finally { + clearTimeout(timeout); + } + } +} + +/** Convert a hex string (with or without 0x prefix) to Uint8Array */ +function hexToBytes(hex: string): Uint8Array { + const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; + const bytes = new Uint8Array(stripped.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(stripped.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} diff --git a/packages/beacon-node/src/execution/engine/sszRestEncoding.ts b/packages/beacon-node/src/execution/engine/sszRestEncoding.ts new file mode 100644 index 000000000000..e5847c029887 --- /dev/null +++ b/packages/beacon-node/src/execution/engine/sszRestEncoding.ts @@ -0,0 +1,608 @@ +/** + * SSZ-REST (EIP-8161) encoding and decoding functions for the Engine API. + * + * All multi-byte integers are little-endian (LE). DataView is used for reading + * and writing to ensure correctness regardless of platform endianness. + */ + +import {ByteListType, ContainerType, ListCompositeType} from "@chainsafe/ssz"; +import {ForkName, ForkSeq} from "@lodestar/params"; +import { + ExecutionPayload, + ExecutionRequests, + Root, + ssz, + bellatrix, + capella, + deneb, + electra, +} from "@lodestar/types"; +import {PayloadAttributes, VersionedHashes} from "./interface.js"; + +// SSZ type: Container { capabilities: List[List[uint8, 64], 128] } +const Capability = new ByteListType(64); +const ExchangeCapabilitiesRequest = new ContainerType({ + capabilities: new ListCompositeType(Capability, 128), +}); + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +function writeUint32LE(buf: Uint8Array, offset: number, value: number): void { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + view.setUint32(offset, value, true); +} + +function readUint32LE(buf: Uint8Array, offset: number): number { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + return view.getUint32(offset, true); +} + +function writeUint64LE(buf: Uint8Array, offset: number, value: bigint): void { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + view.setBigUint64(offset, value, true); +} + +function readUint64LE(buf: Uint8Array, offset: number): bigint { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + return view.getBigUint64(offset, true); +} + +function writeUint256LE(buf: Uint8Array, offset: number, value: bigint): void { + // Write 256-bit LE as 4x 64-bit LE words + for (let i = 0; i < 4; i++) { + writeUint64LE(buf, offset + i * 8, value & 0xffffffffffffffffn); + value >>= 64n; + } +} + +function readUint256LE(buf: Uint8Array, offset: number): bigint { + let result = 0n; + for (let i = 3; i >= 0; i--) { + result = (result << 64n) | readUint64LE(buf, offset + i * 8); + } + return result; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// --------------------------------------------------------------------------- +// Encode functions +// --------------------------------------------------------------------------- + +/** + * Encode ForkchoiceState: headBlockHash(32) + safeBlockHash(32) + finalizedBlockHash(32) = 96 bytes + */ +export function encodeForkchoiceState( + headBlockHash: Uint8Array, + safeBlockHash: Uint8Array, + finalizedBlockHash: Uint8Array +): Uint8Array { + const buf = new Uint8Array(96); + buf.set(headBlockHash, 0); + buf.set(safeBlockHash, 32); + buf.set(finalizedBlockHash, 64); + return buf; +} + +/** + * Encode a ForkchoiceUpdated request. + * + * Layout: ForkchoiceState(96 fixed) + attributes_offset(4) + List[PayloadAttributes, 1] + * + * List[PayloadAttributes, 1]: empty = absent; offset(4) + element data = present + * (PayloadAttributes is variable-size, so the list uses a 4-byte item offset) + * + * PayloadAttributes V3: timestamp(8) + prevRandao(32) + suggestedFeeRecipient(20) + * + withdrawals_offset(4) + parentBeaconBlockRoot(32) + withdrawals list + * + * Each withdrawal: index(8) + validatorIndex(8) + address(20) + amount(8) = 44 bytes + */ +export function encodeForkchoiceUpdatedRequest( + headBlockHash: Uint8Array, + safeBlockHash: Uint8Array, + finalizedBlockHash: Uint8Array, + attributes?: PayloadAttributes +): Uint8Array { + // Fixed part: 96 (forkchoice state) + 4 (attributes offset) = 100 + const FIXED_SIZE = 100; + + if (!attributes) { + // No attributes: offset points to end (empty list) + const buf = new Uint8Array(FIXED_SIZE); + buf.set(headBlockHash, 0); + buf.set(safeBlockHash, 32); + buf.set(finalizedBlockHash, 64); + writeUint32LE(buf, 96, FIXED_SIZE); + return buf; + } + + // Encode PayloadAttributes + const feeRecipientBytes = hexToBytes20(attributes.suggestedFeeRecipient); + const withdrawals = attributes.withdrawals ?? []; + const parentBeaconBlockRoot = attributes.parentBeaconBlockRoot; + + // PayloadAttributes fixed part: timestamp(8) + prevRandao(32) + suggestedFeeRecipient(20) + // + withdrawals_offset(4) + parentBeaconBlockRoot(32) = 96 + const ATTR_FIXED = 96; + const withdrawalsSize = withdrawals.length * 44; + const attrTotalSize = ATTR_FIXED + withdrawalsSize; + + // Total: FIXED_SIZE + 4 (list item offset) + attrTotalSize + const totalSize = FIXED_SIZE + 4 + attrTotalSize; + const buf = new Uint8Array(totalSize); + + // ForkchoiceState + buf.set(headBlockHash, 0); + buf.set(safeBlockHash, 32); + buf.set(finalizedBlockHash, 64); + + // Offset to attributes list data + writeUint32LE(buf, 96, FIXED_SIZE); + + // List[PayloadAttributes, 1] with 1 element: item offset(4) + element data + let pos = FIXED_SIZE; + writeUint32LE(buf, pos, 4); // offset to element data (past the single item offset) + pos += 4; + + // PayloadAttributes element data + writeUint64LE(buf, pos, BigInt(attributes.timestamp)); + pos += 8; + buf.set(attributes.prevRandao, pos); + pos += 32; + buf.set(feeRecipientBytes, pos); + pos += 20; + // withdrawals_offset: relative to element start + writeUint32LE(buf, pos, ATTR_FIXED); + pos += 4; + if (parentBeaconBlockRoot) { + buf.set(parentBeaconBlockRoot, pos); + } + pos += 32; + + // Withdrawals + for (const w of withdrawals) { + writeUint64LE(buf, pos, BigInt(w.index)); + pos += 8; + writeUint64LE(buf, pos, BigInt(w.validatorIndex)); + pos += 8; + buf.set(w.address, pos); + pos += 20; + writeUint64LE(buf, pos, BigInt(w.amount)); + pos += 8; + } + + return buf; +} + +/** + * Encode a NewPayload request. + * + * V1/V2: just the ExecutionPayload SSZ bytes + * V3: payload_offset(4) + hashes_offset(4) + parentBeaconBlockRoot(32 fixed) + payload SSZ + hashes (32 each) + * V4: V3 layout + requests_offset(4) + execution_requests SSZ + */ +export function encodeNewPayloadRequest( + fork: ForkName, + executionPayload: ExecutionPayload, + versionedHashes?: VersionedHashes, + parentBeaconBlockRoot?: Root, + executionRequests?: ExecutionRequests +): Uint8Array { + // Serialize the execution payload using lodestar SSZ codecs + const payloadSsz = serializeExecutionPayloadSsz(fork, executionPayload); + + const forkSeq = ForkSeq[fork]; + + if (forkSeq < ForkSeq.deneb) { + // V1/V2: just the raw payload bytes + return payloadSsz; + } + + if (!versionedHashes) throw Error("versionedHashes required for deneb+"); + if (!parentBeaconBlockRoot) throw Error("parentBeaconBlockRoot required for deneb+"); + + const hashesBytes = versionedHashes.length * 32; + + if (forkSeq >= ForkSeq.electra && executionRequests) { + // V4: payload_offset(4) + hashes_offset(4) + parentBeaconBlockRoot(32) + requests_offset(4) = 44 fixed + const FIXED_SIZE = 44; + + const requestsSsz = serializeExecutionRequestsSsz(executionRequests); + + const payloadOffset = FIXED_SIZE; + const hashesOffset = payloadOffset + payloadSsz.length; + const requestsOffset = hashesOffset + hashesBytes; + + const totalSize = requestsOffset + requestsSsz.length; + const buf = new Uint8Array(totalSize); + + writeUint32LE(buf, 0, payloadOffset); + writeUint32LE(buf, 4, hashesOffset); + buf.set(parentBeaconBlockRoot, 8); + writeUint32LE(buf, 40, requestsOffset); + + buf.set(payloadSsz, payloadOffset); + + let pos = hashesOffset; + for (const hash of versionedHashes) { + buf.set(hash, pos); + pos += 32; + } + + buf.set(requestsSsz, requestsOffset); + return buf; + } + + // V3: payload_offset(4) + hashes_offset(4) + parentBeaconBlockRoot(32) = 40 fixed + const FIXED_SIZE = 40; + const payloadOffset = FIXED_SIZE; + const hashesOffset = payloadOffset + payloadSsz.length; + + const totalSize = hashesOffset + hashesBytes; + const buf = new Uint8Array(totalSize); + + writeUint32LE(buf, 0, payloadOffset); + writeUint32LE(buf, 4, hashesOffset); + buf.set(parentBeaconBlockRoot, 8); + + buf.set(payloadSsz, payloadOffset); + + let pos = hashesOffset; + for (const hash of versionedHashes) { + buf.set(hash, pos); + pos += 32; + } + + return buf; +} + +/** + * Encode a GetPayload request: just the 8-byte payload ID. + */ +export function encodeGetPayloadRequest(payloadId: Uint8Array): Uint8Array { + if (payloadId.length !== 8) { + throw Error(`Invalid payloadId length ${payloadId.length}, expected 8`); + } + return payloadId; +} + +/** + * Encode a GetBlobs request. + * Container: hashes_offset(4) + concatenated 32-byte hashes + */ +export function encodeGetBlobsRequest(versionedHashes: VersionedHashes): Uint8Array { + const FIXED_SIZE = 4; + const hashesSize = versionedHashes.length * 32; + const buf = new Uint8Array(FIXED_SIZE + hashesSize); + writeUint32LE(buf, 0, FIXED_SIZE); + let pos = FIXED_SIZE; + for (const hash of versionedHashes) { + buf.set(hash, pos); + pos += 32; + } + return buf; +} + +/** + * Encode ExchangeCapabilities as SSZ Container { capabilities: List[List[uint8, 64], 128] }. + */ +export function encodeExchangeCapabilities(capabilities: string[]): Uint8Array { + return ExchangeCapabilitiesRequest.serialize({ + capabilities: capabilities.map((s) => textEncoder.encode(s)), + }); +} + +// --------------------------------------------------------------------------- +// Decode functions +// --------------------------------------------------------------------------- + +/** Status byte mapping */ +const PAYLOAD_STATUS_MAP: Record = { + 0: "VALID", + 1: "INVALID", + 2: "SYNCING", + 3: "ACCEPTED", + 4: "INVALID_BLOCK_HASH", +}; + +export interface DecodedPayloadStatus { + status: string; + latestValidHash: string | null; + validationError: string | null; +} + +/** + * Decode PayloadStatus from SSZ-REST response. + * + * Layout: + * Byte 0: status (0=VALID, 1=INVALID, 2=SYNCING, 3=ACCEPTED, 4=INVALID_BLOCK_HASH) + * Bytes 1-4: latestValidHash offset (uint32 LE) + * Bytes 5-8: validationError offset (uint32 LE) + * Variable: latestValidHash as List[Hash32, 1] (0 bytes = absent, 32 bytes = present) + * Variable: validationError as UTF-8 bytes + */ +export function decodePayloadStatus(data: Uint8Array): DecodedPayloadStatus { + if (data.length < 9) { + throw Error(`PayloadStatus too short: ${data.length} bytes, expected at least 9`); + } + + const statusByte = data[0]; + const status = PAYLOAD_STATUS_MAP[statusByte]; + if (status === undefined) { + throw Error(`Unknown payload status byte: ${statusByte}`); + } + + const latestValidHashOffset = readUint32LE(data, 1); + const validationErrorOffset = readUint32LE(data, 5); + + // Decode latestValidHash: List[Hash32, 1] — 0 bytes = absent, 32 bytes = present + let latestValidHash: string | null = null; + const hashLen = validationErrorOffset - latestValidHashOffset; + if (hashLen === 32) { + const hashBytes = data.subarray(latestValidHashOffset, validationErrorOffset); + latestValidHash = "0x" + bytesToHex(hashBytes); + } + + // Decode validationError + let validationError: string | null = null; + if (validationErrorOffset < data.length) { + const errorBytes = data.subarray(validationErrorOffset); + if (errorBytes.length > 0) { + validationError = textDecoder.decode(errorBytes); + } + } + + return {status, latestValidHash, validationError}; +} + +export interface DecodedForkchoiceUpdatedResponse { + payloadStatus: DecodedPayloadStatus; + payloadId: string | null; +} + +/** + * Decode ForkchoiceUpdated response. + * + * Layout: + * Bytes 0-3: payloadStatus offset + * Bytes 4-7: payloadId offset + * Variable: payloadStatus (decoded with decodePayloadStatus) + * Variable: payloadId as List[Bytes8, 1] (0 bytes = absent, 8 bytes = present) + */ +export function decodeForkchoiceUpdatedResponse(data: Uint8Array): DecodedForkchoiceUpdatedResponse { + if (data.length < 8) { + throw Error(`ForkchoiceUpdatedResponse too short: ${data.length} bytes, expected at least 8`); + } + + const payloadStatusOffset = readUint32LE(data, 0); + const payloadIdOffset = readUint32LE(data, 4); + + // Determine payloadStatus extent + const payloadStatusEnd = payloadIdOffset < data.length ? payloadIdOffset : data.length; + const payloadStatusBytes = data.subarray(payloadStatusOffset, payloadStatusEnd); + const payloadStatus = decodePayloadStatus(payloadStatusBytes); + + // Decode payloadId: List[Bytes8, 1] — 0 bytes = absent, 8 bytes = present + let payloadId: string | null = null; + const pidData = data.subarray(payloadIdOffset); + if (pidData.length === 8) { + payloadId = "0x" + bytesToHex(pidData); + } + + return {payloadStatus, payloadId}; +} + +export interface DecodedGetPayloadResponse { + /** Raw SSZ bytes of the ExecutionPayload */ + executionPayloadSsz: Uint8Array; + /** Block value as bigint (uint256 LE) */ + blockValue: bigint; + /** Raw SSZ bytes of the BlobsBundle, may be empty */ + blobsBundleSsz: Uint8Array; + /** Whether the builder should be overridden */ + shouldOverrideBuilder: boolean; + /** Raw SSZ bytes of execution requests, may be empty */ + executionRequestsSsz: Uint8Array; +} + +/** + * Decode GetPayload response. + * + * Layout: + * Bytes 0-3: executionPayload offset + * Bytes 4-35: blockValue (uint256 LE, 32 bytes) + * Bytes 36-39: blobsBundle offset + * Byte 40: shouldOverrideBuilder (boolean) + * Bytes 41-44: executionRequests offset + * + * Fixed header = 45 bytes (if executionRequests field present) or 41 bytes (without) + */ +export function decodeGetPayloadResponse(data: Uint8Array): DecodedGetPayloadResponse { + // Determine layout based on data length and offsets + // Minimum: 41 bytes without executionRequests + if (data.length < 41) { + throw Error(`GetPayloadResponse too short: ${data.length} bytes, expected at least 41`); + } + + const executionPayloadOffset = readUint32LE(data, 0); + const blockValue = readUint256LE(data, 4); + const blobsBundleOffset = readUint32LE(data, 36); + const shouldOverrideBuilder = data[40] !== 0; + + let executionRequestsOffset: number; + let hasExecutionRequests = false; + + // If executionPayloadOffset >= 45, we have the executionRequests offset field + if (executionPayloadOffset >= 45 && data.length >= 45) { + executionRequestsOffset = readUint32LE(data, 41); + hasExecutionRequests = true; + } else { + executionRequestsOffset = data.length; + } + + // Extract regions + const executionPayloadSsz = data.subarray(executionPayloadOffset, blobsBundleOffset); + const blobsBundleEnd = hasExecutionRequests ? executionRequestsOffset : data.length; + const blobsBundleSsz = data.subarray(blobsBundleOffset, blobsBundleEnd); + const executionRequestsSsz = hasExecutionRequests ? data.subarray(executionRequestsOffset) : new Uint8Array(0); + + return { + executionPayloadSsz, + blockValue, + blobsBundleSsz, + shouldOverrideBuilder, + executionRequestsSsz, + }; +} + +/** + * Decode ExchangeCapabilities response (SSZ Container with List[List[uint8, 64], 128]). + */ +export function decodeExchangeCapabilities(data: Uint8Array): string[] { + if (data.length < 4) { + return []; + } + try { + const decoded = ExchangeCapabilitiesRequest.deserialize(data); + return decoded.capabilities.map((cap) => textDecoder.decode(cap)); + } catch { + return []; + } +} + +export interface DecodedBlobAndProof { + blob: Uint8Array; + kzgProof: Uint8Array; +} + +/** + * Decode GetBlobs response: returns array of {blob, kzgProof}. + * + * Layout: list_offset(4) + N item_offsets(4 each) + items + * Each item: blob(131072 bytes) + proof(48 bytes) + */ +export function decodeGetBlobsResponse(data: Uint8Array): DecodedBlobAndProof[] { + if (data.length < 4) { + return []; + } + + const listOffset = readUint32LE(data, 0); + if (listOffset >= data.length) { + return []; + } + + const listData = data.subarray(listOffset); + if (listData.length === 0) { + return []; + } + + // Each blob+proof is fixed size: 131072 + 48 = 131120 bytes + const BLOB_SIZE = 131072; + const PROOF_SIZE = 48; + const ITEM_SIZE = BLOB_SIZE + PROOF_SIZE; + + const numItems = Math.floor(listData.length / ITEM_SIZE); + const result: DecodedBlobAndProof[] = []; + + for (let i = 0; i < numItems; i++) { + const itemStart = i * ITEM_SIZE; + result.push({ + blob: listData.subarray(itemStart, itemStart + BLOB_SIZE), + kzgProof: listData.subarray(itemStart + BLOB_SIZE, itemStart + ITEM_SIZE), + }); + } + + return result; +} + +// --------------------------------------------------------------------------- +// SSZ serialization helpers +// --------------------------------------------------------------------------- + +/** + * Serialize an ExecutionPayload to SSZ bytes using the @lodestar/types codec + * appropriate for the given fork. + */ +function serializeExecutionPayloadSsz(fork: ForkName, payload: ExecutionPayload): Uint8Array { + const forkSeq = ForkSeq[fork]; + if (forkSeq >= ForkSeq.electra) { + return ssz.electra.ExecutionPayload.serialize(payload as unknown as electra.ExecutionPayload); + } + if (forkSeq >= ForkSeq.deneb) { + return ssz.deneb.ExecutionPayload.serialize(payload as unknown as deneb.ExecutionPayload); + } + if (forkSeq >= ForkSeq.capella) { + return ssz.capella.ExecutionPayload.serialize(payload as unknown as capella.ExecutionPayload); + } + return ssz.bellatrix.ExecutionPayload.serialize(payload as unknown as bellatrix.ExecutionPayload); +} + +/** + * Serialize ExecutionRequests to a single SSZ byte array. + * Concatenates the type-prefixed request lists. + */ +function serializeExecutionRequestsSsz(executionRequests: ExecutionRequests): Uint8Array { + const parts: Uint8Array[] = []; + + if (executionRequests.deposits.length > 0) { + const bytes = ssz.electra.DepositRequests.serialize(executionRequests.deposits); + const prefixed = new Uint8Array(1 + bytes.length); + prefixed[0] = 0x00; // DEPOSIT_REQUEST_TYPE + prefixed.set(bytes, 1); + parts.push(prefixed); + } + + if (executionRequests.withdrawals.length > 0) { + const bytes = ssz.electra.WithdrawalRequests.serialize(executionRequests.withdrawals); + const prefixed = new Uint8Array(1 + bytes.length); + prefixed[0] = 0x01; // WITHDRAWAL_REQUEST_TYPE + prefixed.set(bytes, 1); + parts.push(prefixed); + } + + if (executionRequests.consolidations.length > 0) { + const bytes = ssz.electra.ConsolidationRequests.serialize(executionRequests.consolidations); + const prefixed = new Uint8Array(1 + bytes.length); + prefixed[0] = 0x02; // CONSOLIDATION_REQUEST_TYPE + prefixed.set(bytes, 1); + parts.push(prefixed); + } + + // Concatenate + const totalLen = parts.reduce((sum, p) => sum + p.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function bytesToHex(bytes: Uint8Array): string { + let hex = ""; + for (const b of bytes) { + hex += b.toString(16).padStart(2, "0"); + } + return hex; +} + +function hexToBytes20(hex: string): Uint8Array { + const stripped = hex.startsWith("0x") ? hex.slice(2) : hex; + if (stripped.length !== 40) { + throw Error(`Expected 20-byte hex address, got ${stripped.length / 2} bytes`); + } + const bytes = new Uint8Array(20); + for (let i = 0; i < 20; i++) { + bytes[i] = parseInt(stripped.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +}