UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

423 lines • 22.4 kB
import { ForkName, ForkSeq, SLOTS_PER_EPOCH, isForkPostFulu } from "@lodestar/params"; import { strip0xPrefix } from "@lodestar/utils"; 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 { ErrorJsonRpcResponse, HttpRpcError, JsonRpcHttpClientEvent, } from "./jsonRpcHttpClient.js"; import { PayloadIdCache } from "./payloadIdCache.js"; import { BLOB_AND_PROOF_V2_RPC_BYTES, assertReqSizeLimit, deserializeBlobAndProofs, deserializeBlobAndProofsV2, deserializeBlobAndProofsV2IntoBytes, deserializeExecutionPayloadBody, parseExecutionPayload, serializeBeaconBlockRoot, serializeExecutionPayload, serializeExecutionRequests, serializePayloadAttributes, serializeVersionedHashes, } from "./types.js"; import { bytesToData, getExecutionEngineState, numToQuantity } 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; /** * 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 = { routeId: "notifyNewPayload" }; const forkchoiceUpdatedV1Opts = { routeId: "forkchoiceUpdated" }; const getPayloadOpts = { routeId: "getPayload" }; const getPayloadBodiesByHashOpts = { routeId: "getPayloadBodiesByHash" }; const getPayloadBodiesByRangeOpts = { routeId: "getPayloadBodiesByRange" }; const getBlobsV1Opts = { routeId: "getBlobsV1" }; const getBlobsV2Opts = { routeId: "getBlobsV2" }; const getClientVersionOpts = { 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 { rpc; opts; logger; metrics; // 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.ONLINE; /** Cached EL client version from the latest getClientVersion call */ clientVersion; 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. */ rpcFetchQueue; jobQueueProcessor = async ({ method, params, methodOpts }) => { return this.rpc.fetchWithRetries({ method, params }, methodOpts); }; constructor(rpc, { metrics, signal, logger }, opts) { this.rpc = rpc; this.opts = opts; this.rpcFetchQueue = new JobItemQueue(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, executionPayload, versionedHashes, parentBlockRoot, executionRequests) { 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; 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).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: 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, 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.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, }); 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, payloadId) { let method; 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({ 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] }, getPayloadBodiesByHashOpts); 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] }, getPayloadBodiesByRangeOpts); return response.map(deserializeExecutionPayloadBody); } async getBlobs(fork, versionedHashes) { assertReqSizeLimit(versionedHashes.length, MAX_VERSIONED_HASHES); const versionedHashesHex = versionedHashes.map(bytesToData); if (isForkPostFulu(fork)) { return await this.getBlobsV2(versionedHashesHex); } return await this.getBlobsV1(versionedHashesHex); } async getBlobsV1(versionedHashesHex) { const response = await this.rpc.fetchWithRetries({ 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); } async getBlobsV2(versionedHashesHex, buffers) { 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({ 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); } async getClientVersion(clientVersion) { const method = "engine_getClientVersionV1"; const response = await this.rpc.fetchWithRetries({ method, params: [{ ...clientVersion, commit: `0x${clientVersion.commit}` }] }, getClientVersionOpts); 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