"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Rpc = exports.CustomJsonRpcProvider = void 0;
const ethers_1 = require("ethers");
const helpers_1 = require("../utils/helpers");
const logger_1 = require("../utils/logger");
const error_1 = require("../utils/error");
const types_1 = require("./types");
const type2_mngr_1 = require("../transmitter/type2.mngr");
const flatted_1 = require("flatted");
const IS_HEALTHY = 1;
const IS_UNHEALTHY = 0;
const MAX_GAS_FEE_BUMP = 999999;
class CustomJsonRpcProvider extends ethers_1.ethers.providers.JsonRpcProvider {
    urls;
    networkConfig;
    prometheus;
    config;
    redisClient;
    preferBlockPi;
    preferQuickNode;
    childLogger;
    gasFeesBump = 0;
    lastFeeData = null;
    feeData = {
        gasPrice: ethers_1.BigNumber.from(0),
        lastBaseFeePerGas: ethers_1.BigNumber.from(0),
        maxFeePerGas: ethers_1.BigNumber.from(0),
        maxPriorityFeePerGas: ethers_1.BigNumber.from(0),
    };
    gasPrice = ethers_1.BigNumber.from(0);
    healthy = false;
    providers = [];
    preferredProvider;
    constructor(urls, // ensure that at least one URL is provided
    networkConfig, prometheus, config, redisClient, // pass redis to rpc.init() to have it available
    preferBlockPi = false, // if true, prefer BlockPi RPCs over QuickNode
    preferQuickNode = false) {
        // Require at least one URL to be provided
        if (urls.length === 0) {
            throw new Error('🔌❗ RPC provider URLs array cannot be empty!');
        }
        const hasQuickNodeRPC = urls.some((rpc) => rpc.includes('quiknode'));
        const hasBlockPiRPC = urls.some((rpc) => rpc.includes('blockpi'));
        const networkDescriptorForEthers = {
            name: networkConfig.name,
            chainId: parseInt(networkConfig.chainId, 16), // ethers expects chainId as a number
        };
        const urlsDeduplicated = [...new Set(urls)];
        const preferredUrl = preferBlockPi && hasBlockPiRPC
            ? urlsDeduplicated.find((url) => url.includes('blockpi'))
            : preferQuickNode && hasQuickNodeRPC
                ? urlsDeduplicated.find((url) => url.includes('quiknode'))
                : urlsDeduplicated[0];
        const childLogger = logger_1.logger.child({ networkId: networkConfig.id });
        logger_1.logger.debug({
            networkId: networkConfig.id,
            urls,
            hasQuickNodeRPC,
            hasBlockPiRPC,
            preferredUrl,
        }, '🔌 Initializing RPC provider');
        const provider = new ethers_1.ethers.providers.JsonRpcProvider({
            url: preferredUrl,
        });
        const rpcHostname = new URL(preferredUrl).hostname;
        super(preferredUrl, networkDescriptorForEthers);
        this.urls = urls;
        this.networkConfig = networkConfig;
        this.prometheus = prometheus;
        this.config = config;
        this.redisClient = redisClient;
        this.preferBlockPi = preferBlockPi;
        this.preferQuickNode = preferQuickNode;
        this.preferredProvider = provider;
        this.childLogger = logger_1.logger.child({ networkId: networkConfig.id });
        // Add error handling for the provider
        this.on('error', (error) => {
            const err = (0, error_1.shallowExtractErrorDetails)(error);
            childLogger.warn({ error: err.message }, '❌ RPC provider error caught');
            this.prometheus.rpcCall.inc({
                rpcHostname,
                network: this.networkConfig.id,
                url: preferredUrl,
                status: err.message,
            });
        });
        this.on('debug', (info) => {
            if (info.action === 'response') {
                if ('error' in info) {
                    this.prometheus.rpcCall.inc({
                        rpcHostname,
                        network: this.networkConfig.id,
                        url: preferredUrl,
                        status: info.error?.reason || info.error?.cause || 'unknown',
                    });
                }
                else {
                    this.prometheus.rpcCall.inc({
                        rpcHostname,
                        network: this.networkConfig.id,
                        url: preferredUrl,
                        status: 'ok',
                    });
                }
            }
        });
        void this.startFetchingGasPrice();
    }
    getEffectiveGasPrice() {
        // If EIP-1559 is supported, return maxFeePerGas + assume tip 10%
        if (this.networkConfig.txType === 2 && this.feeData.lastBaseFeePerGas && this.feeData.lastBaseFeePerGas.gt(0)) {
            return this.feeData.lastBaseFeePerGas.add(this.feeData.lastBaseFeePerGas.mul(10).div(100));
        }
        // Otherwise, return gasPrice
        return this.gasPrice.add(this.gasPrice.mul(10).div(100));
    }
    startFetchingGasPrice() {
        logger_1.logger.debug({ network: this.networkConfig.id, interval: this.config.rpc.gasPriceFetchIntervalSec }, '⏳ Starting gas price fetcher');
        void this.fetchAndCacheGasPrice();
        setInterval(async () => {
            await this.fetchAndCacheGasPrice();
        }, this.config.rpc.gasPriceFetchIntervalSec * 1000);
    }
    async fetchAndCacheGasPrice() {
        const cacheKey = `${this.config.appName}:${this.networkConfig.id}:${this.config.rpc.feeDataCacheKey}`;
        try {
            let scopedFeeData = null;
            // Try to retrieve feeData from cache first
            if (this.redisClient) {
                const cachedFeeData = await this.redisClient.get(cacheKey);
                if (cachedFeeData) {
                    try {
                        const parsedCache = JSON.parse(cachedFeeData);
                        scopedFeeData = {
                            gasPrice: ethers_1.BigNumber.from(parsedCache.gasPrice),
                            lastBaseFeePerGas: ethers_1.BigNumber.from(parsedCache.lastBaseFeePerGas),
                            maxFeePerGas: ethers_1.BigNumber.from(parsedCache.maxFeePerGas),
                            maxPriorityFeePerGas: ethers_1.BigNumber.from(parsedCache.maxPriorityFeePerGas),
                        };
                        logger_1.logger.trace({ network: this.networkConfig.id }, '🚀 feeData loaded from Redis cache');
                    }
                    catch (error) {
                        logger_1.logger.error({ error: error.message, network: this.networkConfig.id }, 'Could not parse cached feeData');
                    }
                }
            }
            // If cache miss, fetch from RPC
            if (!scopedFeeData) {
                scopedFeeData = await this.getFeeData();
                // Cache the new feeData if Redis client is available
                if (this.redisClient && scopedFeeData) {
                    const serializableFeeData = {
                        gasPrice: scopedFeeData.gasPrice?.toString() || '0',
                        lastBaseFeePerGas: scopedFeeData.lastBaseFeePerGas?.toString() || '0',
                        maxFeePerGas: scopedFeeData.maxFeePerGas?.toString() || '0',
                        maxPriorityFeePerGas: scopedFeeData.maxPriorityFeePerGas?.toString() || '0',
                    };
                    const expirySeconds = Math.ceil(this.config.rpc.gasPriceFetchIntervalSec * 1.5);
                    await this.redisClient.set(cacheKey, JSON.stringify(serializableFeeData), {
                        EX: expirySeconds,
                    });
                    logger_1.logger.trace({ network: this.networkConfig.id }, '💾 feeData cached in Redis');
                }
            }
            this.feeData = {
                gasPrice: ethers_1.BigNumber.from(scopedFeeData.gasPrice || 0),
                lastBaseFeePerGas: ethers_1.BigNumber.from(scopedFeeData.lastBaseFeePerGas || 0),
                maxFeePerGas: ethers_1.BigNumber.from(scopedFeeData.maxFeePerGas || 0),
                maxPriorityFeePerGas: ethers_1.BigNumber.from(scopedFeeData.maxPriorityFeePerGas || 0),
            };
        }
        catch (error) {
            logger_1.logger.warn({ error: error.message, network: this.networkConfig.id }, 'Error fetching feeData');
            return;
        }
        try {
            this.gasPrice = await this.getGasPrice();
        }
        catch (error) {
            logger_1.logger.warn({ error: error.message, network: this.networkConfig.id }, 'Error fetching gasPrice');
            return;
        }
    }
    bumpGasFee() {
        if (this.gasFeesBump > MAX_GAS_FEE_BUMP) {
            return;
        }
        this.gasFeesBump += 1;
    }
    setGasFee(bumpCount = 0) {
        this.gasFeesBump = bumpCount;
    }
    resetGasFeeBump() {
        this.gasFeesBump = 1;
    }
    async checkProviderHealth() {
        const maxHealthCheckRetries = 3;
        const retryDelay = 1; // initial retry delay in seconds
        await this.ready;
        for (let retryAttempt = 0; retryAttempt < maxHealthCheckRetries; retryAttempt++) {
            try {
                await this.getBlockNumber();
                this.healthy = true;
                return true;
            }
            catch (error) {
                const err = (0, error_1.shallowExtractErrorDetails)(error);
                this.childLogger.warn({ error: (0, flatted_1.stringify)(err.message), url: this.urls }, '⚠️ Error during health check');
                this.healthy = false;
                if (this.isTransientError(err)) {
                    await (0, helpers_1.sleep)(retryDelay * Math.pow(2, retryAttempt + 1), `Retrying health check: ${err.message}`);
                }
                else {
                    break;
                }
            }
        }
        return false;
    }
    getFeeData() {
        return super.getFeeData();
    }
    getBlock(...args) {
        return super.getBlock(...args);
    }
    /**
     * @param feeData The most recently fetched fee data.
     * @param lastFeeData The last fetched fee data.
     */
    getNextGasSettings(feeData, lastFeeData, _retries, _gasLimitCap) {
        if (!feeData) {
            throw new Error('No fee data available');
        }
        const retries = this.gasFeesBump;
        if (!lastFeeData ||
            !lastFeeData ||
            !lastFeeData.maxPriorityFeePerGas ||
            !lastFeeData.lastBaseFeePerGas ||
            !feeData.maxPriorityFeePerGas ||
            !feeData.lastBaseFeePerGas) {
            throw new Error('No fee data available');
        }
        const oldMaxPriorityFeePerGas = lastFeeData.maxPriorityFeePerGas;
        const newMaxPriorityFeePerGas = feeData.maxPriorityFeePerGas;
        const oldBaseFeePerGas = lastFeeData.lastBaseFeePerGas;
        const newBaseFeePerGas = feeData.lastBaseFeePerGas;
        // eslint-disable-next-line prefer-const
        let [updatedTip, updatedBaseFee] = type2_mngr_1.Type2TxMgr.updateFeesWithRetry(oldBaseFeePerGas.mul(2 * retries + 2), oldBaseFeePerGas, newBaseFeePerGas.mul(2 * retries + 2), newBaseFeePerGas, false, retries);
        if (retries > 0) {
            logger_1.logger.trace({
                oldMaxPriorityFeePerGas: oldMaxPriorityFeePerGas.toNumber(),
                oldBaseFeePerGas: oldBaseFeePerGas.toNumber(),
                newMaxPriorityFeePerGas: newMaxPriorityFeePerGas.toNumber(),
                newBaseFeePerGas: newBaseFeePerGas.toNumber(),
                updatedTip: updatedTip.toNumber(),
                updatedBaseFee: updatedBaseFee.toNumber(),
                retries,
            }, '🔥 Updated gas fees');
        }
        let maxFeePerGas = updatedBaseFee.add(updatedTip);
        const allowedLimit = _gasLimitCap || this.networkConfig.gasPriceLimit;
        if (maxFeePerGas.gt(allowedLimit)) {
            logger_1.logger.warn({
                networkId: this.networkConfig.id,
                maxFeePerGas: maxFeePerGas.toString(),
                allowedLimit: allowedLimit.toString(),
            }, '⚠️ Max fee per gas exceeds configured gas price limit, capping it');
            maxFeePerGas = allowedLimit;
        }
        if (maxFeePerGas.lt(updatedBaseFee)) {
            logger_1.logger.debug({
                networkId: this.networkConfig.id,
                maxFeePerGas: maxFeePerGas.toString(),
                updatedBaseFee: updatedBaseFee.toString(),
            }, '⚠️ Max fee per gas is less than base fee, adjusting to base fee');
            updatedTip = maxFeePerGas;
        }
        return {
            maxFeePerGas,
            maxPriorityFeePerGas: updatedTip,
            type: 2,
        };
    }
    estimateGasParams(retries = 0, value, forceTxType, gasLimitCap) {
        let { txType } = this.networkConfig;
        if (forceTxType || forceTxType === 0) {
            txType = forceTxType;
        }
        try {
            if (txType === 2) {
                const { 
                // ghost-chains: can have very low values for lastBaseFeePerGas (7 wei)
                // don't rely on it too heavily for gas calculations as it may be way smaller than normal bids
                lastBaseFeePerGas, 
                // general: maxFeePerGas can have crazy values, we hard-cap it to base fee + increase
                maxFeePerGas, 
                // arbitrum-sepolia: maxPriorityFeePerGas can get crazy
                maxPriorityFeePerGas, } = this.feeData;
                // https://docs.ethers.org/v5/api/providers/types/#providers-FeeData
                if (!lastBaseFeePerGas || !maxFeePerGas || !maxPriorityFeePerGas) {
                    throw new Error('Failed to fetch gas fee data');
                }
                // Prevent Gas price below configured minimum gas price for linea
                if (this.networkConfig.id === 'lint' || this.networkConfig.id === 'bast') {
                    retries = Math.max(retries, 1);
                    retries *= 2;
                }
                const txParams = this.getNextGasSettings(this.feeData, this.lastFeeData || this.feeData, (retries + 1) * 2, gasLimitCap);
                logger_1.logger.trace({
                    networkId: this.networkConfig.id,
                    txType,
                    txParams: txParams,
                    feeData: this.feeData,
                    gasFeesBump: this.gasFeesBump,
                    retries: retries,
                    network: this.networkConfig.id,
                }, '🔥 Estimated tx params');
                this.lastFeeData = this.feeData;
                return {
                    ...txParams,
                    value,
                };
            }
            else {
                // https://docs.ethers.org/v5/api/providers/provider/#Provider-getGasPrice
                return {
                    gasPrice: this.gasPrice.mul(200 + this.gasFeesBump * 100).div(100),
                    type: 0,
                    value,
                };
            }
        }
        catch (error) {
            const err = (0, error_1.shallowExtractErrorDetails)(error);
            this.childLogger.error({ error: err.message }, 'Error during estimateGasParams');
            throw error;
        }
    }
    /**
     * Fetches the current nonce for the given wallet on the given network.
     *
     * @param {string}  walletAddress The wallet to get nonce for.
     * @returns {Promise<number>} The current nonce for the given network and wallet.
     */
    async getCurrentNonce(walletAddress) {
        try {
            return await this.getTransactionCount(walletAddress);
        }
        catch (error) {
            logger_1.logger.warn({
                networkId: this.networkConfig.id,
                error: error.message,
                code: error.code,
            }, '💥 Could not fetch current tx count (nonce) from RPC');
            throw error;
        }
    }
    /**
     * Determines if an error is transient, meaning it is likely temporary and may be resolved
     * by retrying the operation, e.g. network issues, rate limiting, etc.
     *
     * @param {any} error The error object received from the JSON RPC provider.
     * @return {boolean} Returns true if the error is considered transient, false otherwise.
     */
    isTransientError(error) {
        const transientErrorCodes = [
            types_1.JSONRPCErrorCode.RATE_LIMITING,
            types_1.JSONRPCErrorCode.INTERNAL_ERROR, // can arise from too many requests
            types_1.JSONRPCErrorCode.METHOD_NOT_FOUND, // @TODO, should this be considered transient?
            types_1.JSONRPCErrorCode.SERVER_ERROR,
        ];
        const transientErrorMessages = [
            'timeout',
            'rate limit',
            'too many requests',
            'network failure',
            'internal json-rpc error',
            'service unavailable',
            'node connection',
        ];
        const { code, message } = (0, error_1.extractCodeAndMessageFromJsonRpcError)(error);
        if (transientErrorCodes.includes(code)) {
            return true;
        }
        for (const transientMessage of transientErrorMessages) {
            if (message.toLowerCase().includes(transientMessage)) {
                return true;
            }
        }
        // consider network errors as transient
        if (error instanceof Error && (error.name === 'NetworkError' || error.message.includes('network'))) {
            return true;
        }
        // default to non-transient, if not matched
        return false;
    }
}
exports.CustomJsonRpcProvider = CustomJsonRpcProvider;
class Rpc {
    config;
    prometheus;
    provider = {};
    redisClient;
    constructor(config, prometheus) {
        this.config = config;
        this.prometheus = prometheus;
    }
    /**
     * Initializes or re-initializes RPC provider from a given list
     *
     * @param {string[]} providersUrls URLs of the JSON RPC providers.
     * @param {NetworkIdT3rn} network The network ID.
     * @param redisClient
     * @return {CustomJsonRpcProvider} The initialized provider.
     */
    init(providersUrls = [], network, redisClient) {
        if (providersUrls.length === 0) {
            logger_1.logger.error({ networkId: network.id }, `❌ RPC provider URLS array cannot be empty for network ${network.id}!`);
            throw new Error(`❌ RPC provider URLS array cannot be empty for network ${network.id}!`);
        }
        if (redisClient) {
            this.redisClient = redisClient;
            logger_1.logger.debug(`Redis enabled in RPC for ${this.config.appName} service`);
        }
        if (this.provider[network.id]) {
            logger_1.logger.debug({ providersUrls }, `🚨 Provider already initialized for network ${network.id}`);
            return this.provider[network.id];
        }
        const provider = new CustomJsonRpcProvider(
        // @ts-ignore providersUrls is not empty
        providersUrls, network, this.prometheus, this.config, this.redisClient);
        this.provider[network.id] = provider;
        void this.maintainRpcConnection(network);
        logger_1.logger.debug({ providersUrls, networkId: network.id }, '🔌 Network initialized');
        return provider;
    }
    /**
     * Fetches the balance of a wallet on a specific network.
     *
     * @param {NetworkIdT3rn} networkId The network ID.
     * @param {string} address The address of the wallet to check the balance of.
     *
     * @returns {Promise<BigNumber | null>} The response object containing the balance and a success flag.
     */
    async fetchWalletBalance(networkId, address, symbol) {
        const provider = this.provider[networkId];
        try {
            if (!provider) {
                logger_1.logger.warn({
                    sourceNetwork: networkId,
                    address,
                    symbol,
                }, '🤔 This should not have been reachable. Check the init method of Rpc');
                return;
            }
            const balance = await provider.getBalance(address);
            return balance;
        }
        catch (error) {
            const err = (0, error_1.shallowExtractErrorDetails)(error);
            logger_1.logger.error({
                sourceNetwork: networkId,
                address,
                symbol,
                code: err.code,
                error: err.message,
            }, '❌ RPC error at getBalance');
        }
    }
    async fetchTokenBalance(networkId, address, tokenAddress, symbol) {
        try {
            const provider = this.provider[networkId];
            if (!provider) {
                logger_1.logger.error({ networkId, tokenAddress, address }, `Tried to fetch token balance of for network but provider not found`);
                throw new Error(`Provider not found for network ${networkId}`);
            }
            const tokenContract = new ethers_1.ethers.Contract(tokenAddress, ['function balanceOf(address) view returns (uint256)'], provider);
            const balance = await tokenContract.balanceOf(address);
            return balance;
        }
        catch (error) {
            const err = (0, error_1.shallowExtractErrorDetails)(error);
            logger_1.logger.error({
                sourceNetwork: networkId,
                address,
                tokenAddress,
                symbol,
                code: err.code,
                error: err.message,
            }, '❌ Error fetching token balance');
        }
    }
    /**
     * Returns the current height of the given network by querying the current provider.
     *
     * @param {NetworkIdT3rn} networkId
     * @return {Promise<number>} The current block number.
     */
    async getCurrentHeight(networkId) {
        try {
            const provider = this.provider[networkId];
            if (!provider) {
                throw new Error('🤔 Code should not reach this point. Check the init method of Rpc');
            }
            return await provider.getBlockNumber();
        }
        catch (error) {
            const err = (0, error_1.shallowExtractErrorDetails)(error);
            logger_1.logger.error({ networkId, error: err.message }, '🔴 Error getting block number');
            throw error;
        }
    }
    /**
     * Maintains the RPC connection by continuously checking the health of the current provider
     * and switching to a new provider if the current one becomes unhealthy.
     *
     * @param {NetworkConfig} network The network configuration.
     */
    async maintainRpcConnection(network) {
        const networkProvider = this.provider[network.id] ?? this.init(network.rpcEndpoints, network);
        const rpcProm = this.prometheus.networkStatus.labels({
            networkId: network.id,
            rpcHostname: 'batcher',
        });
        if (!networkProvider) {
            logger_1.logger.error(`🚨 RPC provider for network ${network.id} not found. Skipping...`);
            return;
        }
        while (true) {
            try {
                const isHealthy = await networkProvider.checkProviderHealth();
                rpcProm.set(isHealthy ? IS_HEALTHY : IS_UNHEALTHY);
            }
            catch (error) {
                logger_1.logger.error({ error: error.message, networkId: network.id }, '🚨 Unexpected error during RPC health check');
                await (0, helpers_1.sleep)(Math.max(1, Number(this.config.rpc.healthCheckIntervalSec)), 'Waiting between RPC health-checks');
            }
            await (0, helpers_1.sleep)(Math.max(1, Number(this.config.rpc.healthCheckIntervalSec * 5)), 'Waiting between RPC health-checks');
        }
    }
}
exports.Rpc = Rpc;
//# sourceMappingURL=rpc.service.js.map