feat: expand non-evm relay and route planning support
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user