@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
646 lines (581 loc) • 25.1 kB
text/typescript
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 {EPOCHS_PER_BATCH} from "../../sync/constants.js";
import {getLodestarClientVersion} from "../../util/metadata.js";
import {JobItemQueue} from "../../util/queue/index.js";
import {
ClientCode,
ClientVersion,
ExecutePayloadResponse,
ExecutionEngineState,
ExecutionPayloadStatus,
IExecutionEngine,
PayloadAttributes,
PayloadId,
VersionedHashes,
} from "./interface.js";
import {
ErrorJsonRpcResponse,
HttpRpcError,
IJsonRpcHttpClient,
JsonRpcHttpClientEvent,
ReqOpts,
} from "./jsonRpcHttpClient.js";
import {PayloadIdCache} from "./payloadIdCache.js";
import {
BLOB_AND_PROOF_V2_RPC_BYTES,
EngineApiRpcParamTypes,
EngineApiRpcReturnTypes,
ExecutionPayloadBody,
assertReqSizeLimit,
deserializeBlobAndProofs,
deserializeBlobAndProofsV2,
deserializeBlobAndProofsV2IntoBytes,
deserializeExecutionPayloadBody,
parseExecutionPayload,
serializeBeaconBlockRoot,
serializeExecutionPayload,
serializeExecutionRequests,
serializePayloadAttributes,
serializeVersionedHashes,
} from "./types.js";
import {bytesToData, getExecutionEngineState, numToQuantity} from "./utils.js";
export type ExecutionEngineModules = {
signal: AbortSignal;
metrics?: Metrics | null;
logger: Logger;
};
export type ExecutionEngineHttpOpts = {
urls: string[];
retries: number;
retryDelay: number;
timeout?: number;
/**
* 256 bit jwt secret in hex format without the leading 0x. If provided, the execution engine
* rpc requests will be bundled by an authorization header having a fresh jwt token on each
* request, as the EL auth specs mandate the fresh of the token (iat) to be checked within
* +-5 seconds interval.
*/
jwtSecretHex?: string;
/**
* An identifier string passed as CLI arg that will be set in `id` field of jwt claims
*/
jwtId?: string;
/**
* A version string that will be set in `clv` field of jwt claims
*/
jwtVersion?: string;
/**
* Lodestar version to be used for `ClientVersion`
*/
version?: string;
/**
* Lodestar commit to be used for `ClientVersion`
*/
commit?: string;
};
export const defaultExecutionEngineHttpOpts: ExecutionEngineHttpOpts = {
/**
* By default ELs host engine api on an auth protected 8551 port, would need a jwt secret to be
* specified to bundle jwt tokens if that is the case. In case one has access to an open
* port/url, one can override this and skip providing a jwt secret.
*/
urls: ["http://localhost:8551"],
retries: 2,
retryDelay: 2000,
timeout: 12000,
};
/**
* Size for the serializing queue for fcUs and new payloads, the max length could be equal to
* EPOCHS_PER_BATCH * 2 in case new payloads are also not awaited serially
*/
const QUEUE_MAX_LENGTH = EPOCHS_PER_BATCH * SLOTS_PER_EPOCH * 2;
/**
* Maximum number of version hashes that can be sent in a getBlobs request
* Clients must support at least 128 versionedHashes, so we avoid sending more
* https://github.com/ethereum/execution-apis/blob/main/src/engine/cancun.md#specification-3
*/
const MAX_VERSIONED_HASHES = 128;
// Define static options once to prevent extra allocations
const notifyNewPayloadOpts: ReqOpts = {routeId: "notifyNewPayload"};
const forkchoiceUpdatedV1Opts: ReqOpts = {routeId: "forkchoiceUpdated"};
const getPayloadOpts: ReqOpts = {routeId: "getPayload"};
const getPayloadBodiesByHashOpts: ReqOpts = {routeId: "getPayloadBodiesByHash"};
const getPayloadBodiesByRangeOpts: ReqOpts = {routeId: "getPayloadBodiesByRange"};
const getBlobsV1Opts: ReqOpts = {routeId: "getBlobsV1"};
const getBlobsV2Opts: ReqOpts = {routeId: "getBlobsV2"};
const getClientVersionOpts: ReqOpts = {routeId: "getClientVersion"};
/**
* based on Ethereum JSON-RPC API and inherits the following properties of this standard:
* - Supported communication protocols (HTTP and WebSocket)
* - Message format and encoding notation
* - Error codes improvement proposal
*
* Client software MUST expose Engine API at a port independent from JSON-RPC API. The default port for the Engine API is 8550 for HTTP and 8551 for WebSocket.
* https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.1/src/engine/interop/specification.md
*/
export class ExecutionEngineHttp implements IExecutionEngine {
private logger: Logger;
private metrics: Metrics | null;
// The default state is ONLINE, it will be updated to SYNCING once we receive the first payload
// This assumption is better than the OFFLINE state, since we can't be sure if the EL is offline and being offline may trigger some notifications
// It's safer to to avoid false positives and assume that the EL is syncing until we receive the first payload
state: ExecutionEngineState = ExecutionEngineState.ONLINE;
/** Cached EL client version from the latest getClientVersion call */
clientVersion?: ClientVersion | null;
readonly payloadIdCache = new PayloadIdCache();
/**
* A queue to serialize the fcUs and newPayloads calls:
*
* While syncing, lodestar has a batch processing module which calls new payloads in batch followed by fcUs.
* Even though we await for responses to new payloads serially, we just trigger fcUs consecutively. This
* may lead to the EL receiving the fcUs out of the order and may break the EL's backfill/beacon sync. Since
* the order of new payloads and fcUs is pretty important to EL, this queue will serialize the calls in the
* order with which we make them.
*/
private readonly rpcFetchQueue: JobItemQueue<[EngineRequest], EngineResponse>;
private jobQueueProcessor = async ({method, params, methodOpts}: EngineRequest): Promise<EngineResponse> => {
return this.rpc.fetchWithRetries<EngineApiRpcReturnTypes[typeof method], EngineApiRpcParamTypes[typeof method]>(
{method, params},
methodOpts
);
};
constructor(
private readonly rpc: IJsonRpcHttpClient,
{metrics, signal, logger}: ExecutionEngineModules,
private readonly opts?: ExecutionEngineHttpOpts
) {
this.rpcFetchQueue = new JobItemQueue<[EngineRequest], EngineResponse>(
this.jobQueueProcessor,
{maxLength: QUEUE_MAX_LENGTH, maxConcurrency: 1, noYieldIfOneItem: true, signal},
metrics?.engineHttpProcessorQueue
);
this.logger = logger;
this.metrics = metrics ?? null;
this.rpc.emitter.on(JsonRpcHttpClientEvent.ERROR, ({error}) => {
this.updateEngineState(getExecutionEngineState({payloadError: error, oldState: this.state}));
});
this.rpc.emitter.on(JsonRpcHttpClientEvent.RESPONSE, () => {
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.updateEngineState(getExecutionEngineState({targetState: ExecutionEngineState.ONLINE, oldState: this.state}));
});
}
/**
* `engine_newPayloadV1`
* From: https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.6/src/engine/specification.md#engine_newpayloadv1
*
* Client software MUST respond to this method call in the following way:
*
* 1. {status: INVALID_BLOCK_HASH, latestValidHash: null, validationError:
* errorMessage | null} if the blockHash validation has failed
*
* 2. {status: SYNCING, latestValidHash: null, validationError: null} if the payload
* extends the canonical chain and requisite data for its validation is missing
* with the payload status obtained from the Payload validation process if the payload
* has been fully validated while processing the call
*
* 3. {status: ACCEPTED, latestValidHash: null, validationError: null} if the
* following conditions are met:
* i) the blockHash of the payload is valid
* ii) the payload doesn't extend the canonical chain
* iii) the payload hasn't been fully validated.
*
* If any of the above fails due to errors unrelated to the normal processing flow of the method, client software MUST respond with an error object.
*/
async notifyNewPayload(
fork: ForkName,
executionPayload: ExecutionPayload,
versionedHashes?: VersionedHashes,
parentBlockRoot?: Root,
executionRequests?: ExecutionRequests
): Promise<ExecutePayloadResponse> {
const method =
ForkSeq[fork] >= ForkSeq.gloas
? "engine_newPayloadV5"
: ForkSeq[fork] >= ForkSeq.electra
? "engine_newPayloadV4"
: ForkSeq[fork] >= ForkSeq.deneb
? "engine_newPayloadV3"
: ForkSeq[fork] >= ForkSeq.capella
? "engine_newPayloadV2"
: "engine_newPayloadV1";
const serializedExecutionPayload = serializeExecutionPayload(fork, executionPayload);
let engineRequest: EngineRequest;
if (ForkSeq[fork] >= ForkSeq.deneb) {
if (versionedHashes === undefined) {
throw Error(`versionedHashes required in notifyNewPayload for fork=${fork}`);
}
if (parentBlockRoot === undefined) {
throw Error(`parentBlockRoot required in notifyNewPayload for fork=${fork}`);
}
const serializedVersionedHashes = serializeVersionedHashes(versionedHashes);
const parentBeaconBlockRoot = serializeBeaconBlockRoot(parentBlockRoot);
if (ForkSeq[fork] >= ForkSeq.electra) {
if (executionRequests === undefined) {
throw Error(`executionRequests required in notifyNewPayload for fork=${fork}`);
}
const serializedExecutionRequests = serializeExecutionRequests(executionRequests);
engineRequest = {
method: ForkSeq[fork] >= ForkSeq.gloas ? "engine_newPayloadV5" : "engine_newPayloadV4",
params: [
serializedExecutionPayload,
serializedVersionedHashes,
parentBeaconBlockRoot,
serializedExecutionRequests,
],
methodOpts: notifyNewPayloadOpts,
};
} else {
engineRequest = {
method: "engine_newPayloadV3",
params: [serializedExecutionPayload, serializedVersionedHashes, parentBeaconBlockRoot],
methodOpts: notifyNewPayloadOpts,
};
}
} else {
const method = ForkSeq[fork] >= ForkSeq.capella ? "engine_newPayloadV2" : "engine_newPayloadV1";
engineRequest = {
method,
params: [serializedExecutionPayload],
methodOpts: notifyNewPayloadOpts,
};
}
const {status, latestValidHash, validationError} = await (
this.rpcFetchQueue.push(engineRequest) as Promise<EngineApiRpcReturnTypes[typeof method]>
).catch((e: Error) => {
if (e instanceof HttpRpcError || e instanceof ErrorJsonRpcResponse) {
return {status: ExecutionPayloadStatus.ELERROR, latestValidHash: null, validationError: e.message};
}
return {status: ExecutionPayloadStatus.UNAVAILABLE, latestValidHash: null, validationError: e.message};
});
this.updateEngineState(getExecutionEngineState({payloadStatus: status, oldState: this.state}));
switch (status) {
case ExecutionPayloadStatus.VALID:
return {status, latestValidHash: latestValidHash ?? "0x0", validationError: null};
case ExecutionPayloadStatus.INVALID:
// As per latest specs if latestValidHash can be null and it would mean only
// invalidate this block
return {status, latestValidHash, validationError};
case ExecutionPayloadStatus.SYNCING:
case ExecutionPayloadStatus.ACCEPTED:
return {status, latestValidHash: null, validationError: null};
case ExecutionPayloadStatus.INVALID_BLOCK_HASH:
return {status, latestValidHash: null, validationError: validationError ?? "Malformed block"};
case ExecutionPayloadStatus.UNAVAILABLE:
case ExecutionPayloadStatus.ELERROR:
return {
status,
latestValidHash: null,
validationError: validationError ?? "Unknown ELERROR",
};
default:
return {
status: ExecutionPayloadStatus.ELERROR,
latestValidHash: null,
validationError: `Invalid EL status on executePayload: ${status}`,
};
}
}
/**
* `engine_forkchoiceUpdatedV1`
* From: https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.6/src/engine/specification.md#engine_forkchoiceupdatedv1
*
* Client software MUST respond to this method call in the following way:
*
* 1. {payloadStatus: {status: SYNCING, latestValidHash: null, validationError: null}
* , payloadId: null}
* if forkchoiceState.headBlockHash references an unknown payload or a payload that
* can't be validated because requisite data for the validation is missing
*
* 2. {payloadStatus: {status: INVALID, latestValidHash: null, validationError:
* errorMessage | null}, payloadId: null}
* obtained from the Payload validation process if the payload is deemed INVALID
*
* 3. {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash,
* validationError: null}, payloadId: null}
* if the payload is deemed VALID and a build process hasn't been started
*
* 4. {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash,
* validationError: null}, payloadId: buildProcessId}
* if the payload is deemed VALID and the build process has begun.
*
* If any of the above fails due to errors unrelated to the normal processing flow of the method, client software MUST respond with an error object.
*/
async notifyForkchoiceUpdate(
fork: ForkName,
headBlockHash: RootHex,
safeBlockHash: RootHex,
finalizedBlockHash: RootHex,
payloadAttributes?: PayloadAttributes
): Promise<PayloadId | null> {
// Once on capella, should this need to be permanently switched to v2 when payload attrs
// not provided
const method =
ForkSeq[fork] >= ForkSeq.gloas
? "engine_forkchoiceUpdatedV4"
: ForkSeq[fork] >= ForkSeq.deneb
? "engine_forkchoiceUpdatedV3"
: ForkSeq[fork] >= ForkSeq.capella
? "engine_forkchoiceUpdatedV2"
: "engine_forkchoiceUpdatedV1";
const payloadAttributesRpc = payloadAttributes ? serializePayloadAttributes(payloadAttributes) : undefined;
// If we are just fcUing and not asking execution for payload, retry is not required
// and we can move on, as the next fcU will be issued soon on the new slot
const fcUReqOpts =
payloadAttributes !== undefined ? forkchoiceUpdatedV1Opts : {...forkchoiceUpdatedV1Opts, retries: 0};
const request = this.rpcFetchQueue.push({
method,
params: [{headBlockHash, safeBlockHash, finalizedBlockHash}, payloadAttributesRpc],
methodOpts: fcUReqOpts,
}) as Promise<EngineApiRpcReturnTypes[typeof method]>;
const {
payloadStatus: {status, latestValidHash: _latestValidHash, validationError},
payloadId,
} = await request;
this.updateEngineState(getExecutionEngineState({payloadStatus: status, oldState: this.state}));
this.metrics?.engineNotifyForkchoiceUpdateResult.inc({result: status});
switch (status) {
case ExecutionPayloadStatus.VALID:
// if payloadAttributes are provided, a valid payloadId is expected
if (payloadAttributesRpc) {
if (!payloadId || payloadId === "0x") {
throw Error(`Received invalid payloadId=${payloadId}`);
}
this.payloadIdCache.add({headBlockHash, finalizedBlockHash, ...payloadAttributesRpc}, payloadId);
void this.prunePayloadIdCache();
}
return payloadId !== "0x" ? payloadId : null;
case ExecutionPayloadStatus.SYNCING:
// Throw error on syncing if requested to produce a block, else silently ignore
if (payloadAttributes) {
throw Error("Execution Layer Syncing");
}
return null;
case ExecutionPayloadStatus.INVALID:
throw Error(
`Invalid ${payloadAttributes ? "prepare payload" : "forkchoice request"}, validationError=${
validationError ?? ""
}`
);
default:
throw Error(`Unknown status ${status}`);
}
}
/**
* `engine_getPayloadV1`
*
* 1. Given the payloadId client software MUST respond with the most recent version of the payload that is available in the corresponding building process at the time of receiving the call.
* 2. The call MUST be responded with 5: Unavailable payload error if the building process identified by the payloadId doesn't exist.
* 3. Client software MAY stop the corresponding building process after serving this call.
*/
async getPayload(
fork: ForkName,
payloadId: PayloadId
): Promise<{
executionPayload: ExecutionPayload;
executionPayloadValue: Wei;
blobsBundle?: BlobsBundle;
executionRequests?: ExecutionRequests;
shouldOverrideBuilder?: boolean;
}> {
let method: keyof EngineApiRpcReturnTypes;
switch (fork) {
case ForkName.phase0:
case ForkName.altair:
case ForkName.bellatrix:
method = "engine_getPayloadV1";
break;
case ForkName.capella:
method = "engine_getPayloadV2";
break;
case ForkName.deneb:
method = "engine_getPayloadV3";
break;
case ForkName.electra:
method = "engine_getPayloadV4";
break;
case ForkName.fulu:
method = "engine_getPayloadV5";
break;
default:
method = "engine_getPayloadV6";
break;
}
const payloadResponse = await this.rpc.fetchWithRetries<
EngineApiRpcReturnTypes[typeof method],
EngineApiRpcParamTypes[typeof method]
>(
{
method,
params: [payloadId],
},
getPayloadOpts
);
return parseExecutionPayload(fork, payloadResponse);
}
async prunePayloadIdCache(): Promise<void> {
this.payloadIdCache.prune();
}
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);
return response.map(deserializeExecutionPayloadBody);
}
async getPayloadBodiesByRange(
_fork: ForkName,
startBlockNumber: number,
blockCount: number
): Promise<(ExecutionPayloadBody | null)[]> {
const method = "engine_getPayloadBodiesByRangeV1";
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);
return response.map(deserializeExecutionPayloadBody);
}
async getBlobs(
fork: ForkPostFulu,
versionedHashes: VersionedHashes,
buffers?: Uint8Array[]
): Promise<BlobAndProofV2[] | null>;
async getBlobs(
fork: ForkPreFulu,
versionedHashes: VersionedHashes,
buffers?: Uint8Array[]
): Promise<(BlobAndProof | null)[]>;
async getBlobs(
fork: ForkName,
versionedHashes: VersionedHashes
): Promise<BlobAndProofV2[] | (BlobAndProof | null)[] | null> {
assertReqSizeLimit(versionedHashes.length, MAX_VERSIONED_HASHES);
const versionedHashesHex = versionedHashes.map(bytesToData);
if (isForkPostFulu(fork)) {
return await this.getBlobsV2(versionedHashesHex);
}
return await this.getBlobsV1(versionedHashesHex);
}
private async getBlobsV1(versionedHashesHex: string[]) {
const response = await this.rpc.fetchWithRetries<
EngineApiRpcReturnTypes["engine_getBlobsV1"],
EngineApiRpcParamTypes["engine_getBlobsV1"]
>(
{
method: "engine_getBlobsV1",
params: [versionedHashesHex],
},
getBlobsV1Opts
);
const invalidLength = response.length !== versionedHashesHex.length;
if (invalidLength) {
const error = `Invalid engine_getBlobsV1 response length=${response.length} versionedHashes=${versionedHashesHex.length}`;
this.logger.error(error);
throw Error(error);
}
return response.map(deserializeBlobAndProofs);
}
private async getBlobsV2(versionedHashesHex: string[], buffers?: Uint8Array[]) {
if (buffers) {
if (buffers.length !== versionedHashesHex.length) {
throw Error(`Invalid buffers length=${buffers.length} versionedHashes=${versionedHashesHex.length}`);
}
for (const [i, buffer] of buffers.entries()) {
if (buffer.length !== BLOB_AND_PROOF_V2_RPC_BYTES) {
throw Error(`Invalid buffer[${i}] length=${buffer.length} expected=${BLOB_AND_PROOF_V2_RPC_BYTES}`);
}
}
}
const response = await this.rpc.fetchWithRetries<
EngineApiRpcReturnTypes["engine_getBlobsV2"],
EngineApiRpcParamTypes["engine_getBlobsV2"]
>(
{
method: "engine_getBlobsV2",
params: [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;
if (invalidLength) {
const error = `Invalid engine_getBlobsV2 response length=${response?.length ?? "null"} versionedHashes=${versionedHashesHex.length}`;
this.logger.error(error);
throw Error(error);
}
if (response == null) {
return null;
}
if (buffers) {
// getBlobsV2() is designed to called once per slot so we expect to have buffers
return response.map((data, i) => deserializeBlobAndProofsV2IntoBytes(data, buffers[i]));
}
return response.map(deserializeBlobAndProofsV2);
}
private async getClientVersion(clientVersion: ClientVersion): Promise<ClientVersion[]> {
const method = "engine_getClientVersionV1";
const response = await this.rpc.fetchWithRetries<
EngineApiRpcReturnTypes[typeof method],
EngineApiRpcParamTypes[typeof method]
>({method, params: [{...clientVersion, commit: `0x${clientVersion.commit}`}]}, getClientVersionOpts);
const clientVersions = response.map((cv) => {
const code = cv.code in ClientCode ? ClientCode[cv.code as keyof typeof ClientCode] : ClientCode.XX;
return {code, name: cv.name, version: cv.version, commit: strip0xPrefix(cv.commit)};
});
if (clientVersions.length === 0) {
throw Error("Received empty client versions array");
}
this.clientVersion = clientVersions[0];
this.logger.debug("Execution client version updated", this.clientVersion);
return clientVersions;
}
private updateEngineState(newState: ExecutionEngineState): void {
const oldState = this.state;
if (oldState === newState) return;
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;
});
break;
case ExecutionEngineState.OFFLINE:
this.logger.error("Execution client went offline", {oldState, newState});
break;
case ExecutionEngineState.SYNCED:
this.logger.info("Execution client is synced", {oldState, newState});
break;
case ExecutionEngineState.SYNCING:
this.logger.warn("Execution client is syncing", {oldState, newState});
break;
case ExecutionEngineState.AUTH_FAILED:
this.logger.error("Execution client authentication failed", {oldState, newState});
break;
}
this.state = newState;
}
}
type EngineRequestKey = keyof EngineApiRpcParamTypes;
type EngineRequestByKey = {
[K in EngineRequestKey]: {method: K; params: EngineApiRpcParamTypes[K]; methodOpts: ReqOpts};
};
type EngineRequest = EngineRequestByKey[EngineRequestKey];
type EngineResponseByKey = {[K in EngineRequestKey]: EngineApiRpcReturnTypes[K]};
type EngineResponse = EngineResponseByKey[EngineRequestKey];