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;
|
||||
}
|
||||
}
|
||||
56
services/token-aggregation/src/api/central-audit.ts
Normal file
56
services/token-aggregation/src/api/central-audit.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Central audit client for token-aggregation admin actions
|
||||
* Sends audit entries to dbis_core Admin Central API when DBIS_CENTRAL_URL and ADMIN_CENTRAL_API_KEY are set.
|
||||
*/
|
||||
|
||||
const DBIS_CENTRAL_URL = process.env.DBIS_CENTRAL_URL?.replace(/\/$/, '');
|
||||
const ADMIN_CENTRAL_API_KEY = process.env.ADMIN_CENTRAL_API_KEY;
|
||||
const SERVICE_NAME = 'token_aggregation';
|
||||
|
||||
function isConfigured(): boolean {
|
||||
return Boolean(DBIS_CENTRAL_URL && ADMIN_CENTRAL_API_KEY);
|
||||
}
|
||||
|
||||
export interface CentralAuditPayload {
|
||||
employeeId: string;
|
||||
action: string;
|
||||
permission: string;
|
||||
resourceType: string;
|
||||
resourceId?: string | null;
|
||||
outcome?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function appendCentralAudit(payload: CentralAuditPayload): Promise<void> {
|
||||
if (!isConfigured()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${DBIS_CENTRAL_URL}/api/admin/central/audit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Central-Key': ADMIN_CENTRAL_API_KEY!,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employeeId: payload.employeeId,
|
||||
action: payload.action,
|
||||
permission: payload.permission ?? 'admin:action',
|
||||
resourceType: payload.resourceType,
|
||||
resourceId: payload.resourceId ?? undefined,
|
||||
project: 'smom-dbis-138',
|
||||
service: SERVICE_NAME,
|
||||
outcome: payload.outcome ?? 'success',
|
||||
metadata: payload.metadata,
|
||||
ipAddress: payload.ipAddress,
|
||||
userAgent: payload.userAgent,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[central-audit] POST failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[central-audit] append failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cacheMiddleware(_ttl?: number) {
|
||||
return (req: unknown, res: unknown, next: () => void) => next();
|
||||
}
|
||||
55
services/token-aggregation/src/api/middleware/auth.ts
Normal file
55
services/token-aggregation/src/api/middleware/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'token-aggregation-secret';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
role?: string;
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Access token required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { userId: number; username: string; role: string };
|
||||
req.userId = payload.userId;
|
||||
req.username = payload.username;
|
||||
req.role = payload.role;
|
||||
req.user = { id: payload.userId, username: payload.username, role: payload.role };
|
||||
next();
|
||||
} catch {
|
||||
res.status(403).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRole(...allowed: string[]) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||
if (!req.role || !allowed.includes(req.role)) {
|
||||
res.status(403).json({ error: 'Insufficient permissions' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function generateToken(userId: number, username: string, role: string): string {
|
||||
return jwt.sign(
|
||||
{ userId, username, role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
}
|
||||
46
services/token-aggregation/src/api/middleware/cache.ts
Normal file
46
services/token-aggregation/src/api/middleware/cache.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const DEFAULT_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
export function cacheMiddleware(ttl: number = DEFAULT_TTL) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const key = `${req.method}:${req.originalUrl}`;
|
||||
const cached = cache.get(key);
|
||||
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
// Store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override json method to cache response
|
||||
res.json = function (body: any) {
|
||||
cache.set(key, {
|
||||
data: body,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function clearCacheForPattern(pattern: string): void {
|
||||
for (const key of cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
services/token-aggregation/src/api/middleware/rate-limit.ts
Normal file
20
services/token-aggregation/src/api/middleware/rate-limit.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10);
|
||||
const maxRequests = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10);
|
||||
|
||||
export const apiRateLimiter = rateLimit({
|
||||
windowMs,
|
||||
max: maxRequests,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
export const strictRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 10,
|
||||
message: 'Too many requests, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
388
services/token-aggregation/src/api/routes/admin.ts
Normal file
388
services/token-aggregation/src/api/routes/admin.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AdminRepository } from '../../database/repositories/admin-repo';
|
||||
import { authenticateToken, requireRole, AuthRequest, generateToken } from '../middleware/auth';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { appendCentralAudit } from '../central-audit';
|
||||
|
||||
const router: Router = Router();
|
||||
const adminRepo = new AdminRepository();
|
||||
|
||||
// Authentication routes (public)
|
||||
router.post('/auth/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const user = await adminRepo.getAdminUserByUsername(username);
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const isValid = await adminRepo.verifyPassword(user, password);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await adminRepo.pool.query(
|
||||
`UPDATE admin_users SET last_login = NOW() WHERE id = $1`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user.id!, user.username, user.role);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// All admin routes require authentication
|
||||
router.use(authenticateToken);
|
||||
|
||||
// API Keys Management
|
||||
router.get('/api-keys', requireRole('admin', 'super_admin', 'operator'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = req.query.provider as string | undefined;
|
||||
const keys = await adminRepo.getApiKeys(provider);
|
||||
res.json({ apiKeys: keys });
|
||||
} catch (error) {
|
||||
console.error('Error fetching API keys:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api-keys', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { provider, keyName, apiKey, rateLimitPerMinute, rateLimitPerDay, expiresAt } = req.body;
|
||||
|
||||
if (!provider || !keyName || !apiKey) {
|
||||
return res.status(400).json({ error: 'Provider, keyName, and apiKey are required' });
|
||||
}
|
||||
|
||||
// Simple encryption (in production, use proper encryption)
|
||||
const encrypted = Buffer.from(apiKey).toString('base64');
|
||||
|
||||
const newKey = await adminRepo.createApiKey({
|
||||
provider,
|
||||
keyName,
|
||||
apiKeyEncrypted: encrypted,
|
||||
isActive: true,
|
||||
rateLimitPerMinute,
|
||||
rateLimitPerDay,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
|
||||
createdBy: req.user?.username,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await adminRepo.createAuditLog(
|
||||
req.user?.id || null,
|
||||
'create',
|
||||
'api_key',
|
||||
newKey.id || null,
|
||||
null,
|
||||
{ provider, keyName },
|
||||
req.ip,
|
||||
req.get('user-agent')
|
||||
);
|
||||
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'create', permission: 'admin:action', resourceType: 'api_key', resourceId: String(newKey.id ?? ''), metadata: { provider, keyName }, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
|
||||
|
||||
res.status(201).json({ apiKey: { ...newKey, apiKeyEncrypted: undefined } });
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const updates: any = {};
|
||||
|
||||
if (req.body.isActive !== undefined) updates.isActive = req.body.isActive;
|
||||
if (req.body.rateLimitPerMinute !== undefined) updates.rateLimitPerMinute = req.body.rateLimitPerMinute;
|
||||
if (req.body.expiresAt !== undefined) updates.expiresAt = new Date(req.body.expiresAt);
|
||||
|
||||
const oldKey = await adminRepo.getApiKey(id);
|
||||
await adminRepo.updateApiKey(id, updates);
|
||||
|
||||
// Audit log
|
||||
await adminRepo.createAuditLog(
|
||||
req.user?.id || null,
|
||||
'update',
|
||||
'api_key',
|
||||
id,
|
||||
oldKey,
|
||||
updates,
|
||||
req.ip,
|
||||
req.get('user-agent')
|
||||
);
|
||||
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'update', permission: 'admin:action', resourceType: 'api_key', resourceId: String(id), metadata: updates, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating API key:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/api-keys/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const oldKey = await adminRepo.getApiKey(id);
|
||||
|
||||
await adminRepo.deleteApiKey(id);
|
||||
|
||||
// Audit log
|
||||
await adminRepo.createAuditLog(
|
||||
req.user?.id || null,
|
||||
'delete',
|
||||
'api_key',
|
||||
id,
|
||||
oldKey,
|
||||
null,
|
||||
req.ip,
|
||||
req.get('user-agent')
|
||||
);
|
||||
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'delete', permission: 'admin:action', resourceType: 'api_key', resourceId: String(id), ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// API Endpoints Management
|
||||
router.get('/endpoints', requireRole('admin', 'super_admin', 'operator', 'viewer'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = req.query.chainId ? parseInt(req.query.chainId as string, 10) : undefined;
|
||||
const endpointType = req.query.endpointType as string | undefined;
|
||||
const endpoints = await adminRepo.getEndpoints(chainId, endpointType);
|
||||
res.json({ endpoints });
|
||||
} catch (error) {
|
||||
console.error('Error fetching endpoints:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/endpoints', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
chainId,
|
||||
endpointType,
|
||||
endpointName,
|
||||
endpointUrl,
|
||||
isPrimary,
|
||||
requiresAuth,
|
||||
authType,
|
||||
authConfig,
|
||||
rateLimitPerMinute,
|
||||
timeoutMs,
|
||||
} = req.body;
|
||||
|
||||
if (!chainId || !endpointType || !endpointName || !endpointUrl) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const endpoint = await adminRepo.createEndpoint({
|
||||
chainId,
|
||||
endpointType,
|
||||
endpointName,
|
||||
endpointUrl,
|
||||
isPrimary: isPrimary || false,
|
||||
isActive: true,
|
||||
requiresAuth: requiresAuth || false,
|
||||
authType,
|
||||
authConfig,
|
||||
rateLimitPerMinute,
|
||||
timeoutMs: timeoutMs || 10000,
|
||||
healthCheckEnabled: true,
|
||||
createdBy: req.user?.username,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await adminRepo.createAuditLog(
|
||||
req.user?.id || null,
|
||||
'create',
|
||||
'endpoint',
|
||||
endpoint.id || null,
|
||||
null,
|
||||
{ chainId, endpointType, endpointName, endpointUrl },
|
||||
req.ip,
|
||||
req.get('user-agent')
|
||||
);
|
||||
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'create', permission: 'admin:action', resourceType: 'endpoint', resourceId: String(endpoint.id ?? ''), metadata: { chainId, endpointType, endpointName, endpointUrl }, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
|
||||
|
||||
res.status(201).json({ endpoint });
|
||||
} catch (error) {
|
||||
console.error('Error creating endpoint:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/endpoints/:id', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const updates: any = {};
|
||||
|
||||
if (req.body.endpointUrl !== undefined) updates.endpointUrl = req.body.endpointUrl;
|
||||
if (req.body.isActive !== undefined) updates.isActive = req.body.isActive;
|
||||
if (req.body.isPrimary !== undefined) updates.isPrimary = req.body.isPrimary;
|
||||
|
||||
await adminRepo.updateEndpoint(id, updates);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating endpoint:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DEX Factory Management
|
||||
router.get('/dex-factories', requireRole('admin', 'super_admin', 'operator', 'viewer'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = req.query.chainId ? parseInt(req.query.chainId as string, 10) : undefined;
|
||||
const factories = await adminRepo.getDexFactories(chainId);
|
||||
res.json({ factories });
|
||||
} catch (error) {
|
||||
console.error('Error fetching DEX factories:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/dex-factories', requireRole('admin', 'super_admin'), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
chainId,
|
||||
dexType,
|
||||
factoryAddress,
|
||||
routerAddress,
|
||||
poolManagerAddress,
|
||||
startBlock,
|
||||
description,
|
||||
} = req.body;
|
||||
|
||||
if (!chainId || !dexType || !factoryAddress) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const factory = await adminRepo.createDexFactory({
|
||||
chainId,
|
||||
dexType,
|
||||
factoryAddress,
|
||||
routerAddress,
|
||||
poolManagerAddress,
|
||||
startBlock: startBlock || 0,
|
||||
isActive: true,
|
||||
description,
|
||||
createdBy: req.user?.username,
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await adminRepo.createAuditLog(
|
||||
req.user?.id || null,
|
||||
'create',
|
||||
'dex_factory',
|
||||
factory.id || null,
|
||||
null,
|
||||
{ chainId, dexType, factoryAddress },
|
||||
req.ip,
|
||||
req.get('user-agent')
|
||||
);
|
||||
appendCentralAudit({ employeeId: String(req.user?.id ?? req.user?.username ?? 'unknown'), action: 'create', permission: 'admin:action', resourceType: 'dex_factory', resourceId: String(factory.id ?? ''), metadata: { chainId, dexType, factoryAddress }, ipAddress: req.ip, userAgent: req.get('user-agent') ?? undefined }).catch(() => {});
|
||||
|
||||
res.status(201).json({ factory });
|
||||
} catch (error) {
|
||||
console.error('Error creating DEX factory:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Service Status
|
||||
router.get('/status', requireRole('admin', 'super_admin', 'operator', 'viewer'), cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const [apiKeys, endpoints, factories] = await Promise.all([
|
||||
adminRepo.getApiKeys(),
|
||||
adminRepo.getEndpoints(),
|
||||
adminRepo.getDexFactories(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
status: 'operational',
|
||||
stats: {
|
||||
apiKeys: {
|
||||
total: apiKeys.length,
|
||||
active: apiKeys.filter((k) => k.isActive).length,
|
||||
},
|
||||
endpoints: {
|
||||
total: endpoints.length,
|
||||
active: endpoints.filter((e) => e.isActive).length,
|
||||
},
|
||||
factories: {
|
||||
total: factories.length,
|
||||
active: factories.filter((f) => f.isActive).length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Audit Log
|
||||
router.get('/audit-log', requireRole('admin', 'super_admin'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string, 10) || 100;
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
|
||||
const result = await adminRepo.pool.query(
|
||||
`SELECT al.*, au.username
|
||||
FROM admin_audit_log al
|
||||
LEFT JOIN admin_users au ON al.user_id = au.id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
res.json({
|
||||
logs: result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
action: row.action,
|
||||
resourceType: row.resource_type,
|
||||
resourceId: row.resource_id,
|
||||
oldValues: row.old_values,
|
||||
newValues: row.new_values,
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent,
|
||||
createdAt: row.created_at,
|
||||
})),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
count: result.rows.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching audit log:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
64
services/token-aggregation/src/api/routes/config.ts
Normal file
64
services/token-aggregation/src/api/routes/config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getNetworks, getConfigByChain, API_VERSION } from '../../config/networks';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/v1/networks
|
||||
* Full EIP-3085 chain params for wallet_addEthereumChain (Chain 138, 1, 651940).
|
||||
* If NETWORKS_JSON_URL is set (e.g. GitHub raw URL), fetches and returns that JSON; otherwise uses built-in networks.
|
||||
*/
|
||||
router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim();
|
||||
if (networksJsonUrl) {
|
||||
try {
|
||||
const data = (await fetchRemoteJson(networksJsonUrl)) as { version?: string; networks?: unknown[] };
|
||||
return res.json({
|
||||
version: data.version ?? API_VERSION,
|
||||
networks: data.networks ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('NETWORKS_JSON_URL fetch failed, using built-in networks:', err);
|
||||
}
|
||||
}
|
||||
const networks = getNetworks();
|
||||
res.json({
|
||||
version: API_VERSION,
|
||||
networks,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/config
|
||||
* Oracles (and optional config) per chain. Query: chainId (optional).
|
||||
* If chainId provided, returns config for that chain only.
|
||||
*/
|
||||
router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const networks = getNetworks();
|
||||
if (chainIdParam) {
|
||||
const chainId = parseInt(chainIdParam, 10);
|
||||
const config = getConfigByChain(chainId);
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: 'Chain not found', chainId });
|
||||
}
|
||||
return res.json({ version: API_VERSION, chainId, ...config });
|
||||
}
|
||||
const chains = networks.map((n) => ({
|
||||
chainId: n.chainIdDecimal,
|
||||
oracles: n.oracles,
|
||||
}));
|
||||
res.json({ version: API_VERSION, chains });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
110
services/token-aggregation/src/api/routes/quote.ts
Normal file
110
services/token-aggregation/src/api/routes/quote.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
|
||||
const router: Router = Router();
|
||||
const poolRepo = new PoolRepository();
|
||||
|
||||
/**
|
||||
* Uniswap V2-style constant-product quote: amountOut = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997)
|
||||
*/
|
||||
function quoteAmountOut(
|
||||
amountIn: bigint,
|
||||
reserveIn: bigint,
|
||||
reserveOut: bigint
|
||||
): bigint {
|
||||
if (reserveIn === BigInt(0)) {
|
||||
return BigInt(0);
|
||||
}
|
||||
const amountInWithFee = amountIn * BigInt(997);
|
||||
const numerator = reserveOut * amountInWithFee;
|
||||
const denominator = reserveIn * BigInt(1000) + amountInWithFee;
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/quote
|
||||
* Returns an estimated amountOut for a token swap (constant-product from first available pool).
|
||||
* Query: chainId, tokenIn, tokenOut, amountIn (raw amount in token's smallest unit).
|
||||
*/
|
||||
router.get(
|
||||
'/quote',
|
||||
cacheMiddleware(60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const tokenIn = (req.query.tokenIn as string)?.toLowerCase();
|
||||
const tokenOut = (req.query.tokenOut as string)?.toLowerCase();
|
||||
const amountInRaw = req.query.amountIn as string;
|
||||
|
||||
if (!chainId || !tokenIn || !tokenOut || amountInRaw === undefined) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required query: chainId, tokenIn, tokenOut, amountIn',
|
||||
amountOut: null,
|
||||
});
|
||||
}
|
||||
|
||||
let amountIn: bigint;
|
||||
try {
|
||||
amountIn = BigInt(amountInRaw);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid amountIn (must be integer string)',
|
||||
amountOut: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenIn === tokenOut) {
|
||||
return res.json({ amountOut: amountInRaw, poolAddress: null });
|
||||
}
|
||||
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, tokenIn);
|
||||
const pairPools = pools.filter(
|
||||
(p) =>
|
||||
p.token0Address.toLowerCase() === tokenOut ||
|
||||
p.token1Address.toLowerCase() === tokenOut
|
||||
);
|
||||
|
||||
if (pairPools.length === 0) {
|
||||
return res.json({
|
||||
amountOut: null,
|
||||
error: 'No pool found for this token pair',
|
||||
poolAddress: null,
|
||||
});
|
||||
}
|
||||
|
||||
let bestAmountOut = BigInt(0);
|
||||
let bestPool = pairPools[0];
|
||||
|
||||
for (const pool of pairPools) {
|
||||
const reserveIn =
|
||||
pool.token0Address.toLowerCase() === tokenIn
|
||||
? BigInt(pool.reserve0)
|
||||
: BigInt(pool.reserve1);
|
||||
const reserveOut =
|
||||
pool.token0Address.toLowerCase() === tokenOut
|
||||
? BigInt(pool.reserve0)
|
||||
: BigInt(pool.reserve1);
|
||||
const out = quoteAmountOut(amountIn, reserveIn, reserveOut);
|
||||
if (out > bestAmountOut) {
|
||||
bestAmountOut = out;
|
||||
bestPool = pool;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
amountOut: bestAmountOut.toString(),
|
||||
poolAddress: bestPool.poolAddress,
|
||||
dexType: bestPool.dexType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Quote error:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
amountOut: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
88
services/token-aggregation/src/api/routes/report.test.ts
Normal file
88
services/token-aggregation/src/api/routes/report.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Integration tests for report API (CMC, CoinGecko)
|
||||
* Uses native fetch + http server (no deprecated supertest)
|
||||
*/
|
||||
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import reportRoutes from './report';
|
||||
|
||||
jest.mock('../../database/repositories/token-repo', () => ({
|
||||
TokenRepository: jest.fn().mockImplementation(() => ({
|
||||
getToken: jest.fn().mockResolvedValue(null),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../database/repositories/market-data-repo', () => ({
|
||||
MarketDataRepository: jest.fn().mockImplementation(() => ({
|
||||
getMarketData: jest.fn().mockResolvedValue(null),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../database/repositories/pool-repo', () => ({
|
||||
PoolRepository: jest.fn().mockImplementation(() => ({
|
||||
getPoolsByToken: jest.fn().mockResolvedValue([]),
|
||||
getPoolsByChain: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1/report', reportRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Report API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = createApp();
|
||||
const started = await startServer(app);
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/cmc', () => {
|
||||
it('returns 200 with cmc format', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/cmc?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).toHaveProperty('generatedAt');
|
||||
expect(body).toHaveProperty('chainId', 138);
|
||||
expect(body).toHaveProperty('format', 'coinmarketcap-dex');
|
||||
expect(body).toHaveProperty('tokens');
|
||||
expect(Array.isArray(body.tokens)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts chainId 651940', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/cmc?chainId=651940`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.chainId).toBe(651940);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/coingecko', () => {
|
||||
it('returns 200 with coingecko format', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/coingecko?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body).toHaveProperty('generatedAt');
|
||||
expect(body).toHaveProperty('chainId', 138);
|
||||
expect(body).toHaveProperty('format', 'coingecko-submission');
|
||||
expect(body).toHaveProperty('tokens');
|
||||
expect(Array.isArray(body.tokens)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
407
services/token-aggregation/src/api/routes/report.ts
Normal file
407
services/token-aggregation/src/api/routes/report.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* CMC and CoinGecko reporting API: all tokens, liquidity, volume, and reportable data.
|
||||
* Use for listing submissions and external aggregator sync.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { TokenRepository } from '../../database/repositories/token-repo';
|
||||
import { MarketDataRepository } from '../../database/repositories/market-data-repo';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import {
|
||||
CANONICAL_TOKENS,
|
||||
getCanonicalTokensByChain,
|
||||
getLogoUriForSpec,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { getSupportedChainIds } from '../../config/chains';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
import { buildCrossChainReport } from '../../indexer/cross-chain-indexer';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
const marketDataRepo = new MarketDataRepository();
|
||||
const poolRepo = new PoolRepository();
|
||||
|
||||
/** Build token entries with DB market/pool data for a chain */
|
||||
async function buildTokenReport(chainId: number) {
|
||||
const canonical = getCanonicalTokensByChain(chainId);
|
||||
const out: Array<{
|
||||
chainId: number;
|
||||
address: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
type: string;
|
||||
decimals: number;
|
||||
currencyCode?: string;
|
||||
market?: {
|
||||
priceUsd?: number;
|
||||
volume24h: number;
|
||||
volume7d: number;
|
||||
volume30d: number;
|
||||
marketCapUsd?: number;
|
||||
liquidityUsd: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
pools: Array<{
|
||||
poolAddress: string;
|
||||
dex: string;
|
||||
token0: string;
|
||||
token1: string;
|
||||
tvl: number;
|
||||
volume24h: number;
|
||||
}>;
|
||||
fromDb: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const spec of canonical) {
|
||||
const address = spec.addresses[chainId];
|
||||
if (!address || String(address).trim() === '') continue;
|
||||
|
||||
const [dbToken, marketData, pools] = await Promise.all([
|
||||
tokenRepo.getToken(chainId, address),
|
||||
marketDataRepo.getMarketData(chainId, address),
|
||||
poolRepo.getPoolsByToken(chainId, address),
|
||||
]);
|
||||
|
||||
out.push({
|
||||
chainId,
|
||||
address: address.toLowerCase(),
|
||||
symbol: spec.symbol,
|
||||
name: dbToken?.name ?? spec.name,
|
||||
type: spec.type,
|
||||
decimals: spec.decimals,
|
||||
currencyCode: spec.currencyCode,
|
||||
market: marketData
|
||||
? {
|
||||
priceUsd: marketData.priceUsd,
|
||||
volume24h: marketData.volume24h,
|
||||
volume7d: marketData.volume7d,
|
||||
volume30d: marketData.volume30d,
|
||||
marketCapUsd: marketData.marketCapUsd,
|
||||
liquidityUsd: marketData.liquidityUsd,
|
||||
lastUpdated: marketData.lastUpdated?.toISOString() ?? '',
|
||||
}
|
||||
: undefined,
|
||||
pools: pools.map((p) => ({
|
||||
poolAddress: p.poolAddress,
|
||||
dex: p.dexType,
|
||||
token0: p.token0Address,
|
||||
token1: p.token1Address,
|
||||
tvl: p.totalLiquidityUsd,
|
||||
volume24h: p.volume24h,
|
||||
})),
|
||||
fromDb: !!dbToken,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** GET /report/cross-chain — cross-chain pools, bridge volume, atomic swaps (Chain 138, ALL Mainnet) */
|
||||
router.get(
|
||||
'/cross-chain',
|
||||
cacheMiddleware(2 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10) || 138;
|
||||
const report = await buildCrossChainReport(chainId);
|
||||
res.json({
|
||||
...report,
|
||||
format: 'cross-chain-report',
|
||||
documentation: 'Use for CMC/CoinGecko submission alongside single-chain reports. Includes CCIP, Alltra, Trustless bridge events and volume by lane.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building report/cross-chain:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
crossChainPools: [],
|
||||
volumeByLane: [],
|
||||
atomicSwapVolume24h: 0,
|
||||
bridgeVolume24hTotal: 0,
|
||||
events: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/all — all tokens, pools, liquidity, volume (unified) + cross-chain */
|
||||
router.get(
|
||||
'/all',
|
||||
cacheMiddleware(2 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIds = chainIdParam
|
||||
? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n))
|
||||
: getSupportedChainIds();
|
||||
|
||||
const tokensByChain: Record<number, Awaited<ReturnType<typeof buildTokenReport>>> = {};
|
||||
const poolsByChain: Record<number, Awaited<ReturnType<typeof poolRepo.getPoolsByChain>>> = {};
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
tokensByChain[chainId] = await buildTokenReport(chainId);
|
||||
poolsByChain[chainId] = await poolRepo.getPoolsByChain(chainId);
|
||||
}
|
||||
|
||||
const crossChainReport = await buildCrossChainReport(138).catch(() => null);
|
||||
|
||||
const totalLiquidityByChain: Record<number, number> = {};
|
||||
const totalVolume24hByChain: Record<number, number> = {};
|
||||
for (const chainId of chainIds) {
|
||||
const pools = poolsByChain[chainId] || [];
|
||||
totalLiquidityByChain[chainId] = pools.reduce((s, p) => s + (p.totalLiquidityUsd || 0), 0);
|
||||
totalVolume24hByChain[chainId] = pools.reduce((s, p) => s + (p.volume24h || 0), 0);
|
||||
}
|
||||
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
chains: chainIds,
|
||||
tokens: tokensByChain,
|
||||
pools: poolsByChain,
|
||||
summary: {
|
||||
totalLiquidityUsdByChain: totalLiquidityByChain,
|
||||
totalVolume24hUsdByChain: totalVolume24hByChain,
|
||||
tokenCountByChain: Object.fromEntries(
|
||||
chainIds.map((c) => [c, (tokensByChain[c] || []).length])
|
||||
),
|
||||
poolCountByChain: Object.fromEntries(
|
||||
chainIds.map((c) => [c, (poolsByChain[c] || []).length])
|
||||
),
|
||||
crossChainBridgeVolume24h: crossChainReport?.bridgeVolume24hTotal,
|
||||
crossChainAtomicSwapVolume24h: crossChainReport?.atomicSwapVolume24h,
|
||||
},
|
||||
crossChain: crossChainReport
|
||||
? {
|
||||
crossChainPools: crossChainReport.crossChainPools,
|
||||
volumeByLane: crossChainReport.volumeByLane,
|
||||
atomicSwapVolume24h: crossChainReport.atomicSwapVolume24h,
|
||||
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building report/all:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/coingecko — format suitable for CoinGecko submission / API */
|
||||
router.get(
|
||||
'/coingecko',
|
||||
cacheMiddleware(2 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10) || 138;
|
||||
const tokens = await buildTokenReport(chainId);
|
||||
|
||||
const coingeckoFormat = tokens.map((t) => ({
|
||||
chain_id: chainId,
|
||||
contract_address: t.address,
|
||||
id: `${t.symbol.toLowerCase()}-${chainId}`,
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
asset_platform_id: chainId === 138 ? 'defi-oracle-meta' : chainId === 651940 ? 'all-mainnet' : `chain-${chainId}`,
|
||||
decimals: t.decimals,
|
||||
description: t.currencyCode ? `ISO-4217 ${t.currencyCode} compliant token` : undefined,
|
||||
market_data: t.market
|
||||
? {
|
||||
current_price: { usd: t.market.priceUsd },
|
||||
total_volume: t.market.volume24h,
|
||||
market_cap: t.market.marketCapUsd,
|
||||
liquidity_usd: t.market.liquidityUsd,
|
||||
last_updated: t.market.lastUpdated,
|
||||
}
|
||||
: undefined,
|
||||
liquidity_pools: t.pools.map((p) => ({
|
||||
pool_address: p.poolAddress,
|
||||
dex_id: p.dex,
|
||||
tvl_usd: p.tvl,
|
||||
volume_24h_usd: p.volume24h,
|
||||
})),
|
||||
}));
|
||||
|
||||
const crossChainReport = await buildCrossChainReport(chainId).catch(() => null);
|
||||
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
chainId,
|
||||
format: 'coingecko-submission',
|
||||
tokens: coingeckoFormat,
|
||||
crossChain: crossChainReport
|
||||
? {
|
||||
crossChainPools: crossChainReport.crossChainPools,
|
||||
volumeByLane: crossChainReport.volumeByLane,
|
||||
atomicSwapVolume24h: crossChainReport.atomicSwapVolume24h,
|
||||
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
|
||||
}
|
||||
: undefined,
|
||||
documentation: 'https://www.coingecko.com/en/api/documentation',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building report/coingecko:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/cmc — format suitable for CoinMarketCap submission / API */
|
||||
router.get(
|
||||
'/cmc',
|
||||
cacheMiddleware(2 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10) || 138;
|
||||
const tokens = await buildTokenReport(chainId);
|
||||
|
||||
const cmcFormat = tokens.map((t) => ({
|
||||
chain_id: chainId,
|
||||
contract_address: t.address,
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
decimals: t.decimals,
|
||||
volume_24h: t.market?.volume24h,
|
||||
market_cap: t.market?.marketCapUsd,
|
||||
liquidity_usd: t.market?.liquidityUsd ?? t.pools.reduce((s, p) => s + p.tvl, 0),
|
||||
pairs: t.pools.map((p) => ({
|
||||
pair_address: p.poolAddress,
|
||||
dex_id: p.dex,
|
||||
base: t.address,
|
||||
quote: p.token0 === t.address ? p.token1 : p.token0,
|
||||
liquidity_usd: p.tvl,
|
||||
volume_24h_usd: p.volume24h,
|
||||
})),
|
||||
}));
|
||||
|
||||
const crossChainReport = await buildCrossChainReport(chainId).catch(() => null);
|
||||
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
chainId,
|
||||
format: 'coinmarketcap-dex',
|
||||
tokens: cmcFormat,
|
||||
crossChain: crossChainReport
|
||||
? {
|
||||
crossChainPools: crossChainReport.crossChainPools,
|
||||
volumeByLane: crossChainReport.volumeByLane,
|
||||
atomicSwapVolume24h: crossChainReport.atomicSwapVolume24h,
|
||||
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
|
||||
}
|
||||
: undefined,
|
||||
documentation: 'https://coinmarketcap.com/api/documentation',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building report/cmc:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/token-list — flat list of all canonical tokens (Uniswap token list format with logoURI).
|
||||
* If TOKEN_LIST_JSON_URL is set (e.g. GitHub raw URL), fetches and returns that JSON; optional ?chainId= filters tokens.
|
||||
*/
|
||||
router.get(
|
||||
'/token-list',
|
||||
cacheMiddleware(5 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tokenListUrl = process.env.TOKEN_LIST_JSON_URL?.trim();
|
||||
if (tokenListUrl) {
|
||||
try {
|
||||
const data = (await fetchRemoteJson(tokenListUrl)) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
timestamp?: string;
|
||||
logoURI?: string;
|
||||
tokens?: Array<{ chainId?: number; address?: string; symbol?: string; name?: string; decimals?: number; [key: string]: unknown }>;
|
||||
};
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
let tokens = Array.isArray(data.tokens) ? data.tokens : [];
|
||||
if (!isNaN(chainIdFilter as number)) {
|
||||
tokens = tokens.filter((t) => t.chainId === chainIdFilter);
|
||||
}
|
||||
return res.json({
|
||||
name: data.name ?? 'Token List',
|
||||
version: data.version ?? '1.0.0',
|
||||
timestamp: data.timestamp ?? new Date().toISOString(),
|
||||
logoURI: data.logoURI,
|
||||
tokens,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('TOKEN_LIST_JSON_URL fetch failed, using built-in token list:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIds = chainIdParam
|
||||
? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n))
|
||||
: getSupportedChainIds();
|
||||
|
||||
const list: Array<{
|
||||
chainId: number;
|
||||
address: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
decimals: number;
|
||||
type: string;
|
||||
logoURI: string;
|
||||
}> = [];
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
const specs = getCanonicalTokensByChain(chainId);
|
||||
for (const spec of specs) {
|
||||
const address = spec.addresses[chainId];
|
||||
if (address) {
|
||||
list.push({
|
||||
chainId,
|
||||
address: address.toLowerCase(),
|
||||
symbol: spec.symbol,
|
||||
name: spec.name,
|
||||
decimals: spec.decimals,
|
||||
type: spec.type,
|
||||
logoURI: getLogoUriForSpec(spec),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
name: 'GRU Canonical Token List',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
logoURI: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png',
|
||||
tokens: list,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building report/token-list:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/canonical — raw canonical spec list (no DB merge) */
|
||||
router.get(
|
||||
'/canonical',
|
||||
cacheMiddleware(10 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
tokens: CANONICAL_TOKENS.map((t) => ({
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
decimals: t.decimals,
|
||||
currencyCode: t.currencyCode,
|
||||
addresses: t.addresses,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error building report/canonical:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
134
services/token-aggregation/src/api/routes/token-mapping.ts
Normal file
134
services/token-aggregation/src/api/routes/token-mapping.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Token mapping API: exposes config/token-mapping-loader (multichain) when run from monorepo.
|
||||
* GET /api/v1/token-mapping?fromChain=138&toChain=651940
|
||||
* GET /api/v1/token-mapping/pairs
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
|
||||
const router: Router = Router();
|
||||
const require = createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Resolve path to repo root (proxmox) from token-aggregation src/api/routes -> 5 levels up */
|
||||
const PROXMOX_ROOT = path.resolve(__dirname, '../../../../../');
|
||||
const LOADER_PATH = path.join(PROXMOX_ROOT, 'config', 'token-mapping-loader.cjs');
|
||||
|
||||
function loadMultichainLoader(): {
|
||||
getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record<string, string>; addressMapToFrom: Record<string, string> } | null;
|
||||
getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>;
|
||||
getMappedAddress: (from: number, to: number, addr: string) => string | undefined;
|
||||
} | null {
|
||||
try {
|
||||
const loader = require(LOADER_PATH);
|
||||
if (loader?.getTokenMappingForPair && loader?.getAllMultichainPairs && loader?.getMappedAddress) {
|
||||
return loader;
|
||||
}
|
||||
} catch {
|
||||
// config not available when run outside monorepo
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/token-mapping?fromChain=138&toChain=651940
|
||||
* Returns token mapping for a chain pair (from config/token-mapping-multichain.json).
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
cacheMiddleware(5 * 60 * 1000),
|
||||
(req: Request, res: Response) => {
|
||||
const fromChain = parseInt(String(req.query.fromChain ?? req.query.from), 10);
|
||||
const toChain = parseInt(String(req.query.toChain ?? req.query.to), 10);
|
||||
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain)) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing or invalid fromChain and toChain query params',
|
||||
example: '/api/v1/token-mapping?fromChain=138&toChain=651940',
|
||||
});
|
||||
}
|
||||
|
||||
const loader = loadMultichainLoader();
|
||||
if (!loader) {
|
||||
return res.status(503).json({
|
||||
error: 'Token mapping config not available (run from monorepo with config/token-mapping-multichain.json)',
|
||||
});
|
||||
}
|
||||
|
||||
const result = loader.getTokenMappingForPair(fromChain, toChain);
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
error: 'No token mapping for this chain pair',
|
||||
fromChain,
|
||||
toChain,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
fromChainId: fromChain,
|
||||
toChainId: toChain,
|
||||
tokens: result.tokens,
|
||||
addressMapFromTo: result.addressMapFromTo,
|
||||
addressMapToFrom: result.addressMapToFrom,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/token-mapping/pairs
|
||||
* Returns all defined chain pairs.
|
||||
*/
|
||||
router.get(
|
||||
'/pairs',
|
||||
cacheMiddleware(5 * 60 * 1000),
|
||||
(req: Request, res: Response) => {
|
||||
const loader = loadMultichainLoader();
|
||||
if (!loader) {
|
||||
return res.status(503).json({
|
||||
error: 'Token mapping config not available (run from monorepo)',
|
||||
});
|
||||
}
|
||||
const pairs = loader.getAllMultichainPairs();
|
||||
return res.json({ pairs });
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=0x...
|
||||
* Returns mapped token address on target chain.
|
||||
*/
|
||||
router.get(
|
||||
'/resolve',
|
||||
cacheMiddleware(5 * 60 * 1000),
|
||||
(req: Request, res: Response) => {
|
||||
const fromChain = parseInt(String(req.query.fromChain ?? req.query.from), 10);
|
||||
const toChain = parseInt(String(req.query.toChain ?? req.query.to), 10);
|
||||
const address = String(req.query.address ?? req.query.token ?? '').trim();
|
||||
if (!Number.isFinite(fromChain) || !Number.isFinite(toChain) || !address) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing or invalid fromChain, toChain, or address',
|
||||
example: '/api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
});
|
||||
}
|
||||
|
||||
const loader = loadMultichainLoader();
|
||||
if (!loader) {
|
||||
return res.status(503).json({
|
||||
error: 'Token mapping config not available (run from monorepo)',
|
||||
});
|
||||
}
|
||||
|
||||
const mapped = loader.getMappedAddress(fromChain, toChain, address);
|
||||
return res.json({
|
||||
fromChainId: fromChain,
|
||||
toChainId: toChain,
|
||||
addressOnSource: address,
|
||||
addressOnTarget: mapped ?? null,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
291
services/token-aggregation/src/api/routes/tokens.ts
Normal file
291
services/token-aggregation/src/api/routes/tokens.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { TokenRepository } from '../../database/repositories/token-repo';
|
||||
import { MarketDataRepository } from '../../database/repositories/market-data-repo';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import { OHLCVGenerator } from '../../indexer/ohlcv-generator';
|
||||
import { CoinGeckoAdapter } from '../../adapters/coingecko-adapter';
|
||||
import { CoinMarketCapAdapter } from '../../adapters/cmc-adapter';
|
||||
import { DexScreenerAdapter } from '../../adapters/dexscreener-adapter';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
const marketDataRepo = new MarketDataRepository();
|
||||
const poolRepo = new PoolRepository();
|
||||
const ohlcvGenerator = new OHLCVGenerator();
|
||||
const coingeckoAdapter = new CoinGeckoAdapter();
|
||||
const cmcAdapter = new CoinMarketCapAdapter();
|
||||
const dexscreenerAdapter = new DexScreenerAdapter();
|
||||
|
||||
router.get('/chains', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
chains: [
|
||||
{
|
||||
chainId: 138,
|
||||
name: 'DeFi Oracle Meta Mainnet',
|
||||
explorerUrl: 'https://explorer.d-bis.org',
|
||||
},
|
||||
{
|
||||
chainId: 651940,
|
||||
name: 'ALL Mainnet',
|
||||
explorerUrl: 'https://alltra.global',
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const limit = parseInt(req.query.limit as string, 10) || 50;
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
const includeDodoPool = (req.query.includeDodoPool as string) === '1' || (req.query.includeDodoPool as string) === 'true';
|
||||
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const tokens = await tokenRepo.getTokens(chainId, limit, offset);
|
||||
const tokensWithMarketData = await Promise.all(
|
||||
tokens.map(async (token) => {
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, token.address);
|
||||
const out: Record<string, unknown> = {
|
||||
...token,
|
||||
market: marketData || undefined,
|
||||
};
|
||||
if (includeDodoPool) {
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, token.address);
|
||||
const dodoPool = pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo');
|
||||
out.hasDodoPool = !!dodoPool;
|
||||
out.pmmPool = dodoPool?.poolAddress || undefined;
|
||||
}
|
||||
return out;
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
tokens: tokensWithMarketData,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
count: tokensWithMarketData.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching tokens:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const address = req.params.address;
|
||||
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const token = await tokenRepo.getToken(chainId, address);
|
||||
if (!token) {
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
|
||||
const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([
|
||||
marketDataRepo.getMarketData(chainId, address),
|
||||
poolRepo.getPoolsByToken(chainId, address),
|
||||
coingeckoAdapter.getTokenByContract(chainId, address),
|
||||
cmcAdapter.getTokenByContract(chainId, address),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, address),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
token: {
|
||||
...token,
|
||||
onChain: {
|
||||
totalSupply: token.totalSupply,
|
||||
},
|
||||
market: marketData || undefined,
|
||||
external: {
|
||||
coingecko: coingeckoData || undefined,
|
||||
cmc: cmcData || undefined,
|
||||
dexscreener: dexscreenerData || undefined,
|
||||
},
|
||||
pools: pools.map((pool) => ({
|
||||
address: pool.poolAddress,
|
||||
dex: pool.dexType,
|
||||
token0: pool.token0Address,
|
||||
token1: pool.token1Address,
|
||||
reserves: {
|
||||
token0: pool.reserve0,
|
||||
token1: pool.reserve1,
|
||||
},
|
||||
tvl: pool.totalLiquidityUsd,
|
||||
volume24h: pool.volume24h,
|
||||
})),
|
||||
hasDodoPool: pools.some((p) => (p.dexType || '').toLowerCase() === 'dodo'),
|
||||
pmmPool: pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo')?.poolAddress || undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching token:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const address = req.params.address;
|
||||
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, address);
|
||||
|
||||
res.json({
|
||||
pools: pools.map((pool) => ({
|
||||
address: pool.poolAddress,
|
||||
dex: pool.dexType,
|
||||
token0: pool.token0Address,
|
||||
token1: pool.token1Address,
|
||||
reserves: {
|
||||
token0: pool.reserve0,
|
||||
token1: pool.reserve1,
|
||||
},
|
||||
tvl: pool.totalLiquidityUsd,
|
||||
volume24h: pool.volume24h,
|
||||
feeTier: pool.feeTier,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching pools:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/:address/ohlcv', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const address = req.params.address;
|
||||
const interval = (req.query.interval as string) || '1h';
|
||||
const from = req.query.from ? new Date(req.query.from as string) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const to = req.query.to ? new Date(req.query.to as string) : new Date();
|
||||
const poolAddress = req.query.poolAddress as string | undefined;
|
||||
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
if (!['5m', '15m', '1h', '4h', '24h'].includes(interval)) {
|
||||
return res.status(400).json({ error: 'Invalid interval. Must be one of: 5m, 15m, 1h, 4h, 24h' });
|
||||
}
|
||||
|
||||
const ohlcv = await ohlcvGenerator.getOHLCV(
|
||||
chainId,
|
||||
address,
|
||||
interval as any,
|
||||
from,
|
||||
to,
|
||||
poolAddress
|
||||
);
|
||||
|
||||
res.json({
|
||||
chainId,
|
||||
tokenAddress: address,
|
||||
interval,
|
||||
data: ohlcv,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching OHLCV:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/:address/signals', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const address = req.params.address;
|
||||
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const trending = await coingeckoAdapter.getTrending();
|
||||
|
||||
res.json({
|
||||
chainId,
|
||||
tokenAddress: address,
|
||||
signals: {
|
||||
trendingRank: trending.findIndex((t) => t.symbol.toLowerCase() === address.toLowerCase()),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching signals:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/search', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const query = req.query.q as string;
|
||||
|
||||
if (!chainId || !query) {
|
||||
return res.status(400).json({ error: 'chainId and q (query) are required' });
|
||||
}
|
||||
|
||||
const tokens = await tokenRepo.searchTokens(chainId, query, 20);
|
||||
|
||||
res.json({
|
||||
query,
|
||||
chainId,
|
||||
results: tokens,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching tokens:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/pools/:poolAddress', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
const poolAddress = req.params.poolAddress;
|
||||
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const pool = await poolRepo.getPool(chainId, poolAddress);
|
||||
if (!pool) {
|
||||
return res.status(404).json({ error: 'Pool not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
pool: {
|
||||
address: pool.poolAddress,
|
||||
dex: pool.dexType,
|
||||
token0: pool.token0Address,
|
||||
token1: pool.token1Address,
|
||||
reserves: {
|
||||
token0: pool.reserve0,
|
||||
token1: pool.reserve1,
|
||||
},
|
||||
tvl: pool.totalLiquidityUsd,
|
||||
volume24h: pool.volume24h,
|
||||
feeTier: pool.feeTier,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching pool:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
162
services/token-aggregation/src/api/server.ts
Normal file
162
services/token-aggregation/src/api/server.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import express, { Express, Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import compression from 'compression';
|
||||
import { apiRateLimiter, strictRateLimiter } from './middleware/rate-limit';
|
||||
import tokenRoutes from './routes/tokens';
|
||||
import reportRoutes from './routes/report';
|
||||
import adminRoutes from './routes/admin';
|
||||
import configRoutes from './routes/config';
|
||||
import bridgeRoutes from './routes/bridge';
|
||||
import quoteRoutes from './routes/quote';
|
||||
import tokenMappingRoutes from './routes/token-mapping';
|
||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import winston from 'winston';
|
||||
|
||||
// Setup logger
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export class ApiServer {
|
||||
private app: Express;
|
||||
private port: number;
|
||||
private indexer: MultiChainIndexer;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.port = parseInt(process.env.PORT || '3000', 10);
|
||||
this.indexer = new MultiChainIndexer();
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupErrorHandling();
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
// CORS
|
||||
this.app.use(cors());
|
||||
|
||||
// Compression
|
||||
this.app.use(compression());
|
||||
|
||||
// Body parsing
|
||||
this.app.use(express.json());
|
||||
this.app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Rate limiting
|
||||
this.app.use('/api/v1', apiRateLimiter);
|
||||
|
||||
// Request logging
|
||||
this.app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
// Health check
|
||||
this.app.get('/health', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check database connection
|
||||
const pool = getDatabasePool();
|
||||
await pool.query('SELECT 1');
|
||||
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: 'connected',
|
||||
indexer: 'running',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API routes
|
||||
this.app.use('/api/v1', tokenRoutes);
|
||||
this.app.use('/api/v1', configRoutes);
|
||||
this.app.use('/api/v1/report', reportRoutes);
|
||||
this.app.use('/api/v1/bridge', bridgeRoutes);
|
||||
this.app.use('/api/v1/token-mapping', tokenMappingRoutes);
|
||||
this.app.use('/api/v1', quoteRoutes);
|
||||
|
||||
// Admin routes (stricter rate limit)
|
||||
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);
|
||||
|
||||
// Root
|
||||
this.app.get('/', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
name: 'Token Aggregation Service',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
api: '/api/v1',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupErrorHandling(): void {
|
||||
// 404 handler
|
||||
this.app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
this.app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
// Initialize indexer
|
||||
await this.indexer.initialize();
|
||||
|
||||
// Start indexing
|
||||
await this.indexer.startAll();
|
||||
|
||||
// Start server
|
||||
this.app.listen(this.port, () => {
|
||||
logger.info(`Token Aggregation Service listening on port ${this.port}`);
|
||||
logger.info(`Health check: http://localhost:${this.port}/health`);
|
||||
logger.info(`API: http://localhost:${this.port}/api/v1`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.indexer.stopAll();
|
||||
logger.info('Server stopped');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Fetch JSON from a URL with in-memory caching.
|
||||
* Used for TOKEN_LIST_JSON_URL, BRIDGE_LIST_JSON_URL, NETWORKS_JSON_URL (e.g. GitHub raw URLs).
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface CacheEntry {
|
||||
data: unknown;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
export async function fetchRemoteJson(
|
||||
url: string,
|
||||
ttlMs: number = CACHE_TTL_MS
|
||||
): Promise<unknown> {
|
||||
const trimmed = url?.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('URL is required');
|
||||
}
|
||||
const entry = cache.get(trimmed);
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
return entry.data;
|
||||
}
|
||||
const res = await fetch(trimmed, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
cache.set(trimmed, { data, expiresAt: Date.now() + ttlMs });
|
||||
return data;
|
||||
}
|
||||
|
||||
export function clearRemoteJsonCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
201
services/token-aggregation/src/config/canonical-tokens.ts
Normal file
201
services/token-aggregation/src/config/canonical-tokens.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Canonical token list for GRU base money, W-tokens, asset (ac*), and debt (vdc* / sdc*) tokens.
|
||||
* Used for CMC/CoinGecko reporting and token-aggregation indexing.
|
||||
* Addresses can be overridden via env (e.g. CUSDC_ADDRESS_138) or filled by indexer.
|
||||
*/
|
||||
|
||||
export type TokenType = 'base' | 'w' | 'asset' | 'debt';
|
||||
|
||||
export interface CanonicalTokenSpec {
|
||||
symbol: string;
|
||||
name: string;
|
||||
type: TokenType;
|
||||
decimals: number;
|
||||
currencyCode?: string; // ISO-4217 for base/w
|
||||
/** ChainId -> contract address (placeholder or from env) */
|
||||
addresses: Partial<Record<number, string>>;
|
||||
description?: string;
|
||||
websiteUrl?: string;
|
||||
logoUrl?: string;
|
||||
/** v1 matrix symbol for origin reference only (e.g. cXUSDC when bridged); on Chain 138 symbol is v0 only (no X) */
|
||||
v1Symbol?: string;
|
||||
/** v0 symbol alias; on ChainID 138 tokens use v0 only (cUSDC, cUSDT), no chain designator */
|
||||
v0Alias?: string;
|
||||
}
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_25 = 25; // Cronos
|
||||
const CHAIN_651940 = 651940;
|
||||
/** L2/mainnet chain IDs for cUSDT/cUSDC multichain (env: CUSDT_ADDRESS_56, CUSDC_ADDRESS_137, etc.) */
|
||||
const L2_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 25, 100, 42220, 1111] as const;
|
||||
|
||||
/** Verified addresses from CHAIN138_TOKEN_ADDRESSES, .env, and deployment summaries */
|
||||
const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
cUSDC: {
|
||||
[CHAIN_138]: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
[CHAIN_651940]: '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', // AUSDC on ALL Mainnet
|
||||
[1]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum USDC
|
||||
[56]: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // BSC USDC
|
||||
[137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c1369', // Polygon USDC
|
||||
[100]: '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', // Gnosis USDC
|
||||
[10]: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism USDC
|
||||
[42161]: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum USDC
|
||||
[8453]: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base USDC
|
||||
[43114]: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', // Avalanche USDC
|
||||
[CHAIN_25]: '0xc21223249CA28397B4B6541dfFaEcC539BfF0c59', // Cronos USDC
|
||||
[42220]: '0xcebA9300f2b948710d2653dD7B07f33A8B32118C', // Celo USDC
|
||||
[1111]: '0xE3F5a90F9cb311505cd691a46596599aA1A0AD7D', // Wemix USDC
|
||||
},
|
||||
cUSDT: {
|
||||
[CHAIN_138]: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
[CHAIN_651940]: '0x015B1897Ed5279930bC2Be46F661894d219292A6', // AUSDT primary on ALL Mainnet
|
||||
[1]: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // Ethereum USDT
|
||||
[56]: '0x55d398326f99059fF775485246999027B3197955', // BSC USDT
|
||||
[137]: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', // Polygon USDT
|
||||
[100]: '0x4ECaBa5870353805a9F068101A40E0f32ed605C6', // Gnosis USDT
|
||||
[10]: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', // Optimism USDT
|
||||
[42161]: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // Arbitrum USDT
|
||||
[8453]: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', // Base USDT
|
||||
[43114]: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7', // Avalanche USDT
|
||||
[CHAIN_25]: '0x66e4286603D22FF153A6547700f37C7Eae42F8E2', // Cronos USDT
|
||||
[42220]: '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e', // Celo USDT
|
||||
[1111]: '0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F', // Wemix USDT
|
||||
},
|
||||
// Compliant Fiat on Chain 138 — from DeployCompliantFiatTokens (2026-02-27)
|
||||
cEURC: { [CHAIN_138]: '0x8085961F9cF02b4d800A3c6d386D31da4B34266a' },
|
||||
cEURT: { [CHAIN_138]: '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72' },
|
||||
cGBPC: { [CHAIN_138]: '0x003960f16D9d34F2e98d62723B6721Fb92074aD2' },
|
||||
cGBPT: { [CHAIN_138]: '0x350f54e4D23795f86A9c03988c7135357CCaD97c' },
|
||||
cAUDC: { [CHAIN_138]: '0xD51482e567c03899eecE3CAe8a058161FD56069D' },
|
||||
cJPYC: { [CHAIN_138]: '0xEe269e1226a334182aace90056EE4ee5Cc8A6770' },
|
||||
cCHFC: { [CHAIN_138]: '0x873990849DDa5117d7C644f0aF24370797C03885' },
|
||||
cCADC: { [CHAIN_138]: '0x54dBd40cF05e15906A2C21f600937e96787f5679' },
|
||||
cXAUC: { [CHAIN_138]: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b' },
|
||||
cXAUT: { [CHAIN_138]: '0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E' },
|
||||
// ISO-4217W on Cronos (25) — from DeployISO4217WSystem
|
||||
USDW: { [CHAIN_25]: '0x948690147D2e50ffe50C5d38C14125aD6a9FA036' },
|
||||
EURW: { [CHAIN_25]: '0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd' },
|
||||
GBPW: { [CHAIN_25]: '0xFb4B6Cc81211F7d886950158294A44C312abCA29' },
|
||||
AUDW: { [CHAIN_25]: '0xf9f5D0ACD71C76F9476F10B3F3d3E201F0883C68' },
|
||||
JPYW: { [CHAIN_25]: '0xeE17bB0322383fecCA2784fbE2d4CD7d02b1905B' },
|
||||
CHFW: { [CHAIN_25]: '0xc9750828124D4c10e7a6f4B655cA8487bD3842EB' },
|
||||
CADW: { [CHAIN_25]: '0x328Cd365Bb35524297E68ED28c6fF2C9557d1363' },
|
||||
};
|
||||
|
||||
function addr(symbol: string, chainId: number): string | undefined {
|
||||
const key = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
|
||||
const envVal = process.env[key];
|
||||
if (envVal && envVal.trim() !== '') return envVal;
|
||||
return FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
}
|
||||
|
||||
export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
// --- Base (GRU-M1) ---
|
||||
// Chain 138 v0 only (no X): cUSDC on 138; cXUSDC used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
{ symbol: 'cUSDC', name: 'USD Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDC', addresses: { [CHAIN_138]: addr('cUSDC', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDC', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDC', id)])) } },
|
||||
// Chain 138 v0 only (no X): cUSDT on 138; cXUSDT used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
{ symbol: 'cUSDT', name: 'Tether USD (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDT', addresses: { [CHAIN_138]: addr('cUSDT', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDT', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDT', id)])) } },
|
||||
{ symbol: 'cEURC', name: 'Euro Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURC', CHAIN_138), [CHAIN_651940]: addr('cEURC', CHAIN_651940) } },
|
||||
{ symbol: 'cEURT', name: 'Tether EUR (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURT', CHAIN_138), [CHAIN_651940]: addr('cEURT', CHAIN_651940) } },
|
||||
{ symbol: 'cGBPC', name: 'Pound Sterling (Compliant)', type: 'base', decimals: 6, currencyCode: 'GBP', addresses: { [CHAIN_138]: addr('cGBPC', CHAIN_138), [CHAIN_651940]: addr('cGBPC', CHAIN_651940) } },
|
||||
{ symbol: 'cGBPT', name: 'Tether GBP (Compliant)', type: 'base', decimals: 6, currencyCode: 'GBP', addresses: { [CHAIN_138]: addr('cGBPT', CHAIN_138), [CHAIN_651940]: addr('cGBPT', CHAIN_651940) } },
|
||||
{ symbol: 'cAUDC', name: 'Australian Dollar (Compliant)', type: 'base', decimals: 6, currencyCode: 'AUD', addresses: { [CHAIN_138]: addr('cAUDC', CHAIN_138), [CHAIN_651940]: addr('cAUDC', CHAIN_651940) } },
|
||||
{ symbol: 'cJPYC', name: 'Japanese Yen (Compliant)', type: 'base', decimals: 6, currencyCode: 'JPY', addresses: { [CHAIN_138]: addr('cJPYC', CHAIN_138), [CHAIN_651940]: addr('cJPYC', CHAIN_651940) } },
|
||||
{ symbol: 'cCHFC', name: 'Swiss Franc (Compliant)', type: 'base', decimals: 6, currencyCode: 'CHF', addresses: { [CHAIN_138]: addr('cCHFC', CHAIN_138), [CHAIN_651940]: addr('cCHFC', CHAIN_651940) } },
|
||||
{ symbol: 'cCADC', name: 'Canadian Dollar (Compliant)', type: 'base', decimals: 6, currencyCode: 'CAD', addresses: { [CHAIN_138]: addr('cCADC', CHAIN_138), [CHAIN_651940]: addr('cCADC', CHAIN_651940) } },
|
||||
{ symbol: 'cXAUC', name: 'Gold (Compliant)', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138), [CHAIN_651940]: addr('cXAUC', CHAIN_651940) } },
|
||||
{ symbol: 'cXAUT', name: 'Tether XAU (Compliant)', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138), [CHAIN_651940]: addr('cXAUT', CHAIN_651940) } },
|
||||
{ symbol: 'LiXAU', name: 'XAU Liquidity-adjusted', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) } },
|
||||
// --- ISO-4217 W ---
|
||||
{ symbol: 'USDW', name: 'USD W Token', type: 'w', decimals: 2, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDW', CHAIN_138), [CHAIN_25]: addr('USDW', CHAIN_25), [CHAIN_651940]: addr('USDW', CHAIN_651940) } },
|
||||
{ symbol: 'EURW', name: 'EUR W Token', type: 'w', decimals: 2, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('EURW', CHAIN_138), [CHAIN_25]: addr('EURW', CHAIN_25), [CHAIN_651940]: addr('EURW', CHAIN_651940) } },
|
||||
{ symbol: 'GBPW', name: 'GBP W Token', type: 'w', decimals: 2, currencyCode: 'GBP', addresses: { [CHAIN_138]: addr('GBPW', CHAIN_138), [CHAIN_25]: addr('GBPW', CHAIN_25), [CHAIN_651940]: addr('GBPW', CHAIN_651940) } },
|
||||
{ symbol: 'AUDW', name: 'AUD W Token', type: 'w', decimals: 2, currencyCode: 'AUD', addresses: { [CHAIN_138]: addr('AUDW', CHAIN_138), [CHAIN_25]: addr('AUDW', CHAIN_25), [CHAIN_651940]: addr('AUDW', CHAIN_651940) } },
|
||||
{ symbol: 'JPYW', name: 'JPY W Token', type: 'w', decimals: 2, currencyCode: 'JPY', addresses: { [CHAIN_138]: addr('JPYW', CHAIN_138), [CHAIN_25]: addr('JPYW', CHAIN_25), [CHAIN_651940]: addr('JPYW', CHAIN_651940) } },
|
||||
{ symbol: 'CHFW', name: 'CHF W Token', type: 'w', decimals: 2, currencyCode: 'CHF', addresses: { [CHAIN_138]: addr('CHFW', CHAIN_138), [CHAIN_25]: addr('CHFW', CHAIN_25), [CHAIN_651940]: addr('CHFW', CHAIN_651940) } },
|
||||
{ symbol: 'CADW', name: 'CAD W Token', type: 'w', decimals: 2, currencyCode: 'CAD', addresses: { [CHAIN_138]: addr('CADW', CHAIN_138), [CHAIN_25]: addr('CADW', CHAIN_25), [CHAIN_651940]: addr('CADW', CHAIN_651940) } },
|
||||
// --- Asset (ac*) ---
|
||||
{ symbol: 'acUSDC', name: 'Deposit cUSDC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acUSDC', CHAIN_138), [CHAIN_651940]: addr('acUSDC', CHAIN_651940) } },
|
||||
{ symbol: 'acUSDT', name: 'Deposit cUSDT', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acUSDT', CHAIN_138), [CHAIN_651940]: addr('acUSDT', CHAIN_651940) } },
|
||||
{ symbol: 'acEURC', name: 'Deposit cEURC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acEURC', CHAIN_138), [CHAIN_651940]: addr('acEURC', CHAIN_651940) } },
|
||||
{ symbol: 'acGBPC', name: 'Deposit cGBPC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acGBPC', CHAIN_138), [CHAIN_651940]: addr('acGBPC', CHAIN_651940) } },
|
||||
{ symbol: 'acAUDC', name: 'Deposit cAUDC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acAUDC', CHAIN_138), [CHAIN_651940]: addr('acAUDC', CHAIN_651940) } },
|
||||
{ symbol: 'acJPYC', name: 'Deposit cJPYC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acJPYC', CHAIN_138), [CHAIN_651940]: addr('acJPYC', CHAIN_651940) } },
|
||||
{ symbol: 'acCHFC', name: 'Deposit cCHFC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acCHFC', CHAIN_138), [CHAIN_651940]: addr('acCHFC', CHAIN_651940) } },
|
||||
{ symbol: 'acCADC', name: 'Deposit cCADC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acCADC', CHAIN_138), [CHAIN_651940]: addr('acCADC', CHAIN_651940) } },
|
||||
{ symbol: 'acXAUC', name: 'Deposit cXAUC', type: 'asset', decimals: 6, addresses: { [CHAIN_138]: addr('acXAUC', CHAIN_138), [CHAIN_651940]: addr('acXAUC', CHAIN_651940) } },
|
||||
// --- Debt (vdc* / sdc*) ---
|
||||
{ symbol: 'vdcUSDC', name: 'Debt cUSDC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcUSDC', CHAIN_138), [CHAIN_651940]: addr('vdcUSDC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcUSDC', name: 'Debt cUSDC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcUSDC', CHAIN_138), [CHAIN_651940]: addr('sdcUSDC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcUSDT', name: 'Debt cUSDT (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcUSDT', CHAIN_138), [CHAIN_651940]: addr('vdcUSDT', CHAIN_651940) } },
|
||||
{ symbol: 'sdcUSDT', name: 'Debt cUSDT (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcUSDT', CHAIN_138), [CHAIN_651940]: addr('sdcUSDT', CHAIN_651940) } },
|
||||
{ symbol: 'vdcEURC', name: 'Debt cEURC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcEURC', CHAIN_138), [CHAIN_651940]: addr('vdcEURC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcEURC', name: 'Debt cEURC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcEURC', CHAIN_138), [CHAIN_651940]: addr('sdcEURC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcGBPC', name: 'Debt cGBPC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcGBPC', CHAIN_138), [CHAIN_651940]: addr('vdcGBPC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcGBPC', name: 'Debt cGBPC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcGBPC', CHAIN_138), [CHAIN_651940]: addr('sdcGBPC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcAUDC', name: 'Debt cAUDC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcAUDC', CHAIN_138), [CHAIN_651940]: addr('vdcAUDC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcAUDC', name: 'Debt cAUDC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcAUDC', CHAIN_138), [CHAIN_651940]: addr('sdcAUDC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcJPYC', name: 'Debt cJPYC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcJPYC', CHAIN_138), [CHAIN_651940]: addr('vdcJPYC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcJPYC', name: 'Debt cJPYC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcJPYC', CHAIN_138), [CHAIN_651940]: addr('sdcJPYC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcCHFC', name: 'Debt cCHFC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcCHFC', CHAIN_138), [CHAIN_651940]: addr('vdcCHFC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcCHFC', name: 'Debt cCHFC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcCHFC', CHAIN_138), [CHAIN_651940]: addr('sdcCHFC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcCADC', name: 'Debt cCADC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcCADC', CHAIN_138), [CHAIN_651940]: addr('vdcCADC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcCADC', name: 'Debt cCADC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcCADC', CHAIN_138), [CHAIN_651940]: addr('sdcCADC', CHAIN_651940) } },
|
||||
{ symbol: 'vdcXAUC', name: 'Debt cXAUC (variable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('vdcXAUC', CHAIN_138), [CHAIN_651940]: addr('vdcXAUC', CHAIN_651940) } },
|
||||
{ symbol: 'sdcXAUC', name: 'Debt cXAUC (stable)', type: 'debt', decimals: 6, addresses: { [CHAIN_138]: addr('sdcXAUC', CHAIN_138), [CHAIN_651940]: addr('sdcXAUC', CHAIN_651940) } },
|
||||
];
|
||||
|
||||
export function getCanonicalTokensByChain(chainId: number): CanonicalTokenSpec[] {
|
||||
return CANONICAL_TOKENS.filter(
|
||||
(t) => t.addresses[chainId] && String(t.addresses[chainId]).trim() !== ''
|
||||
);
|
||||
}
|
||||
|
||||
export function getCanonicalTokenByAddress(chainId: number, address: string): CanonicalTokenSpec | undefined {
|
||||
const lower = address.toLowerCase();
|
||||
return CANONICAL_TOKENS.find((t) => t.addresses[chainId]?.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
/** IPFS-hosted logo URLs (Pinata) for Uniswap token list (logoURI).
|
||||
* Every token must have logoURI for MetaMask to display icons. getLogoUriForSpec resolves
|
||||
* ac-tokens from base (c*), vdc/sdc from base; unknown symbols fall back to ETH_LOGO. */
|
||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs';
|
||||
const ETH_LOGO = `${IPFS_GATEWAY}/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong`;
|
||||
const USDC_LOGO = `${IPFS_GATEWAY}/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm`;
|
||||
const USDT_LOGO = `${IPFS_GATEWAY}/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP`;
|
||||
|
||||
const LOGO_BY_SYMBOL: Record<string, string> = {
|
||||
cUSDC: USDC_LOGO,
|
||||
cUSDT: USDT_LOGO,
|
||||
cEURC: USDC_LOGO,
|
||||
cEURT: USDT_LOGO,
|
||||
cGBPC: `${IPFS_GATEWAY}/QmNQF73WjxU6FwTXNH8PXoDRFaSFKTYQWL7d4Q1kdRVJ4o`,
|
||||
cGBPT: `${IPFS_GATEWAY}/QmV4frsJmDTWzLdxdj1z81uMqVXcbGpHZLzwkpj6GvEX4k`,
|
||||
cAUDC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cJPYC: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
cCHFC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cCADC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cXAUC: ETH_LOGO,
|
||||
cXAUT: ETH_LOGO,
|
||||
LiXAU: `${IPFS_GATEWAY}/QmUVY5trUM5N1UnS4abReb66fNzGw7kenjU9AjL7TgR3M1`,
|
||||
USDW: USDC_LOGO,
|
||||
EURW: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
GBPW: `${IPFS_GATEWAY}/QmT2nJ6WyhYBCsYJ6NfS1BPAqiGKkCEuMxiC8ye93Co1hF`,
|
||||
AUDW: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
JPYW: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
CHFW: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
CADW: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
};
|
||||
|
||||
/** Resolve logo URI for a canonical token (Uniswap token list format). */
|
||||
export function getLogoUriForSpec(spec: CanonicalTokenSpec): string {
|
||||
if (spec.logoUrl) return spec.logoUrl;
|
||||
const bySymbol = LOGO_BY_SYMBOL[spec.symbol];
|
||||
if (bySymbol) return bySymbol;
|
||||
if (spec.symbol.startsWith('ac')) return getLogoUriForSpec(CANONICAL_TOKENS.find((t) => t.symbol === spec.symbol.replace('ac', 'c')) || spec);
|
||||
if (spec.symbol.startsWith('vdc') || spec.symbol.startsWith('sdc')) {
|
||||
const base = spec.symbol.replace(/^(vd|sd)c/, 'c');
|
||||
return getLogoUriForSpec(CANONICAL_TOKENS.find((t) => t.symbol === base) || spec);
|
||||
}
|
||||
return ETH_LOGO;
|
||||
}
|
||||
150
services/token-aggregation/src/config/chains.ts
Normal file
150
services/token-aggregation/src/config/chains.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export interface ChainConfig {
|
||||
chainId: number;
|
||||
name: string;
|
||||
rpcUrl: string;
|
||||
explorerUrl: string;
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
blockTime: number; // Average block time in seconds
|
||||
confirmations: number; // Required confirmations for finality
|
||||
}
|
||||
|
||||
export const CHAIN_CONFIGS: Record<number, ChainConfig> = {
|
||||
138: {
|
||||
chainId: 138,
|
||||
name: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: process.env.CHAIN_138_RPC_URL || process.env.RPC_URL_138_PUBLIC || process.env.RPC_URL_138 || 'https://rpc-http-pub.d-bis.org',
|
||||
explorerUrl: 'https://explorer.d-bis.org',
|
||||
nativeCurrency: {
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18,
|
||||
},
|
||||
blockTime: 5, // QBFT consensus, ~5 second blocks
|
||||
confirmations: 1, // QBFT finality
|
||||
},
|
||||
651940: {
|
||||
chainId: 651940,
|
||||
name: 'ALL Mainnet',
|
||||
rpcUrl: process.env.CHAIN_651940_RPC_URL || 'https://mainnet-rpc.alltra.global',
|
||||
explorerUrl: 'https://alltra.global',
|
||||
nativeCurrency: {
|
||||
name: 'ALL',
|
||||
symbol: 'ALL',
|
||||
decimals: 18,
|
||||
},
|
||||
blockTime: 3, // Estimated
|
||||
confirmations: 12, // Standard EVM confirmations
|
||||
},
|
||||
// cW* edge pool chains (pool-matrix); set CHAIN_*_RPC_URL to enable indexing
|
||||
1: {
|
||||
chainId: 1,
|
||||
name: 'Ethereum Mainnet',
|
||||
rpcUrl: process.env.CHAIN_1_RPC_URL || process.env.ETHEREUM_MAINNET_RPC || 'https://eth.llamarpc.com',
|
||||
explorerUrl: 'https://etherscan.io',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockTime: 12,
|
||||
confirmations: 12,
|
||||
},
|
||||
10: {
|
||||
chainId: 10,
|
||||
name: 'Optimism',
|
||||
rpcUrl: process.env.CHAIN_10_RPC_URL || 'https://mainnet.optimism.io',
|
||||
explorerUrl: 'https://optimistic.etherscan.io',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockTime: 2,
|
||||
confirmations: 1,
|
||||
},
|
||||
56: {
|
||||
chainId: 56,
|
||||
name: 'BSC (BNB Chain)',
|
||||
rpcUrl: process.env.CHAIN_56_RPC_URL || 'https://bsc-dataseed.binance.org',
|
||||
explorerUrl: 'https://bscscan.com',
|
||||
nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 },
|
||||
blockTime: 3,
|
||||
confirmations: 15,
|
||||
},
|
||||
100: {
|
||||
chainId: 100,
|
||||
name: 'Gnosis Chain',
|
||||
rpcUrl: process.env.CHAIN_100_RPC_URL || 'https://rpc.gnosischain.com',
|
||||
explorerUrl: 'https://gnosisscan.io',
|
||||
nativeCurrency: { name: 'xDAI', symbol: 'xDAI', decimals: 18 },
|
||||
blockTime: 5,
|
||||
confirmations: 12,
|
||||
},
|
||||
137: {
|
||||
chainId: 137,
|
||||
name: 'Polygon',
|
||||
rpcUrl: process.env.CHAIN_137_RPC_URL || 'https://polygon-rpc.com',
|
||||
explorerUrl: 'https://polygonscan.com',
|
||||
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
|
||||
blockTime: 2,
|
||||
confirmations: 128,
|
||||
},
|
||||
42161: {
|
||||
chainId: 42161,
|
||||
name: 'Arbitrum One',
|
||||
rpcUrl: process.env.CHAIN_42161_RPC_URL || 'https://arb1.arbitrum.io/rpc',
|
||||
explorerUrl: 'https://arbiscan.io',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockTime: 0.25,
|
||||
confirmations: 1,
|
||||
},
|
||||
8453: {
|
||||
chainId: 8453,
|
||||
name: 'Base',
|
||||
rpcUrl: process.env.CHAIN_8453_RPC_URL || 'https://mainnet.base.org',
|
||||
explorerUrl: 'https://basescan.org',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockTime: 2,
|
||||
confirmations: 1,
|
||||
},
|
||||
43114: {
|
||||
chainId: 43114,
|
||||
name: 'Avalanche C-Chain',
|
||||
rpcUrl: process.env.CHAIN_43114_RPC_URL || 'https://api.avax.network/ext/bc/C/rpc',
|
||||
explorerUrl: 'https://snowtrace.io',
|
||||
nativeCurrency: { name: 'AVAX', symbol: 'AVAX', decimals: 18 },
|
||||
blockTime: 2,
|
||||
confirmations: 1,
|
||||
},
|
||||
25: {
|
||||
chainId: 25,
|
||||
name: 'Cronos',
|
||||
rpcUrl: process.env.CHAIN_25_RPC_URL || 'https://evm.cronos.org',
|
||||
explorerUrl: 'https://cronoscan.com',
|
||||
nativeCurrency: { name: 'CRO', symbol: 'CRO', decimals: 18 },
|
||||
blockTime: 6,
|
||||
confirmations: 12,
|
||||
},
|
||||
42220: {
|
||||
chainId: 42220,
|
||||
name: 'Celo',
|
||||
rpcUrl: process.env.CHAIN_42220_RPC_URL || 'https://forno.celo.org',
|
||||
explorerUrl: 'https://celoscan.io',
|
||||
nativeCurrency: { name: 'CELO', symbol: 'CELO', decimals: 18 },
|
||||
blockTime: 5,
|
||||
confirmations: 1,
|
||||
},
|
||||
1111: {
|
||||
chainId: 1111,
|
||||
name: 'Wemix',
|
||||
rpcUrl: process.env.CHAIN_1111_RPC_URL || 'https://api.wemix.com',
|
||||
explorerUrl: 'https://scan.wemix.com',
|
||||
nativeCurrency: { name: 'WEMIX', symbol: 'WEMIX', decimals: 18 },
|
||||
blockTime: 2,
|
||||
confirmations: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export function getChainConfig(chainId: number): ChainConfig | undefined {
|
||||
return CHAIN_CONFIGS[chainId];
|
||||
}
|
||||
|
||||
export function getSupportedChainIds(): number[] {
|
||||
return Object.keys(CHAIN_CONFIGS).map((id) => parseInt(id, 10));
|
||||
}
|
||||
210
services/token-aggregation/src/config/dex-factories.ts
Normal file
210
services/token-aggregation/src/config/dex-factories.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export type DexType = 'uniswap_v2' | 'uniswap_v3' | 'dodo' | 'custom';
|
||||
|
||||
export interface UniswapV2Config {
|
||||
factory: string;
|
||||
router: string;
|
||||
startBlock: number;
|
||||
}
|
||||
|
||||
export interface UniswapV3Config {
|
||||
factory: string;
|
||||
router: string;
|
||||
startBlock: number;
|
||||
}
|
||||
|
||||
export interface DodoConfig {
|
||||
poolManager?: string; // DODO PoolManager contract (allPools + poolRegistry ABI)
|
||||
dodoPmmIntegration?: string; // DODOPMMIntegration contract (getAllPools + getPoolConfig + getPoolReserves)
|
||||
dodoVendingMachine?: string; // DODO Vending Machine Factory
|
||||
dodoApprove?: string; // DODO Approve contract
|
||||
startBlock: number;
|
||||
}
|
||||
|
||||
export interface CustomDexConfig {
|
||||
factory: string;
|
||||
router?: string;
|
||||
startBlock: number;
|
||||
pairCreatedEvent?: string; // Event signature for pair creation
|
||||
}
|
||||
|
||||
export interface DexFactoryConfig {
|
||||
uniswap_v2?: UniswapV2Config[];
|
||||
uniswap_v3?: UniswapV3Config[];
|
||||
dodo?: DodoConfig[];
|
||||
custom?: CustomDexConfig[];
|
||||
}
|
||||
|
||||
export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
138: {
|
||||
// DODO PMM Integration - index from DODOPMMIntegration or PoolManager
|
||||
dodo: [
|
||||
{
|
||||
poolManager: process.env.CHAIN_138_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_138_DODO_PMM_INTEGRATION || '',
|
||||
dodoVendingMachine: process.env.CHAIN_138_DODO_VENDING_MACHINE || '',
|
||||
startBlock: 0,
|
||||
},
|
||||
],
|
||||
// UniswapV2 - if deployed
|
||||
uniswap_v2: process.env.CHAIN_138_UNISWAP_V2_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_138_UNISWAP_V2_FACTORY,
|
||||
router: process.env.CHAIN_138_UNISWAP_V2_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_138_UNISWAP_V2_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
// UniswapV3 - if deployed
|
||||
uniswap_v3: process.env.CHAIN_138_UNISWAP_V3_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_138_UNISWAP_V3_FACTORY,
|
||||
router: process.env.CHAIN_138_UNISWAP_V3_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_138_UNISWAP_V3_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
651940: {
|
||||
// ALL Mainnet - DEX factories to be discovered/configured
|
||||
// These can be set via environment variables or discovered on-chain
|
||||
uniswap_v2: process.env.CHAIN_651940_UNISWAP_V2_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_651940_UNISWAP_V2_FACTORY,
|
||||
router: process.env.CHAIN_651940_UNISWAP_V2_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_651940_UNISWAP_V2_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
uniswap_v3: process.env.CHAIN_651940_UNISWAP_V3_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_651940_UNISWAP_V3_FACTORY,
|
||||
router: process.env.CHAIN_651940_UNISWAP_V3_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_651940_UNISWAP_V3_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
dodo: process.env.CHAIN_651940_DODO_POOL_MANAGER
|
||||
? [
|
||||
{
|
||||
poolManager: process.env.CHAIN_651940_DODO_POOL_MANAGER,
|
||||
dodoVendingMachine: process.env.CHAIN_651940_DODO_VENDING_MACHINE || '',
|
||||
startBlock: parseInt(process.env.CHAIN_651940_DODO_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
// cW* edge chains (1, 10, 56, 100, 137): set CHAIN_*_DODO_PMM_INTEGRATION or CHAIN_*_DODO_POOL_MANAGER to index DODO/pools
|
||||
1: {
|
||||
dodo:
|
||||
process.env.CHAIN_1_DODO_PMM_INTEGRATION || process.env.CHAIN_1_DODO_POOL_MANAGER
|
||||
? [
|
||||
{
|
||||
poolManager: process.env.CHAIN_1_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_1_DODO_PMM_INTEGRATION || '',
|
||||
dodoVendingMachine: process.env.CHAIN_1_DODO_VENDING_MACHINE || '',
|
||||
startBlock: parseInt(process.env.CHAIN_1_DODO_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
10: {
|
||||
dodo:
|
||||
process.env.CHAIN_10_DODO_PMM_INTEGRATION || process.env.CHAIN_10_DODO_POOL_MANAGER
|
||||
? [
|
||||
{
|
||||
poolManager: process.env.CHAIN_10_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_10_DODO_PMM_INTEGRATION || '',
|
||||
dodoVendingMachine: process.env.CHAIN_10_DODO_VENDING_MACHINE || '',
|
||||
startBlock: parseInt(process.env.CHAIN_10_DODO_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
56: {
|
||||
dodo:
|
||||
process.env.CHAIN_56_DODO_PMM_INTEGRATION || process.env.CHAIN_56_DODO_POOL_MANAGER
|
||||
? [
|
||||
{
|
||||
poolManager: process.env.CHAIN_56_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_56_DODO_PMM_INTEGRATION || '',
|
||||
dodoVendingMachine: process.env.CHAIN_56_DODO_VENDING_MACHINE || '',
|
||||
startBlock: parseInt(process.env.CHAIN_56_DODO_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
100: {
|
||||
dodo:
|
||||
process.env.CHAIN_100_DODO_PMM_INTEGRATION || process.env.CHAIN_100_DODO_POOL_MANAGER
|
||||
? [
|
||||
{
|
||||
poolManager: process.env.CHAIN_100_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_100_DODO_PMM_INTEGRATION || '',
|
||||
dodoVendingMachine: process.env.CHAIN_100_DODO_VENDING_MACHINE || '',
|
||||
startBlock: parseInt(process.env.CHAIN_100_DODO_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
137: {
|
||||
dodo:
|
||||
process.env.CHAIN_137_DODO_PMM_INTEGRATION || process.env.CHAIN_137_DODO_POOL_MANAGER
|
||||
? [
|
||||
{
|
||||
poolManager: process.env.CHAIN_137_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_137_DODO_PMM_INTEGRATION || '',
|
||||
dodoVendingMachine: process.env.CHAIN_137_DODO_VENDING_MACHINE || '',
|
||||
startBlock: parseInt(process.env.CHAIN_137_DODO_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DEX factory configuration for a chain
|
||||
*/
|
||||
export function getDexFactories(chainId: number): DexFactoryConfig | undefined {
|
||||
return DEX_FACTORIES[chainId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a DEX type is configured for a chain
|
||||
*/
|
||||
export function hasDexType(chainId: number, dexType: DexType): boolean {
|
||||
const config = DEX_FACTORIES[chainId];
|
||||
if (!config) return false;
|
||||
|
||||
switch (dexType) {
|
||||
case 'uniswap_v2':
|
||||
return !!config.uniswap_v2 && config.uniswap_v2.length > 0;
|
||||
case 'uniswap_v3':
|
||||
return !!config.uniswap_v3 && config.uniswap_v3.length > 0;
|
||||
case 'dodo':
|
||||
return !!config.dodo && config.dodo.length > 0;
|
||||
case 'custom':
|
||||
return !!config.custom && config.custom.length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured DEX types for a chain
|
||||
*/
|
||||
export function getConfiguredDexTypes(chainId: number): DexType[] {
|
||||
const config = DEX_FACTORIES[chainId];
|
||||
if (!config) return [];
|
||||
|
||||
const types: DexType[] = [];
|
||||
if (hasDexType(chainId, 'uniswap_v2')) types.push('uniswap_v2');
|
||||
if (hasDexType(chainId, 'uniswap_v3')) types.push('uniswap_v3');
|
||||
if (hasDexType(chainId, 'dodo')) types.push('dodo');
|
||||
if (hasDexType(chainId, 'custom')) types.push('custom');
|
||||
|
||||
return types;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* ISO-4217 compliant token symbol registry.
|
||||
* v0 symbols (cUSDT, cUSDC) are maintained on ChainID 138 only; the X designator is left out on 138.
|
||||
* X is used only for origin reference (e.g. bridged cWXUSDC). See docs/04-configuration/ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
*/
|
||||
|
||||
export type AssetTypeChar = 'C' | 'T' | 'W';
|
||||
|
||||
export interface V1SymbolIdentity {
|
||||
iso: string;
|
||||
type: AssetTypeChar;
|
||||
originChain: string;
|
||||
}
|
||||
|
||||
/** v0 symbol (Chain 138 only) → identity; originChain 'X' denotes 138 for reporting/bridged reference only */
|
||||
export const V0_TO_V1_SYMBOL_MAP: Record<string, V1SymbolIdentity> = {
|
||||
cUSDT: { iso: 'USD', type: 'T', originChain: 'X' },
|
||||
cUSDC: { iso: 'USD', type: 'C', originChain: 'X' },
|
||||
};
|
||||
|
||||
/** Financial chain designators: X = Chain 138 (origin only), A = Alltra. Chain 138 native symbols use v0 (no X). */
|
||||
export const FIN_CHAIN_SET = ['X', 'A'] as const;
|
||||
export type FinChainDesignator = (typeof FIN_CHAIN_SET)[number];
|
||||
|
||||
/** Supported ISO-4217 codes (3 letters). Extend via registry. */
|
||||
export const ISO4217_SUPPORTED = [
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'JPY',
|
||||
'AUD',
|
||||
'CHF',
|
||||
'CAD',
|
||||
'CNY',
|
||||
'XAU',
|
||||
] as const;
|
||||
export type ISO4217Code = (typeof ISO4217_SUPPORTED)[number];
|
||||
|
||||
/** Valid type suffix for compliant symbols */
|
||||
export const ASSET_TYPE_SET = ['C', 'T', 'W'] as const;
|
||||
|
||||
export function isFinChainDesignator(c: string): boolean {
|
||||
return (FIN_CHAIN_SET as readonly string[]).includes(c);
|
||||
}
|
||||
|
||||
export function isISO4217Supported(code: string): boolean {
|
||||
return (ISO4217_SUPPORTED as readonly string[]).includes(code);
|
||||
}
|
||||
|
||||
export function isAssetTypeChar(c: string): boolean {
|
||||
return (ASSET_TYPE_SET as readonly string[]).includes(c);
|
||||
}
|
||||
|
||||
export function getV1IdentityForV0Symbol(v0Symbol: string): V1SymbolIdentity | undefined {
|
||||
return V0_TO_V1_SYMBOL_MAP[v0Symbol];
|
||||
}
|
||||
187
services/token-aggregation/src/config/networks.ts
Normal file
187
services/token-aggregation/src/config/networks.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Full EIP-3085 chain params and oracles for Snap / wallet_addEthereumChain.
|
||||
* Single source of truth for Chain 138, Ethereum Mainnet, and ALL Mainnet.
|
||||
*/
|
||||
|
||||
export interface OracleEntry {
|
||||
name: string;
|
||||
address: string;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
export interface NetworkEntry {
|
||||
chainId: string;
|
||||
chainIdDecimal: number;
|
||||
chainName: string;
|
||||
rpcUrls: string[];
|
||||
nativeCurrency: { name: string; symbol: string; decimals: number };
|
||||
blockExplorerUrls: string[];
|
||||
iconUrls?: string[];
|
||||
oracles: OracleEntry[];
|
||||
}
|
||||
|
||||
export const NETWORKS: NetworkEntry[] = [
|
||||
{
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrls: [
|
||||
'https://rpc-http-pub.d-bis.org',
|
||||
'https://rpc.d-bis.org',
|
||||
'https://rpc2.d-bis.org',
|
||||
'https://rpc.defi-oracle.io',
|
||||
],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
iconUrls: [
|
||||
'https://explorer.d-bis.org/favicon.ico',
|
||||
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png',
|
||||
],
|
||||
oracles: [
|
||||
{ name: 'ETH/USD', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6', decimals: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
chainId: '0x1',
|
||||
chainIdDecimal: 1,
|
||||
chainName: 'Ethereum Mainnet',
|
||||
rpcUrls: [
|
||||
'https://eth.llamarpc.com',
|
||||
'https://rpc.ankr.com/eth',
|
||||
'https://ethereum.publicnode.com',
|
||||
'https://1rpc.io/eth',
|
||||
],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://etherscan.io'],
|
||||
iconUrls: [
|
||||
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png',
|
||||
],
|
||||
oracles: [
|
||||
{ name: 'ETH/USD', address: '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419', decimals: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
chainId: '0x9f2c4',
|
||||
chainIdDecimal: 651940,
|
||||
chainName: 'ALL Mainnet',
|
||||
rpcUrls: ['https://mainnet-rpc.alltra.global'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://alltra.global'],
|
||||
iconUrls: [
|
||||
'https://alltra.global/favicon.ico',
|
||||
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png',
|
||||
],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0x38',
|
||||
chainIdDecimal: 56,
|
||||
chainName: 'BNB Smart Chain',
|
||||
rpcUrls: ['https://bsc-dataseed.binance.org', 'https://bsc-dataseed1.defibit.io'],
|
||||
nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 },
|
||||
blockExplorerUrls: ['https://bscscan.com'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/smartchain/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0x89',
|
||||
chainIdDecimal: 137,
|
||||
chainName: 'Polygon',
|
||||
rpcUrls: ['https://polygon-rpc.com', 'https://rpc.ankr.com/polygon'],
|
||||
nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
|
||||
blockExplorerUrls: ['https://polygonscan.com'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/polygon/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0x64',
|
||||
chainIdDecimal: 100,
|
||||
chainName: 'Gnosis Chain',
|
||||
rpcUrls: ['https://rpc.gnosischain.com', 'https://rpc.ankr.com/gnosis'],
|
||||
nativeCurrency: { name: 'xDAI', symbol: 'xDAI', decimals: 18 },
|
||||
blockExplorerUrls: ['https://gnosisscan.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/xdai/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0xa',
|
||||
chainIdDecimal: 10,
|
||||
chainName: 'Optimism',
|
||||
rpcUrls: ['https://mainnet.optimism.io', 'https://optimism.llamarpc.com'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://optimistic.etherscan.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/optimism/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0xa4b1',
|
||||
chainIdDecimal: 42161,
|
||||
chainName: 'Arbitrum One',
|
||||
rpcUrls: ['https://arb1.arbitrum.io/rpc', 'https://arbitrum.llamarpc.com'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://arbiscan.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/arbitrum/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0x2105',
|
||||
chainIdDecimal: 8453,
|
||||
chainName: 'Base',
|
||||
rpcUrls: ['https://mainnet.base.org', 'https://base.llamarpc.com'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://basescan.org'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/base/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0xa86a',
|
||||
chainIdDecimal: 43114,
|
||||
chainName: 'Avalanche C-Chain',
|
||||
rpcUrls: ['https://api.avax.network/ext/bc/C/rpc', 'https://avalanche-c-chain-rpc.publicnode.com'],
|
||||
nativeCurrency: { name: 'AVAX', symbol: 'AVAX', decimals: 18 },
|
||||
blockExplorerUrls: ['https://snowtrace.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/avalanchec/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0x19',
|
||||
chainIdDecimal: 25,
|
||||
chainName: 'Cronos',
|
||||
rpcUrls: ['https://evm.cronos.org', 'https://cronos-rpc.publicnode.com'],
|
||||
nativeCurrency: { name: 'CRO', symbol: 'CRO', decimals: 18 },
|
||||
blockExplorerUrls: ['https://cronoscan.com'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/cronos/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0xa4ec',
|
||||
chainIdDecimal: 42220,
|
||||
chainName: 'Celo',
|
||||
rpcUrls: ['https://forno.celo.org', 'https://celo-rpc.publicnode.com'],
|
||||
nativeCurrency: { name: 'CELO', symbol: 'CELO', decimals: 18 },
|
||||
blockExplorerUrls: ['https://celoscan.io'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/celo/info/logo.png'],
|
||||
oracles: [],
|
||||
},
|
||||
{
|
||||
chainId: '0x457',
|
||||
chainIdDecimal: 1111,
|
||||
chainName: 'Wemix',
|
||||
rpcUrls: ['https://api.wemix.com', 'https://wemix-rpc.publicnode.com'],
|
||||
nativeCurrency: { name: 'WEMIX', symbol: 'WEMIX', decimals: 18 },
|
||||
blockExplorerUrls: ['https://scan.wemix.com'],
|
||||
iconUrls: ['https://scan.wemix.com/favicon.ico'],
|
||||
oracles: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const API_VERSION = '1.0.0';
|
||||
|
||||
export function getNetworks(): NetworkEntry[] {
|
||||
return NETWORKS;
|
||||
}
|
||||
|
||||
export function getConfigByChain(chainId: number): { oracles: OracleEntry[] } | undefined {
|
||||
const net = NETWORKS.find((n) => n.chainIdDecimal === chainId);
|
||||
return net ? { oracles: net.oracles } : undefined;
|
||||
}
|
||||
53
services/token-aggregation/src/database/client.ts
Normal file
53
services/token-aggregation/src/database/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Pool, PoolConfig } from 'pg';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
let pool: Pool | null = null;
|
||||
|
||||
export interface DatabaseConfig {
|
||||
connectionString?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
user?: string;
|
||||
password?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function getDatabasePool(): Pool {
|
||||
if (pool) {
|
||||
return pool;
|
||||
}
|
||||
|
||||
const config: PoolConfig = {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
min: parseInt(process.env.DATABASE_POOL_MIN || '2', 10),
|
||||
max: parseInt(process.env.DATABASE_POOL_MAX || '10', 10),
|
||||
};
|
||||
|
||||
// If connectionString is not provided, use individual config
|
||||
if (!config.connectionString) {
|
||||
config.host = process.env.DB_HOST || 'localhost';
|
||||
config.port = parseInt(process.env.DB_PORT || '5432', 10);
|
||||
config.database = process.env.DB_NAME || 'explorer_db';
|
||||
config.user = process.env.DB_USER || 'postgres';
|
||||
config.password = process.env.DB_PASSWORD || '';
|
||||
}
|
||||
|
||||
pool = new Pool(config);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle database client', err);
|
||||
});
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function closeDatabasePool(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
export interface ApiKey {
|
||||
id?: number;
|
||||
provider: 'coingecko' | 'coinmarketcap' | 'dexscreener' | 'custom';
|
||||
keyName: string;
|
||||
apiKeyEncrypted: string;
|
||||
isActive: boolean;
|
||||
rateLimitPerMinute?: number;
|
||||
rateLimitPerDay?: number;
|
||||
lastUsedAt?: Date;
|
||||
expiresAt?: Date;
|
||||
createdBy?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ApiEndpoint {
|
||||
id?: number;
|
||||
chainId: number;
|
||||
endpointType: 'rpc' | 'explorer' | 'indexer' | 'custom';
|
||||
endpointName: string;
|
||||
endpointUrl: string;
|
||||
isPrimary: boolean;
|
||||
isActive: boolean;
|
||||
requiresAuth: boolean;
|
||||
authType?: 'jwt' | 'api_key' | 'basic' | 'none';
|
||||
authConfig?: any;
|
||||
rateLimitPerMinute?: number;
|
||||
timeoutMs: number;
|
||||
healthCheckEnabled: boolean;
|
||||
lastHealthCheck?: Date;
|
||||
healthCheckStatus?: 'healthy' | 'unhealthy' | 'unknown';
|
||||
createdBy?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface DexFactoryConfig {
|
||||
id?: number;
|
||||
chainId: number;
|
||||
dexType: 'uniswap_v2' | 'uniswap_v3' | 'dodo' | 'custom';
|
||||
factoryAddress: string;
|
||||
routerAddress?: string;
|
||||
poolManagerAddress?: string;
|
||||
startBlock: number;
|
||||
isActive: boolean;
|
||||
description?: string;
|
||||
createdBy?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id?: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
passwordHash: string;
|
||||
role: 'super_admin' | 'admin' | 'operator' | 'viewer';
|
||||
isActive: boolean;
|
||||
lastLogin?: Date;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export class AdminRepository {
|
||||
public pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
// API Keys Management
|
||||
async createApiKey(apiKey: ApiKey): Promise<ApiKey> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO api_keys (
|
||||
provider, key_name, api_key_encrypted, is_active,
|
||||
rate_limit_per_minute, rate_limit_per_day, expires_at, created_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
apiKey.provider,
|
||||
apiKey.keyName,
|
||||
apiKey.apiKeyEncrypted,
|
||||
apiKey.isActive,
|
||||
apiKey.rateLimitPerMinute,
|
||||
apiKey.rateLimitPerDay,
|
||||
apiKey.expiresAt,
|
||||
apiKey.createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
return this.mapApiKey(result.rows[0]);
|
||||
}
|
||||
|
||||
async getApiKeys(provider?: string): Promise<ApiKey[]> {
|
||||
let query = `SELECT * FROM api_keys WHERE is_active = true`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (provider) {
|
||||
query += ` AND provider = $1`;
|
||||
params.push(provider);
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
return result.rows.map((row) => this.mapApiKey(row));
|
||||
}
|
||||
|
||||
async getApiKey(id: number): Promise<ApiKey | null> {
|
||||
const result = await this.pool.query(`SELECT * FROM api_keys WHERE id = $1`, [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.mapApiKey(result.rows[0]);
|
||||
}
|
||||
|
||||
async updateApiKey(id: number, updates: Partial<ApiKey>): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (updates.isActive !== undefined) {
|
||||
fields.push(`is_active = $${paramCount++}`);
|
||||
values.push(updates.isActive);
|
||||
}
|
||||
if (updates.rateLimitPerMinute !== undefined) {
|
||||
fields.push(`rate_limit_per_minute = $${paramCount++}`);
|
||||
values.push(updates.rateLimitPerMinute);
|
||||
}
|
||||
if (updates.expiresAt !== undefined) {
|
||||
fields.push(`expires_at = $${paramCount++}`);
|
||||
values.push(updates.expiresAt);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
await this.pool.query(
|
||||
`UPDATE api_keys SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${paramCount}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
async deleteApiKey(id: number): Promise<void> {
|
||||
await this.pool.query(`UPDATE api_keys SET is_active = false, updated_at = NOW() WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
// API Endpoints Management
|
||||
async createEndpoint(endpoint: ApiEndpoint): Promise<ApiEndpoint> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO api_endpoints (
|
||||
chain_id, endpoint_type, endpoint_name, endpoint_url,
|
||||
is_primary, is_active, requires_auth, auth_type, auth_config,
|
||||
rate_limit_per_minute, timeout_ms, health_check_enabled, created_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
endpoint.chainId,
|
||||
endpoint.endpointType,
|
||||
endpoint.endpointName,
|
||||
endpoint.endpointUrl,
|
||||
endpoint.isPrimary,
|
||||
endpoint.isActive,
|
||||
endpoint.requiresAuth,
|
||||
endpoint.authType,
|
||||
endpoint.authConfig ? JSON.stringify(endpoint.authConfig) : null,
|
||||
endpoint.rateLimitPerMinute,
|
||||
endpoint.timeoutMs,
|
||||
endpoint.healthCheckEnabled,
|
||||
endpoint.createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
return this.mapEndpoint(result.rows[0]);
|
||||
}
|
||||
|
||||
async getEndpoints(chainId?: number, endpointType?: string): Promise<ApiEndpoint[]> {
|
||||
let query = `SELECT * FROM api_endpoints WHERE is_active = true`;
|
||||
const params: any[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (chainId) {
|
||||
query += ` AND chain_id = $${paramCount++}`;
|
||||
params.push(chainId);
|
||||
}
|
||||
if (endpointType) {
|
||||
query += ` AND endpoint_type = $${paramCount++}`;
|
||||
params.push(endpointType);
|
||||
}
|
||||
|
||||
query += ` ORDER BY chain_id, endpoint_type, is_primary DESC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
return result.rows.map((row) => this.mapEndpoint(row));
|
||||
}
|
||||
|
||||
async updateEndpoint(id: number, updates: Partial<ApiEndpoint>): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (updates.endpointUrl !== undefined) {
|
||||
fields.push(`endpoint_url = $${paramCount++}`);
|
||||
values.push(updates.endpointUrl);
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
fields.push(`is_active = $${paramCount++}`);
|
||||
values.push(updates.isActive);
|
||||
}
|
||||
if (updates.isPrimary !== undefined) {
|
||||
fields.push(`is_primary = $${paramCount++}`);
|
||||
values.push(updates.isPrimary);
|
||||
}
|
||||
if (updates.healthCheckStatus !== undefined) {
|
||||
fields.push(`health_check_status = $${paramCount++}, last_health_check = NOW()`);
|
||||
values.push(updates.healthCheckStatus);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
values.push(id);
|
||||
await this.pool.query(
|
||||
`UPDATE api_endpoints SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${paramCount}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
// DEX Factory Management
|
||||
async createDexFactory(config: DexFactoryConfig): Promise<DexFactoryConfig> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO dex_factory_config (
|
||||
chain_id, dex_type, factory_address, router_address,
|
||||
pool_manager_address, start_block, is_active, description, created_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
config.chainId,
|
||||
config.dexType,
|
||||
config.factoryAddress.toLowerCase(),
|
||||
config.routerAddress?.toLowerCase(),
|
||||
config.poolManagerAddress?.toLowerCase(),
|
||||
config.startBlock,
|
||||
config.isActive,
|
||||
config.description,
|
||||
config.createdBy,
|
||||
]
|
||||
);
|
||||
|
||||
return this.mapDexFactory(result.rows[0]);
|
||||
}
|
||||
|
||||
async getDexFactories(chainId?: number): Promise<DexFactoryConfig[]> {
|
||||
let query = `SELECT * FROM dex_factory_config WHERE is_active = true`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (chainId) {
|
||||
query += ` AND chain_id = $1`;
|
||||
params.push(chainId);
|
||||
}
|
||||
|
||||
query += ` ORDER BY chain_id, dex_type`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
return result.rows.map((row) => this.mapDexFactory(row));
|
||||
}
|
||||
|
||||
// Admin Users
|
||||
async createAdminUser(user: Omit<AdminUser, 'id' | 'createdAt' | 'updatedAt'>): Promise<AdminUser> {
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO admin_users (username, email, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[user.username, user.email, user.passwordHash, user.role, user.isActive]
|
||||
);
|
||||
|
||||
return this.mapAdminUser(result.rows[0]);
|
||||
}
|
||||
|
||||
async getAdminUserByUsername(username: string): Promise<AdminUser | null> {
|
||||
const result = await this.pool.query(`SELECT * FROM admin_users WHERE username = $1`, [username]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.mapAdminUser(result.rows[0]);
|
||||
}
|
||||
|
||||
async verifyPassword(user: AdminUser, password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, user.passwordHash);
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
// Audit Log
|
||||
async createAuditLog(
|
||||
userId: number | null,
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: number | null,
|
||||
oldValues: any,
|
||||
newValues: any,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO admin_audit_log (
|
||||
user_id, action, resource_type, resource_id,
|
||||
old_values, new_values, ip_address, user_agent
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
oldValues ? JSON.stringify(oldValues) : null,
|
||||
newValues ? JSON.stringify(newValues) : null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Mappers
|
||||
private mapApiKey(row: any): ApiKey {
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
keyName: row.key_name,
|
||||
apiKeyEncrypted: row.api_key_encrypted,
|
||||
isActive: row.is_active,
|
||||
rateLimitPerMinute: row.rate_limit_per_minute,
|
||||
rateLimitPerDay: row.rate_limit_per_day,
|
||||
lastUsedAt: row.last_used_at,
|
||||
expiresAt: row.expires_at,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapEndpoint(row: any): ApiEndpoint {
|
||||
return {
|
||||
id: row.id,
|
||||
chainId: row.chain_id,
|
||||
endpointType: row.endpoint_type,
|
||||
endpointName: row.endpoint_name,
|
||||
endpointUrl: row.endpoint_url,
|
||||
isPrimary: row.is_primary,
|
||||
isActive: row.is_active,
|
||||
requiresAuth: row.requires_auth,
|
||||
authType: row.auth_type,
|
||||
authConfig: row.auth_config,
|
||||
rateLimitPerMinute: row.rate_limit_per_minute,
|
||||
timeoutMs: row.timeout_ms,
|
||||
healthCheckEnabled: row.health_check_enabled,
|
||||
lastHealthCheck: row.last_health_check,
|
||||
healthCheckStatus: row.health_check_status,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapDexFactory(row: any): DexFactoryConfig {
|
||||
return {
|
||||
id: row.id,
|
||||
chainId: row.chain_id,
|
||||
dexType: row.dex_type,
|
||||
factoryAddress: row.factory_address,
|
||||
routerAddress: row.router_address,
|
||||
poolManagerAddress: row.pool_manager_address,
|
||||
startBlock: parseInt(row.start_block, 10),
|
||||
isActive: row.is_active,
|
||||
description: row.description,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private mapAdminUser(row: any): AdminUser {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
passwordHash: row.password_hash,
|
||||
role: row.role,
|
||||
isActive: row.is_active,
|
||||
lastLogin: row.last_login,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
import { TokenMarketData } from './token-repo';
|
||||
|
||||
export class MarketDataRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
async getMarketData(chainId: number, tokenAddress: string): Promise<TokenMarketData | null> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, token_address, price_usd, price_change_24h, volume_24h, volume_7d, volume_30d,
|
||||
market_cap_usd, liquidity_usd, holders_count, transfers_24h, last_updated
|
||||
FROM token_market_data
|
||||
WHERE chain_id = $1 AND token_address = $2`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
chainId: row.chain_id,
|
||||
tokenAddress: row.token_address,
|
||||
priceUsd: row.price_usd ? parseFloat(row.price_usd) : undefined,
|
||||
priceChange24h: row.price_change_24h ? parseFloat(row.price_change_24h) : undefined,
|
||||
volume24h: parseFloat(row.volume_24h || '0'),
|
||||
volume7d: parseFloat(row.volume_7d || '0'),
|
||||
volume30d: parseFloat(row.volume_30d || '0'),
|
||||
marketCapUsd: row.market_cap_usd ? parseFloat(row.market_cap_usd) : undefined,
|
||||
liquidityUsd: parseFloat(row.liquidity_usd || '0'),
|
||||
holdersCount: row.holders_count || 0,
|
||||
transfers24h: row.transfers_24h || 0,
|
||||
lastUpdated: row.last_updated,
|
||||
};
|
||||
}
|
||||
|
||||
async upsertMarketData(data: TokenMarketData): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_market_data (
|
||||
chain_id, token_address, price_usd, price_change_24h, volume_24h, volume_7d, volume_30d,
|
||||
market_cap_usd, liquidity_usd, holders_count, transfers_24h, last_updated
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (chain_id, token_address) DO UPDATE SET
|
||||
price_usd = EXCLUDED.price_usd,
|
||||
price_change_24h = EXCLUDED.price_change_24h,
|
||||
volume_24h = EXCLUDED.volume_24h,
|
||||
volume_7d = EXCLUDED.volume_7d,
|
||||
volume_30d = EXCLUDED.volume_30d,
|
||||
market_cap_usd = EXCLUDED.market_cap_usd,
|
||||
liquidity_usd = EXCLUDED.liquidity_usd,
|
||||
holders_count = EXCLUDED.holders_count,
|
||||
transfers_24h = EXCLUDED.transfers_24h,
|
||||
last_updated = EXCLUDED.last_updated`,
|
||||
[
|
||||
data.chainId,
|
||||
data.tokenAddress.toLowerCase(),
|
||||
data.priceUsd,
|
||||
data.priceChange24h,
|
||||
data.volume24h,
|
||||
data.volume7d,
|
||||
data.volume30d,
|
||||
data.marketCapUsd,
|
||||
data.liquidityUsd,
|
||||
data.holdersCount,
|
||||
data.transfers24h,
|
||||
data.lastUpdated,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getTopTokensByVolume(chainId: number, limit: number = 50): Promise<TokenMarketData[]> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, token_address, price_usd, price_change_24h, volume_24h, volume_7d, volume_30d,
|
||||
market_cap_usd, liquidity_usd, holders_count, transfers_24h, last_updated
|
||||
FROM token_market_data
|
||||
WHERE chain_id = $1 AND volume_24h > 0
|
||||
ORDER BY volume_24h DESC
|
||||
LIMIT $2`,
|
||||
[chainId, limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
chainId: row.chain_id,
|
||||
tokenAddress: row.token_address,
|
||||
priceUsd: row.price_usd ? parseFloat(row.price_usd) : undefined,
|
||||
priceChange24h: row.price_change_24h ? parseFloat(row.price_change_24h) : undefined,
|
||||
volume24h: parseFloat(row.volume_24h || '0'),
|
||||
volume7d: parseFloat(row.volume_7d || '0'),
|
||||
volume30d: parseFloat(row.volume_30d || '0'),
|
||||
marketCapUsd: row.market_cap_usd ? parseFloat(row.market_cap_usd) : undefined,
|
||||
liquidityUsd: parseFloat(row.liquidity_usd || '0'),
|
||||
holdersCount: row.holders_count || 0,
|
||||
transfers24h: row.transfers_24h || 0,
|
||||
lastUpdated: row.last_updated,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopTokensByLiquidity(chainId: number, limit: number = 50): Promise<TokenMarketData[]> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, token_address, price_usd, price_change_24h, volume_24h, volume_7d, volume_30d,
|
||||
market_cap_usd, liquidity_usd, holders_count, transfers_24h, last_updated
|
||||
FROM token_market_data
|
||||
WHERE chain_id = $1 AND liquidity_usd > 0
|
||||
ORDER BY liquidity_usd DESC
|
||||
LIMIT $2`,
|
||||
[chainId, limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
chainId: row.chain_id,
|
||||
tokenAddress: row.token_address,
|
||||
priceUsd: row.price_usd ? parseFloat(row.price_usd) : undefined,
|
||||
priceChange24h: row.price_change_24h ? parseFloat(row.price_change_24h) : undefined,
|
||||
volume24h: parseFloat(row.volume_24h || '0'),
|
||||
volume7d: parseFloat(row.volume_7d || '0'),
|
||||
volume30d: parseFloat(row.volume_30d || '0'),
|
||||
marketCapUsd: row.market_cap_usd ? parseFloat(row.market_cap_usd) : undefined,
|
||||
liquidityUsd: parseFloat(row.liquidity_usd || '0'),
|
||||
holdersCount: row.holders_count || 0,
|
||||
transfers24h: row.transfers_24h || 0,
|
||||
lastUpdated: row.last_updated,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
|
||||
export type DexType = 'uniswap_v2' | 'uniswap_v3' | 'dodo' | 'custom';
|
||||
|
||||
export interface LiquidityPool {
|
||||
id?: number;
|
||||
chainId: number;
|
||||
poolAddress: string;
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
dexType: DexType;
|
||||
factoryAddress?: string;
|
||||
routerAddress?: string;
|
||||
reserve0: string;
|
||||
reserve1: string;
|
||||
reserve0Usd: number;
|
||||
reserve1Usd: number;
|
||||
totalLiquidityUsd: number;
|
||||
volume24h: number;
|
||||
feeTier?: number;
|
||||
createdAtBlock?: number;
|
||||
createdAtTimestamp?: Date;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export interface PoolReserveSnapshot {
|
||||
chainId: number;
|
||||
poolAddress: string;
|
||||
reserve0: string;
|
||||
reserve1: string;
|
||||
reserve0Usd?: number;
|
||||
reserve1Usd?: number;
|
||||
totalLiquidityUsd?: number;
|
||||
blockNumber: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class PoolRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
async getPool(chainId: number, poolAddress: string): Promise<LiquidityPool | null> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT id, chain_id, pool_address, token0_address, token1_address, dex_type,
|
||||
factory_address, router_address, reserve0, reserve1, reserve0_usd, reserve1_usd,
|
||||
total_liquidity_usd, volume_24h, fee_tier, created_at_block, created_at_timestamp, last_updated
|
||||
FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND pool_address = $2`,
|
||||
[chainId, poolAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapRowToPool(result.rows[0]);
|
||||
}
|
||||
|
||||
async getPoolsByChain(chainId: number, limit: number = 500): Promise<LiquidityPool[]> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT id, chain_id, pool_address, token0_address, token1_address, dex_type,
|
||||
factory_address, router_address, reserve0, reserve1, reserve0_usd, reserve1_usd,
|
||||
total_liquidity_usd, volume_24h, fee_tier, created_at_block, created_at_timestamp, last_updated
|
||||
FROM liquidity_pools
|
||||
WHERE chain_id = $1
|
||||
ORDER BY total_liquidity_usd DESC NULLS LAST
|
||||
LIMIT $2`,
|
||||
[chainId, limit]
|
||||
);
|
||||
return result.rows.map((row) => this.mapRowToPool(row));
|
||||
}
|
||||
|
||||
async getPoolsByToken(chainId: number, tokenAddress: string): Promise<LiquidityPool[]> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT id, chain_id, pool_address, token0_address, token1_address, dex_type,
|
||||
factory_address, router_address, reserve0, reserve1, reserve0_usd, reserve1_usd,
|
||||
total_liquidity_usd, volume_24h, fee_tier, created_at_block, created_at_timestamp, last_updated
|
||||
FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)
|
||||
ORDER BY total_liquidity_usd DESC`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => this.mapRowToPool(row));
|
||||
}
|
||||
|
||||
async upsertPool(pool: LiquidityPool): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO liquidity_pools (
|
||||
chain_id, pool_address, token0_address, token1_address, dex_type,
|
||||
factory_address, router_address, reserve0, reserve1, reserve0_usd, reserve1_usd,
|
||||
total_liquidity_usd, volume_24h, fee_tier, created_at_block, created_at_timestamp, last_updated
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (chain_id, pool_address) DO UPDATE SET
|
||||
token0_address = EXCLUDED.token0_address,
|
||||
token1_address = EXCLUDED.token1_address,
|
||||
dex_type = EXCLUDED.dex_type,
|
||||
factory_address = EXCLUDED.factory_address,
|
||||
router_address = EXCLUDED.router_address,
|
||||
reserve0 = EXCLUDED.reserve0,
|
||||
reserve1 = EXCLUDED.reserve1,
|
||||
reserve0_usd = EXCLUDED.reserve0_usd,
|
||||
reserve1_usd = EXCLUDED.reserve1_usd,
|
||||
total_liquidity_usd = EXCLUDED.total_liquidity_usd,
|
||||
volume_24h = EXCLUDED.volume_24h,
|
||||
fee_tier = EXCLUDED.fee_tier,
|
||||
last_updated = EXCLUDED.last_updated`,
|
||||
[
|
||||
pool.chainId,
|
||||
pool.poolAddress.toLowerCase(),
|
||||
pool.token0Address.toLowerCase(),
|
||||
pool.token1Address.toLowerCase(),
|
||||
pool.dexType,
|
||||
pool.factoryAddress?.toLowerCase(),
|
||||
pool.routerAddress?.toLowerCase(),
|
||||
pool.reserve0,
|
||||
pool.reserve1,
|
||||
pool.reserve0Usd,
|
||||
pool.reserve1Usd,
|
||||
pool.totalLiquidityUsd,
|
||||
pool.volume24h,
|
||||
pool.feeTier,
|
||||
pool.createdAtBlock,
|
||||
pool.createdAtTimestamp,
|
||||
pool.lastUpdated,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async addReserveSnapshot(snapshot: PoolReserveSnapshot): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO pool_reserves_history (
|
||||
chain_id, pool_address, reserve0, reserve1, reserve0_usd, reserve1_usd,
|
||||
total_liquidity_usd, block_number, timestamp
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
snapshot.chainId,
|
||||
snapshot.poolAddress.toLowerCase(),
|
||||
snapshot.reserve0,
|
||||
snapshot.reserve1,
|
||||
snapshot.reserve0Usd,
|
||||
snapshot.reserve1Usd,
|
||||
snapshot.totalLiquidityUsd,
|
||||
snapshot.blockNumber,
|
||||
snapshot.timestamp,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getReserveHistory(
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
limit: number = 1000
|
||||
): Promise<PoolReserveSnapshot[]> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, pool_address, reserve0, reserve1, reserve0_usd, reserve1_usd,
|
||||
total_liquidity_usd, block_number, timestamp
|
||||
FROM pool_reserves_history
|
||||
WHERE chain_id = $1 AND pool_address = $2 AND timestamp >= $3 AND timestamp <= $4
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $5`,
|
||||
[chainId, poolAddress.toLowerCase(), from, to, limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
chainId: row.chain_id,
|
||||
poolAddress: row.pool_address,
|
||||
reserve0: row.reserve0,
|
||||
reserve1: row.reserve1,
|
||||
reserve0Usd: row.reserve0_usd ? parseFloat(row.reserve0_usd) : undefined,
|
||||
reserve1Usd: row.reserve1_usd ? parseFloat(row.reserve1_usd) : undefined,
|
||||
totalLiquidityUsd: row.total_liquidity_usd ? parseFloat(row.total_liquidity_usd) : undefined,
|
||||
blockNumber: parseInt(row.block_number, 10),
|
||||
timestamp: row.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
private mapRowToPool(row: any): LiquidityPool {
|
||||
return {
|
||||
id: row.id,
|
||||
chainId: row.chain_id,
|
||||
poolAddress: row.pool_address,
|
||||
token0Address: row.token0_address,
|
||||
token1Address: row.token1_address,
|
||||
dexType: row.dex_type,
|
||||
factoryAddress: row.factory_address,
|
||||
routerAddress: row.router_address,
|
||||
reserve0: row.reserve0,
|
||||
reserve1: row.reserve1,
|
||||
reserve0Usd: parseFloat(row.reserve0_usd || '0'),
|
||||
reserve1Usd: parseFloat(row.reserve1_usd || '0'),
|
||||
totalLiquidityUsd: parseFloat(row.total_liquidity_usd || '0'),
|
||||
volume24h: parseFloat(row.volume_24h || '0'),
|
||||
feeTier: row.fee_tier,
|
||||
createdAtBlock: row.created_at_block,
|
||||
createdAtTimestamp: row.created_at_timestamp,
|
||||
lastUpdated: row.last_updated,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
|
||||
export interface Token {
|
||||
chainId: number;
|
||||
address: string;
|
||||
name?: string;
|
||||
symbol?: string;
|
||||
decimals?: number;
|
||||
totalSupply?: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
description?: string;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
export interface TokenMarketData {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
priceUsd?: number;
|
||||
priceChange24h?: number;
|
||||
volume24h: number;
|
||||
volume7d: number;
|
||||
volume30d: number;
|
||||
marketCapUsd?: number;
|
||||
liquidityUsd: number;
|
||||
holdersCount: number;
|
||||
transfers24h: number;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
export class TokenRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
async getToken(chainId: number, address: string): Promise<Token | null> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, address, name, symbol, decimals, total_supply, logo_url, website_url, description, verified
|
||||
FROM tokens
|
||||
WHERE chain_id = $1 AND address = $2`,
|
||||
[chainId, address.toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
chainId: row.chain_id,
|
||||
address: row.address,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
decimals: row.decimals,
|
||||
totalSupply: row.total_supply?.toString(),
|
||||
logoUrl: row.logo_url,
|
||||
websiteUrl: row.website_url,
|
||||
description: row.description,
|
||||
verified: row.verified,
|
||||
};
|
||||
}
|
||||
|
||||
async getTokens(chainId: number, limit: number = 50, offset: number = 0): Promise<Token[]> {
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, address, name, symbol, decimals, total_supply, logo_url, website_url, description, verified
|
||||
FROM tokens
|
||||
WHERE chain_id = $1
|
||||
ORDER BY address
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[chainId, limit, offset]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
chainId: row.chain_id,
|
||||
address: row.address,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
decimals: row.decimals,
|
||||
totalSupply: row.total_supply?.toString(),
|
||||
logoUrl: row.logo_url,
|
||||
websiteUrl: row.website_url,
|
||||
description: row.description,
|
||||
verified: row.verified,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsertToken(token: Token): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO tokens (chain_id, address, name, symbol, decimals, total_supply, logo_url, website_url, description, verified)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (chain_id, address) DO UPDATE SET
|
||||
name = COALESCE(EXCLUDED.name, tokens.name),
|
||||
symbol = COALESCE(EXCLUDED.symbol, tokens.symbol),
|
||||
decimals = COALESCE(EXCLUDED.decimals, tokens.decimals),
|
||||
total_supply = COALESCE(EXCLUDED.total_supply, tokens.total_supply),
|
||||
logo_url = COALESCE(EXCLUDED.logo_url, tokens.logo_url),
|
||||
website_url = COALESCE(EXCLUDED.website_url, tokens.website_url),
|
||||
description = COALESCE(EXCLUDED.description, tokens.description),
|
||||
verified = COALESCE(EXCLUDED.verified, tokens.verified),
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
token.chainId,
|
||||
token.address.toLowerCase(),
|
||||
token.name,
|
||||
token.symbol,
|
||||
token.decimals,
|
||||
token.totalSupply,
|
||||
token.logoUrl,
|
||||
token.websiteUrl,
|
||||
token.description,
|
||||
token.verified,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async searchTokens(chainId: number, query: string, limit: number = 20): Promise<Token[]> {
|
||||
const searchPattern = `%${query.toLowerCase()}%`;
|
||||
const result = await this.pool.query(
|
||||
`SELECT chain_id, address, name, symbol, decimals, total_supply, logo_url, website_url, description, verified
|
||||
FROM tokens
|
||||
WHERE chain_id = $1
|
||||
AND (LOWER(address) LIKE $2 OR LOWER(symbol) LIKE $2 OR LOWER(name) LIKE $2)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(address) = $3 THEN 1
|
||||
WHEN LOWER(symbol) = $3 THEN 2
|
||||
WHEN LOWER(name) = $3 THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
symbol
|
||||
LIMIT $4`,
|
||||
[chainId, searchPattern, query.toLowerCase(), limit]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
chainId: row.chain_id,
|
||||
address: row.address,
|
||||
name: row.name,
|
||||
symbol: row.symbol,
|
||||
decimals: row.decimals,
|
||||
totalSupply: row.total_supply?.toString(),
|
||||
logoUrl: row.logo_url,
|
||||
websiteUrl: row.website_url,
|
||||
description: row.description,
|
||||
verified: row.verified,
|
||||
}));
|
||||
}
|
||||
}
|
||||
48
services/token-aggregation/src/index.ts
Normal file
48
services/token-aggregation/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { ApiServer } from './api/server';
|
||||
import { closeDatabasePool } from './database/client';
|
||||
|
||||
// Load smom-dbis-138 root .env first (single source); works from dist/ or src/
|
||||
const rootEnvCandidates = [
|
||||
path.resolve(__dirname, '../../.env'), // from dist/
|
||||
path.resolve(__dirname, '../../../.env'), // from src/
|
||||
];
|
||||
for (const p of rootEnvCandidates) {
|
||||
if (existsSync(p)) {
|
||||
dotenv.config({ path: p });
|
||||
break;
|
||||
}
|
||||
}
|
||||
dotenv.config();
|
||||
// Fill contract/token addresses from config/smart-contracts-master.json when not set (e.g. CUSDC_ADDRESS_138, CUSDT_ADDRESS_138)
|
||||
try {
|
||||
const loaderPath = path.resolve(__dirname, '../../../../config/contracts-loader.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { loadContractsIntoProcessEnv } = require(loaderPath);
|
||||
if (typeof loadContractsIntoProcessEnv === 'function') loadContractsIntoProcessEnv([138, 1]);
|
||||
} catch (_) { /* optional when run outside proxmox repo */ }
|
||||
|
||||
const server = new ApiServer();
|
||||
|
||||
// Start server
|
||||
server.start().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down gracefully...');
|
||||
await server.stop();
|
||||
await closeDatabasePool();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('SIGINT received, shutting down gracefully...');
|
||||
await server.stop();
|
||||
await closeDatabasePool();
|
||||
process.exit(0);
|
||||
});
|
||||
248
services/token-aggregation/src/indexer/chain-indexer.ts
Normal file
248
services/token-aggregation/src/indexer/chain-indexer.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { ethers } from 'ethers';
|
||||
import { getChainConfig, getSupportedChainIds } from '../config/chains';
|
||||
import { TokenIndexer } from './token-indexer';
|
||||
import { PoolIndexer } from './pool-indexer';
|
||||
import { VolumeCalculator } from './volume-calculator';
|
||||
import { OHLCVGenerator } from './ohlcv-generator';
|
||||
import { MarketDataRepository } from '../database/repositories/market-data-repo';
|
||||
import { CoinGeckoAdapter } from '../adapters/coingecko-adapter';
|
||||
import { CoinMarketCapAdapter } from '../adapters/cmc-adapter';
|
||||
import { DexScreenerAdapter } from '../adapters/dexscreener-adapter';
|
||||
|
||||
export class ChainIndexer {
|
||||
private chainId: number;
|
||||
private provider: ethers.JsonRpcProvider;
|
||||
private tokenIndexer: TokenIndexer;
|
||||
private poolIndexer: PoolIndexer;
|
||||
private volumeCalculator: VolumeCalculator;
|
||||
private ohlcvGenerator: OHLCVGenerator;
|
||||
private marketDataRepo: MarketDataRepository;
|
||||
private adapters: {
|
||||
coingecko: CoinGeckoAdapter;
|
||||
cmc: CoinMarketCapAdapter;
|
||||
dexscreener: DexScreenerAdapter;
|
||||
};
|
||||
private isRunning: boolean = false;
|
||||
private indexingInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(chainId: number) {
|
||||
const config = getChainConfig(chainId);
|
||||
if (!config) {
|
||||
throw new Error(`Chain ${chainId} not configured`);
|
||||
}
|
||||
|
||||
this.chainId = chainId;
|
||||
this.provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
||||
this.tokenIndexer = new TokenIndexer(chainId, config.rpcUrl);
|
||||
this.poolIndexer = new PoolIndexer(chainId, config.rpcUrl);
|
||||
this.volumeCalculator = new VolumeCalculator();
|
||||
this.ohlcvGenerator = new OHLCVGenerator();
|
||||
this.marketDataRepo = new MarketDataRepository();
|
||||
|
||||
this.adapters = {
|
||||
coingecko: new CoinGeckoAdapter(),
|
||||
cmc: new CoinMarketCapAdapter(),
|
||||
dexscreener: new DexScreenerAdapter(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start indexing process
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
console.warn(`Chain indexer for ${this.chainId} is already running`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
console.log(`Starting chain indexer for chain ${this.chainId}`);
|
||||
|
||||
// Initial indexing
|
||||
await this.indexAll();
|
||||
|
||||
// Set up periodic indexing
|
||||
const interval = parseInt(process.env.INDEXING_INTERVAL || '5000', 10);
|
||||
this.indexingInterval = setInterval(() => {
|
||||
this.indexAll().catch((error) => {
|
||||
console.error(`Error in periodic indexing for chain ${this.chainId}:`, error);
|
||||
});
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop indexing process
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
if (this.indexingInterval) {
|
||||
clearInterval(this.indexingInterval);
|
||||
this.indexingInterval = undefined;
|
||||
}
|
||||
|
||||
console.log(`Stopped chain indexer for chain ${this.chainId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index all data (pools, tokens, market data)
|
||||
*/
|
||||
private async indexAll(): Promise<void> {
|
||||
try {
|
||||
// 1. Index pools
|
||||
console.log(`Indexing pools for chain ${this.chainId}...`);
|
||||
await this.poolIndexer.indexAllPools();
|
||||
|
||||
// 2. Discover and index tokens from pools
|
||||
console.log(`Discovering tokens for chain ${this.chainId}...`);
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
const tokenAddresses = new Set<string>();
|
||||
pools.forEach((pool) => {
|
||||
tokenAddresses.add(pool.token0Address);
|
||||
tokenAddresses.add(pool.token1Address);
|
||||
});
|
||||
await this.tokenIndexer.indexTokens(Array.from(tokenAddresses));
|
||||
|
||||
// 3. Calculate volumes and update market data
|
||||
console.log(`Calculating volumes for chain ${this.chainId}...`);
|
||||
for (const tokenAddress of tokenAddresses) {
|
||||
await this.updateMarketData(tokenAddress);
|
||||
}
|
||||
|
||||
// 4. Generate OHLCV data
|
||||
console.log(`Generating OHLCV for chain ${this.chainId}...`);
|
||||
const intervals: Array<'5m' | '1h' | '24h'> = ['5m', '1h', '24h'];
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Last 7 days
|
||||
|
||||
for (const tokenAddress of tokenAddresses) {
|
||||
for (const interval of intervals) {
|
||||
await this.ohlcvGenerator.generateAndStore(
|
||||
this.chainId,
|
||||
tokenAddress,
|
||||
interval,
|
||||
from,
|
||||
now
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in indexAll for chain ${this.chainId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update market data for a token
|
||||
*/
|
||||
private async updateMarketData(tokenAddress: string): Promise<void> {
|
||||
try {
|
||||
// Calculate on-chain volume
|
||||
const volumeMetrics = await this.volumeCalculator.calculateTokenVolume(
|
||||
this.chainId,
|
||||
tokenAddress
|
||||
);
|
||||
|
||||
// Get external market data
|
||||
const [coingeckoData, cmcData, dexscreenerData] = await Promise.all([
|
||||
this.adapters.coingecko.getMarketData(this.chainId, tokenAddress),
|
||||
this.adapters.cmc.getMarketData(this.chainId, tokenAddress),
|
||||
this.adapters.dexscreener.getMarketData(this.chainId, tokenAddress),
|
||||
]);
|
||||
|
||||
// Merge external data (prefer CoinGecko, fallback to others)
|
||||
const externalData = coingeckoData || dexscreenerData || cmcData;
|
||||
|
||||
// Get pools for liquidity calculation
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
const tokenPools = pools.filter(
|
||||
(p) => p.token0Address === tokenAddress || p.token1Address === tokenAddress
|
||||
);
|
||||
const totalLiquidity = tokenPools.reduce((sum, p) => sum + p.totalLiquidityUsd, 0);
|
||||
|
||||
// Update market data
|
||||
await this.marketDataRepo.upsertMarketData({
|
||||
chainId: this.chainId,
|
||||
tokenAddress,
|
||||
priceUsd: externalData?.priceUsd,
|
||||
priceChange24h: externalData?.priceChange24h,
|
||||
volume24h: volumeMetrics.volume24h || externalData?.volume24h || 0,
|
||||
volume7d: volumeMetrics.volume7d,
|
||||
volume30d: volumeMetrics.volume30d,
|
||||
marketCapUsd: externalData?.marketCapUsd,
|
||||
liquidityUsd: totalLiquidity || externalData?.liquidityUsd || 0,
|
||||
holdersCount: 0, // Would need to calculate from token transfers
|
||||
transfers24h: volumeMetrics.txCount24h,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error updating market data for ${tokenAddress}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current block number
|
||||
*/
|
||||
async getCurrentBlock(): Promise<number> {
|
||||
return await this.provider.getBlockNumber();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-chain indexer orchestrator
|
||||
*/
|
||||
export class MultiChainIndexer {
|
||||
private indexers: Map<number, ChainIndexer> = new Map();
|
||||
|
||||
/**
|
||||
* Initialize indexers for all supported chains
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const chainIds = getSupportedChainIds();
|
||||
for (const chainId of chainIds) {
|
||||
try {
|
||||
const indexer = new ChainIndexer(chainId);
|
||||
this.indexers.set(chainId, indexer);
|
||||
console.log(`Initialized indexer for chain ${chainId}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize indexer for chain ${chainId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all indexers
|
||||
*/
|
||||
async startAll(): Promise<void> {
|
||||
for (const [chainId, indexer] of this.indexers) {
|
||||
try {
|
||||
await indexer.start();
|
||||
} catch (error) {
|
||||
console.error(`Failed to start indexer for chain ${chainId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all indexers
|
||||
*/
|
||||
stopAll(): void {
|
||||
for (const [chainId, indexer] of this.indexers) {
|
||||
try {
|
||||
indexer.stop();
|
||||
} catch (error) {
|
||||
console.error(`Failed to stop indexer for chain ${chainId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get indexer for a specific chain
|
||||
*/
|
||||
getIndexer(chainId: number): ChainIndexer | undefined {
|
||||
return this.indexers.get(chainId);
|
||||
}
|
||||
}
|
||||
430
services/token-aggregation/src/indexer/cross-chain-indexer.ts
Normal file
430
services/token-aggregation/src/indexer/cross-chain-indexer.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Cross-chain indexer: fetches bridge/swap events and aggregates volume for CMC/CoinGecko reporting.
|
||||
* Queries Chain 138 (and optionally 651940) for CrossChainTransferInitiated, SwapAndBridgeExecuted,
|
||||
* LockForAlltra, AlltraBridgeInitiated, etc.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { CHAIN_138_BRIDGES, BridgeConfig } from '../config/cross-chain-bridges';
|
||||
|
||||
export interface CrossChainEvent {
|
||||
txHash: string;
|
||||
blockNumber: number;
|
||||
timestamp: number;
|
||||
sourceChainId: number;
|
||||
destChainId: number;
|
||||
destChainName: string;
|
||||
bridgeType: string;
|
||||
tokenSymbol?: string;
|
||||
amountWei: string;
|
||||
amountUsd?: number;
|
||||
sender?: string;
|
||||
recipient?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface CrossChainVolumeByLane {
|
||||
sourceChainId: number;
|
||||
destChainId: number;
|
||||
destChainName: string;
|
||||
bridgeType: string;
|
||||
tokenSymbol?: string;
|
||||
volume24hWei: string;
|
||||
volume7dWei: string;
|
||||
volume30dWei: string;
|
||||
txCount24h: number;
|
||||
txCount7d: number;
|
||||
txCount30d: number;
|
||||
}
|
||||
|
||||
export interface CrossChainReport {
|
||||
generatedAt: string;
|
||||
crossChainPools: Array<{
|
||||
type: string;
|
||||
sourceChainId: number;
|
||||
destChainId: number;
|
||||
destChainName: string;
|
||||
bridgeAddress: string;
|
||||
tokenSymbol?: string;
|
||||
bridgeType: string;
|
||||
tvlUsd?: number;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
volumeByLane: CrossChainVolumeByLane[];
|
||||
atomicSwapVolume24h: number;
|
||||
bridgeVolume24hTotal: number;
|
||||
events: CrossChainEvent[];
|
||||
}
|
||||
|
||||
const CCIP_TRANSFER_ABI = [
|
||||
'event CrossChainTransferInitiated(bytes32 indexed messageId, address indexed sender, uint64 indexed destinationChainSelector, address recipient, uint256 amount, uint256 nonce)',
|
||||
'event CrossChainTransferCompleted(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address indexed recipient, uint256 amount)',
|
||||
];
|
||||
|
||||
const SWAP_BRIDGE_ABI = [
|
||||
'event SwapAndBridgeExecuted(address indexed sourceToken, address indexed bridgeableToken, uint256 amountIn, uint256 amountToBridge, uint64 destinationChainSelector, address recipient, bytes32 messageId)',
|
||||
];
|
||||
|
||||
const ALLTRA_LOCK_ABI = [
|
||||
'event LockForAlltra(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient, uint256 sourceChainId)',
|
||||
'event UnlockOnAlltra(bytes32 indexed requestId, address indexed recipient, address indexed token, uint256 amount)',
|
||||
];
|
||||
|
||||
const ALLTRA_ADAPTER_ABI = [
|
||||
'event AlltraBridgeInitiated(bytes32 indexed requestId, address indexed sender, address indexed token, uint256 amount, address recipient)',
|
||||
'event AlltraBridgeConfirmed(bytes32 indexed requestId, address indexed recipient, address indexed token, uint256 amount)',
|
||||
];
|
||||
|
||||
const UNIVERAL_CCIP_ABI = [
|
||||
'event BridgeExecuted(bytes32 indexed messageId, address indexed token, address indexed sender, uint256 amount, uint64 destinationChain, address recipient, bool usedPMM)',
|
||||
'event MessageReceived(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address sender, address token, uint256 amount)',
|
||||
];
|
||||
|
||||
function nowSec(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function msAgo(hours: number): number {
|
||||
return nowSec() - hours * 3600;
|
||||
}
|
||||
|
||||
/** Fetch CrossChainTransferInitiated events from CCIP WETH bridges */
|
||||
async function fetchCCIPEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
bridge: BridgeConfig,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<CrossChainEvent[]> {
|
||||
const events: CrossChainEvent[] = [];
|
||||
try {
|
||||
const contract = new ethers.Contract(bridge.address, CCIP_TRANSFER_ABI, provider);
|
||||
const filter = contract.filters.CrossChainTransferInitiated();
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { messageId: string; sender: string; destinationChainSelector: bigint; recipient: string; amount: bigint };
|
||||
const lane = bridge.lanes.find((l) => l.destSelector === args.destinationChainSelector?.toString());
|
||||
const destChainId = lane?.destChainId ?? 0;
|
||||
const destChainName = lane?.destChainName ?? `Chain ${args.destinationChainSelector}`;
|
||||
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: bridge.chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: bridge.type,
|
||||
tokenSymbol: bridge.tokenSymbol,
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
messageId: args.messageId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Cross-chain indexer: CCIP events for ${bridge.address} failed:`, err);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Fetch SwapAndBridgeExecuted (optional - contract may not be deployed) */
|
||||
async function fetchSwapBridgeEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
address: string | undefined,
|
||||
chainId: number,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<CrossChainEvent[]> {
|
||||
if (!address || address === '0x0000000000000000000000000000000000000000') return [];
|
||||
const events: CrossChainEvent[] = [];
|
||||
try {
|
||||
const contract = new ethers.Contract(address, SWAP_BRIDGE_ABI, provider);
|
||||
const filter = contract.filters.SwapAndBridgeExecuted();
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { sourceToken: string; bridgeableToken: string; amountIn: bigint; amountToBridge: bigint; destinationChainSelector: bigint; recipient: string; messageId: string };
|
||||
const selector = args.destinationChainSelector?.toString();
|
||||
const destChainId = selector === '5009297550715157269' ? 1 : 0;
|
||||
const destChainName = destChainId === 1 ? 'Ethereum Mainnet' : `Selector ${selector}`;
|
||||
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: 'swap_bridge',
|
||||
amountWei: args.amountToBridge?.toString() ?? '0',
|
||||
recipient: args.recipient,
|
||||
messageId: args.messageId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Contract may not exist
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Fetch LockForAlltra (AlltraCustomBridge) or AlltraBridgeInitiated (AlltraAdapter) */
|
||||
async function fetchAlltraEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
bridge: BridgeConfig,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<CrossChainEvent[]> {
|
||||
const events: CrossChainEvent[] = [];
|
||||
const lane = bridge.lanes[0];
|
||||
const destChainId = lane?.destChainId ?? 651940;
|
||||
const destChainName = lane?.destChainName ?? 'ALL Mainnet';
|
||||
|
||||
try {
|
||||
const lockContract = new ethers.Contract(bridge.address, ALLTRA_LOCK_ABI, provider);
|
||||
const lockLogs = await lockContract.queryFilter(
|
||||
lockContract.filters.LockForAlltra(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
).catch(() => []);
|
||||
|
||||
for (const log of lockLogs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { requestId: string; sender: string; token: string; amount: bigint; recipient: string };
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: bridge.chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: 'alltra',
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Try AlltraAdapter (AlltraBridgeInitiated) if LockForAlltra not present
|
||||
}
|
||||
|
||||
try {
|
||||
const adapterContract = new ethers.Contract(bridge.address, ALLTRA_ADAPTER_ABI, provider);
|
||||
const initLogs = await adapterContract.queryFilter(
|
||||
adapterContract.filters.AlltraBridgeInitiated(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
).catch(() => []);
|
||||
|
||||
for (const log of initLogs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { requestId: string; sender: string; token: string; amount: bigint; recipient: string };
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: bridge.chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: 'alltra',
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Cross-chain indexer: Alltra events for ${bridge.address} failed:`, err);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Fetch BridgeExecuted from UniversalCCIPBridge */
|
||||
async function fetchUniversalCCIPEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
bridge: BridgeConfig,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<CrossChainEvent[]> {
|
||||
const events: CrossChainEvent[] = [];
|
||||
try {
|
||||
const contract = new ethers.Contract(bridge.address, UNIVERAL_CCIP_ABI, provider);
|
||||
const filter = contract.filters.BridgeExecuted?.() ?? { address: bridge.address };
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { messageId: string; token: string; sender: string; amount: bigint; destinationChain: bigint; recipient: string };
|
||||
const selector = args.destinationChain?.toString();
|
||||
const lane = bridge.lanes.find((l) => l.destSelector === selector);
|
||||
const destChainId = lane?.destChainId ?? (selector === '5009297550715157269' ? 1 : 651940);
|
||||
const destChainName = lane?.destChainName ?? `Chain ${selector}`;
|
||||
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: log.blockNumber,
|
||||
timestamp: 0,
|
||||
sourceChainId: bridge.chainId,
|
||||
destChainId,
|
||||
destChainName,
|
||||
bridgeType: 'universal_ccip',
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
messageId: args.messageId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Cross-chain indexer: UniversalCCIP events for ${bridge.address} failed:`, err);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Aggregate events into volume by lane */
|
||||
function aggregateVolumeByLane(
|
||||
events: CrossChainEvent[],
|
||||
window24h: number,
|
||||
window7d: number,
|
||||
window30d: number
|
||||
): CrossChainVolumeByLane[] {
|
||||
const byKey = new Map<string, { lane: CrossChainVolumeByLane; events: CrossChainEvent[] }>();
|
||||
|
||||
for (const e of events) {
|
||||
const key = `${e.sourceChainId}-${e.destChainId}-${e.bridgeType}-${e.tokenSymbol ?? ''}`;
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
lane: {
|
||||
sourceChainId: e.sourceChainId,
|
||||
destChainId: e.destChainId,
|
||||
destChainName: e.destChainName,
|
||||
bridgeType: e.bridgeType,
|
||||
tokenSymbol: e.tokenSymbol,
|
||||
volume24hWei: '0',
|
||||
volume7dWei: '0',
|
||||
volume30dWei: '0',
|
||||
txCount24h: 0,
|
||||
txCount7d: 0,
|
||||
txCount30d: 0,
|
||||
},
|
||||
events: [],
|
||||
});
|
||||
}
|
||||
byKey.get(key)!.events.push(e);
|
||||
}
|
||||
|
||||
const result: CrossChainVolumeByLane[] = [];
|
||||
for (const { lane, events: laneEvents } of byKey.values()) {
|
||||
let v24 = BigInt(0);
|
||||
let v7 = BigInt(0);
|
||||
let v30 = BigInt(0);
|
||||
let c24 = 0;
|
||||
let c7 = 0;
|
||||
let c30 = 0;
|
||||
|
||||
for (const e of laneEvents) {
|
||||
const amt = BigInt(e.amountWei);
|
||||
if (e.timestamp >= window24h) {
|
||||
v24 += amt;
|
||||
c24++;
|
||||
}
|
||||
if (e.timestamp >= window7d) {
|
||||
v7 += amt;
|
||||
c7++;
|
||||
}
|
||||
if (e.timestamp >= window30d) {
|
||||
v30 += amt;
|
||||
c30++;
|
||||
}
|
||||
}
|
||||
// If timestamps are 0, assume all events are in window (we don't have block->time easily)
|
||||
if (laneEvents.length > 0 && laneEvents.every((x) => x.timestamp === 0)) {
|
||||
v24 = laneEvents.reduce((s, x) => s + BigInt(x.amountWei), BigInt(0));
|
||||
v7 = v24;
|
||||
v30 = v24;
|
||||
c24 = laneEvents.length;
|
||||
c7 = c24;
|
||||
c30 = c24;
|
||||
}
|
||||
|
||||
lane.volume24hWei = v24.toString();
|
||||
lane.volume7dWei = v7.toString();
|
||||
lane.volume30dWei = v30.toString();
|
||||
lane.txCount24h = c24;
|
||||
lane.txCount7d = c7;
|
||||
lane.txCount30d = c30;
|
||||
result.push(lane);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Build full cross-chain report */
|
||||
export async function buildCrossChainReport(chainId: number = 138): Promise<CrossChainReport> {
|
||||
const config = getChainConfig(chainId);
|
||||
if (!config) {
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
crossChainPools: [],
|
||||
volumeByLane: [],
|
||||
atomicSwapVolume24h: 0,
|
||||
bridgeVolume24hTotal: 0,
|
||||
events: [],
|
||||
};
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
|
||||
const currentBlock = await provider.getBlockNumber();
|
||||
const blocksPerDay = Math.floor((24 * 3600) / config.blockTime);
|
||||
const fromBlock = Math.max(0, currentBlock - blocksPerDay * 30);
|
||||
|
||||
const window24h = msAgo(24);
|
||||
const window7d = msAgo(7 * 24);
|
||||
const window30d = msAgo(30 * 24);
|
||||
|
||||
const allEvents: CrossChainEvent[] = [];
|
||||
|
||||
const bridges = CHAIN_138_BRIDGES.filter((b) => b.chainId === chainId);
|
||||
|
||||
for (const bridge of bridges) {
|
||||
if (bridge.type === 'ccip_weth9' || bridge.type === 'ccip_weth10') {
|
||||
const evts = await fetchCCIPEvents(provider, bridge, fromBlock, currentBlock);
|
||||
allEvents.push(...evts);
|
||||
} else if (bridge.type === 'alltra') {
|
||||
const evts = await fetchAlltraEvents(provider, bridge, fromBlock, currentBlock);
|
||||
allEvents.push(...evts);
|
||||
} else if (bridge.type === 'universal_ccip') {
|
||||
const evts = await fetchUniversalCCIPEvents(provider, bridge, fromBlock, currentBlock);
|
||||
allEvents.push(...evts);
|
||||
}
|
||||
}
|
||||
|
||||
const swapBridgeAddr = process.env.SWAP_BRIDGE_COORDINATOR_ADDRESS;
|
||||
const swapEvts = await fetchSwapBridgeEvents(provider, swapBridgeAddr, chainId, fromBlock, currentBlock);
|
||||
allEvents.push(...swapEvts);
|
||||
|
||||
const volumeByLane = aggregateVolumeByLane(allEvents, window24h, window7d, window30d);
|
||||
|
||||
let bridgeVolume24hTotal = 0;
|
||||
for (const v of volumeByLane) {
|
||||
const amt = parseFloat(v.volume24hWei) / 1e18;
|
||||
bridgeVolume24hTotal += amt; // Approximate; real USD would need price oracle
|
||||
}
|
||||
|
||||
const crossChainPools = bridges.map((b) =>
|
||||
b.lanes.map((lane) => ({
|
||||
type: b.type,
|
||||
sourceChainId: b.chainId,
|
||||
destChainId: lane.destChainId,
|
||||
destChainName: lane.destChainName,
|
||||
bridgeAddress: b.address,
|
||||
tokenSymbol: b.tokenSymbol,
|
||||
bridgeType: lane.bridgeType,
|
||||
isActive: true,
|
||||
}))
|
||||
).flat();
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
crossChainPools,
|
||||
volumeByLane,
|
||||
atomicSwapVolume24h: swapEvts.reduce((s, e) => s + parseFloat(e.amountWei) / 1e18, 0),
|
||||
bridgeVolume24hTotal,
|
||||
events: allEvents.slice(0, 500), // Limit for response size
|
||||
};
|
||||
}
|
||||
220
services/token-aggregation/src/indexer/ohlcv-generator.ts
Normal file
220
services/token-aggregation/src/indexer/ohlcv-generator.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
|
||||
export type OHLCVInterval = '5m' | '15m' | '1h' | '4h' | '24h';
|
||||
|
||||
export interface OHLCVData {
|
||||
timestamp: Date;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
volumeUsd: number;
|
||||
}
|
||||
|
||||
export class OHLCVGenerator {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OHLCV data for a token
|
||||
*/
|
||||
async generateOHLCV(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
interval: OHLCVInterval,
|
||||
from: Date,
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
const intervalMs = this.getIntervalMs(interval);
|
||||
const results: OHLCVData[] = [];
|
||||
|
||||
// Get swap events for the time range
|
||||
let query = `
|
||||
SELECT timestamp, amount_usd, price_usd
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND (token0_address = $2 OR token1_address = $2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4
|
||||
`;
|
||||
const params: any[] = [chainId, tokenAddress.toLowerCase(), from, to];
|
||||
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $5`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group swaps by interval
|
||||
const intervals = new Map<number, OHLCVData>();
|
||||
|
||||
result.rows.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp);
|
||||
const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs;
|
||||
const price = parseFloat(row.price_usd || '0');
|
||||
const volume = parseFloat(row.amount_usd || '0');
|
||||
|
||||
if (!intervals.has(intervalStart)) {
|
||||
intervals.set(intervalStart, {
|
||||
timestamp: new Date(intervalStart),
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
volume: 0,
|
||||
volumeUsd: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const ohlcv = intervals.get(intervalStart)!;
|
||||
ohlcv.high = Math.max(ohlcv.high, price);
|
||||
ohlcv.low = Math.min(ohlcv.low, price);
|
||||
ohlcv.close = price;
|
||||
ohlcv.volume += 1;
|
||||
ohlcv.volumeUsd += volume;
|
||||
});
|
||||
|
||||
// Convert map to array and sort by timestamp
|
||||
return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OHLCV data in database
|
||||
*/
|
||||
async storeOHLCV(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
interval: OHLCVInterval,
|
||||
data: OHLCVData[],
|
||||
poolAddress?: string
|
||||
): Promise<void> {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const values = data.map((d, i) => {
|
||||
const base = i * 8;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`;
|
||||
});
|
||||
|
||||
const params: any[] = [];
|
||||
data.forEach((d) => {
|
||||
params.push(
|
||||
chainId,
|
||||
tokenAddress.toLowerCase(),
|
||||
poolAddress?.toLowerCase() || null,
|
||||
interval,
|
||||
d.open,
|
||||
d.high,
|
||||
d.low,
|
||||
d.close,
|
||||
d.volume,
|
||||
d.volumeUsd,
|
||||
d.timestamp
|
||||
);
|
||||
});
|
||||
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_ohlcv (
|
||||
chain_id, token_address, pool_address, interval_type,
|
||||
open_price, high_price, low_price, close_price, volume, volume_usd, timestamp
|
||||
)
|
||||
VALUES ${values.join(', ')}
|
||||
ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET
|
||||
open_price = EXCLUDED.open_price,
|
||||
high_price = EXCLUDED.high_price,
|
||||
low_price = EXCLUDED.low_price,
|
||||
close_price = EXCLUDED.close_price,
|
||||
volume = EXCLUDED.volume,
|
||||
volume_usd = EXCLUDED.volume_usd`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OHLCV data from database
|
||||
*/
|
||||
async getOHLCV(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
interval: OHLCVInterval,
|
||||
from: Date,
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
let query = `
|
||||
SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd
|
||||
FROM token_ohlcv
|
||||
WHERE chain_id = $1
|
||||
AND token_address = $2
|
||||
AND interval_type = $3
|
||||
AND timestamp >= $4
|
||||
AND timestamp <= $5
|
||||
`;
|
||||
const params: any[] = [chainId, tokenAddress.toLowerCase(), interval, from, to];
|
||||
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $6`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
} else {
|
||||
query += ` AND pool_address IS NULL`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
open: parseFloat(row.open_price),
|
||||
high: parseFloat(row.high_price),
|
||||
low: parseFloat(row.low_price),
|
||||
close: parseFloat(row.close_price),
|
||||
volume: parseFloat(row.volume || '0'),
|
||||
volumeUsd: parseFloat(row.volume_usd || '0'),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interval duration in milliseconds
|
||||
*/
|
||||
private getIntervalMs(interval: OHLCVInterval): number {
|
||||
const intervals: Record<OHLCVInterval, number> = {
|
||||
'5m': 5 * 60 * 1000,
|
||||
'15m': 15 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'4h': 4 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return intervals[interval];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store OHLCV for a token
|
||||
*/
|
||||
async generateAndStore(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
interval: OHLCVInterval,
|
||||
from: Date,
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
const data = await this.generateOHLCV(chainId, tokenAddress, interval, from, to, poolAddress);
|
||||
if (data.length > 0) {
|
||||
await this.storeOHLCV(chainId, tokenAddress, interval, data, poolAddress);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
381
services/token-aggregation/src/indexer/pool-indexer.ts
Normal file
381
services/token-aggregation/src/indexer/pool-indexer.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { ethers } from 'ethers';
|
||||
import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo';
|
||||
import { getDexFactories, UniswapV2Config, UniswapV3Config, DodoConfig } from '../config/dex-factories';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
|
||||
// UniswapV2 Factory ABI
|
||||
const UNISWAP_V2_FACTORY_ABI = [
|
||||
'event PairCreated(address indexed token0, address indexed token1, address pair, uint)',
|
||||
'function allPairsLength() view returns (uint256)',
|
||||
'function allPairs(uint256) view returns (address)',
|
||||
];
|
||||
|
||||
// UniswapV2 Pair ABI
|
||||
const UNISWAP_V2_PAIR_ABI = [
|
||||
'function token0() view returns (address)',
|
||||
'function token1() view returns (address)',
|
||||
'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
|
||||
'function totalSupply() view returns (uint256)',
|
||||
];
|
||||
|
||||
// UniswapV3 Factory ABI
|
||||
const UNISWAP_V3_FACTORY_ABI = [
|
||||
'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)',
|
||||
'function owner() view returns (address)',
|
||||
];
|
||||
|
||||
// UniswapV3 Pool ABI
|
||||
const UNISWAP_V3_POOL_ABI = [
|
||||
'function token0() view returns (address)',
|
||||
'function token1() view returns (address)',
|
||||
'function fee() view returns (uint24)',
|
||||
'function liquidity() view returns (uint128)',
|
||||
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
|
||||
];
|
||||
|
||||
// DODO PoolManager ABI (simplified)
|
||||
const DODO_POOL_MANAGER_ABI = [
|
||||
'function allPools() view returns (address[])',
|
||||
'function tokenPools(address) view returns (address[])',
|
||||
'function poolRegistry(address) view returns (tuple(address pool, address provider, address tokenA, address tokenB, uint256 liquidityUSD, uint256 volume24h, uint256 createdAt, uint256 lastUpdateTime, bool isActive))',
|
||||
];
|
||||
|
||||
// DODOPMMIntegration ABI (getAllPools + getPoolConfig + getPoolReserves + getPoolPriceOrOracle)
|
||||
const DODO_PMM_INTEGRATION_ABI = [
|
||||
'function getAllPools() view returns (address[])',
|
||||
'function getPoolConfig(address) view returns (tuple(address pool, address baseToken, address quoteToken, uint256 lpFeeRate, uint256 i, uint256 k, bool isOpenTWAP, uint256 createdAt))',
|
||||
'function getPoolReserves(address) view returns (uint256 baseReserve, uint256 quoteReserve)',
|
||||
'function getPoolPriceOrOracle(address) view returns (uint256 price)',
|
||||
];
|
||||
|
||||
// Swap event signatures
|
||||
const UNISWAP_V2_SWAP_TOPIC = ethers.id('Swap(address,uint256,uint256,uint256,uint256,address)');
|
||||
const UNISWAP_V3_SWAP_TOPIC = ethers.id('Swap(address,address,int256,int256,uint160,uint128,int24)');
|
||||
|
||||
export class PoolIndexer {
|
||||
private provider: ethers.JsonRpcProvider;
|
||||
private poolRepo: PoolRepository;
|
||||
private chainId: number;
|
||||
|
||||
constructor(chainId: number, rpcUrl: string) {
|
||||
this.chainId = chainId;
|
||||
this.provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
this.poolRepo = new PoolRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Index all pools for configured DEX types
|
||||
*/
|
||||
async indexAllPools(): Promise<LiquidityPool[]> {
|
||||
const dexConfig = getDexFactories(this.chainId);
|
||||
if (!dexConfig) {
|
||||
console.warn(`No DEX configuration found for chain ${this.chainId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const allPools: LiquidityPool[] = [];
|
||||
|
||||
// Index UniswapV2 pools
|
||||
if (dexConfig.uniswap_v2) {
|
||||
for (const config of dexConfig.uniswap_v2) {
|
||||
const pools = await this.indexUniswapV2Pools(config);
|
||||
allPools.push(...pools);
|
||||
}
|
||||
}
|
||||
|
||||
// Index UniswapV3 pools
|
||||
if (dexConfig.uniswap_v3) {
|
||||
for (const config of dexConfig.uniswap_v3) {
|
||||
const pools = await this.indexUniswapV3Pools(config);
|
||||
allPools.push(...pools);
|
||||
}
|
||||
}
|
||||
|
||||
// Index DODO pools (PoolManager and/or DODOPMMIntegration)
|
||||
if (dexConfig.dodo) {
|
||||
for (const config of dexConfig.dodo) {
|
||||
const pools = await this.indexDodoPools(config);
|
||||
allPools.push(...pools);
|
||||
}
|
||||
}
|
||||
|
||||
return allPools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index DODO PMM pools from DODOPMMIntegration contract
|
||||
*/
|
||||
private async indexDodoPmmIntegrationPools(config: DodoConfig): Promise<LiquidityPool[]> {
|
||||
const pools: LiquidityPool[] = [];
|
||||
const integrationAddress = config.dodoPmmIntegration;
|
||||
if (!integrationAddress || integrationAddress.trim() === '') {
|
||||
return pools;
|
||||
}
|
||||
|
||||
try {
|
||||
const integration = new ethers.Contract(
|
||||
integrationAddress,
|
||||
DODO_PMM_INTEGRATION_ABI,
|
||||
this.provider
|
||||
);
|
||||
|
||||
const poolAddresses: string[] = await integration.getAllPools();
|
||||
|
||||
for (const poolAddress of poolAddresses) {
|
||||
try {
|
||||
const [configResult, reservesResult, priceResult] = await Promise.all([
|
||||
integration.getPoolConfig(poolAddress),
|
||||
integration.getPoolReserves(poolAddress),
|
||||
integration.getPoolPriceOrOracle(poolAddress).catch(() => 0n),
|
||||
]);
|
||||
|
||||
const cfg = configResult as unknown as [string, string, string, bigint, bigint, bigint, boolean, bigint];
|
||||
const baseToken = cfg[1];
|
||||
const quoteToken = cfg[2];
|
||||
const createdAt = cfg[7];
|
||||
const baseReserve = (reservesResult as [bigint, bigint])[0];
|
||||
const quoteReserve = (reservesResult as [bigint, bigint])[1];
|
||||
const price = typeof priceResult === 'bigint' ? priceResult : BigInt(0);
|
||||
|
||||
// totalLiquidityUsd: baseReserve * price (quote per base) + quoteReserve, in 18 decimals then scale
|
||||
let totalLiquidityUsd = 0;
|
||||
if (price > 0n) {
|
||||
const baseValue = (baseReserve * price) / BigInt(1e18);
|
||||
totalLiquidityUsd = parseFloat(ethers.formatEther(baseValue + quoteReserve));
|
||||
}
|
||||
|
||||
const pool: LiquidityPool = {
|
||||
chainId: this.chainId,
|
||||
poolAddress: poolAddress.toLowerCase(),
|
||||
token0Address: baseToken.toLowerCase(),
|
||||
token1Address: quoteToken.toLowerCase(),
|
||||
dexType: 'dodo',
|
||||
factoryAddress: integrationAddress.toLowerCase(),
|
||||
reserve0: baseReserve.toString(),
|
||||
reserve1: quoteReserve.toString(),
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd,
|
||||
volume24h: 0, // No 24h volume from contract; requires event indexer
|
||||
createdAtBlock: 0,
|
||||
createdAtTimestamp: createdAt ? new Date(Number(createdAt) * 1000) : new Date(),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
} catch (err) {
|
||||
console.error(`Error indexing DODO PMM pool ${poolAddress}:`, err);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error indexing DODO PMM Integration pools:', error);
|
||||
}
|
||||
|
||||
return pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index UniswapV2 pools from PairCreated events
|
||||
*/
|
||||
private async indexUniswapV2Pools(config: UniswapV2Config): Promise<LiquidityPool[]> {
|
||||
const pools: LiquidityPool[] = [];
|
||||
const factory = new ethers.Contract(config.factory, UNISWAP_V2_FACTORY_ABI, this.provider);
|
||||
|
||||
try {
|
||||
// Get current block
|
||||
const currentBlock = await this.provider.getBlockNumber();
|
||||
const fromBlock = config.startBlock || Math.max(0, currentBlock - 10000);
|
||||
|
||||
// Listen for PairCreated events
|
||||
const filter = factory.filters.PairCreated();
|
||||
const events = await factory.queryFilter(filter, fromBlock, currentBlock);
|
||||
|
||||
for (const event of events) {
|
||||
const ev = event as ethers.EventLog;
|
||||
if (ev.args && ev.args.length >= 3) {
|
||||
const token0 = ev.args[0] as string;
|
||||
const token1 = ev.args[1] as string;
|
||||
const pairAddress = ev.args[2] as string;
|
||||
|
||||
// Get pair reserves
|
||||
const pair = new ethers.Contract(pairAddress, UNISWAP_V2_PAIR_ABI, this.provider);
|
||||
const [reserve0, reserve1] = await pair.getReserves();
|
||||
|
||||
const pool: LiquidityPool = {
|
||||
chainId: this.chainId,
|
||||
poolAddress: pairAddress.toLowerCase(),
|
||||
token0Address: token0.toLowerCase(),
|
||||
token1Address: token1.toLowerCase(),
|
||||
dexType: 'uniswap_v2',
|
||||
factoryAddress: config.factory.toLowerCase(),
|
||||
routerAddress: config.router?.toLowerCase(),
|
||||
reserve0: reserve0.toString(),
|
||||
reserve1: reserve1.toString(),
|
||||
reserve0Usd: 0, // Will be calculated with price oracle
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
volume24h: 0,
|
||||
createdAtBlock: event.blockNumber,
|
||||
createdAtTimestamp: new Date((await this.provider.getBlock(event.blockNumber))!.timestamp * 1000),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error indexing UniswapV2 pools:`, error);
|
||||
}
|
||||
|
||||
return pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index UniswapV3 pools from PoolCreated events
|
||||
*/
|
||||
private async indexUniswapV3Pools(config: UniswapV3Config): Promise<LiquidityPool[]> {
|
||||
const pools: LiquidityPool[] = [];
|
||||
const factory = new ethers.Contract(config.factory, UNISWAP_V3_FACTORY_ABI, this.provider);
|
||||
|
||||
try {
|
||||
const currentBlock = await this.provider.getBlockNumber();
|
||||
const fromBlock = config.startBlock || Math.max(0, currentBlock - 10000);
|
||||
|
||||
const filter = factory.filters.PoolCreated();
|
||||
const events = await factory.queryFilter(filter, fromBlock, currentBlock);
|
||||
|
||||
for (const event of events) {
|
||||
const ev = event as ethers.EventLog;
|
||||
if (ev.args && ev.args.length >= 5) {
|
||||
const token0 = ev.args[0] as string;
|
||||
const token1 = ev.args[1] as string;
|
||||
const fee = ev.args[2] as number;
|
||||
const poolAddress = ev.args[4] as string;
|
||||
|
||||
const poolContract = new ethers.Contract(poolAddress, UNISWAP_V3_POOL_ABI, this.provider);
|
||||
await poolContract.liquidity();
|
||||
|
||||
const pool: LiquidityPool = {
|
||||
chainId: this.chainId,
|
||||
poolAddress: poolAddress.toLowerCase(),
|
||||
token0Address: token0.toLowerCase(),
|
||||
token1Address: token1.toLowerCase(),
|
||||
dexType: 'uniswap_v3',
|
||||
factoryAddress: config.factory.toLowerCase(),
|
||||
routerAddress: config.router?.toLowerCase(),
|
||||
reserve0: '0', // UniswapV3 uses different reserve model
|
||||
reserve1: '0',
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
volume24h: 0,
|
||||
feeTier: fee,
|
||||
createdAtBlock: event.blockNumber,
|
||||
createdAtTimestamp: new Date((await this.provider.getBlock(event.blockNumber))!.timestamp * 1000),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error indexing UniswapV3 pools:`, error);
|
||||
}
|
||||
|
||||
return pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index DODO pools from PoolManager and/or DODOPMMIntegration
|
||||
*/
|
||||
private async indexDodoPools(config: DodoConfig): Promise<LiquidityPool[]> {
|
||||
const pools: LiquidityPool[] = [];
|
||||
|
||||
// Index from DODOPMMIntegration when configured
|
||||
if (config.dodoPmmIntegration?.trim()) {
|
||||
const pmmPools = await this.indexDodoPmmIntegrationPools(config);
|
||||
pools.push(...pmmPools);
|
||||
}
|
||||
|
||||
// Index from PoolManager when configured
|
||||
if (!config.poolManager?.trim()) {
|
||||
return pools;
|
||||
}
|
||||
|
||||
try {
|
||||
const poolManager = new ethers.Contract(
|
||||
config.poolManager,
|
||||
DODO_POOL_MANAGER_ABI,
|
||||
this.provider
|
||||
);
|
||||
|
||||
const allPools = await poolManager.allPools();
|
||||
|
||||
for (const poolAddress of allPools) {
|
||||
try {
|
||||
const poolInfo = await poolManager.poolRegistry(poolAddress);
|
||||
if (!poolInfo.isActive) continue;
|
||||
|
||||
const pool: LiquidityPool = {
|
||||
chainId: this.chainId,
|
||||
poolAddress: poolAddress.toLowerCase(),
|
||||
token0Address: poolInfo.tokenA.toLowerCase(),
|
||||
token1Address: poolInfo.tokenB.toLowerCase(),
|
||||
dexType: 'dodo',
|
||||
factoryAddress: config.poolManager.toLowerCase(),
|
||||
reserve0: '0',
|
||||
reserve1: '0',
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: parseFloat(ethers.formatEther(poolInfo.liquidityUSD || '0')),
|
||||
volume24h: parseFloat(ethers.formatEther(poolInfo.volume24h || '0')),
|
||||
createdAtBlock: parseInt(poolInfo.createdAt?.toString() || '0', 10),
|
||||
createdAtTimestamp: poolInfo.createdAt
|
||||
? new Date(parseInt(poolInfo.createdAt.toString(), 10) * 1000)
|
||||
: new Date(),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
} catch (error) {
|
||||
console.error(`Error indexing DODO pool ${poolAddress}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error indexing DODO pools:`, error);
|
||||
}
|
||||
|
||||
return pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pool reserves
|
||||
*/
|
||||
async updatePoolReserves(poolAddress: string, dexType: DexType): Promise<void> {
|
||||
const pool = await this.poolRepo.getPool(this.chainId, poolAddress);
|
||||
if (!pool) {
|
||||
console.warn(`Pool ${poolAddress} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dexType === 'uniswap_v2') {
|
||||
const pair = new ethers.Contract(poolAddress, UNISWAP_V2_PAIR_ABI, this.provider);
|
||||
const [reserve0, reserve1] = await pair.getReserves();
|
||||
|
||||
pool.reserve0 = reserve0.toString();
|
||||
pool.reserve1 = reserve1.toString();
|
||||
pool.lastUpdated = new Date();
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
}
|
||||
// UniswapV3 and DODO use different models, would need specific implementations
|
||||
} catch (error) {
|
||||
console.error(`Error updating pool reserves for ${poolAddress}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
131
services/token-aggregation/src/indexer/token-indexer.ts
Normal file
131
services/token-aggregation/src/indexer/token-indexer.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ethers } from 'ethers';
|
||||
import { TokenRepository, Token } from '../database/repositories/token-repo';
|
||||
|
||||
// ERC20 ABI for token metadata
|
||||
const ERC20_ABI = [
|
||||
'function name() view returns (string)',
|
||||
'function symbol() view returns (string)',
|
||||
'function decimals() view returns (uint8)',
|
||||
'function totalSupply() view returns (uint256)',
|
||||
];
|
||||
|
||||
export class TokenIndexer {
|
||||
private provider: ethers.JsonRpcProvider;
|
||||
private tokenRepo: TokenRepository;
|
||||
private chainId: number;
|
||||
|
||||
constructor(chainId: number, rpcUrl: string) {
|
||||
this.chainId = chainId;
|
||||
this.provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
this.tokenRepo = new TokenRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and index a token by address
|
||||
*/
|
||||
async indexToken(address: string): Promise<Token | null> {
|
||||
try {
|
||||
const tokenContract = new ethers.Contract(address, ERC20_ABI, this.provider);
|
||||
|
||||
// Fetch token metadata in parallel
|
||||
const [name, symbol, decimals, totalSupply] = await Promise.allSettled([
|
||||
tokenContract.name(),
|
||||
tokenContract.symbol(),
|
||||
tokenContract.decimals(),
|
||||
tokenContract.totalSupply(),
|
||||
]);
|
||||
|
||||
const token: Token = {
|
||||
chainId: this.chainId,
|
||||
address: address.toLowerCase(),
|
||||
name: name.status === 'fulfilled' ? name.value : undefined,
|
||||
symbol: symbol.status === 'fulfilled' ? symbol.value : undefined,
|
||||
decimals: decimals.status === 'fulfilled' ? decimals.value : undefined,
|
||||
totalSupply:
|
||||
totalSupply.status === 'fulfilled' ? totalSupply.value.toString() : undefined,
|
||||
verified: false, // Can be updated later via verification process
|
||||
};
|
||||
|
||||
// Save to database
|
||||
await this.tokenRepo.upsertToken(token);
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error(`Error indexing token ${address} on chain ${this.chainId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index multiple tokens
|
||||
*/
|
||||
async indexTokens(addresses: string[]): Promise<Token[]> {
|
||||
const results = await Promise.allSettled(
|
||||
addresses.map((address) => this.indexToken(address))
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((result) => result.status === 'fulfilled' && result.value !== null)
|
||||
.map((result) => (result as PromiseFulfilledResult<Token>).value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover tokens from Transfer events
|
||||
*/
|
||||
async discoverTokensFromTransfers(
|
||||
fromBlock: number,
|
||||
toBlock: number,
|
||||
batchSize: number = 1000
|
||||
): Promise<string[]> {
|
||||
const discoveredAddresses = new Set<string>();
|
||||
|
||||
// ERC20 Transfer event signature
|
||||
const transferTopic = ethers.id('Transfer(address,address,uint256)');
|
||||
|
||||
try {
|
||||
for (let start = fromBlock; start <= toBlock; start += batchSize) {
|
||||
const end = Math.min(start + batchSize - 1, toBlock);
|
||||
|
||||
const logs = await this.provider.getLogs({
|
||||
fromBlock: start,
|
||||
toBlock: end,
|
||||
topics: [transferTopic],
|
||||
});
|
||||
|
||||
// Extract token addresses from logs
|
||||
logs.forEach((log) => {
|
||||
discoveredAddresses.add(log.address.toLowerCase());
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Discovered ${discoveredAddresses.size} unique tokens from blocks ${start}-${end}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error discovering tokens from transfers:`, error);
|
||||
}
|
||||
|
||||
return Array.from(discoveredAddresses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update token metadata (useful for refreshing data)
|
||||
*/
|
||||
async updateTokenMetadata(address: string): Promise<Token | null> {
|
||||
return this.indexToken(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token from database or index if not found
|
||||
*/
|
||||
async getOrIndexToken(address: string): Promise<Token | null> {
|
||||
// Try to get from database first
|
||||
const existing = await this.tokenRepo.getToken(this.chainId, address);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Index if not found
|
||||
return this.indexToken(address);
|
||||
}
|
||||
}
|
||||
187
services/token-aggregation/src/indexer/volume-calculator.ts
Normal file
187
services/token-aggregation/src/indexer/volume-calculator.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
|
||||
export interface VolumeMetrics {
|
||||
volume5m: number;
|
||||
volume1h: number;
|
||||
volume24h: number;
|
||||
volume7d: number;
|
||||
volume30d: number;
|
||||
txCount5m: number;
|
||||
txCount1h: number;
|
||||
txCount24h: number;
|
||||
}
|
||||
|
||||
export class VolumeCalculator {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume metrics for a token across all pools
|
||||
*/
|
||||
async calculateTokenVolume(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
now: Date = new Date()
|
||||
): Promise<VolumeMetrics> {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// Get all pools for this token
|
||||
const poolsResult = await this.pool.query(
|
||||
`SELECT pool_address FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
const poolAddresses = poolsResult.rows.map((row) => row.pool_address);
|
||||
|
||||
if (poolAddresses.length === 0) {
|
||||
return {
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate volume for each interval
|
||||
const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now),
|
||||
this.calculateTxCounts(chainId, poolAddresses, intervals, now),
|
||||
]);
|
||||
|
||||
return {
|
||||
volume5m,
|
||||
volume1h,
|
||||
volume24h,
|
||||
volume7d,
|
||||
volume30d,
|
||||
txCount5m: txCounts['5m'],
|
||||
txCount1h: txCounts['1h'],
|
||||
txCount24h: txCounts['24h'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume for a specific interval
|
||||
*/
|
||||
private async calculateVolumeForInterval(
|
||||
chainId: number,
|
||||
poolAddresses: string[],
|
||||
tokenAddress: string,
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<number> {
|
||||
if (poolAddresses.length === 0) return 0;
|
||||
|
||||
const result = await this.pool.query(
|
||||
`SELECT COALESCE(SUM(amount_usd), 0) as total_volume
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = ANY($2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4
|
||||
AND (token0_address = $5 OR token1_address = $5)`,
|
||||
[chainId, poolAddresses, from, to, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
return parseFloat(result.rows[0]?.total_volume || '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate transaction counts for different intervals
|
||||
*/
|
||||
private async calculateTxCounts(
|
||||
chainId: number,
|
||||
poolAddresses: string[],
|
||||
intervals: Record<string, Date>,
|
||||
now: Date
|
||||
): Promise<Record<string, number>> {
|
||||
if (poolAddresses.length === 0) {
|
||||
return { '5m': 0, '1h': 0, '24h': 0 };
|
||||
}
|
||||
|
||||
const [count5m, count1h, count24h] = await Promise.all([
|
||||
this.pool.query(
|
||||
`SELECT COUNT(DISTINCT transaction_hash) as count
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = ANY($2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddresses, intervals['5m'], now]
|
||||
),
|
||||
this.pool.query(
|
||||
`SELECT COUNT(DISTINCT transaction_hash) as count
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = ANY($2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddresses, intervals['1h'], now]
|
||||
),
|
||||
this.pool.query(
|
||||
`SELECT COUNT(DISTINCT transaction_hash) as count
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = ANY($2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddresses, intervals['24h'], now]
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
'5m': parseInt(count5m.rows[0]?.count || '0', 10),
|
||||
'1h': parseInt(count1h.rows[0]?.count || '0', 10),
|
||||
'24h': parseInt(count24h.rows[0]?.count || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume for a specific pool
|
||||
*/
|
||||
async calculatePoolVolume(
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
interval: '5m' | '1h' | '24h' | '7d' | '30d',
|
||||
now: Date = new Date()
|
||||
): Promise<number> {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
const result = await this.pool.query(
|
||||
`SELECT COALESCE(SUM(amount_usd), 0) as total_volume
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = $2
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddress.toLowerCase(), intervals[interval], now]
|
||||
);
|
||||
|
||||
return parseFloat(result.rows[0]?.total_volume || '0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for ISO-4217 compliant symbol validation and parsing.
|
||||
*/
|
||||
|
||||
import {
|
||||
isValidNativeSymbol,
|
||||
isValidBridgedSymbol,
|
||||
parseSymbol,
|
||||
} from './iso4217-symbol-validator';
|
||||
import { V0_TO_V1_SYMBOL_MAP, getV1IdentityForV0Symbol } from '../config/iso4217-symbol-registry';
|
||||
|
||||
describe('isValidNativeSymbol', () => {
|
||||
it('accepts valid 6-char native symbols', () => {
|
||||
expect(isValidNativeSymbol('cAUSDT')).toBe(true);
|
||||
expect(isValidNativeSymbol('cXUSDC')).toBe(true);
|
||||
expect(isValidNativeSymbol('cAUSDC')).toBe(true);
|
||||
expect(isValidNativeSymbol('cXEURC')).toBe(true);
|
||||
expect(isValidNativeSymbol('cAGBPT')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects v0 legacy (no chain designator)', () => {
|
||||
expect(isValidNativeSymbol('cUSDT')).toBe(false);
|
||||
expect(isValidNativeSymbol('cUSDC')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing type', () => {
|
||||
expect(isValidNativeSymbol('cAUSD')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wrong length', () => {
|
||||
expect(isValidNativeSymbol('cAUSDCX')).toBe(false);
|
||||
expect(isValidNativeSymbol('cAUSD')).toBe(false);
|
||||
expect(isValidNativeSymbol('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid ISO code', () => {
|
||||
expect(isValidNativeSymbol('cAXXXT')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid chain designator', () => {
|
||||
expect(isValidNativeSymbol('cBUSDT')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidBridgedSymbol', () => {
|
||||
it('accepts valid 7-char bridged symbols', () => {
|
||||
expect(isValidBridgedSymbol('cWAUSDT')).toBe(true);
|
||||
expect(isValidBridgedSymbol('cWXUSDC')).toBe(true);
|
||||
expect(isValidBridgedSymbol('cWAEURC')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects native 6-char', () => {
|
||||
expect(isValidBridgedSymbol('cAUSDT')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing W at position 2', () => {
|
||||
expect(isValidBridgedSymbol('cWAUSD')).toBe(false);
|
||||
expect(isValidBridgedSymbol('cAUSDTX')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid length', () => {
|
||||
expect(isValidBridgedSymbol('cWAUSD')).toBe(false);
|
||||
expect(isValidBridgedSymbol('cWAUSDTW')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSymbol', () => {
|
||||
it('parses v0 symbols and returns v1 identity', () => {
|
||||
const cusdt = parseSymbol('cUSDT');
|
||||
expect(cusdt).not.toBeNull();
|
||||
expect(cusdt!.type).toBe('v0');
|
||||
expect(cusdt!.iso).toBe('USD');
|
||||
expect(cusdt!.typeChar).toBe('T');
|
||||
expect(cusdt!.originChain).toBe('X');
|
||||
expect(cusdt!.v1Identity).toEqual({ iso: 'USD', type: 'T', originChain: 'X' });
|
||||
|
||||
const cusdc = parseSymbol('cUSDC');
|
||||
expect(cusdc).not.toBeNull();
|
||||
expect(cusdc!.type).toBe('v0');
|
||||
expect(cusdc!.iso).toBe('USD');
|
||||
expect(cusdc!.typeChar).toBe('C');
|
||||
expect(cusdc!.originChain).toBe('X');
|
||||
});
|
||||
|
||||
it('parses native 6-char symbols', () => {
|
||||
const p = parseSymbol('cAUSDT');
|
||||
expect(p).not.toBeNull();
|
||||
expect(p!.type).toBe('native');
|
||||
expect(p!.iso).toBe('USD');
|
||||
expect(p!.typeChar).toBe('T');
|
||||
expect(p!.originChain).toBe('A');
|
||||
});
|
||||
|
||||
it('parses bridged 7-char symbols', () => {
|
||||
const p = parseSymbol('cWXUSDC');
|
||||
expect(p).not.toBeNull();
|
||||
expect(p!.type).toBe('bridged');
|
||||
expect(p!.iso).toBe('USD');
|
||||
expect(p!.typeChar).toBe('C');
|
||||
expect(p!.originChain).toBe('X');
|
||||
});
|
||||
|
||||
it('returns null for invalid or unknown symbols', () => {
|
||||
expect(parseSymbol('cAUSD')).toBeNull();
|
||||
expect(parseSymbol('invalid')).toBeNull();
|
||||
expect(parseSymbol('')).toBeNull();
|
||||
expect(parseSymbol(null as unknown as string)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0 to v1 map lookup', () => {
|
||||
it('V0_TO_V1_SYMBOL_MAP has correct entries', () => {
|
||||
expect(V0_TO_V1_SYMBOL_MAP.cUSDT).toEqual({ iso: 'USD', type: 'T', originChain: 'X' });
|
||||
expect(V0_TO_V1_SYMBOL_MAP.cUSDC).toEqual({ iso: 'USD', type: 'C', originChain: 'X' });
|
||||
});
|
||||
|
||||
it('getV1IdentityForV0Symbol returns identity for v0 symbols', () => {
|
||||
expect(getV1IdentityForV0Symbol('cUSDT')).toEqual({ iso: 'USD', type: 'T', originChain: 'X' });
|
||||
expect(getV1IdentityForV0Symbol('cUSDC')).toEqual({ iso: 'USD', type: 'C', originChain: 'X' });
|
||||
expect(getV1IdentityForV0Symbol('cAUSDT')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ISO-4217 compliant token symbol validation.
|
||||
* Validates 6-char native and 7-char bridged symbols; parses and resolves v0 legacy.
|
||||
* See docs/04-configuration/ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
*/
|
||||
|
||||
import {
|
||||
FIN_CHAIN_SET,
|
||||
ISO4217_SUPPORTED,
|
||||
ASSET_TYPE_SET,
|
||||
V0_TO_V1_SYMBOL_MAP,
|
||||
isFinChainDesignator,
|
||||
isISO4217Supported,
|
||||
isAssetTypeChar,
|
||||
type V1SymbolIdentity,
|
||||
} from '../config/iso4217-symbol-registry';
|
||||
|
||||
export type ParsedSymbolType = 'native' | 'bridged' | 'v0';
|
||||
|
||||
export interface ParsedSymbol {
|
||||
type: ParsedSymbolType;
|
||||
iso?: string;
|
||||
typeChar?: string;
|
||||
originChain?: string;
|
||||
v1Identity?: V1SymbolIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates native (6-char) symbol: c + FinChain + ISO4217 + Type
|
||||
*/
|
||||
export function isValidNativeSymbol(s: string): boolean {
|
||||
if (typeof s !== 'string' || s.length !== 6) return false;
|
||||
if (s[0] !== 'c') return false;
|
||||
if (!isFinChainDesignator(s[1])) return false;
|
||||
const iso = s.slice(2, 5);
|
||||
if (!isISO4217Supported(iso)) return false;
|
||||
if (!isAssetTypeChar(s[5])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates bridged (7-char) symbol: c + W + OriginFinChain + ISO4217 + Type
|
||||
*/
|
||||
export function isValidBridgedSymbol(s: string): boolean {
|
||||
if (typeof s !== 'string' || s.length !== 7) return false;
|
||||
if (s[0] !== 'c') return false;
|
||||
if (s[1] !== 'W') return false;
|
||||
if (!isFinChainDesignator(s[2])) return false;
|
||||
const iso = s.slice(3, 6);
|
||||
if (!isISO4217Supported(iso)) return false;
|
||||
if (!isAssetTypeChar(s[6])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a symbol and returns its type and components, or null if invalid/unknown.
|
||||
* v0 symbols (e.g. cUSDT, cUSDC) return type 'v0' with v1Identity from registry.
|
||||
*/
|
||||
export function parseSymbol(s: string): ParsedSymbol | null {
|
||||
if (typeof s !== 'string' || s.length === 0) return null;
|
||||
|
||||
const v0Identity = V0_TO_V1_SYMBOL_MAP[s];
|
||||
if (v0Identity) {
|
||||
return {
|
||||
type: 'v0',
|
||||
iso: v0Identity.iso,
|
||||
typeChar: v0Identity.type,
|
||||
originChain: v0Identity.originChain,
|
||||
v1Identity: v0Identity,
|
||||
};
|
||||
}
|
||||
|
||||
if (s.length === 6 && isValidNativeSymbol(s)) {
|
||||
return {
|
||||
type: 'native',
|
||||
iso: s.slice(2, 5),
|
||||
typeChar: s[5],
|
||||
originChain: s[1],
|
||||
};
|
||||
}
|
||||
|
||||
if (s.length === 7 && isValidBridgedSymbol(s)) {
|
||||
return {
|
||||
type: 'bridged',
|
||||
iso: s.slice(3, 6),
|
||||
typeChar: s[6],
|
||||
originChain: s[2],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user