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 2 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,
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<string>;
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

@ -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<ChainMetadata>,
) {
const tokenPriceGetter = CoinGeckoTokenPriceGetter.withDefaultCoinGecko(
const tokenPriceGetter = new CoinGeckoTokenPriceGetter({
chainMetadata,
await getCoinGeckoApiKey(),
);
apiKey: await getCoinGeckoApiKey(),
});
setInterval(async () => {
try {

@ -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",

@ -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,
});
describe('getTokenPrice', () => {
it('returns a token price', async () => {
expect(await tokenPriceGetter.getTokenPrice(chainA)).to.equal(priceA);
if (MOCK_FETCH_CALLS) {
stub = sinon
.stub(tokenPriceGetter, 'fetchPriceData')
.returns(Promise.resolve([priceA, priceB]));
}
});
afterEach(() => {
if (MOCK_FETCH_CALLS && stub) {
stub.restore();
}
});
it('caches a token price', async () => {
mockCoinGecko.setFail(chainA, true);
expect(await tokenPriceGetter.getTokenPrice(chainA)).to.equal(priceA);
mockCoinGecko.setFail(chainA, false);
describe('getTokenPriceByIds', () => {
it('returns token prices', async () => {
// stubbed results
expect(
await tokenPriceGetter.getTokenPriceByIds([
ethereum.name,
solanamainnet.name,
]),
).to.eql([priceA, priceB]);
});
});
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);
});
});
});

@ -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<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 = {
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<ChainMetadata>;
constructor(
coinGecko: CoinGeckoInterface,
chainMetadata: ChainMap<ChainMetadata>,
expirySeconds?: number,
constructor({
chainMetadata,
apiKey,
expirySeconds,
sleepMsBetweenRequests = 5000,
) {
this.coinGecko = coinGecko;
}: {
chainMetadata: ChainMap<ChainMetadata>;
apiKey?: string;
expirySeconds?: number;
sleepMsBetweenRequests?: number;
}) {
this.apiKey = apiKey;
this.cache = new TokenPriceCache(expirySeconds);
this.metadata = chainMetadata;
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(
chain: ChainName,
currency: string = 'usd',
@ -105,6 +89,15 @@ export class CoinGeckoTokenPriceGetter implements TokenPriceGetter {
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(
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<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';
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<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,
GasPriceConfig,
NativeTokenPriceConfig,
getCoingeckoTokenPrices,
getCosmosChainGasPrice,
getGasPrice,
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 { 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,
}),
],
},
},
});
},
};

@ -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"

Loading…
Cancel
Save