chore: sync submodule state (parent ref update)
Made-with: Cursor
This commit is contained in:
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user