Add live route matrix and stable bridge decision flows
Some checks are pending
CI/CD Pipeline / Solidity Contracts (push) Waiting to run
CI/CD Pipeline / Security Scanning (push) Waiting to run
CI/CD Pipeline / Lint and Format (push) Waiting to run
CI/CD Pipeline / Terraform Validation (push) Waiting to run
CI/CD Pipeline / Kubernetes Validation (push) Waiting to run
Deploy ChainID 138 / Deploy ChainID 138 (push) Waiting to run
Validation / validate-genesis (push) Waiting to run
Validation / validate-terraform (push) Waiting to run
Validation / validate-kubernetes (push) Waiting to run
Validation / validate-smart-contracts (push) Waiting to run
Validation / validate-security (push) Waiting to run
Validation / validate-documentation (push) Waiting to run
Verify Deployment / Verify Deployment (push) Waiting to run

This commit is contained in:
defiQUG
2026-03-27 12:02:36 -07:00
parent a780eff7c5
commit 721cdeb92f
16 changed files with 2001 additions and 6 deletions

View File

@@ -0,0 +1,74 @@
# Route Decision Tree
This document describes the live route-selection tree used by the Token Aggregation Service.
## What It Does
- Resolves pools from the live indexed database.
- Normalizes token labels using the token table and canonical token list.
- Falls back to raw addresses when token metadata is missing, so "missing quote token" pools still render.
- Returns pool depth and freshness on every request.
- Expands the route tree with bridge and destination-swap legs when a destination chain is provided.
## API
### Live tree
```bash
GET /api/v1/routes/tree?chainId=138&tokenIn=0x...&tokenOut=0x...&amountIn=1000000&destinationChainId=1
```
### Depth summary
```bash
GET /api/v1/routes/depth?chainId=138&tokenIn=0x...&tokenOut=0x...&amountIn=1000000&destinationChainId=1
```
## Decision Order
1. Resolve the source token and destination token.
2. Load all live pools for the source token on the source chain.
3. Prefer direct pools for the requested pair, ordered by TVL.
4. If a destination chain is provided, add:
- bridge leg
- destination swap leg, when destination liquidity exists
5. Mark pools with stale or missing depth so routing can avoid them.
## Decision Tree
```mermaid
flowchart TD
A["Start"] --> B["Resolve source token"]
B --> C["Load live source pools"]
C --> D{"Direct pool exists?"}
D -->|Yes| E["Rank by TVL and freshness"]
D -->|No| F["Bridge or fallback route"]
E --> G{"Destination chain provided?"}
F --> G
G -->|No| H["Return direct-pool decision"]
G -->|Yes| I["Add bridge leg"]
I --> J{"Destination liquidity exists?"}
J -->|Yes| K["Add destination swap leg"]
J -->|No| L["Bridge-only decision"]
K --> M["Return atomic-swap-bridge tree"]
L --> N["Return bridge-only tree"]
```
## Missing Quote Token Pools
The service now resolves token labels in this order:
1. Token row from the database.
2. Canonical token mapping for the chain.
3. Raw address fallback.
That means pools with incomplete token metadata still appear in the API and UI, instead of collapsing to `?` or being dropped entirely.
## Notes
- Route depth is derived from current pool TVL and freshness.
- The endpoint is intentionally short-cache so it follows the current pool index.
- For the full funding flow, the tree reflects:
- direct pool on Chain 138
- atomic swap + bridge from Chain 138
- destination-side completion on the target chain

View File

@@ -10,7 +10,8 @@
"dev": "ts-node src/index.ts",
"test": "jest",
"lint": "eslint src --ext .ts",
"migrate": "node -r dotenv/config dist/database/migrations.js"
"migrate": "node -r dotenv/config dist/database/migrations.js",
"example:partner-payloads": "node scripts/resolve-partner-payloads-example.mjs"
},
"dependencies": {
"axios": "^1.13.5",

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env node
import axios from 'axios';
const baseUrl = process.env.TOKEN_AGGREGATION_BASE_URL || 'http://localhost:3000';
const body = {
partner: process.env.PARTNER || '0x',
amount: process.env.AMOUNT || '1000000',
fromChainId: process.env.FROM_CHAIN_ID ? Number(process.env.FROM_CHAIN_ID) : 138,
toChainId: process.env.TO_CHAIN_ID ? Number(process.env.TO_CHAIN_ID) : 138,
routeType: process.env.ROUTE_TYPE || 'swap',
tokenIn: process.env.TOKEN_IN || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
tokenOut: process.env.TOKEN_OUT || '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
takerAddress: process.env.TAKER_ADDRESS || '0x000000000000000000000000000000000000dEaD',
recipient: process.env.RECIPIENT || '0x000000000000000000000000000000000000dEaD',
includeUnsupported: String(process.env.INCLUDE_UNSUPPORTED || 'true').toLowerCase() === 'true',
};
async function main() {
const response = await axios.post(
`${baseUrl.replace(/\/+$/, '')}/api/v1/routes/partner-payloads/resolve`,
body,
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
timeout: 15000,
}
);
console.log(JSON.stringify(response.data, null, 2));
}
main().catch((error) => {
const message = error?.response?.data || error?.message || error;
console.error('Partner payload resolution failed:', message);
process.exit(1);
});

View File

@@ -0,0 +1,91 @@
import { Router, Request, Response } from 'express';
import { cacheMiddleware } from '../middleware/cache';
import {
AggregatorRouteFilters,
filterLiveAggregatorRoutes,
getAggregatorRouteMatrixPath,
loadAggregatorRouteMatrix,
} from '../../config/aggregator-route-matrix';
const router: Router = Router();
function parseFilters(req: Request): AggregatorRouteFilters {
const fromChainId = req.query.fromChainId ? parseInt(String(req.query.fromChainId), 10) : undefined;
const toChainId = req.query.toChainId ? parseInt(String(req.query.toChainId), 10) : undefined;
return {
family: req.query.family ? String(req.query.family) : undefined,
fromChainId: Number.isFinite(fromChainId) ? fromChainId : undefined,
toChainId: Number.isFinite(toChainId) ? toChainId : undefined,
routeType: req.query.routeType ? String(req.query.routeType) : undefined,
tokenIn: req.query.tokenIn ? String(req.query.tokenIn) : undefined,
tokenOut: req.query.tokenOut ? String(req.query.tokenOut) : undefined,
};
}
/**
* GET /api/v1/routes/matrix
* Returns the canonical aggregator route matrix from config/aggregator-route-matrix.json.
*/
router.get('/routes/matrix', cacheMiddleware(30 * 1000), (req: Request, res: Response) => {
const matrix = loadAggregatorRouteMatrix();
if (!matrix) {
return res.status(503).json({
error: 'Aggregator route matrix not available',
});
}
const filters = parseFilters(req);
const includeNonLive = String(req.query.includeNonLive ?? 'false').toLowerCase() === 'true';
const liveRoutes = filterLiveAggregatorRoutes(
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
filters
);
return res.json({
generatedAt: new Date().toISOString(),
sourcePath: getAggregatorRouteMatrixPath(),
version: matrix.version,
updated: matrix.updated,
filters,
homeChainId: matrix.homeChainId,
liveRoutes,
blockedOrPlannedRoutes: includeNonLive ? matrix.blockedOrPlannedRoutes : undefined,
counts: {
liveSwapRoutes: matrix.liveSwapRoutes.length,
liveBridgeRoutes: matrix.liveBridgeRoutes.length,
blockedOrPlannedRoutes: matrix.blockedOrPlannedRoutes.length,
filteredLiveRoutes: liveRoutes.length,
},
});
});
/**
* GET /api/v1/routes/ingestion
* Adapter-focused flat export of only live routes, filtered by family/chain/token when provided.
*/
router.get('/routes/ingestion', cacheMiddleware(30 * 1000), (req: Request, res: Response) => {
const matrix = loadAggregatorRouteMatrix();
if (!matrix) {
return res.status(503).json({
error: 'Aggregator route matrix not available',
});
}
const filters = parseFilters(req);
const liveRoutes = filterLiveAggregatorRoutes(
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
filters
);
return res.json({
generatedAt: new Date().toISOString(),
format: 'aggregator-ingestion-v1',
version: matrix.version,
updated: matrix.updated,
filters,
routes: liveRoutes,
});
});
export default router;

View File

@@ -0,0 +1,319 @@
import { Router, Request, Response } from 'express';
import { cacheMiddleware } from '../middleware/cache';
import {
filterLiveAggregatorRoutes,
loadAggregatorRouteMatrix,
} from '../../config/aggregator-route-matrix';
import {
buildPartnerPayload,
PartnerName,
} from '../../services/partner-payload-adapters';
import { dispatchPartnerPayload } from '../../services/partner-payload-dispatcher';
import { buildInternalExecutionPlan } from '../../services/internal-execution-plan';
const router: Router = Router();
interface PartnerPayloadRequestBody {
partner?: string;
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;
}
function normalizePartner(input: string | undefined): PartnerName | null {
if (!input) return null;
const value = input.trim().toLowerCase();
if (value === '1inch') return '1inch';
if (value === '0x' || value === 'zeroex') return '0x';
if (value === 'lifi') return 'LiFi';
return null;
}
function buildPayloads(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;
}) {
const matrix = loadAggregatorRouteMatrix();
if (!matrix) {
return {
error: {
status: 503,
body: {
error: 'Aggregator route matrix not available',
},
},
};
}
const liveRoutes = filterLiveAggregatorRoutes(
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
{
fromChainId: args.fromChainId,
toChainId: args.toChainId,
routeType: args.routeType,
tokenIn: args.tokenIn,
tokenOut: args.tokenOut,
}
);
const payloads = liveRoutes.map((route) =>
buildPartnerPayload(args.partner, route, {
amount: args.amount,
takerAddress: args.takerAddress,
fromAddress: args.fromAddress,
toAddress: args.toAddress,
recipient: args.recipient,
slippagePercent: args.slippagePercent,
slippageBps: args.slippageBps,
})
);
const filteredPayloads = args.includeUnsupported ? payloads : payloads.filter((payload) => payload.supported);
return {
result: {
generatedAt: new Date().toISOString(),
format: 'partner-payload-templates-v1',
partner: args.partner,
amount: args.amount,
count: filteredPayloads.length,
supportedCount: payloads.filter((payload) => payload.supported).length,
payloads: filteredPayloads,
},
};
}
/**
* GET /api/v1/routes/partner-payloads
* 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) => {
const partner = normalizePartner(req.query.partner ? String(req.query.partner) : undefined);
if (!partner) {
return res.status(400).json({
error: 'partner is required and must be one of: 1inch, 0x, LiFi',
example: '/api/v1/routes/partner-payloads?partner=LiFi&amount=1000000',
});
}
const amount = req.query.amount ? String(req.query.amount) : '';
if (!amount) {
return res.status(400).json({
error: 'amount is required',
example: '/api/v1/routes/partner-payloads?partner=0x&amount=1000000',
});
}
const response = buildPayloads({
partner,
amount,
fromChainId: req.query.fromChainId ? parseInt(String(req.query.fromChainId), 10) : undefined,
toChainId: req.query.toChainId ? parseInt(String(req.query.toChainId), 10) : undefined,
routeType: req.query.routeType ? String(req.query.routeType) : undefined,
tokenIn: req.query.tokenIn ? String(req.query.tokenIn) : undefined,
tokenOut: req.query.tokenOut ? String(req.query.tokenOut) : undefined,
takerAddress: req.query.takerAddress ? String(req.query.takerAddress) : undefined,
fromAddress: req.query.fromAddress ? String(req.query.fromAddress) : undefined,
toAddress: req.query.toAddress ? String(req.query.toAddress) : undefined,
recipient: req.query.recipient ? String(req.query.recipient) : undefined,
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',
});
if (response.error) {
return res.status(response.error.status).json(response.error.body);
}
return res.json(response.result);
});
/**
* 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) => {
const body = (req.body ?? {}) as PartnerPayloadRequestBody;
const partner = normalizePartner(body.partner);
if (!partner) {
return res.status(400).json({
error: 'partner is required and must be one of: 1inch, 0x, LiFi',
example: {
partner: '0x',
amount: '1000000',
fromChainId: 138,
routeType: 'swap',
},
});
}
if (!body.amount || !String(body.amount).trim()) {
return res.status(400).json({
error: 'amount is required',
});
}
const response = buildPayloads({
partner,
amount: String(body.amount),
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
toChainId: typeof body.toChainId === 'number' ? body.toChainId : undefined,
routeType: body.routeType,
tokenIn: body.tokenIn,
tokenOut: body.tokenOut,
takerAddress: body.takerAddress,
fromAddress: body.fromAddress,
toAddress: body.toAddress,
recipient: body.recipient,
slippagePercent: body.slippagePercent,
slippageBps: body.slippageBps,
includeUnsupported: body.includeUnsupported === true,
});
if (response.error) {
return res.status(response.error.status).json(response.error.body);
}
return res.json(response.result);
});
/**
* POST /api/v1/routes/partner-payloads/dispatch
* Resolves partner payloads and dispatches exactly one supported payload.
*/
router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Response) => {
const body = (req.body ?? {}) as PartnerPayloadRequestBody;
const partner = normalizePartner(body.partner);
if (!partner) {
return res.status(400).json({
error: 'partner is required and must be one of: 1inch, 0x, LiFi',
});
}
if (!body.amount || !String(body.amount).trim()) {
return res.status(400).json({
error: 'amount is required',
});
}
const response = buildPayloads({
partner,
amount: String(body.amount),
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
toChainId: typeof body.toChainId === 'number' ? body.toChainId : undefined,
routeType: body.routeType,
tokenIn: body.tokenIn,
tokenOut: body.tokenOut,
takerAddress: body.takerAddress,
fromAddress: body.fromAddress,
toAddress: body.toAddress,
recipient: body.recipient,
slippagePercent: body.slippagePercent,
slippageBps: body.slippageBps,
includeUnsupported: true,
});
if (response.error) {
return res.status(response.error.status).json(response.error.body);
}
const supportedPayloads = response.result.payloads.filter((payload) => payload.supported);
const selectedPayload = body.routeId
? supportedPayloads.find((payload) => payload.routeId === body.routeId)
: supportedPayloads.length === 1
? supportedPayloads[0]
: undefined;
if (!selectedPayload) {
const fallback = buildInternalExecutionPlan({
routeId: body.routeId,
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
toChainId: typeof body.toChainId === 'number' ? body.toChainId : undefined,
tokenIn: body.tokenIn,
tokenOut: body.tokenOut,
amountIn: String(body.amount),
slippageBps: body.slippageBps,
});
return res.status(400).json({
error: body.routeId
? 'No supported payload found for the requested routeId'
: 'Dispatch requires exactly one supported payload; refine filters or pass routeId',
supportedRouteIds: supportedPayloads.map((payload) => payload.routeId),
fallbackPlan: fallback.plan,
fallbackError: fallback.error,
});
}
const dispatchResult = await dispatchPartnerPayload(selectedPayload);
return res.json({
generatedAt: new Date().toISOString(),
partner,
routeId: selectedPayload.routeId,
dispatch: dispatchResult,
});
});
/**
* POST /api/v1/routes/internal-execution-plan
* Returns a Chain 138 DODO PMM fallback execution plan for one live internal route.
*/
router.post('/routes/internal-execution-plan', (req: Request, res: Response) => {
const body = (req.body ?? {}) as PartnerPayloadRequestBody;
if (!body.amount || !String(body.amount).trim()) {
return res.status(400).json({
error: 'amount is required',
});
}
const result = buildInternalExecutionPlan({
routeId: body.routeId,
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
toChainId: typeof body.toChainId === 'number' ? body.toChainId : undefined,
tokenIn: body.tokenIn,
tokenOut: body.tokenOut,
amountIn: String(body.amount),
slippageBps: body.slippageBps,
});
if (result.error || !result.plan) {
return res.status(400).json({
error: result.error || 'Unable to build internal execution plan',
candidateRouteIds: result.candidateRouteIds,
});
}
return res.json({
generatedAt: new Date().toISOString(),
format: 'internal-execution-plan-v1',
plan: result.plan,
});
});
export default router;

View File

@@ -0,0 +1,94 @@
import { Router, Request, Response } from 'express';
import { cacheMiddleware } from '../middleware/cache';
import { RouteDecisionTreeService } from '../../services/route-decision-tree';
const router = Router();
const treeService = new RouteDecisionTreeService();
/**
* GET /api/v1/routes/tree
* Query:
* - chainId
* - tokenIn
* - tokenOut (optional)
* - amountIn (optional)
* - destinationChainId (optional)
*/
router.get('/routes/tree', cacheMiddleware(10 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const tokenIn = req.query.tokenIn as string;
const tokenOut = req.query.tokenOut as string | undefined;
const amountIn = req.query.amountIn as string | undefined;
const destinationChainIdRaw = req.query.destinationChainId as string | undefined;
const destinationChainId = destinationChainIdRaw ? parseInt(destinationChainIdRaw, 10) : undefined;
if (!chainId || !tokenIn) {
return res.status(400).json({
error: 'chainId and tokenIn are required',
});
}
const tree = await treeService.build({
chainId,
tokenIn,
tokenOut,
amountIn,
destinationChainId,
});
res.json(tree);
} catch (error) {
// eslint-disable-next-line no-console -- route error logging
console.error('Route tree error:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Internal server error',
});
}
});
/**
* GET /api/v1/routes/depth
* Convenience endpoint for the most relevant depth metrics.
*/
router.get('/routes/depth', cacheMiddleware(10 * 1000), async (req: Request, res: Response) => {
try {
const chainId = parseInt(req.query.chainId as string, 10);
const tokenIn = req.query.tokenIn as string;
const tokenOut = req.query.tokenOut as string | undefined;
const amountIn = req.query.amountIn as string | undefined;
const destinationChainIdRaw = req.query.destinationChainId as string | undefined;
const destinationChainId = destinationChainIdRaw ? parseInt(destinationChainIdRaw, 10) : undefined;
if (!chainId || !tokenIn) {
return res.status(400).json({
error: 'chainId and tokenIn are required',
});
}
const tree = await treeService.build({
chainId,
tokenIn,
tokenOut,
amountIn,
destinationChainId,
});
res.json({
generatedAt: tree.generatedAt,
decision: tree.decision,
source: tree.source,
destination: tree.destination,
pools: tree.pools,
missingQuoteTokenPools: tree.missingQuoteTokenPools,
});
} catch (error) {
// eslint-disable-next-line no-console -- route error logging
console.error('Route depth error:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Internal server error',
});
}
});
export default router;

View File

@@ -8,9 +8,12 @@ import adminRoutes from './routes/admin';
import configRoutes from './routes/config';
import bridgeRoutes from './routes/bridge';
import quoteRoutes from './routes/quote';
import routeTreeRoutes from './routes/routes';
import tokenMappingRoutes from './routes/token-mapping';
import heatmapRoutes from './routes/heatmap';
import arbitrageRoutes from './routes/arbitrage';
import aggregatorRouteMatrixRoutes from './routes/aggregator-routes';
import partnerPayloadRoutes from './routes/partner-payloads';
import { MultiChainIndexer } from '../indexer/chain-indexer';
import { getDatabasePool } from '../database/client';
import winston from 'winston';
@@ -102,10 +105,13 @@ export class ApiServer {
this.app.use('/api/v1', configRoutes);
this.app.use('/api/v1/report', reportRoutes);
this.app.use('/api/v1/bridge', bridgeRoutes);
this.app.use('/api/v1', routeTreeRoutes);
this.app.use('/api/v1/token-mapping', tokenMappingRoutes);
this.app.use('/api/v1', quoteRoutes);
this.app.use('/api/v1', heatmapRoutes);
this.app.use('/api/v1', arbitrageRoutes);
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
this.app.use('/api/v1', partnerPayloadRoutes);
// Admin routes (stricter rate limit)
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);

View File

@@ -0,0 +1,87 @@
import axios, { AxiosInstance } from 'axios';
export type PartnerName = '1inch' | '0x' | 'LiFi';
export interface ResolvePartnerPayloadsRequest {
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;
}
export interface PartnerPayloadTemplate {
partner: PartnerName;
routeId: string;
supported: boolean;
reason?: string;
endpoint: string;
method: 'GET';
headers: Record<string, string>;
query: Record<string, string>;
route: {
routeId: string;
status: 'live';
fromChainId: number;
toChainId: number;
routeType: 'swap' | 'bridge';
};
docs: string[];
}
export interface ResolvePartnerPayloadsResponse {
generatedAt: string;
format: 'partner-payload-templates-v1';
partner: PartnerName;
amount: string;
count: number;
supportedCount: number;
payloads: PartnerPayloadTemplate[];
}
export class PartnerPayloadClient {
private readonly http: AxiosInstance;
constructor(baseUrl = 'http://localhost:3000') {
this.http = axios.create({
baseURL: baseUrl.replace(/\/+$/, ''),
timeout: 15_000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
}
async resolvePartnerPayloads(
request: ResolvePartnerPayloadsRequest
): Promise<ResolvePartnerPayloadsResponse> {
const response = await this.http.post<ResolvePartnerPayloadsResponse>(
'/api/v1/routes/partner-payloads/resolve',
request
);
return response.data;
}
async getPartnerPayloads(
request: ResolvePartnerPayloadsRequest
): Promise<ResolvePartnerPayloadsResponse> {
const response = await this.http.get<ResolvePartnerPayloadsResponse>(
'/api/v1/routes/partner-payloads',
{
params: request,
}
);
return response.data;
}
}

View File

@@ -0,0 +1,158 @@
import fs from 'fs';
import path from 'path';
export type AggregatorFamily = '1inch' | '0x' | 'LiFi';
export type RouteStatus = 'live' | 'planned' | 'blocked';
export type RouteType = 'swap' | 'bridge' | 'swap-bridge-swap';
export interface AggregatorRouteLeg {
kind: string;
protocol?: string;
executor?: string;
executorAddress?: string;
poolAddress?: string;
tokenInAddress?: string;
tokenOutAddress?: string;
reserves?: Record<string, string>;
}
export interface LiveAggregatorRoute {
routeId: string;
status: 'live';
aggregatorFamilies: AggregatorFamily[];
fromChainId: number;
toChainId: number;
tokenInSymbol?: string;
tokenInAddress?: string;
tokenOutSymbol?: string;
tokenOutAddress?: string;
assetSymbol?: string;
assetAddress?: string;
routeType: 'swap' | 'bridge';
hopCount?: number;
bridgeType?: string;
bridgeAddress?: string;
label?: string;
intermediateSymbols?: string[];
legs?: AggregatorRouteLeg[];
tags?: string[];
notes?: string[];
}
export interface NonLiveAggregatorRoute {
routeId: string;
status: Exclude<RouteStatus, 'live'>;
fromChainId: number;
toChainId: number;
routeType: RouteType;
reason: string;
tokenInSymbols?: string[];
}
export interface AggregatorRouteMatrix {
$schema?: string;
description?: string;
version: string;
updated: string;
homeChainId: number;
metadata?: {
generatedFrom?: string[];
verification?: {
verifiedAt?: string;
verifiedBy?: string;
rpc?: string;
};
adapterNotes?: string[];
};
chains?: Record<string, unknown>;
tokens?: Record<string, unknown>;
liveSwapRoutes: LiveAggregatorRoute[];
liveBridgeRoutes: LiveAggregatorRoute[];
blockedOrPlannedRoutes: NonLiveAggregatorRoute[];
}
let cachedMatrix: AggregatorRouteMatrix | null = null;
let cachedPath: string | null = null;
function candidatePaths(): string[] {
return [
path.resolve(process.cwd(), '../../../config/aggregator-route-matrix.json'),
path.resolve(process.cwd(), '../../config/aggregator-route-matrix.json'),
path.resolve(__dirname, '../../../../../../config/aggregator-route-matrix.json'),
path.resolve(__dirname, '../../../../../config/aggregator-route-matrix.json'),
];
}
export function resolveAggregatorRouteMatrixPath(): string | null {
for (const candidate of candidatePaths()) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
export function loadAggregatorRouteMatrix(forceReload = false): AggregatorRouteMatrix | null {
if (cachedMatrix && !forceReload) {
return cachedMatrix;
}
const matrixPath = resolveAggregatorRouteMatrixPath();
if (!matrixPath) {
return null;
}
const raw = fs.readFileSync(matrixPath, 'utf8');
cachedMatrix = JSON.parse(raw) as AggregatorRouteMatrix;
cachedPath = matrixPath;
return cachedMatrix;
}
export function getAggregatorRouteMatrixPath(): string | null {
if (cachedPath) {
return cachedPath;
}
return resolveAggregatorRouteMatrixPath();
}
export interface AggregatorRouteFilters {
family?: string;
fromChainId?: number;
toChainId?: number;
routeType?: string;
tokenIn?: string;
tokenOut?: string;
}
function normalizeAddress(value?: string): string | undefined {
return value?.trim().toLowerCase() || undefined;
}
function normalizeFamily(value?: string): AggregatorFamily | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === '1inch') return '1inch';
if (normalized === '0x' || normalized === 'zeroex' || normalized === '0xapi') return '0x';
if (normalized === 'lifi') return 'LiFi';
return undefined;
}
export function filterLiveAggregatorRoutes(
routes: LiveAggregatorRoute[],
filters: AggregatorRouteFilters
): LiveAggregatorRoute[] {
const family = normalizeFamily(filters.family);
const tokenIn = normalizeAddress(filters.tokenIn);
const tokenOut = normalizeAddress(filters.tokenOut);
return routes.filter((route) => {
if (family && !route.aggregatorFamilies.includes(family)) return false;
if (filters.fromChainId && route.fromChainId !== filters.fromChainId) return false;
if (filters.toChainId && route.toChainId !== filters.toChainId) return false;
if (filters.routeType && route.routeType !== filters.routeType) return false;
if (tokenIn && normalizeAddress(route.tokenInAddress) !== tokenIn && normalizeAddress(route.assetAddress) !== tokenIn) return false;
if (tokenOut && normalizeAddress(route.tokenOutAddress) !== tokenOut && normalizeAddress(route.assetAddress) !== tokenOut) return false;
return true;
});
}

View File

@@ -31,6 +31,12 @@ const L2_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 25, 100, 42220, 1111]
/** Verified addresses from CHAIN138_TOKEN_ADDRESSES, .env, and deployment summaries */
const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
USDC: {
[CHAIN_138]: '0x71D6687F38b93CCad569Fa6352c876eea967201b',
},
USDT: {
[CHAIN_138]: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
},
cUSDC: {
[CHAIN_138]: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
[CHAIN_651940]: '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', // AUSDC on ALL Mainnet
@@ -83,6 +89,12 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
};
function addr(symbol: string, chainId: number): string | undefined {
if (chainId === CHAIN_138 && symbol === 'USDT') {
return process.env.USDT_ADDRESS_138 || process.env.OFFICIAL_USDT_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId];
}
if (chainId === CHAIN_138 && symbol === 'USDC') {
return process.env.USDC_ADDRESS_138 || process.env.OFFICIAL_USDC_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId];
}
const key = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
const envVal = process.env[key];
if (envVal && envVal.trim() !== '') return envVal;
@@ -91,6 +103,9 @@ function addr(symbol: string, chainId: number): string | undefined {
export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
// --- Base (GRU-M1) ---
// Local Chain 138 quote-side mirror stables used by PMM pools.
{ symbol: 'USDC', name: 'USD Coin (Official Mirror)', type: 'base', decimals: 6, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDC', CHAIN_138) || '' } },
{ symbol: 'USDT', name: 'Tether USD (Official Mirror)', type: 'base', decimals: 6, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDT', CHAIN_138) || '' } },
// Chain 138 v0 only (no X): cUSDC on 138; cXUSDC used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
{ symbol: 'cUSDC', name: 'USD Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDC', addresses: { [CHAIN_138]: addr('cUSDC', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDC', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDC', id)])) } },
// Chain 138 v0 only (no X): cUSDT on 138; cXUSDT used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
@@ -103,8 +118,24 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
{ symbol: 'cJPYC', name: 'Japanese Yen (Compliant)', type: 'base', decimals: 6, currencyCode: 'JPY', addresses: { [CHAIN_138]: addr('cJPYC', CHAIN_138), [CHAIN_651940]: addr('cJPYC', CHAIN_651940) } },
{ symbol: 'cCHFC', name: 'Swiss Franc (Compliant)', type: 'base', decimals: 6, currencyCode: 'CHF', addresses: { [CHAIN_138]: addr('cCHFC', CHAIN_138), [CHAIN_651940]: addr('cCHFC', CHAIN_651940) } },
{ symbol: 'cCADC', name: 'Canadian Dollar (Compliant)', type: 'base', decimals: 6, currencyCode: 'CAD', addresses: { [CHAIN_138]: addr('cCADC', CHAIN_138), [CHAIN_651940]: addr('cCADC', CHAIN_651940) } },
{ symbol: 'cXAUC', name: 'Gold (Compliant)', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138), [CHAIN_651940]: addr('cXAUC', CHAIN_651940) } },
{ symbol: 'cXAUT', name: 'Tether XAU (Compliant)', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138), [CHAIN_651940]: addr('cXAUT', CHAIN_651940) } },
{
symbol: 'cXAUC',
name: 'Gold (Compliant)',
type: 'base',
decimals: 6,
currencyCode: 'XAU',
description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).',
addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138), [CHAIN_651940]: addr('cXAUC', CHAIN_651940) },
},
{
symbol: 'cXAUT',
name: 'Tether XAU (Compliant)',
type: 'base',
decimals: 6,
currencyCode: 'XAU',
description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).',
addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138), [CHAIN_651940]: addr('cXAUT', CHAIN_651940) },
},
{ symbol: 'LiXAU', name: 'XAU Liquidity-adjusted', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) } },
// --- ISO-4217 W ---
{ symbol: 'USDW', name: 'USD W Token', type: 'w', decimals: 2, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDW', CHAIN_138), [CHAIN_25]: addr('USDW', CHAIN_25), [CHAIN_651940]: addr('USDW', CHAIN_651940) } },
@@ -156,6 +187,13 @@ export function getCanonicalTokenByAddress(chainId: number, address: string): Ca
return CANONICAL_TOKENS.find((t) => t.addresses[chainId]?.toLowerCase() === lower);
}
export function getCanonicalTokenBySymbol(chainId: number, symbol: string): CanonicalTokenSpec | undefined {
const normalized = symbol.trim().toLowerCase();
return CANONICAL_TOKENS.find(
(t) => t.symbol.toLowerCase() === normalized && t.addresses[chainId] && String(t.addresses[chainId]).trim() !== ''
);
}
/** IPFS-hosted logo URLs (Pinata) for Uniswap token list (logoURI).
* Every token must have logoURI for MetaMask to display icons. getLogoUriForSpec resolves
* ac-tokens from base (c*), vdc/sdc from base; unknown symbols fall back to ETH_LOGO. */
@@ -165,6 +203,8 @@ const USDC_LOGO = `${IPFS_GATEWAY}/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjD
const USDT_LOGO = `${IPFS_GATEWAY}/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP`;
const LOGO_BY_SYMBOL: Record<string, string> = {
USDC: USDC_LOGO,
USDT: USDT_LOGO,
cUSDC: USDC_LOGO,
cUSDT: USDT_LOGO,
cEURC: USDC_LOGO,

View File

@@ -13,7 +13,7 @@ export interface BridgeLane {
export interface BridgeConfig {
address: string;
chainId: number;
type: 'ccip_weth9' | 'ccip_weth10' | 'alltra' | 'universal_ccip';
type: 'ccip_weth9' | 'ccip_weth10' | 'ccip_stable' | 'alltra' | 'universal_ccip';
tokenSymbol?: string;
lanes: BridgeLane[];
}
@@ -26,7 +26,16 @@ function envAddr(key: string): string {
return typeof v === 'string' && v.startsWith('0x') ? v : '';
}
function envAnyAddr(...keys: string[]): string {
for (const key of keys) {
const value = envAddr(key);
if (value) return value;
}
return '';
}
export const CHAIN_138_BRIDGES: BridgeConfig[] = [];
const STABLE_ASSET_SYMBOLS = new Set(['USDT', 'USDC', 'CUSDT', 'CUSDC']);
if (envAddr('CCIPWETH9_BRIDGE_CHAIN138')) {
CHAIN_138_BRIDGES.push({
@@ -54,6 +63,19 @@ if (envAddr('CCIPWETH10_BRIDGE_CHAIN138')) {
});
}
if (envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138')) {
CHAIN_138_BRIDGES.push({
address: envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138'),
chainId: chainId138,
type: 'ccip_stable',
tokenSymbol: 'STABLE',
lanes: [
{ destSelector: '5009297550715157269', destChainId: 1, destChainName: 'Ethereum' },
{ destSelector: '16015286601757825753', destChainId: 651940, destChainName: 'ALL Mainnet' },
],
});
}
if (envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS')) {
const addr = envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS');
CHAIN_138_BRIDGES.push({
@@ -64,9 +86,9 @@ if (envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS')
});
}
if (envAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS')) {
if (envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE')) {
CHAIN_138_BRIDGES.push({
address: envAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS'),
address: envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE'),
chainId: chainId138,
type: 'universal_ccip',
lanes: [
@@ -89,6 +111,8 @@ export interface RoutingRegistryEntry {
const ALLTRA_ADAPTER_138 = envAddr('ALLTRA_ADAPTER_ADDRESS') || envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || '0x66FEBA2fC9a0B47F26DD4284DAd24F970436B8Dc';
const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0x971cD9D156f193df8051E48043C476e53ECd4693';
const CCIP_STABLE_138 = envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138');
const UNIVERSAL_CCIP_138 = envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE');
/**
* Get routing registry entry for (fromChain, toChain, asset).
@@ -115,6 +139,49 @@ export function getRouteFromRegistry(
};
}
if (fromChain === 138 || toChain === 138) {
const normalizedAsset = asset.trim().toUpperCase();
const isStableAsset = STABLE_ASSET_SYMBOLS.has(normalizedAsset);
if (isStableAsset) {
if (CCIP_STABLE_138) {
return {
pathType: 'CCIP',
bridgeAddress: CCIP_STABLE_138,
bridgeChainId: 138,
label: 'CCIPStableBridge',
fromChain,
toChain,
asset,
};
}
if (UNIVERSAL_CCIP_138) {
return {
pathType: 'CCIP',
bridgeAddress: UNIVERSAL_CCIP_138,
bridgeChainId: 138,
label: 'UniversalCCIPBridge',
fromChain,
toChain,
asset,
};
}
return null;
}
if (normalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) {
return {
pathType: 'CCIP',
bridgeAddress: UNIVERSAL_CCIP_138,
bridgeChainId: 138,
label: 'UniversalCCIPBridge',
fromChain,
toChain,
asset,
};
}
return {
pathType: 'CCIP',
bridgeAddress: CCIP_WETH9_138,

View File

@@ -0,0 +1,208 @@
import { LiveAggregatorRoute, loadAggregatorRouteMatrix } from '../config/aggregator-route-matrix';
export interface InternalExecutionPlanRequest {
routeId?: string;
fromChainId?: number;
toChainId?: number;
tokenIn?: string;
tokenOut?: string;
amountIn: string;
slippageBps?: string;
}
export interface InternalExecutionStep {
kind: 'approve' | 'swap';
description: string;
tokenAddress?: string;
spender?: string;
contractAddress?: string;
functionName?: string;
signature?: string;
args?: Array<string | number | Record<string, string>>;
amountSource?: 'user_input' | 'previous_step_output';
estimatedAmountOut?: string;
minAmountOut?: string;
}
export interface InternalExecutionPlan {
routeId: string;
chainId: number;
executor: {
protocol: 'dodo_pmm';
contractAddress: string;
};
amountIn: string;
slippageBps: string;
notes: string[];
steps: InternalExecutionStep[];
}
function quoteAmountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint {
if (reserveIn === 0n) return 0n;
const amountInWithFee = amountIn * 997n;
return (reserveOut * amountInWithFee) / (reserveIn * 1000n + amountInWithFee);
}
function normalizeAddress(address?: string): string | undefined {
return address?.trim().toLowerCase() || undefined;
}
function findRoute(request: InternalExecutionPlanRequest, routes: LiveAggregatorRoute[]): LiveAggregatorRoute | undefined {
if (request.routeId) {
return routes.find((route) => route.routeId === request.routeId);
}
const tokenIn = normalizeAddress(request.tokenIn);
const tokenOut = normalizeAddress(request.tokenOut);
const matches = routes.filter((route) => {
if (request.fromChainId && route.fromChainId !== request.fromChainId) return false;
if (request.toChainId && route.toChainId !== request.toChainId) return false;
if (tokenIn && normalizeAddress(route.tokenInAddress) !== tokenIn) return false;
if (tokenOut && normalizeAddress(route.tokenOutAddress) !== tokenOut) return false;
return route.routeType === 'swap';
});
return matches.length === 1 ? matches[0] : undefined;
}
function buildPoolReserveMap(routes: LiveAggregatorRoute[]): Map<string, Record<string, string>> {
const map = new Map<string, Record<string, string>>();
for (const route of routes) {
for (const leg of route.legs || []) {
if (leg.poolAddress && leg.reserves) {
map.set(leg.poolAddress.toLowerCase(), leg.reserves);
}
}
}
return map;
}
function estimateLegOut(
amountIn: bigint,
leg: NonNullable<LiveAggregatorRoute['legs']>[number],
reserveMap: Map<string, Record<string, string>>
): string | undefined {
if (!leg.poolAddress || !leg.tokenInAddress || !leg.tokenOutAddress) return undefined;
const reserves = reserveMap.get(leg.poolAddress.toLowerCase());
if (!reserves) return undefined;
const tokenInAddress = leg.tokenInAddress.toLowerCase();
const tokenOutAddress = leg.tokenOutAddress.toLowerCase();
const entries = Object.entries(reserves);
const reserveInEntry = entries.find(([_, value], idx) => {
const key = entries[idx][0];
return key.toLowerCase().includes('cusdt') && tokenInAddress === '0x93e66202a11b1772e55407b32b44e5cd8eda7f22'
|| key.toLowerCase().includes('cusdc') && tokenInAddress === '0xf22258f57794cc8e06237084b353ab30fffa640b'
|| key.toLowerCase().includes('usdt') && tokenInAddress === '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1'
|| key.toLowerCase().includes('usdc') && tokenInAddress === '0x71d6687f38b93ccad569fa6352c876eea967201b'
|| key.toLowerCase().includes('ceurt') && tokenInAddress === '0xdf4b71c61e5912712c1bdd451416b9ac26949d72'
|| key.toLowerCase().includes('cxauc') && tokenInAddress === '0x290e52a8819a4fbd0714e517225429aa2b70ec6b';
});
const reserveOutEntry = entries.find(([_, value], idx) => {
const key = entries[idx][0];
return key.toLowerCase().includes('cusdt') && tokenOutAddress === '0x93e66202a11b1772e55407b32b44e5cd8eda7f22'
|| key.toLowerCase().includes('cusdc') && tokenOutAddress === '0xf22258f57794cc8e06237084b353ab30fffa640b'
|| key.toLowerCase().includes('usdt') && tokenOutAddress === '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1'
|| key.toLowerCase().includes('usdc') && tokenOutAddress === '0x71d6687f38b93ccad569fa6352c876eea967201b'
|| key.toLowerCase().includes('ceurt') && tokenOutAddress === '0xdf4b71c61e5912712c1bdd451416b9ac26949d72'
|| key.toLowerCase().includes('cxauc') && tokenOutAddress === '0x290e52a8819a4fbd0714e517225429aa2b70ec6b';
});
if (!reserveInEntry || !reserveOutEntry) return undefined;
const reserveIn = BigInt(reserveInEntry[1].replace('.', ''));
const reserveOut = BigInt(reserveOutEntry[1].replace('.', ''));
return quoteAmountOut(amountIn, reserveIn, reserveOut).toString();
}
export function buildInternalExecutionPlan(
request: InternalExecutionPlanRequest
): { plan?: InternalExecutionPlan; error?: string; candidateRouteIds?: string[] } {
const matrix = loadAggregatorRouteMatrix();
if (!matrix) {
return { error: 'Aggregator route matrix not available' };
}
const routes = matrix.liveSwapRoutes.filter((route) => route.fromChainId === 138 && route.toChainId === 138);
const route = findRoute(request, routes);
if (!route) {
const candidateRouteIds = routes
.filter((candidate) =>
!request.fromChainId || candidate.fromChainId === request.fromChainId
)
.map((candidate) => candidate.routeId);
return {
error: request.routeId
? 'No live internal route found for routeId'
: 'Internal execution planning requires exactly one matching live Chain 138 swap route',
candidateRouteIds,
};
}
const reserveMap = buildPoolReserveMap(matrix.liveSwapRoutes);
const amountIn = BigInt(request.amountIn);
const slippageBps = request.slippageBps || '100';
const executorAddress = route.legs?.[0]?.executorAddress || '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
const steps: InternalExecutionStep[] = [];
let currentAmount = amountIn;
for (let i = 0; i < (route.legs || []).length; i += 1) {
const leg = route.legs?.[i];
if (!leg || !leg.poolAddress || !leg.tokenInAddress) continue;
steps.push({
kind: 'approve',
description: `Approve token for DODOPMMIntegration before leg ${i + 1}`,
tokenAddress: leg.tokenInAddress,
spender: executorAddress,
amountSource: i === 0 ? 'user_input' : 'previous_step_output',
});
const estimatedOut = estimateLegOut(currentAmount, leg, reserveMap);
const minAmountOut = estimatedOut
? ((BigInt(estimatedOut) * (10_000n - BigInt(slippageBps))) / 10_000n).toString()
: '0';
steps.push({
kind: 'swap',
description: `Execute DODO PMM swap leg ${i + 1}`,
contractAddress: executorAddress,
functionName: 'swapExactIn',
signature: 'swapExactIn(address,address,uint256,uint256)',
args: [
leg.poolAddress,
leg.tokenInAddress,
i === 0 ? request.amountIn : { source: 'previous_step_output' },
minAmountOut,
],
amountSource: i === 0 ? 'user_input' : 'previous_step_output',
estimatedAmountOut: estimatedOut,
minAmountOut,
});
if (estimatedOut) {
currentAmount = BigInt(estimatedOut);
}
}
return {
plan: {
routeId: route.routeId,
chainId: 138,
executor: {
protocol: 'dodo_pmm',
contractAddress: executorAddress,
},
amountIn: request.amountIn,
slippageBps,
notes: [
'Fallback plan is for internal Chain 138 execution via DODOPMMIntegration.',
'Estimated outputs use the service quote approximation and should be treated as guidance.',
'Approve steps are included explicitly because DODOPMMIntegration pulls tokens with transferFrom.',
],
steps,
},
};
}

View File

@@ -0,0 +1,178 @@
import { LiveAggregatorRoute } from '../config/aggregator-route-matrix';
export type PartnerName = '1inch' | '0x' | 'LiFi';
export interface PartnerAdapterContext {
amount: string;
takerAddress?: string;
fromAddress?: string;
toAddress?: string;
recipient?: string;
slippagePercent?: string;
slippageBps?: string;
}
export interface PartnerPayloadResult {
partner: PartnerName;
routeId: string;
supported: boolean;
reason?: string;
endpoint: string;
method: 'GET';
headers: Record<string, string>;
query: Record<string, string>;
route: LiveAggregatorRoute;
docs: string[];
}
const ONE_INCH_SUPPORTED_CHAINS = new Set([
1, 10, 56, 100, 130, 137, 146, 324, 501, 8453, 43114, 42161, 59144,
]);
const ZERO_X_SUPPORTED_CHAINS = new Set([
1, 10, 56, 130, 137, 8453, 42161, 43114,
]);
function defaultAddress(address?: string): string {
return address || '0x000000000000000000000000000000000000dEaD';
}
function getSellToken(route: LiveAggregatorRoute): string | null {
return route.tokenInAddress || route.assetAddress || null;
}
function getBuyToken(route: LiveAggregatorRoute): string | null {
return route.tokenOutAddress || route.assetAddress || null;
}
export function build1inchClassicSwapPayload(
route: LiveAggregatorRoute,
context: PartnerAdapterContext
): PartnerPayloadResult {
const src = getSellToken(route);
const dst = getBuyToken(route);
const supported = route.routeType === 'swap' && route.fromChainId === route.toChainId && ONE_INCH_SUPPORTED_CHAINS.has(route.fromChainId);
const endpoint = `https://api.1inch.com/swap/v6.1/${route.fromChainId}/swap`;
return {
partner: '1inch',
routeId: route.routeId,
supported,
reason: supported
? undefined
: '1inch Classic Swap does not document support for this route chain or route type; Chain 138 is not in the published supported chain list.',
endpoint,
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: 'Bearer <ONE_INCH_API_KEY>',
},
query: {
src: src || '',
dst: dst || '',
amount: context.amount,
from: defaultAddress(context.fromAddress || context.takerAddress),
slippage: context.slippagePercent || '1',
disableEstimate: 'false',
allowPartialFill: 'false',
},
route,
docs: [
'https://business.1inch.com/portal/documentation/apis/swap/classic-swap/quick-start',
'https://business.1inch.com/portal/documentation/apis/swap/classic-swap/introduction',
],
};
}
export function buildZeroXAllowanceHolderPricePayload(
route: LiveAggregatorRoute,
context: PartnerAdapterContext
): PartnerPayloadResult {
const sellToken = getSellToken(route);
const buyToken = getBuyToken(route);
const supported = route.routeType === 'swap' && route.fromChainId === route.toChainId && ZERO_X_SUPPORTED_CHAINS.has(route.fromChainId);
return {
partner: '0x',
routeId: route.routeId,
supported,
reason: supported
? undefined
: '0x Swap API supports published EVM chains, but this route chain is not in the current public support set; Chain 138 is unsupported.',
endpoint: 'https://api.0x.org/swap/allowance-holder/price',
method: 'GET',
headers: {
'0x-api-key': '<ZEROX_API_KEY>',
'0x-version': 'v2',
Accept: 'application/json',
},
query: {
chainId: String(route.fromChainId),
sellToken: sellToken || '',
buyToken: buyToken || '',
sellAmount: context.amount,
taker: defaultAddress(context.takerAddress || context.fromAddress),
recipient: defaultAddress(context.recipient || context.toAddress || context.takerAddress || context.fromAddress),
slippageBps: context.slippageBps || '100',
},
route,
docs: [
'https://docs.0x.org/api-reference/openapi-yaml/gasless/getprice',
'https://docs.0x.org/docs/0x-swap-api/additional-topics/how-to-set-your-token-allowances',
],
};
}
export function buildLiFiQuotePayload(
route: LiveAggregatorRoute,
context: PartnerAdapterContext
): PartnerPayloadResult {
const fromToken = getSellToken(route);
const toToken = getBuyToken(route);
const supported = route.fromChainId !== 138 && route.toChainId !== 138 && route.fromChainId !== 651940 && route.toChainId !== 651940;
return {
partner: 'LiFi',
routeId: route.routeId,
supported,
reason: supported
? undefined
: 'LI.FI quote payload shape is valid, but this route depends on a custom chain not documented in LI.FI public chain support.',
endpoint: 'https://li.quest/v1/quote',
method: 'GET',
headers: {
Accept: 'application/json',
'x-lifi-api-key': '<LIFI_API_KEY_OPTIONAL>',
},
query: {
fromChain: String(route.fromChainId),
toChain: String(route.toChainId),
fromToken: fromToken || '',
toToken: toToken || '',
fromAmount: context.amount,
fromAddress: defaultAddress(context.fromAddress || context.takerAddress),
toAddress: defaultAddress(context.toAddress || context.recipient || context.fromAddress || context.takerAddress),
slippage: context.slippagePercent || '0.03',
},
route,
docs: [
'https://docs.li.fi/api-reference/introduction',
'https://docs.li.fi/agents/overview',
],
};
}
export function buildPartnerPayload(
partner: PartnerName,
route: LiveAggregatorRoute,
context: PartnerAdapterContext
): PartnerPayloadResult {
switch (partner) {
case '1inch':
return build1inchClassicSwapPayload(route, context);
case '0x':
return buildZeroXAllowanceHolderPricePayload(route, context);
case 'LiFi':
return buildLiFiQuotePayload(route, context);
}
}

View File

@@ -0,0 +1,109 @@
import axios, { AxiosResponse } from 'axios';
import { PartnerPayloadResult } from './partner-payload-adapters';
export interface DispatchPartnerPayloadResult {
routeId: string;
partner: string;
supported: boolean;
dispatched: boolean;
endpoint: string;
statusCode?: number;
data?: unknown;
error?: string;
}
function resolveHeaders(payload: PartnerPayloadResult): Record<string, string> {
const headers = { ...payload.headers };
if (payload.partner === '1inch' && process.env.ONE_INCH_API_KEY) {
headers.Authorization = `Bearer ${process.env.ONE_INCH_API_KEY}`;
}
if (payload.partner === '0x' && process.env.ZEROX_API_KEY) {
headers['0x-api-key'] = process.env.ZEROX_API_KEY;
}
if (payload.partner === 'LiFi' && process.env.LIFI_API_KEY) {
headers['x-lifi-api-key'] = process.env.LIFI_API_KEY;
}
return headers;
}
function missingCredentialReason(payload: PartnerPayloadResult): string | null {
if (payload.partner === '1inch' && !process.env.ONE_INCH_API_KEY) {
return 'ONE_INCH_API_KEY is not set';
}
if (payload.partner === '0x' && !process.env.ZEROX_API_KEY) {
return 'ZEROX_API_KEY is not set';
}
return null;
}
export async function dispatchPartnerPayload(
payload: PartnerPayloadResult
): Promise<DispatchPartnerPayloadResult> {
if (!payload.supported) {
return {
routeId: payload.routeId,
partner: payload.partner,
supported: false,
dispatched: false,
endpoint: payload.endpoint,
error: payload.reason || 'Payload is not supported',
};
}
const credentialError = missingCredentialReason(payload);
if (credentialError) {
return {
routeId: payload.routeId,
partner: payload.partner,
supported: true,
dispatched: false,
endpoint: payload.endpoint,
error: credentialError,
};
}
try {
const response: AxiosResponse = await axios.get(payload.endpoint, {
params: payload.query,
headers: resolveHeaders(payload),
timeout: 20_000,
});
return {
routeId: payload.routeId,
partner: payload.partner,
supported: true,
dispatched: true,
endpoint: payload.endpoint,
statusCode: response.status,
data: response.data,
};
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
return {
routeId: payload.routeId,
partner: payload.partner,
supported: true,
dispatched: false,
endpoint: payload.endpoint,
statusCode: error.response?.status,
data: error.response?.data,
error: error.message,
};
}
return {
routeId: payload.routeId,
partner: payload.partner,
supported: true,
dispatched: false,
endpoint: payload.endpoint,
error: error instanceof Error ? error.message : 'Unknown dispatch error',
};
}
}

View File

@@ -0,0 +1,449 @@
import { TokenRepository } from '../database/repositories/token-repo';
import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo';
import { getChainConfig } from '../config/chains';
import { ResolvedTokenDisplay, resolvePoolTokenDisplays, resolveTokenDisplay } from './token-display';
import { Contract, JsonRpcProvider } from 'ethers';
import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from '../config/canonical-tokens';
import { getRouteFromRegistry } from '../config/cross-chain-bridges';
const CHAIN_138 = 138;
const CHAIN_138_PMM_INTEGRATION =
process.env.CHAIN_138_DODO_PMM_INTEGRATION ||
process.env.DODO_PMM_INTEGRATION_ADDRESS ||
process.env.DODO_PMM_INTEGRATION ||
'0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
const PMM_ABI = [
'function pools(address,address) view returns (address)',
];
const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)',
];
export type RouteNodeKind =
| 'direct-pool'
| 'bridge'
| 'atomic-swap-bridge'
| 'destination-swap'
| 'fallback';
export interface RouteDepthMetrics {
tvlUsd: number;
reserve0: string;
reserve1: string;
estimatedTradeCapacityUsd: number;
freshnessSeconds: number | null;
status: 'live' | 'stale' | 'unavailable';
}
export interface RouteNode {
id: string;
kind: RouteNodeKind;
label: string;
chainId: number;
chainName: string;
status: 'live' | 'partial' | 'stale' | 'unavailable';
depth?: RouteDepthMetrics;
tokenIn?: ResolvedTokenDisplay;
tokenOut?: ResolvedTokenDisplay;
poolAddress?: string;
dexType?: string;
path?: string[];
children?: RouteNode[];
notes?: string[];
}
export interface RouteDecisionTreeRequest {
chainId: number;
tokenIn: string;
tokenOut?: string;
amountIn?: string;
destinationChainId?: number;
}
export interface MissingQuoteTokenPool {
poolAddress: string;
chainId: number;
token0Address: string;
token1Address: string;
token0Symbol: string;
token1Symbol: string;
reason: string;
}
export interface RouteDecisionTreeResponse {
generatedAt: string;
source: {
chainId: number;
chainName: string;
tokenIn: ResolvedTokenDisplay;
tokenOut?: ResolvedTokenDisplay;
amountIn?: string;
};
destination?: {
chainId: number;
chainName: string;
};
decision: 'direct-pool' | 'atomic-swap-bridge' | 'bridge-only' | 'destination-swap' | 'unresolved';
tree: RouteNode[];
pools: Array<{
poolAddress: string;
dexType: DexType;
token0: ResolvedTokenDisplay;
token1: ResolvedTokenDisplay;
depth: RouteDepthMetrics;
}>;
missingQuoteTokenPools: MissingQuoteTokenPool[];
}
function estimateTradeCapacityUsd(pool: LiquidityPool): number {
const tvl = Math.max(0, pool.totalLiquidityUsd || 0);
if (tvl === 0) return 0;
const freshnessBoost = pool.lastUpdated
? Math.max(0.25, 1 - (Date.now() - pool.lastUpdated.getTime()) / (60 * 60 * 1000))
: 0.5;
const capacity = tvl * 0.2 * freshnessBoost;
return Math.max(0, Math.min(tvl, capacity));
}
function buildDepth(pool: LiquidityPool): RouteDepthMetrics {
const freshnessSeconds = pool.lastUpdated ? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000)) : null;
const status = freshnessSeconds === null
? 'unavailable'
: freshnessSeconds < 300
? 'live'
: freshnessSeconds < 1800
? 'stale'
: 'unavailable';
return {
tvlUsd: pool.totalLiquidityUsd || 0,
reserve0: pool.reserve0,
reserve1: pool.reserve1,
estimatedTradeCapacityUsd: estimateTradeCapacityUsd(pool),
freshnessSeconds,
status,
};
}
function deriveDecision(
sourceChainId: number,
destinationChainId: number | undefined,
directPoolCount: number
): RouteDecisionTreeResponse['decision'] {
if (directPoolCount > 0 && (!destinationChainId || destinationChainId === sourceChainId)) {
return 'direct-pool';
}
if (sourceChainId === 138 && destinationChainId && destinationChainId !== 138) {
return directPoolCount > 0 ? 'atomic-swap-bridge' : 'bridge-only';
}
if (destinationChainId && destinationChainId !== sourceChainId) {
return directPoolCount > 0 ? 'destination-swap' : 'bridge-only';
}
return directPoolCount > 0 ? 'direct-pool' : 'unresolved';
}
interface BridgeResolution {
assetSymbol: string;
localQuoteAddress?: string;
route: ReturnType<typeof getRouteFromRegistry>;
}
export class RouteDecisionTreeService {
private tokenRepo: TokenRepository;
private poolRepo: PoolRepository;
constructor(tokenRepo = new TokenRepository(), poolRepo = new PoolRepository()) {
this.tokenRepo = tokenRepo;
this.poolRepo = poolRepo;
}
async build(request: RouteDecisionTreeRequest): Promise<RouteDecisionTreeResponse> {
const chainConfig = getChainConfig(request.chainId);
const destinationConfig = request.destinationChainId ? getChainConfig(request.destinationChainId) : undefined;
const destinationChainId = request.destinationChainId ?? request.chainId;
const sourceTokenIn = await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenIn);
const sourceTokenOut = request.tokenOut
? await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut)
: undefined;
const destinationTokenOut =
request.destinationChainId && request.tokenOut && request.destinationChainId !== request.chainId
? await resolveTokenDisplay(this.tokenRepo, request.destinationChainId, request.tokenOut)
: sourceTokenOut;
const bridgeResolution = this.resolveBridgeResolution(
request.chainId,
destinationChainId,
request.tokenIn,
request.tokenOut
);
const acceptableTokenOutAddresses = new Set<string>();
if (request.tokenOut) acceptableTokenOutAddresses.add(request.tokenOut.toLowerCase());
if (bridgeResolution?.localQuoteAddress) acceptableTokenOutAddresses.add(bridgeResolution.localQuoteAddress.toLowerCase());
const pools = await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn);
const directPools = request.tokenOut
? pools.filter((pool) =>
acceptableTokenOutAddresses.has(pool.token0Address.toLowerCase()) ||
acceptableTokenOutAddresses.has(pool.token1Address.toLowerCase())
)
: pools;
const destinationPools =
request.tokenOut && destinationChainId !== request.chainId
? await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut)
: [];
let resolvedPools = await Promise.all(
directPools
.slice()
.sort((a, b) => (b.totalLiquidityUsd || 0) - (a.totalLiquidityUsd || 0))
.map(async (pool) => {
const { token0, token1 } = await resolvePoolTokenDisplays(
this.tokenRepo,
request.chainId,
pool.token0Address,
pool.token1Address
);
return {
poolAddress: pool.poolAddress,
dexType: pool.dexType,
token0,
token1,
depth: buildDepth(pool),
};
})
);
const resolvedDestinationPools = await Promise.all(
destinationPools
.slice()
.sort((a, b) => (b.totalLiquidityUsd || 0) - (a.totalLiquidityUsd || 0))
.map(async (pool) => {
const { token0, token1 } = await resolvePoolTokenDisplays(
this.tokenRepo,
destinationChainId,
pool.token0Address,
pool.token1Address
);
return {
poolAddress: pool.poolAddress,
dexType: pool.dexType,
token0,
token1,
depth: buildDepth(pool),
};
})
);
if (request.chainId === CHAIN_138 && destinationChainId === CHAIN_138 && request.tokenOut && resolvedPools.length === 0) {
const liveFallbackPool = await this.findLiveDirectPoolFallback(request, sourceTokenIn, sourceTokenOut);
if (liveFallbackPool) {
resolvedPools = [liveFallbackPool, ...resolvedPools];
}
}
const missingQuoteTokenPools = await this.findMissingQuoteTokenPools(request.chainId, pools);
const tree: RouteNode[] = resolvedPools.map((pool) => {
const children: RouteNode[] = [];
if (destinationConfig && destinationChainId !== request.chainId) {
children.push({
id: `${request.chainId}:bridge:${pool.poolAddress}:${destinationChainId}`,
kind: 'bridge',
label: `Bridge to ${destinationConfig.name}`,
chainId: destinationChainId,
chainName: destinationConfig.name,
status: bridgeResolution?.route ? 'live' : 'partial',
notes: bridgeResolution?.route
? [
`Bridge route ${bridgeResolution.route.label} is configured for ${bridgeResolution.assetSymbol}`,
`Bridge address ${bridgeResolution.route.bridgeAddress}`,
]
: ['Bridge leg requires a configured production bridge lane for this asset'],
});
if (resolvedDestinationPools.length > 0) {
children.push({
id: `${destinationChainId}:swap:${request.tokenOut || 'unknown'}`,
kind: 'destination-swap',
label: `Destination swap to ${destinationTokenOut?.symbol || request.tokenOut || 'target'}`,
chainId: destinationChainId,
chainName: destinationConfig.name,
status: resolvedDestinationPools[0].depth.status,
depth: resolvedDestinationPools[0].depth,
tokenIn: destinationTokenOut,
tokenOut: destinationTokenOut,
poolAddress: resolvedDestinationPools[0].poolAddress,
dexType: resolvedDestinationPools[0].dexType,
notes: [
`Best destination pool: ${resolvedDestinationPools[0].token0.symbol}/${resolvedDestinationPools[0].token1.symbol}`,
`Destination TVL $${resolvedDestinationPools[0].depth.tvlUsd.toFixed(2)}`,
],
});
}
}
return {
id: `${request.chainId}:${pool.dexType}:${pool.poolAddress}`,
kind: 'direct-pool',
label: `${pool.token0.symbol}/${pool.token1.symbol}`,
chainId: request.chainId,
chainName: chainConfig?.name || `Chain ${request.chainId}`,
status: pool.depth.status,
depth: pool.depth,
tokenIn: sourceTokenIn,
tokenOut: sourceTokenOut,
poolAddress: pool.poolAddress,
dexType: pool.dexType,
path: [request.tokenIn, pool.token0.address, pool.token1.address].filter(Boolean),
notes: [
pool.depth.tvlUsd > 0 ? `TVL $${pool.depth.tvlUsd.toFixed(2)}` : 'No TVL reported',
`Estimated capacity $${pool.depth.estimatedTradeCapacityUsd.toFixed(2)}`,
],
children,
};
});
if (tree.length === 0 && destinationConfig && destinationChainId !== request.chainId) {
tree.push({
id: `${request.chainId}:bridge-only:${destinationChainId}`,
kind: 'bridge',
label: `Bridge to ${destinationConfig.name}`,
chainId: destinationChainId,
chainName: destinationConfig.name,
status: bridgeResolution?.route ? 'live' : 'partial',
tokenIn: sourceTokenIn,
tokenOut: sourceTokenOut,
notes: bridgeResolution?.route
? [
`Configured ${bridgeResolution.route.label} route for ${bridgeResolution.assetSymbol}`,
bridgeResolution.localQuoteAddress
? 'Destination asset maps to a source-chain quote mirror before bridging'
: 'Destination asset is directly bridgeable from the source token',
]
: [
'No direct local pool for this pair',
'Use the bridge leg first, then complete with destination liquidity',
],
});
}
const decision = deriveDecision(request.chainId, request.destinationChainId, resolvedPools.length);
return {
generatedAt: new Date().toISOString(),
source: {
chainId: request.chainId,
chainName: chainConfig?.name || `Chain ${request.chainId}`,
tokenIn: sourceTokenIn,
tokenOut: sourceTokenOut,
amountIn: request.amountIn,
},
destination: destinationConfig
? { chainId: destinationConfig.chainId, chainName: destinationConfig.name }
: undefined,
decision,
tree,
pools: resolvedPools,
missingQuoteTokenPools,
};
}
private async findMissingQuoteTokenPools(
chainId: number,
pools: LiquidityPool[]
): Promise<MissingQuoteTokenPool[]> {
const out: MissingQuoteTokenPool[] = [];
for (const pool of pools) {
const [token0, token1] = await Promise.all([
resolveTokenDisplay(this.tokenRepo, chainId, pool.token0Address),
resolveTokenDisplay(this.tokenRepo, chainId, pool.token1Address),
]);
if (token1.source === 'fallback') {
out.push({
poolAddress: pool.poolAddress,
chainId,
token0Address: pool.token0Address,
token1Address: pool.token1Address,
token0Symbol: token0.symbol,
token1Symbol: token1.symbol,
reason: 'Quote token metadata missing in token index; canonical or fallback resolution used',
});
}
}
return out;
}
private async findLiveDirectPoolFallback(
request: RouteDecisionTreeRequest,
sourceTokenIn: ResolvedTokenDisplay,
sourceTokenOut?: ResolvedTokenDisplay
): Promise<RouteDecisionTreeResponse['pools'][number] | null> {
const chainConfig = getChainConfig(request.chainId);
if (!chainConfig || request.chainId !== CHAIN_138 || !request.tokenOut) return null;
try {
const provider = new JsonRpcProvider(chainConfig.rpcUrl);
const integration = new Contract(CHAIN_138_PMM_INTEGRATION, PMM_ABI, provider);
const poolAddress = String(await integration.pools(request.tokenIn, request.tokenOut));
if (!poolAddress || /^0x0{40}$/i.test(poolAddress)) return null;
const code = await provider.getCode(poolAddress);
if (!code || code === '0x') return null;
const tokenInContract = new Contract(request.tokenIn, ERC20_ABI, provider);
const tokenOutContract = new Contract(request.tokenOut, ERC20_ABI, provider);
const [reserve0, reserve1] = await Promise.all([
tokenInContract.balanceOf(poolAddress),
tokenOutContract.balanceOf(poolAddress),
]);
const live = reserve0 > 0n && reserve1 > 0n;
return {
poolAddress,
dexType: 'dodo',
token0: sourceTokenIn,
token1: sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut),
depth: {
tvlUsd: 0,
reserve0: reserve0.toString(),
reserve1: reserve1.toString(),
estimatedTradeCapacityUsd: 0,
freshnessSeconds: 0,
status: live ? 'live' : 'stale',
},
};
} catch {
return null;
}
}
private resolveBridgeResolution(
sourceChainId: number,
destinationChainId: number,
tokenInAddress: string,
tokenOutAddress?: string
): BridgeResolution | null {
if (!tokenOutAddress || sourceChainId === destinationChainId) return null;
const sourceTokenInSpec = getCanonicalTokenByAddress(sourceChainId, tokenInAddress);
const destinationTokenOutSpec = getCanonicalTokenByAddress(destinationChainId, tokenOutAddress);
const bridgeAssetSymbol = destinationTokenOutSpec?.symbol || sourceTokenInSpec?.symbol;
if (!bridgeAssetSymbol) return null;
const route = getRouteFromRegistry(sourceChainId, destinationChainId, bridgeAssetSymbol);
if (!route) return null;
const localQuoteSpec = getCanonicalTokenBySymbol(sourceChainId, bridgeAssetSymbol);
return {
assetSymbol: bridgeAssetSymbol,
localQuoteAddress: localQuoteSpec?.addresses[sourceChainId],
route,
};
}
}

View File

@@ -0,0 +1,73 @@
import { TokenRepository, Token } from '../database/repositories/token-repo';
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
export interface ResolvedTokenDisplay {
address: string;
symbol: string;
name: string;
decimals?: number;
source: 'db' | 'canonical' | 'fallback';
token?: Token;
}
function shortenAddress(address: string): string {
const lower = address.toLowerCase();
return `${lower.slice(0, 6)}...${lower.slice(-4)}`;
}
export async function resolveTokenDisplay(
tokenRepo: TokenRepository,
chainId: number,
address: string
): Promise<ResolvedTokenDisplay> {
const normalized = address.toLowerCase();
const [dbToken, canonicalToken] = await Promise.all([
tokenRepo.getToken(chainId, normalized).catch(() => null),
Promise.resolve(getCanonicalTokenByAddress(chainId, normalized)),
]);
if (dbToken) {
return {
address: normalized,
symbol: dbToken.symbol || canonicalToken?.symbol || shortenAddress(normalized),
name: dbToken.name || canonicalToken?.name || dbToken.symbol || shortenAddress(normalized),
decimals: dbToken.decimals ?? canonicalToken?.decimals,
source: 'db',
token: dbToken,
};
}
if (canonicalToken) {
return {
address: normalized,
symbol: canonicalToken.symbol,
name: canonicalToken.name,
decimals: canonicalToken.decimals,
source: 'canonical',
};
}
return {
address: normalized,
symbol: shortenAddress(normalized),
name: normalized,
source: 'fallback',
};
}
export async function resolvePoolTokenDisplays(
tokenRepo: TokenRepository,
chainId: number,
token0Address: string,
token1Address: string
): Promise<{
token0: ResolvedTokenDisplay;
token1: ResolvedTokenDisplay;
}> {
const [token0, token1] = await Promise.all([
resolveTokenDisplay(tokenRepo, chainId, token0Address),
resolveTokenDisplay(tokenRepo, chainId, token1Address),
]);
return { token0, token1 };
}