From 16f8821e1245918087178b97496a2f7f6268f61c Mon Sep 17 00:00:00 2001 From: 0xKoaj <141654454+0xKoaj@users.noreply.github.com> Date: Thu, 19 Dec 2024 20:04:52 -0300 Subject: [PATCH] feat: add alchemy price source (#632) * feat: add alchemy price source * refactor: reuse alchemy networks --- src/sdk/builders/price-builder.ts | 5 +- .../price-sources/alchemy-price-source.ts | 108 ++++++++++++++++++ .../provider-sources/alchemy-provider.ts | 31 +---- src/shared/alchemy.ts | 31 +++++ .../services/prices/price-sources.spec.ts | 3 + 5 files changed, 147 insertions(+), 31 deletions(-) create mode 100644 src/services/prices/price-sources/alchemy-price-source.ts create mode 100644 src/shared/alchemy.ts diff --git a/src/sdk/builders/price-builder.ts b/src/sdk/builders/price-builder.ts index 191f98ae..5920cdca 100644 --- a/src/sdk/builders/price-builder.ts +++ b/src/sdk/builders/price-builder.ts @@ -11,11 +11,12 @@ import { FastestPriceSource } from '@services/prices/price-sources/fastest-price import { AggregatorPriceSource, PriceAggregationMethod } from '@services/prices/price-sources/aggregator-price-source'; import { BalmyPriceSource } from '@services/prices/price-sources/balmy-price-source'; import { CodexPriceSource } from '@services/prices/price-sources/codex-price-source'; - +import { AlchemyPriceSource } from '@services/prices/price-sources/alchemy-price-source'; export type PriceSourceInput = | { type: 'defi-llama' } | { type: 'codex'; apiKey: string } | { type: 'odos' } + | { type: 'alchemy'; apiKey: string } | { type: 'coingecko' } | { type: 'balmy'; apiKey: string } | { type: 'prioritized'; sources: PriceSourceInput[] } @@ -39,6 +40,8 @@ function buildSource(source: PriceSourceInput | undefined, { fetchService }: { f return new PrioritizedPriceSource([coingecko, defiLlama]); case 'codex': return new CodexPriceSource(fetchService, source.apiKey); + case 'alchemy': + return new AlchemyPriceSource(fetchService, source.apiKey); case 'defi-llama': return defiLlama; case 'odos': diff --git a/src/services/prices/price-sources/alchemy-price-source.ts b/src/services/prices/price-sources/alchemy-price-source.ts new file mode 100644 index 00000000..29c41c03 --- /dev/null +++ b/src/services/prices/price-sources/alchemy-price-source.ts @@ -0,0 +1,108 @@ +import { ChainId, TimeString, Timestamp, TokenAddress } from '@types'; +import { IFetchService } from '@services/fetch/types'; +import { PriceResult, IPriceSource, PricesQueriesSupport, TokenPrice, PriceInput } from '../types'; +import { Chains, getChainByKeyOrFail } from '@chains'; +import { reduceTimeout, timeoutPromise } from '@shared/timeouts'; +import { filterRejectedResults, groupByChain, isSameAddress, splitInChunks } from '@shared/utils'; +import { Addresses } from '@shared/constants'; +import { ALCHEMY_NETWORKS } from '@shared/alchemy'; +export class AlchemyPriceSource implements IPriceSource { + constructor(private readonly fetch: IFetchService, private readonly apiKey: string) { + if (!this.apiKey) throw new Error('API key is required'); + } + + supportedQueries() { + const support: PricesQueriesSupport = { + getCurrentPrices: true, + getHistoricalPrices: false, + getBulkHistoricalPrices: false, + getChart: false, + }; + const entries = Object.entries(ALCHEMY_NETWORKS).map(([chainId]) => [chainId, support]); + return Object.fromEntries(entries); + } + + async getCurrentPrices({ + tokens, + config, + }: { + tokens: PriceInput[]; + config: { timeout?: TimeString } | undefined; + }): Promise>> { + const groupedByChain = groupByChain(tokens, ({ token }) => token); + const reducedTimeout = reduceTimeout(config?.timeout, '100'); + const promises = Object.entries(groupedByChain).map(async ([chainId, tokens]) => [ + Number(chainId), + await timeoutPromise(this.getCurrentPricesInChain(Number(chainId), tokens, reducedTimeout), reducedTimeout), + ]); + return Object.fromEntries(await filterRejectedResults(promises)); + } + + getHistoricalPrices(_: { + tokens: PriceInput[]; + timestamp: Timestamp; + searchWidth: TimeString | undefined; + config: { timeout?: TimeString } | undefined; + }): Promise>> { + // Only supports historical prices searching by token symbol + return Promise.reject(new Error('Operation not supported')); + } + + getBulkHistoricalPrices(_: { + tokens: { chainId: ChainId; token: TokenAddress; timestamp: Timestamp }[]; + searchWidth: TimeString | undefined; + config: { timeout?: TimeString } | undefined; + }): Promise>>> { + return Promise.reject(new Error('Operation not supported')); + } + + async getChart(_: { + tokens: PriceInput[]; + span: number; + period: TimeString; + bound: { from: Timestamp } | { upTo: Timestamp | 'now' }; + searchWidth?: TimeString; + config: { timeout?: TimeString } | undefined; + }): Promise>> { + return Promise.reject(new Error('Operation not supported')); + } + + private async getCurrentPricesInChain(chainId: ChainId, addresses: TokenAddress[], timeout?: TimeString) { + const url = `https://api.g.alchemy.com/prices/v1/${this.apiKey}/tokens/by-address`; + const result: Record = {}; + const chunks = splitInChunks(addresses, 25); + const promises = chunks.map(async (chunk) => { + const response = await this.fetch.fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + addresses: chunk.map((address) => ({ + network: ALCHEMY_NETWORKS[chainId].key, + // Alchemy doesn't support native tokens (only on Ethereum), so we use the wrapped native token + address: + isSameAddress(address, Addresses.NATIVE_TOKEN) && chainId !== Chains.ETHEREUM.chainId + ? getChainByKeyOrFail(chainId).wToken + : address, + })), + }), + timeout, + }); + + if (!response.ok) { + return; + } + const body: Response = await response.json(); + chunk.forEach((address, index) => { + const tokenPrice = body.data[index].prices[0]; + if (!tokenPrice) return; + const timestamp = Math.floor(new Date(tokenPrice.lastUpdatedAt).getTime() / 1000); + result[address] = { price: Number(tokenPrice.value), closestTimestamp: timestamp }; + }); + }); + + await Promise.all(promises); + return result; + } +} + +type Response = { data: { address: TokenAddress; prices: { currency: string; value: string; lastUpdatedAt: string }[] }[] }; diff --git a/src/services/providers/provider-sources/alchemy-provider.ts b/src/services/providers/provider-sources/alchemy-provider.ts index 4b68fbab..b4939ead 100644 --- a/src/services/providers/provider-sources/alchemy-provider.ts +++ b/src/services/providers/provider-sources/alchemy-provider.ts @@ -1,35 +1,6 @@ import { ChainId } from '@types'; -import { Chains } from '@chains'; import { BaseHttpProvider } from './base/base-http-provider'; - -const ALCHEMY_NETWORKS: Record = { - [Chains.ETHEREUM.chainId]: { key: 'eth-mainnet' }, - [Chains.ETHEREUM_SEPOLIA.chainId]: { key: 'eth-sepolia' }, - [Chains.OPTIMISM.chainId]: { key: 'opt-mainnet' }, - // [Chains.OPTIMISM_SEPOLIA.chainId]: { key: 'opt-sepolia' }, - [Chains.ARBITRUM.chainId]: { key: 'arb-mainnet' }, - // [Chains.ARBITRUM_SEPOLIA.chainId]: { key: 'arb-sepolia' }, - [Chains.POLYGON.chainId]: { key: 'polygon-mainnet' }, - [Chains.POLYGON_MUMBAI.chainId]: { key: 'polygon-mumbai' }, - [Chains.ASTAR.chainId]: { key: 'astar-mainnet' }, - [Chains.BLAST.chainId]: { key: 'blast-mainnet' }, - [Chains.BNB_CHAIN.chainId]: { key: 'bnb-mainnet', onlyPaid: true }, - [Chains.AVALANCHE.chainId]: { key: 'avax-mainnet', onlyPaid: true }, - [Chains.FANTOM.chainId]: { key: 'fantom-mainnet' }, - [Chains.METIS_ANDROMEDA.chainId]: { key: 'metis-mainnet', onlyPaid: true }, - [Chains.POLYGON_ZKEVM.chainId]: { key: 'polygonzkevm-mainnet' }, - // [Chains.POLYGON_ZKEVM_TESTNET.chainId]: { key: 'polygonzkevm-testnet' }, - [Chains.BASE.chainId]: { key: 'base-mainnet' }, - [Chains.GNOSIS.chainId]: { key: 'gnosis-mainnet', onlyPaid: true }, - [Chains.SCROLL.chainId]: { key: 'scroll-mainnet' }, - [Chains.opBNB.chainId]: { key: 'opbnb-mainnet', onlyPaid: true }, - // [Chains.BASE_SEPOLIA.chainId]: { key: 'base-sepolia' }, - // [Chains.ZKSYNC.chainId]: { key: 'zksync-mainnet' }, - // [Chains.ZKSYNC_SEPOLIA.chainId]: { key: 'zksync-sepolia' }, - [Chains.MANTLE.chainId]: { key: 'mantle-mainnet' }, - [Chains.ROOTSTOCK.chainId]: { key: 'rootstock-mainnet' }, - [Chains.LINEA.chainId]: { key: 'linea-mainnet' }, -}; +import { ALCHEMY_NETWORKS } from '@shared/alchemy'; export class AlchemyProviderSource extends BaseHttpProvider { private readonly supported: ChainId[]; diff --git a/src/shared/alchemy.ts b/src/shared/alchemy.ts new file mode 100644 index 00000000..a7776d3f --- /dev/null +++ b/src/shared/alchemy.ts @@ -0,0 +1,31 @@ +import { Chains } from '@chains'; +import { ChainId } from '@types'; + +export const ALCHEMY_NETWORKS: Record = { + [Chains.ETHEREUM.chainId]: { key: 'eth-mainnet' }, + [Chains.ETHEREUM_SEPOLIA.chainId]: { key: 'eth-sepolia' }, + [Chains.OPTIMISM.chainId]: { key: 'opt-mainnet' }, + // [Chains.OPTIMISM_SEPOLIA.chainId]: { key: 'opt-sepolia' }, + [Chains.ARBITRUM.chainId]: { key: 'arb-mainnet' }, + // [Chains.ARBITRUM_SEPOLIA.chainId]: { key: 'arb-sepolia' }, + [Chains.POLYGON.chainId]: { key: 'polygon-mainnet' }, + [Chains.POLYGON_MUMBAI.chainId]: { key: 'polygon-mumbai' }, + [Chains.ASTAR.chainId]: { key: 'astar-mainnet' }, + [Chains.BLAST.chainId]: { key: 'blast-mainnet' }, + [Chains.BNB_CHAIN.chainId]: { key: 'bnb-mainnet', onlyPaid: true }, + [Chains.AVALANCHE.chainId]: { key: 'avax-mainnet', onlyPaid: true }, + [Chains.FANTOM.chainId]: { key: 'fantom-mainnet' }, + [Chains.METIS_ANDROMEDA.chainId]: { key: 'metis-mainnet', onlyPaid: true }, + [Chains.POLYGON_ZKEVM.chainId]: { key: 'polygonzkevm-mainnet' }, + // [Chains.POLYGON_ZKEVM_TESTNET.chainId]: { key: 'polygonzkevm-testnet' }, + [Chains.BASE.chainId]: { key: 'base-mainnet' }, + [Chains.GNOSIS.chainId]: { key: 'gnosis-mainnet', onlyPaid: true }, + [Chains.SCROLL.chainId]: { key: 'scroll-mainnet' }, + [Chains.opBNB.chainId]: { key: 'opbnb-mainnet', onlyPaid: true }, + // [Chains.BASE_SEPOLIA.chainId]: { key: 'base-sepolia' }, + // [Chains.ZKSYNC.chainId]: { key: 'zksync-mainnet' }, + // [Chains.ZKSYNC_SEPOLIA.chainId]: { key: 'zksync-sepolia' }, + [Chains.MANTLE.chainId]: { key: 'mantle-mainnet' }, + [Chains.ROOTSTOCK.chainId]: { key: 'rootstock-mainnet' }, + [Chains.LINEA.chainId]: { key: 'linea-mainnet' }, +}; diff --git a/test/integration/services/prices/price-sources.spec.ts b/test/integration/services/prices/price-sources.spec.ts index 95191a4b..f8ecbf9b 100644 --- a/test/integration/services/prices/price-sources.spec.ts +++ b/test/integration/services/prices/price-sources.spec.ts @@ -15,6 +15,7 @@ import { PrioritizedPriceSource } from '@services/prices/price-sources/prioritiz import { FastestPriceSource } from '@services/prices/price-sources/fastest-price-source'; import { AggregatorPriceSource } from '@services/prices/price-sources/aggregator-price-source'; import { CodexPriceSource } from '@services/prices/price-sources/codex-price-source'; +import { AlchemyPriceSource } from '@services/prices/price-sources/alchemy-price-source'; chai.use(chaiAsPromised); dotenv.config(); @@ -37,6 +38,7 @@ const CACHED_PRICE_SOURCE = new CachedPriceSource(DEFI_LLAMA_PRICE_SOURCE, { maxSize: 100, }); const CODEX_PRICE_SOURCE = new CodexPriceSource(FETCH_SERVICE, process.env.CODEX_API_KEY!); +const ALCHEMY_PRICE_SOURCE = new AlchemyPriceSource(FETCH_SERVICE, process.env.ALCHEMY_API_KEY!); const PRIORITIZED_PRICE_SOURCE = new PrioritizedPriceSource([ODOS_PRICE_SOURCE, DEFI_LLAMA_PRICE_SOURCE]); const FASTEST_PRICE_SOURCE = new FastestPriceSource([ODOS_PRICE_SOURCE, DEFI_LLAMA_PRICE_SOURCE]); const AGGREGATOR_PRICE_SOURCE = new AggregatorPriceSource([ODOS_PRICE_SOURCE, DEFI_LLAMA_PRICE_SOURCE], 'median'); @@ -55,6 +57,7 @@ describe('Token Price Sources', () => { // priceSourceTest({ title: 'Balmy', source: BALMY_PRICE_SOURCE }); Needs API key // priceSourceTest({ title: 'Coingecko Source', source: COINGECKO_TOKEN_SOURCE }); Commented out because of rate limiting issues priceSourceTest({ title: 'Codex Source', source: CODEX_PRICE_SOURCE }); + priceSourceTest({ title: 'Alchemy Source', source: ALCHEMY_PRICE_SOURCE }); function priceSourceTest({ title, source }: { title: string; source: IPriceSource }) { describe(title, () => { queryTest({