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

@@ -14,8 +14,22 @@ export const config = {
sourceChain: { rpcUrl: process.env.RPC_URL_138 || process.env.RPC_URL || "http://127.0.0.1:8545" },
etherlinkRpcUrl: process.env.ETHERLINK_RPC_URL || "https://node.mainnet.etherlink.com",
etherlinkRelayBridge: process.env.ETHERLINK_RELAY_BRIDGE || "",
relayPrivateKey: process.env.ETHERLINK_RELAY_PRIVATE_KEY || process.env.PRIVATE_KEY,
relayPrivateKey: resolvePrivateKey(
process.env.ETHERLINK_RELAY_PRIVATE_KEY,
process.env.PRIVATE_KEY,
process.env.DEPLOYER_PRIVATE_KEY,
),
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || "5000", 10),
maxConcurrent: parseInt(process.env.ETHERLINK_RELAY_MAX_CONCURRENT || "5", 10),
queueDepthLimit: parseInt(process.env.ETHERLINK_RELAY_QUEUE_DEPTH || "100", 10),
};
function resolvePrivateKey(...candidates) {
for (const candidate of candidates) {
if (!candidate) continue;
const value = candidate.trim();
if (!value || value.includes('${')) continue;
if (/^0x[0-9a-fA-F]{64}$/.test(value)) return value;
}
return "";
}

View File

@@ -0,0 +1,53 @@
export const NON_EVM_RELAY_LIFECYCLE = [
'initiate',
'observe_destination',
'confirm_finalize',
'reject_replay',
'refund_recover'
] as const;
export type NonEvmRelayLifecycleStep = (typeof NON_EVM_RELAY_LIFECYCLE)[number];
export type NonEvmExposureStatus =
| 'planned'
| 'operator_ready'
| 'public_ready'
| 'live'
| 'gated_by_chain138_prerequisites';
export interface NonEvmNetworkPolicy {
identifier: string;
relayMode: 'custom_relay' | 'custom_relay_scaffold' | 'parallel_program';
destinationProgramModel: string;
signerFundingPolicy: string;
finalityPolicy: string;
publicExposureStatus: Exclude<NonEvmExposureStatus, 'gated_by_chain138_prerequisites'>;
}
export interface NonEvmRelayObservation {
requestId: string;
lifecycleStep: NonEvmRelayLifecycleStep;
destinationReference?: string;
fulfillmentId?: string;
finalityValue?: number;
replayRejected?: boolean;
}
export function deriveExposureStatus(
policy: NonEvmNetworkPolicy,
checks: {
adapterPresent: boolean;
relaySurfacePresent: boolean;
chain138PrerequisitesReady: boolean;
}
): NonEvmExposureStatus {
if (!checks.adapterPresent || !checks.relaySurfacePresent) {
return 'planned';
}
if (!checks.chain138PrerequisitesReady) {
return 'gated_by_chain138_prerequisites';
}
return policy.publicExposureStatus;
}

View File

@@ -0,0 +1,11 @@
SOLANA_RELAY_RUNTIME_CONFIG=../../config/solana-relay-runtime.json
SOLANA_OPERATOR_HOST=192.168.11.11
SOLANA_CLUSTER=mainnet-beta
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
SOLANA_KEYPAIR_PATH=/root/.config/solana/id.json
SOLANA_OPERATOR_WALLET=26HRdYbob5LmVM638jfpDd6ELyHz6TqP25YHVd9dGT4A
SOLANA_DESTINATION_WALLET=26HRdYbob5LmVM638jfpDd6ELyHz6TqP25YHVd9dGT4A
SOLANA_ADAPTER_ADDRESS=
SOLANA_MIN_FINALITY_SLOTS=32
CHAIN138_RPC_URL=http://192.168.11.211:8545
CHAIN_REGISTRY_ADDRESS=0x6949137625CA923A4e9C80D5bc7DF673f9bbb84F

View File

@@ -0,0 +1,13 @@
{
"name": "solana-relay",
"private": true,
"type": "module",
"scripts": {
"relay": "node src/relay-worker.mjs",
"relay:once": "node src/relay-worker.mjs --once"
},
"dependencies": {
"dotenv": "^16.6.1",
"ethers": "^6.15.0"
}
}

View File

@@ -0,0 +1,116 @@
import {
NON_EVM_RELAY_LIFECYCLE,
type NonEvmNetworkPolicy,
type NonEvmRelayObservation
} from '../../non-evm-relay/lifecycle';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const SOLANA_RELAY_POLICY: NonEvmNetworkPolicy = {
identifier: 'Solana',
relayMode: 'custom_relay',
destinationProgramModel: 'spl_or_bridge_wrapped_cw',
signerFundingPolicy: 'sol_operator_signer',
finalityPolicy: 'slot_finality>=32',
publicExposureStatus: 'live'
};
export interface SolanaRelayAssetRuntime {
chain138Symbol: string;
sourceToken: string;
solanaSymbol: string;
solanaMint: string;
decimals: number;
smokeTestAmountRaw: string;
}
export interface SolanaRelayRuntimeConfig {
sourceChain: {
chainId: number;
chainRegistryAddress: string;
registryIdentifier: string;
adapterAddress: string | null;
};
solana: {
cluster: string;
rpcUrl: string;
operatorWallet: string;
confirmationFinality: number;
recipientEncoding: string;
};
relay: {
mode: string;
runtimeConfigEnv: string;
syntheticConfirmationPrefix: string;
publicExposureStatus?: string;
workerEntryPoint?: string;
};
smokeTests: {
defaultAssets: string[];
wethRawAmount: string;
gruRawAmount: string;
requireAdapterRegistration: boolean;
};
assets: SolanaRelayAssetRuntime[];
}
export function resolveSolanaRelayRuntimeConfigPath(): string {
const explicit = process.env.SOLANA_RELAY_RUNTIME_CONFIG;
if (explicit && explicit.trim() !== '') {
return path.resolve(process.cwd(), explicit);
}
return path.resolve(__dirname, '../../../../config/solana-relay-runtime.json');
}
export function loadSolanaRelayRuntimeConfig(): SolanaRelayRuntimeConfig {
const runtimePath = resolveSolanaRelayRuntimeConfigPath();
const raw = fs.readFileSync(runtimePath, 'utf8');
return JSON.parse(raw) as SolanaRelayRuntimeConfig;
}
/**
* Shared lifecycle surface for the Solana relay worker.
* The actual host-side worker entry point lives in relay-worker.mjs and performs
* real Solana settlement plus Chain 138 finalization.
*/
export class SolanaRelayService {
readonly lifecycle = NON_EVM_RELAY_LIFECYCLE;
readonly runtime: SolanaRelayRuntimeConfig;
constructor(runtime = loadSolanaRelayRuntimeConfig()) {
this.runtime = runtime;
}
recordObservation(observation: NonEvmRelayObservation): NonEvmRelayObservation {
return {
...observation,
lifecycleStep: observation.lifecycleStep
};
}
getAsset(symbol: string): SolanaRelayAssetRuntime | undefined {
return this.runtime.assets.find((asset) => asset.solanaSymbol === symbol || asset.chain138Symbol === symbol);
}
buildSmokeObservation(
requestId: string,
symbol: string,
destinationReference?: string
): NonEvmRelayObservation {
const asset = this.getAsset(symbol);
const destination = destinationReference || asset?.solanaMint || this.runtime.solana.operatorWallet;
return {
requestId,
lifecycleStep: 'confirm_finalize',
destinationReference: destination,
fulfillmentId: `${this.runtime.relay.syntheticConfirmationPrefix}:${symbol}:${requestId}`,
finalityValue: this.runtime.solana.confirmationFinality,
replayRejected: false
};
}
}

View File

@@ -0,0 +1,239 @@
import fs from 'fs';
import path from 'path';
import process from 'process';
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import { ethers } from 'ethers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const serviceRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(serviceRoot, '..', '..', '..');
const stateDir = path.join(serviceRoot, 'state');
const defaultStatePath = path.join(stateDir, 'relay-state.json');
const defaultHealthPath = path.join(repoRoot, 'reports', 'status', 'solana-relay-worker-health.json');
dotenv.config({ path: path.join(repoRoot, '.env') });
dotenv.config({ path: path.join(repoRoot, 'smom-dbis-138/.env') });
dotenv.config({ path: path.join(process.env.HOME || '', '.secure-secrets', 'private-keys.env'), override: true });
function readJson(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function loadRuntime() {
const runtimePath = process.env.SOLANA_RELAY_RUNTIME_CONFIG
? path.resolve(process.cwd(), process.env.SOLANA_RELAY_RUNTIME_CONFIG)
: path.join(repoRoot, 'config', 'solana-relay-runtime.json');
return {
path: runtimePath,
config: JSON.parse(fs.readFileSync(runtimePath, 'utf8'))
};
}
function normalizeAddress(value) {
return ethers.getAddress(String(value).toLowerCase());
}
function rawToUiAmount(rawValue, decimals) {
const raw = BigInt(rawValue);
const base = 10n ** BigInt(decimals);
const whole = raw / base;
const fraction = raw % base;
if (fraction === 0n) return whole.toString();
const fractionString = fraction.toString().padStart(decimals, '0').replace(/0+$/, '');
return `${whole.toString()}.${fractionString}`;
}
function decodeRecipient(destination, recipientBytes) {
if (destination && destination.trim() !== '') return destination.trim();
if (!recipientBytes || recipientBytes === '0x') return null;
return ethers.toUtf8String(recipientBytes).trim();
}
function runLocalCommand(command, args) {
return execFileSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
env: process.env
}).trim();
}
function ensureAta(mint, wallet, keypairPath) {
try {
runLocalCommand('spl-token', ['create-account', mint, '--owner', wallet, '--fee-payer', keypairPath]);
} catch {
// No-op when the associated token account already exists.
}
}
function mintToDestination(mint, uiAmount, wallet, keypairPath) {
ensureAta(mint, wallet, keypairPath);
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const output = runLocalCommand('spl-token', [
'mint',
mint,
uiAmount,
'--mint-authority',
keypairPath,
'--recipient-owner',
wallet
]);
const match = output.match(/Signature:\s+(\S+)/);
if (match) return match[1];
} catch {
// Retry with a fresh blockhash on transient Solana RPC errors.
}
}
throw new Error(`Unable to parse Solana mint signature for ${mint}`);
}
function confirmSolanaSignature(signature) {
try {
return runLocalCommand('solana', ['confirm', signature, '--output', 'json']);
} catch (error) {
throw new Error(`Solana confirmation failed for ${signature}: ${error.message}`);
}
}
function buildFulfillmentId(requestId, solanaSymbol, solanaMint) {
return ethers.keccak256(
ethers.solidityPacked(['bytes32', 'string', 'string'], [requestId, solanaSymbol, solanaMint])
);
}
async function main() {
const once = process.argv.includes('--once');
const runtimeBundle = loadRuntime();
const runtime = runtimeBundle.config;
const rpcUrl = process.env.RPC_URL_138 || process.env.CHAIN138_RPC_URL || runtime.sourceChain.rpcEnv;
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey || privateKey.includes('${')) {
throw new Error('PRIVATE_KEY is required');
}
const provider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(privateKey, provider);
const adapterAddress = normalizeAddress(runtime.sourceChain.adapterAddress);
const adapterAbi = [
'function confirmTransaction(bytes32 requestId,string txSignature,uint256 finalizedSlot,bytes32 fulfillmentId)',
'function getBridgeStatus(bytes32 requestId) view returns (tuple(address sender,address token,uint256 amount,bytes destinationData,bytes32 requestId,uint8 status,uint256 createdAt,uint256 completedAt))',
'event SolanaBridgeInitiated(bytes32 indexed requestId,address indexed sender,address indexed token,uint256 amount,string destination,bytes recipient)'
];
const adapter = new ethers.Contract(adapterAddress, adapterAbi, signer);
const interfaceDecoder = adapter.interface;
const assetBySourceToken = new Map(
(runtime.assets || []).map((asset) => [normalizeAddress(asset.sourceToken), asset])
);
const state = readJson(defaultStatePath, { lastBlock: 0, processed: {} });
const latestBlock = await provider.getBlockNumber();
const fromBlock = Math.max(0, Number(state.lastBlock || 0) + 1);
const health = {
generatedAt: new Date().toISOString(),
runtimeConfig: runtimeBundle.path,
adapterAddress,
signer: signer.address,
fromBlock,
latestBlock,
processedThisRun: [],
mode: once ? 'once' : 'poll'
};
if (fromBlock > latestBlock) {
state.lastBlock = latestBlock;
writeJson(defaultStatePath, state);
writeJson(defaultHealthPath, health);
return;
}
const logs = await provider.getLogs({
address: adapterAddress,
fromBlock,
toBlock: latestBlock,
topics: [ethers.id('SolanaBridgeInitiated(bytes32,address,address,uint256,string,bytes)')]
});
for (const log of logs) {
const parsed = interfaceDecoder.parseLog(log);
const requestId = parsed.args.requestId;
if (state.processed[requestId]) continue;
const request = await adapter.getBridgeStatus(requestId);
if (Number(request.status) !== 1) continue;
const sourceToken = normalizeAddress(parsed.args.token);
const asset = assetBySourceToken.get(sourceToken);
if (!asset) {
throw new Error(`No Solana runtime asset for source token ${sourceToken}`);
}
const recipientWallet = decodeRecipient(parsed.args.destination, parsed.args.recipient);
if (!recipientWallet) {
throw new Error(`Missing Solana recipient for request ${requestId}`);
}
const uiAmount = rawToUiAmount(request.amount.toString(), asset.decimals);
const solanaSignature = mintToDestination(
asset.solanaMint,
uiAmount,
recipientWallet,
runtime.solana.keypairPath
);
confirmSolanaSignature(solanaSignature);
const fulfillmentId = buildFulfillmentId(requestId, asset.solanaSymbol, asset.solanaMint);
const confirmTx = await adapter.confirmTransaction(
requestId,
solanaSignature,
BigInt(runtime.solana.confirmationFinality),
fulfillmentId,
{
type: 0,
gasPrice: BigInt(process.env.CHAIN138_TX_GAS_PRICE || '200000')
}
);
const confirmReceipt = await confirmTx.wait();
state.processed[requestId] = {
blockNumber: log.blockNumber,
sourceToken,
solanaSymbol: asset.solanaSymbol,
solanaMint: asset.solanaMint,
recipientWallet,
requestAmountRaw: request.amount.toString(),
solanaSignature,
confirmTxHash: confirmReceipt.hash,
processedAt: new Date().toISOString()
};
health.processedThisRun.push({
requestId,
solanaSymbol: asset.solanaSymbol,
solanaSignature,
confirmTxHash: confirmReceipt.hash
});
}
state.lastBlock = latestBlock;
writeJson(defaultStatePath, state);
writeJson(defaultHealthPath, health);
}
main().catch((error) => {
writeJson(defaultHealthPath, {
generatedAt: new Date().toISOString(),
status: 'error',
error: error.message
});
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,22 @@
import {
NON_EVM_RELAY_LIFECYCLE,
type NonEvmNetworkPolicy,
type NonEvmRelayObservation
} from '../../non-evm-relay/lifecycle';
export const STELLAR_RELAY_POLICY: NonEvmNetworkPolicy = {
identifier: 'Stellar',
relayMode: 'custom_relay_scaffold',
destinationProgramModel: 'stellar_issued_or_wrapped_cw',
signerFundingPolicy: 'xlm_operator_signer',
finalityPolicy: 'ledger_finality>=1',
publicExposureStatus: 'operator_ready'
};
export class StellarRelayService {
readonly lifecycle = NON_EVM_RELAY_LIFECYCLE;
recordObservation(observation: NonEvmRelayObservation): NonEvmRelayObservation {
return observation;
}
}

View File

@@ -15,8 +15,22 @@ export const config = {
tezosAdapterAddress: process.env.TEZOS_ADAPTER_ADDRESS || '',
tezosRpcUrl: process.env.TEZOS_RPC_URL || 'https://mainnet.smartpy.io',
tezosMinterAddress: process.env.TEZOS_MINTER_ADDRESS || '',
oraclePrivateKey: process.env.TEZOS_RELAY_ORACLE_KEY || process.env.PRIVATE_KEY,
oraclePrivateKey: resolvePrivateKey(
process.env.TEZOS_RELAY_ORACLE_KEY,
process.env.PRIVATE_KEY,
process.env.DEPLOYER_PRIVATE_KEY,
),
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '5000', 10),
maxConcurrent: parseInt(process.env.TEZOS_RELAY_MAX_CONCURRENT || '5', 10),
mockTezosRelay: process.env.MOCK_TEZOS_RELAY === 'true',
};
function resolvePrivateKey(...candidates) {
for (const candidate of candidates) {
if (!candidate) continue;
const value = candidate.trim();
if (!value || value.includes('${')) continue;
if (/^0x[0-9a-fA-F]{64}$/.test(value)) return value;
}
return '';
}

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

View File

@@ -0,0 +1,22 @@
import {
NON_EVM_RELAY_LIFECYCLE,
type NonEvmNetworkPolicy,
type NonEvmRelayObservation
} from '../../non-evm-relay/lifecycle';
export const TRON_RELAY_POLICY: NonEvmNetworkPolicy = {
identifier: 'Tron',
relayMode: 'custom_relay_scaffold',
destinationProgramModel: 'trc20_or_bridge_wrapped_cw',
signerFundingPolicy: 'trx_operator_signer',
finalityPolicy: 'block_finality>=20',
publicExposureStatus: 'operator_ready'
};
export class TronRelayService {
readonly lifecycle = NON_EVM_RELAY_LIFECYCLE;
recordObservation(observation: NonEvmRelayObservation): NonEvmRelayObservation {
return observation;
}
}