feat: expand non-evm relay and route planning support
This commit is contained in:
@@ -230,14 +230,29 @@ Configure DEX factory addresses in `src/config/dex-factories.ts` or via environm
|
||||
```bash
|
||||
# ChainID 138
|
||||
CHAIN_138_DODO_POOL_MANAGER=0x...
|
||||
CHAIN_138_UNISWAP_V2_FACTORY=0x...
|
||||
CHAIN_138_UNISWAP_V3_FACTORY=0x...
|
||||
CHAIN_138_UNISWAP_V2_FACTORY=0x0C30F6e67Ab3667fCc2f5CEA8e274ef1FB920279
|
||||
CHAIN_138_UNISWAP_V2_ROUTER=0x3019A7fDc76ba7F64F18d78e66842760037ee638
|
||||
CHAIN_138_UNISWAP_V2_START_BLOCK=4041370
|
||||
CHAIN_138_SUSHISWAP_FACTORY=0x2871207ff0d56089D70c0134d33f1291B6Fce0BE
|
||||
CHAIN_138_SUSHISWAP_ROUTER=0xB37b93D38559f53b62ab020A14919f2630a1aE34
|
||||
CHAIN_138_SUSHISWAP_START_BLOCK=4041495
|
||||
CHAIN_138_UNISWAP_V3_FACTORY=0x2f7219276e3ce367dB9ec74C1196a8ecEe67841C
|
||||
CHAIN_138_UNISWAP_V3_ROUTER=0xde9cD8ee2811E6E64a41D5F68Be315d33995975E
|
||||
|
||||
# ChainID 651940
|
||||
CHAIN_651940_UNISWAP_V2_FACTORY=0x...
|
||||
CHAIN_651940_UNISWAP_V2_ROUTER=0x...
|
||||
CHAIN_651940_UNISWAP_V2_START_BLOCK=0
|
||||
CHAIN_651940_UNISWAP_V3_FACTORY=0x...
|
||||
CHAIN_651940_UNISWAP_V3_ROUTER=0x...
|
||||
CHAIN_651940_UNISWAP_V3_START_BLOCK=0
|
||||
CHAIN_651940_HYDX_FACTORY=0x...
|
||||
CHAIN_651940_HYDX_ROUTER=0x...
|
||||
CHAIN_651940_HYDX_START_BLOCK=0
|
||||
```
|
||||
|
||||
For ALL Mainnet non-DODO discovery, the repo now treats `HYDX` as the canonical custom venue surface when factory/router details are known. The broader `651940` non-DODO inventory is tracked in `config/allmainnet-non-dodo-protocol-surface.json`.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The service includes:
|
||||
|
||||
@@ -175,15 +175,30 @@ For ChainID 138, configure DODO PoolManager address:
|
||||
|
||||
```bash
|
||||
CHAIN_138_DODO_POOL_MANAGER=0x...
|
||||
CHAIN_138_UNISWAP_V2_FACTORY=0x0C30F6e67Ab3667fCc2f5CEA8e274ef1FB920279
|
||||
CHAIN_138_UNISWAP_V2_ROUTER=0x3019A7fDc76ba7F64F18d78e66842760037ee638
|
||||
CHAIN_138_UNISWAP_V2_START_BLOCK=4041370
|
||||
CHAIN_138_SUSHISWAP_FACTORY=0x2871207ff0d56089D70c0134d33f1291B6Fce0BE
|
||||
CHAIN_138_SUSHISWAP_ROUTER=0xB37b93D38559f53b62ab020A14919f2630a1aE34
|
||||
CHAIN_138_SUSHISWAP_START_BLOCK=4041495
|
||||
```
|
||||
|
||||
For ChainID 651940, configure DEX factories as they are discovered:
|
||||
|
||||
```bash
|
||||
CHAIN_651940_UNISWAP_V2_FACTORY=0x...
|
||||
CHAIN_651940_UNISWAP_V2_ROUTER=0x...
|
||||
CHAIN_651940_UNISWAP_V2_START_BLOCK=0
|
||||
CHAIN_651940_UNISWAP_V3_FACTORY=0x...
|
||||
CHAIN_651940_UNISWAP_V3_ROUTER=0x...
|
||||
CHAIN_651940_UNISWAP_V3_START_BLOCK=0
|
||||
CHAIN_651940_HYDX_FACTORY=0x...
|
||||
CHAIN_651940_HYDX_ROUTER=0x...
|
||||
CHAIN_651940_HYDX_START_BLOCK=0
|
||||
```
|
||||
|
||||
The canonical ALL Mainnet non-DODO inventory is also tracked in the parent repo at `config/allmainnet-non-dodo-protocol-surface.json`.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import reportRoutes from './report';
|
||||
import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens';
|
||||
|
||||
jest.mock('../../database/repositories/token-repo', () => ({
|
||||
TokenRepository: jest.fn().mockImplementation(() => ({
|
||||
@@ -124,6 +125,30 @@ describe('Report API', () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('fills canonical fallback usd pricing when market data is absent', async () => {
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
expect(weth?.addresses[138]).toBeTruthy();
|
||||
const wethAddress = String(weth?.addresses[138]).toLowerCase();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/all?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
const tokens138 = body.tokens?.['138'];
|
||||
expect(Array.isArray(tokens138)).toBe(true);
|
||||
|
||||
const wethEntry = tokens138.find((token: Record<string, any>) => token.address === wethAddress);
|
||||
expect(wethEntry).toMatchObject({
|
||||
symbol: 'WETH',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
volume24h: 0,
|
||||
liquidityUsd: 0,
|
||||
lastUpdated: '2026-04-15T00:00:00.000Z',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
@@ -413,6 +438,68 @@ describe('Report API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gru-v2-pmm-pools', () => {
|
||||
it('returns resolved PMM pools from deployment-status when file is set', async () => {
|
||||
const previousPath = process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
const tempPath = `/tmp/token-aggregation-gru-v2-pmm-${Date.now()}.json`;
|
||||
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = tempPath;
|
||||
await import('fs/promises').then((fs) =>
|
||||
fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 'test-gru-pools',
|
||||
updated: '2026-04-18',
|
||||
homeChainId: 138,
|
||||
chains: {
|
||||
'1': {
|
||||
name: 'Ethereum Mainnet',
|
||||
cwTokens: { cWUSDT: '0xaf5017d0163ecb99d9b5d94e3b4d7b09af44d8ae' },
|
||||
anchorAddresses: { USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' },
|
||||
pmmPools: [
|
||||
{
|
||||
base: 'cWUSDT',
|
||||
quote: 'USDC',
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
feeBps: 3,
|
||||
role: 'public_routing',
|
||||
publicRoutingEnabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gru-v2-pmm-pools?chainId=1`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.source).toBe('deployment-status-file');
|
||||
expect(body.complete).toBe(true);
|
||||
expect(body.version).toBe('test-gru-pools');
|
||||
expect(Array.isArray(body.pools)).toBe(true);
|
||||
expect((body.pools as unknown[]).length).toBeGreaterThanOrEqual(1);
|
||||
expect((body.pools as Array<{ poolAddress: string }>)[0]).toMatchObject({
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
section: 'pmmPools',
|
||||
});
|
||||
} finally {
|
||||
await import('fs/promises').then((fs) => fs.unlink(tempPath).catch(() => undefined));
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
} else {
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = previousPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
it('reads the live gas rollout registry from deployment-status json when available', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gas-registry?chainId=10`);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
loadDeploymentStatusFile,
|
||||
type CwRegistryChain,
|
||||
} from '../../config/deployment-status';
|
||||
import { getGruV2DeploymentPoolRows } from '../../config/gru-v2-deployment-pools';
|
||||
import { getCanonicalPriceSnapshotGeneratedAt, getCanonicalPriceUsd } from '../../services/canonical-price-oracle';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -94,6 +96,8 @@ async function buildTokenReport(chainId: number) {
|
||||
})
|
||||
);
|
||||
|
||||
const fallbackPriceUsd = getCanonicalPriceUsd(chainId, address);
|
||||
|
||||
out.push({
|
||||
chainId,
|
||||
address: address.toLowerCase(),
|
||||
@@ -110,7 +114,7 @@ async function buildTokenReport(chainId: number) {
|
||||
liquiditySourceSymbol: spec.liquiditySourceSymbol,
|
||||
market: marketData
|
||||
? {
|
||||
priceUsd: marketData.priceUsd,
|
||||
priceUsd: marketData.priceUsd ?? fallbackPriceUsd,
|
||||
volume24h: marketData.volume24h,
|
||||
volume7d: marketData.volume7d,
|
||||
volume30d: marketData.volume30d,
|
||||
@@ -118,6 +122,15 @@ async function buildTokenReport(chainId: number) {
|
||||
liquidityUsd: marketData.liquidityUsd,
|
||||
lastUpdated: marketData.lastUpdated?.toISOString() ?? '',
|
||||
}
|
||||
: fallbackPriceUsd !== undefined
|
||||
? {
|
||||
priceUsd: fallbackPriceUsd,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
lastUpdated: `${getCanonicalPriceSnapshotGeneratedAt()}T00:00:00.000Z`,
|
||||
}
|
||||
: undefined,
|
||||
pools: resolvedPools.map((p) => ({
|
||||
poolAddress: p.poolAddress,
|
||||
@@ -543,6 +556,36 @@ router.get('/cw-registry', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/gru-v2-pmm-pools — all GRU v2 PMM pools from deployment-status (stable, volatile, gas) with resolved token addresses. */
|
||||
router.get('/gru-v2-pmm-pools', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
const fileBacked = loadDeploymentStatusFile();
|
||||
let pools = getGruV2DeploymentPoolRows();
|
||||
|
||||
if (chainIdFilter && !Number.isNaN(chainIdFilter)) {
|
||||
pools = pools.filter((p) => p.chainId === chainIdFilter);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: fileBacked ? 'deployment-status-file' : 'none',
|
||||
complete: !!fileBacked,
|
||||
version: fileBacked?.data.version,
|
||||
updated: fileBacked?.data.updated,
|
||||
lastModified: fileBacked?.lastModified,
|
||||
homeChainId: fileBacked?.data.homeChainId,
|
||||
count: pools.length,
|
||||
pools,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/gru-v2-pmm-pools:', error);
|
||||
res.status(500).json({ error: 'Internal server error', pools: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/gas-registry — live gas-family rollout registry from deployment-status.json plus GRU transport metadata. */
|
||||
router.get('/gas-registry', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
219
services/token-aggregation/src/api/routes/tokens.test.ts
Normal file
219
services/token-aggregation/src/api/routes/tokens.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens';
|
||||
|
||||
const mockGetTokens = jest.fn();
|
||||
const mockGetToken = jest.fn();
|
||||
const mockSearchTokens = jest.fn();
|
||||
const mockGetMarketData = jest.fn();
|
||||
const mockGetPoolsByToken = jest.fn();
|
||||
const mockGetPool = jest.fn();
|
||||
const mockGetLiveDodoPools = jest.fn();
|
||||
const mockResolveTokenDisplay = jest.fn();
|
||||
const mockResolvePoolTokenDisplays = jest.fn();
|
||||
const mockGetTokenByContract = jest.fn();
|
||||
|
||||
jest.mock('../../database/repositories/token-repo', () => ({
|
||||
TokenRepository: jest.fn().mockImplementation(() => ({
|
||||
getTokens: mockGetTokens,
|
||||
getToken: mockGetToken,
|
||||
searchTokens: mockSearchTokens,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../database/repositories/market-data-repo', () => ({
|
||||
MarketDataRepository: jest.fn().mockImplementation(() => ({
|
||||
getMarketData: mockGetMarketData,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../database/repositories/pool-repo', () => ({
|
||||
PoolRepository: jest.fn().mockImplementation(() => ({
|
||||
getPoolsByToken: mockGetPoolsByToken,
|
||||
getPool: mockGetPool,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../indexer/ohlcv-generator', () => ({
|
||||
OHLCVGenerator: jest.fn().mockImplementation(() => ({
|
||||
getOHLCV: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockGetMarketDataAdapter = jest.fn();
|
||||
|
||||
jest.mock('../../adapters/coingecko-adapter', () => ({
|
||||
CoinGeckoAdapter: jest.fn().mockImplementation(() => ({
|
||||
getTokenByContract: mockGetTokenByContract,
|
||||
getMarketData: mockGetMarketDataAdapter,
|
||||
getTrending: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../adapters/cmc-adapter', () => ({
|
||||
CoinMarketCapAdapter: jest.fn().mockImplementation(() => ({
|
||||
getTokenByContract: mockGetTokenByContract,
|
||||
getMarketData: mockGetMarketDataAdapter,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../adapters/dexscreener-adapter', () => ({
|
||||
DexScreenerAdapter: jest.fn().mockImplementation(() => ({
|
||||
getTokenByContract: mockGetTokenByContract,
|
||||
getMarketData: mockGetMarketDataAdapter,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/live-dodo-fallback', () => ({
|
||||
getLiveDodoPools: (...args: unknown[]) => mockGetLiveDodoPools(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/token-display', () => ({
|
||||
resolveTokenDisplay: (...args: unknown[]) => mockResolveTokenDisplay(...args),
|
||||
resolvePoolTokenDisplays: (...args: unknown[]) => mockResolvePoolTokenDisplays(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
const tokensRoutes = require('./tokens').default as typeof import('./tokens').default;
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1', tokensRoutes);
|
||||
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('Tokens 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;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetMarketDataAdapter.mockResolvedValue(null);
|
||||
mockGetTokens.mockResolvedValue([]);
|
||||
mockGetToken.mockResolvedValue(null);
|
||||
mockSearchTokens.mockResolvedValue([]);
|
||||
mockGetMarketData.mockResolvedValue(null);
|
||||
mockGetPoolsByToken.mockResolvedValue([]);
|
||||
mockGetPool.mockResolvedValue(null);
|
||||
mockGetLiveDodoPools.mockResolvedValue([]);
|
||||
mockResolveTokenDisplay.mockResolvedValue({
|
||||
address: '',
|
||||
name: 'Unknown Token',
|
||||
symbol: 'UNKNOWN',
|
||||
decimals: 18,
|
||||
source: 'fallback',
|
||||
});
|
||||
mockResolvePoolTokenDisplays.mockResolvedValue({
|
||||
token0: { address: '', symbol: 'UNKNOWN', name: 'Unknown Token', source: 'fallback' },
|
||||
token1: { address: '', symbol: 'UNKNOWN', name: 'Unknown Token', source: 'fallback' },
|
||||
});
|
||||
mockGetTokenByContract.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('lists canonical 138 tokens with stable and ETH-family fallback pricing when db market data is missing', async () => {
|
||||
const usdt = getCanonicalTokenBySymbol(138, 'USDT');
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
const weth10 = getCanonicalTokenBySymbol(138, 'WETH10');
|
||||
|
||||
expect(usdt?.addresses[138]).toBeTruthy();
|
||||
expect(weth?.addresses[138]).toBeTruthy();
|
||||
expect(weth10?.addresses[138]).toBeTruthy();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/tokens?chainId=138&limit=400`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('canonical');
|
||||
|
||||
const findByAddress = (address?: string) =>
|
||||
body.tokens.find((token: Record<string, any>) => token.address === address?.toLowerCase());
|
||||
|
||||
expect(findByAddress(usdt?.addresses[138])).toMatchObject({
|
||||
symbol: 'USDT',
|
||||
decimals: 6,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 1,
|
||||
volume24h: 0,
|
||||
liquidityUsd: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findByAddress(weth?.addresses[138])).toMatchObject({
|
||||
symbol: 'WETH',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findByAddress(weth10?.addresses[138])).toMatchObject({
|
||||
symbol: 'WETH10',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing priceUsd on token detail responses while preserving repository market fields', async () => {
|
||||
const weth10 = getCanonicalTokenBySymbol(138, 'WETH10');
|
||||
expect(weth10?.addresses[138]).toBeTruthy();
|
||||
const weth10Address = String(weth10?.addresses[138]).toLowerCase();
|
||||
|
||||
mockGetMarketData.mockResolvedValue({
|
||||
chainId: 138,
|
||||
tokenAddress: weth10Address,
|
||||
priceUsd: undefined,
|
||||
volume24h: 1234,
|
||||
volume7d: 5678,
|
||||
volume30d: 9012,
|
||||
liquidityUsd: 3456,
|
||||
holdersCount: 78,
|
||||
transfers24h: 9,
|
||||
lastUpdated: new Date('2026-04-16T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/tokens/${weth10Address}?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.token).toMatchObject({
|
||||
symbol: 'WETH10',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
volume24h: 1234,
|
||||
liquidityUsd: 3456,
|
||||
}),
|
||||
hasDodoPool: false,
|
||||
});
|
||||
expect(body.token.canonicalLiquidity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
|
||||
import {
|
||||
buildExplorerLinks,
|
||||
mergeMarketWithValuation,
|
||||
resolveUsdValuation,
|
||||
} from '../../services/valuation-precedence';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -26,6 +31,26 @@ const coingeckoAdapter = new CoinGeckoAdapter();
|
||||
const cmcAdapter = new CoinMarketCapAdapter();
|
||||
const dexscreenerAdapter = new DexScreenerAdapter();
|
||||
|
||||
function buildMarketPricingExplorer(
|
||||
chainId: number,
|
||||
displayAddress: string,
|
||||
lookupAddress: string,
|
||||
marketData: Awaited<ReturnType<MarketDataRepository['getMarketData']>>,
|
||||
external: { coingecko?: Awaited<ReturnType<CoinGeckoAdapter['getMarketData']>>; cmc?: Awaited<ReturnType<CoinMarketCapAdapter['getMarketData']>>; dexscreener?: Awaited<ReturnType<DexScreenerAdapter['getMarketData']>> } | null
|
||||
) {
|
||||
const pricing = resolveUsdValuation({
|
||||
chainId,
|
||||
normalizedAddress: lookupAddress.toLowerCase(),
|
||||
indexer: marketData,
|
||||
coingecko: external?.coingecko ?? undefined,
|
||||
cmc: external?.cmc ?? undefined,
|
||||
dexscreener: external?.dexscreener ?? undefined,
|
||||
});
|
||||
const market = mergeMarketWithValuation(chainId, displayAddress.toLowerCase(), marketData, pricing);
|
||||
const explorer = buildExplorerLinks(chainId, displayAddress);
|
||||
return { market, pricing, explorer };
|
||||
}
|
||||
|
||||
function tokenFromCanonical(chainId: number, address: string): Token | null {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase());
|
||||
if (!spec) {
|
||||
@@ -182,10 +207,20 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
const { tokens, source } = await getTokensWithFallback(chainId, limit, offset);
|
||||
const tokensWithMarketData = await Promise.all(
|
||||
tokens.map(async (token) => {
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, token.address);
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, token.address);
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, resolution.lookupAddress);
|
||||
const { market, pricing, explorer } = buildMarketPricingExplorer(
|
||||
chainId,
|
||||
token.address,
|
||||
resolution.lookupAddress,
|
||||
marketData,
|
||||
null
|
||||
);
|
||||
const out: Record<string, unknown> = {
|
||||
...token,
|
||||
market: marketData || undefined,
|
||||
market: market || undefined,
|
||||
pricing,
|
||||
explorer,
|
||||
};
|
||||
if (includeDodoPool) {
|
||||
const pools = await getPoolsByTokenWithFallback(chainId, token.address);
|
||||
@@ -228,13 +263,32 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
|
||||
const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([
|
||||
const [
|
||||
marketDataRaw,
|
||||
pools,
|
||||
coingeckoData,
|
||||
cmcData,
|
||||
dexscreenerData,
|
||||
coingeckoMarket,
|
||||
cmcMarket,
|
||||
dexscreenerMarket,
|
||||
] = await Promise.all([
|
||||
marketDataRepo.getMarketData(chainId, resolution.lookupAddress),
|
||||
getPoolsByTokenWithFallback(chainId, normalizedAddress),
|
||||
coingeckoAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
coingeckoAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
]);
|
||||
const { market: marketData, pricing, explorer } = buildMarketPricingExplorer(
|
||||
chainId,
|
||||
normalizedAddress,
|
||||
resolution.lookupAddress,
|
||||
marketDataRaw,
|
||||
{ coingecko: coingeckoMarket, cmc: cmcMarket, dexscreener: dexscreenerMarket }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token: {
|
||||
@@ -243,6 +297,8 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
totalSupply: token.totalSupply,
|
||||
},
|
||||
market: marketData || undefined,
|
||||
pricing,
|
||||
explorer,
|
||||
external: {
|
||||
coingecko: coingeckoData || undefined,
|
||||
cmc: cmcData || undefined,
|
||||
|
||||
@@ -115,6 +115,26 @@ describe('canonical cW token catalog', () => {
|
||||
expect(cwethL2?.addresses[10]).toBe('0xce7200000000000000000000000000000000000a');
|
||||
expect(getCanonicalTokenByAddress(10, '0xce7200000000000000000000000000000000000a')?.symbol).toBe('cWETHL2');
|
||||
expect(getTokenRegistryFamily(cwethL2!)).toBe('gas_native');
|
||||
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
expect(weth).toMatchObject({
|
||||
symbol: 'WETH',
|
||||
type: 'w',
|
||||
currencyCode: 'ETH',
|
||||
decimals: 18,
|
||||
});
|
||||
expect(weth?.addresses[138]).toBe('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
|
||||
expect(getCanonicalTokenByAddress(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')?.symbol).toBe('WETH');
|
||||
|
||||
const weth10 = getCanonicalTokenBySymbol(138, 'WETH10');
|
||||
expect(weth10).toMatchObject({
|
||||
symbol: 'WETH10',
|
||||
type: 'w',
|
||||
currencyCode: 'ETH',
|
||||
decimals: 18,
|
||||
});
|
||||
expect(weth10?.addresses[138]).toBe('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f');
|
||||
expect(getCanonicalTokenByAddress(138, '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')?.symbol).toBe('WETH10');
|
||||
});
|
||||
|
||||
it('surfaces cAUSDT on Chain 138 from env and keeps cWAUSDT fallback mirrors on active public chains', () => {
|
||||
|
||||
@@ -247,6 +247,8 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
cCADC: { [CHAIN_138]: '0x54dBd40cF05e15906A2C21f600937e96787f5679' },
|
||||
cXAUC: { [CHAIN_138]: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b' },
|
||||
cXAUT: { [CHAIN_138]: '0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E' },
|
||||
WETH: { [CHAIN_138]: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' },
|
||||
WETH10: { [CHAIN_138]: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f' },
|
||||
// ISO-4217W on Cronos (25) — from DeployISO4217WSystem
|
||||
USDW: { [CHAIN_25]: '0x948690147D2e50ffe50C5d38C14125aD6a9FA036' },
|
||||
EURW: { [CHAIN_25]: '0x58a8D8F78F1B65c06dAd7542eC46b299629A60dd' },
|
||||
@@ -450,6 +452,26 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
{ symbol: 'cWUSDC', name: 'USD Coin (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDC.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDC', id)])) } },
|
||||
{ symbol: 'cWUSDT', name: 'Tether USD (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDT.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDT', id)])) } },
|
||||
{ symbol: 'cWUSDW', name: 'USD W (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDW.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDW', id)])) } },
|
||||
{
|
||||
symbol: 'WETH',
|
||||
name: 'Wrapped Ether (WETH9)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Legacy WETH9 surface used on Chain 138 for canonical ETH swap routing and CCIP WETH9 bridge lanes.',
|
||||
addresses: { [CHAIN_138]: addr('WETH', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'WETH10',
|
||||
name: 'Wrapped Ether 10',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Chain 138 WETH10 pilot wrapped ETH surface used by DODO v3 routing and flash-capable paths.',
|
||||
addresses: { [CHAIN_138]: addr('WETH10', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWBTC',
|
||||
name: 'Bitcoin (Compliant Wrapped Monetary Unit)',
|
||||
@@ -728,6 +750,8 @@ const LOGO_BY_SYMBOL: Record<string, string> = {
|
||||
cWUSDC: USDC_LOGO,
|
||||
cWUSDT: USDT_LOGO,
|
||||
cWUSDW: USDC_LOGO,
|
||||
WETH: ETH_LOGO,
|
||||
WETH10: ETH_LOGO,
|
||||
cEURC: `${GRU_LOGO_BASE}/cEURC.svg`,
|
||||
cEURT: `${GRU_LOGO_BASE}/cEURT.svg`,
|
||||
cGBPC: `${GRU_LOGO_BASE}/cGBPC.svg`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DexType = 'uniswap_v2' | 'uniswap_v3' | 'dodo' | 'custom';
|
||||
export type DexType = 'uniswap_v2' | 'uniswap_v3' | 'sushiswap' | 'dodo' | 'custom';
|
||||
|
||||
export interface UniswapV2Config {
|
||||
factory: string;
|
||||
@@ -30,6 +30,7 @@ export interface CustomDexConfig {
|
||||
export interface DexFactoryConfig {
|
||||
uniswap_v2?: UniswapV2Config[];
|
||||
uniswap_v3?: UniswapV3Config[];
|
||||
sushiswap?: UniswapV2Config[];
|
||||
dodo?: DodoConfig[];
|
||||
custom?: CustomDexConfig[];
|
||||
}
|
||||
@@ -38,6 +39,35 @@ export interface DexFactoryConfig {
|
||||
const CANONICAL_CHAIN138_DODO_PMM_INTEGRATION =
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
function getUniswapV2Config(chainId: number): UniswapV2Config[] | undefined {
|
||||
const factory = process.env[`CHAIN_${chainId}_UNISWAP_V2_FACTORY`];
|
||||
if (!factory) return undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
factory,
|
||||
router: process.env[`CHAIN_${chainId}_UNISWAP_V2_ROUTER`] || '',
|
||||
startBlock: parseInt(process.env[`CHAIN_${chainId}_UNISWAP_V2_START_BLOCK`] || '0', 10),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getDodoConfig(chainId: number): DodoConfig[] | undefined {
|
||||
const poolManager = process.env[`CHAIN_${chainId}_DODO_POOL_MANAGER`] || '';
|
||||
const dodoPmmIntegration = process.env[`CHAIN_${chainId}_DODO_PMM_INTEGRATION`] || '';
|
||||
|
||||
if (!poolManager && !dodoPmmIntegration) return undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
poolManager,
|
||||
dodoPmmIntegration,
|
||||
dodoVendingMachine: process.env[`CHAIN_${chainId}_DODO_VENDING_MACHINE`] || '',
|
||||
startBlock: parseInt(process.env[`CHAIN_${chainId}_DODO_START_BLOCK`] || '0', 10),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
138: {
|
||||
// DODO PMM Integration - index from DODOPMMIntegration or PoolManager
|
||||
@@ -51,12 +81,13 @@ export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
},
|
||||
],
|
||||
// UniswapV2 - if deployed
|
||||
uniswap_v2: process.env.CHAIN_138_UNISWAP_V2_FACTORY
|
||||
uniswap_v2: getUniswapV2Config(138),
|
||||
sushiswap: process.env.CHAIN_138_SUSHISWAP_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),
|
||||
factory: process.env.CHAIN_138_SUSHISWAP_FACTORY,
|
||||
router: process.env.CHAIN_138_SUSHISWAP_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_138_SUSHISWAP_START_BLOCK || '0', 10),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
@@ -74,15 +105,7 @@ export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
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_v2: getUniswapV2Config(651940),
|
||||
uniswap_v3: process.env.CHAIN_651940_UNISWAP_V3_FACTORY
|
||||
? [
|
||||
{
|
||||
@@ -101,72 +124,61 @@ export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
custom: process.env.CHAIN_651940_HYDX_FACTORY
|
||||
? [
|
||||
{
|
||||
factory: process.env.CHAIN_651940_HYDX_FACTORY,
|
||||
router: process.env.CHAIN_651940_HYDX_ROUTER || '',
|
||||
startBlock: parseInt(process.env.CHAIN_651940_HYDX_START_BLOCK || '0', 10),
|
||||
pairCreatedEvent: process.env.CHAIN_651940_HYDX_PAIR_CREATED_EVENT || '',
|
||||
},
|
||||
]
|
||||
: 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,
|
||||
uniswap_v2: getUniswapV2Config(1),
|
||||
dodo: getDodoConfig(1),
|
||||
},
|
||||
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,
|
||||
uniswap_v2: getUniswapV2Config(10),
|
||||
dodo: getDodoConfig(10),
|
||||
},
|
||||
25: {
|
||||
uniswap_v2: getUniswapV2Config(25),
|
||||
dodo: getDodoConfig(25),
|
||||
},
|
||||
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,
|
||||
uniswap_v2: getUniswapV2Config(56),
|
||||
dodo: getDodoConfig(56),
|
||||
},
|
||||
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,
|
||||
uniswap_v2: getUniswapV2Config(100),
|
||||
dodo: getDodoConfig(100),
|
||||
},
|
||||
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,
|
||||
uniswap_v2: getUniswapV2Config(137),
|
||||
dodo: getDodoConfig(137),
|
||||
},
|
||||
8453: {
|
||||
uniswap_v2: getUniswapV2Config(8453),
|
||||
dodo: getDodoConfig(8453),
|
||||
},
|
||||
42161: {
|
||||
uniswap_v2: getUniswapV2Config(42161),
|
||||
dodo: getDodoConfig(42161),
|
||||
},
|
||||
42220: {
|
||||
uniswap_v2: getUniswapV2Config(42220),
|
||||
dodo: getDodoConfig(42220),
|
||||
},
|
||||
43114: {
|
||||
uniswap_v2: getUniswapV2Config(43114),
|
||||
dodo: getDodoConfig(43114),
|
||||
},
|
||||
1111: {
|
||||
uniswap_v2: getUniswapV2Config(1111),
|
||||
dodo: getDodoConfig(1111),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -189,6 +201,8 @@ export function hasDexType(chainId: number, dexType: DexType): boolean {
|
||||
return !!config.uniswap_v2 && config.uniswap_v2.length > 0;
|
||||
case 'uniswap_v3':
|
||||
return !!config.uniswap_v3 && config.uniswap_v3.length > 0;
|
||||
case 'sushiswap':
|
||||
return !!config.sushiswap && config.sushiswap.length > 0;
|
||||
case 'dodo':
|
||||
return !!config.dodo && config.dodo.length > 0;
|
||||
case 'custom':
|
||||
@@ -208,6 +222,7 @@ export function getConfiguredDexTypes(chainId: number): DexType[] {
|
||||
const types: DexType[] = [];
|
||||
if (hasDexType(chainId, 'uniswap_v2')) types.push('uniswap_v2');
|
||||
if (hasDexType(chainId, 'uniswap_v3')) types.push('uniswap_v3');
|
||||
if (hasDexType(chainId, 'sushiswap')) types.push('sushiswap');
|
||||
if (hasDexType(chainId, 'dodo')) types.push('dodo');
|
||||
if (hasDexType(chainId, 'custom')) types.push('custom');
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DeploymentStatusFile } from './deployment-status';
|
||||
import {
|
||||
buildGruV2PoolRegistryFromDeploymentData,
|
||||
preferGruV2OfficialDexPairs,
|
||||
resolveDeploymentTokenAddress,
|
||||
} from './gru-v2-deployment-pools';
|
||||
|
||||
describe('gru-v2-deployment-pools', () => {
|
||||
it('resolveDeploymentTokenAddress checks cwTokens, gasMirrors, anchors, gasQuoteAddresses', () => {
|
||||
const chain = {
|
||||
cwTokens: { cWUSDT: '0xcc' },
|
||||
anchorAddresses: { USDC: '0xaa' },
|
||||
gasQuoteAddresses: { WETH: '0xee' },
|
||||
};
|
||||
expect(resolveDeploymentTokenAddress(chain, 'cWUSDT')).toBe('0xcc');
|
||||
expect(resolveDeploymentTokenAddress(chain, 'USDC')).toBe('0xaa');
|
||||
expect(resolveDeploymentTokenAddress(chain, 'WETH')).toBe('0xee');
|
||||
expect(resolveDeploymentTokenAddress(chain, 'MISSING')).toBeNull();
|
||||
});
|
||||
|
||||
it('buildGruV2PoolRegistryFromDeploymentData merges pmmPools, pmmPoolsVolatile, and gasPmmPools', () => {
|
||||
const data = {
|
||||
chains: {
|
||||
'1': {
|
||||
name: 'Ethereum Mainnet',
|
||||
cwTokens: { cWUSDT: '0xaf5017d0163ecb99d9b5d94e3b4d7b09af44d8ae' },
|
||||
anchorAddresses: {
|
||||
USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
|
||||
TRUU: '0xdae0fafd65385e7775cf75b1398735155ef6acd2',
|
||||
},
|
||||
pmmPools: [
|
||||
{
|
||||
base: 'cWUSDT',
|
||||
quote: 'USDC',
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
feeBps: 3,
|
||||
role: 'public_routing',
|
||||
publicRoutingEnabled: true,
|
||||
},
|
||||
],
|
||||
pmmPoolsVolatile: [
|
||||
{
|
||||
base: 'cWUSDT',
|
||||
quote: 'TRUU',
|
||||
poolAddress: '0x2222222222222222222222222222222222222222',
|
||||
feeBps: 30,
|
||||
role: 'truu_routing',
|
||||
},
|
||||
],
|
||||
gasMirrors: { cWETH: '0xf6dc5587e18f27adff60e303fdd98f35b50fa8a5' },
|
||||
gasQuoteAddresses: {
|
||||
WETH: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
|
||||
},
|
||||
gasPmmPools: [
|
||||
{
|
||||
familyKey: 'eth_mainnet',
|
||||
base: 'cWETH',
|
||||
quote: 'USDC',
|
||||
poolAddress: '0x3333333333333333333333333333333333333333',
|
||||
feeBps: 30,
|
||||
role: 'public_routing',
|
||||
venue: 'dodo_pmm',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as DeploymentStatusFile;
|
||||
|
||||
const { rows, byChainTokenPools } = buildGruV2PoolRegistryFromDeploymentData(data);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.section).sort()).toEqual(['gasPmmPools', 'pmmPools', 'pmmPoolsVolatile']);
|
||||
|
||||
const usdt = '0xaf5017d0163ecb99d9b5d94e3b4d7b09af44d8ae';
|
||||
expect(byChainTokenPools.get(`1:${usdt}`)?.has('0x1111111111111111111111111111111111111111')).toBe(true);
|
||||
expect(byChainTokenPools.get(`1:${usdt}`)?.has('0x2222222222222222222222222222222222222222')).toBe(true);
|
||||
});
|
||||
|
||||
it('preferGruV2OfficialDexPairs leaves pairs unchanged when no deployment pools index the token', () => {
|
||||
const pairs = [{ pairAddress: '0xbb', priceUsd: '1' }];
|
||||
expect(preferGruV2OfficialDexPairs(99999, '0x0000000000000000000000000000000000000001', pairs)).toEqual(pairs);
|
||||
});
|
||||
});
|
||||
197
services/token-aggregation/src/config/gru-v2-deployment-pools.ts
Normal file
197
services/token-aggregation/src/config/gru-v2-deployment-pools.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { DeploymentStatusFile } from './deployment-status';
|
||||
import { loadDeploymentStatusFile, resolveDeploymentStatusPath } from './deployment-status';
|
||||
|
||||
export type GruV2PmmSection = 'pmmPools' | 'pmmPoolsVolatile' | 'gasPmmPools';
|
||||
|
||||
export interface GruV2DeploymentPoolRow {
|
||||
chainId: number;
|
||||
chainName: string;
|
||||
section: GruV2PmmSection;
|
||||
baseSymbol: string;
|
||||
quoteSymbol: string;
|
||||
baseAddress: string;
|
||||
quoteAddress: string;
|
||||
poolAddress: string;
|
||||
feeBps?: number;
|
||||
role?: string;
|
||||
publicRoutingEnabled?: boolean;
|
||||
familyKey?: string;
|
||||
venue?: string;
|
||||
}
|
||||
|
||||
interface ChainTokenMaps {
|
||||
cwTokens?: Record<string, string>;
|
||||
gasMirrors?: Record<string, string>;
|
||||
anchorAddresses?: Record<string, string>;
|
||||
gasQuoteAddresses?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Resolve a pool leg symbol to an address using deployment-status chain maps (cW*, anchors, gas quotes). */
|
||||
export function resolveDeploymentTokenAddress(chain: ChainTokenMaps, symbol: string): string | null {
|
||||
const candidates = [
|
||||
chain.cwTokens?.[symbol],
|
||||
chain.gasMirrors?.[symbol],
|
||||
chain.anchorAddresses?.[symbol],
|
||||
chain.gasQuoteAddresses?.[symbol],
|
||||
];
|
||||
for (const a of candidates) {
|
||||
if (typeof a === 'string' && a.startsWith('0x')) {
|
||||
return a.toLowerCase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pushTokenPoolIndex(
|
||||
byChainTokenPools: Map<string, Set<string>>,
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
poolAddress: string
|
||||
): void {
|
||||
const key = `${chainId}:${tokenAddress.toLowerCase()}`;
|
||||
let set = byChainTokenPools.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byChainTokenPools.set(key, set);
|
||||
}
|
||||
set.add(poolAddress.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build GRU v2 PMM pool rows and a (chainId:token) → official pool addresses index from deployment-status data.
|
||||
* Includes stable mesh (`pmmPools`), volatile (`pmmPoolsVolatile`), and gas (`gasPmmPools`) sections.
|
||||
*/
|
||||
export function buildGruV2PoolRegistryFromDeploymentData(data: DeploymentStatusFile): {
|
||||
rows: GruV2DeploymentPoolRow[];
|
||||
byChainTokenPools: Map<string, Set<string>>;
|
||||
} {
|
||||
const rows: GruV2DeploymentPoolRow[] = [];
|
||||
const byChainTokenPools = new Map<string, Set<string>>();
|
||||
|
||||
const sections: GruV2PmmSection[] = ['pmmPools', 'pmmPoolsVolatile', 'gasPmmPools'];
|
||||
|
||||
for (const [cid, rawChain] of Object.entries(data.chains ?? {})) {
|
||||
const chainId = Number(cid);
|
||||
if (Number.isNaN(chainId)) continue;
|
||||
const chain = rawChain as ChainTokenMaps & {
|
||||
name?: string;
|
||||
pmmPools?: unknown;
|
||||
pmmPoolsVolatile?: unknown;
|
||||
gasPmmPools?: unknown;
|
||||
};
|
||||
const chainName = typeof chain.name === 'string' ? chain.name : `Chain ${cid}`;
|
||||
|
||||
for (const section of sections) {
|
||||
const arr = chain[section];
|
||||
if (!Array.isArray(arr)) continue;
|
||||
|
||||
for (const raw of arr) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const pool = raw as Record<string, unknown>;
|
||||
const poolAddress = typeof pool.poolAddress === 'string' ? pool.poolAddress.trim() : '';
|
||||
const baseSymbol = typeof pool.base === 'string' ? pool.base : '';
|
||||
const quoteSymbol = typeof pool.quote === 'string' ? pool.quote : '';
|
||||
if (!poolAddress.startsWith('0x') || !baseSymbol || !quoteSymbol) continue;
|
||||
|
||||
const baseAddress = resolveDeploymentTokenAddress(chain, baseSymbol);
|
||||
const quoteAddress = resolveDeploymentTokenAddress(chain, quoteSymbol);
|
||||
if (!baseAddress || !quoteAddress) continue;
|
||||
|
||||
const row: GruV2DeploymentPoolRow = {
|
||||
chainId,
|
||||
chainName,
|
||||
section,
|
||||
baseSymbol,
|
||||
quoteSymbol,
|
||||
baseAddress,
|
||||
quoteAddress,
|
||||
poolAddress: poolAddress.toLowerCase(),
|
||||
feeBps: typeof pool.feeBps === 'number' ? pool.feeBps : undefined,
|
||||
role: typeof pool.role === 'string' ? pool.role : undefined,
|
||||
publicRoutingEnabled:
|
||||
typeof pool.publicRoutingEnabled === 'boolean' ? pool.publicRoutingEnabled : undefined,
|
||||
familyKey: typeof pool.familyKey === 'string' ? pool.familyKey : undefined,
|
||||
venue: typeof pool.venue === 'string' ? pool.venue : undefined,
|
||||
};
|
||||
rows.push(row);
|
||||
|
||||
pushTokenPoolIndex(byChainTokenPools, chainId, baseAddress, poolAddress);
|
||||
pushTokenPoolIndex(byChainTokenPools, chainId, quoteAddress, poolAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const c = a.chainId - b.chainId;
|
||||
if (c !== 0) return c;
|
||||
return a.poolAddress.localeCompare(b.poolAddress);
|
||||
});
|
||||
|
||||
return { rows, byChainTokenPools };
|
||||
}
|
||||
|
||||
let cachedSnapshot: {
|
||||
sourcePath: string;
|
||||
lastModified: string;
|
||||
rows: GruV2DeploymentPoolRow[];
|
||||
byChainTokenPools: Map<string, Set<string>>;
|
||||
} | null = null;
|
||||
|
||||
function ensureRegistry(): void {
|
||||
const loaded = loadDeploymentStatusFile();
|
||||
const sourcePath = resolveDeploymentStatusPath() ?? '';
|
||||
const lm = loaded?.lastModified ?? '';
|
||||
if (cachedSnapshot && cachedSnapshot.lastModified === lm && cachedSnapshot.sourcePath === sourcePath) {
|
||||
return;
|
||||
}
|
||||
if (!loaded) {
|
||||
cachedSnapshot = { sourcePath, lastModified: lm, rows: [], byChainTokenPools: new Map() };
|
||||
return;
|
||||
}
|
||||
const built = buildGruV2PoolRegistryFromDeploymentData(loaded.data);
|
||||
cachedSnapshot = {
|
||||
sourcePath,
|
||||
lastModified: lm,
|
||||
rows: built.rows,
|
||||
byChainTokenPools: built.byChainTokenPools,
|
||||
};
|
||||
}
|
||||
|
||||
/** All resolved GRU v2 PMM pools from `deployment-status.json` (when available). */
|
||||
export function getGruV2DeploymentPoolRows(): GruV2DeploymentPoolRow[] {
|
||||
ensureRegistry();
|
||||
return cachedSnapshot?.rows ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Official pool contract addresses for this token on this chain (from deployment-status).
|
||||
* Returns `null` when the registry is empty or this token has no GRU pools — callers should use all DexScreener pairs.
|
||||
*/
|
||||
export function getOfficialGruV2PoolAddressesForToken(chainId: number, normalizedTokenAddress: string): Set<string> | null {
|
||||
ensureRegistry();
|
||||
if (!cachedSnapshot || cachedSnapshot.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const key = `${chainId}:${normalizedTokenAddress.toLowerCase()}`;
|
||||
const set = cachedSnapshot.byChainTokenPools.get(key);
|
||||
if (!set || set.size === 0) {
|
||||
return null;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* When DexScreener returns multiple pairs, prefer rows whose `pairAddress` is an official GRU v2 pool; if none match, keep full list.
|
||||
*/
|
||||
export function preferGruV2OfficialDexPairs<T extends { pairAddress: string }>(
|
||||
chainId: number,
|
||||
tokenAddress: string,
|
||||
pairs: T[]
|
||||
): T[] {
|
||||
const official = getOfficialGruV2PoolAddressesForToken(chainId, tokenAddress.toLowerCase());
|
||||
if (!official || official.size === 0) {
|
||||
return pairs;
|
||||
}
|
||||
const preferred = pairs.filter((p) => official.has(p.pairAddress.toLowerCase()));
|
||||
return preferred.length > 0 ? preferred : pairs;
|
||||
}
|
||||
@@ -94,6 +94,10 @@ function encodeOneInchRoute(router: string): string {
|
||||
return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']);
|
||||
}
|
||||
|
||||
function encodeRouterV2Route(factory: string, router: string): string {
|
||||
return abiCoder.encode(['address', 'address'], [factory, router]);
|
||||
}
|
||||
|
||||
function chain138DodoCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const dodoProvider =
|
||||
@@ -384,6 +388,140 @@ function chain138UniswapCapabilities(): ProviderCapabilityRecord {
|
||||
};
|
||||
}
|
||||
|
||||
function chain138UniswapV2Capabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const factory = normalizeAddress(process.env.CHAIN_138_UNISWAP_V2_FACTORY);
|
||||
const router = normalizeAddress(process.env.CHAIN_138_UNISWAP_V2_ROUTER);
|
||||
const wethUsdtPair = normalizeAddress(process.env.CHAIN138_UNISWAP_V2_NATIVE_WETH_USDT_PAIR);
|
||||
const wethUsdcPair = normalizeAddress(process.env.CHAIN138_UNISWAP_V2_NATIVE_WETH_USDC_PAIR);
|
||||
const cusdtCusdcPair = normalizeAddress(process.env.CHAIN138_UNISWAP_V2_NATIVE_CUSDT_CUSDC_PAIR);
|
||||
const status = factory && router ? 'live' : 'planned';
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v2',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { factory, router, pair: wethUsdtPair } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeRouterV2Route(factory, router) : undefined,
|
||||
notes: ['Canonical Chain 138 native Uniswap v2 WETH/USDT venue.'],
|
||||
reason: status === 'planned' ? 'Configure CHAIN_138_UNISWAP_V2_FACTORY and CHAIN_138_UNISWAP_V2_ROUTER after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v2',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { factory, router, pair: wethUsdcPair } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeRouterV2Route(factory, router) : undefined,
|
||||
notes: ['Canonical Chain 138 native Uniswap v2 WETH/USDC venue.'],
|
||||
reason: status === 'planned' ? 'Configure CHAIN_138_UNISWAP_V2_FACTORY and CHAIN_138_UNISWAP_V2_ROUTER after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v2',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'cUSDC',
|
||||
tokenBAddress: assets.cUSDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { factory, router, pair: cusdtCusdcPair } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeRouterV2Route(factory, router) : undefined,
|
||||
notes: ['Canonical Chain 138 native Uniswap v2 GRU stable venue.'],
|
||||
reason: status === 'planned' ? 'Configure CHAIN_138_UNISWAP_V2_FACTORY and CHAIN_138_UNISWAP_V2_ROUTER after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v2',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Canonical Chain 138 native Uniswap v2 router/factory path.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138SushiswapCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const factory = normalizeAddress(process.env.CHAIN_138_SUSHISWAP_FACTORY);
|
||||
const router = normalizeAddress(process.env.CHAIN_138_SUSHISWAP_ROUTER);
|
||||
const wethUsdtPair = normalizeAddress(process.env.CHAIN138_SUSHISWAP_NATIVE_WETH_USDT_PAIR);
|
||||
const wethUsdcPair = normalizeAddress(process.env.CHAIN138_SUSHISWAP_NATIVE_WETH_USDC_PAIR);
|
||||
const cusdtCusdcPair = normalizeAddress(process.env.CHAIN138_SUSHISWAP_NATIVE_CUSDT_CUSDC_PAIR);
|
||||
const status = factory && router ? 'live' : 'planned';
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'sushiswap',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { factory, router, pair: wethUsdtPair } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeRouterV2Route(factory, router) : undefined,
|
||||
notes: ['Canonical Chain 138 native SushiSwap-compatible WETH/USDT venue.'],
|
||||
reason: status === 'planned' ? 'Configure CHAIN_138_SUSHISWAP_FACTORY and CHAIN_138_SUSHISWAP_ROUTER after Chain 138 Sushi deployment.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'sushiswap',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { factory, router, pair: wethUsdcPair } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeRouterV2Route(factory, router) : undefined,
|
||||
notes: ['Canonical Chain 138 native SushiSwap-compatible WETH/USDC venue.'],
|
||||
reason: status === 'planned' ? 'Configure CHAIN_138_SUSHISWAP_FACTORY and CHAIN_138_SUSHISWAP_ROUTER after Chain 138 Sushi deployment.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'sushiswap',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'cUSDC',
|
||||
tokenBAddress: assets.cUSDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { factory, router, pair: cusdtCusdcPair } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeRouterV2Route(factory, router) : undefined,
|
||||
notes: ['Canonical Chain 138 native SushiSwap-compatible GRU stable venue.'],
|
||||
reason: status === 'planned' ? 'Configure CHAIN_138_SUSHISWAP_FACTORY and CHAIN_138_SUSHISWAP_ROUTER after Chain 138 Sushi deployment.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'sushiswap',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Canonical Chain 138 native SushiSwap-compatible router/factory path.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138BalancerCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const vault = normalizeAddress(process.env.BALANCER_VAULT || CHAIN138_PILOT_BALANCER_VAULT);
|
||||
@@ -538,6 +676,8 @@ export function getProviderCapabilities(chainId: number): ProviderCapabilityReco
|
||||
chain138DodoCapabilities(),
|
||||
chain138DodoV3Capabilities(),
|
||||
chain138UniswapCapabilities(),
|
||||
chain138UniswapV2Capabilities(),
|
||||
chain138SushiswapCapabilities(),
|
||||
chain138BalancerCapabilities(),
|
||||
chain138CurveCapabilities(),
|
||||
chain138OneInchCapabilities(),
|
||||
|
||||
@@ -16,7 +16,7 @@ export function resolveRoutingPolicy(
|
||||
|
||||
const baseStandard: RoutingPolicy = {
|
||||
profile: 'standard',
|
||||
allowedProviders: ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch'],
|
||||
allowedProviders: ['dodo', 'dodo_v3', 'uniswap_v3', 'uniswap_v2', 'sushiswap', 'balancer', 'curve', 'one_inch'],
|
||||
defaultIntermediateAddresses: defaultIntermediates,
|
||||
allowBridge: constraints.allowBridge !== false,
|
||||
allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'CCIPWETH9Bridge', 'UniversalCCIPBridge', 'AlltraAdapter'],
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ApiEndpoint {
|
||||
export interface DexFactoryConfig {
|
||||
id?: number;
|
||||
chainId: number;
|
||||
dexType: 'uniswap_v2' | 'uniswap_v3' | 'dodo' | 'custom';
|
||||
dexType: 'uniswap_v2' | 'uniswap_v3' | 'sushiswap' | 'dodo' | 'custom';
|
||||
factoryAddress: string;
|
||||
routerAddress?: string;
|
||||
poolManagerAddress?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
|
||||
export type DexType = 'uniswap_v2' | 'uniswap_v3' | 'dodo' | 'custom';
|
||||
export type DexType = 'uniswap_v2' | 'uniswap_v3' | 'sushiswap' | 'dodo' | 'custom';
|
||||
|
||||
export interface LiquidityPool {
|
||||
id?: number;
|
||||
|
||||
@@ -9,6 +9,8 @@ import { CoinGeckoAdapter } from '../adapters/coingecko-adapter';
|
||||
import { CoinMarketCapAdapter } from '../adapters/cmc-adapter';
|
||||
import { DexScreenerAdapter } from '../adapters/dexscreener-adapter';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getCanonicalPriceUsd } from '../services/canonical-price-oracle';
|
||||
import { pickExternalMarketDataForIndexer } from '../services/valuation-precedence';
|
||||
|
||||
export class ChainIndexer {
|
||||
private chainId: number;
|
||||
@@ -153,8 +155,12 @@ export class ChainIndexer {
|
||||
this.adapters.dexscreener.getMarketData(this.chainId, tokenAddress),
|
||||
]);
|
||||
|
||||
// Merge external data (prefer CoinGecko, fallback to others)
|
||||
const externalData = coingeckoData || dexscreenerData || cmcData;
|
||||
const externalData = pickExternalMarketDataForIndexer(this.chainId, tokenAddress.toLowerCase(), {
|
||||
coingecko: coingeckoData,
|
||||
cmc: cmcData,
|
||||
dexscreener: dexscreenerData,
|
||||
});
|
||||
const canonicalPriceUsd = getCanonicalPriceUsd(this.chainId, tokenAddress);
|
||||
|
||||
// Get pools for liquidity calculation
|
||||
const tokenPools = pools.filter(
|
||||
@@ -166,7 +172,7 @@ export class ChainIndexer {
|
||||
await this.marketDataRepo.upsertMarketData({
|
||||
chainId: this.chainId,
|
||||
tokenAddress,
|
||||
priceUsd: externalData?.priceUsd,
|
||||
priceUsd: externalData?.priceUsd ?? canonicalPriceUsd,
|
||||
priceChange24h: externalData?.priceChange24h,
|
||||
volume24h: volumeMetrics.volume24h || externalData?.volume24h || 0,
|
||||
volume7d: volumeMetrics.volume7d,
|
||||
|
||||
@@ -83,6 +83,7 @@ export class PoolIndexer {
|
||||
const hasDexConfig =
|
||||
!!dexConfig &&
|
||||
(dexConfig.uniswap_v2?.length ||
|
||||
dexConfig.sushiswap?.length ||
|
||||
dexConfig.uniswap_v3?.length ||
|
||||
dexConfig.dodo?.length ||
|
||||
dexConfig.custom?.length);
|
||||
@@ -100,7 +101,14 @@ export class PoolIndexer {
|
||||
// Index UniswapV2 pools
|
||||
if (dexConfig.uniswap_v2) {
|
||||
for (const config of dexConfig.uniswap_v2) {
|
||||
const pools = await this.indexUniswapV2Pools(config);
|
||||
const pools = await this.indexUniswapV2Pools(config, 'uniswap_v2');
|
||||
allPools.push(...pools);
|
||||
}
|
||||
}
|
||||
|
||||
if (dexConfig.sushiswap) {
|
||||
for (const config of dexConfig.sushiswap) {
|
||||
const pools = await this.indexUniswapV2Pools(config, 'sushiswap');
|
||||
allPools.push(...pools);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +216,10 @@ export class PoolIndexer {
|
||||
/**
|
||||
* Index UniswapV2 pools from PairCreated events
|
||||
*/
|
||||
private async indexUniswapV2Pools(config: UniswapV2Config): Promise<LiquidityPool[]> {
|
||||
private async indexUniswapV2Pools(
|
||||
config: UniswapV2Config,
|
||||
dexType: 'uniswap_v2' | 'sushiswap'
|
||||
): Promise<LiquidityPool[]> {
|
||||
const pools: LiquidityPool[] = [];
|
||||
const factory = new ethers.Contract(config.factory, UNISWAP_V2_FACTORY_ABI, this.provider);
|
||||
|
||||
@@ -237,7 +248,7 @@ export class PoolIndexer {
|
||||
poolAddress: pairAddress.toLowerCase(),
|
||||
token0Address: token0.toLowerCase(),
|
||||
token1Address: token1.toLowerCase(),
|
||||
dexType: 'uniswap_v2',
|
||||
dexType,
|
||||
factoryAddress: config.factory.toLowerCase(),
|
||||
routerAddress: config.router?.toLowerCase(),
|
||||
reserve0: reserve0.toString(),
|
||||
@@ -255,7 +266,7 @@ export class PoolIndexer {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error indexing UniswapV2 pools:`, error);
|
||||
logger.error(`Error indexing ${dexType} pools:`, error);
|
||||
}
|
||||
|
||||
return pools;
|
||||
@@ -390,7 +401,7 @@ export class PoolIndexer {
|
||||
}
|
||||
|
||||
try {
|
||||
if (dexType === 'uniswap_v2') {
|
||||
if (dexType === 'uniswap_v2' || dexType === 'sushiswap') {
|
||||
const pair = new ethers.Contract(poolAddress, UNISWAP_V2_PAIR_ABI, this.provider);
|
||||
const [reserve0, reserve1] = await pair.getReserves();
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ function providerProtocol(provider: PlannerProvider): string {
|
||||
return 'dodo_v3';
|
||||
case 'uniswap_v3':
|
||||
return 'uniswap_v3';
|
||||
case 'uniswap_v2':
|
||||
return 'uniswap_v2';
|
||||
case 'sushiswap':
|
||||
return 'sushiswap';
|
||||
case 'balancer':
|
||||
return 'balancer';
|
||||
case 'curve':
|
||||
@@ -42,6 +46,10 @@ function providerLabel(provider: PlannerProvider): string {
|
||||
return 'DODO V3 / D3MM';
|
||||
case 'uniswap_v3':
|
||||
return 'Uniswap V3';
|
||||
case 'uniswap_v2':
|
||||
return 'Uniswap V2';
|
||||
case 'sushiswap':
|
||||
return 'SushiSwap';
|
||||
case 'balancer':
|
||||
return 'Balancer';
|
||||
case 'curve':
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('BestExecutionPlanner', () => {
|
||||
expect(response.legs[0].provider).toBe('dodo_v3');
|
||||
expect(response.estimatedAmountOut).toBe('211660490');
|
||||
expect(response.routePlan).toBeDefined();
|
||||
expect(response.routePlan?.legs[0]?.provider).toBe(6);
|
||||
expect(response.routePlan?.legs[0]?.provider).toBe(8);
|
||||
expect(response.riskFlags).toContain('pilot-venue');
|
||||
expect(response.riskFlags).not.toContain('manual-execution-only');
|
||||
});
|
||||
|
||||
@@ -29,20 +29,24 @@ import {
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
const ROUTER_V2_RECIPIENT_PLACEHOLDER = ZeroAddress;
|
||||
const DEFAULT_INTENT_BRIDGE_COORDINATOR_V2 = normalizeAddress('0x7D0022B7e8360172fd9C0bB6778113b7Ea3674E7');
|
||||
const PROVIDER_PRIORITY: PlannerProvider[] = ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch', 'partner'];
|
||||
const PROVIDER_PRIORITY: PlannerProvider[] = ['dodo', 'dodo_v3', 'uniswap_v3', 'uniswap_v2', 'sushiswap', 'balancer', 'curve', 'one_inch', 'partner'];
|
||||
const PROVIDER_ENUM: Partial<Record<PlannerProvider, number>> = {
|
||||
dodo: 0,
|
||||
uniswap_v3: 1,
|
||||
balancer: 2,
|
||||
curve: 3,
|
||||
one_inch: 4,
|
||||
partner: 5,
|
||||
dodo_v3: 6,
|
||||
uniswap_v2: 2,
|
||||
sushiswap: 3,
|
||||
balancer: 4,
|
||||
curve: 5,
|
||||
one_inch: 6,
|
||||
partner: 7,
|
||||
dodo_v3: 8,
|
||||
};
|
||||
const PROVIDER_GAS_USD: Record<PlannerProvider, number> = {
|
||||
dodo: 0.22,
|
||||
dodo_v3: 0.3,
|
||||
uniswap_v3: 0.28,
|
||||
uniswap_v2: 0.24,
|
||||
sushiswap: 0.25,
|
||||
balancer: 0.34,
|
||||
curve: 0.29,
|
||||
one_inch: 0.48,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { getCanonicalTokenBySymbol } from '../config/canonical-tokens';
|
||||
import { getCanonicalPriceUsd, resolveCanonicalPriceUsd, resolveCanonicalPriceUsdForSpec } from './canonical-price-oracle';
|
||||
|
||||
describe('canonical-price-oracle', () => {
|
||||
it('pegs Chain 138 USD-family mirrors to one dollar', () => {
|
||||
expect(getCanonicalPriceUsd(138, '0x71D6687F38b93CCad569Fa6352c876eea967201b')).toBe(1);
|
||||
expect(getCanonicalPriceUsd(138, '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(1);
|
||||
expect(getCanonicalPriceUsd(138, '0xf22258f57794CC8E06237084b353Ab30fFfa640b')).toBe(1);
|
||||
});
|
||||
|
||||
it('anchors GRU v2 and lending wrappers to the matching c* ISO-4217 asset family', () => {
|
||||
const cUsdcV2 = getCanonicalTokenBySymbol(138, 'cUSDC_V2');
|
||||
expect(cUsdcV2?.addresses[138]).toBeTruthy();
|
||||
|
||||
expect(resolveCanonicalPriceUsd(138, String(cUsdcV2?.addresses[138]))).toMatchObject({
|
||||
priceUsd: 1,
|
||||
referenceSymbol: 'USD',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCanonicalPriceUsdForSpec({
|
||||
symbol: 'acUSDC',
|
||||
name: 'Deposit cUSDC',
|
||||
type: 'asset',
|
||||
decimals: 6,
|
||||
addresses: { 138: '0xac00000000000000000000000000000000000138' },
|
||||
})
|
||||
).toMatchObject({
|
||||
priceUsd: 1,
|
||||
referenceSymbol: 'USD',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCanonicalPriceUsdForSpec({
|
||||
symbol: 'vdcEURC',
|
||||
name: 'Debt cEURC (variable)',
|
||||
type: 'debt',
|
||||
decimals: 6,
|
||||
addresses: { 138: '0xbdc0000000000000000000000000000000000138' },
|
||||
})
|
||||
).toMatchObject({
|
||||
priceUsd: 1.1780,
|
||||
referenceSymbol: 'EUR',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCanonicalPriceUsdForSpec({
|
||||
symbol: 'sdcCADC',
|
||||
name: 'Debt cCADC (stable)',
|
||||
type: 'debt',
|
||||
decimals: 6,
|
||||
addresses: { 138: '0xcdc0000000000000000000000000000000000138' },
|
||||
})
|
||||
).toMatchObject({
|
||||
priceUsd: 0.7255928549430243,
|
||||
referenceSymbol: 'CAD',
|
||||
});
|
||||
});
|
||||
|
||||
it('prices fiat GRU W tokens from the same ISO-4217 oracle references as the c* canonicals', () => {
|
||||
const usdw = getCanonicalTokenBySymbol(25, 'USDW');
|
||||
const eurw = getCanonicalTokenBySymbol(25, 'EURW');
|
||||
|
||||
expect(usdw?.addresses[25]).toBeTruthy();
|
||||
expect(eurw?.addresses[25]).toBeTruthy();
|
||||
|
||||
expect(resolveCanonicalPriceUsd(25, String(usdw?.addresses[25]))).toMatchObject({
|
||||
priceUsd: 1,
|
||||
referenceSymbol: 'USD',
|
||||
});
|
||||
expect(resolveCanonicalPriceUsd(25, String(eurw?.addresses[25]))).toMatchObject({
|
||||
priceUsd: 1.1780,
|
||||
referenceSymbol: 'EUR',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves WETH9 and WETH10 to the ETH peg with 18-decimal canonical metadata', () => {
|
||||
expect(resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')).toMatchObject({
|
||||
priceUsd: 2490,
|
||||
referenceSymbol: 'ETH',
|
||||
});
|
||||
expect(resolveCanonicalPriceUsd(138, '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')).toMatchObject({
|
||||
priceUsd: 2490,
|
||||
referenceSymbol: 'ETH',
|
||||
});
|
||||
});
|
||||
|
||||
it('provides repo-local fallback pegs for commodity and monetary-unit canonicals', () => {
|
||||
expect(resolveCanonicalPriceUsd(138, '0x290E52a8819A4fbD0714E517225429aA2B70EC6b')).toMatchObject({
|
||||
referenceSymbol: 'XAU',
|
||||
source: 'repo-fallback',
|
||||
});
|
||||
expect(resolveCanonicalPriceUsd(138, '0xcb7c000000000000000000000000000000000138')).toMatchObject({
|
||||
priceUsd: 90000,
|
||||
referenceSymbol: 'BTC',
|
||||
source: 'repo-fallback',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { type CanonicalTokenSpec, getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from '../config/canonical-tokens';
|
||||
|
||||
export interface CanonicalPriceResolution {
|
||||
priceUsd?: number;
|
||||
referenceSymbol?: string;
|
||||
source: 'env' | 'repo-fallback' | 'unresolved';
|
||||
}
|
||||
|
||||
const FX_SNAPSHOT_GENERATED_AT = '2026-04-15';
|
||||
|
||||
// Repo-local inferred FX snapshot from scripts/lib/extraction_gap_closure.py.
|
||||
const REPO_FALLBACK_PRICE_USD: Record<string, number> = {
|
||||
USD: 1,
|
||||
EUR: 1.1780,
|
||||
GBP: 1.3550353712543854,
|
||||
AUD: 0.7136366390016357,
|
||||
CAD: 0.7255928549430243,
|
||||
CHF: 1.2776572668112798,
|
||||
JPY: 0.006285683794888213,
|
||||
XAU: 5163.3401260328355,
|
||||
ETH: 2490,
|
||||
BTC: 90000,
|
||||
BNB: 610,
|
||||
POL: 0.78,
|
||||
AVAX: 48,
|
||||
CELO: 0.72,
|
||||
CRO: 0.14,
|
||||
XDAI: 1,
|
||||
};
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function readEnvPrice(keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const raw = process.env[key];
|
||||
if (!raw || raw.trim() === '') continue;
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveReferenceSymbol(spec: CanonicalTokenSpec): string | undefined {
|
||||
const symbol = spec.symbol.toUpperCase();
|
||||
const currencyCode = String(spec.currencyCode || '').trim().toUpperCase();
|
||||
|
||||
if (
|
||||
symbol === 'WETH' ||
|
||||
symbol === 'WETH9' ||
|
||||
symbol === 'WETH10' ||
|
||||
symbol === 'CETH' ||
|
||||
symbol === 'CETHL2' ||
|
||||
symbol === 'CWETH' ||
|
||||
symbol === 'CWETHL2'
|
||||
) {
|
||||
return 'ETH';
|
||||
}
|
||||
|
||||
if (symbol === 'CBTC' || symbol === 'CWBTC') {
|
||||
return 'BTC';
|
||||
}
|
||||
|
||||
if (symbol === 'CXAUC' || symbol === 'CXAUT' || symbol === 'CAXAUC' || symbol === 'CAXAUT' || symbol === 'CWAXAUC' || symbol === 'CWAXAUT' || symbol === 'LIXAU') {
|
||||
return 'XAU';
|
||||
}
|
||||
|
||||
if (currencyCode) {
|
||||
return currencyCode;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePegSourceSpec(spec: CanonicalTokenSpec, seenSymbols: Set<string> = new Set()): CanonicalTokenSpec {
|
||||
const symbol = spec.symbol.trim();
|
||||
const symbolUpper = symbol.toUpperCase();
|
||||
if (seenSymbols.has(symbolUpper)) {
|
||||
return spec;
|
||||
}
|
||||
|
||||
const nextSeen = new Set(seenSymbols);
|
||||
nextSeen.add(symbolUpper);
|
||||
|
||||
const familySymbol = String(spec.familySymbol || '').trim();
|
||||
if (familySymbol) {
|
||||
const familyMatch = getCanonicalTokenByAddressFromSymbolFamily(spec, familySymbol);
|
||||
if (familyMatch) {
|
||||
return resolvePegSourceSpec(familyMatch, nextSeen);
|
||||
}
|
||||
}
|
||||
|
||||
const lendingWrapperMatch = /^(ac|vdc|sdc)(.+)$/i.exec(symbol);
|
||||
if (lendingWrapperMatch) {
|
||||
const underlyingSymbol = `c${lendingWrapperMatch[2]}`;
|
||||
const underlyingMatch = getCanonicalTokenByAddressFromSymbolFamily(spec, underlyingSymbol);
|
||||
if (underlyingMatch) {
|
||||
return resolvePegSourceSpec(underlyingMatch, nextSeen);
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
function getCanonicalTokenByAddressFromSymbolFamily(spec: CanonicalTokenSpec, symbol: string): CanonicalTokenSpec | undefined {
|
||||
for (const [chainIdText, address] of Object.entries(spec.addresses)) {
|
||||
if (!address || String(address).trim() === '') continue;
|
||||
const chainId = Number(chainIdText);
|
||||
if (!Number.isFinite(chainId)) continue;
|
||||
const familyMatch = getCanonicalTokenBySymbol(chainId, symbol);
|
||||
if (familyMatch) {
|
||||
return familyMatch;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveEnvPriceKeys(referenceSymbol: string): string[] {
|
||||
const symbol = referenceSymbol.toUpperCase();
|
||||
if (symbol === 'XAU') {
|
||||
return [
|
||||
'CHAIN138_CANONICAL_PRICE_USD_XAU',
|
||||
'CANONICAL_PRICE_USD_XAU',
|
||||
'XAU_SPOT_USD',
|
||||
'GOLD_USD_PRICE',
|
||||
];
|
||||
}
|
||||
|
||||
if (symbol === 'ETH') {
|
||||
return [
|
||||
'CHAIN138_CANONICAL_PRICE_USD_ETH',
|
||||
'CANONICAL_PRICE_USD_ETH',
|
||||
'ETH_PRICE_USD',
|
||||
'CHAIN138_D3_PILOT_WETH_USD',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
`CHAIN138_CANONICAL_PRICE_USD_${symbol}`,
|
||||
`CANONICAL_PRICE_USD_${symbol}`,
|
||||
`${symbol}_PRICE_USD`,
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveCanonicalPriceUsdForSpec(spec: CanonicalTokenSpec): CanonicalPriceResolution {
|
||||
const pegSourceSpec = resolvePegSourceSpec(spec);
|
||||
const referenceSymbol = resolveReferenceSymbol(pegSourceSpec);
|
||||
if (!referenceSymbol) {
|
||||
return { source: 'unresolved' };
|
||||
}
|
||||
|
||||
const envPrice = readEnvPrice(resolveEnvPriceKeys(referenceSymbol));
|
||||
if (envPrice !== undefined) {
|
||||
return {
|
||||
priceUsd: envPrice,
|
||||
referenceSymbol,
|
||||
source: 'env',
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = REPO_FALLBACK_PRICE_USD[referenceSymbol];
|
||||
if (fallback !== undefined) {
|
||||
return {
|
||||
priceUsd: fallback,
|
||||
referenceSymbol,
|
||||
source: 'repo-fallback',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
referenceSymbol,
|
||||
source: 'unresolved',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCanonicalPriceUsd(chainId: number, address: string): CanonicalPriceResolution {
|
||||
const spec = getCanonicalTokenByAddress(chainId, normalizeAddress(address));
|
||||
if (!spec) {
|
||||
return { source: 'unresolved' };
|
||||
}
|
||||
return resolveCanonicalPriceUsdForSpec(spec);
|
||||
}
|
||||
|
||||
export function getCanonicalPriceUsd(chainId: number, address: string): number | undefined {
|
||||
return resolveCanonicalPriceUsd(chainId, address).priceUsd;
|
||||
}
|
||||
|
||||
export function getCanonicalPriceSnapshotGeneratedAt(): string {
|
||||
return FX_SNAPSHOT_GENERATED_AT;
|
||||
}
|
||||
@@ -28,7 +28,20 @@ describe('estimateChain138DodoLiquidityUsd', () => {
|
||||
expect(result.totalLiquidityUsd).toBe(210_830);
|
||||
});
|
||||
|
||||
it('keeps non-USD pairs at zero without a usable USD side', () => {
|
||||
it('keeps WETH9 on the ETH peg even when live oracle price is unavailable', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
reserve0: 10n * 10n ** 18n,
|
||||
reserve1: 24_900n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBe(24_900);
|
||||
expect(result.reserve1Usd).toBe(24_900);
|
||||
expect(result.totalLiquidityUsd).toBe(49_800);
|
||||
});
|
||||
|
||||
it('values non-USD canonical pairs from their repo-local peg references', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f',
|
||||
token1Address: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b',
|
||||
@@ -36,11 +49,22 @@ describe('estimateChain138DodoLiquidityUsd', () => {
|
||||
reserve1: 5n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
expect(result.reserve0Usd).toBe(24_900);
|
||||
expect(result.reserve1Usd).toBeCloseTo(25_816.700630164178, 6);
|
||||
expect(result.totalLiquidityUsd).toBeCloseTo(50_716.70063016418, 6);
|
||||
});
|
||||
|
||||
it('values XAU/stable DODO pools from the canonical gold peg', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b',
|
||||
token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
reserve0: 5n * 10n ** 6n,
|
||||
reserve1: 25_816n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBeCloseTo(25_816.700630164178, 6);
|
||||
expect(result.reserve1Usd).toBe(25_816);
|
||||
expect(result.totalLiquidityUsd).toBeCloseTo(51_632.70063016418, 6);
|
||||
});
|
||||
|
||||
it('values cBTC/stable DODO pools using satoshi precision and the BTC fallback price', () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
|
||||
import { getCanonicalPriceUsd } from './canonical-price-oracle';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_WETH_USD_PRICE = 2100;
|
||||
const DEFAULT_BTC_USD_PRICE = 90000;
|
||||
|
||||
export interface Chain138DodoLiquidityUsd {
|
||||
reserve0Usd: number;
|
||||
@@ -20,21 +19,6 @@ function decimalsForAddress(address: string): number {
|
||||
return Number(spec?.decimals ?? 18);
|
||||
}
|
||||
|
||||
function isUsdAddress(address: string): boolean {
|
||||
const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address));
|
||||
return spec?.currencyCode === 'USD';
|
||||
}
|
||||
|
||||
function isWethLikeAddress(address: string): boolean {
|
||||
const symbol = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address))?.symbol?.toUpperCase();
|
||||
return symbol === 'WETH' || symbol === 'WETH10';
|
||||
}
|
||||
|
||||
function isBtcLikeAddress(address: string): boolean {
|
||||
const symbol = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address))?.symbol?.toUpperCase();
|
||||
return symbol === 'CBTC';
|
||||
}
|
||||
|
||||
function parseAmount(value: bigint, decimals: number): number {
|
||||
if (value <= 0n) return 0;
|
||||
const parsed = Number(formatUnits(value, decimals));
|
||||
@@ -60,54 +44,32 @@ export function estimateChain138DodoLiquidityUsd(args: {
|
||||
const reserve1Amount = parseAmount(args.reserve1, decimalsForAddress(token1Address));
|
||||
const price = parsePrice(args.price);
|
||||
|
||||
const token0IsUsd = isUsdAddress(token0Address);
|
||||
const token1IsUsd = isUsdAddress(token1Address);
|
||||
|
||||
if (reserve0Amount <= 0 || reserve1Amount <= 0) {
|
||||
return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
}
|
||||
|
||||
if (token0IsUsd && token1IsUsd) {
|
||||
return {
|
||||
reserve0Usd: reserve0Amount,
|
||||
reserve1Usd: reserve1Amount,
|
||||
totalLiquidityUsd: reserve0Amount + reserve1Amount,
|
||||
};
|
||||
let token0PriceUsd = getCanonicalPriceUsd(CHAIN_138, token0Address) ?? 0;
|
||||
let token1PriceUsd = getCanonicalPriceUsd(CHAIN_138, token1Address) ?? 0;
|
||||
|
||||
if (price > 0) {
|
||||
if (token1PriceUsd === 1 && token0PriceUsd !== 1) {
|
||||
token0PriceUsd = price;
|
||||
}
|
||||
if (token0PriceUsd === 1 && token1PriceUsd !== 1) {
|
||||
token1PriceUsd = price;
|
||||
}
|
||||
}
|
||||
|
||||
if (token1IsUsd) {
|
||||
const reserve0Usd =
|
||||
price > 0
|
||||
? reserve0Amount * price
|
||||
: isWethLikeAddress(token0Address)
|
||||
? reserve0Amount * DEFAULT_WETH_USD_PRICE
|
||||
: isBtcLikeAddress(token0Address)
|
||||
? reserve0Amount * DEFAULT_BTC_USD_PRICE
|
||||
: 0;
|
||||
|
||||
return {
|
||||
reserve0Usd,
|
||||
reserve1Usd: reserve1Amount,
|
||||
totalLiquidityUsd: reserve0Usd > 0 ? reserve0Usd + reserve1Amount : 0,
|
||||
};
|
||||
if (token0PriceUsd <= 0 || token1PriceUsd <= 0) {
|
||||
return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
}
|
||||
|
||||
if (token0IsUsd) {
|
||||
const reserve1Usd =
|
||||
price > 0
|
||||
? reserve1Amount / price
|
||||
: isWethLikeAddress(token1Address)
|
||||
? reserve1Amount * DEFAULT_WETH_USD_PRICE
|
||||
: isBtcLikeAddress(token1Address)
|
||||
? reserve1Amount * DEFAULT_BTC_USD_PRICE
|
||||
: 0;
|
||||
const reserve0Usd = reserve0Amount * token0PriceUsd;
|
||||
const reserve1Usd = reserve1Amount * token1PriceUsd;
|
||||
|
||||
return {
|
||||
reserve0Usd: reserve0Amount,
|
||||
reserve1Usd,
|
||||
totalLiquidityUsd: reserve1Usd > 0 ? reserve0Amount + reserve1Usd : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
return {
|
||||
reserve0Usd,
|
||||
reserve1Usd,
|
||||
totalLiquidityUsd: reserve0Usd + reserve1Usd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export type PlannerProvider = 'dodo' | 'dodo_v3' | 'uniswap_v3' | 'balancer' | 'curve' | 'one_inch' | 'partner';
|
||||
export type PlannerProvider =
|
||||
| 'dodo'
|
||||
| 'dodo_v3'
|
||||
| 'uniswap_v3'
|
||||
| 'uniswap_v2'
|
||||
| 'sushiswap'
|
||||
| 'balancer'
|
||||
| 'curve'
|
||||
| 'one_inch'
|
||||
| 'partner';
|
||||
export type PlannerLegKind = 'swap' | 'bridge';
|
||||
export type PlannerDecision = 'direct-pool' | 'multi-hop' | 'swap-bridge-swap' | 'bridge-only' | 'unresolved';
|
||||
export type ComplianceProfile = 'standard' | 'institutional';
|
||||
|
||||
@@ -30,6 +30,10 @@ function providerFromDexType(dexType: string): PlannerProvider | null {
|
||||
return 'dodo_v3';
|
||||
case 'uniswap_v3':
|
||||
return 'uniswap_v3';
|
||||
case 'uniswap_v2':
|
||||
return 'uniswap_v2';
|
||||
case 'sushiswap':
|
||||
return 'sushiswap';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
isGruV2CwMeshEdgeToken,
|
||||
pickExternalMarketDataForIndexer,
|
||||
resolveUsdValuation,
|
||||
mergeMarketWithValuation,
|
||||
} from './valuation-precedence';
|
||||
|
||||
/** Bridged `cW*` on Ethereum; on Chain 138 the native form is `cUSDC` (`c*`), not `cW*`. */
|
||||
const ETH_MAINNET_CWUSDC = '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a';
|
||||
|
||||
describe('valuation-precedence', () => {
|
||||
const prevIndexerAge = process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS;
|
||||
const prevEdgeCwDexFirst = process.env.TOKEN_AGG_EDGE_CW_DEX_FIRST;
|
||||
|
||||
afterEach(() => {
|
||||
if (prevIndexerAge === undefined) {
|
||||
delete process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS;
|
||||
} else {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = prevIndexerAge;
|
||||
}
|
||||
if (prevEdgeCwDexFirst === undefined) {
|
||||
delete process.env.TOKEN_AGG_EDGE_CW_DEX_FIRST;
|
||||
} else {
|
||||
process.env.TOKEN_AGG_EDGE_CW_DEX_FIRST = prevEdgeCwDexFirst;
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers fresh indexer price over canonical', () => {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '3600';
|
||||
const lu = new Date(Date.now() - 60_000);
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 1,
|
||||
normalizedAddress: '0x0000000000000000000000000000000000000001',
|
||||
indexer: {
|
||||
chainId: 1,
|
||||
tokenAddress: '0x0000000000000000000000000000000000000001',
|
||||
priceUsd: 2.5,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: lu,
|
||||
},
|
||||
coingecko: { priceUsd: 9.99, lastUpdated: new Date() },
|
||||
});
|
||||
expect(v.sourceLayer).toBe('indexer_market');
|
||||
expect(v.priceUsd).toBe(2.5);
|
||||
expect(v.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('skips stale indexer and uses external when indexer is too old', () => {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '60';
|
||||
const lu = new Date(Date.now() - 120_000);
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 1,
|
||||
normalizedAddress: '0x0000000000000000000000000000000000000001',
|
||||
indexer: {
|
||||
chainId: 1,
|
||||
tokenAddress: '0x0000000000000000000000000000000000000001',
|
||||
priceUsd: 2.5,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: lu,
|
||||
},
|
||||
coingecko: { priceUsd: 9.99, lastUpdated: new Date() },
|
||||
});
|
||||
expect(v.sourceLayer).toBe('external_coingecko');
|
||||
expect(v.priceUsd).toBe(9.99);
|
||||
expect(v.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to stale indexer when nothing else is available', () => {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '60';
|
||||
const lu = new Date(Date.now() - 120_000);
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 1,
|
||||
normalizedAddress: '0x0000000000000000000000000000000000000001',
|
||||
indexer: {
|
||||
chainId: 1,
|
||||
tokenAddress: '0x0000000000000000000000000000000000000001',
|
||||
priceUsd: 2.5,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: lu,
|
||||
},
|
||||
});
|
||||
expect(v.sourceLayer).toBe('indexer_market');
|
||||
expect(v.priceUsd).toBe(2.5);
|
||||
expect(v.stale).toBe(true);
|
||||
});
|
||||
|
||||
it('detects GRU v2 cW* on edge chains via canonical registry', () => {
|
||||
expect(isGruV2CwMeshEdgeToken(1, ETH_MAINNET_CWUSDC)).toBe(true);
|
||||
expect(isGruV2CwMeshEdgeToken(138, ETH_MAINNET_CWUSDC)).toBe(false);
|
||||
expect(isGruV2CwMeshEdgeToken(1, '0x0000000000000000000000000000000000000001')).toBe(false);
|
||||
});
|
||||
|
||||
it('for edge GRU v2 cW*, prefers DexScreener over CoinGecko when indexer is stale', () => {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '60';
|
||||
const lu = new Date(Date.now() - 120_000);
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 1,
|
||||
normalizedAddress: ETH_MAINNET_CWUSDC,
|
||||
indexer: {
|
||||
chainId: 1,
|
||||
tokenAddress: ETH_MAINNET_CWUSDC,
|
||||
priceUsd: 2.5,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: lu,
|
||||
},
|
||||
coingecko: { priceUsd: 9.99, lastUpdated: new Date() },
|
||||
dexscreener: { priceUsd: 1.0, lastUpdated: new Date() },
|
||||
});
|
||||
expect(v.gruV2CwEdgeDexPriority).toBe(true);
|
||||
expect(v.sourceLayer).toBe('external_dexscreener');
|
||||
expect(v.priceUsd).toBe(1.0);
|
||||
expect(v.precedenceRank).toBe(2);
|
||||
});
|
||||
|
||||
it('pickExternalMarketDataForIndexer prefers DexScreener for edge cW*', () => {
|
||||
const dex = { priceUsd: 1, lastUpdated: new Date() };
|
||||
const cg = { priceUsd: 2, lastUpdated: new Date() };
|
||||
const picked = pickExternalMarketDataForIndexer(1, ETH_MAINNET_CWUSDC, {
|
||||
coingecko: cg,
|
||||
cmc: null,
|
||||
dexscreener: dex,
|
||||
});
|
||||
expect(picked?.priceUsd).toBe(1);
|
||||
});
|
||||
|
||||
it('pickExternalMarketDataForIndexer prefers CoinGecko when edge cW* flag is off', () => {
|
||||
process.env.TOKEN_AGG_EDGE_CW_DEX_FIRST = '0';
|
||||
const dex = { priceUsd: 1, lastUpdated: new Date() };
|
||||
const cg = { priceUsd: 2, lastUpdated: new Date() };
|
||||
const picked = pickExternalMarketDataForIndexer(1, ETH_MAINNET_CWUSDC, {
|
||||
coingecko: cg,
|
||||
cmc: null,
|
||||
dexscreener: dex,
|
||||
});
|
||||
expect(picked?.priceUsd).toBe(2);
|
||||
});
|
||||
|
||||
it('TOKEN_AGG_EDGE_CW_DEX_FIRST=0 restores CoinGecko-before-DexScreener for edge cW*', () => {
|
||||
process.env.TOKEN_AGG_EDGE_CW_DEX_FIRST = '0';
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '60';
|
||||
const lu = new Date(Date.now() - 120_000);
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 1,
|
||||
normalizedAddress: ETH_MAINNET_CWUSDC,
|
||||
indexer: {
|
||||
chainId: 1,
|
||||
tokenAddress: ETH_MAINNET_CWUSDC,
|
||||
priceUsd: 2.5,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: lu,
|
||||
},
|
||||
coingecko: { priceUsd: 9.99, lastUpdated: new Date() },
|
||||
dexscreener: { priceUsd: 1.0, lastUpdated: new Date() },
|
||||
});
|
||||
expect(v.gruV2CwEdgeDexPriority).toBe(false);
|
||||
expect(v.sourceLayer).toBe('external_coingecko');
|
||||
expect(v.priceUsd).toBe(9.99);
|
||||
});
|
||||
|
||||
it('mergeMarketWithValuation applies priced layer onto indexer row', () => {
|
||||
const pricing = resolveUsdValuation({
|
||||
chainId: 138,
|
||||
normalizedAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase(),
|
||||
indexer: null,
|
||||
coingecko: undefined,
|
||||
});
|
||||
const m = mergeMarketWithValuation(138, '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', null, pricing);
|
||||
expect(m?.priceUsd).toBe(pricing.priceUsd);
|
||||
});
|
||||
});
|
||||
276
services/token-aggregation/src/services/valuation-precedence.ts
Normal file
276
services/token-aggregation/src/services/valuation-precedence.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { MarketData } from '../adapters/base-adapter';
|
||||
import type { TokenMarketData } from '../database/repositories/token-repo';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
|
||||
import { resolveCanonicalPriceUsd } from './canonical-price-oracle';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
|
||||
/**
|
||||
* When true (default), bridged GRU v2 **`cW*`** tokens (non–Chain-138) prefer DexScreener before CoinGecko/CMC.
|
||||
* Native Chain 138 assets use **`c*`** naming (e.g. cUSDT); those are not bridged `cW*` and this flag does not apply on 138.
|
||||
*/
|
||||
function readEdgeCwDexFirst(): boolean {
|
||||
const raw = process.env.TOKEN_AGG_EDGE_CW_DEX_FIRST;
|
||||
if (raw === undefined || raw === '') return true;
|
||||
return raw === '1' || raw.toLowerCase() === 'true' || raw.toLowerCase() === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* True for **bridged** GRU v2 transport on a public network: symbols **`cW*`** (e.g. cWUSDT, cWEURC, cWXAUC, cWETH).
|
||||
* On **Chain 138**, native GRU v2 uses **`c*`** (cUSDT, cUSDC, …), not `cW*` — this helper always returns false on 138
|
||||
* so valuation for home-chain assets follows the default layer order.
|
||||
*/
|
||||
export function isGruV2CwMeshEdgeToken(chainId: number, normalizedAddress: string): boolean {
|
||||
if (chainId === CHAIN_138) return false;
|
||||
const spec = getCanonicalTokenByAddress(chainId, normalizedAddress.toLowerCase());
|
||||
if (!spec) return false;
|
||||
return /^cW/i.test(spec.symbol.trim());
|
||||
}
|
||||
|
||||
export type PriceSourceLayer =
|
||||
| 'indexer_market'
|
||||
| 'external_coingecko'
|
||||
| 'external_coinmarketcap'
|
||||
| 'external_dexscreener'
|
||||
| 'canonical_env'
|
||||
| 'canonical_repo_fallback'
|
||||
| 'none';
|
||||
|
||||
export interface ExplorerLinks {
|
||||
chainId: number;
|
||||
explorerBaseUrl?: string;
|
||||
/** Primary explorer URL for this contract (Blockscout/Etherscan style). */
|
||||
addressUrl?: string;
|
||||
/** Alias for UIs that expect a “token page”; often same as address on explorers. */
|
||||
tokenUrl?: string;
|
||||
}
|
||||
|
||||
export interface TokenPricing {
|
||||
/** Omitted when no layer produced a price. */
|
||||
priceUsd?: number;
|
||||
sourceLayer: PriceSourceLayer;
|
||||
/** 1 = highest precedence (indexer). */
|
||||
precedenceRank: number;
|
||||
/** True when the chosen layer is older than configured max age (indexer only today). */
|
||||
stale: boolean;
|
||||
maxAgeSeconds: number;
|
||||
/** ISO 8601 — effective “as of” for the displayed price. */
|
||||
asOf: string;
|
||||
ageSeconds?: number;
|
||||
indexerLastUpdated?: string;
|
||||
referenceSymbol?: string;
|
||||
canonicalResolutionSource?: 'env' | 'repo-fallback' | 'unresolved';
|
||||
/** Bridged `cW*` on an edge chain used DexScreener-priority ordering (native `c*` on 138 never sets this). */
|
||||
gruV2CwEdgeDexPriority?: boolean;
|
||||
}
|
||||
|
||||
export interface ValuationInput {
|
||||
chainId: number;
|
||||
normalizedAddress: string;
|
||||
indexer: TokenMarketData | null;
|
||||
coingecko?: MarketData | null;
|
||||
cmc?: MarketData | null;
|
||||
dexscreener?: MarketData | null;
|
||||
}
|
||||
|
||||
function readIndexerMaxAgeSeconds(): number {
|
||||
const raw = process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n > 0 ? n : 900;
|
||||
}
|
||||
|
||||
function ageMsOf(date: Date): number {
|
||||
return Date.now() - date.getTime();
|
||||
}
|
||||
|
||||
function iso(d: Date): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Layered USD valuation:
|
||||
* - Default: indexer (staleness-aware) → CoinGecko → CMC → DexScreener → canonical env/repo.
|
||||
* - **Bridged GRU v2 `cW*` on edge networks** (not Chain 138; native **`c*`** lives on 138 only): indexer →
|
||||
* **DexScreener → CoinGecko → CMC** → canonical, so that chain’s DEX/aggregated prices lead before generic repo FX.
|
||||
* If the indexer price exists but is stale, we fall through to fresher layers before accepting the stale indexer.
|
||||
*/
|
||||
export function resolveUsdValuation(input: ValuationInput): TokenPricing {
|
||||
const maxAgeSeconds = readIndexerMaxAgeSeconds();
|
||||
const { chainId, normalizedAddress, indexer } = input;
|
||||
|
||||
const edgeCwDexPriority = readEdgeCwDexFirst() && isGruV2CwMeshEdgeToken(chainId, normalizedAddress);
|
||||
|
||||
const canonical = resolveCanonicalPriceUsd(chainId, normalizedAddress);
|
||||
const referenceSymbol = canonical.referenceSymbol;
|
||||
const canonicalResolutionSource = canonical.source === 'unresolved' ? 'unresolved' : canonical.source;
|
||||
|
||||
type Candidate = {
|
||||
layer: PriceSourceLayer;
|
||||
rank: number;
|
||||
priceUsd: number;
|
||||
stale: boolean;
|
||||
asOf: Date;
|
||||
};
|
||||
|
||||
const out: Candidate[] = [];
|
||||
|
||||
if (indexer?.priceUsd !== undefined && indexer.priceUsd !== null) {
|
||||
const lu = indexer.lastUpdated instanceof Date ? indexer.lastUpdated : new Date(indexer.lastUpdated);
|
||||
const stale = ageMsOf(lu) > maxAgeSeconds * 1000;
|
||||
out.push({
|
||||
layer: 'indexer_market',
|
||||
rank: 1,
|
||||
priceUsd: indexer.priceUsd,
|
||||
stale,
|
||||
asOf: lu,
|
||||
});
|
||||
}
|
||||
|
||||
const pushExternal = (layer: PriceSourceLayer, rank: number, md: MarketData | null | undefined): void => {
|
||||
if (!md || md.priceUsd === undefined || md.priceUsd === null) return;
|
||||
const asOf = md.lastUpdated instanceof Date ? md.lastUpdated : new Date();
|
||||
out.push({
|
||||
layer,
|
||||
rank,
|
||||
priceUsd: md.priceUsd,
|
||||
stale: false,
|
||||
asOf,
|
||||
});
|
||||
};
|
||||
|
||||
if (edgeCwDexPriority) {
|
||||
pushExternal('external_dexscreener', 2, input.dexscreener);
|
||||
pushExternal('external_coingecko', 3, input.coingecko);
|
||||
pushExternal('external_coinmarketcap', 4, input.cmc);
|
||||
} else {
|
||||
pushExternal('external_coingecko', 2, input.coingecko);
|
||||
pushExternal('external_coinmarketcap', 3, input.cmc);
|
||||
pushExternal('external_dexscreener', 4, input.dexscreener);
|
||||
}
|
||||
|
||||
if (canonical.priceUsd !== undefined && canonical.source === 'env') {
|
||||
out.push({
|
||||
layer: 'canonical_env',
|
||||
rank: 5,
|
||||
priceUsd: canonical.priceUsd,
|
||||
stale: false,
|
||||
asOf: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
if (canonical.priceUsd !== undefined && canonical.source === 'repo-fallback') {
|
||||
out.push({
|
||||
layer: 'canonical_repo_fallback',
|
||||
rank: 6,
|
||||
priceUsd: canonical.priceUsd,
|
||||
stale: false,
|
||||
asOf: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
const byRank = [...out].sort((a, b) => a.rank - b.rank);
|
||||
const fresh = byRank.find((c) => !c.stale);
|
||||
const winner = fresh || byRank[0];
|
||||
|
||||
if (!winner) {
|
||||
return {
|
||||
sourceLayer: 'none',
|
||||
precedenceRank: 99,
|
||||
stale: true,
|
||||
maxAgeSeconds,
|
||||
asOf: iso(new Date()),
|
||||
referenceSymbol,
|
||||
canonicalResolutionSource,
|
||||
gruV2CwEdgeDexPriority: edgeCwDexPriority,
|
||||
};
|
||||
}
|
||||
|
||||
const ageSeconds = Math.max(0, Math.floor(ageMsOf(winner.asOf) / 1000));
|
||||
|
||||
return {
|
||||
priceUsd: winner.priceUsd,
|
||||
sourceLayer: winner.layer,
|
||||
precedenceRank: winner.rank,
|
||||
stale: winner.stale,
|
||||
maxAgeSeconds,
|
||||
asOf: iso(winner.asOf),
|
||||
ageSeconds,
|
||||
indexerLastUpdated: indexer?.lastUpdated
|
||||
? indexer.lastUpdated instanceof Date
|
||||
? iso(indexer.lastUpdated)
|
||||
: String(indexer.lastUpdated)
|
||||
: undefined,
|
||||
referenceSymbol,
|
||||
canonicalResolutionSource,
|
||||
gruV2CwEdgeDexPriority: edgeCwDexPriority,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses one external feed for persisting indexer `market_data` rows — same **coingecko / dexscreener / cmc** order as
|
||||
* {@link resolveUsdValuation} (bridged **`cW*`** on edge chains prefer DexScreener when enabled).
|
||||
*/
|
||||
export function pickExternalMarketDataForIndexer(
|
||||
chainId: number,
|
||||
normalizedAddress: string,
|
||||
sources: { coingecko: MarketData | null; cmc: MarketData | null; dexscreener: MarketData | null }
|
||||
): MarketData | null {
|
||||
const edgeCw = readEdgeCwDexFirst() && isGruV2CwMeshEdgeToken(chainId, normalizedAddress);
|
||||
if (edgeCw) {
|
||||
return sources.dexscreener ?? sources.coingecko ?? sources.cmc ?? null;
|
||||
}
|
||||
return sources.coingecko ?? sources.dexscreener ?? sources.cmc ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge DB/indexer market row with layered valuation (priceUsd + optional fields).
|
||||
*/
|
||||
export function mergeMarketWithValuation(
|
||||
chainId: number,
|
||||
normalizedAddress: string,
|
||||
marketData: TokenMarketData | null,
|
||||
pricing: TokenPricing
|
||||
): TokenMarketData | null {
|
||||
const addr = normalizedAddress.toLowerCase();
|
||||
if (pricing.sourceLayer === 'none' && !marketData) {
|
||||
return null;
|
||||
}
|
||||
if (!marketData) {
|
||||
if (pricing.priceUsd === undefined) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
chainId,
|
||||
tokenAddress: addr,
|
||||
priceUsd: pricing.priceUsd,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: new Date(pricing.asOf),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...marketData,
|
||||
priceUsd: pricing.priceUsd !== undefined ? pricing.priceUsd : marketData.priceUsd,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExplorerLinks(chainId: number, address: string): ExplorerLinks {
|
||||
const cfg = getChainConfig(chainId);
|
||||
const base = cfg?.explorerUrl?.replace(/\/$/, '') || '';
|
||||
const lower = address.toLowerCase();
|
||||
if (!base) {
|
||||
return { chainId };
|
||||
}
|
||||
const url = `${base}/address/${lower}`;
|
||||
return {
|
||||
chainId,
|
||||
explorerBaseUrl: base,
|
||||
addressUrl: url,
|
||||
tokenUrl: url,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user