From 5f41b113467ff458d3103bc4c5885441ede744ff Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Thu, 7 Nov 2024 16:04:25 -0500 Subject: [PATCH] fix: Remove coingecko-api-v3 library and de-dupe fetching utils (#4837) ### Description - Remove coingecko-api-v3 lib which is unnecessary and breaks bundling - Remove `getCoingeckoTokenPrices` function which is redundant with `CoinGeckoTokenPriceGetter` - Simplify `CoinGeckoTokenPriceGetter` and remove need for Mock class Related: https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/4787 See discussion here: https://discord.com/channels/935678348330434570/1304125818817220653 ### Drive-by changes - Fix bundling issue with Storybook in widgets lib ### Backward compatibility No ### Testing Rewrote unit tests and tested Storybook --- .changeset/unlucky-pillows-clap.md | 5 + typescript/cli/src/config/hooks.ts | 16 +++- .../monitor-warp-routes-balances.ts | 8 +- typescript/sdk/package.json | 1 - typescript/sdk/src/gas/token-prices.test.ts | 94 +++++++++++++------ typescript/sdk/src/gas/token-prices.ts | 85 +++++++++-------- typescript/sdk/src/gas/utils.ts | 35 ------- typescript/sdk/src/index.ts | 1 - typescript/sdk/src/test/MockCoinGecko.ts | 47 ---------- typescript/widgets/.storybook/main.ts | 10 ++ yarn.lock | 17 ---- 11 files changed, 141 insertions(+), 178 deletions(-) create mode 100644 .changeset/unlucky-pillows-clap.md delete mode 100644 typescript/sdk/src/test/MockCoinGecko.ts diff --git a/.changeset/unlucky-pillows-clap.md b/.changeset/unlucky-pillows-clap.md new file mode 100644 index 000000000..ddefb4f2e --- /dev/null +++ b/.changeset/unlucky-pillows-clap.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': major +--- + +Remove getCoingeckoTokenPrices (use CoinGeckoTokenPriceGetter instead) diff --git a/typescript/cli/src/config/hooks.ts b/typescript/cli/src/config/hooks.ts index 0bfd8cb1f..e8df64dc0 100644 --- a/typescript/cli/src/config/hooks.ts +++ b/typescript/cli/src/config/hooks.ts @@ -8,12 +8,12 @@ import { ChainMap, ChainMetadata, ChainName, + CoinGeckoTokenPriceGetter, HookConfig, HookConfigSchema, HookType, IgpHookConfig, MultiProtocolProvider, - getCoingeckoTokenPrices, getGasPrice, getLocalStorageGasOracleConfig, } from '@hyperlane-xyz/sdk'; @@ -305,9 +305,17 @@ async function getIgpTokenPrices( ) { const isTestnet = context.chainMetadata[Object.keys(filteredMetadata)[0]].isTestnet; - const fetchedPrices = isTestnet - ? objMap(filteredMetadata, () => '10') - : await getCoingeckoTokenPrices(filteredMetadata); + + let fetchedPrices: ChainMap; + if (isTestnet) { + fetchedPrices = objMap(filteredMetadata, () => '10'); + } else { + const tokenPriceGetter = new CoinGeckoTokenPriceGetter({ + chainMetadata: filteredMetadata, + }); + const results = await tokenPriceGetter.getAllTokenPrices(); + fetchedPrices = objMap(results, (v) => v.toString()); + } logBlue( isTestnet diff --git a/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts b/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts index fd4da97f5..06b4fb78f 100644 --- a/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts +++ b/typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts @@ -3,12 +3,12 @@ import { ethers } from 'ethers'; import { Gauge, Registry } from 'prom-client'; import { + ERC20__factory, HypXERC20Lockbox__factory, HypXERC20__factory, IXERC20, IXERC20__factory, } from '@hyperlane-xyz/core'; -import { ERC20__factory } from '@hyperlane-xyz/core'; import { createWarpRouteConfigId } from '@hyperlane-xyz/registry'; import { ChainMap, @@ -638,10 +638,10 @@ async function checkWarpRouteMetrics( tokenConfig: WarpRouteConfig, chainMetadata: ChainMap, ) { - const tokenPriceGetter = CoinGeckoTokenPriceGetter.withDefaultCoinGecko( + const tokenPriceGetter = new CoinGeckoTokenPriceGetter({ chainMetadata, - await getCoinGeckoApiKey(), - ); + apiKey: await getCoinGeckoApiKey(), + }); setInterval(async () => { try { diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json index dbb340026..2eec9a68f 100644 --- a/typescript/sdk/package.json +++ b/typescript/sdk/package.json @@ -15,7 +15,6 @@ "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.95.4", "bignumber.js": "^9.1.1", - "coingecko-api-v3": "^0.0.29", "cosmjs-types": "^0.9.0", "cross-fetch": "^3.1.5", "ethers": "^5.7.2", diff --git a/typescript/sdk/src/gas/token-prices.test.ts b/typescript/sdk/src/gas/token-prices.test.ts index 48deda8bc..dbc1dc76b 100644 --- a/typescript/sdk/src/gas/token-prices.test.ts +++ b/typescript/sdk/src/gas/token-prices.test.ts @@ -1,51 +1,83 @@ import { expect } from 'chai'; +import sinon from 'sinon'; + +import { ethereum, solanamainnet } from '@hyperlane-xyz/registry'; import { TestChainName, testChainMetadata } from '../consts/testChains.js'; -import { MockCoinGecko } from '../test/MockCoinGecko.js'; import { CoinGeckoTokenPriceGetter } from './token-prices.js'; +const MOCK_FETCH_CALLS = true; + describe('TokenPriceGetter', () => { let tokenPriceGetter: CoinGeckoTokenPriceGetter; - let mockCoinGecko: MockCoinGecko; - const chainA = TestChainName.test1, - chainB = TestChainName.test2, - priceA = 1, - priceB = 5.5; - before(async () => { - mockCoinGecko = new MockCoinGecko(); - // Origin token - mockCoinGecko.setTokenPrice(chainA, priceA); - // Destination token - mockCoinGecko.setTokenPrice(chainB, priceB); - tokenPriceGetter = new CoinGeckoTokenPriceGetter( - mockCoinGecko, - testChainMetadata, - undefined, - 0, - ); + + const chainA = TestChainName.test1; + const chainB = TestChainName.test2; + const priceA = 2; + const priceB = 5; + let stub: sinon.SinonStub; + + beforeEach(() => { + tokenPriceGetter = new CoinGeckoTokenPriceGetter({ + chainMetadata: { ethereum, solanamainnet, ...testChainMetadata }, + apiKey: 'test', + expirySeconds: 10, + sleepMsBetweenRequests: 10, + }); + + if (MOCK_FETCH_CALLS) { + stub = sinon + .stub(tokenPriceGetter, 'fetchPriceData') + .returns(Promise.resolve([priceA, priceB])); + } }); - describe('getTokenPrice', () => { - it('returns a token price', async () => { - expect(await tokenPriceGetter.getTokenPrice(chainA)).to.equal(priceA); + afterEach(() => { + if (MOCK_FETCH_CALLS && stub) { + stub.restore(); + } + }); + + describe('getTokenPriceByIds', () => { + it('returns token prices', async () => { + // stubbed results + expect( + await tokenPriceGetter.getTokenPriceByIds([ + ethereum.name, + solanamainnet.name, + ]), + ).to.eql([priceA, priceB]); }); + }); - it('caches a token price', async () => { - mockCoinGecko.setFail(chainA, true); - expect(await tokenPriceGetter.getTokenPrice(chainA)).to.equal(priceA); - mockCoinGecko.setFail(chainA, false); + describe('getTokenPrice', () => { + it('returns a token price', async () => { + // hardcoded result of 1 for testnets + expect( + await tokenPriceGetter.getTokenPrice(TestChainName.test1), + ).to.equal(1); + // stubbed result for non-testnet + expect(await tokenPriceGetter.getTokenPrice(ethereum.name)).to.equal( + priceA, + ); }); }); describe('getTokenExchangeRate', () => { it('returns a value consistent with getTokenPrice()', async () => { - const exchangeRate = await tokenPriceGetter.getTokenExchangeRate( - chainA, - chainB, - ); - // Should equal 1 because testnet prices are always forced to 1 - expect(exchangeRate).to.equal(1); + // hardcoded result of 1 for testnets + expect( + await tokenPriceGetter.getTokenExchangeRate(chainA, chainB), + ).to.equal(1); + + // stubbed result for non-testnet + expect( + await tokenPriceGetter.getTokenExchangeRate( + ethereum.name, + solanamainnet.name, + ), + ).to.equal(priceA / priceB); }); }); }); diff --git a/typescript/sdk/src/gas/token-prices.ts b/typescript/sdk/src/gas/token-prices.ts index 80b1ae288..62a477422 100644 --- a/typescript/sdk/src/gas/token-prices.ts +++ b/typescript/sdk/src/gas/token-prices.ts @@ -1,21 +1,15 @@ -import { CoinGeckoClient, SimplePriceResponse } from 'coingecko-api-v3'; - -import { rootLogger, sleep } from '@hyperlane-xyz/utils'; +import { objKeys, rootLogger, sleep } from '@hyperlane-xyz/utils'; import { ChainMetadata } from '../metadata/chainMetadataTypes.js'; import { ChainMap, ChainName } from '../types.js'; +const COINGECKO_PRICE_API = 'https://api.coingecko.com/api/v3/simple/price'; + export interface TokenPriceGetter { getTokenPrice(chain: ChainName): Promise; getTokenExchangeRate(base: ChainName, quote: ChainName): Promise; } -export type CoinGeckoInterface = Pick; -export type CoinGeckoSimplePriceInterface = CoinGeckoClient['simplePrice']; -export type CoinGeckoSimplePriceParams = - Parameters[0]; -export type CoinGeckoResponse = ReturnType; - type TokenPriceCacheEntry = { price: number; timestamp: Date; @@ -65,38 +59,28 @@ class TokenPriceCache { } export class CoinGeckoTokenPriceGetter implements TokenPriceGetter { - protected coinGecko: CoinGeckoInterface; protected cache: TokenPriceCache; + protected apiKey?: string; protected sleepMsBetweenRequests: number; protected metadata: ChainMap; - constructor( - coinGecko: CoinGeckoInterface, - chainMetadata: ChainMap, - expirySeconds?: number, + constructor({ + chainMetadata, + apiKey, + expirySeconds, sleepMsBetweenRequests = 5000, - ) { - this.coinGecko = coinGecko; + }: { + chainMetadata: ChainMap; + apiKey?: string; + expirySeconds?: number; + sleepMsBetweenRequests?: number; + }) { + this.apiKey = apiKey; this.cache = new TokenPriceCache(expirySeconds); this.metadata = chainMetadata; this.sleepMsBetweenRequests = sleepMsBetweenRequests; } - static withDefaultCoinGecko( - chainMetadata: ChainMap, - apiKey?: string, - expirySeconds?: number, - sleepMsBetweenRequests = 5000, - ): CoinGeckoTokenPriceGetter { - const coinGecko = new CoinGeckoClient(undefined, apiKey); - return new CoinGeckoTokenPriceGetter( - coinGecko, - chainMetadata, - expirySeconds, - sleepMsBetweenRequests, - ); - } - async getTokenPrice( chain: ChainName, currency: string = 'usd', @@ -105,6 +89,15 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter { return price; } + async getAllTokenPrices(currency: string = 'usd'): Promise> { + const chains = objKeys(this.metadata); + const prices = await this.getTokenPrices(chains, currency); + return chains.reduce( + (agg, chain, i) => ({ ...agg, [chain]: prices[i] }), + {}, + ); + } + async getTokenExchangeRate( base: ChainName, quote: ChainName, @@ -153,14 +146,9 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter { await sleep(this.sleepMsBetweenRequests); if (toQuery.length > 0) { - let response: SimplePriceResponse; try { - response = await this.coinGecko.simplePrice({ - ids: toQuery.join(','), - vs_currencies: currency, - }); - const prices = toQuery.map((id) => response[id][currency]); - toQuery.map((id, i) => this.cache.put(id, prices[i])); + const prices = await this.fetchPriceData(toQuery, currency); + prices.forEach((price, i) => this.cache.put(toQuery[i], price)); } catch (e) { rootLogger.warn('Error when querying token prices', e); return undefined; @@ -168,4 +156,25 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter { } return ids.map((id) => this.cache.fetch(id)); } + + public async fetchPriceData( + ids: string[], + currency: string, + ): Promise { + let url = `${COINGECKO_PRICE_API}?ids=${Object.entries(ids).join( + ',', + )}&vs_currencies=${currency}`; + if (this.apiKey) { + url += `&x-cg-pro-api-key=${this.apiKey}`; + } + + const resp = await fetch(url); + const idPrices = await resp.json(); + + return ids.map((id) => { + const price = idPrices[id]?.[currency]; + if (!price) throw new Error(`No price found for ${id}`); + return Number(price); + }); + } } diff --git a/typescript/sdk/src/gas/utils.ts b/typescript/sdk/src/gas/utils.ts index 3a658d9b6..6f007398c 100644 --- a/typescript/sdk/src/gas/utils.ts +++ b/typescript/sdk/src/gas/utils.ts @@ -9,7 +9,6 @@ import { } from '../consts/igp.js'; import { ChainMetadataManager } from '../metadata/ChainMetadataManager.js'; import { AgentCosmosGasPrice } from '../metadata/agentConfig.js'; -import { ChainMetadata } from '../metadata/chainMetadataTypes.js'; import { MultiProtocolProvider } from '../providers/MultiProtocolProvider.js'; import { ChainMap, ChainName } from '../types.js'; import { getCosmosRegistryChain } from '../utils/cosmos.js'; @@ -215,37 +214,3 @@ export function getLocalStorageGasOracleConfig({ }; }, {} as ChainMap); } - -const COINGECKO_PRICE_API = 'https://api.coingecko.com/api/v3/simple/price'; - -export async function getCoingeckoTokenPrices( - chainMetadata: ChainMap, - currency = 'usd', -): Promise> { - const ids = objMap( - chainMetadata, - (_, metadata) => metadata.gasCurrencyCoinGeckoId ?? metadata.name, - ); - - const resp = await fetch( - `${COINGECKO_PRICE_API}?ids=${Object.entries(ids).join( - ',', - )}&vs_currencies=${currency}`, - ); - - const idPrices = await resp.json(); - - const prices = objMap(ids, (chain, id) => { - const idData = idPrices[id]; - if (!idData) { - return undefined; - } - const price = idData[currency]; - if (!price) { - return undefined; - } - return price.toString(); - }); - - return prices; -} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index d01a9c68c..6fb5d57a8 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -554,7 +554,6 @@ export { ChainGasOracleParams, GasPriceConfig, NativeTokenPriceConfig, - getCoingeckoTokenPrices, getCosmosChainGasPrice, getGasPrice, getLocalStorageGasOracleConfig, diff --git a/typescript/sdk/src/test/MockCoinGecko.ts b/typescript/sdk/src/test/MockCoinGecko.ts deleted file mode 100644 index 8b410125a..000000000 --- a/typescript/sdk/src/test/MockCoinGecko.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { SimplePriceResponse } from 'coingecko-api-v3'; - -import type { - CoinGeckoInterface, - CoinGeckoResponse, - CoinGeckoSimplePriceInterface, - CoinGeckoSimplePriceParams, -} from '../gas/token-prices.js'; -import type { ChainName } from '../types.js'; - -// A mock CoinGecko intended to be used by tests -export class MockCoinGecko implements CoinGeckoInterface { - // Prices keyed by coingecko id - private tokenPrices: Record; - // Whether or not to fail to return a response, keyed by coingecko id - private fail: Record; - - constructor() { - this.tokenPrices = {}; - this.fail = {}; - } - - price(input: CoinGeckoSimplePriceParams): CoinGeckoResponse { - const data: SimplePriceResponse = {}; - for (const id of input.ids) { - if (this.fail[id]) { - return Promise.reject(`Failed to fetch price for ${id}`); - } - data[id] = { - usd: this.tokenPrices[id], - }; - } - return Promise.resolve(data); - } - - get simplePrice(): CoinGeckoSimplePriceInterface { - return this.price; - } - - setTokenPrice(chain: ChainName, price: number): void { - this.tokenPrices[chain] = price; - } - - setFail(chain: ChainName, fail: boolean): void { - this.fail[chain] = fail; - } -} diff --git a/typescript/widgets/.storybook/main.ts b/typescript/widgets/.storybook/main.ts index 2afe9d398..d4aa7c994 100644 --- a/typescript/widgets/.storybook/main.ts +++ b/typescript/widgets/.storybook/main.ts @@ -1,3 +1,4 @@ +import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; import type { StorybookConfig } from '@storybook/react-vite'; import { mergeConfig } from 'vite'; @@ -19,6 +20,15 @@ const config: StorybookConfig = { async viteFinal(config, { configType }) { return mergeConfig(config, { define: { 'process.env': {} }, + optimizeDeps: { + esbuildOptions: { + plugins: [ + NodeGlobalsPolyfillPlugin({ + buffer: true, + }), + ], + }, + }, }); }, }; diff --git a/yarn.lock b/yarn.lock index 44046d57d..84393f395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8073,7 +8073,6 @@ __metadata: "@types/ws": "npm:^8.5.5" bignumber.js: "npm:^9.1.1" chai: "npm:4.5.0" - coingecko-api-v3: "npm:^0.0.29" cosmjs-types: "npm:^0.9.0" cross-fetch: "npm:^3.1.5" dotenv: "npm:^10.0.0" @@ -16860,15 +16859,6 @@ __metadata: languageName: node linkType: hard -"coingecko-api-v3@npm:^0.0.29": - version: 0.0.29 - resolution: "coingecko-api-v3@npm:0.0.29" - dependencies: - https: "npm:^1.0.0" - checksum: 10/e60a0996472419232a144ec77028c060bd9c289f799dd40d46dbb7229cff3d868a3e35bf88724059dc25767b8136d794789e4dd31711592fa73a7be1ca2fcbc7 - languageName: node - linkType: hard - "collect-v8-coverage@npm:^1.0.0": version: 1.0.2 resolution: "collect-v8-coverage@npm:1.0.2" @@ -21773,13 +21763,6 @@ __metadata: languageName: node linkType: hard -"https@npm:^1.0.0": - version: 1.0.0 - resolution: "https@npm:1.0.0" - checksum: 10/ccea8a8363a018d4b241db7748cff3a85c9f5b71bf80639e9c37dc6823f590f35dda098b80b726930e9f945387c8bfd6b1461df25cab5bf65a31903d81875b5d - languageName: node - linkType: hard - "human-id@npm:^1.0.2": version: 1.0.2 resolution: "human-id@npm:1.0.2"