feat: expand non-evm relay and route planning support

This commit is contained in:
defiQUG
2026-04-18 12:05:34 -07:00
parent da78073104
commit 843cdbf71c
113 changed files with 8542 additions and 222 deletions

View File

@@ -0,0 +1,99 @@
import {
aggregateDexScreenerPairsToMarketData,
normalizeDexScreenerTokenPairsPayload,
type DexScreenerPair,
} from './dexscreener-adapter';
describe('normalizeDexScreenerTokenPairsPayload', () => {
it('accepts a raw JSON array (current API)', () => {
const pairs: DexScreenerPair[] = [
{
chainId: 'ethereum',
dexId: 'x',
url: '',
pairAddress: '0x',
baseToken: { address: '0xa', name: '', symbol: 'A' },
quoteToken: { address: '0xb', name: '', symbol: 'B' },
priceUsd: '1',
},
];
expect(normalizeDexScreenerTokenPairsPayload(pairs)).toEqual(pairs);
});
it('accepts legacy { pairs: [...] } shape', () => {
const inner: DexScreenerPair[] = [
{
chainId: 'ethereum',
dexId: 'x',
url: '',
pairAddress: '0x',
baseToken: { address: '0xa', name: '', symbol: 'A' },
quoteToken: { address: '0xb', name: '', symbol: 'B' },
priceUsd: '2',
},
];
expect(normalizeDexScreenerTokenPairsPayload({ pairs: inner })).toEqual(inner);
});
});
describe('aggregateDexScreenerPairsToMarketData', () => {
it('returns null for empty input', () => {
expect(aggregateDexScreenerPairsToMarketData(null)).toBeNull();
expect(aggregateDexScreenerPairsToMarketData([])).toBeNull();
});
it('picks price from the pair with highest USD liquidity', () => {
const pairs: DexScreenerPair[] = [
{
chainId: 'ethereum',
dexId: 'uniswap',
url: '',
pairAddress: '0x1',
baseToken: { address: '0xt', name: '', symbol: 'T' },
quoteToken: { address: '0xq', name: '', symbol: 'Q' },
priceUsd: '100',
liquidity: { usd: 1000 },
},
{
chainId: 'ethereum',
dexId: 'sushi',
url: '',
pairAddress: '0x2',
baseToken: { address: '0xt', name: '', symbol: 'T' },
quoteToken: { address: '0xq', name: '', symbol: 'Q' },
priceUsd: '1',
liquidity: { usd: 1_000_000 },
},
];
const m = aggregateDexScreenerPairsToMarketData(pairs);
expect(m?.priceUsd).toBe(1);
expect(m?.liquidityUsd).toBe(1_001_000);
});
it('when liquidity is missing, prefers higher 24h volume', () => {
const pairs: DexScreenerPair[] = [
{
chainId: 'ethereum',
dexId: 'a',
url: '',
pairAddress: '0x1',
baseToken: { address: '0xt', name: '', symbol: 'T' },
quoteToken: { address: '0xq', name: '', symbol: 'Q' },
priceUsd: '50',
volume: { h24: 100 },
},
{
chainId: 'ethereum',
dexId: 'b',
url: '',
pairAddress: '0x2',
baseToken: { address: '0xt', name: '', symbol: 'T' },
quoteToken: { address: '0xq', name: '', symbol: 'Q' },
priceUsd: '51',
volume: { h24: 1_000_000 },
},
];
const m = aggregateDexScreenerPairsToMarketData(pairs);
expect(m?.priceUsd).toBe(51);
});
});

View File

@@ -1,8 +1,9 @@
import axios, { AxiosInstance } from 'axios';
import { ExternalApiAdapter, TokenMetadata, MarketData } from './base-adapter';
import { preferGruV2OfficialDexPairs } from '../config/gru-v2-deployment-pools';
import { logger } from '../utils/logger';
interface DexScreenerPair {
export interface DexScreenerPair {
chainId: string;
dexId: string;
url: string;
@@ -59,11 +60,23 @@ interface DexScreenerPair {
}
interface DexScreenerResponse {
schemaVersion: string;
pairs: DexScreenerPair[] | null;
schemaVersion?: string;
pairs?: DexScreenerPair[] | null;
pair?: DexScreenerPair;
}
/** Current API returns a JSON array of pairs; older clients used `{ pairs: [...] }`. */
export function normalizeDexScreenerTokenPairsPayload(data: unknown): DexScreenerPair[] {
if (Array.isArray(data)) {
return data as DexScreenerPair[];
}
if (data && typeof data === 'object' && data !== null && 'pairs' in data) {
const p = (data as DexScreenerResponse).pairs;
return Array.isArray(p) ? p : [];
}
return [];
}
// Chain ID to DexScreener chain identifier mapping
// DexScreener uses chain identifiers like 'ethereum', 'bsc', etc.
const CHAIN_TO_DEXSCREENER_ID: Record<number, string> = {
@@ -74,7 +87,11 @@ const CHAIN_TO_DEXSCREENER_ID: Record<number, string> = {
42161: 'arbitrum',
10: 'optimism',
8453: 'base',
// Note: 138 and 651940 are likely not supported
100: 'gnosis',
42220: 'celo',
25: 'cronos',
1111: 'wemix',
// Chain 138 / ALL Mainnet: not listed on public DexScreener API
};
// Reverse mapping for lookup
@@ -83,6 +100,55 @@ Object.entries(CHAIN_TO_DEXSCREENER_ID).forEach(([chainId, dexId]) => {
DEXSCREENER_ID_TO_CHAIN[dexId] = parseInt(chainId, 10);
});
/** Prefer the pair with the most USD liquidity, then highest 24h volume (avoids averaging thin pools). */
function pairLiquidityScore(pair: DexScreenerPair): number {
const liq = pair.liquidity?.usd ?? 0;
if (liq > 0) return liq;
return pair.volume?.h24 ?? 0;
}
/**
* Aggregates DexScreener token-pairs response into a single {@link MarketData} snapshot.
* Exported for unit tests.
*/
export function aggregateDexScreenerPairsToMarketData(pairs: DexScreenerPair[] | null | undefined): MarketData | null {
if (!pairs || pairs.length === 0) {
return null;
}
let totalVolume24h = 0;
let totalLiquidity = 0;
for (const pair of pairs) {
if (pair.volume?.h24) totalVolume24h += pair.volume.h24;
if (pair.liquidity?.usd) totalLiquidity += pair.liquidity.usd;
}
const priced = pairs.filter((p) => p.priceUsd && !Number.isNaN(parseFloat(p.priceUsd)));
if (priced.length === 0) {
return null;
}
let best = priced[0]!;
let bestScore = pairLiquidityScore(best);
for (let i = 1; i < priced.length; i++) {
const p = priced[i]!;
const s = pairLiquidityScore(p);
if (s > bestScore) {
best = p;
bestScore = s;
}
}
const priceUsd = parseFloat(best.priceUsd!);
return {
priceUsd: Number.isFinite(priceUsd) ? priceUsd : undefined,
volume24h: totalVolume24h > 0 ? totalVolume24h : undefined,
liquidityUsd: totalLiquidity > 0 ? totalLiquidity : undefined,
lastUpdated: new Date(),
};
}
export class DexScreenerAdapter implements ExternalApiAdapter {
private api: AxiosInstance;
private apiKey?: string;
@@ -131,11 +197,10 @@ export class DexScreenerAdapter implements ExternalApiAdapter {
return false;
}
const response = await this.api.get<DexScreenerResponse>(
`/token-pairs/v1/${dexId}/${testAddress}`
);
const response = await this.api.get<unknown>(`/token-pairs/v1/${dexId}/${testAddress}`);
const supported = response.status === 200 && (response.data.pairs?.length ?? 0) > 0;
const pairs = normalizeDexScreenerTokenPairsPayload(response.data);
const supported = response.status === 200 && pairs.length > 0;
this.cache.set(cacheKey, {
data: supported,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hour cache
@@ -181,39 +246,17 @@ export class DexScreenerAdapter implements ExternalApiAdapter {
}
try {
const response = await this.api.get<DexScreenerResponse>(
`/token-pairs/v1/${dexId}/${address.toLowerCase()}`
);
const response = await this.api.get<unknown>(`/token-pairs/v1/${dexId}/${address.toLowerCase()}`);
if (!response.data.pairs || response.data.pairs.length === 0) {
const pairsRaw = normalizeDexScreenerTokenPairsPayload(response.data);
if (pairsRaw.length === 0) {
return null;
}
const pairs = preferGruV2OfficialDexPairs(chainId, address.toLowerCase(), pairsRaw);
const marketData = aggregateDexScreenerPairsToMarketData(pairs);
if (!marketData) {
return null;
}
// Aggregate data from all pairs
let totalVolume24h = 0;
let totalLiquidity = 0;
let avgPrice = 0;
let priceCount = 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;
}
// txns h24 available on pair.txns?.h24 for future use
});
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, {
@@ -242,11 +285,10 @@ export class DexScreenerAdapter implements ExternalApiAdapter {
}
try {
const response = await this.api.get<DexScreenerResponse>(
`/token-pairs/v1/${dexId}/${tokenAddress.toLowerCase()}`
);
const response = await this.api.get<unknown>(`/token-pairs/v1/${dexId}/${tokenAddress.toLowerCase()}`);
return response.data.pairs || [];
const raw = normalizeDexScreenerTokenPairsPayload(response.data);
return preferGruV2OfficialDexPairs(chainId, tokenAddress.toLowerCase(), raw);
} catch (error) {
logger.error(`Error fetching DexScreener pairs for ${tokenAddress} on chain ${chainId}:`, error);
return [];
@@ -263,11 +305,11 @@ export class DexScreenerAdapter implements ExternalApiAdapter {
}
try {
const response = await this.api.get<DexScreenerResponse>(
const response = await this.api.get<{ pair?: DexScreenerPair }>(
`/latest/dex/pairs/${dexId}/${pairAddress.toLowerCase()}`
);
return response.data.pair || null;
return response.data.pair ?? null;
} catch (error: unknown) {
const err = error as { response?: { status?: number } };
if (err.response?.status === 404) {
@@ -300,13 +342,13 @@ export class DexScreenerAdapter implements ExternalApiAdapter {
for (const chunk of chunks) {
try {
const response = await this.api.get<DexScreenerResponse>(
const response = await this.api.get<unknown>(
`/tokens/v1/${dexId}/${chunk.map((addr) => addr.toLowerCase()).join(',')}`
);
if (response.data.pairs) {
// Group pairs by token address
response.data.pairs.forEach((pair) => {
const batchPairs = normalizeDexScreenerTokenPairsPayload(response.data);
if (batchPairs.length > 0) {
batchPairs.forEach((pair) => {
const baseAddr = pair.baseToken.address.toLowerCase();
const quoteAddr = pair.quoteToken.address.toLowerCase();