diff --git a/balancer-js/examples/data/api-token-price-service.ts b/balancer-js/examples/data/api-token-price-service.ts new file mode 100644 index 000000000..a7101c62a --- /dev/null +++ b/balancer-js/examples/data/api-token-price-service.ts @@ -0,0 +1,25 @@ +/** + * Display APRs for pool ids hardcoded under `const ids` + * Run command: yarn example ./examples/data/token-prices.ts + */ +import { ApiTokenPriceService } from '@/modules/sor/token-price/apiTokenPriceService'; + +const dai = '0x6b175474e89094c44da98b954eedeac495271d0f'; +const weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; +const ohm = '0X64AA3364F17A4D01C6F1751FD97C2BD3D7E7F1D5'; + +(async () => { + const apiTokenPriceService = new ApiTokenPriceService(1); + const daiPriceInEth = await apiTokenPriceService.getNativeAssetPriceInToken( + dai + ); + console.log('Dai Price In ETH: ' + daiPriceInEth); + const wethPriceInEth = await apiTokenPriceService.getNativeAssetPriceInToken( + weth + ); + console.log('WETH Price In ETH: ' + wethPriceInEth); + const ohmPriceInEth = await apiTokenPriceService.getNativeAssetPriceInToken( + ohm + ); + console.log('OHM Price In ETH: ' + ohmPriceInEth); +})(); diff --git a/balancer-js/src/lib/utils/coingecko-api.ts b/balancer-js/src/lib/utils/coingecko-api.ts new file mode 100644 index 000000000..b56c42ab8 --- /dev/null +++ b/balancer-js/src/lib/utils/coingecko-api.ts @@ -0,0 +1,13 @@ +export function getCoingeckoApiBaseUrl(isDemoApi = true): string { + if (isDemoApi) { + return 'https://api.coingecko.com/api/v3/'; + } + return 'https://pro-api.coingecko.com/api/v3/'; +} + +export function getCoingeckoApiKeyHeaderName(isDemoApi = true): string { + if (isDemoApi) { + return 'x-cg-demo-api-key'; + } + return 'x-cg-pro-api-key'; +} diff --git a/balancer-js/src/modules/data/token-prices/coingecko-historical.ts b/balancer-js/src/modules/data/token-prices/coingecko-historical.ts index c574c826a..5ef94b1bf 100644 --- a/balancer-js/src/modules/data/token-prices/coingecko-historical.ts +++ b/balancer-js/src/modules/data/token-prices/coingecko-historical.ts @@ -9,6 +9,10 @@ import { } from '@/types'; import axios, { AxiosError } from 'axios'; import { tokenAddressForPricing } from '@/lib/utils'; +import { + getCoingeckoApiBaseUrl, + getCoingeckoApiKeyHeaderName, +} from '@/lib/utils/coingecko-api'; const HOUR = 60 * 60; @@ -18,16 +22,19 @@ const HOUR = 60 * 60; export class CoingeckoHistoricalPriceRepository implements Findable { prices: TokenPrices = {}; nativePrice?: Promise; - urlBase: string; - apiKey?: string; - + private readonly urlBase: string; + private readonly apiKey?: string; + private readonly coingeckoApiKeyHeaderName: string; constructor(private chainId: Network = 1, coingecko?: CoingeckoConfig) { - this.urlBase = `https://${ - coingecko?.coingeckoApiKey && !coingecko.isDemoApiKey ? 'pro-' : '' - }api.coingecko.com/api/v3/coins/${this.platform( + this.urlBase = `${getCoingeckoApiBaseUrl( + coingecko?.isDemoApiKey + )}coins/${this.platform( chainId )}/contract/%TOKEN_ADDRESS%/market_chart/range?vs_currency=usd`; this.apiKey = coingecko?.coingeckoApiKey; + this.coingeckoApiKeyHeaderName = getCoingeckoApiKeyHeaderName( + coingecko?.isDemoApiKey + ); } private async fetch( @@ -40,7 +47,7 @@ export class CoingeckoHistoricalPriceRepository implements Findable { try { const { data } = await axios.get(url, { signal, - headers: { 'x-cg-pro-api-key': this.apiKey ?? '' }, + headers: { [this.coingeckoApiKeyHeaderName]: this.apiKey ?? '' }, }); console.timeEnd(`fetching coingecko historical for ${address}`); console.log(data); diff --git a/balancer-js/src/modules/data/token-prices/coingecko.ts b/balancer-js/src/modules/data/token-prices/coingecko.ts index dbb3d0f8e..ec3c6a0d9 100644 --- a/balancer-js/src/modules/data/token-prices/coingecko.ts +++ b/balancer-js/src/modules/data/token-prices/coingecko.ts @@ -9,6 +9,10 @@ import { import axios, { AxiosError } from 'axios'; import { TOKENS } from '@/lib/constants/tokens'; import { Debouncer, tokenAddressForPricing } from '@/lib/utils'; +import { + getCoingeckoApiBaseUrl, + getCoingeckoApiKeyHeaderName, +} from '@/lib/utils/coingecko-api'; /** * Simple coingecko price source implementation. Configurable by network and token addresses. @@ -16,7 +20,9 @@ import { Debouncer, tokenAddressForPricing } from '@/lib/utils'; export class CoingeckoPriceRepository implements Findable { prices: { [key: string]: Promise } = {}; nativePrice?: Promise; - urlBase: string; + private readonly url: string; + private readonly urlNative: string; + private readonly coingeckoApiKeyHeaderName: string; baseTokenAddresses: string[]; debouncer: Debouncer; apiKey?: string; @@ -27,9 +33,15 @@ export class CoingeckoPriceRepository implements Findable { coingecko?: CoingeckoConfig ) { this.baseTokenAddresses = tokenAddresses.map(tokenAddressForPricing); - this.urlBase = `https://api.coingecko.com/api/v3/simple/token_price/${this.platform( - chainId - )}?vs_currencies=usd,eth`; + this.url = `${getCoingeckoApiBaseUrl( + coingecko?.isDemoApiKey + )}simple/token_price/${this.platform(chainId)}?vs_currencies=usd,eth`; + this.urlNative = `${getCoingeckoApiBaseUrl( + coingecko?.isDemoApiKey + )}simple/price/?vs_currencies=eth,usd&ids=`; + this.coingeckoApiKeyHeaderName = getCoingeckoApiKeyHeaderName( + coingecko?.isDemoApiKey + ); this.apiKey = coingecko?.coingeckoApiKey; this.debouncer = new Debouncer( this.fetch.bind(this), @@ -43,10 +55,15 @@ export class CoingeckoPriceRepository implements Findable { { signal }: { signal?: AbortSignal } = {} ): Promise { try { - const { data } = await axios.get(this.url(addresses), { - signal, - headers: { ApiKey: this.apiKey ?? '' }, - }); + const { data } = await axios.get( + `${this.url}&contract_addresses=${addresses.join(',')}`, + { + signal, + headers: { + [this.coingeckoApiKeyHeaderName]: this.apiKey ?? '', + }, + } + ); return data; } catch (error) { const message = ['Error fetching token prices from coingecko']; @@ -74,10 +91,12 @@ export class CoingeckoPriceRepository implements Findable { if (this.chainId === 137) assetId = Assets.MATIC; if (this.chainId === 100) assetId = Assets.XDAI; return axios - .get<{ [key in Assets]: Price }>( - `https://api.coingecko.com/api/v3/simple/price/?vs_currencies=eth,usd&ids=${assetId}`, - { signal } - ) + .get<{ [key in Assets]: Price }>(`${this.urlNative}${assetId}`, { + signal, + headers: { + [this.coingeckoApiKeyHeaderName]: this.apiKey ?? '', + }, + }) .then(({ data }) => { return data[assetId]; }) @@ -161,8 +180,4 @@ export class CoingeckoPriceRepository implements Findable { return '2'; } - - private url(addresses: string[]): string { - return `${this.urlBase}&contract_addresses=${addresses.join(',')}`; - } } diff --git a/balancer-js/src/modules/pools/queries/queries.integration.spec.ts b/balancer-js/src/modules/pools/queries/queries.integration.spec.ts index e4773efde..eae591c1f 100644 --- a/balancer-js/src/modules/pools/queries/queries.integration.spec.ts +++ b/balancer-js/src/modules/pools/queries/queries.integration.spec.ts @@ -30,12 +30,12 @@ const balPool = { }; const composableStablePool = { - id: '0x4edcb2b46377530bc18bb4d2c7fe46a992c73e100000000000000000000003ec', + id: '0x05ff47afada98a98982113758878f9a8b9fdda0a000000000000000000000645', poolType: PoolType.ComposableStable, tokensList: [ - '0x4edcb2b46377530bc18bb4d2c7fe46a992c73e10', - '0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0', - '0xbe9895146f7af43049ca1c1ae358b0541ea49704', + '0x05ff47afada98a98982113758878f9a8b9fdda0a', + '0xae78736cd615f374d3085123a210448e74fc6393', + '0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee', ], }; diff --git a/balancer-js/src/modules/sor/sor.module.ts b/balancer-js/src/modules/sor/sor.module.ts index a1ec2f515..a656b4134 100644 --- a/balancer-js/src/modules/sor/sor.module.ts +++ b/balancer-js/src/modules/sor/sor.module.ts @@ -1,7 +1,6 @@ import { SOR, SorConfig, TokenPriceService } from '@balancer-labs/sor'; import { Provider, JsonRpcProvider } from '@ethersproject/providers'; import { SubgraphPoolDataService } from './pool-data/subgraphPoolDataService'; -import { CoingeckoTokenPriceService } from './token-price/coingeckoTokenPriceService'; import { SubgraphClient, createSubgraphClient, @@ -10,10 +9,13 @@ import { BalancerNetworkConfig, BalancerSdkConfig, BalancerSdkSorConfig, + CoingeckoConfig, } from '@/types'; import { SubgraphTokenPriceService } from './token-price/subgraphTokenPriceService'; import { getNetworkConfig } from '@/modules/sdk.helpers'; import { POOLS_TO_IGNORE } from '@/lib/constants/poolsToIgnore'; +import { ApiTokenPriceService } from '@/modules/sor/token-price/apiTokenPriceService'; +import { CoingeckoTokenPriceService } from '@/modules/sor/token-price/coingeckoTokenPriceService'; export class Sor extends SOR { constructor(sdkConfig: BalancerSdkConfig) { @@ -36,7 +38,8 @@ export class Sor extends SOR { const tokenPriceService = Sor.getTokenPriceService( network, sorConfig, - subgraphClient + subgraphClient, + sdkConfig.coingecko ); super(provider, sorNetworkConfig, poolDataService, tokenPriceService); @@ -44,7 +47,7 @@ export class Sor extends SOR { private static getSorConfig(config: BalancerSdkConfig): BalancerSdkSorConfig { return { - tokenPriceService: 'coingecko', + tokenPriceService: 'api', poolDataService: 'subgraph', fetchOnChainBalances: true, ...config.sor, @@ -89,17 +92,20 @@ export class Sor extends SOR { private static getTokenPriceService( network: BalancerNetworkConfig, sorConfig: BalancerSdkSorConfig, - subgraphClient: SubgraphClient + subgraphClient: SubgraphClient, + coingeckoConfig?: CoingeckoConfig ): TokenPriceService { + if (sorConfig.tokenPriceService === 'coingecko' && coingeckoConfig) { + return new CoingeckoTokenPriceService(network.chainId, coingeckoConfig); + } if (typeof sorConfig.tokenPriceService === 'object') { return sorConfig.tokenPriceService; } else if (sorConfig.tokenPriceService === 'subgraph') { - new SubgraphTokenPriceService( + return new SubgraphTokenPriceService( subgraphClient, network.addresses.tokens.wrappedNativeAsset ); } - - return new CoingeckoTokenPriceService(network.chainId); + return new ApiTokenPriceService(network.chainId); } } diff --git a/balancer-js/src/modules/sor/token-price/apiTokenPriceService.ts b/balancer-js/src/modules/sor/token-price/apiTokenPriceService.ts new file mode 100644 index 000000000..821332c6d --- /dev/null +++ b/balancer-js/src/modules/sor/token-price/apiTokenPriceService.ts @@ -0,0 +1,64 @@ +import { TokenPriceService } from '@balancer-labs/sor'; +import { gql, request } from 'graphql-request'; +import { Network } from '@/types'; + +export class ApiTokenPriceService implements TokenPriceService { + private chainKey: string; + + private balancerApiUrl = 'https://api-v3.balancer.fi/'; + + private tokenPriceQuery = gql` + query queryTokenPrices($chainKey: GqlChain!) { + tokenGetCurrentPrices(chains: [$chainKey]) { + address + price + } + } + `; + + constructor(private readonly chainId: number) { + this.chainKey = Network[chainId]; + } + async getNativeAssetPriceInToken(tokenAddress: string): Promise { + const { tokenGetCurrentPrices: tokenPrices } = await request( + this.balancerApiUrl, + this.tokenPriceQuery, + { + chainKey: this.chainKey, + } + ); + const tokenPriceUsd = ( + tokenPrices as { address: string; price: number }[] + ).find( + ({ address }) => address.toLowerCase() === tokenAddress.toLowerCase() + ); + if (!tokenPriceUsd) { + throw new Error('Token Price not found in the API'); + } + const nativeAssetPriceUsd = ( + tokenPrices as { address: string; price: number }[] + ).find( + ({ address }) => + address.toLowerCase() === + NativeAssetAddress[this.chainKey as keyof typeof NativeAssetAddress] + ); + if (!nativeAssetPriceUsd) { + throw new Error('Native Token Price not found in the API'); + } + const tokenPriceInNativeAsset = + tokenPriceUsd.price / nativeAssetPriceUsd.price; + return String(tokenPriceInNativeAsset); + } +} + +enum NativeAssetAddress { + MAINNET = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + POLYGON = '0x0000000000000000000000000000000000001010', + ARBITRUM = '0x912ce59144191c1204e64559fe8253a0e49e6548', + AVALANCHE = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + BASE = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + FANTOM = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + GNOSIS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + OPTIMISM = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + ZKEVM = '0xa2036f0538221a77a3937f1379699f44945018d0', +} diff --git a/balancer-js/src/modules/sor/token-price/coingeckoTokenPriceService.ts b/balancer-js/src/modules/sor/token-price/coingeckoTokenPriceService.ts index 4a9de1c0e..ef455d054 100644 --- a/balancer-js/src/modules/sor/token-price/coingeckoTokenPriceService.ts +++ b/balancer-js/src/modules/sor/token-price/coingeckoTokenPriceService.ts @@ -1,10 +1,27 @@ import { TokenPriceService } from '@balancer-labs/sor'; import axios from 'axios'; import { BALANCER_NETWORK_CONFIG } from '@/lib/constants/config'; -import { Network, BalancerNetworkConfig } from '@/types'; +import { Network, BalancerNetworkConfig, CoingeckoConfig } from '@/types'; +import { + getCoingeckoApiBaseUrl, + getCoingeckoApiKeyHeaderName, +} from '@/lib/utils/coingecko-api'; export class CoingeckoTokenPriceService implements TokenPriceService { - constructor(private readonly chainId: number) {} + private readonly urlBase: string; + private readonly apiKey: string; + private readonly coingeckoApiKeyHeaderName: string; + constructor(private readonly chainId: number, coingecko: CoingeckoConfig) { + this.urlBase = `${getCoingeckoApiBaseUrl( + coingecko?.isDemoApiKey + )}simple/token_price/${this.platformId}?vs_currencies=${ + this.nativeAssetId + }`; + this.coingeckoApiKeyHeaderName = getCoingeckoApiKeyHeaderName( + coingecko?.isDemoApiKey + ); + this.apiKey = coingecko.coingeckoApiKey; + } public async getNativeAssetPriceInToken( tokenAddress: string @@ -22,12 +39,13 @@ export class CoingeckoTokenPriceService implements TokenPriceService { * @returns the price of 1 ETH in terms of the token base units */ async getTokenPriceInNativeAsset(tokenAddress: string): Promise { - const endpoint = `https://api.coingecko.com/api/v3/simple/token_price/${this.platformId}?contract_addresses=${tokenAddress}&vs_currencies=${this.nativeAssetId}`; + const endpoint = `${this.urlBase}&contract_addresses=${tokenAddress}`; const { data } = await axios.get(endpoint, { headers: { Accept: 'application/json', 'Content-Type': 'application/json', + [this.coingeckoApiKeyHeaderName]: this.apiKey ?? '', }, }); diff --git a/balancer-js/src/types.ts b/balancer-js/src/types.ts index b6ec8ba16..ceebbc987 100644 --- a/balancer-js/src/types.ts +++ b/balancer-js/src/types.ts @@ -58,7 +58,7 @@ export interface BalancerTenderlyConfig { export interface BalancerSdkSorConfig { //use a built-in service or provide a custom implementation of a TokenPriceService //defaults to coingecko - tokenPriceService: 'coingecko' | 'subgraph' | TokenPriceService; + tokenPriceService: 'api' | 'coingecko' | 'subgraph' | TokenPriceService; //use a built-in service or provide a custom implementation of a PoolDataService //defaults to subgraph poolDataService: 'subgraph' | PoolDataService;