From b6ddd236e22de887d96cefff53881abb5c061567 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sat, 6 Jun 2026 10:54:12 -0700 Subject: [PATCH] fix(token-aggregation): emit Uniswap-schema token lists for mainnet cWUSDC Normalize /report/token-list to checksummed addresses, version objects, and extensions bag; add pinned static ethereum-mainnet list endpoint. Co-authored-by: Cursor --- .../ethereum-mainnet.tokenlist.json | 104 ++++++++++ .../src/api/routes/report.test.ts | 72 +++++-- .../src/api/routes/report.ts | 183 ++++++++++++------ services/token-aggregation/src/api/server.ts | 1 + .../src/api/utils/uniswap-token-list.test.ts | 76 ++++++++ .../src/api/utils/uniswap-token-list.ts | 119 ++++++++++++ 6 files changed, 486 insertions(+), 69 deletions(-) create mode 100644 services/token-aggregation/config/token-lists/ethereum-mainnet.tokenlist.json create mode 100644 services/token-aggregation/src/api/utils/uniswap-token-list.test.ts create mode 100644 services/token-aggregation/src/api/utils/uniswap-token-list.ts diff --git a/services/token-aggregation/config/token-lists/ethereum-mainnet.tokenlist.json b/services/token-aggregation/config/token-lists/ethereum-mainnet.tokenlist.json new file mode 100644 index 0000000..7bd53eb --- /dev/null +++ b/services/token-aggregation/config/token-lists/ethereum-mainnet.tokenlist.json @@ -0,0 +1,104 @@ +{ + "name": "DBIS Ethereum Mainnet GRU", + "version": { + "major": 1, + "minor": 1, + "patch": 0 + }, + "timestamp": "2026-06-06T00:00:00.000Z", + "logoURI": "https://d-bis.org/tokens/cwusdc.svg", + "keywords": [ + "ethereum", + "mainnet", + "stablecoin", + "gru", + "dbis", + "cwusdc" + ], + "tokens": [ + { + "chainId": 1, + "address": "0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a", + "name": "Wrapped cUSDC", + "symbol": "cWUSDC", + "decimals": 6, + "logoURI": "https://d-bis.org/tokens/cwusdc.svg", + "tags": [ + "stablecoin", + "defi", + "compliant", + "fiat", + "cash", + "gru", + "wrapped" + ], + "extensions": { + "category": "tokenized-fiat", + "instrument": "emoney-wrapped-transport", + "currency": "USD", + "settlement": "fiat", + "cashLike": true, + "backing": "cash,cash-equivalents,gru-reserve-policy", + "gruVersion": "v1", + "gruFamily": "cUSDC", + "canonicalSourceChainId": 138, + "canonicalSourceAddress": "0xf22258f57794CC8E06237084b353Ab30fFfa640b" + } + }, + { + "chainId": 1, + "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "name": "Tether USD", + "symbol": "USDT", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + "tags": [ + "stablecoin", + "defi", + "fiat", + "cash" + ], + "extensions": { + "category": "tokenized-fiat", + "instrument": "fiat-backed-stablecoin", + "currency": "USD", + "settlement": "fiat", + "cashLike": true, + "backing": "cash,cash-equivalents", + "x402Ready": false, + "fwdCanon": false, + "walletClass": "cash-like-token" + } + } + ], + "tags": { + "stablecoin": { + "name": "Stablecoin", + "description": "Stable value tokens pegged to fiat" + }, + "defi": { + "name": "DeFi", + "description": "Decentralized Finance tokens" + }, + "compliant": { + "name": "Compliant", + "description": "DBIS GRU compliant eMoney transport assets" + }, + "fiat": { + "name": "Fiat", + "description": "Fiat referenced tokens" + }, + "cash": { + "name": "Cashlike", + "description": "Cash reserve or cash rail assets" + }, + "gru": { + "name": "GRU", + "description": "Global Reserve Unit family assets" + }, + "wrapped": { + "name": "Wrapped", + "description": "Public network wrapped transport mirrors of hub assets" + } + } +} diff --git a/services/token-aggregation/src/api/routes/report.test.ts b/services/token-aggregation/src/api/routes/report.test.ts index be11267..11303aa 100644 --- a/services/token-aggregation/src/api/routes/report.test.ts +++ b/services/token-aggregation/src/api/routes/report.test.ts @@ -482,9 +482,11 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cUSDT_V2', chainId: 138, - familySymbol: 'cUSDT', - deploymentVersion: 'v2', - preferredForX402: true, + extensions: expect.objectContaining({ + familySymbol: 'cUSDT', + deploymentVersion: 'v2', + preferredForX402: true, + }), }), expect.objectContaining({ symbol: 'cUSDC', @@ -493,9 +495,11 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cUSDC_V2', chainId: 138, - familySymbol: 'cUSDC', - deploymentVersion: 'v2', - preferredForX402: true, + extensions: expect.objectContaining({ + familySymbol: 'cUSDC', + deploymentVersion: 'v2', + preferredForX402: true, + }), }), ]) ); @@ -598,7 +602,9 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cBTC', chainId: 138, - registryFamily: 'monetary_unit', + extensions: expect.objectContaining({ + registryFamily: 'monetary_unit', + }), }), ]) ); @@ -611,7 +617,9 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cWBTC', chainId: 1, - registryFamily: 'monetary_unit', + extensions: expect.objectContaining({ + registryFamily: 'monetary_unit', + }), }), ]) ); @@ -626,12 +634,16 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cETH', chainId: 138, - registryFamily: 'gas_native', + extensions: expect.objectContaining({ + registryFamily: 'gas_native', + }), }), expect.objectContaining({ symbol: 'cETHL2', chainId: 138, - registryFamily: 'gas_native', + extensions: expect.objectContaining({ + registryFamily: 'gas_native', + }), }), ]) ); @@ -644,7 +656,9 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cWETHL2', chainId: 10, - registryFamily: 'gas_native', + extensions: expect.objectContaining({ + registryFamily: 'gas_native', + }), }), ]) ); @@ -657,7 +671,9 @@ describe('Report API', () => { expect.objectContaining({ symbol: 'cWETH', chainId: 1, - registryFamily: 'gas_native', + extensions: expect.objectContaining({ + registryFamily: 'gas_native', + }), }), ]) ); @@ -669,10 +685,40 @@ describe('Report API', () => { const body = (await res.json()) as Record; const cwusdc = body.tokens.find((token: Record) => token.symbol === 'cWUSDC'); expect(cwusdc).toMatchObject({ + name: 'Wrapped cUSDC', + address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', logoURI: expect.stringMatching(/^https:\/\/127\.0\.0\.1:\d+\/api\/v1\/report\/logo\/cUSDC$/), - originalLogoURI: expect.stringContaining('/token-lists/logos/gru/cUSDC.svg'), + extensions: expect.objectContaining({ + originalLogoURI: expect.stringContaining('/token-lists/logos/gru/cUSDC.svg'), + }), }); }); + + it('returns Uniswap token list schema fields for mainnet lists', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/token-list?chainId=1`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.name).toBe('DBIS Ethereum Mainnet GRU'); + expect(body.version).toEqual({ major: 1, minor: 0, patch: 0 }); + expect(body.tokens[0]).not.toHaveProperty('type'); + }); + + it('serves the pinned static ethereum-mainnet token list', async () => { + const res = await fetch(`${baseUrl}/api/v1/report/token-list/static/ethereum-mainnet`); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.name).toBe('DBIS Ethereum Mainnet GRU'); + expect(body.version).toEqual({ major: 1, minor: 1, patch: 0 }); + expect(body.tokens).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + symbol: 'cWUSDC', + name: 'Wrapped cUSDC', + address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', + }), + ]) + ); + }); }); describe('GET /api/v1/report/cw-registry', () => { diff --git a/services/token-aggregation/src/api/routes/report.ts b/services/token-aggregation/src/api/routes/report.ts index b90715f..f7d75c7 100644 --- a/services/token-aggregation/src/api/routes/report.ts +++ b/services/token-aggregation/src/api/routes/report.ts @@ -38,6 +38,11 @@ import { import { getGruV2DeploymentPoolRows } from '../../config/gru-v2-deployment-pools'; import { getCanonicalPriceSnapshotGeneratedAt, getCanonicalPriceUsd } from '../../services/canonical-price-oracle'; import { pmmVaultReserveFromChain, resolvePmmQuoteRpcUrl } from '../../services/pmm-onchain-quote'; +import { + buildUniswapTokenList, + MAINNET_PUBLIC_DISPLAY_NAMES, + resolveTokenListName, +} from '../utils/uniswap-token-list'; const router: Router = Router(); const tokenRepo = new TokenRepository(); @@ -628,6 +633,70 @@ function absoluteReportLogoUri(remoteLogoUri: string, symbol: string): string { return localLogoURI ?? remoteLogoUri; } +function resolveEthereumMainnetTokenListPath(): string | null { + const candidates = [ + process.env.ETHEREUM_MAINNET_TOKEN_LIST_JSON_PATH?.trim(), + path.join(__dirname, '../../../config/token-lists/ethereum-mainnet.tokenlist.json'), + path.join(process.cwd(), 'config/token-lists/ethereum-mainnet.tokenlist.json'), + ].filter((value): value is string => Boolean(value)); + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function loadStaticEthereumMainnetTokenList(): Record | null { + const filePath = resolveEthereumMainnetTokenListPath(); + if (!filePath) return null; + try { + return JSON.parse(readFileSync(filePath, 'utf8')) as Record; + } catch (error) { + logger.error('Failed to read static ethereum-mainnet token list:', error); + return null; + } +} + +function finalizeUniswapTokenListResponse( + req: Request, + res: Response, + payload: Record, + chainIds: number[] +): void { + const logoURI = absolutePublicUrl(req, typeof payload.logoURI === 'string' ? payload.logoURI : undefined); + const tokens = Array.isArray(payload.tokens) + ? payload.tokens.map((token) => { + const record = token as Record; + return { + ...record, + logoURI: + absolutePublicUrl(req, typeof record.logoURI === 'string' ? record.logoURI : undefined) ?? + record.logoURI, + }; + }) + : []; + + const displayNames = chainIds.length === 1 && chainIds[0] === 1 ? MAINNET_PUBLIC_DISPLAY_NAMES : undefined; + const body = buildUniswapTokenList({ + name: resolveTokenListName( + chainIds, + typeof payload.name === 'string' ? payload.name : 'GRU Canonical Token List' + ), + version: payload.version, + timestamp: typeof payload.timestamp === 'string' ? payload.timestamp : new Date().toISOString(), + logoURI, + tokens, + keywords: Array.isArray(payload.keywords) ? (payload.keywords as string[]) : undefined, + tags: + payload.tags && typeof payload.tags === 'object' && !Array.isArray(payload.tags) + ? (payload.tags as Record) + : undefined, + displayNames, + }); + + res.json(body); +} + /** Build token entries with DB market/pool data for a chain */ async function buildTokenReport(chainId: number) { const canonical = getCanonicalTokensByChain(chainId); @@ -1511,7 +1580,25 @@ router.get( } ); -/** GET /report/token-list — flat list of all canonical tokens (Uniswap token list format with logoURI). +/** GET /report/token-list/static/ethereum-mainnet — pinned Uniswap-schema list for tokenlists.org submissions. */ +router.get( + '/token-list/static/ethereum-mainnet', + cacheMiddleware(5 * 60 * 1000), + (req: Request, res: Response) => { + try { + const payload = loadStaticEthereumMainnetTokenList(); + if (!payload) { + return res.status(404).json({ error: 'Static ethereum-mainnet token list not found' }); + } + return finalizeUniswapTokenListResponse(req, res, payload, [1]); + } catch (error) { + logger.error('Error serving static ethereum-mainnet token list:', error); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +/** GET /report/token-list — Uniswap token list schema (version object, checksummed addresses, extensions bag). * If TOKEN_LIST_JSON_URL is set (e.g. GitHub raw URL), fetches and returns that JSON; optional ?chainId= filters tokens. */ router.get( @@ -1519,58 +1606,37 @@ router.get( cacheMiddleware(5 * 60 * 1000), async (req: Request, res: Response) => { try { - const tokenListUrl = process.env.TOKEN_LIST_JSON_URL?.trim(); - if (tokenListUrl) { - try { - const data = (await fetchRemoteJson(tokenListUrl)) as { - name?: string; - version?: string; - timestamp?: string; - logoURI?: string; - tokens?: Array<{ chainId?: number; address?: string; symbol?: string; name?: string; decimals?: number; [key: string]: unknown }>; - }; - const chainIdParam = req.query.chainId as string | undefined; - const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null; - let tokens = Array.isArray(data.tokens) ? data.tokens : []; - if (!isNaN(chainIdFilter as number)) { - tokens = tokens.filter((t) => t.chainId === chainIdFilter); - } - const normalizedTokens = tokens.map((token) => ({ - ...token, - logoURI: absolutePublicUrl(req, typeof token.logoURI === 'string' ? token.logoURI : undefined) ?? token.logoURI, - })); - return res.json({ - name: data.name ?? 'Token List', - version: data.version ?? '1.0.0', - timestamp: data.timestamp ?? new Date().toISOString(), - logoURI: absolutePublicUrl(req, data.logoURI) ?? data.logoURI, - tokens: normalizedTokens, - }); - } catch (err) { - logger.error('TOKEN_LIST_JSON_URL fetch failed, using built-in token list:', err); - } - } - const chainIdParam = req.query.chainId as string | undefined; const chainIds = chainIdParam ? [parseInt(chainIdParam, 10)].filter((n) => !isNaN(n)) : getSupportedChainIds(); - const list: Array<{ - chainId: number; - address: string; - symbol: string; - name: string; - decimals: number; - type: string; - logoURI: string; - originalLogoURI?: string; - registryFamily?: string; - familySymbol?: string; - deploymentVersion?: string; - deploymentStatus?: string; - preferredForX402?: boolean; - }> = []; + const tokenListUrl = process.env.TOKEN_LIST_JSON_URL?.trim(); + if (tokenListUrl) { + try { + const data = (await fetchRemoteJson(tokenListUrl)) as Record; + let tokens = Array.isArray(data.tokens) ? data.tokens : []; + if (chainIds.length === 1) { + tokens = tokens.filter((token) => { + const record = token as { chainId?: number }; + return record.chainId === chainIds[0]; + }); + } + return finalizeUniswapTokenListResponse( + req, + res, + { + ...data, + tokens, + }, + chainIds + ); + } catch (err) { + logger.error('TOKEN_LIST_JSON_URL fetch failed, using built-in token list:', err); + } + } + + const list: Array> = []; for (const chainId of chainIds) { const specs = getCanonicalTokensByChain(chainId); @@ -1580,7 +1646,7 @@ router.get( const originalLogoURI = getLogoUriForSpec(spec); list.push({ chainId, - address: address.toLowerCase(), + address, symbol: spec.symbol, name: spec.name, decimals: spec.decimals, @@ -1597,13 +1663,18 @@ router.get( } } - res.json({ - name: 'GRU Canonical Token List', - version: '1.0.0', - timestamp: new Date().toISOString(), - logoURI: absolutePublicUrl(req, DBIS_CHAIN_138_LOGO_PATH), - tokens: list, - }); + return finalizeUniswapTokenListResponse( + req, + res, + { + name: 'GRU Canonical Token List', + version: { major: 1, minor: 0, patch: 0 }, + timestamp: new Date().toISOString(), + logoURI: absolutePublicUrl(req, DBIS_CHAIN_138_LOGO_PATH), + tokens: list, + }, + chainIds + ); } catch (error) { logger.error('Error building report/token-list:', error); res.status(500).json({ error: 'Internal server error' }); diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index 8d4525f..c4d4669 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -203,6 +203,7 @@ export class ApiServer { tokenMappingPairs: '/api/v1/token-mapping/pairs', tokenMappingResolve: '/api/v1/token-mapping/resolve', reportTokenList: '/api/v1/report/token-list', + reportTokenListEthereumMainnet: '/api/v1/report/token-list/static/ethereum-mainnet', routesTree: '/api/v1/routes/tree', plannerProvidersCapabilities: '/api/v2/providers/capabilities', plannerRoutesPlan: '/api/v2/routes/plan', diff --git a/services/token-aggregation/src/api/utils/uniswap-token-list.test.ts b/services/token-aggregation/src/api/utils/uniswap-token-list.test.ts new file mode 100644 index 0000000..b76bd49 --- /dev/null +++ b/services/token-aggregation/src/api/utils/uniswap-token-list.test.ts @@ -0,0 +1,76 @@ +import { + buildUniswapTokenList, + checksumTokenAddress, + normalizeTokenListVersion, + toUniswapTokenListToken, +} from './uniswap-token-list'; + +describe('uniswap-token-list utils', () => { + it('normalizes string versions into major/minor/patch objects', () => { + expect(normalizeTokenListVersion('1.2.3')).toEqual({ major: 1, minor: 2, patch: 3 }); + expect(normalizeTokenListVersion({ major: 4, minor: 5, patch: 6 })).toEqual({ + major: 4, + minor: 5, + patch: 6, + }); + }); + + it('moves non-schema token fields into extensions and checksums addresses', () => { + const token = toUniswapTokenListToken( + { + chainId: 1, + address: '0x2de5f116bfce3d0f922d9c8351e0c5fc24b9284a', + symbol: 'cWUSDC', + name: 'USD Coin (Compliant Wrapped ISO-4217 M1)', + decimals: 6, + logoURI: 'https://d-bis.org/tokens/cwusdc.svg', + type: 'w', + registryFamily: 'iso4217', + originalLogoURI: 'https://example.com/logo.svg', + }, + { displayNames: { cWUSDC: 'Wrapped cUSDC' } } + ); + + expect(token).toMatchObject({ + chainId: 1, + address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', + symbol: 'cWUSDC', + name: 'Wrapped cUSDC', + decimals: 6, + logoURI: 'https://d-bis.org/tokens/cwusdc.svg', + extensions: { + type: 'w', + registryFamily: 'iso4217', + originalLogoURI: 'https://example.com/logo.svg', + }, + }); + expect(token).not.toHaveProperty('type'); + expect(token).not.toHaveProperty('registryFamily'); + expect(checksumTokenAddress('0x2de5f116bfce3d0f922d9c8351e0c5fc24b9284a')).toBe( + '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a' + ); + }); + + it('builds a schema-compliant list envelope', () => { + const list = buildUniswapTokenList({ + name: 'DBIS Ethereum Mainnet GRU', + version: '1.1.0', + timestamp: '2026-06-06T00:00:00.000Z', + logoURI: 'https://d-bis.org/tokens/cwusdc.svg', + tokens: [ + { + chainId: 1, + address: '0x2de5f116bfce3d0f922d9c8351e0c5fc24b9284a', + symbol: 'cWUSDC', + name: 'Wrapped cUSDC', + decimals: 6, + logoURI: 'https://d-bis.org/tokens/cwusdc.svg', + }, + ], + }); + + expect(list.version).toEqual({ major: 1, minor: 1, patch: 0 }); + expect(Array.isArray(list.tokens)).toBe(true); + expect((list.tokens as unknown[]).length).toBe(1); + }); +}); diff --git a/services/token-aggregation/src/api/utils/uniswap-token-list.ts b/services/token-aggregation/src/api/utils/uniswap-token-list.ts new file mode 100644 index 0000000..2c2cecd --- /dev/null +++ b/services/token-aggregation/src/api/utils/uniswap-token-list.ts @@ -0,0 +1,119 @@ +import { getAddress } from 'ethers'; + +export type UniswapTokenListVersion = { + major: number; + minor: number; + patch: number; +}; + +const TOKEN_EXTENSION_TOP_LEVEL_KEYS = [ + 'type', + 'originalLogoURI', + 'registryFamily', + 'familySymbol', + 'deploymentVersion', + 'deploymentStatus', + 'preferredForX402', +] as const; + +/** Wallet/registry-facing names for mainnet transport tokens. */ +export const MAINNET_PUBLIC_DISPLAY_NAMES: Record = { + cWUSDC: 'Wrapped cUSDC', + cWUSDT: 'Wrapped cUSDT', +}; + +export function normalizeTokenListVersion(version: unknown): UniswapTokenListVersion { + if (version && typeof version === 'object' && 'major' in version) { + const record = version as { major?: unknown; minor?: unknown; patch?: unknown }; + return { + major: Number(record.major ?? 1), + minor: Number(record.minor ?? 0), + patch: Number(record.patch ?? 0), + }; + } + + if (typeof version === 'string' && version.trim() !== '') { + const [major = '1', minor = '0', patch = '0'] = version.trim().split('.'); + return { + major: Number.parseInt(major, 10) || 1, + minor: Number.parseInt(minor, 10) || 0, + patch: Number.parseInt(patch, 10) || 0, + }; + } + + return { major: 1, minor: 0, patch: 0 }; +} + +export function checksumTokenAddress(address: string): string { + try { + return getAddress(address); + } catch { + return address; + } +} + +export function toUniswapTokenListToken( + token: Record, + options?: { displayNames?: Record } +): Record { + const extensions: Record = {}; + + for (const key of TOKEN_EXTENSION_TOP_LEVEL_KEYS) { + if (token[key] !== undefined) { + extensions[key] = token[key]; + } + } + + if (token.extensions && typeof token.extensions === 'object' && !Array.isArray(token.extensions)) { + Object.assign(extensions, token.extensions as Record); + } + + const symbol = String(token.symbol ?? ''); + const displayName = options?.displayNames?.[symbol]; + const out: Record = { + chainId: token.chainId, + address: checksumTokenAddress(String(token.address ?? '')), + name: displayName ?? token.name, + symbol, + decimals: token.decimals, + }; + + if (token.logoURI) out.logoURI = token.logoURI; + if (Array.isArray(token.tags) && token.tags.length > 0) out.tags = token.tags; + if (Object.keys(extensions).length > 0) out.extensions = extensions; + + return out; +} + +export function buildUniswapTokenList(params: { + name: string; + version: unknown; + timestamp: string; + logoURI?: string; + tokens: Array>; + keywords?: string[]; + tags?: Record; + displayNames?: Record; +}): Record { + const body: Record = { + name: params.name, + timestamp: params.timestamp, + version: normalizeTokenListVersion(params.version), + tokens: params.tokens.map((token) => + toUniswapTokenListToken(token, { displayNames: params.displayNames }) + ), + }; + + if (params.logoURI) body.logoURI = params.logoURI; + if (params.keywords?.length) body.keywords = params.keywords; + if (params.tags && Object.keys(params.tags).length > 0) body.tags = params.tags; + + return body; +} + +export function resolveTokenListName(chainIds: number[], defaultName: string): string { + if (chainIds.length === 1 && chainIds[0] === 1) { + return 'DBIS Ethereum Mainnet GRU'; + } + return defaultName; +}