chore: sync submodule state (parent ref update)
Made-with: Cursor
This commit is contained in:
54
services/token-aggregation/src/adapters/base-adapter.ts
Normal file
54
services/token-aggregation/src/adapters/base-adapter.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Base adapter interface for external API integrations
|
||||
*/
|
||||
export interface ExternalApiAdapter {
|
||||
/**
|
||||
* Check if the chain is supported by this API provider
|
||||
*/
|
||||
checkChainSupport(chainId: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get token metadata by contract address
|
||||
*/
|
||||
getTokenByContract(chainId: number, address: string): Promise<TokenMetadata | null>;
|
||||
|
||||
/**
|
||||
* Get market data for a token
|
||||
*/
|
||||
getMarketData(chainId: number, address: string): Promise<MarketData | null>;
|
||||
|
||||
/**
|
||||
* Get provider name
|
||||
*/
|
||||
getProviderName(): string;
|
||||
}
|
||||
|
||||
export interface TokenMetadata {
|
||||
id?: string; // Provider-specific ID (e.g., CoinGecko coin ID)
|
||||
name?: string;
|
||||
symbol?: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
socialLinks?: {
|
||||
twitter?: string;
|
||||
telegram?: string;
|
||||
discord?: string;
|
||||
github?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MarketData {
|
||||
priceUsd?: number;
|
||||
priceChange24h?: number;
|
||||
volume24h?: number;
|
||||
marketCapUsd?: number;
|
||||
liquidityUsd?: number;
|
||||
lastUpdated?: Date;
|
||||
}
|
||||
|
||||
export interface ApiCacheEntry {
|
||||
key: string;
|
||||
data: any;
|
||||
expiresAt: Date;
|
||||
}
|
||||
328
services/token-aggregation/src/adapters/cmc-adapter.ts
Normal file
328
services/token-aggregation/src/adapters/cmc-adapter.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { ExternalApiAdapter, TokenMetadata, MarketData } from './base-adapter';
|
||||
|
||||
interface CMCDexPair {
|
||||
pair_address: string;
|
||||
base: {
|
||||
address: string;
|
||||
symbol: string;
|
||||
};
|
||||
quote: {
|
||||
address: string;
|
||||
symbol: string;
|
||||
};
|
||||
dex_id: string;
|
||||
price: string;
|
||||
price_usd?: string;
|
||||
volume_24h?: {
|
||||
base: string;
|
||||
quote: string;
|
||||
usd?: string;
|
||||
};
|
||||
liquidity?: {
|
||||
usd?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CMCDexPairsResponse {
|
||||
data: CMCDexPair[];
|
||||
}
|
||||
|
||||
interface CMCPairQuote {
|
||||
pair_address: string;
|
||||
price: string;
|
||||
price_usd?: string;
|
||||
volume_24h?: {
|
||||
usd?: string;
|
||||
};
|
||||
liquidity?: {
|
||||
usd?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CMCPairQuotesResponse {
|
||||
data: Record<string, CMCPairQuote>;
|
||||
}
|
||||
|
||||
interface CMCOHLCV {
|
||||
time_open: string;
|
||||
time_close: string;
|
||||
quote: {
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CMCOHLCVResponse {
|
||||
data: {
|
||||
pairs: Array<{
|
||||
pair_address: string;
|
||||
timeframes: Record<string, CMCOHLCV[]>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Chain ID to CMC chain identifier mapping
|
||||
// Note: CMC uses different identifiers, these may need to be updated
|
||||
const CHAIN_TO_CMC_ID: Record<number, string> = {
|
||||
1: '1', // Ethereum
|
||||
56: '1839', // BSC
|
||||
137: '3890', // Polygon
|
||||
43114: '5805', // Avalanche
|
||||
42161: '42161', // Arbitrum
|
||||
10: '42170', // Optimism
|
||||
8453: '8453', // Base
|
||||
// 138 and 651940 likely not supported
|
||||
};
|
||||
|
||||
export class CoinMarketCapAdapter implements ExternalApiAdapter {
|
||||
private api: AxiosInstance;
|
||||
private apiKey?: string;
|
||||
private cache: Map<string, { data: any; expiresAt: Date }> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.COINMARKETCAP_API_KEY;
|
||||
if (!this.apiKey) {
|
||||
console.warn('CoinMarketCap API key not provided. CMC adapter will not function.');
|
||||
}
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL: 'https://pro-api.coinmarketcap.com',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'X-CMC_PRO_API_KEY': this.apiKey || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
return 'coinmarketcap';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chain is supported by CoinMarketCap DEX API
|
||||
*/
|
||||
async checkChainSupport(chainId: number): Promise<boolean> {
|
||||
// CMC DEX API support is limited and requires API key
|
||||
if (!this.apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cmcChainId = CHAIN_TO_CMC_ID[chainId];
|
||||
if (!cmcChainId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to fetch DEX pairs to verify support
|
||||
try {
|
||||
const response = await this.api.get('/v4/dex/spot-pairs/latest', {
|
||||
params: {
|
||||
chain_id: cmcChainId,
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
return response.status === 200;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 400 || error.response?.status === 404) {
|
||||
return false; // Chain not supported
|
||||
}
|
||||
console.error(`Error checking CMC chain support for ${chainId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token by contract address (CMC doesn't have direct contract lookup in free tier)
|
||||
*/
|
||||
async getTokenByContract(chainId: number, address: string): Promise<TokenMetadata | null> {
|
||||
// CMC DEX API doesn't provide token metadata directly
|
||||
// Would need CMC Pro API with different endpoints
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market data via DEX pairs
|
||||
*/
|
||||
async getMarketData(chainId: number, address: string): Promise<MarketData | null> {
|
||||
if (!this.apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cmcChainId = CHAIN_TO_CMC_ID[chainId];
|
||||
if (!cmcChainId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `cmc_market_${chainId}_${address.toLowerCase()}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get DEX pairs for this token
|
||||
const response = await this.api.get<CMCDexPairsResponse>('/v4/dex/spot-pairs/latest', {
|
||||
params: {
|
||||
chain_id: cmcChainId,
|
||||
base_address: address.toLowerCase(),
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data.data || response.data.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aggregate data from all pairs
|
||||
let totalVolume24h = 0;
|
||||
let totalLiquidity = 0;
|
||||
let avgPrice = 0;
|
||||
let priceCount = 0;
|
||||
|
||||
response.data.data.forEach((pair) => {
|
||||
if (pair.price_usd) {
|
||||
avgPrice += parseFloat(pair.price_usd);
|
||||
priceCount++;
|
||||
}
|
||||
if (pair.volume_24h?.usd) {
|
||||
totalVolume24h += parseFloat(pair.volume_24h.usd);
|
||||
}
|
||||
if (pair.liquidity?.usd) {
|
||||
totalLiquidity += parseFloat(pair.liquidity.usd);
|
||||
}
|
||||
});
|
||||
|
||||
const marketData: MarketData = {
|
||||
priceUsd: priceCount > 0 ? avgPrice / priceCount : undefined,
|
||||
volume24h: totalVolume24h > 0 ? totalVolume24h : undefined,
|
||||
liquidityUsd: totalLiquidity > 0 ? totalLiquidity : undefined,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
// Cache for 5 minutes
|
||||
this.cache.set(cacheKey, {
|
||||
data: marketData,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return marketData;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404 || error.response?.status === 400) {
|
||||
return null;
|
||||
}
|
||||
console.error(`Error fetching CMC market data for ${address} on chain ${chainId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DEX pairs for a token
|
||||
*/
|
||||
async getDexPairs(chainId: number, tokenAddress: string): Promise<CMCDexPair[]> {
|
||||
if (!this.apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cmcChainId = CHAIN_TO_CMC_ID[chainId];
|
||||
if (!cmcChainId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CMCDexPairsResponse>('/v4/dex/spot-pairs/latest', {
|
||||
params: {
|
||||
chain_id: cmcChainId,
|
||||
base_address: tokenAddress.toLowerCase(),
|
||||
limit: 100,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching CMC DEX pairs for ${tokenAddress} on chain ${chainId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pair quotes
|
||||
*/
|
||||
async getPairQuotes(chainId: number, pairAddresses: string[]): Promise<CMCPairQuote[]> {
|
||||
if (!this.apiKey || pairAddresses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cmcChainId = CHAIN_TO_CMC_ID[chainId];
|
||||
if (!cmcChainId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CMCPairQuotesResponse>('/v4/dex/pairs/quotes/latest', {
|
||||
params: {
|
||||
chain_id: cmcChainId,
|
||||
pair_addresses: pairAddresses.join(','),
|
||||
},
|
||||
});
|
||||
|
||||
return Object.values(response.data.data || {});
|
||||
} catch (error) {
|
||||
console.error(`Error fetching CMC pair quotes for chain ${chainId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OHLCV data for pairs
|
||||
*/
|
||||
async getOHLCV(
|
||||
chainId: number,
|
||||
pairAddress: string,
|
||||
interval: '5m' | '15m' | '1h' | '4h' | '24h',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<CMCOHLCV[]> {
|
||||
if (!this.apiKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cmcChainId = CHAIN_TO_CMC_ID[chainId];
|
||||
if (!cmcChainId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const intervalMap: Record<string, string> = {
|
||||
'5m': '5m',
|
||||
'15m': '15m',
|
||||
'1h': '1h',
|
||||
'4h': '4h',
|
||||
'24h': '1d',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CMCOHLCVResponse>('/v4/dex/pairs/ohlcv/historical', {
|
||||
params: {
|
||||
chain_id: cmcChainId,
|
||||
pair_address: pairAddress.toLowerCase(),
|
||||
interval: intervalMap[interval] || '1h',
|
||||
time_start: Math.floor(from.getTime() / 1000),
|
||||
time_end: Math.floor(to.getTime() / 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const pair = response.data.data.pairs?.[0];
|
||||
if (!pair) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return pair.timeframes[intervalMap[interval] || '1h'] || [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching CMC OHLCV for ${pairAddress} on chain ${chainId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
323
services/token-aggregation/src/adapters/coingecko-adapter.ts
Normal file
323
services/token-aggregation/src/adapters/coingecko-adapter.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { ExternalApiAdapter, TokenMetadata, MarketData } from './base-adapter';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
|
||||
interface CoinGeckoPlatform {
|
||||
id: string;
|
||||
chain_identifier: number;
|
||||
name: string;
|
||||
shortname: string;
|
||||
}
|
||||
|
||||
interface CoinGeckoCoin {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
description?: {
|
||||
en?: string;
|
||||
};
|
||||
image?: {
|
||||
large?: string;
|
||||
small?: string;
|
||||
thumb?: string;
|
||||
};
|
||||
links?: {
|
||||
homepage?: string[];
|
||||
twitter_screen_name?: string;
|
||||
telegram_channel_identifier?: string;
|
||||
subreddit_url?: string;
|
||||
repos_url?: {
|
||||
github?: string[];
|
||||
};
|
||||
};
|
||||
market_data?: {
|
||||
current_price?: {
|
||||
usd?: number;
|
||||
};
|
||||
price_change_percentage_24h?: number;
|
||||
total_volume?: {
|
||||
usd?: number;
|
||||
};
|
||||
market_cap?: {
|
||||
usd?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface CoinGeckoMarket {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
image?: string;
|
||||
current_price?: number;
|
||||
price_change_percentage_24h?: number;
|
||||
total_volume?: number;
|
||||
market_cap?: number;
|
||||
}
|
||||
|
||||
interface CoinGeckoTrending {
|
||||
coins: Array<{
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
thumb?: string;
|
||||
score?: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// Chain ID to CoinGecko platform ID mapping
|
||||
const CHAIN_TO_PLATFORM: Record<number, string> = {
|
||||
1: 'ethereum',
|
||||
56: 'binance-smart-chain',
|
||||
137: 'polygon-pos',
|
||||
43114: 'avalanche',
|
||||
42161: 'arbitrum-one',
|
||||
10: 'optimistic-ethereum',
|
||||
8453: 'base',
|
||||
// Note: 138 and 651940 are likely not supported, will return null gracefully
|
||||
};
|
||||
|
||||
export class CoinGeckoAdapter implements ExternalApiAdapter {
|
||||
private api: AxiosInstance;
|
||||
private apiKey?: string;
|
||||
private supportedPlatforms: Map<number, string> = new Map();
|
||||
private cache: Map<string, { data: any; expiresAt: Date }> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.COINGECKO_API_KEY;
|
||||
const baseURL = this.apiKey
|
||||
? 'https://pro-api.coingecko.com/api/v3'
|
||||
: 'https://api.coingecko.com/api/v3';
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL,
|
||||
timeout: 10000,
|
||||
headers: this.apiKey
|
||||
? {
|
||||
'x-cg-pro-api-key': this.apiKey,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
return 'coingecko';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chain is supported by CoinGecko
|
||||
*/
|
||||
async checkChainSupport(chainId: number): Promise<boolean> {
|
||||
// Check cache first
|
||||
const cacheKey = `chain_support_${chainId}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load supported platforms if not already loaded
|
||||
if (this.supportedPlatforms.size === 0) {
|
||||
await this.loadSupportedPlatforms();
|
||||
}
|
||||
|
||||
const supported = this.supportedPlatforms.has(chainId);
|
||||
this.cache.set(cacheKey, {
|
||||
data: supported,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hour cache
|
||||
});
|
||||
return supported;
|
||||
} catch (error) {
|
||||
console.error(`Error checking CoinGecko chain support for ${chainId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load supported platforms from CoinGecko
|
||||
*/
|
||||
private async loadSupportedPlatforms(): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.get<CoinGeckoPlatform[]>('/asset_platforms');
|
||||
response.data.forEach((platform) => {
|
||||
if (platform.chain_identifier) {
|
||||
this.supportedPlatforms.set(platform.chain_identifier, platform.id);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading CoinGecko platforms:', error);
|
||||
// Fallback to known mappings
|
||||
Object.entries(CHAIN_TO_PLATFORM).forEach(([chainId, platformId]) => {
|
||||
this.supportedPlatforms.set(parseInt(chainId, 10), platformId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token by contract address
|
||||
*/
|
||||
async getTokenByContract(chainId: number, address: string): Promise<TokenMetadata | null> {
|
||||
const platformId = this.supportedPlatforms.get(chainId);
|
||||
if (!platformId) {
|
||||
return null; // Chain not supported
|
||||
}
|
||||
|
||||
const cacheKey = `token_${chainId}_${address.toLowerCase()}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CoinGeckoCoin>(
|
||||
`/coins/${platformId}/contract/${address.toLowerCase()}`
|
||||
);
|
||||
|
||||
const metadata: TokenMetadata = {
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
symbol: response.data.symbol,
|
||||
description: response.data.description?.en,
|
||||
logoUrl: response.data.image?.large || response.data.image?.small,
|
||||
websiteUrl: response.data.links?.homepage?.[0],
|
||||
socialLinks: {
|
||||
twitter: response.data.links?.twitter_screen_name
|
||||
? `https://twitter.com/${response.data.links.twitter_screen_name}`
|
||||
: undefined,
|
||||
telegram: response.data.links?.telegram_channel_identifier
|
||||
? `https://t.me/${response.data.links.telegram_channel_identifier}`
|
||||
: undefined,
|
||||
github: response.data.links?.repos_url?.github?.[0],
|
||||
},
|
||||
};
|
||||
|
||||
// Cache for 1 hour
|
||||
this.cache.set(cacheKey, {
|
||||
data: metadata,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
return metadata;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return null; // Token not found
|
||||
}
|
||||
console.error(`Error fetching CoinGecko token ${address} on chain ${chainId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market data for a token
|
||||
*/
|
||||
async getMarketData(chainId: number, address: string): Promise<MarketData | null> {
|
||||
const platformId = this.supportedPlatforms.get(chainId);
|
||||
if (!platformId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `market_${chainId}_${address.toLowerCase()}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CoinGeckoCoin>(
|
||||
`/coins/${platformId}/contract/${address.toLowerCase()}`
|
||||
);
|
||||
|
||||
const marketData: MarketData = {
|
||||
priceUsd: response.data.market_data?.current_price?.usd,
|
||||
priceChange24h: response.data.market_data?.price_change_percentage_24h,
|
||||
volume24h: response.data.market_data?.total_volume?.usd,
|
||||
marketCapUsd: response.data.market_data?.market_cap?.usd,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
// Cache for 5 minutes
|
||||
this.cache.set(cacheKey, {
|
||||
data: marketData,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return marketData;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error(`Error fetching CoinGecko market data for ${address} on chain ${chainId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending tokens
|
||||
*/
|
||||
async getTrending(): Promise<Array<{ id: string; name: string; symbol: string; score: number }>> {
|
||||
const cacheKey = 'trending';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CoinGeckoTrending>('/search/trending');
|
||||
const trending = response.data.coins.map((coin) => ({
|
||||
id: coin.item.id,
|
||||
name: coin.item.name,
|
||||
symbol: coin.item.symbol,
|
||||
score: coin.item.score || 0,
|
||||
}));
|
||||
|
||||
// Cache for 10 minutes
|
||||
this.cache.set(cacheKey, {
|
||||
data: trending,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
});
|
||||
|
||||
return trending;
|
||||
} catch (error) {
|
||||
console.error('Error fetching CoinGecko trending:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market data for multiple tokens
|
||||
*/
|
||||
async getMarkets(coinIds: string[]): Promise<CoinGeckoMarket[]> {
|
||||
if (coinIds.length === 0) return [];
|
||||
|
||||
const cacheKey = `markets_${coinIds.join(',')}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<CoinGeckoMarket[]>('/coins/markets', {
|
||||
params: {
|
||||
vs_currency: 'usd',
|
||||
ids: coinIds.join(','),
|
||||
order: 'market_cap_desc',
|
||||
per_page: coinIds.length,
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Cache for 5 minutes
|
||||
this.cache.set(cacheKey, {
|
||||
data: response.data,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching CoinGecko markets:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
329
services/token-aggregation/src/adapters/dexscreener-adapter.ts
Normal file
329
services/token-aggregation/src/adapters/dexscreener-adapter.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { ExternalApiAdapter, TokenMetadata, MarketData } from './base-adapter';
|
||||
|
||||
interface DexScreenerPair {
|
||||
chainId: string;
|
||||
dexId: string;
|
||||
url: string;
|
||||
pairAddress: string;
|
||||
baseToken: {
|
||||
address: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
quoteToken: {
|
||||
address: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
};
|
||||
priceNative?: string;
|
||||
priceUsd?: string;
|
||||
txns?: {
|
||||
m5?: {
|
||||
buys?: number;
|
||||
sells?: number;
|
||||
};
|
||||
h1?: {
|
||||
buys?: number;
|
||||
sells?: number;
|
||||
};
|
||||
h6?: {
|
||||
buys?: number;
|
||||
sells?: number;
|
||||
};
|
||||
h24?: {
|
||||
buys?: number;
|
||||
sells?: number;
|
||||
};
|
||||
};
|
||||
volume?: {
|
||||
h24?: number;
|
||||
h6?: number;
|
||||
h1?: number;
|
||||
m5?: number;
|
||||
};
|
||||
priceChange?: {
|
||||
m5?: number;
|
||||
h1?: number;
|
||||
h6?: number;
|
||||
h24?: number;
|
||||
};
|
||||
liquidity?: {
|
||||
usd?: number;
|
||||
base?: number;
|
||||
quote?: number;
|
||||
};
|
||||
fdv?: number;
|
||||
pairCreatedAt?: number;
|
||||
}
|
||||
|
||||
interface DexScreenerResponse {
|
||||
schemaVersion: string;
|
||||
pairs: DexScreenerPair[] | null;
|
||||
pair?: DexScreenerPair;
|
||||
}
|
||||
|
||||
// Chain ID to DexScreener chain identifier mapping
|
||||
// DexScreener uses chain identifiers like 'ethereum', 'bsc', etc.
|
||||
const CHAIN_TO_DEXSCREENER_ID: Record<number, string> = {
|
||||
1: 'ethereum',
|
||||
56: 'bsc',
|
||||
137: 'polygon',
|
||||
43114: 'avalanche',
|
||||
42161: 'arbitrum',
|
||||
10: 'optimism',
|
||||
8453: 'base',
|
||||
// Note: 138 and 651940 are likely not supported
|
||||
};
|
||||
|
||||
// Reverse mapping for lookup
|
||||
const DEXSCREENER_ID_TO_CHAIN: Record<string, number> = {};
|
||||
Object.entries(CHAIN_TO_DEXSCREENER_ID).forEach(([chainId, dexId]) => {
|
||||
DEXSCREENER_ID_TO_CHAIN[dexId] = parseInt(chainId, 10);
|
||||
});
|
||||
|
||||
export class DexScreenerAdapter implements ExternalApiAdapter {
|
||||
private api: AxiosInstance;
|
||||
private apiKey?: string;
|
||||
private cache: Map<string, { data: any; expiresAt: Date }> = new Map();
|
||||
private supportedChains: Set<number> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.DEXSCREENER_API_KEY;
|
||||
this.api = axios.create({
|
||||
baseURL: 'https://api.dexscreener.com',
|
||||
timeout: 10000,
|
||||
headers: this.apiKey
|
||||
? {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
return 'dexscreener';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chain is supported by DexScreener
|
||||
*/
|
||||
async checkChainSupport(chainId: number): Promise<boolean> {
|
||||
const dexId = CHAIN_TO_DEXSCREENER_ID[chainId];
|
||||
if (!dexId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `chain_support_${chainId}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Try a test request to verify support
|
||||
try {
|
||||
// Use a known token address for testing (e.g., WETH on Ethereum)
|
||||
const testAddress = chainId === 1 ? '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' : '';
|
||||
if (!testAddress) {
|
||||
// For unknown chains, assume not supported unless proven otherwise
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await this.api.get<DexScreenerResponse>(
|
||||
`/token-pairs/v1/${dexId}/${testAddress}`
|
||||
);
|
||||
|
||||
const supported = response.status === 200 && (response.data.pairs?.length ?? 0) > 0;
|
||||
this.cache.set(cacheKey, {
|
||||
data: supported,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hour cache
|
||||
});
|
||||
|
||||
if (supported) {
|
||||
this.supportedChains.add(chainId);
|
||||
}
|
||||
|
||||
return supported;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404 || error.response?.status === 400) {
|
||||
return false;
|
||||
}
|
||||
console.error(`Error checking DexScreener chain support for ${chainId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token by contract address (DexScreener doesn't provide token metadata)
|
||||
*/
|
||||
async getTokenByContract(chainId: number, address: string): Promise<TokenMetadata | null> {
|
||||
// DexScreener doesn't provide token metadata, only pair data
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get market data via token pairs
|
||||
*/
|
||||
async getMarketData(chainId: number, address: string): Promise<MarketData | null> {
|
||||
const dexId = CHAIN_TO_DEXSCREENER_ID[chainId];
|
||||
if (!dexId) {
|
||||
return null; // Chain not supported
|
||||
}
|
||||
|
||||
const cacheKey = `market_${chainId}_${address.toLowerCase()}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<DexScreenerResponse>(
|
||||
`/token-pairs/v1/${dexId}/${address.toLowerCase()}`
|
||||
);
|
||||
|
||||
if (!response.data.pairs || response.data.pairs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Aggregate data from all pairs
|
||||
let totalVolume24h = 0;
|
||||
let totalLiquidity = 0;
|
||||
let avgPrice = 0;
|
||||
let priceCount = 0;
|
||||
let totalTxns24h = 0;
|
||||
|
||||
response.data.pairs.forEach((pair) => {
|
||||
if (pair.priceUsd) {
|
||||
avgPrice += parseFloat(pair.priceUsd);
|
||||
priceCount++;
|
||||
}
|
||||
if (pair.volume?.h24) {
|
||||
totalVolume24h += pair.volume.h24;
|
||||
}
|
||||
if (pair.liquidity?.usd) {
|
||||
totalLiquidity += pair.liquidity.usd;
|
||||
}
|
||||
if (pair.txns?.h24) {
|
||||
totalTxns24h += (pair.txns.h24.buys || 0) + (pair.txns.h24.sells || 0);
|
||||
}
|
||||
});
|
||||
|
||||
const marketData: MarketData = {
|
||||
priceUsd: priceCount > 0 ? avgPrice / priceCount : undefined,
|
||||
volume24h: totalVolume24h > 0 ? totalVolume24h : undefined,
|
||||
liquidityUsd: totalLiquidity > 0 ? totalLiquidity : undefined,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
// Cache for 2 minutes (DexScreener updates frequently)
|
||||
this.cache.set(cacheKey, {
|
||||
data: marketData,
|
||||
expiresAt: new Date(Date.now() + 2 * 60 * 1000),
|
||||
});
|
||||
|
||||
return marketData;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return null; // Token not found
|
||||
}
|
||||
console.error(`Error fetching DexScreener market data for ${address} on chain ${chainId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pairs for a token
|
||||
*/
|
||||
async getTokenPairs(chainId: number, tokenAddress: string): Promise<DexScreenerPair[]> {
|
||||
const dexId = CHAIN_TO_DEXSCREENER_ID[chainId];
|
||||
if (!dexId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<DexScreenerResponse>(
|
||||
`/token-pairs/v1/${dexId}/${tokenAddress.toLowerCase()}`
|
||||
);
|
||||
|
||||
return response.data.pairs || [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching DexScreener pairs for ${tokenAddress} on chain ${chainId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pair data by address
|
||||
*/
|
||||
async getPairData(chainId: number, pairAddress: string): Promise<DexScreenerPair | null> {
|
||||
const dexId = CHAIN_TO_DEXSCREENER_ID[chainId];
|
||||
if (!dexId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.get<DexScreenerResponse>(
|
||||
`/latest/dex/pairs/${dexId}/${pairAddress.toLowerCase()}`
|
||||
);
|
||||
|
||||
return response.data.pair || null;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
console.error(`Error fetching DexScreener pair data for ${pairAddress} on chain ${chainId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pairs for multiple tokens (up to 30)
|
||||
*/
|
||||
async getMultipleTokenPairs(
|
||||
chainId: number,
|
||||
tokenAddresses: string[]
|
||||
): Promise<Record<string, DexScreenerPair[]>> {
|
||||
const dexId = CHAIN_TO_DEXSCREENER_ID[chainId];
|
||||
if (!dexId || tokenAddresses.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// DexScreener limits to 30 addresses per request
|
||||
const chunks = [];
|
||||
for (let i = 0; i < tokenAddresses.length; i += 30) {
|
||||
chunks.push(tokenAddresses.slice(i, i + 30));
|
||||
}
|
||||
|
||||
const results: Record<string, DexScreenerPair[]> = {};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const response = await this.api.get<DexScreenerResponse>(
|
||||
`/tokens/v1/${dexId}/${chunk.map((addr) => addr.toLowerCase()).join(',')}`
|
||||
);
|
||||
|
||||
if (response.data.pairs) {
|
||||
// Group pairs by token address
|
||||
response.data.pairs.forEach((pair) => {
|
||||
const baseAddr = pair.baseToken.address.toLowerCase();
|
||||
const quoteAddr = pair.quoteToken.address.toLowerCase();
|
||||
|
||||
if (chunk.includes(baseAddr)) {
|
||||
if (!results[baseAddr]) results[baseAddr] = [];
|
||||
results[baseAddr].push(pair);
|
||||
}
|
||||
if (chunk.includes(quoteAddr)) {
|
||||
if (!results[quoteAddr]) results[quoteAddr] = [];
|
||||
results[quoteAddr].push(pair);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching DexScreener pairs for chunk on chain ${chainId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user