chore: sync submodule state (parent ref update)

Made-with: Cursor
This commit is contained in:
defiQUG
2026-03-02 12:14:09 -08:00
parent 50ab378da9
commit 5efe36b1e0
1100 changed files with 155024 additions and 8674 deletions

View 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;
}

View 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 [];
}
}
}

View 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 [];
}
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,3 @@
export function cacheMiddleware(_ttl?: number) {
return (req: unknown, res: unknown, next: () => void) => next();
}

View 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' }
);
}

View 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);
}
}
}

View 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,
});

View 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;

View 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;

View 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;

View 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);
});
});
});

View 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;

View 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;

View 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;

View 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');
}
}

View File

@@ -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();
}

View 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;
}

View 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));
}

View 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;
}

View File

@@ -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];
}

View 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;
}

View 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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
}));
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
}));
}
}

View 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);
});

View 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);
}
}

View 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
};
}

View 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;
}
}

View 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);
}
}
}

View 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);
}
}

View 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');
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}