feat(chain138): Monad CCIP, token aggregation OMNL gates, HYBX client, and PMM deploy updates.
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m11s
CI/CD Pipeline / Security Scanning (push) Has been cancelled
CI/CD Pipeline / Lint and Format (push) Has been cancelled
CI/CD Pipeline / Terraform Validation (push) Has been cancelled
CI/CD Pipeline / Kubernetes Validation (push) Has been cancelled
Validation / validate-genesis (push) Has been cancelled
Validation / validate-terraform (push) Has been cancelled
Validation / validate-kubernetes (push) Has been cancelled
Validation / validate-smart-contracts (push) Has been cancelled
Validation / validate-security (push) Has been cancelled
Validation / validate-documentation (push) Has been cancelled
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 1m4s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 31s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 29s
Verify Deployment / Verify Deployment (push) Failing after 57s
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m11s
CI/CD Pipeline / Security Scanning (push) Has been cancelled
CI/CD Pipeline / Lint and Format (push) Has been cancelled
CI/CD Pipeline / Terraform Validation (push) Has been cancelled
CI/CD Pipeline / Kubernetes Validation (push) Has been cancelled
Validation / validate-genesis (push) Has been cancelled
Validation / validate-terraform (push) Has been cancelled
Validation / validate-kubernetes (push) Has been cancelled
Validation / validate-smart-contracts (push) Has been cancelled
Validation / validate-security (push) Has been cancelled
Validation / validate-documentation (push) Has been cancelled
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 1m4s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 31s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 29s
Verify Deployment / Verify Deployment (push) Failing after 57s
Relay router, reserve system, oracle publisher, token-aggregation compliance middleware, and Monad deployment scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -92,6 +92,7 @@ const REFERENCE_SYMBOL_TO_COIN_ID: Record<string, string> = {
|
||||
CELO: 'celo',
|
||||
CRO: 'crypto-com-chain',
|
||||
XDAI: 'xdai',
|
||||
LINK: 'chainlink',
|
||||
};
|
||||
|
||||
export class CoinGeckoAdapter implements ExternalApiAdapter {
|
||||
@@ -344,6 +345,10 @@ export class CoinGeckoAdapter implements ExternalApiAdapter {
|
||||
*/
|
||||
async getReferenceMarketData(referenceSymbol: string): Promise<MarketData | null> {
|
||||
const symbol = referenceSymbol.trim().toUpperCase();
|
||||
if (symbol === 'USD') {
|
||||
return { priceUsd: 1, lastUpdated: new Date() };
|
||||
}
|
||||
|
||||
const coinId = REFERENCE_SYMBOL_TO_COIN_ID[symbol];
|
||||
if (!coinId) {
|
||||
return null;
|
||||
|
||||
@@ -19,13 +19,33 @@ function looksLikeGenericErrorPayload(body: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildCacheKey(req: Request): string {
|
||||
const base = `${req.method}:${req.originalUrl}`;
|
||||
if (req.method !== 'POST' && req.method !== 'PUT' && req.method !== 'PATCH') {
|
||||
return base;
|
||||
}
|
||||
try {
|
||||
const body = req.body;
|
||||
if (body == null) {
|
||||
return base;
|
||||
}
|
||||
const serialized = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
if (!serialized || serialized === '{}' || serialized === '[]') {
|
||||
return base;
|
||||
}
|
||||
return `${base}:${serialized}`;
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
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 key = buildCacheKey(req);
|
||||
const cached = bypassCache ? undefined : cache.get(key);
|
||||
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
|
||||
@@ -8,6 +8,9 @@ export function omnlAuditMiddleware(req: Request, res: Response, next: NextFunct
|
||||
correlationId: String(req.headers[CORRELATION_ID_HEADER] ?? req.headers['x-omnl-trace-id'] ?? ''),
|
||||
requestId: String(req.headers[REQUEST_ID_HEADER] ?? ''),
|
||||
});
|
||||
req.headers[CORRELATION_ID_HEADER] = ctx.correlationId;
|
||||
req.headers['x-omnl-trace-id'] = ctx.correlationId;
|
||||
req.headers[REQUEST_ID_HEADER] = ctx.requestId;
|
||||
const traceId = ctx.correlationId;
|
||||
res.setHeader('X-OMNL-Trace-Id', traceId);
|
||||
res.setHeader(CORRELATION_ID_HEADER, ctx.correlationId);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { MockComplianceDecisionEngine } from '@dbis/integration-foundation';
|
||||
import path from 'node:path';
|
||||
import { createComplianceDecisionEngine } from '../../compliance/volume13-engine.js';
|
||||
|
||||
const engine = new MockComplianceDecisionEngine();
|
||||
const PROXMOX_ROOT = process.env.PROXMOX_ROOT || path.resolve(process.cwd(), '../../../..');
|
||||
const engine = createComplianceDecisionEngine(PROXMOX_ROOT);
|
||||
|
||||
const FINANCIAL_POST_PATHS = [
|
||||
'/omnl/iso20022/messages',
|
||||
|
||||
@@ -245,3 +245,38 @@ describe('Bridge API GRU Transport status', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridge API relay lane messages', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServer(createApp());
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 138-monad relay messages with seed canaries when skipRpc=1', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/messages?lane=138-monad&skipRpc=1`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.laneId).toBe('138-monad-relay');
|
||||
expect(body.integration).toMatchObject({ ccipExplorer: false, creRequired: false });
|
||||
const messages = body.messages as Array<{ messageId: string; status: string }>;
|
||||
expect(Array.isArray(messages)).toBe(true);
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(messages.some((m) => m.status === 'delivered')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown lane', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/bridge/messages?lane=unknown`);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { buildRelayLaneReport } from '../../indexer/relay-lane-indexer';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router: Router = Router();
|
||||
@@ -224,6 +225,41 @@ router.get('/metrics', (_req: Request, res: Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/bridge/messages?lane=138-monad
|
||||
* Custom relay lane tracker (Chain 138 ↔ Monad). Not indexed by ccip.chain.link.
|
||||
*/
|
||||
router.get('/messages', async (req: Request, res: Response) => {
|
||||
const lane = String(req.query.lane ?? '138-monad').trim().toLowerCase();
|
||||
if (lane !== '138-monad' && lane !== 'monad-138' && lane !== '138-monad-relay') {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: 'Unsupported lane',
|
||||
supported: ['138-monad', 'monad-138', '138-monad-relay'],
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Math.min(500, Math.max(1, Number(req.query.limit ?? 100) || 100));
|
||||
const fromBlockDays = Math.min(90, Math.max(1, Number(req.query.days ?? 30) || 30));
|
||||
const skipRpc = req.query.skipRpc === '1' || req.query.skipRpc === 'true';
|
||||
|
||||
try {
|
||||
const report = await buildRelayLaneReport({ fromBlockDays, limit, skipRpc });
|
||||
res.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=120');
|
||||
return res.json({
|
||||
ok: true,
|
||||
lane,
|
||||
...report,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('bridge/messages failed:', err);
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
error: 'Relay lane indexer unavailable',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/preflight', (_req: Request, res: Response) => {
|
||||
const gruTransport = buildGruTransportStatus();
|
||||
if (!gruTransport) {
|
||||
|
||||
@@ -140,6 +140,49 @@ describe('Config API runtime networks loader', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('merges built-in oracles when runtime networks file omits them', async () => {
|
||||
const tempPath = path.join('/tmp', `token-aggregation-networks-no-oracles-${Date.now()}.json`);
|
||||
await fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify({
|
||||
version: { major: 1, minor: 2, patch: 0 },
|
||||
chains: [
|
||||
{
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
process.env.NETWORKS_JSON_PATH = tempPath;
|
||||
|
||||
try {
|
||||
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.oracles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'ETH/USD (aggregator)' }),
|
||||
])
|
||||
);
|
||||
|
||||
const networksRes = await fetch(`${baseUrl}/api/v1/networks`);
|
||||
const networksBody = (await networksRes.json()) as Record<string, any>;
|
||||
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-icons/chain-138.png'])
|
||||
);
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves wallet-facing MetaMask aliases with absolute token images', async () => {
|
||||
const networksRes = await fetch(`${baseUrl}/api/v1/config/networks`);
|
||||
expect(networksRes.status).toBe(200);
|
||||
@@ -162,7 +205,10 @@ describe('Config API runtime networks loader', () => {
|
||||
expect(metamaskBody.addEthereumChain).toMatchObject({
|
||||
chainId: '0x8a',
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
});
|
||||
expect(metamaskBody.addEthereumChain).not.toHaveProperty('chainIdDecimal');
|
||||
expect(metamaskBody.addEthereumChain).not.toHaveProperty('oracles');
|
||||
expect(metamaskBody.watchAssets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -187,35 +233,45 @@ describe('Config API runtime networks loader', () => {
|
||||
image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/weth\.svg\?v=20260510$/),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'ERC20',
|
||||
options: expect.objectContaining({
|
||||
symbol: 'cWEMIX',
|
||||
image: expect.stringMatching(/^https:\/\/d-bis\.org\/tokens\/cwemix\.svg\?v=20260510$/),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
const placeholderSymbols = ['cAVAX', 'cBNB', 'cETH', 'cETHL2', 'cPOL', 'cCRO', 'cXDAI', 'cCELO', 'cWEMIX'];
|
||||
for (const symbol of placeholderSymbols) {
|
||||
expect(
|
||||
metamaskBody.watchAssets.some((entry: Record<string, any>) => entry.options?.symbol === symbol),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(metamaskBody.watchAssets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'ERC20',
|
||||
options: expect.objectContaining({
|
||||
address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
symbol: 'cUSDC',
|
||||
}),
|
||||
metadata: expect.objectContaining({
|
||||
catalogSymbol: 'cUSDC_V2',
|
||||
familySymbol: 'cUSDC',
|
||||
deploymentVersion: 'v2',
|
||||
catalogSymbol: 'cUSDC',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
const cusdcEntries = metamaskBody.watchAssets.filter(
|
||||
(entry: Record<string, any>) => entry.options?.symbol === 'cUSDC',
|
||||
);
|
||||
const cusdtEntries = metamaskBody.watchAssets.filter(
|
||||
(entry: Record<string, any>) => entry.options?.symbol === 'cUSDT',
|
||||
);
|
||||
expect(cusdcEntries).toHaveLength(1);
|
||||
expect(cusdtEntries).toHaveLength(1);
|
||||
expect(
|
||||
metamaskBody.watchAssets.some(
|
||||
(entry: Record<string, any>) => entry.options?.symbol === 'cUSDC_V2' || entry.options?.symbol === 'cUSDT_V2'
|
||||
)
|
||||
(entry: Record<string, any>) => entry.metadata?.catalogSymbol === 'cUSDC_V2',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
metamaskBody.watchAssets.some(
|
||||
(entry: Record<string, any>) => entry.options?.symbol === 'cUSDC_V2' || entry.options?.symbol === 'cUSDT_V2',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { getNetworks, getConfigByChain, API_VERSION, type NetworkEntry } from '../../config/networks';
|
||||
import { toWalletAddEthereumChainParams } from '../../lib/wallet-add-ethereum-chain';
|
||||
import { dedupeWatchAssetEntriesBySymbol, isWalletWatchAssetEligible } from '../../lib/wallet-watch-eligible';
|
||||
import { getCanonicalTokensByChain, getLogoUriForSpec, getTokenRegistryFamily } from '../../config/canonical-tokens';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { fetchRemoteJson } from '../utils/fetch-remote-json';
|
||||
@@ -72,17 +74,27 @@ function resolveWalletWatchAssetSymbol(spec: { symbol: string; familySymbol?: st
|
||||
return spec.symbol;
|
||||
}
|
||||
|
||||
const HUB_COMPLIANT_WATCH_SYMBOLS = new Set(['cUSDC', 'cUSDT']);
|
||||
const HUB_COMPLIANT_WATCH_CHAIN_IDS = new Set([138, 651940]);
|
||||
function usesGenericEthDiamondIcons(iconUrls: unknown): boolean {
|
||||
if (!Array.isArray(iconUrls) || iconUrls.length === 0) return true;
|
||||
return iconUrls.every(
|
||||
(url) => typeof url === 'string' && url.includes('eth-diamond-black.png'),
|
||||
);
|
||||
}
|
||||
|
||||
function isWalletWatchAssetEligible(spec: { symbol: string }, chainId: number): boolean {
|
||||
// Off the hub, cUSDC/cUSDT catalog rows point at official USDC/USDT peg contracts
|
||||
// (symbol USDC/USDT on-chain). MetaMask rejects wallet_watchAsset when options.symbol
|
||||
// does not match ERC-20 symbol(). Public wrapped transports are cWUSDC/cWUSDT instead.
|
||||
if (HUB_COMPLIANT_WATCH_SYMBOLS.has(spec.symbol) && !HUB_COMPLIANT_WATCH_CHAIN_IDS.has(chainId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
function enrichNetworksWithBuiltInMetadata(payload: NetworksPayload): NetworksPayload {
|
||||
const builtInByChain = new Map(getNetworks().map((entry) => [entry.chainIdDecimal, entry]));
|
||||
const networks = (payload.networks as NetworkEntry[]).map((network) => {
|
||||
const builtIn = builtInByChain.get(Number(network.chainIdDecimal));
|
||||
if (!builtIn) return network;
|
||||
const oracles =
|
||||
Array.isArray(network.oracles) && network.oracles.length > 0 ? network.oracles : builtIn.oracles;
|
||||
const iconUrls =
|
||||
usesGenericEthDiamondIcons(network.iconUrls) && builtIn.iconUrls?.length
|
||||
? builtIn.iconUrls
|
||||
: network.iconUrls;
|
||||
return { ...network, oracles, iconUrls };
|
||||
});
|
||||
return { ...payload, networks };
|
||||
}
|
||||
|
||||
type RuntimeNetworksPayload = {
|
||||
@@ -117,8 +129,10 @@ function resolveRuntimeNetworksPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.NETWORKS_JSON_PATH,
|
||||
process.env.CONFIG_NETWORKS_JSON_PATH,
|
||||
'/opt/explorer/config/metamask/DUAL_CHAIN_NETWORKS.json',
|
||||
path.join(os.homedir(), 'projects/explorer-monorepo/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.join(os.homedir(), 'projects/explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json')
|
||||
path.join(os.homedir(), 'projects/explorer-monorepo/backend/config/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
path.join(os.homedir(), 'projects/proxmox/docs/04-configuration/metamask/DUAL_CHAIN_NETWORKS.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -184,10 +198,10 @@ function loadRuntimeNetworksPayload(): NetworksPayload | null {
|
||||
|
||||
async function resolveNetworksPayload(): Promise<NetworksPayload> {
|
||||
const remotePayload = await loadRemoteNetworksPayload();
|
||||
if (remotePayload) return remotePayload;
|
||||
if (remotePayload) return enrichNetworksWithBuiltInMetadata(remotePayload);
|
||||
|
||||
const runtimePayload = loadRuntimeNetworksPayload();
|
||||
if (runtimePayload) return runtimePayload;
|
||||
if (runtimePayload) return enrichNetworksWithBuiltInMetadata(runtimePayload);
|
||||
|
||||
return {
|
||||
source: 'built-in',
|
||||
@@ -231,40 +245,54 @@ router.get(['/config/metamask', '/metamask'], cacheMiddleware(5 * 60 * 1000), as
|
||||
return;
|
||||
}
|
||||
|
||||
const watchAssets = getCanonicalTokensByChain(chainId)
|
||||
.filter((spec) => isWalletWatchAssetEligible(spec, chainId))
|
||||
.map((spec) => {
|
||||
const address = spec.addresses[chainId];
|
||||
if (!address) return null;
|
||||
const originalLogoURI = getLogoUriForSpec(spec);
|
||||
return {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address,
|
||||
symbol: resolveWalletWatchAssetSymbol(spec),
|
||||
decimals: spec.decimals,
|
||||
image: appendWalletMetadataVersion(absolutePublicUrl(req, localLogoPathForSymbol(spec.symbol, originalLogoURI))),
|
||||
},
|
||||
metadata: {
|
||||
name: spec.name,
|
||||
catalogSymbol: spec.symbol,
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const watchAssets = dedupeWatchAssetEntriesBySymbol(
|
||||
getCanonicalTokensByChain(chainId)
|
||||
.filter((spec) => isWalletWatchAssetEligible(spec, chainId))
|
||||
.map((spec) => {
|
||||
const address = spec.addresses[chainId];
|
||||
if (!address) return null;
|
||||
const originalLogoURI = getLogoUriForSpec(spec);
|
||||
return {
|
||||
type: 'ERC20' as const,
|
||||
options: {
|
||||
address,
|
||||
symbol: resolveWalletWatchAssetSymbol(spec),
|
||||
decimals: spec.decimals,
|
||||
image: appendWalletMetadataVersion(
|
||||
absolutePublicUrl(req, localLogoPathForSymbol(spec.symbol, originalLogoURI)),
|
||||
),
|
||||
},
|
||||
metadata: {
|
||||
name: spec.name,
|
||||
catalogSymbol: spec.symbol,
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
|
||||
);
|
||||
|
||||
const publicBase = resolvePublicBaseUrl(req);
|
||||
|
||||
res.json({
|
||||
source: payload.source,
|
||||
version: payload.version,
|
||||
chainId,
|
||||
addEthereumChain: network,
|
||||
addEthereumChain: toWalletAddEthereumChainParams(network),
|
||||
watchAssets,
|
||||
priceFeed: {
|
||||
iso4217: true,
|
||||
spotPricesV2: `${publicBase}/token-aggregation/api/v1/prices/metamask/v2/chains/${chainId}/spot-prices?tokenAddresses={addresses}&vsCurrency=usd`,
|
||||
submissionBundle: `${publicBase}/token-aggregation/api/v1/report/metamask-price-feed?chainId=${chainId}`,
|
||||
coingeckoReport: `${publicBase}/token-aggregation/api/v1/report/coingecko?chainId=${chainId}`,
|
||||
note: 'MetaMask native USD display requires chain 138 on price.api.cx.metamask.io or CoinGecko listing; these endpoints supply ISO-4217 valuation until then.',
|
||||
},
|
||||
caveats: [
|
||||
'MetaMask custom-token prices are controlled by MetaMask and its upstream asset/price providers; this endpoint supplies wallet metadata, logo URLs, and token-add payloads but cannot force MetaMask to render fiat prices.',
|
||||
'MetaMask custom-token prices are controlled by MetaMask and its upstream asset/price providers; spotPricesV2 supplies ISO-4217 USD rates but MetaMask will not read them until chain 138 is registered on price.api.cx.metamask.io or tokens are listed on CoinGecko.',
|
||||
'MetaMask stores one custom token row per symbol on a network. Staged V2 deployments that share a family symbol with live V1 contracts are omitted here so balances do not disappear after import.',
|
||||
'After metadata changes, remove and re-add the custom network/token in MetaMask or use the companion UI to call wallet_addEthereumChain and wallet_watchAsset again.',
|
||||
],
|
||||
});
|
||||
@@ -287,11 +315,16 @@ router.get('/config', cacheMiddleware(5 * 60 * 1000), async (req: Request, res:
|
||||
if (chainIdParam) {
|
||||
const chainId = parseInt(chainIdParam, 10);
|
||||
const matchingNetwork = networks.find((network) => Number(network.chainIdDecimal) === chainId);
|
||||
const builtIn = getConfigByChain(chainId);
|
||||
const runtimeOracles = matchingNetwork?.oracles;
|
||||
const config = matchingNetwork
|
||||
? {
|
||||
oracles: (matchingNetwork.oracles as unknown[]) ?? [],
|
||||
oracles:
|
||||
Array.isArray(runtimeOracles) && runtimeOracles.length > 0
|
||||
? runtimeOracles
|
||||
: (builtIn?.oracles ?? []),
|
||||
}
|
||||
: getConfigByChain(chainId);
|
||||
: builtIn;
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: 'Chain not found', chainId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import express from 'express';
|
||||
import metamaskPriceRoutes from './metamask-prices';
|
||||
import { primeLiveReferencePriceUsd } from '../../services/canonical-price-oracle';
|
||||
|
||||
jest.mock('../middleware/cache', () => ({
|
||||
cacheMiddleware: () => (_req: unknown, _res: unknown, next: () => void) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/iso4217-fx-refresh', () => ({
|
||||
ensureIso4217FxPrimed: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/chain138-onchain-reference-prices', () => ({
|
||||
refreshChain138LiveReferencePrices: jest.fn().mockResolvedValue(undefined),
|
||||
getChain138EthUsdOnChain: jest.fn().mockResolvedValue({ priceUsd: 1772, source: 'aggregator' }),
|
||||
}));
|
||||
|
||||
describe('metamask spot prices API', () => {
|
||||
let baseUrl = '';
|
||||
let server: ReturnType<express.Application['listen']> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.TOKEN_AGGREGATION_SKIP_DB_READS = '1';
|
||||
primeLiveReferencePriceUsd('USD', 1);
|
||||
primeLiveReferencePriceUsd('AUD', 0.71);
|
||||
|
||||
const app = express();
|
||||
app.use('/api/v1/prices/metamask', metamaskPriceRoutes);
|
||||
await new Promise<void>((resolve) => {
|
||||
server = app.listen(0, () => {
|
||||
const address = server?.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
baseUrl = `http://127.0.0.1:${port}/api/v1/prices/metamask`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns MetaMask v2 spot price map for WETH via native oracle on chain 138', async () => {
|
||||
const address = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/v2/chains/138/spot-prices?tokenAddresses=${address}&vsCurrency=usd`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, { usd?: number }>;
|
||||
expect(body[address]?.usd).toBe(1772);
|
||||
});
|
||||
|
||||
it('returns MetaMask v2 spot price map for canonical cUSDC', async () => {
|
||||
const address = '0xf22258f57794cc8e06237084b353ab30fffa640b';
|
||||
const res = await fetch(
|
||||
`${baseUrl}/v2/chains/138/spot-prices?tokenAddresses=${address}&vsCurrency=usd`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, { usd?: number }>;
|
||||
expect(body[address]?.usd).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
118
services/token-aggregation/src/api/routes/metamask-prices.ts
Normal file
118
services/token-aggregation/src/api/routes/metamask-prices.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getCanonicalTokensByChain } from '../../config/canonical-tokens';
|
||||
import { cacheMiddleware } from '../middleware/cache';
|
||||
import { resolveIso4217SpotPrice } from '../../services/iso4217-spot-price';
|
||||
import { refreshChain138LiveReferencePrices } from '../../services/chain138-onchain-reference-prices';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
function parseChainId(value: unknown): number | null {
|
||||
const chainId = parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(chainId) && chainId > 0 ? chainId : null;
|
||||
}
|
||||
|
||||
function parseTokenAddresses(raw: unknown): string[] {
|
||||
const values = Array.isArray(raw)
|
||||
? raw.flatMap((entry) => String(entry).split(','))
|
||||
: String(raw ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim());
|
||||
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!/^0x[a-f0-9]{40}$/.test(normalized) || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
if (out.length >= 120) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeVsCurrency(raw: unknown): string {
|
||||
const value = String(raw ?? 'usd')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return value || 'usd';
|
||||
}
|
||||
|
||||
async function buildMetamaskSpotPriceMap(
|
||||
chainId: number,
|
||||
addresses: string[],
|
||||
vsCurrency: string,
|
||||
): Promise<Record<string, Record<string, number>>> {
|
||||
await refreshChain138LiveReferencePrices(chainId);
|
||||
|
||||
const response: Record<string, Record<string, number>> = {};
|
||||
|
||||
await Promise.all(
|
||||
addresses.map(async (address) => {
|
||||
const spot = await resolveIso4217SpotPrice(chainId, address);
|
||||
if (spot.priceUsd != null && spot.priceUsd > 0) {
|
||||
response[address] = { [vsCurrency]: spot.priceUsd };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* MetaMask Codefi Price API compatible spot prices for ISO-4217 c* tokens.
|
||||
* MetaMask extension/mobile will not consume this until chain 138 is registered on
|
||||
* price.api.cx.metamask.io — use this URL in Consensys / CoinGecko submission packets.
|
||||
*/
|
||||
router.get(
|
||||
'/v2/chains/:chainId/spot-prices',
|
||||
cacheMiddleware(60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseChainId(req.params.chainId);
|
||||
if (!chainId) {
|
||||
res.status(400).json({ message: 'Invalid chainId' });
|
||||
return;
|
||||
}
|
||||
|
||||
const vsCurrency = normalizeVsCurrency(req.query.vsCurrency);
|
||||
const addresses = parseTokenAddresses(req.query.tokenAddresses);
|
||||
if (addresses.length === 0) {
|
||||
res.status(400).json({ message: 'tokenAddresses query parameter is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prices = await buildMetamaskSpotPriceMap(chainId, addresses, vsCurrency);
|
||||
res.json(prices);
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/v1/chains/:chainId/spot-prices/:tokenAddress',
|
||||
cacheMiddleware(60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseChainId(req.params.chainId);
|
||||
const address = String(req.params.tokenAddress || '').trim().toLowerCase();
|
||||
if (!chainId || !/^0x[a-f0-9]{40}$/.test(address)) {
|
||||
res.status(400).json({ message: 'Invalid chainId or tokenAddress' });
|
||||
return;
|
||||
}
|
||||
|
||||
const vsCurrency = normalizeVsCurrency(req.query.vsCurrency);
|
||||
const spot = await resolveIso4217SpotPrice(chainId, address);
|
||||
if (spot.priceUsd == null || !(spot.priceUsd > 0)) {
|
||||
res.status(404).json({ message: 'Price unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ [vsCurrency]: spot.priceUsd });
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from '../../services/omnl-iso20022-store';
|
||||
import { verifyOmnlWebhookSignature } from '../../services/omnl-webhooks';
|
||||
import { appendOmnlAudit } from '../../services/omnl-audit-log';
|
||||
import { listOmnlEntities, getOmnlEntity } from '../../services/omnl-entity-registry';
|
||||
import { validateIso20022Payload } from '../../services/omnl-iso20022-validate';
|
||||
import { getWeb3ComplianceSummary, buildNotarizationIntent } from '../../services/omnl-web3-compliance';
|
||||
import {
|
||||
buildComplianceConsoleSnapshot,
|
||||
@@ -125,7 +127,21 @@ router.get('/omnl/disclosures/full', omnlSensitiveRouteGuard, async (req: Reques
|
||||
res.json(await buildFullDisclosure(lineId));
|
||||
});
|
||||
|
||||
router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, (req: Request, res: Response) => {
|
||||
router.get('/omnl/entities', omnlSensitiveRouteGuard, (_req, res) => {
|
||||
res.json(listOmnlEntities());
|
||||
});
|
||||
|
||||
router.get('/omnl/entities/:clientNumber', omnlSensitiveRouteGuard, (req, res) => {
|
||||
const n = parseInt(String(req.params.clientNumber), 10);
|
||||
const entity = getOmnlEntity(n);
|
||||
if (!entity) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
res.json(entity);
|
||||
});
|
||||
|
||||
router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
messageType?: Iso20022MessageType;
|
||||
@@ -139,6 +155,11 @@ router.post('/omnl/iso20022/messages', omnlSensitiveRouteGuard, (req: Request, r
|
||||
res.status(400).json({ error: 'messageType and payload required' });
|
||||
return;
|
||||
}
|
||||
const schema = await validateIso20022Payload(body.messageType, body.payload);
|
||||
if (!schema.valid) {
|
||||
res.status(400).json({ error: 'iso20022_schema_invalid', details: schema.errors });
|
||||
return;
|
||||
}
|
||||
const record = saveIso20022Message({
|
||||
messageType: body.messageType,
|
||||
payload: body.payload,
|
||||
|
||||
@@ -593,6 +593,20 @@ describe('Report API', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes deterministic gas placeholders from wallet-facing token list', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=138&wallet=1`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
const symbols = body.tokens.map((token: Record<string, any>) => token.symbol);
|
||||
const placeholderSymbols = ['cAVAX', 'cBNB', 'cETH', 'cETHL2', 'cPOL', 'cCRO', 'cXDAI', 'cCELO', 'cWEMIX'];
|
||||
for (const symbol of placeholderSymbols) {
|
||||
expect(symbols).not.toContain(symbol);
|
||||
}
|
||||
expect(symbols).toEqual(
|
||||
expect.arrayContaining(['cUSDT', 'cUSDC', 'cBTC', 'WETH'])
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -636,6 +650,7 @@ describe('Report API', () => {
|
||||
chainId: 138,
|
||||
extensions: expect.objectContaining({
|
||||
registryFamily: 'gas_native',
|
||||
walletWatchEligible: false,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -643,6 +658,7 @@ describe('Report API', () => {
|
||||
chainId: 138,
|
||||
extensions: expect.objectContaining({
|
||||
registryFamily: 'gas_native',
|
||||
walletWatchEligible: false,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MarketDataRepository } from '../../database/repositories/market-data-re
|
||||
import { PoolRepository } from '../../database/repositories/pool-repo';
|
||||
import {
|
||||
CANONICAL_TOKENS,
|
||||
getCanonicalTokenByAddress,
|
||||
getCanonicalTokensByChain,
|
||||
getLogoUriForSpec,
|
||||
getTokenRegistryFamily,
|
||||
@@ -37,6 +38,8 @@ import {
|
||||
} from '../../config/deployment-status';
|
||||
import { getGruV2DeploymentPoolRows } from '../../config/gru-v2-deployment-pools';
|
||||
import { getCanonicalPriceSnapshotGeneratedAt, getCanonicalPriceUsd } from '../../services/canonical-price-oracle';
|
||||
import { getIso4217FxLastRefreshAt } from '../../services/iso4217-fx-refresh';
|
||||
import { buildIso4217TokenListExtensions } from '../../services/iso4217-token-metadata';
|
||||
import { pmmVaultReserveFromChain, resolvePmmQuoteRpcUrl } from '../../services/pmm-onchain-quote';
|
||||
import {
|
||||
buildUniswapTokenList,
|
||||
@@ -46,6 +49,8 @@ import {
|
||||
import { buildCuratedPoolRegistry } from '../../services/pool-registry-report';
|
||||
import { enrichCuratedPoolRegistryWithLiveLiquidity } from '../../services/pool-registry-enrichment';
|
||||
import { buildLpPositionsReport } from '../../services/lp-positions-report';
|
||||
import { isDeterministicPlaceholderAddress } from '../../lib/deterministic-placeholder-address';
|
||||
import { isWalletWatchEligibleTokenAddress, isWalletWatchAssetEligible } from '../../lib/wallet-watch-eligible';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -310,11 +315,6 @@ function hasExternalOfficialQuoteLiquidity(token: { chainId: number; symbol: str
|
||||
return EXTERNAL_OFFICIAL_QUOTE_ASSETS.has(`${token.chainId}:${token.symbol}:${token.address.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function isDeterministicPlaceholderAddress(address: string): boolean {
|
||||
const normalized = address.toLowerCase();
|
||||
return /^0x[a-f0-9]{4}0{24,}[a-f0-9]{1,8}$/.test(normalized);
|
||||
}
|
||||
|
||||
function buildSupplyProofEnrichment(
|
||||
chainId: number,
|
||||
address: string,
|
||||
@@ -698,19 +698,52 @@ function finalizeUniswapTokenListResponse(
|
||||
req: Request,
|
||||
res: Response,
|
||||
payload: Record<string, unknown>,
|
||||
chainIds: number[]
|
||||
chainIds: number[],
|
||||
options?: { walletEligibleOnly?: boolean },
|
||||
): void {
|
||||
const logoURI = absolutePublicUrl(req, typeof payload.logoURI === 'string' ? payload.logoURI : undefined);
|
||||
const walletEligibleOnly = options?.walletEligibleOnly === true;
|
||||
const tokens = Array.isArray(payload.tokens)
|
||||
? payload.tokens.map((token) => {
|
||||
const record = token as Record<string, unknown>;
|
||||
return {
|
||||
...record,
|
||||
logoURI:
|
||||
absolutePublicUrl(req, typeof record.logoURI === 'string' ? record.logoURI : undefined) ??
|
||||
record.logoURI,
|
||||
};
|
||||
})
|
||||
? payload.tokens
|
||||
.map((token) => {
|
||||
const record = token as Record<string, unknown>;
|
||||
const address = typeof record.address === 'string' ? record.address : undefined;
|
||||
const walletWatchEligible = isWalletWatchEligibleTokenAddress(address);
|
||||
const chainId = typeof record.chainId === 'number' ? record.chainId : undefined;
|
||||
const spec =
|
||||
chainId && address ? getCanonicalTokenByAddress(chainId, address) : undefined;
|
||||
const iso4217Extensions = spec ? buildIso4217TokenListExtensions(spec) : {};
|
||||
const extensions =
|
||||
record.extensions && typeof record.extensions === 'object' && !Array.isArray(record.extensions)
|
||||
? (record.extensions as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...record,
|
||||
logoURI:
|
||||
absolutePublicUrl(req, typeof record.logoURI === 'string' ? record.logoURI : undefined) ??
|
||||
record.logoURI,
|
||||
extensions: {
|
||||
...iso4217Extensions,
|
||||
...extensions,
|
||||
walletWatchEligible,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((token) => {
|
||||
if (!walletEligibleOnly) return true;
|
||||
const record = token as Record<string, unknown>;
|
||||
const address = typeof record.address === 'string' ? record.address : undefined;
|
||||
if (!isWalletWatchEligibleTokenAddress(address)) return false;
|
||||
const extensions =
|
||||
record.extensions && typeof record.extensions === 'object' && !Array.isArray(record.extensions)
|
||||
? (record.extensions as Record<string, unknown>)
|
||||
: {};
|
||||
const status = String(extensions.deploymentStatus || '').toLowerCase();
|
||||
const version = String(extensions.deploymentVersion || '').toLowerCase();
|
||||
const familySymbol = extensions.familySymbol;
|
||||
if (status === 'staged' || (version === 'v2' && familySymbol)) return false;
|
||||
return true;
|
||||
})
|
||||
: [];
|
||||
|
||||
const displayNames = chainIds.length === 1 && chainIds[0] === 1 ? MAINNET_PUBLIC_DISPLAY_NAMES : undefined;
|
||||
@@ -1516,6 +1549,60 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/** GET /report/metamask-price-feed — MetaMask / Consensys price-provider submission bundle for ISO-4217 c* tokens. */
|
||||
router.get(
|
||||
'/metamask-price-feed',
|
||||
cacheMiddleware(2 * 60 * 1000),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10) || 138;
|
||||
const publicBase = resolvePublicBaseUrl(req);
|
||||
|
||||
const tokens = getCanonicalTokensByChain(chainId)
|
||||
.map((spec) => {
|
||||
const address = spec.addresses[chainId];
|
||||
if (!address) return null;
|
||||
const priceUsd = getCanonicalPriceUsd(chainId, address);
|
||||
return {
|
||||
chainId,
|
||||
address,
|
||||
symbol: spec.symbol,
|
||||
name: spec.name,
|
||||
decimals: spec.decimals,
|
||||
priceUsd,
|
||||
extensions: buildIso4217TokenListExtensions(spec),
|
||||
};
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => Boolean(row));
|
||||
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
schema: 'dbis-metamask-price-feed/v1',
|
||||
chainId,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
assetPlatformId: 'defi-oracle-meta',
|
||||
iso4217Pricing: true,
|
||||
fxLastRefreshAt: getIso4217FxLastRefreshAt() || null,
|
||||
canonicalPriceSnapshot: getCanonicalPriceSnapshotGeneratedAt(),
|
||||
metamaskPriceApiStatus: 'chain_138_not_registered_on_price_api_cx_metamask_io',
|
||||
spotPricesV2: `${publicBase}/token-aggregation/api/v1/prices/metamask/v2/chains/${chainId}/spot-prices?tokenAddresses={addresses}&vsCurrency=usd`,
|
||||
spotPricesV1: `${publicBase}/token-aggregation/api/v1/prices/metamask/v1/chains/${chainId}/spot-prices/{tokenAddress}?vsCurrency=usd`,
|
||||
coingeckoReport: `${publicBase}/token-aggregation/api/v1/report/coingecko?chainId=${chainId}`,
|
||||
tokenList: `${publicBase}/token-aggregation/api/v1/report/token-list?chainId=${chainId}`,
|
||||
tokens,
|
||||
submissionNotes: [
|
||||
'Request Consensys to register chainId 138 on price.api.cx.metamask.io pointing at spotPricesV2, or list tokens on CoinGecko under asset platform defi-oracle-meta.',
|
||||
'c* = Compliant eMoney; XXX = ISO-4217; C = Cash (M1); T = Treasury — coingeckoPriceProxyId is USD display proxy only.',
|
||||
'Live FX for ISO-4217 fiat codes refreshes every 5 minutes from Frankfurter + CoinGecko reference markets.',
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/metamask-price-feed:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /report/token-price/:symbol — compact reviewer-facing price and evidence snapshot. */
|
||||
router.get(
|
||||
'/token-price/:symbol',
|
||||
@@ -1644,14 +1731,19 @@ router.get(
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const walletEligibleOnly =
|
||||
String(req.query.wallet ?? req.query.walletEligible ?? '').trim() === '1' ||
|
||||
String(req.query.wallet ?? req.query.walletEligible ?? '').trim().toLowerCase() === 'true';
|
||||
const chainIds = chainIdParam
|
||||
? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n))
|
||||
: getSupportedChainIds();
|
||||
|
||||
const finalizeOptions = { walletEligibleOnly };
|
||||
|
||||
if (chainIds.length === 1 && chainIds[0] === 138) {
|
||||
const fileList = loadDbis138TokenListFromFile();
|
||||
if (fileList) {
|
||||
return finalizeUniswapTokenListResponse(req, res, fileList, chainIds);
|
||||
return finalizeUniswapTokenListResponse(req, res, fileList, chainIds, finalizeOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1673,7 +1765,8 @@ router.get(
|
||||
...data,
|
||||
tokens,
|
||||
},
|
||||
chainIds
|
||||
chainIds,
|
||||
finalizeOptions,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('TOKEN_LIST_JSON_URL fetch failed, using built-in token list:', err);
|
||||
@@ -1686,24 +1779,25 @@ router.get(
|
||||
const specs = getCanonicalTokensByChain(chainId);
|
||||
for (const spec of specs) {
|
||||
const address = spec.addresses[chainId];
|
||||
if (address) {
|
||||
const originalLogoURI = getLogoUriForSpec(spec);
|
||||
list.push({
|
||||
chainId,
|
||||
address,
|
||||
symbol: spec.symbol,
|
||||
name: spec.name,
|
||||
decimals: spec.decimals,
|
||||
type: spec.type,
|
||||
logoURI: absoluteLogoUri(req, originalLogoURI, spec.symbol),
|
||||
originalLogoURI,
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
preferredForX402: spec.preferredForX402,
|
||||
});
|
||||
}
|
||||
if (!address) continue;
|
||||
if (walletEligibleOnly && !isWalletWatchAssetEligible(spec, chainId)) continue;
|
||||
const originalLogoURI = getLogoUriForSpec(spec);
|
||||
list.push({
|
||||
chainId,
|
||||
address,
|
||||
symbol: spec.symbol,
|
||||
name: spec.name,
|
||||
decimals: spec.decimals,
|
||||
type: spec.type,
|
||||
logoURI: absoluteLogoUri(req, originalLogoURI, spec.symbol),
|
||||
originalLogoURI,
|
||||
registryFamily: getTokenRegistryFamily(spec),
|
||||
familySymbol: spec.familySymbol,
|
||||
deploymentVersion: spec.deploymentVersion,
|
||||
deploymentStatus: spec.deploymentStatus,
|
||||
preferredForX402: spec.preferredForX402,
|
||||
extensions: buildIso4217TokenListExtensions(spec),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1717,7 +1811,8 @@ router.get(
|
||||
logoURI: 'https://d-bis.org/brand/chain-138.svg',
|
||||
tokens: list,
|
||||
},
|
||||
chainIds
|
||||
chainIds,
|
||||
finalizeOptions,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error building report/token-list:', error);
|
||||
|
||||
@@ -79,10 +79,19 @@ jest.mock('../../services/token-display', () => ({
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
jest.mock('../../services/chain138-onchain-reference-prices', () => ({
|
||||
refreshChain138LiveReferencePrices: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/blockscout-token-meta', () => ({
|
||||
fetchBlockscoutTokenMeta: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const tokensRoutes = require('./tokens').default as typeof import('./tokens').default;
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1', tokensRoutes);
|
||||
return app;
|
||||
}
|
||||
@@ -429,4 +438,67 @@ describe('Tokens API', () => {
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns distinct market-batch snapshots per requested address set', async () => {
|
||||
const cusdt = getCanonicalTokenBySymbol(138, 'cUSDT');
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
expect(cusdt?.addresses[138]).toBeTruthy();
|
||||
expect(weth?.addresses[138]).toBeTruthy();
|
||||
|
||||
const cusdtAddress = String(cusdt?.addresses[138]).toLowerCase();
|
||||
const wethAddress = String(weth?.addresses[138]).toLowerCase();
|
||||
|
||||
mockGetMarketData.mockImplementation(async (_chainId: number, address: string) => {
|
||||
if (address === cusdtAddress) {
|
||||
return {
|
||||
chainId: 138,
|
||||
tokenAddress: cusdtAddress,
|
||||
priceUsd: 1,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 1_000_000,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: new Date('2026-04-26T03:31:01.988Z'),
|
||||
};
|
||||
}
|
||||
if (address === wethAddress) {
|
||||
return {
|
||||
chainId: 138,
|
||||
tokenAddress: wethAddress,
|
||||
priceUsd: 1680,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 5_000_000,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: new Date('2026-04-26T03:31:01.988Z'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const first = await fetch(`${baseUrl}/api/v1/tokens/market-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chainId: 138, addresses: [cusdtAddress] }),
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
const firstBody = (await first.json()) as Record<string, any>;
|
||||
expect(firstBody.requested).toBe(1);
|
||||
expect(firstBody.snapshots?.[0]?.address).toBe(cusdtAddress);
|
||||
|
||||
const second = await fetch(`${baseUrl}/api/v1/tokens/market-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chainId: 138, addresses: [wethAddress] }),
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
const secondBody = (await second.json()) as Record<string, any>;
|
||||
expect(secondBody.requested).toBe(1);
|
||||
expect(secondBody.snapshots?.[0]?.address).toBe(wethAddress);
|
||||
expect(secondBody.snapshots?.[0]?.priceUsd).toBe(1680);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { filterPoolsForExposure, shouldExposePublicPool } from '../../config/gru
|
||||
import {
|
||||
getCanonicalTokenByAddress,
|
||||
getCanonicalTokensByChain,
|
||||
getCanonicalTokenBySymbol,
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { resolveCanonicalPriceUsd } from '../../services/canonical-price-oracle';
|
||||
@@ -22,6 +23,9 @@ import {
|
||||
mergeMarketWithValuation,
|
||||
resolveUsdValuation,
|
||||
} from '../../services/valuation-precedence';
|
||||
import { fetchBlockscoutTokenMeta } from '../../services/blockscout-token-meta';
|
||||
import { refreshChain138LiveReferencePrices } from '../../services/chain138-onchain-reference-prices';
|
||||
import { MAX_SINGLE_POOL_TVL_USD, resolveVisibleTokenLiquidityUsd } from '../../services/token-visible-liquidity';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -241,18 +245,215 @@ async function getTokenWithFallback(chainId: number, address: string): Promise<T
|
||||
livePools.find((pool) => pool.token0Address === resolution.lookupAddress || pool.token1Address === resolution.lookupAddress)
|
||||
? resolution.lookupAddress
|
||||
: null;
|
||||
if (!liveAddress) {
|
||||
if (liveAddress) {
|
||||
const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress);
|
||||
return {
|
||||
chainId,
|
||||
address: normalized,
|
||||
name: display.name,
|
||||
symbol: display.symbol,
|
||||
decimals: display.decimals,
|
||||
verified: display.source !== 'fallback',
|
||||
};
|
||||
}
|
||||
|
||||
const blockscoutMeta = await fetchBlockscoutTokenMeta(chainId, normalized);
|
||||
if (blockscoutMeta) {
|
||||
return {
|
||||
chainId,
|
||||
address: normalized,
|
||||
name: blockscoutMeta.name,
|
||||
symbol: blockscoutMeta.symbol,
|
||||
decimals: blockscoutMeta.decimals,
|
||||
totalSupply: blockscoutMeta.totalSupply,
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface TokenMarketSnapshot {
|
||||
address: string;
|
||||
symbol?: string;
|
||||
name?: string;
|
||||
decimals?: number;
|
||||
priceUsd?: number;
|
||||
liquidityUsd?: number;
|
||||
volume24h?: number;
|
||||
lastUpdated?: string;
|
||||
source?: string;
|
||||
blockscoutExchangeRate?: number;
|
||||
pricingKind?: 'spot' | 'lp-share';
|
||||
isCanonicalClone?: boolean;
|
||||
}
|
||||
|
||||
function isLikelyLpReceiptSymbol(symbol: string | undefined): boolean {
|
||||
if (!symbol) {
|
||||
return false;
|
||||
}
|
||||
const upper = symbol.toUpperCase();
|
||||
return upper.includes('DLP') || upper.endsWith('-LP') || upper.startsWith('LP-') || upper === 'LP';
|
||||
}
|
||||
|
||||
async function resolveTokenRow(
|
||||
chainId: number,
|
||||
normalizedAddress: string,
|
||||
hint?: { symbol?: string; name?: string; decimals?: number },
|
||||
): Promise<Token | null> {
|
||||
const fromRepo = await getTokenWithFallback(chainId, normalizedAddress);
|
||||
if (fromRepo) {
|
||||
return fromRepo;
|
||||
}
|
||||
|
||||
const blockscoutMeta = await fetchBlockscoutTokenMeta(chainId, normalizedAddress);
|
||||
if (blockscoutMeta) {
|
||||
return {
|
||||
chainId,
|
||||
address: normalizedAddress,
|
||||
name: blockscoutMeta.name,
|
||||
symbol: blockscoutMeta.symbol,
|
||||
decimals: blockscoutMeta.decimals,
|
||||
totalSupply: blockscoutMeta.totalSupply,
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
const canonical = tokenFromCanonical(chainId, normalizedAddress);
|
||||
if (canonical) {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
if (hint?.symbol) {
|
||||
return {
|
||||
chainId,
|
||||
address: normalizedAddress,
|
||||
name: hint.name || hint.symbol,
|
||||
symbol: hint.symbol,
|
||||
decimals: Number.isInteger(hint.decimals) ? Number(hint.decimals) : 18,
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveSymbolFallbackPriceUsd(
|
||||
chainId: number,
|
||||
normalizedAddress: string,
|
||||
symbol: string | undefined,
|
||||
): Promise<number | undefined> {
|
||||
if (!symbol) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const symbolSpec = getCanonicalTokenBySymbol(chainId, symbol);
|
||||
if (!symbolSpec) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canonicalAddress = symbolSpec.addresses?.[chainId]?.toLowerCase();
|
||||
if (canonicalAddress && canonicalAddress !== normalizedAddress) {
|
||||
const canonicalSnapshot = await resolveTokenMarketSnapshot(chainId, canonicalAddress, {
|
||||
allowSymbolFallback: false,
|
||||
});
|
||||
return canonicalSnapshot?.priceUsd;
|
||||
}
|
||||
|
||||
const canonicalPrice = resolveCanonicalPriceUsd(chainId, normalizedAddress);
|
||||
return canonicalPrice.priceUsd;
|
||||
}
|
||||
|
||||
async function resolveTokenMarketSnapshot(
|
||||
chainId: number,
|
||||
address: string,
|
||||
options?: { allowSymbolFallback?: boolean; hint?: { symbol?: string; name?: string; decimals?: number } },
|
||||
): Promise<TokenMarketSnapshot | null> {
|
||||
const normalizedAddress = address.toLowerCase();
|
||||
const allowSymbolFallback = options?.allowSymbolFallback !== false;
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalizedAddress);
|
||||
const token = await resolveTokenRow(chainId, normalizedAddress, options?.hint);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const display = await resolveTokenDisplay(tokenRepo, chainId, liveAddress);
|
||||
return {
|
||||
const canonicalSpec = getCanonicalTokenByAddress(chainId, normalizedAddress);
|
||||
const symbolSpec = token.symbol ? getCanonicalTokenBySymbol(chainId, token.symbol) : undefined;
|
||||
const canonicalAddressForSymbol = symbolSpec?.addresses?.[chainId]?.toLowerCase();
|
||||
const isCanonicalClone = Boolean(
|
||||
symbolSpec &&
|
||||
canonicalAddressForSymbol &&
|
||||
canonicalAddressForSymbol !== normalizedAddress &&
|
||||
!canonicalSpec,
|
||||
);
|
||||
const pricingKind = isLikelyLpReceiptSymbol(token.symbol) ? 'lp-share' : 'spot';
|
||||
|
||||
const [marketDataRaw, blockscoutMeta, coingeckoMarket, cmcMarket, dexscreenerMarket] = await Promise.all([
|
||||
marketDataRepo.getMarketData(chainId, resolution.lookupAddress),
|
||||
fetchBlockscoutTokenMeta(chainId, normalizedAddress),
|
||||
coingeckoAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
]);
|
||||
|
||||
const externalFeeds = await enrichExternalFeedsWithReferencePrice(chainId, resolution.lookupAddress, {
|
||||
coingecko: coingeckoMarket,
|
||||
cmc: cmcMarket,
|
||||
dexscreener: dexscreenerMarket,
|
||||
});
|
||||
const { market: marketData, pricing } = buildMarketPricingExplorer(
|
||||
chainId,
|
||||
address: normalized,
|
||||
name: display.name,
|
||||
symbol: display.symbol,
|
||||
decimals: display.decimals,
|
||||
verified: display.source !== 'fallback',
|
||||
normalizedAddress,
|
||||
resolution.lookupAddress,
|
||||
marketDataRaw,
|
||||
externalFeeds,
|
||||
);
|
||||
const market = normalizePossiblyRawLiquidityUsd(marketData, token);
|
||||
|
||||
let priceUsd = market?.priceUsd;
|
||||
if ((priceUsd == null || !(priceUsd > 0)) && blockscoutMeta?.exchangeRate != null) {
|
||||
priceUsd = blockscoutMeta.exchangeRate;
|
||||
}
|
||||
if (
|
||||
allowSymbolFallback &&
|
||||
(priceUsd == null || !(priceUsd > 0)) &&
|
||||
pricingKind !== 'lp-share'
|
||||
) {
|
||||
const fallbackPrice = await resolveSymbolFallbackPriceUsd(chainId, normalizedAddress, token.symbol);
|
||||
if (fallbackPrice != null && fallbackPrice > 0) {
|
||||
priceUsd = fallbackPrice;
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_MEANINGFUL_LIQUIDITY_USD = 1000;
|
||||
const marketLiquidityRaw =
|
||||
market?.liquidityUsd != null && market.liquidityUsd > 0 ? market.liquidityUsd : 0;
|
||||
const marketLiquidityUsd =
|
||||
marketLiquidityRaw > MAX_SINGLE_POOL_TVL_USD ? 0 : marketLiquidityRaw;
|
||||
const poolLiquidityUsd = await resolveVisibleTokenLiquidityUsd(chainId, normalizedAddress);
|
||||
const bestLiquidityUsd = Math.max(
|
||||
marketLiquidityUsd >= MIN_MEANINGFUL_LIQUIDITY_USD ? marketLiquidityUsd : 0,
|
||||
poolLiquidityUsd,
|
||||
);
|
||||
const liquidityUsd = bestLiquidityUsd > 0 ? bestLiquidityUsd : undefined;
|
||||
|
||||
return {
|
||||
address: normalizedAddress,
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
decimals: token.decimals,
|
||||
priceUsd: priceUsd != null && priceUsd > 0 ? priceUsd : undefined,
|
||||
liquidityUsd,
|
||||
volume24h: market?.volume24h,
|
||||
lastUpdated: market?.lastUpdated instanceof Date
|
||||
? market.lastUpdated.toISOString()
|
||||
: market?.lastUpdated
|
||||
? new Date(market.lastUpdated).toISOString()
|
||||
: undefined,
|
||||
source: pricing?.sourceLayer || (blockscoutMeta?.exchangeRate != null ? 'blockscout' : undefined),
|
||||
blockscoutExchangeRate: blockscoutMeta?.exchangeRate,
|
||||
pricingKind,
|
||||
isCanonicalClone,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -517,6 +718,8 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
|
||||
await refreshChain138LiveReferencePrices(chainId);
|
||||
|
||||
const { tokens, source } = await getTokensWithFallback(chainId, limit, offset);
|
||||
const tokensWithMarketData = await Promise.all(
|
||||
tokens.map(async (token) => {
|
||||
@@ -560,6 +763,83 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tokens/market-batch', cacheMiddleware(30 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(String(req.body?.chainId ?? req.query.chainId ?? ''), 10);
|
||||
const rawAddresses = req.body?.addresses;
|
||||
if (!chainId) {
|
||||
return res.status(400).json({ error: 'chainId is required' });
|
||||
}
|
||||
const hasAddressList = Array.isArray(rawAddresses);
|
||||
const hasTokenHints = Array.isArray(req.body?.tokens);
|
||||
if (!hasAddressList && !hasTokenHints) {
|
||||
return res.status(400).json({ error: 'addresses array or tokens array is required' });
|
||||
}
|
||||
|
||||
await refreshChain138LiveReferencePrices(chainId);
|
||||
|
||||
const hintByAddress = new Map<string, { symbol?: string; name?: string; decimals?: number }>();
|
||||
const rawTokens = req.body?.tokens;
|
||||
if (Array.isArray(rawTokens)) {
|
||||
for (const row of rawTokens) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const hintAddress = String((row as Record<string, unknown>).address || '').trim().toLowerCase();
|
||||
if (!/^0x[a-f0-9]{40}$/.test(hintAddress)) continue;
|
||||
hintByAddress.set(hintAddress, {
|
||||
symbol: typeof (row as Record<string, unknown>).symbol === 'string'
|
||||
? String((row as Record<string, unknown>).symbol)
|
||||
: undefined,
|
||||
name: typeof (row as Record<string, unknown>).name === 'string'
|
||||
? String((row as Record<string, unknown>).name)
|
||||
: undefined,
|
||||
decimals: Number((row as Record<string, unknown>).decimals) || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addressSet = new Set<string>();
|
||||
if (Array.isArray(rawAddresses)) {
|
||||
for (const value of rawAddresses) {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (/^0x[a-f0-9]{40}$/.test(normalized)) {
|
||||
addressSet.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const hintAddress of hintByAddress.keys()) {
|
||||
addressSet.add(hintAddress);
|
||||
}
|
||||
|
||||
const addresses = [...addressSet].slice(0, 120);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
addresses.map(async (address) => resolveTokenMarketSnapshot(chainId, address, {
|
||||
hint: hintByAddress.get(address),
|
||||
})),
|
||||
);
|
||||
|
||||
const snapshots: TokenMarketSnapshot[] = [];
|
||||
for (const row of settled) {
|
||||
if (row.status === 'fulfilled' && row.value) {
|
||||
snapshots.push(row.value);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
chainId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
requested: addresses.length,
|
||||
resolved: snapshots.length,
|
||||
snapshots,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching token market batch:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainId = parseInt(req.query.chainId as string, 10);
|
||||
@@ -576,6 +856,8 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
|
||||
await refreshChain138LiveReferencePrices(chainId);
|
||||
|
||||
const [
|
||||
marketDataRaw,
|
||||
pools,
|
||||
|
||||
@@ -27,6 +27,7 @@ import omnlComplianceRoutes from './routes/omnl-compliance-routes';
|
||||
import omnlTerminalRoutes from './routes/omnl-terminal-routes';
|
||||
import checkpointRoutes from './routes/checkpoint';
|
||||
import reserveCapacityRoutes from './routes/reserve-capacity';
|
||||
import metamaskPriceRoutes from './routes/metamask-prices';
|
||||
import { MultiChainIndexer } from '../indexer/chain-indexer';
|
||||
import { OmnlEventPoller } from '../indexer/omnl-event-poller';
|
||||
import { getDatabasePool } from '../database/client';
|
||||
@@ -250,6 +251,7 @@ export class ApiServer {
|
||||
tokenDetail: '/api/v1/tokens/{address}',
|
||||
quote: '/api/v1/quote',
|
||||
bridgeRoutes: '/api/v1/bridge/routes',
|
||||
bridgeMessages: '/api/v1/bridge/messages?lane=138-monad',
|
||||
bridgeStatus: '/api/v1/bridge/status',
|
||||
bridgeMetrics: '/api/v1/bridge/metrics',
|
||||
bridgePreflight: '/api/v1/bridge/preflight',
|
||||
@@ -260,6 +262,8 @@ export class ApiServer {
|
||||
reportOfficialProtocols: '/api/v1/report/official-protocols',
|
||||
reportOpenApi: '/api/v1/report/openapi.json',
|
||||
reportTokenList: '/api/v1/report/token-list',
|
||||
metamaskSpotPricesV2: '/api/v1/prices/metamask/v2/chains/{chainId}/spot-prices',
|
||||
metamaskPriceFeedReport: '/api/v1/report/metamask-price-feed?chainId=138',
|
||||
reportTokenListEthereumMainnet: '/api/v1/report/token-list/static/ethereum-mainnet',
|
||||
routesTree: '/api/v1/routes/tree',
|
||||
plannerProvidersCapabilities: '/api/v2/providers/capabilities',
|
||||
@@ -292,6 +296,7 @@ export class ApiServer {
|
||||
this.app.use('/api/v1', partnerPayloadRoutes);
|
||||
this.app.use('/api/v1', checkpointRoutes);
|
||||
this.app.use('/api/v1', reserveCapacityRoutes);
|
||||
this.app.use('/api/v1/prices/metamask', metamaskPriceRoutes);
|
||||
this.app.use('/api/v1', omnlComplianceRoutes);
|
||||
this.app.use('/api/v1', omnlTerminalRoutes);
|
||||
this.app.use('/api/v1', omnlRoutes);
|
||||
|
||||
@@ -47,6 +47,7 @@ const WETH9_DESTINATIONS: Record<string, string> = {
|
||||
'Cronos (25)': '0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab',
|
||||
'Celo (42220)': '0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7',
|
||||
'Wemix (1111)': '0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04',
|
||||
'Monad (143)': '0xC158b6cD3A3088C52F797D41f5Aa02825361629e',
|
||||
};
|
||||
|
||||
const WETH10_DESTINATIONS: Record<string, string> = {
|
||||
|
||||
118
services/token-aggregation/src/compliance/volume13-engine.ts
Normal file
118
services/token-aggregation/src/compliance/volume13-engine.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
ComplianceDecisionEngine,
|
||||
ComplianceEvaluationInput,
|
||||
ComplianceEvaluationResult,
|
||||
} from '@dbis/integration-foundation';
|
||||
import { MockComplianceDecisionEngine } from '@dbis/integration-foundation';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
type Rule = { id: string; decision?: string; trigger?: { riskHints?: string[]; amountGte?: number } };
|
||||
type SanctionEntry = { id: string; names?: string[]; active?: boolean };
|
||||
|
||||
function loadJson<T>(p: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export class Volume13ComplianceDecisionEngine implements ComplianceDecisionEngine {
|
||||
private readonly fallback = new MockComplianceDecisionEngine();
|
||||
private readonly rules: Rule[];
|
||||
private readonly sanctions: SanctionEntry[];
|
||||
private readonly vendor: string;
|
||||
private auditCounter = 0;
|
||||
|
||||
constructor(root: string) {
|
||||
this.vendor = process.env.COMPLIANCE_VENDOR || 'internal';
|
||||
const rulesDoc = loadJson<{ rules?: Rule[] }>(
|
||||
path.join(root, 'config/hybx-omnl-dbis/volume13-aml-decision-rules.v1.json'),
|
||||
{}
|
||||
);
|
||||
const sanctionsDoc = loadJson<{ entries?: SanctionEntry[] }>(
|
||||
path.join(root, 'config/compliance/internal-sanctions-list.v1.json'),
|
||||
{}
|
||||
);
|
||||
this.rules = rulesDoc.rules ?? [];
|
||||
this.sanctions = sanctionsDoc.entries ?? [];
|
||||
}
|
||||
|
||||
private auditId(): string {
|
||||
this.auditCounter += 1;
|
||||
return `audit-v13-${this.vendor}-${this.auditCounter}`;
|
||||
}
|
||||
|
||||
private sanctioned(entityId: string): boolean {
|
||||
const n = entityId.toLowerCase();
|
||||
return this.sanctions.some(
|
||||
(e) =>
|
||||
e.active !== false &&
|
||||
(e.id.toLowerCase() === n || (e.names ?? []).some((x) => n.includes(x.toLowerCase())))
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateTransaction(input: ComplianceEvaluationInput): Promise<ComplianceEvaluationResult> {
|
||||
const hints = input.riskHints ?? [];
|
||||
if (this.sanctioned(input.entityId) || hints.includes('sanctions_hit')) {
|
||||
return {
|
||||
decision: 'block',
|
||||
risk_score: 100,
|
||||
reason_codes: ['SANCTIONS_HIT'],
|
||||
evidence_refs: ['volume13-sanctions'],
|
||||
manual_review_required: true,
|
||||
audit_event_id: this.auditId(),
|
||||
};
|
||||
}
|
||||
for (const rule of this.rules) {
|
||||
const t = rule.trigger ?? {};
|
||||
if (t.riskHints?.some((h) => hints.includes(h))) {
|
||||
return {
|
||||
decision: (rule.decision as ComplianceEvaluationResult['decision']) ?? 'review',
|
||||
risk_score: 80,
|
||||
reason_codes: [rule.id],
|
||||
evidence_refs: [rule.id],
|
||||
manual_review_required: true,
|
||||
audit_event_id: this.auditId(),
|
||||
};
|
||||
}
|
||||
if (t.amountGte != null && Number(input.amount) >= t.amountGte) {
|
||||
return {
|
||||
decision: (rule.decision as ComplianceEvaluationResult['decision']) ?? 'hold',
|
||||
risk_score: 65,
|
||||
reason_codes: [rule.id],
|
||||
evidence_refs: [rule.id],
|
||||
manual_review_required: true,
|
||||
audit_event_id: this.auditId(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return this.fallback.evaluateTransaction(input);
|
||||
}
|
||||
|
||||
async evaluateEntity(entityId: string, tenantId: string): Promise<ComplianceEvaluationResult> {
|
||||
if (this.sanctioned(entityId)) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
risk_score: 95,
|
||||
reason_codes: ['ENTITY_SANCTIONS'],
|
||||
evidence_refs: [entityId],
|
||||
manual_review_required: true,
|
||||
audit_event_id: this.auditId(),
|
||||
};
|
||||
}
|
||||
return this.fallback.evaluateEntity(entityId, tenantId);
|
||||
}
|
||||
|
||||
async evaluateCounterparty(counterpartyId: string, tenantId: string): Promise<ComplianceEvaluationResult> {
|
||||
return this.evaluateEntity(counterpartyId, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createComplianceDecisionEngine(proxomoxRoot: string): ComplianceDecisionEngine {
|
||||
if (process.env.OMNL_COMPLIANCE_GATE !== '1') {
|
||||
return new MockComplianceDecisionEngine();
|
||||
}
|
||||
return new Volume13ComplianceDecisionEngine(proxomoxRoot);
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* Source of truth: config/rwa-capital-markets-taxonomy.v1.json
|
||||
*/
|
||||
|
||||
import taxonomy from '../../../../../config/rwa-capital-markets-taxonomy.v1.json';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export interface CapitalMarketsFields {
|
||||
assetClass: string;
|
||||
@@ -28,8 +29,31 @@ type TaxonomyInstrument = {
|
||||
defillamaEligible?: boolean;
|
||||
};
|
||||
|
||||
type TaxonomyDocument = {
|
||||
version: string;
|
||||
instruments: TaxonomyInstrument[];
|
||||
};
|
||||
|
||||
function projectRoot(): string {
|
||||
return (
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(__dirname, '../../../../../..')
|
||||
);
|
||||
}
|
||||
|
||||
function loadTaxonomyDocument(): TaxonomyDocument {
|
||||
const path = resolve(projectRoot(), 'config/rwa-capital-markets-taxonomy.v1.json');
|
||||
if (!existsSync(path)) {
|
||||
return { version: 'unknown', instruments: [] };
|
||||
}
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as TaxonomyDocument;
|
||||
}
|
||||
|
||||
const taxonomy = loadTaxonomyDocument();
|
||||
|
||||
const byTicker = new Map<string, CapitalMarketsFields>(
|
||||
(taxonomy.instruments as TaxonomyInstrument[]).map((row) => [
|
||||
taxonomy.instruments.map((row) => [
|
||||
row.ticker,
|
||||
{
|
||||
assetClass: row.asset_class,
|
||||
|
||||
@@ -141,6 +141,15 @@ export const CHAIN_CONFIGS: Record<number, ChainConfig> = {
|
||||
blockTime: 2,
|
||||
confirmations: 1,
|
||||
},
|
||||
143: {
|
||||
chainId: 143,
|
||||
name: 'Monad Mainnet',
|
||||
rpcUrl: process.env.CHAIN_143_RPC_URL || process.env.MONAD_MAINNET_RPC || 'https://rpc.monad.xyz',
|
||||
explorerUrl: 'https://monadscan.com',
|
||||
nativeCurrency: { name: 'Monad', symbol: 'MON', decimals: 18 },
|
||||
blockTime: 1,
|
||||
confirmations: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export function getChainConfig(chainId: number): ChainConfig | undefined {
|
||||
|
||||
@@ -48,6 +48,12 @@ if (envAddr('CCIPWETH9_BRIDGE_CHAIN138')) {
|
||||
lanes: [
|
||||
{ destSelector: '5009297550715157269', destChainId: 1, destChainName: 'Ethereum' },
|
||||
{ destSelector: '16015286601757825753', destChainId: 651940, destChainName: 'ALL Mainnet' },
|
||||
{
|
||||
destSelector: '8481857512324358265',
|
||||
destChainId: 143,
|
||||
destChainName: 'Monad Mainnet',
|
||||
bridgeType: 'ccip_relay_monad',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
127
services/token-aggregation/src/config/monad-relay-lane.ts
Normal file
127
services/token-aggregation/src/config/monad-relay-lane.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface MonadRelayLaneChainSide {
|
||||
chainId: number;
|
||||
selector: string;
|
||||
explorer: string;
|
||||
rpcEnv?: string;
|
||||
rpcDefault?: string;
|
||||
sendBridgeWeth9: string;
|
||||
relayBridgeInbound?: string;
|
||||
relayBridgeOutbound?: string;
|
||||
ccipEventRouter?: string;
|
||||
ccipRelayRouter?: string;
|
||||
ccipRouterSend?: string;
|
||||
hubBridgeWeth9?: string;
|
||||
}
|
||||
|
||||
export interface MonadRelaySeedMessage {
|
||||
messageId: string;
|
||||
direction: '138-to-monad' | 'monad-to-138';
|
||||
status: 'initiated' | 'relaying' | 'delivered' | 'failed';
|
||||
sourceTxHash?: string;
|
||||
relayTxHash?: string;
|
||||
amountWei?: string;
|
||||
tokenSymbol?: string;
|
||||
sender?: string;
|
||||
recipient?: string;
|
||||
}
|
||||
|
||||
export interface MonadRelayLaneConfig {
|
||||
version: string;
|
||||
laneId: string;
|
||||
description: string;
|
||||
integration?: {
|
||||
explorerApi?: string;
|
||||
ccipExplorer?: boolean;
|
||||
relayService?: string;
|
||||
creRequired?: boolean;
|
||||
creNotes?: string;
|
||||
};
|
||||
chain138: MonadRelayLaneChainSide;
|
||||
monad: MonadRelayLaneChainSide;
|
||||
seedMessages?: MonadRelaySeedMessage[];
|
||||
}
|
||||
|
||||
const BUILTIN_DEFAULTS: MonadRelayLaneConfig = {
|
||||
version: '2026-06-13',
|
||||
laneId: '138-monad-relay',
|
||||
description: 'Custom CCIP relay lane (event-only routers + off-chain relay).',
|
||||
chain138: {
|
||||
chainId: 138,
|
||||
selector: '16015286601757825753',
|
||||
explorer: 'https://explorer.d-bis.org',
|
||||
rpcEnv: 'RPC_URL_138',
|
||||
sendBridgeWeth9: '0xcacfd227A040002e49e2e01626363071324f820a',
|
||||
relayBridgeInbound: '0xEB87B3bE09Ab383Ea391caf5bCE70B44Dbd80A9B',
|
||||
ccipEventRouter: '0x42DAb7b888Dd382bD5Adcf9E038dBF1fD03b4817',
|
||||
ccipRelayRouter: '0xe75d26bc558a28442f30750c6d97bffb46f39abc',
|
||||
},
|
||||
monad: {
|
||||
chainId: 143,
|
||||
selector: '8481857512324358265',
|
||||
explorer: 'https://monadscan.com',
|
||||
rpcEnv: 'MONAD_MAINNET_RPC',
|
||||
rpcDefault: 'https://rpc.monad.xyz',
|
||||
sendBridgeWeth9: '0xF9DD3349C7f11812d727Db5335690b1e6AD16a52',
|
||||
relayBridgeOutbound: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e',
|
||||
ccipRelayRouter: '0x89dd12025bfCD38A168455A44B400e913ED33BE2',
|
||||
ccipRouterSend: '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
hubBridgeWeth9: '0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e',
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function resolveMonadRelayLaneConfigPath(): string | null {
|
||||
const candidates = uniquePaths([
|
||||
process.env.MONAD_RELAY_LANE_JSON_PATH,
|
||||
path.resolve(process.cwd(), 'config', 'monad-chain138-relay-lane.v1.json'),
|
||||
path.resolve(process.cwd(), '..', 'config', 'monad-chain138-relay-lane.v1.json'),
|
||||
path.resolve(process.cwd(), '..', '..', 'config', 'monad-chain138-relay-lane.v1.json'),
|
||||
path.resolve(process.cwd(), '..', '..', '..', 'config', 'monad-chain138-relay-lane.v1.json'),
|
||||
path.resolve(__dirname, '../../../../../config/monad-chain138-relay-lane.v1.json'),
|
||||
]);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadMonadRelayLaneConfig(): MonadRelayLaneConfig {
|
||||
const configPath = resolveMonadRelayLaneConfigPath();
|
||||
if (!configPath) return BUILTIN_DEFAULTS;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as MonadRelayLaneConfig;
|
||||
return {
|
||||
...BUILTIN_DEFAULTS,
|
||||
...parsed,
|
||||
chain138: { ...BUILTIN_DEFAULTS.chain138, ...parsed.chain138 },
|
||||
monad: { ...BUILTIN_DEFAULTS.monad, ...parsed.monad },
|
||||
};
|
||||
} catch {
|
||||
return BUILTIN_DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMonadRpcUrl(config: MonadRelayLaneConfig = loadMonadRelayLaneConfig()): string {
|
||||
const envKey = config.monad.rpcEnv ?? 'MONAD_MAINNET_RPC';
|
||||
const fromEnv = process.env[envKey]?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return config.monad.rpcDefault ?? 'https://rpc.monad.xyz';
|
||||
}
|
||||
@@ -25,12 +25,7 @@ export const NETWORKS: NetworkEntry[] = [
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrls: [
|
||||
'https://rpc-http-pub.d-bis.org',
|
||||
'https://rpc.d-bis.org',
|
||||
'https://rpc2.d-bis.org',
|
||||
'https://rpc.defi-oracle.io',
|
||||
],
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
iconUrls: [
|
||||
@@ -39,7 +34,8 @@ export const NETWORKS: NetworkEntry[] = [
|
||||
'https://explorer.d-bis.org/favicon.ico',
|
||||
],
|
||||
oracles: [
|
||||
{ name: 'ETH/USD', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6', decimals: 8 },
|
||||
{ name: 'ETH/USD (proxy)', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6', decimals: 8 },
|
||||
{ name: 'ETH/USD (aggregator)', address: '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506', decimals: 8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { closeDatabasePool } from './database/client';
|
||||
import { logger } from './utils/logger';
|
||||
import { startRouteMatrixScheduler } from './services/route-matrix-scheduler';
|
||||
import { startGruReserveCapacityScheduler } from './services/gru-reserve-capacity-scheduler';
|
||||
import { startIso4217FxScheduler } from './services/iso4217-fx-refresh';
|
||||
|
||||
// Load smom-dbis-138 root .env first (single source); works from dist/ or src/
|
||||
const rootEnvCandidates = [
|
||||
@@ -30,6 +31,7 @@ try {
|
||||
const server = new ApiServer();
|
||||
startRouteMatrixScheduler();
|
||||
startGruReserveCapacityScheduler();
|
||||
startIso4217FxScheduler();
|
||||
|
||||
// Start server
|
||||
server.start().catch((error) => {
|
||||
|
||||
@@ -10,8 +10,11 @@ import { CoinMarketCapAdapter } from '../adapters/cmc-adapter';
|
||||
import { DexScreenerAdapter } from '../adapters/dexscreener-adapter';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getCanonicalPriceUsd } from '../services/canonical-price-oracle';
|
||||
import { getChain138EthUsdOnChain } from '../services/chain138-onchain-reference-prices';
|
||||
import { pickExternalMarketDataForIndexer } from '../services/valuation-precedence';
|
||||
|
||||
const CHAIN138_WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
||||
|
||||
export class ChainIndexer {
|
||||
private chainId: number;
|
||||
private provider: ethers.JsonRpcProvider;
|
||||
@@ -171,6 +174,14 @@ export class ChainIndexer {
|
||||
});
|
||||
const canonicalPriceUsd = getCanonicalPriceUsd(this.chainId, tokenAddress);
|
||||
|
||||
let priceUsd = externalData?.priceUsd ?? canonicalPriceUsd;
|
||||
if (this.chainId === 138 && tokenAddress.toLowerCase() === CHAIN138_WETH) {
|
||||
const native = await getChain138EthUsdOnChain();
|
||||
if (native?.priceUsd != null && native.priceUsd > 0) {
|
||||
priceUsd = native.priceUsd;
|
||||
}
|
||||
}
|
||||
|
||||
// Get pools for liquidity calculation
|
||||
const tokenPools = pools.filter(
|
||||
(p) => p.token0Address === tokenAddress || p.token1Address === tokenAddress
|
||||
@@ -181,7 +192,7 @@ export class ChainIndexer {
|
||||
await this.marketDataRepo.upsertMarketData({
|
||||
chainId: this.chainId,
|
||||
tokenAddress,
|
||||
priceUsd: externalData?.priceUsd ?? canonicalPriceUsd,
|
||||
priceUsd,
|
||||
priceChange24h: externalData?.priceChange24h,
|
||||
volume24h: volumeMetrics.volume24h || externalData?.volume24h || 0,
|
||||
volume7d: volumeMetrics.volume7d,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mergeRelayLaneMessages } from './relay-lane-indexer';
|
||||
import type { MonadRelayLaneConfig } from '../config/monad-relay-lane';
|
||||
|
||||
const TEST_CONFIG: MonadRelayLaneConfig = {
|
||||
version: 'test',
|
||||
laneId: '138-monad-relay',
|
||||
description: 'test lane',
|
||||
chain138: {
|
||||
chainId: 138,
|
||||
selector: '16015286601757825753',
|
||||
explorer: 'https://explorer.d-bis.org',
|
||||
sendBridgeWeth9: '0xcacfd227A040002e49e2e01626363071324f820a',
|
||||
relayBridgeInbound: '0xEB87B3bE09Ab383Ea391caf5bCE70B44Dbd80A9B',
|
||||
},
|
||||
monad: {
|
||||
chainId: 143,
|
||||
selector: '8481857512324358265',
|
||||
explorer: 'https://monadscan.com',
|
||||
sendBridgeWeth9: '0xF9DD3349C7f11812d727Db5335690b1e6AD16a52',
|
||||
relayBridgeOutbound: '0xC158b6cD3A3088C52F797D41f5Aa02825361629e',
|
||||
},
|
||||
};
|
||||
|
||||
describe('mergeRelayLaneMessages', () => {
|
||||
it('merges initiated and completed by messageId', () => {
|
||||
const messageId = '0xdebc46c8ce3b4698331768dcddb9c6ee69c7d891fbe48e930f383a27330bf8e8';
|
||||
const messages = mergeRelayLaneMessages(
|
||||
TEST_CONFIG,
|
||||
[
|
||||
{
|
||||
messageId,
|
||||
direction: '138-to-monad',
|
||||
sourceChainId: 138,
|
||||
destChainId: 143,
|
||||
destSelector: '8481857512324358265',
|
||||
sourceTxHash: '0xd2200edf828a7310aebc28e704f668732a5eb4fcba648b7339aa132a1496dd39',
|
||||
sourceBlockNumber: 100,
|
||||
sender: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
recipient: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
amountWei: '1000000000000000',
|
||||
tokenSymbol: 'WETH',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
messageId,
|
||||
direction: '138-to-monad',
|
||||
destChainId: 143,
|
||||
destTxHash: '0xbd79554a89e2abb3024462009725851f57a54eb8ddfd3d095068607c8a5e2402',
|
||||
destBlockNumber: 200,
|
||||
recipient: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
amountWei: '1000000000000000',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].status).toBe('delivered');
|
||||
expect(messages[0].sourceTxHash).toMatch(/^0x/);
|
||||
expect(messages[0].relayTxHash).toMatch(/^0x/);
|
||||
expect(messages[0].explorerLinks.sourceTx).toContain('explorer.d-bis.org');
|
||||
expect(messages[0].explorerLinks.relayTx).toContain('monadscan.com');
|
||||
});
|
||||
|
||||
it('includes seed messages when RPC data is absent', () => {
|
||||
const messages = mergeRelayLaneMessages(TEST_CONFIG, [], [], [
|
||||
{
|
||||
messageId: '0x82dcbc28d1e7b50a0b9caa1a9415078148dd78b5aeba9a23aa5714ef6cf8b970',
|
||||
direction: 'monad-to-138',
|
||||
status: 'delivered',
|
||||
sourceTxHash: '0x27d4ccf43c8bc17bab56b0a18c830dce21dd7ae175fd24e2261cb3a414f994ae',
|
||||
relayTxHash: '0x4435ecacc79919f64e6de6cd6b2d454a92580bb72a4c7e320d06cff2354e9567',
|
||||
amountWei: '1000000000000000',
|
||||
tokenSymbol: 'WMON',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].direction).toBe('monad-to-138');
|
||||
expect(messages[0].explorerLinks.sourceTx).toContain('monadscan.com');
|
||||
expect(messages[0].explorerLinks.relayTx).toContain('explorer.d-bis.org');
|
||||
});
|
||||
});
|
||||
447
services/token-aggregation/src/indexer/relay-lane-indexer.ts
Normal file
447
services/token-aggregation/src/indexer/relay-lane-indexer.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Indexes custom CCIP relay lane messages for Chain 138 ↔ Monad (143).
|
||||
* Correlates CrossChainTransferInitiated (source) with CrossChainTransferCompleted (destination).
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { resolveChain138RpcUrl } from '../config/chain138-rpc';
|
||||
import {
|
||||
loadMonadRelayLaneConfig,
|
||||
MonadRelayLaneConfig,
|
||||
MonadRelaySeedMessage,
|
||||
resolveMonadRpcUrl,
|
||||
} from '../config/monad-relay-lane';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const CCIP_TRANSFER_ABI = [
|
||||
'event CrossChainTransferInitiated(bytes32 indexed messageId, address indexed sender, uint64 indexed destinationChainSelector, address recipient, uint256 amount, uint256 nonce)',
|
||||
'event CrossChainTransferCompleted(bytes32 indexed messageId, uint64 indexed sourceChainSelector, address indexed recipient, uint256 amount)',
|
||||
'function processedTransfers(bytes32 messageId) view returns (bool)',
|
||||
];
|
||||
|
||||
const CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN = Math.max(
|
||||
1,
|
||||
Number(process.env.CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN || 5000)
|
||||
);
|
||||
|
||||
export type RelayLaneDirection = '138-to-monad' | 'monad-to-138';
|
||||
export type RelayLaneStatus = 'initiated' | 'relaying' | 'delivered' | 'failed';
|
||||
|
||||
export interface RelayLaneInitiatedEvent {
|
||||
messageId: string;
|
||||
direction: RelayLaneDirection;
|
||||
sourceChainId: number;
|
||||
destChainId: number;
|
||||
destSelector: string;
|
||||
sourceTxHash: string;
|
||||
sourceBlockNumber: number;
|
||||
sender: string;
|
||||
recipient: string;
|
||||
amountWei: string;
|
||||
tokenSymbol: string;
|
||||
}
|
||||
|
||||
export interface RelayLaneCompletedEvent {
|
||||
messageId: string;
|
||||
direction: RelayLaneDirection;
|
||||
destChainId: number;
|
||||
destTxHash: string;
|
||||
destBlockNumber: number;
|
||||
recipient: string;
|
||||
amountWei: string;
|
||||
}
|
||||
|
||||
export interface RelayLaneMessage {
|
||||
messageId: string;
|
||||
laneId: string;
|
||||
direction: RelayLaneDirection;
|
||||
status: RelayLaneStatus;
|
||||
sourceChainId: number;
|
||||
destChainId: number;
|
||||
tokenSymbol: string;
|
||||
amountWei: string;
|
||||
sender?: string;
|
||||
recipient?: string;
|
||||
sourceTxHash?: string;
|
||||
sourceBlockNumber?: number;
|
||||
relayTxHash?: string;
|
||||
destTxHash?: string;
|
||||
destBlockNumber?: number;
|
||||
explorerLinks: {
|
||||
sourceTx?: string;
|
||||
relayTx?: string;
|
||||
destTx?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RelayLaneReport {
|
||||
generatedAt: string;
|
||||
laneId: string;
|
||||
description: string;
|
||||
integration: {
|
||||
ccipExplorer: boolean;
|
||||
creRequired: boolean;
|
||||
relayService?: string;
|
||||
notes?: string;
|
||||
};
|
||||
chains: {
|
||||
chain138: { chainId: number; selector: string; explorer: string };
|
||||
monad: { chainId: number; selector: string; explorer: string };
|
||||
};
|
||||
contracts: {
|
||||
chain138SendBridge: string;
|
||||
chain138RelayBridge: string;
|
||||
monadSendBridge: string;
|
||||
monadRelayBridge: string;
|
||||
};
|
||||
messages: RelayLaneMessage[];
|
||||
stats: {
|
||||
total: number;
|
||||
delivered: number;
|
||||
pending: number;
|
||||
};
|
||||
}
|
||||
|
||||
function txExplorer(base: string, txHash?: string): string | undefined {
|
||||
if (!txHash?.startsWith('0x')) return undefined;
|
||||
const trimmed = base.replace(/\/$/, '');
|
||||
return `${trimmed}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
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]>,
|
||||
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[] = [];
|
||||
const chunkSpan = Math.min(CROSS_CHAIN_QUERY_FALLBACK_BLOCK_SPAN, toBlock - fromBlock + 1);
|
||||
for (let start = fromBlock; start <= toBlock; start += chunkSpan) {
|
||||
const end = Math.min(start + chunkSpan - 1, toBlock);
|
||||
const chunk = await queryFilterWithRangeFallback(contract, filter, start, end);
|
||||
logs.push(...chunk);
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
async function fetchInitiated(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
bridgeAddress: string,
|
||||
destSelector: string,
|
||||
direction: RelayLaneDirection,
|
||||
sourceChainId: number,
|
||||
destChainId: number,
|
||||
tokenSymbol: string,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<RelayLaneInitiatedEvent[]> {
|
||||
const events: RelayLaneInitiatedEvent[] = [];
|
||||
try {
|
||||
const contract = new ethers.Contract(bridgeAddress, CCIP_TRANSFER_ABI, provider);
|
||||
const filter = contract.filters.CrossChainTransferInitiated();
|
||||
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;
|
||||
};
|
||||
if (args.destinationChainSelector?.toString() !== destSelector) continue;
|
||||
|
||||
events.push({
|
||||
messageId: args.messageId,
|
||||
direction,
|
||||
sourceChainId,
|
||||
destChainId,
|
||||
destSelector,
|
||||
sourceTxHash: log.transactionHash,
|
||||
sourceBlockNumber: log.blockNumber,
|
||||
sender: args.sender,
|
||||
recipient: args.recipient,
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
tokenSymbol,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Relay lane indexer: initiated events for ${bridgeAddress} failed:`, err);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function fetchCompleted(
|
||||
provider: ethers.JsonRpcProvider,
|
||||
bridgeAddress: string,
|
||||
sourceSelector: string,
|
||||
direction: RelayLaneDirection,
|
||||
destChainId: number,
|
||||
fromBlock: number,
|
||||
toBlock: number
|
||||
): Promise<RelayLaneCompletedEvent[]> {
|
||||
const events: RelayLaneCompletedEvent[] = [];
|
||||
try {
|
||||
const contract = new ethers.Contract(bridgeAddress, CCIP_TRANSFER_ABI, provider);
|
||||
const filter = contract.filters.CrossChainTransferCompleted();
|
||||
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;
|
||||
sourceChainSelector: bigint;
|
||||
recipient: string;
|
||||
amount: bigint;
|
||||
};
|
||||
if (args.sourceChainSelector?.toString() !== sourceSelector) continue;
|
||||
|
||||
events.push({
|
||||
messageId: args.messageId,
|
||||
direction,
|
||||
destChainId,
|
||||
destTxHash: log.transactionHash,
|
||||
destBlockNumber: log.blockNumber,
|
||||
recipient: args.recipient,
|
||||
amountWei: args.amount?.toString() ?? '0',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Relay lane indexer: completed events for ${bridgeAddress} failed:`, err);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Merge initiated + completed + seed rows into explorer-facing messages. Exported for tests. */
|
||||
export function mergeRelayLaneMessages(
|
||||
config: MonadRelayLaneConfig,
|
||||
initiated: RelayLaneInitiatedEvent[],
|
||||
completed: RelayLaneCompletedEvent[],
|
||||
seeds: MonadRelaySeedMessage[] = []
|
||||
): RelayLaneMessage[] {
|
||||
const byId = new Map<string, RelayLaneMessage>();
|
||||
|
||||
const upsert = (messageId: string, patch: Partial<RelayLaneMessage> & Pick<RelayLaneMessage, 'direction'>): void => {
|
||||
const key = messageId.toLowerCase();
|
||||
const existing = byId.get(key);
|
||||
const sourceExplorer =
|
||||
patch.direction === '138-to-monad' ? config.chain138.explorer : config.monad.explorer;
|
||||
const destExplorer =
|
||||
patch.direction === '138-to-monad' ? config.monad.explorer : config.chain138.explorer;
|
||||
|
||||
const merged: RelayLaneMessage = {
|
||||
messageId,
|
||||
laneId: config.laneId,
|
||||
direction: patch.direction,
|
||||
status: patch.status ?? existing?.status ?? 'initiated',
|
||||
sourceChainId: patch.sourceChainId ?? existing?.sourceChainId ?? (patch.direction === '138-to-monad' ? 138 : 143),
|
||||
destChainId: patch.destChainId ?? existing?.destChainId ?? (patch.direction === '138-to-monad' ? 143 : 138),
|
||||
tokenSymbol: patch.tokenSymbol ?? existing?.tokenSymbol ?? (patch.direction === '138-to-monad' ? 'WETH' : 'WMON'),
|
||||
amountWei: patch.amountWei ?? existing?.amountWei ?? '0',
|
||||
sender: patch.sender ?? existing?.sender,
|
||||
recipient: patch.recipient ?? existing?.recipient,
|
||||
sourceTxHash: patch.sourceTxHash ?? existing?.sourceTxHash,
|
||||
sourceBlockNumber: patch.sourceBlockNumber ?? existing?.sourceBlockNumber,
|
||||
relayTxHash: patch.relayTxHash ?? existing?.relayTxHash,
|
||||
destTxHash: patch.destTxHash ?? existing?.destTxHash,
|
||||
destBlockNumber: patch.destBlockNumber ?? existing?.destBlockNumber,
|
||||
explorerLinks: {
|
||||
sourceTx: txExplorer(sourceExplorer, patch.sourceTxHash ?? existing?.sourceTxHash),
|
||||
relayTx: txExplorer(destExplorer, patch.relayTxHash ?? existing?.relayTxHash ?? patch.destTxHash ?? existing?.destTxHash),
|
||||
destTx: txExplorer(destExplorer, patch.destTxHash ?? existing?.destTxHash),
|
||||
},
|
||||
};
|
||||
byId.set(key, merged);
|
||||
};
|
||||
|
||||
for (const e of initiated) {
|
||||
upsert(e.messageId, {
|
||||
direction: e.direction,
|
||||
status: 'initiated',
|
||||
sourceChainId: e.sourceChainId,
|
||||
destChainId: e.destChainId,
|
||||
tokenSymbol: e.tokenSymbol,
|
||||
amountWei: e.amountWei,
|
||||
sender: e.sender,
|
||||
recipient: e.recipient,
|
||||
sourceTxHash: e.sourceTxHash,
|
||||
sourceBlockNumber: e.sourceBlockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
for (const e of completed) {
|
||||
upsert(e.messageId, {
|
||||
direction: e.direction,
|
||||
status: 'delivered',
|
||||
destChainId: e.destChainId,
|
||||
amountWei: e.amountWei,
|
||||
recipient: e.recipient,
|
||||
relayTxHash: e.destTxHash,
|
||||
destTxHash: e.destTxHash,
|
||||
destBlockNumber: e.destBlockNumber,
|
||||
});
|
||||
}
|
||||
|
||||
for (const seed of seeds) {
|
||||
upsert(seed.messageId, {
|
||||
direction: seed.direction,
|
||||
status: seed.status,
|
||||
tokenSymbol: seed.tokenSymbol,
|
||||
amountWei: seed.amountWei,
|
||||
sender: seed.sender,
|
||||
recipient: seed.recipient,
|
||||
sourceTxHash: seed.sourceTxHash,
|
||||
relayTxHash: seed.relayTxHash,
|
||||
destTxHash: seed.relayTxHash,
|
||||
});
|
||||
}
|
||||
|
||||
return [...byId.values()].sort((a, b) => {
|
||||
const blockA = a.sourceBlockNumber ?? a.destBlockNumber ?? 0;
|
||||
const blockB = b.sourceBlockNumber ?? b.destBlockNumber ?? 0;
|
||||
return blockB - blockA;
|
||||
});
|
||||
}
|
||||
|
||||
export interface BuildRelayLaneReportOptions {
|
||||
fromBlockDays?: number;
|
||||
limit?: number;
|
||||
skipRpc?: boolean;
|
||||
}
|
||||
|
||||
export async function buildRelayLaneReport(
|
||||
options: BuildRelayLaneReportOptions = {}
|
||||
): Promise<RelayLaneReport> {
|
||||
const config = loadMonadRelayLaneConfig();
|
||||
const fromBlockDays = options.fromBlockDays ?? 30;
|
||||
const limit = options.limit ?? 100;
|
||||
const seeds = config.seedMessages ?? [];
|
||||
|
||||
let initiated: RelayLaneInitiatedEvent[] = [];
|
||||
let completed: RelayLaneCompletedEvent[] = [];
|
||||
|
||||
if (!options.skipRpc) {
|
||||
try {
|
||||
const chain138Rpc = resolveChain138RpcUrl();
|
||||
const monadRpc = resolveMonadRpcUrl(config);
|
||||
const chain138Provider = new ethers.JsonRpcProvider(chain138Rpc);
|
||||
const monadProvider = new ethers.JsonRpcProvider(monadRpc);
|
||||
|
||||
const [chain138Block, monadBlock] = await Promise.all([
|
||||
chain138Provider.getBlockNumber(),
|
||||
monadProvider.getBlockNumber(),
|
||||
]);
|
||||
|
||||
const chain138From = Math.max(0, chain138Block - Math.floor((fromBlockDays * 86400) / 5));
|
||||
const monadFrom = Math.max(0, monadBlock - Math.floor((fromBlockDays * 86400) / 1));
|
||||
|
||||
const [
|
||||
initiated138,
|
||||
initiatedMonad,
|
||||
completedMonad,
|
||||
completed138,
|
||||
] = await Promise.all([
|
||||
fetchInitiated(
|
||||
chain138Provider,
|
||||
config.chain138.sendBridgeWeth9,
|
||||
config.monad.selector,
|
||||
'138-to-monad',
|
||||
config.chain138.chainId,
|
||||
config.monad.chainId,
|
||||
'WETH',
|
||||
chain138From,
|
||||
chain138Block
|
||||
),
|
||||
fetchInitiated(
|
||||
monadProvider,
|
||||
config.monad.sendBridgeWeth9,
|
||||
config.chain138.selector,
|
||||
'monad-to-138',
|
||||
config.monad.chainId,
|
||||
config.chain138.chainId,
|
||||
'WMON',
|
||||
monadFrom,
|
||||
monadBlock
|
||||
),
|
||||
fetchCompleted(
|
||||
monadProvider,
|
||||
config.monad.relayBridgeOutbound ?? '',
|
||||
config.chain138.selector,
|
||||
'138-to-monad',
|
||||
config.monad.chainId,
|
||||
monadFrom,
|
||||
monadBlock
|
||||
),
|
||||
fetchCompleted(
|
||||
chain138Provider,
|
||||
config.chain138.relayBridgeInbound ?? '',
|
||||
config.monad.selector,
|
||||
'monad-to-138',
|
||||
config.chain138.chainId,
|
||||
chain138From,
|
||||
chain138Block
|
||||
),
|
||||
]);
|
||||
|
||||
initiated = [...initiated138, ...initiatedMonad];
|
||||
completed = [...completedMonad, ...completed138];
|
||||
} catch (err) {
|
||||
logger.warn('Relay lane indexer: RPC scan failed, returning seed messages only:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const messages = mergeRelayLaneMessages(config, initiated, completed, seeds).slice(0, limit);
|
||||
const delivered = messages.filter((m) => m.status === 'delivered').length;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
laneId: config.laneId,
|
||||
description: config.description,
|
||||
integration: {
|
||||
ccipExplorer: config.integration?.ccipExplorer ?? false,
|
||||
creRequired: config.integration?.creRequired ?? false,
|
||||
relayService: config.integration?.relayService,
|
||||
notes: config.integration?.creNotes,
|
||||
},
|
||||
chains: {
|
||||
chain138: {
|
||||
chainId: config.chain138.chainId,
|
||||
selector: config.chain138.selector,
|
||||
explorer: config.chain138.explorer,
|
||||
},
|
||||
monad: {
|
||||
chainId: config.monad.chainId,
|
||||
selector: config.monad.selector,
|
||||
explorer: config.monad.explorer,
|
||||
},
|
||||
},
|
||||
contracts: {
|
||||
chain138SendBridge: config.chain138.sendBridgeWeth9,
|
||||
chain138RelayBridge: config.chain138.relayBridgeInbound ?? '',
|
||||
monadSendBridge: config.monad.sendBridgeWeth9,
|
||||
monadRelayBridge: config.monad.relayBridgeOutbound ?? '',
|
||||
},
|
||||
messages,
|
||||
stats: {
|
||||
total: messages.length,
|
||||
delivered,
|
||||
pending: messages.length - delivered,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { isDeterministicPlaceholderAddress } from './deterministic-placeholder-address';
|
||||
|
||||
describe('isDeterministicPlaceholderAddress', () => {
|
||||
it('detects GRU transport placeholders on Chain 138', () => {
|
||||
expect(isDeterministicPlaceholderAddress('0xcaaa00000000000000000000000000000000008a')).toBe(true);
|
||||
expect(isDeterministicPlaceholderAddress('0xc11100000000000000000000000000000000008a')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag live deployed token addresses', () => {
|
||||
expect(isDeterministicPlaceholderAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(false);
|
||||
expect(isDeterministicPlaceholderAddress('0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** GRU transport placeholders (e.g. 0xcaaa…008a) — not deployed ERC-20 contracts. */
|
||||
export function isDeterministicPlaceholderAddress(address: string): boolean {
|
||||
const normalized = address.toLowerCase();
|
||||
return /^0x[a-f0-9]{4}0{24,}[a-f0-9]{1,8}$/.test(normalized);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toWalletAddEthereumChainParams } from './wallet-add-ethereum-chain';
|
||||
|
||||
describe('toWalletAddEthereumChainParams', () => {
|
||||
it('strips MetaMask-unsupported keys', () => {
|
||||
const out = toWalletAddEthereumChainParams({
|
||||
chainId: '0x8a',
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'wss://rpc-ws-pub.d-bis.org'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
iconUrls: ['https://explorer.d-bis.org/favicon.ico'],
|
||||
oracles: [{ name: 'ETH/USD', address: '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6' }],
|
||||
} as never);
|
||||
|
||||
expect(out).toEqual({
|
||||
chainId: '0x8a',
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org'],
|
||||
iconUrls: ['https://explorer.d-bis.org/favicon.ico'],
|
||||
});
|
||||
expect(out).not.toHaveProperty('chainIdDecimal');
|
||||
expect(out).not.toHaveProperty('oracles');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NetworkEntry } from '../config/networks';
|
||||
|
||||
/** EIP-3085 fields accepted by MetaMask `wallet_addEthereumChain` (strict subset). */
|
||||
export type WalletAddEthereumChainParams = {
|
||||
chainId: string;
|
||||
chainName: string;
|
||||
rpcUrls: string[];
|
||||
nativeCurrency: { name: string; symbol: string; decimals: number };
|
||||
blockExplorerUrls?: string[];
|
||||
iconUrls?: string[];
|
||||
};
|
||||
|
||||
function httpsOnly(urls: string[] | undefined): string[] | undefined {
|
||||
if (!urls?.length) return undefined;
|
||||
const filtered = urls.filter((url) => typeof url === 'string' && url.startsWith('https://'));
|
||||
return filtered.length > 0 ? filtered : undefined;
|
||||
}
|
||||
|
||||
/** Strip `chainIdDecimal`, `oracles`, and other keys MetaMask rejects. */
|
||||
export function toWalletAddEthereumChainParams(
|
||||
network: Pick<
|
||||
NetworkEntry,
|
||||
'chainId' | 'chainName' | 'rpcUrls' | 'nativeCurrency' | 'blockExplorerUrls' | 'iconUrls'
|
||||
>
|
||||
): WalletAddEthereumChainParams {
|
||||
const rpcUrls = httpsOnly(network.rpcUrls) ?? ['https://rpc-http-pub.d-bis.org'];
|
||||
const out: WalletAddEthereumChainParams = {
|
||||
chainId: network.chainId,
|
||||
chainName: network.chainName,
|
||||
rpcUrls,
|
||||
nativeCurrency: network.nativeCurrency,
|
||||
};
|
||||
const blockExplorerUrls = httpsOnly(network.blockExplorerUrls);
|
||||
if (blockExplorerUrls) out.blockExplorerUrls = blockExplorerUrls;
|
||||
const iconUrls = httpsOnly(network.iconUrls);
|
||||
if (iconUrls) out.iconUrls = iconUrls;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
isWalletWatchAssetEligible,
|
||||
isWalletWatchEligibleTokenAddress,
|
||||
dedupeWatchAssetEntriesBySymbol,
|
||||
} from './wallet-watch-eligible';
|
||||
|
||||
describe('wallet-watch-eligible', () => {
|
||||
it('excludes deterministic placeholder addresses', () => {
|
||||
expect(isWalletWatchEligibleTokenAddress('0xcaaa00000000000000000000000000000000008a')).toBe(false);
|
||||
expect(isWalletWatchEligibleTokenAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes gas-native placeholder specs on Chain 138', () => {
|
||||
expect(
|
||||
isWalletWatchAssetEligible(
|
||||
{ symbol: 'cAVAX', addresses: { 138: '0xcaaa00000000000000000000000000000000008a' } },
|
||||
138,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes staged V2 family deployments that would collide in MetaMask', () => {
|
||||
expect(
|
||||
isWalletWatchAssetEligible(
|
||||
{
|
||||
symbol: 'cUSDC_V2',
|
||||
familySymbol: 'cUSDC',
|
||||
deploymentVersion: 'v2',
|
||||
deploymentStatus: 'staged',
|
||||
addresses: { 138: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d' },
|
||||
},
|
||||
138,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isWalletWatchAssetEligible(
|
||||
{ symbol: 'cUSDC', addresses: { 138: '0xf22258f57794CC8E06237084b353Ab30fFfa640b' } },
|
||||
138,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('dedupes watchAsset rows by wallet symbol', () => {
|
||||
const deduped = dedupeWatchAssetEntriesBySymbol([
|
||||
{
|
||||
options: { symbol: 'cUSDC', address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b' },
|
||||
metadata: { catalogSymbol: 'cUSDC' },
|
||||
},
|
||||
{
|
||||
options: { symbol: 'cUSDC', address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d' },
|
||||
metadata: { catalogSymbol: 'cUSDC_V2', deploymentVersion: 'v2', deploymentStatus: 'staged' },
|
||||
},
|
||||
]);
|
||||
expect(deduped).toHaveLength(1);
|
||||
expect(deduped[0]?.options.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b');
|
||||
});
|
||||
});
|
||||
68
services/token-aggregation/src/lib/wallet-watch-eligible.ts
Normal file
68
services/token-aggregation/src/lib/wallet-watch-eligible.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { isDeterministicPlaceholderAddress } from './deterministic-placeholder-address';
|
||||
|
||||
const HUB_COMPLIANT_WATCH_SYMBOLS = new Set(['cUSDC', 'cUSDT']);
|
||||
const HUB_COMPLIANT_WATCH_CHAIN_IDS = new Set([138, 651940]);
|
||||
|
||||
export type WalletWatchAssetSpec = {
|
||||
symbol: string;
|
||||
addresses: Partial<Record<number, string>>;
|
||||
deploymentStatus?: string;
|
||||
deploymentVersion?: string;
|
||||
familySymbol?: string;
|
||||
};
|
||||
|
||||
export function isStagedWalletWatchAssetSpec(spec: WalletWatchAssetSpec): boolean {
|
||||
const status = String(spec.deploymentStatus || '').trim().toLowerCase();
|
||||
const version = String(spec.deploymentVersion || '').trim().toLowerCase();
|
||||
return status === 'staged' || (version === 'v2' && Boolean(spec.familySymbol));
|
||||
}
|
||||
|
||||
export function isWalletWatchAssetEligible(spec: WalletWatchAssetSpec, chainId: number): boolean {
|
||||
if (HUB_COMPLIANT_WATCH_SYMBOLS.has(spec.symbol) && !HUB_COMPLIANT_WATCH_CHAIN_IDS.has(chainId)) {
|
||||
return false;
|
||||
}
|
||||
if (isStagedWalletWatchAssetSpec(spec)) {
|
||||
return false;
|
||||
}
|
||||
const address = spec.addresses[chainId];
|
||||
if (address && isDeterministicPlaceholderAddress(address)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isWalletWatchEligibleTokenAddress(address: string | undefined | null): boolean {
|
||||
if (!address) return false;
|
||||
return !isDeterministicPlaceholderAddress(address);
|
||||
}
|
||||
|
||||
export type WalletWatchAssetEntryLike = {
|
||||
options: { symbol: string; address: string };
|
||||
metadata?: {
|
||||
deploymentStatus?: string;
|
||||
deploymentVersion?: string;
|
||||
catalogSymbol?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** MetaMask keeps one custom token row per symbol — keep the live deployment when duplicates exist. */
|
||||
export function dedupeWatchAssetEntriesBySymbol<T extends WalletWatchAssetEntryLike>(entries: T[]): T[] {
|
||||
const rank = (entry: T): number => {
|
||||
let score = 0;
|
||||
const status = String(entry.metadata?.deploymentStatus || '').toLowerCase();
|
||||
const version = String(entry.metadata?.deploymentVersion || '').toLowerCase();
|
||||
if (status === 'staged') score += 100;
|
||||
if (version === 'v2') score += 50;
|
||||
return score;
|
||||
};
|
||||
|
||||
const bySymbol = new Map<string, T>();
|
||||
for (const entry of entries) {
|
||||
const key = entry.options.symbol.trim().toLowerCase();
|
||||
const existing = bySymbol.get(key);
|
||||
if (!existing || rank(entry) < rank(existing)) {
|
||||
bySymbol.set(key, entry);
|
||||
}
|
||||
}
|
||||
return Array.from(bySymbol.values());
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { CHAIN_CONFIGS } from '../config/chains';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface BlockscoutTokenMeta {
|
||||
address: string;
|
||||
name?: string;
|
||||
symbol?: string;
|
||||
decimals: number;
|
||||
totalSupply?: string;
|
||||
exchangeRate?: number;
|
||||
}
|
||||
|
||||
function parseExchangeRate(value: unknown): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined;
|
||||
}
|
||||
|
||||
export async function fetchBlockscoutTokenMeta(
|
||||
chainId: number,
|
||||
address: string,
|
||||
): Promise<BlockscoutTokenMeta | null> {
|
||||
const explorerUrl = CHAIN_CONFIGS[chainId]?.explorerUrl?.replace(/\/$/, '');
|
||||
if (!explorerUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = address.toLowerCase();
|
||||
try {
|
||||
const response = await fetch(`${explorerUrl}/api/v2/tokens/${normalized}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as {
|
||||
address?: string;
|
||||
name?: string | null;
|
||||
symbol?: string | null;
|
||||
decimals?: string | number | null;
|
||||
total_supply?: string | null;
|
||||
exchange_rate?: string | number | null;
|
||||
};
|
||||
|
||||
if (!raw.address) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
address: raw.address.toLowerCase(),
|
||||
name: raw.name || undefined,
|
||||
symbol: raw.symbol || undefined,
|
||||
decimals: Number(raw.decimals ?? 18),
|
||||
totalSupply: raw.total_supply || undefined,
|
||||
exchangeRate: parseExchangeRate(raw.exchange_rate),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('Blockscout token meta fetch failed', { chainId, address: normalized, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { getCanonicalTokenBySymbol } from '../config/canonical-tokens';
|
||||
import { getCanonicalPriceUsd, resolveCanonicalPriceUsd, resolveCanonicalPriceUsdForSpec } from './canonical-price-oracle';
|
||||
import {
|
||||
getCanonicalPriceUsd,
|
||||
primeLiveReferencePriceUsd,
|
||||
resolveCanonicalPriceUsd,
|
||||
resolveCanonicalPriceUsdForSpec,
|
||||
} from './canonical-price-oracle';
|
||||
|
||||
describe('canonical-price-oracle', () => {
|
||||
it('pegs Chain 138 USD-family mirrors to one dollar', () => {
|
||||
@@ -75,13 +80,25 @@ describe('canonical-price-oracle', () => {
|
||||
});
|
||||
|
||||
it('resolves WETH9 and WETH10 to the ETH peg with 18-decimal canonical metadata', () => {
|
||||
expect(resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')).toMatchObject({
|
||||
priceUsd: 2490,
|
||||
const weth = resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
|
||||
expect(weth).toMatchObject({
|
||||
referenceSymbol: 'ETH',
|
||||
});
|
||||
expect(resolveCanonicalPriceUsd(138, '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')).toMatchObject({
|
||||
priceUsd: 2490,
|
||||
expect(weth.priceUsd).toBeGreaterThan(0);
|
||||
expect(['env', 'on-chain', 'repo-fallback']).toContain(weth.source);
|
||||
|
||||
const weth10 = resolveCanonicalPriceUsd(138, '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f');
|
||||
expect(weth10.referenceSymbol).toBe('ETH');
|
||||
expect(weth10.priceUsd).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('prefers live on-chain ETH over env when drift exceeds threshold on chain 138', () => {
|
||||
process.env.CHAIN138_CANONICAL_PRICE_USD_ETH = '2100';
|
||||
primeLiveReferencePriceUsd('ETH', 1772);
|
||||
expect(resolveCanonicalPriceUsd(138, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')).toMatchObject({
|
||||
priceUsd: 1772,
|
||||
referenceSymbol: 'ETH',
|
||||
source: 'on-chain',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,49 @@ import { type CanonicalTokenSpec, getCanonicalTokenByAddress, getCanonicalTokenB
|
||||
export interface CanonicalPriceResolution {
|
||||
priceUsd?: number;
|
||||
referenceSymbol?: string;
|
||||
source: 'env' | 'repo-fallback' | 'unresolved';
|
||||
source: 'env' | 'on-chain' | 'repo-fallback' | 'unresolved';
|
||||
}
|
||||
|
||||
const FX_SNAPSHOT_GENERATED_AT = '2026-04-15';
|
||||
const CHAIN_138 = 138;
|
||||
|
||||
/** Max bps drift before live Chain 138 ETH/USD aggregator beats env canonical (default 3%). */
|
||||
export function readChain138EthOracleDriftBps(): number {
|
||||
const raw = process.env.TOKEN_AGG_CHAIN138_ETH_ORACLE_DRIFT_BPS;
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) && n >= 0 ? n : 300;
|
||||
}
|
||||
|
||||
export function ethUsdOracleDriftBps(indexerPrice: number, oraclePrice: number): number {
|
||||
if (!(oraclePrice > 0) || !(indexerPrice > 0)) {
|
||||
return 0;
|
||||
}
|
||||
return (Math.abs(indexerPrice - oraclePrice) / oraclePrice) * 10000;
|
||||
}
|
||||
|
||||
const liveReferencePrices = new Map<string, { priceUsd: number; expiresAt: number }>();
|
||||
|
||||
export function primeLiveReferencePriceUsd(
|
||||
referenceSymbol: string,
|
||||
priceUsd: number | undefined,
|
||||
ttlMs = 120_000,
|
||||
): void {
|
||||
if (!priceUsd || !(priceUsd > 0)) {
|
||||
return;
|
||||
}
|
||||
liveReferencePrices.set(referenceSymbol.toUpperCase(), {
|
||||
priceUsd,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
function readLiveReferencePriceUsd(referenceSymbol: string): number | undefined {
|
||||
const row = liveReferencePrices.get(referenceSymbol.toUpperCase());
|
||||
if (!row || row.expiresAt <= Date.now()) {
|
||||
return undefined;
|
||||
}
|
||||
return row.priceUsd;
|
||||
}
|
||||
|
||||
// Repo-local inferred FX snapshot from scripts/lib/extraction_gap_closure.py.
|
||||
const REPO_FALLBACK_PRICE_USD: Record<string, number> = {
|
||||
@@ -18,14 +57,19 @@ const REPO_FALLBACK_PRICE_USD: Record<string, number> = {
|
||||
CHF: 1.2776572668112798,
|
||||
JPY: 0.006285683794888213,
|
||||
XAU: 5163.3401260328355,
|
||||
ETH: 2490,
|
||||
ETH: 1680,
|
||||
BTC: 90000,
|
||||
LINK: 8,
|
||||
BNB: 610,
|
||||
POL: 0.78,
|
||||
AVAX: 48,
|
||||
CELO: 0.72,
|
||||
CRO: 0.14,
|
||||
XDAI: 1,
|
||||
LIPMG: 1100,
|
||||
LIBMG1: 4.25,
|
||||
LIBMG2: 18.5,
|
||||
LIBMG3: 0.12,
|
||||
};
|
||||
|
||||
function normalizeAddress(value: string): string {
|
||||
@@ -68,6 +112,11 @@ function resolveReferenceSymbol(spec: CanonicalTokenSpec): string | undefined {
|
||||
return 'XAU';
|
||||
}
|
||||
|
||||
if (symbol === 'LIPMG') return 'LIPMG';
|
||||
if (symbol === 'LIBMG1') return 'LIBMG1';
|
||||
if (symbol === 'LIBMG2') return 'LIBMG2';
|
||||
if (symbol === 'LIBMG3') return 'LIBMG3';
|
||||
|
||||
if (currencyCode) {
|
||||
return currencyCode;
|
||||
}
|
||||
@@ -161,6 +210,15 @@ export function resolveCanonicalPriceUsdForSpec(spec: CanonicalTokenSpec): Canon
|
||||
};
|
||||
}
|
||||
|
||||
const livePrice = readLiveReferencePriceUsd(referenceSymbol);
|
||||
if (livePrice !== undefined) {
|
||||
return {
|
||||
priceUsd: livePrice,
|
||||
referenceSymbol,
|
||||
source: 'on-chain',
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = REPO_FALLBACK_PRICE_USD[referenceSymbol];
|
||||
if (fallback !== undefined) {
|
||||
return {
|
||||
@@ -181,7 +239,28 @@ export function resolveCanonicalPriceUsd(chainId: number, address: string): Cano
|
||||
if (!spec) {
|
||||
return { source: 'unresolved' };
|
||||
}
|
||||
return resolveCanonicalPriceUsdForSpec(spec);
|
||||
const base = resolveCanonicalPriceUsdForSpec(spec);
|
||||
if (chainId !== CHAIN_138 || base.referenceSymbol !== 'ETH') {
|
||||
return base;
|
||||
}
|
||||
|
||||
const liveEth = readLiveReferencePriceUsd('ETH');
|
||||
if (liveEth === undefined) {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (base.source === 'env' && base.priceUsd !== undefined) {
|
||||
if (ethUsdOracleDriftBps(base.priceUsd, liveEth) > readChain138EthOracleDriftBps()) {
|
||||
return { priceUsd: liveEth, referenceSymbol: 'ETH', source: 'on-chain' };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
if (base.source === 'repo-fallback' || base.source === 'unresolved' || base.priceUsd === undefined) {
|
||||
return { priceUsd: liveEth, referenceSymbol: 'ETH', source: 'on-chain' };
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export function getCanonicalPriceUsd(chainId: number, address: string): number | undefined {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ethers } from 'ethers';
|
||||
import { primeLiveReferencePriceUsd } from './canonical-price-oracle';
|
||||
|
||||
const ROUND_DATA_ABI = [
|
||||
'function latestRoundData() view returns (uint80 roundId,int256 answer,uint256 startedAt,uint256 updatedAt,uint80 answeredInRound)',
|
||||
];
|
||||
|
||||
const DEFAULT_RPC = 'http://192.168.11.211:8545';
|
||||
const DEFAULT_AGGREGATOR = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506';
|
||||
const DEFAULT_PROXY = '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6';
|
||||
|
||||
interface CachedPrice {
|
||||
priceUsd: number;
|
||||
expiresAt: number;
|
||||
source: 'aggregator' | 'proxy';
|
||||
}
|
||||
|
||||
let cachedEthUsd: CachedPrice | undefined;
|
||||
|
||||
function readAggregatorAddress(): string {
|
||||
return (
|
||||
process.env.AGGREGATOR_ADDRESS?.trim() ||
|
||||
process.env.ORACLE_AGGREGATOR_ADDRESS?.trim() ||
|
||||
DEFAULT_AGGREGATOR
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
function readProxyAddress(): string {
|
||||
return (
|
||||
process.env.ORACLE_PROXY_ADDRESS?.trim() ||
|
||||
process.env.ORACLE_PROXY?.trim() ||
|
||||
DEFAULT_PROXY
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
function readRpcUrl(): string {
|
||||
return (
|
||||
process.env.RPC_URL_138?.trim() ||
|
||||
process.env.CHAIN138_RPC_URL?.trim() ||
|
||||
DEFAULT_RPC
|
||||
);
|
||||
}
|
||||
|
||||
async function readEthUsdFromContract(address: string, rpcUrl: string): Promise<number | undefined> {
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl);
|
||||
const contract = new ethers.Contract(address, ROUND_DATA_ABI, provider);
|
||||
const result = await contract.latestRoundData();
|
||||
const answer = Number(result.answer ?? result[1]);
|
||||
if (!Number.isFinite(answer) || answer <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return answer / 1e8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live ETH/USD from Chain 138 on-chain feeds (aggregator preferred, proxy fallback).
|
||||
*/
|
||||
export async function getChain138EthUsdOnChain(): Promise<{ priceUsd: number; source: 'aggregator' | 'proxy' } | undefined> {
|
||||
if (cachedEthUsd && cachedEthUsd.expiresAt > Date.now()) {
|
||||
return { priceUsd: cachedEthUsd.priceUsd, source: cachedEthUsd.source };
|
||||
}
|
||||
|
||||
const rpcUrl = readRpcUrl();
|
||||
const aggregator = readAggregatorAddress();
|
||||
const proxy = readProxyAddress();
|
||||
|
||||
for (const [address, source] of [[aggregator, 'aggregator'], [proxy, 'proxy']] as const) {
|
||||
try {
|
||||
const priceUsd = await readEthUsdFromContract(address, rpcUrl);
|
||||
if (priceUsd != null && priceUsd > 0) {
|
||||
cachedEthUsd = { priceUsd, expiresAt: Date.now() + 60_000, source };
|
||||
return { priceUsd, source };
|
||||
}
|
||||
} catch {
|
||||
// try next feed
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function clearChain138OnChainPriceCache(): void {
|
||||
cachedEthUsd = undefined;
|
||||
}
|
||||
|
||||
export async function refreshChain138LiveReferencePrices(chainId: number): Promise<void> {
|
||||
if (chainId !== 138) {
|
||||
return;
|
||||
}
|
||||
const eth = await getChain138EthUsdOnChain();
|
||||
if (eth) {
|
||||
primeLiveReferencePriceUsd('ETH', eth.priceUsd);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ const DEFAULT_BALANCER_WETH_USDT_POOL_ID = '0x877cd220759e8c94b82f55450c85d382ae
|
||||
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 DEFAULT_WETH_USD_PRICE = (() => {
|
||||
const fromEnv = Number(process.env.CHAIN138_CANONICAL_PRICE_USD_ETH || process.env.CANONICAL_PRICE_USD_ETH || '');
|
||||
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 1732;
|
||||
})();
|
||||
|
||||
const WETH = '0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'.toLowerCase();
|
||||
const USDT = '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1'.toLowerCase();
|
||||
|
||||
@@ -55,7 +55,11 @@ function getPilotConfig(): DodoV3PilotConfig {
|
||||
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,
|
||||
wethUsdPrice:
|
||||
normalizeNumber(process.env.CHAIN138_D3_PILOT_WETH_USD)?.valueOf() ||
|
||||
normalizeNumber(process.env.CHAIN138_CANONICAL_PRICE_USD_ETH)?.valueOf() ||
|
||||
normalizeNumber(process.env.CANONICAL_PRICE_USD_ETH)?.valueOf() ||
|
||||
1732,
|
||||
liquidityOverrideUsd: normalizeNumber(process.env.CHAIN138_D3_PILOT_TOTAL_LIQUIDITY_USD),
|
||||
};
|
||||
}
|
||||
|
||||
140
services/token-aggregation/src/services/iso4217-fx-refresh.ts
Normal file
140
services/token-aggregation/src/services/iso4217-fx-refresh.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import axios from 'axios';
|
||||
import { logger } from '../utils/logger';
|
||||
import { primeLiveReferencePriceUsd } from './canonical-price-oracle';
|
||||
|
||||
const FRANKFURTER_CURRENCIES = ['EUR', 'GBP', 'AUD', 'CAD', 'CHF', 'JPY'] as const;
|
||||
const COINGECKO_REFERENCE_IDS: Record<string, string> = {
|
||||
ETH: 'ethereum',
|
||||
BTC: 'bitcoin',
|
||||
LINK: 'chainlink',
|
||||
XAU: 'pax-gold',
|
||||
};
|
||||
|
||||
const COINGECKO_COMMODITY_IDS = {
|
||||
platinum: 'platinum',
|
||||
palladium: 'palladium',
|
||||
copper: 'copper',
|
||||
nickel: 'nickel',
|
||||
cobalt: 'cobalt',
|
||||
} as const;
|
||||
|
||||
let lastRefreshAt = 0;
|
||||
let refreshInFlight: Promise<void> | null = null;
|
||||
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function coingeckoBaseUrl(): string {
|
||||
return process.env.COINGECKO_API_KEY
|
||||
? 'https://pro-api.coingecko.com/api/v3'
|
||||
: 'https://api.coingecko.com/api/v3';
|
||||
}
|
||||
|
||||
function coingeckoHeaders(): Record<string, string> {
|
||||
const key = process.env.COINGECKO_API_KEY?.trim();
|
||||
return key ? { 'x-cg-pro-api-key': key } : {};
|
||||
}
|
||||
|
||||
async function fetchFrankfurterUsdPerUnit(currency: string): Promise<number | undefined> {
|
||||
try {
|
||||
const response = await axios.get<{ rates?: { USD?: number } }>(
|
||||
`https://api.frankfurter.app/latest?from=${encodeURIComponent(currency)}&to=USD`,
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
const usd = response.data?.rates?.USD;
|
||||
if (usd !== undefined && Number.isFinite(usd) && usd > 0) {
|
||||
return usd;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Frankfurter FX fetch failed for ${currency}:`, error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchCoingeckoUsd(coinId: string): Promise<number | undefined> {
|
||||
try {
|
||||
const response = await axios.get<Record<string, { usd?: number }>>(
|
||||
`${coingeckoBaseUrl()}/simple/price`,
|
||||
{
|
||||
timeout: 8000,
|
||||
headers: coingeckoHeaders(),
|
||||
params: {
|
||||
ids: coinId,
|
||||
vs_currencies: 'usd',
|
||||
},
|
||||
},
|
||||
);
|
||||
const usd = response.data?.[coinId]?.usd;
|
||||
if (usd !== undefined && Number.isFinite(usd) && usd > 0) {
|
||||
return usd;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`CoinGecko FX fetch failed for ${coinId}:`, error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Refresh live ISO-4217 USD reference rates used for c* / cW* valuation. */
|
||||
export async function refreshIso4217FxRates(ttlMs = DEFAULT_TTL_MS): Promise<void> {
|
||||
primeLiveReferencePriceUsd('USD', 1, ttlMs);
|
||||
|
||||
const commodityEntries = await Promise.all(
|
||||
Object.entries(COINGECKO_COMMODITY_IDS).map(async ([key, coinId]) => {
|
||||
const priceUsd = await fetchCoingeckoUsd(coinId);
|
||||
return [key, priceUsd] as const;
|
||||
}),
|
||||
);
|
||||
const commodities = Object.fromEntries(commodityEntries.filter(([, price]) => price != null)) as Partial<
|
||||
Record<keyof typeof COINGECKO_COMMODITY_IDS, number>
|
||||
>;
|
||||
|
||||
await Promise.all([
|
||||
...FRANKFURTER_CURRENCIES.map(async (currency) => {
|
||||
const priceUsd = await fetchFrankfurterUsdPerUnit(currency);
|
||||
if (priceUsd !== undefined) {
|
||||
primeLiveReferencePriceUsd(currency, priceUsd, ttlMs);
|
||||
}
|
||||
}),
|
||||
...Object.entries(COINGECKO_REFERENCE_IDS).map(async ([symbol, coinId]) => {
|
||||
const priceUsd = await fetchCoingeckoUsd(coinId);
|
||||
if (priceUsd !== undefined) {
|
||||
primeLiveReferencePriceUsd(symbol, priceUsd, ttlMs);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
if (commodities.platinum != null && commodities.palladium != null) {
|
||||
primeLiveReferencePriceUsd('LIPMG', (commodities.platinum + commodities.palladium) / 2, ttlMs);
|
||||
}
|
||||
if (commodities.copper != null) {
|
||||
primeLiveReferencePriceUsd('LIBMG1', commodities.copper, ttlMs);
|
||||
}
|
||||
if (commodities.nickel != null && commodities.cobalt != null) {
|
||||
primeLiveReferencePriceUsd('LIBMG2', commodities.nickel * 0.8 + commodities.cobalt * 0.2, ttlMs);
|
||||
} else if (commodities.nickel != null) {
|
||||
primeLiveReferencePriceUsd('LIBMG2', commodities.nickel, ttlMs);
|
||||
}
|
||||
|
||||
lastRefreshAt = Date.now();
|
||||
}
|
||||
|
||||
export async function ensureIso4217FxPrimed(ttlMs = DEFAULT_TTL_MS): Promise<void> {
|
||||
if (Date.now() - lastRefreshAt < ttlMs) {
|
||||
return;
|
||||
}
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = refreshIso4217FxRates(ttlMs).finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
await refreshInFlight;
|
||||
}
|
||||
|
||||
export function startIso4217FxScheduler(intervalMs = DEFAULT_TTL_MS): NodeJS.Timeout {
|
||||
void refreshIso4217FxRates(intervalMs);
|
||||
return setInterval(() => {
|
||||
void refreshIso4217FxRates(intervalMs);
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
export function getIso4217FxLastRefreshAt(): number {
|
||||
return lastRefreshAt;
|
||||
}
|
||||
118
services/token-aggregation/src/services/iso4217-spot-price.ts
Normal file
118
services/token-aggregation/src/services/iso4217-spot-price.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
|
||||
import { MarketDataRepository } from '../database/repositories/market-data-repo';
|
||||
import { getChain138EthUsdOnChain } from './chain138-onchain-reference-prices';
|
||||
import { resolveCanonicalPriceUsd } from './canonical-price-oracle';
|
||||
import { ensureIso4217FxPrimed } from './iso4217-fx-refresh';
|
||||
|
||||
const marketDataRepo = new MarketDataRepository();
|
||||
|
||||
export type Iso4217SpotPrice = {
|
||||
priceUsd?: number;
|
||||
symbol?: string;
|
||||
currencyCode?: string;
|
||||
referenceSymbol?: string;
|
||||
source: 'market-data' | 'iso4217-oracle' | 'native-oracle' | 'unresolved';
|
||||
};
|
||||
|
||||
const NATIVE_ETH_SENTINELS = new Set([
|
||||
'0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
||||
'0x0000000000000000000000000000000000000000',
|
||||
]);
|
||||
|
||||
const CHAIN138_ETH_USD_ORACLE_PROXY = '0x3304b747e565a97ec8ac220b0b6a1f6ffdb837e6';
|
||||
const CHAIN138_ETH_USD_ORACLE_AGGREGATOR = '0x99b3511a2d315a497c8112c1fdd8d508d4b1e506';
|
||||
const CHAIN138_WETH9 = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
||||
|
||||
function isChain138EthUsdPegToken(chainId: number, normalized: string, symbol?: string): boolean {
|
||||
if (chainId !== 138) return false;
|
||||
if (normalized === CHAIN138_WETH9) return true;
|
||||
const sym = symbol?.toUpperCase();
|
||||
return sym === 'WETH' || sym === 'WETH9' || sym === 'WETH10' || sym === 'CETH' || sym === 'CETHL2';
|
||||
}
|
||||
|
||||
async function resolveEthUsdFeedPrice(chainId: number, normalized: string, symbol?: string): Promise<Iso4217SpotPrice | undefined> {
|
||||
if (chainId !== 138) return undefined;
|
||||
const isFeed =
|
||||
normalized === CHAIN138_ETH_USD_ORACLE_PROXY ||
|
||||
normalized === CHAIN138_ETH_USD_ORACLE_AGGREGATOR ||
|
||||
symbol?.toUpperCase() === 'ETH-USD';
|
||||
if (!isFeed) return undefined;
|
||||
|
||||
const native = await getChain138EthUsdOnChain();
|
||||
if (native?.priceUsd && native.priceUsd > 0) {
|
||||
return {
|
||||
priceUsd: native.priceUsd,
|
||||
symbol: 'ETH-USD',
|
||||
currencyCode: 'USD',
|
||||
referenceSymbol: 'ETH',
|
||||
source: 'native-oracle',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveIso4217SpotPrice(chainId: number, address: string): Promise<Iso4217SpotPrice> {
|
||||
await ensureIso4217FxPrimed();
|
||||
|
||||
const normalized = address.trim().toLowerCase();
|
||||
if (NATIVE_ETH_SENTINELS.has(normalized)) {
|
||||
const native = chainId === 138 ? await getChain138EthUsdOnChain() : undefined;
|
||||
if (native?.priceUsd && native.priceUsd > 0) {
|
||||
return {
|
||||
priceUsd: native.priceUsd,
|
||||
symbol: 'ETH',
|
||||
currencyCode: 'ETH',
|
||||
referenceSymbol: 'ETH',
|
||||
source: 'native-oracle',
|
||||
};
|
||||
}
|
||||
const ethFallback = resolveCanonicalPriceUsd(chainId, normalized);
|
||||
return {
|
||||
priceUsd: ethFallback.priceUsd,
|
||||
symbol: 'ETH',
|
||||
currencyCode: 'ETH',
|
||||
referenceSymbol: 'ETH',
|
||||
source: ethFallback.priceUsd ? 'iso4217-oracle' : 'unresolved',
|
||||
};
|
||||
}
|
||||
|
||||
const spec = getCanonicalTokenByAddress(chainId, normalized);
|
||||
const feedPrice = await resolveEthUsdFeedPrice(chainId, normalized, spec?.symbol);
|
||||
if (feedPrice) {
|
||||
return feedPrice;
|
||||
}
|
||||
|
||||
if (isChain138EthUsdPegToken(chainId, normalized, spec?.symbol)) {
|
||||
const native = await getChain138EthUsdOnChain();
|
||||
if (native?.priceUsd && native.priceUsd > 0) {
|
||||
return {
|
||||
priceUsd: native.priceUsd,
|
||||
symbol: spec?.symbol ?? 'WETH',
|
||||
currencyCode: spec?.currencyCode ?? 'ETH',
|
||||
referenceSymbol: 'ETH',
|
||||
source: 'native-oracle',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, normalized);
|
||||
const canonical = resolveCanonicalPriceUsd(chainId, normalized);
|
||||
|
||||
const priceUsd =
|
||||
marketData?.priceUsd != null && marketData.priceUsd > 0
|
||||
? marketData.priceUsd
|
||||
: canonical.priceUsd;
|
||||
|
||||
return {
|
||||
priceUsd,
|
||||
symbol: spec?.symbol,
|
||||
currencyCode: spec?.currencyCode,
|
||||
referenceSymbol: canonical.referenceSymbol,
|
||||
source:
|
||||
marketData?.priceUsd != null && marketData.priceUsd > 0
|
||||
? 'market-data'
|
||||
: canonical.priceUsd != null
|
||||
? 'iso4217-oracle'
|
||||
: 'unresolved',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { buildIso4217TokenListExtensions, resolveCoingeckoPriceProxyId } from './iso4217-token-metadata';
|
||||
|
||||
describe('iso4217-token-metadata', () => {
|
||||
it('maps cUSDC to USD Cash proxy and cUSDT to USD Treasury proxy', () => {
|
||||
expect(resolveCoingeckoPriceProxyId({ symbol: 'cUSDC', currencyCode: 'USD' })).toBe('usd-coin');
|
||||
expect(resolveCoingeckoPriceProxyId({ symbol: 'cUSDT', currencyCode: 'USD' })).toBe('tether');
|
||||
});
|
||||
|
||||
it('builds ISO-4217 extensions for compliant fiat tokens', () => {
|
||||
const extensions = buildIso4217TokenListExtensions({
|
||||
symbol: 'cAUDC',
|
||||
currencyCode: 'AUD',
|
||||
type: 'base',
|
||||
name: 'Australian Dollar (Compliant)',
|
||||
});
|
||||
|
||||
expect(extensions.iso4217).toBe('AUD');
|
||||
expect(extensions.gruAssetClass).toBe('C');
|
||||
expect(extensions.gruLayer).toBe('c');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type CanonicalTokenSpec } from '../config/canonical-tokens';
|
||||
|
||||
/** CoinGecko IDs used only as USD valuation proxies for ISO-4217 c* eMoney (not issuer identity). */
|
||||
/** USD valuation proxies for CoinGecko / MetaMask submissions — not issuer identity. */
|
||||
const ISO4217_COINGECKO_PROXY: Record<string, string> = {
|
||||
USD: 'usd-coin',
|
||||
EUR: 'euro-coin',
|
||||
XAU: 'pax-gold',
|
||||
ETH: 'ethereum',
|
||||
BTC: 'bitcoin',
|
||||
LINK: 'chainlink',
|
||||
};
|
||||
|
||||
const USD_TREASURY_SYMBOLS = new Set(['CUSDT', 'CEURT', 'CGBPT', 'CXAUT']);
|
||||
|
||||
export function resolveIso4217AssetClassSuffix(symbol: string): 'C' | 'T' | undefined {
|
||||
const upper = symbol.trim().toUpperCase();
|
||||
if (upper.length < 2) return undefined;
|
||||
const suffix = upper.at(-1);
|
||||
if (suffix === 'C' || suffix === 'T') return suffix;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveCoingeckoPriceProxyId(spec: Pick<CanonicalTokenSpec, 'symbol' | 'currencyCode'>): string | undefined {
|
||||
const currency = String(spec.currencyCode || '').trim().toUpperCase();
|
||||
if (currency && ISO4217_COINGECKO_PROXY[currency]) {
|
||||
const upper = spec.symbol.trim().toUpperCase();
|
||||
if (currency === 'USD' && USD_TREASURY_SYMBOLS.has(upper)) {
|
||||
return 'tether';
|
||||
}
|
||||
return ISO4217_COINGECKO_PROXY[currency];
|
||||
}
|
||||
|
||||
if (spec.symbol.trim().toUpperCase() === 'LINK') return ISO4217_COINGECKO_PROXY.LINK;
|
||||
if (spec.symbol.trim().toUpperCase().includes('BTC')) return ISO4217_COINGECKO_PROXY.BTC;
|
||||
if (spec.symbol.trim().toUpperCase().includes('ETH')) return ISO4217_COINGECKO_PROXY.ETH;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildIso4217TokenListExtensions(
|
||||
spec: Pick<CanonicalTokenSpec, 'symbol' | 'currencyCode' | 'type' | 'name'>,
|
||||
): Record<string, unknown> {
|
||||
const currencyCode = String(spec.currencyCode || '').trim().toUpperCase() || undefined;
|
||||
const assetClass = resolveIso4217AssetClassSuffix(spec.symbol);
|
||||
const coingeckoPriceProxyId = resolveCoingeckoPriceProxyId(spec);
|
||||
|
||||
return {
|
||||
gruLayer: spec.type === 'w' ? 'cW' : 'c',
|
||||
iso4217: currencyCode,
|
||||
gruAssetClass: assetClass,
|
||||
gruAssetClassLabel:
|
||||
assetClass === 'C'
|
||||
? 'Cash (Tokenized M1)'
|
||||
: assetClass === 'T'
|
||||
? 'Treasury (Tokenized GILT or equivalent security)'
|
||||
: undefined,
|
||||
coingeckoPriceProxyId,
|
||||
pricingNote:
|
||||
'Native GRU Compliant eMoney — CoinGecko proxy is for USD display only, not third-party issuer identity.',
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function getOmnlApiCatalog(): {
|
||||
return {
|
||||
service: 'HYBX OMNL',
|
||||
basePath: '/api/v1',
|
||||
version: '1.4.0',
|
||||
version: '1.5.0',
|
||||
endpoints: [
|
||||
{ method: 'GET', path: '/omnl/openapi.json', description: 'OpenAPI 3.0 JSON (Swagger-compatible)', auth: 'none' },
|
||||
{ method: 'GET', path: '/omnl/catalog', description: 'This catalog (machine-readable)', auth: 'none' },
|
||||
@@ -23,6 +23,8 @@ export function getOmnlApiCatalog(): {
|
||||
{ method: 'GET', path: '/omnl/reconcile-anchor', description: 'SHA-256 of canonical IPSAS registry + journal matrix JSON', auth: 'OMNL_API_KEY when OMNL_REQUIRE_API_KEY=1' },
|
||||
{ method: 'GET', path: '/omnl/reconcile/triple-state', description: 'Fineract + on-chain + custodian reconcile', query: ['lineId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/disclosures/full', description: 'IFRS 7/9/13 + IAS 21/37 extracts', query: ['lineId'], auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/entities', description: 'Regulated entity registry (Fineract master data)', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/entities/:clientNumber', description: 'Single entity by client number', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'POST', path: '/omnl/iso20022/messages', description: 'Store ISO 20022 message (10y retention)', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/iso20022/messages', description: 'List stored ISO messages', auth: 'OMNL_API_KEY' },
|
||||
{ method: 'GET', path: '/omnl/roles/matrix', description: 'Fineract SoD roles matrix JSON', auth: 'OMNL_API_KEY' },
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export type OmnlEntitySummary = {
|
||||
clientNumber: number;
|
||||
accountNo: string;
|
||||
entityName: string;
|
||||
lei: string;
|
||||
source: 'fineract_master_data';
|
||||
};
|
||||
|
||||
function proxmoxRoot(): string {
|
||||
return (
|
||||
process.env.PROXMOX_ROOT?.trim() ||
|
||||
process.env.PHOENIX_REPO_ROOT?.trim() ||
|
||||
resolve(process.cwd(), '../../../..')
|
||||
);
|
||||
}
|
||||
|
||||
export function listOmnlEntities(): {
|
||||
source: string;
|
||||
count: number;
|
||||
entities: OmnlEntitySummary[];
|
||||
} {
|
||||
const path = resolve(
|
||||
proxmoxRoot(),
|
||||
'docs/04-configuration/mifos-omnl-central-bank/OMNL_ENTITY_MASTER_DATA.json'
|
||||
);
|
||||
if (!existsSync(path)) {
|
||||
return { source: path, count: 0, entities: [] };
|
||||
}
|
||||
const raw = JSON.parse(readFileSync(path, 'utf8')) as {
|
||||
entities?: Array<{
|
||||
clientNumber: number;
|
||||
accountNo: string;
|
||||
entityName: string;
|
||||
lei?: string;
|
||||
}>;
|
||||
};
|
||||
const entities = (raw.entities ?? []).map((e) => ({
|
||||
clientNumber: e.clientNumber,
|
||||
accountNo: e.accountNo,
|
||||
entityName: e.entityName,
|
||||
lei: e.lei ?? '',
|
||||
source: 'fineract_master_data' as const,
|
||||
}));
|
||||
return { source: path, count: entities.length, entities };
|
||||
}
|
||||
|
||||
export function getOmnlEntity(clientNumber: number): OmnlEntitySummary | null {
|
||||
const { entities } = listOmnlEntities();
|
||||
return entities.find((e) => e.clientNumber === clientNumber) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PassthroughSchemaValidator } from '@dbis/integration-foundation';
|
||||
|
||||
const validator = new PassthroughSchemaValidator();
|
||||
|
||||
export async function validateIso20022Payload(
|
||||
messageType: string,
|
||||
payload: string
|
||||
): Promise<{ valid: boolean; errors: string[] }> {
|
||||
return validator.validate(payload, messageType);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MAX_SINGLE_POOL_TVL_USD, sanePoolTvlUsd } from './token-visible-liquidity';
|
||||
|
||||
describe('sanePoolTvlUsd', () => {
|
||||
it('keeps canonical Chain 138 live pool TVL even when very large', () => {
|
||||
expect(
|
||||
sanePoolTvlUsd(138, '0x9e89bae009adf128782e19e8341996c596ac40dc', 33_200_672),
|
||||
).toBe(33_200_672);
|
||||
});
|
||||
|
||||
it('drops mis-normalized single-pool TVL above the sanity cap', () => {
|
||||
expect(
|
||||
sanePoolTvlUsd(138, '0xaae68830a55767722618e869882c6ed064cc1eb2', 51_558_997_888),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('allows reasonable non-canonical pool TVL', () => {
|
||||
expect(
|
||||
sanePoolTvlUsd(138, '0xb53a0508940b1ff90f1aad4f6cb50a7012fe5593', 10_104_786),
|
||||
).toBe(10_104_786);
|
||||
});
|
||||
|
||||
it('exports a 500M sanity cap', () => {
|
||||
expect(MAX_SINGLE_POOL_TVL_USD).toBe(500_000_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { formatUnits } from 'ethers';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { CHAIN138_CANONICAL_LIVE_POOLS, isChain138CanonicalLivePool } from '../config/chain138-live-dodo-pools';
|
||||
import { filterPoolsForExposure } from '../config/gru-transport';
|
||||
import { getGruV2DeploymentPoolRows } from '../config/gru-v2-deployment-pools';
|
||||
import { resolveCanonicalQuoteAddress } from '../config/canonical-tokens';
|
||||
import { PoolRepository, LiquidityPool } from '../database/repositories/pool-repo';
|
||||
import { getLiveDodoPools } from './live-dodo-fallback';
|
||||
import { pmmVaultReserveFromChain, resolvePmmQuoteRpcUrl } from './pmm-onchain-quote';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const poolRepo = new PoolRepository();
|
||||
const MAX_SINGLE_POOL_TVL_USD = 500_000_000;
|
||||
const LIVE_UNISWAP_CATALOG_REL = 'config/live-uniswap-v2-pool-catalog.json';
|
||||
|
||||
interface LiveUniswapCatalogRow {
|
||||
chainId: number;
|
||||
poolAddress: string;
|
||||
baseAddress: string;
|
||||
quoteAddress: string;
|
||||
totalLiquidityUsd?: number;
|
||||
}
|
||||
|
||||
function loadLiveUniswapCatalogRows(): LiveUniswapCatalogRow[] {
|
||||
const configured = process.env.TOKEN_AGGREGATION_LIVE_UNISWAP_V2_POOL_CATALOG_JSON?.trim();
|
||||
const roots = [
|
||||
configured,
|
||||
path.join(process.cwd(), LIVE_UNISWAP_CATALOG_REL),
|
||||
path.join(process.cwd(), 'smom-dbis-138', 'services', 'token-aggregation', LIVE_UNISWAP_CATALOG_REL),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const candidate of roots) {
|
||||
if (!existsSync(candidate)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(candidate, 'utf8')) as { pools?: LiveUniswapCatalogRow[] };
|
||||
return (parsed.pools ?? []).filter(
|
||||
(row) =>
|
||||
Number.isFinite(row.chainId) &&
|
||||
row.poolAddress?.startsWith('0x') &&
|
||||
row.baseAddress?.startsWith('0x') &&
|
||||
row.quoteAddress?.startsWith('0x'),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('token-visible-liquidity: unable to parse UniV2 catalog', { candidate, error });
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function matchesTokenAddress(
|
||||
pool: { token0Address: string; token1Address: string },
|
||||
lookupAddress: string,
|
||||
normalizedAddress: string,
|
||||
): boolean {
|
||||
return (
|
||||
pool.token0Address === lookupAddress ||
|
||||
pool.token1Address === lookupAddress ||
|
||||
pool.token0Address === normalizedAddress ||
|
||||
pool.token1Address === normalizedAddress
|
||||
);
|
||||
}
|
||||
|
||||
function sanePoolTvlUsd(chainId: number, poolAddress: string, totalLiquidityUsd?: number): number {
|
||||
const tvl = Number(totalLiquidityUsd);
|
||||
if (!Number.isFinite(tvl) || tvl <= 0) return 0;
|
||||
if (chainId === 138 && isChain138CanonicalLivePool(poolAddress)) return tvl;
|
||||
if (tvl > MAX_SINGLE_POOL_TVL_USD) return 0;
|
||||
return tvl;
|
||||
}
|
||||
|
||||
function mergePoolsByAddress(existing: Map<string, LiquidityPool>, pools: LiquidityPool[]): void {
|
||||
for (const pool of pools) {
|
||||
const key = pool.poolAddress.toLowerCase();
|
||||
const prior = existing.get(key);
|
||||
const nextTvl = sanePoolTvlUsd(pool.chainId, pool.poolAddress, pool.totalLiquidityUsd);
|
||||
const priorTvl = prior ? sanePoolTvlUsd(prior.chainId, prior.poolAddress, prior.totalLiquidityUsd) : 0;
|
||||
if (!prior || nextTvl > priorTvl) {
|
||||
existing.set(key, { ...pool, totalLiquidityUsd: nextTvl });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectVisiblePools(chainId: number, address: string): Promise<LiquidityPool[]> {
|
||||
const normalized = address.toLowerCase();
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, normalized);
|
||||
const merged = new Map<string, LiquidityPool>();
|
||||
|
||||
try {
|
||||
const dbPools = filterPoolsForExposure(
|
||||
chainId,
|
||||
await poolRepo.getPoolsByToken(chainId, resolution.lookupAddress),
|
||||
).filter((pool) => matchesTokenAddress(pool, resolution.lookupAddress, normalized));
|
||||
mergePoolsByAddress(merged, dbPools);
|
||||
} catch (error) {
|
||||
logger.warn('token-visible-liquidity: DB pool lookup failed', {
|
||||
chainId,
|
||||
address: resolution.lookupAddress,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const livePools = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId)).filter((pool) =>
|
||||
matchesTokenAddress(pool, resolution.lookupAddress, normalized),
|
||||
);
|
||||
mergePoolsByAddress(merged, livePools);
|
||||
} catch (error) {
|
||||
logger.warn('token-visible-liquidity: live DODO lookup failed', { chainId, address: normalized, error });
|
||||
}
|
||||
|
||||
if (chainId === 138 && merged.size === 0) {
|
||||
const canonicalOnly = filterPoolsForExposure(chainId, await getLiveDodoPools(chainId)).filter(
|
||||
(pool) =>
|
||||
CHAIN138_CANONICAL_LIVE_POOLS.some((live) => live.toLowerCase() === pool.poolAddress.toLowerCase()) &&
|
||||
matchesTokenAddress(pool, resolution.lookupAddress, normalized),
|
||||
);
|
||||
mergePoolsByAddress(merged, canonicalOnly);
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function sumLiveUniswapCatalogLiquidityUsd(chainId: number, tokenAddress: string): number {
|
||||
const token = tokenAddress.toLowerCase();
|
||||
return loadLiveUniswapCatalogRows()
|
||||
.filter(
|
||||
(row) =>
|
||||
row.chainId === chainId &&
|
||||
(row.baseAddress.toLowerCase() === token || row.quoteAddress.toLowerCase() === token),
|
||||
)
|
||||
.reduce((sum, row) => {
|
||||
const tvl = sanePoolTvlUsd(chainId, row.poolAddress, row.totalLiquidityUsd);
|
||||
return sum + tvl;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function sumPoolTvl(chainId: number, pools: LiquidityPool[]): number {
|
||||
return pools.reduce(
|
||||
(sum, pool) => sum + sanePoolTvlUsd(chainId, pool.poolAddress, pool.totalLiquidityUsd),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackPoolUsdPrice(symbol?: string): number {
|
||||
const normalized = String(symbol || '').toUpperCase();
|
||||
if (normalized.includes('XAU')) return Number(process.env.TOKEN_AGGREGATION_XAU_USD || '0') || 0;
|
||||
if (normalized === 'WETH' || normalized === 'ETH') return Number(process.env.TOKEN_AGGREGATION_ETH_USD || '0') || 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function fallbackPoolDecimals(symbol?: string): number {
|
||||
const normalized = String(symbol || '').toUpperCase();
|
||||
if (normalized === 'WETH' || normalized === 'ETH' || normalized === 'LINK') return 18;
|
||||
return 6;
|
||||
}
|
||||
|
||||
async function sumGruV2DeploymentLiquidityUsd(chainId: number, tokenAddress: string): Promise<number> {
|
||||
if (chainId !== 138) return 0;
|
||||
const token = tokenAddress.toLowerCase();
|
||||
const rpcUrl = resolvePmmQuoteRpcUrl();
|
||||
if (!rpcUrl) return 0;
|
||||
|
||||
const rows = getGruV2DeploymentPoolRows().filter(
|
||||
(pool) =>
|
||||
pool.chainId === chainId &&
|
||||
(pool.baseAddress.toLowerCase() === token || pool.quoteAddress.toLowerCase() === token),
|
||||
);
|
||||
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const reserves = await pmmVaultReserveFromChain({ rpcUrl, poolAddress: row.poolAddress });
|
||||
if (!reserves) continue;
|
||||
const baseUnits = Number(formatUnits(reserves.baseReserveRaw, fallbackPoolDecimals(row.baseSymbol)));
|
||||
const quoteUnits = Number(formatUnits(reserves.quoteReserveRaw, fallbackPoolDecimals(row.quoteSymbol)));
|
||||
if (!Number.isFinite(baseUnits) || !Number.isFinite(quoteUnits)) continue;
|
||||
const reserveUsd =
|
||||
baseUnits * fallbackPoolUsdPrice(row.baseSymbol) + quoteUnits * fallbackPoolUsdPrice(row.quoteSymbol);
|
||||
const saneReserveUsd = sanePoolTvlUsd(chainId, row.poolAddress, reserveUsd);
|
||||
if (Number.isFinite(saneReserveUsd) && saneReserveUsd > 0) {
|
||||
total += saneReserveUsd;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Best-effort visible on-chain / indexed liquidity for wallet and market-batch surfaces. */
|
||||
export async function resolveVisibleTokenLiquidityUsd(chainId: number, address: string): Promise<number> {
|
||||
const normalized = address.toLowerCase();
|
||||
const pools = await collectVisiblePools(chainId, normalized);
|
||||
|
||||
if (chainId === 138) {
|
||||
const canonicalLiveTvl = pools
|
||||
.filter((pool) => isChain138CanonicalLivePool(pool.poolAddress))
|
||||
.reduce(
|
||||
(sum, pool) => sum + sanePoolTvlUsd(chainId, pool.poolAddress, pool.totalLiquidityUsd),
|
||||
0,
|
||||
);
|
||||
const gruTvl = await sumGruV2DeploymentLiquidityUsd(chainId, normalized);
|
||||
const catalogTvl = sumLiveUniswapCatalogLiquidityUsd(chainId, normalized);
|
||||
const canonicalFirst = Math.max(canonicalLiveTvl, gruTvl, catalogTvl);
|
||||
if (canonicalFirst > 0) {
|
||||
return canonicalFirst;
|
||||
}
|
||||
}
|
||||
|
||||
const indexedTvl = sumPoolTvl(chainId, pools);
|
||||
const catalogTvl = sumLiveUniswapCatalogLiquidityUsd(chainId, normalized);
|
||||
const gruTvl = await sumGruV2DeploymentLiquidityUsd(chainId, normalized);
|
||||
return Math.max(indexedTvl, catalogTvl, gruTvl);
|
||||
}
|
||||
|
||||
export { sanePoolTvlUsd, MAX_SINGLE_POOL_TVL_USD };
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
resolveUsdValuation,
|
||||
mergeMarketWithValuation,
|
||||
} from './valuation-precedence';
|
||||
import { primeLiveReferencePriceUsd } from './canonical-price-oracle';
|
||||
|
||||
const CHAIN138_WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
||||
|
||||
/** Bridged `cW*` on Ethereum; on Chain 138 the native form is `cUSDC` (`c*`), not `cW*`. */
|
||||
const ETH_MAINNET_CWUSDC = '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a';
|
||||
@@ -185,13 +188,14 @@ describe('valuation-precedence', () => {
|
||||
it('skips fresh indexer when it only mirrors repo-fallback FX and CoinGecko is available', () => {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '3600';
|
||||
const lu = new Date();
|
||||
const cxauc = '0x290e52a8819a4fbD0714E517225429aA2B70EC6b';
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 138,
|
||||
normalizedAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
normalizedAddress: cxauc,
|
||||
indexer: {
|
||||
chainId: 138,
|
||||
tokenAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
|
||||
priceUsd: 2490,
|
||||
tokenAddress: cxauc,
|
||||
priceUsd: 5163.3401260328355,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
@@ -207,6 +211,35 @@ describe('valuation-precedence', () => {
|
||||
expect(v.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers live Chain 138 native oracle over stale PMM indexer when ETH drifts', () => {
|
||||
process.env.TOKEN_AGG_PRICE_INDEXER_MAX_AGE_SECONDS = '3600';
|
||||
process.env.CHAIN138_CANONICAL_PRICE_USD_ETH = '2100';
|
||||
primeLiveReferencePriceUsd('ETH', 1772);
|
||||
|
||||
const lu = new Date();
|
||||
const v = resolveUsdValuation({
|
||||
chainId: 138,
|
||||
normalizedAddress: CHAIN138_WETH,
|
||||
indexer: {
|
||||
chainId: 138,
|
||||
tokenAddress: CHAIN138_WETH,
|
||||
priceUsd: 2100,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 10_000_000,
|
||||
holdersCount: 0,
|
||||
transfers24h: 0,
|
||||
lastUpdated: lu,
|
||||
},
|
||||
coingecko: { priceUsd: 1760, lastUpdated: new Date() },
|
||||
});
|
||||
|
||||
expect(v.sourceLayer).toBe('native_oracle');
|
||||
expect(v.priceUsd).toBe(1772);
|
||||
expect(v.precedenceRank).toBe(0);
|
||||
});
|
||||
|
||||
it('mergeMarketWithValuation applies priced layer onto indexer row', () => {
|
||||
const pricing = resolveUsdValuation({
|
||||
chainId: 138,
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { MarketData } from '../adapters/base-adapter';
|
||||
import type { TokenMarketData } from '../database/repositories/token-repo';
|
||||
import { getChainConfig } from '../config/chains';
|
||||
import { getCanonicalTokenByAddress } from '../config/canonical-tokens';
|
||||
import { resolveCanonicalPriceUsd, type CanonicalPriceResolution } from './canonical-price-oracle';
|
||||
import {
|
||||
ethUsdOracleDriftBps,
|
||||
readChain138EthOracleDriftBps,
|
||||
resolveCanonicalPriceUsd,
|
||||
type CanonicalPriceResolution,
|
||||
} from './canonical-price-oracle';
|
||||
|
||||
const CHAIN_138 = 138;
|
||||
|
||||
@@ -29,6 +34,7 @@ export function isGruV2CwMeshEdgeToken(chainId: number, normalizedAddress: strin
|
||||
}
|
||||
|
||||
export type PriceSourceLayer =
|
||||
| 'native_oracle'
|
||||
| 'indexer_market'
|
||||
| 'external_coingecko'
|
||||
| 'external_coinmarketcap'
|
||||
@@ -60,7 +66,7 @@ export interface TokenPricing {
|
||||
ageSeconds?: number;
|
||||
indexerLastUpdated?: string;
|
||||
referenceSymbol?: string;
|
||||
canonicalResolutionSource?: 'env' | 'repo-fallback' | 'unresolved';
|
||||
canonicalResolutionSource?: 'env' | 'on-chain' | 'repo-fallback' | 'unresolved';
|
||||
/** Bridged `cW*` on an edge chain used DexScreener-priority ordering (native `c*` on 138 never sets this). */
|
||||
gruV2CwEdgeDexPriority?: boolean;
|
||||
}
|
||||
@@ -129,10 +135,34 @@ export function resolveUsdValuation(input: ValuationInput): TokenPricing {
|
||||
|
||||
const out: Candidate[] = [];
|
||||
|
||||
const onChainEthUsd =
|
||||
chainId === CHAIN_138 &&
|
||||
referenceSymbol === 'ETH' &&
|
||||
canonical.source === 'on-chain' &&
|
||||
canonical.priceUsd !== undefined
|
||||
? canonical.priceUsd
|
||||
: undefined;
|
||||
|
||||
if (onChainEthUsd !== undefined) {
|
||||
out.push({
|
||||
layer: 'native_oracle',
|
||||
rank: 0,
|
||||
priceUsd: onChainEthUsd,
|
||||
stale: false,
|
||||
asOf: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
if (indexer?.priceUsd !== undefined && indexer.priceUsd !== null) {
|
||||
const lu = indexer.lastUpdated instanceof Date ? indexer.lastUpdated : new Date(indexer.lastUpdated);
|
||||
const stale =
|
||||
let stale =
|
||||
ageMsOf(lu) > maxAgeSeconds * 1000 || indexerMatchesRepoFallbackSnapshot(indexer, canonical);
|
||||
if (
|
||||
onChainEthUsd !== undefined &&
|
||||
ethUsdOracleDriftBps(indexer.priceUsd, onChainEthUsd) > readChain138EthOracleDriftBps()
|
||||
) {
|
||||
stale = true;
|
||||
}
|
||||
out.push({
|
||||
layer: 'indexer_market',
|
||||
rank: 1,
|
||||
|
||||
Reference in New Issue
Block a user