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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user