feat: bridges, PMM, flash workflow, token-aggregation, and deployment docs
- CCIP/trustless bridge contracts, GRU tokens, DEX/PMM tests, reserve vault. - Token-aggregation service routes, planner, chain config, relay env templates. - Config snapshots and multi-chain deployment markdown updates. - gitignore services/btc-intake/dist/ (tsc output); do not track dist. Run forge build && forge test before deploy (large solc graph). Made-with: Cursor
This commit is contained in:
@@ -10,22 +10,31 @@ const DEFAULT_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
export function cacheMiddleware(ttl: number = DEFAULT_TTL) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const bypassCache =
|
||||
req.query.refresh === '1' ||
|
||||
req.query.noCache === '1' ||
|
||||
/\bno-cache\b|\bno-store\b/i.test(req.header('cache-control') || '');
|
||||
const key = `${req.method}:${req.originalUrl}`;
|
||||
const cached = cache.get(key);
|
||||
const cached = bypassCache ? undefined : cache.get(key);
|
||||
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
res.setHeader('X-Token-Aggregation-Cache', 'hit');
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
res.setHeader('X-Token-Aggregation-Cache', bypassCache ? 'bypass' : 'miss');
|
||||
|
||||
// Store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override json method to cache response
|
||||
res.json = function (body: unknown) {
|
||||
cache.set(key, {
|
||||
data: body,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
if (!bypassCache) {
|
||||
cache.set(key, {
|
||||
data: body,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
}
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
|
||||
247
services/token-aggregation/src/api/routes/bridge.test.ts
Normal file
247
services/token-aggregation/src/api/routes/bridge.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import bridgeRoutes from './bridge';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1/bridge', bridgeRoutes);
|
||||
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('Bridge API GRU Transport status', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE;
|
||||
const originalBscBridge = process.env.CW_BRIDGE_BSC;
|
||||
const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
const originalReserveSystem = process.env.CW_RESERVE_SYSTEM;
|
||||
const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
const originalBridgeListPath = process.env.BRIDGE_LIST_JSON_PATH;
|
||||
const originalBridgeListUrl = process.env.BRIDGE_LIST_JSON_URL;
|
||||
const originalCwL1Bridge = process.env.CW_L1_BRIDGE;
|
||||
const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalChain138Bridge === undefined) {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge;
|
||||
}
|
||||
if (originalBscBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_BSC = originalBscBridge;
|
||||
}
|
||||
if (originalReserveVerifier === undefined) {
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier;
|
||||
}
|
||||
if (originalReserveVault === undefined) {
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
} else {
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault;
|
||||
}
|
||||
if (originalReserveSystem === undefined) {
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
} else {
|
||||
process.env.CW_RESERVE_SYSTEM = originalReserveSystem;
|
||||
}
|
||||
if (originalMaxOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding;
|
||||
}
|
||||
if (originalBridgeListPath === undefined) {
|
||||
delete process.env.BRIDGE_LIST_JSON_PATH;
|
||||
} else {
|
||||
process.env.BRIDGE_LIST_JSON_PATH = originalBridgeListPath;
|
||||
}
|
||||
if (originalBridgeListUrl === undefined) {
|
||||
delete process.env.BRIDGE_LIST_JSON_URL;
|
||||
} else {
|
||||
process.env.BRIDGE_LIST_JSON_URL = originalBridgeListUrl;
|
||||
}
|
||||
if (originalCwL1Bridge === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE = originalCwL1Bridge;
|
||||
}
|
||||
if (originalCwL1BridgeChain138 === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns GRU transport pair runtime readiness on /status', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/status`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gruTransport?.summary?.runtimeReadyTransportPairs).toEqual(expect.any(Number));
|
||||
expect(body.gruTransport?.pairs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '138-56-cUSDT-cWUSDT',
|
||||
runtimeReady: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: '138-10-cETHL2-cWETHL2',
|
||||
assetClass: 'gas_native',
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.gruTransport?.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_mainnet',
|
||||
backingMode: 'strict_escrow',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns GRU transport summary counts on /metrics', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/metrics`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gruTransport?.summary).toMatchObject({
|
||||
transportPairs: expect.any(Number),
|
||||
runtimeReadyTransportPairs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns blocked pairs with missing requirements on /preflight', async () => {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/preflight`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.gruTransport?.blockedPairs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '138-56-cUSDT-cWUSDT',
|
||||
runtimeReady: false,
|
||||
runtimeMissingRequirements: expect.arrayContaining([
|
||||
'bridge:l1Bridge',
|
||||
'bridge:l2Bridge',
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: '138-10-cETHL2-cWETHL2',
|
||||
backingMode: 'hybrid_cap',
|
||||
runtimeMissingRequirements: expect.arrayContaining([
|
||||
'supplyAccounting:outstanding',
|
||||
'supplyAccounting:treasuryCap',
|
||||
]),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('reads runtime bridge routes from a local json file when configured', async () => {
|
||||
const tempPath = path.join('/tmp', `bridge-routes-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
routes: {
|
||||
weth9: {
|
||||
'Dynamic Ethereum Mainnet (1)': '0x9999999999999999999999999999999999999999',
|
||||
},
|
||||
weth10: {},
|
||||
},
|
||||
chain138Bridges: {
|
||||
weth9: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
weth10: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
})
|
||||
);
|
||||
process.env.BRIDGE_LIST_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/routes`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('runtime-file');
|
||||
expect(body.routes?.weth9).toMatchObject({
|
||||
'Dynamic Ethereum Mainnet (1)': '0x9999999999999999999999999999999999999999',
|
||||
});
|
||||
expect(body.chain138Bridges?.weth9).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a runtime bridge routes file when BRIDGE_LIST_JSON_URL fails', async () => {
|
||||
const tempPath = path.join('/tmp', `bridge-routes-fallback-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
routes: {
|
||||
weth9: {
|
||||
'Fallback Ethereum Mainnet (1)': '0x1234512345123451234512345123451234512345',
|
||||
},
|
||||
weth10: {},
|
||||
},
|
||||
chain138Bridges: {
|
||||
weth9: '0xcccccccccccccccccccccccccccccccccccccccc',
|
||||
weth10: '0xdddddddddddddddddddddddddddddddddddddddd',
|
||||
},
|
||||
})
|
||||
);
|
||||
process.env.BRIDGE_LIST_JSON_URL = 'http://127.0.0.1:1/bridge-routes.json';
|
||||
process.env.BRIDGE_LIST_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/routes`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('runtime-file');
|
||||
expect(body.routes?.weth9).toMatchObject({
|
||||
'Fallback Ethereum Mainnet (1)': '0x1234512345123451234512345123451234512345',
|
||||
});
|
||||
expect(body.chain138Bridges?.weth9).toBe('0xcccccccccccccccccccccccccccccccccccccccc');
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,229 @@
|
||||
/**
|
||||
* Bridge API: cross-chain bridge status and metrics.
|
||||
* GET /api/v1/bridge/status, /api/v1/bridge/metrics — stubbed or delegated to cross-chain report.
|
||||
* GET /api/v1/bridge/routes — CCIP WETH9/WETH10 + Trustless (Snap / dApps).
|
||||
* GET /api/v1/bridge/status, /api/v1/bridge/metrics, /api/v1/bridge/preflight — GRU Transport readiness + cross-chain guidance.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
import { buildDefaultBridgeRoutes } from '../utils/default-bridge-routes';
|
||||
import { getActivePublicPools, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
function buildGruTransportStatus() {
|
||||
const metadata = getGruTransportMetadata();
|
||||
const transportPairs = getActiveTransportPairs();
|
||||
const publicPools = getActivePublicPools();
|
||||
|
||||
if (!metadata) return null;
|
||||
|
||||
return {
|
||||
system: metadata.system,
|
||||
terminology: metadata.terminology,
|
||||
summary: metadata.counts,
|
||||
gasAssetFamilies: metadata.gasAssetFamilies ?? [],
|
||||
gasRedeemGroups: metadata.gasRedeemGroups ?? [],
|
||||
gasProtocolExposure: metadata.gasProtocolExposure ?? [],
|
||||
pairs: transportPairs.map((pair) => ({
|
||||
key: pair.key,
|
||||
canonicalChainId: pair.canonicalChainId,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
assetClass: pair.assetClass ?? null,
|
||||
familyKey: pair.familyKey ?? null,
|
||||
laneGroup: pair.laneGroup ?? null,
|
||||
backingMode: pair.backingMode ?? null,
|
||||
redeemPolicy: pair.redeemPolicy ?? null,
|
||||
wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null,
|
||||
stableQuoteSymbol: pair.stableQuoteSymbol ?? null,
|
||||
referenceVenue: pair.referenceVenue ?? null,
|
||||
eligible: pair.eligible === true,
|
||||
runtimeReady: pair.runtimeReady === true,
|
||||
runtimeBridgeReady: pair.runtimeBridgeReady === true,
|
||||
runtimeReserveVerifierReady: pair.runtimeReserveVerifierReady === true,
|
||||
runtimeMaxOutstandingReady: pair.runtimeMaxOutstandingReady === true,
|
||||
runtimeSupplyAccountingReady: pair.runtimeSupplyAccountingReady ?? null,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
runtimeOutstandingValue: pair.runtimeOutstandingValue ?? null,
|
||||
runtimeEscrowedValue: pair.runtimeEscrowedValue ?? null,
|
||||
runtimeTreasuryBackedValue: pair.runtimeTreasuryBackedValue ?? null,
|
||||
runtimeTreasuryCapValue: pair.runtimeTreasuryCapValue ?? null,
|
||||
bridgeAvailable: pair.bridgeAvailable ?? null,
|
||||
protocolExposure: pair.protocolExposure ?? null,
|
||||
eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) ? pair.eligibilityBlockers : [],
|
||||
runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements) ? pair.runtimeMissingRequirements : [],
|
||||
activePublicPoolKeys: Array.isArray(pair.publicPoolKeys) ? pair.publicPoolKeys : [],
|
||||
})),
|
||||
publicPools,
|
||||
};
|
||||
}
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveBridgeRoutesPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.BRIDGE_LIST_JSON_PATH,
|
||||
process.env.BRIDGE_ROUTES_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'config/bridge-routes-chain138-default.json'),
|
||||
path.resolve(process.cwd(), '../config/bridge-routes-chain138-default.json'),
|
||||
path.resolve(process.cwd(), '../../config/bridge-routes-chain138-default.json'),
|
||||
path.resolve(__dirname, '../../../../../config/bridge-routes-chain138-default.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadRuntimeBridgeRoutes(): { payload: Record<string, unknown>; lastModified?: string } | null {
|
||||
const filePath = resolveBridgeRoutesPath();
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
payload: JSON.parse(raw) as Record<string, unknown>,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/bridge/routes
|
||||
* Optional BRIDGE_LIST_JSON_URL — remote JSON replaces entire payload (5m cache).
|
||||
*/
|
||||
router.get('/routes', async (_req: Request, res: Response) => {
|
||||
const gruTransportMetadata = getGruTransportMetadata();
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
const url = process.env.BRIDGE_LIST_JSON_URL?.trim();
|
||||
if (url) {
|
||||
try {
|
||||
const data = await fetchRemoteJson(url);
|
||||
const basePayload = data && typeof data === 'object' ? data : { data };
|
||||
res.json({
|
||||
source: 'remote-url',
|
||||
...basePayload,
|
||||
gruTransport: gruTransportMetadata
|
||||
? {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
activeTransportPairs: getActiveTransportPairs(),
|
||||
activePublicPools: getActivePublicPools(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.error('BRIDGE_LIST_JSON_URL fetch failed, trying runtime file/built-in routes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimePayload = loadRuntimeBridgeRoutes();
|
||||
if (runtimePayload) {
|
||||
res.json({
|
||||
source: 'runtime-file',
|
||||
lastModified: runtimePayload.lastModified,
|
||||
...runtimePayload.payload,
|
||||
gruTransport: gruTransportMetadata
|
||||
? {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
activeTransportPairs: getActiveTransportPairs(),
|
||||
activePublicPools: getActivePublicPools(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
source: 'built-in',
|
||||
...buildDefaultBridgeRoutes(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/status', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
res.json({
|
||||
ok: true,
|
||||
bridges: [],
|
||||
message: 'Bridge status: use /api/v1/report/cross-chain for volume/lanes.',
|
||||
gruTransport,
|
||||
message: 'Bridge status includes GRU Transport runtime readiness. Use /api/v1/bridge/preflight for missing refs and /api/v1/report/cross-chain for volume/lanes.',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/metrics', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
res.json({
|
||||
ok: true,
|
||||
lanes: [],
|
||||
message: 'Bridge metrics: use /api/v1/report/cross-chain for aggregated data.',
|
||||
gruTransport: gruTransport
|
||||
? {
|
||||
system: gruTransport.system,
|
||||
summary: gruTransport.summary,
|
||||
}
|
||||
: null,
|
||||
message: 'Bridge metrics include GRU Transport summary counts. Use /api/v1/report/cross-chain for aggregated data.',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/preflight', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
if (!gruTransport) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
error: 'GRU transport config not available',
|
||||
});
|
||||
}
|
||||
|
||||
const blockedPairs = gruTransport.pairs.filter(
|
||||
(pair) => pair.eligible !== true || pair.runtimeReady !== true
|
||||
);
|
||||
const readyPairs = gruTransport.pairs.filter(
|
||||
(pair) => pair.eligible === true && pair.runtimeReady === true
|
||||
);
|
||||
|
||||
return res.json({
|
||||
ok: blockedPairs.length === 0,
|
||||
generatedAt: new Date().toISOString(),
|
||||
gruTransport: {
|
||||
system: gruTransport.system,
|
||||
summary: gruTransport.summary,
|
||||
blockedPairs,
|
||||
readyPairs: readyPairs.map((pair) => ({
|
||||
key: pair.key,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
assetClass: pair.assetClass ?? null,
|
||||
familyKey: pair.familyKey ?? null,
|
||||
backingMode: pair.backingMode ?? null,
|
||||
redeemPolicy: pair.redeemPolicy ?? null,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
142
services/token-aggregation/src/api/routes/config.test.ts
Normal file
142
services/token-aggregation/src/api/routes/config.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import configRoutes from './config';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1', configRoutes);
|
||||
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('Config API runtime networks loader', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalNetworksPath = process.env.NETWORKS_JSON_PATH;
|
||||
const originalNetworksUrl = process.env.NETWORKS_JSON_URL;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNetworksPath === undefined) {
|
||||
delete process.env.NETWORKS_JSON_PATH;
|
||||
} else {
|
||||
process.env.NETWORKS_JSON_PATH = originalNetworksPath;
|
||||
}
|
||||
if (originalNetworksUrl === undefined) {
|
||||
delete process.env.NETWORKS_JSON_URL;
|
||||
} else {
|
||||
process.env.NETWORKS_JSON_URL = originalNetworksUrl;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('serves networks from a runtime file when configured', async () => {
|
||||
const tempPath = path.join('/tmp', `token-aggregation-networks-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
version: { major: 9, minor: 9, patch: 9 },
|
||||
chains: [
|
||||
{
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'Dynamic Chain 138',
|
||||
rpcUrls: ['https://dynamic-rpc.example'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://dynamic-explorer.example'],
|
||||
oracles: [{ name: 'ETH/USD', address: '0x1111111111111111111111111111111111111111' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
process.env.NETWORKS_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/networks`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('runtime-file');
|
||||
expect(body.version).toBe('9.9.9');
|
||||
expect(body.networks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
chainName: 'Dynamic Chain 138',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const cfgRes = await fetch(`${baseUrl}/api/v1/config?chainId=138`);
|
||||
expect(cfgRes.status).toBe(200);
|
||||
const cfgBody = (await cfgRes.json()) as Record<string, any>;
|
||||
expect(cfgBody.source).toBe('runtime-file');
|
||||
expect(cfgBody.oracles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'ETH/USD',
|
||||
}),
|
||||
])
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the remote networks source for config when NETWORKS_JSON_URL is set', async () => {
|
||||
const remotePayload = {
|
||||
version: '7.7.7',
|
||||
networks: [
|
||||
{
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'Remote Chain 138',
|
||||
rpcUrls: ['https://remote-rpc.example'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://remote-explorer.example'],
|
||||
oracles: [{ name: 'BTC/USD', address: '0x2222222222222222222222222222222222222222' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const remoteServer = createServer((_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(remotePayload));
|
||||
});
|
||||
await new Promise<void>((resolve) => remoteServer.listen(0, () => resolve()));
|
||||
const remotePort = (remoteServer.address() as { port: number }).port;
|
||||
process.env.NETWORKS_JSON_URL = `http://127.0.0.1:${remotePort}/networks.json`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/config?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('remote-url');
|
||||
expect(body.version).toBe('7.7.7');
|
||||
expect(body.oracles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'BTC/USD',
|
||||
}),
|
||||
])
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => remoteServer.close((err) => (err ? reject(err) : resolve())));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getNetworks, getConfigByChain, API_VERSION } from '../../config/networks';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
@@ -6,6 +8,122 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
type RuntimeNetworksPayload = {
|
||||
version?: string | { major?: number; minor?: number; patch?: number };
|
||||
networks?: unknown[];
|
||||
chains?: unknown[];
|
||||
};
|
||||
|
||||
type NetworksPayload = {
|
||||
source: 'remote-url' | 'runtime-file' | 'built-in';
|
||||
version: string;
|
||||
networks: unknown[];
|
||||
lastModified?: string;
|
||||
};
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveRuntimeNetworksPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.NETWORKS_JSON_PATH,
|
||||
process.env.CONFIG_NETWORKS_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), 'explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../../explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(__dirname, '../../../../../../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeVersion(value: RuntimeNetworksPayload['version']): string {
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
||||
if (value && typeof value === 'object') {
|
||||
const major = 'major' in value ? Number(value.major ?? 0) : 0;
|
||||
const minor = 'minor' in value ? Number(value.minor ?? 0) : 0;
|
||||
const patch = 'patch' in value ? Number(value.patch ?? 0) : 0;
|
||||
return `${major}.${minor}.${patch}`;
|
||||
}
|
||||
return API_VERSION;
|
||||
}
|
||||
|
||||
function extractNetworks(payload: RuntimeNetworksPayload | { version?: string; networks?: unknown[] }): unknown[] {
|
||||
if (Array.isArray(payload.networks)) return payload.networks;
|
||||
if ('chains' in payload && Array.isArray(payload.chains)) return payload.chains;
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadRemoteNetworksPayload(): Promise<NetworksPayload | null> {
|
||||
const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim();
|
||||
if (!networksJsonUrl) return null;
|
||||
|
||||
try {
|
||||
const data = (await fetchRemoteJson(networksJsonUrl)) as RuntimeNetworksPayload;
|
||||
return {
|
||||
source: 'remote-url',
|
||||
version: normalizeVersion(data.version),
|
||||
networks: extractNetworks(data),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('NETWORKS_JSON_URL fetch failed, trying runtime file/built-in networks:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadRuntimeNetworksPayload(): NetworksPayload | null {
|
||||
const filePath = resolveRuntimeNetworksPath();
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const stat = fs.statSync(filePath);
|
||||
const parsed = JSON.parse(raw) as RuntimeNetworksPayload;
|
||||
|
||||
return {
|
||||
source: 'runtime-file',
|
||||
version: normalizeVersion(parsed.version),
|
||||
networks: extractNetworks(parsed),
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('NETWORKS_JSON_PATH read failed, using built-in networks:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNetworksPayload(): Promise<NetworksPayload> {
|
||||
const remotePayload = await loadRemoteNetworksPayload();
|
||||
if (remotePayload) return remotePayload;
|
||||
|
||||
const runtimePayload = loadRuntimeNetworksPayload();
|
||||
if (runtimePayload) return runtimePayload;
|
||||
|
||||
return {
|
||||
source: 'built-in',
|
||||
version: API_VERSION,
|
||||
networks: getNetworks(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/networks
|
||||
* Full EIP-3085 chain params for wallet_addEthereumChain (Chain 138, 1, 651940).
|
||||
@@ -13,23 +131,9 @@ const router: Router = Router();
|
||||
*/
|
||||
router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim();
|
||||
if (networksJsonUrl) {
|
||||
try {
|
||||
const data = (await fetchRemoteJson(networksJsonUrl)) as { version?: string; networks?: unknown[] };
|
||||
return res.json({
|
||||
version: data.version ?? API_VERSION,
|
||||
networks: data.networks ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('NETWORKS_JSON_URL fetch failed, using built-in networks:', err);
|
||||
}
|
||||
}
|
||||
const networks = getNetworks();
|
||||
res.json({
|
||||
version: API_VERSION,
|
||||
networks,
|
||||
});
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
const payload = await resolveNetworksPayload();
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
@@ -42,21 +146,37 @@ router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res
|
||||
*/
|
||||
router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const networks = getNetworks();
|
||||
const payload = await resolveNetworksPayload();
|
||||
const networks = payload.networks as Array<{ chainIdDecimal?: number; oracles?: unknown[] }>;
|
||||
if (chainIdParam) {
|
||||
const chainId = parseInt(chainIdParam, 10);
|
||||
const config = getConfigByChain(chainId);
|
||||
const matchingNetwork = networks.find((network) => Number(network.chainIdDecimal) === chainId);
|
||||
const config = matchingNetwork
|
||||
? {
|
||||
oracles: (matchingNetwork.oracles as unknown[]) ?? [],
|
||||
}
|
||||
: getConfigByChain(chainId);
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: 'Chain not found', chainId });
|
||||
}
|
||||
return res.json({ version: API_VERSION, chainId, ...config });
|
||||
return res.json({
|
||||
source: payload.source,
|
||||
version: payload.version,
|
||||
chainId,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
const chains = networks.map((n) => ({
|
||||
chainId: n.chainIdDecimal,
|
||||
oracles: n.oracles,
|
||||
chainId: Number(n.chainIdDecimal),
|
||||
oracles: Array.isArray(n.oracles) ? n.oracles : [],
|
||||
}));
|
||||
res.json({ version: API_VERSION, chains });
|
||||
res.json({
|
||||
source: payload.source,
|
||||
version: payload.version,
|
||||
chains,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DEFAULT_HEATMAP_ASSETS,
|
||||
} from '../../config/heatmap-chains';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { filterPoolsForExposure, isPublicPoolRoutable } from '../../config/gru-transport';
|
||||
|
||||
const router = Router();
|
||||
const poolRepo = new PoolRepository();
|
||||
@@ -32,7 +33,7 @@ router.get('/heatmap', cacheMiddleware(60 * 1000), async (req: Request, res: Res
|
||||
const matrix: number[][] = [];
|
||||
for (const chainId of chainIds) {
|
||||
const row: number[] = [];
|
||||
const pools = await poolRepo.getPoolsByChain(chainId, 500);
|
||||
const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId, 500));
|
||||
const symbolToTvl: Record<string, number> = {};
|
||||
for (const sym of assets) symbolToTvl[sym] = 0;
|
||||
for (const pool of pools) {
|
||||
@@ -72,7 +73,7 @@ router.get('/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Respo
|
||||
if (!chainId || isNaN(chainId)) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
const pools = await poolRepo.getPoolsByChain(chainId, 500);
|
||||
const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId, 500));
|
||||
const list = await Promise.all(
|
||||
pools.map(async (p) => {
|
||||
const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, p.token0Address, p.token1Address);
|
||||
@@ -89,7 +90,7 @@ router.get('/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Respo
|
||||
reserve1: p.reserve1,
|
||||
},
|
||||
isDeployed: true,
|
||||
isRoutable: true,
|
||||
isRoutable: isPublicPoolRoutable(chainId, p.poolAddress),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
|
||||
let partnerPayloadRoutes: any;
|
||||
const mockPlan = jest.fn();
|
||||
|
||||
jest.mock('../../services/best-execution-planner', () => ({
|
||||
__esModule: true,
|
||||
BestExecutionPlanner: jest.fn().mockImplementation(() => ({
|
||||
plan: mockPlan,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../config/aggregator-route-matrix', () => ({
|
||||
__esModule: true,
|
||||
loadAggregatorRouteMatrix: jest.fn(() => ({
|
||||
version: '2.0.0',
|
||||
updated: '2026-04-02T00:00:00.000Z',
|
||||
homeChainId: 138,
|
||||
liveSwapRoutes: [],
|
||||
liveBridgeRoutes: [],
|
||||
blockedOrPlannedRoutes: [],
|
||||
})),
|
||||
filterLiveAggregatorRoutes: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1', partnerPayloadRoutes);
|
||||
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('Partner payload API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
partnerPayloadRoutes = require('./partner-payloads').default;
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlan.mockReset();
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('prefers planner-v2 routes when concrete route inputs are provided', async () => {
|
||||
mockPlan.mockResolvedValue({
|
||||
planId: 'planner-route-1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision: 'direct-pool',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenIn: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
tokenOut: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1',
|
||||
estimatedAmountOut: '1800000000',
|
||||
minAmountOut: '1782000000',
|
||||
estimatedGasUsd: 0.22,
|
||||
confidenceScore: 0.91,
|
||||
riskFlags: [],
|
||||
selectedRouteReason: 'Selected deepest eligible dodo pool.',
|
||||
rejectedAlternatives: [],
|
||||
staleness: { maxFreshnessSeconds: 15, hasStaleLeg: false },
|
||||
alternatives: [],
|
||||
legs: [
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'dodo',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenInAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
tokenOutAddress: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1',
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: 'USDT',
|
||||
estimatedAmountIn: '1000000000000000000',
|
||||
estimatedAmountOut: '1800000000',
|
||||
minAmountOut: '1782000000',
|
||||
target: '0x86ada6ef91a3b450f89f2b751e93b1b7a3218895',
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
providerData: { poolAddress: '0x1111111111111111111111111111111111111111' },
|
||||
providerDataHex: '0x',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/routes/partner-payloads?partner=0x&amount=1000000000000000000&fromChainId=138&tokenIn=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&tokenOut=0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1&includeUnsupported=true`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.routeSource).toBe('planner-v2-preferred');
|
||||
expect(body.count).toBe(1);
|
||||
expect(body.payloads[0].routeId).toBe('planner-route-1');
|
||||
expect(mockPlan).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import {
|
||||
AggregatorRouteLeg,
|
||||
LiveAggregatorRoute,
|
||||
filterLiveAggregatorRoutes,
|
||||
loadAggregatorRouteMatrix,
|
||||
} from '../../config/aggregator-route-matrix';
|
||||
@@ -10,8 +12,11 @@ import {
|
||||
} from '../../services/partner-payload-adapters';
|
||||
import { dispatchPartnerPayload } from '../../services/partner-payload-dispatcher';
|
||||
import { buildInternalExecutionPlan } from '../../services/internal-execution-plan';
|
||||
import { BestExecutionPlanner } from '../../services/best-execution-planner';
|
||||
import { routeFromPlannerLegs } from '../../services/aggregator-route-matrix-generator';
|
||||
|
||||
const router: Router = Router();
|
||||
const planner = new BestExecutionPlanner();
|
||||
|
||||
interface PartnerPayloadRequestBody {
|
||||
partner?: string;
|
||||
@@ -55,9 +60,117 @@ function buildPayloads(args: {
|
||||
slippagePercent?: string;
|
||||
slippageBps?: string;
|
||||
includeUnsupported?: boolean;
|
||||
routeId?: string;
|
||||
}) {
|
||||
return buildPayloadsAsync(args);
|
||||
}
|
||||
|
||||
function dedupeRoutes(routes: LiveAggregatorRoute[]): LiveAggregatorRoute[] {
|
||||
const byId = new Map<string, LiveAggregatorRoute>();
|
||||
for (const route of routes) {
|
||||
byId.set(route.routeId, route);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function plannerLegsToAggregatorLegs(routeLegs: Array<{
|
||||
kind: string;
|
||||
provider: string;
|
||||
target?: string;
|
||||
poolAddress?: string;
|
||||
bridgeAddress?: string;
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
}>): AggregatorRouteLeg[] {
|
||||
return routeLegs.map((leg) => ({
|
||||
kind: leg.kind,
|
||||
protocol: leg.provider,
|
||||
executor: leg.provider,
|
||||
executorAddress: leg.target,
|
||||
poolAddress: leg.poolAddress || leg.bridgeAddress,
|
||||
tokenInAddress: leg.tokenInAddress,
|
||||
tokenOutAddress: leg.tokenOutAddress,
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolvePlannerDerivedRoutes(args: {
|
||||
amount: string;
|
||||
fromChainId?: number;
|
||||
toChainId?: number;
|
||||
routeType?: string;
|
||||
tokenIn?: string;
|
||||
tokenOut?: string;
|
||||
recipient?: string;
|
||||
slippageBps?: string;
|
||||
}): Promise<LiveAggregatorRoute[]> {
|
||||
if (!args.fromChainId || !args.tokenIn || !args.tokenOut) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const wantsBridge = Boolean(args.toChainId && args.toChainId !== args.fromChainId);
|
||||
if (args.routeType === 'swap' && wantsBridge) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await planner.plan({
|
||||
sourceChainId: args.fromChainId,
|
||||
destinationChainId: wantsBridge ? args.toChainId : args.fromChainId,
|
||||
tokenIn: args.tokenIn,
|
||||
tokenOut: args.tokenOut,
|
||||
amountIn: args.amount,
|
||||
recipient: args.recipient,
|
||||
constraints: {
|
||||
allowBridge: wantsBridge || args.routeType === 'bridge' || args.routeType === 'swap-bridge-swap',
|
||||
maxSlippageBps: args.slippageBps ? Number(args.slippageBps) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.decision === 'unresolved' || response.legs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const aggregatorLegs = plannerLegsToAggregatorLegs(response.legs);
|
||||
const bridgeLeg = response.legs.find((leg) => leg.kind === 'bridge');
|
||||
const routeType = bridgeLeg ? 'bridge' : 'swap';
|
||||
|
||||
return [
|
||||
routeFromPlannerLegs({
|
||||
routeId: response.planId,
|
||||
fromChainId: response.sourceChainId,
|
||||
toChainId: response.destinationChainId,
|
||||
tokenInAddress: response.legs[0]?.tokenInAddress || args.tokenIn,
|
||||
tokenOutAddress: response.legs[response.legs.length - 1]?.tokenOutAddress || args.tokenOut,
|
||||
assetAddress: bridgeLeg?.tokenInAddress,
|
||||
assetSymbol: bridgeLeg?.tokenInSymbol,
|
||||
routeType,
|
||||
bridgeType: bridgeLeg?.bridgeType,
|
||||
bridgeAddress: bridgeLeg?.bridgeAddress,
|
||||
label: response.selectedRouteReason,
|
||||
legs: aggregatorLegs,
|
||||
notes: response.riskFlags,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function buildPayloadsAsync(args: {
|
||||
partner: PartnerName;
|
||||
amount: string;
|
||||
fromChainId?: number;
|
||||
toChainId?: number;
|
||||
routeType?: string;
|
||||
tokenIn?: string;
|
||||
tokenOut?: string;
|
||||
takerAddress?: string;
|
||||
fromAddress?: string;
|
||||
toAddress?: string;
|
||||
recipient?: string;
|
||||
slippagePercent?: string;
|
||||
slippageBps?: string;
|
||||
includeUnsupported?: boolean;
|
||||
routeId?: string;
|
||||
}) {
|
||||
const matrix = loadAggregatorRouteMatrix();
|
||||
if (!matrix) {
|
||||
if (!matrix && !args.fromChainId) {
|
||||
return {
|
||||
error: {
|
||||
status: 503,
|
||||
@@ -68,15 +181,21 @@ function buildPayloads(args: {
|
||||
};
|
||||
}
|
||||
|
||||
const liveRoutes = filterLiveAggregatorRoutes(
|
||||
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
|
||||
{
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
routeType: args.routeType,
|
||||
tokenIn: args.tokenIn,
|
||||
tokenOut: args.tokenOut,
|
||||
}
|
||||
const plannerRoutes = await resolvePlannerDerivedRoutes(args);
|
||||
const matrixRoutes = matrix
|
||||
? filterLiveAggregatorRoutes(
|
||||
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
|
||||
{
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
routeType: args.routeType,
|
||||
tokenIn: args.tokenIn,
|
||||
tokenOut: args.tokenOut,
|
||||
}
|
||||
)
|
||||
: [];
|
||||
const liveRoutes = dedupeRoutes([...plannerRoutes, ...matrixRoutes]).filter((route) =>
|
||||
args.routeId ? route.routeId === args.routeId : true
|
||||
);
|
||||
|
||||
const payloads = liveRoutes.map((route) =>
|
||||
@@ -99,6 +218,7 @@ function buildPayloads(args: {
|
||||
format: 'partner-payload-templates-v1',
|
||||
partner: args.partner,
|
||||
amount: args.amount,
|
||||
routeSource: plannerRoutes.length > 0 ? 'planner-v2-preferred' : 'matrix-fallback',
|
||||
count: filteredPayloads.length,
|
||||
supportedCount: payloads.filter((payload) => payload.supported).length,
|
||||
payloads: filteredPayloads,
|
||||
@@ -111,7 +231,7 @@ function buildPayloads(args: {
|
||||
* Returns partner-specific request payload templates generated from live ingestion routes.
|
||||
* By default returns only supported payloads; pass includeUnsupported=true to inspect all templates.
|
||||
*/
|
||||
router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request, res: Response) => {
|
||||
router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
|
||||
const partner = normalizePartner(req.query.partner ? String(req.query.partner) : undefined);
|
||||
if (!partner) {
|
||||
return res.status(400).json({
|
||||
@@ -128,7 +248,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request
|
||||
});
|
||||
}
|
||||
|
||||
const response = buildPayloads({
|
||||
const response = await buildPayloads({
|
||||
partner,
|
||||
amount,
|
||||
fromChainId: req.query.fromChainId ? parseInt(String(req.query.fromChainId), 10) : undefined,
|
||||
@@ -143,6 +263,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request
|
||||
slippagePercent: req.query.slippagePercent ? String(req.query.slippagePercent) : undefined,
|
||||
slippageBps: req.query.slippageBps ? String(req.query.slippageBps) : undefined,
|
||||
includeUnsupported: String(req.query.includeUnsupported ?? 'false').toLowerCase() === 'true',
|
||||
routeId: req.query.routeId ? String(req.query.routeId) : undefined,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -156,7 +277,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request
|
||||
* POST /api/v1/routes/partner-payloads/resolve
|
||||
* Accepts JSON body and returns only supported partner payloads by default.
|
||||
*/
|
||||
router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req: Request, res: Response) => {
|
||||
router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
|
||||
const body = (req.body ?? {}) as PartnerPayloadRequestBody;
|
||||
const partner = normalizePartner(body.partner);
|
||||
|
||||
@@ -178,7 +299,7 @@ router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req
|
||||
});
|
||||
}
|
||||
|
||||
const response = buildPayloads({
|
||||
const response = await buildPayloads({
|
||||
partner,
|
||||
amount: String(body.amount),
|
||||
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
|
||||
@@ -193,6 +314,7 @@ router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req
|
||||
slippagePercent: body.slippagePercent,
|
||||
slippageBps: body.slippageBps,
|
||||
includeUnsupported: body.includeUnsupported === true,
|
||||
routeId: body.routeId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -222,7 +344,7 @@ router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Respo
|
||||
});
|
||||
}
|
||||
|
||||
const response = buildPayloads({
|
||||
const response = await buildPayloads({
|
||||
partner,
|
||||
amount: String(body.amount),
|
||||
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
|
||||
@@ -237,6 +359,7 @@ router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Respo
|
||||
slippagePercent: body.slippagePercent,
|
||||
slippageBps: body.slippageBps,
|
||||
includeUnsupported: true,
|
||||
routeId: body.routeId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
|
||||
154
services/token-aggregation/src/api/routes/planner-v2.test.ts
Normal file
154
services/token-aggregation/src/api/routes/planner-v2.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
let plannerV2Routes: any;
|
||||
const mockPlan = jest.fn();
|
||||
const mockCapabilities = jest.fn();
|
||||
const mockBuild = jest.fn();
|
||||
|
||||
jest.mock('../../services/best-execution-planner', () => ({
|
||||
__esModule: true,
|
||||
BestExecutionPlanner: jest.fn().mockImplementation(() => ({
|
||||
plan: mockPlan,
|
||||
getCapabilities: mockCapabilities,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/internal-execution-plan-v2', () => ({
|
||||
__esModule: true,
|
||||
InternalExecutionPlanV2Builder: jest.fn().mockImplementation(() => ({
|
||||
build: mockBuild,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v2', plannerV2Routes);
|
||||
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('Planner V2 API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
plannerV2Routes = require('./planner-v2').default;
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlan.mockReset();
|
||||
mockCapabilities.mockReset();
|
||||
mockBuild.mockReset();
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns provider capabilities', async () => {
|
||||
mockCapabilities.mockReturnValue([{ provider: 'dodo', live: true }]);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/providers/capabilities?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.providers).toEqual([{ provider: 'dodo', live: true }]);
|
||||
});
|
||||
|
||||
it('returns a planner response for one-chain route planning', async () => {
|
||||
mockPlan.mockResolvedValue({
|
||||
planId: 'test-plan',
|
||||
decision: 'direct-pool',
|
||||
estimatedAmountOut: '1000',
|
||||
minAmountOut: '990',
|
||||
estimatedGasUsd: 0.22,
|
||||
legs: [],
|
||||
alternatives: [],
|
||||
confidenceScore: 0.9,
|
||||
riskFlags: [],
|
||||
selectedRouteReason: 'selected',
|
||||
rejectedAlternatives: [],
|
||||
staleness: { maxFreshnessSeconds: 12, hasStaleLeg: false },
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/routes/plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
amountIn: '1000',
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.planId).toBe('test-plan');
|
||||
expect(mockPlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an internal execution plan when the planner can encode calldata', async () => {
|
||||
mockBuild.mockResolvedValue({
|
||||
generatedAt: new Date().toISOString(),
|
||||
plannerResponse: {
|
||||
planId: 'test-plan',
|
||||
},
|
||||
execution: {
|
||||
kind: 'route',
|
||||
contractAddress: '0xrouter',
|
||||
functionName: 'executeRoute',
|
||||
signature: 'executeRoute((...))',
|
||||
args: [],
|
||||
encodedCalldata: '0xdeadbeef',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/routes/internal-execution-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
amountIn: '1000',
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.execution.encodedCalldata).toBe('0xdeadbeef');
|
||||
});
|
||||
|
||||
it('returns a JSON 500 instead of crashing when internal plan generation rejects', async () => {
|
||||
mockBuild.mockRejectedValue(new Error('planner exploded'));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/routes/internal-execution-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
amountIn: '1000',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
141
services/token-aggregation/src/api/routes/planner-v2.ts
Normal file
141
services/token-aggregation/src/api/routes/planner-v2.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { BestExecutionPlanner } from '../../services/best-execution-planner';
|
||||
import { InternalExecutionPlanV2Builder } from '../../services/internal-execution-plan-v2';
|
||||
import { PlannerRequest } from '../../services/planner-v2-types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
const planner = new BestExecutionPlanner();
|
||||
const executionPlanBuilder = new InternalExecutionPlanV2Builder(planner);
|
||||
|
||||
function parsePlannerRequest(body: Record<string, unknown>): PlannerRequest {
|
||||
const constraintsBody = body.constraints && typeof body.constraints === 'object'
|
||||
? (body.constraints as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
sourceChainId: Number(body.sourceChainId),
|
||||
destinationChainId: body.destinationChainId !== undefined ? Number(body.destinationChainId) : undefined,
|
||||
tokenIn: String(body.tokenIn || ''),
|
||||
tokenOut: String(body.tokenOut || ''),
|
||||
amountIn: String(body.amountIn || ''),
|
||||
recipient: body.recipient ? String(body.recipient) : undefined,
|
||||
constraints: constraintsBody
|
||||
? {
|
||||
maxSlippageBps: typeof constraintsBody.maxSlippageBps === 'number'
|
||||
? Number(constraintsBody.maxSlippageBps)
|
||||
: undefined,
|
||||
allowedProviders: Array.isArray(constraintsBody.allowedProviders)
|
||||
? (constraintsBody.allowedProviders as string[])
|
||||
.map((value) => String(value)) as NonNullable<PlannerRequest['constraints']>['allowedProviders']
|
||||
: undefined,
|
||||
maxLegs: typeof constraintsBody.maxLegs === 'number'
|
||||
? Number(constraintsBody.maxLegs)
|
||||
: undefined,
|
||||
allowedIntermediates: Array.isArray(constraintsBody.allowedIntermediates)
|
||||
? (constraintsBody.allowedIntermediates as string[]).map((value) => String(value))
|
||||
: undefined,
|
||||
complianceProfile: typeof constraintsBody.complianceProfile === 'string'
|
||||
? String(constraintsBody.complianceProfile) as NonNullable<PlannerRequest['constraints']>['complianceProfile']
|
||||
: undefined,
|
||||
allowBridge: typeof constraintsBody.allowBridge === 'boolean'
|
||||
? Boolean(constraintsBody.allowBridge)
|
||||
: undefined,
|
||||
preferredBridges: Array.isArray(constraintsBody.preferredBridges)
|
||||
? (constraintsBody.preferredBridges as string[]).map((value) => String(value))
|
||||
: undefined,
|
||||
allowCommodityIntermediates: typeof constraintsBody.allowCommodityIntermediates === 'boolean'
|
||||
? Boolean(constraintsBody.allowCommodityIntermediates)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlannerRequest(request: PlannerRequest): string | null {
|
||||
if (!request.sourceChainId || Number.isNaN(request.sourceChainId)) return 'sourceChainId is required';
|
||||
if (!request.tokenIn) return 'tokenIn is required';
|
||||
if (!request.tokenOut) return 'tokenOut is required';
|
||||
if (!request.amountIn) return 'amountIn is required';
|
||||
try {
|
||||
BigInt(request.amountIn);
|
||||
} catch {
|
||||
return 'amountIn must be an integer string';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handlePlannerFailure(res: Response, action: string, error: unknown) {
|
||||
logger.error(`Planner v2 ${action} failed`, error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
router.get('/providers/capabilities', cacheMiddleware(15 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = Number(req.query.chainId || '138');
|
||||
return res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
chainId,
|
||||
providers: planner.getCapabilities(chainId),
|
||||
});
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'provider capability lookup', error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/routes/plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const request = parsePlannerRequest((req.body || {}) as Record<string, unknown>);
|
||||
const error = validatePlannerRequest(request);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const response = await planner.plan({
|
||||
...request,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
});
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'route planning', error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/intents/plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const request = parsePlannerRequest((req.body || {}) as Record<string, unknown>);
|
||||
const error = validatePlannerRequest(request);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
if (!request.destinationChainId || request.destinationChainId === request.sourceChainId) {
|
||||
return res.status(400).json({ error: 'destinationChainId must be different from sourceChainId for intent planning' });
|
||||
}
|
||||
|
||||
const response = await planner.plan(request);
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'intent planning', error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/routes/internal-execution-plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const request = parsePlannerRequest((req.body || {}) as Record<string, unknown>);
|
||||
const error = validatePlannerRequest(request);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const result = await executionPlanBuilder.build(request);
|
||||
if (result.error) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'internal execution plan build', error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
142
services/token-aggregation/src/api/routes/quote.test.ts
Normal file
142
services/token-aggregation/src/api/routes/quote.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import quoteRoutes from './quote';
|
||||
import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens';
|
||||
|
||||
var mockGetPoolsByToken: jest.Mock;
|
||||
var mockGetLiveDodoPools: jest.Mock;
|
||||
|
||||
jest.mock('../../database/repositories/pool-repo', () => {
|
||||
mockGetPoolsByToken = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
PoolRepository: jest.fn().mockImplementation(() => ({
|
||||
getPoolsByToken: mockGetPoolsByToken,
|
||||
})),
|
||||
};
|
||||
});
|
||||
jest.mock('../../services/live-dodo-fallback', () => {
|
||||
mockGetLiveDodoPools = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
getLiveDodoPools: mockGetLiveDodoPools,
|
||||
};
|
||||
});
|
||||
/** Real GRU loader filters pools by on-file routable addresses; synthetic test pools would be dropped. */
|
||||
jest.mock('../../config/gru-transport', () => ({
|
||||
filterPoolsForRouting: <T>(_chainId: number, pools: T[]) => pools,
|
||||
}));
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1', quoteRoutes);
|
||||
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('Quote API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetPoolsByToken.mockReset();
|
||||
mockGetLiveDodoPools.mockReset();
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('quotes staged cUSDT_V2 against active cUSDT liquidity on Chain 138', async () => {
|
||||
const cusdtV2 = getCanonicalTokenBySymbol(138, 'cUSDT_V2');
|
||||
const cusdtV1 = getCanonicalTokenBySymbol(138, 'cUSDT');
|
||||
if (!cusdtV2?.addresses[138] || !cusdtV1?.addresses[138]) {
|
||||
throw new Error('cUSDT_V2 / cUSDT Chain 138 addresses required for this test');
|
||||
}
|
||||
const tokenInV2 = String(cusdtV2.addresses[138]);
|
||||
const cusdtV1Lookup = String(cusdtV1.addresses[138]).toLowerCase();
|
||||
|
||||
mockGetPoolsByToken.mockImplementation(async (_chainId: number, tokenAddress: string) => {
|
||||
if (tokenAddress.toLowerCase() === cusdtV1Lookup) {
|
||||
return [
|
||||
{
|
||||
chainId: 138,
|
||||
poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
token0Address: cusdtV1Lookup,
|
||||
token1Address: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1',
|
||||
dexType: 'dodo',
|
||||
reserve0: '1000000',
|
||||
reserve1: '1000000',
|
||||
reserve0Usd: 1000000,
|
||||
reserve1Usd: 1000000,
|
||||
totalLiquidityUsd: 2000000,
|
||||
volume24h: 10000,
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/quote?chainId=138&tokenIn=${encodeURIComponent(tokenInV2)}&tokenOut=0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1&amountIn=1000`
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.amountOut).toBeTruthy();
|
||||
expect(['constant-product', 'pmm-onchain']).toContain(body.quoteEngine);
|
||||
expect(body.poolAddress).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
expect(body.canonicalLiquidity).toMatchObject({
|
||||
requestedTokenInSymbol: 'cUSDT_V2',
|
||||
lookupTokenInSymbol: 'cUSDT',
|
||||
lookupTokenInAddress: cusdtV1Lookup,
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(mockGetPoolsByToken).toHaveBeenCalledWith(138, cusdtV1Lookup);
|
||||
});
|
||||
|
||||
it('falls back to live DODO pools when indexed liquidity is empty', async () => {
|
||||
mockGetPoolsByToken.mockResolvedValue([]);
|
||||
mockGetLiveDodoPools.mockResolvedValue([
|
||||
{
|
||||
chainId: 138,
|
||||
poolAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
|
||||
token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
dexType: 'dodo',
|
||||
factoryAddress: '0x86ada6ef91a3b450f89f2b751e93b1b7a3218895',
|
||||
reserve0: '1000000',
|
||||
reserve1: '1000000',
|
||||
reserve0Usd: 1000000,
|
||||
reserve1Usd: 1000000,
|
||||
totalLiquidityUsd: 2000000,
|
||||
volume24h: 0,
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/quote?chainId=138&tokenIn=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22&tokenOut=0xf22258f57794CC8E06237084b353Ab30fFfa640b&amountIn=1000`
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.amountOut).toBeTruthy();
|
||||
expect(['constant-product', 'pmm-onchain']).toContain(body.quoteEngine);
|
||||
expect(body.poolAddress).toBe('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||
expect(body.executorAddress).toBe('0x86ada6ef91a3b450f89f2b751e93b1b7a3218895');
|
||||
expect(mockGetLiveDodoPools).toHaveBeenCalledWith(138);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,24 @@ import { Router, Request, Response } from 'express';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { filterPoolsForRouting } from '../../config/gru-transport';
|
||||
import { resolveCanonicalQuoteAddress } from '../../config/canonical-tokens';
|
||||
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
|
||||
import {
|
||||
pmmQuoteAmountOutFromChain,
|
||||
resolvePmmQuoteRpcUrl,
|
||||
resolvePmmQuoteTrader,
|
||||
} from '../../services/pmm-onchain-quote';
|
||||
|
||||
const router: Router = Router();
|
||||
const poolRepo = new PoolRepository();
|
||||
|
||||
/**
|
||||
* Uniswap V2-style constant-product quote: amountOut = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997)
|
||||
*
|
||||
* Note: DODO PMM pools do not follow this curve; `amountOut` for dexType `dodo` can diverge from
|
||||
* on-chain `querySellBase` / `querySellQuote`. Clients that set swap minOut from this endpoint alone
|
||||
* may revert (wallets show failed gas estimation). Prefer on-chain PMM quotes for execution bounds.
|
||||
*/
|
||||
function quoteAmountOut(
|
||||
amountIn: bigint,
|
||||
@@ -42,6 +54,7 @@ router.get(
|
||||
return res.status(400).json({
|
||||
error: 'Missing required query: chainId, tokenIn, tokenOut, amountIn',
|
||||
amountOut: null,
|
||||
quoteEngine: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,18 +65,37 @@ router.get(
|
||||
return res.status(400).json({
|
||||
error: 'Invalid amountIn (must be integer string)',
|
||||
amountOut: null,
|
||||
quoteEngine: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenIn === tokenOut) {
|
||||
return res.json({ amountOut: amountInRaw, poolAddress: null });
|
||||
return res.json({
|
||||
amountOut: amountInRaw,
|
||||
poolAddress: null,
|
||||
quoteEngine: 'identity',
|
||||
});
|
||||
}
|
||||
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, tokenIn);
|
||||
const pairPools = pools.filter(
|
||||
const tokenInResolution = resolveCanonicalQuoteAddress(chainId, tokenIn);
|
||||
const tokenOutResolution = resolveCanonicalQuoteAddress(chainId, tokenOut);
|
||||
const indexedPoolsRaw = await poolRepo.getPoolsByToken(
|
||||
chainId,
|
||||
tokenInResolution.lookupAddress
|
||||
);
|
||||
const indexedPools = filterPoolsForRouting(chainId, indexedPoolsRaw ?? []);
|
||||
const livePools =
|
||||
indexedPools.length > 0
|
||||
? []
|
||||
: filterPoolsForRouting(chainId, (await getLiveDodoPools(chainId)) ?? []).filter(
|
||||
(pool) =>
|
||||
pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress ||
|
||||
pool.token1Address.toLowerCase() === tokenInResolution.lookupAddress
|
||||
);
|
||||
const pairPools = [...indexedPools, ...livePools].filter(
|
||||
(p) =>
|
||||
p.token0Address.toLowerCase() === tokenOut ||
|
||||
p.token1Address.toLowerCase() === tokenOut
|
||||
p.token0Address.toLowerCase() === tokenOutResolution.lookupAddress ||
|
||||
p.token1Address.toLowerCase() === tokenOutResolution.lookupAddress
|
||||
);
|
||||
|
||||
if (pairPools.length === 0) {
|
||||
@@ -71,6 +103,18 @@ router.get(
|
||||
amountOut: null,
|
||||
error: 'No pool found for this token pair',
|
||||
poolAddress: null,
|
||||
quoteEngine: null,
|
||||
canonicalLiquidity: {
|
||||
requestedTokenInAddress: tokenInResolution.requestedAddress,
|
||||
requestedTokenOutAddress: tokenOutResolution.requestedAddress,
|
||||
requestedTokenInSymbol: tokenInResolution.requestedSymbol,
|
||||
requestedTokenOutSymbol: tokenOutResolution.requestedSymbol,
|
||||
lookupTokenInAddress: tokenInResolution.lookupAddress,
|
||||
lookupTokenOutAddress: tokenOutResolution.lookupAddress,
|
||||
lookupTokenInSymbol: tokenInResolution.lookupSymbol,
|
||||
lookupTokenOutSymbol: tokenOutResolution.lookupSymbol,
|
||||
usedFallback: tokenInResolution.usedFallback || tokenOutResolution.usedFallback,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,11 +123,11 @@ router.get(
|
||||
|
||||
for (const pool of pairPools) {
|
||||
const reserveIn =
|
||||
pool.token0Address.toLowerCase() === tokenIn
|
||||
pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress
|
||||
? BigInt(pool.reserve0)
|
||||
: BigInt(pool.reserve1);
|
||||
const reserveOut =
|
||||
pool.token0Address.toLowerCase() === tokenOut
|
||||
pool.token0Address.toLowerCase() === tokenOutResolution.lookupAddress
|
||||
? BigInt(pool.reserve0)
|
||||
: BigInt(pool.reserve1);
|
||||
const out = quoteAmountOut(amountIn, reserveIn, reserveOut);
|
||||
@@ -93,16 +137,46 @@ router.get(
|
||||
}
|
||||
}
|
||||
|
||||
let quoteEngine: 'constant-product' | 'pmm-onchain' = 'constant-product';
|
||||
const pmmRpc = resolvePmmQuoteRpcUrl();
|
||||
if (chainId === 138 && bestPool.dexType === 'dodo' && pmmRpc) {
|
||||
const onChainOut = await pmmQuoteAmountOutFromChain({
|
||||
rpcUrl: pmmRpc,
|
||||
poolAddress: bestPool.poolAddress,
|
||||
tokenInLookup: tokenInResolution.lookupAddress,
|
||||
amountIn,
|
||||
traderForView: resolvePmmQuoteTrader(),
|
||||
});
|
||||
if (onChainOut !== null && onChainOut > BigInt(0)) {
|
||||
bestAmountOut = onChainOut;
|
||||
quoteEngine = 'pmm-onchain';
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
amountOut: bestAmountOut.toString(),
|
||||
poolAddress: bestPool.poolAddress,
|
||||
dexType: bestPool.dexType,
|
||||
executorAddress: bestPool.factoryAddress || null,
|
||||
quoteEngine,
|
||||
canonicalLiquidity: {
|
||||
requestedTokenInAddress: tokenInResolution.requestedAddress,
|
||||
requestedTokenOutAddress: tokenOutResolution.requestedAddress,
|
||||
requestedTokenInSymbol: tokenInResolution.requestedSymbol,
|
||||
requestedTokenOutSymbol: tokenOutResolution.requestedSymbol,
|
||||
lookupTokenInAddress: tokenInResolution.lookupAddress,
|
||||
lookupTokenOutAddress: tokenOutResolution.lookupAddress,
|
||||
lookupTokenInSymbol: tokenInResolution.lookupSymbol,
|
||||
lookupTokenOutSymbol: tokenOutResolution.lookupSymbol,
|
||||
usedFallback: tokenInResolution.usedFallback || tokenOutResolution.usedFallback,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Quote error:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
amountOut: null,
|
||||
quoteEngine: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ jest.mock('../../database/repositories/pool-repo', () => ({
|
||||
getPoolsByChain: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../indexer/cross-chain-indexer', () => ({
|
||||
buildCrossChainReport: jest.fn().mockResolvedValue({
|
||||
generatedAt: '2026-03-30T00:00:00.000Z',
|
||||
chainId: 138,
|
||||
crossChainPools: [],
|
||||
volumeByLane: [],
|
||||
atomicSwapVolume24h: 0,
|
||||
bridgeVolume24hTotal: 0,
|
||||
events: [],
|
||||
}),
|
||||
}));
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
@@ -49,8 +60,16 @@ describe('Report API', () => {
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/cmc', () => {
|
||||
@@ -85,4 +104,345 @@ describe('Report API', () => {
|
||||
expect(Array.isArray(body.tokens)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/all', () => {
|
||||
it('includes GRU transport summary for operator visibility', async () => {
|
||||
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>;
|
||||
expect(body.gruTransport?.system?.name).toBe('GRU Monetary Transport Layer');
|
||||
expect(body.gruTransport?.summary).toMatchObject({
|
||||
transportPairs: expect.any(Number),
|
||||
runtimeReadyTransportPairs: expect.any(Number),
|
||||
});
|
||||
expect(body.gruTransport?.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
it('returns both chain summaries and runtime pairs for gas rollout consumers', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gas-registry`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_mainnet',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.runtimePairs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '138-1-cETH-cWETH',
|
||||
familyKey: 'eth_mainnet',
|
||||
destinationChainId: 1,
|
||||
destinationChainName: 'Ethereum Mainnet',
|
||||
wrappedNativeQuoteSymbol: 'WETH',
|
||||
stableQuoteSymbol: 'USDC',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.chains).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
chainId: 1,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/token-list', () => {
|
||||
it('surfaces both V1 and V2 Chain 138 canonical GRU deployments explicitly', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDT',
|
||||
chainId: 138,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDT_V2',
|
||||
chainId: 138,
|
||||
familySymbol: 'cUSDT',
|
||||
deploymentVersion: 'v2',
|
||||
preferredForX402: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDC',
|
||||
chainId: 138,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDC_V2',
|
||||
chainId: 138,
|
||||
familySymbol: 'cUSDC',
|
||||
deploymentVersion: 'v2',
|
||||
preferredForX402: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the cUSDW hub asset on Chain 138 and cWUSDW on active edge chains', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDW',
|
||||
chainId: 138,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const bscRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=56`);
|
||||
expect(bscRes.status).toBe(200);
|
||||
const bscBody = (await bscRes.json()) as Record<string, any>;
|
||||
expect(bscBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWUSDW',
|
||||
chainId: 56,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces cAUSDT on Chain 138 when configured and cWAUSDT on active edge chains', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cAUSDT',
|
||||
chainId: 138,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const bscRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=56`);
|
||||
expect(bscRes.status).toBe(200);
|
||||
const bscBody = (await bscRes.json()) as Record<string, any>;
|
||||
expect(bscBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 56,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const polygonRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=137`);
|
||||
expect(polygonRes.status).toBe(200);
|
||||
const polygonBody = (await polygonRes.json()) as Record<string, any>;
|
||||
expect(polygonBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 137,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const avalancheRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=43114`);
|
||||
expect(avalancheRes.status).toBe(200);
|
||||
const avalancheBody = (await avalancheRes.json()) as Record<string, any>;
|
||||
expect(avalancheBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 43114,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const celoRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=42220`);
|
||||
expect(celoRes.status).toBe(200);
|
||||
const celoBody = (await celoRes.json()) as Record<string, any>;
|
||||
expect(celoBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 42220,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces cBTC on Chain 138 and cWBTC on the staged public mesh with monetary-unit metadata', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cBTC',
|
||||
chainId: 138,
|
||||
registryFamily: 'monetary_unit',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const mainnetRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`);
|
||||
expect(mainnetRes.status).toBe(200);
|
||||
const mainnetBody = (await mainnetRes.json()) as Record<string, any>;
|
||||
expect(mainnetBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWBTC',
|
||||
chainId: 1,
|
||||
registryFamily: 'monetary_unit',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces gas-native canonicals on Chain 138 and mirrored cW gas tokens on their public lanes', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cETH',
|
||||
chainId: 138,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cETHL2',
|
||||
chainId: 138,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const optimismRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=10`);
|
||||
expect(optimismRes.status).toBe(200);
|
||||
const optimismBody = (await optimismRes.json()) as Record<string, any>;
|
||||
expect(optimismBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWETHL2',
|
||||
chainId: 10,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const mainnetRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`);
|
||||
expect(mainnetRes.status).toBe(200);
|
||||
const mainnetBody = (await mainnetRes.json()) as Record<string, any>;
|
||||
expect(mainnetBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWETH',
|
||||
chainId: 1,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/cw-registry', () => {
|
||||
it('reads the live cW registry from deployment-status json when available', async () => {
|
||||
const previousPath = process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
const tempPath = `/tmp/token-aggregation-cw-registry-${Date.now()}.json`;
|
||||
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = tempPath;
|
||||
await import('fs/promises').then((fs) =>
|
||||
fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 'test-1',
|
||||
updated: '2026-04-04',
|
||||
chains: {
|
||||
'56': {
|
||||
name: 'BSC',
|
||||
cwTokens: {
|
||||
cWAUSDT: '0xe1a51Bc037a79AB36767561B147eb41780124934',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/cw-registry?chainId=56`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('deployment-status-file');
|
||||
expect(body.complete).toBe(true);
|
||||
expect(body.version).toBe('test-1');
|
||||
expect(body.chains).toEqual([
|
||||
expect.objectContaining({
|
||||
chainId: 56,
|
||||
name: 'BSC',
|
||||
tokens: [
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
} 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`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('deployment-status-file');
|
||||
expect(body.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.chains).toEqual([
|
||||
expect.objectContaining({
|
||||
chainId: 10,
|
||||
families: [
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
mirroredSymbol: 'cWETHL2',
|
||||
dodoPmm: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
quote: 'WETH',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CANONICAL_TOKENS,
|
||||
getCanonicalTokensByChain,
|
||||
getLogoUriForSpec,
|
||||
getTokenRegistryFamily,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { resolvePoolTokenDisplays } from '../../services/token-display';
|
||||
import { getSupportedChainIds } from '../../config/chains';
|
||||
@@ -18,6 +19,13 @@ import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
import { buildCrossChainReport } from '../../indexer/cross-chain-indexer';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { filterPoolsForExposure, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport';
|
||||
import {
|
||||
buildCwRegistryChains,
|
||||
buildGasRegistryChains,
|
||||
loadDeploymentStatusFile,
|
||||
type CwRegistryChain,
|
||||
} from '../../config/deployment-status';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -35,6 +43,12 @@ async function buildTokenReport(chainId: number) {
|
||||
type: string;
|
||||
decimals: number;
|
||||
currencyCode?: string;
|
||||
registryFamily?: string;
|
||||
familySymbol?: string;
|
||||
deploymentVersion?: string;
|
||||
deploymentStatus?: string;
|
||||
preferredForX402?: boolean;
|
||||
liquiditySourceSymbol?: string;
|
||||
market?: {
|
||||
priceUsd?: number;
|
||||
volume24h: number;
|
||||
@@ -64,9 +78,10 @@ async function buildTokenReport(chainId: number) {
|
||||
marketDataRepo.getMarketData(chainId, address),
|
||||
poolRepo.getPoolsByToken(chainId, address),
|
||||
]);
|
||||
const exposedPools = filterPoolsForExposure(chainId, pools);
|
||||
|
||||
const resolvedPools = await Promise.all(
|
||||
pools.map(async (p) => {
|
||||
exposedPools.map(async (p) => {
|
||||
const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, p.token0Address, p.token1Address);
|
||||
return {
|
||||
poolAddress: p.poolAddress,
|
||||
@@ -87,6 +102,12 @@ async function buildTokenReport(chainId: number) {
|
||||
type: spec.type,
|
||||
decimals: spec.decimals,
|
||||
currencyCode: spec.currencyCode,
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
preferredForX402: spec.preferredForX402,
|
||||
liquiditySourceSymbol: spec.liquiditySourceSymbol,
|
||||
market: marketData
|
||||
? {
|
||||
priceUsd: marketData.priceUsd,
|
||||
@@ -115,6 +136,87 @@ async function buildTokenReport(chainId: number) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function describeToken(spec: { currencyCode?: string; registryFamily?: string }): string | undefined {
|
||||
const family = String(spec.registryFamily || '').trim();
|
||||
const code = String(spec.currencyCode || '').trim().toUpperCase();
|
||||
if (!code) return undefined;
|
||||
if (family === 'gas_native') {
|
||||
return `Governance-approved gas-native ${code} compliant token`;
|
||||
}
|
||||
if (family === 'monetary_unit') {
|
||||
return `GRU monetary-unit ${code} compliant token`;
|
||||
}
|
||||
if (family === 'commodity') {
|
||||
return `Governance-approved commodity ${code} compliant token`;
|
||||
}
|
||||
return `ISO-4217 ${code} compliant token`;
|
||||
}
|
||||
|
||||
function buildGruTransportOverview() {
|
||||
const gruTransportMetadata = getGruTransportMetadata();
|
||||
if (!gruTransportMetadata) return undefined;
|
||||
|
||||
return {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
gasAssetFamilies: gruTransportMetadata.gasAssetFamilies ?? [],
|
||||
gasRedeemGroups: gruTransportMetadata.gasRedeemGroups ?? [],
|
||||
gasProtocolExposure: gruTransportMetadata.gasProtocolExposure ?? [],
|
||||
activeTransportPairs: getActiveTransportPairs().map((pair) => ({
|
||||
key: pair.key,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
destinationChainName: pair.destinationChainName ?? null,
|
||||
assetClass: pair.assetClass,
|
||||
familyKey: pair.familyKey,
|
||||
backingMode: pair.backingMode,
|
||||
redeemPolicy: pair.redeemPolicy,
|
||||
wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null,
|
||||
stableQuoteSymbol: pair.stableQuoteSymbol ?? null,
|
||||
eligible: pair.eligible === true,
|
||||
runtimeReady: pair.runtimeReady === true,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
eligibilityBlockers: Array.isArray(pair.eligibilityBlockers)
|
||||
? pair.eligibilityBlockers
|
||||
: [],
|
||||
runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements)
|
||||
? pair.runtimeMissingRequirements
|
||||
: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCanonicalCwFallback(chainIdFilter?: number | null): CwRegistryChain[] {
|
||||
const grouped = new Map<number, CwRegistryChain>();
|
||||
|
||||
for (const spec of CANONICAL_TOKENS) {
|
||||
if (spec.type !== 'w') continue;
|
||||
for (const [chainIdText, address] of Object.entries(spec.addresses)) {
|
||||
const chainId = Number(chainIdText);
|
||||
if (!address || Number.isNaN(chainId)) continue;
|
||||
if (chainIdFilter && chainId !== chainIdFilter) continue;
|
||||
|
||||
const existing = grouped.get(chainId) ?? {
|
||||
chainId,
|
||||
chainIdText,
|
||||
name: `Chain ${chainIdText}`,
|
||||
tokens: [],
|
||||
};
|
||||
|
||||
existing.tokens.push({ symbol: spec.symbol, address });
|
||||
grouped.set(chainId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((row) => ({
|
||||
...row,
|
||||
tokens: row.tokens.sort((a, b) => a.symbol.localeCompare(b.symbol)),
|
||||
}))
|
||||
.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
|
||||
/** GET /report/cross-chain — cross-chain pools, bridge volume, atomic swaps (Chain 138, ALL Mainnet) */
|
||||
router.get(
|
||||
'/cross-chain',
|
||||
@@ -158,10 +260,11 @@ router.get(
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
tokensByChain[chainId] = await buildTokenReport(chainId);
|
||||
poolsByChain[chainId] = await poolRepo.getPoolsByChain(chainId);
|
||||
poolsByChain[chainId] = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId));
|
||||
}
|
||||
|
||||
const crossChainReport = await buildCrossChainReport(138).catch(() => null);
|
||||
const gruTransport = buildGruTransportOverview();
|
||||
|
||||
const totalLiquidityByChain: Record<number, number> = {};
|
||||
const totalVolume24hByChain: Record<number, number> = {};
|
||||
@@ -196,6 +299,7 @@ router.get(
|
||||
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
|
||||
}
|
||||
: undefined,
|
||||
gruTransport,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/all:', error);
|
||||
@@ -221,7 +325,7 @@ router.get(
|
||||
name: t.name,
|
||||
asset_platform_id: chainId === 138 ? 'defi-oracle-meta' : chainId === 651940 ? 'all-mainnet' : `chain-${chainId}`,
|
||||
decimals: t.decimals,
|
||||
description: t.currencyCode ? `ISO-4217 ${t.currencyCode} compliant token` : undefined,
|
||||
description: describeToken(t),
|
||||
market_data: t.market
|
||||
? {
|
||||
current_price: { usd: t.market.priceUsd },
|
||||
@@ -364,6 +468,11 @@ router.get(
|
||||
decimals: number;
|
||||
type: string;
|
||||
logoURI: string;
|
||||
registryFamily?: string;
|
||||
familySymbol?: string;
|
||||
deploymentVersion?: string;
|
||||
deploymentStatus?: string;
|
||||
preferredForX402?: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
@@ -379,6 +488,11 @@ router.get(
|
||||
decimals: spec.decimals,
|
||||
type: spec.type,
|
||||
logoURI: getLogoUriForSpec(spec),
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
preferredForX402: spec.preferredForX402,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -398,20 +512,112 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/cw-registry — live cW* registry from deployment-status.json when available. */
|
||||
router.get('/cw-registry', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
const fileBackedRegistry = loadDeploymentStatusFile();
|
||||
|
||||
let chains = fileBackedRegistry
|
||||
? buildCwRegistryChains(fileBackedRegistry.data)
|
||||
: buildCanonicalCwFallback(chainIdFilter);
|
||||
|
||||
if (chainIdFilter && !Number.isNaN(chainIdFilter)) {
|
||||
chains = chains.filter((row) => row.chainId === chainIdFilter);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: fileBackedRegistry ? 'deployment-status-file' : 'canonical-fallback',
|
||||
complete: !!fileBackedRegistry,
|
||||
version: fileBackedRegistry?.data.version,
|
||||
updated: fileBackedRegistry?.data.updated,
|
||||
lastModified: fileBackedRegistry?.lastModified,
|
||||
chains,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/cw-registry:', error);
|
||||
res.status(500).json({ error: 'Internal server error', chains: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** 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 {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
const fileBackedRegistry = loadDeploymentStatusFile();
|
||||
const gruTransport = buildGruTransportOverview();
|
||||
const runtimeGasPairs = getActiveTransportPairs()
|
||||
.filter((pair) => pair.assetClass === 'gas_native')
|
||||
.map((pair) => ({
|
||||
key: pair.key,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
destinationChainName: pair.destinationChainName ?? null,
|
||||
familyKey: pair.familyKey ?? null,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null,
|
||||
stableQuoteSymbol: pair.stableQuoteSymbol ?? null,
|
||||
backingMode: pair.backingMode ?? null,
|
||||
redeemPolicy: pair.redeemPolicy ?? null,
|
||||
runtimeReady: pair.runtimeReady === true,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) ? pair.eligibilityBlockers : [],
|
||||
runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements)
|
||||
? pair.runtimeMissingRequirements
|
||||
: [],
|
||||
}));
|
||||
|
||||
let chains = fileBackedRegistry ? buildGasRegistryChains(fileBackedRegistry.data) : [];
|
||||
if (chainIdFilter && !Number.isNaN(chainIdFilter)) {
|
||||
chains = chains.filter((row) => row.chainId === chainIdFilter);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: fileBackedRegistry ? 'deployment-status-file' : 'transport-config-only',
|
||||
complete: !!fileBackedRegistry,
|
||||
version: fileBackedRegistry?.data.version,
|
||||
updated: fileBackedRegistry?.data.updated,
|
||||
lastModified: fileBackedRegistry?.lastModified,
|
||||
gasAssetFamilies: gruTransport?.gasAssetFamilies ?? [],
|
||||
gasRedeemGroups: gruTransport?.gasRedeemGroups ?? [],
|
||||
gasProtocolExposure: gruTransport?.gasProtocolExposure ?? [],
|
||||
runtimePairs: runtimeGasPairs,
|
||||
chains,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/gas-registry:', error);
|
||||
res.status(500).json({ error: 'Internal server error', chains: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/canonical — raw canonical spec list (no DB merge) */
|
||||
router.get(
|
||||
'/canonical',
|
||||
cacheMiddleware(10 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gruTransport = buildGruTransportOverview();
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
gruTransport,
|
||||
tokens: CANONICAL_TOKENS.map((t) => ({
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
decimals: t.decimals,
|
||||
currencyCode: t.currencyCode,
|
||||
registryFamily: getTokenRegistryFamily(t),
|
||||
familySymbol: t.familySymbol,
|
||||
deploymentVersion: t.deploymentVersion,
|
||||
deploymentStatus: t.deploymentStatus,
|
||||
preferredForX402: t.preferredForX402,
|
||||
liquiditySourceSymbol: t.liquiditySourceSymbol,
|
||||
addresses: t.addresses,
|
||||
})),
|
||||
});
|
||||
|
||||
215
services/token-aggregation/src/api/routes/token-mapping.test.ts
Normal file
215
services/token-aggregation/src/api/routes/token-mapping.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import tokenMappingRoutes from './token-mapping';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1/token-mapping', tokenMappingRoutes);
|
||||
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('Token mapping API with GRU Transport overlay', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE;
|
||||
const originalBscBridge = process.env.CW_BRIDGE_BSC;
|
||||
const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
const originalReserveSystem = process.env.CW_RESERVE_SYSTEM;
|
||||
const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
const originalMainnetBridge = process.env.CW_BRIDGE_MAINNET;
|
||||
const originalBtcMainnetOutstanding = process.env.CW_MAX_OUTSTANDING_BTC_MAINNET;
|
||||
const originalOptimismBridge = process.env.CW_BRIDGE_OPTIMISM;
|
||||
const originalGasHybridVerifier = process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138;
|
||||
const originalGasEscrowVault = process.env.CW_GAS_ESCROW_VAULT_CHAIN138;
|
||||
const originalGasTreasurySystem = process.env.CW_GAS_TREASURY_SYSTEM;
|
||||
const originalEthL2Outstanding = process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Supply = process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Escrowed = process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Treasury = process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Cap = process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM;
|
||||
const originalCwL1Bridge = process.env.CW_L1_BRIDGE;
|
||||
const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalChain138Bridge === undefined) {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge;
|
||||
}
|
||||
if (originalBscBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_BSC = originalBscBridge;
|
||||
}
|
||||
if (originalReserveVerifier === undefined) {
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier;
|
||||
}
|
||||
if (originalReserveVault === undefined) {
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
} else {
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault;
|
||||
}
|
||||
if (originalReserveSystem === undefined) {
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
} else {
|
||||
process.env.CW_RESERVE_SYSTEM = originalReserveSystem;
|
||||
}
|
||||
if (originalMaxOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding;
|
||||
}
|
||||
if (originalMainnetBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_MAINNET;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_MAINNET = originalMainnetBridge;
|
||||
}
|
||||
if (originalBtcMainnetOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_BTC_MAINNET;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_BTC_MAINNET = originalBtcMainnetOutstanding;
|
||||
}
|
||||
for (const [key, value] of Object.entries({
|
||||
CW_BRIDGE_OPTIMISM: originalOptimismBridge,
|
||||
CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138: originalGasHybridVerifier,
|
||||
CW_GAS_ESCROW_VAULT_CHAIN138: originalGasEscrowVault,
|
||||
CW_GAS_TREASURY_SYSTEM: originalGasTreasurySystem,
|
||||
CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Outstanding,
|
||||
CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Supply,
|
||||
CW_GAS_ESCROWED_ETH_L2_OPTIMISM: originalEthL2Escrowed,
|
||||
CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM: originalEthL2Treasury,
|
||||
CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM: originalEthL2Cap,
|
||||
})) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
if (originalCwL1Bridge === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE = originalCwL1Bridge;
|
||||
}
|
||||
if (originalCwL1BridgeChain138 === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns the active GRU transport overlay', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/token-mapping/transport/active`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.system).toMatchObject({
|
||||
name: 'GRU Monetary Transport Layer',
|
||||
shortName: 'GRU Transport',
|
||||
});
|
||||
expect(Array.isArray(body.transportPairs)).toBe(true);
|
||||
expect((body.transportPairs as unknown[]).length).toBeGreaterThan(0);
|
||||
expect(body.counts).toMatchObject({
|
||||
transportPairs: expect.any(Number),
|
||||
runtimeReadyTransportPairs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves active cUSDT transport from Chain 138 to BSC', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const source = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=${source}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.addressOnTarget).toBe('0x9a1D0dBEE997929ED02fD19E0E199704d20914dB');
|
||||
expect(body.activeTransportEligible).toBe(true);
|
||||
expect(body.gruTransportRuntimeReady).toBe(true);
|
||||
expect(body.gruTransportPairKey).toBe('138-56-cUSDT-cWUSDT');
|
||||
expect(body.gruTransportCanonicalToken).toMatchObject({
|
||||
symbol: 'cUSDT',
|
||||
activeVersion: 'v1',
|
||||
x402PreferredVersion: 'v2',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves active cBTC transport from Chain 138 to Ethereum mainnet', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_MAINNET = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_BTC_MAINNET = '2100000000000000';
|
||||
|
||||
const source = '0xcb7c000000000000000000000000000000000138';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=1&address=${source}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.addressOnTarget).toBe('0xcb7c000000000000000000000000000000000001');
|
||||
expect(body.activeTransportEligible).toBe(true);
|
||||
expect(body.gruTransportRuntimeReady).toBe(true);
|
||||
expect(body.gruTransportPairKey).toBe('138-1-cBTC-cWBTC');
|
||||
});
|
||||
|
||||
it('resolves gas-family transport metadata for the shared ETH L2 lane', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_OPTIMISM = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_GAS_ESCROW_VAULT_CHAIN138 = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_GAS_TREASURY_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM = '100';
|
||||
process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM = '25';
|
||||
process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM = '25';
|
||||
|
||||
/** Matches token-mapping-multichain.json 138→10 Compliant_ETH_L2_cW (not FALLBACK cETHL2 placeholder). */
|
||||
const source = '0x18a6b163d255cc0cb32b99697843b487d059907d';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=10&address=${source}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.addressOnTarget).toBe('0x95007ec50d0766162f77848edf7bdc4eba147fb4');
|
||||
expect(body.activeTransportEligible).toBe(true);
|
||||
expect(body.gruTransportRuntimeReady).toBe(true);
|
||||
expect(body.gruTransportPairKey).toBe('138-10-cETHL2-cWETHL2');
|
||||
expect(body.gruTransportAssetClass).toBe('gas_native');
|
||||
expect(body.gruTransportFamilyKey).toBe('eth_l2');
|
||||
expect(body.gruTransportBackingMode).toBe('hybrid_cap');
|
||||
expect(body.gruTransportRedeemPolicy).toBe('family_fungible_inventory_gated');
|
||||
});
|
||||
});
|
||||
@@ -5,29 +5,47 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { loadTokenMappingLoader } from '../../config/repo-config-loader';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
/** Repo root (proxmox): when run from token-aggregation cwd, 2 levels up to smom-dbis-138, 1 more to proxmox */
|
||||
const PROXMOX_ROOT = path.resolve(process.cwd(), '../../..');
|
||||
const LOADER_PATH = path.join(PROXMOX_ROOT, 'config', 'token-mapping-loader.cjs');
|
||||
const requireLoader = createRequire(path.join(PROXMOX_ROOT, 'package.json'));
|
||||
|
||||
function loadMultichainLoader(): {
|
||||
getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record<string, string>; addressMapToFrom: Record<string, string> } | null;
|
||||
getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>;
|
||||
getMappedAddress: (from: number, to: number, addr: string) => string | undefined;
|
||||
getGruTransportMetadata?: () => {
|
||||
system: Record<string, unknown> | null;
|
||||
terminology: Record<string, string>;
|
||||
enabledCanonicalTokens: Array<Record<string, unknown>>;
|
||||
enabledDestinationChains: Array<Record<string, unknown>>;
|
||||
counts: Record<string, number>;
|
||||
} | null;
|
||||
getActiveTransportPairs?: () => Array<Record<string, unknown>>;
|
||||
getActiveTransportPair?: (from: number, to: number, criteria?: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
getActivePublicPools?: () => Array<Record<string, unknown>>;
|
||||
getEnabledCanonicalToken?: (identifier: string) => Record<string, unknown> | null;
|
||||
isGasRedemptionPathAllowed?: (from: number, to: number, identifier: string) => boolean;
|
||||
} | null {
|
||||
try {
|
||||
const loader = requireLoader(LOADER_PATH);
|
||||
if (loader?.getTokenMappingForPair && loader?.getAllMultichainPairs && loader?.getMappedAddress) {
|
||||
return loader;
|
||||
}
|
||||
} catch {
|
||||
// config not available when run outside monorepo
|
||||
const loader = loadTokenMappingLoader<{
|
||||
getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record<string, string>; addressMapToFrom: Record<string, string> } | null;
|
||||
getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>;
|
||||
getMappedAddress: (from: number, to: number, addr: string) => string | undefined;
|
||||
getGruTransportMetadata?: () => {
|
||||
system: Record<string, unknown> | null;
|
||||
terminology: Record<string, string>;
|
||||
enabledCanonicalTokens: Array<Record<string, unknown>>;
|
||||
enabledDestinationChains: Array<Record<string, unknown>>;
|
||||
counts: Record<string, number>;
|
||||
} | null;
|
||||
getActiveTransportPairs?: () => Array<Record<string, unknown>>;
|
||||
getActiveTransportPair?: (from: number, to: number, criteria?: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
getActivePublicPools?: () => Array<Record<string, unknown>>;
|
||||
getEnabledCanonicalToken?: (identifier: string) => Record<string, unknown> | null;
|
||||
isGasRedemptionPathAllowed?: (from: number, to: number, identifier: string) => boolean;
|
||||
}>();
|
||||
if (loader) {
|
||||
return loader;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -65,12 +83,31 @@ router.get(
|
||||
});
|
||||
}
|
||||
|
||||
const activePairs = loader.getActiveTransportPairs
|
||||
? loader
|
||||
.getActiveTransportPairs()
|
||||
.filter((pair) => {
|
||||
const canonicalChainId = Number(pair.canonicalChainId);
|
||||
const destinationChainId = Number(pair.destinationChainId);
|
||||
return (
|
||||
(canonicalChainId === fromChain && destinationChainId === toChain) ||
|
||||
(canonicalChainId === toChain && destinationChainId === fromChain)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
return res.json({
|
||||
fromChainId: fromChain,
|
||||
toChainId: toChain,
|
||||
tokens: result.tokens,
|
||||
addressMapFromTo: result.addressMapFromTo,
|
||||
addressMapToFrom: result.addressMapToFrom,
|
||||
gruTransport: loader.getGruTransportMetadata
|
||||
? {
|
||||
system: loader.getGruTransportMetadata()?.system ?? null,
|
||||
activePairs,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -90,7 +127,38 @@ router.get(
|
||||
});
|
||||
}
|
||||
const pairs = loader.getAllMultichainPairs();
|
||||
return res.json({ pairs });
|
||||
return res.json({
|
||||
pairs,
|
||||
gruTransport: loader.getGruTransportMetadata
|
||||
? {
|
||||
system: loader.getGruTransportMetadata()?.system ?? null,
|
||||
activePairs: loader.getActiveTransportPairs ? loader.getActiveTransportPairs() : [],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/token-mapping/transport/active
|
||||
* Returns the GRU Monetary Transport Layer overlay as seen by token-aggregation.
|
||||
*/
|
||||
router.get(
|
||||
'/transport/active',
|
||||
cacheMiddleware(5 * 60 * 1000),
|
||||
(_req: Request, res: Response) => {
|
||||
const loader = loadMultichainLoader();
|
||||
if (!loader || !loader.getGruTransportMetadata || !loader.getActiveTransportPairs) {
|
||||
return res.status(503).json({
|
||||
error: 'GRU transport config not available (run from monorepo with config/gru-transport-active.json)',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
...loader.getGruTransportMetadata(),
|
||||
transportPairs: loader.getActiveTransportPairs(),
|
||||
publicPools: loader.getActivePublicPools ? loader.getActivePublicPools() : [],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -120,11 +188,64 @@ router.get(
|
||||
}
|
||||
|
||||
const mapped = loader.getMappedAddress(fromChain, toChain, address);
|
||||
const activeTransportPair = loader.getActiveTransportPair
|
||||
? loader.getActiveTransportPair(fromChain, toChain, {
|
||||
address,
|
||||
targetTokenAddress: mapped ?? null,
|
||||
})
|
||||
: null;
|
||||
const canonicalTokenIdentifier =
|
||||
(activeTransportPair && typeof activeTransportPair.canonicalSymbol === 'string'
|
||||
? activeTransportPair.canonicalSymbol
|
||||
: null) ??
|
||||
address;
|
||||
const canonicalToken = loader.getEnabledCanonicalToken
|
||||
? loader.getEnabledCanonicalToken(canonicalTokenIdentifier)
|
||||
: null;
|
||||
return res.json({
|
||||
fromChainId: fromChain,
|
||||
toChainId: toChain,
|
||||
addressOnSource: address,
|
||||
addressOnTarget: mapped ?? null,
|
||||
activeTransportEligible: !!activeTransportPair && activeTransportPair.eligible === true,
|
||||
gruTransportRuntimeReady: !!activeTransportPair && activeTransportPair.runtimeReady === true,
|
||||
gruTransportPairKey:
|
||||
activeTransportPair && typeof activeTransportPair.key === 'string' ? activeTransportPair.key : null,
|
||||
gruTransportAssetClass:
|
||||
activeTransportPair && typeof activeTransportPair.assetClass === 'string'
|
||||
? activeTransportPair.assetClass
|
||||
: null,
|
||||
gruTransportFamilyKey:
|
||||
activeTransportPair && typeof activeTransportPair.familyKey === 'string'
|
||||
? activeTransportPair.familyKey
|
||||
: null,
|
||||
gruTransportBackingMode:
|
||||
activeTransportPair && typeof activeTransportPair.backingMode === 'string'
|
||||
? activeTransportPair.backingMode
|
||||
: null,
|
||||
gruTransportRedeemPolicy:
|
||||
activeTransportPair && typeof activeTransportPair.redeemPolicy === 'string'
|
||||
? activeTransportPair.redeemPolicy
|
||||
: null,
|
||||
gruTransportWrappedNativeQuoteSymbol:
|
||||
activeTransportPair && typeof activeTransportPair.wrappedNativeQuoteSymbol === 'string'
|
||||
? activeTransportPair.wrappedNativeQuoteSymbol
|
||||
: null,
|
||||
gruTransportStableQuoteSymbol:
|
||||
activeTransportPair && typeof activeTransportPair.stableQuoteSymbol === 'string'
|
||||
? activeTransportPair.stableQuoteSymbol
|
||||
: null,
|
||||
gruTransportReferenceVenue:
|
||||
activeTransportPair && typeof activeTransportPair.referenceVenue === 'string'
|
||||
? activeTransportPair.referenceVenue
|
||||
: null,
|
||||
gruGasRedemptionPathAllowed:
|
||||
activeTransportPair &&
|
||||
typeof activeTransportPair.familyKey === 'string' &&
|
||||
loader.isGasRedemptionPathAllowed
|
||||
? loader.isGasRedemptionPathAllowed(fromChain, toChain, activeTransportPair.familyKey)
|
||||
: null,
|
||||
gruTransportCanonicalToken: canonicalToken,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { TokenRepository } from '../../database/repositories/token-repo';
|
||||
import { TokenRepository, Token } from '../../database/repositories/token-repo';
|
||||
import { MarketDataRepository } from '../../database/repositories/market-data-repo';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import { PoolRepository, LiquidityPool } from '../../database/repositories/pool-repo';
|
||||
import { OHLCVGenerator } from '../../indexer/ohlcv-generator';
|
||||
import { CoinGeckoAdapter } from '../../adapters/coingecko-adapter';
|
||||
import { CoinMarketCapAdapter } from '../../adapters/cmc-adapter';
|
||||
import { DexScreenerAdapter } from '../../adapters/dexscreener-adapter';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { resolvePoolTokenDisplays } from '../../services/token-display';
|
||||
import { resolvePoolTokenDisplays, resolveTokenDisplay } from '../../services/token-display';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { filterPoolsForExposure, shouldExposePublicPool } from '../../config/gru-transport';
|
||||
import {
|
||||
getCanonicalTokenByAddress,
|
||||
getCanonicalTokensByChain,
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -19,6 +26,122 @@ const coingeckoAdapter = new CoinGeckoAdapter();
|
||||
const cmcAdapter = new CoinMarketCapAdapter();
|
||||
const dexscreenerAdapter = new DexScreenerAdapter();
|
||||
|
||||
function tokenFromCanonical(chainId: number, address: string): Token | null {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase());
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chainId,
|
||||
address: address.toLowerCase(),
|
||||
name: spec.name,
|
||||
symbol: spec.symbol,
|
||||
decimals: spec.decimals,
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPoolsByTokenWithFallback(chainId: number, address: string): Promise<LiquidityPool[]> {
|
||||
const normalized = address.toLowerCase();
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalized);
|
||||
const dbPools = filterPoolsForExposure(
|
||||
chainId,
|
||||
await poolRepo.getPoolsByToken(chainId, resolution.lookupAddress)
|
||||
);
|
||||
if (dbPools.length > 0) {
|
||||
return dbPools;
|
||||
}
|
||||
|
||||
const livePools = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId));
|
||||
return livePools.filter(
|
||||
(pool) =>
|
||||
pool.token0Address === resolution.lookupAddress ||
|
||||
pool.token1Address === resolution.lookupAddress ||
|
||||
pool.token0Address === normalized ||
|
||||
pool.token1Address === normalized
|
||||
);
|
||||
}
|
||||
|
||||
async function getTokenWithFallback(chainId: number, address: string): Promise<Token | null> {
|
||||
const normalized = address.toLowerCase();
|
||||
const token = await tokenRepo.getToken(chainId, normalized);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const canonical = tokenFromCanonical(chainId, normalized);
|
||||
if (canonical) {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalized);
|
||||
const livePools = await getLiveDodoPools(chainId);
|
||||
const liveAddress =
|
||||
livePools.find((pool) => pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress)
|
||||
? resolution.lookupAddress
|
||||
: null;
|
||||
if (!liveAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress);
|
||||
return {
|
||||
chainId,
|
||||
address: normalized,
|
||||
name: display.name,
|
||||
symbol: display.symbol,
|
||||
decimals: display.decimals,
|
||||
verified: display.source !== 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
async function getTokensWithFallback(
|
||||
chainId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ tokens: Token[]; source: 'db' | 'live-dodo' | 'canonical' }> {
|
||||
const dbTokens = await tokenRepo.getTokens(chainId, limit, offset);
|
||||
if (dbTokens.length > 0) {
|
||||
return { tokens: dbTokens, source: 'db' };
|
||||
}
|
||||
|
||||
const livePools = await getLiveDodoPools(chainId);
|
||||
if (livePools.length > 0) {
|
||||
const tokenAddresses = [...new Set(livePools.flatMap((pool) => [pool.token0Address, pool.token1Address]))];
|
||||
const liveTokens = await Promise.all(
|
||||
tokenAddresses.map(async (address) => {
|
||||
const display = await resolveTokenDisplay(tokenRepo, chainId, address);
|
||||
return {
|
||||
chainId,
|
||||
address: display.address,
|
||||
name: display.name,
|
||||
symbol: display.symbol,
|
||||
decimals: display.decimals,
|
||||
verified: display.source !== 'fallback',
|
||||
} as Token;
|
||||
})
|
||||
);
|
||||
const sorted = liveTokens.sort((a, b) =>
|
||||
`${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`)
|
||||
);
|
||||
return { tokens: sorted.slice(offset, offset + limit), source: 'live-dodo' };
|
||||
}
|
||||
|
||||
const canonicalTokens = getCanonicalTokensByChain(chainId)
|
||||
.map((spec) => ({
|
||||
chainId,
|
||||
address: String(spec.addresses[chainId]).toLowerCase(),
|
||||
name: spec.name,
|
||||
symbol: spec.symbol,
|
||||
decimals: spec.decimals,
|
||||
verified: true,
|
||||
}) as Token)
|
||||
.sort((a, b) => `${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`));
|
||||
|
||||
return { tokens: canonicalTokens.slice(offset, offset + limit), source: 'canonical' };
|
||||
}
|
||||
|
||||
router.get('/chains', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
@@ -51,7 +174,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const tokens = await tokenRepo.getTokens(chainId, limit, offset);
|
||||
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);
|
||||
@@ -60,7 +183,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
market: marketData || undefined,
|
||||
};
|
||||
if (includeDodoPool) {
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, token.address);
|
||||
const pools = await getPoolsByTokenWithFallback(chainId, token.address);
|
||||
const dodoPool = pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo');
|
||||
out.hasDodoPool = !!dodoPool;
|
||||
out.pmmPool = dodoPool?.poolAddress || undefined;
|
||||
@@ -76,6 +199,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
offset,
|
||||
count: tokensWithMarketData.length,
|
||||
},
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tokens:', error);
|
||||
@@ -92,17 +216,19 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const token = await tokenRepo.getToken(chainId, address);
|
||||
const normalizedAddress = address.toLowerCase();
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress);
|
||||
const token = await getTokenWithFallback(chainId, normalizedAddress);
|
||||
if (!token) {
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
|
||||
const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([
|
||||
marketDataRepo.getMarketData(chainId, address),
|
||||
poolRepo.getPoolsByToken(chainId, address),
|
||||
coingeckoAdapter.getTokenByContract(chainId, address),
|
||||
cmcAdapter.getTokenByContract(chainId, address),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, address),
|
||||
marketDataRepo.getMarketData(chainId, resolution.lookupAddress),
|
||||
getPoolsByTokenWithFallback(chainId, normalizedAddress),
|
||||
coingeckoAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
@@ -131,6 +257,15 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
})),
|
||||
hasDodoPool: pools.some((p) => (p.dexType || '').toLowerCase() === 'dodo'),
|
||||
pmmPool: pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo')?.poolAddress || undefined,
|
||||
canonicalLiquidity:
|
||||
resolution.usedFallback
|
||||
? {
|
||||
requestedAddress: normalizedAddress,
|
||||
lookupAddress: resolution.lookupAddress,
|
||||
requestedSymbol: resolution.requestedSymbol,
|
||||
lookupSymbol: resolution.lookupSymbol,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,7 +283,7 @@ router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Req
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, address);
|
||||
const pools = await getPoolsByTokenWithFallback(chainId, address);
|
||||
|
||||
res.json({
|
||||
pools: await Promise.all(
|
||||
@@ -280,7 +415,10 @@ router.get('/pools/:poolAddress', cacheMiddleware(60 * 1000), async (req: Reques
|
||||
}
|
||||
|
||||
const pool = await poolRepo.getPool(chainId, poolAddress);
|
||||
if (!pool) {
|
||||
if (
|
||||
!pool ||
|
||||
!shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address)
|
||||
) {
|
||||
return res.status(404).json({ error: 'Pool not found' });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import heatmapRoutes from './routes/heatmap';
|
||||
import arbitrageRoutes from './routes/arbitrage';
|
||||
import aggregatorRouteMatrixRoutes from './routes/aggregator-routes';
|
||||
import partnerPayloadRoutes from './routes/partner-payloads';
|
||||
import plannerV2Routes from './routes/planner-v2';
|
||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import winston from 'winston';
|
||||
@@ -39,19 +40,42 @@ const logger = winston.createLogger({
|
||||
export class ApiServer {
|
||||
private app: Express;
|
||||
private port: number;
|
||||
private indexer: MultiChainIndexer;
|
||||
private indexerEnabled: boolean;
|
||||
private indexer: MultiChainIndexer | null;
|
||||
|
||||
private resolveTrustProxySetting(): boolean | number | string {
|
||||
const raw = (process.env.EXPRESS_TRUST_PROXY ?? process.env.TRUST_PROXY ?? '1').trim();
|
||||
const normalized = raw.toLowerCase();
|
||||
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
|
||||
return raw;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.port = parseInt(process.env.PORT || '3000', 10);
|
||||
this.indexer = new MultiChainIndexer();
|
||||
this.indexerEnabled = this.resolveFeatureFlag('ENABLE_INDEXER', true);
|
||||
this.indexer = this.indexerEnabled ? new MultiChainIndexer() : null;
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupErrorHandling();
|
||||
}
|
||||
|
||||
private resolveFeatureFlag(name: string, fallback: boolean): boolean {
|
||||
const raw = (process.env[name] || '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (['1', 'true', 'yes', 'on'].includes(raw)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(raw)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
const trustProxy = this.resolveTrustProxySetting();
|
||||
this.app.set('trust proxy', trustProxy);
|
||||
|
||||
// CORS
|
||||
this.app.use(cors());
|
||||
|
||||
@@ -69,6 +93,8 @@ export class ApiServer {
|
||||
this.app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
forwardedFor: req.get('x-forwarded-for'),
|
||||
trustProxy,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
next();
|
||||
@@ -88,7 +114,7 @@ export class ApiServer {
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: 'connected',
|
||||
indexer: 'running',
|
||||
indexer: this.indexerEnabled ? 'running' : 'disabled',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -112,6 +138,7 @@ export class ApiServer {
|
||||
this.app.use('/api/v1', arbitrageRoutes);
|
||||
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
|
||||
this.app.use('/api/v1', partnerPayloadRoutes);
|
||||
this.app.use('/api/v2', plannerV2Routes);
|
||||
|
||||
// Admin routes (stricter rate limit)
|
||||
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);
|
||||
@@ -124,6 +151,7 @@ export class ApiServer {
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
api: '/api/v1',
|
||||
apiV2: '/api/v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -148,11 +176,12 @@ export class ApiServer {
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
// Initialize indexer
|
||||
await this.indexer.initialize();
|
||||
|
||||
// Start indexing
|
||||
await this.indexer.startAll();
|
||||
if (this.indexer) {
|
||||
await this.indexer.initialize();
|
||||
await this.indexer.startAll();
|
||||
} else {
|
||||
logger.info('Token aggregation indexer disabled by ENABLE_INDEXER flag');
|
||||
}
|
||||
|
||||
// Start server
|
||||
this.app.listen(this.port, () => {
|
||||
@@ -167,7 +196,7 @@ export class ApiServer {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.indexer.stopAll();
|
||||
this.indexer?.stopAll();
|
||||
logger.info('Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Built-in CCIP / Trustless bridge route payload when BRIDGE_LIST_JSON_URL is unset.
|
||||
* Aligns with MetaMask Snap expectations and docs/07-ccip/CCIP_BRIDGE_MAINNET_CONNECTION.md.
|
||||
*/
|
||||
|
||||
import { getActivePublicPools, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport';
|
||||
|
||||
export interface BridgeRoutesPayload {
|
||||
routes: {
|
||||
weth9: Record<string, string>;
|
||||
weth10: Record<string, string>;
|
||||
trustless?: Record<string, string>;
|
||||
};
|
||||
chain138Bridges: {
|
||||
weth9: string;
|
||||
weth10: string;
|
||||
trustless?: string;
|
||||
};
|
||||
tokenMappingApi: {
|
||||
basePath: string;
|
||||
pairs: string;
|
||||
resolve: string;
|
||||
note: string;
|
||||
};
|
||||
gruTransport?: {
|
||||
system: unknown;
|
||||
summary?: Record<string, number>;
|
||||
activeTransportPairs: unknown[];
|
||||
activePublicPools: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WETH9_138 = '0xcacfd227A040002e49e2e01626363071324f820a';
|
||||
const DEFAULT_WETH10_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0';
|
||||
const DEFAULT_LOCKBOX_138 = '0xFce6f50B312B3D936Ea9693C5C9531CF92a3324c';
|
||||
|
||||
/** Destination-side WETH9 receivers (relay-backed where noted in CCIP docs). */
|
||||
const WETH9_DESTINATIONS: Record<string, string> = {
|
||||
'Ethereum Mainnet (1)': '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939',
|
||||
'BNB Chain (56)': '0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C',
|
||||
'Avalanche C-Chain (43114)': '0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F',
|
||||
};
|
||||
|
||||
function envAddr(key: string, fallback: string): string {
|
||||
const v = process.env[key];
|
||||
return typeof v === 'string' && v.startsWith('0x') ? v : fallback;
|
||||
}
|
||||
|
||||
export function buildDefaultBridgeRoutes(): BridgeRoutesPayload {
|
||||
const inboxEth = process.env.INBOX_ETH?.trim();
|
||||
const trustlessRoutes: Record<string, string> = {};
|
||||
if (inboxEth?.startsWith('0x')) {
|
||||
trustlessRoutes['Ethereum Mainnet (1)'] = inboxEth;
|
||||
}
|
||||
|
||||
const gruTransportMetadata = getGruTransportMetadata();
|
||||
|
||||
return {
|
||||
routes: {
|
||||
weth9: { ...WETH9_DESTINATIONS },
|
||||
weth10: { ...WETH9_DESTINATIONS },
|
||||
...(Object.keys(trustlessRoutes).length > 0 ? { trustless: trustlessRoutes } : {}),
|
||||
},
|
||||
chain138Bridges: {
|
||||
weth9: envAddr('CCIPWETH9_BRIDGE_CHAIN138', DEFAULT_WETH9_138),
|
||||
weth10: envAddr('CCIPWETH10_BRIDGE_CHAIN138', DEFAULT_WETH10_138),
|
||||
trustless: envAddr('LOCKBOX_138', DEFAULT_LOCKBOX_138),
|
||||
},
|
||||
tokenMappingApi: {
|
||||
basePath: '/api/v1/token-mapping',
|
||||
pairs: '/api/v1/token-mapping/pairs',
|
||||
resolve: '/api/v1/token-mapping/resolve',
|
||||
note: 'Resolve bridged token addresses between chains; requires monorepo config/token-mapping-multichain.json on server.',
|
||||
},
|
||||
gruTransport: gruTransportMetadata
|
||||
? {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
activeTransportPairs: getActiveTransportPairs(),
|
||||
activePublicPools: getActivePublicPools(),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
258
services/token-aggregation/src/config/canonical-tokens.test.ts
Normal file
258
services/token-aggregation/src/config/canonical-tokens.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
getCanonicalTokenByAddress,
|
||||
getCanonicalTokenBySymbol,
|
||||
getTokenRegistryFamily,
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from './canonical-tokens';
|
||||
|
||||
describe('canonical cW token catalog', () => {
|
||||
it('models cWUSDT, cWUSDC, cWAUSDT, and cWUSDW as first-class wrapped GRU transport assets', () => {
|
||||
const cwUsdt = getCanonicalTokenBySymbol(56, 'cWUSDT');
|
||||
expect(cwUsdt).toMatchObject({
|
||||
symbol: 'cWUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwUsdt?.addresses[56]).toBe('0x9a1D0dBEE997929ED02fD19E0E199704d20914dB');
|
||||
expect(getCanonicalTokenByAddress(56, '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB')?.symbol).toBe('cWUSDT');
|
||||
|
||||
const cwUsdc = getCanonicalTokenBySymbol(8453, 'cWUSDC');
|
||||
expect(cwUsdc).toMatchObject({
|
||||
symbol: 'cWUSDC',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwUsdc?.addresses[8453]).toBe('0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105');
|
||||
expect(getCanonicalTokenByAddress(8453, '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105')?.symbol).toBe('cWUSDC');
|
||||
|
||||
const cwAusdt = getCanonicalTokenBySymbol(56, 'cWAUSDT');
|
||||
expect(cwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwAusdt?.addresses[56]).toBe('0xe1a51Bc037a79AB36767561B147eb41780124934');
|
||||
expect(getCanonicalTokenByAddress(56, '0xe1a51Bc037a79AB36767561B147eb41780124934')?.symbol).toBe('cWAUSDT');
|
||||
|
||||
const cwUsdw = getCanonicalTokenBySymbol(56, 'cWUSDW');
|
||||
expect(cwUsdw).toMatchObject({
|
||||
symbol: 'cWUSDW',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwUsdw?.addresses[56]).toBe('0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55');
|
||||
expect(getCanonicalTokenByAddress(56, '0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55')?.symbol).toBe('cWUSDW');
|
||||
});
|
||||
|
||||
it('surfaces cUSDW on Chain 138 as the repo-native USDW hub asset', () => {
|
||||
const cusdw = getCanonicalTokenBySymbol(138, 'cUSDW');
|
||||
expect(cusdw).toMatchObject({
|
||||
symbol: 'cUSDW',
|
||||
type: 'base',
|
||||
currencyCode: 'USD',
|
||||
decimals: 6,
|
||||
});
|
||||
expect(cusdw?.addresses[138]).toBe('0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e');
|
||||
});
|
||||
|
||||
it('models cBTC and cWBTC as GRU monetary-unit assets with satoshi precision', () => {
|
||||
const cbtc = getCanonicalTokenBySymbol(138, 'cBTC');
|
||||
expect(cbtc).toMatchObject({
|
||||
symbol: 'cBTC',
|
||||
type: 'base',
|
||||
currencyCode: 'BTC',
|
||||
decimals: 8,
|
||||
});
|
||||
expect(cbtc?.addresses[138]).toBe('0xcb7c000000000000000000000000000000000138');
|
||||
expect(getTokenRegistryFamily(cbtc!)).toBe('monetary_unit');
|
||||
|
||||
const cwbtc = getCanonicalTokenBySymbol(1, 'cWBTC');
|
||||
expect(cwbtc).toMatchObject({
|
||||
symbol: 'cWBTC',
|
||||
type: 'w',
|
||||
currencyCode: 'BTC',
|
||||
decimals: 8,
|
||||
});
|
||||
expect(cwbtc?.addresses[1]).toBe('0xcb7c000000000000000000000000000000000001');
|
||||
expect(getCanonicalTokenByAddress(1, '0xcb7c000000000000000000000000000000000001')?.symbol).toBe('cWBTC');
|
||||
expect(getTokenRegistryFamily(cwbtc!)).toBe('monetary_unit');
|
||||
});
|
||||
|
||||
it('models gas-native families on Chain 138 and their public cW mirrors with gas-native metadata', () => {
|
||||
const ceth = getCanonicalTokenBySymbol(138, 'cETH');
|
||||
expect(ceth).toMatchObject({
|
||||
symbol: 'cETH',
|
||||
type: 'base',
|
||||
currencyCode: 'ETH',
|
||||
decimals: 18,
|
||||
});
|
||||
expect(ceth?.addresses[138]).toBe('0xce7e00000000000000000000000000000000008a');
|
||||
expect(getTokenRegistryFamily(ceth!)).toBe('gas_native');
|
||||
|
||||
const cethL2 = getCanonicalTokenBySymbol(138, 'cETHL2');
|
||||
expect(cethL2).toMatchObject({
|
||||
symbol: 'cETHL2',
|
||||
type: 'base',
|
||||
currencyCode: 'ETH',
|
||||
decimals: 18,
|
||||
});
|
||||
expect(cethL2?.addresses[138]).toBe('0xce7200000000000000000000000000000000008a');
|
||||
|
||||
const cweth = getCanonicalTokenBySymbol(1, 'cWETH');
|
||||
expect(cweth).toMatchObject({
|
||||
symbol: 'cWETH',
|
||||
type: 'w',
|
||||
currencyCode: 'ETH',
|
||||
});
|
||||
expect(cweth?.addresses[1]).toBe('0xce7e000000000000000000000000000000000001');
|
||||
|
||||
const cwethL2 = getCanonicalTokenBySymbol(10, 'cWETHL2');
|
||||
expect(cwethL2).toMatchObject({
|
||||
symbol: 'cWETHL2',
|
||||
type: 'w',
|
||||
currencyCode: 'ETH',
|
||||
});
|
||||
expect(cwethL2?.addresses[10]).toBe('0xce7200000000000000000000000000000000000a');
|
||||
expect(getCanonicalTokenByAddress(10, '0xce7200000000000000000000000000000000000a')?.symbol).toBe('cWETHL2');
|
||||
expect(getTokenRegistryFamily(cwethL2!)).toBe('gas_native');
|
||||
});
|
||||
|
||||
it('surfaces cAUSDT on Chain 138 from env and keeps cWAUSDT fallback mirrors on active public chains', () => {
|
||||
const previousBase = process.env.CAUSDT_ADDRESS_138;
|
||||
process.env.CAUSDT_ADDRESS_138 = '0x2222222222222222222222222222222222222222';
|
||||
jest.resetModules();
|
||||
|
||||
const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens');
|
||||
const causdt = reloaded.getCanonicalTokenBySymbol(138, 'cAUSDT');
|
||||
const polygonCwAusdt = reloaded.getCanonicalTokenBySymbol(137, 'cWAUSDT');
|
||||
const avalancheCwAusdt = reloaded.getCanonicalTokenBySymbol(43114, 'cWAUSDT');
|
||||
const celoCwAusdt = reloaded.getCanonicalTokenBySymbol(42220, 'cWAUSDT');
|
||||
|
||||
expect(causdt).toMatchObject({
|
||||
symbol: 'cAUSDT',
|
||||
type: 'base',
|
||||
currencyCode: 'USD',
|
||||
decimals: 6,
|
||||
});
|
||||
expect(causdt?.addresses[138]).toBe('0x2222222222222222222222222222222222222222');
|
||||
expect(reloaded.getCanonicalTokenByAddress(138, '0x2222222222222222222222222222222222222222')?.symbol).toBe('cAUSDT');
|
||||
|
||||
expect(polygonCwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(polygonCwAusdt?.addresses[137]).toBe('0xf12e262F85107df26741726b074606CaFa24AAe7');
|
||||
|
||||
expect(avalancheCwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(avalancheCwAusdt?.addresses[43114]).toBe('0xff3084410A732231472Ee9f93F5855dA89CC5254');
|
||||
|
||||
expect(celoCwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(celoCwAusdt?.addresses[42220]).toBe('0xC158b6cD3A3088C52F797D41f5Aa02825361629e');
|
||||
|
||||
if (previousBase === undefined) {
|
||||
delete process.env.CAUSDT_ADDRESS_138;
|
||||
} else {
|
||||
process.env.CAUSDT_ADDRESS_138 = previousBase;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('picks up Polygon cWUSDW from env as soon as the wrapped mirror is deployed', () => {
|
||||
const previous = process.env.CWUSDW_ADDRESS_137;
|
||||
process.env.CWUSDW_ADDRESS_137 = '0x1111111111111111111111111111111111111111';
|
||||
jest.resetModules();
|
||||
|
||||
const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens');
|
||||
const polygonCwUsdw = reloaded.getCanonicalTokenBySymbol(137, 'cWUSDW');
|
||||
|
||||
expect(polygonCwUsdw).toMatchObject({
|
||||
symbol: 'cWUSDW',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(polygonCwUsdw?.addresses[137]).toBe('0x1111111111111111111111111111111111111111');
|
||||
expect(reloaded.getCanonicalTokenByAddress(137, '0x1111111111111111111111111111111111111111')?.symbol).toBe('cWUSDW');
|
||||
|
||||
if (previous === undefined) {
|
||||
delete process.env.CWUSDW_ADDRESS_137;
|
||||
} else {
|
||||
process.env.CWUSDW_ADDRESS_137 = previous;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('models the Alltra gold exception as env-gated cAXAUC/cAXAUT and cWAXAUC/cWAXAUT on chain 651940', () => {
|
||||
const previousCaxauc = process.env.CAXAUC_ADDRESS_651940;
|
||||
const previousCwaxauc = process.env.CWAXAUC_ADDRESS_651940;
|
||||
process.env.CAXAUC_ADDRESS_651940 = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CWAXAUC_ADDRESS_651940 = '0x4444444444444444444444444444444444444444';
|
||||
jest.resetModules();
|
||||
|
||||
const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens');
|
||||
const caxauc = reloaded.getCanonicalTokenBySymbol(651940, 'cAXAUC');
|
||||
const cwaxauc = reloaded.getCanonicalTokenBySymbol(651940, 'cWAXAUC');
|
||||
const cxaucOnAlltra = reloaded.getCanonicalTokenBySymbol(651940, 'cXAUC');
|
||||
|
||||
expect(caxauc).toMatchObject({
|
||||
symbol: 'cAXAUC',
|
||||
type: 'base',
|
||||
currencyCode: 'XAU',
|
||||
});
|
||||
expect(caxauc?.addresses[651940]).toBe('0x3333333333333333333333333333333333333333');
|
||||
expect(reloaded.getCanonicalTokenByAddress(651940, '0x3333333333333333333333333333333333333333')?.symbol).toBe('cAXAUC');
|
||||
|
||||
expect(cwaxauc).toMatchObject({
|
||||
symbol: 'cWAXAUC',
|
||||
type: 'w',
|
||||
currencyCode: 'XAU',
|
||||
});
|
||||
expect(cwaxauc?.addresses[651940]).toBe('0x4444444444444444444444444444444444444444');
|
||||
expect(reloaded.getCanonicalTokenByAddress(651940, '0x4444444444444444444444444444444444444444')?.symbol).toBe('cWAXAUC');
|
||||
|
||||
expect(cxaucOnAlltra).toBeUndefined();
|
||||
|
||||
if (previousCaxauc === undefined) {
|
||||
delete process.env.CAXAUC_ADDRESS_651940;
|
||||
} else {
|
||||
process.env.CAXAUC_ADDRESS_651940 = previousCaxauc;
|
||||
}
|
||||
if (previousCwaxauc === undefined) {
|
||||
delete process.env.CWAXAUC_ADDRESS_651940;
|
||||
} else {
|
||||
process.env.CWAXAUC_ADDRESS_651940 = previousCwaxauc;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('surfaces Chain 138 V2 x402 deployments explicitly and resolves quote fallback to V1 liquidity', () => {
|
||||
const cusdtV1 = getCanonicalTokenBySymbol(138, 'cUSDT');
|
||||
const cusdtV2 = getCanonicalTokenBySymbol(138, 'cUSDT_V2');
|
||||
const v2Addr = cusdtV2?.addresses[138];
|
||||
expect(cusdtV2).toMatchObject({
|
||||
symbol: 'cUSDT_V2',
|
||||
familySymbol: 'cUSDT',
|
||||
deploymentVersion: 'v2',
|
||||
preferredForX402: true,
|
||||
liquiditySourceSymbol: 'cUSDT',
|
||||
});
|
||||
expect(v2Addr && String(v2Addr).trim()).toBeTruthy();
|
||||
expect(getCanonicalTokenByAddress(138, String(v2Addr))?.symbol).toBe('cUSDT_V2');
|
||||
|
||||
const quoteResolution = resolveCanonicalQuoteAddress(138, String(v2Addr));
|
||||
expect(quoteResolution).toMatchObject({
|
||||
requestedSymbol: 'cUSDT_V2',
|
||||
lookupSymbol: 'cUSDT',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(quoteResolution.lookupAddress).toBe(String(cusdtV1?.addresses[138] || '').toLowerCase());
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,20 @@
|
||||
* Addresses can be overridden via env (e.g. CUSDC_ADDRESS_138) or filled by indexer.
|
||||
*/
|
||||
|
||||
import { isISO4217Supported } from './iso4217-symbol-registry';
|
||||
import { isMonetaryUnitSupported } from './monetary-unit-symbol-registry';
|
||||
import { loadTokenMappingLoader } from './repo-config-loader';
|
||||
|
||||
export type TokenType = 'base' | 'w' | 'asset' | 'debt';
|
||||
export type TokenRegistryFamily = 'iso4217' | 'commodity' | 'monetary_unit' | 'gas_native' | 'unclassified';
|
||||
|
||||
export interface CanonicalTokenSpec {
|
||||
symbol: string;
|
||||
name: string;
|
||||
type: TokenType;
|
||||
decimals: number;
|
||||
currencyCode?: string; // ISO-4217 for base/w
|
||||
currencyCode?: string;
|
||||
registryFamily?: TokenRegistryFamily;
|
||||
/** ChainId -> contract address (placeholder or from env) */
|
||||
addresses: Partial<Record<number, string>>;
|
||||
description?: string;
|
||||
@@ -21,14 +27,60 @@ export interface CanonicalTokenSpec {
|
||||
v1Symbol?: string;
|
||||
/** v0 symbol alias; on ChainID 138 tokens use v0 only (cUSDC, cUSDT), no chain designator */
|
||||
v0Alias?: string;
|
||||
/** Shared family symbol when multiple on-chain deployments exist for the same monetary unit. */
|
||||
familySymbol?: string;
|
||||
/** Deployment version when a family has multiple contract surfaces. */
|
||||
deploymentVersion?: string;
|
||||
/** Deployment lifecycle status (for example active or staged). */
|
||||
deploymentStatus?: string;
|
||||
/** Whether this deployment is the preferred x402 / permit-capable surface. */
|
||||
preferredForX402?: boolean;
|
||||
/** Symbol whose current liquidity should be used for quote fallback until cutover. */
|
||||
liquiditySourceSymbol?: string;
|
||||
}
|
||||
|
||||
interface GruTransportDeployment {
|
||||
version?: string;
|
||||
address?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface GruTransportCanonicalToken {
|
||||
symbol?: string;
|
||||
activeVersion?: string;
|
||||
activeAddress?: string;
|
||||
x402PreferredVersion?: string;
|
||||
x402PreferredAddress?: string;
|
||||
deployments?: GruTransportDeployment[];
|
||||
}
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_25 = 25; // Cronos
|
||||
const CHAIN_651940 = 651940;
|
||||
const LEGACY_CHAIN_ENV_SUFFIX: Partial<Record<number, string>> = {
|
||||
1: 'MAINNET',
|
||||
10: 'OPTIMISM',
|
||||
25: 'CRONOS',
|
||||
56: 'BSC',
|
||||
100: 'GNOSIS',
|
||||
137: 'POLYGON',
|
||||
42161: 'ARBITRUM',
|
||||
43114: 'AVALANCHE',
|
||||
8453: 'BASE',
|
||||
};
|
||||
/** L2/mainnet chain IDs for cUSDT/cUSDC multichain (env: CUSDT_ADDRESS_56, CUSDC_ADDRESS_137, etc.) */
|
||||
const L2_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 25, 100, 42220, 1111] as const;
|
||||
|
||||
const GRU_CW_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 100, 42220] as const;
|
||||
const BTC_CW_CHAIN_IDS = [1, 10, 25, 56, 100, 137, 42161, 42220, 43114, 8453, 1111] as const;
|
||||
const ETH_MAINNET_CW_CHAIN_IDS = [1] as const;
|
||||
const ETH_L2_CW_CHAIN_IDS = [10, 42161, 8453] as const;
|
||||
const BNB_CW_CHAIN_IDS = [56] as const;
|
||||
const POL_CW_CHAIN_IDS = [137] as const;
|
||||
const AVAX_CW_CHAIN_IDS = [43114] as const;
|
||||
const CRO_CW_CHAIN_IDS = [25] as const;
|
||||
const XDAI_CW_CHAIN_IDS = [100] as const;
|
||||
const CELO_CW_CHAIN_IDS = [42220] as const;
|
||||
const WEMIX_CW_CHAIN_IDS = [1111] as const;
|
||||
/** Verified addresses from CHAIN138_TOKEN_ADDRESSES, .env, and deployment summaries */
|
||||
const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
USDC: {
|
||||
@@ -42,7 +94,7 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
[CHAIN_651940]: '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', // AUSDC on ALL Mainnet
|
||||
[1]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum USDC
|
||||
[56]: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // BSC USDC
|
||||
[137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c1369', // Polygon USDC
|
||||
[137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon USDC
|
||||
[100]: '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', // Gnosis USDC
|
||||
[10]: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism USDC
|
||||
[42161]: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum USDC
|
||||
@@ -67,6 +119,119 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
[42220]: '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e', // Celo USDT
|
||||
[1111]: '0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F', // Wemix USDT
|
||||
},
|
||||
cUSDC_V2: {
|
||||
[CHAIN_138]: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
|
||||
},
|
||||
cUSDT_V2: {
|
||||
[CHAIN_138]: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
|
||||
},
|
||||
cUSDW: {
|
||||
[CHAIN_138]: '0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e',
|
||||
},
|
||||
cBTC: {
|
||||
[CHAIN_138]: '0xcb7c000000000000000000000000000000000138',
|
||||
},
|
||||
cETH: {
|
||||
[CHAIN_138]: '0xce7e00000000000000000000000000000000008a',
|
||||
},
|
||||
cETHL2: {
|
||||
[CHAIN_138]: '0xce7200000000000000000000000000000000008a',
|
||||
},
|
||||
cBNB: {
|
||||
[CHAIN_138]: '0xcb6b00000000000000000000000000000000008a',
|
||||
},
|
||||
cPOL: {
|
||||
[CHAIN_138]: '0xc90100000000000000000000000000000000008a',
|
||||
},
|
||||
cAVAX: {
|
||||
[CHAIN_138]: '0xcaaa00000000000000000000000000000000008a',
|
||||
},
|
||||
cCRO: {
|
||||
[CHAIN_138]: '0xcc2000000000000000000000000000000000008a',
|
||||
},
|
||||
cXDAI: {
|
||||
[CHAIN_138]: '0xcda100000000000000000000000000000000008a',
|
||||
},
|
||||
cCELO: {
|
||||
[CHAIN_138]: '0xcce100000000000000000000000000000000008a',
|
||||
},
|
||||
cWEMIX: {
|
||||
[CHAIN_138]: '0xc11100000000000000000000000000000000008a',
|
||||
},
|
||||
cWAUSDT: {
|
||||
[56]: '0xe1a51Bc037a79AB36767561B147eb41780124934',
|
||||
[137]: '0xf12e262F85107df26741726b074606CaFa24AAe7',
|
||||
[43114]: '0xff3084410A732231472Ee9f93F5855dA89CC5254',
|
||||
[42220]: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e',
|
||||
},
|
||||
cWUSDC: {
|
||||
[1]: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||||
[56]: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
|
||||
[137]: '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4',
|
||||
[100]: '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4',
|
||||
[10]: '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105',
|
||||
[42161]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF',
|
||||
[8453]: '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105',
|
||||
[43114]: '0x0C242b513008Cd49C89078F5aFb237A3112251EB',
|
||||
[42220]: '0x4C38F9A5ed68A04cd28a72E8c68C459Ec34576f3',
|
||||
},
|
||||
cWUSDT: {
|
||||
[1]: '0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE',
|
||||
[56]: '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB',
|
||||
[137]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF',
|
||||
[100]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF',
|
||||
[10]: '0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6',
|
||||
[42161]: '0x73ADaF7dBa95221c080db5631466d2bC54f6a76B',
|
||||
[8453]: '0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6',
|
||||
[43114]: '0x8142BA530B08f3950128601F00DaaA678213DFdf',
|
||||
[42220]: '0x73376eB92c16977B126dB9112936A20Fa0De3442',
|
||||
},
|
||||
cWUSDW: {
|
||||
[56]: '0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55',
|
||||
[43114]: '0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae',
|
||||
},
|
||||
cWBTC: {
|
||||
[1]: '0xcb7c000000000000000000000000000000000001',
|
||||
[10]: '0xcb7c00000000000000000000000000000000000a',
|
||||
[25]: '0xcb7c000000000000000000000000000000000019',
|
||||
[56]: '0xcb7c000000000000000000000000000000000038',
|
||||
[100]: '0xcb7c000000000000000000000000000000000064',
|
||||
[137]: '0xcb7c000000000000000000000000000000000089',
|
||||
[1111]: '0xcb7c000000000000000000000000000000000457',
|
||||
[8453]: '0xcb7c000000000000000000000000000000002105',
|
||||
[42161]: '0xcb7c00000000000000000000000000000000a4b1',
|
||||
[42220]: '0xcb7c00000000000000000000000000000000a4ec',
|
||||
[43114]: '0xcb7c00000000000000000000000000000000a86a',
|
||||
},
|
||||
cWETH: {
|
||||
[1]: '0xce7e000000000000000000000000000000000001',
|
||||
},
|
||||
cWETHL2: {
|
||||
[10]: '0xce7200000000000000000000000000000000000a',
|
||||
[42161]: '0xce7200000000000000000000000000000000a4b1',
|
||||
[8453]: '0xce72000000000000000000000000000000002105',
|
||||
},
|
||||
cWBNB: {
|
||||
[56]: '0xcb6b000000000000000000000000000000000038',
|
||||
},
|
||||
cWPOL: {
|
||||
[137]: '0xc901000000000000000000000000000000000089',
|
||||
},
|
||||
cWAVAX: {
|
||||
[43114]: '0xcaaa00000000000000000000000000000000a86a',
|
||||
},
|
||||
cWCRO: {
|
||||
[25]: '0xcc20000000000000000000000000000000000019',
|
||||
},
|
||||
cWXDAI: {
|
||||
[100]: '0xcda1000000000000000000000000000000000064',
|
||||
},
|
||||
cWCELO: {
|
||||
[42220]: '0xcce100000000000000000000000000000000a4ec',
|
||||
},
|
||||
cWWEMIX: {
|
||||
[1111]: '0xc111000000000000000000000000000000000457',
|
||||
},
|
||||
// Compliant Fiat on Chain 138 — from DeployCompliantFiatTokens (2026-02-27)
|
||||
cEURC: { [CHAIN_138]: '0x8085961F9cF02b4d800A3c6d386D31da4B34266a' },
|
||||
cEURT: { [CHAIN_138]: '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72' },
|
||||
@@ -88,6 +253,50 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
CADW: { [CHAIN_25]: '0x328Cd365Bb35524297E68ED28c6fF2C9557d1363' },
|
||||
};
|
||||
|
||||
function normalizeAddress(address?: string | null): string {
|
||||
return typeof address === 'string' ? address.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getGruTransportCanonicalToken(symbol: string): GruTransportCanonicalToken | null {
|
||||
const loader = loadTokenMappingLoader<{
|
||||
getEnabledCanonicalToken?: (identifier: string) => GruTransportCanonicalToken | null;
|
||||
}>();
|
||||
return loader?.getEnabledCanonicalToken?.(symbol) ?? null;
|
||||
}
|
||||
|
||||
function getTransportLookup(symbol: string): { baseSymbol: string; version: 'v1' | 'v2' } | null {
|
||||
if (symbol === 'cUSDT' || symbol === 'cUSDC') {
|
||||
return { baseSymbol: symbol, version: 'v1' };
|
||||
}
|
||||
if (symbol === 'cUSDT_V2') {
|
||||
return { baseSymbol: 'cUSDT', version: 'v2' };
|
||||
}
|
||||
if (symbol === 'cUSDC_V2') {
|
||||
return { baseSymbol: 'cUSDC', version: 'v2' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTransportDeploymentAddress(symbol: string, version: 'v1' | 'v2'): string | undefined {
|
||||
const token = getGruTransportCanonicalToken(symbol);
|
||||
if (!token) return undefined;
|
||||
|
||||
const deploymentMatch = Array.isArray(token.deployments)
|
||||
? token.deployments.find((deployment) => String(deployment.version || '').trim().toLowerCase() === version)
|
||||
: null;
|
||||
if (deploymentMatch?.address) return deploymentMatch.address;
|
||||
|
||||
if (version === 'v1') {
|
||||
if (String(token.activeVersion || '').trim().toLowerCase() === 'v1' && token.activeAddress) {
|
||||
return token.activeAddress;
|
||||
}
|
||||
} else if (String(token.x402PreferredVersion || '').trim().toLowerCase() === 'v2' && token.x402PreferredAddress) {
|
||||
return token.x402PreferredAddress;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function addr(symbol: string, chainId: number): string | undefined {
|
||||
if (chainId === CHAIN_138 && symbol === 'USDT') {
|
||||
return process.env.USDT_ADDRESS_138 || process.env.OFFICIAL_USDT_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
@@ -95,9 +304,27 @@ function addr(symbol: string, chainId: number): string | undefined {
|
||||
if (chainId === CHAIN_138 && symbol === 'USDC') {
|
||||
return process.env.USDC_ADDRESS_138 || process.env.OFFICIAL_USDC_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
}
|
||||
if (chainId === CHAIN_138) {
|
||||
const transportLookup = getTransportLookup(symbol);
|
||||
if (transportLookup) {
|
||||
const envKey = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
|
||||
const envVal = process.env[envKey];
|
||||
if (envVal && envVal.trim() !== '') return envVal;
|
||||
return (
|
||||
getTransportDeploymentAddress(transportLookup.baseSymbol, transportLookup.version) ||
|
||||
FALLBACK_ADDRESSES[symbol]?.[chainId]
|
||||
);
|
||||
}
|
||||
}
|
||||
const key = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
|
||||
const envVal = process.env[key];
|
||||
if (envVal && envVal.trim() !== '') return envVal;
|
||||
const legacySuffix = LEGACY_CHAIN_ENV_SUFFIX[chainId];
|
||||
if (legacySuffix) {
|
||||
const legacyKey = `${symbol.replace(/-/g, '_').toUpperCase()}_${legacySuffix}`;
|
||||
const legacyVal = process.env[legacyKey];
|
||||
if (legacyVal && legacyVal.trim() !== '') return legacyVal;
|
||||
}
|
||||
return FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
}
|
||||
|
||||
@@ -108,8 +335,217 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
{ symbol: 'USDT', name: 'Tether USD (Official Mirror)', type: 'base', decimals: 6, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDT', CHAIN_138) || '' } },
|
||||
// Chain 138 v0 only (no X): cUSDC on 138; cXUSDC used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
{ symbol: 'cUSDC', name: 'USD Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDC', addresses: { [CHAIN_138]: addr('cUSDC', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDC', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDC', id)])) } },
|
||||
{ symbol: 'cUSDC_V2', name: 'USD Coin (Compliant V2)', type: 'base', decimals: 6, currencyCode: 'USD', familySymbol: 'cUSDC', deploymentVersion: 'v2', deploymentStatus: 'staged', preferredForX402: true, liquiditySourceSymbol: 'cUSDC', description: 'Chain 138 x402 / permit-capable V2 deployment. Liquidity and PMM routing remain on cUSDC until cutover.', addresses: { [CHAIN_138]: addr('cUSDC_V2', CHAIN_138) || '' } },
|
||||
// Chain 138 v0 only (no X): cUSDT on 138; cXUSDT used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
{ symbol: 'cUSDT', name: 'Tether USD (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDT', addresses: { [CHAIN_138]: addr('cUSDT', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDT', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDT', id)])) } },
|
||||
{ symbol: 'cUSDT_V2', name: 'Tether USD (Compliant V2)', type: 'base', decimals: 6, currencyCode: 'USD', familySymbol: 'cUSDT', deploymentVersion: 'v2', deploymentStatus: 'staged', preferredForX402: true, liquiditySourceSymbol: 'cUSDT', description: 'Chain 138 x402 / permit-capable V2 deployment. Liquidity and PMM routing remain on cUSDT until cutover.', addresses: { [CHAIN_138]: addr('cUSDT_V2', CHAIN_138) || '' } },
|
||||
{ symbol: 'cAUSDT', name: 'Alltra USD Token (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', description: 'Live Chain 138 compliant landing asset for the ALL Mainnet AUSDT corridor.', addresses: { [CHAIN_138]: addr('cAUSDT', CHAIN_138) || '' } },
|
||||
{ symbol: 'cUSDW', name: 'USD W (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', description: 'Chain 138 repo-native cUSDW hub asset for D-WIN-aligned PMM and cWUSDW transport planning.', addresses: { [CHAIN_138]: addr('cUSDW', CHAIN_138) || '' } },
|
||||
{
|
||||
symbol: 'cBTC',
|
||||
name: 'Bitcoin (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 8,
|
||||
currencyCode: 'BTC',
|
||||
registryFamily: 'monetary_unit',
|
||||
description: 'Canonical Chain 138 compliant Bitcoin monetary unit with satoshi-precision accounting and custody-backed mint controls.',
|
||||
addresses: { [CHAIN_138]: addr('cBTC', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cETH',
|
||||
name: 'Ether Mainnet (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Ethereum mainnet gas inventory. This family remains isolated from the shared ETH L2 family.',
|
||||
addresses: { [CHAIN_138]: addr('cETH', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cETHL2',
|
||||
name: 'Ether L2 Basket (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of the shared ETH L2 family across Optimism, Arbitrum, and Base.',
|
||||
addresses: { [CHAIN_138]: addr('cETHL2', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cBNB',
|
||||
name: 'BNB (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'BNB',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of BNB gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cBNB', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cPOL',
|
||||
name: 'POL (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'POL',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Polygon gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cPOL', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cAVAX',
|
||||
name: 'Avalanche (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'AVAX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Avalanche gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cAVAX', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cCRO',
|
||||
name: 'Cronos (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'CRO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Cronos gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cCRO', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cXDAI',
|
||||
name: 'xDAI (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'XDAI',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Gnosis Chain xDAI gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cXDAI', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cCELO',
|
||||
name: 'Celo (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'CELO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Celo gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cCELO', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWEMIX',
|
||||
name: 'Wemix Hub (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'WEMIX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Wemix gas inventory. The public mirror keeps the distinct cWWEMIX symbol to avoid naming collisions.',
|
||||
addresses: { [CHAIN_138]: addr('cWEMIX', CHAIN_138) || '' },
|
||||
},
|
||||
// Public-network transport mirrors for canonical Chain 138 c* assets.
|
||||
{ symbol: 'cWAUSDT', name: 'Alltra USD Token (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form for the live Chain 138 cAUSDT surface.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWAUSDT', id)])) } },
|
||||
{ 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: 'cWBTC',
|
||||
name: 'Bitcoin (Compliant Wrapped Monetary Unit)',
|
||||
type: 'w',
|
||||
decimals: 8,
|
||||
currencyCode: 'BTC',
|
||||
registryFamily: 'monetary_unit',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cBTC. Distinct from Ethereum WBTC and other third-party wrapped BTC products.',
|
||||
addresses: { ...Object.fromEntries(BTC_CW_CHAIN_IDS.map((id) => [id, addr('cWBTC', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWETH',
|
||||
name: 'Ether Mainnet (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cETH for Ethereum mainnet only.',
|
||||
addresses: { ...Object.fromEntries(ETH_MAINNET_CW_CHAIN_IDS.map((id) => [id, addr('cWETH', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWETHL2',
|
||||
name: 'Ether L2 Basket (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cETHL2 across the approved ETH L2 family.',
|
||||
addresses: { ...Object.fromEntries(ETH_L2_CW_CHAIN_IDS.map((id) => [id, addr('cWETHL2', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWBNB',
|
||||
name: 'BNB (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'BNB',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cBNB.',
|
||||
addresses: { ...Object.fromEntries(BNB_CW_CHAIN_IDS.map((id) => [id, addr('cWBNB', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWPOL',
|
||||
name: 'POL (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'POL',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cPOL.',
|
||||
addresses: { ...Object.fromEntries(POL_CW_CHAIN_IDS.map((id) => [id, addr('cWPOL', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWAVAX',
|
||||
name: 'Avalanche (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'AVAX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cAVAX.',
|
||||
addresses: { ...Object.fromEntries(AVAX_CW_CHAIN_IDS.map((id) => [id, addr('cWAVAX', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWCRO',
|
||||
name: 'Cronos (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'CRO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cCRO.',
|
||||
addresses: { ...Object.fromEntries(CRO_CW_CHAIN_IDS.map((id) => [id, addr('cWCRO', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWXDAI',
|
||||
name: 'xDAI (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'XDAI',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cXDAI.',
|
||||
addresses: { ...Object.fromEntries(XDAI_CW_CHAIN_IDS.map((id) => [id, addr('cWXDAI', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWCELO',
|
||||
name: 'Celo (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'CELO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cCELO.',
|
||||
addresses: { ...Object.fromEntries(CELO_CW_CHAIN_IDS.map((id) => [id, addr('cWCELO', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWWEMIX',
|
||||
name: 'Wemix (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'WEMIX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cWEMIX. The doubled W preserves the cW* naming discipline and keeps the canonical hub symbol distinct.',
|
||||
addresses: { ...Object.fromEntries(WEMIX_CW_CHAIN_IDS.map((id) => [id, addr('cWWEMIX', id)])) },
|
||||
},
|
||||
{ symbol: 'cEURC', name: 'Euro Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURC', CHAIN_138), [CHAIN_651940]: addr('cEURC', CHAIN_651940) } },
|
||||
{ symbol: 'cEURT', name: 'Tether EUR (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURT', CHAIN_138), [CHAIN_651940]: addr('cEURT', CHAIN_651940) } },
|
||||
{ symbol: 'cGBPC', name: 'Pound Sterling (Compliant)', type: 'base', decimals: 6, currencyCode: 'GBP', addresses: { [CHAIN_138]: addr('cGBPC', CHAIN_138), [CHAIN_651940]: addr('cGBPC', CHAIN_651940) } },
|
||||
@@ -125,7 +561,7 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).',
|
||||
addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138), [CHAIN_651940]: addr('cXAUC', CHAIN_651940) },
|
||||
addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138) },
|
||||
},
|
||||
{
|
||||
symbol: 'cXAUT',
|
||||
@@ -134,7 +570,43 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).',
|
||||
addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138), [CHAIN_651940]: addr('cXAUT', CHAIN_651940) },
|
||||
addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138) },
|
||||
},
|
||||
{
|
||||
symbol: 'cAXAUC',
|
||||
name: 'Alltra Gold Coin',
|
||||
type: 'base',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet native-unwrapped gold landing asset for the 138 -> 651940 corridor.',
|
||||
addresses: { [CHAIN_651940]: addr('cAXAUC', CHAIN_651940) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cAXAUT',
|
||||
name: 'Alltra Tether XAU',
|
||||
type: 'base',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet native-unwrapped XAU token for the 138 -> 651940 corridor.',
|
||||
addresses: { [CHAIN_651940]: addr('cAXAUT', CHAIN_651940) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWAXAUC',
|
||||
name: 'Alltra Wrapped Gold Coin',
|
||||
type: 'w',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet bridge-minted wrapped gold representation for inbound Chain 138 cXAUC transport.',
|
||||
addresses: { [CHAIN_651940]: addr('cWAXAUC', CHAIN_651940) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWAXAUT',
|
||||
name: 'Alltra Wrapped Tether XAU',
|
||||
type: 'w',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet bridge-minted wrapped XAU representation for inbound Chain 138 cXAUT transport.',
|
||||
addresses: { [CHAIN_651940]: addr('cWAXAUT', CHAIN_651940) || '' },
|
||||
},
|
||||
{ symbol: 'LiXAU', name: 'XAU Liquidity-adjusted', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) } },
|
||||
// --- ISO-4217 W ---
|
||||
@@ -183,7 +655,7 @@ export function getCanonicalTokensByChain(chainId: number): CanonicalTokenSpec[]
|
||||
}
|
||||
|
||||
export function getCanonicalTokenByAddress(chainId: number, address: string): CanonicalTokenSpec | undefined {
|
||||
const lower = address.toLowerCase();
|
||||
const lower = normalizeAddress(address);
|
||||
return CANONICAL_TOKENS.find((t) => t.addresses[chainId]?.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
@@ -194,29 +666,78 @@ export function getCanonicalTokenBySymbol(chainId: number, symbol: string): Cano
|
||||
);
|
||||
}
|
||||
|
||||
export interface CanonicalQuoteAddressResolution {
|
||||
requestedAddress: string;
|
||||
requestedSymbol?: string;
|
||||
lookupAddress: string;
|
||||
lookupSymbol?: string;
|
||||
usedFallback: boolean;
|
||||
}
|
||||
|
||||
export function resolveCanonicalQuoteAddress(chainId: number, address: string): CanonicalQuoteAddressResolution {
|
||||
const requestedAddress = normalizeAddress(address);
|
||||
const requestedSpec = getCanonicalTokenByAddress(chainId, requestedAddress);
|
||||
if (!requestedSpec) {
|
||||
return {
|
||||
requestedAddress,
|
||||
lookupAddress: requestedAddress,
|
||||
usedFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
const liquiditySourceSymbol = requestedSpec.liquiditySourceSymbol || requestedSpec.symbol;
|
||||
const liquiditySpec = getCanonicalTokenBySymbol(chainId, liquiditySourceSymbol) || requestedSpec;
|
||||
const lookupAddress = normalizeAddress(liquiditySpec.addresses[chainId] || requestedAddress);
|
||||
|
||||
return {
|
||||
requestedAddress,
|
||||
requestedSymbol: requestedSpec.symbol,
|
||||
lookupAddress,
|
||||
lookupSymbol: liquiditySpec.symbol,
|
||||
usedFallback: lookupAddress !== requestedAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/** IPFS-hosted logo URLs (Pinata) for Uniswap token list (logoURI).
|
||||
* Every token must have logoURI for MetaMask to display icons. getLogoUriForSpec resolves
|
||||
* ac-tokens from base (c*), vdc/sdc from base; unknown symbols fall back to ETH_LOGO. */
|
||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs';
|
||||
const GRU_LOGO_BASE =
|
||||
'https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru';
|
||||
const ETH_LOGO = `${IPFS_GATEWAY}/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong`;
|
||||
const USDC_LOGO = `${IPFS_GATEWAY}/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm`;
|
||||
const USDT_LOGO = `${IPFS_GATEWAY}/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP`;
|
||||
const USDC_LOGO = `${GRU_LOGO_BASE}/cUSDC.svg`;
|
||||
const USDT_LOGO = `${GRU_LOGO_BASE}/cUSDT.svg`;
|
||||
const BTC_LOGO = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png';
|
||||
|
||||
const LOGO_BY_SYMBOL: Record<string, string> = {
|
||||
USDC: USDC_LOGO,
|
||||
USDT: USDT_LOGO,
|
||||
cUSDC: USDC_LOGO,
|
||||
cUSDT: USDT_LOGO,
|
||||
cEURC: USDC_LOGO,
|
||||
cEURT: USDT_LOGO,
|
||||
cGBPC: `${IPFS_GATEWAY}/QmNQF73WjxU6FwTXNH8PXoDRFaSFKTYQWL7d4Q1kdRVJ4o`,
|
||||
cGBPT: `${IPFS_GATEWAY}/QmV4frsJmDTWzLdxdj1z81uMqVXcbGpHZLzwkpj6GvEX4k`,
|
||||
cAUDC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cJPYC: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
cCHFC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cCADC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cXAUC: ETH_LOGO,
|
||||
cXAUT: ETH_LOGO,
|
||||
cUSDC_V2: USDC_LOGO,
|
||||
cUSDT_V2: USDT_LOGO,
|
||||
cAUSDT: USDT_LOGO,
|
||||
cUSDW: USDC_LOGO,
|
||||
cBTC: BTC_LOGO,
|
||||
cWAUSDT: USDT_LOGO,
|
||||
cWBTC: BTC_LOGO,
|
||||
cWUSDC: USDC_LOGO,
|
||||
cWUSDT: USDT_LOGO,
|
||||
cWUSDW: USDC_LOGO,
|
||||
cEURC: `${GRU_LOGO_BASE}/cEURC.svg`,
|
||||
cEURT: `${GRU_LOGO_BASE}/cEURT.svg`,
|
||||
cGBPC: `${GRU_LOGO_BASE}/cGBPC.svg`,
|
||||
cGBPT: `${GRU_LOGO_BASE}/cGBPT.svg`,
|
||||
cAUDC: `${GRU_LOGO_BASE}/cAUDC.svg`,
|
||||
cJPYC: `${GRU_LOGO_BASE}/cJPYC.svg`,
|
||||
cCHFC: `${GRU_LOGO_BASE}/cCHFC.svg`,
|
||||
cCADC: `${GRU_LOGO_BASE}/cCADC.svg`,
|
||||
cXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`,
|
||||
cXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`,
|
||||
cAXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`,
|
||||
cAXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`,
|
||||
cWAXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`,
|
||||
cWAXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`,
|
||||
LiXAU: `${IPFS_GATEWAY}/QmUVY5trUM5N1UnS4abReb66fNzGw7kenjU9AjL7TgR3M1`,
|
||||
USDW: USDC_LOGO,
|
||||
EURW: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
@@ -239,3 +760,13 @@ export function getLogoUriForSpec(spec: CanonicalTokenSpec): string {
|
||||
}
|
||||
return ETH_LOGO;
|
||||
}
|
||||
|
||||
export function getTokenRegistryFamily(spec: Pick<CanonicalTokenSpec, 'currencyCode' | 'registryFamily'>): TokenRegistryFamily {
|
||||
if (spec.registryFamily) return spec.registryFamily;
|
||||
const code = String(spec.currencyCode || '').trim().toUpperCase();
|
||||
if (!code) return 'unclassified';
|
||||
if (code === 'XAU') return 'commodity';
|
||||
if (isISO4217Supported(code)) return 'iso4217';
|
||||
if (isMonetaryUnitSupported(code)) return 'monetary_unit';
|
||||
return 'unclassified';
|
||||
}
|
||||
|
||||
13
services/token-aggregation/src/config/chain138-rpc.ts
Normal file
13
services/token-aggregation/src/config/chain138-rpc.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const DEFAULT_CHAIN138_RPC_URL = 'https://rpc-http-pub.d-bis.org';
|
||||
|
||||
export function resolveChain138RpcUrl(): string {
|
||||
return String(
|
||||
process.env.CHAIN_138_RPC_URL ||
|
||||
process.env.RPC_URL_138 ||
|
||||
process.env.RPC_URL_138_PUBLIC ||
|
||||
process.env.RPC_HTTP_PUB_URL ||
|
||||
DEFAULT_CHAIN138_RPC_URL
|
||||
).trim();
|
||||
}
|
||||
|
||||
export { DEFAULT_CHAIN138_RPC_URL };
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveChain138RpcUrl } from './chain138-rpc';
|
||||
|
||||
export interface ChainConfig {
|
||||
chainId: number;
|
||||
name: string;
|
||||
@@ -16,7 +18,7 @@ export const CHAIN_CONFIGS: Record<number, ChainConfig> = {
|
||||
138: {
|
||||
chainId: 138,
|
||||
name: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: process.env.CHAIN_138_RPC_URL || process.env.RPC_URL_138_PUBLIC || process.env.RPC_URL_138 || 'https://rpc-http-pub.d-bis.org',
|
||||
rpcUrl: resolveChain138RpcUrl(),
|
||||
explorerUrl: 'https://explorer.d-bis.org',
|
||||
nativeCurrency: {
|
||||
name: 'Ether',
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Used by cross-chain-indexer for CCIP/Alltra/UniversalCCIP event aggregation.
|
||||
*/
|
||||
|
||||
import { loadTokenMappingLoader } from './repo-config-loader';
|
||||
|
||||
export interface BridgeLane {
|
||||
destSelector: string;
|
||||
destChainId: number;
|
||||
@@ -114,6 +116,45 @@ const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0x971cD9D156f193
|
||||
const CCIP_STABLE_138 = envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138');
|
||||
const UNIVERSAL_CCIP_138 = envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE');
|
||||
|
||||
interface RepoConfigLoader {
|
||||
getRoutingRegistryRoutes?: () => RoutingRegistryEntry[];
|
||||
getActiveTransportPair?: (
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
criteria?: Record<string, unknown>
|
||||
) => (RoutingRegistryEntry & {
|
||||
canonicalSymbol?: string;
|
||||
peer?: {
|
||||
l1Bridge?: { address?: string; env?: string };
|
||||
l2Bridge?: { address?: string; env?: string };
|
||||
};
|
||||
eligible?: boolean;
|
||||
}) | null;
|
||||
resolveConfigRef?: (ref?: { address?: string; env?: string }) => string;
|
||||
}
|
||||
|
||||
function loadRepoConfigLoader(): RepoConfigLoader | null {
|
||||
return loadTokenMappingLoader<RepoConfigLoader>();
|
||||
}
|
||||
|
||||
function normalizeTransportAsset(asset: string): string {
|
||||
const normalized = asset.trim().toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (normalized.startsWith('cw')) {
|
||||
return `c${normalized.slice(2)}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePeerBridgeAddress(
|
||||
loader: RepoConfigLoader | null,
|
||||
pair: NonNullable<ReturnType<NonNullable<RepoConfigLoader['getActiveTransportPair']>>>,
|
||||
sourceChainId: number
|
||||
): string {
|
||||
const ref = sourceChainId === chainId138 ? pair.peer?.l1Bridge : pair.peer?.l2Bridge;
|
||||
const resolved = loader?.resolveConfigRef?.(ref);
|
||||
return resolved || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routing registry entry for (fromChain, toChain, asset).
|
||||
* Used by UI and indexer to choose ALT vs CCIP and to fill routing in activity_events.
|
||||
@@ -125,6 +166,51 @@ export function getRouteFromRegistry(
|
||||
asset: string = 'WETH',
|
||||
): RoutingRegistryEntry | null {
|
||||
if (fromChain === toChain) return null;
|
||||
const loader = loadRepoConfigLoader();
|
||||
const normalizedAsset = normalizeTransportAsset(asset);
|
||||
|
||||
const activeTransportPair = loader?.getActiveTransportPair?.(fromChain, toChain, { symbol: normalizedAsset });
|
||||
if (activeTransportPair) {
|
||||
if (activeTransportPair.eligible) {
|
||||
const bridgeAddress = resolvePeerBridgeAddress(loader, activeTransportPair, fromChain);
|
||||
if (bridgeAddress) {
|
||||
return {
|
||||
pathType: 'CCIP',
|
||||
bridgeAddress,
|
||||
bridgeChainId: fromChain === chainId138 ? chainId138 : fromChain,
|
||||
label: 'GRUTransport',
|
||||
fromChain,
|
||||
toChain,
|
||||
asset: activeTransportPair.canonicalSymbol || asset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Active GRU transport assets must not silently escape into legacy bridge paths.
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryRoutes = loader?.getRoutingRegistryRoutes?.() || [];
|
||||
const routeMatch =
|
||||
registryRoutes.find(
|
||||
(route) =>
|
||||
route.fromChain === fromChain &&
|
||||
route.toChain === toChain &&
|
||||
typeof route.asset === 'string' &&
|
||||
route.asset.trim().toLowerCase() === asset.trim().toLowerCase()
|
||||
) ||
|
||||
registryRoutes.find(
|
||||
(route) =>
|
||||
route.fromChain === fromChain &&
|
||||
route.toChain === toChain &&
|
||||
typeof route.asset === 'string' &&
|
||||
route.asset.trim().toLowerCase() === normalizedAsset
|
||||
);
|
||||
|
||||
if (routeMatch) {
|
||||
return routeMatch;
|
||||
}
|
||||
|
||||
const is138To651940 = fromChain === 138 && toChain === 651940;
|
||||
const is651940To138 = fromChain === 651940 && toChain === 138;
|
||||
if (is138To651940 || is651940To138) {
|
||||
@@ -139,8 +225,8 @@ export function getRouteFromRegistry(
|
||||
};
|
||||
}
|
||||
if (fromChain === 138 || toChain === 138) {
|
||||
const normalizedAsset = asset.trim().toUpperCase();
|
||||
const isStableAsset = STABLE_ASSET_SYMBOLS.has(normalizedAsset);
|
||||
const legacyNormalizedAsset = asset.trim().toUpperCase();
|
||||
const isStableAsset = STABLE_ASSET_SYMBOLS.has(legacyNormalizedAsset);
|
||||
|
||||
if (isStableAsset) {
|
||||
if (CCIP_STABLE_138) {
|
||||
@@ -170,7 +256,7 @@ export function getRouteFromRegistry(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) {
|
||||
if (legacyNormalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) {
|
||||
return {
|
||||
pathType: 'CCIP',
|
||||
bridgeAddress: UNIVERSAL_CCIP_138,
|
||||
|
||||
201
services/token-aggregation/src/config/deployment-status.ts
Normal file
201
services/token-aggregation/src/config/deployment-status.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface DeploymentStatusFile {
|
||||
version?: string;
|
||||
updated?: string;
|
||||
chains?: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
cwTokens?: Record<string, string>;
|
||||
gasMirrors?: Record<string, string>;
|
||||
gasQuoteAddresses?: Record<string, string>;
|
||||
gasPmmPools?: Array<Record<string, unknown>>;
|
||||
gasReferenceVenues?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LoadedDeploymentStatus {
|
||||
data: DeploymentStatusFile;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
export interface CwRegistryChain {
|
||||
chainId: number;
|
||||
chainIdText: string;
|
||||
name: string;
|
||||
tokens: Array<{
|
||||
symbol: string;
|
||||
address: string;
|
||||
assetClass?: string;
|
||||
familyKey?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GasRegistryChain {
|
||||
chainId: number;
|
||||
chainIdText: string;
|
||||
name: string;
|
||||
families: Array<{
|
||||
familyKey: string;
|
||||
mirroredSymbol: string;
|
||||
mirrorAddress?: string;
|
||||
dodoPmm: Array<Record<string, unknown>>;
|
||||
referenceVenues: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDeploymentStatusCandidates(): string[] {
|
||||
return uniquePaths([
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH,
|
||||
process.env.CW_REGISTRY_JSON_PATH,
|
||||
process.env.CROSS_CHAIN_PMM_DEPLOYMENT_STATUS_PATH,
|
||||
path.resolve(process.cwd(), 'cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
path.resolve(process.cwd(), '..', 'cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
path.resolve(process.cwd(), '..', '..', 'cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
path.resolve(__dirname, '../../../../../cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveDeploymentStatusPath(): string | null {
|
||||
for (const candidate of buildDeploymentStatusCandidates()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadDeploymentStatusFile(): LoadedDeploymentStatus | null {
|
||||
const filePath = resolveDeploymentStatusPath();
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
data: JSON.parse(raw) as DeploymentStatusFile,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCwRegistryChains(data: DeploymentStatusFile): CwRegistryChain[] {
|
||||
const chains = data.chains ?? {};
|
||||
const rows: CwRegistryChain[] = [];
|
||||
|
||||
for (const [chainIdText, chain] of Object.entries(chains)) {
|
||||
const gasFamilyByMirror = new Map<string, string>();
|
||||
for (const pool of chain.gasPmmPools ?? []) {
|
||||
const familyKey = typeof pool.familyKey === 'string' ? pool.familyKey : '';
|
||||
const base = typeof pool.base === 'string' ? pool.base : '';
|
||||
if (familyKey && base) {
|
||||
gasFamilyByMirror.set(base, familyKey);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
...Object.entries(chain.cwTokens ?? {})
|
||||
.filter(([, address]) => typeof address === 'string' && address.trim() !== '')
|
||||
.map(([symbol, address]) => ({ symbol, address })),
|
||||
...Object.entries(chain.gasMirrors ?? {})
|
||||
.filter(([, address]) => typeof address === 'string' && address.trim() !== '')
|
||||
.map(([symbol, address]) => ({
|
||||
symbol,
|
||||
address,
|
||||
assetClass: 'gas_native',
|
||||
familyKey: gasFamilyByMirror.get(symbol),
|
||||
})),
|
||||
];
|
||||
|
||||
if (tokens.length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
chainId: Number(chainIdText),
|
||||
chainIdText,
|
||||
name: chain.name || `Chain ${chainIdText}`,
|
||||
tokens: tokens.sort((a, b) => a.symbol.localeCompare(b.symbol)),
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
|
||||
export function buildGasRegistryChains(data: DeploymentStatusFile): GasRegistryChain[] {
|
||||
const rows: GasRegistryChain[] = [];
|
||||
|
||||
for (const [chainIdText, chain] of Object.entries(data.chains ?? {})) {
|
||||
const familyMap = new Map<
|
||||
string,
|
||||
{
|
||||
familyKey: string;
|
||||
mirroredSymbol: string;
|
||||
mirrorAddress?: string;
|
||||
dodoPmm: Array<Record<string, unknown>>;
|
||||
referenceVenues: Array<Record<string, unknown>>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const pool of chain.gasPmmPools ?? []) {
|
||||
const familyKey = typeof pool.familyKey === 'string' ? pool.familyKey : '';
|
||||
const mirroredSymbol = typeof pool.base === 'string' ? pool.base : '';
|
||||
if (!familyKey || !mirroredSymbol) continue;
|
||||
const existing = familyMap.get(familyKey) ?? {
|
||||
familyKey,
|
||||
mirroredSymbol,
|
||||
mirrorAddress: chain.gasMirrors?.[mirroredSymbol],
|
||||
dodoPmm: [],
|
||||
referenceVenues: [],
|
||||
};
|
||||
existing.dodoPmm.push(pool);
|
||||
familyMap.set(familyKey, existing);
|
||||
}
|
||||
|
||||
for (const venue of chain.gasReferenceVenues ?? []) {
|
||||
const familyKey = typeof venue.familyKey === 'string' ? venue.familyKey : '';
|
||||
if (!familyKey) continue;
|
||||
const existing = familyMap.get(familyKey) ?? {
|
||||
familyKey,
|
||||
mirroredSymbol: typeof venue.base === 'string' ? venue.base : '',
|
||||
mirrorAddress: typeof venue.base === 'string' ? chain.gasMirrors?.[venue.base] : undefined,
|
||||
dodoPmm: [],
|
||||
referenceVenues: [],
|
||||
};
|
||||
existing.referenceVenues.push(venue);
|
||||
familyMap.set(familyKey, existing);
|
||||
}
|
||||
|
||||
const families = Array.from(familyMap.values()).sort((a, b) => a.familyKey.localeCompare(b.familyKey));
|
||||
if (families.length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
chainId: Number(chainIdText),
|
||||
chainIdText,
|
||||
name: chain.name || `Chain ${chainIdText}`,
|
||||
families,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
@@ -34,13 +34,18 @@ export interface DexFactoryConfig {
|
||||
custom?: CustomDexConfig[];
|
||||
}
|
||||
|
||||
/** Canonical DODOPMMIntegration on Chain 138 — see docs/11-references/CONTRACT_ADDRESSES_REFERENCE.md */
|
||||
const CANONICAL_CHAIN138_DODO_PMM_INTEGRATION =
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
138: {
|
||||
// DODO PMM Integration - index from DODOPMMIntegration or PoolManager
|
||||
dodo: [
|
||||
{
|
||||
poolManager: process.env.CHAIN_138_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_138_DODO_PMM_INTEGRATION || '',
|
||||
dodoPmmIntegration:
|
||||
process.env.CHAIN_138_DODO_PMM_INTEGRATION || CANONICAL_CHAIN138_DODO_PMM_INTEGRATION,
|
||||
dodoVendingMachine: process.env.CHAIN_138_DODO_VENDING_MACHINE || '',
|
||||
startBlock: 0,
|
||||
},
|
||||
|
||||
435
services/token-aggregation/src/config/gru-transport.test.ts
Normal file
435
services/token-aggregation/src/config/gru-transport.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { resolveTokenMappingLoaderPath } from './repo-config-loader';
|
||||
import { getRouteFromRegistry } from './cross-chain-bridges';
|
||||
import {
|
||||
filterPoolsForExposure,
|
||||
filterPoolsForRouting,
|
||||
getActiveTransportPairs,
|
||||
getGasAssetFamilies,
|
||||
getGasProtocolExposure,
|
||||
getGasRedeemGroups,
|
||||
getGruTransportMetadata,
|
||||
isGasRedemptionPathAllowed,
|
||||
isPublicPoolActive,
|
||||
isPublicPoolRoutable,
|
||||
} from './gru-transport';
|
||||
|
||||
describe('GRU Transport overlay', () => {
|
||||
const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE;
|
||||
const originalBscBridge = process.env.CW_BRIDGE_BSC;
|
||||
const originalStableBridge = process.env.CCIP_STABLE_BRIDGE_CHAIN138;
|
||||
const originalStablecoinBridge = process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138;
|
||||
const originalUniversalBridge = process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS;
|
||||
const originalUniversalBridgeAlt = process.env.UNIVERSAL_CCIP_BRIDGE;
|
||||
const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
const originalReserveSystem = process.env.CW_RESERVE_SYSTEM;
|
||||
const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
const originalGasStrictVerifier = process.env.CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138;
|
||||
const originalGasHybridVerifier = process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138;
|
||||
const originalGasEscrowVault = process.env.CW_GAS_ESCROW_VAULT_CHAIN138;
|
||||
const originalGasTreasurySystem = process.env.CW_GAS_TREASURY_SYSTEM;
|
||||
const originalMainnetBridge = process.env.CW_BRIDGE_MAINNET;
|
||||
const originalOptimismBridge = process.env.CW_BRIDGE_OPTIMISM;
|
||||
const originalEthMainnetOutstanding = process.env.CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET;
|
||||
const originalEthMainnetSupply = process.env.CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET;
|
||||
const originalEthMainnetEscrowed = process.env.CW_GAS_ESCROWED_ETH_MAINNET_MAINNET;
|
||||
const originalEthMainnetTreasury = process.env.CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET;
|
||||
const originalEthL2Outstanding = process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Supply = process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Escrowed = process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Treasury = process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Cap = process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM;
|
||||
const originalTokenMappingLoaderPath = process.env.TOKEN_MAPPING_LOADER_PATH;
|
||||
const originalCwL1Bridge = process.env.CW_L1_BRIDGE;
|
||||
const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalChain138Bridge === undefined) {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge;
|
||||
}
|
||||
|
||||
if (originalCwL1Bridge === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE = originalCwL1Bridge;
|
||||
}
|
||||
if (originalCwL1BridgeChain138 === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138;
|
||||
}
|
||||
|
||||
if (originalBscBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_BSC = originalBscBridge;
|
||||
}
|
||||
|
||||
if (originalStableBridge === undefined) {
|
||||
delete process.env.CCIP_STABLE_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CCIP_STABLE_BRIDGE_CHAIN138 = originalStableBridge;
|
||||
}
|
||||
|
||||
if (originalStablecoinBridge === undefined) {
|
||||
delete process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138 = originalStablecoinBridge;
|
||||
}
|
||||
|
||||
if (originalUniversalBridge === undefined) {
|
||||
delete process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS;
|
||||
} else {
|
||||
process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS = originalUniversalBridge;
|
||||
}
|
||||
|
||||
if (originalUniversalBridgeAlt === undefined) {
|
||||
delete process.env.UNIVERSAL_CCIP_BRIDGE;
|
||||
} else {
|
||||
process.env.UNIVERSAL_CCIP_BRIDGE = originalUniversalBridgeAlt;
|
||||
}
|
||||
|
||||
if (originalReserveVerifier === undefined) {
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier;
|
||||
}
|
||||
|
||||
if (originalReserveVault === undefined) {
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
} else {
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault;
|
||||
}
|
||||
|
||||
if (originalReserveSystem === undefined) {
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
} else {
|
||||
process.env.CW_RESERVE_SYSTEM = originalReserveSystem;
|
||||
}
|
||||
|
||||
if (originalMaxOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({
|
||||
CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138: originalGasStrictVerifier,
|
||||
CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138: originalGasHybridVerifier,
|
||||
CW_GAS_ESCROW_VAULT_CHAIN138: originalGasEscrowVault,
|
||||
CW_GAS_TREASURY_SYSTEM: originalGasTreasurySystem,
|
||||
CW_BRIDGE_MAINNET: originalMainnetBridge,
|
||||
CW_BRIDGE_OPTIMISM: originalOptimismBridge,
|
||||
CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET: originalEthMainnetOutstanding,
|
||||
CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET: originalEthMainnetSupply,
|
||||
CW_GAS_ESCROWED_ETH_MAINNET_MAINNET: originalEthMainnetEscrowed,
|
||||
CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET: originalEthMainnetTreasury,
|
||||
CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Outstanding,
|
||||
CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Supply,
|
||||
CW_GAS_ESCROWED_ETH_L2_OPTIMISM: originalEthL2Escrowed,
|
||||
CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM: originalEthL2Treasury,
|
||||
CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM: originalEthL2Cap,
|
||||
})) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (originalTokenMappingLoaderPath === undefined) {
|
||||
delete process.env.TOKEN_MAPPING_LOADER_PATH;
|
||||
} else {
|
||||
process.env.TOKEN_MAPPING_LOADER_PATH = originalTokenMappingLoaderPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads GRU Monetary Transport Layer metadata and active transport pairs', () => {
|
||||
const metadata = getGruTransportMetadata();
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata?.system?.name).toBe('GRU Monetary Transport Layer');
|
||||
expect(metadata?.system?.shortName).toBe('GRU Transport');
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
expect(pairs.length).toBe(44);
|
||||
expect(pairs.every((pair) => pair.eligible)).toBe(true);
|
||||
expect(pairs.every((pair) => typeof pair.runtimeReady === 'boolean')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-25-cUSDT-cWUSDT')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-1-cBTC-cWBTC')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-10-cETHL2-cWETHL2')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-1111-cWEMIX-cWWEMIX')).toBe(false);
|
||||
const bscUsdt = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT');
|
||||
const mainnetEth = pairs.find((pair) => pair.key === '138-1-cETH-cWETH');
|
||||
expect(bscUsdt?.bridgeCanonicalAssetVersion).toBe('v1');
|
||||
expect(bscUsdt?.bridgeMirroredAssetVersion).toBe('v1');
|
||||
expect(bscUsdt?.destinationChainName).toBe('BSC');
|
||||
expect(bscUsdt?.destinationChainSelector).toBe('11344663589394136015');
|
||||
expect(mainnetEth?.destinationChainSelector).toBe('5009297550715157269');
|
||||
expect(metadata?.counts.enabledDestinationChains).toBe(10);
|
||||
expect(metadata?.counts.configuredTransportPairs).toBe(45);
|
||||
expect(metadata?.counts.deferredTransportPairs).toBe(1);
|
||||
expect(metadata?.counts.gasAssetFamilies).toBe(9);
|
||||
expect(metadata?.counts.gasTransportPairs).toBe(10);
|
||||
});
|
||||
|
||||
it('publishes gas-family metadata and enforces the ETH split via redeem groups', () => {
|
||||
const gasFamilies = getGasAssetFamilies();
|
||||
const redeemGroups = getGasRedeemGroups();
|
||||
const protocolExposure = getGasProtocolExposure();
|
||||
|
||||
expect(gasFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_mainnet',
|
||||
backingMode: 'strict_escrow',
|
||||
mirroredSymbol: 'cWETH',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
mirroredSymbol: 'cWETHL2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(redeemGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
allowedChains: [10, 42161, 8453],
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(protocolExposure).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '10-eth_l2',
|
||||
chainId: 10,
|
||||
familyKey: 'eth_l2',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: '1111-wemix',
|
||||
active: false,
|
||||
status: 'deferred',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(gasFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'wemix',
|
||||
active: false,
|
||||
status: 'deferred',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(isGasRedemptionPathAllowed(10, 42161, 'eth_l2')).toBe(true);
|
||||
expect(isGasRedemptionPathAllowed(1, 10, 'eth_mainnet')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Chain 138 pools visible but hides inactive public cW pools', () => {
|
||||
const pools = [
|
||||
{
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
token0Address: '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB',
|
||||
token1Address: '0x55d398326f99059fF775485246999027B3197955',
|
||||
},
|
||||
{
|
||||
poolAddress: '0x2222222222222222222222222222222222222222',
|
||||
token0Address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
token1Address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterPoolsForExposure(56, [pools[0]])).toEqual([]);
|
||||
expect(filterPoolsForRouting(56, [pools[0]])).toEqual([]);
|
||||
expect(filterPoolsForExposure(138, [pools[1]])).toEqual([pools[1]]);
|
||||
expect(filterPoolsForRouting(138, [pools[1]])).toEqual([pools[1]]);
|
||||
});
|
||||
|
||||
it('marks public cW pools inactive and non-routable until explicitly enabled', () => {
|
||||
expect(isPublicPoolActive(56, '0x1111111111111111111111111111111111111111')).toBe(false);
|
||||
expect(isPublicPoolRoutable(56, '0x1111111111111111111111111111111111111111')).toBe(false);
|
||||
});
|
||||
|
||||
it('routes active c* transport through GRU Transport while keeping WETH on legacy lanes', () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const gruRoute = getRouteFromRegistry(138, 56, 'cUSDT');
|
||||
expect(gruRoute).not.toBeNull();
|
||||
expect(gruRoute?.label).toBe('GRUTransport');
|
||||
expect(gruRoute?.bridgeAddress).toBe('0x3333333333333333333333333333333333333333');
|
||||
|
||||
const wethRoute = getRouteFromRegistry(138, 56, 'WETH');
|
||||
expect(wethRoute).not.toBeNull();
|
||||
expect(wethRoute?.label).toBe('CCIPWETH9Bridge');
|
||||
});
|
||||
|
||||
it('reports runtime-ready transport pairs when bridge and reserve refs resolve', () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
const bscUsdtPair = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT');
|
||||
expect(bscUsdtPair?.runtimeBridgeReady).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeReserveVerifierReady).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeMaxOutstandingReady).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeReady).toBe(true);
|
||||
|
||||
const metadata = getGruTransportMetadata();
|
||||
expect(metadata?.counts.runtimeReadyTransportPairs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reports missing runtime requirements when bridge and reserve refs are absent', () => {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
const bscUsdtPair = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT');
|
||||
expect(bscUsdtPair?.eligible).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeReady).toBe(false);
|
||||
expect(bscUsdtPair?.runtimeMissingRequirements).toEqual(
|
||||
expect.arrayContaining([
|
||||
'bridge:l1Bridge',
|
||||
'bridge:l2Bridge',
|
||||
'policy:maxOutstanding',
|
||||
'reserveVerifier:bridgeRef',
|
||||
'reserveVerifier:vaultRef',
|
||||
'reserveVerifier:reserveSystemRef',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates gas-lane supply accounting separately for strict and hybrid backing', () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_MAINNET = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_GAS_ESCROW_VAULT_CHAIN138 = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET = '100';
|
||||
process.env.CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET = '100';
|
||||
process.env.CW_GAS_ESCROWED_ETH_MAINNET_MAINNET = '100';
|
||||
process.env.CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET = '0';
|
||||
|
||||
process.env.CW_BRIDGE_OPTIMISM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138 = '0x8888888888888888888888888888888888888888';
|
||||
process.env.CW_GAS_TREASURY_SYSTEM = '0x9999999999999999999999999999999999999999';
|
||||
process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM = '100';
|
||||
process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM = '25';
|
||||
process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM = '25';
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
const strictPair = pairs.find((pair) => pair.key === '138-1-cETH-cWETH');
|
||||
const hybridPair = pairs.find((pair) => pair.key === '138-10-cETHL2-cWETHL2');
|
||||
|
||||
expect(strictPair?.runtimeSupplyAccountingReady).toBe(true);
|
||||
expect(strictPair?.supplyInvariantSatisfied).toBe(true);
|
||||
expect(strictPair?.runtimeReady).toBe(true);
|
||||
|
||||
expect(hybridPair?.runtimeSupplyAccountingReady).toBe(true);
|
||||
expect(hybridPair?.supplyInvariantSatisfied).toBe(true);
|
||||
expect(hybridPair?.runtimeReady).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the active GRU mirrored address even when raw pair ordering is ambiguous', () => {
|
||||
const loader = require(path.join(process.cwd(), '../../..', 'config', 'token-mapping-loader.cjs')) as {
|
||||
getMappedAddress: (fromChainId: number, toChainId: number, tokenAddressOnSource: string, jsonPath?: string) => string | undefined;
|
||||
};
|
||||
const fixturePath = path.join(os.tmpdir(), `gru-transport-mapping-${Date.now()}.json`);
|
||||
const canonicalAddress = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
|
||||
const mirroredAddress = '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB';
|
||||
const nativeAddress = '0x55d398326f99059fF775485246999027B3197955';
|
||||
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pairs: [
|
||||
{
|
||||
fromChainId: 138,
|
||||
toChainId: 56,
|
||||
tokens: [
|
||||
{
|
||||
key: 'Compliant_USDT_cW',
|
||||
name: 'cUSDT->cWUSDT',
|
||||
addressFrom: canonicalAddress,
|
||||
addressTo: mirroredAddress,
|
||||
},
|
||||
{
|
||||
key: 'Compliant_USDT',
|
||||
name: 'cUSDT',
|
||||
addressFrom: canonicalAddress,
|
||||
addressTo: nativeAddress,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
expect(loader.getMappedAddress(138, 56, canonicalAddress, fixturePath)).toBe(mirroredAddress);
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fall back to legacy stable bridges for active GRU assets when peer bridges are missing', () => {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
process.env.CCIP_STABLE_BRIDGE_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS = '0x6666666666666666666666666666666666666666';
|
||||
|
||||
jest.resetModules();
|
||||
const { getRouteFromRegistry: getFreshRouteFromRegistry } = require('./cross-chain-bridges') as typeof import('./cross-chain-bridges');
|
||||
|
||||
expect(getFreshRouteFromRegistry(138, 56, 'cUSDT')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the token mapping loader from an explicit deployed-layout env override', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'token-mapping-loader-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const loaderPath = path.join(configDir, 'token-mapping-loader.cjs');
|
||||
|
||||
fs.writeFileSync(
|
||||
loaderPath,
|
||||
'module.exports = { getGruTransportMetadata: () => ({ system: { shortName: "Test" }, counts: { transportPairs: 0 } }), getActiveTransportPairs: () => [] };'
|
||||
);
|
||||
|
||||
process.env.TOKEN_MAPPING_LOADER_PATH = loaderPath;
|
||||
|
||||
try {
|
||||
expect(resolveTokenMappingLoaderPath()).toBe(loaderPath);
|
||||
expect(getGruTransportMetadata()?.system?.shortName).toBe('Test');
|
||||
expect(getActiveTransportPairs()).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
338
services/token-aggregation/src/config/gru-transport.ts
Normal file
338
services/token-aggregation/src/config/gru-transport.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { loadTokenMappingLoader } from './repo-config-loader';
|
||||
|
||||
export interface ConfigRef {
|
||||
address?: string | null;
|
||||
env?: string | null;
|
||||
}
|
||||
|
||||
export interface GruTransportSystemMetadata {
|
||||
name: string;
|
||||
shortName: string;
|
||||
canonicalChainId: number;
|
||||
canonicalChainName?: string;
|
||||
transportClass?: string;
|
||||
publicPoolModel?: string;
|
||||
hardPegTruth?: string;
|
||||
wethTransportSeparated?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface GruTransportMetadata {
|
||||
system: GruTransportSystemMetadata | null;
|
||||
terminology: Record<string, string>;
|
||||
enabledCanonicalTokens: Array<Record<string, unknown>>;
|
||||
enabledDestinationChains: Array<Record<string, unknown>>;
|
||||
gasAssetFamilies?: GruTransportGasAssetFamily[];
|
||||
gasRedeemGroups?: GruTransportGasRedeemGroup[];
|
||||
gasProtocolExposure?: GruTransportGasProtocolExposure[];
|
||||
counts: {
|
||||
enabledCanonicalTokens: number;
|
||||
enabledDestinationChains: number;
|
||||
approvedBridgePeers: number;
|
||||
transportPairs: number;
|
||||
configuredTransportPairs?: number;
|
||||
deferredTransportPairs?: number;
|
||||
gasAssetFamilies?: number;
|
||||
gasRedeemGroups?: number;
|
||||
gasProtocolExposure?: number;
|
||||
gasTransportPairs?: number;
|
||||
strictEscrowTransportPairs?: number;
|
||||
hybridCapTransportPairs?: number;
|
||||
eligibleTransportPairs?: number;
|
||||
runtimeReadyTransportPairs?: number;
|
||||
publicPools: number;
|
||||
activePublicPools?: number;
|
||||
routablePublicPools?: number;
|
||||
mcpVisiblePublicPools?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GruTransportGasAssetFamily {
|
||||
familyKey: string;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
canonicalSymbol138: string;
|
||||
mirroredSymbol: string;
|
||||
assetClass: string;
|
||||
originChains: number[];
|
||||
laneGroup: string;
|
||||
backingMode: string;
|
||||
redeemPolicy: string;
|
||||
wrappedNativeQuoteSymbol: string;
|
||||
stableQuoteSymbol: string;
|
||||
referenceVenue: string;
|
||||
perLaneCaps?: Record<string, string>;
|
||||
displayAliases?: Record<string, string>;
|
||||
hubRebalance?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GruTransportGasRedeemGroup {
|
||||
key: string;
|
||||
familyKey: string;
|
||||
allowedChains: number[];
|
||||
redeemPolicy: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GruTransportGasProtocolExposure {
|
||||
key: string;
|
||||
chainId: number;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
familyKey: string;
|
||||
mirroredSymbol: string;
|
||||
backingMode: string;
|
||||
dodoPmm?: Record<string, unknown>;
|
||||
uniswapV3?: Record<string, unknown>;
|
||||
balancer?: Record<string, unknown>;
|
||||
curve?: Record<string, unknown>;
|
||||
oneInch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GruTransportBridgePeer {
|
||||
key: string;
|
||||
chainId: number;
|
||||
chainName: string;
|
||||
ccipChainSelector?: string;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
bridgeKind: string;
|
||||
l1Bridge?: ConfigRef;
|
||||
l2Bridge?: ConfigRef;
|
||||
freezeTokenPairRequired?: boolean;
|
||||
freezeDestinationRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface GruTransportPair {
|
||||
key: string;
|
||||
canonicalChainId: number;
|
||||
destinationChainId: number;
|
||||
destinationChainName?: string | null;
|
||||
destinationChainSelector?: string | null;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
canonicalSymbol: string;
|
||||
mirroredSymbol: string;
|
||||
/** From gru-transport-active enabledCanonicalTokens[].bridge.canonicalAssetVersion */
|
||||
bridgeCanonicalAssetVersion?: string;
|
||||
/** From gru-transport-active enabledCanonicalTokens[].bridge.mirroredAssetVersion */
|
||||
bridgeMirroredAssetVersion?: string;
|
||||
mappingKey: string;
|
||||
peerKey: string;
|
||||
phase?: string;
|
||||
routeDiscoveryEnabled?: boolean;
|
||||
mcpVisible?: boolean;
|
||||
reserveVerifierKey?: string;
|
||||
maxOutstanding?: {
|
||||
required?: boolean;
|
||||
amount?: string;
|
||||
env?: string;
|
||||
};
|
||||
publicPoolKeys?: string[];
|
||||
assetClass?: string;
|
||||
familyKey?: string;
|
||||
laneGroup?: string;
|
||||
backingMode?: string;
|
||||
redeemPolicy?: string;
|
||||
wrappedNativeQuoteSymbol?: string;
|
||||
stableQuoteSymbol?: string;
|
||||
referenceVenue?: string;
|
||||
protocolExposureKey?: string;
|
||||
supplyAccounting?: Record<string, unknown>;
|
||||
canonicalAddress?: string | null;
|
||||
mirroredAddress?: string | null;
|
||||
mirrorDeploymentAddress?: string | null;
|
||||
peer?: GruTransportBridgePeer | null;
|
||||
mappingFound?: boolean;
|
||||
mirrorDeployed?: boolean;
|
||||
canonicalEnabled?: boolean;
|
||||
destinationEnabled?: boolean;
|
||||
bridgeAvailable?: boolean | null;
|
||||
bridgePeerConfigured?: boolean;
|
||||
maxOutstandingConfigured?: boolean;
|
||||
reserveVerifierConfigured?: boolean;
|
||||
runtimeL1BridgeAddress?: string | null;
|
||||
runtimeL2BridgeAddress?: string | null;
|
||||
runtimeBridgeReady?: boolean;
|
||||
runtimeMaxOutstandingValue?: string | null;
|
||||
runtimeMaxOutstandingReady?: boolean;
|
||||
runtimeReserveVerifierBridgeAddress?: string | null;
|
||||
runtimeReserveVerifierAddress?: string | null;
|
||||
runtimeReserveVaultAddress?: string | null;
|
||||
runtimeReserveSystemAddress?: string | null;
|
||||
runtimeReserveVerifierReady?: boolean;
|
||||
runtimeOutstandingValue?: string | null;
|
||||
runtimeEscrowedValue?: string | null;
|
||||
runtimeTreasuryBackedValue?: string | null;
|
||||
runtimeTreasuryCapValue?: string | null;
|
||||
runtimeSupplyAccountingReady?: boolean | null;
|
||||
supplyInvariantSatisfied?: boolean | null;
|
||||
protocolExposure?: GruTransportGasProtocolExposure | null;
|
||||
runtimeMissingRequirements?: string[];
|
||||
eligibilityBlockers?: string[];
|
||||
runtimeReady?: boolean;
|
||||
eligible?: boolean;
|
||||
}
|
||||
|
||||
export interface GruTransportPublicPool {
|
||||
key: string;
|
||||
chainId: number;
|
||||
baseSymbol: string;
|
||||
quoteSymbol: string;
|
||||
poolAddress?: string | null;
|
||||
active?: boolean;
|
||||
routingEnabled?: boolean;
|
||||
mcpVisible?: boolean;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
export interface PublicPoolLike {
|
||||
poolAddress: string;
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
}
|
||||
|
||||
interface GruTransportLoader {
|
||||
getGruTransportMetadata?: () => GruTransportMetadata | null;
|
||||
getGasAssetFamilies?: () => GruTransportGasAssetFamily[];
|
||||
getGasRedeemGroups?: () => GruTransportGasRedeemGroup[];
|
||||
getGasProtocolExposure?: () => GruTransportGasProtocolExposure[];
|
||||
isGasRedemptionPathAllowed?: (fromChainId: number, toChainId: number, identifier: string) => boolean;
|
||||
getActiveTransportPairs?: () => GruTransportPair[];
|
||||
getActiveTransportPair?: (
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
criteria?: Record<string, unknown>
|
||||
) => GruTransportPair | null;
|
||||
getApprovedBridgePeer?: (chainId: number) => GruTransportBridgePeer | null;
|
||||
getActivePublicPools?: () => GruTransportPublicPool[];
|
||||
isPublicPoolActive?: (chainId: number, poolAddress: string) => boolean;
|
||||
isPublicPoolRoutable?: (chainId: number, poolAddress: string) => boolean;
|
||||
isPublicPoolMcpVisible?: (chainId: number, poolAddress: string) => boolean;
|
||||
shouldExposePublicPool?: (
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
) => boolean;
|
||||
shouldUsePublicPoolForRouting?: (
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
) => boolean;
|
||||
resolveConfigRef?: (ref: ConfigRef | undefined) => string;
|
||||
}
|
||||
|
||||
function loadGruTransportLoader(): GruTransportLoader | null {
|
||||
const loader = loadTokenMappingLoader<GruTransportLoader>();
|
||||
if (loader?.getGruTransportMetadata && loader?.getActiveTransportPairs) {
|
||||
return loader;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGruTransportMetadata(): GruTransportMetadata | null {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGruTransportMetadata?.() ?? null;
|
||||
}
|
||||
|
||||
export function getGasAssetFamilies(): GruTransportGasAssetFamily[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGasAssetFamilies?.() ?? getGruTransportMetadata()?.gasAssetFamilies ?? [];
|
||||
}
|
||||
|
||||
export function getGasRedeemGroups(): GruTransportGasRedeemGroup[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGasRedeemGroups?.() ?? getGruTransportMetadata()?.gasRedeemGroups ?? [];
|
||||
}
|
||||
|
||||
export function getGasProtocolExposure(): GruTransportGasProtocolExposure[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGasProtocolExposure?.() ?? getGruTransportMetadata()?.gasProtocolExposure ?? [];
|
||||
}
|
||||
|
||||
export function isGasRedemptionPathAllowed(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
identifier: string
|
||||
): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isGasRedemptionPathAllowed?.(fromChainId, toChainId, identifier) ?? false;
|
||||
}
|
||||
|
||||
export function getActiveTransportPairs(): GruTransportPair[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getActiveTransportPairs?.() ?? [];
|
||||
}
|
||||
|
||||
export function getActiveTransportPairBySymbol(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
symbol: string
|
||||
): GruTransportPair | null {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getActiveTransportPair?.(fromChainId, toChainId, { symbol }) ?? null;
|
||||
}
|
||||
|
||||
export function getApprovedBridgePeerByChain(chainId: number): GruTransportBridgePeer | null {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getApprovedBridgePeer?.(chainId) ?? null;
|
||||
}
|
||||
|
||||
export function resolveConfigRef(ref: ConfigRef | undefined): string {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.resolveConfigRef?.(ref) ?? '';
|
||||
}
|
||||
|
||||
export function getActivePublicPools(): GruTransportPublicPool[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getActivePublicPools?.() ?? [];
|
||||
}
|
||||
|
||||
export function isPublicPoolActive(chainId: number, poolAddress: string): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isPublicPoolActive?.(chainId, poolAddress) ?? true;
|
||||
}
|
||||
|
||||
export function isPublicPoolRoutable(chainId: number, poolAddress: string): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isPublicPoolRoutable?.(chainId, poolAddress) ?? true;
|
||||
}
|
||||
|
||||
export function isPublicPoolMcpVisible(chainId: number, poolAddress: string): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isPublicPoolMcpVisible?.(chainId, poolAddress) ?? false;
|
||||
}
|
||||
|
||||
export function shouldExposePublicPool(
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.shouldExposePublicPool?.(chainId, poolAddress, token0Address, token1Address) ?? true;
|
||||
}
|
||||
|
||||
export function shouldUsePublicPoolForRouting(
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.shouldUsePublicPoolForRouting?.(chainId, poolAddress, token0Address, token1Address) ?? true;
|
||||
}
|
||||
|
||||
export function filterPoolsForExposure<T extends PublicPoolLike>(chainId: number, pools: T[]): T[] {
|
||||
return pools.filter((pool) =>
|
||||
shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterPoolsForRouting<T extends PublicPoolLike>(chainId: number, pools: T[]): T[] {
|
||||
return pools.filter((pool) =>
|
||||
shouldUsePublicPoolForRouting(chainId, pool.poolAddress, pool.token0Address, pool.token1Address)
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* Aligns with real-robinhood project_plans and ultra_advanced_global_arbitrage_engine_blueprint.
|
||||
*/
|
||||
|
||||
import { resolveChain138RpcUrl } from './chain138-rpc';
|
||||
|
||||
export type ChainGroup = 'hub' | 'edge' | 'althub' | 'external';
|
||||
|
||||
export interface HeatmapChain {
|
||||
@@ -57,9 +59,9 @@ export const DEFAULT_HEATMAP_ASSETS = [
|
||||
|
||||
function buildChains(): HeatmapChain[] {
|
||||
const rpc = (cid: number) =>
|
||||
process.env[`CHAIN_${cid}_RPC_URL`] ||
|
||||
process.env[`RPC_URL_138`] ||
|
||||
'https://rpc.d-bis.org';
|
||||
cid === 138
|
||||
? resolveChain138RpcUrl()
|
||||
: process.env[`CHAIN_${cid}_RPC_URL`] || 'https://rpc.d-bis.org';
|
||||
const explorer = (cid: number) => {
|
||||
const urls: Record<number, string> = {
|
||||
138: 'https://explorer.d-bis.org',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
getMonetaryUnitByCode,
|
||||
getMonetaryUnitBySymbol,
|
||||
isMonetaryUnitSupported,
|
||||
} from './monetary-unit-symbol-registry';
|
||||
|
||||
describe('monetary-unit symbol registry', () => {
|
||||
it('tracks BTC as a non-ISO GRU monetary unit family', () => {
|
||||
expect(isMonetaryUnitSupported('BTC')).toBe(true);
|
||||
expect(getMonetaryUnitByCode('BTC')).toMatchObject({
|
||||
code: 'BTC',
|
||||
canonicalSymbol: 'cBTC',
|
||||
wrappedSymbol: 'cWBTC',
|
||||
mappingKey: 'Compliant_BTC_cW',
|
||||
decimals: 8,
|
||||
});
|
||||
expect(getMonetaryUnitBySymbol('cWBTC')).toMatchObject({
|
||||
code: 'BTC',
|
||||
canonicalSymbol: 'cBTC',
|
||||
wrappedSymbol: 'cWBTC',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface MonetaryUnitSymbolIdentity {
|
||||
code: string;
|
||||
canonicalSymbol: string;
|
||||
wrappedSymbol: string;
|
||||
mappingKey: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
interface MonetaryUnitManifestEntry {
|
||||
code: string;
|
||||
canonicalSymbol: string;
|
||||
wrappedSymbol: string;
|
||||
mappingKey: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
interface MonetaryUnitManifestFile {
|
||||
monetaryUnits?: MonetaryUnitManifestEntry[];
|
||||
}
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveMonetaryUnitManifestPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.GRU_MONETARY_UNIT_MANIFEST_PATH,
|
||||
process.env.MONETARY_UNIT_MANIFEST_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve(process.cwd(), '../config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve(process.cwd(), '../../config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve('/config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve(__dirname, '../../../../../config/gru-monetary-unit-manifest.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadMonetaryUnitManifest(): MonetaryUnitManifestFile {
|
||||
const filePath = resolveMonetaryUnitManifestPath();
|
||||
if (!filePath) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as MonetaryUnitManifestFile;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const monetaryUnitManifest = loadMonetaryUnitManifest();
|
||||
|
||||
const MONETARY_UNIT_ENTRIES = (
|
||||
Array.isArray(monetaryUnitManifest.monetaryUnits)
|
||||
? monetaryUnitManifest.monetaryUnits
|
||||
: []
|
||||
).map((entry) => ({
|
||||
code: String(entry.code || '').trim().toUpperCase(),
|
||||
canonicalSymbol: String(entry.canonicalSymbol || '').trim(),
|
||||
wrappedSymbol: String(entry.wrappedSymbol || '').trim(),
|
||||
mappingKey: String(entry.mappingKey || '').trim(),
|
||||
decimals: Number(entry.decimals || 0),
|
||||
}));
|
||||
|
||||
export const MONETARY_UNIT_SUPPORTED = MONETARY_UNIT_ENTRIES.map((entry) => entry.code) as string[];
|
||||
|
||||
export const MONETARY_UNIT_BY_CODE: Record<string, MonetaryUnitSymbolIdentity> = Object.fromEntries(
|
||||
MONETARY_UNIT_ENTRIES.map((entry) => [
|
||||
entry.code,
|
||||
{
|
||||
code: entry.code,
|
||||
canonicalSymbol: entry.canonicalSymbol,
|
||||
wrappedSymbol: entry.wrappedSymbol,
|
||||
mappingKey: entry.mappingKey,
|
||||
decimals: entry.decimals,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
export const MONETARY_UNIT_BY_SYMBOL: Record<string, MonetaryUnitSymbolIdentity> = Object.fromEntries(
|
||||
MONETARY_UNIT_ENTRIES.flatMap((entry) => [
|
||||
[
|
||||
entry.canonicalSymbol,
|
||||
{
|
||||
code: entry.code,
|
||||
canonicalSymbol: entry.canonicalSymbol,
|
||||
wrappedSymbol: entry.wrappedSymbol,
|
||||
mappingKey: entry.mappingKey,
|
||||
decimals: entry.decimals,
|
||||
},
|
||||
],
|
||||
[
|
||||
entry.wrappedSymbol,
|
||||
{
|
||||
code: entry.code,
|
||||
canonicalSymbol: entry.canonicalSymbol,
|
||||
wrappedSymbol: entry.wrappedSymbol,
|
||||
mappingKey: entry.mappingKey,
|
||||
decimals: entry.decimals,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
export function isMonetaryUnitSupported(code: string): boolean {
|
||||
return Boolean(MONETARY_UNIT_BY_CODE[String(code || '').trim().toUpperCase()]);
|
||||
}
|
||||
|
||||
export function getMonetaryUnitByCode(code: string): MonetaryUnitSymbolIdentity | undefined {
|
||||
return MONETARY_UNIT_BY_CODE[String(code || '').trim().toUpperCase()];
|
||||
}
|
||||
|
||||
export function getMonetaryUnitBySymbol(symbol: string): MonetaryUnitSymbolIdentity | undefined {
|
||||
return MONETARY_UNIT_BY_SYMBOL[String(symbol || '').trim()];
|
||||
}
|
||||
563
services/token-aggregation/src/config/provider-capabilities.ts
Normal file
563
services/token-aggregation/src/config/provider-capabilities.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import { AbiCoder } from 'ethers';
|
||||
import {
|
||||
PlannerProvider,
|
||||
ProviderCapabilityRecord,
|
||||
ProviderPairCapability,
|
||||
} from '../services/planner-v2-types';
|
||||
import { encodeChain138DodoV3ProviderData, isChain138DodoV3ExecutionLive } from '../services/dodo-v3-pilot';
|
||||
import { getChain138PilotVenueEdges } from '../services/chain138-pilot-venues';
|
||||
import { getChain138RoutingAssets } from './routing-assets';
|
||||
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN138_UNISWAP_V3_ROUTER = '0xde9cd8ee2811e6e64a41d5f68be315d33995975e';
|
||||
const CHAIN138_UNISWAP_V3_QUOTER = '0x6abbb1ceb2468e748a03a00cd6aa9bfe893afa1f';
|
||||
const CHAIN138_PILOT_BALANCER_VAULT = '0x96423d7c1727698d8a25ebfb88131e9422d1a3c3';
|
||||
const CHAIN138_PILOT_CURVE_3POOL = '0xe440ec15805be4c7babcd17a63b8c8a08a492e0f';
|
||||
const CHAIN138_PILOT_ONEINCH_ROUTER = '0x500b84b1bc6f59c1898a5fe538ea20a758757a4f';
|
||||
const CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324';
|
||||
const CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID = '0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b';
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function liveOrPlannedAddress(value?: string): 'live' | 'planned' {
|
||||
return normalizeAddress(value) ? 'live' : 'planned';
|
||||
}
|
||||
|
||||
function bidirectionalPair(args: {
|
||||
chainId: number;
|
||||
provider: PlannerProvider;
|
||||
tokenASymbol: string;
|
||||
tokenAAddress: string;
|
||||
tokenBSymbol: string;
|
||||
tokenBAddress: string;
|
||||
status: 'live' | 'planned' | 'blocked';
|
||||
target?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
notes?: string[];
|
||||
reason?: string;
|
||||
}): ProviderPairCapability[] {
|
||||
return [
|
||||
{
|
||||
chainId: args.chainId,
|
||||
provider: args.provider,
|
||||
legType: 'swap',
|
||||
status: args.status,
|
||||
tokenInSymbol: args.tokenASymbol,
|
||||
tokenInAddress: normalizeAddress(args.tokenAAddress),
|
||||
tokenOutSymbol: args.tokenBSymbol,
|
||||
tokenOutAddress: normalizeAddress(args.tokenBAddress),
|
||||
target: normalizeAddress(args.target),
|
||||
providerData: args.providerData,
|
||||
providerDataHex: args.providerDataHex,
|
||||
notes: args.notes,
|
||||
reason: args.reason,
|
||||
},
|
||||
{
|
||||
chainId: args.chainId,
|
||||
provider: args.provider,
|
||||
legType: 'swap',
|
||||
status: args.status,
|
||||
tokenInSymbol: args.tokenBSymbol,
|
||||
tokenInAddress: normalizeAddress(args.tokenBAddress),
|
||||
tokenOutSymbol: args.tokenASymbol,
|
||||
tokenOutAddress: normalizeAddress(args.tokenAAddress),
|
||||
target: normalizeAddress(args.target),
|
||||
providerData: args.providerData,
|
||||
providerDataHex: args.providerDataHex,
|
||||
notes: args.notes,
|
||||
reason: args.reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function encodeDodoPool(poolAddress: string): string {
|
||||
return abiCoder.encode(['address'], [poolAddress]);
|
||||
}
|
||||
|
||||
function encodeUniswapRoute(fee: number, quoter: string): string {
|
||||
return abiCoder.encode(['bytes', 'uint24', 'address', 'bool'], ['0x', fee, quoter, false]);
|
||||
}
|
||||
|
||||
function encodeBalancerRoute(poolId: string): string {
|
||||
return abiCoder.encode(['bytes32'], [poolId]);
|
||||
}
|
||||
|
||||
function encodeCurveRoute(i: number, j: number, useUnderlying: boolean): string {
|
||||
return abiCoder.encode(['int128', 'int128', 'bool'], [i, j, useUnderlying]);
|
||||
}
|
||||
|
||||
function encodeOneInchRoute(router: string): string {
|
||||
return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']);
|
||||
}
|
||||
|
||||
function chain138DodoCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const dodoProvider =
|
||||
normalizeAddress(process.env.DODO_PMM_PROVIDER_ADDRESS) ||
|
||||
normalizeAddress(process.env.DODO_PMM_PROVIDER) ||
|
||||
'0x3f729632e9553ebaccde2e9b4c8f2b285b014f2e';
|
||||
|
||||
const stablePool = '0x9e89bae009adf128782e19e8341996c596ac40dc';
|
||||
const cusdtUsdtPool = '0x866cb44b59303d8dc5f4f9e3e7a8e8b0bf238d66';
|
||||
const cusdcUsdcPool = '0xc39b7d0f40838cbfb54649d327f49a6dac964062';
|
||||
const cusdtXaucPool = '0x1aa55e2001e5651349aff5a63fd7a7ae44f0f1b0';
|
||||
const cusdcXaucPool = '0xea9ac6357cacb42a83b9082b870610363b177cba';
|
||||
const ceurtXaucPool = '0xba99bc1eaac164569d5aca96c806934ddaf970cf';
|
||||
const cbtcCusdtPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CUSDT);
|
||||
const cbtcCusdcPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CUSDC);
|
||||
const cbtcXaucPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CXAUC);
|
||||
const wethUsdtPool = normalizeAddress(process.env.CHAIN138_POOL_WETH_USDT);
|
||||
const wethUsdcPool = normalizeAddress(process.env.CHAIN138_POOL_WETH_USDC);
|
||||
|
||||
const pairs: ProviderPairCapability[] = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'cUSDC',
|
||||
tokenBAddress: assets.cUSDC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: stablePool },
|
||||
providerDataHex: encodeDodoPool(stablePool),
|
||||
notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdtUsdtPool },
|
||||
providerDataHex: encodeDodoPool(cusdtUsdtPool),
|
||||
notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDC',
|
||||
tokenAAddress: assets.cUSDC.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdcUsdcPool },
|
||||
providerDataHex: encodeDodoPool(cusdcUsdcPool),
|
||||
notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdtXaucPool },
|
||||
providerDataHex: encodeDodoPool(cusdtXaucPool),
|
||||
notes: ['Commodity route; excluded unless policy allows commodity intermediates.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDC',
|
||||
tokenAAddress: assets.cUSDC.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdcXaucPool },
|
||||
providerDataHex: encodeDodoPool(cusdcXaucPool),
|
||||
notes: ['Commodity route; excluded unless policy allows commodity intermediates.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cEURT',
|
||||
tokenAAddress: assets.cEURT.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: ceurtXaucPool },
|
||||
providerDataHex: encodeDodoPool(ceurtXaucPool),
|
||||
notes: ['Commodity route; excluded unless policy allows commodity intermediates.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cBTC',
|
||||
tokenAAddress: assets.cBTC.address,
|
||||
tokenBSymbol: 'cUSDT',
|
||||
tokenBAddress: assets.cUSDT.address,
|
||||
status: liveOrPlannedAddress(cbtcCusdtPool),
|
||||
target: dodoProvider,
|
||||
providerData: cbtcCusdtPool ? { poolAddress: cbtcCusdtPool } : undefined,
|
||||
providerDataHex: cbtcCusdtPool ? encodeDodoPool(cbtcCusdtPool) : undefined,
|
||||
notes: ['Bitcoin monetary-unit route for the jewelry-box program.'],
|
||||
reason: cbtcCusdtPool ? undefined : 'Set CHAIN138_POOL_CBTC_CUSDT after the canonical cBTC/cUSDT PMM pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cBTC',
|
||||
tokenAAddress: assets.cBTC.address,
|
||||
tokenBSymbol: 'cUSDC',
|
||||
tokenBAddress: assets.cUSDC.address,
|
||||
status: liveOrPlannedAddress(cbtcCusdcPool),
|
||||
target: dodoProvider,
|
||||
providerData: cbtcCusdcPool ? { poolAddress: cbtcCusdcPool } : undefined,
|
||||
providerDataHex: cbtcCusdcPool ? encodeDodoPool(cbtcCusdcPool) : undefined,
|
||||
notes: ['Bitcoin monetary-unit route for the jewelry-box program.'],
|
||||
reason: cbtcCusdcPool ? undefined : 'Set CHAIN138_POOL_CBTC_CUSDC after the canonical cBTC/cUSDC PMM pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cBTC',
|
||||
tokenAAddress: assets.cBTC.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: liveOrPlannedAddress(cbtcXaucPool),
|
||||
target: dodoProvider,
|
||||
providerData: cbtcXaucPool ? { poolAddress: cbtcXaucPool } : undefined,
|
||||
providerDataHex: cbtcXaucPool ? encodeDodoPool(cbtcXaucPool) : undefined,
|
||||
notes: ['Bitcoin-to-gold route for jewelry-box rebalances; excluded unless policy allows commodity intermediates.'],
|
||||
reason: cbtcXaucPool ? undefined : 'Set CHAIN138_POOL_CBTC_CXAUC after the canonical cBTC/cXAUC PMM pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status: wethUsdtPool ? 'live' : 'planned',
|
||||
target: dodoProvider,
|
||||
providerData: wethUsdtPool ? { poolAddress: wethUsdtPool } : undefined,
|
||||
providerDataHex: wethUsdtPool ? encodeDodoPool(wethUsdtPool) : undefined,
|
||||
notes: ['Phase 1 WETH lane for router-v2 stable execution.'],
|
||||
reason: wethUsdtPool ? undefined : 'Set CHAIN138_POOL_WETH_USDT after the canonical WETH/USDT pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status: wethUsdcPool ? 'live' : 'planned',
|
||||
target: dodoProvider,
|
||||
providerData: wethUsdcPool ? { poolAddress: wethUsdcPool } : undefined,
|
||||
providerDataHex: wethUsdcPool ? encodeDodoPool(wethUsdcPool) : undefined,
|
||||
notes: ['Phase 1 WETH lane for router-v2 stable execution.'],
|
||||
reason: wethUsdcPool ? undefined : 'Set CHAIN138_POOL_WETH_USDC after the canonical WETH/USDC pool is created and funded.',
|
||||
}),
|
||||
];
|
||||
|
||||
const livePairs = pairs.filter((pair) => pair.status === 'live');
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
executionMode: 'onchain',
|
||||
live: livePairs.length > 0,
|
||||
quoteLive: livePairs.length > 0,
|
||||
executionLive: livePairs.length > 0,
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['DODO is the first production executor for Chain 138 router-v2 rollout.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138DodoV3Capabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const enabled = process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0';
|
||||
const proxy = normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS) || '0xc9a11abb7c63d88546be24d58a6d95e3762cb843';
|
||||
const pool = normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS) || '0x6550a3a59070061a262a893a1d6f3f490affdbda';
|
||||
const status = enabled && proxy && pool ? 'live' : 'planned';
|
||||
const executionLive = status === 'live' && isChain138DodoV3ExecutionLive();
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo_v3',
|
||||
tokenASymbol: 'WETH10',
|
||||
tokenAAddress: assets.WETH10.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: proxy,
|
||||
providerData: status === 'live'
|
||||
? { poolAddress: pool, proxyAddress: proxy, quoteMethod: 'querySellTokens' }
|
||||
: undefined,
|
||||
providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(pool) : undefined,
|
||||
notes: [
|
||||
'Canonical Chain 138 DODO v3 / D3MM pilot route.',
|
||||
executionLive
|
||||
? 'Planner visibility, quote selection, and EnhancedSwapRouterV2 execution are live for the canonical pilot pair.'
|
||||
: 'Planner visibility and quote selection are live; EnhancedSwapRouterV2 adapter support is still pending.',
|
||||
],
|
||||
reason: status === 'planned'
|
||||
? 'Set CHAIN138_ENABLE_DODO_V3_ROUTING=1 and ensure CHAIN138_D3_MM_ADDRESS / CHAIN138_D3_PROXY_ADDRESS are configured to expose the pilot venue.'
|
||||
: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo_v3',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive,
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: [
|
||||
executionLive
|
||||
? 'Private DODO v3 / D3MM Chain 138 pilot is live in planner-v2 visibility and internal execution-plan calldata.'
|
||||
: 'Private DODO v3 / D3MM Chain 138 pilot promoted into planner-v2 visibility.',
|
||||
executionLive
|
||||
? 'Route discovery and execution-plan generation are live for the canonical pilot pair.'
|
||||
: 'Route discovery is live, but internal execution-plan calldata is intentionally withheld until a dedicated D3 route executor adapter exists.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138UniswapCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const router = normalizeAddress(process.env.UNISWAP_V3_ROUTER || CHAIN138_UNISWAP_V3_ROUTER);
|
||||
const quoter = normalizeAddress(process.env.UNISWAP_QUOTER_ADDRESS || process.env.UNISWAP_QUOTER || CHAIN138_UNISWAP_V3_QUOTER);
|
||||
const wethUsdtFee = Number(process.env.UNISWAP_V3_WETH_USDT_FEE || '500');
|
||||
const wethUsdcFee = Number(process.env.UNISWAP_V3_WETH_USDC_FEE || '500');
|
||||
const status = router && quoter ? 'live' : 'planned';
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v3',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { fee: wethUsdtFee, quoter } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeUniswapRoute(wethUsdtFee, quoter) : undefined,
|
||||
notes: ['Canonical Chain 138 upstream-native Uniswap v3 WETH/USDT venue.'],
|
||||
reason: status === 'planned' ? 'Configure UNISWAP_V3_ROUTER and UNISWAP_QUOTER_ADDRESS after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v3',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { fee: wethUsdcFee, quoter } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeUniswapRoute(wethUsdcFee, quoter) : undefined,
|
||||
notes: ['Canonical Chain 138 upstream-native Uniswap v3 WETH/USDC venue.'],
|
||||
reason: status === 'planned' ? 'Configure UNISWAP_V3_ROUTER and UNISWAP_QUOTER_ADDRESS after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v3',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Canonical Chain 138 upstream-native Uniswap v3 router/quoter path.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138BalancerCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const vault = normalizeAddress(process.env.BALANCER_VAULT || CHAIN138_PILOT_BALANCER_VAULT);
|
||||
const wethUsdtPoolId = process.env.BALANCER_WETH_USDT_POOL_ID || CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID;
|
||||
const wethUsdcPoolId = process.env.BALANCER_WETH_USDC_POOL_ID || CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID;
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'balancer',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status: vault && wethUsdtPoolId ? 'live' : 'planned',
|
||||
target: vault,
|
||||
providerData: vault && wethUsdtPoolId ? { poolId: wethUsdtPoolId } : undefined,
|
||||
providerDataHex: vault && wethUsdtPoolId ? encodeBalancerRoute(wethUsdtPoolId) : undefined,
|
||||
notes: ['Enabled only after Chain 138 Balancer pool IDs are set.'],
|
||||
reason: vault && wethUsdtPoolId ? undefined : 'Configure BALANCER_VAULT and BALANCER_WETH_USDT_POOL_ID once the pool exists.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'balancer',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status: vault && wethUsdcPoolId ? 'live' : 'planned',
|
||||
target: vault,
|
||||
providerData: vault && wethUsdcPoolId ? { poolId: wethUsdcPoolId } : undefined,
|
||||
providerDataHex: vault && wethUsdcPoolId ? encodeBalancerRoute(wethUsdcPoolId) : undefined,
|
||||
notes: ['Enabled only after Chain 138 Balancer pool IDs are set.'],
|
||||
reason: vault && wethUsdcPoolId ? undefined : 'Configure BALANCER_VAULT and BALANCER_WETH_USDC_POOL_ID once the pool exists.',
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'balancer',
|
||||
executionMode: 'onchain',
|
||||
live: pairs.some((pair) => pair.status === 'live'),
|
||||
quoteLive: pairs.some((pair) => pair.status === 'live'),
|
||||
executionLive: pairs.some((pair) => pair.status === 'live'),
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Balancer stays disabled until the minimum viable Chain 138 venue set exists.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138CurveCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const curvePool = normalizeAddress(process.env.CURVE_3POOL || CHAIN138_PILOT_CURVE_3POOL);
|
||||
const status = liveOrPlannedAddress(curvePool);
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'curve',
|
||||
tokenASymbol: 'USDT',
|
||||
tokenAAddress: assets.USDT.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: curvePool,
|
||||
providerData: status === 'live' ? { i: 0, j: 1, useUnderlying: false } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeCurveRoute(0, 1, false) : undefined,
|
||||
notes: ['Curve is reserved for stable-stable legs; no direct WETH path is configured.'],
|
||||
reason: status === 'planned' ? 'Configure CURVE_3POOL once the Chain 138 stable-stable venue is live.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'curve',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Curve is intentionally constrained to stable-stable execution for router-v2.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138PartnerCapabilities(): ProviderCapabilityRecord {
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'partner',
|
||||
executionMode: 'partner',
|
||||
live: false,
|
||||
quoteLive: false,
|
||||
executionLive: false,
|
||||
supportedLegTypes: ['swap', 'bridge'],
|
||||
pairs: [],
|
||||
notes: ['1inch, 0x, and LiFi remain partner payload adapters until explicit Chain 138 live support is verified.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138OneInchCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const router = normalizeAddress(process.env.ONEINCH_ROUTER || CHAIN138_PILOT_ONEINCH_ROUTER);
|
||||
const status = router ? 'live' : 'planned';
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'one_inch',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { executor: router, allowanceTarget: router } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeOneInchRoute(router) : undefined,
|
||||
notes: ['Enabled after the Chain 138 pilot-compatible 1inch router is deployed and funded.'],
|
||||
reason: status === 'planned' ? 'Configure ONEINCH_ROUTER once the Chain 138 pilot-compatible router is live.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'one_inch',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { executor: router, allowanceTarget: router } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeOneInchRoute(router) : undefined,
|
||||
notes: ['Enabled after the Chain 138 pilot-compatible 1inch router is deployed and funded.'],
|
||||
reason: status === 'planned' ? 'Configure ONEINCH_ROUTER once the Chain 138 pilot-compatible router is live.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'one_inch',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['1inch is promoted from partner-only placeholder to an executable Chain 138 pilot-compatible router when configured.'],
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderCapabilities(chainId: number): ProviderCapabilityRecord[] {
|
||||
if (chainId !== CHAIN_138) return [];
|
||||
return [
|
||||
chain138DodoCapabilities(),
|
||||
chain138DodoV3Capabilities(),
|
||||
chain138UniswapCapabilities(),
|
||||
chain138BalancerCapabilities(),
|
||||
chain138CurveCapabilities(),
|
||||
chain138OneInchCapabilities(),
|
||||
chain138PartnerCapabilities(),
|
||||
];
|
||||
}
|
||||
|
||||
export function findProviderPairCapability(
|
||||
chainId: number,
|
||||
provider: PlannerProvider,
|
||||
tokenInAddress: string,
|
||||
tokenOutAddress: string
|
||||
): ProviderPairCapability | undefined {
|
||||
const normalizedIn = normalizeAddress(tokenInAddress);
|
||||
const normalizedOut = normalizeAddress(tokenOutAddress);
|
||||
return getProviderCapabilities(chainId)
|
||||
.find((record) => record.provider === provider)
|
||||
?.pairs.find(
|
||||
(pair) =>
|
||||
pair.tokenInAddress === normalizedIn &&
|
||||
pair.tokenOutAddress === normalizedOut
|
||||
);
|
||||
}
|
||||
53
services/token-aggregation/src/config/repo-config-loader.ts
Normal file
53
services/token-aggregation/src/config/repo-config-loader.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const requireHere = createRequire(__filename);
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildTokenMappingLoaderCandidates(): string[] {
|
||||
return uniquePaths([
|
||||
process.env.TOKEN_MAPPING_LOADER_PATH,
|
||||
process.env.GRU_TRANSPORT_LOADER_PATH,
|
||||
process.env.PROXMOX_TOKEN_MAPPING_LOADER_PATH,
|
||||
path.resolve(process.cwd(), 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(process.cwd(), '..', 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(process.cwd(), '..', '..', 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(process.cwd(), '..', '..', '..', 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(__dirname, '../../../../../config/token-mapping-loader.cjs'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveTokenMappingLoaderPath(): string | null {
|
||||
for (const candidate of buildTokenMappingLoaderCandidates()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadTokenMappingLoader<T>(): T | null {
|
||||
const loaderPath = resolveTokenMappingLoaderPath();
|
||||
if (!loaderPath) return null;
|
||||
|
||||
try {
|
||||
return requireHere(loaderPath) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
130
services/token-aggregation/src/config/routing-assets.ts
Normal file
130
services/token-aggregation/src/config/routing-assets.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from './canonical-tokens';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_138_WETH = (
|
||||
process.env.WETH ||
|
||||
process.env.WETH9 ||
|
||||
process.env.WETH_ADDRESS_138 ||
|
||||
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
|
||||
).toLowerCase();
|
||||
const CHAIN_138_WETH10 = (
|
||||
process.env.WETH10 ||
|
||||
process.env.WETH10_ADDRESS ||
|
||||
process.env.WETH10_ADDRESS_138 ||
|
||||
'0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f'
|
||||
).toLowerCase();
|
||||
|
||||
export interface RoutingAssetSpec {
|
||||
symbol: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
kind: 'wrapped' | 'stable' | 'compliant' | 'commodity' | 'monetary_unit';
|
||||
}
|
||||
|
||||
function requireSymbolAddress(chainId: number, symbol: string, fallback?: string): string {
|
||||
const canonical = getCanonicalTokenBySymbol(chainId, symbol)?.addresses?.[chainId];
|
||||
const value = String(canonical || fallback || '').trim().toLowerCase();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getChain138RoutingAssets(): Record<string, RoutingAssetSpec> {
|
||||
return {
|
||||
WETH: {
|
||||
symbol: 'WETH',
|
||||
address: CHAIN_138_WETH,
|
||||
decimals: 18,
|
||||
kind: 'wrapped',
|
||||
},
|
||||
WETH10: {
|
||||
symbol: 'WETH10',
|
||||
address: CHAIN_138_WETH10,
|
||||
decimals: 18,
|
||||
kind: 'wrapped',
|
||||
},
|
||||
USDT: {
|
||||
symbol: 'USDT',
|
||||
address: requireSymbolAddress(CHAIN_138, 'USDT', '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'),
|
||||
decimals: 6,
|
||||
kind: 'stable',
|
||||
},
|
||||
USDC: {
|
||||
symbol: 'USDC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'USDC', '0x71D6687F38b93CCad569Fa6352c876eea967201b'),
|
||||
decimals: 6,
|
||||
kind: 'stable',
|
||||
},
|
||||
cUSDT: {
|
||||
symbol: 'cUSDT',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cUSDT', '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'),
|
||||
decimals: 6,
|
||||
kind: 'compliant',
|
||||
},
|
||||
cUSDC: {
|
||||
symbol: 'cUSDC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cUSDC', '0xf22258f57794CC8E06237084b353Ab30fFfa640b'),
|
||||
decimals: 6,
|
||||
kind: 'compliant',
|
||||
},
|
||||
cBTC: {
|
||||
symbol: 'cBTC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cBTC', '0xcb7c000000000000000000000000000000000138'),
|
||||
decimals: 8,
|
||||
kind: 'monetary_unit',
|
||||
},
|
||||
cEURT: {
|
||||
symbol: 'cEURT',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cEURT', '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72'),
|
||||
decimals: 6,
|
||||
kind: 'compliant',
|
||||
},
|
||||
cXAUC: {
|
||||
symbol: 'cXAUC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cXAUC', '0x290E52a8819A4fbD0714E517225429aA2B70EC6b'),
|
||||
decimals: 6,
|
||||
kind: 'commodity',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultIntermediateAddresses(chainId: number): string[] {
|
||||
if (chainId !== CHAIN_138) return [];
|
||||
const assets = getChain138RoutingAssets();
|
||||
return [
|
||||
assets.WETH.address,
|
||||
assets.USDT.address,
|
||||
assets.USDC.address,
|
||||
assets.cUSDT.address,
|
||||
assets.cUSDC.address,
|
||||
assets.cBTC.address,
|
||||
];
|
||||
}
|
||||
|
||||
export function getCommodityIntermediateAddresses(chainId: number): string[] {
|
||||
if (chainId !== CHAIN_138) return [];
|
||||
return [getChain138RoutingAssets().cXAUC.address];
|
||||
}
|
||||
|
||||
export function getRoutingSymbolForAddress(chainId: number, address: string): string | undefined {
|
||||
const normalized = address.trim().toLowerCase();
|
||||
if (chainId === CHAIN_138) {
|
||||
const assets = Object.values(getChain138RoutingAssets());
|
||||
const asset = assets.find((entry) => entry.address === normalized);
|
||||
if (asset) return asset.symbol;
|
||||
}
|
||||
|
||||
return getCanonicalTokenByAddress(chainId, normalized)?.symbol;
|
||||
}
|
||||
|
||||
export function getRoutingAddressForSymbol(chainId: number, symbol: string): string | undefined {
|
||||
if (chainId === CHAIN_138) {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const normalized = symbol.trim().toUpperCase();
|
||||
if (normalized === 'WETH') {
|
||||
return assets.WETH.address;
|
||||
}
|
||||
if (normalized === 'WETH10') {
|
||||
return assets.WETH10.address;
|
||||
}
|
||||
}
|
||||
return getCanonicalTokenBySymbol(chainId, symbol)?.addresses?.[chainId]?.toLowerCase();
|
||||
}
|
||||
68
services/token-aggregation/src/config/routing-policies.ts
Normal file
68
services/token-aggregation/src/config/routing-policies.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { PlannerConstraints, RoutingPolicy } from '../services/planner-v2-types';
|
||||
import {
|
||||
getCommodityIntermediateAddresses,
|
||||
getDefaultIntermediateAddresses,
|
||||
} from './routing-assets';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
|
||||
export function resolveRoutingPolicy(
|
||||
chainId: number,
|
||||
constraints: PlannerConstraints = {}
|
||||
): RoutingPolicy {
|
||||
const requestedProfile = constraints.complianceProfile || 'standard';
|
||||
const defaultIntermediates = getDefaultIntermediateAddresses(chainId);
|
||||
const commodityIntermediates = getCommodityIntermediateAddresses(chainId);
|
||||
|
||||
const baseStandard: RoutingPolicy = {
|
||||
profile: 'standard',
|
||||
allowedProviders: ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch'],
|
||||
defaultIntermediateAddresses: defaultIntermediates,
|
||||
allowBridge: constraints.allowBridge !== false,
|
||||
allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'CCIPWETH9Bridge', 'UniversalCCIPBridge', 'AlltraAdapter'],
|
||||
maxLegs: Math.min(Math.max(constraints.maxLegs || 3, 1), 3),
|
||||
allowCommodityIntermediates: constraints.allowCommodityIntermediates === true,
|
||||
notes: ['Standard policy allows live Chain 138 venues, including the DODO v3 pilot, but still requires explicit opt-in for commodity intermediates.'],
|
||||
};
|
||||
|
||||
const baseInstitutional: RoutingPolicy = {
|
||||
profile: 'institutional',
|
||||
allowedProviders: ['dodo'],
|
||||
defaultIntermediateAddresses: defaultIntermediates,
|
||||
allowBridge: constraints.allowBridge !== false,
|
||||
allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'UniversalCCIPBridge', 'AlltraAdapter'],
|
||||
maxLegs: Math.min(Math.max(constraints.maxLegs || 3, 1), 3),
|
||||
allowCommodityIntermediates: false,
|
||||
notes: ['Institutional policy restricts execution to canonical compliant assets and approved bridge rails.'],
|
||||
};
|
||||
|
||||
const basePolicy = requestedProfile === 'institutional' ? baseInstitutional : baseStandard;
|
||||
const allowedProviders = constraints.allowedProviders?.length
|
||||
? basePolicy.allowedProviders.filter((provider) => constraints.allowedProviders?.includes(provider))
|
||||
: basePolicy.allowedProviders;
|
||||
|
||||
const allowedIntermediates = constraints.allowedIntermediates?.length
|
||||
? constraints.allowedIntermediates.map((value) => value.toLowerCase())
|
||||
: basePolicy.defaultIntermediateAddresses.slice();
|
||||
|
||||
if (basePolicy.allowCommodityIntermediates) {
|
||||
for (const commodity of commodityIntermediates) {
|
||||
if (!allowedIntermediates.includes(commodity)) {
|
||||
allowedIntermediates.push(commodity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...basePolicy,
|
||||
allowedProviders,
|
||||
defaultIntermediateAddresses: allowedIntermediates,
|
||||
allowedBridgeLabels: constraints.preferredBridges?.length
|
||||
? basePolicy.allowedBridgeLabels.filter((bridge) => constraints.preferredBridges?.includes(bridge))
|
||||
: basePolicy.allowedBridgeLabels,
|
||||
notes: [
|
||||
...basePolicy.notes,
|
||||
...(chainId === CHAIN_138 ? ['Chain 138 routing defaults to WETH, USDT, USDC, cUSDT, and cUSDC intermediates.'] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PlannerMetricsRepository } from './planner-metrics-repo';
|
||||
|
||||
describe('PlannerMetricsRepository', () => {
|
||||
function makeRepoWithRejectedQuery(error: Error & { code?: string }): PlannerMetricsRepository {
|
||||
const repo = new PlannerMetricsRepository();
|
||||
(repo as any).pool = {
|
||||
query: jest.fn().mockRejectedValue(error),
|
||||
};
|
||||
return repo;
|
||||
}
|
||||
|
||||
it('treats transient connection errors as cache misses', async () => {
|
||||
const repo = makeRepoWithRejectedQuery(
|
||||
Object.assign(new Error('connect ECONNREFUSED 172.18.0.3:5432'), { code: 'ECONNREFUSED' })
|
||||
);
|
||||
|
||||
await expect(repo.getCachedPlan('request-hash')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('skips cache writes when the planner metrics database is temporarily unreachable', async () => {
|
||||
const repo = makeRepoWithRejectedQuery(
|
||||
Object.assign(new Error('connect EHOSTUNREACH 76.53.10.36:443'), { code: 'EHOSTUNREACH' })
|
||||
);
|
||||
|
||||
await expect(
|
||||
repo.cachePlan('request-hash', {
|
||||
planId: 'plan-1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision: 'direct-pool',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
estimatedAmountOut: '1000',
|
||||
minAmountOut: '990',
|
||||
estimatedGasUsd: 0.1,
|
||||
legs: [],
|
||||
alternatives: [],
|
||||
confidenceScore: 0.9,
|
||||
riskFlags: [],
|
||||
selectedRouteReason: 'selected',
|
||||
rejectedAlternatives: [],
|
||||
staleness: { maxFreshnessSeconds: 0, hasStaleLeg: false },
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
import { ProviderCapabilityRecord, PlannerResponse } from '../../services/planner-v2-types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
interface CachedPlanRow {
|
||||
plan_id: string;
|
||||
response_json: PlannerResponse;
|
||||
}
|
||||
|
||||
export class PlannerMetricsRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
private isMissingRelationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as { code?: string }).code;
|
||||
const message = (error as { message?: string }).message || '';
|
||||
return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist'));
|
||||
}
|
||||
|
||||
private isTransientConnectionError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = String((error as { code?: string }).code || '');
|
||||
const message = String((error as { message?: string }).message || '');
|
||||
return (
|
||||
['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', '57P01'].includes(code) ||
|
||||
message.includes('connect ECONNREFUSED') ||
|
||||
message.includes('connect EHOSTUNREACH') ||
|
||||
message.includes('Connection terminated unexpectedly')
|
||||
);
|
||||
}
|
||||
|
||||
private isNonFatalError(error: unknown): boolean {
|
||||
return this.isMissingRelationError(error) || this.isTransientConnectionError(error);
|
||||
}
|
||||
|
||||
private logSuppressedError(action: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Planner metrics ${action} skipped: ${message}`);
|
||||
}
|
||||
|
||||
async getCachedPlan(requestHash: string): Promise<PlannerResponse | null> {
|
||||
try {
|
||||
const result = await this.pool.query<CachedPlanRow>(
|
||||
`SELECT plan_id, response_json
|
||||
FROM route_plan_cache
|
||||
WHERE request_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[requestHash]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0].response_json;
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('cache lookup', error);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cachePlan(requestHash: string, response: PlannerResponse, ttlSeconds: number = 30): Promise<void> {
|
||||
try {
|
||||
await this.pool.query(
|
||||
`INSERT INTO route_plan_cache (
|
||||
plan_id, request_hash, chain_id, destination_chain_id, decision, response_json, expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW() + ($7::text || ' seconds')::interval)
|
||||
ON CONFLICT (plan_id) DO UPDATE SET
|
||||
request_hash = EXCLUDED.request_hash,
|
||||
chain_id = EXCLUDED.chain_id,
|
||||
destination_chain_id = EXCLUDED.destination_chain_id,
|
||||
decision = EXCLUDED.decision,
|
||||
response_json = EXCLUDED.response_json,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
created_at = NOW()`,
|
||||
[
|
||||
response.planId,
|
||||
requestHash,
|
||||
response.sourceChainId,
|
||||
response.destinationChainId,
|
||||
response.decision,
|
||||
JSON.stringify(response),
|
||||
String(ttlSeconds),
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('cache write', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async recordProviderSnapshots(chainId: number, records: ProviderCapabilityRecord[]): Promise<void> {
|
||||
try {
|
||||
for (const record of records) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO provider_health_snapshots (
|
||||
chain_id, provider, status, supports_execution, supports_quote, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||
[
|
||||
chainId,
|
||||
record.provider,
|
||||
record.live ? 'live' : 'planned',
|
||||
record.executionLive,
|
||||
record.quoteLive,
|
||||
JSON.stringify({
|
||||
executionMode: record.executionMode,
|
||||
supportedLegTypes: record.supportedLegTypes,
|
||||
pairs: record.pairs,
|
||||
notes: record.notes || [],
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('provider snapshot write', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async recordPlannedRouteMetrics(response: PlannerResponse): Promise<void> {
|
||||
try {
|
||||
for (const [index, leg] of response.legs.entries()) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO route_execution_metrics (
|
||||
plan_id, chain_id, provider, hop_index, token_in_address, token_out_address,
|
||||
estimated_amount_out, status, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||
[
|
||||
response.planId,
|
||||
leg.sourceChainId,
|
||||
leg.provider,
|
||||
index,
|
||||
leg.tokenInAddress,
|
||||
leg.tokenOutAddress,
|
||||
leg.estimatedAmountOut,
|
||||
'planned',
|
||||
JSON.stringify({
|
||||
kind: leg.kind,
|
||||
target: leg.target,
|
||||
poolAddress: leg.poolAddress,
|
||||
providerData: leg.providerData || {},
|
||||
bridgeType: leg.bridgeType,
|
||||
bridgeAddress: leg.bridgeAddress,
|
||||
gasEstimate: leg.gasEstimate,
|
||||
freshnessSeconds: leg.freshnessSeconds,
|
||||
notes: leg.notes || [],
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('route metrics write', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { existsSync } from 'fs';
|
||||
import { ApiServer } from './api/server';
|
||||
import { closeDatabasePool } from './database/client';
|
||||
import { logger } from './utils/logger';
|
||||
import { startRouteMatrixScheduler } from './services/route-matrix-scheduler';
|
||||
|
||||
// Load smom-dbis-138 root .env first (single source); works from dist/ or src/
|
||||
const rootEnvCandidates = [
|
||||
@@ -26,6 +27,7 @@ try {
|
||||
} catch (_) { /* optional when run outside proxmox repo */ }
|
||||
|
||||
const server = new ApiServer();
|
||||
startRouteMatrixScheduler();
|
||||
|
||||
// Start server
|
||||
server.start().catch((error) => {
|
||||
|
||||
@@ -95,11 +95,10 @@ export class ChainIndexer {
|
||||
try {
|
||||
// 1. Index pools
|
||||
logger.info(`Indexing pools for chain ${this.chainId}...`);
|
||||
await this.poolIndexer.indexAllPools();
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
|
||||
// 2. Discover and index tokens from pools
|
||||
logger.info(`Discovering tokens for chain ${this.chainId}...`);
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
const tokenAddresses = new Set<string>();
|
||||
pools.forEach((pool) => {
|
||||
tokenAddresses.add(pool.token0Address);
|
||||
@@ -110,7 +109,7 @@ export class ChainIndexer {
|
||||
// 3. Calculate volumes and update market data
|
||||
logger.info(`Calculating volumes for chain ${this.chainId}...`);
|
||||
for (const tokenAddress of tokenAddresses) {
|
||||
await this.updateMarketData(tokenAddress);
|
||||
await this.updateMarketData(tokenAddress, pools);
|
||||
}
|
||||
|
||||
// 4. Generate OHLCV data
|
||||
@@ -139,7 +138,7 @@ export class ChainIndexer {
|
||||
/**
|
||||
* Update market data for a token
|
||||
*/
|
||||
private async updateMarketData(tokenAddress: string): Promise<void> {
|
||||
private async updateMarketData(tokenAddress: string, pools: Awaited<ReturnType<PoolIndexer['indexAllPools']>>): Promise<void> {
|
||||
try {
|
||||
// Calculate on-chain volume
|
||||
const volumeMetrics = await this.volumeCalculator.calculateTokenVolume(
|
||||
@@ -158,7 +157,6 @@ export class ChainIndexer {
|
||||
const externalData = coingeckoData || dexscreenerData || cmcData;
|
||||
|
||||
// Get pools for liquidity calculation
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
const tokenPools = pools.filter(
|
||||
(p) => p.token0Address === tokenAddress || p.token1Address === tokenAddress
|
||||
);
|
||||
|
||||
@@ -82,6 +82,11 @@ const UNIVERAL_CCIP_ABI = [
|
||||
'event MessageReceived(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address sender, address token, uint256 amount)',
|
||||
];
|
||||
|
||||
const CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN = Math.max(
|
||||
1,
|
||||
Number(process.env.CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN || 5000)
|
||||
);
|
||||
|
||||
function nowSec(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
@@ -90,6 +95,47 @@ function msAgo(hours: number): number {
|
||||
return nowSec() - hours * 3600;
|
||||
}
|
||||
|
||||
function isRpcRangeLimitError(error: unknown): boolean {
|
||||
const parts = [
|
||||
typeof error === 'object' && error ? (error as { message?: string }).message : '',
|
||||
typeof error === 'object' && error ? (error as { shortMessage?: string }).shortMessage : '',
|
||||
typeof error === 'object' && error
|
||||
? ((error as { error?: { message?: string } }).error?.message ?? '')
|
||||
: '',
|
||||
];
|
||||
|
||||
return parts.some((part) =>
|
||||
typeof part === 'string' && /range limit|exceeds maximum rpc range/i.test(part)
|
||||
);
|
||||
}
|
||||
|
||||
async function queryFilterWithRangeFallback(
|
||||
contract: ethers.Contract,
|
||||
filter: ReturnType<ethers.Contract['filters'][string]> | { address: string },
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<ethers.EventLog[]> {
|
||||
try {
|
||||
return (await contract.queryFilter(filter as never, fromBlock, toBlock)) as ethers.EventLog[];
|
||||
} catch (error) {
|
||||
if (!isRpcRangeLimitError(error) || fromBlock >= toBlock) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const logs: ethers.EventLog[] = [];
|
||||
for (
|
||||
let start = fromBlock;
|
||||
start <= toBlock;
|
||||
start += CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN
|
||||
) {
|
||||
const end = Math.min(start + CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN - 1, toBlock);
|
||||
const chunk = (await contract.queryFilter(filter as never, start, end)) as ethers.EventLog[];
|
||||
logs.push(...chunk);
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
/** Fetch CrossChainTransferInitiated events from CCIP WETH bridges */
|
||||
async function fetchCCIPEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
@@ -101,7 +147,7 @@ async function fetchCCIPEvents(
|
||||
try {
|
||||
const contract = new ethers.Contract(bridge.address, CCIP_TRANSFER_ABI, provider);
|
||||
const filter = contract.filters.CrossChainTransferInitiated();
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { messageId: string; sender: string; destinationChainSelector: bigint; recipient: string; amount: bigint };
|
||||
@@ -143,7 +189,7 @@ async function fetchSwapBridgeEvents(
|
||||
try {
|
||||
const contract = new ethers.Contract(address, SWAP_BRIDGE_ABI, provider);
|
||||
const filter = contract.filters.SwapAndBridgeExecuted();
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { sourceToken: string; bridgeableToken: string; amountIn: bigint; amountToBridge: bigint; destinationChainSelector: bigint; recipient: string; messageId: string };
|
||||
@@ -184,7 +230,8 @@ async function fetchAlltraEvents(
|
||||
|
||||
try {
|
||||
const lockContract = new ethers.Contract(bridge.address, ALLTRA_LOCK_ABI, provider);
|
||||
const lockLogs = await lockContract.queryFilter(
|
||||
const lockLogs = await queryFilterWithRangeFallback(
|
||||
lockContract,
|
||||
lockContract.filters.LockForAlltra(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
@@ -211,7 +258,8 @@ async function fetchAlltraEvents(
|
||||
|
||||
try {
|
||||
const adapterContract = new ethers.Contract(bridge.address, ALLTRA_ADAPTER_ABI, provider);
|
||||
const initLogs = await adapterContract.queryFilter(
|
||||
const initLogs = await queryFilterWithRangeFallback(
|
||||
adapterContract,
|
||||
adapterContract.filters.AlltraBridgeInitiated(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
@@ -249,7 +297,7 @@ async function fetchUniversalCCIPEvents(
|
||||
try {
|
||||
const contract = new ethers.Contract(bridge.address, UNIVERAL_CCIP_ABI, provider);
|
||||
const filter = contract.filters.BridgeExecuted?.() ?? { address: bridge.address };
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { messageId: string; token: string; sender: string; amount: bigint; destinationChain: bigint; recipient: string };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const mockQuery = jest.fn();
|
||||
const mockLoggerInfo = jest.fn();
|
||||
|
||||
jest.mock('../database/client', () => ({
|
||||
getDatabasePool: () => ({ query: mockQuery }),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
logger: {
|
||||
info: mockLoggerInfo,
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { OHLCVGenerator } from './ohlcv-generator';
|
||||
|
||||
describe('OHLCVGenerator', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockLoggerInfo.mockReset();
|
||||
});
|
||||
|
||||
it('returns empty data and logs once when swap tables are not provisioned yet', async () => {
|
||||
mockQuery.mockRejectedValue({
|
||||
code: '42P01',
|
||||
message: 'relation "swap_events" does not exist',
|
||||
});
|
||||
|
||||
const generator = new OHLCVGenerator();
|
||||
|
||||
await expect(
|
||||
generator.generateOHLCV(
|
||||
138,
|
||||
'0xToken',
|
||||
'1h',
|
||||
new Date('2026-03-01T00:00:00.000Z'),
|
||||
new Date('2026-03-02T00:00:00.000Z')
|
||||
)
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
generator.generateOHLCV(
|
||||
138,
|
||||
'0xToken',
|
||||
'1h',
|
||||
new Date('2026-03-01T00:00:00.000Z'),
|
||||
new Date('2026-03-02T00:00:00.000Z')
|
||||
)
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mockLoggerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
||||
'Skipping OHLCV generation; relation "swap_events" is not available yet',
|
||||
expect.objectContaining({
|
||||
operation: 'OHLCV generation',
|
||||
relation: 'swap_events',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export type OHLCVInterval = '5m' | '15m' | '1h' | '4h' | '24h';
|
||||
|
||||
@@ -15,11 +16,48 @@ export interface OHLCVData {
|
||||
|
||||
export class OHLCVGenerator {
|
||||
private pool: Pool;
|
||||
private static readonly missingRelationWarnings = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
private normalizePoolAddress(poolAddress?: string): string {
|
||||
return poolAddress?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
private isMissingRelationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as { code?: string }).code;
|
||||
const message = (error as { message?: string }).message || '';
|
||||
return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist'));
|
||||
}
|
||||
|
||||
private getMissingRelationName(error: unknown): string {
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: string }).message || '')
|
||||
: '';
|
||||
const match = message.match(/relation "([^"]+)"/);
|
||||
return match?.[1] || 'unknown relation';
|
||||
}
|
||||
|
||||
private logMissingRelationOnce(operation: string, error: unknown): void {
|
||||
const relation = this.getMissingRelationName(error);
|
||||
const key = `${operation}:${relation}`;
|
||||
if (OHLCVGenerator.missingRelationWarnings.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
OHLCVGenerator.missingRelationWarnings.add(key);
|
||||
logger.info(`Skipping ${operation}; relation "${relation}" is not available yet`, {
|
||||
operation,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OHLCV data for a token
|
||||
*/
|
||||
@@ -31,63 +69,71 @@ export class OHLCVGenerator {
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
const intervalMs = this.getIntervalMs(interval);
|
||||
try {
|
||||
const intervalMs = this.getIntervalMs(interval);
|
||||
|
||||
// Get swap events for the time range
|
||||
let query = `
|
||||
SELECT timestamp, amount_usd, price_usd
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND (token0_address = $2 OR token1_address = $2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), from, to];
|
||||
// Get swap events for the time range
|
||||
let query = `
|
||||
SELECT timestamp, amount_usd, price_usd
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND (token0_address = $2 OR token1_address = $2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), from, to];
|
||||
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $5`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group swaps by interval
|
||||
const intervals = new Map<number, OHLCVData>();
|
||||
|
||||
result.rows.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp);
|
||||
const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs;
|
||||
const price = parseFloat(row.price_usd || '0');
|
||||
const volume = parseFloat(row.amount_usd || '0');
|
||||
|
||||
if (!intervals.has(intervalStart)) {
|
||||
intervals.set(intervalStart, {
|
||||
timestamp: new Date(intervalStart),
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
volume: 0,
|
||||
volumeUsd: 0,
|
||||
});
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $5`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
}
|
||||
|
||||
const ohlcv = intervals.get(intervalStart)!;
|
||||
ohlcv.high = Math.max(ohlcv.high, price);
|
||||
ohlcv.low = Math.min(ohlcv.low, price);
|
||||
ohlcv.close = price;
|
||||
ohlcv.volume += 1;
|
||||
ohlcv.volumeUsd += volume;
|
||||
});
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
// Convert map to array and sort by timestamp
|
||||
return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group swaps by interval
|
||||
const intervals = new Map<number, OHLCVData>();
|
||||
|
||||
result.rows.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp);
|
||||
const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs;
|
||||
const price = parseFloat(row.price_usd || '0');
|
||||
const volume = parseFloat(row.amount_usd || '0');
|
||||
|
||||
if (!intervals.has(intervalStart)) {
|
||||
intervals.set(intervalStart, {
|
||||
timestamp: new Date(intervalStart),
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
volume: 0,
|
||||
volumeUsd: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const ohlcv = intervals.get(intervalStart)!;
|
||||
ohlcv.high = Math.max(ohlcv.high, price);
|
||||
ohlcv.low = Math.min(ohlcv.low, price);
|
||||
ohlcv.close = price;
|
||||
ohlcv.volume += 1;
|
||||
ohlcv.volumeUsd += volume;
|
||||
});
|
||||
|
||||
// Convert map to array and sort by timestamp
|
||||
return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('OHLCV generation', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,17 +148,19 @@ export class OHLCVGenerator {
|
||||
): Promise<void> {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const values = data.map((d, i) => {
|
||||
const base = i * 8;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`;
|
||||
const normalizedPoolAddress = this.normalizePoolAddress(poolAddress);
|
||||
|
||||
const values = data.map((_, i) => {
|
||||
const base = i * 11;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11})`;
|
||||
});
|
||||
|
||||
const params: (string | number | Date | null)[] = [];
|
||||
const params: (string | number | Date)[] = [];
|
||||
data.forEach((d) => {
|
||||
params.push(
|
||||
chainId,
|
||||
tokenAddress.toLowerCase(),
|
||||
poolAddress?.toLowerCase() || null,
|
||||
normalizedPoolAddress,
|
||||
interval,
|
||||
d.open,
|
||||
d.high,
|
||||
@@ -124,21 +172,29 @@ export class OHLCVGenerator {
|
||||
);
|
||||
});
|
||||
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_ohlcv (
|
||||
chain_id, token_address, pool_address, interval_type,
|
||||
open_price, high_price, low_price, close_price, volume, volume_usd, timestamp
|
||||
)
|
||||
VALUES ${values.join(', ')}
|
||||
ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET
|
||||
open_price = EXCLUDED.open_price,
|
||||
high_price = EXCLUDED.high_price,
|
||||
low_price = EXCLUDED.low_price,
|
||||
close_price = EXCLUDED.close_price,
|
||||
volume = EXCLUDED.volume,
|
||||
volume_usd = EXCLUDED.volume_usd`,
|
||||
params
|
||||
);
|
||||
try {
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_ohlcv (
|
||||
chain_id, token_address, pool_address, interval_type,
|
||||
open_price, high_price, low_price, close_price, volume, volume_usd, timestamp
|
||||
)
|
||||
VALUES ${values.join(', ')}
|
||||
ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET
|
||||
open_price = EXCLUDED.open_price,
|
||||
high_price = EXCLUDED.high_price,
|
||||
low_price = EXCLUDED.low_price,
|
||||
close_price = EXCLUDED.close_price,
|
||||
volume = EXCLUDED.volume,
|
||||
volume_usd = EXCLUDED.volume_usd`,
|
||||
params
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('OHLCV storage', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,37 +208,45 @@ export class OHLCVGenerator {
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
let query = `
|
||||
SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd
|
||||
FROM token_ohlcv
|
||||
WHERE chain_id = $1
|
||||
AND token_address = $2
|
||||
AND interval_type = $3
|
||||
AND timestamp >= $4
|
||||
AND timestamp <= $5
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), interval, from, to];
|
||||
try {
|
||||
let query = `
|
||||
SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd
|
||||
FROM token_ohlcv
|
||||
WHERE chain_id = $1
|
||||
AND token_address = $2
|
||||
AND interval_type = $3
|
||||
AND timestamp >= $4
|
||||
AND timestamp <= $5
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), interval, from, to];
|
||||
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $6`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
} else {
|
||||
query += ` AND pool_address IS NULL`;
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $6`;
|
||||
params.push(this.normalizePoolAddress(poolAddress));
|
||||
} else {
|
||||
query += ` AND pool_address = ''`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
open: parseFloat(row.open_price),
|
||||
high: parseFloat(row.high_price),
|
||||
low: parseFloat(row.low_price),
|
||||
close: parseFloat(row.close_price),
|
||||
volume: parseFloat(row.volume || '0'),
|
||||
volumeUsd: parseFloat(row.volume_usd || '0'),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('OHLCV lookup', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
open: parseFloat(row.open_price),
|
||||
high: parseFloat(row.high_price),
|
||||
low: parseFloat(row.low_price),
|
||||
close: parseFloat(row.close_price),
|
||||
volume: parseFloat(row.volume || '0'),
|
||||
volumeUsd: parseFloat(row.volume_usd || '0'),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ethers } from 'ethers';
|
||||
import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo';
|
||||
import { getDexFactories, UniswapV2Config, UniswapV3Config, DodoConfig } from '../config/dex-factories';
|
||||
import { logger } from '../utils/logger';
|
||||
import { shouldExposePublicPool } from '../config/gru-transport';
|
||||
import { estimateChain138DodoLiquidityUsd } from '../services/chain138-dodo-liquidity';
|
||||
|
||||
// UniswapV2 Factory ABI
|
||||
const UNISWAP_V2_FACTORY_ABI = [
|
||||
@@ -49,6 +51,8 @@ const DODO_PMM_INTEGRATION_ABI = [
|
||||
];
|
||||
|
||||
export class PoolIndexer {
|
||||
private static missingDexConfigLogged = new Set<number>();
|
||||
private static staleDodoPoolsLogged = new Set<string>();
|
||||
private provider: ethers.JsonRpcProvider;
|
||||
private poolRepo: PoolRepository;
|
||||
private chainId: number;
|
||||
@@ -59,13 +63,35 @@ export class PoolIndexer {
|
||||
this.poolRepo = new PoolRepository();
|
||||
}
|
||||
|
||||
private async persistPoolIfExposed(pool: LiquidityPool, pools: LiquidityPool[]): Promise<void> {
|
||||
if (!shouldExposePublicPool(this.chainId, pool.poolAddress, pool.token0Address, pool.token1Address)) {
|
||||
logger.info(
|
||||
`Skipping inactive GRU public pool ${pool.poolAddress} on chain ${this.chainId} until gru-transport-active.json marks it active`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index all pools for configured DEX types
|
||||
*/
|
||||
async indexAllPools(): Promise<LiquidityPool[]> {
|
||||
const dexConfig = getDexFactories(this.chainId);
|
||||
if (!dexConfig) {
|
||||
logger.warn(`No DEX configuration found for chain ${this.chainId}`);
|
||||
const hasDexConfig =
|
||||
!!dexConfig &&
|
||||
(dexConfig.uniswap_v2?.length ||
|
||||
dexConfig.uniswap_v3?.length ||
|
||||
dexConfig.dodo?.length ||
|
||||
dexConfig.custom?.length);
|
||||
|
||||
if (!hasDexConfig) {
|
||||
if (!PoolIndexer.missingDexConfigLogged.has(this.chainId)) {
|
||||
logger.info(`Skipping pool indexing for chain ${this.chainId}; no DEX configuration is active yet`);
|
||||
PoolIndexer.missingDexConfigLogged.add(this.chainId);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -133,12 +159,15 @@ export class PoolIndexer {
|
||||
const quoteReserve = (reservesResult as [bigint, bigint])[1];
|
||||
const price = typeof priceResult === 'bigint' ? priceResult : BigInt(0);
|
||||
|
||||
// totalLiquidityUsd: baseReserve * price (quote per base) + quoteReserve, in 18 decimals then scale
|
||||
let totalLiquidityUsd = 0;
|
||||
if (price > 0n) {
|
||||
const baseValue = (baseReserve * price) / BigInt(1e18);
|
||||
totalLiquidityUsd = parseFloat(ethers.formatEther(baseValue + quoteReserve));
|
||||
}
|
||||
const liquidityUsd = this.chainId === 138
|
||||
? estimateChain138DodoLiquidityUsd({
|
||||
token0Address: baseToken,
|
||||
token1Address: quoteToken,
|
||||
reserve0: baseReserve,
|
||||
reserve1: quoteReserve,
|
||||
price,
|
||||
})
|
||||
: { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
|
||||
const pool: LiquidityPool = {
|
||||
chainId: this.chainId,
|
||||
@@ -149,19 +178,24 @@ export class PoolIndexer {
|
||||
factoryAddress: integrationAddress.toLowerCase(),
|
||||
reserve0: baseReserve.toString(),
|
||||
reserve1: quoteReserve.toString(),
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd,
|
||||
reserve0Usd: liquidityUsd.reserve0Usd,
|
||||
reserve1Usd: liquidityUsd.reserve1Usd,
|
||||
totalLiquidityUsd: liquidityUsd.totalLiquidityUsd,
|
||||
volume24h: 0, // No 24h volume from contract; requires event indexer
|
||||
createdAtBlock: 0,
|
||||
createdAtTimestamp: createdAt ? new Date(Number(createdAt) * 1000) : new Date(),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
} catch (err) {
|
||||
logger.warn(`Skipping DODO PMM pool ${poolAddress}; it may have been removed from integration state.`, err);
|
||||
const stalePoolKey = `${this.chainId}:${poolAddress.toLowerCase()}`;
|
||||
if (!PoolIndexer.staleDodoPoolsLogged.has(stalePoolKey)) {
|
||||
logger.info(
|
||||
`Skipping stale DODO PMM pool ${poolAddress} on chain ${this.chainId}; it is no longer registered in DODOPMMIntegration`
|
||||
);
|
||||
PoolIndexer.staleDodoPoolsLogged.add(stalePoolKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -217,8 +251,7 @@ export class PoolIndexer {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -273,8 +306,7 @@ export class PoolIndexer {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -335,8 +367,7 @@ export class PoolIndexer {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
} catch (error) {
|
||||
logger.error(`Error indexing DODO pool ${poolAddress}:`, error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const mockQuery = jest.fn();
|
||||
const mockLoggerInfo = jest.fn();
|
||||
|
||||
jest.mock('../database/client', () => ({
|
||||
getDatabasePool: () => ({ query: mockQuery }),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
logger: {
|
||||
info: mockLoggerInfo,
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { VolumeCalculator } from './volume-calculator';
|
||||
|
||||
describe('VolumeCalculator', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockLoggerInfo.mockReset();
|
||||
});
|
||||
|
||||
it('returns zero metrics and logs once when market-data tables are not provisioned yet', async () => {
|
||||
mockQuery.mockRejectedValue({
|
||||
code: '42P01',
|
||||
message: 'relation "liquidity_pools" does not exist',
|
||||
});
|
||||
|
||||
const calculator = new VolumeCalculator();
|
||||
|
||||
await expect(calculator.calculateTokenVolume(138, '0xToken')).resolves.toEqual({
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
});
|
||||
|
||||
await expect(calculator.calculateTokenVolume(138, '0xToken')).resolves.toEqual({
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
});
|
||||
|
||||
expect(mockLoggerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
||||
'Skipping volume calculation; relation "liquidity_pools" is not available yet',
|
||||
expect.objectContaining({
|
||||
operation: 'volume calculation',
|
||||
relation: 'liquidity_pools',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface VolumeMetrics {
|
||||
volume5m: number;
|
||||
@@ -14,11 +15,44 @@ export interface VolumeMetrics {
|
||||
|
||||
export class VolumeCalculator {
|
||||
private pool: Pool;
|
||||
private static readonly missingRelationWarnings = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
private isMissingRelationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as { code?: string }).code;
|
||||
const message = (error as { message?: string }).message || '';
|
||||
return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist'));
|
||||
}
|
||||
|
||||
private getMissingRelationName(error: unknown): string {
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: string }).message || '')
|
||||
: '';
|
||||
const match = message.match(/relation "([^"]+)"/);
|
||||
return match?.[1] || 'unknown relation';
|
||||
}
|
||||
|
||||
private logMissingRelationOnce(operation: string, error: unknown): void {
|
||||
const relation = this.getMissingRelationName(error);
|
||||
const key = `${operation}:${relation}`;
|
||||
if (VolumeCalculator.missingRelationWarnings.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
VolumeCalculator.missingRelationWarnings.add(key);
|
||||
logger.info(`Skipping ${operation}; relation "${relation}" is not available yet`, {
|
||||
operation,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume metrics for a token across all pools
|
||||
*/
|
||||
@@ -27,56 +61,55 @@ export class VolumeCalculator {
|
||||
tokenAddress: string,
|
||||
now: Date = new Date()
|
||||
): Promise<VolumeMetrics> {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// Get all pools for this token
|
||||
const poolsResult = await this.pool.query(
|
||||
`SELECT pool_address FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
const poolAddresses = poolsResult.rows.map((row) => row.pool_address);
|
||||
|
||||
if (poolAddresses.length === 0) {
|
||||
return {
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
try {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// Get all pools for this token
|
||||
const poolsResult = await this.pool.query(
|
||||
`SELECT pool_address FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
const poolAddresses = poolsResult.rows.map((row) => row.pool_address);
|
||||
|
||||
if (poolAddresses.length === 0) {
|
||||
return this.zeroMetrics();
|
||||
}
|
||||
|
||||
// Calculate volume for each interval
|
||||
const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now),
|
||||
this.calculateTxCounts(chainId, poolAddresses, intervals, now),
|
||||
]);
|
||||
|
||||
return {
|
||||
volume5m,
|
||||
volume1h,
|
||||
volume24h,
|
||||
volume7d,
|
||||
volume30d,
|
||||
txCount5m: txCounts['5m'],
|
||||
txCount1h: txCounts['1h'],
|
||||
txCount24h: txCounts['24h'],
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('volume calculation', error);
|
||||
return this.zeroMetrics();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate volume for each interval
|
||||
const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now),
|
||||
this.calculateTxCounts(chainId, poolAddresses, intervals, now),
|
||||
]);
|
||||
|
||||
return {
|
||||
volume5m,
|
||||
volume1h,
|
||||
volume24h,
|
||||
volume7d,
|
||||
volume30d,
|
||||
txCount5m: txCounts['5m'],
|
||||
txCount1h: txCounts['1h'],
|
||||
txCount24h: txCounts['24h'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,24 +197,45 @@ export class VolumeCalculator {
|
||||
interval: '5m' | '1h' | '24h' | '7d' | '30d',
|
||||
now: Date = new Date()
|
||||
): Promise<number> {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
try {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
const result = await this.pool.query(
|
||||
`SELECT COALESCE(SUM(amount_usd), 0) as total_volume
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = $2
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddress.toLowerCase(), intervals[interval], now]
|
||||
);
|
||||
|
||||
return parseFloat(result.rows[0]?.total_volume || '0');
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('pool volume calculation', error);
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private zeroMetrics(): VolumeMetrics {
|
||||
return {
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
};
|
||||
|
||||
const result = await this.pool.query(
|
||||
`SELECT COALESCE(SUM(amount_usd), 0) as total_volume
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = $2
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddress.toLowerCase(), intervals[interval], now]
|
||||
);
|
||||
|
||||
return parseFloat(result.rows[0]?.total_volume || '0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
AggregatorFamily,
|
||||
AggregatorRouteMatrix,
|
||||
AggregatorRouteLeg,
|
||||
LiveAggregatorRoute,
|
||||
NonLiveAggregatorRoute,
|
||||
} from '../config/aggregator-route-matrix';
|
||||
import { getProviderCapabilities } from '../config/provider-capabilities';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { getChain138RoutingAssets, getRoutingSymbolForAddress } from '../config/routing-assets';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
import { PlannerProvider, SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const PARTNER_FAMILIES: AggregatorFamily[] = ['1inch', '0x', 'LiFi'];
|
||||
|
||||
function providerProtocol(provider: PlannerProvider): string {
|
||||
switch (provider) {
|
||||
case 'dodo':
|
||||
return 'dodo_pmm';
|
||||
case 'dodo_v3':
|
||||
return 'dodo_v3';
|
||||
case 'uniswap_v3':
|
||||
return 'uniswap_v3';
|
||||
case 'balancer':
|
||||
return 'balancer';
|
||||
case 'curve':
|
||||
return 'curve';
|
||||
case 'one_inch':
|
||||
return 'one_inch';
|
||||
case 'partner':
|
||||
return 'partner';
|
||||
}
|
||||
}
|
||||
|
||||
function providerLabel(provider: PlannerProvider): string {
|
||||
switch (provider) {
|
||||
case 'dodo':
|
||||
return 'DODO PMM';
|
||||
case 'dodo_v3':
|
||||
return 'DODO V3 / D3MM';
|
||||
case 'uniswap_v3':
|
||||
return 'Uniswap V3';
|
||||
case 'balancer':
|
||||
return 'Balancer';
|
||||
case 'curve':
|
||||
return 'Curve';
|
||||
case 'one_inch':
|
||||
return '1inch';
|
||||
case 'partner':
|
||||
return 'Partner';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAddress(value?: string): string | undefined {
|
||||
return value?.trim().toLowerCase() || undefined;
|
||||
}
|
||||
|
||||
function makeRouteId(prefix: string, parts: Array<string | number | undefined>): string {
|
||||
return [prefix, ...parts]
|
||||
.filter((part): part is string | number => part !== undefined && part !== null && String(part).trim().length > 0)
|
||||
.map((part) => String(part).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
function dedupeRoutes<T extends { routeId: string }>(routes: T[]): T[] {
|
||||
const byId = new Map<string, T>();
|
||||
for (const route of routes) {
|
||||
byId.set(route.routeId, route);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function toSwapLeg(edge: SwapGraphEdge): AggregatorRouteLeg {
|
||||
return {
|
||||
kind: 'swap',
|
||||
protocol: providerProtocol(edge.provider),
|
||||
executor: providerLabel(edge.provider),
|
||||
executorAddress: normalizeAddress(edge.target),
|
||||
poolAddress: normalizeAddress(edge.poolAddress),
|
||||
tokenInAddress: normalizeAddress(edge.tokenInAddress),
|
||||
tokenOutAddress: normalizeAddress(edge.tokenOutAddress),
|
||||
reserves: {
|
||||
reserveIn: edge.reserveIn,
|
||||
reserveOut: edge.reserveOut,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSwapRoute(homeChainId: number, edge: SwapGraphEdge): LiveAggregatorRoute {
|
||||
return {
|
||||
routeId: makeRouteId(`chain-${homeChainId}-swap`, [
|
||||
edge.provider,
|
||||
edge.tokenInSymbol || edge.tokenInAddress,
|
||||
edge.tokenOutSymbol || edge.tokenOutAddress,
|
||||
edge.poolAddress?.slice(0, 10),
|
||||
]),
|
||||
status: 'live',
|
||||
aggregatorFamilies: PARTNER_FAMILIES,
|
||||
fromChainId: homeChainId,
|
||||
toChainId: homeChainId,
|
||||
tokenInSymbol: edge.tokenInSymbol,
|
||||
tokenInAddress: normalizeAddress(edge.tokenInAddress),
|
||||
tokenOutSymbol: edge.tokenOutSymbol,
|
||||
tokenOutAddress: normalizeAddress(edge.tokenOutAddress),
|
||||
routeType: 'swap',
|
||||
hopCount: 1,
|
||||
label: `${providerLabel(edge.provider)} ${edge.tokenInSymbol || edge.tokenInAddress} -> ${edge.tokenOutSymbol || edge.tokenOutAddress}`,
|
||||
intermediateSymbols: [],
|
||||
legs: [toSwapLeg(edge)],
|
||||
tags: ['planner-v2-generated', edge.provider],
|
||||
notes: [
|
||||
'Generated from live planner route graph.',
|
||||
...(edge.notes || []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildBridgeRoute(args: {
|
||||
fromChainId: number;
|
||||
toChainId: number;
|
||||
assetSymbol: string;
|
||||
assetAddress?: string;
|
||||
bridgeType: string;
|
||||
bridgeAddress: string;
|
||||
label: string;
|
||||
notes?: string[];
|
||||
}): LiveAggregatorRoute {
|
||||
return {
|
||||
routeId: makeRouteId('bridge', [
|
||||
args.fromChainId,
|
||||
args.toChainId,
|
||||
args.assetSymbol,
|
||||
args.label,
|
||||
]),
|
||||
status: 'live',
|
||||
aggregatorFamilies: PARTNER_FAMILIES,
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
assetSymbol: args.assetSymbol,
|
||||
assetAddress: normalizeAddress(args.assetAddress),
|
||||
routeType: 'bridge',
|
||||
bridgeType: args.bridgeType,
|
||||
bridgeAddress: normalizeAddress(args.bridgeAddress),
|
||||
label: `${args.label} ${args.assetSymbol} ${args.fromChainId} -> ${args.toChainId}`,
|
||||
tags: ['planner-v2-generated', 'bridge'],
|
||||
notes: [
|
||||
'Generated from bridge registry and planner visibility.',
|
||||
...(args.notes || []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultOutputPath(): string {
|
||||
return path.resolve(process.cwd(), '../../../config/aggregator-route-matrix.json');
|
||||
}
|
||||
|
||||
export class AggregatorRouteMatrixGenerator {
|
||||
private graphBuilder: RouteGraphBuilder;
|
||||
|
||||
constructor(graphBuilder = new RouteGraphBuilder()) {
|
||||
this.graphBuilder = graphBuilder;
|
||||
}
|
||||
|
||||
async generate(homeChainId: number = 138): Promise<AggregatorRouteMatrix> {
|
||||
const edges = await this.graphBuilder.buildSwapEdges(homeChainId);
|
||||
const liveSwapRoutes = dedupeRoutes(edges.map((edge) => buildSwapRoute(homeChainId, edge)));
|
||||
|
||||
const routingAssets = Object.values(getChain138RoutingAssets());
|
||||
const bridgeTargets = [1, 651940];
|
||||
const liveBridgeRoutes = dedupeRoutes(
|
||||
bridgeTargets.flatMap((destinationChainId) =>
|
||||
this.graphBuilder
|
||||
.buildBridgeCandidates(
|
||||
homeChainId,
|
||||
destinationChainId,
|
||||
routingAssets.map((asset) => asset.symbol)
|
||||
)
|
||||
.map((candidate) =>
|
||||
buildBridgeRoute({
|
||||
fromChainId: candidate.fromChainId,
|
||||
toChainId: candidate.toChainId,
|
||||
assetSymbol: candidate.assetSymbol,
|
||||
assetAddress: candidate.sourceTokenAddress,
|
||||
bridgeType: candidate.bridgeType,
|
||||
bridgeAddress: candidate.bridgeAddress,
|
||||
label: candidate.bridgeLabel,
|
||||
notes: candidate.notes,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const blockedOrPlannedRoutes: NonLiveAggregatorRoute[] = getProviderCapabilities(homeChainId)
|
||||
.flatMap((record) =>
|
||||
record.pairs
|
||||
.filter((pair): pair is typeof pair & { status: 'planned' | 'blocked' } => pair.status !== 'live')
|
||||
.map((pair) => ({
|
||||
routeId: makeRouteId('chain-138-capability', [
|
||||
record.provider,
|
||||
pair.status,
|
||||
pair.tokenInSymbol,
|
||||
pair.tokenOutSymbol,
|
||||
]),
|
||||
status: pair.status,
|
||||
fromChainId: pair.chainId,
|
||||
toChainId: pair.chainId,
|
||||
routeType: pair.legType === 'bridge' ? 'bridge' : 'swap',
|
||||
reason: pair.reason || pair.notes?.join(' ') || `${record.provider} ${pair.status}`,
|
||||
tokenInSymbols: [pair.tokenInSymbol, pair.tokenOutSymbol],
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
description: 'Planner-v2-generated aggregator route visibility matrix for Chain 138 and approved bridge lanes.',
|
||||
version: '2.0.0',
|
||||
updated: new Date().toISOString(),
|
||||
homeChainId,
|
||||
metadata: {
|
||||
generatedFrom: [
|
||||
'services/token-aggregation/src/services/route-graph-builder.ts',
|
||||
'services/token-aggregation/src/config/provider-capabilities.ts',
|
||||
'services/token-aggregation/src/config/cross-chain-bridges.ts',
|
||||
],
|
||||
verification: {
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verifiedBy: 'services/token-aggregation planner-v2 generator',
|
||||
rpc: resolveChain138RpcUrl(),
|
||||
},
|
||||
adapterNotes: [
|
||||
'This file is generated from planner-v2 graph and provider capability truth.',
|
||||
'Partner payload generation should prefer planner-v2 outputs over this visibility artifact when route inputs are available.',
|
||||
'Only live routes should be considered executable candidates.',
|
||||
],
|
||||
},
|
||||
chains: {
|
||||
'138': { name: 'Chain 138' },
|
||||
'1': { name: 'Ethereum Mainnet' },
|
||||
'651940': { name: 'ALL Mainnet' },
|
||||
},
|
||||
tokens: Object.fromEntries(
|
||||
routingAssets.map((asset) => [asset.symbol, { address: asset.address, decimals: asset.decimals, kind: asset.kind }])
|
||||
),
|
||||
liveSwapRoutes,
|
||||
liveBridgeRoutes,
|
||||
blockedOrPlannedRoutes: dedupeRoutes(blockedOrPlannedRoutes),
|
||||
};
|
||||
}
|
||||
|
||||
async writeToFile(outputPath: string = resolveDefaultOutputPath(), homeChainId: number = 138): Promise<string> {
|
||||
const matrix = await this.generate(homeChainId);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(matrix, null, 2)}\n`, 'utf8');
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function routeFromPlannerLegs(args: {
|
||||
routeId: string;
|
||||
fromChainId: number;
|
||||
toChainId: number;
|
||||
tokenInAddress?: string;
|
||||
tokenOutAddress?: string;
|
||||
assetAddress?: string;
|
||||
assetSymbol?: string;
|
||||
routeType: 'swap' | 'bridge';
|
||||
bridgeType?: string;
|
||||
bridgeAddress?: string;
|
||||
label: string;
|
||||
legs: AggregatorRouteLeg[];
|
||||
notes?: string[];
|
||||
}): LiveAggregatorRoute {
|
||||
return {
|
||||
routeId: args.routeId,
|
||||
status: 'live',
|
||||
aggregatorFamilies: PARTNER_FAMILIES,
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
tokenInAddress: normalizeAddress(args.tokenInAddress),
|
||||
tokenOutAddress: normalizeAddress(args.tokenOutAddress),
|
||||
tokenInSymbol: args.tokenInAddress ? getRoutingSymbolForAddress(args.fromChainId, args.tokenInAddress) : undefined,
|
||||
tokenOutSymbol: args.tokenOutAddress ? getRoutingSymbolForAddress(args.toChainId, args.tokenOutAddress) : undefined,
|
||||
assetAddress: normalizeAddress(args.assetAddress),
|
||||
assetSymbol: args.assetSymbol,
|
||||
routeType: args.routeType,
|
||||
bridgeType: args.bridgeType,
|
||||
bridgeAddress: normalizeAddress(args.bridgeAddress),
|
||||
label: args.label,
|
||||
hopCount: args.routeType === 'swap' ? args.legs.length : undefined,
|
||||
legs: args.legs,
|
||||
tags: ['planner-v2-generated'],
|
||||
notes: args.notes || [],
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PoolRepository } from '../database/repositories/pool-repo';
|
||||
import { TokenRepository } from '../database/repositories/token-repo';
|
||||
import { getRoutesList, getChainIds } from '../config/heatmap-chains';
|
||||
import { filterPoolsForRouting, getActiveTransportPairs } from '../config/gru-transport';
|
||||
|
||||
export interface ArbitrageOpportunity {
|
||||
cycleId: string;
|
||||
@@ -21,12 +22,18 @@ const poolRepo = new PoolRepository();
|
||||
const tokenRepo = new TokenRepository();
|
||||
|
||||
const HUB_CHAIN = 138;
|
||||
const EDGE_CHAINS = getChainIds().filter((c) => c !== HUB_CHAIN && c !== 651940);
|
||||
const EDGE_CHAINS = Array.from(
|
||||
new Set(
|
||||
getActiveTransportPairs()
|
||||
.map((pair) => pair.destinationChainId)
|
||||
.filter((chainId) => chainId !== HUB_CHAIN && chainId !== 651940)
|
||||
)
|
||||
);
|
||||
|
||||
/** Same-chain triangle on 138: e.g. cUSDT -> cUSDC -> cUSDT via two pools. */
|
||||
async function getSameChainCycles(): Promise<ArbitrageOpportunity[]> {
|
||||
const out: ArbitrageOpportunity[] = [];
|
||||
const pools = await poolRepo.getPoolsByChain(HUB_CHAIN, 100);
|
||||
const pools = filterPoolsForRouting(HUB_CHAIN, await poolRepo.getPoolsByChain(HUB_CHAIN, 100));
|
||||
for (const p of pools) {
|
||||
const t0 = await tokenRepo.getToken(HUB_CHAIN, p.token0Address);
|
||||
const t1 = await tokenRepo.getToken(HUB_CHAIN, p.token1Address);
|
||||
@@ -53,7 +60,8 @@ async function getSameChainCycles(): Promise<ArbitrageOpportunity[]> {
|
||||
/** Hub-edge-hub: 138 -> edge -> 138 (SBS). */
|
||||
function getHubEdgeHubCycles(): ArbitrageOpportunity[] {
|
||||
const out: ArbitrageOpportunity[] = [];
|
||||
const routes = getRoutesList();
|
||||
const activeDestinations = new Set(EDGE_CHAINS);
|
||||
const routes = getRoutesList().filter((route) => activeDestinations.has(route.toChainId));
|
||||
const hubOut = routes.filter((r) => r.fromChainId === HUB_CHAIN && r.toChainId !== HUB_CHAIN);
|
||||
for (const r of hubOut.slice(0, 5)) {
|
||||
out.push({
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { BestExecutionPlanner } from './best-execution-planner';
|
||||
import { PlannerMetricsRepository } from '../database/repositories/planner-metrics-repo';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
import type { BridgeRouteCandidate, PlannerResponse, SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const mockDodoV3Quote = jest.fn();
|
||||
|
||||
jest.mock('./dodo-v3-pilot', () => ({
|
||||
__esModule: true,
|
||||
quoteChain138DodoV3AmountOut: (...args: unknown[]) => mockDodoV3Quote(...args),
|
||||
encodeChain138DodoV3ProviderData: (poolAddress: string) =>
|
||||
`0x000000000000000000000000${String(poolAddress).replace(/^0x/, '').toLowerCase()}`,
|
||||
isChain138DodoV3ExecutionLive: () => true,
|
||||
}));
|
||||
|
||||
class MockPlannerMetricsRepository {
|
||||
async getCachedPlan(): Promise<PlannerResponse | null> {
|
||||
return null;
|
||||
}
|
||||
async cachePlan(): Promise<void> {}
|
||||
async recordProviderSnapshots(): Promise<void> {}
|
||||
async recordPlannedRouteMetrics(): Promise<void> {}
|
||||
}
|
||||
|
||||
class MockGraphBuilder {
|
||||
private swapEdges: SwapGraphEdge[];
|
||||
private bridgeCandidates: BridgeRouteCandidate[];
|
||||
|
||||
constructor(swapEdges: SwapGraphEdge[] = [], bridgeCandidates: BridgeRouteCandidate[] = []) {
|
||||
this.swapEdges = swapEdges;
|
||||
this.bridgeCandidates = bridgeCandidates;
|
||||
}
|
||||
|
||||
async buildSwapEdges(): Promise<SwapGraphEdge[]> {
|
||||
return this.swapEdges;
|
||||
}
|
||||
|
||||
buildBridgeCandidates(): BridgeRouteCandidate[] {
|
||||
return this.bridgeCandidates;
|
||||
}
|
||||
}
|
||||
|
||||
const WETH = '0xc02aaA39b223fe8d0a0e5c4f27ead9083c756cc2'.toLowerCase();
|
||||
const USDT = '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1'.toLowerCase();
|
||||
const USDC = '0x71d6687f38b93ccad569fa6352c876eea967201b'.toLowerCase();
|
||||
const CUSDT = '0x93e66202a11b1772e55407b32b44e5cd8eda7f22'.toLowerCase();
|
||||
const CEURT = '0xdf4b71c61e5912712c1bdd451416b9ac26949d72'.toLowerCase();
|
||||
const CXAUC = '0x290e52a8819a4fbd0714e517225429aa2b70ec6b'.toLowerCase();
|
||||
const WETH10 = '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f'.toLowerCase();
|
||||
|
||||
function swapEdge(
|
||||
tokenInAddress: string,
|
||||
tokenOutAddress: string,
|
||||
reserveIn: string,
|
||||
reserveOut: string,
|
||||
tokenInSymbol: string,
|
||||
tokenOutSymbol: string,
|
||||
provider: SwapGraphEdge['provider'] = 'dodo'
|
||||
): SwapGraphEdge {
|
||||
return {
|
||||
kind: 'swap',
|
||||
provider,
|
||||
chainId: 138,
|
||||
tokenInAddress,
|
||||
tokenOutAddress,
|
||||
tokenInSymbol,
|
||||
tokenOutSymbol,
|
||||
reserveIn,
|
||||
reserveOut,
|
||||
target: '0x3f729632e9553ebaccde2e9b4c8f2b285b014f2e',
|
||||
poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
providerData: { poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
|
||||
providerDataHex: '0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
totalLiquidityUsd: 2_000_000,
|
||||
freshnessSeconds: 60,
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('BestExecutionPlanner', () => {
|
||||
beforeEach(() => {
|
||||
mockDodoV3Quote.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct route when it scores better than multi-hop', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH, USDT, '100000000000000000000', '2000000000000', 'WETH', 'USDT'),
|
||||
swapEdge(WETH, CUSDT, '100000000000000000000', '1900000000000', 'WETH', 'cUSDT'),
|
||||
swapEdge(CUSDT, USDT, '1900000000000', '1850000000000', 'cUSDT', 'USDT'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH,
|
||||
tokenOut: USDT,
|
||||
amountIn: '1000000000000000000',
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('direct-pool');
|
||||
expect(response.legs).toHaveLength(1);
|
||||
expect(response.legs[0].tokenOutAddress).toBe(USDT);
|
||||
});
|
||||
|
||||
it('chooses multi-hop when no direct route exists', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH, CUSDT, '100000000000000000000', '1900000000000', 'WETH', 'cUSDT'),
|
||||
swapEdge(CUSDT, USDC, '1900000000000', '1880000000000', 'cUSDT', 'USDC'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH,
|
||||
tokenOut: USDC,
|
||||
amountIn: '1000000000000000000',
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('multi-hop');
|
||||
expect(response.legs).toHaveLength(2);
|
||||
expect(response.legs[0].tokenOutAddress).toBe(CUSDT);
|
||||
expect(response.legs[1].tokenOutAddress).toBe(USDC);
|
||||
});
|
||||
|
||||
it('blocks commodity intermediates under institutional policy unless explicitly enabled', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(CEURT, CXAUC, '1000000000000', '1000000000000', 'cEURT', 'cXAUC'),
|
||||
swapEdge(CXAUC, CUSDT, '1000000000000', '1000000000000', 'cXAUC', 'cUSDT'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: CEURT,
|
||||
tokenOut: CUSDT,
|
||||
amountIn: '1000000',
|
||||
constraints: {
|
||||
complianceProfile: 'institutional',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('unresolved');
|
||||
expect(response.riskFlags).toContain('no-route');
|
||||
});
|
||||
|
||||
it('returns deterministic plan ids for identical requests', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH, USDT, '100000000000000000000', '2000000000000', 'WETH', 'USDT'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const request = {
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH,
|
||||
tokenOut: USDT,
|
||||
amountIn: '1000000000000000000',
|
||||
};
|
||||
|
||||
const first = await planner.plan(request);
|
||||
const second = await planner.plan(request);
|
||||
|
||||
expect(first.planId).toBe(second.planId);
|
||||
expect(first.legs).toEqual(second.legs);
|
||||
});
|
||||
|
||||
it('returns a live planner route and executable router-v2 calldata for the DODO v3 pilot when execution is enabled', async () => {
|
||||
mockDodoV3Quote.mockResolvedValue(211660490n);
|
||||
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH10, USDT, '2010000000000000000', '4978833460', 'WETH10', 'USDT', 'dodo_v3'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH10,
|
||||
tokenOut: USDT,
|
||||
amountIn: '100000000000000000',
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('direct-pool');
|
||||
expect(response.legs).toHaveLength(1);
|
||||
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.riskFlags).toContain('pilot-venue');
|
||||
expect(response.riskFlags).not.toContain('manual-execution-only');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,783 @@
|
||||
import { AbiCoder, Contract, JsonRpcProvider, ZeroAddress, formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress, resolveCanonicalQuoteAddress } from '../config/canonical-tokens';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { getProviderCapabilities } from '../config/provider-capabilities';
|
||||
import { resolveRoutingPolicy } from '../config/routing-policies';
|
||||
import {
|
||||
getCommodityIntermediateAddresses,
|
||||
getDefaultIntermediateAddresses,
|
||||
getRoutingAddressForSymbol,
|
||||
getRoutingSymbolForAddress,
|
||||
} from '../config/routing-assets';
|
||||
import { PlannerMetricsRepository } from '../database/repositories/planner-metrics-repo';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
import { quoteChain138DodoV3AmountOut } from './dodo-v3-pilot';
|
||||
import {
|
||||
BridgeRouteCandidate,
|
||||
EncodedBridgeIntentPlan,
|
||||
EncodedPlannerRoutePlan,
|
||||
PlannerAlternative,
|
||||
PlannerDecision,
|
||||
PlannerLeg,
|
||||
PlannerProvider,
|
||||
PlannerRequest,
|
||||
PlannerResponse,
|
||||
RoutingPolicy,
|
||||
SwapGraphEdge,
|
||||
} from './planner-v2-types';
|
||||
|
||||
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_ENUM: Partial<Record<PlannerProvider, number>> = {
|
||||
dodo: 0,
|
||||
uniswap_v3: 1,
|
||||
balancer: 2,
|
||||
curve: 3,
|
||||
one_inch: 4,
|
||||
partner: 5,
|
||||
dodo_v3: 6,
|
||||
};
|
||||
const PROVIDER_GAS_USD: Record<PlannerProvider, number> = {
|
||||
dodo: 0.22,
|
||||
dodo_v3: 0.3,
|
||||
uniswap_v3: 0.28,
|
||||
balancer: 0.34,
|
||||
curve: 0.29,
|
||||
one_inch: 0.48,
|
||||
partner: 0.55,
|
||||
};
|
||||
const uniswapQuoterAbi = [
|
||||
'function quoteExactInputSingle((address,address,uint256,uint24,uint160) params) view returns (uint256,uint160,uint32,uint256)',
|
||||
'function quoteExactInputSingle(address tokenIn,address tokenOut,uint24 fee,uint256 amountIn,uint160 sqrtPriceLimitX96) view returns (uint256)',
|
||||
] as const;
|
||||
|
||||
interface RouteCandidate {
|
||||
decision: PlannerDecision;
|
||||
estimatedAmountOut: bigint;
|
||||
estimatedGasUsd: number;
|
||||
score: number;
|
||||
legs: PlannerLeg[];
|
||||
selectedRouteReason: string;
|
||||
rejectedAlternatives: string[];
|
||||
riskFlags: string[];
|
||||
}
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
let sharedProvider: JsonRpcProvider | null = null;
|
||||
let sharedProviderUrl = '';
|
||||
|
||||
function getProvider(): JsonRpcProvider {
|
||||
const rpcUrl = resolveChain138RpcUrl();
|
||||
if (!sharedProvider || sharedProviderUrl !== rpcUrl) {
|
||||
sharedProvider = new JsonRpcProvider(rpcUrl);
|
||||
sharedProviderUrl = rpcUrl;
|
||||
}
|
||||
return sharedProvider;
|
||||
}
|
||||
|
||||
function safeBigInt(value: string): bigint {
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
function quoteAmountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint {
|
||||
if (amountIn <= 0n || reserveIn <= 0n || reserveOut <= 0n) {
|
||||
return 0n;
|
||||
}
|
||||
const amountInWithFee = amountIn * 997n;
|
||||
return (reserveOut * amountInWithFee) / (reserveIn * 1000n + amountInWithFee);
|
||||
}
|
||||
|
||||
function sortEdges(edges: SwapGraphEdge[]): SwapGraphEdge[] {
|
||||
return edges.slice().sort((a, b) => {
|
||||
const providerDiff = PROVIDER_PRIORITY.indexOf(a.provider) - PROVIDER_PRIORITY.indexOf(b.provider);
|
||||
if (providerDiff !== 0) return providerDiff;
|
||||
const liquidityDiff = (b.totalLiquidityUsd || 0) - (a.totalLiquidityUsd || 0);
|
||||
if (liquidityDiff !== 0) return liquidityDiff;
|
||||
return a.tokenOutAddress.localeCompare(b.tokenOutAddress);
|
||||
});
|
||||
}
|
||||
|
||||
function decimalsForAddress(chainId: number, address: string): number {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address);
|
||||
if (spec?.decimals) return Number(spec.decimals);
|
||||
const symbol = getRoutingSymbolForAddress(chainId, address);
|
||||
if (symbol === 'WETH' || symbol === 'WETH10') return 18;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function normalizedOutput(chainId: number, address: string, amount: bigint): number {
|
||||
const decimals = decimalsForAddress(chainId, address);
|
||||
return Number(formatUnits(amount, decimals));
|
||||
}
|
||||
|
||||
function providerDataHexForEdge(edge: SwapGraphEdge): string {
|
||||
if (edge.providerDataHex) {
|
||||
return edge.providerDataHex;
|
||||
}
|
||||
if (edge.provider === 'dodo' && edge.poolAddress) {
|
||||
return abiCoder.encode(['address'], [edge.poolAddress]);
|
||||
}
|
||||
return '0x';
|
||||
}
|
||||
|
||||
function providerDataHexForLeg(leg: PlannerLeg): string | undefined {
|
||||
if (leg.providerDataHex && leg.providerDataHex !== '0x') {
|
||||
return leg.providerDataHex;
|
||||
}
|
||||
if (leg.provider === 'dodo' && leg.poolAddress) {
|
||||
return abiCoder.encode(['address'], [leg.poolAddress]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMinAmountOut(amountOut: bigint, maxSlippageBps: number): bigint {
|
||||
return (amountOut * BigInt(10_000 - maxSlippageBps)) / 10_000n;
|
||||
}
|
||||
|
||||
function computeConfidence(legs: PlannerLeg[]): number {
|
||||
const stalePenalty = legs.some((leg) => (leg.freshnessSeconds || 0) > 1800) ? 0.15 : 0;
|
||||
const hopPenalty = Math.max(0, legs.length - 1) * 0.07;
|
||||
return Math.max(0.1, Math.min(0.99, 0.92 - stalePenalty - hopPenalty));
|
||||
}
|
||||
|
||||
function computeRiskFlags(legs: PlannerLeg[], policy: RoutingPolicy): string[] {
|
||||
const flags = new Set<string>();
|
||||
if (legs.length > 1) {
|
||||
flags.add('multi-hop');
|
||||
}
|
||||
if (legs.some((leg) => leg.kind === 'bridge')) {
|
||||
flags.add('cross-chain');
|
||||
}
|
||||
if (legs.some((leg) => (leg.freshnessSeconds || 0) > 1800)) {
|
||||
flags.add('stale-liquidity');
|
||||
}
|
||||
if (!policy.allowCommodityIntermediates && legs.some((leg) => leg.tokenInSymbol === 'cXAUC' || leg.tokenOutSymbol === 'cXAUC')) {
|
||||
flags.add('commodity-path-blocked');
|
||||
}
|
||||
if (legs.some((leg) => leg.provider === 'dodo_v3')) {
|
||||
flags.add('pilot-venue');
|
||||
if (!legs.every((leg) => leg.provider !== 'dodo_v3' || Boolean(providerDataHexForLeg(leg)))) {
|
||||
flags.add('manual-execution-only');
|
||||
}
|
||||
}
|
||||
return Array.from(flags);
|
||||
}
|
||||
|
||||
async function quoteUniswapV3AmountOut(edge: SwapGraphEdge, amountIn: bigint): Promise<bigint> {
|
||||
const providerData = (edge.providerData || {}) as { quoter?: string; fee?: number };
|
||||
const quoter = normalizeAddress(String(providerData.quoter || ''));
|
||||
const fee = Number(providerData.fee || 3000);
|
||||
if (!quoter) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const contract = new Contract(quoter, uniswapQuoterAbi, getProvider());
|
||||
try {
|
||||
const result = await contract.quoteExactInputSingle([
|
||||
edge.tokenInAddress,
|
||||
edge.tokenOutAddress,
|
||||
amountIn,
|
||||
fee,
|
||||
0,
|
||||
]);
|
||||
return BigInt(String(Array.isArray(result) ? result[0] : result));
|
||||
} catch {
|
||||
try {
|
||||
const result = await contract['quoteExactInputSingle(address,address,uint24,uint256,uint160)'](
|
||||
edge.tokenInAddress,
|
||||
edge.tokenOutAddress,
|
||||
fee,
|
||||
amountIn,
|
||||
0
|
||||
);
|
||||
return BigInt(String(result));
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BestExecutionPlanner {
|
||||
private graphBuilder: RouteGraphBuilder;
|
||||
private plannerRepo: PlannerMetricsRepository;
|
||||
|
||||
constructor(
|
||||
graphBuilder = new RouteGraphBuilder(),
|
||||
plannerRepo = new PlannerMetricsRepository()
|
||||
) {
|
||||
this.graphBuilder = graphBuilder;
|
||||
this.plannerRepo = plannerRepo;
|
||||
}
|
||||
|
||||
async plan(request: PlannerRequest): Promise<PlannerResponse> {
|
||||
const normalizedRequest = {
|
||||
...request,
|
||||
tokenIn: normalizeAddress(request.tokenIn),
|
||||
tokenOut: normalizeAddress(request.tokenOut),
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
};
|
||||
const policy = resolveRoutingPolicy(normalizedRequest.sourceChainId, normalizedRequest.constraints || {});
|
||||
const requestHash = this.requestHash(normalizedRequest, policy);
|
||||
const cached = await this.plannerRepo.getCachedPlan(requestHash);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const capabilities = getProviderCapabilities(normalizedRequest.sourceChainId);
|
||||
await this.plannerRepo.recordProviderSnapshots(normalizedRequest.sourceChainId, capabilities);
|
||||
|
||||
const response =
|
||||
normalizedRequest.destinationChainId === normalizedRequest.sourceChainId
|
||||
? await this.planOneChain(normalizedRequest, policy)
|
||||
: await this.planCrossChain(normalizedRequest, policy);
|
||||
|
||||
await this.plannerRepo.cachePlan(requestHash, response);
|
||||
await this.plannerRepo.recordPlannedRouteMetrics(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
getCapabilities(chainId: number) {
|
||||
return getProviderCapabilities(chainId);
|
||||
}
|
||||
|
||||
private requestHash(request: PlannerRequest, policy: RoutingPolicy): string {
|
||||
return JSON.stringify({
|
||||
request,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
private async quoteEdgeAmountOut(
|
||||
edge: SwapGraphEdge,
|
||||
amountIn: bigint,
|
||||
quoteCache: Map<string, bigint>
|
||||
): Promise<bigint> {
|
||||
if (edge.provider === 'uniswap_v3') {
|
||||
const cacheKey = [edge.provider, edge.target, edge.tokenInAddress, edge.tokenOutAddress, amountIn.toString()].join(':');
|
||||
const cached = quoteCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const quoted = await quoteUniswapV3AmountOut(edge, amountIn);
|
||||
if (quoted > 0n) {
|
||||
quoteCache.set(cacheKey, quoted);
|
||||
return quoted;
|
||||
}
|
||||
const fallback = quoteAmountOut(amountIn, safeBigInt(edge.reserveIn), safeBigInt(edge.reserveOut));
|
||||
quoteCache.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (edge.provider !== 'dodo_v3') {
|
||||
return quoteAmountOut(amountIn, safeBigInt(edge.reserveIn), safeBigInt(edge.reserveOut));
|
||||
}
|
||||
|
||||
const cacheKey = [edge.provider, edge.poolAddress, edge.tokenInAddress, edge.tokenOutAddress, amountIn.toString()].join(':');
|
||||
const cached = quoteCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const quoted = await quoteChain138DodoV3AmountOut({
|
||||
tokenInAddress: edge.tokenInAddress,
|
||||
tokenOutAddress: edge.tokenOutAddress,
|
||||
amountIn,
|
||||
});
|
||||
quoteCache.set(cacheKey, quoted);
|
||||
return quoted;
|
||||
} catch {
|
||||
quoteCache.set(cacheKey, 0n);
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
private async planOneChain(request: PlannerRequest, policy: RoutingPolicy): Promise<PlannerResponse> {
|
||||
const sourceResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenIn);
|
||||
const targetResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenOut);
|
||||
const amountIn = safeBigInt(request.amountIn);
|
||||
const maxSlippageBps = request.constraints?.maxSlippageBps || 100;
|
||||
|
||||
const edges = await this.graphBuilder.buildSwapEdges(request.sourceChainId);
|
||||
const allowedProviders = new Set(policy.allowedProviders);
|
||||
const allowedTokens = new Set<string>([
|
||||
sourceResolution.lookupAddress,
|
||||
targetResolution.lookupAddress,
|
||||
...policy.defaultIntermediateAddresses,
|
||||
...(policy.allowCommodityIntermediates ? getCommodityIntermediateAddresses(request.sourceChainId) : []),
|
||||
]);
|
||||
const eligibleEdges = sortEdges(
|
||||
edges.filter((edge) => {
|
||||
if (!allowedProviders.has(edge.provider)) return false;
|
||||
if (!edge.target || !providerDataHexForEdge(edge)) return false;
|
||||
if (edge.tokenOutSymbol === 'cXAUC' || edge.tokenInSymbol === 'cXAUC') {
|
||||
return policy.allowCommodityIntermediates;
|
||||
}
|
||||
return allowedTokens.has(edge.tokenOutAddress) || edge.tokenOutAddress === targetResolution.lookupAddress;
|
||||
})
|
||||
);
|
||||
|
||||
const byTokenIn = new Map<string, SwapGraphEdge[]>();
|
||||
for (const edge of eligibleEdges) {
|
||||
const key = edge.tokenInAddress;
|
||||
const list = byTokenIn.get(key) || [];
|
||||
list.push(edge);
|
||||
byTokenIn.set(key, list);
|
||||
}
|
||||
|
||||
const candidates: RouteCandidate[] = [];
|
||||
const visited = new Set<string>([sourceResolution.lookupAddress]);
|
||||
const quoteCache = new Map<string, bigint>();
|
||||
const dfs = async (
|
||||
currentToken: string,
|
||||
currentAmount: bigint,
|
||||
path: PlannerLeg[],
|
||||
depth: number
|
||||
): Promise<void> => {
|
||||
if (currentToken === targetResolution.lookupAddress && path.length > 0) {
|
||||
candidates.push(this.toCandidate(path, currentAmount, request.sourceChainId, targetResolution.lookupAddress, policy));
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth >= policy.maxLegs) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const edge of byTokenIn.get(currentToken) || []) {
|
||||
if (visited.has(edge.tokenOutAddress)) continue;
|
||||
const quotedAmount = await this.quoteEdgeAmountOut(edge, currentAmount, quoteCache);
|
||||
if (quotedAmount <= 0n) continue;
|
||||
|
||||
const leg: PlannerLeg = {
|
||||
kind: 'swap',
|
||||
provider: edge.provider,
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.sourceChainId,
|
||||
tokenInAddress: edge.tokenInAddress,
|
||||
tokenOutAddress: edge.tokenOutAddress,
|
||||
tokenInSymbol: edge.tokenInSymbol,
|
||||
tokenOutSymbol: edge.tokenOutSymbol,
|
||||
estimatedAmountIn: currentAmount.toString(),
|
||||
estimatedAmountOut: quotedAmount.toString(),
|
||||
minAmountOut: buildMinAmountOut(quotedAmount, maxSlippageBps).toString(),
|
||||
target: edge.target,
|
||||
poolAddress: edge.poolAddress,
|
||||
providerData: edge.providerData,
|
||||
providerDataHex: providerDataHexForEdge(edge),
|
||||
gasEstimate: Math.round((PROVIDER_GAS_USD[edge.provider] / 2500) * 1_000_000),
|
||||
freshnessSeconds: edge.freshnessSeconds,
|
||||
totalLiquidityUsd: edge.totalLiquidityUsd,
|
||||
notes: edge.notes,
|
||||
};
|
||||
|
||||
visited.add(edge.tokenOutAddress);
|
||||
await dfs(edge.tokenOutAddress, quotedAmount, [...path, leg], depth + 1);
|
||||
visited.delete(edge.tokenOutAddress);
|
||||
}
|
||||
};
|
||||
|
||||
await dfs(sourceResolution.lookupAddress, amountIn, [], 0);
|
||||
const sortedCandidates = candidates.sort((a, b) => b.score - a.score);
|
||||
const best = sortedCandidates[0];
|
||||
|
||||
if (!best) {
|
||||
return this.emptyResponse(request, 'unresolved', 'No eligible one-chain route found with the current policy and provider capabilities.');
|
||||
}
|
||||
|
||||
const routePlan = this.buildEncodedRoutePlan(
|
||||
request,
|
||||
best.legs,
|
||||
sourceResolution.lookupAddress,
|
||||
targetResolution.lookupAddress
|
||||
);
|
||||
return this.toResponse(
|
||||
request,
|
||||
best,
|
||||
sortedCandidates.slice(1, 4),
|
||||
'One-chain best execution selected from live pool graph.',
|
||||
routePlan
|
||||
);
|
||||
}
|
||||
|
||||
private async planCrossChain(request: PlannerRequest, policy: RoutingPolicy): Promise<PlannerResponse> {
|
||||
const sourceResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenIn);
|
||||
const destinationResolution = resolveCanonicalQuoteAddress(request.destinationChainId || request.sourceChainId, request.tokenOut);
|
||||
const amountIn = safeBigInt(request.amountIn);
|
||||
|
||||
const candidateBridgeSymbols = Array.from(
|
||||
new Set(
|
||||
policy.defaultIntermediateAddresses
|
||||
.map((address) => getRoutingSymbolForAddress(request.sourceChainId, address))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
);
|
||||
const bridgeCandidates = this.graphBuilder.buildBridgeCandidates(
|
||||
request.sourceChainId,
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
candidateBridgeSymbols
|
||||
).filter((candidate) => policy.allowedBridgeLabels.includes(candidate.bridgeLabel));
|
||||
|
||||
const combined: RouteCandidate[] = [];
|
||||
for (const bridgeCandidate of bridgeCandidates) {
|
||||
const sourceCandidate = await this.planSegment(
|
||||
request.sourceChainId,
|
||||
sourceResolution.lookupAddress,
|
||||
bridgeCandidate.sourceTokenAddress,
|
||||
amountIn,
|
||||
policy,
|
||||
Math.min(policy.maxLegs, 2)
|
||||
);
|
||||
if (!sourceCandidate) continue;
|
||||
|
||||
const bridgeFeeBps = bridgeCandidate.bridgeType === 'CCIP' ? 20 : 25;
|
||||
const bridgedAmount = buildMinAmountOut(sourceCandidate.estimatedAmountOut, bridgeFeeBps);
|
||||
|
||||
const destinationCandidate = await this.planSegment(
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
bridgeCandidate.destinationTokenAddress,
|
||||
destinationResolution.lookupAddress,
|
||||
bridgedAmount,
|
||||
policy,
|
||||
Math.min(policy.maxLegs, 2)
|
||||
);
|
||||
|
||||
const bridgeLeg: PlannerLeg = {
|
||||
kind: 'bridge',
|
||||
provider: 'partner',
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
tokenInAddress: bridgeCandidate.sourceTokenAddress,
|
||||
tokenOutAddress: bridgeCandidate.destinationTokenAddress,
|
||||
tokenInSymbol: bridgeCandidate.assetSymbol,
|
||||
tokenOutSymbol: bridgeCandidate.assetSymbol,
|
||||
estimatedAmountIn: sourceCandidate.estimatedAmountOut.toString(),
|
||||
estimatedAmountOut: bridgedAmount.toString(),
|
||||
minAmountOut: bridgedAmount.toString(),
|
||||
bridgeType: bridgeCandidate.bridgeType,
|
||||
bridgeAddress: bridgeCandidate.bridgeAddress,
|
||||
gasEstimate: 250000,
|
||||
notes: bridgeCandidate.notes,
|
||||
};
|
||||
|
||||
const destinationLegs = destinationCandidate?.legs || [];
|
||||
const estimatedAmountOut = destinationCandidate?.estimatedAmountOut || bridgedAmount;
|
||||
const legs = [...sourceCandidate.legs, bridgeLeg, ...destinationLegs];
|
||||
combined.push({
|
||||
decision: sourceCandidate.legs.length > 0 || destinationLegs.length > 0 ? 'swap-bridge-swap' : 'bridge-only',
|
||||
estimatedAmountOut,
|
||||
estimatedGasUsd: sourceCandidate.estimatedGasUsd + 0.45 + (destinationCandidate?.estimatedGasUsd || 0),
|
||||
score: sourceCandidate.score + (destinationCandidate?.score || normalizedOutput(request.destinationChainId || request.sourceChainId, bridgeCandidate.destinationTokenAddress, bridgedAmount)) - 0.45,
|
||||
legs,
|
||||
selectedRouteReason: `Bridge asset ${bridgeCandidate.assetSymbol} selected via ${bridgeCandidate.bridgeLabel}.`,
|
||||
rejectedAlternatives: [],
|
||||
riskFlags: ['cross-chain'],
|
||||
});
|
||||
}
|
||||
|
||||
const sortedCandidates = combined.sort((a, b) => b.score - a.score);
|
||||
const best = sortedCandidates[0];
|
||||
if (!best) {
|
||||
return this.emptyResponse(request, 'unresolved', 'No eligible cross-chain route found with the current policy and bridge registry.');
|
||||
}
|
||||
|
||||
const sourceSwapLegs = best.legs.filter(
|
||||
(leg) => leg.kind === 'swap' && leg.sourceChainId === request.sourceChainId
|
||||
);
|
||||
const destinationSwapLegs = best.legs.filter(
|
||||
(leg) => leg.kind === 'swap' && leg.sourceChainId === (request.destinationChainId || request.sourceChainId)
|
||||
);
|
||||
const bridgeLeg = best.legs.find((leg) => leg.kind === 'bridge');
|
||||
const intentCoordinator = normalizeAddress(
|
||||
process.env.INTENT_BRIDGE_COORDINATOR_V2_ADDRESS ||
|
||||
(request.sourceChainId === 138 ? DEFAULT_INTENT_BRIDGE_COORDINATOR_V2 : '')
|
||||
);
|
||||
|
||||
const sourceRoutePlan = sourceSwapLegs.length > 0
|
||||
? this.buildEncodedRoutePlan(
|
||||
request,
|
||||
sourceSwapLegs,
|
||||
sourceResolution.lookupAddress,
|
||||
bridgeLeg?.tokenInAddress || sourceResolution.lookupAddress,
|
||||
intentCoordinator || ROUTER_V2_RECIPIENT_PLACEHOLDER
|
||||
)
|
||||
: undefined;
|
||||
const destinationRoutePlan = destinationSwapLegs.length > 0
|
||||
? this.buildEncodedRoutePlan(
|
||||
{
|
||||
...request,
|
||||
sourceChainId: request.destinationChainId || request.sourceChainId,
|
||||
recipient: request.recipient,
|
||||
},
|
||||
destinationSwapLegs,
|
||||
bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress,
|
||||
destinationResolution.lookupAddress
|
||||
) || this.emptyEncodedRoutePlan(
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress,
|
||||
destinationResolution.lookupAddress,
|
||||
destinationSwapLegs[destinationSwapLegs.length - 1]?.estimatedAmountOut || bridgeLeg?.estimatedAmountOut || request.amountIn,
|
||||
request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER
|
||||
)
|
||||
: this.emptyEncodedRoutePlan(
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress,
|
||||
destinationResolution.lookupAddress,
|
||||
bridgeLeg?.estimatedAmountOut || request.amountIn,
|
||||
request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER
|
||||
);
|
||||
|
||||
const bridgeIntentPlan: EncodedBridgeIntentPlan | undefined =
|
||||
bridgeLeg && sourceRoutePlan && intentCoordinator
|
||||
? {
|
||||
sourcePlan: {
|
||||
...sourceRoutePlan,
|
||||
recipient: intentCoordinator,
|
||||
},
|
||||
bridgeType: bridgeLeg.bridgeType || 'CCIP',
|
||||
bridgeData: abiCoder.encode(
|
||||
['address', 'string'],
|
||||
[bridgeLeg.bridgeAddress || ZeroAddress, bridgeLeg.bridgeType || 'CCIP']
|
||||
),
|
||||
destinationPlan: destinationRoutePlan,
|
||||
recipient: normalizeAddress(request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER),
|
||||
deadline: String(Math.floor(Date.now() / 1000) + 300),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return this.toResponse(
|
||||
request,
|
||||
best,
|
||||
sortedCandidates.slice(1, 4),
|
||||
'Cross-chain best execution selected from source swap, bridge registry, and destination swap candidates.',
|
||||
undefined,
|
||||
bridgeIntentPlan
|
||||
);
|
||||
}
|
||||
|
||||
private async planSegment(
|
||||
chainId: number,
|
||||
tokenIn: string,
|
||||
tokenOut: string,
|
||||
amountIn: bigint,
|
||||
policy: RoutingPolicy,
|
||||
maxLegs: number
|
||||
): Promise<RouteCandidate | null> {
|
||||
if (tokenIn === tokenOut) {
|
||||
return {
|
||||
decision: 'direct-pool',
|
||||
estimatedAmountOut: amountIn,
|
||||
estimatedGasUsd: 0,
|
||||
score: normalizedOutput(chainId, tokenOut, amountIn),
|
||||
legs: [],
|
||||
selectedRouteReason: 'Token already matches bridge asset or destination asset.',
|
||||
rejectedAlternatives: [],
|
||||
riskFlags: [],
|
||||
};
|
||||
}
|
||||
|
||||
const request: PlannerRequest = {
|
||||
sourceChainId: chainId,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn: amountIn.toString(),
|
||||
constraints: {
|
||||
complianceProfile: policy.profile,
|
||||
allowBridge: false,
|
||||
maxLegs,
|
||||
allowedProviders: policy.allowedProviders,
|
||||
allowedIntermediates: policy.defaultIntermediateAddresses,
|
||||
allowCommodityIntermediates: policy.allowCommodityIntermediates,
|
||||
},
|
||||
};
|
||||
const response = await this.planOneChain(request, {
|
||||
...policy,
|
||||
maxLegs,
|
||||
});
|
||||
if (!response.estimatedAmountOut || response.legs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
decision: response.decision,
|
||||
estimatedAmountOut: safeBigInt(response.estimatedAmountOut),
|
||||
estimatedGasUsd: response.estimatedGasUsd,
|
||||
score: normalizedOutput(chainId, tokenOut, safeBigInt(response.estimatedAmountOut)) - response.estimatedGasUsd,
|
||||
legs: response.legs,
|
||||
selectedRouteReason: response.selectedRouteReason,
|
||||
rejectedAlternatives: response.rejectedAlternatives,
|
||||
riskFlags: response.riskFlags,
|
||||
};
|
||||
}
|
||||
|
||||
private toCandidate(
|
||||
legs: PlannerLeg[],
|
||||
estimatedAmountOut: bigint,
|
||||
chainId: number,
|
||||
outputToken: string,
|
||||
policy: RoutingPolicy
|
||||
): RouteCandidate {
|
||||
const estimatedGasUsd = legs.reduce((total, leg) => total + PROVIDER_GAS_USD[leg.provider], 0);
|
||||
const stalePenalty = legs.reduce((total, leg) => {
|
||||
const freshness = leg.freshnessSeconds || 0;
|
||||
return total + (freshness > 1800 ? 0.15 : freshness > 300 ? 0.05 : 0);
|
||||
}, 0);
|
||||
const hopPenalty = Math.max(0, legs.length - 1) * 0.03;
|
||||
const score = normalizedOutput(chainId, outputToken, estimatedAmountOut) - estimatedGasUsd - stalePenalty - hopPenalty;
|
||||
|
||||
return {
|
||||
decision: legs.length === 1 ? 'direct-pool' : 'multi-hop',
|
||||
estimatedAmountOut,
|
||||
estimatedGasUsd,
|
||||
score,
|
||||
legs,
|
||||
selectedRouteReason: legs.length === 1
|
||||
? legs[0].provider === 'dodo_v3'
|
||||
? 'Selected live DODO v3 / D3MM pilot quote for the requested direct pair.'
|
||||
: `Selected deepest eligible ${legs[0].provider} pool for the requested pair.`
|
||||
: `Selected multi-hop path through ${legs.map((leg) => leg.tokenOutSymbol || leg.tokenOutAddress).join(' -> ')}.`,
|
||||
rejectedAlternatives: [],
|
||||
riskFlags: computeRiskFlags(legs, policy),
|
||||
};
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
request: PlannerRequest,
|
||||
best: RouteCandidate,
|
||||
alternatives: RouteCandidate[],
|
||||
selectedRouteReason: string,
|
||||
routePlan?: EncodedPlannerRoutePlan,
|
||||
bridgeIntentPlan?: EncodedBridgeIntentPlan
|
||||
): PlannerResponse {
|
||||
const planId = Buffer.from(this.requestHash(request, resolveRoutingPolicy(request.sourceChainId, request.constraints || {}))).toString('base64url');
|
||||
const alternativePayloads: PlannerAlternative[] = alternatives.map((candidate, index) => ({
|
||||
routeId: `${planId}-alt-${index + 1}`,
|
||||
decision: candidate.decision,
|
||||
estimatedAmountOut: candidate.estimatedAmountOut.toString(),
|
||||
estimatedGasUsd: Number(candidate.estimatedGasUsd.toFixed(4)),
|
||||
providerPath: candidate.legs.map((leg) => leg.provider),
|
||||
legCount: candidate.legs.length,
|
||||
score: Number(candidate.score.toFixed(6)),
|
||||
notes: [candidate.selectedRouteReason, ...candidate.riskFlags],
|
||||
}));
|
||||
const maxFreshness = best.legs.reduce<number | null>((current, leg) => {
|
||||
if (leg.freshnessSeconds === null || leg.freshnessSeconds === undefined) return current;
|
||||
if (current === null) return leg.freshnessSeconds;
|
||||
return Math.max(current, leg.freshnessSeconds);
|
||||
}, null);
|
||||
|
||||
return {
|
||||
planId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision: best.decision,
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
tokenIn: request.tokenIn,
|
||||
tokenOut: request.tokenOut,
|
||||
estimatedAmountOut: best.estimatedAmountOut.toString(),
|
||||
minAmountOut: best.legs.length > 0 ? best.legs[best.legs.length - 1].minAmountOut : request.amountIn,
|
||||
estimatedGasUsd: Number(best.estimatedGasUsd.toFixed(4)),
|
||||
legs: best.legs,
|
||||
alternatives: alternativePayloads,
|
||||
confidenceScore: Number(computeConfidence(best.legs).toFixed(4)),
|
||||
riskFlags: best.riskFlags,
|
||||
selectedRouteReason,
|
||||
rejectedAlternatives: best.rejectedAlternatives,
|
||||
staleness: {
|
||||
maxFreshnessSeconds: maxFreshness,
|
||||
hasStaleLeg: best.legs.some((leg) => (leg.freshnessSeconds || 0) > 300),
|
||||
},
|
||||
routePlan,
|
||||
bridgeIntentPlan,
|
||||
};
|
||||
}
|
||||
|
||||
private emptyResponse(
|
||||
request: PlannerRequest,
|
||||
decision: PlannerDecision,
|
||||
reason: string
|
||||
): PlannerResponse {
|
||||
const planId = Buffer.from(this.requestHash(request, resolveRoutingPolicy(request.sourceChainId, request.constraints || {}))).toString('base64url');
|
||||
return {
|
||||
planId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision,
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
tokenIn: request.tokenIn,
|
||||
tokenOut: request.tokenOut,
|
||||
estimatedAmountOut: null,
|
||||
minAmountOut: null,
|
||||
estimatedGasUsd: 0,
|
||||
legs: [],
|
||||
alternatives: [],
|
||||
confidenceScore: 0,
|
||||
riskFlags: ['no-route'],
|
||||
selectedRouteReason: reason,
|
||||
rejectedAlternatives: [],
|
||||
staleness: {
|
||||
maxFreshnessSeconds: null,
|
||||
hasStaleLeg: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildEncodedRoutePlan(
|
||||
request: PlannerRequest,
|
||||
legs: PlannerLeg[],
|
||||
inputToken: string,
|
||||
outputToken: string,
|
||||
recipientOverride?: string
|
||||
): EncodedPlannerRoutePlan | undefined {
|
||||
if (!legs.every((leg) => PROVIDER_ENUM[leg.provider] !== undefined && Boolean(providerDataHexForLeg(leg)))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const recipient = normalizeAddress(recipientOverride || request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER);
|
||||
const deadline = String(Math.floor(Date.now() / 1000) + 300);
|
||||
const amountIn = legs[0]?.estimatedAmountIn || request.amountIn;
|
||||
const minAmountOut = legs[legs.length - 1]?.minAmountOut || request.amountIn;
|
||||
|
||||
return {
|
||||
chainId: request.sourceChainId,
|
||||
inputToken,
|
||||
outputToken,
|
||||
amountIn,
|
||||
minAmountOut,
|
||||
recipient,
|
||||
deadline,
|
||||
legs: legs.map((leg, index) => ({
|
||||
provider: PROVIDER_ENUM[leg.provider] as number,
|
||||
tokenIn: leg.tokenInAddress,
|
||||
tokenOut: leg.tokenOutAddress,
|
||||
amountSource: index === 0 ? 0 : 1,
|
||||
minAmountOut: leg.minAmountOut,
|
||||
target: leg.target || ZeroAddress,
|
||||
providerData: providerDataHexForLeg(leg) as string,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private emptyEncodedRoutePlan(
|
||||
chainId: number,
|
||||
inputToken: string,
|
||||
outputToken: string,
|
||||
amountIn: string,
|
||||
recipient: string
|
||||
): EncodedPlannerRoutePlan {
|
||||
return {
|
||||
chainId,
|
||||
inputToken,
|
||||
outputToken,
|
||||
amountIn,
|
||||
minAmountOut: amountIn,
|
||||
recipient: normalizeAddress(recipient),
|
||||
deadline: String(Math.floor(Date.now() / 1000) + 300),
|
||||
legs: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
|
||||
describe('estimateChain138DodoLiquidityUsd', () => {
|
||||
it('values Chain 138 stable-to-stable DODO pools with token decimals', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
token1Address: '0x71D6687F38b93CCad569Fa6352c876eea967201b',
|
||||
reserve0: 999_999_997_998n,
|
||||
reserve1: 999_999_997_998n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBeCloseTo(999_999.997998, 6);
|
||||
expect(result.reserve1Usd).toBeCloseTo(999_999.997998, 6);
|
||||
expect(result.totalLiquidityUsd).toBeCloseTo(1_999_999.995996, 6);
|
||||
});
|
||||
|
||||
it('values WETH/stable DODO pools with stable decimals and oracle price', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f',
|
||||
token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
reserve0: 50n * 10n ** 18n,
|
||||
reserve1: 105_830n * 10n ** 6n,
|
||||
price: 2_100n * 10n ** 18n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBe(105_000);
|
||||
expect(result.reserve1Usd).toBe(105_830);
|
||||
expect(result.totalLiquidityUsd).toBe(210_830);
|
||||
});
|
||||
|
||||
it('keeps non-USD pairs at zero without a usable USD side', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f',
|
||||
token1Address: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b',
|
||||
reserve0: 10n * 10n ** 18n,
|
||||
reserve1: 5n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('values cBTC/stable DODO pools using satoshi precision and the BTC fallback price', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xcb7c000000000000000000000000000000000138',
|
||||
token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
reserve0: 2n * 10n ** 8n,
|
||||
reserve1: 181_000n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBe(180_000);
|
||||
expect(result.reserve1Usd).toBe(181_000);
|
||||
expect(result.totalLiquidityUsd).toBe(361_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_WETH_USD_PRICE = 2100;
|
||||
const DEFAULT_BTC_USD_PRICE = 90000;
|
||||
|
||||
export interface Chain138DodoLiquidityUsd {
|
||||
reserve0Usd: number;
|
||||
reserve1Usd: number;
|
||||
totalLiquidityUsd: number;
|
||||
}
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function decimalsForAddress(address: string): number {
|
||||
const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address));
|
||||
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));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parsePrice(price?: bigint): number {
|
||||
if (!price || price <= 0n) return 0;
|
||||
const parsed = Number(formatUnits(price, 18));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function estimateChain138DodoLiquidityUsd(args: {
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
reserve0: bigint;
|
||||
reserve1: bigint;
|
||||
price?: bigint;
|
||||
}): Chain138DodoLiquidityUsd {
|
||||
const token0Address = normalizeAddress(args.token0Address);
|
||||
const token1Address = normalizeAddress(args.token1Address);
|
||||
const reserve0Amount = parseAmount(args.reserve0, decimalsForAddress(token0Address));
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 (token0IsUsd) {
|
||||
const reserve1Usd =
|
||||
price > 0
|
||||
? reserve1Amount / price
|
||||
: isWethLikeAddress(token1Address)
|
||||
? reserve1Amount * DEFAULT_WETH_USD_PRICE
|
||||
: isBtcLikeAddress(token1Address)
|
||||
? reserve1Amount * DEFAULT_BTC_USD_PRICE
|
||||
: 0;
|
||||
|
||||
return {
|
||||
reserve0Usd: reserve0Amount,
|
||||
reserve1Usd,
|
||||
totalLiquidityUsd: reserve1Usd > 0 ? reserve0Amount + reserve1Usd : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
}
|
||||
562
services/token-aggregation/src/services/chain138-pilot-venues.ts
Normal file
562
services/token-aggregation/src/services/chain138-pilot-venues.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { AbiCoder, Contract, JsonRpcProvider, formatUnits } from 'ethers';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
|
||||
const DEFAULT_PILOT_UNISWAP_ROUTER = '0xd164d9ccfacf5d9f91698f296ae0cd245d964384';
|
||||
const DEFAULT_NATIVE_UNISWAP_FACTORY = '0x2f7219276e3ce367db9ec74c1196a8ecee67841c';
|
||||
const DEFAULT_NATIVE_UNISWAP_ROUTER = '0xde9cd8ee2811e6e64a41d5f68be315d33995975e';
|
||||
const DEFAULT_NATIVE_UNISWAP_QUOTER = '0x6abbb1ceb2468e748a03a00cd6aa9bfe893afa1f';
|
||||
const DEFAULT_NATIVE_UNISWAP_WETH_USDT_POOL = '0xa893add35aefe6a6d858eb01828be4592f12c9f5';
|
||||
const DEFAULT_NATIVE_UNISWAP_WETH_USDC_POOL = '0xec745bfb6b3cd32f102d594e5f432d8d85b19391';
|
||||
const DEFAULT_BALANCER_VAULT = '0x96423d7c1727698d8a25ebfb88131e9422d1a3c3';
|
||||
const DEFAULT_CURVE_3POOL = '0xe440ec15805be4c7babcd17a63b8c8a08a492e0f';
|
||||
const DEFAULT_ONEINCH_ROUTER = '0x500b84b1bc6f59c1898a5fe538ea20a758757a4f';
|
||||
const DEFAULT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324';
|
||||
const DEFAULT_BALANCER_WETH_USDC_POOL_ID = '0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b';
|
||||
const DEFAULT_PILOT_UNISWAP_FEE = 3000;
|
||||
const DEFAULT_NATIVE_UNISWAP_FEE = 500;
|
||||
const DEFAULT_WETH_USD_PRICE = 2100;
|
||||
|
||||
const WETH = '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase();
|
||||
const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'.toLowerCase();
|
||||
const USDC = '0x71D6687F38b93CCad569Fa6352c876eea967201b'.toLowerCase();
|
||||
|
||||
const uniswapAbi = [
|
||||
'function getPairReserves(address tokenA,address tokenB,uint24 fee) view returns (uint256 reserveIn,uint256 reserveOut,bool exists)',
|
||||
];
|
||||
|
||||
const nativeUniswapFactoryAbi = [
|
||||
'function getPool(address tokenA,address tokenB,uint24 fee) view returns (address)',
|
||||
];
|
||||
|
||||
const erc20Abi = [
|
||||
'function balanceOf(address account) view returns (uint256)',
|
||||
];
|
||||
|
||||
const balancerAbi = [
|
||||
'function getPoolTokens(bytes32 poolId) view returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock)',
|
||||
];
|
||||
|
||||
const curveAbi = [
|
||||
'function reserves(uint256 index) view returns (uint256)',
|
||||
];
|
||||
|
||||
const oneInchAbi = [
|
||||
'function getRouteReserves(address tokenA,address tokenB) view returns (uint256 reserveIn,uint256 reserveOut,bool exists)',
|
||||
];
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function providerDataHexForUniswap(router: string, quoter: string, fee: number): string {
|
||||
return abiCoder.encode(['bytes', 'uint24', 'address', 'bool'], ['0x', fee, quoter, false]);
|
||||
}
|
||||
|
||||
function providerDataHexForBalancer(poolId: string): string {
|
||||
return abiCoder.encode(['bytes32'], [poolId]);
|
||||
}
|
||||
|
||||
function providerDataHexForCurve(): string {
|
||||
return abiCoder.encode(['int128', 'int128', 'bool'], [0, 1, false]);
|
||||
}
|
||||
|
||||
function providerDataHexForOneInch(router: string): string {
|
||||
return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']);
|
||||
}
|
||||
|
||||
let sharedProvider: JsonRpcProvider | null = null;
|
||||
let sharedProviderUrl = '';
|
||||
|
||||
function getProvider(rpcUrl: string): JsonRpcProvider {
|
||||
if (!sharedProvider || sharedProviderUrl !== rpcUrl) {
|
||||
sharedProvider = new JsonRpcProvider(rpcUrl);
|
||||
sharedProviderUrl = rpcUrl;
|
||||
}
|
||||
return sharedProvider;
|
||||
}
|
||||
|
||||
function totalLiquidityUsdForWethPair(reserveWeth: bigint, reserveStable: bigint): number {
|
||||
return Number(formatUnits(reserveWeth, 18)) * DEFAULT_WETH_USD_PRICE + Number(formatUnits(reserveStable, 6));
|
||||
}
|
||||
|
||||
function totalLiquidityUsdForStablePair(reserveA: bigint, reserveB: bigint): number {
|
||||
return Number(formatUnits(reserveA, 6)) + Number(formatUnits(reserveB, 6));
|
||||
}
|
||||
|
||||
function isNativeUniswapConfigured(router: string, quoter: string): boolean {
|
||||
return Boolean(
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_FACTORY ||
|
||||
process.env.CHAIN_138_UNISWAP_V3_FACTORY ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL ||
|
||||
router === DEFAULT_NATIVE_UNISWAP_ROUTER ||
|
||||
quoter === DEFAULT_NATIVE_UNISWAP_QUOTER ||
|
||||
(router && router !== DEFAULT_PILOT_UNISWAP_ROUTER)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveNativeUniswapPoolAddress(args: {
|
||||
factory: string;
|
||||
configuredPool: string;
|
||||
tokenA: string;
|
||||
tokenB: string;
|
||||
fee: number;
|
||||
rpcUrl: string;
|
||||
}): Promise<string> {
|
||||
if (args.configuredPool) {
|
||||
return args.configuredPool;
|
||||
}
|
||||
if (!args.factory) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const factory = new Contract(args.factory, nativeUniswapFactoryAbi, getProvider(args.rpcUrl));
|
||||
return normalizeAddress(await factory.getPool(args.tokenA, args.tokenB, args.fee));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function erc20Balance(token: string, account: string, rpcUrl: string): Promise<bigint> {
|
||||
const contract = new Contract(token, erc20Abi, getProvider(rpcUrl));
|
||||
const balance = await contract.balanceOf(account);
|
||||
return BigInt(String(balance));
|
||||
}
|
||||
|
||||
function buildUniswapEdges(args: {
|
||||
router: string;
|
||||
quoter: string;
|
||||
fee: number;
|
||||
reserveWeth: bigint;
|
||||
reserveStable: bigint;
|
||||
stableAddress: string;
|
||||
stableSymbol: 'USDT' | 'USDC';
|
||||
notes: string[];
|
||||
}): SwapGraphEdge[] {
|
||||
const edges: SwapGraphEdge[] = [];
|
||||
if (args.reserveWeth <= 0n || args.reserveStable <= 0n) {
|
||||
return edges;
|
||||
}
|
||||
const providerDataHex = providerDataHexForUniswap(args.router, args.quoter, args.fee);
|
||||
const liquidity = totalLiquidityUsdForWethPair(args.reserveWeth, args.reserveStable);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'uniswap_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: args.stableAddress,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: args.stableSymbol,
|
||||
reserveIn: args.reserveWeth.toString(),
|
||||
reserveOut: args.reserveStable.toString(),
|
||||
target: args.router,
|
||||
providerData: { fee: args.fee, quoter: args.quoter },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: args.notes,
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'uniswap_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: args.stableAddress,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: args.stableSymbol,
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: args.reserveStable.toString(),
|
||||
reserveOut: args.reserveWeth.toString(),
|
||||
target: args.router,
|
||||
providerData: { fee: args.fee, quoter: args.quoter },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: args.notes,
|
||||
}
|
||||
);
|
||||
return edges;
|
||||
}
|
||||
|
||||
async function getNativeUniswapEdges(args: {
|
||||
rpcUrl: string;
|
||||
router: string;
|
||||
quoter: string;
|
||||
feeUsdt: number;
|
||||
feeUsdc: number;
|
||||
}): Promise<SwapGraphEdge[]> {
|
||||
const factory = normalizeAddress(
|
||||
process.env.CHAIN_138_UNISWAP_V3_FACTORY ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_FACTORY ||
|
||||
DEFAULT_NATIVE_UNISWAP_FACTORY
|
||||
);
|
||||
const configuredWethUsdtPool = normalizeAddress(
|
||||
process.env.UNISWAP_V3_WETH_USDT_POOL ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL ||
|
||||
DEFAULT_NATIVE_UNISWAP_WETH_USDT_POOL
|
||||
);
|
||||
const configuredWethUsdcPool = normalizeAddress(
|
||||
process.env.UNISWAP_V3_WETH_USDC_POOL ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL ||
|
||||
DEFAULT_NATIVE_UNISWAP_WETH_USDC_POOL
|
||||
);
|
||||
|
||||
const [wethUsdtPool, wethUsdcPool] = await Promise.all([
|
||||
resolveNativeUniswapPoolAddress({
|
||||
factory,
|
||||
configuredPool: configuredWethUsdtPool,
|
||||
tokenA: WETH,
|
||||
tokenB: USDT,
|
||||
fee: args.feeUsdt,
|
||||
rpcUrl: args.rpcUrl,
|
||||
}),
|
||||
resolveNativeUniswapPoolAddress({
|
||||
factory,
|
||||
configuredPool: configuredWethUsdcPool,
|
||||
tokenA: WETH,
|
||||
tokenB: USDC,
|
||||
fee: args.feeUsdc,
|
||||
rpcUrl: args.rpcUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
const [wethUsdtBalances, wethUsdcBalances] = await Promise.all([
|
||||
wethUsdtPool
|
||||
? Promise.all([
|
||||
erc20Balance(WETH, wethUsdtPool, args.rpcUrl),
|
||||
erc20Balance(USDT, wethUsdtPool, args.rpcUrl),
|
||||
])
|
||||
: Promise.resolve<[bigint, bigint]>([0n, 0n]),
|
||||
wethUsdcPool
|
||||
? Promise.all([
|
||||
erc20Balance(WETH, wethUsdcPool, args.rpcUrl),
|
||||
erc20Balance(USDC, wethUsdcPool, args.rpcUrl),
|
||||
])
|
||||
: Promise.resolve<[bigint, bigint]>([0n, 0n]),
|
||||
]);
|
||||
|
||||
return [
|
||||
...buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdt,
|
||||
reserveWeth: wethUsdtBalances[0],
|
||||
reserveStable: wethUsdtBalances[1],
|
||||
stableAddress: USDT,
|
||||
stableSymbol: 'USDT',
|
||||
notes: ['Chain 138 upstream-native Uniswap v3 WETH/USDT venue.'],
|
||||
}),
|
||||
...buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdc,
|
||||
reserveWeth: wethUsdcBalances[0],
|
||||
reserveStable: wethUsdcBalances[1],
|
||||
stableAddress: USDC,
|
||||
stableSymbol: 'USDC',
|
||||
notes: ['Chain 138 upstream-native Uniswap v3 WETH/USDC venue.'],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function getPilotUniswapEdges(args: {
|
||||
rpcUrl: string;
|
||||
router: string;
|
||||
quoter: string;
|
||||
feeUsdt: number;
|
||||
feeUsdc: number;
|
||||
}): Promise<SwapGraphEdge[]> {
|
||||
const contract = new Contract(args.router, uniswapAbi, getProvider(args.rpcUrl));
|
||||
const [wethUsdt, wethUsdc] = await Promise.all([
|
||||
contract.getPairReserves(WETH, USDT, args.feeUsdt),
|
||||
contract.getPairReserves(WETH, USDC, args.feeUsdc),
|
||||
]);
|
||||
|
||||
return [
|
||||
...(Boolean(wethUsdt[2])
|
||||
? buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdt,
|
||||
reserveWeth: BigInt(String(wethUsdt[0])),
|
||||
reserveStable: BigInt(String(wethUsdt[1])),
|
||||
stableAddress: USDT,
|
||||
stableSymbol: 'USDT',
|
||||
notes: ['Chain 138 pilot-compatible Uniswap v3 WETH/USDT venue.'],
|
||||
})
|
||||
: []),
|
||||
...(Boolean(wethUsdc[2])
|
||||
? buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdc,
|
||||
reserveWeth: BigInt(String(wethUsdc[0])),
|
||||
reserveStable: BigInt(String(wethUsdc[1])),
|
||||
stableAddress: USDC,
|
||||
stableSymbol: 'USDC',
|
||||
notes: ['Chain 138 pilot-compatible Uniswap v3 WETH/USDC venue.'],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
async function getUniswapEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const router = normalizeAddress(process.env.UNISWAP_V3_ROUTER || DEFAULT_NATIVE_UNISWAP_ROUTER);
|
||||
const quoter = normalizeAddress(
|
||||
process.env.UNISWAP_QUOTER_ADDRESS ||
|
||||
process.env.UNISWAP_QUOTER ||
|
||||
(router === DEFAULT_PILOT_UNISWAP_ROUTER ? router : DEFAULT_NATIVE_UNISWAP_QUOTER)
|
||||
);
|
||||
if (!router || !quoter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isNativeUniswapConfigured(router, quoter)) {
|
||||
return getNativeUniswapEdges({
|
||||
rpcUrl,
|
||||
router,
|
||||
quoter,
|
||||
feeUsdt: Number(process.env.UNISWAP_V3_WETH_USDT_FEE || DEFAULT_NATIVE_UNISWAP_FEE),
|
||||
feeUsdc: Number(process.env.UNISWAP_V3_WETH_USDC_FEE || DEFAULT_NATIVE_UNISWAP_FEE),
|
||||
});
|
||||
}
|
||||
|
||||
return getPilotUniswapEdges({
|
||||
rpcUrl,
|
||||
router,
|
||||
quoter,
|
||||
feeUsdt: Number(process.env.UNISWAP_V3_WETH_USDT_FEE || DEFAULT_PILOT_UNISWAP_FEE),
|
||||
feeUsdc: Number(process.env.UNISWAP_V3_WETH_USDC_FEE || DEFAULT_PILOT_UNISWAP_FEE),
|
||||
});
|
||||
}
|
||||
|
||||
async function getBalancerEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const vault = normalizeAddress(process.env.BALANCER_VAULT || DEFAULT_BALANCER_VAULT);
|
||||
const wethUsdtPoolId = process.env.BALANCER_WETH_USDT_POOL_ID || DEFAULT_BALANCER_WETH_USDT_POOL_ID;
|
||||
const wethUsdcPoolId = process.env.BALANCER_WETH_USDC_POOL_ID || DEFAULT_BALANCER_WETH_USDC_POOL_ID;
|
||||
if (!vault) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contract = new Contract(vault, balancerAbi, getProvider(rpcUrl));
|
||||
const edges: SwapGraphEdge[] = [];
|
||||
|
||||
for (const pair of [
|
||||
{ poolId: wethUsdtPoolId, stable: USDT, stableSymbol: 'USDT' },
|
||||
{ poolId: wethUsdcPoolId, stable: USDC, stableSymbol: 'USDC' },
|
||||
]) {
|
||||
if (!pair.poolId) continue;
|
||||
const result = await contract.getPoolTokens(pair.poolId);
|
||||
const tokens = (result[0] as string[]).map(normalizeAddress);
|
||||
const balances = (result[1] as bigint[]).map((value) => BigInt(String(value)));
|
||||
const wethIndex = tokens.indexOf(WETH);
|
||||
const stableIndex = tokens.indexOf(pair.stable);
|
||||
if (wethIndex === -1 || stableIndex === -1) continue;
|
||||
const reserveWeth = balances[wethIndex];
|
||||
const reserveStable = balances[stableIndex];
|
||||
const providerDataHex = providerDataHexForBalancer(pair.poolId);
|
||||
const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveStable);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'balancer',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: pair.stable,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: pair.stableSymbol,
|
||||
reserveIn: reserveWeth.toString(),
|
||||
reserveOut: reserveStable.toString(),
|
||||
target: vault,
|
||||
providerData: { poolId: pair.poolId },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: [`Chain 138 pilot-compatible Balancer ${pair.stableSymbol}/WETH venue.`],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'balancer',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: pair.stable,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: pair.stableSymbol,
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: reserveStable.toString(),
|
||||
reserveOut: reserveWeth.toString(),
|
||||
target: vault,
|
||||
providerData: { poolId: pair.poolId },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: [`Chain 138 pilot-compatible Balancer ${pair.stableSymbol}/WETH venue.`],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
async function getCurveEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const curvePool = normalizeAddress(process.env.CURVE_3POOL || DEFAULT_CURVE_3POOL);
|
||||
if (!curvePool) {
|
||||
return [];
|
||||
}
|
||||
const contract = new Contract(curvePool, curveAbi, getProvider(rpcUrl));
|
||||
const [reserveUsdtRaw, reserveUsdcRaw] = await Promise.all([contract.reserves(0), contract.reserves(1)]);
|
||||
const reserveUsdt = BigInt(String(reserveUsdtRaw));
|
||||
const reserveUsdc = BigInt(String(reserveUsdcRaw));
|
||||
const providerDataHex = providerDataHexForCurve();
|
||||
const liquidity = totalLiquidityUsdForStablePair(reserveUsdt, reserveUsdc);
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'curve',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDT,
|
||||
tokenOutAddress: USDC,
|
||||
tokenInSymbol: 'USDT',
|
||||
tokenOutSymbol: 'USDC',
|
||||
reserveIn: reserveUsdt.toString(),
|
||||
reserveOut: reserveUsdc.toString(),
|
||||
target: curvePool,
|
||||
providerData: { i: 0, j: 1, useUnderlying: false },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible Curve 3Pool stable/stable venue.'],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'curve',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDC,
|
||||
tokenOutAddress: USDT,
|
||||
tokenInSymbol: 'USDC',
|
||||
tokenOutSymbol: 'USDT',
|
||||
reserveIn: reserveUsdc.toString(),
|
||||
reserveOut: reserveUsdt.toString(),
|
||||
target: curvePool,
|
||||
providerData: { i: 0, j: 1, useUnderlying: false },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible Curve 3Pool stable/stable venue.'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function getOneInchEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const router = normalizeAddress(process.env.ONEINCH_ROUTER || DEFAULT_ONEINCH_ROUTER);
|
||||
if (!router) {
|
||||
return [];
|
||||
}
|
||||
const contract = new Contract(router, oneInchAbi, getProvider(rpcUrl));
|
||||
const [wethUsdt, wethUsdc] = await Promise.all([
|
||||
contract.getRouteReserves(WETH, USDT),
|
||||
contract.getRouteReserves(WETH, USDC),
|
||||
]);
|
||||
|
||||
const providerDataHex = providerDataHexForOneInch(router);
|
||||
const edges: SwapGraphEdge[] = [];
|
||||
if (Boolean(wethUsdt[2])) {
|
||||
const reserveWeth = BigInt(String(wethUsdt[0]));
|
||||
const reserveUsdt = BigInt(String(wethUsdt[1]));
|
||||
const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveUsdt);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: USDT,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: 'USDT',
|
||||
reserveIn: reserveWeth.toString(),
|
||||
reserveOut: reserveUsdt.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDT,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: 'USDT',
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: reserveUsdt.toString(),
|
||||
reserveOut: reserveWeth.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
}
|
||||
);
|
||||
}
|
||||
if (Boolean(wethUsdc[2])) {
|
||||
const reserveWeth = BigInt(String(wethUsdc[0]));
|
||||
const reserveUsdc = BigInt(String(wethUsdc[1]));
|
||||
const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveUsdc);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: USDC,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: 'USDC',
|
||||
reserveIn: reserveWeth.toString(),
|
||||
reserveOut: reserveUsdc.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDC,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: 'USDC',
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: reserveUsdc.toString(),
|
||||
reserveOut: reserveWeth.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
}
|
||||
);
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
export async function getChain138PilotVenueEdges(): Promise<SwapGraphEdge[]> {
|
||||
const rpcUrl = resolveChain138RpcUrl();
|
||||
const [uniswapEdges, balancerEdges, curveEdges, oneInchEdges] = await Promise.all([
|
||||
getUniswapEdges(rpcUrl),
|
||||
getBalancerEdges(rpcUrl),
|
||||
getCurveEdges(rpcUrl),
|
||||
getOneInchEdges(rpcUrl),
|
||||
]);
|
||||
return [...uniswapEdges, ...balancerEdges, ...curveEdges, ...oneInchEdges];
|
||||
}
|
||||
204
services/token-aggregation/src/services/dodo-v3-pilot.ts
Normal file
204
services/token-aggregation/src/services/dodo-v3-pilot.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { AbiCoder, Contract, JsonRpcProvider, formatUnits } from 'ethers';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_D3_POOL = '0x6550A3a59070061a262a893A1D6F3F490afFDBDA';
|
||||
const DEFAULT_D3_PROXY = '0xc9a11abB7C63d88546Be24D58a6d95e3762cB843';
|
||||
const DEFAULT_ROUTER_V2 = '0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce';
|
||||
const DEFAULT_WETH10 = '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f';
|
||||
const DEFAULT_USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1';
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
|
||||
const d3MmAbi = [
|
||||
'function getTokenReserve(address token) view returns (uint256)',
|
||||
'function querySellTokens(address fromToken,address toToken,uint256 fromAmount) view returns (uint256,uint256,uint256,uint256,uint256)',
|
||||
] as const;
|
||||
|
||||
export interface DodoV3PilotConfig {
|
||||
enabled: boolean;
|
||||
chainId: number;
|
||||
rpcUrl: string;
|
||||
poolAddress: string;
|
||||
proxyAddress: string;
|
||||
weth10Address: string;
|
||||
usdtAddress: string;
|
||||
wethUsdPrice: number;
|
||||
liquidityOverrideUsd?: number;
|
||||
}
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeNumber(value?: string): number | undefined {
|
||||
const parsed = Number(value || '');
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function encodeChain138DodoV3ProviderData(poolAddress: string): string {
|
||||
return abiCoder.encode(['address'], [normalizeAddress(poolAddress)]);
|
||||
}
|
||||
|
||||
export function isChain138DodoV3ExecutionLive(): boolean {
|
||||
const enabled = process.env.CHAIN138_ENABLE_DODO_V3_EXECUTION !== '0';
|
||||
const router = normalizeAddress(process.env.ENHANCED_SWAP_ROUTER_V2_ADDRESS || DEFAULT_ROUTER_V2);
|
||||
return enabled && Boolean(router);
|
||||
}
|
||||
|
||||
function getPilotConfig(): DodoV3PilotConfig {
|
||||
return {
|
||||
enabled: process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0',
|
||||
chainId: CHAIN_138,
|
||||
rpcUrl: resolveChain138RpcUrl(),
|
||||
poolAddress: normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS || DEFAULT_D3_POOL),
|
||||
proxyAddress: normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS || DEFAULT_D3_PROXY),
|
||||
weth10Address: normalizeAddress(process.env.CHAIN138_D3_WETH10_ADDRESS || process.env.WETH10_ADDRESS || DEFAULT_WETH10),
|
||||
usdtAddress: normalizeAddress(process.env.CHAIN138_D3_USDT_ADDRESS || process.env.USDT_ADDRESS_138 || DEFAULT_USDT),
|
||||
wethUsdPrice: normalizeNumber(process.env.CHAIN138_D3_PILOT_WETH_USD)?.valueOf() || 2100,
|
||||
liquidityOverrideUsd: normalizeNumber(process.env.CHAIN138_D3_PILOT_TOTAL_LIQUIDITY_USD),
|
||||
};
|
||||
}
|
||||
|
||||
let sharedProvider: JsonRpcProvider | null = null;
|
||||
let sharedProviderUrl = '';
|
||||
|
||||
function getProvider(rpcUrl: string): JsonRpcProvider {
|
||||
if (!sharedProvider || sharedProviderUrl !== rpcUrl) {
|
||||
sharedProvider = new JsonRpcProvider(rpcUrl);
|
||||
sharedProviderUrl = rpcUrl;
|
||||
}
|
||||
return sharedProvider;
|
||||
}
|
||||
|
||||
function buildNotes(poolAddress: string, proxyAddress: string, executionLive: boolean): string[] {
|
||||
return [
|
||||
'DODO v3 / D3MM Chain 138 pilot venue.',
|
||||
`Canonical private pilot pool ${poolAddress} executes through D3Proxy ${proxyAddress}.`,
|
||||
executionLive
|
||||
? 'Planner-v2 exposure and EnhancedSwapRouterV2 internal execution-plan calldata are live for the canonical pilot pair.'
|
||||
: 'Planner-v2 exposure is live, but EnhancedSwapRouterV2 execution is disabled in the local environment.',
|
||||
];
|
||||
}
|
||||
|
||||
function approximateLiquidityUsd(args: {
|
||||
wethReserve: bigint;
|
||||
usdtReserve: bigint;
|
||||
wethUsdPrice: number;
|
||||
liquidityOverrideUsd?: number;
|
||||
}): number {
|
||||
if (args.liquidityOverrideUsd && Number.isFinite(args.liquidityOverrideUsd)) {
|
||||
return args.liquidityOverrideUsd;
|
||||
}
|
||||
|
||||
const wethSideUsd = Number(formatUnits(args.wethReserve, 18)) * args.wethUsdPrice;
|
||||
const usdtSideUsd = Number(formatUnits(args.usdtReserve, 6));
|
||||
return Number((wethSideUsd + usdtSideUsd).toFixed(2));
|
||||
}
|
||||
|
||||
function isCanonicalPilotPair(config: DodoV3PilotConfig, tokenInAddress: string, tokenOutAddress: string): boolean {
|
||||
const tokenIn = normalizeAddress(tokenInAddress);
|
||||
const tokenOut = normalizeAddress(tokenOutAddress);
|
||||
return (
|
||||
(tokenIn === config.weth10Address && tokenOut === config.usdtAddress) ||
|
||||
(tokenIn === config.usdtAddress && tokenOut === config.weth10Address)
|
||||
);
|
||||
}
|
||||
|
||||
export async function quoteChain138DodoV3AmountOut(args: {
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
amountIn: bigint | string;
|
||||
}): Promise<bigint> {
|
||||
const config = getPilotConfig();
|
||||
if (!config.enabled || !config.poolAddress || !config.proxyAddress) {
|
||||
return 0n;
|
||||
}
|
||||
if (!isCanonicalPilotPair(config, args.tokenInAddress, args.tokenOutAddress)) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const pool = new Contract(config.poolAddress, d3MmAbi, getProvider(config.rpcUrl));
|
||||
const quote = await pool.querySellTokens(
|
||||
normalizeAddress(args.tokenInAddress),
|
||||
normalizeAddress(args.tokenOutAddress),
|
||||
BigInt(args.amountIn)
|
||||
);
|
||||
|
||||
return BigInt(String(quote[1]));
|
||||
}
|
||||
|
||||
export async function getChain138DodoV3PilotEdges(): Promise<SwapGraphEdge[]> {
|
||||
const config = getPilotConfig();
|
||||
if (!config.enabled || !config.poolAddress || !config.proxyAddress || !config.weth10Address || !config.usdtAddress) {
|
||||
return [];
|
||||
}
|
||||
const executionLive = isChain138DodoV3ExecutionLive();
|
||||
|
||||
const pool = new Contract(config.poolAddress, d3MmAbi, getProvider(config.rpcUrl));
|
||||
const [wethReserveRaw, usdtReserveRaw] = await Promise.all([
|
||||
pool.getTokenReserve(config.weth10Address),
|
||||
pool.getTokenReserve(config.usdtAddress),
|
||||
]);
|
||||
|
||||
const wethReserve = BigInt(String(wethReserveRaw));
|
||||
const usdtReserve = BigInt(String(usdtReserveRaw));
|
||||
if (wethReserve <= 0n || usdtReserve <= 0n) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const totalLiquidityUsd = approximateLiquidityUsd({
|
||||
wethReserve,
|
||||
usdtReserve,
|
||||
wethUsdPrice: config.wethUsdPrice,
|
||||
liquidityOverrideUsd: config.liquidityOverrideUsd,
|
||||
});
|
||||
const notes = buildNotes(config.poolAddress, config.proxyAddress, executionLive);
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'dodo_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: config.weth10Address,
|
||||
tokenOutAddress: config.usdtAddress,
|
||||
tokenInSymbol: 'WETH10',
|
||||
tokenOutSymbol: 'USDT',
|
||||
reserveIn: wethReserve.toString(),
|
||||
reserveOut: usdtReserve.toString(),
|
||||
target: config.proxyAddress,
|
||||
poolAddress: config.poolAddress,
|
||||
providerData: {
|
||||
poolAddress: config.poolAddress,
|
||||
quoteMethod: 'querySellTokens',
|
||||
executionTarget: config.proxyAddress,
|
||||
},
|
||||
providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(config.poolAddress) : undefined,
|
||||
totalLiquidityUsd,
|
||||
freshnessSeconds: 0,
|
||||
notes,
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'dodo_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: config.usdtAddress,
|
||||
tokenOutAddress: config.weth10Address,
|
||||
tokenInSymbol: 'USDT',
|
||||
tokenOutSymbol: 'WETH10',
|
||||
reserveIn: usdtReserve.toString(),
|
||||
reserveOut: wethReserve.toString(),
|
||||
target: config.proxyAddress,
|
||||
poolAddress: config.poolAddress,
|
||||
providerData: {
|
||||
poolAddress: config.poolAddress,
|
||||
quoteMethod: 'querySellTokens',
|
||||
executionTarget: config.proxyAddress,
|
||||
},
|
||||
providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(config.poolAddress) : undefined,
|
||||
totalLiquidityUsd,
|
||||
freshnessSeconds: 0,
|
||||
notes,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Interface, ZeroAddress } from 'ethers';
|
||||
import { BestExecutionPlanner } from './best-execution-planner';
|
||||
import { EncodedBridgeIntentPlan, EncodedPlannerRoutePlan, PlannerRequest } from './planner-v2-types';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_ROUTER_V2_ADDRESS = '0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce';
|
||||
const DEFAULT_INTENT_BRIDGE_COORDINATOR_V2_ADDRESS = '0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7';
|
||||
|
||||
const routeInterface = new Interface([
|
||||
'function executeRoute((uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs) plan)',
|
||||
]);
|
||||
|
||||
const intentInterface = new Interface([
|
||||
'function executeIntent((((uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs),bytes32 bridgeType,bytes bridgeData,(uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs),address recipient,uint256 deadline)) intent)',
|
||||
]);
|
||||
|
||||
export interface InternalExecutionPlanV2Result {
|
||||
generatedAt: string;
|
||||
plannerResponse: Awaited<ReturnType<BestExecutionPlanner['plan']>>;
|
||||
execution?: {
|
||||
kind: 'route' | 'bridge-intent';
|
||||
contractAddress: string;
|
||||
functionName: string;
|
||||
signature: string;
|
||||
args: [EncodedPlannerRoutePlan] | [EncodedBridgeIntentPlan];
|
||||
encodedCalldata: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class InternalExecutionPlanV2Builder {
|
||||
private planner: BestExecutionPlanner;
|
||||
|
||||
constructor(planner = new BestExecutionPlanner()) {
|
||||
this.planner = planner;
|
||||
}
|
||||
|
||||
async build(request: PlannerRequest): Promise<InternalExecutionPlanV2Result> {
|
||||
const plannerResponse = await this.planner.plan(request);
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
if (plannerResponse.bridgeIntentPlan) {
|
||||
const contractAddress = (
|
||||
process.env.INTENT_BRIDGE_COORDINATOR_V2_ADDRESS ||
|
||||
(plannerResponse.sourceChainId === CHAIN_138 ? DEFAULT_INTENT_BRIDGE_COORDINATOR_V2_ADDRESS : '')
|
||||
).trim().toLowerCase();
|
||||
if (!contractAddress) {
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
error: 'INTENT_BRIDGE_COORDINATOR_V2_ADDRESS is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
execution: {
|
||||
kind: 'bridge-intent',
|
||||
contractAddress,
|
||||
functionName: 'executeIntent',
|
||||
signature: 'executeIntent(((uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]),bytes32,bytes,(uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]),address,uint256))',
|
||||
args: [plannerResponse.bridgeIntentPlan],
|
||||
encodedCalldata: intentInterface.encodeFunctionData('executeIntent', [plannerResponse.bridgeIntentPlan]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (plannerResponse.routePlan && plannerResponse.routePlan.legs.length > 0) {
|
||||
const contractAddress = (
|
||||
process.env.ENHANCED_SWAP_ROUTER_V2_ADDRESS ||
|
||||
(plannerResponse.sourceChainId === CHAIN_138 ? DEFAULT_ROUTER_V2_ADDRESS : '')
|
||||
).trim().toLowerCase();
|
||||
if (!contractAddress) {
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
error: 'ENHANCED_SWAP_ROUTER_V2_ADDRESS is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
execution: {
|
||||
kind: 'route',
|
||||
contractAddress,
|
||||
functionName: 'executeRoute',
|
||||
signature: 'executeRoute((uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]))',
|
||||
args: [plannerResponse.routePlan],
|
||||
encodedCalldata: routeInterface.encodeFunctionData('executeRoute', [plannerResponse.routePlan]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
error: plannerResponse.decision === 'bridge-only'
|
||||
? 'Bridge-only planner result does not require EnhancedSwapRouterV2 calldata'
|
||||
: plannerResponse.legs.length > 0
|
||||
? 'Planner route includes one or more providers that are not yet executable through EnhancedSwapRouterV2'
|
||||
: 'No executable planner route found',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function buildInternalExecutionPlan(
|
||||
const reserveMap = buildPoolReserveMap(matrix.liveSwapRoutes);
|
||||
const amountIn = BigInt(request.amountIn);
|
||||
const slippageBps = request.slippageBps || '100';
|
||||
const executorAddress = route.legs?.[0]?.executorAddress || '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
|
||||
const executorAddress = route.legs?.[0]?.executorAddress || '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
const steps: InternalExecutionStep[] = [];
|
||||
let currentAmount = amountIn;
|
||||
|
||||
178
services/token-aggregation/src/services/live-dodo-fallback.ts
Normal file
178
services/token-aggregation/src/services/live-dodo-fallback.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ethers } from 'ethers';
|
||||
import type { LiquidityPool } from '../database/repositories/pool-repo';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { getDexFactories } from '../config/dex-factories';
|
||||
import { shouldExposePublicPool } from '../config/gru-transport';
|
||||
import { logger } from '../utils/logger';
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
|
||||
const DODO_PMM_INTEGRATION_ABI = [
|
||||
'function getAllPools() view returns (address[])',
|
||||
'function getPoolConfig(address) view returns (tuple(address pool, address baseToken, address quoteToken, uint256 lpFeeRate, uint256 i, uint256 k, bool isOpenTWAP, uint256 createdAt))',
|
||||
'function getPoolReserves(address) view returns (uint256 baseReserve, uint256 quoteReserve)',
|
||||
'function getPoolPriceOrOracle(address) view returns (uint256 price)',
|
||||
];
|
||||
|
||||
const DODO_DVM_POOL_ABI = [
|
||||
'function _BASE_TOKEN_() view returns (address)',
|
||||
'function _QUOTE_TOKEN_() view returns (address)',
|
||||
'function getVaultReserve() view returns (uint256 baseReserve, uint256 quoteReserve)',
|
||||
'function getMidPrice() view returns (uint256)',
|
||||
];
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
const livePoolCache = new Map<number, { expiresAt: number; pools: LiquidityPool[] }>();
|
||||
|
||||
interface LivePoolSnapshot {
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
reserve0: bigint;
|
||||
reserve1: bigint;
|
||||
price: bigint;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function configuredIntegrations(chainId: number): string[] {
|
||||
const dodoConfigs = getDexFactories(chainId)?.dodo || [];
|
||||
return [...new Set(
|
||||
dodoConfigs
|
||||
.map((config) => config.dodoPmmIntegration?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)];
|
||||
}
|
||||
|
||||
async function readPoolViaIntegration(
|
||||
integration: ethers.Contract,
|
||||
poolAddressRaw: string
|
||||
): Promise<LivePoolSnapshot> {
|
||||
const [configResult, reservesResult, priceResult] = await Promise.all([
|
||||
integration.getPoolConfig(poolAddressRaw),
|
||||
integration.getPoolReserves(poolAddressRaw),
|
||||
integration.getPoolPriceOrOracle(poolAddressRaw).catch(() => 0n),
|
||||
]);
|
||||
|
||||
const cfg = configResult as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
bigint,
|
||||
bigint,
|
||||
bigint,
|
||||
boolean,
|
||||
bigint,
|
||||
];
|
||||
const [reserve0, reserve1] = reservesResult as [bigint, bigint];
|
||||
|
||||
return {
|
||||
token0Address: cfg[1].toLowerCase(),
|
||||
token1Address: cfg[2].toLowerCase(),
|
||||
reserve0,
|
||||
reserve1,
|
||||
price: typeof priceResult === 'bigint' ? priceResult : 0n,
|
||||
createdAt: cfg[7] ? new Date(Number(cfg[7]) * 1000) : new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPoolDirectly(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
poolAddressRaw: string
|
||||
): Promise<LivePoolSnapshot> {
|
||||
const pool = new ethers.Contract(poolAddressRaw, DODO_DVM_POOL_ABI, provider);
|
||||
const [baseToken, quoteToken, reservesResult, midPriceResult] = await Promise.all([
|
||||
pool._BASE_TOKEN_(),
|
||||
pool._QUOTE_TOKEN_(),
|
||||
pool.getVaultReserve(),
|
||||
pool.getMidPrice().catch(() => 0n),
|
||||
]);
|
||||
const [reserve0, reserve1] = reservesResult as [bigint, bigint];
|
||||
|
||||
return {
|
||||
token0Address: String(baseToken).toLowerCase(),
|
||||
token1Address: String(quoteToken).toLowerCase(),
|
||||
reserve0,
|
||||
reserve1,
|
||||
price: typeof midPriceResult === 'bigint' ? midPriceResult : 0n,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLiveDodoPools(chainId: number): Promise<LiquidityPool[]> {
|
||||
const now = Date.now();
|
||||
const cached = livePoolCache.get(chainId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.pools;
|
||||
}
|
||||
|
||||
const chainConfig = getChainConfig(chainId);
|
||||
const integrations = configuredIntegrations(chainId);
|
||||
if (!chainConfig || integrations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(chainConfig.rpcUrl);
|
||||
const poolsByAddress = new Map<string, LiquidityPool>();
|
||||
|
||||
for (const integrationAddress of integrations) {
|
||||
try {
|
||||
const integration = new ethers.Contract(
|
||||
integrationAddress,
|
||||
DODO_PMM_INTEGRATION_ABI,
|
||||
provider
|
||||
);
|
||||
const poolAddresses = (await integration.getAllPools()) as string[];
|
||||
|
||||
for (const poolAddressRaw of poolAddresses) {
|
||||
try {
|
||||
const poolAddress = poolAddressRaw.toLowerCase();
|
||||
const snapshot =
|
||||
await readPoolViaIntegration(integration, poolAddressRaw).catch(async () =>
|
||||
readPoolDirectly(provider, poolAddressRaw)
|
||||
);
|
||||
const { token0Address, token1Address, reserve0, reserve1, price, createdAt } = snapshot;
|
||||
|
||||
if (!shouldExposePublicPool(chainId, poolAddress, token0Address, token1Address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const liquidityUsd = chainId === 138
|
||||
? estimateChain138DodoLiquidityUsd({
|
||||
token0Address,
|
||||
token1Address,
|
||||
reserve0,
|
||||
reserve1,
|
||||
price,
|
||||
})
|
||||
: { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
|
||||
poolsByAddress.set(poolAddress, {
|
||||
chainId,
|
||||
poolAddress,
|
||||
token0Address,
|
||||
token1Address,
|
||||
dexType: 'dodo',
|
||||
factoryAddress: integrationAddress,
|
||||
reserve0: reserve0.toString(),
|
||||
reserve1: reserve1.toString(),
|
||||
reserve0Usd: liquidityUsd.reserve0Usd,
|
||||
reserve1Usd: liquidityUsd.reserve1Usd,
|
||||
totalLiquidityUsd: liquidityUsd.totalLiquidityUsd,
|
||||
volume24h: 0,
|
||||
createdAtBlock: 0,
|
||||
createdAtTimestamp: createdAt,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`Skipping unreadable live DODO pool ${poolAddressRaw} on chain ${chainId}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Live DODO fallback failed for chain ${chainId} integration ${integrationAddress}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pools = Array.from(poolsByAddress.values()).sort(
|
||||
(a, b) => b.totalLiquidityUsd - a.totalLiquidityUsd
|
||||
);
|
||||
livePoolCache.set(chainId, { expiresAt: now + CACHE_TTL_MS, pools });
|
||||
return pools;
|
||||
}
|
||||
187
services/token-aggregation/src/services/planner-v2-types.ts
Normal file
187
services/token-aggregation/src/services/planner-v2-types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
export type PlannerProvider = 'dodo' | 'dodo_v3' | 'uniswap_v3' | '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';
|
||||
|
||||
export interface PlannerConstraints {
|
||||
maxSlippageBps?: number;
|
||||
allowedProviders?: PlannerProvider[];
|
||||
maxLegs?: number;
|
||||
allowedIntermediates?: string[];
|
||||
complianceProfile?: ComplianceProfile;
|
||||
allowBridge?: boolean;
|
||||
preferredBridges?: string[];
|
||||
allowCommodityIntermediates?: boolean;
|
||||
}
|
||||
|
||||
export interface PlannerRequest {
|
||||
sourceChainId: number;
|
||||
destinationChainId?: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
recipient?: string;
|
||||
constraints?: PlannerConstraints;
|
||||
}
|
||||
|
||||
export interface EncodedPlannerRouteLeg {
|
||||
provider: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountSource: number;
|
||||
minAmountOut: string;
|
||||
target: string;
|
||||
providerData: string;
|
||||
}
|
||||
|
||||
export interface EncodedPlannerRoutePlan {
|
||||
chainId: number;
|
||||
inputToken: string;
|
||||
outputToken: string;
|
||||
amountIn: string;
|
||||
minAmountOut: string;
|
||||
recipient: string;
|
||||
deadline: string;
|
||||
legs: EncodedPlannerRouteLeg[];
|
||||
}
|
||||
|
||||
export interface EncodedBridgeIntentPlan {
|
||||
sourcePlan: EncodedPlannerRoutePlan;
|
||||
bridgeType: string;
|
||||
bridgeData: string;
|
||||
destinationPlan: EncodedPlannerRoutePlan;
|
||||
recipient: string;
|
||||
deadline: string;
|
||||
}
|
||||
|
||||
export interface PlannerLeg {
|
||||
kind: PlannerLegKind;
|
||||
provider: PlannerProvider;
|
||||
sourceChainId: number;
|
||||
destinationChainId: number;
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
tokenInSymbol?: string;
|
||||
tokenOutSymbol?: string;
|
||||
estimatedAmountIn: string;
|
||||
estimatedAmountOut: string;
|
||||
minAmountOut: string;
|
||||
target?: string;
|
||||
poolAddress?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
bridgeType?: string;
|
||||
bridgeAddress?: string;
|
||||
gasEstimate?: number;
|
||||
freshnessSeconds?: number | null;
|
||||
totalLiquidityUsd?: number;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface PlannerAlternative {
|
||||
routeId: string;
|
||||
decision: PlannerDecision;
|
||||
estimatedAmountOut: string;
|
||||
estimatedGasUsd: number;
|
||||
providerPath: PlannerProvider[];
|
||||
legCount: number;
|
||||
score: number;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PlannerStaleness {
|
||||
maxFreshnessSeconds: number | null;
|
||||
hasStaleLeg: boolean;
|
||||
}
|
||||
|
||||
export interface PlannerResponse {
|
||||
planId: string;
|
||||
generatedAt: string;
|
||||
decision: PlannerDecision;
|
||||
sourceChainId: number;
|
||||
destinationChainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
estimatedAmountOut: string | null;
|
||||
minAmountOut: string | null;
|
||||
estimatedGasUsd: number;
|
||||
legs: PlannerLeg[];
|
||||
alternatives: PlannerAlternative[];
|
||||
confidenceScore: number;
|
||||
riskFlags: string[];
|
||||
selectedRouteReason: string;
|
||||
rejectedAlternatives: string[];
|
||||
staleness: PlannerStaleness;
|
||||
routePlan?: EncodedPlannerRoutePlan;
|
||||
bridgeIntentPlan?: EncodedBridgeIntentPlan;
|
||||
}
|
||||
|
||||
export interface ProviderPairCapability {
|
||||
chainId: number;
|
||||
provider: PlannerProvider;
|
||||
legType: PlannerLegKind;
|
||||
status: 'live' | 'planned' | 'blocked';
|
||||
tokenInSymbol: string;
|
||||
tokenInAddress: string;
|
||||
tokenOutSymbol: string;
|
||||
tokenOutAddress: string;
|
||||
target?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
notes?: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilityRecord {
|
||||
chainId: number;
|
||||
provider: PlannerProvider;
|
||||
executionMode: 'onchain' | 'partner';
|
||||
live: boolean;
|
||||
quoteLive: boolean;
|
||||
executionLive: boolean;
|
||||
supportedLegTypes: PlannerLegKind[];
|
||||
pairs: ProviderPairCapability[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface RoutingPolicy {
|
||||
profile: ComplianceProfile;
|
||||
allowedProviders: PlannerProvider[];
|
||||
defaultIntermediateAddresses: string[];
|
||||
allowBridge: boolean;
|
||||
allowedBridgeLabels: string[];
|
||||
maxLegs: number;
|
||||
allowCommodityIntermediates: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface SwapGraphEdge {
|
||||
kind: 'swap';
|
||||
provider: PlannerProvider;
|
||||
chainId: number;
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
tokenInSymbol?: string;
|
||||
tokenOutSymbol?: string;
|
||||
reserveIn: string;
|
||||
reserveOut: string;
|
||||
target?: string;
|
||||
poolAddress?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
totalLiquidityUsd: number;
|
||||
freshnessSeconds: number | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface BridgeRouteCandidate {
|
||||
bridgeType: string;
|
||||
bridgeAddress: string;
|
||||
bridgeLabel: string;
|
||||
assetSymbol: string;
|
||||
sourceTokenAddress: string;
|
||||
destinationTokenAddress: string;
|
||||
fromChainId: number;
|
||||
toChainId: number;
|
||||
notes: string[];
|
||||
}
|
||||
58
services/token-aggregation/src/services/pmm-onchain-quote.ts
Normal file
58
services/token-aggregation/src/services/pmm-onchain-quote.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
|
||||
const POOL_ABI = [
|
||||
'function _BASE_TOKEN_() view returns (address)',
|
||||
'function _QUOTE_TOKEN_() view returns (address)',
|
||||
'function querySellBase(address,uint256) view returns (uint256,uint256)',
|
||||
'function querySellQuote(address,uint256) view returns (uint256,uint256)',
|
||||
];
|
||||
|
||||
/**
|
||||
* PMM / DVM on-chain output for tokenIn amount. Matches DODOPMMIntegration.swapExactIn semantics.
|
||||
* Returns null if RPC fails, pool is not a DVM, or tokenIn is not base/quote.
|
||||
*/
|
||||
export async function pmmQuoteAmountOutFromChain(params: {
|
||||
rpcUrl: string;
|
||||
poolAddress: string;
|
||||
tokenInLookup: string;
|
||||
amountIn: bigint;
|
||||
/** Passed to querySell* (MT fee view); default deployer is typical for operator tooling. */
|
||||
traderForView: string;
|
||||
}): Promise<bigint | null> {
|
||||
const { rpcUrl, poolAddress, tokenInLookup, amountIn, traderForView } = params;
|
||||
try {
|
||||
const provider = new JsonRpcProvider(rpcUrl);
|
||||
const pool = new Contract(poolAddress, POOL_ABI, provider);
|
||||
const base = (await pool._BASE_TOKEN_()).toString().toLowerCase();
|
||||
const quote = (await pool._QUOTE_TOKEN_()).toString().toLowerCase();
|
||||
const ti = tokenInLookup.toLowerCase();
|
||||
if (ti === base) {
|
||||
const [out] = await pool.querySellBase(traderForView, amountIn);
|
||||
return BigInt(out.toString());
|
||||
}
|
||||
if (ti === quote) {
|
||||
const [out] = await pool.querySellQuote(traderForView, amountIn);
|
||||
return BigInt(out.toString());
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** RPC for PMM eth_call quotes on Chain 138 (optional; unset = skip on-chain override). */
|
||||
export function resolvePmmQuoteRpcUrl(): string {
|
||||
return (
|
||||
process.env.TOKEN_AGGREGATION_PMM_RPC_URL ||
|
||||
process.env.TOKEN_AGGREGATION_CHAIN138_RPC_URL ||
|
||||
process.env.RPC_URL_138 ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
export function resolvePmmQuoteTrader(): string {
|
||||
return (
|
||||
process.env.TOKEN_AGGREGATION_PMM_QUERY_TRADER ||
|
||||
'0x4A666F96fC8764181194447A7dFdb7d471b301C8'
|
||||
).trim();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ResolvedTokenDisplay } from './token-display';
|
||||
import { classifyPoolDepthStatus, estimateChain138FallbackDepthUsd } from './route-decision-tree';
|
||||
|
||||
function token(address: string, symbol: string, decimals: number): ResolvedTokenDisplay {
|
||||
return {
|
||||
address,
|
||||
symbol,
|
||||
name: symbol,
|
||||
decimals,
|
||||
source: 'canonical',
|
||||
};
|
||||
}
|
||||
|
||||
describe('estimateChain138FallbackDepthUsd', () => {
|
||||
it('derives tvl from the stable side for funded mixed pairs', () => {
|
||||
const weth10 = token('0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f', 'WETH10', 18);
|
||||
const usdt = token('0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', 'USDT', 6);
|
||||
|
||||
const result = estimateChain138FallbackDepthUsd(
|
||||
weth10,
|
||||
50n * 10n ** 18n,
|
||||
usdt,
|
||||
200_000n * 10n ** 6n
|
||||
);
|
||||
|
||||
expect(result.tvlUsd).toBe(400_000);
|
||||
expect(result.estimatedTradeCapacityUsd).toBe(80_000);
|
||||
});
|
||||
|
||||
it('adds both sides for funded stable-to-stable pools', () => {
|
||||
const cusdcV2 = token('0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d', 'cUSDC_V2', 6);
|
||||
const usdc = token('0x71D6687F38b93CCad569Fa6352c876eea967201b', 'USDC', 6);
|
||||
|
||||
const result = estimateChain138FallbackDepthUsd(
|
||||
cusdcV2,
|
||||
1_000_000n * 10n ** 6n,
|
||||
usdc,
|
||||
1_000_000n * 10n ** 6n
|
||||
);
|
||||
|
||||
expect(result.tvlUsd).toBe(2_000_000);
|
||||
expect(result.estimatedTradeCapacityUsd).toBe(400_000);
|
||||
});
|
||||
|
||||
it('keeps zero-dollar metrics for non-stable or partially funded pools', () => {
|
||||
const weth = token('0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f', 'WETH10', 18);
|
||||
const cxauc = token('0x290E52a8819A4fbD0714E517225429aA2B70EC6b', 'cXAUC', 18);
|
||||
|
||||
expect(
|
||||
estimateChain138FallbackDepthUsd(weth, 10n * 10n ** 18n, cxauc, 5n * 10n ** 18n)
|
||||
).toEqual({
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
estimateChain138FallbackDepthUsd(weth, 10n * 10n ** 18n, token('0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', 'USDT', 6), 0n)
|
||||
).toEqual({
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyPoolDepthStatus', () => {
|
||||
it('marks zero-liquidity pools unavailable even if freshness is recent', () => {
|
||||
expect(classifyPoolDepthStatus(0, 0, 0)).toBe('unavailable');
|
||||
expect(classifyPoolDepthStatus(1000, 0, 120)).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('keeps funded pools live or stale based on freshness', () => {
|
||||
expect(classifyPoolDepthStatus(1000, 200, 60)).toBe('live');
|
||||
expect(classifyPoolDepthStatus(1000, 200, 900)).toBe('stale');
|
||||
expect(classifyPoolDepthStatus(1000, 200, 3600)).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,18 @@ import { TokenRepository } from '../database/repositories/token-repo';
|
||||
import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { ResolvedTokenDisplay, resolvePoolTokenDisplays, resolveTokenDisplay } from './token-display';
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
import { Contract, JsonRpcProvider, formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from '../config/canonical-tokens';
|
||||
import { getRouteFromRegistry } from '../config/cross-chain-bridges';
|
||||
import { filterPoolsForRouting } from '../config/gru-transport';
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_138_PMM_INTEGRATION =
|
||||
process.env.CHAIN_138_DODO_PMM_INTEGRATION ||
|
||||
process.env.DODO_PMM_INTEGRATION_ADDRESS ||
|
||||
process.env.DODO_PMM_INTEGRATION ||
|
||||
'0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
const PMM_ABI = [
|
||||
'function pools(address,address) view returns (address)',
|
||||
@@ -21,6 +23,8 @@ const ERC20_ABI = [
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
];
|
||||
|
||||
const CHAIN_138_FALLBACK_CAPACITY_RATIO = 0.2;
|
||||
|
||||
export type RouteNodeKind =
|
||||
| 'direct-pool'
|
||||
| 'bridge'
|
||||
@@ -97,8 +101,24 @@ export interface RouteDecisionTreeResponse {
|
||||
missingQuoteTokenPools: MissingQuoteTokenPool[];
|
||||
}
|
||||
|
||||
function normalizedTvlUsd(pool: LiquidityPool): number {
|
||||
let tvl = Math.max(0, pool.totalLiquidityUsd || 0);
|
||||
if (pool.chainId === CHAIN_138 && pool.dexType === 'dodo') {
|
||||
const estimated = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: pool.token0Address,
|
||||
token1Address: pool.token1Address,
|
||||
reserve0: BigInt(pool.reserve0 || '0'),
|
||||
reserve1: BigInt(pool.reserve1 || '0'),
|
||||
}).totalLiquidityUsd;
|
||||
if (estimated > 0) {
|
||||
tvl = Math.max(tvl, estimated);
|
||||
}
|
||||
}
|
||||
return tvl;
|
||||
}
|
||||
|
||||
function estimateTradeCapacityUsd(pool: LiquidityPool): number {
|
||||
const tvl = Math.max(0, pool.totalLiquidityUsd || 0);
|
||||
const tvl = normalizedTvlUsd(pool);
|
||||
if (tvl === 0) return 0;
|
||||
const freshnessBoost = pool.lastUpdated
|
||||
? Math.max(0.25, 1 - (Date.now() - pool.lastUpdated.getTime()) / (60 * 60 * 1000))
|
||||
@@ -107,26 +127,101 @@ function estimateTradeCapacityUsd(pool: LiquidityPool): number {
|
||||
return Math.max(0, Math.min(tvl, capacity));
|
||||
}
|
||||
|
||||
export function classifyPoolDepthStatus(
|
||||
tvlUsd: number,
|
||||
estimatedTradeCapacityUsd: number,
|
||||
freshnessSeconds: number | null
|
||||
): RouteDepthMetrics['status'] {
|
||||
if (!(tvlUsd > 0) || !(estimatedTradeCapacityUsd > 0)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (freshnessSeconds === null) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (freshnessSeconds < 300) {
|
||||
return 'live';
|
||||
}
|
||||
if (freshnessSeconds < 1800) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
function buildDepth(pool: LiquidityPool): RouteDepthMetrics {
|
||||
const freshnessSeconds = pool.lastUpdated ? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000)) : null;
|
||||
const status = freshnessSeconds === null
|
||||
? 'unavailable'
|
||||
: freshnessSeconds < 300
|
||||
? 'live'
|
||||
: freshnessSeconds < 1800
|
||||
? 'stale'
|
||||
: 'unavailable';
|
||||
const tvlUsd = normalizedTvlUsd(pool);
|
||||
const estimatedTradeCapacityUsd = estimateTradeCapacityUsd(pool);
|
||||
const status = classifyPoolDepthStatus(tvlUsd, estimatedTradeCapacityUsd, freshnessSeconds);
|
||||
|
||||
return {
|
||||
tvlUsd: pool.totalLiquidityUsd || 0,
|
||||
tvlUsd,
|
||||
reserve0: pool.reserve0,
|
||||
reserve1: pool.reserve1,
|
||||
estimatedTradeCapacityUsd: estimateTradeCapacityUsd(pool),
|
||||
estimatedTradeCapacityUsd,
|
||||
freshnessSeconds,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function isChain138UsdFallbackToken(token: ResolvedTokenDisplay): boolean {
|
||||
if (!token?.address) return false;
|
||||
const spec = getCanonicalTokenByAddress(CHAIN_138, token.address);
|
||||
return spec?.currencyCode === 'USD';
|
||||
}
|
||||
|
||||
function getChain138FallbackTokenDecimals(token: ResolvedTokenDisplay): number {
|
||||
const spec = token?.address ? getCanonicalTokenByAddress(CHAIN_138, token.address) : undefined;
|
||||
return Number(token?.decimals ?? spec?.decimals ?? 18);
|
||||
}
|
||||
|
||||
function parseFallbackTokenAmount(value: bigint, decimals: number): number {
|
||||
if (value <= 0n) return 0;
|
||||
const parsed = Number(formatUnits(value, decimals));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function estimateChain138FallbackDepthUsd(
|
||||
token0: ResolvedTokenDisplay,
|
||||
reserve0: bigint,
|
||||
token1: ResolvedTokenDisplay,
|
||||
reserve1: bigint
|
||||
): Pick<RouteDepthMetrics, 'tvlUsd' | 'estimatedTradeCapacityUsd'> {
|
||||
if (reserve0 <= 0n || reserve1 <= 0n) {
|
||||
return {
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const reserve0Usd = isChain138UsdFallbackToken(token0)
|
||||
? parseFallbackTokenAmount(reserve0, getChain138FallbackTokenDecimals(token0))
|
||||
: 0;
|
||||
const reserve1Usd = isChain138UsdFallbackToken(token1)
|
||||
? parseFallbackTokenAmount(reserve1, getChain138FallbackTokenDecimals(token1))
|
||||
: 0;
|
||||
|
||||
let tvlUsd = 0;
|
||||
if (reserve0Usd > 0 && reserve1Usd > 0) {
|
||||
tvlUsd = reserve0Usd + reserve1Usd;
|
||||
} else if (reserve0Usd > 0) {
|
||||
tvlUsd = reserve0Usd * 2;
|
||||
} else if (reserve1Usd > 0) {
|
||||
tvlUsd = reserve1Usd * 2;
|
||||
}
|
||||
|
||||
if (tvlUsd <= 0) {
|
||||
return {
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tvlUsd,
|
||||
estimatedTradeCapacityUsd: Math.max(0, Math.min(tvlUsd, tvlUsd * CHAIN_138_FALLBACK_CAPACITY_RATIO)),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveDecision(
|
||||
sourceChainId: number,
|
||||
destinationChainId: number | undefined,
|
||||
@@ -183,7 +278,10 @@ export class RouteDecisionTreeService {
|
||||
if (request.tokenOut) acceptableTokenOutAddresses.add(request.tokenOut.toLowerCase());
|
||||
if (bridgeResolution?.localQuoteAddress) acceptableTokenOutAddresses.add(bridgeResolution.localQuoteAddress.toLowerCase());
|
||||
|
||||
const pools = await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn);
|
||||
const pools = filterPoolsForRouting(
|
||||
request.chainId,
|
||||
await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn)
|
||||
);
|
||||
const directPools = request.tokenOut
|
||||
? pools.filter((pool) =>
|
||||
acceptableTokenOutAddresses.has(pool.token0Address.toLowerCase()) ||
|
||||
@@ -193,7 +291,10 @@ export class RouteDecisionTreeService {
|
||||
|
||||
const destinationPools =
|
||||
request.tokenOut && destinationChainId !== request.chainId
|
||||
? await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut)
|
||||
? filterPoolsForRouting(
|
||||
destinationChainId,
|
||||
await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut)
|
||||
)
|
||||
: [];
|
||||
|
||||
let resolvedPools = await Promise.all(
|
||||
@@ -238,10 +339,19 @@ export class RouteDecisionTreeService {
|
||||
})
|
||||
);
|
||||
|
||||
if (request.chainId === CHAIN_138 && destinationChainId === CHAIN_138 && request.tokenOut && resolvedPools.length === 0) {
|
||||
const shouldTryLiveFallback =
|
||||
request.chainId === CHAIN_138 &&
|
||||
destinationChainId === CHAIN_138 &&
|
||||
request.tokenOut &&
|
||||
(resolvedPools.length === 0 || resolvedPools.every((pool) => pool.depth.status === 'unavailable'));
|
||||
|
||||
if (shouldTryLiveFallback) {
|
||||
const liveFallbackPool = await this.findLiveDirectPoolFallback(request, sourceTokenIn, sourceTokenOut);
|
||||
if (liveFallbackPool) {
|
||||
resolvedPools = [liveFallbackPool, ...resolvedPools];
|
||||
resolvedPools = [
|
||||
liveFallbackPool,
|
||||
...resolvedPools.filter((pool) => pool.poolAddress.toLowerCase() !== liveFallbackPool.poolAddress.toLowerCase()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,17 +513,19 @@ export class RouteDecisionTreeService {
|
||||
tokenOutContract.balanceOf(poolAddress),
|
||||
]);
|
||||
const live = reserve0 > 0n && reserve1 > 0n;
|
||||
const token1 = sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut);
|
||||
const depthUsd = estimateChain138FallbackDepthUsd(sourceTokenIn, reserve0, token1, reserve1);
|
||||
|
||||
return {
|
||||
poolAddress,
|
||||
dexType: 'dodo',
|
||||
token0: sourceTokenIn,
|
||||
token1: sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut),
|
||||
token1,
|
||||
depth: {
|
||||
tvlUsd: 0,
|
||||
tvlUsd: depthUsd.tvlUsd,
|
||||
reserve0: reserve0.toString(),
|
||||
reserve1: reserve1.toString(),
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
estimatedTradeCapacityUsd: depthUsd.estimatedTradeCapacityUsd,
|
||||
freshnessSeconds: 0,
|
||||
status: live ? 'live' : 'stale',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { LiquidityPool, PoolRepository } from '../database/repositories/pool-repo';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
|
||||
jest.mock('./dodo-v3-pilot', () => ({
|
||||
__esModule: true,
|
||||
getChain138DodoV3PilotEdges: jest.fn().mockResolvedValue([]),
|
||||
isChain138DodoV3ExecutionLive: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('./chain138-pilot-venues', () => ({
|
||||
__esModule: true,
|
||||
getChain138PilotVenueEdges: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('./live-dodo-fallback', () => ({
|
||||
__esModule: true,
|
||||
getLiveDodoPools: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
function pool(overrides: Partial<LiquidityPool>): LiquidityPool {
|
||||
return {
|
||||
chainId: 138,
|
||||
poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
|
||||
token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
dexType: 'dodo',
|
||||
reserve0: '1000000',
|
||||
reserve1: '1000000',
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
volume24h: 0,
|
||||
lastUpdated: new Date('2026-04-05T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class MockPoolRepo {
|
||||
constructor(private readonly pools: LiquidityPool[]) {}
|
||||
|
||||
async getPoolsByChain(): Promise<LiquidityPool[]> {
|
||||
return this.pools;
|
||||
}
|
||||
}
|
||||
|
||||
describe('RouteGraphBuilder', () => {
|
||||
it('filters dust Chain 138 DODO pools from planner visibility', async () => {
|
||||
const builder = new RouteGraphBuilder(
|
||||
new MockPoolRepo([
|
||||
pool({
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
token0Address: '0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
token1Address: '0x71d6687f38b93ccad569fa6352c876eea967201b',
|
||||
reserve0: '999999997998',
|
||||
reserve1: '999999997998',
|
||||
}),
|
||||
pool({
|
||||
poolAddress: '0x2222222222222222222222222222222222222222',
|
||||
reserve0: '1001',
|
||||
reserve1: '1001',
|
||||
}),
|
||||
]) as unknown as PoolRepository
|
||||
);
|
||||
|
||||
const edges = await builder.buildSwapEdges(138);
|
||||
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges.every((edge) => edge.poolAddress === '0x1111111111111111111111111111111111111111')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps non-DODO venues visible even when they are small', async () => {
|
||||
const builder = new RouteGraphBuilder(
|
||||
new MockPoolRepo([
|
||||
pool({
|
||||
poolAddress: '0x3333333333333333333333333333333333333333',
|
||||
dexType: 'uniswap_v3',
|
||||
token0Address: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
token1Address: '0x71D6687F38b93CCad569Fa6352c876eea967201b',
|
||||
reserve0: '10000000000000000',
|
||||
reserve1: '10000',
|
||||
totalLiquidityUsd: 1,
|
||||
}),
|
||||
]) as unknown as PoolRepository
|
||||
);
|
||||
|
||||
const edges = await builder.buildSwapEdges(138);
|
||||
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges[0].provider).toBe('uniswap_v3');
|
||||
expect(edges[1].provider).toBe('uniswap_v3');
|
||||
});
|
||||
});
|
||||
172
services/token-aggregation/src/services/route-graph-builder.ts
Normal file
172
services/token-aggregation/src/services/route-graph-builder.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { AbiCoder } from 'ethers';
|
||||
import { PoolRepository } from '../database/repositories/pool-repo';
|
||||
import type { LiquidityPool } from '../database/repositories/pool-repo';
|
||||
import { filterPoolsForRouting } from '../config/gru-transport';
|
||||
import { findProviderPairCapability } from '../config/provider-capabilities';
|
||||
import { getRouteFromRegistry } from '../config/cross-chain-bridges';
|
||||
import {
|
||||
getRoutingAddressForSymbol,
|
||||
getRoutingSymbolForAddress,
|
||||
} from '../config/routing-assets';
|
||||
import { getChain138DodoV3PilotEdges } from './dodo-v3-pilot';
|
||||
import { getChain138PilotVenueEdges } from './chain138-pilot-venues';
|
||||
import { getLiveDodoPools } from './live-dodo-fallback';
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
import { BridgeRouteCandidate, PlannerProvider, SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_138_MIN_LIVE_DODO_LIQUIDITY_USD = 100;
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function providerFromDexType(dexType: string): PlannerProvider | null {
|
||||
switch (dexType) {
|
||||
case 'dodo':
|
||||
return 'dodo';
|
||||
case 'dodo_v3':
|
||||
return 'dodo_v3';
|
||||
case 'uniswap_v3':
|
||||
return 'uniswap_v3';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupePools<T extends { poolAddress: string }>(items: T[]): T[] {
|
||||
const byAddress = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
byAddress.set(item.poolAddress.toLowerCase(), item);
|
||||
}
|
||||
return Array.from(byAddress.values());
|
||||
}
|
||||
|
||||
function isMeaningfullyFundedPool(chainId: number, pool: LiquidityPool): boolean {
|
||||
if (chainId !== CHAIN_138 || pool.dexType !== 'dodo') {
|
||||
return true;
|
||||
}
|
||||
const reserve0 = BigInt(pool.reserve0 || '0');
|
||||
const reserve1 = BigInt(pool.reserve1 || '0');
|
||||
const estimatedUsd = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: pool.token0Address,
|
||||
token1Address: pool.token1Address,
|
||||
reserve0,
|
||||
reserve1,
|
||||
}).totalLiquidityUsd;
|
||||
const totalLiquidityUsd = Math.max(pool.totalLiquidityUsd || 0, estimatedUsd);
|
||||
return totalLiquidityUsd >= CHAIN_138_MIN_LIVE_DODO_LIQUIDITY_USD;
|
||||
}
|
||||
|
||||
export class RouteGraphBuilder {
|
||||
private poolRepo: PoolRepository;
|
||||
|
||||
constructor(poolRepo = new PoolRepository()) {
|
||||
this.poolRepo = poolRepo;
|
||||
}
|
||||
|
||||
async buildSwapEdges(chainId: number): Promise<SwapGraphEdge[]> {
|
||||
let indexedPools: LiquidityPool[] = [];
|
||||
try {
|
||||
indexedPools = filterPoolsForRouting(chainId, await this.poolRepo.getPoolsByChain(chainId, 500));
|
||||
} catch {
|
||||
indexedPools = [];
|
||||
}
|
||||
const liveDodoPools = chainId === 138
|
||||
? filterPoolsForRouting(chainId, await getLiveDodoPools(chainId))
|
||||
: [];
|
||||
const liveDodoV3PilotEdges = chainId === 138
|
||||
? await getChain138DodoV3PilotEdges()
|
||||
: [];
|
||||
const livePilotVenueEdges = chainId === 138
|
||||
? await getChain138PilotVenueEdges()
|
||||
: [];
|
||||
const pools = dedupePools([...indexedPools, ...liveDodoPools]).filter((pool) =>
|
||||
isMeaningfullyFundedPool(chainId, pool)
|
||||
);
|
||||
|
||||
const poolEdges = pools.flatMap((pool) => {
|
||||
const provider = providerFromDexType(pool.dexType);
|
||||
if (!provider) return [];
|
||||
|
||||
const token0 = normalizeAddress(pool.token0Address);
|
||||
const token1 = normalizeAddress(pool.token1Address);
|
||||
const token0Symbol = getRoutingSymbolForAddress(chainId, token0);
|
||||
const token1Symbol = getRoutingSymbolForAddress(chainId, token1);
|
||||
const capability = findProviderPairCapability(chainId, provider, token0, token1)
|
||||
|| findProviderPairCapability(chainId, provider, token1, token0);
|
||||
|
||||
const target = capability?.target || (provider === 'dodo' ? normalizeAddress(process.env.DODO_PMM_PROVIDER_ADDRESS || process.env.DODO_PMM_PROVIDER || '0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e') : undefined);
|
||||
const providerData = capability?.providerData || (provider === 'dodo' ? { poolAddress: pool.poolAddress.toLowerCase() } : undefined);
|
||||
const providerDataHex = capability?.providerDataHex || (provider === 'dodo'
|
||||
? abiCoder.encode(['address'], [pool.poolAddress.toLowerCase()])
|
||||
: undefined);
|
||||
|
||||
const baseEdge = {
|
||||
kind: 'swap' as const,
|
||||
provider,
|
||||
chainId,
|
||||
target,
|
||||
poolAddress: pool.poolAddress.toLowerCase(),
|
||||
providerData,
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: pool.totalLiquidityUsd || 0,
|
||||
freshnessSeconds: pool.lastUpdated
|
||||
? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000))
|
||||
: null,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
...baseEdge,
|
||||
tokenInAddress: token0,
|
||||
tokenOutAddress: token1,
|
||||
tokenInSymbol: token0Symbol,
|
||||
tokenOutSymbol: token1Symbol,
|
||||
reserveIn: String(pool.reserve0),
|
||||
reserveOut: String(pool.reserve1),
|
||||
},
|
||||
{
|
||||
...baseEdge,
|
||||
tokenInAddress: token1,
|
||||
tokenOutAddress: token0,
|
||||
tokenInSymbol: token1Symbol,
|
||||
tokenOutSymbol: token0Symbol,
|
||||
reserveIn: String(pool.reserve1),
|
||||
reserveOut: String(pool.reserve0),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...poolEdges, ...liveDodoV3PilotEdges, ...livePilotVenueEdges];
|
||||
}
|
||||
|
||||
buildBridgeCandidates(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
assetSymbols: string[]
|
||||
): BridgeRouteCandidate[] {
|
||||
return assetSymbols.flatMap((symbol) => {
|
||||
const route = getRouteFromRegistry(fromChainId, toChainId, symbol);
|
||||
if (!route) return [];
|
||||
|
||||
const sourceTokenAddress = getRoutingAddressForSymbol(fromChainId, symbol);
|
||||
const destinationTokenAddress = getRoutingAddressForSymbol(toChainId, symbol);
|
||||
if (!sourceTokenAddress || !destinationTokenAddress) return [];
|
||||
|
||||
return [{
|
||||
bridgeType: route.pathType,
|
||||
bridgeAddress: route.bridgeAddress.toLowerCase(),
|
||||
bridgeLabel: route.label,
|
||||
assetSymbol: route.asset || symbol,
|
||||
sourceTokenAddress: sourceTokenAddress.toLowerCase(),
|
||||
destinationTokenAddress: destinationTokenAddress.toLowerCase(),
|
||||
fromChainId,
|
||||
toChainId,
|
||||
notes: [`Registry route ${route.label}`],
|
||||
}];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import cron from 'node-cron';
|
||||
import { AggregatorRouteMatrixGenerator } from './aggregator-route-matrix-generator';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export function startRouteMatrixScheduler(): void {
|
||||
const cronSpec = (process.env.ROUTE_MATRIX_CRON || '').trim();
|
||||
const generateOnStart = String(process.env.GENERATE_ROUTE_MATRIX_ON_START || 'false').toLowerCase() === 'true';
|
||||
|
||||
if (!cronSpec && !generateOnStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = new AggregatorRouteMatrixGenerator();
|
||||
|
||||
const runGeneration = async () => {
|
||||
try {
|
||||
const outputPath = await generator.writeToFile();
|
||||
logger.info('Planner-v2 route matrix generated', { outputPath });
|
||||
} catch (error) {
|
||||
logger.warn('Planner-v2 route matrix generation failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (generateOnStart) {
|
||||
void runGeneration();
|
||||
}
|
||||
|
||||
if (cronSpec) {
|
||||
cron.schedule(cronSpec, () => {
|
||||
void runGeneration();
|
||||
});
|
||||
logger.info('Planner-v2 route matrix scheduler enabled', { cronSpec });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user