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:
2
packages/settlement-core/dist/types.d.ts
vendored
2
packages/settlement-core/dist/types.d.ts
vendored
@@ -34,6 +34,8 @@ export type ExternalTransferRequest = {
|
||||
targetChainId: number;
|
||||
tokenLineId: string;
|
||||
recipientAddress: string;
|
||||
symbol?: string;
|
||||
tokenAddress?: string;
|
||||
};
|
||||
};
|
||||
export type TokenLoadRequest = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
BIN
services/token-aggregation/public/token-logos/gru/chain-138.png
Normal file
BIN
services/token-aggregation/public/token-logos/gru/chain-138.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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://'));
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
97
services/token-aggregation/src/services/pathfinder-nhop.ts
Normal file
97
services/token-aggregation/src/services/pathfinder-nhop.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user