@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
371 lines • 20.3 kB
JavaScript
import { ForkSeq, SLOTS_PER_EPOCH } from "@lodestar/params";
import { strip0xPrefix } from "@lodestar/utils";
import { ErrorJsonRpcResponse, HttpRpcError, JsonRpcHttpClientEvent, parseJsonRpcErrorCode, } from "../../eth1/provider/jsonRpcHttpClient.js";
import { bytesToData, numToQuantity } from "../../eth1/provider/utils.js";
import { EPOCHS_PER_BATCH } from "../../sync/constants.js";
import { getLodestarClientVersion } from "../../util/metadata.js";
import { JobItemQueue } from "../../util/queue/index.js";
import { ClientCode, ExecutionEngineState, ExecutionPayloadStatus, } from "./interface.js";
import { PayloadIdCache } from "./payloadIdCache.js";
import { assertReqSizeLimit, deserializeBlobAndProofs, deserializeExecutionPayloadBody, parseExecutionPayload, serializeBeaconBlockRoot, serializeExecutionPayload, serializeExecutionRequests, serializePayloadAttributes, serializeVersionedHashes, } from "./types.js";
import { getExecutionEngineState } from "./utils.js";
export const defaultExecutionEngineHttpOpts = {
/**
* 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;
// Define static options once to prevent extra allocations
const notifyNewPayloadOpts = { routeId: "notifyNewPayload" };
const forkchoiceUpdatedV1Opts = { routeId: "forkchoiceUpdated" };
const getPayloadOpts = { routeId: "getPayload" };
/**
* 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 {
constructor(rpc, { metrics, signal, logger }, opts) {
this.rpc = rpc;
this.opts = opts;
this.lastGetBlobsErrorTime = 0;
// 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
this.state = ExecutionEngineState.ONLINE;
this.payloadIdCache = new PayloadIdCache();
this.jobQueueProcessor = async ({ method, params, methodOpts }) => {
return this.rpc.fetchWithRetries({ method, params }, methodOpts);
};
this.rpcFetchQueue = new JobItemQueue(this.jobQueueProcessor, { maxLength: QUEUE_MAX_LENGTH, maxConcurrency: 1, noYieldIfOneItem: true, signal }, metrics?.engineHttpProcessorQueue);
this.logger = logger;
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: INVALID_TERMINAL_BLOCK, latestValidHash: null, validationError:
* errorMessage | null} if terminal block conditions are not satisfied
*
* 3. {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
*
* 4. {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, executionPayload, versionedHashes, parentBlockRoot, executionRequests) {
const method = 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;
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: "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).catch((e) => {
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: INVALID_TERMINAL_BLOCK, latestValidHash: null,
* validationError: errorMessage | null}, payloadId: null}
* either obtained from the Payload validation process or as a result of validating a
* PoW block referenced by forkchoiceState.headBlockHash
*
* 4. {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash,
* validationError: null}, payloadId: null}
* if the payload is deemed VALID and a build process hasn't been started
*
* 5. {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, headBlockHash, safeBlockHash, finalizedBlockHash, payloadAttributes) {
// Once on capella, should this need to be permanently switched to v2 when payload attrs
// not provided
const method = 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,
});
const { payloadStatus: { status, latestValidHash: _latestValidHash, validationError }, payloadId, } = await request;
this.updateEngineState(getExecutionEngineState({ payloadStatus: status, oldState: this.state }));
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, payloadId) {
const method = ForkSeq[fork] >= ForkSeq.electra
? "engine_getPayloadV4"
: ForkSeq[fork] >= ForkSeq.deneb
? "engine_getPayloadV3"
: ForkSeq[fork] >= ForkSeq.capella
? "engine_getPayloadV2"
: "engine_getPayloadV1";
const payloadResponse = await this.rpc.fetchWithRetries({
method,
params: [payloadId],
}, getPayloadOpts);
return parseExecutionPayload(fork, payloadResponse);
}
async prunePayloadIdCache() {
this.payloadIdCache.prune();
}
async getPayloadBodiesByHash(_fork, blockHashes) {
const method = "engine_getPayloadBodiesByHashV1";
assertReqSizeLimit(blockHashes.length, 32);
const response = await this.rpc.fetchWithRetries({ method, params: [blockHashes] });
return response.map(deserializeExecutionPayloadBody);
}
async getPayloadBodiesByRange(_fork, startBlockNumber, blockCount) {
const method = "engine_getPayloadBodiesByRangeV1";
assertReqSizeLimit(blockCount, 32);
const start = numToQuantity(startBlockNumber);
const count = numToQuantity(blockCount);
const response = await this.rpc.fetchWithRetries({ method, params: [start, count] });
return response.map(deserializeExecutionPayloadBody);
}
async getBlobs(_fork, versionedHashes) {
// retry only after a day may be
const GETBLOBS_RETRY_TIMEOUT = 256 * 32 * 12;
const timeNow = Date.now() / 1000;
const timeSinceLastFail = timeNow - this.lastGetBlobsErrorTime;
if (timeSinceLastFail < GETBLOBS_RETRY_TIMEOUT) {
// do not try getblobs since it might not be available
this.logger.debug(`disabled engine_getBlobsV1 api call since last failed < GETBLOBS_RETRY_TIMEOUT=${GETBLOBS_RETRY_TIMEOUT}`, timeSinceLastFail);
throw Error(`engine_getBlobsV1 call recently failed timeSinceLastFail=${timeSinceLastFail} < GETBLOBS_RETRY_TIMEOUT=${GETBLOBS_RETRY_TIMEOUT}`);
}
const method = "engine_getBlobsV1";
assertReqSizeLimit(versionedHashes.length, 128);
const versionedHashesHex = versionedHashes.map(bytesToData);
let response = await this.rpc
.fetchWithRetries({
method,
params: [versionedHashesHex],
})
.catch((e) => {
if (e instanceof ErrorJsonRpcResponse && parseJsonRpcErrorCode(e.response.error.code) === "Method not found") {
this.lastGetBlobsErrorTime = timeNow;
this.logger.debug("disabling engine_getBlobsV1 api call since engine responded with method not availeble", {
retryTimeout: GETBLOBS_RETRY_TIMEOUT,
});
}
throw e;
});
// handle nethermind buggy response
// see: https://discord.com/channels/595666850260713488/1293605631785304088/1298956894274060301
if (response.blobsAndProofs !== undefined) {
response = response.blobsAndProofs;
}
if (response.length !== versionedHashes.length) {
const error = `Invalid engine_getBlobsV1 response length=${response.length} versionedHashes=${versionedHashes.length}`;
this.logger.error(error);
throw Error(error);
}
return response.map(deserializeBlobAndProofs);
}
async getClientVersion(clientVersion) {
const method = "engine_getClientVersionV1";
const response = await this.rpc.fetchWithRetries({ method, params: [{ ...clientVersion, commit: `0x${clientVersion.commit}` }] });
const clientVersions = response.map((cv) => {
const code = cv.code in ClientCode ? ClientCode[cv.code] : 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;
}
updateEngineState(newState) {
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;
}
}
//# sourceMappingURL=http.js.map