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
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:
74
services/token-aggregation/docs/ROUTE_DECISION_TREE.md
Normal file
74
services/token-aggregation/docs/ROUTE_DECISION_TREE.md
Normal 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
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"dev": "ts-node src/index.ts",
|
"dev": "ts-node src/index.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint src --ext .ts",
|
"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": {
|
"dependencies": {
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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;
|
||||||
319
services/token-aggregation/src/api/routes/partner-payloads.ts
Normal file
319
services/token-aggregation/src/api/routes/partner-payloads.ts
Normal 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;
|
||||||
94
services/token-aggregation/src/api/routes/routes.ts
Normal file
94
services/token-aggregation/src/api/routes/routes.ts
Normal 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;
|
||||||
@@ -8,9 +8,12 @@ import adminRoutes from './routes/admin';
|
|||||||
import configRoutes from './routes/config';
|
import configRoutes from './routes/config';
|
||||||
import bridgeRoutes from './routes/bridge';
|
import bridgeRoutes from './routes/bridge';
|
||||||
import quoteRoutes from './routes/quote';
|
import quoteRoutes from './routes/quote';
|
||||||
|
import routeTreeRoutes from './routes/routes';
|
||||||
import tokenMappingRoutes from './routes/token-mapping';
|
import tokenMappingRoutes from './routes/token-mapping';
|
||||||
import heatmapRoutes from './routes/heatmap';
|
import heatmapRoutes from './routes/heatmap';
|
||||||
import arbitrageRoutes from './routes/arbitrage';
|
import arbitrageRoutes from './routes/arbitrage';
|
||||||
|
import aggregatorRouteMatrixRoutes from './routes/aggregator-routes';
|
||||||
|
import partnerPayloadRoutes from './routes/partner-payloads';
|
||||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||||
import { getDatabasePool } from '../database/client';
|
import { getDatabasePool } from '../database/client';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
@@ -102,10 +105,13 @@ export class ApiServer {
|
|||||||
this.app.use('/api/v1', configRoutes);
|
this.app.use('/api/v1', configRoutes);
|
||||||
this.app.use('/api/v1/report', reportRoutes);
|
this.app.use('/api/v1/report', reportRoutes);
|
||||||
this.app.use('/api/v1/bridge', bridgeRoutes);
|
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/token-mapping', tokenMappingRoutes);
|
||||||
this.app.use('/api/v1', quoteRoutes);
|
this.app.use('/api/v1', quoteRoutes);
|
||||||
this.app.use('/api/v1', heatmapRoutes);
|
this.app.use('/api/v1', heatmapRoutes);
|
||||||
this.app.use('/api/v1', arbitrageRoutes);
|
this.app.use('/api/v1', arbitrageRoutes);
|
||||||
|
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
|
||||||
|
this.app.use('/api/v1', partnerPayloadRoutes);
|
||||||
|
|
||||||
// Admin routes (stricter rate limit)
|
// Admin routes (stricter rate limit)
|
||||||
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);
|
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
158
services/token-aggregation/src/config/aggregator-route-matrix.ts
Normal file
158
services/token-aggregation/src/config/aggregator-route-matrix.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 */
|
/** Verified addresses from CHAIN138_TOKEN_ADDRESSES, .env, and deployment summaries */
|
||||||
const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||||
|
USDC: {
|
||||||
|
[CHAIN_138]: '0x71D6687F38b93CCad569Fa6352c876eea967201b',
|
||||||
|
},
|
||||||
|
USDT: {
|
||||||
|
[CHAIN_138]: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||||
|
},
|
||||||
cUSDC: {
|
cUSDC: {
|
||||||
[CHAIN_138]: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
[CHAIN_138]: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||||
[CHAIN_651940]: '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', // AUSDC on ALL Mainnet
|
[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 {
|
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 key = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
|
||||||
const envVal = process.env[key];
|
const envVal = process.env[key];
|
||||||
if (envVal && envVal.trim() !== '') return envVal;
|
if (envVal && envVal.trim() !== '') return envVal;
|
||||||
@@ -91,6 +103,9 @@ function addr(symbol: string, chainId: number): string | undefined {
|
|||||||
|
|
||||||
export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||||
// --- Base (GRU-M1) ---
|
// --- 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
|
// Chain 138 v0 only (no X): cUSDC on 138; cXUSDC used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||||
{ symbol: 'cUSDC', name: 'USD Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDC', addresses: { [CHAIN_138]: addr('cUSDC', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDC', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDC', id)])) } },
|
{ symbol: 'cUSDC', 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
|
// 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: '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: '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: '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) } },
|
{ 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 ---
|
// --- 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) } },
|
{ 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);
|
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).
|
/** IPFS-hosted logo URLs (Pinata) for Uniswap token list (logoURI).
|
||||||
* Every token must have logoURI for MetaMask to display icons. getLogoUriForSpec resolves
|
* 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. */
|
* 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 USDT_LOGO = `${IPFS_GATEWAY}/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP`;
|
||||||
|
|
||||||
const LOGO_BY_SYMBOL: Record<string, string> = {
|
const LOGO_BY_SYMBOL: Record<string, string> = {
|
||||||
|
USDC: USDC_LOGO,
|
||||||
|
USDT: USDT_LOGO,
|
||||||
cUSDC: USDC_LOGO,
|
cUSDC: USDC_LOGO,
|
||||||
cUSDT: USDT_LOGO,
|
cUSDT: USDT_LOGO,
|
||||||
cEURC: USDC_LOGO,
|
cEURC: USDC_LOGO,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface BridgeLane {
|
|||||||
export interface BridgeConfig {
|
export interface BridgeConfig {
|
||||||
address: string;
|
address: string;
|
||||||
chainId: number;
|
chainId: number;
|
||||||
type: 'ccip_weth9' | 'ccip_weth10' | 'alltra' | 'universal_ccip';
|
type: 'ccip_weth9' | 'ccip_weth10' | 'ccip_stable' | 'alltra' | 'universal_ccip';
|
||||||
tokenSymbol?: string;
|
tokenSymbol?: string;
|
||||||
lanes: BridgeLane[];
|
lanes: BridgeLane[];
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,16 @@ function envAddr(key: string): string {
|
|||||||
return typeof v === 'string' && v.startsWith('0x') ? v : '';
|
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[] = [];
|
export const CHAIN_138_BRIDGES: BridgeConfig[] = [];
|
||||||
|
const STABLE_ASSET_SYMBOLS = new Set(['USDT', 'USDC', 'CUSDT', 'CUSDC']);
|
||||||
|
|
||||||
if (envAddr('CCIPWETH9_BRIDGE_CHAIN138')) {
|
if (envAddr('CCIPWETH9_BRIDGE_CHAIN138')) {
|
||||||
CHAIN_138_BRIDGES.push({
|
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')) {
|
if (envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS')) {
|
||||||
const addr = envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS');
|
const addr = envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || envAddr('ALLTRA_ADAPTER_ADDRESS');
|
||||||
CHAIN_138_BRIDGES.push({
|
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({
|
CHAIN_138_BRIDGES.push({
|
||||||
address: envAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS'),
|
address: envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE'),
|
||||||
chainId: chainId138,
|
chainId: chainId138,
|
||||||
type: 'universal_ccip',
|
type: 'universal_ccip',
|
||||||
lanes: [
|
lanes: [
|
||||||
@@ -89,6 +111,8 @@ export interface RoutingRegistryEntry {
|
|||||||
|
|
||||||
const ALLTRA_ADAPTER_138 = envAddr('ALLTRA_ADAPTER_ADDRESS') || envAddr('ALLTRA_CUSTOM_BRIDGE_ADDRESS') || '0x66FEBA2fC9a0B47F26DD4284DAd24F970436B8Dc';
|
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_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).
|
* Get routing registry entry for (fromChain, toChain, asset).
|
||||||
@@ -115,6 +139,49 @@ export function getRouteFromRegistry(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fromChain === 138 || toChain === 138) {
|
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 {
|
return {
|
||||||
pathType: 'CCIP',
|
pathType: 'CCIP',
|
||||||
bridgeAddress: CCIP_WETH9_138,
|
bridgeAddress: CCIP_WETH9_138,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
449
services/token-aggregation/src/services/route-decision-tree.ts
Normal file
449
services/token-aggregation/src/services/route-decision-tree.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
73
services/token-aggregation/src/services/token-display.ts
Normal file
73
services/token-aggregation/src/services/token-display.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user