diff --git a/packages/settlement-core/dist/types.d.ts b/packages/settlement-core/dist/types.d.ts index 06caac8..b7b7942 100644 --- a/packages/settlement-core/dist/types.d.ts +++ b/packages/settlement-core/dist/types.d.ts @@ -34,6 +34,8 @@ export type ExternalTransferRequest = { targetChainId: number; tokenLineId: string; recipientAddress: string; + symbol?: string; + tokenAddress?: string; }; }; export type TokenLoadRequest = { diff --git a/services/dbis-exchange/dist/adapters/token-aggregation.js b/services/dbis-exchange/dist/adapters/token-aggregation.js index c4c2eba..d089b9b 100644 --- a/services/dbis-exchange/dist/adapters/token-aggregation.js +++ b/services/dbis-exchange/dist/adapters/token-aggregation.js @@ -28,7 +28,11 @@ async function fetchRoutePlan(body) { tokenOut: body.tokenOut, amountIn: body.amountIn, recipient: body.recipient, - constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false }, + constraints: { + maxSlippageBps: body.maxSlippageBps ?? 50, + allowBridge: body.allowBridge ?? false, + preferredBridges: body.preferredBridges, + }, }, { timeout: 120000 }); return data; } @@ -40,7 +44,11 @@ async function fetchExecutionPlan(body) { tokenOut: body.tokenOut, amountIn: body.amountIn, recipient: body.recipient, - constraints: { maxSlippageBps: body.maxSlippageBps ?? 50, allowBridge: false }, + constraints: { + maxSlippageBps: body.maxSlippageBps ?? 50, + allowBridge: body.allowBridge ?? false, + preferredBridges: body.preferredBridges, + }, }, { timeout: 120000 }); return data; } diff --git a/services/dbis-exchange/dist/api/routes/exchange.js b/services/dbis-exchange/dist/api/routes/exchange.js index 6c518a0..08a3221 100644 --- a/services/dbis-exchange/dist/api/routes/exchange.js +++ b/services/dbis-exchange/dist/api/routes/exchange.js @@ -90,19 +90,23 @@ function createExchangeRouter() { if (!(0, auth_1.requireTrade)(req, res)) return; try { - const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body; + const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps, destinationChainId, allowBridge, preferredBridges, } = req.body; if (!tokenIn || !tokenOut || !amountIn) { res.status(400).json({ error: 'tokenIn, tokenOut, amountIn required' }); return; } const plan = await (0, token_aggregation_1.fetchRoutePlan)({ sourceChainId: cfg.chainId, - destinationChainId: cfg.chainId, + destinationChainId: destinationChainId ? Number(destinationChainId) : cfg.chainId, tokenIn: String(tokenIn), tokenOut: String(tokenOut), amountIn: String(amountIn), recipient: recipient ? String(recipient) : undefined, maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps, + allowBridge: typeof allowBridge === 'boolean' ? allowBridge : undefined, + preferredBridges: Array.isArray(preferredBridges) + ? preferredBridges.map((value) => String(value)) + : undefined, }); res.json(plan); } @@ -114,19 +118,23 @@ function createExchangeRouter() { if (!(0, auth_1.requireTrade)(req, res)) return; try { - const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps } = req.body; + const { tokenIn, tokenOut, amountIn, recipient, maxSlippageBps, destinationChainId, allowBridge, preferredBridges, } = req.body; if (!tokenIn || !tokenOut || !amountIn || !recipient) { res.status(400).json({ error: 'tokenIn, tokenOut, amountIn, recipient required' }); return; } const plan = await (0, token_aggregation_1.fetchExecutionPlan)({ sourceChainId: cfg.chainId, - destinationChainId: cfg.chainId, + destinationChainId: destinationChainId ? Number(destinationChainId) : cfg.chainId, tokenIn: String(tokenIn), tokenOut: String(tokenOut), amountIn: String(amountIn), recipient: String(recipient), maxSlippageBps: maxSlippageBps ? Number(maxSlippageBps) : cfg.defaultSlippageBps, + allowBridge: typeof allowBridge === 'boolean' ? allowBridge : undefined, + preferredBridges: Array.isArray(preferredBridges) + ? preferredBridges.map((value) => String(value)) + : undefined, }); res.json(plan); } diff --git a/services/settlement-middleware/dist/api/routes/settlement.js b/services/settlement-middleware/dist/api/routes/settlement.js index a277c9a..c46e535 100644 --- a/services/settlement-middleware/dist/api/routes/settlement.js +++ b/services/settlement-middleware/dist/api/routes/settlement.js @@ -220,6 +220,8 @@ function createSettlementRouter() { targetChainId: body.convertToCrypto?.targetChainId ?? 138, tokenLineId: body.convertToCrypto?.tokenLineId ?? `${body.currency ?? 'USD'}-M2`, recipientAddress: body.convertToCrypto?.recipientAddress ?? '', + symbol: body.convertToCrypto?.symbol, + tokenAddress: body.convertToCrypto?.tokenAddress, }, moneyLayers: ['M2', 'M3'], }); diff --git a/services/settlement-middleware/dist/workflows/external-transfer.js b/services/settlement-middleware/dist/workflows/external-transfer.js index 77cfc41..937feba 100644 --- a/services/settlement-middleware/dist/workflows/external-transfer.js +++ b/services/settlement-middleware/dist/workflows/external-transfer.js @@ -136,6 +136,8 @@ async function processExternalTransfer(input) { recipient: req.convertToCrypto.recipientAddress, settlementRef: record.settlementId, dryRun: !cfg.production.allowChainMintExecute, + symbol: req.convertToCrypto.symbol, + tokenAddress: req.convertToCrypto.tokenAddress, }); advance('CHAIN_MINT_REQUESTED', { chainTxHash: mint.txHash }); } diff --git a/services/token-aggregation/public/token-logos/gru/chain-138.png b/services/token-aggregation/public/token-logos/gru/chain-138.png new file mode 100644 index 0000000..ae7dea3 Binary files /dev/null and b/services/token-aggregation/public/token-logos/gru/chain-138.png differ diff --git a/services/token-aggregation/src/api/routes/config.test.ts b/services/token-aggregation/src/api/routes/config.test.ts index 68c42b3..78249aa 100644 --- a/services/token-aggregation/src/api/routes/config.test.ts +++ b/services/token-aggregation/src/api/routes/config.test.ts @@ -176,7 +176,7 @@ describe('Config API runtime networks loader', () => { const chain138 = networksBody.networks.find((entry: Record) => entry.chainIdDecimal === 138); expect(chain138.oracles?.length).toBeGreaterThan(0); expect(chain138.iconUrls).toEqual( - expect.arrayContaining(['https://explorer.d-bis.org/token-aggregation/api/v1/report/logo/chain-138']) + expect.arrayContaining(['https://explorer.d-bis.org/token-icons/chain-138.png']) ); } finally { await fs.unlink(tempPath).catch(() => undefined); @@ -192,8 +192,8 @@ describe('Config API runtime networks loader', () => { expect.objectContaining({ chainIdDecimal: 138, iconUrls: expect.arrayContaining([ - 'https://explorer.d-bis.org/token-aggregation/api/v1/report/logo/chain-138', - 'https://d-bis.org/favicon.ico', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico', ]), }), ]) @@ -221,13 +221,18 @@ describe('Config API runtime networks loader', () => { expect(metamaskBody.tokenDetection.staticTokenListUrl).toBe( 'https://tokens.d-bis.org/lists/dbis-138.tokenlist.json', ); + expect(metamaskBody.addEthereumChain.iconUrls).toEqual( + expect.arrayContaining(['https://explorer.d-bis.org/token-icons/chain-138.png']), + ); expect(metamaskBody.watchAssets).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'ERC20', options: expect.objectContaining({ symbol: 'cUSDC', - image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/cusdc\.svg\?v=20260510$/), + image: expect.stringMatching( + /^https:\/\/raw\.githubusercontent\.com\/trustwallet\/assets\/master\/blockchains\/ethereum\/assets\/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\/logo\.png\?v=20260630$/, + ), }), }), expect.objectContaining({ @@ -235,14 +240,18 @@ describe('Config API runtime networks loader', () => { options: expect.objectContaining({ address: '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03', symbol: 'LINK', - image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/link\.svg\?v=20260510$/), + image: expect.stringMatching( + /^https:\/\/ipfs\.io\/ipfs\/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A\?v=20260630$/, + ), }), }), expect.objectContaining({ type: 'ERC20', options: expect.objectContaining({ symbol: 'WETH', - image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/weth\.svg\?v=20260510$/), + image: expect.stringMatching( + /^https:\/\/ipfs\.io\/ipfs\/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong\?v=20260630$/, + ), }), }), ]) @@ -287,6 +296,40 @@ describe('Config API runtime networks loader', () => { ).toBe(false); }); + it('sanitizes runtime-file private rpc urls from wallet-facing MetaMask aliases', async () => { + const tempPath = path.join('/tmp', `token-aggregation-networks-private-rpc-${Date.now()}.json`); + await fs.writeFile( + tempPath, + JSON.stringify({ + version: { major: 2, minor: 0, patch: 0 }, + chains: [ + { + chainId: '0x8a', + chainIdDecimal: 138, + chainName: 'Defi Oracle Meta Mainnet', + rpcUrls: ['http://192.168.11.221:8545'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://explorer.d-bis.org'], + }, + ], + }) + ); + process.env.NETWORKS_JSON_PATH = tempPath; + + try { + const metamaskRes = await fetch(`${baseUrl}/api/v1/config/metamask?chainId=138`); + expect(metamaskRes.status).toBe(200); + const metamaskBody = (await metamaskRes.json()) as Record; + expect(metamaskBody.source).toBe('runtime-file'); + expect(metamaskBody.addEthereumChain.chainId).toBe('0x8a'); + expect(metamaskBody.addEthereumChain.rpcUrls).toHaveLength(1); + expect(metamaskBody.addEthereumChain.rpcUrls[0]).toMatch(/^https:\/\//); + expect(metamaskBody.addEthereumChain.rpcUrls[0]).not.toContain('192.168.11.221:8545'); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } + }); + it('excludes hub cUSDC/cUSDT peg references from Ethereum mainnet watchAssets', async () => { const metamaskRes = await fetch(`${baseUrl}/api/v1/config/metamask?chainId=1`); expect(metamaskRes.status).toBe(200); diff --git a/services/token-aggregation/src/api/routes/config.ts b/services/token-aggregation/src/api/routes/config.ts index 7de1499..f6247a7 100644 --- a/services/token-aggregation/src/api/routes/config.ts +++ b/services/token-aggregation/src/api/routes/config.ts @@ -9,10 +9,14 @@ import { getCanonicalTokensByChain, getLogoUriForSpec, getTokenRegistryFamily } import { cacheMiddleware } from '../middleware/cache'; import { fetchRemoteJson } from '../utils/fetch-remote-json'; import { logger } from '../../utils/logger'; +import { + METAMASK_WALLET_IMAGE_VERSION, + resolveMetamaskWalletTokenImage, +} from '../../config/metamask-wallet-images'; const router: Router = Router(); const DEFAULT_PUBLIC_BASE_URL = 'https://blockscout.defi-oracle.io'; -const DEFAULT_WALLET_METADATA_VERSION = '20260510'; +const DEFAULT_WALLET_METADATA_VERSION = METAMASK_WALLET_IMAGE_VERSION; function resolvePublicBaseUrl(req: Request): string { const configured = ( @@ -232,7 +236,7 @@ router.get(['/networks', '/config/networks', '/metamask/networks'], cacheMiddlew * GET /api/v1/config/metamask * Wallet-facing aliases for Chain 138 add-chain params and watchAsset token entries. */ -router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { +export async function sendMetamaskConfig(req: Request, res: Response): Promise { try { res.set('Cache-Control', 'public, max-age=0, must-revalidate'); const chainId = parseInt(String(req.query.chainId ?? '138'), 10) || 138; @@ -243,6 +247,14 @@ router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), as res.status(404).json({ error: 'Chain not found', chainId }); return; } + const addEthereumChain = toWalletAddEthereumChainParams({ + chainId: network.chainId, + chainName: network.chainName, + rpcUrls: [...network.rpcUrls], + nativeCurrency: network.nativeCurrency, + blockExplorerUrls: [...network.blockExplorerUrls], + ...(network.iconUrls ? { iconUrls: [...network.iconUrls] } : {}), + }); const watchAssets = dedupeWatchAssetEntriesBySymbol( getCanonicalTokensByChain(chainId) @@ -251,15 +263,19 @@ router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), as const address = spec.addresses[chainId]; if (!address) return null; const originalLogoURI = getLogoUriForSpec(spec); + const walletSymbol = resolveWalletWatchAssetSymbol(spec); + const listFallback = appendWalletMetadataVersion( + absolutePublicUrl(req, localLogoPathForSymbol(spec.symbol, originalLogoURI)) ?? + originalLogoURI, + ); + const walletImage = resolveMetamaskWalletTokenImage(walletSymbol, listFallback); return { type: 'ERC20' as const, options: { address, - symbol: resolveWalletWatchAssetSymbol(spec), + symbol: walletSymbol, decimals: spec.decimals, - image: appendWalletMetadataVersion( - absolutePublicUrl(req, localLogoPathForSymbol(spec.symbol, originalLogoURI)), - ), + ...(walletImage ? { image: walletImage } : {}), }, metadata: { name: spec.name, @@ -280,7 +296,7 @@ router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), as source: payload.source, version: payload.version, chainId, - addEthereumChain: toWalletAddEthereumChainParams(network), + addEthereumChain, watchAssets, tokenDetection: { metaMaskNativeAutodetect: false, @@ -315,6 +331,10 @@ router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), as } catch (error) { res.status(500).json({ error: 'Internal server error' }); } +} + +router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { + await sendMetamaskConfig(req, res); }); /** diff --git a/services/token-aggregation/src/api/routes/planner-v2.ts b/services/token-aggregation/src/api/routes/planner-v2.ts index 5de0eda..d775d92 100644 --- a/services/token-aggregation/src/api/routes/planner-v2.ts +++ b/services/token-aggregation/src/api/routes/planner-v2.ts @@ -2,11 +2,13 @@ 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 { RouteGraphBuilder } from '../../services/route-graph-builder'; import { PlannerRequest } from '../../services/planner-v2-types'; import { logger } from '../../utils/logger'; const router = Router(); const planner = new BestExecutionPlanner(); +const graphBuilder = new RouteGraphBuilder(); const executionPlanBuilder = new InternalExecutionPlanV2Builder(planner); function parsePlannerRequest(body: Record): PlannerRequest { @@ -84,6 +86,30 @@ router.get('/providers/capabilities', cacheMiddleware(15 * 1000), async (req: Re } }); +router.get('/discover/paths', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => { + try { + const chainId = Number(req.query.chainId || '138'); + const tokenIn = String(req.query.tokenIn || ''); + const tokenOut = String(req.query.tokenOut || ''); + if (!tokenIn || !tokenOut) { + return res.status(400).json({ error: 'tokenIn and tokenOut query params are required' }); + } + const maxHops = Number(req.query.maxHops || '5'); + const paths = await graphBuilder.discoverSymbolicSwapPaths(chainId, tokenIn, tokenOut, maxHops); + return res.json({ + generatedAt: new Date().toISOString(), + chainId, + tokenIn, + tokenOut, + maxHops, + pathCount: paths.length, + paths, + }); + } catch (error) { + return handlePlannerFailure(res, 'symbolic path discovery', error); + } +}); + router.post('/routes/plan', async (req: Request, res: Response) => { try { const request = parsePlannerRequest((req.body || {}) as Record); diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 5d10745..24a5bcd 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -8,7 +8,7 @@ import { apiRateLimiter, strictRateLimiter } from './middleware/rate-limit'; import tokenRoutes from './routes/tokens'; import reportRoutes from './routes/report'; import adminRoutes from './routes/admin'; -import configRoutes from './routes/config'; +import configRoutes, { sendMetamaskConfig } from './routes/config'; import bridgeRoutes from './routes/bridge'; import quoteRoutes from './routes/quote'; import routeTreeRoutes from './routes/routes'; @@ -294,6 +294,10 @@ export class ApiServer { this.app.get('/api/v1', sendApiV1Catalog); this.app.get('/api/v1/', sendApiV1Catalog); + // Keep wallet add-chain config on a direct route so it cannot be shadowed by other mounts. + this.app.get('/api/v1/config/metamask', sendMetamaskConfig); + this.app.get('/api/v1/metamask', sendMetamaskConfig); + // API routes this.app.use('/api/v1', tokenRoutes); this.app.use('/api/v1', configRoutes); diff --git a/services/token-aggregation/src/config/metamask-chain138-snaps.ts b/services/token-aggregation/src/config/metamask-chain138-snaps.ts new file mode 100644 index 0000000..0daa224 --- /dev/null +++ b/services/token-aggregation/src/config/metamask-chain138-snaps.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import path from 'path'; + +export type MetamaskChain138SnapSpec = { + npmId: string; + package: string; + latestPublished: string; + preferredInstallVersion: string; + capabilities: string[]; + requiresAllowlist: boolean; + companionSitePath?: string; + note?: string; +}; + +export type MetamaskChain138SnapsConfig = { + schema: string; + snaps: { + marketData: MetamaskChain138SnapSpec; + openCompanion: MetamaskChain138SnapSpec; + }; + priceSurfaces: Record; +}; + +function resolveProxmoxRoot(): string { + return ( + process.env.PROXMOX_ROOT?.trim() || + process.env.PHOENIX_REPO_ROOT?.trim() || + path.resolve(process.cwd(), '../../../..') + ); +} + +let cached: MetamaskChain138SnapsConfig | null = null; + +export function loadMetamaskChain138SnapsConfig(): MetamaskChain138SnapsConfig | null { + if (cached) return cached; + const configPath = path.join(resolveProxmoxRoot(), 'config/metamask-chain138-snaps.v1.json'); + if (!fs.existsSync(configPath)) return null; + try { + cached = JSON.parse(fs.readFileSync(configPath, 'utf8')) as MetamaskChain138SnapsConfig; + return cached; + } catch { + return null; + } +} + +export function buildMetamaskSnapInstallPayload(publicBase: string): Record | null { + const cfg = loadMetamaskChain138SnapsConfig(); + if (!cfg) return null; + const market = cfg.snaps.marketData; + const open = cfg.snaps.openCompanion; + return { + forCStarUsdPrices: { + npmId: market.npmId, + version: market.preferredInstallVersion, + companionSiteUrl: `${publicBase}${market.companionSitePath ?? '/snap/'}`, + rpcMethods: ['get_market_summary', 'show_market_data', 'get_current_price'], + apiBaseUrlHint: `${publicBase}/token-aggregation/api/v1`, + }, + openCompanion: { + npmId: open.npmId, + version: open.preferredInstallVersion, + note: open.note, + }, + priceSurfaces: cfg.priceSurfaces, + allowlistStatus: { + marketDataAllowlisted: false, + openCompanionAllowlisted: false, + stableMetaMaskBlocked: true, + flaskInstallUrl: 'https://metamask.io/flask/', + allowlistFormUrl: 'https://go.metamask.io/snaps-directory-request', + note: 'npm:chain138-snap@0.1.2 is not on MetaMask verified registry yet. Use Add Chain 138 + Tokens tab on Stable; Flask for Snap USD.', + }, + }; +} diff --git a/services/token-aggregation/src/config/metamask-wallet-images.test.ts b/services/token-aggregation/src/config/metamask-wallet-images.test.ts new file mode 100644 index 0000000..2cd04d9 --- /dev/null +++ b/services/token-aggregation/src/config/metamask-wallet-images.test.ts @@ -0,0 +1,29 @@ +import { + METAMASK_CHAIN138_ICON_URLS, + filterMetamaskSafeIconUrls, + resolveMetamaskWalletImageUrl, +} from './metamask-wallet-images'; + +describe('metamask-wallet-images', () => { + it('prefers PNG chain icon for Chain 138', () => { + expect(METAMASK_CHAIN138_ICON_URLS[0]).toMatch(/chain-138\.png$/); + }); + + it('resolves hub stable PNG logos', () => { + expect(resolveMetamaskWalletImageUrl('cUSDC')).toMatch(/logo\.png$/); + expect(resolveMetamaskWalletImageUrl('cWUSDC')).toMatch(/logo\.png$/); + }); + + it('filters SVG icons from wallet_addEthereumChain iconUrls', () => { + expect( + filterMetamaskSafeIconUrls([ + 'https://explorer.d-bis.org/token-aggregation/api/v1/report/logo/chain-138', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://d-bis.org/favicon.ico', + ]), + ).toEqual([ + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://d-bis.org/favicon.ico', + ]); + }); +}); diff --git a/services/token-aggregation/src/config/metamask-wallet-images.ts b/services/token-aggregation/src/config/metamask-wallet-images.ts new file mode 100644 index 0000000..518cbab --- /dev/null +++ b/services/token-aggregation/src/config/metamask-wallet-images.ts @@ -0,0 +1,75 @@ +/** + * PNG/JPEG icon URLs for MetaMask wallet_addEthereumChain and wallet_watchAsset. + * MetaMask frequently fails to render SVG icons (chain picker + token rows). + * Uniswap token lists may keep SVG logoURI; wallet RPC payloads must use raster images. + */ +const TW_ETH = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets'; +const TW_BTC = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png'; +const IPFS = 'https://ipfs.io/ipfs'; + +export const METAMASK_WALLET_IMAGE_VERSION = '20260630'; + +export const METAMASK_CHAIN138_ICON_URLS = [ + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico', +] as const; + +const METAMASK_WALLET_PNG_BY_SYMBOL: Record = { + USDC: `${TW_ETH}/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png`, + USDT: `${TW_ETH}/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png`, + cUSDC: `${TW_ETH}/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png`, + cUSDT: `${TW_ETH}/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png`, + cWUSDC: `${TW_ETH}/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png`, + cWUSDT: `${TW_ETH}/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png`, + cBTC: TW_BTC, + cWBTC: TW_BTC, + WETH: `${IPFS}/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong`, + WETH10: `${IPFS}/QmanDFPHxnbKd6SSNzzXHf9GbpL9dLXSphxDZSPPYE6ds4`, + LINK: `${IPFS}/QmenWcmfNGfssz4HXvrRV912eZDiKqLTt6z2brRYuTGz9A`, + cEURC: `${IPFS}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`, + cEURT: `${IPFS}/QmPh16PY241zNtePyeK7ep1uf1RcARV2ynGAuRU8U7sSqS`, + cGBPC: `${IPFS}/QmT2nJ6WyhYBCsYJ6NfS1BPAqiGKkCEuMxiC8ye93Co1hF`, + cGBPT: `${IPFS}/QmT2nJ6WyhYBCsYJ6NfS1BPAqiGKkCEuMxiC8ye93Co1hF`, +}; + +export function withMetamaskWalletImageVersion(url: string): string { + const version = (process.env.WALLET_METADATA_IMAGE_VERSION || METAMASK_WALLET_IMAGE_VERSION).trim(); + if (!version) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}v=${encodeURIComponent(version)}`; +} + +/** Prefer raster PNG for MetaMask; omit SVG-only fallbacks. */ +export function resolveMetamaskWalletTokenImage(symbol: string, fallbackUrl?: string): string | undefined { + const png = resolveMetamaskWalletImageUrl(symbol); + if (png) return withMetamaskWalletImageVersion(png); + if (fallbackUrl && /\.(png|jpe?g|ico)(\?|$)/i.test(fallbackUrl)) { + return withMetamaskWalletImageVersion(fallbackUrl); + } + return undefined; +} + +/** Resolve a MetaMask-safe raster image URL for wallet_watchAsset.options.image. */ +export function resolveMetamaskWalletImageUrl(symbol: string): string | undefined { + const key = String(symbol || '').trim(); + if (!key) return undefined; + if (METAMASK_WALLET_PNG_BY_SYMBOL[key]) return METAMASK_WALLET_PNG_BY_SYMBOL[key]; + if (key.startsWith('cW')) { + const hub = `c${key.slice(2)}`; + if (METAMASK_WALLET_PNG_BY_SYMBOL[hub]) return METAMASK_WALLET_PNG_BY_SYMBOL[hub]; + } + return undefined; +} + +/** Keep only HTTPS raster icons MetaMask is known to render. */ +export function filterMetamaskSafeIconUrls(urls: string[] | undefined): string[] | undefined { + if (!urls?.length) return undefined; + const raster = urls.filter( + (url) => + typeof url === 'string' && + url.startsWith('https://') && + /\.(png|jpe?g|ico)(\?|$)/i.test(url), + ); + if (raster.length > 0) return raster; + return urls.filter((url) => typeof url === 'string' && url.startsWith('https://')); +} diff --git a/services/token-aggregation/src/config/networks.ts b/services/token-aggregation/src/config/networks.ts index 1c86e36..6a807a6 100644 --- a/services/token-aggregation/src/config/networks.ts +++ b/services/token-aggregation/src/config/networks.ts @@ -4,6 +4,7 @@ */ import { resolveChain138PublicRpcUrls } from './chain138-rpc'; +import { METAMASK_CHAIN138_ICON_URLS } from './metamask-wallet-images'; export interface OracleEntry { name: string; @@ -30,10 +31,7 @@ export const NETWORKS: NetworkEntry[] = [ rpcUrls: resolveChain138PublicRpcUrls(), nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, blockExplorerUrls: ['https://blockscout.defi-oracle.io', 'https://explorer.d-bis.org'], - iconUrls: [ - 'https://explorer.d-bis.org/token-aggregation/api/v1/report/logo/chain-138', - 'https://d-bis.org/favicon.ico', - ], + iconUrls: [...METAMASK_CHAIN138_ICON_URLS], oracles: [ { name: 'ETH/USD (proxy)', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6', decimals: 8 }, { name: 'ETH/USD (aggregator)', address: '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506', decimals: 8 }, diff --git a/services/token-aggregation/src/lib/wallet-add-ethereum-chain.rpc.test.ts b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.rpc.test.ts new file mode 100644 index 0000000..345ceb6 --- /dev/null +++ b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.rpc.test.ts @@ -0,0 +1,25 @@ +import { resolveChain138WalletRpcUrl, isWalletSafeRpcUrl } from '../config/chain138-rpc'; +import { toWalletAddEthereumChainParams } from './wallet-add-ethereum-chain'; + +describe('wallet RPC safety', () => { + const prev = process.env.CHAIN_138_RPC_URL; + + afterEach(() => { + if (prev === undefined) delete process.env.CHAIN_138_RPC_URL; + else process.env.CHAIN_138_RPC_URL = prev; + }); + + it('rejects LAN HTTP RPC for wallets', () => { + process.env.CHAIN_138_RPC_URL = 'http://192.168.11.221:8545'; + expect(isWalletSafeRpcUrl('http://192.168.11.221:8545')).toBe(false); + expect(resolveChain138WalletRpcUrl()).toBe('https://rpc-http-pub.d-bis.org'); + const out = toWalletAddEthereumChainParams({ + chainId: '0x8a', + chainName: 'Defi Oracle Meta Mainnet', + rpcUrls: ['http://192.168.11.221:8545'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://explorer.d-bis.org'], + }); + expect(out.rpcUrls).toEqual(['https://rpc-http-pub.d-bis.org']); + }); +}); diff --git a/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts index 91f7a86..3be56c1 100644 --- a/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts +++ b/services/token-aggregation/src/lib/wallet-add-ethereum-chain.ts @@ -1,5 +1,6 @@ import type { NetworkEntry } from '../config/networks'; import { resolveChain138PublicRpcUrl } from '../config/chain138-rpc'; +import { filterMetamaskSafeIconUrls } from '../config/metamask-wallet-images'; /** EIP-3085 fields accepted by MetaMask `wallet_addEthereumChain` (strict subset). */ export type WalletAddEthereumChainParams = { @@ -37,7 +38,7 @@ export function toWalletAddEthereumChainParams( }; const blockExplorerUrls = httpsOnly(network.blockExplorerUrls); if (blockExplorerUrls) out.blockExplorerUrls = blockExplorerUrls; - const iconUrls = httpsOnly(network.iconUrls); - if (iconUrls) out.iconUrls = iconUrls; + const iconUrls = filterMetamaskSafeIconUrls(httpsOnly(network.iconUrls)); + if (iconUrls?.length) out.iconUrls = iconUrls; return out; } diff --git a/services/token-aggregation/src/services/pathfinder-nhop.ts b/services/token-aggregation/src/services/pathfinder-nhop.ts new file mode 100644 index 0000000..31e7461 --- /dev/null +++ b/services/token-aggregation/src/services/pathfinder-nhop.ts @@ -0,0 +1,97 @@ +/** + * N-hop pathfinder over swap graph edges and bridge candidates (planner-v2). + */ +import type { BridgeRouteCandidate, SwapGraphEdge } from './planner-v2-types'; + +export interface PathStep { + kind: 'swap' | 'bridge'; + fromChainId: number; + toChainId: number; + tokenInSymbol?: string; + tokenOutSymbol?: string; + assetSymbol?: string; + provider?: string; + bridgeLabel?: string; +} + +export interface PathResult { + hops: number; + steps: PathStep[]; +} + +function node(chainId: number, symbol: string): string { + return `${chainId}:${symbol.toUpperCase()}`; +} + +export function buildSwapAdjacency(edges: SwapGraphEdge[]): Map { + const adj = new Map(); + for (const e of edges) { + const src = node(e.chainId, e.tokenInSymbol || e.tokenInAddress); + const step: PathStep = { + kind: 'swap', + fromChainId: e.chainId, + toChainId: e.chainId, + tokenInSymbol: e.tokenInSymbol, + tokenOutSymbol: e.tokenOutSymbol, + provider: e.provider, + }; + const list = adj.get(src) || []; + list.push(step); + adj.set(src, list); + } + return adj; +} + +export function buildBridgeAdjacency( + bridges: BridgeRouteCandidate[], + destSymbolResolver: (candidate: BridgeRouteCandidate) => string +): Map { + const adj = new Map(); + for (const b of bridges) { + const src = node(b.fromChainId, b.assetSymbol); + const destSym = destSymbolResolver(b); + const step: PathStep = { + kind: 'bridge', + fromChainId: b.fromChainId, + toChainId: b.toChainId, + assetSymbol: b.assetSymbol, + tokenInSymbol: b.assetSymbol, + tokenOutSymbol: destSym, + bridgeLabel: b.bridgeLabel, + }; + const list = adj.get(src) || []; + list.push(step); + adj.set(src, list); + } + return adj; +} + +export function findMultiHopPaths( + adj: Map, + start: string, + endPredicate: (end: string, steps: PathStep[]) => boolean, + maxHops = 5, + maxPaths = 20 +): PathResult[] { + const results: PathResult[] = []; + const queue: Array<{ cur: string; steps: PathStep[] }> = [{ cur: start, steps: [] }]; + const seenDepth = new Map([[start, 0]]); + + while (queue.length > 0 && results.length < maxPaths) { + const { cur, steps } = queue.shift()!; + if (steps.length > 0 && endPredicate(cur, steps)) { + results.push({ hops: steps.length, steps }); + continue; + } + if (steps.length >= maxHops) continue; + for (const step of adj.get(cur) || []) { + const sym = step.tokenOutSymbol || step.assetSymbol || ''; + const nxt = node(step.toChainId, sym); + const depth = steps.length + 1; + if ((seenDepth.get(nxt) ?? 99) < depth) continue; + seenDepth.set(nxt, depth); + queue.push({ cur: nxt, steps: [...steps, step] }); + } + } + return results; +} diff --git a/services/token-aggregation/src/services/route-graph-builder.ts b/services/token-aggregation/src/services/route-graph-builder.ts index ec07d3b..e99d350 100644 --- a/services/token-aggregation/src/services/route-graph-builder.ts +++ b/services/token-aggregation/src/services/route-graph-builder.ts @@ -12,6 +12,7 @@ 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 { buildSwapAdjacency, findMultiHopPaths, type PathResult } from './pathfinder-nhop'; import { BridgeRouteCandidate, PlannerProvider, SwapGraphEdge } from './planner-v2-types'; const abiCoder = AbiCoder.defaultAbiCoder(); @@ -173,4 +174,25 @@ export class RouteGraphBuilder { }]; }); } + + /** Symbolic multi-hop path discovery over swap edges (planner / agent discovery). */ + async discoverSymbolicSwapPaths( + chainId: number, + tokenInSymbol: string, + tokenOutSymbol: string, + maxHops = 5, + maxPaths = 20 + ): Promise { + const edges = await this.buildSwapEdges(chainId); + const adj = buildSwapAdjacency(edges); + const start = `${chainId}:${tokenInSymbol.toUpperCase()}`; + const target = `${chainId}:${tokenOutSymbol.toUpperCase()}`; + return findMultiHopPaths( + adj, + start, + (end) => end === target, + maxHops, + maxPaths + ); + } }