feat(token-aggregation): add Chain 138 wallet routing surfaces

Add MetaMask Snap metadata, wallet image helpers, and n-hop planning surfaces so Chain 138 routing and wallet-add flows have consistent API coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-07-07 09:57:09 -07:00
parent 6dcadc86a2
commit c55fab257c
18 changed files with 462 additions and 26 deletions

View File

@@ -34,6 +34,8 @@ export type ExternalTransferRequest = {
targetChainId: number;
tokenLineId: string;
recipientAddress: string;
symbol?: string;
tokenAddress?: string;
};
};
export type TokenLoadRequest = {

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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'],
});

View File

@@ -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 });
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

View File

@@ -176,7 +176,7 @@ describe('Config API runtime networks loader', () => {
const chain138 = networksBody.networks.find((entry: Record<string, any>) => 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<string, any>;
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);

View File

@@ -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<void> {
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);
});
/**

View File

@@ -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<string, unknown>): 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<string, unknown>);

View File

@@ -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);

View File

@@ -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<string, string>;
};
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<string, unknown> | 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.',
},
};
}

View File

@@ -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',
]);
});
});

View File

@@ -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<string, string> = {
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://'));
}

View File

@@ -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 },

View File

@@ -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']);
});
});

View File

@@ -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;
}

View File

@@ -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<string, PathStep[]> {
const adj = new Map<string, PathStep[]>();
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<string, PathStep[]> {
const adj = new Map<string, PathStep[]>();
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<string, PathStep[]>,
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<string, number>([[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;
}

View File

@@ -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<PathResult[]> {
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
);
}
}