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
trevor/disable-rarichain-rpc
J M Rossy 3 weeks ago committed by GitHub
parent 40d59a2f47
commit 5f41b11346
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/unlucky-pillows-clap.md
  2. 16
      typescript/cli/src/config/hooks.ts
  3. 8
      typescript/infra/scripts/warp-routes/monitor-warp-routes-balances.ts
  4. 1
      typescript/sdk/package.json
  5. 94
      typescript/sdk/src/gas/token-prices.test.ts
  6. 85
      typescript/sdk/src/gas/token-prices.ts
  7. 35
      typescript/sdk/src/gas/utils.ts
  8. 1
      typescript/sdk/src/index.ts
  9. 47
      typescript/sdk/src/test/MockCoinGecko.ts
  10. 10
      typescript/widgets/.storybook/main.ts
  11. 17
      yarn.lock

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': major
---
Remove getCoingeckoTokenPrices (use CoinGeckoTokenPriceGetter instead)

@ -8,12 +8,12 @@ import {
ChainMap, ChainMap,
ChainMetadata, ChainMetadata,
ChainName, ChainName,
CoinGeckoTokenPriceGetter,
HookConfig, HookConfig,
HookConfigSchema, HookConfigSchema,
HookType, HookType,
IgpHookConfig, IgpHookConfig,
MultiProtocolProvider, MultiProtocolProvider,
getCoingeckoTokenPrices,
getGasPrice, getGasPrice,
getLocalStorageGasOracleConfig, getLocalStorageGasOracleConfig,
} from '@hyperlane-xyz/sdk'; } from '@hyperlane-xyz/sdk';
@ -305,9 +305,17 @@ async function getIgpTokenPrices(
) { ) {
const isTestnet = const isTestnet =
context.chainMetadata[Object.keys(filteredMetadata)[0]].isTestnet; context.chainMetadata[Object.keys(filteredMetadata)[0]].isTestnet;
const fetchedPrices = isTestnet
? objMap(filteredMetadata, () => '10') let fetchedPrices: ChainMap<string>;
: await getCoingeckoTokenPrices(filteredMetadata); 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( logBlue(
isTestnet isTestnet

@ -3,12 +3,12 @@ import { ethers } from 'ethers';
import { Gauge, Registry } from 'prom-client'; import { Gauge, Registry } from 'prom-client';
import { import {
ERC20__factory,
HypXERC20Lockbox__factory, HypXERC20Lockbox__factory,
HypXERC20__factory, HypXERC20__factory,
IXERC20, IXERC20,
IXERC20__factory, IXERC20__factory,
} from '@hyperlane-xyz/core'; } from '@hyperlane-xyz/core';
import { ERC20__factory } from '@hyperlane-xyz/core';
import { createWarpRouteConfigId } from '@hyperlane-xyz/registry'; import { createWarpRouteConfigId } from '@hyperlane-xyz/registry';
import { import {
ChainMap, ChainMap,
@ -638,10 +638,10 @@ async function checkWarpRouteMetrics(
tokenConfig: WarpRouteConfig, tokenConfig: WarpRouteConfig,
chainMetadata: ChainMap<ChainMetadata>, chainMetadata: ChainMap<ChainMetadata>,
) { ) {
const tokenPriceGetter = CoinGeckoTokenPriceGetter.withDefaultCoinGecko( const tokenPriceGetter = new CoinGeckoTokenPriceGetter({
chainMetadata, chainMetadata,
await getCoinGeckoApiKey(), apiKey: await getCoinGeckoApiKey(),
); });
setInterval(async () => { setInterval(async () => {
try { try {

@ -15,7 +15,6 @@
"@solana/spl-token": "^0.4.9", "@solana/spl-token": "^0.4.9",
"@solana/web3.js": "^1.95.4", "@solana/web3.js": "^1.95.4",
"bignumber.js": "^9.1.1", "bignumber.js": "^9.1.1",
"coingecko-api-v3": "^0.0.29",
"cosmjs-types": "^0.9.0", "cosmjs-types": "^0.9.0",
"cross-fetch": "^3.1.5", "cross-fetch": "^3.1.5",
"ethers": "^5.7.2", "ethers": "^5.7.2",

@ -1,51 +1,83 @@
import { expect } from 'chai'; import { expect } from 'chai';
import sinon from 'sinon';
import { ethereum, solanamainnet } from '@hyperlane-xyz/registry';
import { TestChainName, testChainMetadata } from '../consts/testChains.js'; import { TestChainName, testChainMetadata } from '../consts/testChains.js';
import { MockCoinGecko } from '../test/MockCoinGecko.js';
import { CoinGeckoTokenPriceGetter } from './token-prices.js'; import { CoinGeckoTokenPriceGetter } from './token-prices.js';
const MOCK_FETCH_CALLS = true;
describe('TokenPriceGetter', () => { describe('TokenPriceGetter', () => {
let tokenPriceGetter: CoinGeckoTokenPriceGetter; let tokenPriceGetter: CoinGeckoTokenPriceGetter;
let mockCoinGecko: MockCoinGecko;
const chainA = TestChainName.test1, const chainA = TestChainName.test1;
chainB = TestChainName.test2, const chainB = TestChainName.test2;
priceA = 1, const priceA = 2;
priceB = 5.5; const priceB = 5;
before(async () => { let stub: sinon.SinonStub;
mockCoinGecko = new MockCoinGecko();
// Origin token beforeEach(() => {
mockCoinGecko.setTokenPrice(chainA, priceA); tokenPriceGetter = new CoinGeckoTokenPriceGetter({
// Destination token chainMetadata: { ethereum, solanamainnet, ...testChainMetadata },
mockCoinGecko.setTokenPrice(chainB, priceB); apiKey: 'test',
tokenPriceGetter = new CoinGeckoTokenPriceGetter( expirySeconds: 10,
mockCoinGecko, sleepMsBetweenRequests: 10,
testChainMetadata, });
undefined,
0, if (MOCK_FETCH_CALLS) {
); stub = sinon
.stub(tokenPriceGetter, 'fetchPriceData')
.returns(Promise.resolve([priceA, priceB]));
}
}); });
describe('getTokenPrice', () => { afterEach(() => {
it('returns a token price', async () => { if (MOCK_FETCH_CALLS && stub) {
expect(await tokenPriceGetter.getTokenPrice(chainA)).to.equal(priceA); 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 () => { describe('getTokenPrice', () => {
mockCoinGecko.setFail(chainA, true); it('returns a token price', async () => {
expect(await tokenPriceGetter.getTokenPrice(chainA)).to.equal(priceA); // hardcoded result of 1 for testnets
mockCoinGecko.setFail(chainA, false); 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', () => { describe('getTokenExchangeRate', () => {
it('returns a value consistent with getTokenPrice()', async () => { it('returns a value consistent with getTokenPrice()', async () => {
const exchangeRate = await tokenPriceGetter.getTokenExchangeRate( // hardcoded result of 1 for testnets
chainA, expect(
chainB, await tokenPriceGetter.getTokenExchangeRate(chainA, chainB),
); ).to.equal(1);
// Should equal 1 because testnet prices are always forced to 1
expect(exchangeRate).to.equal(1); // stubbed result for non-testnet
expect(
await tokenPriceGetter.getTokenExchangeRate(
ethereum.name,
solanamainnet.name,
),
).to.equal(priceA / priceB);
}); });
}); });
}); });

@ -1,21 +1,15 @@
import { CoinGeckoClient, SimplePriceResponse } from 'coingecko-api-v3'; import { objKeys, rootLogger, sleep } from '@hyperlane-xyz/utils';
import { rootLogger, sleep } from '@hyperlane-xyz/utils';
import { ChainMetadata } from '../metadata/chainMetadataTypes.js'; import { ChainMetadata } from '../metadata/chainMetadataTypes.js';
import { ChainMap, ChainName } from '../types.js'; import { ChainMap, ChainName } from '../types.js';
const COINGECKO_PRICE_API = 'https://api.coingecko.com/api/v3/simple/price';
export interface TokenPriceGetter { export interface TokenPriceGetter {
getTokenPrice(chain: ChainName): Promise<number>; getTokenPrice(chain: ChainName): Promise<number>;
getTokenExchangeRate(base: ChainName, quote: ChainName): Promise<number>; getTokenExchangeRate(base: ChainName, quote: ChainName): Promise<number>;
} }
export type CoinGeckoInterface = Pick<CoinGeckoClient, 'simplePrice'>;
export type CoinGeckoSimplePriceInterface = CoinGeckoClient['simplePrice'];
export type CoinGeckoSimplePriceParams =
Parameters<CoinGeckoSimplePriceInterface>[0];
export type CoinGeckoResponse = ReturnType<CoinGeckoSimplePriceInterface>;
type TokenPriceCacheEntry = { type TokenPriceCacheEntry = {
price: number; price: number;
timestamp: Date; timestamp: Date;
@ -65,38 +59,28 @@ class TokenPriceCache {
} }
export class CoinGeckoTokenPriceGetter implements TokenPriceGetter { export class CoinGeckoTokenPriceGetter implements TokenPriceGetter {
protected coinGecko: CoinGeckoInterface;
protected cache: TokenPriceCache; protected cache: TokenPriceCache;
protected apiKey?: string;
protected sleepMsBetweenRequests: number; protected sleepMsBetweenRequests: number;
protected metadata: ChainMap<ChainMetadata>; protected metadata: ChainMap<ChainMetadata>;
constructor( constructor({
coinGecko: CoinGeckoInterface, chainMetadata,
chainMetadata: ChainMap<ChainMetadata>, apiKey,
expirySeconds?: number, expirySeconds,
sleepMsBetweenRequests = 5000, sleepMsBetweenRequests = 5000,
) { }: {
this.coinGecko = coinGecko; chainMetadata: ChainMap<ChainMetadata>;
apiKey?: string;
expirySeconds?: number;
sleepMsBetweenRequests?: number;
}) {
this.apiKey = apiKey;
this.cache = new TokenPriceCache(expirySeconds); this.cache = new TokenPriceCache(expirySeconds);
this.metadata = chainMetadata; this.metadata = chainMetadata;
this.sleepMsBetweenRequests = sleepMsBetweenRequests; this.sleepMsBetweenRequests = sleepMsBetweenRequests;
} }
static withDefaultCoinGecko(
chainMetadata: ChainMap<ChainMetadata>,
apiKey?: string,
expirySeconds?: number,
sleepMsBetweenRequests = 5000,
): CoinGeckoTokenPriceGetter {
const coinGecko = new CoinGeckoClient(undefined, apiKey);
return new CoinGeckoTokenPriceGetter(
coinGecko,
chainMetadata,
expirySeconds,
sleepMsBetweenRequests,
);
}
async getTokenPrice( async getTokenPrice(
chain: ChainName, chain: ChainName,
currency: string = 'usd', currency: string = 'usd',
@ -105,6 +89,15 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter {
return price; return price;
} }
async getAllTokenPrices(currency: string = 'usd'): Promise<ChainMap<number>> {
const chains = objKeys(this.metadata);
const prices = await this.getTokenPrices(chains, currency);
return chains.reduce(
(agg, chain, i) => ({ ...agg, [chain]: prices[i] }),
{},
);
}
async getTokenExchangeRate( async getTokenExchangeRate(
base: ChainName, base: ChainName,
quote: ChainName, quote: ChainName,
@ -153,14 +146,9 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter {
await sleep(this.sleepMsBetweenRequests); await sleep(this.sleepMsBetweenRequests);
if (toQuery.length > 0) { if (toQuery.length > 0) {
let response: SimplePriceResponse;
try { try {
response = await this.coinGecko.simplePrice({ const prices = await this.fetchPriceData(toQuery, currency);
ids: toQuery.join(','), prices.forEach((price, i) => this.cache.put(toQuery[i], price));
vs_currencies: currency,
});
const prices = toQuery.map((id) => response[id][currency]);
toQuery.map((id, i) => this.cache.put(id, prices[i]));
} catch (e) { } catch (e) {
rootLogger.warn('Error when querying token prices', e); rootLogger.warn('Error when querying token prices', e);
return undefined; return undefined;
@ -168,4 +156,25 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter {
} }
return ids.map((id) => this.cache.fetch(id)); return ids.map((id) => this.cache.fetch(id));
} }
public async fetchPriceData(
ids: string[],
currency: string,
): Promise<number[]> {
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);
});
}
} }

@ -9,7 +9,6 @@ import {
} from '../consts/igp.js'; } from '../consts/igp.js';
import { ChainMetadataManager } from '../metadata/ChainMetadataManager.js'; import { ChainMetadataManager } from '../metadata/ChainMetadataManager.js';
import { AgentCosmosGasPrice } from '../metadata/agentConfig.js'; import { AgentCosmosGasPrice } from '../metadata/agentConfig.js';
import { ChainMetadata } from '../metadata/chainMetadataTypes.js';
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider.js'; import { MultiProtocolProvider } from '../providers/MultiProtocolProvider.js';
import { ChainMap, ChainName } from '../types.js'; import { ChainMap, ChainName } from '../types.js';
import { getCosmosRegistryChain } from '../utils/cosmos.js'; import { getCosmosRegistryChain } from '../utils/cosmos.js';
@ -215,37 +214,3 @@ export function getLocalStorageGasOracleConfig({
}; };
}, {} as ChainMap<StorageGasOracleConfig>); }, {} as ChainMap<StorageGasOracleConfig>);
} }
const COINGECKO_PRICE_API = 'https://api.coingecko.com/api/v3/simple/price';
export async function getCoingeckoTokenPrices(
chainMetadata: ChainMap<ChainMetadata>,
currency = 'usd',
): Promise<ChainMap<string | undefined>> {
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;
}

@ -554,7 +554,6 @@ export {
ChainGasOracleParams, ChainGasOracleParams,
GasPriceConfig, GasPriceConfig,
NativeTokenPriceConfig, NativeTokenPriceConfig,
getCoingeckoTokenPrices,
getCosmosChainGasPrice, getCosmosChainGasPrice,
getGasPrice, getGasPrice,
getLocalStorageGasOracleConfig, getLocalStorageGasOracleConfig,

@ -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<string, number>;
// Whether or not to fail to return a response, keyed by coingecko id
private fail: Record<string, boolean>;
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;
}
}

@ -1,3 +1,4 @@
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
import type { StorybookConfig } from '@storybook/react-vite'; import type { StorybookConfig } from '@storybook/react-vite';
import { mergeConfig } from 'vite'; import { mergeConfig } from 'vite';
@ -19,6 +20,15 @@ const config: StorybookConfig = {
async viteFinal(config, { configType }) { async viteFinal(config, { configType }) {
return mergeConfig(config, { return mergeConfig(config, {
define: { 'process.env': {} }, define: { 'process.env': {} },
optimizeDeps: {
esbuildOptions: {
plugins: [
NodeGlobalsPolyfillPlugin({
buffer: true,
}),
],
},
},
}); });
}, },
}; };

@ -8073,7 +8073,6 @@ __metadata:
"@types/ws": "npm:^8.5.5" "@types/ws": "npm:^8.5.5"
bignumber.js: "npm:^9.1.1" bignumber.js: "npm:^9.1.1"
chai: "npm:4.5.0" chai: "npm:4.5.0"
coingecko-api-v3: "npm:^0.0.29"
cosmjs-types: "npm:^0.9.0" cosmjs-types: "npm:^0.9.0"
cross-fetch: "npm:^3.1.5" cross-fetch: "npm:^3.1.5"
dotenv: "npm:^10.0.0" dotenv: "npm:^10.0.0"
@ -16860,15 +16859,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "collect-v8-coverage@npm:^1.0.0":
version: 1.0.2 version: 1.0.2
resolution: "collect-v8-coverage@npm:1.0.2" resolution: "collect-v8-coverage@npm:1.0.2"
@ -21773,13 +21763,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "human-id@npm:^1.0.2":
version: 1.0.2 version: 1.0.2
resolution: "human-id@npm:1.0.2" resolution: "human-id@npm:1.0.2"

Loading…
Cancel
Save