"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Coingecko = void 0;
exports.msToS = msToS;
const tslib_1 = require("tslib");
const assert_1 = tslib_1.__importDefault(require("assert"));
const utils_1 = require("../utils");
const CoingeckoErrors_1 = require("./CoingeckoErrors");
function msToS(ms) {
    return Math.floor(ms / 1000);
}
class Coingecko {
    host;
    proHost;
    logger;
    apiKey;
    customPlatformIdMap;
    static instance;
    prices;
    pricesBySymbol;
    _maxPriceAge = 300;
    retryDelay = 1;
    numRetries = 0;
    basicApiTimeout = 500;
    platformIdMap = new Map();
    tokenIdMap = {};
    static get(logger, apiKey, customPlatformIdMap) {
        if (!this.instance)
            this.instance = new Coingecko("https://api.coingecko.com/api/v3", "https://pro-api.coingecko.com/api/v3", logger, apiKey, customPlatformIdMap);
        return this.instance;
    }
    get maxPriceAge() {
        return this._maxPriceAge;
    }
    set maxPriceAge(age) {
        (0, assert_1.default)(age >= 0);
        this.logger.debug({
            at: "Coingecko#maxPriceAge",
            message: `Setting maxPriceAge (S) ${this._maxPriceAge} => ${age}.`,
        });
        this._maxPriceAge = age;
    }
    constructor(host, proHost, logger, apiKey, customPlatformIdMap) {
        this.host = host;
        this.proHost = proHost;
        this.logger = logger;
        this.apiKey = apiKey;
        this.customPlatformIdMap = customPlatformIdMap;
        this.prices = {};
        this.pricesBySymbol = {};
    }
    async getPlatformId(chainId) {
        let id = this.platformIdMap.get(chainId);
        if (id) {
            return id;
        }
        const platforms = await this.getPlatforms();
        this.platformIdMap = new Map(platforms.filter((chain) => Boolean(chain.chain_identifier)).map((chain) => [chain.chain_identifier, chain.id]));
        if (this.customPlatformIdMap) {
            Object.entries(this.customPlatformIdMap).forEach(([chainId, platformId]) => {
                this.platformIdMap.set(Number(chainId), platformId);
            });
        }
        id = this.platformIdMap.get(chainId);
        if (!id) {
            this.logger.error({
                message: `Coingecko does not support chain with id ${chainId}`,
                at: "Coingecko#getPlatformId",
            });
            throw new Error(`Coingecko does not support chain with id ${chainId}`);
        }
        return id;
    }
    async getCoingeckoTokenId(address, chainId) {
        let id;
        try {
            id = (0, utils_1.getCoingeckoTokenIdByAddress)(address, chainId);
            return id;
        }
        catch {
            this.logger.warn({
                at: "Coingecko#getCoingeckoTokenIdByAddress",
                message: `Token with address ${address} not found in constants. Attempting to fetch ID from coingecko API...`,
            });
        }
        const platformId = await this.getPlatformId(chainId);
        id = this.getTokenIdFromAddress(address, platformId);
        if (id) {
            return id;
        }
        await this.updateTokenMap();
        id = this.getTokenIdFromAddress(address, platformId);
        if (!id) {
            const message = `Coin with address ${address} does not exist on chain with id ${chainId}`;
            this.logger.error({
                at: "Coingecko#getCoingeckoTokenIdByAddress",
                message,
            });
            throw new Error(message);
        }
        return id;
    }
    getTokenIdFromAddress(address, platformId) {
        return Object.entries(this.tokenIdMap).find(([_, value]) => Boolean(value?.[platformId]?.toLowerCase() === address.toLowerCase()))?.[0];
    }
    async updateTokenMap() {
        const rawTokenList = await this.call("coins/list?include_platform=true");
        this.tokenIdMap = Object.fromEntries(rawTokenList
            .filter((token) => Boolean(Object.values(token?.platforms)?.length > 0))
            .map((token) => [token.id, token.platforms]));
        return this.tokenIdMap;
    }
    async getHistoricContractPrices(contract, from, to, currency = "usd") {
        (0, assert_1.default)(contract, "requires contract address");
        (0, assert_1.default)(currency, "requires currency symbol");
        (0, assert_1.default)(from, "requires from timestamp");
        (0, assert_1.default)(to, "requires to timestamp");
        const _from = msToS(from);
        const _to = msToS(to);
        const result = await this.call(`coins/ethereum/contract/${encodeURIComponent(contract.toLowerCase())}/market_chart/range/?vs_currency=${encodeURIComponent(currency)}&from=${_from}&to=${_to}`);
        if (result.prices)
            return result.prices;
        throw new Error("Something went wrong fetching coingecko prices!");
    }
    async getContractHistoricDayPrice(contractAddress, date, currency = "usd", chainId = 1) {
        const coingeckoTokenIdentifier = await this.getCoingeckoTokenId(contractAddress, chainId);
        (0, assert_1.default)(date, "Requires date string");
        const url = `coins/${encodeURIComponent(coingeckoTokenIdentifier)}/history`;
        const queryParams = {
            date,
            localization: "false",
        };
        const result = await this.call(`${url}?${new URLSearchParams(queryParams).toString()}`);
        const price = result?.market_data?.current_price?.[currency];
        (0, assert_1.default)(price, `No price found for ${contractAddress} on ${date}`);
        return price;
    }
    getContractDetails(contract_address, platform_id = "ethereum") {
        return this.call(`coins/${encodeURIComponent(platform_id)}/contract/${encodeURIComponent(contract_address.toLowerCase())}`);
    }
    async getCurrentPriceByContract(contractAddress, currency = "usd", chainId = 1) {
        const platform_id = await this.getPlatformId(chainId);
        const priceCache = this.getPriceCache(currency, platform_id);
        let tokenPrice = this.getCachedAddressPrice(contractAddress, currency, platform_id);
        if (tokenPrice === undefined) {
            await this.getContractPrices([contractAddress], currency, platform_id);
            tokenPrice = priceCache[contractAddress];
        }
        if (tokenPrice === undefined) {
            throw new CoingeckoErrors_1.CoingeckoPriceNotFoundError({ identifier: contractAddress, currency, lookupType: "address" });
        }
        return [tokenPrice.timestamp.toString(), tokenPrice.price];
    }
    async getCurrentPriceById(contractAddress, currency = "usd", chainId = 1) {
        const platform_id = await this.getPlatformId(chainId);
        const priceCache = this.getPriceCache(currency, platform_id);
        let tokenPrice = this.getCachedAddressPrice(contractAddress, currency, platform_id);
        if (tokenPrice === undefined) {
            const coingeckoId = await this.getCoingeckoTokenId(contractAddress, chainId);
            const result = await this.call(`simple/price?ids=${encodeURIComponent(coingeckoId)}&vs_currencies=${encodeURIComponent(currency)}&include_last_updated_at=true`);
            const cgPrice = result?.[coingeckoId];
            if (cgPrice === undefined || !cgPrice?.[currency]) {
                this.logger.debug({
                    at: "Coingecko#getCurrentPriceById",
                    message: `No Coingecko price found for id '${coingeckoId}' in ${currency}`,
                });
                throw new CoingeckoErrors_1.CoingeckoPriceNotFoundError({ identifier: coingeckoId, currency, lookupType: "id" });
            }
            else {
                this.updatePriceCache(cgPrice, contractAddress, currency, platform_id);
            }
        }
        tokenPrice = priceCache[contractAddress];
        if (tokenPrice === undefined) {
            throw new CoingeckoErrors_1.CoingeckoPriceNotFoundError({ identifier: contractAddress, currency, lookupType: "address" });
        }
        return [tokenPrice.timestamp.toString(), tokenPrice.price];
    }
    async getCurrentPriceBySymbol(symbol, currency = "usd") {
        let tokenPrice = this.getCachedSymbolPrice(symbol, currency);
        if (tokenPrice === undefined) {
            const result = await this.call(`simple/price?symbols=${encodeURIComponent(symbol)}&vs_currencies=${encodeURIComponent(currency)}&include_last_updated_at=true`);
            const cgPrice = result?.[symbol.toLowerCase()] || result?.[symbol.toUpperCase()];
            if (cgPrice === undefined || !cgPrice?.[currency]) {
                this.logger.debug({
                    at: "Coingecko#getCurrentPriceBySymbol",
                    message: `No Coingecko price found for symbol '${symbol}' in ${currency}`,
                });
                throw new CoingeckoErrors_1.CoingeckoPriceNotFoundError({ identifier: symbol, currency, lookupType: "symbol" });
            }
            else {
                this.updatePriceCacheBySymbol(cgPrice, symbol, currency);
            }
        }
        tokenPrice = this.getCachedSymbolPrice(symbol, currency);
        if (tokenPrice === undefined) {
            throw new CoingeckoErrors_1.CoingeckoPriceNotFoundError({ identifier: symbol, currency, lookupType: "symbol" });
        }
        return [tokenPrice.timestamp.toString(), tokenPrice.price];
    }
    async getContractPrices(addresses, currency = "usd", platform_id = "ethereum") {
        const priceCache = this.getPriceCache(currency, platform_id);
        addresses.forEach((addr) => {
            if (priceCache[addr] === undefined) {
                priceCache[addr] = { address: addr, price: 0, timestamp: 0 };
            }
        });
        const contract_addresses = Object.keys(priceCache);
        (0, assert_1.default)(contract_addresses.length > 0, "Must supply at least 1 contract address");
        this.logger.debug({
            at: "Coingecko#getContractPrices",
            message: `Updating ${platform_id}/${currency} token prices.`,
            tokens: contract_addresses,
        });
        let result = {};
        try {
            result = await this.call(`simple/token_price/${encodeURIComponent(platform_id)}?contract_addresses=${contract_addresses
                .map((addr) => encodeURIComponent(addr))
                .join("%2C")}&vs_currencies=${encodeURIComponent(currency)}&include_last_updated_at=true`);
        }
        catch (err) {
            const errMsg = `Failed to retrieve ${platform_id}/${currency} prices (${err})`;
            this.logger.debug({
                at: "Coingecko#getContractPrices",
                message: errMsg,
                tokens: contract_addresses,
            });
            throw new Error(errMsg);
        }
        contract_addresses.forEach((addr) => {
            const cgPrice = result[addr.toLowerCase()];
            if (cgPrice === undefined) {
                this.logger.debug({
                    at: "Coingecko#getContractPrices",
                    message: `Token ${addr} not included in CoinGecko response.`,
                });
            }
            else {
                this.updatePriceCache(cgPrice, addr, currency, platform_id);
            }
        });
        return addresses.map((addr) => priceCache[addr]);
    }
    getPlatforms() {
        return this.call("asset_platforms");
    }
    call(path) {
        const sendRequest = async () => {
            const { proHost } = this;
            if (this.apiKey === undefined) {
                return await this._callBasic(path);
            }
            try {
                return await this._callBasic(path, this.basicApiTimeout);
            }
            catch (err) {
                this.logger.debug({
                    at: "sdk/coingecko",
                    message: `Basic CG url request failed, falling back to CG PRO host ${proHost}`,
                    errMessage: err.message,
                });
                return await this._callPro(path);
            }
        };
        return (0, utils_1.retry)(sendRequest, this.apiKey === undefined ? this.numRetries : 0, this.retryDelay);
    }
    getPriceCache(currency, platform_id) {
        if (this.prices[platform_id] === undefined)
            this.prices[platform_id] = {};
        if (this.prices[platform_id][currency] === undefined)
            this.prices[platform_id][currency] = {};
        return this.prices[platform_id][currency];
    }
    getPriceCacheBySymbol(symbol) {
        if (this.pricesBySymbol[symbol] === undefined)
            this.pricesBySymbol[symbol] = {};
        return this.pricesBySymbol[symbol];
    }
    getCachedAddressPrice(contractAddress, currency, platform_id) {
        const priceCache = this.getPriceCache(currency, platform_id);
        const now = msToS(Date.now());
        const tokenPrice = priceCache[contractAddress];
        if (tokenPrice === undefined || tokenPrice.timestamp + this.maxPriceAge <= now) {
            if (this.maxPriceAge > 0) {
                this.logger.debug({
                    at: "Coingecko#getCachedAddressPrice",
                    message: `Cache miss on ${platform_id}/${currency} for ${contractAddress}`,
                    maxPriceAge: this.maxPriceAge,
                    tokenPrice: tokenPrice,
                });
            }
            return undefined;
        }
        else {
            this.logger.debug({
                at: "Coingecko#getCachedAddressPrice",
                message: `Cache hit on token ${contractAddress} (age ${now - tokenPrice.timestamp} S).`,
                price: tokenPrice,
            });
            return tokenPrice;
        }
    }
    getCachedSymbolPrice(symbol, currency) {
        const priceCache = this.getPriceCacheBySymbol(symbol);
        const now = msToS(Date.now());
        const tokenPrice = priceCache[currency];
        if (tokenPrice === undefined || tokenPrice.timestamp + this.maxPriceAge <= now) {
            return undefined;
        }
        return tokenPrice;
    }
    updatePriceCache(cgPrice, contractAddress, currency, platform_id) {
        const priceCache = this.getPriceCache(currency, platform_id);
        if (priceCache[contractAddress] === undefined) {
            priceCache[contractAddress] = { address: contractAddress, price: 0, timestamp: 0 };
        }
        if (cgPrice.last_updated_at > priceCache[contractAddress].timestamp) {
            priceCache[contractAddress] = {
                address: contractAddress,
                price: cgPrice[currency],
                timestamp: cgPrice.last_updated_at,
            };
            this.logger.debug({
                at: "Coingecko#updatePriceCache",
                message: `Updated ${platform_id}/${currency}/${contractAddress} token price cache.`,
            });
        }
        else {
            this.logger.debug({
                at: "Coingecko#updatePriceCache",
                message: `No new price available for token ${contractAddress}.`,
                token: cgPrice,
            });
        }
    }
    updatePriceCacheBySymbol(cgPrice, symbol, currency) {
        const priceCache = this.getPriceCacheBySymbol(symbol);
        if (priceCache[currency] === undefined) {
            priceCache[currency] = { price: 0, timestamp: 0 };
        }
        if (cgPrice.last_updated_at > priceCache[currency].timestamp) {
            priceCache[currency] = {
                price: cgPrice[currency],
                timestamp: cgPrice.last_updated_at,
            };
            this.logger.debug({
                at: "Coingecko#updatePriceCacheBySymbol",
                message: `Updated ${symbol}/${currency} token price cache.`,
            });
        }
        else {
            this.logger.debug({
                at: "Coingecko#updatePriceCacheBySymbol",
                message: `No new price available for symbol ${symbol}.`,
                token: cgPrice,
            });
        }
    }
    async _callBasic(path, timeout) {
        const url = `${this.host}/${path}`;
        return await (0, utils_1.fetchWithTimeout)(url, {}, {}, timeout);
    }
    async _callPro(path) {
        const url = `${this.proHost}/${path}`;
        return await (0, utils_1.fetchWithTimeout)(url, { x_cg_pro_api_key: this.apiKey });
    }
}
exports.Coingecko = Coingecko;
//# sourceMappingURL=Coingecko.js.map