Skip to content

Commit

Permalink
feat: add alchemy price source (#632)
Browse files Browse the repository at this point in the history
* feat: add alchemy price source

* refactor: reuse alchemy networks
  • Loading branch information
0xKoaj authored Dec 19, 2024
1 parent 4c885d4 commit 16f8821
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 31 deletions.
5 changes: 4 additions & 1 deletion src/sdk/builders/price-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }
Expand All @@ -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':
Expand Down
108 changes: 108 additions & 0 deletions src/services/prices/price-sources/alchemy-price-source.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ChainId, Record<TokenAddress, PriceResult>>> {
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<Record<ChainId, Record<TokenAddress, PriceResult>>> {
// 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<Record<ChainId, Record<TokenAddress, Record<Timestamp, PriceResult>>>> {
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<Record<ChainId, Record<TokenAddress, PriceResult[]>>> {
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<TokenAddress, PriceResult> = {};
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 }[] }[] };
31 changes: 1 addition & 30 deletions src/services/providers/provider-sources/alchemy-provider.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,6 @@
import { ChainId } from '@types';
import { Chains } from '@chains';
import { BaseHttpProvider } from './base/base-http-provider';

const ALCHEMY_NETWORKS: Record<ChainId, { key: string; onlyPaid?: true }> = {
[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[];
Expand Down
31 changes: 31 additions & 0 deletions src/shared/alchemy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Chains } from '@chains';
import { ChainId } from '@types';

export const ALCHEMY_NETWORKS: Record<ChainId, { key: string; onlyPaid?: true }> = {
[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' },
};
3 changes: 3 additions & 0 deletions test/integration/services/prices/price-sources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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');
Expand All @@ -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({
Expand Down

0 comments on commit 16f8821

Please sign in to comment.