feat: bridges, PMM, flash workflow, token-aggregation, and deployment docs
- CCIP/trustless bridge contracts, GRU tokens, DEX/PMM tests, reserve vault. - Token-aggregation service routes, planner, chain config, relay env templates. - Config snapshots and multi-chain deployment markdown updates. - gitignore services/btc-intake/dist/ (tsc output); do not track dist. Run forge build && forge test before deploy (large solc graph). Made-with: Cursor
This commit is contained in:
137
services/token-aggregation/.env.example
Normal file
137
services/token-aggregation/.env.example
Normal file
@@ -0,0 +1,137 @@
|
||||
# Token-aggregation service — copy to .env and adjust.
|
||||
# See docs/04-configuration/TOKEN_AGGREGATION_REPORT_API_RUNBOOK.md
|
||||
|
||||
PORT=3000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Chain 138 RPC:
|
||||
# - explorer / token-aggregation / Blockscout on the LAN should use the public RPC node directly:
|
||||
# CHAIN_138_RPC_URL=http://192.168.11.221:8545
|
||||
# - external/public clients should use the public FQDN:
|
||||
# CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
# - do not point explorer/read services at the operator core RPC 192.168.11.211:8545
|
||||
|
||||
# GET /api/v1/quote on Chain 138 + DODO: optional on-chain PMM quote (querySellBase/Quote).
|
||||
# Precedence: TOKEN_AGGREGATION_PMM_RPC_URL → TOKEN_AGGREGATION_CHAIN138_RPC_URL → RPC_URL_138.
|
||||
# TOKEN_AGGREGATION_PMM_RPC_URL=http://192.168.11.211:8545
|
||||
# TOKEN_AGGREGATION_PMM_QUERY_TRADER=0x4A666F96fC8764181194447A7dFdb7d471b301C8
|
||||
|
||||
# PMM pools: canonical integration is defaulted in dex-factories.ts if unset.
|
||||
# CHAIN_138_DODO_PMM_INTEGRATION=0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d
|
||||
# CHAIN_138_DODO_POOL_MANAGER=
|
||||
|
||||
# Minimum token report addresses (V1 = PMM / liquidity canonical on Chain 138)
|
||||
CUSDT_ADDRESS_138=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22
|
||||
CUSDC_ADDRESS_138=0xf22258f57794CC8E06237084b353Ab30fFfa640b
|
||||
|
||||
# Compliant USD V2 (ERC-2612 / ERC-3009) — x402 / GRU transport.
|
||||
# Reference: docs/04-configuration/CHAIN138_X402_TOKEN_SUPPORT.md
|
||||
CUSDT_V2_ADDRESS_138=0x8d342d321DdEe97D0c5011DAF8ca0B59DA617D29
|
||||
CUSDC_V2_ADDRESS_138=0x1ac3F4942a71E86A9682D91837E1E71b7BACdF99
|
||||
|
||||
# Live ALL Mainnet AUSDT compliant landing surface on Chain 138.
|
||||
CAUSDT_ADDRESS_138=0x5fdDF65733e3d590463F68f93Cf16E8c04081271
|
||||
|
||||
# Planned ALL Mainnet gold corridor surfaces.
|
||||
# These remain env-gated until the 651940 wrapped + unwrapped gold contracts are deployed.
|
||||
# CAXAUC_ADDRESS_651940=0x...
|
||||
# CAXAUT_ADDRESS_651940=0x...
|
||||
# CWAXAUC_ADDRESS_651940=0x...
|
||||
# CWAXAUT_ADDRESS_651940=0x...
|
||||
|
||||
# Repo-native D-WIN-aligned USDW PMM / transport surface.
|
||||
CUSDW_ADDRESS_138=0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e
|
||||
# Existing public cWAUSDT mirrors for the live AUSDT -> cWAUSDT -> cAUSDT path:
|
||||
CWAUSDT_ADDRESS_56=0xe1a51Bc037a79AB36767561B147eb41780124934
|
||||
CWAUSDT_ADDRESS_137=0xf12e262F85107df26741726b074606CaFa24AAe7
|
||||
CWAUSDT_ADDRESS_43114=0xff3084410A732231472Ee9f93F5855dA89CC5254
|
||||
CWAUSDT_ADDRESS_42220=0xC158b6cD3A3088C52F797D41f5Aa02825361629e
|
||||
# Preferred canonical env names for wrapped cWUSDW edge visibility:
|
||||
CWUSDW_ADDRESS_56=0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55
|
||||
# CWUSDW_ADDRESS_137=0x... # Activate only after Polygon cWUSDW is deployed and approved.
|
||||
CWUSDW_ADDRESS_43114=0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae
|
||||
# Legacy aliases still understood by canonical-tokens.ts:
|
||||
# CWUSDW_BSC=0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55
|
||||
# CWUSDW_AVALANCHE=0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae
|
||||
|
||||
# PostgreSQL (required for persistent index / reports)
|
||||
# DATABASE_URL=postgresql://user:pass@localhost:5432/token_aggregation
|
||||
|
||||
# Indexer tick (ms)
|
||||
# INDEXING_INTERVAL=5000
|
||||
|
||||
# Set to false on public read-only deployments that should not run the
|
||||
# background multi-chain indexer in-process.
|
||||
# ENABLE_INDEXER=true
|
||||
|
||||
# Optional: override built-in bridge/routes JSON (fetched every 5m)
|
||||
# BRIDGE_LIST_JSON_URL=https://example.com/bridge-list.json
|
||||
|
||||
# CCIP / Trustless overrides for /api/v1/bridge/routes defaults
|
||||
# CCIPWETH9_BRIDGE_CHAIN138=
|
||||
# CCIPWETH10_BRIDGE_CHAIN138=
|
||||
# LOCKBOX_138=
|
||||
# INBOX_ETH=
|
||||
|
||||
# GRU Monetary Transport Layer runtime refs
|
||||
# Set these when exposing GRU transport readiness from token-aggregation.
|
||||
# CHAIN138_L1_BRIDGE=0x152ed3e9912161b76bdfd368d0c84b7c31c10de7
|
||||
# CW_BRIDGE_MAINNET=
|
||||
# CW_BRIDGE_BSC=
|
||||
# CW_BRIDGE_POLYGON=
|
||||
# CW_BRIDGE_AVALANCHE=
|
||||
# CW_BRIDGE_CELO=
|
||||
# CW_BRIDGE_ARBITRUM=
|
||||
# CW_BRIDGE_BASE=
|
||||
# CW_BRIDGE_OPTIMISM=
|
||||
# CW_BRIDGE_GNOSIS=
|
||||
# CW_RESERVE_VERIFIER_CHAIN138=
|
||||
# CW_STABLECOIN_RESERVE_VAULT=
|
||||
# CW_RESERVE_SYSTEM=
|
||||
# CW_MAX_OUTSTANDING_USDT_MAINNET=
|
||||
# CW_MAX_OUTSTANDING_USDC_MAINNET=
|
||||
# CW_MAX_OUTSTANDING_USDT_BSC=
|
||||
# CW_MAX_OUTSTANDING_USDC_BSC=
|
||||
# CW_MAX_OUTSTANDING_USDT_POLYGON=
|
||||
# CW_MAX_OUTSTANDING_USDC_POLYGON=
|
||||
# CW_MAX_OUTSTANDING_USDT_AVALANCHE=
|
||||
# CW_MAX_OUTSTANDING_USDC_AVALANCHE=
|
||||
# CW_MAX_OUTSTANDING_USDT_ARBITRUM=
|
||||
# CW_MAX_OUTSTANDING_USDC_ARBITRUM=
|
||||
# CW_MAX_OUTSTANDING_USDT_BASE=
|
||||
# CW_MAX_OUTSTANDING_USDC_BASE=
|
||||
# CW_MAX_OUTSTANDING_USDT_OPTIMISM=
|
||||
# CW_MAX_OUTSTANDING_USDC_OPTIMISM=
|
||||
# CW_MAX_OUTSTANDING_USDT_GNOSIS=
|
||||
# CW_MAX_OUTSTANDING_USDC_GNOSIS=
|
||||
# CW_MAX_OUTSTANDING_USDT_CELO=
|
||||
# CW_MAX_OUTSTANDING_USDC_CELO=
|
||||
# CW_MAX_OUTSTANDING_AUSDT_CELO=
|
||||
# Gas-native rollout refs (Wave 1)
|
||||
# Deployed but not active-by-default: generic gas verifier on Chain 138.
|
||||
# Keep the active gas verifier envs below blank until the live L1 bridge is explicitly wired to it.
|
||||
CW_ASSET_RESERVE_VERIFIER_DEPLOYED_CHAIN138=0xbf26a679586663f87f3bf3f52c79479b8aa8d854
|
||||
# CW_BRIDGE_WEMIX=
|
||||
# CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138=
|
||||
# CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138=
|
||||
# CW_GAS_ESCROW_VAULT_CHAIN138=
|
||||
# CW_GAS_TREASURY_SYSTEM=
|
||||
# CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET=
|
||||
# CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET=
|
||||
# CW_GAS_ESCROWED_ETH_MAINNET_MAINNET=
|
||||
# CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET=
|
||||
# CW_GAS_TREASURY_CAP_ETH_MAINNET_MAINNET=
|
||||
# CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM=
|
||||
# CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM=
|
||||
# CW_GAS_ESCROWED_ETH_L2_OPTIMISM=
|
||||
# CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM=
|
||||
# CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM=
|
||||
# CW_MAX_OUTSTANDING_ETH_L2_ARBITRUM=
|
||||
# CW_MAX_OUTSTANDING_ETH_L2_BASE=
|
||||
# CW_MAX_OUTSTANDING_BNB_BSC=
|
||||
# CW_MAX_OUTSTANDING_POL_POLYGON=
|
||||
# CW_MAX_OUTSTANDING_AVAX_AVALANCHE=
|
||||
# CW_MAX_OUTSTANDING_CRO_CRONOS=
|
||||
# CW_MAX_OUTSTANDING_XDAI_GNOSIS=
|
||||
# CW_MAX_OUTSTANDING_CELO_CELO=
|
||||
# CW_MAX_OUTSTANDING_WEMIX_WEMIX=
|
||||
@@ -81,7 +81,7 @@ pct exec $VMID -- nano /opt/token-aggregation/.env
|
||||
|
||||
Required variables:
|
||||
- `DATABASE_URL`
|
||||
- `CHAIN_138_RPC_URL`
|
||||
- `CHAIN_138_RPC_URL` (`http://192.168.11.221:8545` for LAN/explorer deployments; not the operator core RPC `http://192.168.11.211:8545`)
|
||||
- `CHAIN_651940_RPC_URL`
|
||||
|
||||
### Database Migration
|
||||
|
||||
@@ -23,10 +23,12 @@ cp .env.example .env
|
||||
Minimum required in `.env`:
|
||||
```bash
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/explorer_db
|
||||
CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
CHAIN_138_RPC_URL=http://192.168.11.221:8545
|
||||
CHAIN_651940_RPC_URL=https://mainnet-rpc.alltra.global
|
||||
```
|
||||
|
||||
Use `https://rpc-http-pub.d-bis.org` only for external/public-only deployments. Do not point explorer/read services at the operator core RPC `http://192.168.11.211:8545`.
|
||||
|
||||
### 3. Run Database Migration
|
||||
```bash
|
||||
# Navigate to explorer backend and run migration
|
||||
|
||||
@@ -11,6 +11,7 @@ A comprehensive token aggregation service that indexes token info, volume, liqui
|
||||
- **Chain-Native Indexing**: Indexes tokens, DEX pools, and swap events directly from blockchain
|
||||
- **External API Enrichment**: Enriches data with CoinGecko, CoinMarketCap, and DexScreener
|
||||
- **Multi-DEX Support**: Supports UniswapV2, UniswapV3, and DODO PMM protocols
|
||||
- **Chain 138 PMM quotes**: `GET /api/v1/quote` runs on-chain `querySellBase` / `querySellQuote` when RPC is configured (`RPC_URL_138` or `TOKEN_AGGREGATION_*` in `.env.example`); JSON includes `quoteEngine` (`pmm-onchain` vs `constant-product`)
|
||||
- **OHLCV Data**: Generates Open, High, Low, Close, Volume data for price charts
|
||||
- **Volume Analytics**: Calculates 5m, 1h, 24h, 7d, 30d volume metrics
|
||||
- **REST API**: Unified REST API for all token data
|
||||
@@ -50,7 +51,9 @@ cp .env.example .env
|
||||
4. Configure environment variables in `.env` (see `.env.example` for full list):
|
||||
```bash
|
||||
# Chain RPCs
|
||||
CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
# Explorer-side/LAN deployment: use the public RPC node directly.
|
||||
CHAIN_138_RPC_URL=http://192.168.11.221:8545
|
||||
# External-only deployments may use: https://rpc-http-pub.d-bis.org
|
||||
CHAIN_651940_RPC_URL=https://mainnet-rpc.alltra.global
|
||||
|
||||
# Database
|
||||
@@ -62,7 +65,12 @@ COINMARKETCAP_API_KEY=your_key_here
|
||||
DEXSCREENER_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
**Canonical token addresses (report API):** Set per-chain env vars for tokens you want in `/api/v1/report/*`. Required/minimal for report: `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138` (Chain 138); optionally `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940` for Chain 651940. **Chain 138 compliant fiat** (cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT) have **fallback addresses** in `src/config/canonical-tokens.ts` (DeployCompliantFiatTokens 2026-02-27); they are included in the report without env. Override with `CEURC_ADDRESS_138`, etc. if needed. Other symbols (USDW, acUSDC, vdcUSDC, sdcUSDC, etc.) — see `.env.example`. Unset tokens (with no fallback) are omitted from the report.
|
||||
For Chain 138 there is an important split:
|
||||
- Explorer, Blockscout, token-aggregation, and other read-mostly services should use the public RPC node on `192.168.11.221:8545` when they run on the LAN.
|
||||
- External wallets and dApps should use `https://rpc-http-pub.d-bis.org`.
|
||||
- Operator/deploy workflows use the core RPC `http://192.168.11.211:8545`, but that core endpoint is not for explorer/read traffic.
|
||||
|
||||
**Canonical token addresses (report API):** Set per-chain env vars for tokens you want in `/api/v1/report/*`. Required/minimal for report: `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138` (Chain 138); optionally `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940` for Chain 651940. **Chain 138 compliant fiat** (cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT) have **fallback addresses** in `src/config/canonical-tokens.ts` (DeployCompliantFiatTokens 2026-02-27); they are included in the report without env. The live **ALL Mainnet AUSDT corridor** is also surfaced there as `cAUSDT` on Chain 138 plus `cWAUSDT` mirrors on BSC, Polygon, Avalanche, and Celo. The planned **ALL Mainnet gold corridor** is env-gated under `CAXAUC_ADDRESS_651940`, `CAXAUT_ADDRESS_651940`, `CWAXAUC_ADDRESS_651940`, and `CWAXAUT_ADDRESS_651940`. Other symbols (USDW, acUSDC, vdcUSDC, sdcUSDC, etc.) — see `.env.example`. Unset tokens (with no fallback) are omitted from the report.
|
||||
|
||||
### Required environment variables (canonical tokens — Blitzkrieg Step 1/9)
|
||||
|
||||
@@ -70,8 +78,8 @@ The canonical token list is defined in `src/config/canonical-tokens.ts` and is t
|
||||
|
||||
| Purpose | Env var pattern | Example |
|
||||
|--------|------------------|--------|
|
||||
| Chain 138 | `{SYMBOL}_ADDRESS_138` (symbol with `-` → `_`, uppercase) | `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138`, `USDW_ADDRESS_138`, `ACUSDC_ADDRESS_138`, `VDCUSDC_ADDRESS_138`, `SDCUSDC_ADDRESS_138` |
|
||||
| Chain 651940 (ALL Mainnet) | `{SYMBOL}_ADDRESS_651940` | `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940` |
|
||||
| Chain 138 | `{SYMBOL}_ADDRESS_138` (symbol with `-` → `_`, uppercase) | `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138`, `CAUSDT_ADDRESS_138`, `USDW_ADDRESS_138`, `ACUSDC_ADDRESS_138`, `VDCUSDC_ADDRESS_138`, `SDCUSDC_ADDRESS_138` |
|
||||
| Chain 651940 (ALL Mainnet) | `{SYMBOL}_ADDRESS_651940` | `CUSDC_ADDRESS_651940`, `CUSDT_ADDRESS_651940`, `AUSDT_ADDRESS_651940`, `CAXAUC_ADDRESS_651940`, `CWAXAUC_ADDRESS_651940` |
|
||||
|
||||
**Minimum for report API:** `CUSDC_ADDRESS_138`, `CUSDT_ADDRESS_138`. For full GRU M1 + W-tokens + ac*/vdc*/sdc* coverage, set the corresponding `*_ADDRESS_138` and `*_ADDRESS_651940` vars. See `.env.example` for the full commented list. Refs: [BLITZKRIEG_SUPER_PRO_MAX_MASTER_PLAN](../../../docs/00-meta/BLITZKRIEG_SUPER_PRO_MAX_MASTER_PLAN.md) §2–§3, [PLACEHOLDERS_AND_COMPLETION_MASTER_LIST](../../../docs/00-meta/PLACEHOLDERS_AND_COMPLETION_MASTER_LIST.md).
|
||||
|
||||
@@ -198,8 +206,8 @@ Full API for all coins, tokens, liquidity, and reportable data in CMC/CoinGecko-
|
||||
| `GET /api/v1/report/all` | All tokens, pools, liquidity, volume, and summary by chain |
|
||||
| `GET /api/v1/report/coingecko?chainId=138` | Token list in CoinGecko submission format |
|
||||
| `GET /api/v1/report/cmc?chainId=138` | Token list and DEX pairs in CoinMarketCap format |
|
||||
| `GET /api/v1/report/token-list` | Flat canonical token list (all chains or `?chainId=138`) |
|
||||
| `GET /api/v1/report/canonical` | Raw canonical token spec (symbol, name, type, decimals, addresses) |
|
||||
| `GET /api/v1/report/token-list` | Flat canonical token list, including explicit Chain 138 V1/V2 GRU deployments where available |
|
||||
| `GET /api/v1/report/canonical` | Raw canonical token spec with version/family metadata and addresses |
|
||||
|
||||
See [docs/CMC_COINGECKO_REPORTING.md](docs/CMC_COINGECKO_REPORTING.md) for usage and listing submission.
|
||||
|
||||
|
||||
@@ -99,7 +99,8 @@ Raw canonical token definitions (no DB merge): symbol, name, type, decimals, cur
|
||||
|
||||
The report uses the **canonical token list** in `src/config/canonical-tokens.ts`. It includes:
|
||||
|
||||
- **Base (GRU-M1):** cUSDC, cUSDT, cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT
|
||||
- **Base (GRU-M1):** cUSDC, cUSDT, cAUSDT, cEURC, cEURT, cGBPC, cGBPT, cAUDC, cJPYC, cCHFC, cCADC, cXAUC, cXAUT, plus env-gated ALL Mainnet gold landings `cAXAUC` / `cAXAUT`
|
||||
- **Public transport mirrors (cW*):** cWUSDC, cWUSDT, cWUSDW, live cWAUSDT mirrors on BSC, Polygon, Avalanche, and Celo, plus env-gated ALL Mainnet gold wrappers `cWAXAUC` / `cWAXAUT`
|
||||
- **W-tokens (ISO-4217):** USDW, EURW, GBPW, AUDW, JPYW, CHFW, CADW
|
||||
- **Asset (ac*):** acUSDC, acUSDT, acEURC, acGBPC, acAUDC, acJPYC, acCHFC, acCADC, acXAUC
|
||||
- **Debt (vdc* / sdc*):** vdcUSDC, sdcUSDC, vdcEURC, sdcEURC, vdcGBPC, sdcGBPC, vdcAUDC, sdcAUDC, vdcJPYC, sdcJPYC, vdcCHFC, sdcCHFC, vdcCADC, sdcCADC, vdcXAUC, sdcXAUC
|
||||
@@ -112,6 +113,17 @@ Addresses per chain can be:
|
||||
|
||||
Only tokens with a non-empty address for the requested chain appear in `/report/coingecko`, `/report/cmc`, and in the per-chain sections of `/report/all` and `/report/token-list`.
|
||||
|
||||
For the live **ALL Mainnet AUSDT -> cWAUSDT -> cAUSDT** corridor, that means:
|
||||
|
||||
- `cWAUSDT` appears on BSC, Polygon, Avalanche, and Celo
|
||||
- `cAUSDT` appears on Chain 138 via the live deployed address
|
||||
|
||||
For the planned **ALL Mainnet gold** corridor, the report remains intentionally env-gated until the destination contracts are live:
|
||||
|
||||
- `cAXAUC` / `cAXAUT` only appear on chain `651940` when `CAXAUC_ADDRESS_651940` / `CAXAUT_ADDRESS_651940` are set
|
||||
- `cWAXAUC` / `cWAXAUT` only appear on chain `651940` when `CWAXAUC_ADDRESS_651940` / `CWAXAUT_ADDRESS_651940` are set
|
||||
- generic `cXAUC` / `cXAUT` do **not** appear on `651940`; those remain canonical Chain 138 symbols
|
||||
|
||||
## ERC-20 and DEX Compatibility
|
||||
|
||||
All canonical tokens are designed to be **ERC-20 compliant** and usable in DEX liquidity pools (see `smom-dbis-138/docs/tokenization/TOKEN_SCOPE_GRU.md`). Base and asset tokens are fully transferable; debt tokens can be deployed with optional transferability. The report API does not enforce on-chain checks; it reports whatever is in the canonical list and DB (addresses, market data, pools).
|
||||
|
||||
@@ -37,7 +37,7 @@ cp .env.example .env
|
||||
2. Configure required variables:
|
||||
```bash
|
||||
# Required
|
||||
CHAIN_138_RPC_URL=https://rpc-http-pub.d-bis.org
|
||||
CHAIN_138_RPC_URL=http://192.168.11.221:8545
|
||||
CHAIN_651940_RPC_URL=https://mainnet-rpc.alltra.global
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/explorer_db
|
||||
|
||||
@@ -47,6 +47,8 @@ COINMARKETCAP_API_KEY=your_key_here
|
||||
DEXSCREENER_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
For explorer/LAN deployments, `CHAIN_138_RPC_URL` should point to the public Chain 138 RPC node directly at `http://192.168.11.221:8545`. Use `https://rpc-http-pub.d-bis.org` for external-only consumers. Do not point explorer/read services at the operator core RPC `http://192.168.11.211:8545`.
|
||||
|
||||
## Local Deployment
|
||||
|
||||
### Using npm
|
||||
@@ -105,12 +107,15 @@ kind: ConfigMap
|
||||
metadata:
|
||||
name: token-aggregation-config
|
||||
data:
|
||||
CHAIN_138_RPC_URL: "https://rpc-http-pub.d-bis.org"
|
||||
CHAIN_138_RPC_URL: "http://192.168.11.221:8545"
|
||||
CHAIN_651940_RPC_URL: "https://mainnet-rpc.alltra.global"
|
||||
INDEXING_INTERVAL: "5000"
|
||||
ENABLE_INDEXER: "true"
|
||||
LOG_LEVEL: "info"
|
||||
```
|
||||
|
||||
Set `ENABLE_INDEXER` to `"false"` for public read-only explorer deployments that should serve API traffic without running the in-process multi-chain indexer.
|
||||
|
||||
2. Create a Secret for sensitive data:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
|
||||
@@ -16,9 +16,15 @@ Returns supported chains.
|
||||
|
||||
### GET /api/v1/networks
|
||||
|
||||
Full EIP-3085 chain params for `wallet_addEthereumChain` (Chain 138, Ethereum Mainnet 1, ALL Mainnet 651940). Includes RPC URLs, block explorer URLs, native currency, and oracles per chain. Used by the MetaMask Snap to serve dynamic network and oracle data. If **NETWORKS_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that URL and returns `{ version, networks }`; otherwise uses built-in networks.
|
||||
Full EIP-3085 chain params for `wallet_addEthereumChain` (Chain 138, Ethereum Mainnet 1, ALL Mainnet 651940). Includes RPC URLs, block explorer URLs, native currency, and oracles per chain. Used by the MetaMask Snap to serve dynamic network and oracle data. Source priority is:
|
||||
|
||||
**Response:** `{ version: string, networks: NetworkEntry[] }`
|
||||
1. **NETWORKS_JSON_URL** when configured
|
||||
2. **NETWORKS_JSON_PATH** / **CONFIG_NETWORKS_JSON_PATH** runtime local JSON
|
||||
3. built-in network config
|
||||
|
||||
Responses include `source: "remote-url" | "runtime-file" | "built-in"` and send `Cache-Control: public, max-age=0, must-revalidate`.
|
||||
|
||||
**Response:** `{ source, version: string, networks: NetworkEntry[], lastModified?: string }`
|
||||
Each `NetworkEntry` has: `chainId` (hex), `chainIdDecimal`, `chainName`, `rpcUrls`, `nativeCurrency`, `blockExplorerUrls`, `iconUrls` (chain-specific logos; optional), `oracles: [{ name, address, decimals? }]`. Chain 138 and ALL Mainnet include explorer favicons; Ethereum includes standard ETH logo.
|
||||
|
||||
### GET /api/v1/config
|
||||
@@ -27,14 +33,18 @@ Oracles (and config) per chain. Used by the Snap for USD price feeds (e.g. ETH/U
|
||||
|
||||
**Query:** `chainId` (optional) — if provided, returns config for that chain only.
|
||||
|
||||
**Response (no query):** `{ version: string, chains: [{ chainId, oracles: [{ name, address }] }] }`
|
||||
**Response (chainId=138):** `{ version, chainId: 138, oracles: [{ name, address }] }`
|
||||
`/api/v1/config` follows the same source priority as `/api/v1/networks`, so both endpoints read from the same freshest source rather than drifting.
|
||||
|
||||
**Response (no query):** `{ source, version: string, chains: [{ chainId, oracles: [{ name, address }] }] }`
|
||||
**Response (chainId=138):** `{ source, version, chainId: 138, oracles: [{ name, address }] }`
|
||||
|
||||
---
|
||||
|
||||
## Token list (report)
|
||||
|
||||
**GET /api/v1/report/token-list** returns a Uniswap-style token list with **logoURI** per token and a list-level **logoURI**. Each token has: `chainId`, `address`, `symbol`, `name`, `decimals`, `type`, `logoURI`. Use for MetaMask token list URL or Snap `get_token_list`. Optional query `?chainId=138` filters by chain. If **TOKEN_LIST_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that JSON and returns it (with optional chainId filter); otherwise uses the built-in canonical token list.
|
||||
**GET /api/v1/report/token-list** returns a Uniswap-style token list with **logoURI** per token and a list-level **logoURI**. Each token has: `chainId`, `address`, `symbol`, `name`, `decimals`, `type`, `logoURI`. Chain 138 also exposes staged GRU x402 deployments such as `cUSDT_V2` and `cUSDC_V2` explicitly, with optional metadata fields like `familySymbol`, `deploymentVersion`, and `preferredForX402`. Use for MetaMask token list URL or Snap `get_token_list`. Optional query `?chainId=138` filters by chain. If **TOKEN_LIST_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that JSON and returns it (with optional chainId filter); otherwise uses the built-in canonical token list.
|
||||
|
||||
**GET /api/v1/report/cw-registry** returns the public-chain `cW*` registry grouped by chain. When `DEPLOYMENT_STATUS_JSON_PATH` / `CW_REGISTRY_JSON_PATH` points to `cross-chain-pmm-lps/config/deployment-status.json`, the endpoint reads that file at request time so explorer surfaces do not need a rebuild after registry edits. If the file is unavailable, it falls back to the built-in canonical `cW*` subset.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,7 +52,7 @@ Oracles (and config) per chain. Used by the Snap for USD price feeds (e.g. ETH/U
|
||||
|
||||
### GET /api/v1/quote
|
||||
|
||||
Returns an estimated swap output amount (constant-product from first available pool for the token pair).
|
||||
Returns an estimated swap output amount (constant-product from first available pool for the token pair). When a staged Chain 138 deployment such as `cUSDT_V2` or `cUSDC_V2` is requested before pool cutover, the response includes `canonicalLiquidity` showing whether the quote was resolved through the current active liquidity deployment.
|
||||
|
||||
**Query:**
|
||||
|
||||
@@ -62,12 +72,41 @@ If no pool is found, `amountOut` is `null` and `error` describes. Used by the Me
|
||||
|
||||
### GET /api/v1/bridge/routes
|
||||
|
||||
Returns CCIP bridge routes for WETH9 and WETH10 (Chain 138 and Ethereum Mainnet). Used by the MetaMask Snap and dApps for bridge discovery. If **BRIDGE_LIST_JSON_URL** is set (e.g. GitHub raw URL), the service fetches that JSON and returns `{ routes, chain138Bridges }`; otherwise uses built-in routes.
|
||||
Returns CCIP bridge routes for WETH9 and WETH10 (Chain 138 and Ethereum Mainnet). Used by the MetaMask Snap and dApps for bridge discovery. Source priority is:
|
||||
|
||||
**Response:** `{ routes, chain138Bridges, tokenMappingApi? }`
|
||||
1. **BRIDGE_LIST_JSON_URL** when configured
|
||||
2. **BRIDGE_LIST_JSON_PATH** / **BRIDGE_ROUTES_JSON_PATH** runtime local JSON
|
||||
3. built-in routes
|
||||
|
||||
If the remote URL is configured but fails, the route falls back to the runtime file or built-in payload rather than returning stale hard failure data. Responses include `source` and send `Cache-Control: public, max-age=0, must-revalidate`.
|
||||
|
||||
**Response:** `{ source, routes, chain138Bridges, tokenMappingApi?, lastModified? }`
|
||||
- `routes`: `{ weth9: Record<string, string>, weth10: Record<string, string> }` — destination chain name → bridge address.
|
||||
- `chain138Bridges`: `{ weth9: string, weth10: string }` — Chain 138 bridge addresses.
|
||||
- `tokenMappingApi`: When the service runs from the monorepo, `{ basePath, pairs, resolve, note }` — use the same host and these paths for **cross-chain token address resolution** (138↔651940, 651940↔other chains). Bridge UIs should call `GET /api/v1/token-mapping?fromChain=&toChain=` or `GET /api/v1/token-mapping/resolve?fromChain=&toChain=&address=` when resolving token addresses on destination chains.
|
||||
- `gruTransport.summary`: when GRU Transport overlay is available, includes counts such as `transportPairs`, `eligibleTransportPairs`, and `runtimeReadyTransportPairs`.
|
||||
|
||||
### GET /api/v1/bridge/status
|
||||
|
||||
Returns GRU Monetary Transport Layer pair status, including structural `eligible`, operational `runtimeReady`, and per-pair blockers.
|
||||
|
||||
**Response:** `{ ok, bridges, gruTransport, message }`
|
||||
- `gruTransport.summary`: overlay counts
|
||||
- `gruTransport.pairs[]`: `{ key, canonicalSymbol, mirroredSymbol, destinationChainId, eligible, runtimeReady, eligibilityBlockers[], runtimeMissingRequirements[] }`
|
||||
|
||||
### GET /api/v1/bridge/metrics
|
||||
|
||||
Returns GRU Transport summary counts suitable for dashboards.
|
||||
|
||||
**Response:** `{ ok, lanes, gruTransport: { system, summary }, message }`
|
||||
|
||||
### GET /api/v1/bridge/preflight
|
||||
|
||||
Returns a preflight view of GRU Transport readiness. Use this before deploy, restart, or route enablement.
|
||||
|
||||
**Response:** `{ ok, generatedAt, gruTransport: { system, summary, blockedPairs[], readyPairs[] } }`
|
||||
- `blockedPairs[]`: pairs that are not structurally eligible or not runtime-ready, with `eligibilityBlockers[]` and `runtimeMissingRequirements[]`
|
||||
- `readyPairs[]`: minimal list of pairs fully ready to carry live traffic
|
||||
|
||||
---
|
||||
|
||||
@@ -79,8 +118,11 @@ When run from the monorepo (with `config/token-mapping-multichain.json` availabl
|
||||
|----------|-------------|
|
||||
| `GET /api/v1/token-mapping?fromChain=138&toChain=651940` | Token mapping for a chain pair (tokens, addressMapFromTo, addressMapToFrom). |
|
||||
| `GET /api/v1/token-mapping/pairs` | All defined chain pairs. |
|
||||
| `GET /api/v1/token-mapping/transport/active` | Full GRU Transport overlay view, including counts and active transport pair metadata. |
|
||||
| `GET /api/v1/token-mapping/resolve?fromChain=&toChain=&address=` | Resolve token address on target chain. |
|
||||
|
||||
`GET /api/v1/token-mapping/resolve` also returns `activeTransportEligible`, `gruTransportRuntimeReady`, and `gruTransportPairKey` when the address maps into an active GRU pair.
|
||||
|
||||
Use these for bridge UIs and cross-chain address resolution. See token-aggregation README § Token mapping.
|
||||
|
||||
---
|
||||
@@ -187,7 +229,7 @@ Returns service and database health. Response: `{ status: 'healthy'|'unhealthy',
|
||||
|
||||
## Caching and rate limiting
|
||||
|
||||
- Endpoints use short-lived cache (e.g. 1–5 minutes). Use `Cache-Control` headers from responses.
|
||||
- Endpoints use short-lived cache (e.g. 1–5 minutes). Send `?refresh=1` to bypass the in-process cache when an explorer or operator view needs the latest read immediately.
|
||||
- `/api/v1` is rate-limited; see server configuration for limits.
|
||||
|
||||
---
|
||||
|
||||
78
services/token-aggregation/package-lock.json
generated
78
services/token-aggregation/package-lock.json
generated
@@ -8,37 +8,37 @@
|
||||
"name": "token-aggregation-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^5.1.1",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"ethers": "^6.8.0",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"ethers": "^6.16.0",
|
||||
"express": "^4.22.1",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "^8.11.3",
|
||||
"winston": "^3.11.0"
|
||||
"pg": "^8.18.0",
|
||||
"winston": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.5.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"eslint": "^8.57.1",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6"
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
@@ -78,6 +78,7 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1584,9 +1585,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
||||
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2134,14 +2135,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
@@ -6221,10 +6222,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --runInBand",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"generate:route-matrix:v2": "ts-node scripts/generate-route-matrix-v2.ts",
|
||||
"migrate": "node -r dotenv/config dist/database/migrations.js",
|
||||
"example:partner-payloads": "node scripts/resolve-partner-payloads-example.mjs"
|
||||
},
|
||||
|
||||
19
services/token-aggregation/scripts/apply-lightweight-schema.sh
Executable file
19
services/token-aggregation/scripts/apply-lightweight-schema.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVICE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
SCHEMA_FILE="${SCRIPT_DIR}/bootstrap-lightweight-schema.sql"
|
||||
|
||||
if [ -f "${SERVICE_DIR}/.env" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a && source "${SERVICE_DIR}/.env" && set +a
|
||||
fi
|
||||
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo "DATABASE_URL is required. Set it in ${SERVICE_DIR}/.env or export it before running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Applying lightweight token-aggregation schema using ${SCHEMA_FILE}"
|
||||
psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${SCHEMA_FILE}"
|
||||
@@ -0,0 +1,181 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chain_id INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
name TEXT,
|
||||
symbol TEXT,
|
||||
decimals INTEGER,
|
||||
total_supply NUMERIC(78, 0),
|
||||
logo_url TEXT,
|
||||
website_url TEXT,
|
||||
description TEXT,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (chain_id, address)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_chain_id ON tokens (chain_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_chain_symbol ON tokens (chain_id, symbol);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_chain_name ON tokens (chain_id, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_market_data (
|
||||
chain_id INTEGER NOT NULL,
|
||||
token_address TEXT NOT NULL,
|
||||
price_usd NUMERIC(38, 18),
|
||||
price_change_24h NUMERIC(38, 18),
|
||||
volume_24h NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
volume_7d NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
volume_30d NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
market_cap_usd NUMERIC(38, 18),
|
||||
liquidity_usd NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
holders_count INTEGER NOT NULL DEFAULT 0,
|
||||
transfers_24h INTEGER NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (chain_id, token_address)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_token_market_data_volume_24h
|
||||
ON token_market_data (chain_id, volume_24h DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_token_market_data_liquidity
|
||||
ON token_market_data (chain_id, liquidity_usd DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS liquidity_pools (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chain_id INTEGER NOT NULL,
|
||||
pool_address TEXT NOT NULL,
|
||||
token0_address TEXT NOT NULL,
|
||||
token1_address TEXT NOT NULL,
|
||||
dex_type TEXT NOT NULL,
|
||||
factory_address TEXT,
|
||||
router_address TEXT,
|
||||
reserve0 NUMERIC(78, 0) NOT NULL DEFAULT 0,
|
||||
reserve1 NUMERIC(78, 0) NOT NULL DEFAULT 0,
|
||||
reserve0_usd NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
reserve1_usd NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
total_liquidity_usd NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
volume_24h NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
fee_tier INTEGER,
|
||||
created_at_block BIGINT,
|
||||
created_at_timestamp TIMESTAMPTZ,
|
||||
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (chain_id, pool_address)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_liquidity_pools_chain_liquidity
|
||||
ON liquidity_pools (chain_id, total_liquidity_usd DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_liquidity_pools_chain_token0
|
||||
ON liquidity_pools (chain_id, token0_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_liquidity_pools_chain_token1
|
||||
ON liquidity_pools (chain_id, token1_address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pool_reserves_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chain_id INTEGER NOT NULL,
|
||||
pool_address TEXT NOT NULL,
|
||||
reserve0 NUMERIC(78, 0) NOT NULL,
|
||||
reserve1 NUMERIC(78, 0) NOT NULL,
|
||||
reserve0_usd NUMERIC(38, 18),
|
||||
reserve1_usd NUMERIC(38, 18),
|
||||
total_liquidity_usd NUMERIC(38, 18),
|
||||
block_number BIGINT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pool_reserves_history_lookup
|
||||
ON pool_reserves_history (chain_id, pool_address, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS swap_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chain_id INTEGER NOT NULL,
|
||||
pool_address TEXT NOT NULL,
|
||||
token0_address TEXT NOT NULL,
|
||||
token1_address TEXT NOT NULL,
|
||||
amount_usd NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
price_usd NUMERIC(38, 18),
|
||||
transaction_hash TEXT,
|
||||
log_index INTEGER,
|
||||
block_number BIGINT,
|
||||
timestamp TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_swap_events_pool_time
|
||||
ON swap_events (chain_id, pool_address, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_swap_events_token_time
|
||||
ON swap_events (chain_id, token0_address, token1_address, timestamp DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_swap_events_unique_log
|
||||
ON swap_events (
|
||||
chain_id,
|
||||
pool_address,
|
||||
COALESCE(transaction_hash, ''),
|
||||
COALESCE(log_index, -1)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token_ohlcv (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chain_id INTEGER NOT NULL,
|
||||
token_address TEXT NOT NULL,
|
||||
pool_address TEXT NOT NULL DEFAULT '',
|
||||
interval_type TEXT NOT NULL,
|
||||
open_price NUMERIC(38, 18) NOT NULL,
|
||||
high_price NUMERIC(38, 18) NOT NULL,
|
||||
low_price NUMERIC(38, 18) NOT NULL,
|
||||
close_price NUMERIC(38, 18) NOT NULL,
|
||||
volume NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
volume_usd NUMERIC(38, 18) NOT NULL DEFAULT 0,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (chain_id, token_address, pool_address, interval_type, timestamp)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_token_ohlcv_lookup
|
||||
ON token_ohlcv (chain_id, token_address, interval_type, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_health_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
chain_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
supports_execution BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
supports_quote BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_health_snapshots_lookup
|
||||
ON provider_health_snapshots (chain_id, provider, captured_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS route_plan_cache (
|
||||
plan_id TEXT PRIMARY KEY,
|
||||
request_hash TEXT NOT NULL,
|
||||
chain_id INTEGER NOT NULL,
|
||||
destination_chain_id INTEGER NOT NULL,
|
||||
decision TEXT NOT NULL,
|
||||
response_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_route_plan_cache_lookup
|
||||
ON route_plan_cache (request_hash, expires_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS route_execution_metrics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL,
|
||||
chain_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
hop_index INTEGER NOT NULL,
|
||||
token_in_address TEXT NOT NULL,
|
||||
token_out_address TEXT NOT NULL,
|
||||
estimated_amount_out NUMERIC(78, 0),
|
||||
actual_amount_out NUMERIC(78, 0),
|
||||
status TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_route_execution_metrics_lookup
|
||||
ON route_execution_metrics (plan_id, hop_index, created_at DESC);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,41 @@
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { AggregatorRouteMatrixGenerator } from '../src/services/aggregator-route-matrix-generator';
|
||||
import { closeDatabasePool } from '../src/database/client';
|
||||
|
||||
const rootEnvCandidates = [
|
||||
path.resolve(__dirname, '../../.env'),
|
||||
path.resolve(__dirname, '../../../.env'),
|
||||
];
|
||||
|
||||
for (const candidate of rootEnvCandidates) {
|
||||
if (existsSync(candidate)) {
|
||||
dotenv.config({ path: candidate });
|
||||
break;
|
||||
}
|
||||
}
|
||||
dotenv.config();
|
||||
|
||||
async function main() {
|
||||
const outputPath = process.argv[2]
|
||||
? path.resolve(process.cwd(), process.argv[2])
|
||||
: path.resolve(__dirname, '../../../../config/aggregator-route-matrix.json');
|
||||
|
||||
const generator = new AggregatorRouteMatrixGenerator();
|
||||
try {
|
||||
const writtenPath = await generator.writeToFile(outputPath, 138);
|
||||
process.stdout.write(`${writtenPath}\n`);
|
||||
} finally {
|
||||
await closeDatabasePool();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getProviderCapabilities } from '../src/config/provider-capabilities';
|
||||
import { InternalExecutionPlanV2Builder } from '../src/services/internal-execution-plan-v2';
|
||||
import { BestExecutionPlanner } from '../src/services/best-execution-planner';
|
||||
|
||||
const CHAIN_ID = 138;
|
||||
const WETH10 = '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f';
|
||||
const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1';
|
||||
const D3_PROXY = '0xc9a11abB7C63d88546Be24D58a6d95e3762cB843';
|
||||
const D3_POOL = '0x6550A3a59070061a262a893A1D6F3F490afFDBDA';
|
||||
const ROUTER_V2 = '0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce';
|
||||
const AMOUNT_IN = '100000000000000000';
|
||||
|
||||
class MockPlannerMetricsRepository {
|
||||
async getCachedPlan(): Promise<null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async recordProviderSnapshots(): Promise<void> {}
|
||||
|
||||
async cachePlan(): Promise<void> {}
|
||||
|
||||
async recordPlannedRouteMetrics(): Promise<void> {}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMatrix(): Record<string, unknown> {
|
||||
const root = path.resolve(__dirname, '../../../../');
|
||||
const matrixPath = path.resolve(root, 'config/aggregator-route-matrix.json');
|
||||
const raw = fs.readFileSync(matrixPath, 'utf8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const capabilities = getProviderCapabilities(CHAIN_ID);
|
||||
const dodoV3 = capabilities.find((capability) => capability.provider === 'dodo_v3');
|
||||
assert(dodoV3, 'Missing dodo_v3 provider capability for Chain 138.');
|
||||
assert(dodoV3.live === true, 'Expected dodo_v3 capability to be live.');
|
||||
assert(dodoV3.quoteLive === true, 'Expected dodo_v3 quoteLive=true.');
|
||||
assert(dodoV3.executionLive === true, 'Expected dodo_v3 executionLive=true with the live router-v2 D3 adapter.');
|
||||
|
||||
const pair = dodoV3.pairs.find(
|
||||
(entry) =>
|
||||
entry.status === 'live' &&
|
||||
entry.tokenInAddress === WETH10.toLowerCase() &&
|
||||
entry.tokenOutAddress === USDT.toLowerCase()
|
||||
);
|
||||
assert(pair, 'Missing live WETH10 -> USDT dodo_v3 capability.');
|
||||
assert(pair.target === D3_PROXY.toLowerCase(), `Expected D3 proxy target ${D3_PROXY}, got ${pair.target}.`);
|
||||
|
||||
const planner = new BestExecutionPlanner(undefined, new MockPlannerMetricsRepository() as never);
|
||||
const request = {
|
||||
sourceChainId: CHAIN_ID,
|
||||
destinationChainId: CHAIN_ID,
|
||||
tokenIn: WETH10,
|
||||
tokenOut: USDT,
|
||||
amountIn: AMOUNT_IN,
|
||||
};
|
||||
|
||||
const plan = await planner.plan(request);
|
||||
assert(plan.decision === 'direct-pool', `Expected direct-pool decision, got ${plan.decision}.`);
|
||||
assert(plan.legs.length === 1, `Expected 1 planner leg, got ${plan.legs.length}.`);
|
||||
assert(plan.legs[0].provider === 'dodo_v3', `Expected dodo_v3 provider, got ${plan.legs[0].provider}.`);
|
||||
assert(plan.routePlan !== undefined, 'Expected executable routePlan for dodo_v3 pilot routes.');
|
||||
assert(
|
||||
plan.riskFlags.includes('pilot-venue') && !plan.riskFlags.includes('manual-execution-only'),
|
||||
`Expected pilot-only risk flag set, got ${plan.riskFlags.join(', ')}.`
|
||||
);
|
||||
assert(BigInt(plan.estimatedAmountOut) > 0n, 'Expected positive DODO v3 quote output.');
|
||||
|
||||
const executionBuilder = new InternalExecutionPlanV2Builder(planner);
|
||||
const internalExecution = await executionBuilder.build(request);
|
||||
assert(!internalExecution.error, `Expected executable internal plan, got ${internalExecution.error || 'none'}.`);
|
||||
assert(internalExecution.execution?.kind === 'route', `Expected route execution plan, got ${internalExecution.execution?.kind || 'none'}.`);
|
||||
assert(
|
||||
internalExecution.execution?.contractAddress === ROUTER_V2.toLowerCase(),
|
||||
`Expected router-v2 contract ${ROUTER_V2}, got ${internalExecution.execution?.contractAddress || 'none'}.`
|
||||
);
|
||||
assert(
|
||||
internalExecution.execution?.encodedCalldata?.startsWith('0x434180a2'),
|
||||
'Expected executeRoute calldata for dodo_v3 pilot route.'
|
||||
);
|
||||
|
||||
const matrix = loadMatrix();
|
||||
const liveSwapRoutes = Array.isArray(matrix.liveSwapRoutes) ? matrix.liveSwapRoutes : [];
|
||||
const dodoV3Routes = liveSwapRoutes.filter((route) => {
|
||||
if (!route || typeof route !== 'object') return false;
|
||||
const legs = Array.isArray((route as { legs?: unknown[] }).legs) ? (route as { legs: unknown[] }).legs : [];
|
||||
return legs.some((leg) => leg && typeof leg === 'object' && (leg as { protocol?: string }).protocol === 'dodo_v3');
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
assert(dodoV3Routes.length === 2, `Expected 2 dodo_v3 routes in route matrix, got ${dodoV3Routes.length}.`);
|
||||
for (const route of dodoV3Routes) {
|
||||
const legs = Array.isArray(route.legs) ? route.legs : [];
|
||||
const leg = legs[0] as Record<string, unknown>;
|
||||
assert(leg.poolAddress === D3_POOL.toLowerCase(), `Expected canonical D3 pool ${D3_POOL}, got ${String(leg.poolAddress || '')}.`);
|
||||
assert(leg.executorAddress === D3_PROXY.toLowerCase(), `Expected canonical D3 proxy ${D3_PROXY}, got ${String(leg.executorAddress || '')}.`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
verifiedAt: new Date().toISOString(),
|
||||
chainId: CHAIN_ID,
|
||||
capability: {
|
||||
provider: dodoV3.provider,
|
||||
live: dodoV3.live,
|
||||
quoteLive: dodoV3.quoteLive,
|
||||
executionLive: dodoV3.executionLive,
|
||||
},
|
||||
planner: {
|
||||
decision: plan.decision,
|
||||
provider: plan.legs[0].provider,
|
||||
estimatedAmountOut: plan.estimatedAmountOut,
|
||||
riskFlags: plan.riskFlags,
|
||||
routePlanPresent: plan.routePlan !== undefined,
|
||||
},
|
||||
internalExecutionPlan: {
|
||||
kind: internalExecution.execution?.kind,
|
||||
contractAddress: internalExecution.execution?.contractAddress,
|
||||
error: internalExecution.error,
|
||||
},
|
||||
routeMatrix: {
|
||||
dodoV3RouteIds: dodoV3Routes.map((route) => String(route.routeId || '')),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[fail] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -10,22 +10,31 @@ const DEFAULT_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
export function cacheMiddleware(ttl: number = DEFAULT_TTL) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const bypassCache =
|
||||
req.query.refresh === '1' ||
|
||||
req.query.noCache === '1' ||
|
||||
/\bno-cache\b|\bno-store\b/i.test(req.header('cache-control') || '');
|
||||
const key = `${req.method}:${req.originalUrl}`;
|
||||
const cached = cache.get(key);
|
||||
const cached = bypassCache ? undefined : cache.get(key);
|
||||
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
res.setHeader('X-Token-Aggregation-Cache', 'hit');
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
res.setHeader('X-Token-Aggregation-Cache', bypassCache ? 'bypass' : 'miss');
|
||||
|
||||
// Store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override json method to cache response
|
||||
res.json = function (body: unknown) {
|
||||
cache.set(key, {
|
||||
data: body,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
if (!bypassCache) {
|
||||
cache.set(key, {
|
||||
data: body,
|
||||
expiresAt: Date.now() + ttl,
|
||||
});
|
||||
}
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
|
||||
247
services/token-aggregation/src/api/routes/bridge.test.ts
Normal file
247
services/token-aggregation/src/api/routes/bridge.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import bridgeRoutes from './bridge';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1/bridge', bridgeRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Bridge API GRU Transport status', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE;
|
||||
const originalBscBridge = process.env.CW_BRIDGE_BSC;
|
||||
const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
const originalReserveSystem = process.env.CW_RESERVE_SYSTEM;
|
||||
const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
const originalBridgeListPath = process.env.BRIDGE_LIST_JSON_PATH;
|
||||
const originalBridgeListUrl = process.env.BRIDGE_LIST_JSON_URL;
|
||||
const originalCwL1Bridge = process.env.CW_L1_BRIDGE;
|
||||
const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalChain138Bridge === undefined) {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge;
|
||||
}
|
||||
if (originalBscBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_BSC = originalBscBridge;
|
||||
}
|
||||
if (originalReserveVerifier === undefined) {
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier;
|
||||
}
|
||||
if (originalReserveVault === undefined) {
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
} else {
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault;
|
||||
}
|
||||
if (originalReserveSystem === undefined) {
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
} else {
|
||||
process.env.CW_RESERVE_SYSTEM = originalReserveSystem;
|
||||
}
|
||||
if (originalMaxOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding;
|
||||
}
|
||||
if (originalBridgeListPath === undefined) {
|
||||
delete process.env.BRIDGE_LIST_JSON_PATH;
|
||||
} else {
|
||||
process.env.BRIDGE_LIST_JSON_PATH = originalBridgeListPath;
|
||||
}
|
||||
if (originalBridgeListUrl === undefined) {
|
||||
delete process.env.BRIDGE_LIST_JSON_URL;
|
||||
} else {
|
||||
process.env.BRIDGE_LIST_JSON_URL = originalBridgeListUrl;
|
||||
}
|
||||
if (originalCwL1Bridge === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE = originalCwL1Bridge;
|
||||
}
|
||||
if (originalCwL1BridgeChain138 === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns GRU transport pair runtime readiness on /status', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/status`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gruTransport?.summary?.runtimeReadyTransportPairs).toEqual(expect.any(Number));
|
||||
expect(body.gruTransport?.pairs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '138-56-cUSDT-cWUSDT',
|
||||
runtimeReady: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: '138-10-cETHL2-cWETHL2',
|
||||
assetClass: 'gas_native',
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.gruTransport?.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_mainnet',
|
||||
backingMode: 'strict_escrow',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns GRU transport summary counts on /metrics', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/metrics`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gruTransport?.summary).toMatchObject({
|
||||
transportPairs: expect.any(Number),
|
||||
runtimeReadyTransportPairs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns blocked pairs with missing requirements on /preflight', async () => {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/preflight`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.gruTransport?.blockedPairs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '138-56-cUSDT-cWUSDT',
|
||||
runtimeReady: false,
|
||||
runtimeMissingRequirements: expect.arrayContaining([
|
||||
'bridge:l1Bridge',
|
||||
'bridge:l2Bridge',
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: '138-10-cETHL2-cWETHL2',
|
||||
backingMode: 'hybrid_cap',
|
||||
runtimeMissingRequirements: expect.arrayContaining([
|
||||
'supplyAccounting:outstanding',
|
||||
'supplyAccounting:treasuryCap',
|
||||
]),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('reads runtime bridge routes from a local json file when configured', async () => {
|
||||
const tempPath = path.join('/tmp', `bridge-routes-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
routes: {
|
||||
weth9: {
|
||||
'Dynamic Ethereum Mainnet (1)': '0x9999999999999999999999999999999999999999',
|
||||
},
|
||||
weth10: {},
|
||||
},
|
||||
chain138Bridges: {
|
||||
weth9: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
weth10: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
})
|
||||
);
|
||||
process.env.BRIDGE_LIST_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/routes`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('runtime-file');
|
||||
expect(body.routes?.weth9).toMatchObject({
|
||||
'Dynamic Ethereum Mainnet (1)': '0x9999999999999999999999999999999999999999',
|
||||
});
|
||||
expect(body.chain138Bridges?.weth9).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a runtime bridge routes file when BRIDGE_LIST_JSON_URL fails', async () => {
|
||||
const tempPath = path.join('/tmp', `bridge-routes-fallback-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
routes: {
|
||||
weth9: {
|
||||
'Fallback Ethereum Mainnet (1)': '0x1234512345123451234512345123451234512345',
|
||||
},
|
||||
weth10: {},
|
||||
},
|
||||
chain138Bridges: {
|
||||
weth9: '0xcccccccccccccccccccccccccccccccccccccccc',
|
||||
weth10: '0xdddddddddddddddddddddddddddddddddddddddd',
|
||||
},
|
||||
})
|
||||
);
|
||||
process.env.BRIDGE_LIST_JSON_URL = 'http://127.0.0.1:1/bridge-routes.json';
|
||||
process.env.BRIDGE_LIST_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/routes`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('runtime-file');
|
||||
expect(body.routes?.weth9).toMatchObject({
|
||||
'Fallback Ethereum Mainnet (1)': '0x1234512345123451234512345123451234512345',
|
||||
});
|
||||
expect(body.chain138Bridges?.weth9).toBe('0xcccccccccccccccccccccccccccccccccccccccc');
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,229 @@
|
||||
/**
|
||||
* Bridge API: cross-chain bridge status and metrics.
|
||||
* GET /api/v1/bridge/status, /api/v1/bridge/metrics — stubbed or delegated to cross-chain report.
|
||||
* GET /api/v1/bridge/routes — CCIP WETH9/WETH10 + Trustless (Snap / dApps).
|
||||
* GET /api/v1/bridge/status, /api/v1/bridge/metrics, /api/v1/bridge/preflight — GRU Transport readiness + cross-chain guidance.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
import { buildDefaultBridgeRoutes } from '../utils/default-bridge-routes';
|
||||
import { getActivePublicPools, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
function buildGruTransportStatus() {
|
||||
const metadata = getGruTransportMetadata();
|
||||
const transportPairs = getActiveTransportPairs();
|
||||
const publicPools = getActivePublicPools();
|
||||
|
||||
if (!metadata) return null;
|
||||
|
||||
return {
|
||||
system: metadata.system,
|
||||
terminology: metadata.terminology,
|
||||
summary: metadata.counts,
|
||||
gasAssetFamilies: metadata.gasAssetFamilies ?? [],
|
||||
gasRedeemGroups: metadata.gasRedeemGroups ?? [],
|
||||
gasProtocolExposure: metadata.gasProtocolExposure ?? [],
|
||||
pairs: transportPairs.map((pair) => ({
|
||||
key: pair.key,
|
||||
canonicalChainId: pair.canonicalChainId,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
assetClass: pair.assetClass ?? null,
|
||||
familyKey: pair.familyKey ?? null,
|
||||
laneGroup: pair.laneGroup ?? null,
|
||||
backingMode: pair.backingMode ?? null,
|
||||
redeemPolicy: pair.redeemPolicy ?? null,
|
||||
wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null,
|
||||
stableQuoteSymbol: pair.stableQuoteSymbol ?? null,
|
||||
referenceVenue: pair.referenceVenue ?? null,
|
||||
eligible: pair.eligible === true,
|
||||
runtimeReady: pair.runtimeReady === true,
|
||||
runtimeBridgeReady: pair.runtimeBridgeReady === true,
|
||||
runtimeReserveVerifierReady: pair.runtimeReserveVerifierReady === true,
|
||||
runtimeMaxOutstandingReady: pair.runtimeMaxOutstandingReady === true,
|
||||
runtimeSupplyAccountingReady: pair.runtimeSupplyAccountingReady ?? null,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
runtimeOutstandingValue: pair.runtimeOutstandingValue ?? null,
|
||||
runtimeEscrowedValue: pair.runtimeEscrowedValue ?? null,
|
||||
runtimeTreasuryBackedValue: pair.runtimeTreasuryBackedValue ?? null,
|
||||
runtimeTreasuryCapValue: pair.runtimeTreasuryCapValue ?? null,
|
||||
bridgeAvailable: pair.bridgeAvailable ?? null,
|
||||
protocolExposure: pair.protocolExposure ?? null,
|
||||
eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) ? pair.eligibilityBlockers : [],
|
||||
runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements) ? pair.runtimeMissingRequirements : [],
|
||||
activePublicPoolKeys: Array.isArray(pair.publicPoolKeys) ? pair.publicPoolKeys : [],
|
||||
})),
|
||||
publicPools,
|
||||
};
|
||||
}
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveBridgeRoutesPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.BRIDGE_LIST_JSON_PATH,
|
||||
process.env.BRIDGE_ROUTES_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'config/bridge-routes-chain138-default.json'),
|
||||
path.resolve(process.cwd(), '../config/bridge-routes-chain138-default.json'),
|
||||
path.resolve(process.cwd(), '../../config/bridge-routes-chain138-default.json'),
|
||||
path.resolve(__dirname, '../../../../../config/bridge-routes-chain138-default.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadRuntimeBridgeRoutes(): { payload: Record<string, unknown>; lastModified?: string } | null {
|
||||
const filePath = resolveBridgeRoutesPath();
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
payload: JSON.parse(raw) as Record<string, unknown>,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/bridge/routes
|
||||
* Optional BRIDGE_LIST_JSON_URL — remote JSON replaces entire payload (5m cache).
|
||||
*/
|
||||
router.get('/routes', async (_req: Request, res: Response) => {
|
||||
const gruTransportMetadata = getGruTransportMetadata();
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
const url = process.env.BRIDGE_LIST_JSON_URL?.trim();
|
||||
if (url) {
|
||||
try {
|
||||
const data = await fetchRemoteJson(url);
|
||||
const basePayload = data && typeof data === 'object' ? data : { data };
|
||||
res.json({
|
||||
source: 'remote-url',
|
||||
...basePayload,
|
||||
gruTransport: gruTransportMetadata
|
||||
? {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
activeTransportPairs: getActiveTransportPairs(),
|
||||
activePublicPools: getActivePublicPools(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.error('BRIDGE_LIST_JSON_URL fetch failed, trying runtime file/built-in routes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimePayload = loadRuntimeBridgeRoutes();
|
||||
if (runtimePayload) {
|
||||
res.json({
|
||||
source: 'runtime-file',
|
||||
lastModified: runtimePayload.lastModified,
|
||||
...runtimePayload.payload,
|
||||
gruTransport: gruTransportMetadata
|
||||
? {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
activeTransportPairs: getActiveTransportPairs(),
|
||||
activePublicPools: getActivePublicPools(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
source: 'built-in',
|
||||
...buildDefaultBridgeRoutes(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/status', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
res.json({
|
||||
ok: true,
|
||||
bridges: [],
|
||||
message: 'Bridge status: use /api/v1/report/cross-chain for volume/lanes.',
|
||||
gruTransport,
|
||||
message: 'Bridge status includes GRU Transport runtime readiness. Use /api/v1/bridge/preflight for missing refs and /api/v1/report/cross-chain for volume/lanes.',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/metrics', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
res.json({
|
||||
ok: true,
|
||||
lanes: [],
|
||||
message: 'Bridge metrics: use /api/v1/report/cross-chain for aggregated data.',
|
||||
gruTransport: gruTransport
|
||||
? {
|
||||
system: gruTransport.system,
|
||||
summary: gruTransport.summary,
|
||||
}
|
||||
: null,
|
||||
message: 'Bridge metrics include GRU Transport summary counts. Use /api/v1/report/cross-chain for aggregated data.',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/preflight', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
if (!gruTransport) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
error: 'GRU transport config not available',
|
||||
});
|
||||
}
|
||||
|
||||
const blockedPairs = gruTransport.pairs.filter(
|
||||
(pair) => pair.eligible !== true || pair.runtimeReady !== true
|
||||
);
|
||||
const readyPairs = gruTransport.pairs.filter(
|
||||
(pair) => pair.eligible === true && pair.runtimeReady === true
|
||||
);
|
||||
|
||||
return res.json({
|
||||
ok: blockedPairs.length === 0,
|
||||
generatedAt: new Date().toISOString(),
|
||||
gruTransport: {
|
||||
system: gruTransport.system,
|
||||
summary: gruTransport.summary,
|
||||
blockedPairs,
|
||||
readyPairs: readyPairs.map((pair) => ({
|
||||
key: pair.key,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
assetClass: pair.assetClass ?? null,
|
||||
familyKey: pair.familyKey ?? null,
|
||||
backingMode: pair.backingMode ?? null,
|
||||
redeemPolicy: pair.redeemPolicy ?? null,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
142
services/token-aggregation/src/api/routes/config.test.ts
Normal file
142
services/token-aggregation/src/api/routes/config.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import configRoutes from './config';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1', configRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Config API runtime networks loader', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalNetworksPath = process.env.NETWORKS_JSON_PATH;
|
||||
const originalNetworksUrl = process.env.NETWORKS_JSON_URL;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNetworksPath === undefined) {
|
||||
delete process.env.NETWORKS_JSON_PATH;
|
||||
} else {
|
||||
process.env.NETWORKS_JSON_PATH = originalNetworksPath;
|
||||
}
|
||||
if (originalNetworksUrl === undefined) {
|
||||
delete process.env.NETWORKS_JSON_URL;
|
||||
} else {
|
||||
process.env.NETWORKS_JSON_URL = originalNetworksUrl;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('serves networks from a runtime file when configured', async () => {
|
||||
const tempPath = path.join('/tmp', `token-aggregation-networks-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
version: { major: 9, minor: 9, patch: 9 },
|
||||
chains: [
|
||||
{
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'Dynamic Chain 138',
|
||||
rpcUrls: ['https://dynamic-rpc.example'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://dynamic-explorer.example'],
|
||||
oracles: [{ name: 'ETH/USD', address: '0x1111111111111111111111111111111111111111' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
process.env.NETWORKS_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/networks`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('runtime-file');
|
||||
expect(body.version).toBe('9.9.9');
|
||||
expect(body.networks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
chainName: 'Dynamic Chain 138',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const cfgRes = await fetch(`${baseUrl}/api/v1/config?chainId=138`);
|
||||
expect(cfgRes.status).toBe(200);
|
||||
const cfgBody = (await cfgRes.json()) as Record<string, any>;
|
||||
expect(cfgBody.source).toBe('runtime-file');
|
||||
expect(cfgBody.oracles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'ETH/USD',
|
||||
}),
|
||||
])
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the remote networks source for config when NETWORKS_JSON_URL is set', async () => {
|
||||
const remotePayload = {
|
||||
version: '7.7.7',
|
||||
networks: [
|
||||
{
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'Remote Chain 138',
|
||||
rpcUrls: ['https://remote-rpc.example'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://remote-explorer.example'],
|
||||
oracles: [{ name: 'BTC/USD', address: '0x2222222222222222222222222222222222222222' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const remoteServer = createServer((_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(remotePayload));
|
||||
});
|
||||
await new Promise<void>((resolve) => remoteServer.listen(0, () => resolve()));
|
||||
const remotePort = (remoteServer.address() as { port: number }).port;
|
||||
process.env.NETWORKS_JSON_URL = `http://127.0.0.1:${remotePort}/networks.json`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/config?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('remote-url');
|
||||
expect(body.version).toBe('7.7.7');
|
||||
expect(body.oracles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'BTC/USD',
|
||||
}),
|
||||
])
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => remoteServer.close((err) => (err ? reject(err) : resolve())));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getNetworks, getConfigByChain, API_VERSION } from '../../config/networks';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
@@ -6,6 +8,122 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
type RuntimeNetworksPayload = {
|
||||
version?: string | { major?: number; minor?: number; patch?: number };
|
||||
networks?: unknown[];
|
||||
chains?: unknown[];
|
||||
};
|
||||
|
||||
type NetworksPayload = {
|
||||
source: 'remote-url' | 'runtime-file' | 'built-in';
|
||||
version: string;
|
||||
networks: unknown[];
|
||||
lastModified?: string;
|
||||
};
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveRuntimeNetworksPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.NETWORKS_JSON_PATH,
|
||||
process.env.CONFIG_NETWORKS_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), 'explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(process.cwd(), '../../explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.resolve(__dirname, '../../../../../../explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeVersion(value: RuntimeNetworksPayload['version']): string {
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
||||
if (value && typeof value === 'object') {
|
||||
const major = 'major' in value ? Number(value.major ?? 0) : 0;
|
||||
const minor = 'minor' in value ? Number(value.minor ?? 0) : 0;
|
||||
const patch = 'patch' in value ? Number(value.patch ?? 0) : 0;
|
||||
return `${major}.${minor}.${patch}`;
|
||||
}
|
||||
return API_VERSION;
|
||||
}
|
||||
|
||||
function extractNetworks(payload: RuntimeNetworksPayload | { version?: string; networks?: unknown[] }): unknown[] {
|
||||
if (Array.isArray(payload.networks)) return payload.networks;
|
||||
if ('chains' in payload && Array.isArray(payload.chains)) return payload.chains;
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadRemoteNetworksPayload(): Promise<NetworksPayload | null> {
|
||||
const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim();
|
||||
if (!networksJsonUrl) return null;
|
||||
|
||||
try {
|
||||
const data = (await fetchRemoteJson(networksJsonUrl)) as RuntimeNetworksPayload;
|
||||
return {
|
||||
source: 'remote-url',
|
||||
version: normalizeVersion(data.version),
|
||||
networks: extractNetworks(data),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('NETWORKS_JSON_URL fetch failed, trying runtime file/built-in networks:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadRuntimeNetworksPayload(): NetworksPayload | null {
|
||||
const filePath = resolveRuntimeNetworksPath();
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const stat = fs.statSync(filePath);
|
||||
const parsed = JSON.parse(raw) as RuntimeNetworksPayload;
|
||||
|
||||
return {
|
||||
source: 'runtime-file',
|
||||
version: normalizeVersion(parsed.version),
|
||||
networks: extractNetworks(parsed),
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('NETWORKS_JSON_PATH read failed, using built-in networks:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNetworksPayload(): Promise<NetworksPayload> {
|
||||
const remotePayload = await loadRemoteNetworksPayload();
|
||||
if (remotePayload) return remotePayload;
|
||||
|
||||
const runtimePayload = loadRuntimeNetworksPayload();
|
||||
if (runtimePayload) return runtimePayload;
|
||||
|
||||
return {
|
||||
source: 'built-in',
|
||||
version: API_VERSION,
|
||||
networks: getNetworks(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/networks
|
||||
* Full EIP-3085 chain params for wallet_addEthereumChain (Chain 138, 1, 651940).
|
||||
@@ -13,23 +131,9 @@ const router: Router = Router();
|
||||
*/
|
||||
router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const networksJsonUrl = process.env.NETWORKS_JSON_URL?.trim();
|
||||
if (networksJsonUrl) {
|
||||
try {
|
||||
const data = (await fetchRemoteJson(networksJsonUrl)) as { version?: string; networks?: unknown[] };
|
||||
return res.json({
|
||||
version: data.version ?? API_VERSION,
|
||||
networks: data.networks ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('NETWORKS_JSON_URL fetch failed, using built-in networks:', err);
|
||||
}
|
||||
}
|
||||
const networks = getNetworks();
|
||||
res.json({
|
||||
version: API_VERSION,
|
||||
networks,
|
||||
});
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
const payload = await resolveNetworksPayload();
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
@@ -42,21 +146,37 @@ router.get('/networks', cacheMiddleware(5 * 60 * 1000), async (req: Request, res
|
||||
*/
|
||||
router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const networks = getNetworks();
|
||||
const payload = await resolveNetworksPayload();
|
||||
const networks = payload.networks as Array<{ chainIdDecimal?: number; oracles?: unknown[] }>;
|
||||
if (chainIdParam) {
|
||||
const chainId = parseInt(chainIdParam, 10);
|
||||
const config = getConfigByChain(chainId);
|
||||
const matchingNetwork = networks.find((network) => Number(network.chainIdDecimal) === chainId);
|
||||
const config = matchingNetwork
|
||||
? {
|
||||
oracles: (matchingNetwork.oracles as unknown[]) ?? [],
|
||||
}
|
||||
: getConfigByChain(chainId);
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: 'Chain not found', chainId });
|
||||
}
|
||||
return res.json({ version: API_VERSION, chainId, ...config });
|
||||
return res.json({
|
||||
source: payload.source,
|
||||
version: payload.version,
|
||||
chainId,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
const chains = networks.map((n) => ({
|
||||
chainId: n.chainIdDecimal,
|
||||
oracles: n.oracles,
|
||||
chainId: Number(n.chainIdDecimal),
|
||||
oracles: Array.isArray(n.oracles) ? n.oracles : [],
|
||||
}));
|
||||
res.json({ version: API_VERSION, chains });
|
||||
res.json({
|
||||
source: payload.source,
|
||||
version: payload.version,
|
||||
chains,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DEFAULT_HEATMAP_ASSETS,
|
||||
} from '../../config/heatmap-chains';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { filterPoolsForExposure, isPublicPoolRoutable } from '../../config/gru-transport';
|
||||
|
||||
const router = Router();
|
||||
const poolRepo = new PoolRepository();
|
||||
@@ -32,7 +33,7 @@ router.get('/heatmap', cacheMiddleware(60 * 1000), async (req: Request, res: Res
|
||||
const matrix: number[][] = [];
|
||||
for (const chainId of chainIds) {
|
||||
const row: number[] = [];
|
||||
const pools = await poolRepo.getPoolsByChain(chainId, 500);
|
||||
const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId, 500));
|
||||
const symbolToTvl: Record<string, number> = {};
|
||||
for (const sym of assets) symbolToTvl[sym] = 0;
|
||||
for (const pool of pools) {
|
||||
@@ -72,7 +73,7 @@ router.get('/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Respo
|
||||
if (!chainId || isNaN(chainId)) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
const pools = await poolRepo.getPoolsByChain(chainId, 500);
|
||||
const pools = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId, 500));
|
||||
const list = await Promise.all(
|
||||
pools.map(async (p) => {
|
||||
const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, p.token0Address, p.token1Address);
|
||||
@@ -89,7 +90,7 @@ router.get('/pools', cacheMiddleware(60 * 1000), async (req: Request, res: Respo
|
||||
reserve1: p.reserve1,
|
||||
},
|
||||
isDeployed: true,
|
||||
isRoutable: true,
|
||||
isRoutable: isPublicPoolRoutable(chainId, p.poolAddress),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
|
||||
let partnerPayloadRoutes: any;
|
||||
const mockPlan = jest.fn();
|
||||
|
||||
jest.mock('../../services/best-execution-planner', () => ({
|
||||
__esModule: true,
|
||||
BestExecutionPlanner: jest.fn().mockImplementation(() => ({
|
||||
plan: mockPlan,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../config/aggregator-route-matrix', () => ({
|
||||
__esModule: true,
|
||||
loadAggregatorRouteMatrix: jest.fn(() => ({
|
||||
version: '2.0.0',
|
||||
updated: '2026-04-02T00:00:00.000Z',
|
||||
homeChainId: 138,
|
||||
liveSwapRoutes: [],
|
||||
liveBridgeRoutes: [],
|
||||
blockedOrPlannedRoutes: [],
|
||||
})),
|
||||
filterLiveAggregatorRoutes: jest.fn(() => []),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1', partnerPayloadRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Partner payload API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
partnerPayloadRoutes = require('./partner-payloads').default;
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlan.mockReset();
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('prefers planner-v2 routes when concrete route inputs are provided', async () => {
|
||||
mockPlan.mockResolvedValue({
|
||||
planId: 'planner-route-1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision: 'direct-pool',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenIn: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
tokenOut: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1',
|
||||
estimatedAmountOut: '1800000000',
|
||||
minAmountOut: '1782000000',
|
||||
estimatedGasUsd: 0.22,
|
||||
confidenceScore: 0.91,
|
||||
riskFlags: [],
|
||||
selectedRouteReason: 'Selected deepest eligible dodo pool.',
|
||||
rejectedAlternatives: [],
|
||||
staleness: { maxFreshnessSeconds: 15, hasStaleLeg: false },
|
||||
alternatives: [],
|
||||
legs: [
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'dodo',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenInAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
tokenOutAddress: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1',
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: 'USDT',
|
||||
estimatedAmountIn: '1000000000000000000',
|
||||
estimatedAmountOut: '1800000000',
|
||||
minAmountOut: '1782000000',
|
||||
target: '0x86ada6ef91a3b450f89f2b751e93b1b7a3218895',
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
providerData: { poolAddress: '0x1111111111111111111111111111111111111111' },
|
||||
providerDataHex: '0x',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/routes/partner-payloads?partner=0x&amount=1000000000000000000&fromChainId=138&tokenIn=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&tokenOut=0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1&includeUnsupported=true`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.routeSource).toBe('planner-v2-preferred');
|
||||
expect(body.count).toBe(1);
|
||||
expect(body.payloads[0].routeId).toBe('planner-route-1');
|
||||
expect(mockPlan).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import {
|
||||
AggregatorRouteLeg,
|
||||
LiveAggregatorRoute,
|
||||
filterLiveAggregatorRoutes,
|
||||
loadAggregatorRouteMatrix,
|
||||
} from '../../config/aggregator-route-matrix';
|
||||
@@ -10,8 +12,11 @@ import {
|
||||
} from '../../services/partner-payload-adapters';
|
||||
import { dispatchPartnerPayload } from '../../services/partner-payload-dispatcher';
|
||||
import { buildInternalExecutionPlan } from '../../services/internal-execution-plan';
|
||||
import { BestExecutionPlanner } from '../../services/best-execution-planner';
|
||||
import { routeFromPlannerLegs } from '../../services/aggregator-route-matrix-generator';
|
||||
|
||||
const router: Router = Router();
|
||||
const planner = new BestExecutionPlanner();
|
||||
|
||||
interface PartnerPayloadRequestBody {
|
||||
partner?: string;
|
||||
@@ -55,9 +60,117 @@ function buildPayloads(args: {
|
||||
slippagePercent?: string;
|
||||
slippageBps?: string;
|
||||
includeUnsupported?: boolean;
|
||||
routeId?: string;
|
||||
}) {
|
||||
return buildPayloadsAsync(args);
|
||||
}
|
||||
|
||||
function dedupeRoutes(routes: LiveAggregatorRoute[]): LiveAggregatorRoute[] {
|
||||
const byId = new Map<string, LiveAggregatorRoute>();
|
||||
for (const route of routes) {
|
||||
byId.set(route.routeId, route);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function plannerLegsToAggregatorLegs(routeLegs: Array<{
|
||||
kind: string;
|
||||
provider: string;
|
||||
target?: string;
|
||||
poolAddress?: string;
|
||||
bridgeAddress?: string;
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
}>): AggregatorRouteLeg[] {
|
||||
return routeLegs.map((leg) => ({
|
||||
kind: leg.kind,
|
||||
protocol: leg.provider,
|
||||
executor: leg.provider,
|
||||
executorAddress: leg.target,
|
||||
poolAddress: leg.poolAddress || leg.bridgeAddress,
|
||||
tokenInAddress: leg.tokenInAddress,
|
||||
tokenOutAddress: leg.tokenOutAddress,
|
||||
}));
|
||||
}
|
||||
|
||||
async function resolvePlannerDerivedRoutes(args: {
|
||||
amount: string;
|
||||
fromChainId?: number;
|
||||
toChainId?: number;
|
||||
routeType?: string;
|
||||
tokenIn?: string;
|
||||
tokenOut?: string;
|
||||
recipient?: string;
|
||||
slippageBps?: string;
|
||||
}): Promise<LiveAggregatorRoute[]> {
|
||||
if (!args.fromChainId || !args.tokenIn || !args.tokenOut) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const wantsBridge = Boolean(args.toChainId && args.toChainId !== args.fromChainId);
|
||||
if (args.routeType === 'swap' && wantsBridge) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await planner.plan({
|
||||
sourceChainId: args.fromChainId,
|
||||
destinationChainId: wantsBridge ? args.toChainId : args.fromChainId,
|
||||
tokenIn: args.tokenIn,
|
||||
tokenOut: args.tokenOut,
|
||||
amountIn: args.amount,
|
||||
recipient: args.recipient,
|
||||
constraints: {
|
||||
allowBridge: wantsBridge || args.routeType === 'bridge' || args.routeType === 'swap-bridge-swap',
|
||||
maxSlippageBps: args.slippageBps ? Number(args.slippageBps) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.decision === 'unresolved' || response.legs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const aggregatorLegs = plannerLegsToAggregatorLegs(response.legs);
|
||||
const bridgeLeg = response.legs.find((leg) => leg.kind === 'bridge');
|
||||
const routeType = bridgeLeg ? 'bridge' : 'swap';
|
||||
|
||||
return [
|
||||
routeFromPlannerLegs({
|
||||
routeId: response.planId,
|
||||
fromChainId: response.sourceChainId,
|
||||
toChainId: response.destinationChainId,
|
||||
tokenInAddress: response.legs[0]?.tokenInAddress || args.tokenIn,
|
||||
tokenOutAddress: response.legs[response.legs.length - 1]?.tokenOutAddress || args.tokenOut,
|
||||
assetAddress: bridgeLeg?.tokenInAddress,
|
||||
assetSymbol: bridgeLeg?.tokenInSymbol,
|
||||
routeType,
|
||||
bridgeType: bridgeLeg?.bridgeType,
|
||||
bridgeAddress: bridgeLeg?.bridgeAddress,
|
||||
label: response.selectedRouteReason,
|
||||
legs: aggregatorLegs,
|
||||
notes: response.riskFlags,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function buildPayloadsAsync(args: {
|
||||
partner: PartnerName;
|
||||
amount: string;
|
||||
fromChainId?: number;
|
||||
toChainId?: number;
|
||||
routeType?: string;
|
||||
tokenIn?: string;
|
||||
tokenOut?: string;
|
||||
takerAddress?: string;
|
||||
fromAddress?: string;
|
||||
toAddress?: string;
|
||||
recipient?: string;
|
||||
slippagePercent?: string;
|
||||
slippageBps?: string;
|
||||
includeUnsupported?: boolean;
|
||||
routeId?: string;
|
||||
}) {
|
||||
const matrix = loadAggregatorRouteMatrix();
|
||||
if (!matrix) {
|
||||
if (!matrix && !args.fromChainId) {
|
||||
return {
|
||||
error: {
|
||||
status: 503,
|
||||
@@ -68,15 +181,21 @@ function buildPayloads(args: {
|
||||
};
|
||||
}
|
||||
|
||||
const liveRoutes = filterLiveAggregatorRoutes(
|
||||
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
|
||||
{
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
routeType: args.routeType,
|
||||
tokenIn: args.tokenIn,
|
||||
tokenOut: args.tokenOut,
|
||||
}
|
||||
const plannerRoutes = await resolvePlannerDerivedRoutes(args);
|
||||
const matrixRoutes = matrix
|
||||
? filterLiveAggregatorRoutes(
|
||||
[...matrix.liveSwapRoutes, ...matrix.liveBridgeRoutes],
|
||||
{
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
routeType: args.routeType,
|
||||
tokenIn: args.tokenIn,
|
||||
tokenOut: args.tokenOut,
|
||||
}
|
||||
)
|
||||
: [];
|
||||
const liveRoutes = dedupeRoutes([...plannerRoutes, ...matrixRoutes]).filter((route) =>
|
||||
args.routeId ? route.routeId === args.routeId : true
|
||||
);
|
||||
|
||||
const payloads = liveRoutes.map((route) =>
|
||||
@@ -99,6 +218,7 @@ function buildPayloads(args: {
|
||||
format: 'partner-payload-templates-v1',
|
||||
partner: args.partner,
|
||||
amount: args.amount,
|
||||
routeSource: plannerRoutes.length > 0 ? 'planner-v2-preferred' : 'matrix-fallback',
|
||||
count: filteredPayloads.length,
|
||||
supportedCount: payloads.filter((payload) => payload.supported).length,
|
||||
payloads: filteredPayloads,
|
||||
@@ -111,7 +231,7 @@ function buildPayloads(args: {
|
||||
* Returns partner-specific request payload templates generated from live ingestion routes.
|
||||
* By default returns only supported payloads; pass includeUnsupported=true to inspect all templates.
|
||||
*/
|
||||
router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request, res: Response) => {
|
||||
router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
|
||||
const partner = normalizePartner(req.query.partner ? String(req.query.partner) : undefined);
|
||||
if (!partner) {
|
||||
return res.status(400).json({
|
||||
@@ -128,7 +248,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request
|
||||
});
|
||||
}
|
||||
|
||||
const response = buildPayloads({
|
||||
const response = await buildPayloads({
|
||||
partner,
|
||||
amount,
|
||||
fromChainId: req.query.fromChainId ? parseInt(String(req.query.fromChainId), 10) : undefined,
|
||||
@@ -143,6 +263,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request
|
||||
slippagePercent: req.query.slippagePercent ? String(req.query.slippagePercent) : undefined,
|
||||
slippageBps: req.query.slippageBps ? String(req.query.slippageBps) : undefined,
|
||||
includeUnsupported: String(req.query.includeUnsupported ?? 'false').toLowerCase() === 'true',
|
||||
routeId: req.query.routeId ? String(req.query.routeId) : undefined,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -156,7 +277,7 @@ router.get('/routes/partner-payloads', cacheMiddleware(30 * 1000), (req: Request
|
||||
* POST /api/v1/routes/partner-payloads/resolve
|
||||
* Accepts JSON body and returns only supported partner payloads by default.
|
||||
*/
|
||||
router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req: Request, res: Response) => {
|
||||
router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
|
||||
const body = (req.body ?? {}) as PartnerPayloadRequestBody;
|
||||
const partner = normalizePartner(body.partner);
|
||||
|
||||
@@ -178,7 +299,7 @@ router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req
|
||||
});
|
||||
}
|
||||
|
||||
const response = buildPayloads({
|
||||
const response = await buildPayloads({
|
||||
partner,
|
||||
amount: String(body.amount),
|
||||
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
|
||||
@@ -193,6 +314,7 @@ router.post('/routes/partner-payloads/resolve', cacheMiddleware(30 * 1000), (req
|
||||
slippagePercent: body.slippagePercent,
|
||||
slippageBps: body.slippageBps,
|
||||
includeUnsupported: body.includeUnsupported === true,
|
||||
routeId: body.routeId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -222,7 +344,7 @@ router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Respo
|
||||
});
|
||||
}
|
||||
|
||||
const response = buildPayloads({
|
||||
const response = await buildPayloads({
|
||||
partner,
|
||||
amount: String(body.amount),
|
||||
fromChainId: typeof body.fromChainId === 'number' ? body.fromChainId : undefined,
|
||||
@@ -237,6 +359,7 @@ router.post('/routes/partner-payloads/dispatch', async (req: Request, res: Respo
|
||||
slippagePercent: body.slippagePercent,
|
||||
slippageBps: body.slippageBps,
|
||||
includeUnsupported: true,
|
||||
routeId: body.routeId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
|
||||
154
services/token-aggregation/src/api/routes/planner-v2.test.ts
Normal file
154
services/token-aggregation/src/api/routes/planner-v2.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
let plannerV2Routes: any;
|
||||
const mockPlan = jest.fn();
|
||||
const mockCapabilities = jest.fn();
|
||||
const mockBuild = jest.fn();
|
||||
|
||||
jest.mock('../../services/best-execution-planner', () => ({
|
||||
__esModule: true,
|
||||
BestExecutionPlanner: jest.fn().mockImplementation(() => ({
|
||||
plan: mockPlan,
|
||||
getCapabilities: mockCapabilities,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/internal-execution-plan-v2', () => ({
|
||||
__esModule: true,
|
||||
InternalExecutionPlanV2Builder: jest.fn().mockImplementation(() => ({
|
||||
build: mockBuild,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v2', plannerV2Routes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Planner V2 API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
plannerV2Routes = require('./planner-v2').default;
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlan.mockReset();
|
||||
mockCapabilities.mockReset();
|
||||
mockBuild.mockReset();
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns provider capabilities', async () => {
|
||||
mockCapabilities.mockReturnValue([{ provider: 'dodo', live: true }]);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/providers/capabilities?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.providers).toEqual([{ provider: 'dodo', live: true }]);
|
||||
});
|
||||
|
||||
it('returns a planner response for one-chain route planning', async () => {
|
||||
mockPlan.mockResolvedValue({
|
||||
planId: 'test-plan',
|
||||
decision: 'direct-pool',
|
||||
estimatedAmountOut: '1000',
|
||||
minAmountOut: '990',
|
||||
estimatedGasUsd: 0.22,
|
||||
legs: [],
|
||||
alternatives: [],
|
||||
confidenceScore: 0.9,
|
||||
riskFlags: [],
|
||||
selectedRouteReason: 'selected',
|
||||
rejectedAlternatives: [],
|
||||
staleness: { maxFreshnessSeconds: 12, hasStaleLeg: false },
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/routes/plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
amountIn: '1000',
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.planId).toBe('test-plan');
|
||||
expect(mockPlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an internal execution plan when the planner can encode calldata', async () => {
|
||||
mockBuild.mockResolvedValue({
|
||||
generatedAt: new Date().toISOString(),
|
||||
plannerResponse: {
|
||||
planId: 'test-plan',
|
||||
},
|
||||
execution: {
|
||||
kind: 'route',
|
||||
contractAddress: '0xrouter',
|
||||
functionName: 'executeRoute',
|
||||
signature: 'executeRoute((...))',
|
||||
args: [],
|
||||
encodedCalldata: '0xdeadbeef',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/routes/internal-execution-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
amountIn: '1000',
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.execution.encodedCalldata).toBe('0xdeadbeef');
|
||||
});
|
||||
|
||||
it('returns a JSON 500 instead of crashing when internal plan generation rejects', async () => {
|
||||
mockBuild.mockRejectedValue(new Error('planner exploded'));
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v2/routes/internal-execution-plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
amountIn: '1000',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
await expect(res.json()).resolves.toEqual({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
141
services/token-aggregation/src/api/routes/planner-v2.ts
Normal file
141
services/token-aggregation/src/api/routes/planner-v2.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { BestExecutionPlanner } from '../../services/best-execution-planner';
|
||||
import { InternalExecutionPlanV2Builder } from '../../services/internal-execution-plan-v2';
|
||||
import { PlannerRequest } from '../../services/planner-v2-types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
const planner = new BestExecutionPlanner();
|
||||
const executionPlanBuilder = new InternalExecutionPlanV2Builder(planner);
|
||||
|
||||
function parsePlannerRequest(body: Record<string, unknown>): PlannerRequest {
|
||||
const constraintsBody = body.constraints && typeof body.constraints === 'object'
|
||||
? (body.constraints as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
sourceChainId: Number(body.sourceChainId),
|
||||
destinationChainId: body.destinationChainId !== undefined ? Number(body.destinationChainId) : undefined,
|
||||
tokenIn: String(body.tokenIn || ''),
|
||||
tokenOut: String(body.tokenOut || ''),
|
||||
amountIn: String(body.amountIn || ''),
|
||||
recipient: body.recipient ? String(body.recipient) : undefined,
|
||||
constraints: constraintsBody
|
||||
? {
|
||||
maxSlippageBps: typeof constraintsBody.maxSlippageBps === 'number'
|
||||
? Number(constraintsBody.maxSlippageBps)
|
||||
: undefined,
|
||||
allowedProviders: Array.isArray(constraintsBody.allowedProviders)
|
||||
? (constraintsBody.allowedProviders as string[])
|
||||
.map((value) => String(value)) as NonNullable<PlannerRequest['constraints']>['allowedProviders']
|
||||
: undefined,
|
||||
maxLegs: typeof constraintsBody.maxLegs === 'number'
|
||||
? Number(constraintsBody.maxLegs)
|
||||
: undefined,
|
||||
allowedIntermediates: Array.isArray(constraintsBody.allowedIntermediates)
|
||||
? (constraintsBody.allowedIntermediates as string[]).map((value) => String(value))
|
||||
: undefined,
|
||||
complianceProfile: typeof constraintsBody.complianceProfile === 'string'
|
||||
? String(constraintsBody.complianceProfile) as NonNullable<PlannerRequest['constraints']>['complianceProfile']
|
||||
: undefined,
|
||||
allowBridge: typeof constraintsBody.allowBridge === 'boolean'
|
||||
? Boolean(constraintsBody.allowBridge)
|
||||
: undefined,
|
||||
preferredBridges: Array.isArray(constraintsBody.preferredBridges)
|
||||
? (constraintsBody.preferredBridges as string[]).map((value) => String(value))
|
||||
: undefined,
|
||||
allowCommodityIntermediates: typeof constraintsBody.allowCommodityIntermediates === 'boolean'
|
||||
? Boolean(constraintsBody.allowCommodityIntermediates)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlannerRequest(request: PlannerRequest): string | null {
|
||||
if (!request.sourceChainId || Number.isNaN(request.sourceChainId)) return 'sourceChainId is required';
|
||||
if (!request.tokenIn) return 'tokenIn is required';
|
||||
if (!request.tokenOut) return 'tokenOut is required';
|
||||
if (!request.amountIn) return 'amountIn is required';
|
||||
try {
|
||||
BigInt(request.amountIn);
|
||||
} catch {
|
||||
return 'amountIn must be an integer string';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handlePlannerFailure(res: Response, action: string, error: unknown) {
|
||||
logger.error(`Planner v2 ${action} failed`, error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
router.get('/providers/capabilities', cacheMiddleware(15 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = Number(req.query.chainId || '138');
|
||||
return res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
chainId,
|
||||
providers: planner.getCapabilities(chainId),
|
||||
});
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'provider capability lookup', error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/routes/plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const request = parsePlannerRequest((req.body || {}) as Record<string, unknown>);
|
||||
const error = validatePlannerRequest(request);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const response = await planner.plan({
|
||||
...request,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
});
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'route planning', error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/intents/plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const request = parsePlannerRequest((req.body || {}) as Record<string, unknown>);
|
||||
const error = validatePlannerRequest(request);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
if (!request.destinationChainId || request.destinationChainId === request.sourceChainId) {
|
||||
return res.status(400).json({ error: 'destinationChainId must be different from sourceChainId for intent planning' });
|
||||
}
|
||||
|
||||
const response = await planner.plan(request);
|
||||
return res.json(response);
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'intent planning', error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/routes/internal-execution-plan', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const request = parsePlannerRequest((req.body || {}) as Record<string, unknown>);
|
||||
const error = validatePlannerRequest(request);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const result = await executionPlanBuilder.build(request);
|
||||
if (result.error) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return handlePlannerFailure(res, 'internal execution plan build', error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
142
services/token-aggregation/src/api/routes/quote.test.ts
Normal file
142
services/token-aggregation/src/api/routes/quote.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import quoteRoutes from './quote';
|
||||
import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens';
|
||||
|
||||
var mockGetPoolsByToken: jest.Mock;
|
||||
var mockGetLiveDodoPools: jest.Mock;
|
||||
|
||||
jest.mock('../../database/repositories/pool-repo', () => {
|
||||
mockGetPoolsByToken = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
PoolRepository: jest.fn().mockImplementation(() => ({
|
||||
getPoolsByToken: mockGetPoolsByToken,
|
||||
})),
|
||||
};
|
||||
});
|
||||
jest.mock('../../services/live-dodo-fallback', () => {
|
||||
mockGetLiveDodoPools = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
getLiveDodoPools: mockGetLiveDodoPools,
|
||||
};
|
||||
});
|
||||
/** Real GRU loader filters pools by on-file routable addresses; synthetic test pools would be dropped. */
|
||||
jest.mock('../../config/gru-transport', () => ({
|
||||
filterPoolsForRouting: <T>(_chainId: number, pools: T[]) => pools,
|
||||
}));
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1', quoteRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Quote API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetPoolsByToken.mockReset();
|
||||
mockGetLiveDodoPools.mockReset();
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('quotes staged cUSDT_V2 against active cUSDT liquidity on Chain 138', async () => {
|
||||
const cusdtV2 = getCanonicalTokenBySymbol(138, 'cUSDT_V2');
|
||||
const cusdtV1 = getCanonicalTokenBySymbol(138, 'cUSDT');
|
||||
if (!cusdtV2?.addresses[138] || !cusdtV1?.addresses[138]) {
|
||||
throw new Error('cUSDT_V2 / cUSDT Chain 138 addresses required for this test');
|
||||
}
|
||||
const tokenInV2 = String(cusdtV2.addresses[138]);
|
||||
const cusdtV1Lookup = String(cusdtV1.addresses[138]).toLowerCase();
|
||||
|
||||
mockGetPoolsByToken.mockImplementation(async (_chainId: number, tokenAddress: string) => {
|
||||
if (tokenAddress.toLowerCase() === cusdtV1Lookup) {
|
||||
return [
|
||||
{
|
||||
chainId: 138,
|
||||
poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
token0Address: cusdtV1Lookup,
|
||||
token1Address: '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1',
|
||||
dexType: 'dodo',
|
||||
reserve0: '1000000',
|
||||
reserve1: '1000000',
|
||||
reserve0Usd: 1000000,
|
||||
reserve1Usd: 1000000,
|
||||
totalLiquidityUsd: 2000000,
|
||||
volume24h: 10000,
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/quote?chainId=138&tokenIn=${encodeURIComponent(tokenInV2)}&tokenOut=0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1&amountIn=1000`
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.amountOut).toBeTruthy();
|
||||
expect(['constant-product', 'pmm-onchain']).toContain(body.quoteEngine);
|
||||
expect(body.poolAddress).toBe('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
expect(body.canonicalLiquidity).toMatchObject({
|
||||
requestedTokenInSymbol: 'cUSDT_V2',
|
||||
lookupTokenInSymbol: 'cUSDT',
|
||||
lookupTokenInAddress: cusdtV1Lookup,
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(mockGetPoolsByToken).toHaveBeenCalledWith(138, cusdtV1Lookup);
|
||||
});
|
||||
|
||||
it('falls back to live DODO pools when indexed liquidity is empty', async () => {
|
||||
mockGetPoolsByToken.mockResolvedValue([]);
|
||||
mockGetLiveDodoPools.mockResolvedValue([
|
||||
{
|
||||
chainId: 138,
|
||||
poolAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
|
||||
token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
dexType: 'dodo',
|
||||
factoryAddress: '0x86ada6ef91a3b450f89f2b751e93b1b7a3218895',
|
||||
reserve0: '1000000',
|
||||
reserve1: '1000000',
|
||||
reserve0Usd: 1000000,
|
||||
reserve1Usd: 1000000,
|
||||
totalLiquidityUsd: 2000000,
|
||||
volume24h: 0,
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/quote?chainId=138&tokenIn=0x93E66202A11B1772E55407B32B44e5Cd8eda7f22&tokenOut=0xf22258f57794CC8E06237084b353Ab30fFfa640b&amountIn=1000`
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.amountOut).toBeTruthy();
|
||||
expect(['constant-product', 'pmm-onchain']).toContain(body.quoteEngine);
|
||||
expect(body.poolAddress).toBe('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||
expect(body.executorAddress).toBe('0x86ada6ef91a3b450f89f2b751e93b1b7a3218895');
|
||||
expect(mockGetLiveDodoPools).toHaveBeenCalledWith(138);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,24 @@ import { Router, Request, Response } from 'express';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { filterPoolsForRouting } from '../../config/gru-transport';
|
||||
import { resolveCanonicalQuoteAddress } from '../../config/canonical-tokens';
|
||||
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
|
||||
import {
|
||||
pmmQuoteAmountOutFromChain,
|
||||
resolvePmmQuoteRpcUrl,
|
||||
resolvePmmQuoteTrader,
|
||||
} from '../../services/pmm-onchain-quote';
|
||||
|
||||
const router: Router = Router();
|
||||
const poolRepo = new PoolRepository();
|
||||
|
||||
/**
|
||||
* Uniswap V2-style constant-product quote: amountOut = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997)
|
||||
*
|
||||
* Note: DODO PMM pools do not follow this curve; `amountOut` for dexType `dodo` can diverge from
|
||||
* on-chain `querySellBase` / `querySellQuote`. Clients that set swap minOut from this endpoint alone
|
||||
* may revert (wallets show failed gas estimation). Prefer on-chain PMM quotes for execution bounds.
|
||||
*/
|
||||
function quoteAmountOut(
|
||||
amountIn: bigint,
|
||||
@@ -42,6 +54,7 @@ router.get(
|
||||
return res.status(400).json({
|
||||
error: 'Missing required query: chainId, tokenIn, tokenOut, amountIn',
|
||||
amountOut: null,
|
||||
quoteEngine: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,18 +65,37 @@ router.get(
|
||||
return res.status(400).json({
|
||||
error: 'Invalid amountIn (must be integer string)',
|
||||
amountOut: null,
|
||||
quoteEngine: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenIn === tokenOut) {
|
||||
return res.json({ amountOut: amountInRaw, poolAddress: null });
|
||||
return res.json({
|
||||
amountOut: amountInRaw,
|
||||
poolAddress: null,
|
||||
quoteEngine: 'identity',
|
||||
});
|
||||
}
|
||||
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, tokenIn);
|
||||
const pairPools = pools.filter(
|
||||
const tokenInResolution = resolveCanonicalQuoteAddress(chainId, tokenIn);
|
||||
const tokenOutResolution = resolveCanonicalQuoteAddress(chainId, tokenOut);
|
||||
const indexedPoolsRaw = await poolRepo.getPoolsByToken(
|
||||
chainId,
|
||||
tokenInResolution.lookupAddress
|
||||
);
|
||||
const indexedPools = filterPoolsForRouting(chainId, indexedPoolsRaw ?? []);
|
||||
const livePools =
|
||||
indexedPools.length > 0
|
||||
? []
|
||||
: filterPoolsForRouting(chainId, (await getLiveDodoPools(chainId)) ?? []).filter(
|
||||
(pool) =>
|
||||
pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress ||
|
||||
pool.token1Address.toLowerCase() === tokenInResolution.lookupAddress
|
||||
);
|
||||
const pairPools = [...indexedPools, ...livePools].filter(
|
||||
(p) =>
|
||||
p.token0Address.toLowerCase() === tokenOut ||
|
||||
p.token1Address.toLowerCase() === tokenOut
|
||||
p.token0Address.toLowerCase() === tokenOutResolution.lookupAddress ||
|
||||
p.token1Address.toLowerCase() === tokenOutResolution.lookupAddress
|
||||
);
|
||||
|
||||
if (pairPools.length === 0) {
|
||||
@@ -71,6 +103,18 @@ router.get(
|
||||
amountOut: null,
|
||||
error: 'No pool found for this token pair',
|
||||
poolAddress: null,
|
||||
quoteEngine: null,
|
||||
canonicalLiquidity: {
|
||||
requestedTokenInAddress: tokenInResolution.requestedAddress,
|
||||
requestedTokenOutAddress: tokenOutResolution.requestedAddress,
|
||||
requestedTokenInSymbol: tokenInResolution.requestedSymbol,
|
||||
requestedTokenOutSymbol: tokenOutResolution.requestedSymbol,
|
||||
lookupTokenInAddress: tokenInResolution.lookupAddress,
|
||||
lookupTokenOutAddress: tokenOutResolution.lookupAddress,
|
||||
lookupTokenInSymbol: tokenInResolution.lookupSymbol,
|
||||
lookupTokenOutSymbol: tokenOutResolution.lookupSymbol,
|
||||
usedFallback: tokenInResolution.usedFallback || tokenOutResolution.usedFallback,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,11 +123,11 @@ router.get(
|
||||
|
||||
for (const pool of pairPools) {
|
||||
const reserveIn =
|
||||
pool.token0Address.toLowerCase() === tokenIn
|
||||
pool.token0Address.toLowerCase() === tokenInResolution.lookupAddress
|
||||
? BigInt(pool.reserve0)
|
||||
: BigInt(pool.reserve1);
|
||||
const reserveOut =
|
||||
pool.token0Address.toLowerCase() === tokenOut
|
||||
pool.token0Address.toLowerCase() === tokenOutResolution.lookupAddress
|
||||
? BigInt(pool.reserve0)
|
||||
: BigInt(pool.reserve1);
|
||||
const out = quoteAmountOut(amountIn, reserveIn, reserveOut);
|
||||
@@ -93,16 +137,46 @@ router.get(
|
||||
}
|
||||
}
|
||||
|
||||
let quoteEngine: 'constant-product' | 'pmm-onchain' = 'constant-product';
|
||||
const pmmRpc = resolvePmmQuoteRpcUrl();
|
||||
if (chainId === 138 && bestPool.dexType === 'dodo' && pmmRpc) {
|
||||
const onChainOut = await pmmQuoteAmountOutFromChain({
|
||||
rpcUrl: pmmRpc,
|
||||
poolAddress: bestPool.poolAddress,
|
||||
tokenInLookup: tokenInResolution.lookupAddress,
|
||||
amountIn,
|
||||
traderForView: resolvePmmQuoteTrader(),
|
||||
});
|
||||
if (onChainOut !== null && onChainOut > BigInt(0)) {
|
||||
bestAmountOut = onChainOut;
|
||||
quoteEngine = 'pmm-onchain';
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
amountOut: bestAmountOut.toString(),
|
||||
poolAddress: bestPool.poolAddress,
|
||||
dexType: bestPool.dexType,
|
||||
executorAddress: bestPool.factoryAddress || null,
|
||||
quoteEngine,
|
||||
canonicalLiquidity: {
|
||||
requestedTokenInAddress: tokenInResolution.requestedAddress,
|
||||
requestedTokenOutAddress: tokenOutResolution.requestedAddress,
|
||||
requestedTokenInSymbol: tokenInResolution.requestedSymbol,
|
||||
requestedTokenOutSymbol: tokenOutResolution.requestedSymbol,
|
||||
lookupTokenInAddress: tokenInResolution.lookupAddress,
|
||||
lookupTokenOutAddress: tokenOutResolution.lookupAddress,
|
||||
lookupTokenInSymbol: tokenInResolution.lookupSymbol,
|
||||
lookupTokenOutSymbol: tokenOutResolution.lookupSymbol,
|
||||
usedFallback: tokenInResolution.usedFallback || tokenOutResolution.usedFallback,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Quote error:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
amountOut: null,
|
||||
quoteEngine: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ jest.mock('../../database/repositories/pool-repo', () => ({
|
||||
getPoolsByChain: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
jest.mock('../../indexer/cross-chain-indexer', () => ({
|
||||
buildCrossChainReport: jest.fn().mockResolvedValue({
|
||||
generatedAt: '2026-03-30T00:00:00.000Z',
|
||||
chainId: 138,
|
||||
crossChainPools: [],
|
||||
volumeByLane: [],
|
||||
atomicSwapVolume24h: 0,
|
||||
bridgeVolume24hTotal: 0,
|
||||
events: [],
|
||||
}),
|
||||
}));
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
@@ -49,8 +60,16 @@ describe('Report API', () => {
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/cmc', () => {
|
||||
@@ -85,4 +104,345 @@ describe('Report API', () => {
|
||||
expect(Array.isArray(body.tokens)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/all', () => {
|
||||
it('includes GRU transport summary for operator visibility', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/all?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gruTransport?.system?.name).toBe('GRU Monetary Transport Layer');
|
||||
expect(body.gruTransport?.summary).toMatchObject({
|
||||
transportPairs: expect.any(Number),
|
||||
runtimeReadyTransportPairs: expect.any(Number),
|
||||
});
|
||||
expect(body.gruTransport?.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
it('returns both chain summaries and runtime pairs for gas rollout consumers', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gas-registry`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_mainnet',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.runtimePairs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '138-1-cETH-cWETH',
|
||||
familyKey: 'eth_mainnet',
|
||||
destinationChainId: 1,
|
||||
destinationChainName: 'Ethereum Mainnet',
|
||||
wrappedNativeQuoteSymbol: 'WETH',
|
||||
stableQuoteSymbol: 'USDC',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.chains).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
chainId: 1,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/token-list', () => {
|
||||
it('surfaces both V1 and V2 Chain 138 canonical GRU deployments explicitly', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDT',
|
||||
chainId: 138,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDT_V2',
|
||||
chainId: 138,
|
||||
familySymbol: 'cUSDT',
|
||||
deploymentVersion: 'v2',
|
||||
preferredForX402: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDC',
|
||||
chainId: 138,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDC_V2',
|
||||
chainId: 138,
|
||||
familySymbol: 'cUSDC',
|
||||
deploymentVersion: 'v2',
|
||||
preferredForX402: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the cUSDW hub asset on Chain 138 and cWUSDW on active edge chains', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cUSDW',
|
||||
chainId: 138,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const bscRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=56`);
|
||||
expect(bscRes.status).toBe(200);
|
||||
const bscBody = (await bscRes.json()) as Record<string, any>;
|
||||
expect(bscBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWUSDW',
|
||||
chainId: 56,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces cAUSDT on Chain 138 when configured and cWAUSDT on active edge chains', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cAUSDT',
|
||||
chainId: 138,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const bscRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=56`);
|
||||
expect(bscRes.status).toBe(200);
|
||||
const bscBody = (await bscRes.json()) as Record<string, any>;
|
||||
expect(bscBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 56,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const polygonRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=137`);
|
||||
expect(polygonRes.status).toBe(200);
|
||||
const polygonBody = (await polygonRes.json()) as Record<string, any>;
|
||||
expect(polygonBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 137,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const avalancheRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=43114`);
|
||||
expect(avalancheRes.status).toBe(200);
|
||||
const avalancheBody = (await avalancheRes.json()) as Record<string, any>;
|
||||
expect(avalancheBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 43114,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const celoRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=42220`);
|
||||
expect(celoRes.status).toBe(200);
|
||||
const celoBody = (await celoRes.json()) as Record<string, any>;
|
||||
expect(celoBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
chainId: 42220,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces cBTC on Chain 138 and cWBTC on the staged public mesh with monetary-unit metadata', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cBTC',
|
||||
chainId: 138,
|
||||
registryFamily: 'monetary_unit',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const mainnetRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`);
|
||||
expect(mainnetRes.status).toBe(200);
|
||||
const mainnetBody = (await mainnetRes.json()) as Record<string, any>;
|
||||
expect(mainnetBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWBTC',
|
||||
chainId: 1,
|
||||
registryFamily: 'monetary_unit',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces gas-native canonicals on Chain 138 and mirrored cW gas tokens on their public lanes', async () => {
|
||||
const chain138Res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138`);
|
||||
expect(chain138Res.status).toBe(200);
|
||||
const chain138Body = (await chain138Res.json()) as Record<string, any>;
|
||||
expect(chain138Body.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cETH',
|
||||
chainId: 138,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
symbol: 'cETHL2',
|
||||
chainId: 138,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const optimismRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=10`);
|
||||
expect(optimismRes.status).toBe(200);
|
||||
const optimismBody = (await optimismRes.json()) as Record<string, any>;
|
||||
expect(optimismBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWETHL2',
|
||||
chainId: 10,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const mainnetRes = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`);
|
||||
expect(mainnetRes.status).toBe(200);
|
||||
const mainnetBody = (await mainnetRes.json()) as Record<string, any>;
|
||||
expect(mainnetBody.tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
symbol: 'cWETH',
|
||||
chainId: 1,
|
||||
registryFamily: 'gas_native',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/cw-registry', () => {
|
||||
it('reads the live cW registry from deployment-status json when available', async () => {
|
||||
const previousPath = process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
const tempPath = `/tmp/token-aggregation-cw-registry-${Date.now()}.json`;
|
||||
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = tempPath;
|
||||
await import('fs/promises').then((fs) =>
|
||||
fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 'test-1',
|
||||
updated: '2026-04-04',
|
||||
chains: {
|
||||
'56': {
|
||||
name: 'BSC',
|
||||
cwTokens: {
|
||||
cWAUSDT: '0xe1a51Bc037a79AB36767561B147eb41780124934',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/cw-registry?chainId=56`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('deployment-status-file');
|
||||
expect(body.complete).toBe(true);
|
||||
expect(body.version).toBe('test-1');
|
||||
expect(body.chains).toEqual([
|
||||
expect.objectContaining({
|
||||
chainId: 56,
|
||||
name: 'BSC',
|
||||
tokens: [
|
||||
expect.objectContaining({
|
||||
symbol: 'cWAUSDT',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await import('fs/promises').then((fs) => fs.unlink(tempPath).catch(() => undefined));
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
} else {
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = previousPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
it('reads the live gas rollout registry from deployment-status json when available', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gas-registry?chainId=10`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('deployment-status-file');
|
||||
expect(body.gasAssetFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(body.chains).toEqual([
|
||||
expect.objectContaining({
|
||||
chainId: 10,
|
||||
families: [
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
mirroredSymbol: 'cWETHL2',
|
||||
dodoPmm: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
quote: 'WETH',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CANONICAL_TOKENS,
|
||||
getCanonicalTokensByChain,
|
||||
getLogoUriForSpec,
|
||||
getTokenRegistryFamily,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { resolvePoolTokenDisplays } from '../../services/token-display';
|
||||
import { getSupportedChainIds } from '../../config/chains';
|
||||
@@ -18,6 +19,13 @@ import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
import { buildCrossChainReport } from '../../indexer/cross-chain-indexer';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { filterPoolsForExposure, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport';
|
||||
import {
|
||||
buildCwRegistryChains,
|
||||
buildGasRegistryChains,
|
||||
loadDeploymentStatusFile,
|
||||
type CwRegistryChain,
|
||||
} from '../../config/deployment-status';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -35,6 +43,12 @@ async function buildTokenReport(chainId: number) {
|
||||
type: string;
|
||||
decimals: number;
|
||||
currencyCode?: string;
|
||||
registryFamily?: string;
|
||||
familySymbol?: string;
|
||||
deploymentVersion?: string;
|
||||
deploymentStatus?: string;
|
||||
preferredForX402?: boolean;
|
||||
liquiditySourceSymbol?: string;
|
||||
market?: {
|
||||
priceUsd?: number;
|
||||
volume24h: number;
|
||||
@@ -64,9 +78,10 @@ async function buildTokenReport(chainId: number) {
|
||||
marketDataRepo.getMarketData(chainId, address),
|
||||
poolRepo.getPoolsByToken(chainId, address),
|
||||
]);
|
||||
const exposedPools = filterPoolsForExposure(chainId, pools);
|
||||
|
||||
const resolvedPools = await Promise.all(
|
||||
pools.map(async (p) => {
|
||||
exposedPools.map(async (p) => {
|
||||
const { token0, token1 } = await resolvePoolTokenDisplays(tokenRepo, chainId, p.token0Address, p.token1Address);
|
||||
return {
|
||||
poolAddress: p.poolAddress,
|
||||
@@ -87,6 +102,12 @@ async function buildTokenReport(chainId: number) {
|
||||
type: spec.type,
|
||||
decimals: spec.decimals,
|
||||
currencyCode: spec.currencyCode,
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
preferredForX402: spec.preferredForX402,
|
||||
liquiditySourceSymbol: spec.liquiditySourceSymbol,
|
||||
market: marketData
|
||||
? {
|
||||
priceUsd: marketData.priceUsd,
|
||||
@@ -115,6 +136,87 @@ async function buildTokenReport(chainId: number) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function describeToken(spec: { currencyCode?: string; registryFamily?: string }): string | undefined {
|
||||
const family = String(spec.registryFamily || '').trim();
|
||||
const code = String(spec.currencyCode || '').trim().toUpperCase();
|
||||
if (!code) return undefined;
|
||||
if (family === 'gas_native') {
|
||||
return `Governance-approved gas-native ${code} compliant token`;
|
||||
}
|
||||
if (family === 'monetary_unit') {
|
||||
return `GRU monetary-unit ${code} compliant token`;
|
||||
}
|
||||
if (family === 'commodity') {
|
||||
return `Governance-approved commodity ${code} compliant token`;
|
||||
}
|
||||
return `ISO-4217 ${code} compliant token`;
|
||||
}
|
||||
|
||||
function buildGruTransportOverview() {
|
||||
const gruTransportMetadata = getGruTransportMetadata();
|
||||
if (!gruTransportMetadata) return undefined;
|
||||
|
||||
return {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
gasAssetFamilies: gruTransportMetadata.gasAssetFamilies ?? [],
|
||||
gasRedeemGroups: gruTransportMetadata.gasRedeemGroups ?? [],
|
||||
gasProtocolExposure: gruTransportMetadata.gasProtocolExposure ?? [],
|
||||
activeTransportPairs: getActiveTransportPairs().map((pair) => ({
|
||||
key: pair.key,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
destinationChainName: pair.destinationChainName ?? null,
|
||||
assetClass: pair.assetClass,
|
||||
familyKey: pair.familyKey,
|
||||
backingMode: pair.backingMode,
|
||||
redeemPolicy: pair.redeemPolicy,
|
||||
wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null,
|
||||
stableQuoteSymbol: pair.stableQuoteSymbol ?? null,
|
||||
eligible: pair.eligible === true,
|
||||
runtimeReady: pair.runtimeReady === true,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
eligibilityBlockers: Array.isArray(pair.eligibilityBlockers)
|
||||
? pair.eligibilityBlockers
|
||||
: [],
|
||||
runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements)
|
||||
? pair.runtimeMissingRequirements
|
||||
: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildCanonicalCwFallback(chainIdFilter?: number | null): CwRegistryChain[] {
|
||||
const grouped = new Map<number, CwRegistryChain>();
|
||||
|
||||
for (const spec of CANONICAL_TOKENS) {
|
||||
if (spec.type !== 'w') continue;
|
||||
for (const [chainIdText, address] of Object.entries(spec.addresses)) {
|
||||
const chainId = Number(chainIdText);
|
||||
if (!address || Number.isNaN(chainId)) continue;
|
||||
if (chainIdFilter && chainId !== chainIdFilter) continue;
|
||||
|
||||
const existing = grouped.get(chainId) ?? {
|
||||
chainId,
|
||||
chainIdText,
|
||||
name: `Chain ${chainIdText}`,
|
||||
tokens: [],
|
||||
};
|
||||
|
||||
existing.tokens.push({ symbol: spec.symbol, address });
|
||||
grouped.set(chainId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((row) => ({
|
||||
...row,
|
||||
tokens: row.tokens.sort((a, b) => a.symbol.localeCompare(b.symbol)),
|
||||
}))
|
||||
.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
|
||||
/** GET /report/cross-chain — cross-chain pools, bridge volume, atomic swaps (Chain 138, ALL Mainnet) */
|
||||
router.get(
|
||||
'/cross-chain',
|
||||
@@ -158,10 +260,11 @@ router.get(
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
tokensByChain[chainId] = await buildTokenReport(chainId);
|
||||
poolsByChain[chainId] = await poolRepo.getPoolsByChain(chainId);
|
||||
poolsByChain[chainId] = filterPoolsForExposure(chainId, await poolRepo.getPoolsByChain(chainId));
|
||||
}
|
||||
|
||||
const crossChainReport = await buildCrossChainReport(138).catch(() => null);
|
||||
const gruTransport = buildGruTransportOverview();
|
||||
|
||||
const totalLiquidityByChain: Record<number, number> = {};
|
||||
const totalVolume24hByChain: Record<number, number> = {};
|
||||
@@ -196,6 +299,7 @@ router.get(
|
||||
bridgeVolume24hTotal: crossChainReport.bridgeVolume24hTotal,
|
||||
}
|
||||
: undefined,
|
||||
gruTransport,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/all:', error);
|
||||
@@ -221,7 +325,7 @@ router.get(
|
||||
name: t.name,
|
||||
asset_platform_id: chainId === 138 ? 'defi-oracle-meta' : chainId === 651940 ? 'all-mainnet' : `chain-${chainId}`,
|
||||
decimals: t.decimals,
|
||||
description: t.currencyCode ? `ISO-4217 ${t.currencyCode} compliant token` : undefined,
|
||||
description: describeToken(t),
|
||||
market_data: t.market
|
||||
? {
|
||||
current_price: { usd: t.market.priceUsd },
|
||||
@@ -364,6 +468,11 @@ router.get(
|
||||
decimals: number;
|
||||
type: string;
|
||||
logoURI: string;
|
||||
registryFamily?: string;
|
||||
familySymbol?: string;
|
||||
deploymentVersion?: string;
|
||||
deploymentStatus?: string;
|
||||
preferredForX402?: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
@@ -379,6 +488,11 @@ router.get(
|
||||
decimals: spec.decimals,
|
||||
type: spec.type,
|
||||
logoURI: getLogoUriForSpec(spec),
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
preferredForX402: spec.preferredForX402,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -398,20 +512,112 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/cw-registry — live cW* registry from deployment-status.json when available. */
|
||||
router.get('/cw-registry', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
const fileBackedRegistry = loadDeploymentStatusFile();
|
||||
|
||||
let chains = fileBackedRegistry
|
||||
? buildCwRegistryChains(fileBackedRegistry.data)
|
||||
: buildCanonicalCwFallback(chainIdFilter);
|
||||
|
||||
if (chainIdFilter && !Number.isNaN(chainIdFilter)) {
|
||||
chains = chains.filter((row) => row.chainId === chainIdFilter);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: fileBackedRegistry ? 'deployment-status-file' : 'canonical-fallback',
|
||||
complete: !!fileBackedRegistry,
|
||||
version: fileBackedRegistry?.data.version,
|
||||
updated: fileBackedRegistry?.data.updated,
|
||||
lastModified: fileBackedRegistry?.lastModified,
|
||||
chains,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/cw-registry:', error);
|
||||
res.status(500).json({ error: 'Internal server error', chains: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/gas-registry — live gas-family rollout registry from deployment-status.json plus GRU transport metadata. */
|
||||
router.get('/gas-registry', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
const fileBackedRegistry = loadDeploymentStatusFile();
|
||||
const gruTransport = buildGruTransportOverview();
|
||||
const runtimeGasPairs = getActiveTransportPairs()
|
||||
.filter((pair) => pair.assetClass === 'gas_native')
|
||||
.map((pair) => ({
|
||||
key: pair.key,
|
||||
destinationChainId: pair.destinationChainId,
|
||||
destinationChainName: pair.destinationChainName ?? null,
|
||||
familyKey: pair.familyKey ?? null,
|
||||
canonicalSymbol: pair.canonicalSymbol,
|
||||
mirroredSymbol: pair.mirroredSymbol,
|
||||
wrappedNativeQuoteSymbol: pair.wrappedNativeQuoteSymbol ?? null,
|
||||
stableQuoteSymbol: pair.stableQuoteSymbol ?? null,
|
||||
backingMode: pair.backingMode ?? null,
|
||||
redeemPolicy: pair.redeemPolicy ?? null,
|
||||
runtimeReady: pair.runtimeReady === true,
|
||||
supplyInvariantSatisfied: pair.supplyInvariantSatisfied ?? null,
|
||||
eligibilityBlockers: Array.isArray(pair.eligibilityBlockers) ? pair.eligibilityBlockers : [],
|
||||
runtimeMissingRequirements: Array.isArray(pair.runtimeMissingRequirements)
|
||||
? pair.runtimeMissingRequirements
|
||||
: [],
|
||||
}));
|
||||
|
||||
let chains = fileBackedRegistry ? buildGasRegistryChains(fileBackedRegistry.data) : [];
|
||||
if (chainIdFilter && !Number.isNaN(chainIdFilter)) {
|
||||
chains = chains.filter((row) => row.chainId === chainIdFilter);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: fileBackedRegistry ? 'deployment-status-file' : 'transport-config-only',
|
||||
complete: !!fileBackedRegistry,
|
||||
version: fileBackedRegistry?.data.version,
|
||||
updated: fileBackedRegistry?.data.updated,
|
||||
lastModified: fileBackedRegistry?.lastModified,
|
||||
gasAssetFamilies: gruTransport?.gasAssetFamilies ?? [],
|
||||
gasRedeemGroups: gruTransport?.gasRedeemGroups ?? [],
|
||||
gasProtocolExposure: gruTransport?.gasProtocolExposure ?? [],
|
||||
runtimePairs: runtimeGasPairs,
|
||||
chains,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/gas-registry:', error);
|
||||
res.status(500).json({ error: 'Internal server error', chains: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/canonical — raw canonical spec list (no DB merge) */
|
||||
router.get(
|
||||
'/canonical',
|
||||
cacheMiddleware(10 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gruTransport = buildGruTransportOverview();
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
gruTransport,
|
||||
tokens: CANONICAL_TOKENS.map((t) => ({
|
||||
symbol: t.symbol,
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
decimals: t.decimals,
|
||||
currencyCode: t.currencyCode,
|
||||
registryFamily: getTokenRegistryFamily(t),
|
||||
familySymbol: t.familySymbol,
|
||||
deploymentVersion: t.deploymentVersion,
|
||||
deploymentStatus: t.deploymentStatus,
|
||||
preferredForX402: t.preferredForX402,
|
||||
liquiditySourceSymbol: t.liquiditySourceSymbol,
|
||||
addresses: t.addresses,
|
||||
})),
|
||||
});
|
||||
|
||||
215
services/token-aggregation/src/api/routes/token-mapping.test.ts
Normal file
215
services/token-aggregation/src/api/routes/token-mapping.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import tokenMappingRoutes from './token-mapping';
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1/token-mapping', tokenMappingRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Token mapping API with GRU Transport overlay', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE;
|
||||
const originalBscBridge = process.env.CW_BRIDGE_BSC;
|
||||
const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
const originalReserveSystem = process.env.CW_RESERVE_SYSTEM;
|
||||
const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
const originalMainnetBridge = process.env.CW_BRIDGE_MAINNET;
|
||||
const originalBtcMainnetOutstanding = process.env.CW_MAX_OUTSTANDING_BTC_MAINNET;
|
||||
const originalOptimismBridge = process.env.CW_BRIDGE_OPTIMISM;
|
||||
const originalGasHybridVerifier = process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138;
|
||||
const originalGasEscrowVault = process.env.CW_GAS_ESCROW_VAULT_CHAIN138;
|
||||
const originalGasTreasurySystem = process.env.CW_GAS_TREASURY_SYSTEM;
|
||||
const originalEthL2Outstanding = process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Supply = process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Escrowed = process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Treasury = process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Cap = process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM;
|
||||
const originalCwL1Bridge = process.env.CW_L1_BRIDGE;
|
||||
const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalChain138Bridge === undefined) {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge;
|
||||
}
|
||||
if (originalBscBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_BSC = originalBscBridge;
|
||||
}
|
||||
if (originalReserveVerifier === undefined) {
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier;
|
||||
}
|
||||
if (originalReserveVault === undefined) {
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
} else {
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault;
|
||||
}
|
||||
if (originalReserveSystem === undefined) {
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
} else {
|
||||
process.env.CW_RESERVE_SYSTEM = originalReserveSystem;
|
||||
}
|
||||
if (originalMaxOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding;
|
||||
}
|
||||
if (originalMainnetBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_MAINNET;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_MAINNET = originalMainnetBridge;
|
||||
}
|
||||
if (originalBtcMainnetOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_BTC_MAINNET;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_BTC_MAINNET = originalBtcMainnetOutstanding;
|
||||
}
|
||||
for (const [key, value] of Object.entries({
|
||||
CW_BRIDGE_OPTIMISM: originalOptimismBridge,
|
||||
CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138: originalGasHybridVerifier,
|
||||
CW_GAS_ESCROW_VAULT_CHAIN138: originalGasEscrowVault,
|
||||
CW_GAS_TREASURY_SYSTEM: originalGasTreasurySystem,
|
||||
CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Outstanding,
|
||||
CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Supply,
|
||||
CW_GAS_ESCROWED_ETH_L2_OPTIMISM: originalEthL2Escrowed,
|
||||
CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM: originalEthL2Treasury,
|
||||
CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM: originalEthL2Cap,
|
||||
})) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
if (originalCwL1Bridge === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE = originalCwL1Bridge;
|
||||
}
|
||||
if (originalCwL1BridgeChain138 === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('returns the active GRU transport overlay', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/token-mapping/transport/active`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.system).toMatchObject({
|
||||
name: 'GRU Monetary Transport Layer',
|
||||
shortName: 'GRU Transport',
|
||||
});
|
||||
expect(Array.isArray(body.transportPairs)).toBe(true);
|
||||
expect((body.transportPairs as unknown[]).length).toBeGreaterThan(0);
|
||||
expect(body.counts).toMatchObject({
|
||||
transportPairs: expect.any(Number),
|
||||
runtimeReadyTransportPairs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves active cUSDT transport from Chain 138 to BSC', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const source = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=56&address=${source}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.addressOnTarget).toBe('0x9a1D0dBEE997929ED02fD19E0E199704d20914dB');
|
||||
expect(body.activeTransportEligible).toBe(true);
|
||||
expect(body.gruTransportRuntimeReady).toBe(true);
|
||||
expect(body.gruTransportPairKey).toBe('138-56-cUSDT-cWUSDT');
|
||||
expect(body.gruTransportCanonicalToken).toMatchObject({
|
||||
symbol: 'cUSDT',
|
||||
activeVersion: 'v1',
|
||||
x402PreferredVersion: 'v2',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves active cBTC transport from Chain 138 to Ethereum mainnet', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_MAINNET = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_BTC_MAINNET = '2100000000000000';
|
||||
|
||||
const source = '0xcb7c000000000000000000000000000000000138';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=1&address=${source}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.addressOnTarget).toBe('0xcb7c000000000000000000000000000000000001');
|
||||
expect(body.activeTransportEligible).toBe(true);
|
||||
expect(body.gruTransportRuntimeReady).toBe(true);
|
||||
expect(body.gruTransportPairKey).toBe('138-1-cBTC-cWBTC');
|
||||
});
|
||||
|
||||
it('resolves gas-family transport metadata for the shared ETH L2 lane', async () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_OPTIMISM = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_GAS_ESCROW_VAULT_CHAIN138 = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_GAS_TREASURY_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM = '100';
|
||||
process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM = '25';
|
||||
process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM = '25';
|
||||
|
||||
/** Matches token-mapping-multichain.json 138→10 Compliant_ETH_L2_cW (not FALLBACK cETHL2 placeholder). */
|
||||
const source = '0x18a6b163d255cc0cb32b99697843b487d059907d';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/v1/token-mapping/resolve?fromChain=138&toChain=10&address=${source}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.addressOnTarget).toBe('0x95007ec50d0766162f77848edf7bdc4eba147fb4');
|
||||
expect(body.activeTransportEligible).toBe(true);
|
||||
expect(body.gruTransportRuntimeReady).toBe(true);
|
||||
expect(body.gruTransportPairKey).toBe('138-10-cETHL2-cWETHL2');
|
||||
expect(body.gruTransportAssetClass).toBe('gas_native');
|
||||
expect(body.gruTransportFamilyKey).toBe('eth_l2');
|
||||
expect(body.gruTransportBackingMode).toBe('hybrid_cap');
|
||||
expect(body.gruTransportRedeemPolicy).toBe('family_fungible_inventory_gated');
|
||||
});
|
||||
});
|
||||
@@ -5,29 +5,47 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { loadTokenMappingLoader } from '../../config/repo-config-loader';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
/** Repo root (proxmox): when run from token-aggregation cwd, 2 levels up to smom-dbis-138, 1 more to proxmox */
|
||||
const PROXMOX_ROOT = path.resolve(process.cwd(), '../../..');
|
||||
const LOADER_PATH = path.join(PROXMOX_ROOT, 'config', 'token-mapping-loader.cjs');
|
||||
const requireLoader = createRequire(path.join(PROXMOX_ROOT, 'package.json'));
|
||||
|
||||
function loadMultichainLoader(): {
|
||||
getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record<string, string>; addressMapToFrom: Record<string, string> } | null;
|
||||
getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>;
|
||||
getMappedAddress: (from: number, to: number, addr: string) => string | undefined;
|
||||
getGruTransportMetadata?: () => {
|
||||
system: Record<string, unknown> | null;
|
||||
terminology: Record<string, string>;
|
||||
enabledCanonicalTokens: Array<Record<string, unknown>>;
|
||||
enabledDestinationChains: Array<Record<string, unknown>>;
|
||||
counts: Record<string, number>;
|
||||
} | null;
|
||||
getActiveTransportPairs?: () => Array<Record<string, unknown>>;
|
||||
getActiveTransportPair?: (from: number, to: number, criteria?: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
getActivePublicPools?: () => Array<Record<string, unknown>>;
|
||||
getEnabledCanonicalToken?: (identifier: string) => Record<string, unknown> | null;
|
||||
isGasRedemptionPathAllowed?: (from: number, to: number, identifier: string) => boolean;
|
||||
} | null {
|
||||
try {
|
||||
const loader = requireLoader(LOADER_PATH);
|
||||
if (loader?.getTokenMappingForPair && loader?.getAllMultichainPairs && loader?.getMappedAddress) {
|
||||
return loader;
|
||||
}
|
||||
} catch {
|
||||
// config not available when run outside monorepo
|
||||
const loader = loadTokenMappingLoader<{
|
||||
getTokenMappingForPair: (from: number, to: number) => { tokens: unknown[]; addressMapFromTo: Record<string, string>; addressMapToFrom: Record<string, string> } | null;
|
||||
getAllMultichainPairs: () => Array<{ fromChainId: number; toChainId: number; notes?: string }>;
|
||||
getMappedAddress: (from: number, to: number, addr: string) => string | undefined;
|
||||
getGruTransportMetadata?: () => {
|
||||
system: Record<string, unknown> | null;
|
||||
terminology: Record<string, string>;
|
||||
enabledCanonicalTokens: Array<Record<string, unknown>>;
|
||||
enabledDestinationChains: Array<Record<string, unknown>>;
|
||||
counts: Record<string, number>;
|
||||
} | null;
|
||||
getActiveTransportPairs?: () => Array<Record<string, unknown>>;
|
||||
getActiveTransportPair?: (from: number, to: number, criteria?: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
getActivePublicPools?: () => Array<Record<string, unknown>>;
|
||||
getEnabledCanonicalToken?: (identifier: string) => Record<string, unknown> | null;
|
||||
isGasRedemptionPathAllowed?: (from: number, to: number, identifier: string) => boolean;
|
||||
}>();
|
||||
if (loader) {
|
||||
return loader;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -65,12 +83,31 @@ router.get(
|
||||
});
|
||||
}
|
||||
|
||||
const activePairs = loader.getActiveTransportPairs
|
||||
? loader
|
||||
.getActiveTransportPairs()
|
||||
.filter((pair) => {
|
||||
const canonicalChainId = Number(pair.canonicalChainId);
|
||||
const destinationChainId = Number(pair.destinationChainId);
|
||||
return (
|
||||
(canonicalChainId === fromChain && destinationChainId === toChain) ||
|
||||
(canonicalChainId === toChain && destinationChainId === fromChain)
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
return res.json({
|
||||
fromChainId: fromChain,
|
||||
toChainId: toChain,
|
||||
tokens: result.tokens,
|
||||
addressMapFromTo: result.addressMapFromTo,
|
||||
addressMapToFrom: result.addressMapToFrom,
|
||||
gruTransport: loader.getGruTransportMetadata
|
||||
? {
|
||||
system: loader.getGruTransportMetadata()?.system ?? null,
|
||||
activePairs,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -90,7 +127,38 @@ router.get(
|
||||
});
|
||||
}
|
||||
const pairs = loader.getAllMultichainPairs();
|
||||
return res.json({ pairs });
|
||||
return res.json({
|
||||
pairs,
|
||||
gruTransport: loader.getGruTransportMetadata
|
||||
? {
|
||||
system: loader.getGruTransportMetadata()?.system ?? null,
|
||||
activePairs: loader.getActiveTransportPairs ? loader.getActiveTransportPairs() : [],
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/token-mapping/transport/active
|
||||
* Returns the GRU Monetary Transport Layer overlay as seen by token-aggregation.
|
||||
*/
|
||||
router.get(
|
||||
'/transport/active',
|
||||
cacheMiddleware(5 * 60 * 1000),
|
||||
(_req: Request, res: Response) => {
|
||||
const loader = loadMultichainLoader();
|
||||
if (!loader || !loader.getGruTransportMetadata || !loader.getActiveTransportPairs) {
|
||||
return res.status(503).json({
|
||||
error: 'GRU transport config not available (run from monorepo with config/gru-transport-active.json)',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
...loader.getGruTransportMetadata(),
|
||||
transportPairs: loader.getActiveTransportPairs(),
|
||||
publicPools: loader.getActivePublicPools ? loader.getActivePublicPools() : [],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -120,11 +188,64 @@ router.get(
|
||||
}
|
||||
|
||||
const mapped = loader.getMappedAddress(fromChain, toChain, address);
|
||||
const activeTransportPair = loader.getActiveTransportPair
|
||||
? loader.getActiveTransportPair(fromChain, toChain, {
|
||||
address,
|
||||
targetTokenAddress: mapped ?? null,
|
||||
})
|
||||
: null;
|
||||
const canonicalTokenIdentifier =
|
||||
(activeTransportPair && typeof activeTransportPair.canonicalSymbol === 'string'
|
||||
? activeTransportPair.canonicalSymbol
|
||||
: null) ??
|
||||
address;
|
||||
const canonicalToken = loader.getEnabledCanonicalToken
|
||||
? loader.getEnabledCanonicalToken(canonicalTokenIdentifier)
|
||||
: null;
|
||||
return res.json({
|
||||
fromChainId: fromChain,
|
||||
toChainId: toChain,
|
||||
addressOnSource: address,
|
||||
addressOnTarget: mapped ?? null,
|
||||
activeTransportEligible: !!activeTransportPair && activeTransportPair.eligible === true,
|
||||
gruTransportRuntimeReady: !!activeTransportPair && activeTransportPair.runtimeReady === true,
|
||||
gruTransportPairKey:
|
||||
activeTransportPair && typeof activeTransportPair.key === 'string' ? activeTransportPair.key : null,
|
||||
gruTransportAssetClass:
|
||||
activeTransportPair && typeof activeTransportPair.assetClass === 'string'
|
||||
? activeTransportPair.assetClass
|
||||
: null,
|
||||
gruTransportFamilyKey:
|
||||
activeTransportPair && typeof activeTransportPair.familyKey === 'string'
|
||||
? activeTransportPair.familyKey
|
||||
: null,
|
||||
gruTransportBackingMode:
|
||||
activeTransportPair && typeof activeTransportPair.backingMode === 'string'
|
||||
? activeTransportPair.backingMode
|
||||
: null,
|
||||
gruTransportRedeemPolicy:
|
||||
activeTransportPair && typeof activeTransportPair.redeemPolicy === 'string'
|
||||
? activeTransportPair.redeemPolicy
|
||||
: null,
|
||||
gruTransportWrappedNativeQuoteSymbol:
|
||||
activeTransportPair && typeof activeTransportPair.wrappedNativeQuoteSymbol === 'string'
|
||||
? activeTransportPair.wrappedNativeQuoteSymbol
|
||||
: null,
|
||||
gruTransportStableQuoteSymbol:
|
||||
activeTransportPair && typeof activeTransportPair.stableQuoteSymbol === 'string'
|
||||
? activeTransportPair.stableQuoteSymbol
|
||||
: null,
|
||||
gruTransportReferenceVenue:
|
||||
activeTransportPair && typeof activeTransportPair.referenceVenue === 'string'
|
||||
? activeTransportPair.referenceVenue
|
||||
: null,
|
||||
gruGasRedemptionPathAllowed:
|
||||
activeTransportPair &&
|
||||
typeof activeTransportPair.familyKey === 'string' &&
|
||||
loader.isGasRedemptionPathAllowed
|
||||
? loader.isGasRedemptionPathAllowed(fromChain, toChain, activeTransportPair.familyKey)
|
||||
: null,
|
||||
gruTransportCanonicalToken: canonicalToken,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { TokenRepository } from '../../database/repositories/token-repo';
|
||||
import { TokenRepository, Token } from '../../database/repositories/token-repo';
|
||||
import { MarketDataRepository } from '../../database/repositories/market-data-repo';
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import { PoolRepository, LiquidityPool } from '../../database/repositories/pool-repo';
|
||||
import { OHLCVGenerator } from '../../indexer/ohlcv-generator';
|
||||
import { CoinGeckoAdapter } from '../../adapters/coingecko-adapter';
|
||||
import { CoinMarketCapAdapter } from '../../adapters/cmc-adapter';
|
||||
import { DexScreenerAdapter } from '../../adapters/dexscreener-adapter';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { resolvePoolTokenDisplays } from '../../services/token-display';
|
||||
import { resolvePoolTokenDisplays, resolveTokenDisplay } from '../../services/token-display';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { filterPoolsForExposure, shouldExposePublicPool } from '../../config/gru-transport';
|
||||
import {
|
||||
getCanonicalTokenByAddress,
|
||||
getCanonicalTokensByChain,
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -19,6 +26,122 @@ const coingeckoAdapter = new CoinGeckoAdapter();
|
||||
const cmcAdapter = new CoinMarketCapAdapter();
|
||||
const dexscreenerAdapter = new DexScreenerAdapter();
|
||||
|
||||
function tokenFromCanonical(chainId: number, address: string): Token | null {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase());
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chainId,
|
||||
address: address.toLowerCase(),
|
||||
name: spec.name,
|
||||
symbol: spec.symbol,
|
||||
decimals: spec.decimals,
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPoolsByTokenWithFallback(chainId: number, address: string): Promise<LiquidityPool[]> {
|
||||
const normalized = address.toLowerCase();
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalized);
|
||||
const dbPools = filterPoolsForExposure(
|
||||
chainId,
|
||||
await poolRepo.getPoolsByToken(chainId, resolution.lookupAddress)
|
||||
);
|
||||
if (dbPools.length > 0) {
|
||||
return dbPools;
|
||||
}
|
||||
|
||||
const livePools = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId));
|
||||
return livePools.filter(
|
||||
(pool) =>
|
||||
pool.token0Address === resolution.lookupAddress ||
|
||||
pool.token1Address === resolution.lookupAddress ||
|
||||
pool.token0Address === normalized ||
|
||||
pool.token1Address === normalized
|
||||
);
|
||||
}
|
||||
|
||||
async function getTokenWithFallback(chainId: number, address: string): Promise<Token | null> {
|
||||
const normalized = address.toLowerCase();
|
||||
const token = await tokenRepo.getToken(chainId, normalized);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const canonical = tokenFromCanonical(chainId, normalized);
|
||||
if (canonical) {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalized);
|
||||
const livePools = await getLiveDodoPools(chainId);
|
||||
const liveAddress =
|
||||
livePools.find((pool) => pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress)
|
||||
? resolution.lookupAddress
|
||||
: null;
|
||||
if (!liveAddress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress);
|
||||
return {
|
||||
chainId,
|
||||
address: normalized,
|
||||
name: display.name,
|
||||
symbol: display.symbol,
|
||||
decimals: display.decimals,
|
||||
verified: display.source !== 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
async function getTokensWithFallback(
|
||||
chainId: number,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ tokens: Token[]; source: 'db' | 'live-dodo' | 'canonical' }> {
|
||||
const dbTokens = await tokenRepo.getTokens(chainId, limit, offset);
|
||||
if (dbTokens.length > 0) {
|
||||
return { tokens: dbTokens, source: 'db' };
|
||||
}
|
||||
|
||||
const livePools = await getLiveDodoPools(chainId);
|
||||
if (livePools.length > 0) {
|
||||
const tokenAddresses = [...new Set(livePools.flatMap((pool) => [pool.token0Address, pool.token1Address]))];
|
||||
const liveTokens = await Promise.all(
|
||||
tokenAddresses.map(async (address) => {
|
||||
const display = await resolveTokenDisplay(tokenRepo, chainId, address);
|
||||
return {
|
||||
chainId,
|
||||
address: display.address,
|
||||
name: display.name,
|
||||
symbol: display.symbol,
|
||||
decimals: display.decimals,
|
||||
verified: display.source !== 'fallback',
|
||||
} as Token;
|
||||
})
|
||||
);
|
||||
const sorted = liveTokens.sort((a, b) =>
|
||||
`${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`)
|
||||
);
|
||||
return { tokens: sorted.slice(offset, offset + limit), source: 'live-dodo' };
|
||||
}
|
||||
|
||||
const canonicalTokens = getCanonicalTokensByChain(chainId)
|
||||
.map((spec) => ({
|
||||
chainId,
|
||||
address: String(spec.addresses[chainId]).toLowerCase(),
|
||||
name: spec.name,
|
||||
symbol: spec.symbol,
|
||||
decimals: spec.decimals,
|
||||
verified: true,
|
||||
}) as Token)
|
||||
.sort((a, b) => `${a.symbol || ''}${a.address}`.localeCompare(`${b.symbol || ''}${b.address}`));
|
||||
|
||||
return { tokens: canonicalTokens.slice(offset, offset + limit), source: 'canonical' };
|
||||
}
|
||||
|
||||
router.get('/chains', cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
@@ -51,7 +174,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const tokens = await tokenRepo.getTokens(chainId, limit, offset);
|
||||
const { tokens, source } = await getTokensWithFallback(chainId, limit, offset);
|
||||
const tokensWithMarketData = await Promise.all(
|
||||
tokens.map(async (token) => {
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, token.address);
|
||||
@@ -60,7 +183,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
market: marketData || undefined,
|
||||
};
|
||||
if (includeDodoPool) {
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, token.address);
|
||||
const pools = await getPoolsByTokenWithFallback(chainId, token.address);
|
||||
const dodoPool = pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo');
|
||||
out.hasDodoPool = !!dodoPool;
|
||||
out.pmmPool = dodoPool?.poolAddress || undefined;
|
||||
@@ -76,6 +199,7 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
offset,
|
||||
count: tokensWithMarketData.length,
|
||||
},
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tokens:', error);
|
||||
@@ -92,17 +216,19 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const token = await tokenRepo.getToken(chainId, address);
|
||||
const normalizedAddress = address.toLowerCase();
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress);
|
||||
const token = await getTokenWithFallback(chainId, normalizedAddress);
|
||||
if (!token) {
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
|
||||
const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([
|
||||
marketDataRepo.getMarketData(chainId, address),
|
||||
poolRepo.getPoolsByToken(chainId, address),
|
||||
coingeckoAdapter.getTokenByContract(chainId, address),
|
||||
cmcAdapter.getTokenByContract(chainId, address),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, address),
|
||||
marketDataRepo.getMarketData(chainId, resolution.lookupAddress),
|
||||
getPoolsByTokenWithFallback(chainId, normalizedAddress),
|
||||
coingeckoAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
@@ -131,6 +257,15 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
})),
|
||||
hasDodoPool: pools.some((p) => (p.dexType || '').toLowerCase() === 'dodo'),
|
||||
pmmPool: pools.find((p) => (p.dexType || '').toLowerCase() === 'dodo')?.poolAddress || undefined,
|
||||
canonicalLiquidity:
|
||||
resolution.usedFallback
|
||||
? {
|
||||
requestedAddress: normalizedAddress,
|
||||
lookupAddress: resolution.lookupAddress,
|
||||
requestedSymbol: resolution.requestedSymbol,
|
||||
lookupSymbol: resolution.lookupSymbol,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,7 +283,7 @@ router.get('/tokens/:address/pools', cacheMiddleware(60 * 1000), async (req: Req
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
const pools = await poolRepo.getPoolsByToken(chainId, address);
|
||||
const pools = await getPoolsByTokenWithFallback(chainId, address);
|
||||
|
||||
res.json({
|
||||
pools: await Promise.all(
|
||||
@@ -280,7 +415,10 @@ router.get('/pools/:poolAddress', cacheMiddleware(60 * 1000), async (req: Reques
|
||||
}
|
||||
|
||||
const pool = await poolRepo.getPool(chainId, poolAddress);
|
||||
if (!pool) {
|
||||
if (
|
||||
!pool ||
|
||||
!shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address)
|
||||
) {
|
||||
return res.status(404).json({ error: 'Pool not found' });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import heatmapRoutes from './routes/heatmap';
|
||||
import arbitrageRoutes from './routes/arbitrage';
|
||||
import aggregatorRouteMatrixRoutes from './routes/aggregator-routes';
|
||||
import partnerPayloadRoutes from './routes/partner-payloads';
|
||||
import plannerV2Routes from './routes/planner-v2';
|
||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import winston from 'winston';
|
||||
@@ -39,19 +40,42 @@ const logger = winston.createLogger({
|
||||
export class ApiServer {
|
||||
private app: Express;
|
||||
private port: number;
|
||||
private indexer: MultiChainIndexer;
|
||||
private indexerEnabled: boolean;
|
||||
private indexer: MultiChainIndexer | null;
|
||||
|
||||
private resolveTrustProxySetting(): boolean | number | string {
|
||||
const raw = (process.env.EXPRESS_TRUST_PROXY ?? process.env.TRUST_PROXY ?? '1').trim();
|
||||
const normalized = raw.toLowerCase();
|
||||
|
||||
if (normalized === 'true') return true;
|
||||
if (normalized === 'false') return false;
|
||||
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
|
||||
return raw;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.port = parseInt(process.env.PORT || '3000', 10);
|
||||
this.indexer = new MultiChainIndexer();
|
||||
this.indexerEnabled = this.resolveFeatureFlag('ENABLE_INDEXER', true);
|
||||
this.indexer = this.indexerEnabled ? new MultiChainIndexer() : null;
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupErrorHandling();
|
||||
}
|
||||
|
||||
private resolveFeatureFlag(name: string, fallback: boolean): boolean {
|
||||
const raw = (process.env[name] || '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
if (['1', 'true', 'yes', 'on'].includes(raw)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(raw)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
const trustProxy = this.resolveTrustProxySetting();
|
||||
this.app.set('trust proxy', trustProxy);
|
||||
|
||||
// CORS
|
||||
this.app.use(cors());
|
||||
|
||||
@@ -69,6 +93,8 @@ export class ApiServer {
|
||||
this.app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
forwardedFor: req.get('x-forwarded-for'),
|
||||
trustProxy,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
next();
|
||||
@@ -88,7 +114,7 @@ export class ApiServer {
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: 'connected',
|
||||
indexer: 'running',
|
||||
indexer: this.indexerEnabled ? 'running' : 'disabled',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -112,6 +138,7 @@ export class ApiServer {
|
||||
this.app.use('/api/v1', arbitrageRoutes);
|
||||
this.app.use('/api/v1', aggregatorRouteMatrixRoutes);
|
||||
this.app.use('/api/v1', partnerPayloadRoutes);
|
||||
this.app.use('/api/v2', plannerV2Routes);
|
||||
|
||||
// Admin routes (stricter rate limit)
|
||||
this.app.use('/api/v1/admin', strictRateLimiter, adminRoutes);
|
||||
@@ -124,6 +151,7 @@ export class ApiServer {
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
api: '/api/v1',
|
||||
apiV2: '/api/v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -148,11 +176,12 @@ export class ApiServer {
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
// Initialize indexer
|
||||
await this.indexer.initialize();
|
||||
|
||||
// Start indexing
|
||||
await this.indexer.startAll();
|
||||
if (this.indexer) {
|
||||
await this.indexer.initialize();
|
||||
await this.indexer.startAll();
|
||||
} else {
|
||||
logger.info('Token aggregation indexer disabled by ENABLE_INDEXER flag');
|
||||
}
|
||||
|
||||
// Start server
|
||||
this.app.listen(this.port, () => {
|
||||
@@ -167,7 +196,7 @@ export class ApiServer {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.indexer.stopAll();
|
||||
this.indexer?.stopAll();
|
||||
logger.info('Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Built-in CCIP / Trustless bridge route payload when BRIDGE_LIST_JSON_URL is unset.
|
||||
* Aligns with MetaMask Snap expectations and docs/07-ccip/CCIP_BRIDGE_MAINNET_CONNECTION.md.
|
||||
*/
|
||||
|
||||
import { getActivePublicPools, getActiveTransportPairs, getGruTransportMetadata } from '../../config/gru-transport';
|
||||
|
||||
export interface BridgeRoutesPayload {
|
||||
routes: {
|
||||
weth9: Record<string, string>;
|
||||
weth10: Record<string, string>;
|
||||
trustless?: Record<string, string>;
|
||||
};
|
||||
chain138Bridges: {
|
||||
weth9: string;
|
||||
weth10: string;
|
||||
trustless?: string;
|
||||
};
|
||||
tokenMappingApi: {
|
||||
basePath: string;
|
||||
pairs: string;
|
||||
resolve: string;
|
||||
note: string;
|
||||
};
|
||||
gruTransport?: {
|
||||
system: unknown;
|
||||
summary?: Record<string, number>;
|
||||
activeTransportPairs: unknown[];
|
||||
activePublicPools: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WETH9_138 = '0xcacfd227A040002e49e2e01626363071324f820a';
|
||||
const DEFAULT_WETH10_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0';
|
||||
const DEFAULT_LOCKBOX_138 = '0xFce6f50B312B3D936Ea9693C5C9531CF92a3324c';
|
||||
|
||||
/** Destination-side WETH9 receivers (relay-backed where noted in CCIP docs). */
|
||||
const WETH9_DESTINATIONS: Record<string, string> = {
|
||||
'Ethereum Mainnet (1)': '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939',
|
||||
'BNB Chain (56)': '0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C',
|
||||
'Avalanche C-Chain (43114)': '0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F',
|
||||
};
|
||||
|
||||
function envAddr(key: string, fallback: string): string {
|
||||
const v = process.env[key];
|
||||
return typeof v === 'string' && v.startsWith('0x') ? v : fallback;
|
||||
}
|
||||
|
||||
export function buildDefaultBridgeRoutes(): BridgeRoutesPayload {
|
||||
const inboxEth = process.env.INBOX_ETH?.trim();
|
||||
const trustlessRoutes: Record<string, string> = {};
|
||||
if (inboxEth?.startsWith('0x')) {
|
||||
trustlessRoutes['Ethereum Mainnet (1)'] = inboxEth;
|
||||
}
|
||||
|
||||
const gruTransportMetadata = getGruTransportMetadata();
|
||||
|
||||
return {
|
||||
routes: {
|
||||
weth9: { ...WETH9_DESTINATIONS },
|
||||
weth10: { ...WETH9_DESTINATIONS },
|
||||
...(Object.keys(trustlessRoutes).length > 0 ? { trustless: trustlessRoutes } : {}),
|
||||
},
|
||||
chain138Bridges: {
|
||||
weth9: envAddr('CCIPWETH9_BRIDGE_CHAIN138', DEFAULT_WETH9_138),
|
||||
weth10: envAddr('CCIPWETH10_BRIDGE_CHAIN138', DEFAULT_WETH10_138),
|
||||
trustless: envAddr('LOCKBOX_138', DEFAULT_LOCKBOX_138),
|
||||
},
|
||||
tokenMappingApi: {
|
||||
basePath: '/api/v1/token-mapping',
|
||||
pairs: '/api/v1/token-mapping/pairs',
|
||||
resolve: '/api/v1/token-mapping/resolve',
|
||||
note: 'Resolve bridged token addresses between chains; requires monorepo config/token-mapping-multichain.json on server.',
|
||||
},
|
||||
gruTransport: gruTransportMetadata
|
||||
? {
|
||||
system: gruTransportMetadata.system,
|
||||
summary: gruTransportMetadata.counts,
|
||||
activeTransportPairs: getActiveTransportPairs(),
|
||||
activePublicPools: getActivePublicPools(),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
258
services/token-aggregation/src/config/canonical-tokens.test.ts
Normal file
258
services/token-aggregation/src/config/canonical-tokens.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
getCanonicalTokenByAddress,
|
||||
getCanonicalTokenBySymbol,
|
||||
getTokenRegistryFamily,
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from './canonical-tokens';
|
||||
|
||||
describe('canonical cW token catalog', () => {
|
||||
it('models cWUSDT, cWUSDC, cWAUSDT, and cWUSDW as first-class wrapped GRU transport assets', () => {
|
||||
const cwUsdt = getCanonicalTokenBySymbol(56, 'cWUSDT');
|
||||
expect(cwUsdt).toMatchObject({
|
||||
symbol: 'cWUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwUsdt?.addresses[56]).toBe('0x9a1D0dBEE997929ED02fD19E0E199704d20914dB');
|
||||
expect(getCanonicalTokenByAddress(56, '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB')?.symbol).toBe('cWUSDT');
|
||||
|
||||
const cwUsdc = getCanonicalTokenBySymbol(8453, 'cWUSDC');
|
||||
expect(cwUsdc).toMatchObject({
|
||||
symbol: 'cWUSDC',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwUsdc?.addresses[8453]).toBe('0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105');
|
||||
expect(getCanonicalTokenByAddress(8453, '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105')?.symbol).toBe('cWUSDC');
|
||||
|
||||
const cwAusdt = getCanonicalTokenBySymbol(56, 'cWAUSDT');
|
||||
expect(cwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwAusdt?.addresses[56]).toBe('0xe1a51Bc037a79AB36767561B147eb41780124934');
|
||||
expect(getCanonicalTokenByAddress(56, '0xe1a51Bc037a79AB36767561B147eb41780124934')?.symbol).toBe('cWAUSDT');
|
||||
|
||||
const cwUsdw = getCanonicalTokenBySymbol(56, 'cWUSDW');
|
||||
expect(cwUsdw).toMatchObject({
|
||||
symbol: 'cWUSDW',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(cwUsdw?.addresses[56]).toBe('0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55');
|
||||
expect(getCanonicalTokenByAddress(56, '0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55')?.symbol).toBe('cWUSDW');
|
||||
});
|
||||
|
||||
it('surfaces cUSDW on Chain 138 as the repo-native USDW hub asset', () => {
|
||||
const cusdw = getCanonicalTokenBySymbol(138, 'cUSDW');
|
||||
expect(cusdw).toMatchObject({
|
||||
symbol: 'cUSDW',
|
||||
type: 'base',
|
||||
currencyCode: 'USD',
|
||||
decimals: 6,
|
||||
});
|
||||
expect(cusdw?.addresses[138]).toBe('0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e');
|
||||
});
|
||||
|
||||
it('models cBTC and cWBTC as GRU monetary-unit assets with satoshi precision', () => {
|
||||
const cbtc = getCanonicalTokenBySymbol(138, 'cBTC');
|
||||
expect(cbtc).toMatchObject({
|
||||
symbol: 'cBTC',
|
||||
type: 'base',
|
||||
currencyCode: 'BTC',
|
||||
decimals: 8,
|
||||
});
|
||||
expect(cbtc?.addresses[138]).toBe('0xcb7c000000000000000000000000000000000138');
|
||||
expect(getTokenRegistryFamily(cbtc!)).toBe('monetary_unit');
|
||||
|
||||
const cwbtc = getCanonicalTokenBySymbol(1, 'cWBTC');
|
||||
expect(cwbtc).toMatchObject({
|
||||
symbol: 'cWBTC',
|
||||
type: 'w',
|
||||
currencyCode: 'BTC',
|
||||
decimals: 8,
|
||||
});
|
||||
expect(cwbtc?.addresses[1]).toBe('0xcb7c000000000000000000000000000000000001');
|
||||
expect(getCanonicalTokenByAddress(1, '0xcb7c000000000000000000000000000000000001')?.symbol).toBe('cWBTC');
|
||||
expect(getTokenRegistryFamily(cwbtc!)).toBe('monetary_unit');
|
||||
});
|
||||
|
||||
it('models gas-native families on Chain 138 and their public cW mirrors with gas-native metadata', () => {
|
||||
const ceth = getCanonicalTokenBySymbol(138, 'cETH');
|
||||
expect(ceth).toMatchObject({
|
||||
symbol: 'cETH',
|
||||
type: 'base',
|
||||
currencyCode: 'ETH',
|
||||
decimals: 18,
|
||||
});
|
||||
expect(ceth?.addresses[138]).toBe('0xce7e00000000000000000000000000000000008a');
|
||||
expect(getTokenRegistryFamily(ceth!)).toBe('gas_native');
|
||||
|
||||
const cethL2 = getCanonicalTokenBySymbol(138, 'cETHL2');
|
||||
expect(cethL2).toMatchObject({
|
||||
symbol: 'cETHL2',
|
||||
type: 'base',
|
||||
currencyCode: 'ETH',
|
||||
decimals: 18,
|
||||
});
|
||||
expect(cethL2?.addresses[138]).toBe('0xce7200000000000000000000000000000000008a');
|
||||
|
||||
const cweth = getCanonicalTokenBySymbol(1, 'cWETH');
|
||||
expect(cweth).toMatchObject({
|
||||
symbol: 'cWETH',
|
||||
type: 'w',
|
||||
currencyCode: 'ETH',
|
||||
});
|
||||
expect(cweth?.addresses[1]).toBe('0xce7e000000000000000000000000000000000001');
|
||||
|
||||
const cwethL2 = getCanonicalTokenBySymbol(10, 'cWETHL2');
|
||||
expect(cwethL2).toMatchObject({
|
||||
symbol: 'cWETHL2',
|
||||
type: 'w',
|
||||
currencyCode: 'ETH',
|
||||
});
|
||||
expect(cwethL2?.addresses[10]).toBe('0xce7200000000000000000000000000000000000a');
|
||||
expect(getCanonicalTokenByAddress(10, '0xce7200000000000000000000000000000000000a')?.symbol).toBe('cWETHL2');
|
||||
expect(getTokenRegistryFamily(cwethL2!)).toBe('gas_native');
|
||||
});
|
||||
|
||||
it('surfaces cAUSDT on Chain 138 from env and keeps cWAUSDT fallback mirrors on active public chains', () => {
|
||||
const previousBase = process.env.CAUSDT_ADDRESS_138;
|
||||
process.env.CAUSDT_ADDRESS_138 = '0x2222222222222222222222222222222222222222';
|
||||
jest.resetModules();
|
||||
|
||||
const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens');
|
||||
const causdt = reloaded.getCanonicalTokenBySymbol(138, 'cAUSDT');
|
||||
const polygonCwAusdt = reloaded.getCanonicalTokenBySymbol(137, 'cWAUSDT');
|
||||
const avalancheCwAusdt = reloaded.getCanonicalTokenBySymbol(43114, 'cWAUSDT');
|
||||
const celoCwAusdt = reloaded.getCanonicalTokenBySymbol(42220, 'cWAUSDT');
|
||||
|
||||
expect(causdt).toMatchObject({
|
||||
symbol: 'cAUSDT',
|
||||
type: 'base',
|
||||
currencyCode: 'USD',
|
||||
decimals: 6,
|
||||
});
|
||||
expect(causdt?.addresses[138]).toBe('0x2222222222222222222222222222222222222222');
|
||||
expect(reloaded.getCanonicalTokenByAddress(138, '0x2222222222222222222222222222222222222222')?.symbol).toBe('cAUSDT');
|
||||
|
||||
expect(polygonCwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(polygonCwAusdt?.addresses[137]).toBe('0xf12e262F85107df26741726b074606CaFa24AAe7');
|
||||
|
||||
expect(avalancheCwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(avalancheCwAusdt?.addresses[43114]).toBe('0xff3084410A732231472Ee9f93F5855dA89CC5254');
|
||||
|
||||
expect(celoCwAusdt).toMatchObject({
|
||||
symbol: 'cWAUSDT',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(celoCwAusdt?.addresses[42220]).toBe('0xC158b6cD3A3088C52F797D41f5Aa02825361629e');
|
||||
|
||||
if (previousBase === undefined) {
|
||||
delete process.env.CAUSDT_ADDRESS_138;
|
||||
} else {
|
||||
process.env.CAUSDT_ADDRESS_138 = previousBase;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('picks up Polygon cWUSDW from env as soon as the wrapped mirror is deployed', () => {
|
||||
const previous = process.env.CWUSDW_ADDRESS_137;
|
||||
process.env.CWUSDW_ADDRESS_137 = '0x1111111111111111111111111111111111111111';
|
||||
jest.resetModules();
|
||||
|
||||
const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens');
|
||||
const polygonCwUsdw = reloaded.getCanonicalTokenBySymbol(137, 'cWUSDW');
|
||||
|
||||
expect(polygonCwUsdw).toMatchObject({
|
||||
symbol: 'cWUSDW',
|
||||
type: 'w',
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
expect(polygonCwUsdw?.addresses[137]).toBe('0x1111111111111111111111111111111111111111');
|
||||
expect(reloaded.getCanonicalTokenByAddress(137, '0x1111111111111111111111111111111111111111')?.symbol).toBe('cWUSDW');
|
||||
|
||||
if (previous === undefined) {
|
||||
delete process.env.CWUSDW_ADDRESS_137;
|
||||
} else {
|
||||
process.env.CWUSDW_ADDRESS_137 = previous;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('models the Alltra gold exception as env-gated cAXAUC/cAXAUT and cWAXAUC/cWAXAUT on chain 651940', () => {
|
||||
const previousCaxauc = process.env.CAXAUC_ADDRESS_651940;
|
||||
const previousCwaxauc = process.env.CWAXAUC_ADDRESS_651940;
|
||||
process.env.CAXAUC_ADDRESS_651940 = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CWAXAUC_ADDRESS_651940 = '0x4444444444444444444444444444444444444444';
|
||||
jest.resetModules();
|
||||
|
||||
const reloaded = require('./canonical-tokens') as typeof import('./canonical-tokens');
|
||||
const caxauc = reloaded.getCanonicalTokenBySymbol(651940, 'cAXAUC');
|
||||
const cwaxauc = reloaded.getCanonicalTokenBySymbol(651940, 'cWAXAUC');
|
||||
const cxaucOnAlltra = reloaded.getCanonicalTokenBySymbol(651940, 'cXAUC');
|
||||
|
||||
expect(caxauc).toMatchObject({
|
||||
symbol: 'cAXAUC',
|
||||
type: 'base',
|
||||
currencyCode: 'XAU',
|
||||
});
|
||||
expect(caxauc?.addresses[651940]).toBe('0x3333333333333333333333333333333333333333');
|
||||
expect(reloaded.getCanonicalTokenByAddress(651940, '0x3333333333333333333333333333333333333333')?.symbol).toBe('cAXAUC');
|
||||
|
||||
expect(cwaxauc).toMatchObject({
|
||||
symbol: 'cWAXAUC',
|
||||
type: 'w',
|
||||
currencyCode: 'XAU',
|
||||
});
|
||||
expect(cwaxauc?.addresses[651940]).toBe('0x4444444444444444444444444444444444444444');
|
||||
expect(reloaded.getCanonicalTokenByAddress(651940, '0x4444444444444444444444444444444444444444')?.symbol).toBe('cWAXAUC');
|
||||
|
||||
expect(cxaucOnAlltra).toBeUndefined();
|
||||
|
||||
if (previousCaxauc === undefined) {
|
||||
delete process.env.CAXAUC_ADDRESS_651940;
|
||||
} else {
|
||||
process.env.CAXAUC_ADDRESS_651940 = previousCaxauc;
|
||||
}
|
||||
if (previousCwaxauc === undefined) {
|
||||
delete process.env.CWAXAUC_ADDRESS_651940;
|
||||
} else {
|
||||
process.env.CWAXAUC_ADDRESS_651940 = previousCwaxauc;
|
||||
}
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('surfaces Chain 138 V2 x402 deployments explicitly and resolves quote fallback to V1 liquidity', () => {
|
||||
const cusdtV1 = getCanonicalTokenBySymbol(138, 'cUSDT');
|
||||
const cusdtV2 = getCanonicalTokenBySymbol(138, 'cUSDT_V2');
|
||||
const v2Addr = cusdtV2?.addresses[138];
|
||||
expect(cusdtV2).toMatchObject({
|
||||
symbol: 'cUSDT_V2',
|
||||
familySymbol: 'cUSDT',
|
||||
deploymentVersion: 'v2',
|
||||
preferredForX402: true,
|
||||
liquiditySourceSymbol: 'cUSDT',
|
||||
});
|
||||
expect(v2Addr && String(v2Addr).trim()).toBeTruthy();
|
||||
expect(getCanonicalTokenByAddress(138, String(v2Addr))?.symbol).toBe('cUSDT_V2');
|
||||
|
||||
const quoteResolution = resolveCanonicalQuoteAddress(138, String(v2Addr));
|
||||
expect(quoteResolution).toMatchObject({
|
||||
requestedSymbol: 'cUSDT_V2',
|
||||
lookupSymbol: 'cUSDT',
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(quoteResolution.lookupAddress).toBe(String(cusdtV1?.addresses[138] || '').toLowerCase());
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,20 @@
|
||||
* Addresses can be overridden via env (e.g. CUSDC_ADDRESS_138) or filled by indexer.
|
||||
*/
|
||||
|
||||
import { isISO4217Supported } from './iso4217-symbol-registry';
|
||||
import { isMonetaryUnitSupported } from './monetary-unit-symbol-registry';
|
||||
import { loadTokenMappingLoader } from './repo-config-loader';
|
||||
|
||||
export type TokenType = 'base' | 'w' | 'asset' | 'debt';
|
||||
export type TokenRegistryFamily = 'iso4217' | 'commodity' | 'monetary_unit' | 'gas_native' | 'unclassified';
|
||||
|
||||
export interface CanonicalTokenSpec {
|
||||
symbol: string;
|
||||
name: string;
|
||||
type: TokenType;
|
||||
decimals: number;
|
||||
currencyCode?: string; // ISO-4217 for base/w
|
||||
currencyCode?: string;
|
||||
registryFamily?: TokenRegistryFamily;
|
||||
/** ChainId -> contract address (placeholder or from env) */
|
||||
addresses: Partial<Record<number, string>>;
|
||||
description?: string;
|
||||
@@ -21,14 +27,60 @@ export interface CanonicalTokenSpec {
|
||||
v1Symbol?: string;
|
||||
/** v0 symbol alias; on ChainID 138 tokens use v0 only (cUSDC, cUSDT), no chain designator */
|
||||
v0Alias?: string;
|
||||
/** Shared family symbol when multiple on-chain deployments exist for the same monetary unit. */
|
||||
familySymbol?: string;
|
||||
/** Deployment version when a family has multiple contract surfaces. */
|
||||
deploymentVersion?: string;
|
||||
/** Deployment lifecycle status (for example active or staged). */
|
||||
deploymentStatus?: string;
|
||||
/** Whether this deployment is the preferred x402 / permit-capable surface. */
|
||||
preferredForX402?: boolean;
|
||||
/** Symbol whose current liquidity should be used for quote fallback until cutover. */
|
||||
liquiditySourceSymbol?: string;
|
||||
}
|
||||
|
||||
interface GruTransportDeployment {
|
||||
version?: string;
|
||||
address?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface GruTransportCanonicalToken {
|
||||
symbol?: string;
|
||||
activeVersion?: string;
|
||||
activeAddress?: string;
|
||||
x402PreferredVersion?: string;
|
||||
x402PreferredAddress?: string;
|
||||
deployments?: GruTransportDeployment[];
|
||||
}
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_25 = 25; // Cronos
|
||||
const CHAIN_651940 = 651940;
|
||||
const LEGACY_CHAIN_ENV_SUFFIX: Partial<Record<number, string>> = {
|
||||
1: 'MAINNET',
|
||||
10: 'OPTIMISM',
|
||||
25: 'CRONOS',
|
||||
56: 'BSC',
|
||||
100: 'GNOSIS',
|
||||
137: 'POLYGON',
|
||||
42161: 'ARBITRUM',
|
||||
43114: 'AVALANCHE',
|
||||
8453: 'BASE',
|
||||
};
|
||||
/** L2/mainnet chain IDs for cUSDT/cUSDC multichain (env: CUSDT_ADDRESS_56, CUSDC_ADDRESS_137, etc.) */
|
||||
const L2_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 25, 100, 42220, 1111] as const;
|
||||
|
||||
const GRU_CW_CHAIN_IDS = [1, 56, 137, 10, 42161, 8453, 43114, 100, 42220] as const;
|
||||
const BTC_CW_CHAIN_IDS = [1, 10, 25, 56, 100, 137, 42161, 42220, 43114, 8453, 1111] as const;
|
||||
const ETH_MAINNET_CW_CHAIN_IDS = [1] as const;
|
||||
const ETH_L2_CW_CHAIN_IDS = [10, 42161, 8453] as const;
|
||||
const BNB_CW_CHAIN_IDS = [56] as const;
|
||||
const POL_CW_CHAIN_IDS = [137] as const;
|
||||
const AVAX_CW_CHAIN_IDS = [43114] as const;
|
||||
const CRO_CW_CHAIN_IDS = [25] as const;
|
||||
const XDAI_CW_CHAIN_IDS = [100] as const;
|
||||
const CELO_CW_CHAIN_IDS = [42220] as const;
|
||||
const WEMIX_CW_CHAIN_IDS = [1111] as const;
|
||||
/** Verified addresses from CHAIN138_TOKEN_ADDRESSES, .env, and deployment summaries */
|
||||
const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
USDC: {
|
||||
@@ -42,7 +94,7 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
[CHAIN_651940]: '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', // AUSDC on ALL Mainnet
|
||||
[1]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum USDC
|
||||
[56]: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // BSC USDC
|
||||
[137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c1369', // Polygon USDC
|
||||
[137]: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon USDC
|
||||
[100]: '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', // Gnosis USDC
|
||||
[10]: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism USDC
|
||||
[42161]: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // Arbitrum USDC
|
||||
@@ -67,6 +119,119 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
[42220]: '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e', // Celo USDT
|
||||
[1111]: '0xA649325Aa7C5093d12D6F98EB4378deAe68CE23F', // Wemix USDT
|
||||
},
|
||||
cUSDC_V2: {
|
||||
[CHAIN_138]: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
|
||||
},
|
||||
cUSDT_V2: {
|
||||
[CHAIN_138]: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
|
||||
},
|
||||
cUSDW: {
|
||||
[CHAIN_138]: '0xcA6BFa614935f1AB71c9aB106bAA6FBB6057095e',
|
||||
},
|
||||
cBTC: {
|
||||
[CHAIN_138]: '0xcb7c000000000000000000000000000000000138',
|
||||
},
|
||||
cETH: {
|
||||
[CHAIN_138]: '0xce7e00000000000000000000000000000000008a',
|
||||
},
|
||||
cETHL2: {
|
||||
[CHAIN_138]: '0xce7200000000000000000000000000000000008a',
|
||||
},
|
||||
cBNB: {
|
||||
[CHAIN_138]: '0xcb6b00000000000000000000000000000000008a',
|
||||
},
|
||||
cPOL: {
|
||||
[CHAIN_138]: '0xc90100000000000000000000000000000000008a',
|
||||
},
|
||||
cAVAX: {
|
||||
[CHAIN_138]: '0xcaaa00000000000000000000000000000000008a',
|
||||
},
|
||||
cCRO: {
|
||||
[CHAIN_138]: '0xcc2000000000000000000000000000000000008a',
|
||||
},
|
||||
cXDAI: {
|
||||
[CHAIN_138]: '0xcda100000000000000000000000000000000008a',
|
||||
},
|
||||
cCELO: {
|
||||
[CHAIN_138]: '0xcce100000000000000000000000000000000008a',
|
||||
},
|
||||
cWEMIX: {
|
||||
[CHAIN_138]: '0xc11100000000000000000000000000000000008a',
|
||||
},
|
||||
cWAUSDT: {
|
||||
[56]: '0xe1a51Bc037a79AB36767561B147eb41780124934',
|
||||
[137]: '0xf12e262F85107df26741726b074606CaFa24AAe7',
|
||||
[43114]: '0xff3084410A732231472Ee9f93F5855dA89CC5254',
|
||||
[42220]: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e',
|
||||
},
|
||||
cWUSDC: {
|
||||
[1]: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||||
[56]: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
|
||||
[137]: '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4',
|
||||
[100]: '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4',
|
||||
[10]: '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105',
|
||||
[42161]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF',
|
||||
[8453]: '0x377a5FaA3162b3Fc6f4e267301A3c817bAd18105',
|
||||
[43114]: '0x0C242b513008Cd49C89078F5aFb237A3112251EB',
|
||||
[42220]: '0x4C38F9A5ed68A04cd28a72E8c68C459Ec34576f3',
|
||||
},
|
||||
cWUSDT: {
|
||||
[1]: '0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE',
|
||||
[56]: '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB',
|
||||
[137]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF',
|
||||
[100]: '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF',
|
||||
[10]: '0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6',
|
||||
[42161]: '0x73ADaF7dBa95221c080db5631466d2bC54f6a76B',
|
||||
[8453]: '0x04B2AE3c3bb3d70Df506FAd8717b0FBFC78ED7E6',
|
||||
[43114]: '0x8142BA530B08f3950128601F00DaaA678213DFdf',
|
||||
[42220]: '0x73376eB92c16977B126dB9112936A20Fa0De3442',
|
||||
},
|
||||
cWUSDW: {
|
||||
[56]: '0xC2FA05F12a75Ac84ea778AF9D6935cA807275E55',
|
||||
[43114]: '0xcfdCe5E660FC2C8052BDfa7aEa1865DD753411Ae',
|
||||
},
|
||||
cWBTC: {
|
||||
[1]: '0xcb7c000000000000000000000000000000000001',
|
||||
[10]: '0xcb7c00000000000000000000000000000000000a',
|
||||
[25]: '0xcb7c000000000000000000000000000000000019',
|
||||
[56]: '0xcb7c000000000000000000000000000000000038',
|
||||
[100]: '0xcb7c000000000000000000000000000000000064',
|
||||
[137]: '0xcb7c000000000000000000000000000000000089',
|
||||
[1111]: '0xcb7c000000000000000000000000000000000457',
|
||||
[8453]: '0xcb7c000000000000000000000000000000002105',
|
||||
[42161]: '0xcb7c00000000000000000000000000000000a4b1',
|
||||
[42220]: '0xcb7c00000000000000000000000000000000a4ec',
|
||||
[43114]: '0xcb7c00000000000000000000000000000000a86a',
|
||||
},
|
||||
cWETH: {
|
||||
[1]: '0xce7e000000000000000000000000000000000001',
|
||||
},
|
||||
cWETHL2: {
|
||||
[10]: '0xce7200000000000000000000000000000000000a',
|
||||
[42161]: '0xce7200000000000000000000000000000000a4b1',
|
||||
[8453]: '0xce72000000000000000000000000000000002105',
|
||||
},
|
||||
cWBNB: {
|
||||
[56]: '0xcb6b000000000000000000000000000000000038',
|
||||
},
|
||||
cWPOL: {
|
||||
[137]: '0xc901000000000000000000000000000000000089',
|
||||
},
|
||||
cWAVAX: {
|
||||
[43114]: '0xcaaa00000000000000000000000000000000a86a',
|
||||
},
|
||||
cWCRO: {
|
||||
[25]: '0xcc20000000000000000000000000000000000019',
|
||||
},
|
||||
cWXDAI: {
|
||||
[100]: '0xcda1000000000000000000000000000000000064',
|
||||
},
|
||||
cWCELO: {
|
||||
[42220]: '0xcce100000000000000000000000000000000a4ec',
|
||||
},
|
||||
cWWEMIX: {
|
||||
[1111]: '0xc111000000000000000000000000000000000457',
|
||||
},
|
||||
// Compliant Fiat on Chain 138 — from DeployCompliantFiatTokens (2026-02-27)
|
||||
cEURC: { [CHAIN_138]: '0x8085961F9cF02b4d800A3c6d386D31da4B34266a' },
|
||||
cEURT: { [CHAIN_138]: '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72' },
|
||||
@@ -88,6 +253,50 @@ const FALLBACK_ADDRESSES: Record<string, Partial<Record<number, string>>> = {
|
||||
CADW: { [CHAIN_25]: '0x328Cd365Bb35524297E68ED28c6fF2C9557d1363' },
|
||||
};
|
||||
|
||||
function normalizeAddress(address?: string | null): string {
|
||||
return typeof address === 'string' ? address.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getGruTransportCanonicalToken(symbol: string): GruTransportCanonicalToken | null {
|
||||
const loader = loadTokenMappingLoader<{
|
||||
getEnabledCanonicalToken?: (identifier: string) => GruTransportCanonicalToken | null;
|
||||
}>();
|
||||
return loader?.getEnabledCanonicalToken?.(symbol) ?? null;
|
||||
}
|
||||
|
||||
function getTransportLookup(symbol: string): { baseSymbol: string; version: 'v1' | 'v2' } | null {
|
||||
if (symbol === 'cUSDT' || symbol === 'cUSDC') {
|
||||
return { baseSymbol: symbol, version: 'v1' };
|
||||
}
|
||||
if (symbol === 'cUSDT_V2') {
|
||||
return { baseSymbol: 'cUSDT', version: 'v2' };
|
||||
}
|
||||
if (symbol === 'cUSDC_V2') {
|
||||
return { baseSymbol: 'cUSDC', version: 'v2' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTransportDeploymentAddress(symbol: string, version: 'v1' | 'v2'): string | undefined {
|
||||
const token = getGruTransportCanonicalToken(symbol);
|
||||
if (!token) return undefined;
|
||||
|
||||
const deploymentMatch = Array.isArray(token.deployments)
|
||||
? token.deployments.find((deployment) => String(deployment.version || '').trim().toLowerCase() === version)
|
||||
: null;
|
||||
if (deploymentMatch?.address) return deploymentMatch.address;
|
||||
|
||||
if (version === 'v1') {
|
||||
if (String(token.activeVersion || '').trim().toLowerCase() === 'v1' && token.activeAddress) {
|
||||
return token.activeAddress;
|
||||
}
|
||||
} else if (String(token.x402PreferredVersion || '').trim().toLowerCase() === 'v2' && token.x402PreferredAddress) {
|
||||
return token.x402PreferredAddress;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function addr(symbol: string, chainId: number): string | undefined {
|
||||
if (chainId === CHAIN_138 && symbol === 'USDT') {
|
||||
return process.env.USDT_ADDRESS_138 || process.env.OFFICIAL_USDT_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
@@ -95,9 +304,27 @@ function addr(symbol: string, chainId: number): string | undefined {
|
||||
if (chainId === CHAIN_138 && symbol === 'USDC') {
|
||||
return process.env.USDC_ADDRESS_138 || process.env.OFFICIAL_USDC_ADDRESS || FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
}
|
||||
if (chainId === CHAIN_138) {
|
||||
const transportLookup = getTransportLookup(symbol);
|
||||
if (transportLookup) {
|
||||
const envKey = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
|
||||
const envVal = process.env[envKey];
|
||||
if (envVal && envVal.trim() !== '') return envVal;
|
||||
return (
|
||||
getTransportDeploymentAddress(transportLookup.baseSymbol, transportLookup.version) ||
|
||||
FALLBACK_ADDRESSES[symbol]?.[chainId]
|
||||
);
|
||||
}
|
||||
}
|
||||
const key = `${symbol.replace(/-/g, '_').toUpperCase()}_ADDRESS_${chainId}`;
|
||||
const envVal = process.env[key];
|
||||
if (envVal && envVal.trim() !== '') return envVal;
|
||||
const legacySuffix = LEGACY_CHAIN_ENV_SUFFIX[chainId];
|
||||
if (legacySuffix) {
|
||||
const legacyKey = `${symbol.replace(/-/g, '_').toUpperCase()}_${legacySuffix}`;
|
||||
const legacyVal = process.env[legacyKey];
|
||||
if (legacyVal && legacyVal.trim() !== '') return legacyVal;
|
||||
}
|
||||
return FALLBACK_ADDRESSES[symbol]?.[chainId];
|
||||
}
|
||||
|
||||
@@ -108,8 +335,217 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
{ symbol: 'USDT', name: 'Tether USD (Official Mirror)', type: 'base', decimals: 6, currencyCode: 'USD', addresses: { [CHAIN_138]: addr('USDT', CHAIN_138) || '' } },
|
||||
// Chain 138 v0 only (no X): cUSDC on 138; cXUSDC used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
{ symbol: 'cUSDC', name: 'USD Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDC', addresses: { [CHAIN_138]: addr('cUSDC', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDC', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDC', id)])) } },
|
||||
{ symbol: 'cUSDC_V2', name: 'USD Coin (Compliant V2)', type: 'base', decimals: 6, currencyCode: 'USD', familySymbol: 'cUSDC', deploymentVersion: 'v2', deploymentStatus: 'staged', preferredForX402: true, liquiditySourceSymbol: 'cUSDC', description: 'Chain 138 x402 / permit-capable V2 deployment. Liquidity and PMM routing remain on cUSDC until cutover.', addresses: { [CHAIN_138]: addr('cUSDC_V2', CHAIN_138) || '' } },
|
||||
// Chain 138 v0 only (no X): cUSDT on 138; cXUSDT used only for bridged/origin reference elsewhere. See ISO4217_COMPLIANT_TOKEN_MATRIX.md
|
||||
{ symbol: 'cUSDT', name: 'Tether USD (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', v0Alias: 'cUSDT', addresses: { [CHAIN_138]: addr('cUSDT', CHAIN_138) || '', [CHAIN_651940]: addr('cUSDT', CHAIN_651940) || '', ...Object.fromEntries(L2_CHAIN_IDS.map((id) => [id, addr('cUSDT', id)])) } },
|
||||
{ symbol: 'cUSDT_V2', name: 'Tether USD (Compliant V2)', type: 'base', decimals: 6, currencyCode: 'USD', familySymbol: 'cUSDT', deploymentVersion: 'v2', deploymentStatus: 'staged', preferredForX402: true, liquiditySourceSymbol: 'cUSDT', description: 'Chain 138 x402 / permit-capable V2 deployment. Liquidity and PMM routing remain on cUSDT until cutover.', addresses: { [CHAIN_138]: addr('cUSDT_V2', CHAIN_138) || '' } },
|
||||
{ symbol: 'cAUSDT', name: 'Alltra USD Token (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', description: 'Live Chain 138 compliant landing asset for the ALL Mainnet AUSDT corridor.', addresses: { [CHAIN_138]: addr('cAUSDT', CHAIN_138) || '' } },
|
||||
{ symbol: 'cUSDW', name: 'USD W (Compliant)', type: 'base', decimals: 6, currencyCode: 'USD', description: 'Chain 138 repo-native cUSDW hub asset for D-WIN-aligned PMM and cWUSDW transport planning.', addresses: { [CHAIN_138]: addr('cUSDW', CHAIN_138) || '' } },
|
||||
{
|
||||
symbol: 'cBTC',
|
||||
name: 'Bitcoin (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 8,
|
||||
currencyCode: 'BTC',
|
||||
registryFamily: 'monetary_unit',
|
||||
description: 'Canonical Chain 138 compliant Bitcoin monetary unit with satoshi-precision accounting and custody-backed mint controls.',
|
||||
addresses: { [CHAIN_138]: addr('cBTC', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cETH',
|
||||
name: 'Ether Mainnet (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Ethereum mainnet gas inventory. This family remains isolated from the shared ETH L2 family.',
|
||||
addresses: { [CHAIN_138]: addr('cETH', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cETHL2',
|
||||
name: 'Ether L2 Basket (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of the shared ETH L2 family across Optimism, Arbitrum, and Base.',
|
||||
addresses: { [CHAIN_138]: addr('cETHL2', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cBNB',
|
||||
name: 'BNB (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'BNB',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of BNB gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cBNB', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cPOL',
|
||||
name: 'POL (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'POL',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Polygon gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cPOL', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cAVAX',
|
||||
name: 'Avalanche (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'AVAX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Avalanche gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cAVAX', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cCRO',
|
||||
name: 'Cronos (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'CRO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Cronos gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cCRO', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cXDAI',
|
||||
name: 'xDAI (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'XDAI',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Gnosis Chain xDAI gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cXDAI', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cCELO',
|
||||
name: 'Celo (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'CELO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Celo gas inventory.',
|
||||
addresses: { [CHAIN_138]: addr('cCELO', CHAIN_138) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWEMIX',
|
||||
name: 'Wemix Hub (Compliant)',
|
||||
type: 'base',
|
||||
decimals: 18,
|
||||
currencyCode: 'WEMIX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Canonical Chain 138 representation of Wemix gas inventory. The public mirror keeps the distinct cWWEMIX symbol to avoid naming collisions.',
|
||||
addresses: { [CHAIN_138]: addr('cWEMIX', CHAIN_138) || '' },
|
||||
},
|
||||
// Public-network transport mirrors for canonical Chain 138 c* assets.
|
||||
{ symbol: 'cWAUSDT', name: 'Alltra USD Token (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form for the live Chain 138 cAUSDT surface.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWAUSDT', id)])) } },
|
||||
{ symbol: 'cWUSDC', name: 'USD Coin (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDC.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDC', id)])) } },
|
||||
{ symbol: 'cWUSDT', name: 'Tether USD (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDT.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDT', id)])) } },
|
||||
{ symbol: 'cWUSDW', name: 'USD W (Compliant Wrapped ISO-4217 M1)', type: 'w', decimals: 6, currencyCode: 'USD', description: 'Public-network mirrored transport form of canonical Chain 138 cUSDW.', addresses: { ...Object.fromEntries(GRU_CW_CHAIN_IDS.map((id) => [id, addr('cWUSDW', id)])) } },
|
||||
{
|
||||
symbol: 'cWBTC',
|
||||
name: 'Bitcoin (Compliant Wrapped Monetary Unit)',
|
||||
type: 'w',
|
||||
decimals: 8,
|
||||
currencyCode: 'BTC',
|
||||
registryFamily: 'monetary_unit',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cBTC. Distinct from Ethereum WBTC and other third-party wrapped BTC products.',
|
||||
addresses: { ...Object.fromEntries(BTC_CW_CHAIN_IDS.map((id) => [id, addr('cWBTC', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWETH',
|
||||
name: 'Ether Mainnet (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cETH for Ethereum mainnet only.',
|
||||
addresses: { ...Object.fromEntries(ETH_MAINNET_CW_CHAIN_IDS.map((id) => [id, addr('cWETH', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWETHL2',
|
||||
name: 'Ether L2 Basket (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'ETH',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cETHL2 across the approved ETH L2 family.',
|
||||
addresses: { ...Object.fromEntries(ETH_L2_CW_CHAIN_IDS.map((id) => [id, addr('cWETHL2', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWBNB',
|
||||
name: 'BNB (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'BNB',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cBNB.',
|
||||
addresses: { ...Object.fromEntries(BNB_CW_CHAIN_IDS.map((id) => [id, addr('cWBNB', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWPOL',
|
||||
name: 'POL (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'POL',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cPOL.',
|
||||
addresses: { ...Object.fromEntries(POL_CW_CHAIN_IDS.map((id) => [id, addr('cWPOL', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWAVAX',
|
||||
name: 'Avalanche (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'AVAX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cAVAX.',
|
||||
addresses: { ...Object.fromEntries(AVAX_CW_CHAIN_IDS.map((id) => [id, addr('cWAVAX', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWCRO',
|
||||
name: 'Cronos (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'CRO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cCRO.',
|
||||
addresses: { ...Object.fromEntries(CRO_CW_CHAIN_IDS.map((id) => [id, addr('cWCRO', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWXDAI',
|
||||
name: 'xDAI (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'XDAI',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cXDAI.',
|
||||
addresses: { ...Object.fromEntries(XDAI_CW_CHAIN_IDS.map((id) => [id, addr('cWXDAI', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWCELO',
|
||||
name: 'Celo (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'CELO',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cCELO.',
|
||||
addresses: { ...Object.fromEntries(CELO_CW_CHAIN_IDS.map((id) => [id, addr('cWCELO', id)])) },
|
||||
},
|
||||
{
|
||||
symbol: 'cWWEMIX',
|
||||
name: 'Wemix (Compliant Wrapped)',
|
||||
type: 'w',
|
||||
decimals: 18,
|
||||
currencyCode: 'WEMIX',
|
||||
registryFamily: 'gas_native',
|
||||
description: 'Public-network mirrored transport form of canonical Chain 138 cWEMIX. The doubled W preserves the cW* naming discipline and keeps the canonical hub symbol distinct.',
|
||||
addresses: { ...Object.fromEntries(WEMIX_CW_CHAIN_IDS.map((id) => [id, addr('cWWEMIX', id)])) },
|
||||
},
|
||||
{ symbol: 'cEURC', name: 'Euro Coin (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURC', CHAIN_138), [CHAIN_651940]: addr('cEURC', CHAIN_651940) } },
|
||||
{ symbol: 'cEURT', name: 'Tether EUR (Compliant)', type: 'base', decimals: 6, currencyCode: 'EUR', addresses: { [CHAIN_138]: addr('cEURT', CHAIN_138), [CHAIN_651940]: addr('cEURT', CHAIN_651940) } },
|
||||
{ symbol: 'cGBPC', name: 'Pound Sterling (Compliant)', type: 'base', decimals: 6, currencyCode: 'GBP', addresses: { [CHAIN_138]: addr('cGBPC', CHAIN_138), [CHAIN_651940]: addr('cGBPC', CHAIN_651940) } },
|
||||
@@ -125,7 +561,7 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).',
|
||||
addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138), [CHAIN_651940]: addr('cXAUC', CHAIN_651940) },
|
||||
addresses: { [CHAIN_138]: addr('cXAUC', CHAIN_138) },
|
||||
},
|
||||
{
|
||||
symbol: 'cXAUT',
|
||||
@@ -134,7 +570,43 @@ export const CANONICAL_TOKENS: CanonicalTokenSpec[] = [
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: '1 full token = 1 troy ounce fine gold (10^6 base units = 1 oz).',
|
||||
addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138), [CHAIN_651940]: addr('cXAUT', CHAIN_651940) },
|
||||
addresses: { [CHAIN_138]: addr('cXAUT', CHAIN_138) },
|
||||
},
|
||||
{
|
||||
symbol: 'cAXAUC',
|
||||
name: 'Alltra Gold Coin',
|
||||
type: 'base',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet native-unwrapped gold landing asset for the 138 -> 651940 corridor.',
|
||||
addresses: { [CHAIN_651940]: addr('cAXAUC', CHAIN_651940) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cAXAUT',
|
||||
name: 'Alltra Tether XAU',
|
||||
type: 'base',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet native-unwrapped XAU token for the 138 -> 651940 corridor.',
|
||||
addresses: { [CHAIN_651940]: addr('cAXAUT', CHAIN_651940) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWAXAUC',
|
||||
name: 'Alltra Wrapped Gold Coin',
|
||||
type: 'w',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet bridge-minted wrapped gold representation for inbound Chain 138 cXAUC transport.',
|
||||
addresses: { [CHAIN_651940]: addr('cWAXAUC', CHAIN_651940) || '' },
|
||||
},
|
||||
{
|
||||
symbol: 'cWAXAUT',
|
||||
name: 'Alltra Wrapped Tether XAU',
|
||||
type: 'w',
|
||||
decimals: 6,
|
||||
currencyCode: 'XAU',
|
||||
description: 'Planned ALL Mainnet bridge-minted wrapped XAU representation for inbound Chain 138 cXAUT transport.',
|
||||
addresses: { [CHAIN_651940]: addr('cWAXAUT', CHAIN_651940) || '' },
|
||||
},
|
||||
{ symbol: 'LiXAU', name: 'XAU Liquidity-adjusted', type: 'base', decimals: 6, currencyCode: 'XAU', addresses: { [CHAIN_138]: addr('LiXAU', CHAIN_138), [CHAIN_651940]: addr('LiXAU', CHAIN_651940) } },
|
||||
// --- ISO-4217 W ---
|
||||
@@ -183,7 +655,7 @@ export function getCanonicalTokensByChain(chainId: number): CanonicalTokenSpec[]
|
||||
}
|
||||
|
||||
export function getCanonicalTokenByAddress(chainId: number, address: string): CanonicalTokenSpec | undefined {
|
||||
const lower = address.toLowerCase();
|
||||
const lower = normalizeAddress(address);
|
||||
return CANONICAL_TOKENS.find((t) => t.addresses[chainId]?.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
@@ -194,29 +666,78 @@ export function getCanonicalTokenBySymbol(chainId: number, symbol: string): Cano
|
||||
);
|
||||
}
|
||||
|
||||
export interface CanonicalQuoteAddressResolution {
|
||||
requestedAddress: string;
|
||||
requestedSymbol?: string;
|
||||
lookupAddress: string;
|
||||
lookupSymbol?: string;
|
||||
usedFallback: boolean;
|
||||
}
|
||||
|
||||
export function resolveCanonicalQuoteAddress(chainId: number, address: string): CanonicalQuoteAddressResolution {
|
||||
const requestedAddress = normalizeAddress(address);
|
||||
const requestedSpec = getCanonicalTokenByAddress(chainId, requestedAddress);
|
||||
if (!requestedSpec) {
|
||||
return {
|
||||
requestedAddress,
|
||||
lookupAddress: requestedAddress,
|
||||
usedFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
const liquiditySourceSymbol = requestedSpec.liquiditySourceSymbol || requestedSpec.symbol;
|
||||
const liquiditySpec = getCanonicalTokenBySymbol(chainId, liquiditySourceSymbol) || requestedSpec;
|
||||
const lookupAddress = normalizeAddress(liquiditySpec.addresses[chainId] || requestedAddress);
|
||||
|
||||
return {
|
||||
requestedAddress,
|
||||
requestedSymbol: requestedSpec.symbol,
|
||||
lookupAddress,
|
||||
lookupSymbol: liquiditySpec.symbol,
|
||||
usedFallback: lookupAddress !== requestedAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/** IPFS-hosted logo URLs (Pinata) for Uniswap token list (logoURI).
|
||||
* Every token must have logoURI for MetaMask to display icons. getLogoUriForSpec resolves
|
||||
* ac-tokens from base (c*), vdc/sdc from base; unknown symbols fall back to ETH_LOGO. */
|
||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs';
|
||||
const GRU_LOGO_BASE =
|
||||
'https://raw.githubusercontent.com/Order-of-Hospitallers/proxmox-cp/main/token-lists/logos/gru';
|
||||
const ETH_LOGO = `${IPFS_GATEWAY}/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong`;
|
||||
const USDC_LOGO = `${IPFS_GATEWAY}/QmNPq4D5JXzurmi9jAhogVMzhAQRk1PZ1r9H3qQUV9gjDm`;
|
||||
const USDT_LOGO = `${IPFS_GATEWAY}/QmRfhPs9DcyFPpGjKwF6CCoVDWUHSxkQR34n9NK7JSbPCP`;
|
||||
const USDC_LOGO = `${GRU_LOGO_BASE}/cUSDC.svg`;
|
||||
const USDT_LOGO = `${GRU_LOGO_BASE}/cUSDT.svg`;
|
||||
const BTC_LOGO = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png';
|
||||
|
||||
const LOGO_BY_SYMBOL: Record<string, string> = {
|
||||
USDC: USDC_LOGO,
|
||||
USDT: USDT_LOGO,
|
||||
cUSDC: USDC_LOGO,
|
||||
cUSDT: USDT_LOGO,
|
||||
cEURC: USDC_LOGO,
|
||||
cEURT: USDT_LOGO,
|
||||
cGBPC: `${IPFS_GATEWAY}/QmNQF73WjxU6FwTXNH8PXoDRFaSFKTYQWL7d4Q1kdRVJ4o`,
|
||||
cGBPT: `${IPFS_GATEWAY}/QmV4frsJmDTWzLdxdj1z81uMqVXcbGpHZLzwkpj6GvEX4k`,
|
||||
cAUDC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cJPYC: `${IPFS_GATEWAY}/Qmb9JmuD9ehaQtTLBBZmAoiAbvE53e3FMjkEty8rvbPf9K`,
|
||||
cCHFC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cCADC: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
cXAUC: ETH_LOGO,
|
||||
cXAUT: ETH_LOGO,
|
||||
cUSDC_V2: USDC_LOGO,
|
||||
cUSDT_V2: USDT_LOGO,
|
||||
cAUSDT: USDT_LOGO,
|
||||
cUSDW: USDC_LOGO,
|
||||
cBTC: BTC_LOGO,
|
||||
cWAUSDT: USDT_LOGO,
|
||||
cWBTC: BTC_LOGO,
|
||||
cWUSDC: USDC_LOGO,
|
||||
cWUSDT: USDT_LOGO,
|
||||
cWUSDW: USDC_LOGO,
|
||||
cEURC: `${GRU_LOGO_BASE}/cEURC.svg`,
|
||||
cEURT: `${GRU_LOGO_BASE}/cEURT.svg`,
|
||||
cGBPC: `${GRU_LOGO_BASE}/cGBPC.svg`,
|
||||
cGBPT: `${GRU_LOGO_BASE}/cGBPT.svg`,
|
||||
cAUDC: `${GRU_LOGO_BASE}/cAUDC.svg`,
|
||||
cJPYC: `${GRU_LOGO_BASE}/cJPYC.svg`,
|
||||
cCHFC: `${GRU_LOGO_BASE}/cCHFC.svg`,
|
||||
cCADC: `${GRU_LOGO_BASE}/cCADC.svg`,
|
||||
cXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`,
|
||||
cXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`,
|
||||
cAXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`,
|
||||
cAXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`,
|
||||
cWAXAUC: `${GRU_LOGO_BASE}/cXAUC.svg`,
|
||||
cWAXAUT: `${GRU_LOGO_BASE}/cXAUT.svg`,
|
||||
LiXAU: `${IPFS_GATEWAY}/QmUVY5trUM5N1UnS4abReb66fNzGw7kenjU9AjL7TgR3M1`,
|
||||
USDW: USDC_LOGO,
|
||||
EURW: `${IPFS_GATEWAY}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`,
|
||||
@@ -239,3 +760,13 @@ export function getLogoUriForSpec(spec: CanonicalTokenSpec): string {
|
||||
}
|
||||
return ETH_LOGO;
|
||||
}
|
||||
|
||||
export function getTokenRegistryFamily(spec: Pick<CanonicalTokenSpec, 'currencyCode' | 'registryFamily'>): TokenRegistryFamily {
|
||||
if (spec.registryFamily) return spec.registryFamily;
|
||||
const code = String(spec.currencyCode || '').trim().toUpperCase();
|
||||
if (!code) return 'unclassified';
|
||||
if (code === 'XAU') return 'commodity';
|
||||
if (isISO4217Supported(code)) return 'iso4217';
|
||||
if (isMonetaryUnitSupported(code)) return 'monetary_unit';
|
||||
return 'unclassified';
|
||||
}
|
||||
|
||||
13
services/token-aggregation/src/config/chain138-rpc.ts
Normal file
13
services/token-aggregation/src/config/chain138-rpc.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const DEFAULT_CHAIN138_RPC_URL = 'https://rpc-http-pub.d-bis.org';
|
||||
|
||||
export function resolveChain138RpcUrl(): string {
|
||||
return String(
|
||||
process.env.CHAIN_138_RPC_URL ||
|
||||
process.env.RPC_URL_138 ||
|
||||
process.env.RPC_URL_138_PUBLIC ||
|
||||
process.env.RPC_HTTP_PUB_URL ||
|
||||
DEFAULT_CHAIN138_RPC_URL
|
||||
).trim();
|
||||
}
|
||||
|
||||
export { DEFAULT_CHAIN138_RPC_URL };
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveChain138RpcUrl } from './chain138-rpc';
|
||||
|
||||
export interface ChainConfig {
|
||||
chainId: number;
|
||||
name: string;
|
||||
@@ -16,7 +18,7 @@ export const CHAIN_CONFIGS: Record<number, ChainConfig> = {
|
||||
138: {
|
||||
chainId: 138,
|
||||
name: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrl: process.env.CHAIN_138_RPC_URL || process.env.RPC_URL_138_PUBLIC || process.env.RPC_URL_138 || 'https://rpc-http-pub.d-bis.org',
|
||||
rpcUrl: resolveChain138RpcUrl(),
|
||||
explorerUrl: 'https://explorer.d-bis.org',
|
||||
nativeCurrency: {
|
||||
name: 'Ether',
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Used by cross-chain-indexer for CCIP/Alltra/UniversalCCIP event aggregation.
|
||||
*/
|
||||
|
||||
import { loadTokenMappingLoader } from './repo-config-loader';
|
||||
|
||||
export interface BridgeLane {
|
||||
destSelector: string;
|
||||
destChainId: number;
|
||||
@@ -114,6 +116,45 @@ const CCIP_WETH9_138 = envAddr('CCIPWETH9_BRIDGE_CHAIN138') || '0x971cD9D156f193
|
||||
const CCIP_STABLE_138 = envAnyAddr('CCIP_STABLE_BRIDGE_CHAIN138', 'CCIP_STABLECOIN_BRIDGE_CHAIN138');
|
||||
const UNIVERSAL_CCIP_138 = envAnyAddr('UNIVERSAL_CCIP_BRIDGE_ADDRESS', 'UNIVERSAL_CCIP_BRIDGE');
|
||||
|
||||
interface RepoConfigLoader {
|
||||
getRoutingRegistryRoutes?: () => RoutingRegistryEntry[];
|
||||
getActiveTransportPair?: (
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
criteria?: Record<string, unknown>
|
||||
) => (RoutingRegistryEntry & {
|
||||
canonicalSymbol?: string;
|
||||
peer?: {
|
||||
l1Bridge?: { address?: string; env?: string };
|
||||
l2Bridge?: { address?: string; env?: string };
|
||||
};
|
||||
eligible?: boolean;
|
||||
}) | null;
|
||||
resolveConfigRef?: (ref?: { address?: string; env?: string }) => string;
|
||||
}
|
||||
|
||||
function loadRepoConfigLoader(): RepoConfigLoader | null {
|
||||
return loadTokenMappingLoader<RepoConfigLoader>();
|
||||
}
|
||||
|
||||
function normalizeTransportAsset(asset: string): string {
|
||||
const normalized = asset.trim().toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (normalized.startsWith('cw')) {
|
||||
return `c${normalized.slice(2)}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePeerBridgeAddress(
|
||||
loader: RepoConfigLoader | null,
|
||||
pair: NonNullable<ReturnType<NonNullable<RepoConfigLoader['getActiveTransportPair']>>>,
|
||||
sourceChainId: number
|
||||
): string {
|
||||
const ref = sourceChainId === chainId138 ? pair.peer?.l1Bridge : pair.peer?.l2Bridge;
|
||||
const resolved = loader?.resolveConfigRef?.(ref);
|
||||
return resolved || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routing registry entry for (fromChain, toChain, asset).
|
||||
* Used by UI and indexer to choose ALT vs CCIP and to fill routing in activity_events.
|
||||
@@ -125,6 +166,51 @@ export function getRouteFromRegistry(
|
||||
asset: string = 'WETH',
|
||||
): RoutingRegistryEntry | null {
|
||||
if (fromChain === toChain) return null;
|
||||
const loader = loadRepoConfigLoader();
|
||||
const normalizedAsset = normalizeTransportAsset(asset);
|
||||
|
||||
const activeTransportPair = loader?.getActiveTransportPair?.(fromChain, toChain, { symbol: normalizedAsset });
|
||||
if (activeTransportPair) {
|
||||
if (activeTransportPair.eligible) {
|
||||
const bridgeAddress = resolvePeerBridgeAddress(loader, activeTransportPair, fromChain);
|
||||
if (bridgeAddress) {
|
||||
return {
|
||||
pathType: 'CCIP',
|
||||
bridgeAddress,
|
||||
bridgeChainId: fromChain === chainId138 ? chainId138 : fromChain,
|
||||
label: 'GRUTransport',
|
||||
fromChain,
|
||||
toChain,
|
||||
asset: activeTransportPair.canonicalSymbol || asset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Active GRU transport assets must not silently escape into legacy bridge paths.
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryRoutes = loader?.getRoutingRegistryRoutes?.() || [];
|
||||
const routeMatch =
|
||||
registryRoutes.find(
|
||||
(route) =>
|
||||
route.fromChain === fromChain &&
|
||||
route.toChain === toChain &&
|
||||
typeof route.asset === 'string' &&
|
||||
route.asset.trim().toLowerCase() === asset.trim().toLowerCase()
|
||||
) ||
|
||||
registryRoutes.find(
|
||||
(route) =>
|
||||
route.fromChain === fromChain &&
|
||||
route.toChain === toChain &&
|
||||
typeof route.asset === 'string' &&
|
||||
route.asset.trim().toLowerCase() === normalizedAsset
|
||||
);
|
||||
|
||||
if (routeMatch) {
|
||||
return routeMatch;
|
||||
}
|
||||
|
||||
const is138To651940 = fromChain === 138 && toChain === 651940;
|
||||
const is651940To138 = fromChain === 651940 && toChain === 138;
|
||||
if (is138To651940 || is651940To138) {
|
||||
@@ -139,8 +225,8 @@ export function getRouteFromRegistry(
|
||||
};
|
||||
}
|
||||
if (fromChain === 138 || toChain === 138) {
|
||||
const normalizedAsset = asset.trim().toUpperCase();
|
||||
const isStableAsset = STABLE_ASSET_SYMBOLS.has(normalizedAsset);
|
||||
const legacyNormalizedAsset = asset.trim().toUpperCase();
|
||||
const isStableAsset = STABLE_ASSET_SYMBOLS.has(legacyNormalizedAsset);
|
||||
|
||||
if (isStableAsset) {
|
||||
if (CCIP_STABLE_138) {
|
||||
@@ -170,7 +256,7 @@ export function getRouteFromRegistry(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) {
|
||||
if (legacyNormalizedAsset !== 'WETH' && UNIVERSAL_CCIP_138) {
|
||||
return {
|
||||
pathType: 'CCIP',
|
||||
bridgeAddress: UNIVERSAL_CCIP_138,
|
||||
|
||||
201
services/token-aggregation/src/config/deployment-status.ts
Normal file
201
services/token-aggregation/src/config/deployment-status.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface DeploymentStatusFile {
|
||||
version?: string;
|
||||
updated?: string;
|
||||
chains?: Record<
|
||||
string,
|
||||
{
|
||||
name?: string;
|
||||
cwTokens?: Record<string, string>;
|
||||
gasMirrors?: Record<string, string>;
|
||||
gasQuoteAddresses?: Record<string, string>;
|
||||
gasPmmPools?: Array<Record<string, unknown>>;
|
||||
gasReferenceVenues?: Array<Record<string, unknown>>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LoadedDeploymentStatus {
|
||||
data: DeploymentStatusFile;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
export interface CwRegistryChain {
|
||||
chainId: number;
|
||||
chainIdText: string;
|
||||
name: string;
|
||||
tokens: Array<{
|
||||
symbol: string;
|
||||
address: string;
|
||||
assetClass?: string;
|
||||
familyKey?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GasRegistryChain {
|
||||
chainId: number;
|
||||
chainIdText: string;
|
||||
name: string;
|
||||
families: Array<{
|
||||
familyKey: string;
|
||||
mirroredSymbol: string;
|
||||
mirrorAddress?: string;
|
||||
dodoPmm: Array<Record<string, unknown>>;
|
||||
referenceVenues: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDeploymentStatusCandidates(): string[] {
|
||||
return uniquePaths([
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH,
|
||||
process.env.CW_REGISTRY_JSON_PATH,
|
||||
process.env.CROSS_CHAIN_PMM_DEPLOYMENT_STATUS_PATH,
|
||||
path.resolve(process.cwd(), 'cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
path.resolve(process.cwd(), '..', 'cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
path.resolve(process.cwd(), '..', '..', 'cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
path.resolve(__dirname, '../../../../../cross-chain-pmm-lps/config/deployment-status.json'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveDeploymentStatusPath(): string | null {
|
||||
for (const candidate of buildDeploymentStatusCandidates()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadDeploymentStatusFile(): LoadedDeploymentStatus | null {
|
||||
const filePath = resolveDeploymentStatusPath();
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
data: JSON.parse(raw) as DeploymentStatusFile,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCwRegistryChains(data: DeploymentStatusFile): CwRegistryChain[] {
|
||||
const chains = data.chains ?? {};
|
||||
const rows: CwRegistryChain[] = [];
|
||||
|
||||
for (const [chainIdText, chain] of Object.entries(chains)) {
|
||||
const gasFamilyByMirror = new Map<string, string>();
|
||||
for (const pool of chain.gasPmmPools ?? []) {
|
||||
const familyKey = typeof pool.familyKey === 'string' ? pool.familyKey : '';
|
||||
const base = typeof pool.base === 'string' ? pool.base : '';
|
||||
if (familyKey && base) {
|
||||
gasFamilyByMirror.set(base, familyKey);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
...Object.entries(chain.cwTokens ?? {})
|
||||
.filter(([, address]) => typeof address === 'string' && address.trim() !== '')
|
||||
.map(([symbol, address]) => ({ symbol, address })),
|
||||
...Object.entries(chain.gasMirrors ?? {})
|
||||
.filter(([, address]) => typeof address === 'string' && address.trim() !== '')
|
||||
.map(([symbol, address]) => ({
|
||||
symbol,
|
||||
address,
|
||||
assetClass: 'gas_native',
|
||||
familyKey: gasFamilyByMirror.get(symbol),
|
||||
})),
|
||||
];
|
||||
|
||||
if (tokens.length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
chainId: Number(chainIdText),
|
||||
chainIdText,
|
||||
name: chain.name || `Chain ${chainIdText}`,
|
||||
tokens: tokens.sort((a, b) => a.symbol.localeCompare(b.symbol)),
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
|
||||
export function buildGasRegistryChains(data: DeploymentStatusFile): GasRegistryChain[] {
|
||||
const rows: GasRegistryChain[] = [];
|
||||
|
||||
for (const [chainIdText, chain] of Object.entries(data.chains ?? {})) {
|
||||
const familyMap = new Map<
|
||||
string,
|
||||
{
|
||||
familyKey: string;
|
||||
mirroredSymbol: string;
|
||||
mirrorAddress?: string;
|
||||
dodoPmm: Array<Record<string, unknown>>;
|
||||
referenceVenues: Array<Record<string, unknown>>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const pool of chain.gasPmmPools ?? []) {
|
||||
const familyKey = typeof pool.familyKey === 'string' ? pool.familyKey : '';
|
||||
const mirroredSymbol = typeof pool.base === 'string' ? pool.base : '';
|
||||
if (!familyKey || !mirroredSymbol) continue;
|
||||
const existing = familyMap.get(familyKey) ?? {
|
||||
familyKey,
|
||||
mirroredSymbol,
|
||||
mirrorAddress: chain.gasMirrors?.[mirroredSymbol],
|
||||
dodoPmm: [],
|
||||
referenceVenues: [],
|
||||
};
|
||||
existing.dodoPmm.push(pool);
|
||||
familyMap.set(familyKey, existing);
|
||||
}
|
||||
|
||||
for (const venue of chain.gasReferenceVenues ?? []) {
|
||||
const familyKey = typeof venue.familyKey === 'string' ? venue.familyKey : '';
|
||||
if (!familyKey) continue;
|
||||
const existing = familyMap.get(familyKey) ?? {
|
||||
familyKey,
|
||||
mirroredSymbol: typeof venue.base === 'string' ? venue.base : '',
|
||||
mirrorAddress: typeof venue.base === 'string' ? chain.gasMirrors?.[venue.base] : undefined,
|
||||
dodoPmm: [],
|
||||
referenceVenues: [],
|
||||
};
|
||||
existing.referenceVenues.push(venue);
|
||||
familyMap.set(familyKey, existing);
|
||||
}
|
||||
|
||||
const families = Array.from(familyMap.values()).sort((a, b) => a.familyKey.localeCompare(b.familyKey));
|
||||
if (families.length === 0) continue;
|
||||
|
||||
rows.push({
|
||||
chainId: Number(chainIdText),
|
||||
chainIdText,
|
||||
name: chain.name || `Chain ${chainIdText}`,
|
||||
families,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => a.chainId - b.chainId);
|
||||
}
|
||||
@@ -34,13 +34,18 @@ export interface DexFactoryConfig {
|
||||
custom?: CustomDexConfig[];
|
||||
}
|
||||
|
||||
/** Canonical DODOPMMIntegration on Chain 138 — see docs/11-references/CONTRACT_ADDRESSES_REFERENCE.md */
|
||||
const CANONICAL_CHAIN138_DODO_PMM_INTEGRATION =
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
export const DEX_FACTORIES: Record<number, DexFactoryConfig> = {
|
||||
138: {
|
||||
// DODO PMM Integration - index from DODOPMMIntegration or PoolManager
|
||||
dodo: [
|
||||
{
|
||||
poolManager: process.env.CHAIN_138_DODO_POOL_MANAGER || '',
|
||||
dodoPmmIntegration: process.env.CHAIN_138_DODO_PMM_INTEGRATION || '',
|
||||
dodoPmmIntegration:
|
||||
process.env.CHAIN_138_DODO_PMM_INTEGRATION || CANONICAL_CHAIN138_DODO_PMM_INTEGRATION,
|
||||
dodoVendingMachine: process.env.CHAIN_138_DODO_VENDING_MACHINE || '',
|
||||
startBlock: 0,
|
||||
},
|
||||
|
||||
435
services/token-aggregation/src/config/gru-transport.test.ts
Normal file
435
services/token-aggregation/src/config/gru-transport.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { resolveTokenMappingLoaderPath } from './repo-config-loader';
|
||||
import { getRouteFromRegistry } from './cross-chain-bridges';
|
||||
import {
|
||||
filterPoolsForExposure,
|
||||
filterPoolsForRouting,
|
||||
getActiveTransportPairs,
|
||||
getGasAssetFamilies,
|
||||
getGasProtocolExposure,
|
||||
getGasRedeemGroups,
|
||||
getGruTransportMetadata,
|
||||
isGasRedemptionPathAllowed,
|
||||
isPublicPoolActive,
|
||||
isPublicPoolRoutable,
|
||||
} from './gru-transport';
|
||||
|
||||
describe('GRU Transport overlay', () => {
|
||||
const originalChain138Bridge = process.env.CHAIN138_L1_BRIDGE;
|
||||
const originalBscBridge = process.env.CW_BRIDGE_BSC;
|
||||
const originalStableBridge = process.env.CCIP_STABLE_BRIDGE_CHAIN138;
|
||||
const originalStablecoinBridge = process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138;
|
||||
const originalUniversalBridge = process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS;
|
||||
const originalUniversalBridgeAlt = process.env.UNIVERSAL_CCIP_BRIDGE;
|
||||
const originalReserveVerifier = process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
const originalReserveVault = process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
const originalReserveSystem = process.env.CW_RESERVE_SYSTEM;
|
||||
const originalMaxOutstanding = process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
const originalGasStrictVerifier = process.env.CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138;
|
||||
const originalGasHybridVerifier = process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138;
|
||||
const originalGasEscrowVault = process.env.CW_GAS_ESCROW_VAULT_CHAIN138;
|
||||
const originalGasTreasurySystem = process.env.CW_GAS_TREASURY_SYSTEM;
|
||||
const originalMainnetBridge = process.env.CW_BRIDGE_MAINNET;
|
||||
const originalOptimismBridge = process.env.CW_BRIDGE_OPTIMISM;
|
||||
const originalEthMainnetOutstanding = process.env.CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET;
|
||||
const originalEthMainnetSupply = process.env.CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET;
|
||||
const originalEthMainnetEscrowed = process.env.CW_GAS_ESCROWED_ETH_MAINNET_MAINNET;
|
||||
const originalEthMainnetTreasury = process.env.CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET;
|
||||
const originalEthL2Outstanding = process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Supply = process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Escrowed = process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Treasury = process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM;
|
||||
const originalEthL2Cap = process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM;
|
||||
const originalTokenMappingLoaderPath = process.env.TOKEN_MAPPING_LOADER_PATH;
|
||||
const originalCwL1Bridge = process.env.CW_L1_BRIDGE;
|
||||
const originalCwL1BridgeChain138 = process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalChain138Bridge === undefined) {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CHAIN138_L1_BRIDGE = originalChain138Bridge;
|
||||
}
|
||||
|
||||
if (originalCwL1Bridge === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE = originalCwL1Bridge;
|
||||
}
|
||||
if (originalCwL1BridgeChain138 === undefined) {
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_L1_BRIDGE_CHAIN138 = originalCwL1BridgeChain138;
|
||||
}
|
||||
|
||||
if (originalBscBridge === undefined) {
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
} else {
|
||||
process.env.CW_BRIDGE_BSC = originalBscBridge;
|
||||
}
|
||||
|
||||
if (originalStableBridge === undefined) {
|
||||
delete process.env.CCIP_STABLE_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CCIP_STABLE_BRIDGE_CHAIN138 = originalStableBridge;
|
||||
}
|
||||
|
||||
if (originalStablecoinBridge === undefined) {
|
||||
delete process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138;
|
||||
} else {
|
||||
process.env.CCIP_STABLECOIN_BRIDGE_CHAIN138 = originalStablecoinBridge;
|
||||
}
|
||||
|
||||
if (originalUniversalBridge === undefined) {
|
||||
delete process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS;
|
||||
} else {
|
||||
process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS = originalUniversalBridge;
|
||||
}
|
||||
|
||||
if (originalUniversalBridgeAlt === undefined) {
|
||||
delete process.env.UNIVERSAL_CCIP_BRIDGE;
|
||||
} else {
|
||||
process.env.UNIVERSAL_CCIP_BRIDGE = originalUniversalBridgeAlt;
|
||||
}
|
||||
|
||||
if (originalReserveVerifier === undefined) {
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
} else {
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = originalReserveVerifier;
|
||||
}
|
||||
|
||||
if (originalReserveVault === undefined) {
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
} else {
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = originalReserveVault;
|
||||
}
|
||||
|
||||
if (originalReserveSystem === undefined) {
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
} else {
|
||||
process.env.CW_RESERVE_SYSTEM = originalReserveSystem;
|
||||
}
|
||||
|
||||
if (originalMaxOutstanding === undefined) {
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
} else {
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = originalMaxOutstanding;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({
|
||||
CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138: originalGasStrictVerifier,
|
||||
CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138: originalGasHybridVerifier,
|
||||
CW_GAS_ESCROW_VAULT_CHAIN138: originalGasEscrowVault,
|
||||
CW_GAS_TREASURY_SYSTEM: originalGasTreasurySystem,
|
||||
CW_BRIDGE_MAINNET: originalMainnetBridge,
|
||||
CW_BRIDGE_OPTIMISM: originalOptimismBridge,
|
||||
CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET: originalEthMainnetOutstanding,
|
||||
CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET: originalEthMainnetSupply,
|
||||
CW_GAS_ESCROWED_ETH_MAINNET_MAINNET: originalEthMainnetEscrowed,
|
||||
CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET: originalEthMainnetTreasury,
|
||||
CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Outstanding,
|
||||
CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM: originalEthL2Supply,
|
||||
CW_GAS_ESCROWED_ETH_L2_OPTIMISM: originalEthL2Escrowed,
|
||||
CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM: originalEthL2Treasury,
|
||||
CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM: originalEthL2Cap,
|
||||
})) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (originalTokenMappingLoaderPath === undefined) {
|
||||
delete process.env.TOKEN_MAPPING_LOADER_PATH;
|
||||
} else {
|
||||
process.env.TOKEN_MAPPING_LOADER_PATH = originalTokenMappingLoaderPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads GRU Monetary Transport Layer metadata and active transport pairs', () => {
|
||||
const metadata = getGruTransportMetadata();
|
||||
expect(metadata).not.toBeNull();
|
||||
expect(metadata?.system?.name).toBe('GRU Monetary Transport Layer');
|
||||
expect(metadata?.system?.shortName).toBe('GRU Transport');
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
expect(pairs.length).toBe(44);
|
||||
expect(pairs.every((pair) => pair.eligible)).toBe(true);
|
||||
expect(pairs.every((pair) => typeof pair.runtimeReady === 'boolean')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-25-cUSDT-cWUSDT')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-1-cBTC-cWBTC')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-10-cETHL2-cWETHL2')).toBe(true);
|
||||
expect(pairs.some((pair) => pair.key === '138-1111-cWEMIX-cWWEMIX')).toBe(false);
|
||||
const bscUsdt = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT');
|
||||
const mainnetEth = pairs.find((pair) => pair.key === '138-1-cETH-cWETH');
|
||||
expect(bscUsdt?.bridgeCanonicalAssetVersion).toBe('v1');
|
||||
expect(bscUsdt?.bridgeMirroredAssetVersion).toBe('v1');
|
||||
expect(bscUsdt?.destinationChainName).toBe('BSC');
|
||||
expect(bscUsdt?.destinationChainSelector).toBe('11344663589394136015');
|
||||
expect(mainnetEth?.destinationChainSelector).toBe('5009297550715157269');
|
||||
expect(metadata?.counts.enabledDestinationChains).toBe(10);
|
||||
expect(metadata?.counts.configuredTransportPairs).toBe(45);
|
||||
expect(metadata?.counts.deferredTransportPairs).toBe(1);
|
||||
expect(metadata?.counts.gasAssetFamilies).toBe(9);
|
||||
expect(metadata?.counts.gasTransportPairs).toBe(10);
|
||||
});
|
||||
|
||||
it('publishes gas-family metadata and enforces the ETH split via redeem groups', () => {
|
||||
const gasFamilies = getGasAssetFamilies();
|
||||
const redeemGroups = getGasRedeemGroups();
|
||||
const protocolExposure = getGasProtocolExposure();
|
||||
|
||||
expect(gasFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_mainnet',
|
||||
backingMode: 'strict_escrow',
|
||||
mirroredSymbol: 'cWETH',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
backingMode: 'hybrid_cap',
|
||||
mirroredSymbol: 'cWETHL2',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(redeemGroups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'eth_l2',
|
||||
allowedChains: [10, 42161, 8453],
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(protocolExposure).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: '10-eth_l2',
|
||||
chainId: 10,
|
||||
familyKey: 'eth_l2',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: '1111-wemix',
|
||||
active: false,
|
||||
status: 'deferred',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(gasFamilies).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
familyKey: 'wemix',
|
||||
active: false,
|
||||
status: 'deferred',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(isGasRedemptionPathAllowed(10, 42161, 'eth_l2')).toBe(true);
|
||||
expect(isGasRedemptionPathAllowed(1, 10, 'eth_mainnet')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Chain 138 pools visible but hides inactive public cW pools', () => {
|
||||
const pools = [
|
||||
{
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
token0Address: '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB',
|
||||
token1Address: '0x55d398326f99059fF775485246999027B3197955',
|
||||
},
|
||||
{
|
||||
poolAddress: '0x2222222222222222222222222222222222222222',
|
||||
token0Address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
token1Address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterPoolsForExposure(56, [pools[0]])).toEqual([]);
|
||||
expect(filterPoolsForRouting(56, [pools[0]])).toEqual([]);
|
||||
expect(filterPoolsForExposure(138, [pools[1]])).toEqual([pools[1]]);
|
||||
expect(filterPoolsForRouting(138, [pools[1]])).toEqual([pools[1]]);
|
||||
});
|
||||
|
||||
it('marks public cW pools inactive and non-routable until explicitly enabled', () => {
|
||||
expect(isPublicPoolActive(56, '0x1111111111111111111111111111111111111111')).toBe(false);
|
||||
expect(isPublicPoolRoutable(56, '0x1111111111111111111111111111111111111111')).toBe(false);
|
||||
});
|
||||
|
||||
it('routes active c* transport through GRU Transport while keeping WETH on legacy lanes', () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const gruRoute = getRouteFromRegistry(138, 56, 'cUSDT');
|
||||
expect(gruRoute).not.toBeNull();
|
||||
expect(gruRoute?.label).toBe('GRUTransport');
|
||||
expect(gruRoute?.bridgeAddress).toBe('0x3333333333333333333333333333333333333333');
|
||||
|
||||
const wethRoute = getRouteFromRegistry(138, 56, 'WETH');
|
||||
expect(wethRoute).not.toBeNull();
|
||||
expect(wethRoute?.label).toBe('CCIPWETH9Bridge');
|
||||
});
|
||||
|
||||
it('reports runtime-ready transport pairs when bridge and reserve refs resolve', () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_BSC = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_RESERVE_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_STABLECOIN_RESERVE_VAULT = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_RESERVE_SYSTEM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_MAX_OUTSTANDING_USDT_BSC = '1000000';
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
const bscUsdtPair = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT');
|
||||
expect(bscUsdtPair?.runtimeBridgeReady).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeReserveVerifierReady).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeMaxOutstandingReady).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeReady).toBe(true);
|
||||
|
||||
const metadata = getGruTransportMetadata();
|
||||
expect(metadata?.counts.runtimeReadyTransportPairs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reports missing runtime requirements when bridge and reserve refs are absent', () => {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
delete process.env.CW_RESERVE_VERIFIER_CHAIN138;
|
||||
delete process.env.CW_STABLECOIN_RESERVE_VAULT;
|
||||
delete process.env.CW_RESERVE_SYSTEM;
|
||||
delete process.env.CW_MAX_OUTSTANDING_USDT_BSC;
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
const bscUsdtPair = pairs.find((pair) => pair.key === '138-56-cUSDT-cWUSDT');
|
||||
expect(bscUsdtPair?.eligible).toBe(true);
|
||||
expect(bscUsdtPair?.runtimeReady).toBe(false);
|
||||
expect(bscUsdtPair?.runtimeMissingRequirements).toEqual(
|
||||
expect.arrayContaining([
|
||||
'bridge:l1Bridge',
|
||||
'bridge:l2Bridge',
|
||||
'policy:maxOutstanding',
|
||||
'reserveVerifier:bridgeRef',
|
||||
'reserveVerifier:vaultRef',
|
||||
'reserveVerifier:reserveSystemRef',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates gas-lane supply accounting separately for strict and hybrid backing', () => {
|
||||
process.env.CHAIN138_L1_BRIDGE = '0x3333333333333333333333333333333333333333';
|
||||
process.env.CW_BRIDGE_MAINNET = '0x4444444444444444444444444444444444444444';
|
||||
process.env.CW_GAS_STRICT_ESCROW_VERIFIER_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.CW_GAS_ESCROW_VAULT_CHAIN138 = '0x6666666666666666666666666666666666666666';
|
||||
process.env.CW_MAX_OUTSTANDING_ETH_MAINNET_MAINNET = '100';
|
||||
process.env.CW_GAS_OUTSTANDING_ETH_MAINNET_MAINNET = '100';
|
||||
process.env.CW_GAS_ESCROWED_ETH_MAINNET_MAINNET = '100';
|
||||
process.env.CW_GAS_TREASURY_BACKED_ETH_MAINNET_MAINNET = '0';
|
||||
|
||||
process.env.CW_BRIDGE_OPTIMISM = '0x7777777777777777777777777777777777777777';
|
||||
process.env.CW_GAS_HYBRID_CAP_VERIFIER_CHAIN138 = '0x8888888888888888888888888888888888888888';
|
||||
process.env.CW_GAS_TREASURY_SYSTEM = '0x9999999999999999999999999999999999999999';
|
||||
process.env.CW_MAX_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_OUTSTANDING_ETH_L2_OPTIMISM = '125';
|
||||
process.env.CW_GAS_ESCROWED_ETH_L2_OPTIMISM = '100';
|
||||
process.env.CW_GAS_TREASURY_BACKED_ETH_L2_OPTIMISM = '25';
|
||||
process.env.CW_GAS_TREASURY_CAP_ETH_L2_OPTIMISM = '25';
|
||||
|
||||
const pairs = getActiveTransportPairs();
|
||||
const strictPair = pairs.find((pair) => pair.key === '138-1-cETH-cWETH');
|
||||
const hybridPair = pairs.find((pair) => pair.key === '138-10-cETHL2-cWETHL2');
|
||||
|
||||
expect(strictPair?.runtimeSupplyAccountingReady).toBe(true);
|
||||
expect(strictPair?.supplyInvariantSatisfied).toBe(true);
|
||||
expect(strictPair?.runtimeReady).toBe(true);
|
||||
|
||||
expect(hybridPair?.runtimeSupplyAccountingReady).toBe(true);
|
||||
expect(hybridPair?.supplyInvariantSatisfied).toBe(true);
|
||||
expect(hybridPair?.runtimeReady).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers the active GRU mirrored address even when raw pair ordering is ambiguous', () => {
|
||||
const loader = require(path.join(process.cwd(), '../../..', 'config', 'token-mapping-loader.cjs')) as {
|
||||
getMappedAddress: (fromChainId: number, toChainId: number, tokenAddressOnSource: string, jsonPath?: string) => string | undefined;
|
||||
};
|
||||
const fixturePath = path.join(os.tmpdir(), `gru-transport-mapping-${Date.now()}.json`);
|
||||
const canonicalAddress = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
|
||||
const mirroredAddress = '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB';
|
||||
const nativeAddress = '0x55d398326f99059fF775485246999027B3197955';
|
||||
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pairs: [
|
||||
{
|
||||
fromChainId: 138,
|
||||
toChainId: 56,
|
||||
tokens: [
|
||||
{
|
||||
key: 'Compliant_USDT_cW',
|
||||
name: 'cUSDT->cWUSDT',
|
||||
addressFrom: canonicalAddress,
|
||||
addressTo: mirroredAddress,
|
||||
},
|
||||
{
|
||||
key: 'Compliant_USDT',
|
||||
name: 'cUSDT',
|
||||
addressFrom: canonicalAddress,
|
||||
addressTo: nativeAddress,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
expect(loader.getMappedAddress(138, 56, canonicalAddress, fixturePath)).toBe(mirroredAddress);
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fall back to legacy stable bridges for active GRU assets when peer bridges are missing', () => {
|
||||
delete process.env.CHAIN138_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE;
|
||||
delete process.env.CW_L1_BRIDGE_CHAIN138;
|
||||
delete process.env.CW_BRIDGE_BSC;
|
||||
process.env.CCIP_STABLE_BRIDGE_CHAIN138 = '0x5555555555555555555555555555555555555555';
|
||||
process.env.UNIVERSAL_CCIP_BRIDGE_ADDRESS = '0x6666666666666666666666666666666666666666';
|
||||
|
||||
jest.resetModules();
|
||||
const { getRouteFromRegistry: getFreshRouteFromRegistry } = require('./cross-chain-bridges') as typeof import('./cross-chain-bridges');
|
||||
|
||||
expect(getFreshRouteFromRegistry(138, 56, 'cUSDT')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the token mapping loader from an explicit deployed-layout env override', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'token-mapping-loader-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const loaderPath = path.join(configDir, 'token-mapping-loader.cjs');
|
||||
|
||||
fs.writeFileSync(
|
||||
loaderPath,
|
||||
'module.exports = { getGruTransportMetadata: () => ({ system: { shortName: "Test" }, counts: { transportPairs: 0 } }), getActiveTransportPairs: () => [] };'
|
||||
);
|
||||
|
||||
process.env.TOKEN_MAPPING_LOADER_PATH = loaderPath;
|
||||
|
||||
try {
|
||||
expect(resolveTokenMappingLoaderPath()).toBe(loaderPath);
|
||||
expect(getGruTransportMetadata()?.system?.shortName).toBe('Test');
|
||||
expect(getActiveTransportPairs()).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
338
services/token-aggregation/src/config/gru-transport.ts
Normal file
338
services/token-aggregation/src/config/gru-transport.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { loadTokenMappingLoader } from './repo-config-loader';
|
||||
|
||||
export interface ConfigRef {
|
||||
address?: string | null;
|
||||
env?: string | null;
|
||||
}
|
||||
|
||||
export interface GruTransportSystemMetadata {
|
||||
name: string;
|
||||
shortName: string;
|
||||
canonicalChainId: number;
|
||||
canonicalChainName?: string;
|
||||
transportClass?: string;
|
||||
publicPoolModel?: string;
|
||||
hardPegTruth?: string;
|
||||
wethTransportSeparated?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface GruTransportMetadata {
|
||||
system: GruTransportSystemMetadata | null;
|
||||
terminology: Record<string, string>;
|
||||
enabledCanonicalTokens: Array<Record<string, unknown>>;
|
||||
enabledDestinationChains: Array<Record<string, unknown>>;
|
||||
gasAssetFamilies?: GruTransportGasAssetFamily[];
|
||||
gasRedeemGroups?: GruTransportGasRedeemGroup[];
|
||||
gasProtocolExposure?: GruTransportGasProtocolExposure[];
|
||||
counts: {
|
||||
enabledCanonicalTokens: number;
|
||||
enabledDestinationChains: number;
|
||||
approvedBridgePeers: number;
|
||||
transportPairs: number;
|
||||
configuredTransportPairs?: number;
|
||||
deferredTransportPairs?: number;
|
||||
gasAssetFamilies?: number;
|
||||
gasRedeemGroups?: number;
|
||||
gasProtocolExposure?: number;
|
||||
gasTransportPairs?: number;
|
||||
strictEscrowTransportPairs?: number;
|
||||
hybridCapTransportPairs?: number;
|
||||
eligibleTransportPairs?: number;
|
||||
runtimeReadyTransportPairs?: number;
|
||||
publicPools: number;
|
||||
activePublicPools?: number;
|
||||
routablePublicPools?: number;
|
||||
mcpVisiblePublicPools?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GruTransportGasAssetFamily {
|
||||
familyKey: string;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
canonicalSymbol138: string;
|
||||
mirroredSymbol: string;
|
||||
assetClass: string;
|
||||
originChains: number[];
|
||||
laneGroup: string;
|
||||
backingMode: string;
|
||||
redeemPolicy: string;
|
||||
wrappedNativeQuoteSymbol: string;
|
||||
stableQuoteSymbol: string;
|
||||
referenceVenue: string;
|
||||
perLaneCaps?: Record<string, string>;
|
||||
displayAliases?: Record<string, string>;
|
||||
hubRebalance?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GruTransportGasRedeemGroup {
|
||||
key: string;
|
||||
familyKey: string;
|
||||
allowedChains: number[];
|
||||
redeemPolicy: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GruTransportGasProtocolExposure {
|
||||
key: string;
|
||||
chainId: number;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
familyKey: string;
|
||||
mirroredSymbol: string;
|
||||
backingMode: string;
|
||||
dodoPmm?: Record<string, unknown>;
|
||||
uniswapV3?: Record<string, unknown>;
|
||||
balancer?: Record<string, unknown>;
|
||||
curve?: Record<string, unknown>;
|
||||
oneInch?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GruTransportBridgePeer {
|
||||
key: string;
|
||||
chainId: number;
|
||||
chainName: string;
|
||||
ccipChainSelector?: string;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
bridgeKind: string;
|
||||
l1Bridge?: ConfigRef;
|
||||
l2Bridge?: ConfigRef;
|
||||
freezeTokenPairRequired?: boolean;
|
||||
freezeDestinationRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface GruTransportPair {
|
||||
key: string;
|
||||
canonicalChainId: number;
|
||||
destinationChainId: number;
|
||||
destinationChainName?: string | null;
|
||||
destinationChainSelector?: string | null;
|
||||
active?: boolean;
|
||||
status?: string;
|
||||
canonicalSymbol: string;
|
||||
mirroredSymbol: string;
|
||||
/** From gru-transport-active enabledCanonicalTokens[].bridge.canonicalAssetVersion */
|
||||
bridgeCanonicalAssetVersion?: string;
|
||||
/** From gru-transport-active enabledCanonicalTokens[].bridge.mirroredAssetVersion */
|
||||
bridgeMirroredAssetVersion?: string;
|
||||
mappingKey: string;
|
||||
peerKey: string;
|
||||
phase?: string;
|
||||
routeDiscoveryEnabled?: boolean;
|
||||
mcpVisible?: boolean;
|
||||
reserveVerifierKey?: string;
|
||||
maxOutstanding?: {
|
||||
required?: boolean;
|
||||
amount?: string;
|
||||
env?: string;
|
||||
};
|
||||
publicPoolKeys?: string[];
|
||||
assetClass?: string;
|
||||
familyKey?: string;
|
||||
laneGroup?: string;
|
||||
backingMode?: string;
|
||||
redeemPolicy?: string;
|
||||
wrappedNativeQuoteSymbol?: string;
|
||||
stableQuoteSymbol?: string;
|
||||
referenceVenue?: string;
|
||||
protocolExposureKey?: string;
|
||||
supplyAccounting?: Record<string, unknown>;
|
||||
canonicalAddress?: string | null;
|
||||
mirroredAddress?: string | null;
|
||||
mirrorDeploymentAddress?: string | null;
|
||||
peer?: GruTransportBridgePeer | null;
|
||||
mappingFound?: boolean;
|
||||
mirrorDeployed?: boolean;
|
||||
canonicalEnabled?: boolean;
|
||||
destinationEnabled?: boolean;
|
||||
bridgeAvailable?: boolean | null;
|
||||
bridgePeerConfigured?: boolean;
|
||||
maxOutstandingConfigured?: boolean;
|
||||
reserveVerifierConfigured?: boolean;
|
||||
runtimeL1BridgeAddress?: string | null;
|
||||
runtimeL2BridgeAddress?: string | null;
|
||||
runtimeBridgeReady?: boolean;
|
||||
runtimeMaxOutstandingValue?: string | null;
|
||||
runtimeMaxOutstandingReady?: boolean;
|
||||
runtimeReserveVerifierBridgeAddress?: string | null;
|
||||
runtimeReserveVerifierAddress?: string | null;
|
||||
runtimeReserveVaultAddress?: string | null;
|
||||
runtimeReserveSystemAddress?: string | null;
|
||||
runtimeReserveVerifierReady?: boolean;
|
||||
runtimeOutstandingValue?: string | null;
|
||||
runtimeEscrowedValue?: string | null;
|
||||
runtimeTreasuryBackedValue?: string | null;
|
||||
runtimeTreasuryCapValue?: string | null;
|
||||
runtimeSupplyAccountingReady?: boolean | null;
|
||||
supplyInvariantSatisfied?: boolean | null;
|
||||
protocolExposure?: GruTransportGasProtocolExposure | null;
|
||||
runtimeMissingRequirements?: string[];
|
||||
eligibilityBlockers?: string[];
|
||||
runtimeReady?: boolean;
|
||||
eligible?: boolean;
|
||||
}
|
||||
|
||||
export interface GruTransportPublicPool {
|
||||
key: string;
|
||||
chainId: number;
|
||||
baseSymbol: string;
|
||||
quoteSymbol: string;
|
||||
poolAddress?: string | null;
|
||||
active?: boolean;
|
||||
routingEnabled?: boolean;
|
||||
mcpVisible?: boolean;
|
||||
phase?: string;
|
||||
}
|
||||
|
||||
export interface PublicPoolLike {
|
||||
poolAddress: string;
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
}
|
||||
|
||||
interface GruTransportLoader {
|
||||
getGruTransportMetadata?: () => GruTransportMetadata | null;
|
||||
getGasAssetFamilies?: () => GruTransportGasAssetFamily[];
|
||||
getGasRedeemGroups?: () => GruTransportGasRedeemGroup[];
|
||||
getGasProtocolExposure?: () => GruTransportGasProtocolExposure[];
|
||||
isGasRedemptionPathAllowed?: (fromChainId: number, toChainId: number, identifier: string) => boolean;
|
||||
getActiveTransportPairs?: () => GruTransportPair[];
|
||||
getActiveTransportPair?: (
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
criteria?: Record<string, unknown>
|
||||
) => GruTransportPair | null;
|
||||
getApprovedBridgePeer?: (chainId: number) => GruTransportBridgePeer | null;
|
||||
getActivePublicPools?: () => GruTransportPublicPool[];
|
||||
isPublicPoolActive?: (chainId: number, poolAddress: string) => boolean;
|
||||
isPublicPoolRoutable?: (chainId: number, poolAddress: string) => boolean;
|
||||
isPublicPoolMcpVisible?: (chainId: number, poolAddress: string) => boolean;
|
||||
shouldExposePublicPool?: (
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
) => boolean;
|
||||
shouldUsePublicPoolForRouting?: (
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
) => boolean;
|
||||
resolveConfigRef?: (ref: ConfigRef | undefined) => string;
|
||||
}
|
||||
|
||||
function loadGruTransportLoader(): GruTransportLoader | null {
|
||||
const loader = loadTokenMappingLoader<GruTransportLoader>();
|
||||
if (loader?.getGruTransportMetadata && loader?.getActiveTransportPairs) {
|
||||
return loader;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGruTransportMetadata(): GruTransportMetadata | null {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGruTransportMetadata?.() ?? null;
|
||||
}
|
||||
|
||||
export function getGasAssetFamilies(): GruTransportGasAssetFamily[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGasAssetFamilies?.() ?? getGruTransportMetadata()?.gasAssetFamilies ?? [];
|
||||
}
|
||||
|
||||
export function getGasRedeemGroups(): GruTransportGasRedeemGroup[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGasRedeemGroups?.() ?? getGruTransportMetadata()?.gasRedeemGroups ?? [];
|
||||
}
|
||||
|
||||
export function getGasProtocolExposure(): GruTransportGasProtocolExposure[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getGasProtocolExposure?.() ?? getGruTransportMetadata()?.gasProtocolExposure ?? [];
|
||||
}
|
||||
|
||||
export function isGasRedemptionPathAllowed(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
identifier: string
|
||||
): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isGasRedemptionPathAllowed?.(fromChainId, toChainId, identifier) ?? false;
|
||||
}
|
||||
|
||||
export function getActiveTransportPairs(): GruTransportPair[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getActiveTransportPairs?.() ?? [];
|
||||
}
|
||||
|
||||
export function getActiveTransportPairBySymbol(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
symbol: string
|
||||
): GruTransportPair | null {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getActiveTransportPair?.(fromChainId, toChainId, { symbol }) ?? null;
|
||||
}
|
||||
|
||||
export function getApprovedBridgePeerByChain(chainId: number): GruTransportBridgePeer | null {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getApprovedBridgePeer?.(chainId) ?? null;
|
||||
}
|
||||
|
||||
export function resolveConfigRef(ref: ConfigRef | undefined): string {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.resolveConfigRef?.(ref) ?? '';
|
||||
}
|
||||
|
||||
export function getActivePublicPools(): GruTransportPublicPool[] {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.getActivePublicPools?.() ?? [];
|
||||
}
|
||||
|
||||
export function isPublicPoolActive(chainId: number, poolAddress: string): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isPublicPoolActive?.(chainId, poolAddress) ?? true;
|
||||
}
|
||||
|
||||
export function isPublicPoolRoutable(chainId: number, poolAddress: string): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isPublicPoolRoutable?.(chainId, poolAddress) ?? true;
|
||||
}
|
||||
|
||||
export function isPublicPoolMcpVisible(chainId: number, poolAddress: string): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.isPublicPoolMcpVisible?.(chainId, poolAddress) ?? false;
|
||||
}
|
||||
|
||||
export function shouldExposePublicPool(
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.shouldExposePublicPool?.(chainId, poolAddress, token0Address, token1Address) ?? true;
|
||||
}
|
||||
|
||||
export function shouldUsePublicPoolForRouting(
|
||||
chainId: number,
|
||||
poolAddress: string,
|
||||
token0Address: string,
|
||||
token1Address: string
|
||||
): boolean {
|
||||
const loader = loadGruTransportLoader();
|
||||
return loader?.shouldUsePublicPoolForRouting?.(chainId, poolAddress, token0Address, token1Address) ?? true;
|
||||
}
|
||||
|
||||
export function filterPoolsForExposure<T extends PublicPoolLike>(chainId: number, pools: T[]): T[] {
|
||||
return pools.filter((pool) =>
|
||||
shouldExposePublicPool(chainId, pool.poolAddress, pool.token0Address, pool.token1Address)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterPoolsForRouting<T extends PublicPoolLike>(chainId: number, pools: T[]): T[] {
|
||||
return pools.filter((pool) =>
|
||||
shouldUsePublicPoolForRouting(chainId, pool.poolAddress, pool.token0Address, pool.token1Address)
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* Aligns with real-robinhood project_plans and ultra_advanced_global_arbitrage_engine_blueprint.
|
||||
*/
|
||||
|
||||
import { resolveChain138RpcUrl } from './chain138-rpc';
|
||||
|
||||
export type ChainGroup = 'hub' | 'edge' | 'althub' | 'external';
|
||||
|
||||
export interface HeatmapChain {
|
||||
@@ -57,9 +59,9 @@ export const DEFAULT_HEATMAP_ASSETS = [
|
||||
|
||||
function buildChains(): HeatmapChain[] {
|
||||
const rpc = (cid: number) =>
|
||||
process.env[`CHAIN_${cid}_RPC_URL`] ||
|
||||
process.env[`RPC_URL_138`] ||
|
||||
'https://rpc.d-bis.org';
|
||||
cid === 138
|
||||
? resolveChain138RpcUrl()
|
||||
: process.env[`CHAIN_${cid}_RPC_URL`] || 'https://rpc.d-bis.org';
|
||||
const explorer = (cid: number) => {
|
||||
const urls: Record<number, string> = {
|
||||
138: 'https://explorer.d-bis.org',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
getMonetaryUnitByCode,
|
||||
getMonetaryUnitBySymbol,
|
||||
isMonetaryUnitSupported,
|
||||
} from './monetary-unit-symbol-registry';
|
||||
|
||||
describe('monetary-unit symbol registry', () => {
|
||||
it('tracks BTC as a non-ISO GRU monetary unit family', () => {
|
||||
expect(isMonetaryUnitSupported('BTC')).toBe(true);
|
||||
expect(getMonetaryUnitByCode('BTC')).toMatchObject({
|
||||
code: 'BTC',
|
||||
canonicalSymbol: 'cBTC',
|
||||
wrappedSymbol: 'cWBTC',
|
||||
mappingKey: 'Compliant_BTC_cW',
|
||||
decimals: 8,
|
||||
});
|
||||
expect(getMonetaryUnitBySymbol('cWBTC')).toMatchObject({
|
||||
code: 'BTC',
|
||||
canonicalSymbol: 'cBTC',
|
||||
wrappedSymbol: 'cWBTC',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface MonetaryUnitSymbolIdentity {
|
||||
code: string;
|
||||
canonicalSymbol: string;
|
||||
wrappedSymbol: string;
|
||||
mappingKey: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
interface MonetaryUnitManifestEntry {
|
||||
code: string;
|
||||
canonicalSymbol: string;
|
||||
wrappedSymbol: string;
|
||||
mappingKey: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
interface MonetaryUnitManifestFile {
|
||||
monetaryUnits?: MonetaryUnitManifestEntry[];
|
||||
}
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveMonetaryUnitManifestPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.GRU_MONETARY_UNIT_MANIFEST_PATH,
|
||||
process.env.MONETARY_UNIT_MANIFEST_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve(process.cwd(), '../config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve(process.cwd(), '../../config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve('/config/gru-monetary-unit-manifest.json'),
|
||||
path.resolve(__dirname, '../../../../../config/gru-monetary-unit-manifest.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadMonetaryUnitManifest(): MonetaryUnitManifestFile {
|
||||
const filePath = resolveMonetaryUnitManifestPath();
|
||||
if (!filePath) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as MonetaryUnitManifestFile;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const monetaryUnitManifest = loadMonetaryUnitManifest();
|
||||
|
||||
const MONETARY_UNIT_ENTRIES = (
|
||||
Array.isArray(monetaryUnitManifest.monetaryUnits)
|
||||
? monetaryUnitManifest.monetaryUnits
|
||||
: []
|
||||
).map((entry) => ({
|
||||
code: String(entry.code || '').trim().toUpperCase(),
|
||||
canonicalSymbol: String(entry.canonicalSymbol || '').trim(),
|
||||
wrappedSymbol: String(entry.wrappedSymbol || '').trim(),
|
||||
mappingKey: String(entry.mappingKey || '').trim(),
|
||||
decimals: Number(entry.decimals || 0),
|
||||
}));
|
||||
|
||||
export const MONETARY_UNIT_SUPPORTED = MONETARY_UNIT_ENTRIES.map((entry) => entry.code) as string[];
|
||||
|
||||
export const MONETARY_UNIT_BY_CODE: Record<string, MonetaryUnitSymbolIdentity> = Object.fromEntries(
|
||||
MONETARY_UNIT_ENTRIES.map((entry) => [
|
||||
entry.code,
|
||||
{
|
||||
code: entry.code,
|
||||
canonicalSymbol: entry.canonicalSymbol,
|
||||
wrappedSymbol: entry.wrappedSymbol,
|
||||
mappingKey: entry.mappingKey,
|
||||
decimals: entry.decimals,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
export const MONETARY_UNIT_BY_SYMBOL: Record<string, MonetaryUnitSymbolIdentity> = Object.fromEntries(
|
||||
MONETARY_UNIT_ENTRIES.flatMap((entry) => [
|
||||
[
|
||||
entry.canonicalSymbol,
|
||||
{
|
||||
code: entry.code,
|
||||
canonicalSymbol: entry.canonicalSymbol,
|
||||
wrappedSymbol: entry.wrappedSymbol,
|
||||
mappingKey: entry.mappingKey,
|
||||
decimals: entry.decimals,
|
||||
},
|
||||
],
|
||||
[
|
||||
entry.wrappedSymbol,
|
||||
{
|
||||
code: entry.code,
|
||||
canonicalSymbol: entry.canonicalSymbol,
|
||||
wrappedSymbol: entry.wrappedSymbol,
|
||||
mappingKey: entry.mappingKey,
|
||||
decimals: entry.decimals,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
export function isMonetaryUnitSupported(code: string): boolean {
|
||||
return Boolean(MONETARY_UNIT_BY_CODE[String(code || '').trim().toUpperCase()]);
|
||||
}
|
||||
|
||||
export function getMonetaryUnitByCode(code: string): MonetaryUnitSymbolIdentity | undefined {
|
||||
return MONETARY_UNIT_BY_CODE[String(code || '').trim().toUpperCase()];
|
||||
}
|
||||
|
||||
export function getMonetaryUnitBySymbol(symbol: string): MonetaryUnitSymbolIdentity | undefined {
|
||||
return MONETARY_UNIT_BY_SYMBOL[String(symbol || '').trim()];
|
||||
}
|
||||
563
services/token-aggregation/src/config/provider-capabilities.ts
Normal file
563
services/token-aggregation/src/config/provider-capabilities.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import { AbiCoder } from 'ethers';
|
||||
import {
|
||||
PlannerProvider,
|
||||
ProviderCapabilityRecord,
|
||||
ProviderPairCapability,
|
||||
} from '../services/planner-v2-types';
|
||||
import { encodeChain138DodoV3ProviderData, isChain138DodoV3ExecutionLive } from '../services/dodo-v3-pilot';
|
||||
import { getChain138PilotVenueEdges } from '../services/chain138-pilot-venues';
|
||||
import { getChain138RoutingAssets } from './routing-assets';
|
||||
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN138_UNISWAP_V3_ROUTER = '0xde9cd8ee2811e6e64a41d5f68be315d33995975e';
|
||||
const CHAIN138_UNISWAP_V3_QUOTER = '0x6abbb1ceb2468e748a03a00cd6aa9bfe893afa1f';
|
||||
const CHAIN138_PILOT_BALANCER_VAULT = '0x96423d7c1727698d8a25ebfb88131e9422d1a3c3';
|
||||
const CHAIN138_PILOT_CURVE_3POOL = '0xe440ec15805be4c7babcd17a63b8c8a08a492e0f';
|
||||
const CHAIN138_PILOT_ONEINCH_ROUTER = '0x500b84b1bc6f59c1898a5fe538ea20a758757a4f';
|
||||
const CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324';
|
||||
const CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID = '0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b';
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function liveOrPlannedAddress(value?: string): 'live' | 'planned' {
|
||||
return normalizeAddress(value) ? 'live' : 'planned';
|
||||
}
|
||||
|
||||
function bidirectionalPair(args: {
|
||||
chainId: number;
|
||||
provider: PlannerProvider;
|
||||
tokenASymbol: string;
|
||||
tokenAAddress: string;
|
||||
tokenBSymbol: string;
|
||||
tokenBAddress: string;
|
||||
status: 'live' | 'planned' | 'blocked';
|
||||
target?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
notes?: string[];
|
||||
reason?: string;
|
||||
}): ProviderPairCapability[] {
|
||||
return [
|
||||
{
|
||||
chainId: args.chainId,
|
||||
provider: args.provider,
|
||||
legType: 'swap',
|
||||
status: args.status,
|
||||
tokenInSymbol: args.tokenASymbol,
|
||||
tokenInAddress: normalizeAddress(args.tokenAAddress),
|
||||
tokenOutSymbol: args.tokenBSymbol,
|
||||
tokenOutAddress: normalizeAddress(args.tokenBAddress),
|
||||
target: normalizeAddress(args.target),
|
||||
providerData: args.providerData,
|
||||
providerDataHex: args.providerDataHex,
|
||||
notes: args.notes,
|
||||
reason: args.reason,
|
||||
},
|
||||
{
|
||||
chainId: args.chainId,
|
||||
provider: args.provider,
|
||||
legType: 'swap',
|
||||
status: args.status,
|
||||
tokenInSymbol: args.tokenBSymbol,
|
||||
tokenInAddress: normalizeAddress(args.tokenBAddress),
|
||||
tokenOutSymbol: args.tokenASymbol,
|
||||
tokenOutAddress: normalizeAddress(args.tokenAAddress),
|
||||
target: normalizeAddress(args.target),
|
||||
providerData: args.providerData,
|
||||
providerDataHex: args.providerDataHex,
|
||||
notes: args.notes,
|
||||
reason: args.reason,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function encodeDodoPool(poolAddress: string): string {
|
||||
return abiCoder.encode(['address'], [poolAddress]);
|
||||
}
|
||||
|
||||
function encodeUniswapRoute(fee: number, quoter: string): string {
|
||||
return abiCoder.encode(['bytes', 'uint24', 'address', 'bool'], ['0x', fee, quoter, false]);
|
||||
}
|
||||
|
||||
function encodeBalancerRoute(poolId: string): string {
|
||||
return abiCoder.encode(['bytes32'], [poolId]);
|
||||
}
|
||||
|
||||
function encodeCurveRoute(i: number, j: number, useUnderlying: boolean): string {
|
||||
return abiCoder.encode(['int128', 'int128', 'bool'], [i, j, useUnderlying]);
|
||||
}
|
||||
|
||||
function encodeOneInchRoute(router: string): string {
|
||||
return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']);
|
||||
}
|
||||
|
||||
function chain138DodoCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const dodoProvider =
|
||||
normalizeAddress(process.env.DODO_PMM_PROVIDER_ADDRESS) ||
|
||||
normalizeAddress(process.env.DODO_PMM_PROVIDER) ||
|
||||
'0x3f729632e9553ebaccde2e9b4c8f2b285b014f2e';
|
||||
|
||||
const stablePool = '0x9e89bae009adf128782e19e8341996c596ac40dc';
|
||||
const cusdtUsdtPool = '0x866cb44b59303d8dc5f4f9e3e7a8e8b0bf238d66';
|
||||
const cusdcUsdcPool = '0xc39b7d0f40838cbfb54649d327f49a6dac964062';
|
||||
const cusdtXaucPool = '0x1aa55e2001e5651349aff5a63fd7a7ae44f0f1b0';
|
||||
const cusdcXaucPool = '0xea9ac6357cacb42a83b9082b870610363b177cba';
|
||||
const ceurtXaucPool = '0xba99bc1eaac164569d5aca96c806934ddaf970cf';
|
||||
const cbtcCusdtPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CUSDT);
|
||||
const cbtcCusdcPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CUSDC);
|
||||
const cbtcXaucPool = normalizeAddress(process.env.CHAIN138_POOL_CBTC_CXAUC);
|
||||
const wethUsdtPool = normalizeAddress(process.env.CHAIN138_POOL_WETH_USDT);
|
||||
const wethUsdcPool = normalizeAddress(process.env.CHAIN138_POOL_WETH_USDC);
|
||||
|
||||
const pairs: ProviderPairCapability[] = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'cUSDC',
|
||||
tokenBAddress: assets.cUSDC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: stablePool },
|
||||
providerDataHex: encodeDodoPool(stablePool),
|
||||
notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdtUsdtPool },
|
||||
providerDataHex: encodeDodoPool(cusdtUsdtPool),
|
||||
notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDC',
|
||||
tokenAAddress: assets.cUSDC.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdcUsdcPool },
|
||||
providerDataHex: encodeDodoPool(cusdcUsdcPool),
|
||||
notes: ['Canonical stable pool on the official DODO V2 DVM-backed stack.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDT',
|
||||
tokenAAddress: assets.cUSDT.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdtXaucPool },
|
||||
providerDataHex: encodeDodoPool(cusdtXaucPool),
|
||||
notes: ['Commodity route; excluded unless policy allows commodity intermediates.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cUSDC',
|
||||
tokenAAddress: assets.cUSDC.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: cusdcXaucPool },
|
||||
providerDataHex: encodeDodoPool(cusdcXaucPool),
|
||||
notes: ['Commodity route; excluded unless policy allows commodity intermediates.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cEURT',
|
||||
tokenAAddress: assets.cEURT.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: 'live',
|
||||
target: dodoProvider,
|
||||
providerData: { poolAddress: ceurtXaucPool },
|
||||
providerDataHex: encodeDodoPool(ceurtXaucPool),
|
||||
notes: ['Commodity route; excluded unless policy allows commodity intermediates.'],
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cBTC',
|
||||
tokenAAddress: assets.cBTC.address,
|
||||
tokenBSymbol: 'cUSDT',
|
||||
tokenBAddress: assets.cUSDT.address,
|
||||
status: liveOrPlannedAddress(cbtcCusdtPool),
|
||||
target: dodoProvider,
|
||||
providerData: cbtcCusdtPool ? { poolAddress: cbtcCusdtPool } : undefined,
|
||||
providerDataHex: cbtcCusdtPool ? encodeDodoPool(cbtcCusdtPool) : undefined,
|
||||
notes: ['Bitcoin monetary-unit route for the jewelry-box program.'],
|
||||
reason: cbtcCusdtPool ? undefined : 'Set CHAIN138_POOL_CBTC_CUSDT after the canonical cBTC/cUSDT PMM pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cBTC',
|
||||
tokenAAddress: assets.cBTC.address,
|
||||
tokenBSymbol: 'cUSDC',
|
||||
tokenBAddress: assets.cUSDC.address,
|
||||
status: liveOrPlannedAddress(cbtcCusdcPool),
|
||||
target: dodoProvider,
|
||||
providerData: cbtcCusdcPool ? { poolAddress: cbtcCusdcPool } : undefined,
|
||||
providerDataHex: cbtcCusdcPool ? encodeDodoPool(cbtcCusdcPool) : undefined,
|
||||
notes: ['Bitcoin monetary-unit route for the jewelry-box program.'],
|
||||
reason: cbtcCusdcPool ? undefined : 'Set CHAIN138_POOL_CBTC_CUSDC after the canonical cBTC/cUSDC PMM pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'cBTC',
|
||||
tokenAAddress: assets.cBTC.address,
|
||||
tokenBSymbol: 'cXAUC',
|
||||
tokenBAddress: assets.cXAUC.address,
|
||||
status: liveOrPlannedAddress(cbtcXaucPool),
|
||||
target: dodoProvider,
|
||||
providerData: cbtcXaucPool ? { poolAddress: cbtcXaucPool } : undefined,
|
||||
providerDataHex: cbtcXaucPool ? encodeDodoPool(cbtcXaucPool) : undefined,
|
||||
notes: ['Bitcoin-to-gold route for jewelry-box rebalances; excluded unless policy allows commodity intermediates.'],
|
||||
reason: cbtcXaucPool ? undefined : 'Set CHAIN138_POOL_CBTC_CXAUC after the canonical cBTC/cXAUC PMM pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status: wethUsdtPool ? 'live' : 'planned',
|
||||
target: dodoProvider,
|
||||
providerData: wethUsdtPool ? { poolAddress: wethUsdtPool } : undefined,
|
||||
providerDataHex: wethUsdtPool ? encodeDodoPool(wethUsdtPool) : undefined,
|
||||
notes: ['Phase 1 WETH lane for router-v2 stable execution.'],
|
||||
reason: wethUsdtPool ? undefined : 'Set CHAIN138_POOL_WETH_USDT after the canonical WETH/USDT pool is created and funded.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status: wethUsdcPool ? 'live' : 'planned',
|
||||
target: dodoProvider,
|
||||
providerData: wethUsdcPool ? { poolAddress: wethUsdcPool } : undefined,
|
||||
providerDataHex: wethUsdcPool ? encodeDodoPool(wethUsdcPool) : undefined,
|
||||
notes: ['Phase 1 WETH lane for router-v2 stable execution.'],
|
||||
reason: wethUsdcPool ? undefined : 'Set CHAIN138_POOL_WETH_USDC after the canonical WETH/USDC pool is created and funded.',
|
||||
}),
|
||||
];
|
||||
|
||||
const livePairs = pairs.filter((pair) => pair.status === 'live');
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo',
|
||||
executionMode: 'onchain',
|
||||
live: livePairs.length > 0,
|
||||
quoteLive: livePairs.length > 0,
|
||||
executionLive: livePairs.length > 0,
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['DODO is the first production executor for Chain 138 router-v2 rollout.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138DodoV3Capabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const enabled = process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0';
|
||||
const proxy = normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS) || '0xc9a11abb7c63d88546be24d58a6d95e3762cb843';
|
||||
const pool = normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS) || '0x6550a3a59070061a262a893a1d6f3f490affdbda';
|
||||
const status = enabled && proxy && pool ? 'live' : 'planned';
|
||||
const executionLive = status === 'live' && isChain138DodoV3ExecutionLive();
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo_v3',
|
||||
tokenASymbol: 'WETH10',
|
||||
tokenAAddress: assets.WETH10.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: proxy,
|
||||
providerData: status === 'live'
|
||||
? { poolAddress: pool, proxyAddress: proxy, quoteMethod: 'querySellTokens' }
|
||||
: undefined,
|
||||
providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(pool) : undefined,
|
||||
notes: [
|
||||
'Canonical Chain 138 DODO v3 / D3MM pilot route.',
|
||||
executionLive
|
||||
? 'Planner visibility, quote selection, and EnhancedSwapRouterV2 execution are live for the canonical pilot pair.'
|
||||
: 'Planner visibility and quote selection are live; EnhancedSwapRouterV2 adapter support is still pending.',
|
||||
],
|
||||
reason: status === 'planned'
|
||||
? 'Set CHAIN138_ENABLE_DODO_V3_ROUTING=1 and ensure CHAIN138_D3_MM_ADDRESS / CHAIN138_D3_PROXY_ADDRESS are configured to expose the pilot venue.'
|
||||
: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'dodo_v3',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive,
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: [
|
||||
executionLive
|
||||
? 'Private DODO v3 / D3MM Chain 138 pilot is live in planner-v2 visibility and internal execution-plan calldata.'
|
||||
: 'Private DODO v3 / D3MM Chain 138 pilot promoted into planner-v2 visibility.',
|
||||
executionLive
|
||||
? 'Route discovery and execution-plan generation are live for the canonical pilot pair.'
|
||||
: 'Route discovery is live, but internal execution-plan calldata is intentionally withheld until a dedicated D3 route executor adapter exists.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138UniswapCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const router = normalizeAddress(process.env.UNISWAP_V3_ROUTER || CHAIN138_UNISWAP_V3_ROUTER);
|
||||
const quoter = normalizeAddress(process.env.UNISWAP_QUOTER_ADDRESS || process.env.UNISWAP_QUOTER || CHAIN138_UNISWAP_V3_QUOTER);
|
||||
const wethUsdtFee = Number(process.env.UNISWAP_V3_WETH_USDT_FEE || '500');
|
||||
const wethUsdcFee = Number(process.env.UNISWAP_V3_WETH_USDC_FEE || '500');
|
||||
const status = router && quoter ? 'live' : 'planned';
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v3',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { fee: wethUsdtFee, quoter } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeUniswapRoute(wethUsdtFee, quoter) : undefined,
|
||||
notes: ['Canonical Chain 138 upstream-native Uniswap v3 WETH/USDT venue.'],
|
||||
reason: status === 'planned' ? 'Configure UNISWAP_V3_ROUTER and UNISWAP_QUOTER_ADDRESS after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v3',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { fee: wethUsdcFee, quoter } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeUniswapRoute(wethUsdcFee, quoter) : undefined,
|
||||
notes: ['Canonical Chain 138 upstream-native Uniswap v3 WETH/USDC venue.'],
|
||||
reason: status === 'planned' ? 'Configure UNISWAP_V3_ROUTER and UNISWAP_QUOTER_ADDRESS after Chain 138 native venue deployment.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'uniswap_v3',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Canonical Chain 138 upstream-native Uniswap v3 router/quoter path.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138BalancerCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const vault = normalizeAddress(process.env.BALANCER_VAULT || CHAIN138_PILOT_BALANCER_VAULT);
|
||||
const wethUsdtPoolId = process.env.BALANCER_WETH_USDT_POOL_ID || CHAIN138_PILOT_BALANCER_WETH_USDT_POOL_ID;
|
||||
const wethUsdcPoolId = process.env.BALANCER_WETH_USDC_POOL_ID || CHAIN138_PILOT_BALANCER_WETH_USDC_POOL_ID;
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'balancer',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status: vault && wethUsdtPoolId ? 'live' : 'planned',
|
||||
target: vault,
|
||||
providerData: vault && wethUsdtPoolId ? { poolId: wethUsdtPoolId } : undefined,
|
||||
providerDataHex: vault && wethUsdtPoolId ? encodeBalancerRoute(wethUsdtPoolId) : undefined,
|
||||
notes: ['Enabled only after Chain 138 Balancer pool IDs are set.'],
|
||||
reason: vault && wethUsdtPoolId ? undefined : 'Configure BALANCER_VAULT and BALANCER_WETH_USDT_POOL_ID once the pool exists.',
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'balancer',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status: vault && wethUsdcPoolId ? 'live' : 'planned',
|
||||
target: vault,
|
||||
providerData: vault && wethUsdcPoolId ? { poolId: wethUsdcPoolId } : undefined,
|
||||
providerDataHex: vault && wethUsdcPoolId ? encodeBalancerRoute(wethUsdcPoolId) : undefined,
|
||||
notes: ['Enabled only after Chain 138 Balancer pool IDs are set.'],
|
||||
reason: vault && wethUsdcPoolId ? undefined : 'Configure BALANCER_VAULT and BALANCER_WETH_USDC_POOL_ID once the pool exists.',
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'balancer',
|
||||
executionMode: 'onchain',
|
||||
live: pairs.some((pair) => pair.status === 'live'),
|
||||
quoteLive: pairs.some((pair) => pair.status === 'live'),
|
||||
executionLive: pairs.some((pair) => pair.status === 'live'),
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Balancer stays disabled until the minimum viable Chain 138 venue set exists.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138CurveCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const curvePool = normalizeAddress(process.env.CURVE_3POOL || CHAIN138_PILOT_CURVE_3POOL);
|
||||
const status = liveOrPlannedAddress(curvePool);
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'curve',
|
||||
tokenASymbol: 'USDT',
|
||||
tokenAAddress: assets.USDT.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: curvePool,
|
||||
providerData: status === 'live' ? { i: 0, j: 1, useUnderlying: false } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeCurveRoute(0, 1, false) : undefined,
|
||||
notes: ['Curve is reserved for stable-stable legs; no direct WETH path is configured.'],
|
||||
reason: status === 'planned' ? 'Configure CURVE_3POOL once the Chain 138 stable-stable venue is live.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'curve',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['Curve is intentionally constrained to stable-stable execution for router-v2.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138PartnerCapabilities(): ProviderCapabilityRecord {
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'partner',
|
||||
executionMode: 'partner',
|
||||
live: false,
|
||||
quoteLive: false,
|
||||
executionLive: false,
|
||||
supportedLegTypes: ['swap', 'bridge'],
|
||||
pairs: [],
|
||||
notes: ['1inch, 0x, and LiFi remain partner payload adapters until explicit Chain 138 live support is verified.'],
|
||||
};
|
||||
}
|
||||
|
||||
function chain138OneInchCapabilities(): ProviderCapabilityRecord {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const router = normalizeAddress(process.env.ONEINCH_ROUTER || CHAIN138_PILOT_ONEINCH_ROUTER);
|
||||
const status = router ? 'live' : 'planned';
|
||||
|
||||
const pairs = [
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'one_inch',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDT',
|
||||
tokenBAddress: assets.USDT.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { executor: router, allowanceTarget: router } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeOneInchRoute(router) : undefined,
|
||||
notes: ['Enabled after the Chain 138 pilot-compatible 1inch router is deployed and funded.'],
|
||||
reason: status === 'planned' ? 'Configure ONEINCH_ROUTER once the Chain 138 pilot-compatible router is live.' : undefined,
|
||||
}),
|
||||
...bidirectionalPair({
|
||||
chainId: CHAIN_138,
|
||||
provider: 'one_inch',
|
||||
tokenASymbol: 'WETH',
|
||||
tokenAAddress: assets.WETH.address,
|
||||
tokenBSymbol: 'USDC',
|
||||
tokenBAddress: assets.USDC.address,
|
||||
status,
|
||||
target: router,
|
||||
providerData: status === 'live' ? { executor: router, allowanceTarget: router } : undefined,
|
||||
providerDataHex: status === 'live' ? encodeOneInchRoute(router) : undefined,
|
||||
notes: ['Enabled after the Chain 138 pilot-compatible 1inch router is deployed and funded.'],
|
||||
reason: status === 'planned' ? 'Configure ONEINCH_ROUTER once the Chain 138 pilot-compatible router is live.' : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
return {
|
||||
chainId: CHAIN_138,
|
||||
provider: 'one_inch',
|
||||
executionMode: 'onchain',
|
||||
live: status === 'live',
|
||||
quoteLive: status === 'live',
|
||||
executionLive: status === 'live',
|
||||
supportedLegTypes: ['swap'],
|
||||
pairs,
|
||||
notes: ['1inch is promoted from partner-only placeholder to an executable Chain 138 pilot-compatible router when configured.'],
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderCapabilities(chainId: number): ProviderCapabilityRecord[] {
|
||||
if (chainId !== CHAIN_138) return [];
|
||||
return [
|
||||
chain138DodoCapabilities(),
|
||||
chain138DodoV3Capabilities(),
|
||||
chain138UniswapCapabilities(),
|
||||
chain138BalancerCapabilities(),
|
||||
chain138CurveCapabilities(),
|
||||
chain138OneInchCapabilities(),
|
||||
chain138PartnerCapabilities(),
|
||||
];
|
||||
}
|
||||
|
||||
export function findProviderPairCapability(
|
||||
chainId: number,
|
||||
provider: PlannerProvider,
|
||||
tokenInAddress: string,
|
||||
tokenOutAddress: string
|
||||
): ProviderPairCapability | undefined {
|
||||
const normalizedIn = normalizeAddress(tokenInAddress);
|
||||
const normalizedOut = normalizeAddress(tokenOutAddress);
|
||||
return getProviderCapabilities(chainId)
|
||||
.find((record) => record.provider === provider)
|
||||
?.pairs.find(
|
||||
(pair) =>
|
||||
pair.tokenInAddress === normalizedIn &&
|
||||
pair.tokenOutAddress === normalizedOut
|
||||
);
|
||||
}
|
||||
53
services/token-aggregation/src/config/repo-config-loader.ts
Normal file
53
services/token-aggregation/src/config/repo-config-loader.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const requireHere = createRequire(__filename);
|
||||
|
||||
function uniquePaths(paths: Array<string | undefined | null>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const candidate of paths) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildTokenMappingLoaderCandidates(): string[] {
|
||||
return uniquePaths([
|
||||
process.env.TOKEN_MAPPING_LOADER_PATH,
|
||||
process.env.GRU_TRANSPORT_LOADER_PATH,
|
||||
process.env.PROXMOX_TOKEN_MAPPING_LOADER_PATH,
|
||||
path.resolve(process.cwd(), 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(process.cwd(), '..', 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(process.cwd(), '..', '..', 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(process.cwd(), '..', '..', '..', 'config', 'token-mapping-loader.cjs'),
|
||||
path.resolve(__dirname, '../../../../../config/token-mapping-loader.cjs'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveTokenMappingLoaderPath(): string | null {
|
||||
for (const candidate of buildTokenMappingLoaderCandidates()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadTokenMappingLoader<T>(): T | null {
|
||||
const loaderPath = resolveTokenMappingLoaderPath();
|
||||
if (!loaderPath) return null;
|
||||
|
||||
try {
|
||||
return requireHere(loaderPath) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
130
services/token-aggregation/src/config/routing-assets.ts
Normal file
130
services/token-aggregation/src/config/routing-assets.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from './canonical-tokens';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_138_WETH = (
|
||||
process.env.WETH ||
|
||||
process.env.WETH9 ||
|
||||
process.env.WETH_ADDRESS_138 ||
|
||||
'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
|
||||
).toLowerCase();
|
||||
const CHAIN_138_WETH10 = (
|
||||
process.env.WETH10 ||
|
||||
process.env.WETH10_ADDRESS ||
|
||||
process.env.WETH10_ADDRESS_138 ||
|
||||
'0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f'
|
||||
).toLowerCase();
|
||||
|
||||
export interface RoutingAssetSpec {
|
||||
symbol: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
kind: 'wrapped' | 'stable' | 'compliant' | 'commodity' | 'monetary_unit';
|
||||
}
|
||||
|
||||
function requireSymbolAddress(chainId: number, symbol: string, fallback?: string): string {
|
||||
const canonical = getCanonicalTokenBySymbol(chainId, symbol)?.addresses?.[chainId];
|
||||
const value = String(canonical || fallback || '').trim().toLowerCase();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getChain138RoutingAssets(): Record<string, RoutingAssetSpec> {
|
||||
return {
|
||||
WETH: {
|
||||
symbol: 'WETH',
|
||||
address: CHAIN_138_WETH,
|
||||
decimals: 18,
|
||||
kind: 'wrapped',
|
||||
},
|
||||
WETH10: {
|
||||
symbol: 'WETH10',
|
||||
address: CHAIN_138_WETH10,
|
||||
decimals: 18,
|
||||
kind: 'wrapped',
|
||||
},
|
||||
USDT: {
|
||||
symbol: 'USDT',
|
||||
address: requireSymbolAddress(CHAIN_138, 'USDT', '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'),
|
||||
decimals: 6,
|
||||
kind: 'stable',
|
||||
},
|
||||
USDC: {
|
||||
symbol: 'USDC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'USDC', '0x71D6687F38b93CCad569Fa6352c876eea967201b'),
|
||||
decimals: 6,
|
||||
kind: 'stable',
|
||||
},
|
||||
cUSDT: {
|
||||
symbol: 'cUSDT',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cUSDT', '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'),
|
||||
decimals: 6,
|
||||
kind: 'compliant',
|
||||
},
|
||||
cUSDC: {
|
||||
symbol: 'cUSDC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cUSDC', '0xf22258f57794CC8E06237084b353Ab30fFfa640b'),
|
||||
decimals: 6,
|
||||
kind: 'compliant',
|
||||
},
|
||||
cBTC: {
|
||||
symbol: 'cBTC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cBTC', '0xcb7c000000000000000000000000000000000138'),
|
||||
decimals: 8,
|
||||
kind: 'monetary_unit',
|
||||
},
|
||||
cEURT: {
|
||||
symbol: 'cEURT',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cEURT', '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72'),
|
||||
decimals: 6,
|
||||
kind: 'compliant',
|
||||
},
|
||||
cXAUC: {
|
||||
symbol: 'cXAUC',
|
||||
address: requireSymbolAddress(CHAIN_138, 'cXAUC', '0x290E52a8819A4fbD0714E517225429aA2B70EC6b'),
|
||||
decimals: 6,
|
||||
kind: 'commodity',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultIntermediateAddresses(chainId: number): string[] {
|
||||
if (chainId !== CHAIN_138) return [];
|
||||
const assets = getChain138RoutingAssets();
|
||||
return [
|
||||
assets.WETH.address,
|
||||
assets.USDT.address,
|
||||
assets.USDC.address,
|
||||
assets.cUSDT.address,
|
||||
assets.cUSDC.address,
|
||||
assets.cBTC.address,
|
||||
];
|
||||
}
|
||||
|
||||
export function getCommodityIntermediateAddresses(chainId: number): string[] {
|
||||
if (chainId !== CHAIN_138) return [];
|
||||
return [getChain138RoutingAssets().cXAUC.address];
|
||||
}
|
||||
|
||||
export function getRoutingSymbolForAddress(chainId: number, address: string): string | undefined {
|
||||
const normalized = address.trim().toLowerCase();
|
||||
if (chainId === CHAIN_138) {
|
||||
const assets = Object.values(getChain138RoutingAssets());
|
||||
const asset = assets.find((entry) => entry.address === normalized);
|
||||
if (asset) return asset.symbol;
|
||||
}
|
||||
|
||||
return getCanonicalTokenByAddress(chainId, normalized)?.symbol;
|
||||
}
|
||||
|
||||
export function getRoutingAddressForSymbol(chainId: number, symbol: string): string | undefined {
|
||||
if (chainId === CHAIN_138) {
|
||||
const assets = getChain138RoutingAssets();
|
||||
const normalized = symbol.trim().toUpperCase();
|
||||
if (normalized === 'WETH') {
|
||||
return assets.WETH.address;
|
||||
}
|
||||
if (normalized === 'WETH10') {
|
||||
return assets.WETH10.address;
|
||||
}
|
||||
}
|
||||
return getCanonicalTokenBySymbol(chainId, symbol)?.addresses?.[chainId]?.toLowerCase();
|
||||
}
|
||||
68
services/token-aggregation/src/config/routing-policies.ts
Normal file
68
services/token-aggregation/src/config/routing-policies.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { PlannerConstraints, RoutingPolicy } from '../services/planner-v2-types';
|
||||
import {
|
||||
getCommodityIntermediateAddresses,
|
||||
getDefaultIntermediateAddresses,
|
||||
} from './routing-assets';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
|
||||
export function resolveRoutingPolicy(
|
||||
chainId: number,
|
||||
constraints: PlannerConstraints = {}
|
||||
): RoutingPolicy {
|
||||
const requestedProfile = constraints.complianceProfile || 'standard';
|
||||
const defaultIntermediates = getDefaultIntermediateAddresses(chainId);
|
||||
const commodityIntermediates = getCommodityIntermediateAddresses(chainId);
|
||||
|
||||
const baseStandard: RoutingPolicy = {
|
||||
profile: 'standard',
|
||||
allowedProviders: ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch'],
|
||||
defaultIntermediateAddresses: defaultIntermediates,
|
||||
allowBridge: constraints.allowBridge !== false,
|
||||
allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'CCIPWETH9Bridge', 'UniversalCCIPBridge', 'AlltraAdapter'],
|
||||
maxLegs: Math.min(Math.max(constraints.maxLegs || 3, 1), 3),
|
||||
allowCommodityIntermediates: constraints.allowCommodityIntermediates === true,
|
||||
notes: ['Standard policy allows live Chain 138 venues, including the DODO v3 pilot, but still requires explicit opt-in for commodity intermediates.'],
|
||||
};
|
||||
|
||||
const baseInstitutional: RoutingPolicy = {
|
||||
profile: 'institutional',
|
||||
allowedProviders: ['dodo'],
|
||||
defaultIntermediateAddresses: defaultIntermediates,
|
||||
allowBridge: constraints.allowBridge !== false,
|
||||
allowedBridgeLabels: ['GRUTransport', 'CCIPStableBridge', 'UniversalCCIPBridge', 'AlltraAdapter'],
|
||||
maxLegs: Math.min(Math.max(constraints.maxLegs || 3, 1), 3),
|
||||
allowCommodityIntermediates: false,
|
||||
notes: ['Institutional policy restricts execution to canonical compliant assets and approved bridge rails.'],
|
||||
};
|
||||
|
||||
const basePolicy = requestedProfile === 'institutional' ? baseInstitutional : baseStandard;
|
||||
const allowedProviders = constraints.allowedProviders?.length
|
||||
? basePolicy.allowedProviders.filter((provider) => constraints.allowedProviders?.includes(provider))
|
||||
: basePolicy.allowedProviders;
|
||||
|
||||
const allowedIntermediates = constraints.allowedIntermediates?.length
|
||||
? constraints.allowedIntermediates.map((value) => value.toLowerCase())
|
||||
: basePolicy.defaultIntermediateAddresses.slice();
|
||||
|
||||
if (basePolicy.allowCommodityIntermediates) {
|
||||
for (const commodity of commodityIntermediates) {
|
||||
if (!allowedIntermediates.includes(commodity)) {
|
||||
allowedIntermediates.push(commodity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...basePolicy,
|
||||
allowedProviders,
|
||||
defaultIntermediateAddresses: allowedIntermediates,
|
||||
allowedBridgeLabels: constraints.preferredBridges?.length
|
||||
? basePolicy.allowedBridgeLabels.filter((bridge) => constraints.preferredBridges?.includes(bridge))
|
||||
: basePolicy.allowedBridgeLabels,
|
||||
notes: [
|
||||
...basePolicy.notes,
|
||||
...(chainId === CHAIN_138 ? ['Chain 138 routing defaults to WETH, USDT, USDC, cUSDT, and cUSDC intermediates.'] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PlannerMetricsRepository } from './planner-metrics-repo';
|
||||
|
||||
describe('PlannerMetricsRepository', () => {
|
||||
function makeRepoWithRejectedQuery(error: Error & { code?: string }): PlannerMetricsRepository {
|
||||
const repo = new PlannerMetricsRepository();
|
||||
(repo as any).pool = {
|
||||
query: jest.fn().mockRejectedValue(error),
|
||||
};
|
||||
return repo;
|
||||
}
|
||||
|
||||
it('treats transient connection errors as cache misses', async () => {
|
||||
const repo = makeRepoWithRejectedQuery(
|
||||
Object.assign(new Error('connect ECONNREFUSED 172.18.0.3:5432'), { code: 'ECONNREFUSED' })
|
||||
);
|
||||
|
||||
await expect(repo.getCachedPlan('request-hash')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('skips cache writes when the planner metrics database is temporarily unreachable', async () => {
|
||||
const repo = makeRepoWithRejectedQuery(
|
||||
Object.assign(new Error('connect EHOSTUNREACH 76.53.10.36:443'), { code: 'EHOSTUNREACH' })
|
||||
);
|
||||
|
||||
await expect(
|
||||
repo.cachePlan('request-hash', {
|
||||
planId: 'plan-1',
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision: 'direct-pool',
|
||||
sourceChainId: 138,
|
||||
destinationChainId: 138,
|
||||
tokenIn: '0x1',
|
||||
tokenOut: '0x2',
|
||||
estimatedAmountOut: '1000',
|
||||
minAmountOut: '990',
|
||||
estimatedGasUsd: 0.1,
|
||||
legs: [],
|
||||
alternatives: [],
|
||||
confidenceScore: 0.9,
|
||||
riskFlags: [],
|
||||
selectedRouteReason: 'selected',
|
||||
rejectedAlternatives: [],
|
||||
staleness: { maxFreshnessSeconds: 0, hasStaleLeg: false },
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../client';
|
||||
import { ProviderCapabilityRecord, PlannerResponse } from '../../services/planner-v2-types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
interface CachedPlanRow {
|
||||
plan_id: string;
|
||||
response_json: PlannerResponse;
|
||||
}
|
||||
|
||||
export class PlannerMetricsRepository {
|
||||
private pool: Pool;
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
private isMissingRelationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as { code?: string }).code;
|
||||
const message = (error as { message?: string }).message || '';
|
||||
return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist'));
|
||||
}
|
||||
|
||||
private isTransientConnectionError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = String((error as { code?: string }).code || '');
|
||||
const message = String((error as { message?: string }).message || '');
|
||||
return (
|
||||
['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', '57P01'].includes(code) ||
|
||||
message.includes('connect ECONNREFUSED') ||
|
||||
message.includes('connect EHOSTUNREACH') ||
|
||||
message.includes('Connection terminated unexpectedly')
|
||||
);
|
||||
}
|
||||
|
||||
private isNonFatalError(error: unknown): boolean {
|
||||
return this.isMissingRelationError(error) || this.isTransientConnectionError(error);
|
||||
}
|
||||
|
||||
private logSuppressedError(action: string, error: unknown): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`Planner metrics ${action} skipped: ${message}`);
|
||||
}
|
||||
|
||||
async getCachedPlan(requestHash: string): Promise<PlannerResponse | null> {
|
||||
try {
|
||||
const result = await this.pool.query<CachedPlanRow>(
|
||||
`SELECT plan_id, response_json
|
||||
FROM route_plan_cache
|
||||
WHERE request_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[requestHash]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0].response_json;
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('cache lookup', error);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cachePlan(requestHash: string, response: PlannerResponse, ttlSeconds: number = 30): Promise<void> {
|
||||
try {
|
||||
await this.pool.query(
|
||||
`INSERT INTO route_plan_cache (
|
||||
plan_id, request_hash, chain_id, destination_chain_id, decision, response_json, expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW() + ($7::text || ' seconds')::interval)
|
||||
ON CONFLICT (plan_id) DO UPDATE SET
|
||||
request_hash = EXCLUDED.request_hash,
|
||||
chain_id = EXCLUDED.chain_id,
|
||||
destination_chain_id = EXCLUDED.destination_chain_id,
|
||||
decision = EXCLUDED.decision,
|
||||
response_json = EXCLUDED.response_json,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
created_at = NOW()`,
|
||||
[
|
||||
response.planId,
|
||||
requestHash,
|
||||
response.sourceChainId,
|
||||
response.destinationChainId,
|
||||
response.decision,
|
||||
JSON.stringify(response),
|
||||
String(ttlSeconds),
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('cache write', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async recordProviderSnapshots(chainId: number, records: ProviderCapabilityRecord[]): Promise<void> {
|
||||
try {
|
||||
for (const record of records) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO provider_health_snapshots (
|
||||
chain_id, provider, status, supports_execution, supports_quote, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||
[
|
||||
chainId,
|
||||
record.provider,
|
||||
record.live ? 'live' : 'planned',
|
||||
record.executionLive,
|
||||
record.quoteLive,
|
||||
JSON.stringify({
|
||||
executionMode: record.executionMode,
|
||||
supportedLegTypes: record.supportedLegTypes,
|
||||
pairs: record.pairs,
|
||||
notes: record.notes || [],
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('provider snapshot write', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async recordPlannedRouteMetrics(response: PlannerResponse): Promise<void> {
|
||||
try {
|
||||
for (const [index, leg] of response.legs.entries()) {
|
||||
await this.pool.query(
|
||||
`INSERT INTO route_execution_metrics (
|
||||
plan_id, chain_id, provider, hop_index, token_in_address, token_out_address,
|
||||
estimated_amount_out, status, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
||||
[
|
||||
response.planId,
|
||||
leg.sourceChainId,
|
||||
leg.provider,
|
||||
index,
|
||||
leg.tokenInAddress,
|
||||
leg.tokenOutAddress,
|
||||
leg.estimatedAmountOut,
|
||||
'planned',
|
||||
JSON.stringify({
|
||||
kind: leg.kind,
|
||||
target: leg.target,
|
||||
poolAddress: leg.poolAddress,
|
||||
providerData: leg.providerData || {},
|
||||
bridgeType: leg.bridgeType,
|
||||
bridgeAddress: leg.bridgeAddress,
|
||||
gasEstimate: leg.gasEstimate,
|
||||
freshnessSeconds: leg.freshnessSeconds,
|
||||
notes: leg.notes || [],
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isNonFatalError(error)) {
|
||||
this.logSuppressedError('route metrics write', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { existsSync } from 'fs';
|
||||
import { ApiServer } from './api/server';
|
||||
import { closeDatabasePool } from './database/client';
|
||||
import { logger } from './utils/logger';
|
||||
import { startRouteMatrixScheduler } from './services/route-matrix-scheduler';
|
||||
|
||||
// Load smom-dbis-138 root .env first (single source); works from dist/ or src/
|
||||
const rootEnvCandidates = [
|
||||
@@ -26,6 +27,7 @@ try {
|
||||
} catch (_) { /* optional when run outside proxmox repo */ }
|
||||
|
||||
const server = new ApiServer();
|
||||
startRouteMatrixScheduler();
|
||||
|
||||
// Start server
|
||||
server.start().catch((error) => {
|
||||
|
||||
@@ -95,11 +95,10 @@ export class ChainIndexer {
|
||||
try {
|
||||
// 1. Index pools
|
||||
logger.info(`Indexing pools for chain ${this.chainId}...`);
|
||||
await this.poolIndexer.indexAllPools();
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
|
||||
// 2. Discover and index tokens from pools
|
||||
logger.info(`Discovering tokens for chain ${this.chainId}...`);
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
const tokenAddresses = new Set<string>();
|
||||
pools.forEach((pool) => {
|
||||
tokenAddresses.add(pool.token0Address);
|
||||
@@ -110,7 +109,7 @@ export class ChainIndexer {
|
||||
// 3. Calculate volumes and update market data
|
||||
logger.info(`Calculating volumes for chain ${this.chainId}...`);
|
||||
for (const tokenAddress of tokenAddresses) {
|
||||
await this.updateMarketData(tokenAddress);
|
||||
await this.updateMarketData(tokenAddress, pools);
|
||||
}
|
||||
|
||||
// 4. Generate OHLCV data
|
||||
@@ -139,7 +138,7 @@ export class ChainIndexer {
|
||||
/**
|
||||
* Update market data for a token
|
||||
*/
|
||||
private async updateMarketData(tokenAddress: string): Promise<void> {
|
||||
private async updateMarketData(tokenAddress: string, pools: Awaited<ReturnType<PoolIndexer['indexAllPools']>>): Promise<void> {
|
||||
try {
|
||||
// Calculate on-chain volume
|
||||
const volumeMetrics = await this.volumeCalculator.calculateTokenVolume(
|
||||
@@ -158,7 +157,6 @@ export class ChainIndexer {
|
||||
const externalData = coingeckoData || dexscreenerData || cmcData;
|
||||
|
||||
// Get pools for liquidity calculation
|
||||
const pools = await this.poolIndexer.indexAllPools();
|
||||
const tokenPools = pools.filter(
|
||||
(p) => p.token0Address === tokenAddress || p.token1Address === tokenAddress
|
||||
);
|
||||
|
||||
@@ -82,6 +82,11 @@ const UNIVERAL_CCIP_ABI = [
|
||||
'event MessageReceived(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address sender, address token, uint256 amount)',
|
||||
];
|
||||
|
||||
const CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN = Math.max(
|
||||
1,
|
||||
Number(process.env.CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN || 5000)
|
||||
);
|
||||
|
||||
function nowSec(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
@@ -90,6 +95,47 @@ function msAgo(hours: number): number {
|
||||
return nowSec() - hours * 3600;
|
||||
}
|
||||
|
||||
function isRpcRangeLimitError(error: unknown): boolean {
|
||||
const parts = [
|
||||
typeof error === 'object' && error ? (error as { message?: string }).message : '',
|
||||
typeof error === 'object' && error ? (error as { shortMessage?: string }).shortMessage : '',
|
||||
typeof error === 'object' && error
|
||||
? ((error as { error?: { message?: string } }).error?.message ?? '')
|
||||
: '',
|
||||
];
|
||||
|
||||
return parts.some((part) =>
|
||||
typeof part === 'string' && /range limit|exceeds maximum rpc range/i.test(part)
|
||||
);
|
||||
}
|
||||
|
||||
async function queryFilterWithRangeFallback(
|
||||
contract: ethers.Contract,
|
||||
filter: ReturnType<ethers.Contract['filters'][string]> | { address: string },
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<ethers.EventLog[]> {
|
||||
try {
|
||||
return (await contract.queryFilter(filter as never, fromBlock, toBlock)) as ethers.EventLog[];
|
||||
} catch (error) {
|
||||
if (!isRpcRangeLimitError(error) || fromBlock >= toBlock) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const logs: ethers.EventLog[] = [];
|
||||
for (
|
||||
let start = fromBlock;
|
||||
start <= toBlock;
|
||||
start += CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN
|
||||
) {
|
||||
const end = Math.min(start + CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN - 1, toBlock);
|
||||
const chunk = (await contract.queryFilter(filter as never, start, end)) as ethers.EventLog[];
|
||||
logs.push(...chunk);
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
/** Fetch CrossChainTransferInitiated events from CCIP WETH bridges */
|
||||
async function fetchCCIPEvents(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
@@ -101,7 +147,7 @@ async function fetchCCIPEvents(
|
||||
try {
|
||||
const contract = new ethers.Contract(bridge.address, CCIP_TRANSFER_ABI, provider);
|
||||
const filter = contract.filters.CrossChainTransferInitiated();
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { messageId: string; sender: string; destinationChainSelector: bigint; recipient: string; amount: bigint };
|
||||
@@ -143,7 +189,7 @@ async function fetchSwapBridgeEvents(
|
||||
try {
|
||||
const contract = new ethers.Contract(address, SWAP_BRIDGE_ABI, provider);
|
||||
const filter = contract.filters.SwapAndBridgeExecuted();
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { sourceToken: string; bridgeableToken: string; amountIn: bigint; amountToBridge: bigint; destinationChainSelector: bigint; recipient: string; messageId: string };
|
||||
@@ -184,7 +230,8 @@ async function fetchAlltraEvents(
|
||||
|
||||
try {
|
||||
const lockContract = new ethers.Contract(bridge.address, ALLTRA_LOCK_ABI, provider);
|
||||
const lockLogs = await lockContract.queryFilter(
|
||||
const lockLogs = await queryFilterWithRangeFallback(
|
||||
lockContract,
|
||||
lockContract.filters.LockForAlltra(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
@@ -211,7 +258,8 @@ async function fetchAlltraEvents(
|
||||
|
||||
try {
|
||||
const adapterContract = new ethers.Contract(bridge.address, ALLTRA_ADAPTER_ABI, provider);
|
||||
const initLogs = await adapterContract.queryFilter(
|
||||
const initLogs = await queryFilterWithRangeFallback(
|
||||
adapterContract,
|
||||
adapterContract.filters.AlltraBridgeInitiated(),
|
||||
fromBlock,
|
||||
toBlock
|
||||
@@ -249,7 +297,7 @@ async function fetchUniversalCCIPEvents(
|
||||
try {
|
||||
const contract = new ethers.Contract(bridge.address, UNIVERAL_CCIP_ABI, provider);
|
||||
const filter = contract.filters.BridgeExecuted?.() ?? { address: bridge.address };
|
||||
const logs = await contract.queryFilter(filter, fromBlock, toBlock);
|
||||
const logs = await queryFilterWithRangeFallback(contract, filter, fromBlock, toBlock);
|
||||
|
||||
for (const log of logs) {
|
||||
const args = (log as ethers.EventLog).args as unknown as { messageId: string; token: string; sender: string; amount: bigint; destinationChain: bigint; recipient: string };
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
const mockQuery = jest.fn();
|
||||
const mockLoggerInfo = jest.fn();
|
||||
|
||||
jest.mock('../database/client', () => ({
|
||||
getDatabasePool: () => ({ query: mockQuery }),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
logger: {
|
||||
info: mockLoggerInfo,
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { OHLCVGenerator } from './ohlcv-generator';
|
||||
|
||||
describe('OHLCVGenerator', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockLoggerInfo.mockReset();
|
||||
});
|
||||
|
||||
it('returns empty data and logs once when swap tables are not provisioned yet', async () => {
|
||||
mockQuery.mockRejectedValue({
|
||||
code: '42P01',
|
||||
message: 'relation "swap_events" does not exist',
|
||||
});
|
||||
|
||||
const generator = new OHLCVGenerator();
|
||||
|
||||
await expect(
|
||||
generator.generateOHLCV(
|
||||
138,
|
||||
'0xToken',
|
||||
'1h',
|
||||
new Date('2026-03-01T00:00:00.000Z'),
|
||||
new Date('2026-03-02T00:00:00.000Z')
|
||||
)
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
generator.generateOHLCV(
|
||||
138,
|
||||
'0xToken',
|
||||
'1h',
|
||||
new Date('2026-03-01T00:00:00.000Z'),
|
||||
new Date('2026-03-02T00:00:00.000Z')
|
||||
)
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mockLoggerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
||||
'Skipping OHLCV generation; relation "swap_events" is not available yet',
|
||||
expect.objectContaining({
|
||||
operation: 'OHLCV generation',
|
||||
relation: 'swap_events',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export type OHLCVInterval = '5m' | '15m' | '1h' | '4h' | '24h';
|
||||
|
||||
@@ -15,11 +16,48 @@ export interface OHLCVData {
|
||||
|
||||
export class OHLCVGenerator {
|
||||
private pool: Pool;
|
||||
private static readonly missingRelationWarnings = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
private normalizePoolAddress(poolAddress?: string): string {
|
||||
return poolAddress?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
private isMissingRelationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as { code?: string }).code;
|
||||
const message = (error as { message?: string }).message || '';
|
||||
return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist'));
|
||||
}
|
||||
|
||||
private getMissingRelationName(error: unknown): string {
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: string }).message || '')
|
||||
: '';
|
||||
const match = message.match(/relation "([^"]+)"/);
|
||||
return match?.[1] || 'unknown relation';
|
||||
}
|
||||
|
||||
private logMissingRelationOnce(operation: string, error: unknown): void {
|
||||
const relation = this.getMissingRelationName(error);
|
||||
const key = `${operation}:${relation}`;
|
||||
if (OHLCVGenerator.missingRelationWarnings.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
OHLCVGenerator.missingRelationWarnings.add(key);
|
||||
logger.info(`Skipping ${operation}; relation "${relation}" is not available yet`, {
|
||||
operation,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OHLCV data for a token
|
||||
*/
|
||||
@@ -31,63 +69,71 @@ export class OHLCVGenerator {
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
const intervalMs = this.getIntervalMs(interval);
|
||||
try {
|
||||
const intervalMs = this.getIntervalMs(interval);
|
||||
|
||||
// Get swap events for the time range
|
||||
let query = `
|
||||
SELECT timestamp, amount_usd, price_usd
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND (token0_address = $2 OR token1_address = $2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), from, to];
|
||||
// Get swap events for the time range
|
||||
let query = `
|
||||
SELECT timestamp, amount_usd, price_usd
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND (token0_address = $2 OR token1_address = $2)
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), from, to];
|
||||
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $5`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group swaps by interval
|
||||
const intervals = new Map<number, OHLCVData>();
|
||||
|
||||
result.rows.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp);
|
||||
const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs;
|
||||
const price = parseFloat(row.price_usd || '0');
|
||||
const volume = parseFloat(row.amount_usd || '0');
|
||||
|
||||
if (!intervals.has(intervalStart)) {
|
||||
intervals.set(intervalStart, {
|
||||
timestamp: new Date(intervalStart),
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
volume: 0,
|
||||
volumeUsd: 0,
|
||||
});
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $5`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
}
|
||||
|
||||
const ohlcv = intervals.get(intervalStart)!;
|
||||
ohlcv.high = Math.max(ohlcv.high, price);
|
||||
ohlcv.low = Math.min(ohlcv.low, price);
|
||||
ohlcv.close = price;
|
||||
ohlcv.volume += 1;
|
||||
ohlcv.volumeUsd += volume;
|
||||
});
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
// Convert map to array and sort by timestamp
|
||||
return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group swaps by interval
|
||||
const intervals = new Map<number, OHLCVData>();
|
||||
|
||||
result.rows.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp);
|
||||
const intervalStart = Math.floor(timestamp.getTime() / intervalMs) * intervalMs;
|
||||
const price = parseFloat(row.price_usd || '0');
|
||||
const volume = parseFloat(row.amount_usd || '0');
|
||||
|
||||
if (!intervals.has(intervalStart)) {
|
||||
intervals.set(intervalStart, {
|
||||
timestamp: new Date(intervalStart),
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
volume: 0,
|
||||
volumeUsd: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const ohlcv = intervals.get(intervalStart)!;
|
||||
ohlcv.high = Math.max(ohlcv.high, price);
|
||||
ohlcv.low = Math.min(ohlcv.low, price);
|
||||
ohlcv.close = price;
|
||||
ohlcv.volume += 1;
|
||||
ohlcv.volumeUsd += volume;
|
||||
});
|
||||
|
||||
// Convert map to array and sort by timestamp
|
||||
return Array.from(intervals.values()).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('OHLCV generation', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,17 +148,19 @@ export class OHLCVGenerator {
|
||||
): Promise<void> {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const values = data.map((d, i) => {
|
||||
const base = i * 8;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8})`;
|
||||
const normalizedPoolAddress = this.normalizePoolAddress(poolAddress);
|
||||
|
||||
const values = data.map((_, i) => {
|
||||
const base = i * 11;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11})`;
|
||||
});
|
||||
|
||||
const params: (string | number | Date | null)[] = [];
|
||||
const params: (string | number | Date)[] = [];
|
||||
data.forEach((d) => {
|
||||
params.push(
|
||||
chainId,
|
||||
tokenAddress.toLowerCase(),
|
||||
poolAddress?.toLowerCase() || null,
|
||||
normalizedPoolAddress,
|
||||
interval,
|
||||
d.open,
|
||||
d.high,
|
||||
@@ -124,21 +172,29 @@ export class OHLCVGenerator {
|
||||
);
|
||||
});
|
||||
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_ohlcv (
|
||||
chain_id, token_address, pool_address, interval_type,
|
||||
open_price, high_price, low_price, close_price, volume, volume_usd, timestamp
|
||||
)
|
||||
VALUES ${values.join(', ')}
|
||||
ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET
|
||||
open_price = EXCLUDED.open_price,
|
||||
high_price = EXCLUDED.high_price,
|
||||
low_price = EXCLUDED.low_price,
|
||||
close_price = EXCLUDED.close_price,
|
||||
volume = EXCLUDED.volume,
|
||||
volume_usd = EXCLUDED.volume_usd`,
|
||||
params
|
||||
);
|
||||
try {
|
||||
await this.pool.query(
|
||||
`INSERT INTO token_ohlcv (
|
||||
chain_id, token_address, pool_address, interval_type,
|
||||
open_price, high_price, low_price, close_price, volume, volume_usd, timestamp
|
||||
)
|
||||
VALUES ${values.join(', ')}
|
||||
ON CONFLICT (chain_id, token_address, pool_address, interval_type, timestamp) DO UPDATE SET
|
||||
open_price = EXCLUDED.open_price,
|
||||
high_price = EXCLUDED.high_price,
|
||||
low_price = EXCLUDED.low_price,
|
||||
close_price = EXCLUDED.close_price,
|
||||
volume = EXCLUDED.volume,
|
||||
volume_usd = EXCLUDED.volume_usd`,
|
||||
params
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('OHLCV storage', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,37 +208,45 @@ export class OHLCVGenerator {
|
||||
to: Date,
|
||||
poolAddress?: string
|
||||
): Promise<OHLCVData[]> {
|
||||
let query = `
|
||||
SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd
|
||||
FROM token_ohlcv
|
||||
WHERE chain_id = $1
|
||||
AND token_address = $2
|
||||
AND interval_type = $3
|
||||
AND timestamp >= $4
|
||||
AND timestamp <= $5
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), interval, from, to];
|
||||
try {
|
||||
let query = `
|
||||
SELECT timestamp, open_price, high_price, low_price, close_price, volume, volume_usd
|
||||
FROM token_ohlcv
|
||||
WHERE chain_id = $1
|
||||
AND token_address = $2
|
||||
AND interval_type = $3
|
||||
AND timestamp >= $4
|
||||
AND timestamp <= $5
|
||||
`;
|
||||
const params: (string | number | Date)[] = [chainId, tokenAddress.toLowerCase(), interval, from, to];
|
||||
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $6`;
|
||||
params.push(poolAddress.toLowerCase());
|
||||
} else {
|
||||
query += ` AND pool_address IS NULL`;
|
||||
if (poolAddress) {
|
||||
query += ` AND pool_address = $6`;
|
||||
params.push(this.normalizePoolAddress(poolAddress));
|
||||
} else {
|
||||
query += ` AND pool_address = ''`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
open: parseFloat(row.open_price),
|
||||
high: parseFloat(row.high_price),
|
||||
low: parseFloat(row.low_price),
|
||||
close: parseFloat(row.close_price),
|
||||
volume: parseFloat(row.volume || '0'),
|
||||
volumeUsd: parseFloat(row.volume_usd || '0'),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('OHLCV lookup', error);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
query += ` ORDER BY timestamp ASC`;
|
||||
|
||||
const result = await this.pool.query(query, params);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
open: parseFloat(row.open_price),
|
||||
high: parseFloat(row.high_price),
|
||||
low: parseFloat(row.low_price),
|
||||
close: parseFloat(row.close_price),
|
||||
volume: parseFloat(row.volume || '0'),
|
||||
volumeUsd: parseFloat(row.volume_usd || '0'),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ethers } from 'ethers';
|
||||
import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo';
|
||||
import { getDexFactories, UniswapV2Config, UniswapV3Config, DodoConfig } from '../config/dex-factories';
|
||||
import { logger } from '../utils/logger';
|
||||
import { shouldExposePublicPool } from '../config/gru-transport';
|
||||
import { estimateChain138DodoLiquidityUsd } from '../services/chain138-dodo-liquidity';
|
||||
|
||||
// UniswapV2 Factory ABI
|
||||
const UNISWAP_V2_FACTORY_ABI = [
|
||||
@@ -49,6 +51,8 @@ const DODO_PMM_INTEGRATION_ABI = [
|
||||
];
|
||||
|
||||
export class PoolIndexer {
|
||||
private static missingDexConfigLogged = new Set<number>();
|
||||
private static staleDodoPoolsLogged = new Set<string>();
|
||||
private provider: ethers.JsonRpcProvider;
|
||||
private poolRepo: PoolRepository;
|
||||
private chainId: number;
|
||||
@@ -59,13 +63,35 @@ export class PoolIndexer {
|
||||
this.poolRepo = new PoolRepository();
|
||||
}
|
||||
|
||||
private async persistPoolIfExposed(pool: LiquidityPool, pools: LiquidityPool[]): Promise<void> {
|
||||
if (!shouldExposePublicPool(this.chainId, pool.poolAddress, pool.token0Address, pool.token1Address)) {
|
||||
logger.info(
|
||||
`Skipping inactive GRU public pool ${pool.poolAddress} on chain ${this.chainId} until gru-transport-active.json marks it active`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index all pools for configured DEX types
|
||||
*/
|
||||
async indexAllPools(): Promise<LiquidityPool[]> {
|
||||
const dexConfig = getDexFactories(this.chainId);
|
||||
if (!dexConfig) {
|
||||
logger.warn(`No DEX configuration found for chain ${this.chainId}`);
|
||||
const hasDexConfig =
|
||||
!!dexConfig &&
|
||||
(dexConfig.uniswap_v2?.length ||
|
||||
dexConfig.uniswap_v3?.length ||
|
||||
dexConfig.dodo?.length ||
|
||||
dexConfig.custom?.length);
|
||||
|
||||
if (!hasDexConfig) {
|
||||
if (!PoolIndexer.missingDexConfigLogged.has(this.chainId)) {
|
||||
logger.info(`Skipping pool indexing for chain ${this.chainId}; no DEX configuration is active yet`);
|
||||
PoolIndexer.missingDexConfigLogged.add(this.chainId);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -133,12 +159,15 @@ export class PoolIndexer {
|
||||
const quoteReserve = (reservesResult as [bigint, bigint])[1];
|
||||
const price = typeof priceResult === 'bigint' ? priceResult : BigInt(0);
|
||||
|
||||
// totalLiquidityUsd: baseReserve * price (quote per base) + quoteReserve, in 18 decimals then scale
|
||||
let totalLiquidityUsd = 0;
|
||||
if (price > 0n) {
|
||||
const baseValue = (baseReserve * price) / BigInt(1e18);
|
||||
totalLiquidityUsd = parseFloat(ethers.formatEther(baseValue + quoteReserve));
|
||||
}
|
||||
const liquidityUsd = this.chainId === 138
|
||||
? estimateChain138DodoLiquidityUsd({
|
||||
token0Address: baseToken,
|
||||
token1Address: quoteToken,
|
||||
reserve0: baseReserve,
|
||||
reserve1: quoteReserve,
|
||||
price,
|
||||
})
|
||||
: { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
|
||||
const pool: LiquidityPool = {
|
||||
chainId: this.chainId,
|
||||
@@ -149,19 +178,24 @@ export class PoolIndexer {
|
||||
factoryAddress: integrationAddress.toLowerCase(),
|
||||
reserve0: baseReserve.toString(),
|
||||
reserve1: quoteReserve.toString(),
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd,
|
||||
reserve0Usd: liquidityUsd.reserve0Usd,
|
||||
reserve1Usd: liquidityUsd.reserve1Usd,
|
||||
totalLiquidityUsd: liquidityUsd.totalLiquidityUsd,
|
||||
volume24h: 0, // No 24h volume from contract; requires event indexer
|
||||
createdAtBlock: 0,
|
||||
createdAtTimestamp: createdAt ? new Date(Number(createdAt) * 1000) : new Date(),
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
} catch (err) {
|
||||
logger.warn(`Skipping DODO PMM pool ${poolAddress}; it may have been removed from integration state.`, err);
|
||||
const stalePoolKey = `${this.chainId}:${poolAddress.toLowerCase()}`;
|
||||
if (!PoolIndexer.staleDodoPoolsLogged.has(stalePoolKey)) {
|
||||
logger.info(
|
||||
`Skipping stale DODO PMM pool ${poolAddress} on chain ${this.chainId}; it is no longer registered in DODOPMMIntegration`
|
||||
);
|
||||
PoolIndexer.staleDodoPoolsLogged.add(stalePoolKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -217,8 +251,7 @@ export class PoolIndexer {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -273,8 +306,7 @@ export class PoolIndexer {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -335,8 +367,7 @@ export class PoolIndexer {
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
await this.poolRepo.upsertPool(pool);
|
||||
pools.push(pool);
|
||||
await this.persistPoolIfExposed(pool, pools);
|
||||
} catch (error) {
|
||||
logger.error(`Error indexing DODO pool ${poolAddress}:`, error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const mockQuery = jest.fn();
|
||||
const mockLoggerInfo = jest.fn();
|
||||
|
||||
jest.mock('../database/client', () => ({
|
||||
getDatabasePool: () => ({ query: mockQuery }),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
logger: {
|
||||
info: mockLoggerInfo,
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { VolumeCalculator } from './volume-calculator';
|
||||
|
||||
describe('VolumeCalculator', () => {
|
||||
beforeEach(() => {
|
||||
mockQuery.mockReset();
|
||||
mockLoggerInfo.mockReset();
|
||||
});
|
||||
|
||||
it('returns zero metrics and logs once when market-data tables are not provisioned yet', async () => {
|
||||
mockQuery.mockRejectedValue({
|
||||
code: '42P01',
|
||||
message: 'relation "liquidity_pools" does not exist',
|
||||
});
|
||||
|
||||
const calculator = new VolumeCalculator();
|
||||
|
||||
await expect(calculator.calculateTokenVolume(138, '0xToken')).resolves.toEqual({
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
});
|
||||
|
||||
await expect(calculator.calculateTokenVolume(138, '0xToken')).resolves.toEqual({
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
});
|
||||
|
||||
expect(mockLoggerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
||||
'Skipping volume calculation; relation "liquidity_pools" is not available yet',
|
||||
expect.objectContaining({
|
||||
operation: 'volume calculation',
|
||||
relation: 'liquidity_pools',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface VolumeMetrics {
|
||||
volume5m: number;
|
||||
@@ -14,11 +15,44 @@ export interface VolumeMetrics {
|
||||
|
||||
export class VolumeCalculator {
|
||||
private pool: Pool;
|
||||
private static readonly missingRelationWarnings = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
this.pool = getDatabasePool();
|
||||
}
|
||||
|
||||
private isMissingRelationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = (error as { code?: string }).code;
|
||||
const message = (error as { message?: string }).message || '';
|
||||
return code === '42P01' || (message.includes('relation "') && message.includes('" does not exist'));
|
||||
}
|
||||
|
||||
private getMissingRelationName(error: unknown): string {
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: string }).message || '')
|
||||
: '';
|
||||
const match = message.match(/relation "([^"]+)"/);
|
||||
return match?.[1] || 'unknown relation';
|
||||
}
|
||||
|
||||
private logMissingRelationOnce(operation: string, error: unknown): void {
|
||||
const relation = this.getMissingRelationName(error);
|
||||
const key = `${operation}:${relation}`;
|
||||
if (VolumeCalculator.missingRelationWarnings.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
VolumeCalculator.missingRelationWarnings.add(key);
|
||||
logger.info(`Skipping ${operation}; relation "${relation}" is not available yet`, {
|
||||
operation,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate volume metrics for a token across all pools
|
||||
*/
|
||||
@@ -27,56 +61,55 @@ export class VolumeCalculator {
|
||||
tokenAddress: string,
|
||||
now: Date = new Date()
|
||||
): Promise<VolumeMetrics> {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// Get all pools for this token
|
||||
const poolsResult = await this.pool.query(
|
||||
`SELECT pool_address FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
const poolAddresses = poolsResult.rows.map((row) => row.pool_address);
|
||||
|
||||
if (poolAddresses.length === 0) {
|
||||
return {
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
try {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// Get all pools for this token
|
||||
const poolsResult = await this.pool.query(
|
||||
`SELECT pool_address FROM liquidity_pools
|
||||
WHERE chain_id = $1 AND (token0_address = $2 OR token1_address = $2)`,
|
||||
[chainId, tokenAddress.toLowerCase()]
|
||||
);
|
||||
|
||||
const poolAddresses = poolsResult.rows.map((row) => row.pool_address);
|
||||
|
||||
if (poolAddresses.length === 0) {
|
||||
return this.zeroMetrics();
|
||||
}
|
||||
|
||||
// Calculate volume for each interval
|
||||
const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now),
|
||||
this.calculateTxCounts(chainId, poolAddresses, intervals, now),
|
||||
]);
|
||||
|
||||
return {
|
||||
volume5m,
|
||||
volume1h,
|
||||
volume24h,
|
||||
volume7d,
|
||||
volume30d,
|
||||
txCount5m: txCounts['5m'],
|
||||
txCount1h: txCounts['1h'],
|
||||
txCount24h: txCounts['24h'],
|
||||
};
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('volume calculation', error);
|
||||
return this.zeroMetrics();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate volume for each interval
|
||||
const [volume5m, volume1h, volume24h, volume7d, volume30d, txCounts] = await Promise.all([
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['5m'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['1h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['24h'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['7d'], now),
|
||||
this.calculateVolumeForInterval(chainId, poolAddresses, tokenAddress, intervals['30d'], now),
|
||||
this.calculateTxCounts(chainId, poolAddresses, intervals, now),
|
||||
]);
|
||||
|
||||
return {
|
||||
volume5m,
|
||||
volume1h,
|
||||
volume24h,
|
||||
volume7d,
|
||||
volume30d,
|
||||
txCount5m: txCounts['5m'],
|
||||
txCount1h: txCounts['1h'],
|
||||
txCount24h: txCounts['24h'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,24 +197,45 @@ export class VolumeCalculator {
|
||||
interval: '5m' | '1h' | '24h' | '7d' | '30d',
|
||||
now: Date = new Date()
|
||||
): Promise<number> {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
try {
|
||||
const intervals = {
|
||||
'5m': new Date(now.getTime() - 5 * 60 * 1000),
|
||||
'1h': new Date(now.getTime() - 60 * 60 * 1000),
|
||||
'24h': new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
'7d': new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
|
||||
'30d': new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
const result = await this.pool.query(
|
||||
`SELECT COALESCE(SUM(amount_usd), 0) as total_volume
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = $2
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddress.toLowerCase(), intervals[interval], now]
|
||||
);
|
||||
|
||||
return parseFloat(result.rows[0]?.total_volume || '0');
|
||||
} catch (error) {
|
||||
if (this.isMissingRelationError(error)) {
|
||||
this.logMissingRelationOnce('pool volume calculation', error);
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private zeroMetrics(): VolumeMetrics {
|
||||
return {
|
||||
volume5m: 0,
|
||||
volume1h: 0,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
txCount5m: 0,
|
||||
txCount1h: 0,
|
||||
txCount24h: 0,
|
||||
};
|
||||
|
||||
const result = await this.pool.query(
|
||||
`SELECT COALESCE(SUM(amount_usd), 0) as total_volume
|
||||
FROM swap_events
|
||||
WHERE chain_id = $1
|
||||
AND pool_address = $2
|
||||
AND timestamp >= $3
|
||||
AND timestamp <= $4`,
|
||||
[chainId, poolAddress.toLowerCase(), intervals[interval], now]
|
||||
);
|
||||
|
||||
return parseFloat(result.rows[0]?.total_volume || '0');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
AggregatorFamily,
|
||||
AggregatorRouteMatrix,
|
||||
AggregatorRouteLeg,
|
||||
LiveAggregatorRoute,
|
||||
NonLiveAggregatorRoute,
|
||||
} from '../config/aggregator-route-matrix';
|
||||
import { getProviderCapabilities } from '../config/provider-capabilities';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { getChain138RoutingAssets, getRoutingSymbolForAddress } from '../config/routing-assets';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
import { PlannerProvider, SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const PARTNER_FAMILIES: AggregatorFamily[] = ['1inch', '0x', 'LiFi'];
|
||||
|
||||
function providerProtocol(provider: PlannerProvider): string {
|
||||
switch (provider) {
|
||||
case 'dodo':
|
||||
return 'dodo_pmm';
|
||||
case 'dodo_v3':
|
||||
return 'dodo_v3';
|
||||
case 'uniswap_v3':
|
||||
return 'uniswap_v3';
|
||||
case 'balancer':
|
||||
return 'balancer';
|
||||
case 'curve':
|
||||
return 'curve';
|
||||
case 'one_inch':
|
||||
return 'one_inch';
|
||||
case 'partner':
|
||||
return 'partner';
|
||||
}
|
||||
}
|
||||
|
||||
function providerLabel(provider: PlannerProvider): string {
|
||||
switch (provider) {
|
||||
case 'dodo':
|
||||
return 'DODO PMM';
|
||||
case 'dodo_v3':
|
||||
return 'DODO V3 / D3MM';
|
||||
case 'uniswap_v3':
|
||||
return 'Uniswap V3';
|
||||
case 'balancer':
|
||||
return 'Balancer';
|
||||
case 'curve':
|
||||
return 'Curve';
|
||||
case 'one_inch':
|
||||
return '1inch';
|
||||
case 'partner':
|
||||
return 'Partner';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAddress(value?: string): string | undefined {
|
||||
return value?.trim().toLowerCase() || undefined;
|
||||
}
|
||||
|
||||
function makeRouteId(prefix: string, parts: Array<string | number | undefined>): string {
|
||||
return [prefix, ...parts]
|
||||
.filter((part): part is string | number => part !== undefined && part !== null && String(part).trim().length > 0)
|
||||
.map((part) => String(part).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
function dedupeRoutes<T extends { routeId: string }>(routes: T[]): T[] {
|
||||
const byId = new Map<string, T>();
|
||||
for (const route of routes) {
|
||||
byId.set(route.routeId, route);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function toSwapLeg(edge: SwapGraphEdge): AggregatorRouteLeg {
|
||||
return {
|
||||
kind: 'swap',
|
||||
protocol: providerProtocol(edge.provider),
|
||||
executor: providerLabel(edge.provider),
|
||||
executorAddress: normalizeAddress(edge.target),
|
||||
poolAddress: normalizeAddress(edge.poolAddress),
|
||||
tokenInAddress: normalizeAddress(edge.tokenInAddress),
|
||||
tokenOutAddress: normalizeAddress(edge.tokenOutAddress),
|
||||
reserves: {
|
||||
reserveIn: edge.reserveIn,
|
||||
reserveOut: edge.reserveOut,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSwapRoute(homeChainId: number, edge: SwapGraphEdge): LiveAggregatorRoute {
|
||||
return {
|
||||
routeId: makeRouteId(`chain-${homeChainId}-swap`, [
|
||||
edge.provider,
|
||||
edge.tokenInSymbol || edge.tokenInAddress,
|
||||
edge.tokenOutSymbol || edge.tokenOutAddress,
|
||||
edge.poolAddress?.slice(0, 10),
|
||||
]),
|
||||
status: 'live',
|
||||
aggregatorFamilies: PARTNER_FAMILIES,
|
||||
fromChainId: homeChainId,
|
||||
toChainId: homeChainId,
|
||||
tokenInSymbol: edge.tokenInSymbol,
|
||||
tokenInAddress: normalizeAddress(edge.tokenInAddress),
|
||||
tokenOutSymbol: edge.tokenOutSymbol,
|
||||
tokenOutAddress: normalizeAddress(edge.tokenOutAddress),
|
||||
routeType: 'swap',
|
||||
hopCount: 1,
|
||||
label: `${providerLabel(edge.provider)} ${edge.tokenInSymbol || edge.tokenInAddress} -> ${edge.tokenOutSymbol || edge.tokenOutAddress}`,
|
||||
intermediateSymbols: [],
|
||||
legs: [toSwapLeg(edge)],
|
||||
tags: ['planner-v2-generated', edge.provider],
|
||||
notes: [
|
||||
'Generated from live planner route graph.',
|
||||
...(edge.notes || []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildBridgeRoute(args: {
|
||||
fromChainId: number;
|
||||
toChainId: number;
|
||||
assetSymbol: string;
|
||||
assetAddress?: string;
|
||||
bridgeType: string;
|
||||
bridgeAddress: string;
|
||||
label: string;
|
||||
notes?: string[];
|
||||
}): LiveAggregatorRoute {
|
||||
return {
|
||||
routeId: makeRouteId('bridge', [
|
||||
args.fromChainId,
|
||||
args.toChainId,
|
||||
args.assetSymbol,
|
||||
args.label,
|
||||
]),
|
||||
status: 'live',
|
||||
aggregatorFamilies: PARTNER_FAMILIES,
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
assetSymbol: args.assetSymbol,
|
||||
assetAddress: normalizeAddress(args.assetAddress),
|
||||
routeType: 'bridge',
|
||||
bridgeType: args.bridgeType,
|
||||
bridgeAddress: normalizeAddress(args.bridgeAddress),
|
||||
label: `${args.label} ${args.assetSymbol} ${args.fromChainId} -> ${args.toChainId}`,
|
||||
tags: ['planner-v2-generated', 'bridge'],
|
||||
notes: [
|
||||
'Generated from bridge registry and planner visibility.',
|
||||
...(args.notes || []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultOutputPath(): string {
|
||||
return path.resolve(process.cwd(), '../../../config/aggregator-route-matrix.json');
|
||||
}
|
||||
|
||||
export class AggregatorRouteMatrixGenerator {
|
||||
private graphBuilder: RouteGraphBuilder;
|
||||
|
||||
constructor(graphBuilder = new RouteGraphBuilder()) {
|
||||
this.graphBuilder = graphBuilder;
|
||||
}
|
||||
|
||||
async generate(homeChainId: number = 138): Promise<AggregatorRouteMatrix> {
|
||||
const edges = await this.graphBuilder.buildSwapEdges(homeChainId);
|
||||
const liveSwapRoutes = dedupeRoutes(edges.map((edge) => buildSwapRoute(homeChainId, edge)));
|
||||
|
||||
const routingAssets = Object.values(getChain138RoutingAssets());
|
||||
const bridgeTargets = [1, 651940];
|
||||
const liveBridgeRoutes = dedupeRoutes(
|
||||
bridgeTargets.flatMap((destinationChainId) =>
|
||||
this.graphBuilder
|
||||
.buildBridgeCandidates(
|
||||
homeChainId,
|
||||
destinationChainId,
|
||||
routingAssets.map((asset) => asset.symbol)
|
||||
)
|
||||
.map((candidate) =>
|
||||
buildBridgeRoute({
|
||||
fromChainId: candidate.fromChainId,
|
||||
toChainId: candidate.toChainId,
|
||||
assetSymbol: candidate.assetSymbol,
|
||||
assetAddress: candidate.sourceTokenAddress,
|
||||
bridgeType: candidate.bridgeType,
|
||||
bridgeAddress: candidate.bridgeAddress,
|
||||
label: candidate.bridgeLabel,
|
||||
notes: candidate.notes,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const blockedOrPlannedRoutes: NonLiveAggregatorRoute[] = getProviderCapabilities(homeChainId)
|
||||
.flatMap((record) =>
|
||||
record.pairs
|
||||
.filter((pair): pair is typeof pair & { status: 'planned' | 'blocked' } => pair.status !== 'live')
|
||||
.map((pair) => ({
|
||||
routeId: makeRouteId('chain-138-capability', [
|
||||
record.provider,
|
||||
pair.status,
|
||||
pair.tokenInSymbol,
|
||||
pair.tokenOutSymbol,
|
||||
]),
|
||||
status: pair.status,
|
||||
fromChainId: pair.chainId,
|
||||
toChainId: pair.chainId,
|
||||
routeType: pair.legType === 'bridge' ? 'bridge' : 'swap',
|
||||
reason: pair.reason || pair.notes?.join(' ') || `${record.provider} ${pair.status}`,
|
||||
tokenInSymbols: [pair.tokenInSymbol, pair.tokenOutSymbol],
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
description: 'Planner-v2-generated aggregator route visibility matrix for Chain 138 and approved bridge lanes.',
|
||||
version: '2.0.0',
|
||||
updated: new Date().toISOString(),
|
||||
homeChainId,
|
||||
metadata: {
|
||||
generatedFrom: [
|
||||
'services/token-aggregation/src/services/route-graph-builder.ts',
|
||||
'services/token-aggregation/src/config/provider-capabilities.ts',
|
||||
'services/token-aggregation/src/config/cross-chain-bridges.ts',
|
||||
],
|
||||
verification: {
|
||||
verifiedAt: new Date().toISOString(),
|
||||
verifiedBy: 'services/token-aggregation planner-v2 generator',
|
||||
rpc: resolveChain138RpcUrl(),
|
||||
},
|
||||
adapterNotes: [
|
||||
'This file is generated from planner-v2 graph and provider capability truth.',
|
||||
'Partner payload generation should prefer planner-v2 outputs over this visibility artifact when route inputs are available.',
|
||||
'Only live routes should be considered executable candidates.',
|
||||
],
|
||||
},
|
||||
chains: {
|
||||
'138': { name: 'Chain 138' },
|
||||
'1': { name: 'Ethereum Mainnet' },
|
||||
'651940': { name: 'ALL Mainnet' },
|
||||
},
|
||||
tokens: Object.fromEntries(
|
||||
routingAssets.map((asset) => [asset.symbol, { address: asset.address, decimals: asset.decimals, kind: asset.kind }])
|
||||
),
|
||||
liveSwapRoutes,
|
||||
liveBridgeRoutes,
|
||||
blockedOrPlannedRoutes: dedupeRoutes(blockedOrPlannedRoutes),
|
||||
};
|
||||
}
|
||||
|
||||
async writeToFile(outputPath: string = resolveDefaultOutputPath(), homeChainId: number = 138): Promise<string> {
|
||||
const matrix = await this.generate(homeChainId);
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(matrix, null, 2)}\n`, 'utf8');
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function routeFromPlannerLegs(args: {
|
||||
routeId: string;
|
||||
fromChainId: number;
|
||||
toChainId: number;
|
||||
tokenInAddress?: string;
|
||||
tokenOutAddress?: string;
|
||||
assetAddress?: string;
|
||||
assetSymbol?: string;
|
||||
routeType: 'swap' | 'bridge';
|
||||
bridgeType?: string;
|
||||
bridgeAddress?: string;
|
||||
label: string;
|
||||
legs: AggregatorRouteLeg[];
|
||||
notes?: string[];
|
||||
}): LiveAggregatorRoute {
|
||||
return {
|
||||
routeId: args.routeId,
|
||||
status: 'live',
|
||||
aggregatorFamilies: PARTNER_FAMILIES,
|
||||
fromChainId: args.fromChainId,
|
||||
toChainId: args.toChainId,
|
||||
tokenInAddress: normalizeAddress(args.tokenInAddress),
|
||||
tokenOutAddress: normalizeAddress(args.tokenOutAddress),
|
||||
tokenInSymbol: args.tokenInAddress ? getRoutingSymbolForAddress(args.fromChainId, args.tokenInAddress) : undefined,
|
||||
tokenOutSymbol: args.tokenOutAddress ? getRoutingSymbolForAddress(args.toChainId, args.tokenOutAddress) : undefined,
|
||||
assetAddress: normalizeAddress(args.assetAddress),
|
||||
assetSymbol: args.assetSymbol,
|
||||
routeType: args.routeType,
|
||||
bridgeType: args.bridgeType,
|
||||
bridgeAddress: normalizeAddress(args.bridgeAddress),
|
||||
label: args.label,
|
||||
hopCount: args.routeType === 'swap' ? args.legs.length : undefined,
|
||||
legs: args.legs,
|
||||
tags: ['planner-v2-generated'],
|
||||
notes: args.notes || [],
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PoolRepository } from '../database/repositories/pool-repo';
|
||||
import { TokenRepository } from '../database/repositories/token-repo';
|
||||
import { getRoutesList, getChainIds } from '../config/heatmap-chains';
|
||||
import { filterPoolsForRouting, getActiveTransportPairs } from '../config/gru-transport';
|
||||
|
||||
export interface ArbitrageOpportunity {
|
||||
cycleId: string;
|
||||
@@ -21,12 +22,18 @@ const poolRepo = new PoolRepository();
|
||||
const tokenRepo = new TokenRepository();
|
||||
|
||||
const HUB_CHAIN = 138;
|
||||
const EDGE_CHAINS = getChainIds().filter((c) => c !== HUB_CHAIN && c !== 651940);
|
||||
const EDGE_CHAINS = Array.from(
|
||||
new Set(
|
||||
getActiveTransportPairs()
|
||||
.map((pair) => pair.destinationChainId)
|
||||
.filter((chainId) => chainId !== HUB_CHAIN && chainId !== 651940)
|
||||
)
|
||||
);
|
||||
|
||||
/** Same-chain triangle on 138: e.g. cUSDT -> cUSDC -> cUSDT via two pools. */
|
||||
async function getSameChainCycles(): Promise<ArbitrageOpportunity[]> {
|
||||
const out: ArbitrageOpportunity[] = [];
|
||||
const pools = await poolRepo.getPoolsByChain(HUB_CHAIN, 100);
|
||||
const pools = filterPoolsForRouting(HUB_CHAIN, await poolRepo.getPoolsByChain(HUB_CHAIN, 100));
|
||||
for (const p of pools) {
|
||||
const t0 = await tokenRepo.getToken(HUB_CHAIN, p.token0Address);
|
||||
const t1 = await tokenRepo.getToken(HUB_CHAIN, p.token1Address);
|
||||
@@ -53,7 +60,8 @@ async function getSameChainCycles(): Promise<ArbitrageOpportunity[]> {
|
||||
/** Hub-edge-hub: 138 -> edge -> 138 (SBS). */
|
||||
function getHubEdgeHubCycles(): ArbitrageOpportunity[] {
|
||||
const out: ArbitrageOpportunity[] = [];
|
||||
const routes = getRoutesList();
|
||||
const activeDestinations = new Set(EDGE_CHAINS);
|
||||
const routes = getRoutesList().filter((route) => activeDestinations.has(route.toChainId));
|
||||
const hubOut = routes.filter((r) => r.fromChainId === HUB_CHAIN && r.toChainId !== HUB_CHAIN);
|
||||
for (const r of hubOut.slice(0, 5)) {
|
||||
out.push({
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { BestExecutionPlanner } from './best-execution-planner';
|
||||
import { PlannerMetricsRepository } from '../database/repositories/planner-metrics-repo';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
import type { BridgeRouteCandidate, PlannerResponse, SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const mockDodoV3Quote = jest.fn();
|
||||
|
||||
jest.mock('./dodo-v3-pilot', () => ({
|
||||
__esModule: true,
|
||||
quoteChain138DodoV3AmountOut: (...args: unknown[]) => mockDodoV3Quote(...args),
|
||||
encodeChain138DodoV3ProviderData: (poolAddress: string) =>
|
||||
`0x000000000000000000000000${String(poolAddress).replace(/^0x/, '').toLowerCase()}`,
|
||||
isChain138DodoV3ExecutionLive: () => true,
|
||||
}));
|
||||
|
||||
class MockPlannerMetricsRepository {
|
||||
async getCachedPlan(): Promise<PlannerResponse | null> {
|
||||
return null;
|
||||
}
|
||||
async cachePlan(): Promise<void> {}
|
||||
async recordProviderSnapshots(): Promise<void> {}
|
||||
async recordPlannedRouteMetrics(): Promise<void> {}
|
||||
}
|
||||
|
||||
class MockGraphBuilder {
|
||||
private swapEdges: SwapGraphEdge[];
|
||||
private bridgeCandidates: BridgeRouteCandidate[];
|
||||
|
||||
constructor(swapEdges: SwapGraphEdge[] = [], bridgeCandidates: BridgeRouteCandidate[] = []) {
|
||||
this.swapEdges = swapEdges;
|
||||
this.bridgeCandidates = bridgeCandidates;
|
||||
}
|
||||
|
||||
async buildSwapEdges(): Promise<SwapGraphEdge[]> {
|
||||
return this.swapEdges;
|
||||
}
|
||||
|
||||
buildBridgeCandidates(): BridgeRouteCandidate[] {
|
||||
return this.bridgeCandidates;
|
||||
}
|
||||
}
|
||||
|
||||
const WETH = '0xc02aaA39b223fe8d0a0e5c4f27ead9083c756cc2'.toLowerCase();
|
||||
const USDT = '0x004b63a7b5b0e06f6bb6adb4a5f9f590bf3182d1'.toLowerCase();
|
||||
const USDC = '0x71d6687f38b93ccad569fa6352c876eea967201b'.toLowerCase();
|
||||
const CUSDT = '0x93e66202a11b1772e55407b32b44e5cd8eda7f22'.toLowerCase();
|
||||
const CEURT = '0xdf4b71c61e5912712c1bdd451416b9ac26949d72'.toLowerCase();
|
||||
const CXAUC = '0x290e52a8819a4fbd0714e517225429aa2b70ec6b'.toLowerCase();
|
||||
const WETH10 = '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f'.toLowerCase();
|
||||
|
||||
function swapEdge(
|
||||
tokenInAddress: string,
|
||||
tokenOutAddress: string,
|
||||
reserveIn: string,
|
||||
reserveOut: string,
|
||||
tokenInSymbol: string,
|
||||
tokenOutSymbol: string,
|
||||
provider: SwapGraphEdge['provider'] = 'dodo'
|
||||
): SwapGraphEdge {
|
||||
return {
|
||||
kind: 'swap',
|
||||
provider,
|
||||
chainId: 138,
|
||||
tokenInAddress,
|
||||
tokenOutAddress,
|
||||
tokenInSymbol,
|
||||
tokenOutSymbol,
|
||||
reserveIn,
|
||||
reserveOut,
|
||||
target: '0x3f729632e9553ebaccde2e9b4c8f2b285b014f2e',
|
||||
poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
providerData: { poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
|
||||
providerDataHex: '0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
totalLiquidityUsd: 2_000_000,
|
||||
freshnessSeconds: 60,
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('BestExecutionPlanner', () => {
|
||||
beforeEach(() => {
|
||||
mockDodoV3Quote.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct route when it scores better than multi-hop', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH, USDT, '100000000000000000000', '2000000000000', 'WETH', 'USDT'),
|
||||
swapEdge(WETH, CUSDT, '100000000000000000000', '1900000000000', 'WETH', 'cUSDT'),
|
||||
swapEdge(CUSDT, USDT, '1900000000000', '1850000000000', 'cUSDT', 'USDT'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH,
|
||||
tokenOut: USDT,
|
||||
amountIn: '1000000000000000000',
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('direct-pool');
|
||||
expect(response.legs).toHaveLength(1);
|
||||
expect(response.legs[0].tokenOutAddress).toBe(USDT);
|
||||
});
|
||||
|
||||
it('chooses multi-hop when no direct route exists', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH, CUSDT, '100000000000000000000', '1900000000000', 'WETH', 'cUSDT'),
|
||||
swapEdge(CUSDT, USDC, '1900000000000', '1880000000000', 'cUSDT', 'USDC'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH,
|
||||
tokenOut: USDC,
|
||||
amountIn: '1000000000000000000',
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('multi-hop');
|
||||
expect(response.legs).toHaveLength(2);
|
||||
expect(response.legs[0].tokenOutAddress).toBe(CUSDT);
|
||||
expect(response.legs[1].tokenOutAddress).toBe(USDC);
|
||||
});
|
||||
|
||||
it('blocks commodity intermediates under institutional policy unless explicitly enabled', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(CEURT, CXAUC, '1000000000000', '1000000000000', 'cEURT', 'cXAUC'),
|
||||
swapEdge(CXAUC, CUSDT, '1000000000000', '1000000000000', 'cXAUC', 'cUSDT'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: CEURT,
|
||||
tokenOut: CUSDT,
|
||||
amountIn: '1000000',
|
||||
constraints: {
|
||||
complianceProfile: 'institutional',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('unresolved');
|
||||
expect(response.riskFlags).toContain('no-route');
|
||||
});
|
||||
|
||||
it('returns deterministic plan ids for identical requests', async () => {
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH, USDT, '100000000000000000000', '2000000000000', 'WETH', 'USDT'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const request = {
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH,
|
||||
tokenOut: USDT,
|
||||
amountIn: '1000000000000000000',
|
||||
};
|
||||
|
||||
const first = await planner.plan(request);
|
||||
const second = await planner.plan(request);
|
||||
|
||||
expect(first.planId).toBe(second.planId);
|
||||
expect(first.legs).toEqual(second.legs);
|
||||
});
|
||||
|
||||
it('returns a live planner route and executable router-v2 calldata for the DODO v3 pilot when execution is enabled', async () => {
|
||||
mockDodoV3Quote.mockResolvedValue(211660490n);
|
||||
|
||||
const graphBuilder = new MockGraphBuilder([
|
||||
swapEdge(WETH10, USDT, '2010000000000000000', '4978833460', 'WETH10', 'USDT', 'dodo_v3'),
|
||||
]) as unknown as RouteGraphBuilder;
|
||||
|
||||
const planner = new BestExecutionPlanner(graphBuilder, new MockPlannerMetricsRepository() as unknown as PlannerMetricsRepository);
|
||||
const response = await planner.plan({
|
||||
sourceChainId: 138,
|
||||
tokenIn: WETH10,
|
||||
tokenOut: USDT,
|
||||
amountIn: '100000000000000000',
|
||||
});
|
||||
|
||||
expect(response.decision).toBe('direct-pool');
|
||||
expect(response.legs).toHaveLength(1);
|
||||
expect(response.legs[0].provider).toBe('dodo_v3');
|
||||
expect(response.estimatedAmountOut).toBe('211660490');
|
||||
expect(response.routePlan).toBeDefined();
|
||||
expect(response.routePlan?.legs[0]?.provider).toBe(6);
|
||||
expect(response.riskFlags).toContain('pilot-venue');
|
||||
expect(response.riskFlags).not.toContain('manual-execution-only');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,783 @@
|
||||
import { AbiCoder, Contract, JsonRpcProvider, ZeroAddress, formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress, resolveCanonicalQuoteAddress } from '../config/canonical-tokens';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { getProviderCapabilities } from '../config/provider-capabilities';
|
||||
import { resolveRoutingPolicy } from '../config/routing-policies';
|
||||
import {
|
||||
getCommodityIntermediateAddresses,
|
||||
getDefaultIntermediateAddresses,
|
||||
getRoutingAddressForSymbol,
|
||||
getRoutingSymbolForAddress,
|
||||
} from '../config/routing-assets';
|
||||
import { PlannerMetricsRepository } from '../database/repositories/planner-metrics-repo';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
import { quoteChain138DodoV3AmountOut } from './dodo-v3-pilot';
|
||||
import {
|
||||
BridgeRouteCandidate,
|
||||
EncodedBridgeIntentPlan,
|
||||
EncodedPlannerRoutePlan,
|
||||
PlannerAlternative,
|
||||
PlannerDecision,
|
||||
PlannerLeg,
|
||||
PlannerProvider,
|
||||
PlannerRequest,
|
||||
PlannerResponse,
|
||||
RoutingPolicy,
|
||||
SwapGraphEdge,
|
||||
} from './planner-v2-types';
|
||||
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
const ROUTER_V2_RECIPIENT_PLACEHOLDER = ZeroAddress;
|
||||
const DEFAULT_INTENT_BRIDGE_COORDINATOR_V2 = normalizeAddress('0x7D0022B7e8360172fd9C0bB6778113b7Ea3674E7');
|
||||
const PROVIDER_PRIORITY: PlannerProvider[] = ['dodo', 'dodo_v3', 'uniswap_v3', 'balancer', 'curve', 'one_inch', 'partner'];
|
||||
const PROVIDER_ENUM: Partial<Record<PlannerProvider, number>> = {
|
||||
dodo: 0,
|
||||
uniswap_v3: 1,
|
||||
balancer: 2,
|
||||
curve: 3,
|
||||
one_inch: 4,
|
||||
partner: 5,
|
||||
dodo_v3: 6,
|
||||
};
|
||||
const PROVIDER_GAS_USD: Record<PlannerProvider, number> = {
|
||||
dodo: 0.22,
|
||||
dodo_v3: 0.3,
|
||||
uniswap_v3: 0.28,
|
||||
balancer: 0.34,
|
||||
curve: 0.29,
|
||||
one_inch: 0.48,
|
||||
partner: 0.55,
|
||||
};
|
||||
const uniswapQuoterAbi = [
|
||||
'function quoteExactInputSingle((address,address,uint256,uint24,uint160) params) view returns (uint256,uint160,uint32,uint256)',
|
||||
'function quoteExactInputSingle(address tokenIn,address tokenOut,uint24 fee,uint256 amountIn,uint160 sqrtPriceLimitX96) view returns (uint256)',
|
||||
] as const;
|
||||
|
||||
interface RouteCandidate {
|
||||
decision: PlannerDecision;
|
||||
estimatedAmountOut: bigint;
|
||||
estimatedGasUsd: number;
|
||||
score: number;
|
||||
legs: PlannerLeg[];
|
||||
selectedRouteReason: string;
|
||||
rejectedAlternatives: string[];
|
||||
riskFlags: string[];
|
||||
}
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
let sharedProvider: JsonRpcProvider | null = null;
|
||||
let sharedProviderUrl = '';
|
||||
|
||||
function getProvider(): JsonRpcProvider {
|
||||
const rpcUrl = resolveChain138RpcUrl();
|
||||
if (!sharedProvider || sharedProviderUrl !== rpcUrl) {
|
||||
sharedProvider = new JsonRpcProvider(rpcUrl);
|
||||
sharedProviderUrl = rpcUrl;
|
||||
}
|
||||
return sharedProvider;
|
||||
}
|
||||
|
||||
function safeBigInt(value: string): bigint {
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
function quoteAmountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint {
|
||||
if (amountIn <= 0n || reserveIn <= 0n || reserveOut <= 0n) {
|
||||
return 0n;
|
||||
}
|
||||
const amountInWithFee = amountIn * 997n;
|
||||
return (reserveOut * amountInWithFee) / (reserveIn * 1000n + amountInWithFee);
|
||||
}
|
||||
|
||||
function sortEdges(edges: SwapGraphEdge[]): SwapGraphEdge[] {
|
||||
return edges.slice().sort((a, b) => {
|
||||
const providerDiff = PROVIDER_PRIORITY.indexOf(a.provider) - PROVIDER_PRIORITY.indexOf(b.provider);
|
||||
if (providerDiff !== 0) return providerDiff;
|
||||
const liquidityDiff = (b.totalLiquidityUsd || 0) - (a.totalLiquidityUsd || 0);
|
||||
if (liquidityDiff !== 0) return liquidityDiff;
|
||||
return a.tokenOutAddress.localeCompare(b.tokenOutAddress);
|
||||
});
|
||||
}
|
||||
|
||||
function decimalsForAddress(chainId: number, address: string): number {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address);
|
||||
if (spec?.decimals) return Number(spec.decimals);
|
||||
const symbol = getRoutingSymbolForAddress(chainId, address);
|
||||
if (symbol === 'WETH' || symbol === 'WETH10') return 18;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function normalizedOutput(chainId: number, address: string, amount: bigint): number {
|
||||
const decimals = decimalsForAddress(chainId, address);
|
||||
return Number(formatUnits(amount, decimals));
|
||||
}
|
||||
|
||||
function providerDataHexForEdge(edge: SwapGraphEdge): string {
|
||||
if (edge.providerDataHex) {
|
||||
return edge.providerDataHex;
|
||||
}
|
||||
if (edge.provider === 'dodo' && edge.poolAddress) {
|
||||
return abiCoder.encode(['address'], [edge.poolAddress]);
|
||||
}
|
||||
return '0x';
|
||||
}
|
||||
|
||||
function providerDataHexForLeg(leg: PlannerLeg): string | undefined {
|
||||
if (leg.providerDataHex && leg.providerDataHex !== '0x') {
|
||||
return leg.providerDataHex;
|
||||
}
|
||||
if (leg.provider === 'dodo' && leg.poolAddress) {
|
||||
return abiCoder.encode(['address'], [leg.poolAddress]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildMinAmountOut(amountOut: bigint, maxSlippageBps: number): bigint {
|
||||
return (amountOut * BigInt(10_000 - maxSlippageBps)) / 10_000n;
|
||||
}
|
||||
|
||||
function computeConfidence(legs: PlannerLeg[]): number {
|
||||
const stalePenalty = legs.some((leg) => (leg.freshnessSeconds || 0) > 1800) ? 0.15 : 0;
|
||||
const hopPenalty = Math.max(0, legs.length - 1) * 0.07;
|
||||
return Math.max(0.1, Math.min(0.99, 0.92 - stalePenalty - hopPenalty));
|
||||
}
|
||||
|
||||
function computeRiskFlags(legs: PlannerLeg[], policy: RoutingPolicy): string[] {
|
||||
const flags = new Set<string>();
|
||||
if (legs.length > 1) {
|
||||
flags.add('multi-hop');
|
||||
}
|
||||
if (legs.some((leg) => leg.kind === 'bridge')) {
|
||||
flags.add('cross-chain');
|
||||
}
|
||||
if (legs.some((leg) => (leg.freshnessSeconds || 0) > 1800)) {
|
||||
flags.add('stale-liquidity');
|
||||
}
|
||||
if (!policy.allowCommodityIntermediates && legs.some((leg) => leg.tokenInSymbol === 'cXAUC' || leg.tokenOutSymbol === 'cXAUC')) {
|
||||
flags.add('commodity-path-blocked');
|
||||
}
|
||||
if (legs.some((leg) => leg.provider === 'dodo_v3')) {
|
||||
flags.add('pilot-venue');
|
||||
if (!legs.every((leg) => leg.provider !== 'dodo_v3' || Boolean(providerDataHexForLeg(leg)))) {
|
||||
flags.add('manual-execution-only');
|
||||
}
|
||||
}
|
||||
return Array.from(flags);
|
||||
}
|
||||
|
||||
async function quoteUniswapV3AmountOut(edge: SwapGraphEdge, amountIn: bigint): Promise<bigint> {
|
||||
const providerData = (edge.providerData || {}) as { quoter?: string; fee?: number };
|
||||
const quoter = normalizeAddress(String(providerData.quoter || ''));
|
||||
const fee = Number(providerData.fee || 3000);
|
||||
if (!quoter) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const contract = new Contract(quoter, uniswapQuoterAbi, getProvider());
|
||||
try {
|
||||
const result = await contract.quoteExactInputSingle([
|
||||
edge.tokenInAddress,
|
||||
edge.tokenOutAddress,
|
||||
amountIn,
|
||||
fee,
|
||||
0,
|
||||
]);
|
||||
return BigInt(String(Array.isArray(result) ? result[0] : result));
|
||||
} catch {
|
||||
try {
|
||||
const result = await contract['quoteExactInputSingle(address,address,uint24,uint256,uint160)'](
|
||||
edge.tokenInAddress,
|
||||
edge.tokenOutAddress,
|
||||
fee,
|
||||
amountIn,
|
||||
0
|
||||
);
|
||||
return BigInt(String(result));
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BestExecutionPlanner {
|
||||
private graphBuilder: RouteGraphBuilder;
|
||||
private plannerRepo: PlannerMetricsRepository;
|
||||
|
||||
constructor(
|
||||
graphBuilder = new RouteGraphBuilder(),
|
||||
plannerRepo = new PlannerMetricsRepository()
|
||||
) {
|
||||
this.graphBuilder = graphBuilder;
|
||||
this.plannerRepo = plannerRepo;
|
||||
}
|
||||
|
||||
async plan(request: PlannerRequest): Promise<PlannerResponse> {
|
||||
const normalizedRequest = {
|
||||
...request,
|
||||
tokenIn: normalizeAddress(request.tokenIn),
|
||||
tokenOut: normalizeAddress(request.tokenOut),
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
};
|
||||
const policy = resolveRoutingPolicy(normalizedRequest.sourceChainId, normalizedRequest.constraints || {});
|
||||
const requestHash = this.requestHash(normalizedRequest, policy);
|
||||
const cached = await this.plannerRepo.getCachedPlan(requestHash);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const capabilities = getProviderCapabilities(normalizedRequest.sourceChainId);
|
||||
await this.plannerRepo.recordProviderSnapshots(normalizedRequest.sourceChainId, capabilities);
|
||||
|
||||
const response =
|
||||
normalizedRequest.destinationChainId === normalizedRequest.sourceChainId
|
||||
? await this.planOneChain(normalizedRequest, policy)
|
||||
: await this.planCrossChain(normalizedRequest, policy);
|
||||
|
||||
await this.plannerRepo.cachePlan(requestHash, response);
|
||||
await this.plannerRepo.recordPlannedRouteMetrics(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
getCapabilities(chainId: number) {
|
||||
return getProviderCapabilities(chainId);
|
||||
}
|
||||
|
||||
private requestHash(request: PlannerRequest, policy: RoutingPolicy): string {
|
||||
return JSON.stringify({
|
||||
request,
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
private async quoteEdgeAmountOut(
|
||||
edge: SwapGraphEdge,
|
||||
amountIn: bigint,
|
||||
quoteCache: Map<string, bigint>
|
||||
): Promise<bigint> {
|
||||
if (edge.provider === 'uniswap_v3') {
|
||||
const cacheKey = [edge.provider, edge.target, edge.tokenInAddress, edge.tokenOutAddress, amountIn.toString()].join(':');
|
||||
const cached = quoteCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const quoted = await quoteUniswapV3AmountOut(edge, amountIn);
|
||||
if (quoted > 0n) {
|
||||
quoteCache.set(cacheKey, quoted);
|
||||
return quoted;
|
||||
}
|
||||
const fallback = quoteAmountOut(amountIn, safeBigInt(edge.reserveIn), safeBigInt(edge.reserveOut));
|
||||
quoteCache.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (edge.provider !== 'dodo_v3') {
|
||||
return quoteAmountOut(amountIn, safeBigInt(edge.reserveIn), safeBigInt(edge.reserveOut));
|
||||
}
|
||||
|
||||
const cacheKey = [edge.provider, edge.poolAddress, edge.tokenInAddress, edge.tokenOutAddress, amountIn.toString()].join(':');
|
||||
const cached = quoteCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const quoted = await quoteChain138DodoV3AmountOut({
|
||||
tokenInAddress: edge.tokenInAddress,
|
||||
tokenOutAddress: edge.tokenOutAddress,
|
||||
amountIn,
|
||||
});
|
||||
quoteCache.set(cacheKey, quoted);
|
||||
return quoted;
|
||||
} catch {
|
||||
quoteCache.set(cacheKey, 0n);
|
||||
return 0n;
|
||||
}
|
||||
}
|
||||
|
||||
private async planOneChain(request: PlannerRequest, policy: RoutingPolicy): Promise<PlannerResponse> {
|
||||
const sourceResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenIn);
|
||||
const targetResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenOut);
|
||||
const amountIn = safeBigInt(request.amountIn);
|
||||
const maxSlippageBps = request.constraints?.maxSlippageBps || 100;
|
||||
|
||||
const edges = await this.graphBuilder.buildSwapEdges(request.sourceChainId);
|
||||
const allowedProviders = new Set(policy.allowedProviders);
|
||||
const allowedTokens = new Set<string>([
|
||||
sourceResolution.lookupAddress,
|
||||
targetResolution.lookupAddress,
|
||||
...policy.defaultIntermediateAddresses,
|
||||
...(policy.allowCommodityIntermediates ? getCommodityIntermediateAddresses(request.sourceChainId) : []),
|
||||
]);
|
||||
const eligibleEdges = sortEdges(
|
||||
edges.filter((edge) => {
|
||||
if (!allowedProviders.has(edge.provider)) return false;
|
||||
if (!edge.target || !providerDataHexForEdge(edge)) return false;
|
||||
if (edge.tokenOutSymbol === 'cXAUC' || edge.tokenInSymbol === 'cXAUC') {
|
||||
return policy.allowCommodityIntermediates;
|
||||
}
|
||||
return allowedTokens.has(edge.tokenOutAddress) || edge.tokenOutAddress === targetResolution.lookupAddress;
|
||||
})
|
||||
);
|
||||
|
||||
const byTokenIn = new Map<string, SwapGraphEdge[]>();
|
||||
for (const edge of eligibleEdges) {
|
||||
const key = edge.tokenInAddress;
|
||||
const list = byTokenIn.get(key) || [];
|
||||
list.push(edge);
|
||||
byTokenIn.set(key, list);
|
||||
}
|
||||
|
||||
const candidates: RouteCandidate[] = [];
|
||||
const visited = new Set<string>([sourceResolution.lookupAddress]);
|
||||
const quoteCache = new Map<string, bigint>();
|
||||
const dfs = async (
|
||||
currentToken: string,
|
||||
currentAmount: bigint,
|
||||
path: PlannerLeg[],
|
||||
depth: number
|
||||
): Promise<void> => {
|
||||
if (currentToken === targetResolution.lookupAddress && path.length > 0) {
|
||||
candidates.push(this.toCandidate(path, currentAmount, request.sourceChainId, targetResolution.lookupAddress, policy));
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth >= policy.maxLegs) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const edge of byTokenIn.get(currentToken) || []) {
|
||||
if (visited.has(edge.tokenOutAddress)) continue;
|
||||
const quotedAmount = await this.quoteEdgeAmountOut(edge, currentAmount, quoteCache);
|
||||
if (quotedAmount <= 0n) continue;
|
||||
|
||||
const leg: PlannerLeg = {
|
||||
kind: 'swap',
|
||||
provider: edge.provider,
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.sourceChainId,
|
||||
tokenInAddress: edge.tokenInAddress,
|
||||
tokenOutAddress: edge.tokenOutAddress,
|
||||
tokenInSymbol: edge.tokenInSymbol,
|
||||
tokenOutSymbol: edge.tokenOutSymbol,
|
||||
estimatedAmountIn: currentAmount.toString(),
|
||||
estimatedAmountOut: quotedAmount.toString(),
|
||||
minAmountOut: buildMinAmountOut(quotedAmount, maxSlippageBps).toString(),
|
||||
target: edge.target,
|
||||
poolAddress: edge.poolAddress,
|
||||
providerData: edge.providerData,
|
||||
providerDataHex: providerDataHexForEdge(edge),
|
||||
gasEstimate: Math.round((PROVIDER_GAS_USD[edge.provider] / 2500) * 1_000_000),
|
||||
freshnessSeconds: edge.freshnessSeconds,
|
||||
totalLiquidityUsd: edge.totalLiquidityUsd,
|
||||
notes: edge.notes,
|
||||
};
|
||||
|
||||
visited.add(edge.tokenOutAddress);
|
||||
await dfs(edge.tokenOutAddress, quotedAmount, [...path, leg], depth + 1);
|
||||
visited.delete(edge.tokenOutAddress);
|
||||
}
|
||||
};
|
||||
|
||||
await dfs(sourceResolution.lookupAddress, amountIn, [], 0);
|
||||
const sortedCandidates = candidates.sort((a, b) => b.score - a.score);
|
||||
const best = sortedCandidates[0];
|
||||
|
||||
if (!best) {
|
||||
return this.emptyResponse(request, 'unresolved', 'No eligible one-chain route found with the current policy and provider capabilities.');
|
||||
}
|
||||
|
||||
const routePlan = this.buildEncodedRoutePlan(
|
||||
request,
|
||||
best.legs,
|
||||
sourceResolution.lookupAddress,
|
||||
targetResolution.lookupAddress
|
||||
);
|
||||
return this.toResponse(
|
||||
request,
|
||||
best,
|
||||
sortedCandidates.slice(1, 4),
|
||||
'One-chain best execution selected from live pool graph.',
|
||||
routePlan
|
||||
);
|
||||
}
|
||||
|
||||
private async planCrossChain(request: PlannerRequest, policy: RoutingPolicy): Promise<PlannerResponse> {
|
||||
const sourceResolution = resolveCanonicalQuoteAddress(request.sourceChainId, request.tokenIn);
|
||||
const destinationResolution = resolveCanonicalQuoteAddress(request.destinationChainId || request.sourceChainId, request.tokenOut);
|
||||
const amountIn = safeBigInt(request.amountIn);
|
||||
|
||||
const candidateBridgeSymbols = Array.from(
|
||||
new Set(
|
||||
policy.defaultIntermediateAddresses
|
||||
.map((address) => getRoutingSymbolForAddress(request.sourceChainId, address))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
);
|
||||
const bridgeCandidates = this.graphBuilder.buildBridgeCandidates(
|
||||
request.sourceChainId,
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
candidateBridgeSymbols
|
||||
).filter((candidate) => policy.allowedBridgeLabels.includes(candidate.bridgeLabel));
|
||||
|
||||
const combined: RouteCandidate[] = [];
|
||||
for (const bridgeCandidate of bridgeCandidates) {
|
||||
const sourceCandidate = await this.planSegment(
|
||||
request.sourceChainId,
|
||||
sourceResolution.lookupAddress,
|
||||
bridgeCandidate.sourceTokenAddress,
|
||||
amountIn,
|
||||
policy,
|
||||
Math.min(policy.maxLegs, 2)
|
||||
);
|
||||
if (!sourceCandidate) continue;
|
||||
|
||||
const bridgeFeeBps = bridgeCandidate.bridgeType === 'CCIP' ? 20 : 25;
|
||||
const bridgedAmount = buildMinAmountOut(sourceCandidate.estimatedAmountOut, bridgeFeeBps);
|
||||
|
||||
const destinationCandidate = await this.planSegment(
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
bridgeCandidate.destinationTokenAddress,
|
||||
destinationResolution.lookupAddress,
|
||||
bridgedAmount,
|
||||
policy,
|
||||
Math.min(policy.maxLegs, 2)
|
||||
);
|
||||
|
||||
const bridgeLeg: PlannerLeg = {
|
||||
kind: 'bridge',
|
||||
provider: 'partner',
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
tokenInAddress: bridgeCandidate.sourceTokenAddress,
|
||||
tokenOutAddress: bridgeCandidate.destinationTokenAddress,
|
||||
tokenInSymbol: bridgeCandidate.assetSymbol,
|
||||
tokenOutSymbol: bridgeCandidate.assetSymbol,
|
||||
estimatedAmountIn: sourceCandidate.estimatedAmountOut.toString(),
|
||||
estimatedAmountOut: bridgedAmount.toString(),
|
||||
minAmountOut: bridgedAmount.toString(),
|
||||
bridgeType: bridgeCandidate.bridgeType,
|
||||
bridgeAddress: bridgeCandidate.bridgeAddress,
|
||||
gasEstimate: 250000,
|
||||
notes: bridgeCandidate.notes,
|
||||
};
|
||||
|
||||
const destinationLegs = destinationCandidate?.legs || [];
|
||||
const estimatedAmountOut = destinationCandidate?.estimatedAmountOut || bridgedAmount;
|
||||
const legs = [...sourceCandidate.legs, bridgeLeg, ...destinationLegs];
|
||||
combined.push({
|
||||
decision: sourceCandidate.legs.length > 0 || destinationLegs.length > 0 ? 'swap-bridge-swap' : 'bridge-only',
|
||||
estimatedAmountOut,
|
||||
estimatedGasUsd: sourceCandidate.estimatedGasUsd + 0.45 + (destinationCandidate?.estimatedGasUsd || 0),
|
||||
score: sourceCandidate.score + (destinationCandidate?.score || normalizedOutput(request.destinationChainId || request.sourceChainId, bridgeCandidate.destinationTokenAddress, bridgedAmount)) - 0.45,
|
||||
legs,
|
||||
selectedRouteReason: `Bridge asset ${bridgeCandidate.assetSymbol} selected via ${bridgeCandidate.bridgeLabel}.`,
|
||||
rejectedAlternatives: [],
|
||||
riskFlags: ['cross-chain'],
|
||||
});
|
||||
}
|
||||
|
||||
const sortedCandidates = combined.sort((a, b) => b.score - a.score);
|
||||
const best = sortedCandidates[0];
|
||||
if (!best) {
|
||||
return this.emptyResponse(request, 'unresolved', 'No eligible cross-chain route found with the current policy and bridge registry.');
|
||||
}
|
||||
|
||||
const sourceSwapLegs = best.legs.filter(
|
||||
(leg) => leg.kind === 'swap' && leg.sourceChainId === request.sourceChainId
|
||||
);
|
||||
const destinationSwapLegs = best.legs.filter(
|
||||
(leg) => leg.kind === 'swap' && leg.sourceChainId === (request.destinationChainId || request.sourceChainId)
|
||||
);
|
||||
const bridgeLeg = best.legs.find((leg) => leg.kind === 'bridge');
|
||||
const intentCoordinator = normalizeAddress(
|
||||
process.env.INTENT_BRIDGE_COORDINATOR_V2_ADDRESS ||
|
||||
(request.sourceChainId === 138 ? DEFAULT_INTENT_BRIDGE_COORDINATOR_V2 : '')
|
||||
);
|
||||
|
||||
const sourceRoutePlan = sourceSwapLegs.length > 0
|
||||
? this.buildEncodedRoutePlan(
|
||||
request,
|
||||
sourceSwapLegs,
|
||||
sourceResolution.lookupAddress,
|
||||
bridgeLeg?.tokenInAddress || sourceResolution.lookupAddress,
|
||||
intentCoordinator || ROUTER_V2_RECIPIENT_PLACEHOLDER
|
||||
)
|
||||
: undefined;
|
||||
const destinationRoutePlan = destinationSwapLegs.length > 0
|
||||
? this.buildEncodedRoutePlan(
|
||||
{
|
||||
...request,
|
||||
sourceChainId: request.destinationChainId || request.sourceChainId,
|
||||
recipient: request.recipient,
|
||||
},
|
||||
destinationSwapLegs,
|
||||
bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress,
|
||||
destinationResolution.lookupAddress
|
||||
) || this.emptyEncodedRoutePlan(
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress,
|
||||
destinationResolution.lookupAddress,
|
||||
destinationSwapLegs[destinationSwapLegs.length - 1]?.estimatedAmountOut || bridgeLeg?.estimatedAmountOut || request.amountIn,
|
||||
request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER
|
||||
)
|
||||
: this.emptyEncodedRoutePlan(
|
||||
request.destinationChainId || request.sourceChainId,
|
||||
bridgeLeg?.tokenOutAddress || destinationResolution.lookupAddress,
|
||||
destinationResolution.lookupAddress,
|
||||
bridgeLeg?.estimatedAmountOut || request.amountIn,
|
||||
request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER
|
||||
);
|
||||
|
||||
const bridgeIntentPlan: EncodedBridgeIntentPlan | undefined =
|
||||
bridgeLeg && sourceRoutePlan && intentCoordinator
|
||||
? {
|
||||
sourcePlan: {
|
||||
...sourceRoutePlan,
|
||||
recipient: intentCoordinator,
|
||||
},
|
||||
bridgeType: bridgeLeg.bridgeType || 'CCIP',
|
||||
bridgeData: abiCoder.encode(
|
||||
['address', 'string'],
|
||||
[bridgeLeg.bridgeAddress || ZeroAddress, bridgeLeg.bridgeType || 'CCIP']
|
||||
),
|
||||
destinationPlan: destinationRoutePlan,
|
||||
recipient: normalizeAddress(request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER),
|
||||
deadline: String(Math.floor(Date.now() / 1000) + 300),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return this.toResponse(
|
||||
request,
|
||||
best,
|
||||
sortedCandidates.slice(1, 4),
|
||||
'Cross-chain best execution selected from source swap, bridge registry, and destination swap candidates.',
|
||||
undefined,
|
||||
bridgeIntentPlan
|
||||
);
|
||||
}
|
||||
|
||||
private async planSegment(
|
||||
chainId: number,
|
||||
tokenIn: string,
|
||||
tokenOut: string,
|
||||
amountIn: bigint,
|
||||
policy: RoutingPolicy,
|
||||
maxLegs: number
|
||||
): Promise<RouteCandidate | null> {
|
||||
if (tokenIn === tokenOut) {
|
||||
return {
|
||||
decision: 'direct-pool',
|
||||
estimatedAmountOut: amountIn,
|
||||
estimatedGasUsd: 0,
|
||||
score: normalizedOutput(chainId, tokenOut, amountIn),
|
||||
legs: [],
|
||||
selectedRouteReason: 'Token already matches bridge asset or destination asset.',
|
||||
rejectedAlternatives: [],
|
||||
riskFlags: [],
|
||||
};
|
||||
}
|
||||
|
||||
const request: PlannerRequest = {
|
||||
sourceChainId: chainId,
|
||||
tokenIn,
|
||||
tokenOut,
|
||||
amountIn: amountIn.toString(),
|
||||
constraints: {
|
||||
complianceProfile: policy.profile,
|
||||
allowBridge: false,
|
||||
maxLegs,
|
||||
allowedProviders: policy.allowedProviders,
|
||||
allowedIntermediates: policy.defaultIntermediateAddresses,
|
||||
allowCommodityIntermediates: policy.allowCommodityIntermediates,
|
||||
},
|
||||
};
|
||||
const response = await this.planOneChain(request, {
|
||||
...policy,
|
||||
maxLegs,
|
||||
});
|
||||
if (!response.estimatedAmountOut || response.legs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
decision: response.decision,
|
||||
estimatedAmountOut: safeBigInt(response.estimatedAmountOut),
|
||||
estimatedGasUsd: response.estimatedGasUsd,
|
||||
score: normalizedOutput(chainId, tokenOut, safeBigInt(response.estimatedAmountOut)) - response.estimatedGasUsd,
|
||||
legs: response.legs,
|
||||
selectedRouteReason: response.selectedRouteReason,
|
||||
rejectedAlternatives: response.rejectedAlternatives,
|
||||
riskFlags: response.riskFlags,
|
||||
};
|
||||
}
|
||||
|
||||
private toCandidate(
|
||||
legs: PlannerLeg[],
|
||||
estimatedAmountOut: bigint,
|
||||
chainId: number,
|
||||
outputToken: string,
|
||||
policy: RoutingPolicy
|
||||
): RouteCandidate {
|
||||
const estimatedGasUsd = legs.reduce((total, leg) => total + PROVIDER_GAS_USD[leg.provider], 0);
|
||||
const stalePenalty = legs.reduce((total, leg) => {
|
||||
const freshness = leg.freshnessSeconds || 0;
|
||||
return total + (freshness > 1800 ? 0.15 : freshness > 300 ? 0.05 : 0);
|
||||
}, 0);
|
||||
const hopPenalty = Math.max(0, legs.length - 1) * 0.03;
|
||||
const score = normalizedOutput(chainId, outputToken, estimatedAmountOut) - estimatedGasUsd - stalePenalty - hopPenalty;
|
||||
|
||||
return {
|
||||
decision: legs.length === 1 ? 'direct-pool' : 'multi-hop',
|
||||
estimatedAmountOut,
|
||||
estimatedGasUsd,
|
||||
score,
|
||||
legs,
|
||||
selectedRouteReason: legs.length === 1
|
||||
? legs[0].provider === 'dodo_v3'
|
||||
? 'Selected live DODO v3 / D3MM pilot quote for the requested direct pair.'
|
||||
: `Selected deepest eligible ${legs[0].provider} pool for the requested pair.`
|
||||
: `Selected multi-hop path through ${legs.map((leg) => leg.tokenOutSymbol || leg.tokenOutAddress).join(' -> ')}.`,
|
||||
rejectedAlternatives: [],
|
||||
riskFlags: computeRiskFlags(legs, policy),
|
||||
};
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
request: PlannerRequest,
|
||||
best: RouteCandidate,
|
||||
alternatives: RouteCandidate[],
|
||||
selectedRouteReason: string,
|
||||
routePlan?: EncodedPlannerRoutePlan,
|
||||
bridgeIntentPlan?: EncodedBridgeIntentPlan
|
||||
): PlannerResponse {
|
||||
const planId = Buffer.from(this.requestHash(request, resolveRoutingPolicy(request.sourceChainId, request.constraints || {}))).toString('base64url');
|
||||
const alternativePayloads: PlannerAlternative[] = alternatives.map((candidate, index) => ({
|
||||
routeId: `${planId}-alt-${index + 1}`,
|
||||
decision: candidate.decision,
|
||||
estimatedAmountOut: candidate.estimatedAmountOut.toString(),
|
||||
estimatedGasUsd: Number(candidate.estimatedGasUsd.toFixed(4)),
|
||||
providerPath: candidate.legs.map((leg) => leg.provider),
|
||||
legCount: candidate.legs.length,
|
||||
score: Number(candidate.score.toFixed(6)),
|
||||
notes: [candidate.selectedRouteReason, ...candidate.riskFlags],
|
||||
}));
|
||||
const maxFreshness = best.legs.reduce<number | null>((current, leg) => {
|
||||
if (leg.freshnessSeconds === null || leg.freshnessSeconds === undefined) return current;
|
||||
if (current === null) return leg.freshnessSeconds;
|
||||
return Math.max(current, leg.freshnessSeconds);
|
||||
}, null);
|
||||
|
||||
return {
|
||||
planId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision: best.decision,
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
tokenIn: request.tokenIn,
|
||||
tokenOut: request.tokenOut,
|
||||
estimatedAmountOut: best.estimatedAmountOut.toString(),
|
||||
minAmountOut: best.legs.length > 0 ? best.legs[best.legs.length - 1].minAmountOut : request.amountIn,
|
||||
estimatedGasUsd: Number(best.estimatedGasUsd.toFixed(4)),
|
||||
legs: best.legs,
|
||||
alternatives: alternativePayloads,
|
||||
confidenceScore: Number(computeConfidence(best.legs).toFixed(4)),
|
||||
riskFlags: best.riskFlags,
|
||||
selectedRouteReason,
|
||||
rejectedAlternatives: best.rejectedAlternatives,
|
||||
staleness: {
|
||||
maxFreshnessSeconds: maxFreshness,
|
||||
hasStaleLeg: best.legs.some((leg) => (leg.freshnessSeconds || 0) > 300),
|
||||
},
|
||||
routePlan,
|
||||
bridgeIntentPlan,
|
||||
};
|
||||
}
|
||||
|
||||
private emptyResponse(
|
||||
request: PlannerRequest,
|
||||
decision: PlannerDecision,
|
||||
reason: string
|
||||
): PlannerResponse {
|
||||
const planId = Buffer.from(this.requestHash(request, resolveRoutingPolicy(request.sourceChainId, request.constraints || {}))).toString('base64url');
|
||||
return {
|
||||
planId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
decision,
|
||||
sourceChainId: request.sourceChainId,
|
||||
destinationChainId: request.destinationChainId || request.sourceChainId,
|
||||
tokenIn: request.tokenIn,
|
||||
tokenOut: request.tokenOut,
|
||||
estimatedAmountOut: null,
|
||||
minAmountOut: null,
|
||||
estimatedGasUsd: 0,
|
||||
legs: [],
|
||||
alternatives: [],
|
||||
confidenceScore: 0,
|
||||
riskFlags: ['no-route'],
|
||||
selectedRouteReason: reason,
|
||||
rejectedAlternatives: [],
|
||||
staleness: {
|
||||
maxFreshnessSeconds: null,
|
||||
hasStaleLeg: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildEncodedRoutePlan(
|
||||
request: PlannerRequest,
|
||||
legs: PlannerLeg[],
|
||||
inputToken: string,
|
||||
outputToken: string,
|
||||
recipientOverride?: string
|
||||
): EncodedPlannerRoutePlan | undefined {
|
||||
if (!legs.every((leg) => PROVIDER_ENUM[leg.provider] !== undefined && Boolean(providerDataHexForLeg(leg)))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const recipient = normalizeAddress(recipientOverride || request.recipient || ROUTER_V2_RECIPIENT_PLACEHOLDER);
|
||||
const deadline = String(Math.floor(Date.now() / 1000) + 300);
|
||||
const amountIn = legs[0]?.estimatedAmountIn || request.amountIn;
|
||||
const minAmountOut = legs[legs.length - 1]?.minAmountOut || request.amountIn;
|
||||
|
||||
return {
|
||||
chainId: request.sourceChainId,
|
||||
inputToken,
|
||||
outputToken,
|
||||
amountIn,
|
||||
minAmountOut,
|
||||
recipient,
|
||||
deadline,
|
||||
legs: legs.map((leg, index) => ({
|
||||
provider: PROVIDER_ENUM[leg.provider] as number,
|
||||
tokenIn: leg.tokenInAddress,
|
||||
tokenOut: leg.tokenOutAddress,
|
||||
amountSource: index === 0 ? 0 : 1,
|
||||
minAmountOut: leg.minAmountOut,
|
||||
target: leg.target || ZeroAddress,
|
||||
providerData: providerDataHexForLeg(leg) as string,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private emptyEncodedRoutePlan(
|
||||
chainId: number,
|
||||
inputToken: string,
|
||||
outputToken: string,
|
||||
amountIn: string,
|
||||
recipient: string
|
||||
): EncodedPlannerRoutePlan {
|
||||
return {
|
||||
chainId,
|
||||
inputToken,
|
||||
outputToken,
|
||||
amountIn,
|
||||
minAmountOut: amountIn,
|
||||
recipient: normalizeAddress(recipient),
|
||||
deadline: String(Math.floor(Date.now() / 1000) + 300),
|
||||
legs: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
|
||||
describe('estimateChain138DodoLiquidityUsd', () => {
|
||||
it('values Chain 138 stable-to-stable DODO pools with token decimals', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
token1Address: '0x71D6687F38b93CCad569Fa6352c876eea967201b',
|
||||
reserve0: 999_999_997_998n,
|
||||
reserve1: 999_999_997_998n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBeCloseTo(999_999.997998, 6);
|
||||
expect(result.reserve1Usd).toBeCloseTo(999_999.997998, 6);
|
||||
expect(result.totalLiquidityUsd).toBeCloseTo(1_999_999.995996, 6);
|
||||
});
|
||||
|
||||
it('values WETH/stable DODO pools with stable decimals and oracle price', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f',
|
||||
token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
reserve0: 50n * 10n ** 18n,
|
||||
reserve1: 105_830n * 10n ** 6n,
|
||||
price: 2_100n * 10n ** 18n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBe(105_000);
|
||||
expect(result.reserve1Usd).toBe(105_830);
|
||||
expect(result.totalLiquidityUsd).toBe(210_830);
|
||||
});
|
||||
|
||||
it('keeps non-USD pairs at zero without a usable USD side', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f',
|
||||
token1Address: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b',
|
||||
reserve0: 10n * 10n ** 18n,
|
||||
reserve1: 5n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('values cBTC/stable DODO pools using satoshi precision and the BTC fallback price', () => {
|
||||
const result = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: '0xcb7c000000000000000000000000000000000138',
|
||||
token1Address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
reserve0: 2n * 10n ** 8n,
|
||||
reserve1: 181_000n * 10n ** 6n,
|
||||
});
|
||||
|
||||
expect(result.reserve0Usd).toBe(180_000);
|
||||
expect(result.reserve1Usd).toBe(181_000);
|
||||
expect(result.totalLiquidityUsd).toBe(361_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_WETH_USD_PRICE = 2100;
|
||||
const DEFAULT_BTC_USD_PRICE = 90000;
|
||||
|
||||
export interface Chain138DodoLiquidityUsd {
|
||||
reserve0Usd: number;
|
||||
reserve1Usd: number;
|
||||
totalLiquidityUsd: number;
|
||||
}
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function decimalsForAddress(address: string): number {
|
||||
const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address));
|
||||
return Number(spec?.decimals ?? 18);
|
||||
}
|
||||
|
||||
function isUsdAddress(address: string): boolean {
|
||||
const spec = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address));
|
||||
return spec?.currencyCode === 'USD';
|
||||
}
|
||||
|
||||
function isWethLikeAddress(address: string): boolean {
|
||||
const symbol = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address))?.symbol?.toUpperCase();
|
||||
return symbol === 'WETH' || symbol === 'WETH10';
|
||||
}
|
||||
|
||||
function isBtcLikeAddress(address: string): boolean {
|
||||
const symbol = getCanonicalTokenByAddress(CHAIN_138, normalizeAddress(address))?.symbol?.toUpperCase();
|
||||
return symbol === 'CBTC';
|
||||
}
|
||||
|
||||
function parseAmount(value: bigint, decimals: number): number {
|
||||
if (value <= 0n) return 0;
|
||||
const parsed = Number(formatUnits(value, decimals));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parsePrice(price?: bigint): number {
|
||||
if (!price || price <= 0n) return 0;
|
||||
const parsed = Number(formatUnits(price, 18));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function estimateChain138DodoLiquidityUsd(args: {
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
reserve0: bigint;
|
||||
reserve1: bigint;
|
||||
price?: bigint;
|
||||
}): Chain138DodoLiquidityUsd {
|
||||
const token0Address = normalizeAddress(args.token0Address);
|
||||
const token1Address = normalizeAddress(args.token1Address);
|
||||
const reserve0Amount = parseAmount(args.reserve0, decimalsForAddress(token0Address));
|
||||
const reserve1Amount = parseAmount(args.reserve1, decimalsForAddress(token1Address));
|
||||
const price = parsePrice(args.price);
|
||||
|
||||
const token0IsUsd = isUsdAddress(token0Address);
|
||||
const token1IsUsd = isUsdAddress(token1Address);
|
||||
|
||||
if (reserve0Amount <= 0 || reserve1Amount <= 0) {
|
||||
return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
}
|
||||
|
||||
if (token0IsUsd && token1IsUsd) {
|
||||
return {
|
||||
reserve0Usd: reserve0Amount,
|
||||
reserve1Usd: reserve1Amount,
|
||||
totalLiquidityUsd: reserve0Amount + reserve1Amount,
|
||||
};
|
||||
}
|
||||
|
||||
if (token1IsUsd) {
|
||||
const reserve0Usd =
|
||||
price > 0
|
||||
? reserve0Amount * price
|
||||
: isWethLikeAddress(token0Address)
|
||||
? reserve0Amount * DEFAULT_WETH_USD_PRICE
|
||||
: isBtcLikeAddress(token0Address)
|
||||
? reserve0Amount * DEFAULT_BTC_USD_PRICE
|
||||
: 0;
|
||||
|
||||
return {
|
||||
reserve0Usd,
|
||||
reserve1Usd: reserve1Amount,
|
||||
totalLiquidityUsd: reserve0Usd > 0 ? reserve0Usd + reserve1Amount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (token0IsUsd) {
|
||||
const reserve1Usd =
|
||||
price > 0
|
||||
? reserve1Amount / price
|
||||
: isWethLikeAddress(token1Address)
|
||||
? reserve1Amount * DEFAULT_WETH_USD_PRICE
|
||||
: isBtcLikeAddress(token1Address)
|
||||
? reserve1Amount * DEFAULT_BTC_USD_PRICE
|
||||
: 0;
|
||||
|
||||
return {
|
||||
reserve0Usd: reserve0Amount,
|
||||
reserve1Usd,
|
||||
totalLiquidityUsd: reserve1Usd > 0 ? reserve0Amount + reserve1Usd : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
}
|
||||
562
services/token-aggregation/src/services/chain138-pilot-venues.ts
Normal file
562
services/token-aggregation/src/services/chain138-pilot-venues.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { AbiCoder, Contract, JsonRpcProvider, formatUnits } from 'ethers';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
|
||||
const DEFAULT_PILOT_UNISWAP_ROUTER = '0xd164d9ccfacf5d9f91698f296ae0cd245d964384';
|
||||
const DEFAULT_NATIVE_UNISWAP_FACTORY = '0x2f7219276e3ce367db9ec74c1196a8ecee67841c';
|
||||
const DEFAULT_NATIVE_UNISWAP_ROUTER = '0xde9cd8ee2811e6e64a41d5f68be315d33995975e';
|
||||
const DEFAULT_NATIVE_UNISWAP_QUOTER = '0x6abbb1ceb2468e748a03a00cd6aa9bfe893afa1f';
|
||||
const DEFAULT_NATIVE_UNISWAP_WETH_USDT_POOL = '0xa893add35aefe6a6d858eb01828be4592f12c9f5';
|
||||
const DEFAULT_NATIVE_UNISWAP_WETH_USDC_POOL = '0xec745bfb6b3cd32f102d594e5f432d8d85b19391';
|
||||
const DEFAULT_BALANCER_VAULT = '0x96423d7c1727698d8a25ebfb88131e9422d1a3c3';
|
||||
const DEFAULT_CURVE_3POOL = '0xe440ec15805be4c7babcd17a63b8c8a08a492e0f';
|
||||
const DEFAULT_ONEINCH_ROUTER = '0x500b84b1bc6f59c1898a5fe538ea20a758757a4f';
|
||||
const DEFAULT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae06856c426b56d93092a420facbc324';
|
||||
const DEFAULT_BALANCER_WETH_USDC_POOL_ID = '0xd8dfb18a6baf9b29d8c2dbd74639db87ac558af120df5261dab8e2a5de69013b';
|
||||
const DEFAULT_PILOT_UNISWAP_FEE = 3000;
|
||||
const DEFAULT_NATIVE_UNISWAP_FEE = 500;
|
||||
const DEFAULT_WETH_USD_PRICE = 2100;
|
||||
|
||||
const WETH = '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase();
|
||||
const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'.toLowerCase();
|
||||
const USDC = '0x71D6687F38b93CCad569Fa6352c876eea967201b'.toLowerCase();
|
||||
|
||||
const uniswapAbi = [
|
||||
'function getPairReserves(address tokenA,address tokenB,uint24 fee) view returns (uint256 reserveIn,uint256 reserveOut,bool exists)',
|
||||
];
|
||||
|
||||
const nativeUniswapFactoryAbi = [
|
||||
'function getPool(address tokenA,address tokenB,uint24 fee) view returns (address)',
|
||||
];
|
||||
|
||||
const erc20Abi = [
|
||||
'function balanceOf(address account) view returns (uint256)',
|
||||
];
|
||||
|
||||
const balancerAbi = [
|
||||
'function getPoolTokens(bytes32 poolId) view returns (address[] tokens, uint256[] balances, uint256 lastChangeBlock)',
|
||||
];
|
||||
|
||||
const curveAbi = [
|
||||
'function reserves(uint256 index) view returns (uint256)',
|
||||
];
|
||||
|
||||
const oneInchAbi = [
|
||||
'function getRouteReserves(address tokenA,address tokenB) view returns (uint256 reserveIn,uint256 reserveOut,bool exists)',
|
||||
];
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function providerDataHexForUniswap(router: string, quoter: string, fee: number): string {
|
||||
return abiCoder.encode(['bytes', 'uint24', 'address', 'bool'], ['0x', fee, quoter, false]);
|
||||
}
|
||||
|
||||
function providerDataHexForBalancer(poolId: string): string {
|
||||
return abiCoder.encode(['bytes32'], [poolId]);
|
||||
}
|
||||
|
||||
function providerDataHexForCurve(): string {
|
||||
return abiCoder.encode(['int128', 'int128', 'bool'], [0, 1, false]);
|
||||
}
|
||||
|
||||
function providerDataHexForOneInch(router: string): string {
|
||||
return abiCoder.encode(['address', 'address', 'bytes'], [router, router, '0x']);
|
||||
}
|
||||
|
||||
let sharedProvider: JsonRpcProvider | null = null;
|
||||
let sharedProviderUrl = '';
|
||||
|
||||
function getProvider(rpcUrl: string): JsonRpcProvider {
|
||||
if (!sharedProvider || sharedProviderUrl !== rpcUrl) {
|
||||
sharedProvider = new JsonRpcProvider(rpcUrl);
|
||||
sharedProviderUrl = rpcUrl;
|
||||
}
|
||||
return sharedProvider;
|
||||
}
|
||||
|
||||
function totalLiquidityUsdForWethPair(reserveWeth: bigint, reserveStable: bigint): number {
|
||||
return Number(formatUnits(reserveWeth, 18)) * DEFAULT_WETH_USD_PRICE + Number(formatUnits(reserveStable, 6));
|
||||
}
|
||||
|
||||
function totalLiquidityUsdForStablePair(reserveA: bigint, reserveB: bigint): number {
|
||||
return Number(formatUnits(reserveA, 6)) + Number(formatUnits(reserveB, 6));
|
||||
}
|
||||
|
||||
function isNativeUniswapConfigured(router: string, quoter: string): boolean {
|
||||
return Boolean(
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_FACTORY ||
|
||||
process.env.CHAIN_138_UNISWAP_V3_FACTORY ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL ||
|
||||
router === DEFAULT_NATIVE_UNISWAP_ROUTER ||
|
||||
quoter === DEFAULT_NATIVE_UNISWAP_QUOTER ||
|
||||
(router && router !== DEFAULT_PILOT_UNISWAP_ROUTER)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveNativeUniswapPoolAddress(args: {
|
||||
factory: string;
|
||||
configuredPool: string;
|
||||
tokenA: string;
|
||||
tokenB: string;
|
||||
fee: number;
|
||||
rpcUrl: string;
|
||||
}): Promise<string> {
|
||||
if (args.configuredPool) {
|
||||
return args.configuredPool;
|
||||
}
|
||||
if (!args.factory) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const factory = new Contract(args.factory, nativeUniswapFactoryAbi, getProvider(args.rpcUrl));
|
||||
return normalizeAddress(await factory.getPool(args.tokenA, args.tokenB, args.fee));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function erc20Balance(token: string, account: string, rpcUrl: string): Promise<bigint> {
|
||||
const contract = new Contract(token, erc20Abi, getProvider(rpcUrl));
|
||||
const balance = await contract.balanceOf(account);
|
||||
return BigInt(String(balance));
|
||||
}
|
||||
|
||||
function buildUniswapEdges(args: {
|
||||
router: string;
|
||||
quoter: string;
|
||||
fee: number;
|
||||
reserveWeth: bigint;
|
||||
reserveStable: bigint;
|
||||
stableAddress: string;
|
||||
stableSymbol: 'USDT' | 'USDC';
|
||||
notes: string[];
|
||||
}): SwapGraphEdge[] {
|
||||
const edges: SwapGraphEdge[] = [];
|
||||
if (args.reserveWeth <= 0n || args.reserveStable <= 0n) {
|
||||
return edges;
|
||||
}
|
||||
const providerDataHex = providerDataHexForUniswap(args.router, args.quoter, args.fee);
|
||||
const liquidity = totalLiquidityUsdForWethPair(args.reserveWeth, args.reserveStable);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'uniswap_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: args.stableAddress,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: args.stableSymbol,
|
||||
reserveIn: args.reserveWeth.toString(),
|
||||
reserveOut: args.reserveStable.toString(),
|
||||
target: args.router,
|
||||
providerData: { fee: args.fee, quoter: args.quoter },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: args.notes,
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'uniswap_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: args.stableAddress,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: args.stableSymbol,
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: args.reserveStable.toString(),
|
||||
reserveOut: args.reserveWeth.toString(),
|
||||
target: args.router,
|
||||
providerData: { fee: args.fee, quoter: args.quoter },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: args.notes,
|
||||
}
|
||||
);
|
||||
return edges;
|
||||
}
|
||||
|
||||
async function getNativeUniswapEdges(args: {
|
||||
rpcUrl: string;
|
||||
router: string;
|
||||
quoter: string;
|
||||
feeUsdt: number;
|
||||
feeUsdc: number;
|
||||
}): Promise<SwapGraphEdge[]> {
|
||||
const factory = normalizeAddress(
|
||||
process.env.CHAIN_138_UNISWAP_V3_FACTORY ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_FACTORY ||
|
||||
DEFAULT_NATIVE_UNISWAP_FACTORY
|
||||
);
|
||||
const configuredWethUsdtPool = normalizeAddress(
|
||||
process.env.UNISWAP_V3_WETH_USDT_POOL ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDT_POOL ||
|
||||
DEFAULT_NATIVE_UNISWAP_WETH_USDT_POOL
|
||||
);
|
||||
const configuredWethUsdcPool = normalizeAddress(
|
||||
process.env.UNISWAP_V3_WETH_USDC_POOL ||
|
||||
process.env.CHAIN138_UNISWAP_V3_NATIVE_WETH_USDC_POOL ||
|
||||
DEFAULT_NATIVE_UNISWAP_WETH_USDC_POOL
|
||||
);
|
||||
|
||||
const [wethUsdtPool, wethUsdcPool] = await Promise.all([
|
||||
resolveNativeUniswapPoolAddress({
|
||||
factory,
|
||||
configuredPool: configuredWethUsdtPool,
|
||||
tokenA: WETH,
|
||||
tokenB: USDT,
|
||||
fee: args.feeUsdt,
|
||||
rpcUrl: args.rpcUrl,
|
||||
}),
|
||||
resolveNativeUniswapPoolAddress({
|
||||
factory,
|
||||
configuredPool: configuredWethUsdcPool,
|
||||
tokenA: WETH,
|
||||
tokenB: USDC,
|
||||
fee: args.feeUsdc,
|
||||
rpcUrl: args.rpcUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
const [wethUsdtBalances, wethUsdcBalances] = await Promise.all([
|
||||
wethUsdtPool
|
||||
? Promise.all([
|
||||
erc20Balance(WETH, wethUsdtPool, args.rpcUrl),
|
||||
erc20Balance(USDT, wethUsdtPool, args.rpcUrl),
|
||||
])
|
||||
: Promise.resolve<[bigint, bigint]>([0n, 0n]),
|
||||
wethUsdcPool
|
||||
? Promise.all([
|
||||
erc20Balance(WETH, wethUsdcPool, args.rpcUrl),
|
||||
erc20Balance(USDC, wethUsdcPool, args.rpcUrl),
|
||||
])
|
||||
: Promise.resolve<[bigint, bigint]>([0n, 0n]),
|
||||
]);
|
||||
|
||||
return [
|
||||
...buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdt,
|
||||
reserveWeth: wethUsdtBalances[0],
|
||||
reserveStable: wethUsdtBalances[1],
|
||||
stableAddress: USDT,
|
||||
stableSymbol: 'USDT',
|
||||
notes: ['Chain 138 upstream-native Uniswap v3 WETH/USDT venue.'],
|
||||
}),
|
||||
...buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdc,
|
||||
reserveWeth: wethUsdcBalances[0],
|
||||
reserveStable: wethUsdcBalances[1],
|
||||
stableAddress: USDC,
|
||||
stableSymbol: 'USDC',
|
||||
notes: ['Chain 138 upstream-native Uniswap v3 WETH/USDC venue.'],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function getPilotUniswapEdges(args: {
|
||||
rpcUrl: string;
|
||||
router: string;
|
||||
quoter: string;
|
||||
feeUsdt: number;
|
||||
feeUsdc: number;
|
||||
}): Promise<SwapGraphEdge[]> {
|
||||
const contract = new Contract(args.router, uniswapAbi, getProvider(args.rpcUrl));
|
||||
const [wethUsdt, wethUsdc] = await Promise.all([
|
||||
contract.getPairReserves(WETH, USDT, args.feeUsdt),
|
||||
contract.getPairReserves(WETH, USDC, args.feeUsdc),
|
||||
]);
|
||||
|
||||
return [
|
||||
...(Boolean(wethUsdt[2])
|
||||
? buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdt,
|
||||
reserveWeth: BigInt(String(wethUsdt[0])),
|
||||
reserveStable: BigInt(String(wethUsdt[1])),
|
||||
stableAddress: USDT,
|
||||
stableSymbol: 'USDT',
|
||||
notes: ['Chain 138 pilot-compatible Uniswap v3 WETH/USDT venue.'],
|
||||
})
|
||||
: []),
|
||||
...(Boolean(wethUsdc[2])
|
||||
? buildUniswapEdges({
|
||||
router: args.router,
|
||||
quoter: args.quoter,
|
||||
fee: args.feeUsdc,
|
||||
reserveWeth: BigInt(String(wethUsdc[0])),
|
||||
reserveStable: BigInt(String(wethUsdc[1])),
|
||||
stableAddress: USDC,
|
||||
stableSymbol: 'USDC',
|
||||
notes: ['Chain 138 pilot-compatible Uniswap v3 WETH/USDC venue.'],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
async function getUniswapEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const router = normalizeAddress(process.env.UNISWAP_V3_ROUTER || DEFAULT_NATIVE_UNISWAP_ROUTER);
|
||||
const quoter = normalizeAddress(
|
||||
process.env.UNISWAP_QUOTER_ADDRESS ||
|
||||
process.env.UNISWAP_QUOTER ||
|
||||
(router === DEFAULT_PILOT_UNISWAP_ROUTER ? router : DEFAULT_NATIVE_UNISWAP_QUOTER)
|
||||
);
|
||||
if (!router || !quoter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isNativeUniswapConfigured(router, quoter)) {
|
||||
return getNativeUniswapEdges({
|
||||
rpcUrl,
|
||||
router,
|
||||
quoter,
|
||||
feeUsdt: Number(process.env.UNISWAP_V3_WETH_USDT_FEE || DEFAULT_NATIVE_UNISWAP_FEE),
|
||||
feeUsdc: Number(process.env.UNISWAP_V3_WETH_USDC_FEE || DEFAULT_NATIVE_UNISWAP_FEE),
|
||||
});
|
||||
}
|
||||
|
||||
return getPilotUniswapEdges({
|
||||
rpcUrl,
|
||||
router,
|
||||
quoter,
|
||||
feeUsdt: Number(process.env.UNISWAP_V3_WETH_USDT_FEE || DEFAULT_PILOT_UNISWAP_FEE),
|
||||
feeUsdc: Number(process.env.UNISWAP_V3_WETH_USDC_FEE || DEFAULT_PILOT_UNISWAP_FEE),
|
||||
});
|
||||
}
|
||||
|
||||
async function getBalancerEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const vault = normalizeAddress(process.env.BALANCER_VAULT || DEFAULT_BALANCER_VAULT);
|
||||
const wethUsdtPoolId = process.env.BALANCER_WETH_USDT_POOL_ID || DEFAULT_BALANCER_WETH_USDT_POOL_ID;
|
||||
const wethUsdcPoolId = process.env.BALANCER_WETH_USDC_POOL_ID || DEFAULT_BALANCER_WETH_USDC_POOL_ID;
|
||||
if (!vault) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contract = new Contract(vault, balancerAbi, getProvider(rpcUrl));
|
||||
const edges: SwapGraphEdge[] = [];
|
||||
|
||||
for (const pair of [
|
||||
{ poolId: wethUsdtPoolId, stable: USDT, stableSymbol: 'USDT' },
|
||||
{ poolId: wethUsdcPoolId, stable: USDC, stableSymbol: 'USDC' },
|
||||
]) {
|
||||
if (!pair.poolId) continue;
|
||||
const result = await contract.getPoolTokens(pair.poolId);
|
||||
const tokens = (result[0] as string[]).map(normalizeAddress);
|
||||
const balances = (result[1] as bigint[]).map((value) => BigInt(String(value)));
|
||||
const wethIndex = tokens.indexOf(WETH);
|
||||
const stableIndex = tokens.indexOf(pair.stable);
|
||||
if (wethIndex === -1 || stableIndex === -1) continue;
|
||||
const reserveWeth = balances[wethIndex];
|
||||
const reserveStable = balances[stableIndex];
|
||||
const providerDataHex = providerDataHexForBalancer(pair.poolId);
|
||||
const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveStable);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'balancer',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: pair.stable,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: pair.stableSymbol,
|
||||
reserveIn: reserveWeth.toString(),
|
||||
reserveOut: reserveStable.toString(),
|
||||
target: vault,
|
||||
providerData: { poolId: pair.poolId },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: [`Chain 138 pilot-compatible Balancer ${pair.stableSymbol}/WETH venue.`],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'balancer',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: pair.stable,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: pair.stableSymbol,
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: reserveStable.toString(),
|
||||
reserveOut: reserveWeth.toString(),
|
||||
target: vault,
|
||||
providerData: { poolId: pair.poolId },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: [`Chain 138 pilot-compatible Balancer ${pair.stableSymbol}/WETH venue.`],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
async function getCurveEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const curvePool = normalizeAddress(process.env.CURVE_3POOL || DEFAULT_CURVE_3POOL);
|
||||
if (!curvePool) {
|
||||
return [];
|
||||
}
|
||||
const contract = new Contract(curvePool, curveAbi, getProvider(rpcUrl));
|
||||
const [reserveUsdtRaw, reserveUsdcRaw] = await Promise.all([contract.reserves(0), contract.reserves(1)]);
|
||||
const reserveUsdt = BigInt(String(reserveUsdtRaw));
|
||||
const reserveUsdc = BigInt(String(reserveUsdcRaw));
|
||||
const providerDataHex = providerDataHexForCurve();
|
||||
const liquidity = totalLiquidityUsdForStablePair(reserveUsdt, reserveUsdc);
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'curve',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDT,
|
||||
tokenOutAddress: USDC,
|
||||
tokenInSymbol: 'USDT',
|
||||
tokenOutSymbol: 'USDC',
|
||||
reserveIn: reserveUsdt.toString(),
|
||||
reserveOut: reserveUsdc.toString(),
|
||||
target: curvePool,
|
||||
providerData: { i: 0, j: 1, useUnderlying: false },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible Curve 3Pool stable/stable venue.'],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'curve',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDC,
|
||||
tokenOutAddress: USDT,
|
||||
tokenInSymbol: 'USDC',
|
||||
tokenOutSymbol: 'USDT',
|
||||
reserveIn: reserveUsdc.toString(),
|
||||
reserveOut: reserveUsdt.toString(),
|
||||
target: curvePool,
|
||||
providerData: { i: 0, j: 1, useUnderlying: false },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible Curve 3Pool stable/stable venue.'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function getOneInchEdges(rpcUrl: string): Promise<SwapGraphEdge[]> {
|
||||
const router = normalizeAddress(process.env.ONEINCH_ROUTER || DEFAULT_ONEINCH_ROUTER);
|
||||
if (!router) {
|
||||
return [];
|
||||
}
|
||||
const contract = new Contract(router, oneInchAbi, getProvider(rpcUrl));
|
||||
const [wethUsdt, wethUsdc] = await Promise.all([
|
||||
contract.getRouteReserves(WETH, USDT),
|
||||
contract.getRouteReserves(WETH, USDC),
|
||||
]);
|
||||
|
||||
const providerDataHex = providerDataHexForOneInch(router);
|
||||
const edges: SwapGraphEdge[] = [];
|
||||
if (Boolean(wethUsdt[2])) {
|
||||
const reserveWeth = BigInt(String(wethUsdt[0]));
|
||||
const reserveUsdt = BigInt(String(wethUsdt[1]));
|
||||
const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveUsdt);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: USDT,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: 'USDT',
|
||||
reserveIn: reserveWeth.toString(),
|
||||
reserveOut: reserveUsdt.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDT,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: 'USDT',
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: reserveUsdt.toString(),
|
||||
reserveOut: reserveWeth.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
}
|
||||
);
|
||||
}
|
||||
if (Boolean(wethUsdc[2])) {
|
||||
const reserveWeth = BigInt(String(wethUsdc[0]));
|
||||
const reserveUsdc = BigInt(String(wethUsdc[1]));
|
||||
const liquidity = totalLiquidityUsdForWethPair(reserveWeth, reserveUsdc);
|
||||
edges.push(
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: WETH,
|
||||
tokenOutAddress: USDC,
|
||||
tokenInSymbol: 'WETH',
|
||||
tokenOutSymbol: 'USDC',
|
||||
reserveIn: reserveWeth.toString(),
|
||||
reserveOut: reserveUsdc.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'one_inch',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: USDC,
|
||||
tokenOutAddress: WETH,
|
||||
tokenInSymbol: 'USDC',
|
||||
tokenOutSymbol: 'WETH',
|
||||
reserveIn: reserveUsdc.toString(),
|
||||
reserveOut: reserveWeth.toString(),
|
||||
target: router,
|
||||
providerData: { executor: router, allowanceTarget: router },
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: liquidity,
|
||||
freshnessSeconds: 0,
|
||||
notes: ['Chain 138 pilot-compatible 1inch router lane.'],
|
||||
}
|
||||
);
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
export async function getChain138PilotVenueEdges(): Promise<SwapGraphEdge[]> {
|
||||
const rpcUrl = resolveChain138RpcUrl();
|
||||
const [uniswapEdges, balancerEdges, curveEdges, oneInchEdges] = await Promise.all([
|
||||
getUniswapEdges(rpcUrl),
|
||||
getBalancerEdges(rpcUrl),
|
||||
getCurveEdges(rpcUrl),
|
||||
getOneInchEdges(rpcUrl),
|
||||
]);
|
||||
return [...uniswapEdges, ...balancerEdges, ...curveEdges, ...oneInchEdges];
|
||||
}
|
||||
204
services/token-aggregation/src/services/dodo-v3-pilot.ts
Normal file
204
services/token-aggregation/src/services/dodo-v3-pilot.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { AbiCoder, Contract, JsonRpcProvider, formatUnits } from 'ethers';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import { SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_D3_POOL = '0x6550A3a59070061a262a893A1D6F3F490afFDBDA';
|
||||
const DEFAULT_D3_PROXY = '0xc9a11abB7C63d88546Be24D58a6d95e3762cB843';
|
||||
const DEFAULT_ROUTER_V2 = '0xF1c93F54A5C2fc0d7766Ccb0Ad8f157DFB4C99Ce';
|
||||
const DEFAULT_WETH10 = '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f';
|
||||
const DEFAULT_USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1';
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
|
||||
const d3MmAbi = [
|
||||
'function getTokenReserve(address token) view returns (uint256)',
|
||||
'function querySellTokens(address fromToken,address toToken,uint256 fromAmount) view returns (uint256,uint256,uint256,uint256,uint256)',
|
||||
] as const;
|
||||
|
||||
export interface DodoV3PilotConfig {
|
||||
enabled: boolean;
|
||||
chainId: number;
|
||||
rpcUrl: string;
|
||||
poolAddress: string;
|
||||
proxyAddress: string;
|
||||
weth10Address: string;
|
||||
usdtAddress: string;
|
||||
wethUsdPrice: number;
|
||||
liquidityOverrideUsd?: number;
|
||||
}
|
||||
|
||||
function normalizeAddress(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeNumber(value?: string): number | undefined {
|
||||
const parsed = Number(value || '');
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function encodeChain138DodoV3ProviderData(poolAddress: string): string {
|
||||
return abiCoder.encode(['address'], [normalizeAddress(poolAddress)]);
|
||||
}
|
||||
|
||||
export function isChain138DodoV3ExecutionLive(): boolean {
|
||||
const enabled = process.env.CHAIN138_ENABLE_DODO_V3_EXECUTION !== '0';
|
||||
const router = normalizeAddress(process.env.ENHANCED_SWAP_ROUTER_V2_ADDRESS || DEFAULT_ROUTER_V2);
|
||||
return enabled && Boolean(router);
|
||||
}
|
||||
|
||||
function getPilotConfig(): DodoV3PilotConfig {
|
||||
return {
|
||||
enabled: process.env.CHAIN138_ENABLE_DODO_V3_ROUTING !== '0',
|
||||
chainId: CHAIN_138,
|
||||
rpcUrl: resolveChain138RpcUrl(),
|
||||
poolAddress: normalizeAddress(process.env.CHAIN138_D3_MM_ADDRESS || DEFAULT_D3_POOL),
|
||||
proxyAddress: normalizeAddress(process.env.CHAIN138_D3_PROXY_ADDRESS || DEFAULT_D3_PROXY),
|
||||
weth10Address: normalizeAddress(process.env.CHAIN138_D3_WETH10_ADDRESS || process.env.WETH10_ADDRESS || DEFAULT_WETH10),
|
||||
usdtAddress: normalizeAddress(process.env.CHAIN138_D3_USDT_ADDRESS || process.env.USDT_ADDRESS_138 || DEFAULT_USDT),
|
||||
wethUsdPrice: normalizeNumber(process.env.CHAIN138_D3_PILOT_WETH_USD)?.valueOf() || 2100,
|
||||
liquidityOverrideUsd: normalizeNumber(process.env.CHAIN138_D3_PILOT_TOTAL_LIQUIDITY_USD),
|
||||
};
|
||||
}
|
||||
|
||||
let sharedProvider: JsonRpcProvider | null = null;
|
||||
let sharedProviderUrl = '';
|
||||
|
||||
function getProvider(rpcUrl: string): JsonRpcProvider {
|
||||
if (!sharedProvider || sharedProviderUrl !== rpcUrl) {
|
||||
sharedProvider = new JsonRpcProvider(rpcUrl);
|
||||
sharedProviderUrl = rpcUrl;
|
||||
}
|
||||
return sharedProvider;
|
||||
}
|
||||
|
||||
function buildNotes(poolAddress: string, proxyAddress: string, executionLive: boolean): string[] {
|
||||
return [
|
||||
'DODO v3 / D3MM Chain 138 pilot venue.',
|
||||
`Canonical private pilot pool ${poolAddress} executes through D3Proxy ${proxyAddress}.`,
|
||||
executionLive
|
||||
? 'Planner-v2 exposure and EnhancedSwapRouterV2 internal execution-plan calldata are live for the canonical pilot pair.'
|
||||
: 'Planner-v2 exposure is live, but EnhancedSwapRouterV2 execution is disabled in the local environment.',
|
||||
];
|
||||
}
|
||||
|
||||
function approximateLiquidityUsd(args: {
|
||||
wethReserve: bigint;
|
||||
usdtReserve: bigint;
|
||||
wethUsdPrice: number;
|
||||
liquidityOverrideUsd?: number;
|
||||
}): number {
|
||||
if (args.liquidityOverrideUsd && Number.isFinite(args.liquidityOverrideUsd)) {
|
||||
return args.liquidityOverrideUsd;
|
||||
}
|
||||
|
||||
const wethSideUsd = Number(formatUnits(args.wethReserve, 18)) * args.wethUsdPrice;
|
||||
const usdtSideUsd = Number(formatUnits(args.usdtReserve, 6));
|
||||
return Number((wethSideUsd + usdtSideUsd).toFixed(2));
|
||||
}
|
||||
|
||||
function isCanonicalPilotPair(config: DodoV3PilotConfig, tokenInAddress: string, tokenOutAddress: string): boolean {
|
||||
const tokenIn = normalizeAddress(tokenInAddress);
|
||||
const tokenOut = normalizeAddress(tokenOutAddress);
|
||||
return (
|
||||
(tokenIn === config.weth10Address && tokenOut === config.usdtAddress) ||
|
||||
(tokenIn === config.usdtAddress && tokenOut === config.weth10Address)
|
||||
);
|
||||
}
|
||||
|
||||
export async function quoteChain138DodoV3AmountOut(args: {
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
amountIn: bigint | string;
|
||||
}): Promise<bigint> {
|
||||
const config = getPilotConfig();
|
||||
if (!config.enabled || !config.poolAddress || !config.proxyAddress) {
|
||||
return 0n;
|
||||
}
|
||||
if (!isCanonicalPilotPair(config, args.tokenInAddress, args.tokenOutAddress)) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
const pool = new Contract(config.poolAddress, d3MmAbi, getProvider(config.rpcUrl));
|
||||
const quote = await pool.querySellTokens(
|
||||
normalizeAddress(args.tokenInAddress),
|
||||
normalizeAddress(args.tokenOutAddress),
|
||||
BigInt(args.amountIn)
|
||||
);
|
||||
|
||||
return BigInt(String(quote[1]));
|
||||
}
|
||||
|
||||
export async function getChain138DodoV3PilotEdges(): Promise<SwapGraphEdge[]> {
|
||||
const config = getPilotConfig();
|
||||
if (!config.enabled || !config.poolAddress || !config.proxyAddress || !config.weth10Address || !config.usdtAddress) {
|
||||
return [];
|
||||
}
|
||||
const executionLive = isChain138DodoV3ExecutionLive();
|
||||
|
||||
const pool = new Contract(config.poolAddress, d3MmAbi, getProvider(config.rpcUrl));
|
||||
const [wethReserveRaw, usdtReserveRaw] = await Promise.all([
|
||||
pool.getTokenReserve(config.weth10Address),
|
||||
pool.getTokenReserve(config.usdtAddress),
|
||||
]);
|
||||
|
||||
const wethReserve = BigInt(String(wethReserveRaw));
|
||||
const usdtReserve = BigInt(String(usdtReserveRaw));
|
||||
if (wethReserve <= 0n || usdtReserve <= 0n) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const totalLiquidityUsd = approximateLiquidityUsd({
|
||||
wethReserve,
|
||||
usdtReserve,
|
||||
wethUsdPrice: config.wethUsdPrice,
|
||||
liquidityOverrideUsd: config.liquidityOverrideUsd,
|
||||
});
|
||||
const notes = buildNotes(config.poolAddress, config.proxyAddress, executionLive);
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'dodo_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: config.weth10Address,
|
||||
tokenOutAddress: config.usdtAddress,
|
||||
tokenInSymbol: 'WETH10',
|
||||
tokenOutSymbol: 'USDT',
|
||||
reserveIn: wethReserve.toString(),
|
||||
reserveOut: usdtReserve.toString(),
|
||||
target: config.proxyAddress,
|
||||
poolAddress: config.poolAddress,
|
||||
providerData: {
|
||||
poolAddress: config.poolAddress,
|
||||
quoteMethod: 'querySellTokens',
|
||||
executionTarget: config.proxyAddress,
|
||||
},
|
||||
providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(config.poolAddress) : undefined,
|
||||
totalLiquidityUsd,
|
||||
freshnessSeconds: 0,
|
||||
notes,
|
||||
},
|
||||
{
|
||||
kind: 'swap',
|
||||
provider: 'dodo_v3',
|
||||
chainId: CHAIN_138,
|
||||
tokenInAddress: config.usdtAddress,
|
||||
tokenOutAddress: config.weth10Address,
|
||||
tokenInSymbol: 'USDT',
|
||||
tokenOutSymbol: 'WETH10',
|
||||
reserveIn: usdtReserve.toString(),
|
||||
reserveOut: wethReserve.toString(),
|
||||
target: config.proxyAddress,
|
||||
poolAddress: config.poolAddress,
|
||||
providerData: {
|
||||
poolAddress: config.poolAddress,
|
||||
quoteMethod: 'querySellTokens',
|
||||
executionTarget: config.proxyAddress,
|
||||
},
|
||||
providerDataHex: executionLive ? encodeChain138DodoV3ProviderData(config.poolAddress) : undefined,
|
||||
totalLiquidityUsd,
|
||||
freshnessSeconds: 0,
|
||||
notes,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Interface, ZeroAddress } from 'ethers';
|
||||
import { BestExecutionPlanner } from './best-execution-planner';
|
||||
import { EncodedBridgeIntentPlan, EncodedPlannerRoutePlan, PlannerRequest } from './planner-v2-types';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const DEFAULT_ROUTER_V2_ADDRESS = '0xf1c93f54a5c2fc0d7766ccb0ad8f157dfb4c99ce';
|
||||
const DEFAULT_INTENT_BRIDGE_COORDINATOR_V2_ADDRESS = '0x7d0022b7e8360172fd9c0bb6778113b7ea3674e7';
|
||||
|
||||
const routeInterface = new Interface([
|
||||
'function executeRoute((uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs) plan)',
|
||||
]);
|
||||
|
||||
const intentInterface = new Interface([
|
||||
'function executeIntent((((uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs),bytes32 bridgeType,bytes bridgeData,(uint256 chainId,address inputToken,address outputToken,uint256 amountIn,uint256 minAmountOut,address recipient,uint256 deadline,(uint8 provider,address tokenIn,address tokenOut,uint8 amountSource,uint256 minAmountOut,address target,bytes providerData)[] legs),address recipient,uint256 deadline)) intent)',
|
||||
]);
|
||||
|
||||
export interface InternalExecutionPlanV2Result {
|
||||
generatedAt: string;
|
||||
plannerResponse: Awaited<ReturnType<BestExecutionPlanner['plan']>>;
|
||||
execution?: {
|
||||
kind: 'route' | 'bridge-intent';
|
||||
contractAddress: string;
|
||||
functionName: string;
|
||||
signature: string;
|
||||
args: [EncodedPlannerRoutePlan] | [EncodedBridgeIntentPlan];
|
||||
encodedCalldata: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class InternalExecutionPlanV2Builder {
|
||||
private planner: BestExecutionPlanner;
|
||||
|
||||
constructor(planner = new BestExecutionPlanner()) {
|
||||
this.planner = planner;
|
||||
}
|
||||
|
||||
async build(request: PlannerRequest): Promise<InternalExecutionPlanV2Result> {
|
||||
const plannerResponse = await this.planner.plan(request);
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
if (plannerResponse.bridgeIntentPlan) {
|
||||
const contractAddress = (
|
||||
process.env.INTENT_BRIDGE_COORDINATOR_V2_ADDRESS ||
|
||||
(plannerResponse.sourceChainId === CHAIN_138 ? DEFAULT_INTENT_BRIDGE_COORDINATOR_V2_ADDRESS : '')
|
||||
).trim().toLowerCase();
|
||||
if (!contractAddress) {
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
error: 'INTENT_BRIDGE_COORDINATOR_V2_ADDRESS is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
execution: {
|
||||
kind: 'bridge-intent',
|
||||
contractAddress,
|
||||
functionName: 'executeIntent',
|
||||
signature: 'executeIntent(((uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]),bytes32,bytes,(uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]),address,uint256))',
|
||||
args: [plannerResponse.bridgeIntentPlan],
|
||||
encodedCalldata: intentInterface.encodeFunctionData('executeIntent', [plannerResponse.bridgeIntentPlan]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (plannerResponse.routePlan && plannerResponse.routePlan.legs.length > 0) {
|
||||
const contractAddress = (
|
||||
process.env.ENHANCED_SWAP_ROUTER_V2_ADDRESS ||
|
||||
(plannerResponse.sourceChainId === CHAIN_138 ? DEFAULT_ROUTER_V2_ADDRESS : '')
|
||||
).trim().toLowerCase();
|
||||
if (!contractAddress) {
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
error: 'ENHANCED_SWAP_ROUTER_V2_ADDRESS is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
execution: {
|
||||
kind: 'route',
|
||||
contractAddress,
|
||||
functionName: 'executeRoute',
|
||||
signature: 'executeRoute((uint256,address,address,uint256,uint256,address,uint256,(uint8,address,address,uint8,uint256,address,bytes)[]))',
|
||||
args: [plannerResponse.routePlan],
|
||||
encodedCalldata: routeInterface.encodeFunctionData('executeRoute', [plannerResponse.routePlan]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
plannerResponse,
|
||||
error: plannerResponse.decision === 'bridge-only'
|
||||
? 'Bridge-only planner result does not require EnhancedSwapRouterV2 calldata'
|
||||
: plannerResponse.legs.length > 0
|
||||
? 'Planner route includes one or more providers that are not yet executable through EnhancedSwapRouterV2'
|
||||
: 'No executable planner route found',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function buildInternalExecutionPlan(
|
||||
const reserveMap = buildPoolReserveMap(matrix.liveSwapRoutes);
|
||||
const amountIn = BigInt(request.amountIn);
|
||||
const slippageBps = request.slippageBps || '100';
|
||||
const executorAddress = route.legs?.[0]?.executorAddress || '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
|
||||
const executorAddress = route.legs?.[0]?.executorAddress || '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
const steps: InternalExecutionStep[] = [];
|
||||
let currentAmount = amountIn;
|
||||
|
||||
178
services/token-aggregation/src/services/live-dodo-fallback.ts
Normal file
178
services/token-aggregation/src/services/live-dodo-fallback.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { ethers } from 'ethers';
|
||||
import type { LiquidityPool } from '../database/repositories/pool-repo';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { getDexFactories } from '../config/dex-factories';
|
||||
import { shouldExposePublicPool } from '../config/gru-transport';
|
||||
import { logger } from '../utils/logger';
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
|
||||
const DODO_PMM_INTEGRATION_ABI = [
|
||||
'function getAllPools() view returns (address[])',
|
||||
'function getPoolConfig(address) view returns (tuple(address pool, address baseToken, address quoteToken, uint256 lpFeeRate, uint256 i, uint256 k, bool isOpenTWAP, uint256 createdAt))',
|
||||
'function getPoolReserves(address) view returns (uint256 baseReserve, uint256 quoteReserve)',
|
||||
'function getPoolPriceOrOracle(address) view returns (uint256 price)',
|
||||
];
|
||||
|
||||
const DODO_DVM_POOL_ABI = [
|
||||
'function _BASE_TOKEN_() view returns (address)',
|
||||
'function _QUOTE_TOKEN_() view returns (address)',
|
||||
'function getVaultReserve() view returns (uint256 baseReserve, uint256 quoteReserve)',
|
||||
'function getMidPrice() view returns (uint256)',
|
||||
];
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
const livePoolCache = new Map<number, { expiresAt: number; pools: LiquidityPool[] }>();
|
||||
|
||||
interface LivePoolSnapshot {
|
||||
token0Address: string;
|
||||
token1Address: string;
|
||||
reserve0: bigint;
|
||||
reserve1: bigint;
|
||||
price: bigint;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function configuredIntegrations(chainId: number): string[] {
|
||||
const dodoConfigs = getDexFactories(chainId)?.dodo || [];
|
||||
return [...new Set(
|
||||
dodoConfigs
|
||||
.map((config) => config.dodoPmmIntegration?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)];
|
||||
}
|
||||
|
||||
async function readPoolViaIntegration(
|
||||
integration: ethers.Contract,
|
||||
poolAddressRaw: string
|
||||
): Promise<LivePoolSnapshot> {
|
||||
const [configResult, reservesResult, priceResult] = await Promise.all([
|
||||
integration.getPoolConfig(poolAddressRaw),
|
||||
integration.getPoolReserves(poolAddressRaw),
|
||||
integration.getPoolPriceOrOracle(poolAddressRaw).catch(() => 0n),
|
||||
]);
|
||||
|
||||
const cfg = configResult as unknown as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
bigint,
|
||||
bigint,
|
||||
bigint,
|
||||
boolean,
|
||||
bigint,
|
||||
];
|
||||
const [reserve0, reserve1] = reservesResult as [bigint, bigint];
|
||||
|
||||
return {
|
||||
token0Address: cfg[1].toLowerCase(),
|
||||
token1Address: cfg[2].toLowerCase(),
|
||||
reserve0,
|
||||
reserve1,
|
||||
price: typeof priceResult === 'bigint' ? priceResult : 0n,
|
||||
createdAt: cfg[7] ? new Date(Number(cfg[7]) * 1000) : new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPoolDirectly(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
poolAddressRaw: string
|
||||
): Promise<LivePoolSnapshot> {
|
||||
const pool = new ethers.Contract(poolAddressRaw, DODO_DVM_POOL_ABI, provider);
|
||||
const [baseToken, quoteToken, reservesResult, midPriceResult] = await Promise.all([
|
||||
pool._BASE_TOKEN_(),
|
||||
pool._QUOTE_TOKEN_(),
|
||||
pool.getVaultReserve(),
|
||||
pool.getMidPrice().catch(() => 0n),
|
||||
]);
|
||||
const [reserve0, reserve1] = reservesResult as [bigint, bigint];
|
||||
|
||||
return {
|
||||
token0Address: String(baseToken).toLowerCase(),
|
||||
token1Address: String(quoteToken).toLowerCase(),
|
||||
reserve0,
|
||||
reserve1,
|
||||
price: typeof midPriceResult === 'bigint' ? midPriceResult : 0n,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLiveDodoPools(chainId: number): Promise<LiquidityPool[]> {
|
||||
const now = Date.now();
|
||||
const cached = livePoolCache.get(chainId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.pools;
|
||||
}
|
||||
|
||||
const chainConfig = getChainConfig(chainId);
|
||||
const integrations = configuredIntegrations(chainId);
|
||||
if (!chainConfig || integrations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = new ethers.JsonRpcProvider(chainConfig.rpcUrl);
|
||||
const poolsByAddress = new Map<string, LiquidityPool>();
|
||||
|
||||
for (const integrationAddress of integrations) {
|
||||
try {
|
||||
const integration = new ethers.Contract(
|
||||
integrationAddress,
|
||||
DODO_PMM_INTEGRATION_ABI,
|
||||
provider
|
||||
);
|
||||
const poolAddresses = (await integration.getAllPools()) as string[];
|
||||
|
||||
for (const poolAddressRaw of poolAddresses) {
|
||||
try {
|
||||
const poolAddress = poolAddressRaw.toLowerCase();
|
||||
const snapshot =
|
||||
await readPoolViaIntegration(integration, poolAddressRaw).catch(async () =>
|
||||
readPoolDirectly(provider, poolAddressRaw)
|
||||
);
|
||||
const { token0Address, token1Address, reserve0, reserve1, price, createdAt } = snapshot;
|
||||
|
||||
if (!shouldExposePublicPool(chainId, poolAddress, token0Address, token1Address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const liquidityUsd = chainId === 138
|
||||
? estimateChain138DodoLiquidityUsd({
|
||||
token0Address,
|
||||
token1Address,
|
||||
reserve0,
|
||||
reserve1,
|
||||
price,
|
||||
})
|
||||
: { reserve0Usd: 0, reserve1Usd: 0, totalLiquidityUsd: 0 };
|
||||
|
||||
poolsByAddress.set(poolAddress, {
|
||||
chainId,
|
||||
poolAddress,
|
||||
token0Address,
|
||||
token1Address,
|
||||
dexType: 'dodo',
|
||||
factoryAddress: integrationAddress,
|
||||
reserve0: reserve0.toString(),
|
||||
reserve1: reserve1.toString(),
|
||||
reserve0Usd: liquidityUsd.reserve0Usd,
|
||||
reserve1Usd: liquidityUsd.reserve1Usd,
|
||||
totalLiquidityUsd: liquidityUsd.totalLiquidityUsd,
|
||||
volume24h: 0,
|
||||
createdAtBlock: 0,
|
||||
createdAtTimestamp: createdAt,
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`Skipping unreadable live DODO pool ${poolAddressRaw} on chain ${chainId}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Live DODO fallback failed for chain ${chainId} integration ${integrationAddress}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const pools = Array.from(poolsByAddress.values()).sort(
|
||||
(a, b) => b.totalLiquidityUsd - a.totalLiquidityUsd
|
||||
);
|
||||
livePoolCache.set(chainId, { expiresAt: now + CACHE_TTL_MS, pools });
|
||||
return pools;
|
||||
}
|
||||
187
services/token-aggregation/src/services/planner-v2-types.ts
Normal file
187
services/token-aggregation/src/services/planner-v2-types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
export type PlannerProvider = 'dodo' | 'dodo_v3' | 'uniswap_v3' | 'balancer' | 'curve' | 'one_inch' | 'partner';
|
||||
export type PlannerLegKind = 'swap' | 'bridge';
|
||||
export type PlannerDecision = 'direct-pool' | 'multi-hop' | 'swap-bridge-swap' | 'bridge-only' | 'unresolved';
|
||||
export type ComplianceProfile = 'standard' | 'institutional';
|
||||
|
||||
export interface PlannerConstraints {
|
||||
maxSlippageBps?: number;
|
||||
allowedProviders?: PlannerProvider[];
|
||||
maxLegs?: number;
|
||||
allowedIntermediates?: string[];
|
||||
complianceProfile?: ComplianceProfile;
|
||||
allowBridge?: boolean;
|
||||
preferredBridges?: string[];
|
||||
allowCommodityIntermediates?: boolean;
|
||||
}
|
||||
|
||||
export interface PlannerRequest {
|
||||
sourceChainId: number;
|
||||
destinationChainId?: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountIn: string;
|
||||
recipient?: string;
|
||||
constraints?: PlannerConstraints;
|
||||
}
|
||||
|
||||
export interface EncodedPlannerRouteLeg {
|
||||
provider: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
amountSource: number;
|
||||
minAmountOut: string;
|
||||
target: string;
|
||||
providerData: string;
|
||||
}
|
||||
|
||||
export interface EncodedPlannerRoutePlan {
|
||||
chainId: number;
|
||||
inputToken: string;
|
||||
outputToken: string;
|
||||
amountIn: string;
|
||||
minAmountOut: string;
|
||||
recipient: string;
|
||||
deadline: string;
|
||||
legs: EncodedPlannerRouteLeg[];
|
||||
}
|
||||
|
||||
export interface EncodedBridgeIntentPlan {
|
||||
sourcePlan: EncodedPlannerRoutePlan;
|
||||
bridgeType: string;
|
||||
bridgeData: string;
|
||||
destinationPlan: EncodedPlannerRoutePlan;
|
||||
recipient: string;
|
||||
deadline: string;
|
||||
}
|
||||
|
||||
export interface PlannerLeg {
|
||||
kind: PlannerLegKind;
|
||||
provider: PlannerProvider;
|
||||
sourceChainId: number;
|
||||
destinationChainId: number;
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
tokenInSymbol?: string;
|
||||
tokenOutSymbol?: string;
|
||||
estimatedAmountIn: string;
|
||||
estimatedAmountOut: string;
|
||||
minAmountOut: string;
|
||||
target?: string;
|
||||
poolAddress?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
bridgeType?: string;
|
||||
bridgeAddress?: string;
|
||||
gasEstimate?: number;
|
||||
freshnessSeconds?: number | null;
|
||||
totalLiquidityUsd?: number;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface PlannerAlternative {
|
||||
routeId: string;
|
||||
decision: PlannerDecision;
|
||||
estimatedAmountOut: string;
|
||||
estimatedGasUsd: number;
|
||||
providerPath: PlannerProvider[];
|
||||
legCount: number;
|
||||
score: number;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PlannerStaleness {
|
||||
maxFreshnessSeconds: number | null;
|
||||
hasStaleLeg: boolean;
|
||||
}
|
||||
|
||||
export interface PlannerResponse {
|
||||
planId: string;
|
||||
generatedAt: string;
|
||||
decision: PlannerDecision;
|
||||
sourceChainId: number;
|
||||
destinationChainId: number;
|
||||
tokenIn: string;
|
||||
tokenOut: string;
|
||||
estimatedAmountOut: string | null;
|
||||
minAmountOut: string | null;
|
||||
estimatedGasUsd: number;
|
||||
legs: PlannerLeg[];
|
||||
alternatives: PlannerAlternative[];
|
||||
confidenceScore: number;
|
||||
riskFlags: string[];
|
||||
selectedRouteReason: string;
|
||||
rejectedAlternatives: string[];
|
||||
staleness: PlannerStaleness;
|
||||
routePlan?: EncodedPlannerRoutePlan;
|
||||
bridgeIntentPlan?: EncodedBridgeIntentPlan;
|
||||
}
|
||||
|
||||
export interface ProviderPairCapability {
|
||||
chainId: number;
|
||||
provider: PlannerProvider;
|
||||
legType: PlannerLegKind;
|
||||
status: 'live' | 'planned' | 'blocked';
|
||||
tokenInSymbol: string;
|
||||
tokenInAddress: string;
|
||||
tokenOutSymbol: string;
|
||||
tokenOutAddress: string;
|
||||
target?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
notes?: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilityRecord {
|
||||
chainId: number;
|
||||
provider: PlannerProvider;
|
||||
executionMode: 'onchain' | 'partner';
|
||||
live: boolean;
|
||||
quoteLive: boolean;
|
||||
executionLive: boolean;
|
||||
supportedLegTypes: PlannerLegKind[];
|
||||
pairs: ProviderPairCapability[];
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface RoutingPolicy {
|
||||
profile: ComplianceProfile;
|
||||
allowedProviders: PlannerProvider[];
|
||||
defaultIntermediateAddresses: string[];
|
||||
allowBridge: boolean;
|
||||
allowedBridgeLabels: string[];
|
||||
maxLegs: number;
|
||||
allowCommodityIntermediates: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface SwapGraphEdge {
|
||||
kind: 'swap';
|
||||
provider: PlannerProvider;
|
||||
chainId: number;
|
||||
tokenInAddress: string;
|
||||
tokenOutAddress: string;
|
||||
tokenInSymbol?: string;
|
||||
tokenOutSymbol?: string;
|
||||
reserveIn: string;
|
||||
reserveOut: string;
|
||||
target?: string;
|
||||
poolAddress?: string;
|
||||
providerData?: Record<string, unknown>;
|
||||
providerDataHex?: string;
|
||||
totalLiquidityUsd: number;
|
||||
freshnessSeconds: number | null;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface BridgeRouteCandidate {
|
||||
bridgeType: string;
|
||||
bridgeAddress: string;
|
||||
bridgeLabel: string;
|
||||
assetSymbol: string;
|
||||
sourceTokenAddress: string;
|
||||
destinationTokenAddress: string;
|
||||
fromChainId: number;
|
||||
toChainId: number;
|
||||
notes: string[];
|
||||
}
|
||||
58
services/token-aggregation/src/services/pmm-onchain-quote.ts
Normal file
58
services/token-aggregation/src/services/pmm-onchain-quote.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
|
||||
const POOL_ABI = [
|
||||
'function _BASE_TOKEN_() view returns (address)',
|
||||
'function _QUOTE_TOKEN_() view returns (address)',
|
||||
'function querySellBase(address,uint256) view returns (uint256,uint256)',
|
||||
'function querySellQuote(address,uint256) view returns (uint256,uint256)',
|
||||
];
|
||||
|
||||
/**
|
||||
* PMM / DVM on-chain output for tokenIn amount. Matches DODOPMMIntegration.swapExactIn semantics.
|
||||
* Returns null if RPC fails, pool is not a DVM, or tokenIn is not base/quote.
|
||||
*/
|
||||
export async function pmmQuoteAmountOutFromChain(params: {
|
||||
rpcUrl: string;
|
||||
poolAddress: string;
|
||||
tokenInLookup: string;
|
||||
amountIn: bigint;
|
||||
/** Passed to querySell* (MT fee view); default deployer is typical for operator tooling. */
|
||||
traderForView: string;
|
||||
}): Promise<bigint | null> {
|
||||
const { rpcUrl, poolAddress, tokenInLookup, amountIn, traderForView } = params;
|
||||
try {
|
||||
const provider = new JsonRpcProvider(rpcUrl);
|
||||
const pool = new Contract(poolAddress, POOL_ABI, provider);
|
||||
const base = (await pool._BASE_TOKEN_()).toString().toLowerCase();
|
||||
const quote = (await pool._QUOTE_TOKEN_()).toString().toLowerCase();
|
||||
const ti = tokenInLookup.toLowerCase();
|
||||
if (ti === base) {
|
||||
const [out] = await pool.querySellBase(traderForView, amountIn);
|
||||
return BigInt(out.toString());
|
||||
}
|
||||
if (ti === quote) {
|
||||
const [out] = await pool.querySellQuote(traderForView, amountIn);
|
||||
return BigInt(out.toString());
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** RPC for PMM eth_call quotes on Chain 138 (optional; unset = skip on-chain override). */
|
||||
export function resolvePmmQuoteRpcUrl(): string {
|
||||
return (
|
||||
process.env.TOKEN_AGGREGATION_PMM_RPC_URL ||
|
||||
process.env.TOKEN_AGGREGATION_CHAIN138_RPC_URL ||
|
||||
process.env.RPC_URL_138 ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
export function resolvePmmQuoteTrader(): string {
|
||||
return (
|
||||
process.env.TOKEN_AGGREGATION_PMM_QUERY_TRADER ||
|
||||
'0x4A666F96fC8764181194447A7dFdb7d471b301C8'
|
||||
).trim();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ResolvedTokenDisplay } from './token-display';
|
||||
import { classifyPoolDepthStatus, estimateChain138FallbackDepthUsd } from './route-decision-tree';
|
||||
|
||||
function token(address: string, symbol: string, decimals: number): ResolvedTokenDisplay {
|
||||
return {
|
||||
address,
|
||||
symbol,
|
||||
name: symbol,
|
||||
decimals,
|
||||
source: 'canonical',
|
||||
};
|
||||
}
|
||||
|
||||
describe('estimateChain138FallbackDepthUsd', () => {
|
||||
it('derives tvl from the stable side for funded mixed pairs', () => {
|
||||
const weth10 = token('0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f', 'WETH10', 18);
|
||||
const usdt = token('0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', 'USDT', 6);
|
||||
|
||||
const result = estimateChain138FallbackDepthUsd(
|
||||
weth10,
|
||||
50n * 10n ** 18n,
|
||||
usdt,
|
||||
200_000n * 10n ** 6n
|
||||
);
|
||||
|
||||
expect(result.tvlUsd).toBe(400_000);
|
||||
expect(result.estimatedTradeCapacityUsd).toBe(80_000);
|
||||
});
|
||||
|
||||
it('adds both sides for funded stable-to-stable pools', () => {
|
||||
const cusdcV2 = token('0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d', 'cUSDC_V2', 6);
|
||||
const usdc = token('0x71D6687F38b93CCad569Fa6352c876eea967201b', 'USDC', 6);
|
||||
|
||||
const result = estimateChain138FallbackDepthUsd(
|
||||
cusdcV2,
|
||||
1_000_000n * 10n ** 6n,
|
||||
usdc,
|
||||
1_000_000n * 10n ** 6n
|
||||
);
|
||||
|
||||
expect(result.tvlUsd).toBe(2_000_000);
|
||||
expect(result.estimatedTradeCapacityUsd).toBe(400_000);
|
||||
});
|
||||
|
||||
it('keeps zero-dollar metrics for non-stable or partially funded pools', () => {
|
||||
const weth = token('0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f', 'WETH10', 18);
|
||||
const cxauc = token('0x290E52a8819A4fbD0714E517225429aA2B70EC6b', 'cXAUC', 18);
|
||||
|
||||
expect(
|
||||
estimateChain138FallbackDepthUsd(weth, 10n * 10n ** 18n, cxauc, 5n * 10n ** 18n)
|
||||
).toEqual({
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
estimateChain138FallbackDepthUsd(weth, 10n * 10n ** 18n, token('0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1', 'USDT', 6), 0n)
|
||||
).toEqual({
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyPoolDepthStatus', () => {
|
||||
it('marks zero-liquidity pools unavailable even if freshness is recent', () => {
|
||||
expect(classifyPoolDepthStatus(0, 0, 0)).toBe('unavailable');
|
||||
expect(classifyPoolDepthStatus(1000, 0, 120)).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('keeps funded pools live or stale based on freshness', () => {
|
||||
expect(classifyPoolDepthStatus(1000, 200, 60)).toBe('live');
|
||||
expect(classifyPoolDepthStatus(1000, 200, 900)).toBe('stale');
|
||||
expect(classifyPoolDepthStatus(1000, 200, 3600)).toBe('unavailable');
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,18 @@ import { TokenRepository } from '../database/repositories/token-repo';
|
||||
import { PoolRepository, LiquidityPool, DexType } from '../database/repositories/pool-repo';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { ResolvedTokenDisplay, resolvePoolTokenDisplays, resolveTokenDisplay } from './token-display';
|
||||
import { Contract, JsonRpcProvider } from 'ethers';
|
||||
import { Contract, JsonRpcProvider, formatUnits } from 'ethers';
|
||||
import { getCanonicalTokenByAddress, getCanonicalTokenBySymbol } from '../config/canonical-tokens';
|
||||
import { getRouteFromRegistry } from '../config/cross-chain-bridges';
|
||||
import { filterPoolsForRouting } from '../config/gru-transport';
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_138_PMM_INTEGRATION =
|
||||
process.env.CHAIN_138_DODO_PMM_INTEGRATION ||
|
||||
process.env.DODO_PMM_INTEGRATION_ADDRESS ||
|
||||
process.env.DODO_PMM_INTEGRATION ||
|
||||
'0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
|
||||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
|
||||
|
||||
const PMM_ABI = [
|
||||
'function pools(address,address) view returns (address)',
|
||||
@@ -21,6 +23,8 @@ const ERC20_ABI = [
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
];
|
||||
|
||||
const CHAIN_138_FALLBACK_CAPACITY_RATIO = 0.2;
|
||||
|
||||
export type RouteNodeKind =
|
||||
| 'direct-pool'
|
||||
| 'bridge'
|
||||
@@ -97,8 +101,24 @@ export interface RouteDecisionTreeResponse {
|
||||
missingQuoteTokenPools: MissingQuoteTokenPool[];
|
||||
}
|
||||
|
||||
function normalizedTvlUsd(pool: LiquidityPool): number {
|
||||
let tvl = Math.max(0, pool.totalLiquidityUsd || 0);
|
||||
if (pool.chainId === CHAIN_138 && pool.dexType === 'dodo') {
|
||||
const estimated = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: pool.token0Address,
|
||||
token1Address: pool.token1Address,
|
||||
reserve0: BigInt(pool.reserve0 || '0'),
|
||||
reserve1: BigInt(pool.reserve1 || '0'),
|
||||
}).totalLiquidityUsd;
|
||||
if (estimated > 0) {
|
||||
tvl = Math.max(tvl, estimated);
|
||||
}
|
||||
}
|
||||
return tvl;
|
||||
}
|
||||
|
||||
function estimateTradeCapacityUsd(pool: LiquidityPool): number {
|
||||
const tvl = Math.max(0, pool.totalLiquidityUsd || 0);
|
||||
const tvl = normalizedTvlUsd(pool);
|
||||
if (tvl === 0) return 0;
|
||||
const freshnessBoost = pool.lastUpdated
|
||||
? Math.max(0.25, 1 - (Date.now() - pool.lastUpdated.getTime()) / (60 * 60 * 1000))
|
||||
@@ -107,26 +127,101 @@ function estimateTradeCapacityUsd(pool: LiquidityPool): number {
|
||||
return Math.max(0, Math.min(tvl, capacity));
|
||||
}
|
||||
|
||||
export function classifyPoolDepthStatus(
|
||||
tvlUsd: number,
|
||||
estimatedTradeCapacityUsd: number,
|
||||
freshnessSeconds: number | null
|
||||
): RouteDepthMetrics['status'] {
|
||||
if (!(tvlUsd > 0) || !(estimatedTradeCapacityUsd > 0)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (freshnessSeconds === null) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (freshnessSeconds < 300) {
|
||||
return 'live';
|
||||
}
|
||||
if (freshnessSeconds < 1800) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
function buildDepth(pool: LiquidityPool): RouteDepthMetrics {
|
||||
const freshnessSeconds = pool.lastUpdated ? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000)) : null;
|
||||
const status = freshnessSeconds === null
|
||||
? 'unavailable'
|
||||
: freshnessSeconds < 300
|
||||
? 'live'
|
||||
: freshnessSeconds < 1800
|
||||
? 'stale'
|
||||
: 'unavailable';
|
||||
const tvlUsd = normalizedTvlUsd(pool);
|
||||
const estimatedTradeCapacityUsd = estimateTradeCapacityUsd(pool);
|
||||
const status = classifyPoolDepthStatus(tvlUsd, estimatedTradeCapacityUsd, freshnessSeconds);
|
||||
|
||||
return {
|
||||
tvlUsd: pool.totalLiquidityUsd || 0,
|
||||
tvlUsd,
|
||||
reserve0: pool.reserve0,
|
||||
reserve1: pool.reserve1,
|
||||
estimatedTradeCapacityUsd: estimateTradeCapacityUsd(pool),
|
||||
estimatedTradeCapacityUsd,
|
||||
freshnessSeconds,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function isChain138UsdFallbackToken(token: ResolvedTokenDisplay): boolean {
|
||||
if (!token?.address) return false;
|
||||
const spec = getCanonicalTokenByAddress(CHAIN_138, token.address);
|
||||
return spec?.currencyCode === 'USD';
|
||||
}
|
||||
|
||||
function getChain138FallbackTokenDecimals(token: ResolvedTokenDisplay): number {
|
||||
const spec = token?.address ? getCanonicalTokenByAddress(CHAIN_138, token.address) : undefined;
|
||||
return Number(token?.decimals ?? spec?.decimals ?? 18);
|
||||
}
|
||||
|
||||
function parseFallbackTokenAmount(value: bigint, decimals: number): number {
|
||||
if (value <= 0n) return 0;
|
||||
const parsed = Number(formatUnits(value, decimals));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function estimateChain138FallbackDepthUsd(
|
||||
token0: ResolvedTokenDisplay,
|
||||
reserve0: bigint,
|
||||
token1: ResolvedTokenDisplay,
|
||||
reserve1: bigint
|
||||
): Pick<RouteDepthMetrics, 'tvlUsd' | 'estimatedTradeCapacityUsd'> {
|
||||
if (reserve0 <= 0n || reserve1 <= 0n) {
|
||||
return {
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const reserve0Usd = isChain138UsdFallbackToken(token0)
|
||||
? parseFallbackTokenAmount(reserve0, getChain138FallbackTokenDecimals(token0))
|
||||
: 0;
|
||||
const reserve1Usd = isChain138UsdFallbackToken(token1)
|
||||
? parseFallbackTokenAmount(reserve1, getChain138FallbackTokenDecimals(token1))
|
||||
: 0;
|
||||
|
||||
let tvlUsd = 0;
|
||||
if (reserve0Usd > 0 && reserve1Usd > 0) {
|
||||
tvlUsd = reserve0Usd + reserve1Usd;
|
||||
} else if (reserve0Usd > 0) {
|
||||
tvlUsd = reserve0Usd * 2;
|
||||
} else if (reserve1Usd > 0) {
|
||||
tvlUsd = reserve1Usd * 2;
|
||||
}
|
||||
|
||||
if (tvlUsd <= 0) {
|
||||
return {
|
||||
tvlUsd: 0,
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tvlUsd,
|
||||
estimatedTradeCapacityUsd: Math.max(0, Math.min(tvlUsd, tvlUsd * CHAIN_138_FALLBACK_CAPACITY_RATIO)),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveDecision(
|
||||
sourceChainId: number,
|
||||
destinationChainId: number | undefined,
|
||||
@@ -183,7 +278,10 @@ export class RouteDecisionTreeService {
|
||||
if (request.tokenOut) acceptableTokenOutAddresses.add(request.tokenOut.toLowerCase());
|
||||
if (bridgeResolution?.localQuoteAddress) acceptableTokenOutAddresses.add(bridgeResolution.localQuoteAddress.toLowerCase());
|
||||
|
||||
const pools = await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn);
|
||||
const pools = filterPoolsForRouting(
|
||||
request.chainId,
|
||||
await this.poolRepo.getPoolsByToken(request.chainId, request.tokenIn)
|
||||
);
|
||||
const directPools = request.tokenOut
|
||||
? pools.filter((pool) =>
|
||||
acceptableTokenOutAddresses.has(pool.token0Address.toLowerCase()) ||
|
||||
@@ -193,7 +291,10 @@ export class RouteDecisionTreeService {
|
||||
|
||||
const destinationPools =
|
||||
request.tokenOut && destinationChainId !== request.chainId
|
||||
? await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut)
|
||||
? filterPoolsForRouting(
|
||||
destinationChainId,
|
||||
await this.poolRepo.getPoolsByToken(destinationChainId, request.tokenOut)
|
||||
)
|
||||
: [];
|
||||
|
||||
let resolvedPools = await Promise.all(
|
||||
@@ -238,10 +339,19 @@ export class RouteDecisionTreeService {
|
||||
})
|
||||
);
|
||||
|
||||
if (request.chainId === CHAIN_138 && destinationChainId === CHAIN_138 && request.tokenOut && resolvedPools.length === 0) {
|
||||
const shouldTryLiveFallback =
|
||||
request.chainId === CHAIN_138 &&
|
||||
destinationChainId === CHAIN_138 &&
|
||||
request.tokenOut &&
|
||||
(resolvedPools.length === 0 || resolvedPools.every((pool) => pool.depth.status === 'unavailable'));
|
||||
|
||||
if (shouldTryLiveFallback) {
|
||||
const liveFallbackPool = await this.findLiveDirectPoolFallback(request, sourceTokenIn, sourceTokenOut);
|
||||
if (liveFallbackPool) {
|
||||
resolvedPools = [liveFallbackPool, ...resolvedPools];
|
||||
resolvedPools = [
|
||||
liveFallbackPool,
|
||||
...resolvedPools.filter((pool) => pool.poolAddress.toLowerCase() !== liveFallbackPool.poolAddress.toLowerCase()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,17 +513,19 @@ export class RouteDecisionTreeService {
|
||||
tokenOutContract.balanceOf(poolAddress),
|
||||
]);
|
||||
const live = reserve0 > 0n && reserve1 > 0n;
|
||||
const token1 = sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut);
|
||||
const depthUsd = estimateChain138FallbackDepthUsd(sourceTokenIn, reserve0, token1, reserve1);
|
||||
|
||||
return {
|
||||
poolAddress,
|
||||
dexType: 'dodo',
|
||||
token0: sourceTokenIn,
|
||||
token1: sourceTokenOut || await resolveTokenDisplay(this.tokenRepo, request.chainId, request.tokenOut),
|
||||
token1,
|
||||
depth: {
|
||||
tvlUsd: 0,
|
||||
tvlUsd: depthUsd.tvlUsd,
|
||||
reserve0: reserve0.toString(),
|
||||
reserve1: reserve1.toString(),
|
||||
estimatedTradeCapacityUsd: 0,
|
||||
estimatedTradeCapacityUsd: depthUsd.estimatedTradeCapacityUsd,
|
||||
freshnessSeconds: 0,
|
||||
status: live ? 'live' : 'stale',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { LiquidityPool, PoolRepository } from '../database/repositories/pool-repo';
|
||||
import { RouteGraphBuilder } from './route-graph-builder';
|
||||
|
||||
jest.mock('./dodo-v3-pilot', () => ({
|
||||
__esModule: true,
|
||||
getChain138DodoV3PilotEdges: jest.fn().mockResolvedValue([]),
|
||||
isChain138DodoV3ExecutionLive: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
jest.mock('./chain138-pilot-venues', () => ({
|
||||
__esModule: true,
|
||||
getChain138PilotVenueEdges: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('./live-dodo-fallback', () => ({
|
||||
__esModule: true,
|
||||
getLiveDodoPools: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
function pool(overrides: Partial<LiquidityPool>): LiquidityPool {
|
||||
return {
|
||||
chainId: 138,
|
||||
poolAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
token0Address: '0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
|
||||
token1Address: '0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
dexType: 'dodo',
|
||||
reserve0: '1000000',
|
||||
reserve1: '1000000',
|
||||
reserve0Usd: 0,
|
||||
reserve1Usd: 0,
|
||||
totalLiquidityUsd: 0,
|
||||
volume24h: 0,
|
||||
lastUpdated: new Date('2026-04-05T00:00:00Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
class MockPoolRepo {
|
||||
constructor(private readonly pools: LiquidityPool[]) {}
|
||||
|
||||
async getPoolsByChain(): Promise<LiquidityPool[]> {
|
||||
return this.pools;
|
||||
}
|
||||
}
|
||||
|
||||
describe('RouteGraphBuilder', () => {
|
||||
it('filters dust Chain 138 DODO pools from planner visibility', async () => {
|
||||
const builder = new RouteGraphBuilder(
|
||||
new MockPoolRepo([
|
||||
pool({
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
token0Address: '0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
token1Address: '0x71d6687f38b93ccad569fa6352c876eea967201b',
|
||||
reserve0: '999999997998',
|
||||
reserve1: '999999997998',
|
||||
}),
|
||||
pool({
|
||||
poolAddress: '0x2222222222222222222222222222222222222222',
|
||||
reserve0: '1001',
|
||||
reserve1: '1001',
|
||||
}),
|
||||
]) as unknown as PoolRepository
|
||||
);
|
||||
|
||||
const edges = await builder.buildSwapEdges(138);
|
||||
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges.every((edge) => edge.poolAddress === '0x1111111111111111111111111111111111111111')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps non-DODO venues visible even when they are small', async () => {
|
||||
const builder = new RouteGraphBuilder(
|
||||
new MockPoolRepo([
|
||||
pool({
|
||||
poolAddress: '0x3333333333333333333333333333333333333333',
|
||||
dexType: 'uniswap_v3',
|
||||
token0Address: '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
token1Address: '0x71D6687F38b93CCad569Fa6352c876eea967201b',
|
||||
reserve0: '10000000000000000',
|
||||
reserve1: '10000',
|
||||
totalLiquidityUsd: 1,
|
||||
}),
|
||||
]) as unknown as PoolRepository
|
||||
);
|
||||
|
||||
const edges = await builder.buildSwapEdges(138);
|
||||
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges[0].provider).toBe('uniswap_v3');
|
||||
expect(edges[1].provider).toBe('uniswap_v3');
|
||||
});
|
||||
});
|
||||
172
services/token-aggregation/src/services/route-graph-builder.ts
Normal file
172
services/token-aggregation/src/services/route-graph-builder.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { AbiCoder } from 'ethers';
|
||||
import { PoolRepository } from '../database/repositories/pool-repo';
|
||||
import type { LiquidityPool } from '../database/repositories/pool-repo';
|
||||
import { filterPoolsForRouting } from '../config/gru-transport';
|
||||
import { findProviderPairCapability } from '../config/provider-capabilities';
|
||||
import { getRouteFromRegistry } from '../config/cross-chain-bridges';
|
||||
import {
|
||||
getRoutingAddressForSymbol,
|
||||
getRoutingSymbolForAddress,
|
||||
} from '../config/routing-assets';
|
||||
import { getChain138DodoV3PilotEdges } from './dodo-v3-pilot';
|
||||
import { getChain138PilotVenueEdges } from './chain138-pilot-venues';
|
||||
import { getLiveDodoPools } from './live-dodo-fallback';
|
||||
import { estimateChain138DodoLiquidityUsd } from './chain138-dodo-liquidity';
|
||||
import { BridgeRouteCandidate, PlannerProvider, SwapGraphEdge } from './planner-v2-types';
|
||||
|
||||
const abiCoder = AbiCoder.defaultAbiCoder();
|
||||
const CHAIN_138 = 138;
|
||||
const CHAIN_138_MIN_LIVE_DODO_LIQUIDITY_USD = 100;
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function providerFromDexType(dexType: string): PlannerProvider | null {
|
||||
switch (dexType) {
|
||||
case 'dodo':
|
||||
return 'dodo';
|
||||
case 'dodo_v3':
|
||||
return 'dodo_v3';
|
||||
case 'uniswap_v3':
|
||||
return 'uniswap_v3';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupePools<T extends { poolAddress: string }>(items: T[]): T[] {
|
||||
const byAddress = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
byAddress.set(item.poolAddress.toLowerCase(), item);
|
||||
}
|
||||
return Array.from(byAddress.values());
|
||||
}
|
||||
|
||||
function isMeaningfullyFundedPool(chainId: number, pool: LiquidityPool): boolean {
|
||||
if (chainId !== CHAIN_138 || pool.dexType !== 'dodo') {
|
||||
return true;
|
||||
}
|
||||
const reserve0 = BigInt(pool.reserve0 || '0');
|
||||
const reserve1 = BigInt(pool.reserve1 || '0');
|
||||
const estimatedUsd = estimateChain138DodoLiquidityUsd({
|
||||
token0Address: pool.token0Address,
|
||||
token1Address: pool.token1Address,
|
||||
reserve0,
|
||||
reserve1,
|
||||
}).totalLiquidityUsd;
|
||||
const totalLiquidityUsd = Math.max(pool.totalLiquidityUsd || 0, estimatedUsd);
|
||||
return totalLiquidityUsd >= CHAIN_138_MIN_LIVE_DODO_LIQUIDITY_USD;
|
||||
}
|
||||
|
||||
export class RouteGraphBuilder {
|
||||
private poolRepo: PoolRepository;
|
||||
|
||||
constructor(poolRepo = new PoolRepository()) {
|
||||
this.poolRepo = poolRepo;
|
||||
}
|
||||
|
||||
async buildSwapEdges(chainId: number): Promise<SwapGraphEdge[]> {
|
||||
let indexedPools: LiquidityPool[] = [];
|
||||
try {
|
||||
indexedPools = filterPoolsForRouting(chainId, await this.poolRepo.getPoolsByChain(chainId, 500));
|
||||
} catch {
|
||||
indexedPools = [];
|
||||
}
|
||||
const liveDodoPools = chainId === 138
|
||||
? filterPoolsForRouting(chainId, await getLiveDodoPools(chainId))
|
||||
: [];
|
||||
const liveDodoV3PilotEdges = chainId === 138
|
||||
? await getChain138DodoV3PilotEdges()
|
||||
: [];
|
||||
const livePilotVenueEdges = chainId === 138
|
||||
? await getChain138PilotVenueEdges()
|
||||
: [];
|
||||
const pools = dedupePools([...indexedPools, ...liveDodoPools]).filter((pool) =>
|
||||
isMeaningfullyFundedPool(chainId, pool)
|
||||
);
|
||||
|
||||
const poolEdges = pools.flatMap((pool) => {
|
||||
const provider = providerFromDexType(pool.dexType);
|
||||
if (!provider) return [];
|
||||
|
||||
const token0 = normalizeAddress(pool.token0Address);
|
||||
const token1 = normalizeAddress(pool.token1Address);
|
||||
const token0Symbol = getRoutingSymbolForAddress(chainId, token0);
|
||||
const token1Symbol = getRoutingSymbolForAddress(chainId, token1);
|
||||
const capability = findProviderPairCapability(chainId, provider, token0, token1)
|
||||
|| findProviderPairCapability(chainId, provider, token1, token0);
|
||||
|
||||
const target = capability?.target || (provider === 'dodo' ? normalizeAddress(process.env.DODO_PMM_PROVIDER_ADDRESS || process.env.DODO_PMM_PROVIDER || '0x3f729632E9553EBacCdE2e9b4c8F2B285b014F2e') : undefined);
|
||||
const providerData = capability?.providerData || (provider === 'dodo' ? { poolAddress: pool.poolAddress.toLowerCase() } : undefined);
|
||||
const providerDataHex = capability?.providerDataHex || (provider === 'dodo'
|
||||
? abiCoder.encode(['address'], [pool.poolAddress.toLowerCase()])
|
||||
: undefined);
|
||||
|
||||
const baseEdge = {
|
||||
kind: 'swap' as const,
|
||||
provider,
|
||||
chainId,
|
||||
target,
|
||||
poolAddress: pool.poolAddress.toLowerCase(),
|
||||
providerData,
|
||||
providerDataHex,
|
||||
totalLiquidityUsd: pool.totalLiquidityUsd || 0,
|
||||
freshnessSeconds: pool.lastUpdated
|
||||
? Math.max(0, Math.floor((Date.now() - pool.lastUpdated.getTime()) / 1000))
|
||||
: null,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
...baseEdge,
|
||||
tokenInAddress: token0,
|
||||
tokenOutAddress: token1,
|
||||
tokenInSymbol: token0Symbol,
|
||||
tokenOutSymbol: token1Symbol,
|
||||
reserveIn: String(pool.reserve0),
|
||||
reserveOut: String(pool.reserve1),
|
||||
},
|
||||
{
|
||||
...baseEdge,
|
||||
tokenInAddress: token1,
|
||||
tokenOutAddress: token0,
|
||||
tokenInSymbol: token1Symbol,
|
||||
tokenOutSymbol: token0Symbol,
|
||||
reserveIn: String(pool.reserve1),
|
||||
reserveOut: String(pool.reserve0),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...poolEdges, ...liveDodoV3PilotEdges, ...livePilotVenueEdges];
|
||||
}
|
||||
|
||||
buildBridgeCandidates(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
assetSymbols: string[]
|
||||
): BridgeRouteCandidate[] {
|
||||
return assetSymbols.flatMap((symbol) => {
|
||||
const route = getRouteFromRegistry(fromChainId, toChainId, symbol);
|
||||
if (!route) return [];
|
||||
|
||||
const sourceTokenAddress = getRoutingAddressForSymbol(fromChainId, symbol);
|
||||
const destinationTokenAddress = getRoutingAddressForSymbol(toChainId, symbol);
|
||||
if (!sourceTokenAddress || !destinationTokenAddress) return [];
|
||||
|
||||
return [{
|
||||
bridgeType: route.pathType,
|
||||
bridgeAddress: route.bridgeAddress.toLowerCase(),
|
||||
bridgeLabel: route.label,
|
||||
assetSymbol: route.asset || symbol,
|
||||
sourceTokenAddress: sourceTokenAddress.toLowerCase(),
|
||||
destinationTokenAddress: destinationTokenAddress.toLowerCase(),
|
||||
fromChainId,
|
||||
toChainId,
|
||||
notes: [`Registry route ${route.label}`],
|
||||
}];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import cron from 'node-cron';
|
||||
import { AggregatorRouteMatrixGenerator } from './aggregator-route-matrix-generator';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export function startRouteMatrixScheduler(): void {
|
||||
const cronSpec = (process.env.ROUTE_MATRIX_CRON || '').trim();
|
||||
const generateOnStart = String(process.env.GENERATE_ROUTE_MATRIX_ON_START || 'false').toLowerCase() === 'true';
|
||||
|
||||
if (!cronSpec && !generateOnStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = new AggregatorRouteMatrixGenerator();
|
||||
|
||||
const runGeneration = async () => {
|
||||
try {
|
||||
const outputPath = await generator.writeToFile();
|
||||
logger.info('Planner-v2 route matrix generated', { outputPath });
|
||||
} catch (error) {
|
||||
logger.warn('Planner-v2 route matrix generation failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (generateOnStart) {
|
||||
void runGeneration();
|
||||
}
|
||||
|
||||
if (cronSpec) {
|
||||
cron.schedule(cronSpec, () => {
|
||||
void runGeneration();
|
||||
});
|
||||
logger.info('Planner-v2 route matrix scheduler enabled', { cronSpec });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user