Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Remove coingecko-api-v3 library and de-dupe fetching utils #4837

Merged
merged 3 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
}
jmrossy marked this conversation as resolved.
Show resolved Hide resolved
}
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