diff --git a/services/token-aggregation/docs/ROUTE_DECISION_TREE.md b/services/token-aggregation/docs/ROUTE_DECISION_TREE.md new file mode 100644 index 0000000..963cbbe --- /dev/null +++ b/services/token-aggregation/docs/ROUTE_DECISION_TREE.md @@ -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 diff --git a/services/token-aggregation/package.json b/services/token-aggregation/package.json index aed18a4..c427bbe 100644 --- a/services/token-aggregation/package.json +++ b/services/token-aggregation/package.json @@ -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", diff --git a/services/token-aggregation/scripts/resolve-partner-payloads-example.mjs b/services/token-aggregation/scripts/resolve-partner-payloads-example.mjs new file mode 100644 index 0000000..70025c3 --- /dev/null +++ b/services/token-aggregation/scripts/resolve-partner-payloads-example.mjs @@ -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); +}); + diff --git a/services/token-aggregation/src/api/routes/aggregator-routes.ts b/services/token-aggregation/src/api/routes/aggregator-routes.ts new file mode 100644 index 0000000..decc2ea --- /dev/null +++ b/services/token-aggregation/src/api/routes/aggregator-routes.ts @@ -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; diff --git a/services/token-aggregation/src/api/routes/partner-payloads.ts b/services/token-aggregation/src/api/routes/partner-payloads.ts new file mode 100644 index 0000000..d06f958 --- /dev/null +++ b/services/token-aggregation/src/api/routes/partner-payloads.ts @@ -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; diff --git a/services/token-aggregation/src/api/routes/routes.ts b/services/token-aggregation/src/api/routes/routes.ts new file mode 100644 index 0000000..5d5daf1 --- /dev/null +++ b/services/token-aggregation/src/api/routes/routes.ts @@ -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; diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index d8b7763..335b896 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -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); diff --git a/services/token-aggregation/src/client/partner-payload-client.ts b/services/token-aggregation/src/client/partner-payload-client.ts new file mode 100644 index 0000000..7cf1c3a --- /dev/null +++ b/services/token-aggregation/src/client/partner-payload-client.ts @@ -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; + query: Record; + 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 { + const response = await this.http.post( + '/api/v1/routes/partner-payloads/resolve', + request + ); + return response.data; + } + + async getPartnerPayloads( + request: ResolvePartnerPayloadsRequest + ): Promise { + const response = await this.http.get( + '/api/v1/routes/partner-payloads', + { + params: request, + } + ); + return response.data; + } +} + diff --git a/services/token-aggregation/src/config/aggregator-route-matrix.ts b/services/token-aggregation/src/config/aggregator-route-matrix.ts new file mode 100644 index 0000000..ab49527 --- /dev/null +++ b/services/token-aggregation/src/config/aggregator-route-matrix.ts @@ -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; +} + +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; + 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; + tokens?: Record; + 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; + }); +} + diff --git a/services/token-aggregation/src/config/canonical-tokens.ts b/services/token-aggregation/src/config/canonical-tokens.ts index 070ecb4..ba982ae 100644 --- a/services/token-aggregation/src/config/canonical-tokens.ts +++ b/services/token-aggregation/src/config/canonical-tokens.ts @@ -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>> = { + 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>> = { }; 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 = { + USDC: USDC_LOGO, + USDT: USDT_LOGO, cUSDC: USDC_LOGO, cUSDT: USDT_LOGO, cEURC: USDC_LOGO, diff --git a/services/token-aggregation/src/config/cross-chain-bridges.ts b/services/token-aggregation/src/config/cross-chain-bridges.ts index 8b90808..f55d51a 100644 --- a/services/token-aggregation/src/config/cross-chain-bridges.ts +++ b/services/token-aggregation/src/config/cross-chain-bridges.ts @@ -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, diff --git a/services/token-aggregation/src/services/internal-execution-plan.ts b/services/token-aggregation/src/services/internal-execution-plan.ts new file mode 100644 index 0000000..9e83669 --- /dev/null +++ b/services/token-aggregation/src/services/internal-execution-plan.ts @@ -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>; + 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> { + const map = new Map>(); + 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[number], + reserveMap: Map> +): 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, + }, + }; +} diff --git a/services/token-aggregation/src/services/partner-payload-adapters.ts b/services/token-aggregation/src/services/partner-payload-adapters.ts new file mode 100644 index 0000000..716f1f8 --- /dev/null +++ b/services/token-aggregation/src/services/partner-payload-adapters.ts @@ -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; + query: Record; + 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 ', + }, + 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': '', + '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': '', + }, + 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); + } +} diff --git a/services/token-aggregation/src/services/partner-payload-dispatcher.ts b/services/token-aggregation/src/services/partner-payload-dispatcher.ts new file mode 100644 index 0000000..e9c906c --- /dev/null +++ b/services/token-aggregation/src/services/partner-payload-dispatcher.ts @@ -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 { + 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 { + 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', + }; + } +} + diff --git a/services/token-aggregation/src/services/route-decision-tree.ts b/services/token-aggregation/src/services/route-decision-tree.ts new file mode 100644 index 0000000..cb892fe --- /dev/null +++ b/services/token-aggregation/src/services/route-decision-tree.ts @@ -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; +} + +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 { + 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(); + 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 { + 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 { + 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, + }; + } +} diff --git a/services/token-aggregation/src/services/token-display.ts b/services/token-aggregation/src/services/token-display.ts new file mode 100644 index 0000000..dbe8fcd --- /dev/null +++ b/services/token-aggregation/src/services/token-display.ts @@ -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 { + 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 }; +}