Skip to content

Commit

Permalink
fix: Remove coingecko-api-v3 library and de-dupe fetching utils (#4837)
Browse files Browse the repository at this point in the history
### 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: #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
  • Loading branch information
jmrossy authored Nov 7, 2024
1 parent 40d59a2 commit 5f41b11
Show file tree
Hide file tree
Showing 11 changed files with 141 additions and 178 deletions.
5 changes: 5 additions & 0 deletions .changeset/unlucky-pillows-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/sdk': major
---

Remove getCoingeckoTokenPrices (use CoinGeckoTokenPriceGetter instead)
16 changes: 12 additions & 4 deletions typescript/cli/src/config/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import {
ChainMap,
ChainMetadata,
ChainName,
CoinGeckoTokenPriceGetter,
HookConfig,
HookConfigSchema,
HookType,
IgpHookConfig,
MultiProtocolProvider,
getCoingeckoTokenPrices,
getGasPrice,
getLocalStorageGasOracleConfig,
} from '@hyperlane-xyz/sdk';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion typescript/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 63 additions & 31 deletions typescript/sdk/src/gas/token-prices.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
85 changes: 47 additions & 38 deletions typescript/sdk/src/gas/token-prices.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -153,19 +146,35 @@ 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;
}
}
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);
});
}
}
35 changes: 0 additions & 35 deletions typescript/sdk/src/gas/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
1 change: 0 additions & 1 deletion typescript/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,6 @@ export {
ChainGasOracleParams,
GasPriceConfig,
NativeTokenPriceConfig,
getCoingeckoTokenPrices,
getCosmosChainGasPrice,
getGasPrice,
getLocalStorageGasOracleConfig,
Expand Down
Loading

0 comments on commit 5f41b11

Please sign in to comment.