import assert from "assert";
import { defaultAdapter } from "./adapters";
export function msToS(ms) {
    return Math.floor(ms / 1000);
}
// PriceClient aggregates various user-configured price feeds/sources and retains them for a
// configurable time period. External price lookups are performed according to an ordered list that
// is supplied during instantiation. The PriceClient will iterate over the list until a complete set
// of prices has been retrieved from a single source. External price lookups will opportunistically
// request new prices for _all_ previously requested tokens. Price lookups into PriceClient will be
// served out of its local cache if the token price is less than maxPriceAge seconds. This helps to
// suppress external price lookups and can help to mitigate rate-limiting by external providers.
// See README.md for further information and usage guidelines.
export class PriceClient {
    logger;
    priceFeeds;
    throwOnFailure;
    name = "PriceClient";
    _maxPriceAge = 300; // seconds
    prices = {};
    constructor(logger, priceFeeds, throwOnFailure = false) {
        this.logger = logger;
        this.priceFeeds = priceFeeds;
        this.throwOnFailure = throwOnFailure;
        assert(logger, "No logging instance supplied.");
        assert(priceFeeds.length > 0, "No price feeds supplied.");
        if (!throwOnFailure) {
            priceFeeds.push(new defaultAdapter.PriceFeed());
        }
    }
    get maxPriceAge() {
        return this._maxPriceAge;
    }
    set maxPriceAge(age) {
        assert(age >= 0);
        this.logger.debug({
            at: "PriceClient#maxPriceAge",
            message: `Setting maxPriceAge (S) ${this._maxPriceAge} => ${age}.`,
        });
        this._maxPriceAge = age;
    }
    listPriceFeeds() {
        return this.priceFeeds.map((priceFeed) => priceFeed.name);
    }
    async getPriceByAddress(address, currency = "usd") {
        const tokenPrices = await this.getPricesByAddress([address], currency);
        return tokenPrices[0];
    }
    // note: Input addresses are *always* converted to lower case for storage as
    // keys in the PriceClient cache. Adapters will therefore always receive
    // addresses in lower case form.
    async getPricesByAddress(addresses, currency = "usd") {
        assert(this.priceFeeds.length > 0, "No price feeds are registered.");
        const priceCache = this.getPriceCache(currency);
        // Determine whether each *requested* price is current.
        const now = msToS(Date.now());
        const missed = {};
        addresses.forEach((address) => {
            const addrCacheKey = address.toLowerCase();
            const tokenPrice = priceCache[addrCacheKey] ?? { price: 0, timestamp: 0, address };
            priceCache[addrCacheKey] = tokenPrice; // Update priceCache if necessary;
            const age = now - tokenPrice.timestamp;
            if (age > this.maxPriceAge) {
                missed[address] = age;
            }
        });
        if (Object.keys(missed).length > 0) {
            this.logger.debug({
                at: "PriceClient#getPricesByAddress",
                message: `${currency.toUpperCase()} cache miss (age > ${this.maxPriceAge} S).`,
                tokens: missed,
            });
            await this.updatePrices(currency);
        }
        return addresses.map((address) => {
            const { price, timestamp } = priceCache[address.toLowerCase()];
            return { address, price, timestamp };
        });
    }
    expireCache(currency) {
        const priceCache = this.getPriceCache(currency);
        Object.values(priceCache).forEach((token) => (token.timestamp = 0));
        this.logger.debug({ at: "PriceClient#expireCache", message: `Expired ${currency} cache.` });
    }
    getPriceCache(currency) {
        if (this.prices[currency] === undefined)
            this.prices[currency] = {};
        return this.prices[currency];
    }
    async updatePrices(currency) {
        const priceCache = this.getPriceCache(currency);
        let addrsToRequest = Object.entries(priceCache).map((entry) => entry[1].address);
        let skipped = addrsToRequest;
        let consecutiveZeroAddrs = new Set();
        for (const priceFeed of this.priceFeeds) {
            if (addrsToRequest.length === 0)
                break;
            this.logger.debug({
                at: "PriceClient#updatePrices",
                message: `Looking up prices via ${priceFeed.name}.`,
                tokens: addrsToRequest,
            });
            try {
                const feedPricesResponse = await priceFeed.getPricesByAddress(addrsToRequest, currency);
                skipped = this.updateCache(priceCache, feedPricesResponse, addrsToRequest);
                const zeroPriceAddrs = addrsToRequest.filter((addr) => priceCache[addr.toLowerCase()].price === 0);
                const giveUpAddrs = zeroPriceAddrs.filter((addr) => consecutiveZeroAddrs.has(addr));
                if (giveUpAddrs.length > 0) {
                    this.logger.debug({
                        at: "PriceClient#updatePrices",
                        message: "Giving up on addresses with price of 0 from 2 consecutive providers.",
                        tokens: giveUpAddrs,
                    });
                }
                consecutiveZeroAddrs = new Set(zeroPriceAddrs);
                addrsToRequest = Array.from(new Set([...skipped, ...zeroPriceAddrs.filter((addr) => !giveUpAddrs.includes(addr))]));
                if (addrsToRequest.length === 0)
                    break;
            }
            catch (err) {
                this.logger.debug({
                    at: "PriceClient#updatePrices",
                    message: `Price lookup against ${priceFeed.name} failed (${err}).`,
                    tokens: addrsToRequest,
                });
                // Failover to the next price feed...
            }
        }
        if (skipped.length !== 0) {
            this.logger.debug({
                at: "PriceClient#updatePrices",
                message: "Unable to resolve some token prices.",
                priceFeeds: this.listPriceFeeds(),
                tokens: skipped,
            });
            throw new Error(`Price lookup failed against all price feeds (${this.listPriceFeeds().join(", ")})`);
        }
    }
    updateCache(priceCache, prices, expected) {
        const updated = [];
        const skipped = {}; // Includes reason for skipping
        const now = msToS(Date.now());
        expected.forEach((address) => {
            const addr = address.toLowerCase(); // for internal priceCache lookup.
            const tokenPrice = prices.find((price) => price?.address?.toLowerCase() === addr);
            if (tokenPrice === undefined) {
                skipped[address] = "Not included in price feed response.";
            }
            else if (tokenPrice.timestamp > now) {
                skipped[address] = `Token price timestamp is too new (timestamp ${tokenPrice.timestamp}).`;
            }
            else if (tokenPrice.timestamp >= priceCache[addr].timestamp) {
                const { price, timestamp } = tokenPrice;
                // @todo: Do we care if the token price is older than maxPriceAge?
                priceCache[addr] = { price, timestamp, address: priceCache[addr].address };
                updated.push(tokenPrice);
            }
            else if (tokenPrice.timestamp === priceCache[addr].timestamp) {
                this.logger.debug({
                    at: "PriceClient#updateCache",
                    message: `No new price available for token ${address}.`,
                    token: tokenPrice,
                });
            }
        });
        this.logger.debug({
            at: "PriceClient#updateCache",
            message: `Updated ${updated.length ?? 0} price(s), skipped ${Object.keys(skipped).length ?? 0}.`,
            updated,
            skipped,
        });
        return Object.keys(skipped);
    }
}
//# sourceMappingURL=priceClient.js.map