feat: expand non-evm relay and route planning support

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

View File

@@ -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:

View File

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

View File

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

View File

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

View File

@@ -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`);

View File

@@ -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 {

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

View File

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

View File

@@ -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', () => {

View File

@@ -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`,

View File

@@ -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');

View File

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

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

View File

@@ -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(),

View File

@@ -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'],

View File

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

View File

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

View File

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

View File

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

View File

@@ -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':

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View 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 (nonChain-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 chains 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,
};
}