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.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: 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.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;
            default:
                method = "engine_getPayloadV5";
                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