diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..b800c3906 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(rtk grep *)", + "Bash(rtk read *)", + "Bash(rtk find *)", + "Bash(rtk ls *)", + "Bash(rtk tsc *)", + "Bash(rtk lint *)", + "Bash(rtk prettier *)", + "Bash(rtk proxy *)", + "Bash(python3 -)", + "Bash(rtk vitest *)", + "Bash(MEASURE=1 rtk proxy pnpm exec vitest --config vitest.config.ts --run --reporter=verbose src/token-registry/__tests__/measure.test.ts)" + ] + } +} diff --git a/.pnpm-store/v11/.pnpm-needs-build-marker b/.pnpm-store/v11/.pnpm-needs-build-marker new file mode 100644 index 000000000..e69de29bb diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 000000000..341d90224 Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/packages/app/src/app/api/token-registry/import/[sourceChainId]/[destinationChainId]/[address]/route.ts b/packages/app/src/app/api/token-registry/import/[sourceChainId]/[destinationChainId]/[address]/route.ts new file mode 100644 index 000000000..8d6389bb4 --- /dev/null +++ b/packages/app/src/app/api/token-registry/import/[sourceChainId]/[destinationChainId]/[address]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { isSupportedPair } from '@/app/src/token-registry/constants'; +import { resolveImport } from '@/app/src/token-registry/server/resolveImport'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: NextRequest, + { + params, + }: { + params: Promise<{ + sourceChainId: string; + destinationChainId: string; + address: string; + }>; + }, +) { + const { sourceChainId, destinationChainId, address } = await params; + const pair = { + sourceChainId: Number(sourceChainId), + destinationChainId: Number(destinationChainId), + }; + + if (!isSupportedPair(pair)) { + return NextResponse.json( + { + error: `Unsupported chain pair: ${sourceChainId} -> ${destinationChainId}`, + }, + { status: 404 }, + ); + } + + const resolution = await resolveImport(pair, address); + + if (!resolution) { + return NextResponse.json( + { error: 'Token is not transferable on this chain pair' }, + { status: 404 }, + ); + } + + return NextResponse.json(resolution); +} diff --git a/packages/app/src/app/api/token-registry/routes/[sourceChainId]/[destinationChainId]/route.ts b/packages/app/src/app/api/token-registry/routes/[sourceChainId]/[destinationChainId]/route.ts new file mode 100644 index 000000000..e74932b49 --- /dev/null +++ b/packages/app/src/app/api/token-registry/routes/[sourceChainId]/[destinationChainId]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getRouteMap } from '@/app/src/token-registry/server/generate'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ sourceChainId: string; destinationChainId: string }> }, +) { + const { sourceChainId, destinationChainId } = await params; + const pair = { + sourceChainId: Number(sourceChainId), + destinationChainId: Number(destinationChainId), + }; + + const artifact = await getRouteMap(pair); + + if (!artifact) { + return NextResponse.json( + { + error: `Unsupported chain pair: ${sourceChainId} -> ${destinationChainId}`, + }, + { status: 404 }, + ); + } + + return NextResponse.json(artifact, { + headers: { + 'Cache-Control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=3600', + }, + }); +} diff --git a/packages/app/src/app/api/token-registry/swap-destinations/[sourceChainId]/[destinationChainId]/route.ts b/packages/app/src/app/api/token-registry/swap-destinations/[sourceChainId]/[destinationChainId]/route.ts new file mode 100644 index 000000000..2cefc7af3 --- /dev/null +++ b/packages/app/src/app/api/token-registry/swap-destinations/[sourceChainId]/[destinationChainId]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getSwapDestinations } from '@/app/src/token-registry/server/generate'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ sourceChainId: string; destinationChainId: string }> }, +) { + const { sourceChainId, destinationChainId } = await params; + const pair = { + sourceChainId: Number(sourceChainId), + destinationChainId: Number(destinationChainId), + }; + + const swapDestinations = await getSwapDestinations(pair); + + if (swapDestinations === null) { + return NextResponse.json( + { + error: `Unsupported chain pair: ${sourceChainId} -> ${destinationChainId}`, + }, + { status: 404 }, + ); + } + + return NextResponse.json(swapDestinations, { + headers: { + 'Cache-Control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=3600', + }, + }); +} diff --git a/packages/app/src/app/api/token-registry/tokens/[chainId]/route.ts b/packages/app/src/app/api/token-registry/tokens/[chainId]/route.ts new file mode 100644 index 000000000..47a890e2c --- /dev/null +++ b/packages/app/src/app/api/token-registry/tokens/[chainId]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { supportedChainIds } from '@/app/src/token-registry/constants'; +import { getChainTokens } from '@/app/src/token-registry/server/generate'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ chainId: string }> }, +) { + const { chainId: rawChainId } = await params; + const chainId = Number(rawChainId); + + if (!supportedChainIds.includes(chainId)) { + return NextResponse.json({ error: `Unsupported chain: ${rawChainId}` }, { status: 404 }); + } + + const tokens = await getChainTokens(chainId); + + return NextResponse.json(tokens, { + headers: { + 'Cache-Control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=3600', + }, + }); +} diff --git a/packages/app/src/app/token-registry-poc/TokenRegistryPoc.tsx b/packages/app/src/app/token-registry-poc/TokenRegistryPoc.tsx new file mode 100644 index 000000000..f7fdb515a --- /dev/null +++ b/packages/app/src/app/token-registry-poc/TokenRegistryPoc.tsx @@ -0,0 +1,491 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import useSWR from 'swr'; +import { twMerge } from 'tailwind-merge'; + +import { ChainIds, chainNames } from '@/app/src/token-registry/constants'; +import { + filterTokens, + getDestinationTokens, + getRoutes, + getSourceTokens, + hasSwapRoute, +} from '@/app/src/token-registry/selectors'; +import { + Address, + ChainPair, + ImportResolution, + ProviderId, + RouteMapArtifact, + RouteOption, + Token, + TokenId, + TokenPayload, + isNativeToken, + pairKey, + toTokenId, +} from '@/app/src/token-registry/types'; +import { RegistryView, buildRegistryView, hydrateTokens } from '@/app/src/token-registry/view'; + +const ROW_HEIGHT = 56; // px — must match TokenRow's h-14 +const LIST_HEIGHT = 512; // px — must match the list container's h-[32rem] +const OVERSCAN = 5; + +// the app theme overrides `blue` and `orange` with flat colors (no -500 +// scale), so those scales never compile — use sky/amber instead +const providerBadgeClasses: Record = { + canonical: 'bg-sky-500/20 text-sky-300', + cctp: 'bg-green-500/20 text-green-300', + layerzero: 'bg-purple-500/20 text-purple-300', + lifi: 'bg-amber-500/20 text-amber-300', +}; + +const providerLabels: Record = { + canonical: 'Canonical bridge', + cctp: 'CCTP', + layerzero: 'LayerZero', + lifi: 'LiFi', +}; + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + return response.json() as Promise; +} + +function truncateAddress(address: string) { + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function TokenIcon({ token }: { token: Token }) { + return ( +
+ {token.symbol.slice(0, 3)} +
+ ); +} + +function TokenRow({ + token, + isSelected, + onSelect, +}: { + token: Token; + isSelected?: boolean; + onSelect?: (tokenId: TokenId) => void; +}) { + const content = ( + <> + + + {token.symbol} + {token.name} + + + {isNativeToken(token) ? 'native' : truncateAddress(token.address)} + + + ); + + if (!onSelect) { + return
{content}
; + } + + return ( + + ); +} + +function VirtualizedTokenList({ + tokens, + selectedTokenId, + onSelect, +}: { + tokens: Token[]; + selectedTokenId?: TokenId | null; + onSelect?: (tokenId: TokenId) => void; +}) { + const [scrollTop, setScrollTop] = useState(0); + + const totalHeight = tokens.length * ROW_HEIGHT; + // clamp so a shrinking list (search) cannot leave us scrolled past the end + const clampedScrollTop = Math.min(scrollTop, Math.max(0, totalHeight - LIST_HEIGHT)); + const start = Math.max(0, Math.floor(clampedScrollTop / ROW_HEIGHT) - OVERSCAN); + const end = Math.min( + tokens.length, + Math.ceil((clampedScrollTop + LIST_HEIGHT) / ROW_HEIGHT) + OVERSCAN, + ); + const visibleTokens = tokens.slice(start, end); + + if (tokens.length === 0) { + return ( +
+ No tokens match. +
+ ); + } + + return ( +
setScrollTop(event.currentTarget.scrollTop)} + > +
+
    + {visibleTokens.map((token) => ( +
  • + +
  • + ))} +
+
+
+ ); +} + +function DestinationLabel({ + destinationTokenId, + tokens, +}: { + destinationTokenId: TokenId | undefined; + tokens: Map; +}) { + if (!destinationTokenId) { + return ( + + Swap only — pick a destination from the swap set + + ); + } + + const token = tokens.get(destinationTokenId); + if (!token) { + return → {truncateAddress(destinationTokenId)}; + } + + return ( + + → {token.symbol}{' '} + + ({isNativeToken(token) ? 'native' : truncateAddress(token.address)}) + + + ); +} + +export function TokenRegistryPoc() { + const [pair, setPair] = useState({ + sourceChainId: ChainIds.Ethereum, + destinationChainId: ChainIds.ArbitrumOne, + }); + const [search, setSearch] = useState(''); + const [destinationSearch, setDestinationSearch] = useState(''); + const [isDestinationPickerOpen, setIsDestinationPickerOpen] = useState(false); + const [selectedTokenId, setSelectedTokenId] = useState(null); + const [overlays, setOverlays] = useState>({}); + const [importStatus, setImportStatus] = useState<'idle' | 'loading' | 'error'>('idle'); + + const currentPairKey = pairKey(pair); + const pairOverlays = useMemo(() => overlays[currentPairKey] ?? [], [overlays, currentPairKey]); + + const { data: sourceTokensPayload, error: sourceTokensError } = useSWR( + [pair.sourceChainId, 'token-registry-chain-tokens'], + ([chainId]) => fetchJson(`/api/token-registry/tokens/${chainId}`), + ); + const { data: destinationTokensPayload, error: destinationTokensError } = useSWR( + [pair.destinationChainId, 'token-registry-chain-tokens'], + ([chainId]) => fetchJson(`/api/token-registry/tokens/${chainId}`), + ); + const { data: artifact, error: artifactError } = useSWR( + [pair.sourceChainId, pair.destinationChainId, 'token-registry-routes'], + ([sourceChainId, destinationChainId]) => + fetchJson( + `/api/token-registry/routes/${sourceChainId}/${destinationChainId}`, + ), + ); + + // hydrate once per pair load — joining, sorting and search indexing happen + // here, not per keystroke + const view = useMemo(() => { + if (!artifact || !sourceTokensPayload || !destinationTokensPayload) { + return undefined; + } + return buildRegistryView({ + artifact, + sourceChainTokens: hydrateTokens(pair.sourceChainId, sourceTokensPayload), + destinationChainTokens: hydrateTokens(pair.destinationChainId, destinationTokensPayload), + overlays: pairOverlays, + }); + }, [ + artifact, + sourceTokensPayload, + destinationTokensPayload, + pairOverlays, + pair.sourceChainId, + pair.destinationChainId, + ]); + + const sourceTokens = useMemo( + () => (view ? getSourceTokens(view, search) : []), + [view, search], + ); + + const selectedToken = view && selectedTokenId ? view.tokens.get(selectedTokenId) : null; + + const selectedRoutes = useMemo( + () => (view && selectedTokenId ? getRoutes(view, selectedTokenId) : []), + [view, selectedTokenId], + ); + + const selectedHasSwapRoute = Boolean( + view && selectedTokenId && hasSwapRoute(view, selectedTokenId), + ); + // the swap destination set is only fetched when the user actually opens + // the destination picker — not on token selection + const { data: swapDestinationAddresses } = useSWR( + selectedHasSwapRoute && isDestinationPickerOpen + ? [pair.sourceChainId, pair.destinationChainId, 'token-registry-swap-destinations'] + : null, + ([sourceChainId, destinationChainId]) => + fetchJson( + `/api/token-registry/swap-destinations/${sourceChainId}/${destinationChainId}`, + ), + ); + + // destination *picker* data: fixed bridge outputs ∪ swap set, sorted like + // the source list. Only swap-capable tokens need a picker — fixed-output + // routes already display their destination on the route card. + const allDestinationTokens = useMemo(() => { + if (!view || !selectedTokenId || !swapDestinationAddresses) { + return []; + } + return getDestinationTokens({ + view, + sourceTokenId: selectedTokenId, + swapDestinations: swapDestinationAddresses, + }).sort((a, b) => a.symbol.localeCompare(b.symbol)); + }, [view, selectedTokenId, swapDestinationAddresses]); + + const destinationTokens = useMemo( + () => (view ? filterTokens(view, allDestinationTokens, destinationSearch) : []), + [view, allDestinationTokens, destinationSearch], + ); + + const trimmedSearch = search.trim().toLowerCase(); + const isAddressSearch = /^0x[0-9a-f]{40}$/.test(trimmedSearch); + const canImport = + isAddressSearch && sourceTokens.every((token) => token.address !== trimmedSearch); + + const error = sourceTokensError ?? destinationTokensError ?? artifactError; + const isLoading = !error && !view; + + function flipDirection() { + setPair((previous) => ({ + sourceChainId: previous.destinationChainId, + destinationChainId: previous.sourceChainId, + })); + setSelectedTokenId(null); + setDestinationSearch(''); + setIsDestinationPickerOpen(false); + setImportStatus('idle'); + } + + function handleSelectToken(tokenId: TokenId) { + setSelectedTokenId(tokenId); + setDestinationSearch(''); + setIsDestinationPickerOpen(false); + } + + async function handleImport() { + setImportStatus('loading'); + try { + const resolution = await fetchJson( + `/api/token-registry/import/${pair.sourceChainId}/${pair.destinationChainId}/${trimmedSearch}`, + ); + setOverlays((previous) => ({ + ...previous, + [currentPairKey]: [...(previous[currentPairKey] ?? []), resolution], + })); + setSelectedTokenId(toTokenId(pair.sourceChainId, trimmedSearch)); + setSearch(''); + setImportStatus('idle'); + } catch { + setImportStatus('error'); + } + } + + return ( +
+

Token Registry PoC

+ +
+ {chainNames[pair.sourceChainId]} + + {chainNames[pair.destinationChainId]} +
+ + {error &&

Failed to load the registry: {String(error)}

} + {isLoading && !error &&

Loading registry…

} + + {view && ( +
+
+ setSearch(event.target.value)} + placeholder="Search by symbol, name or address" + className="w-full rounded-md border border-white/20 bg-white/5 px-3 py-2 text-sm outline-none placeholder:text-white/40 focus:border-white/50" + /> + + {canImport && ( +
+

No token with this address in the registry.

+ + {importStatus === 'error' && ( +

+ Not transferable to {chainNames[pair.destinationChainId]} via the canonical + bridge. +

+ )} +
+ )} + +

+ {sourceTokens.length} transferable token + {sourceTokens.length === 1 ? '' : 's'} +

+ +
+ +
+
+ +
+ {!selectedToken && ( +

Select a source token to see its routes.

+ )} + + {selectedToken && ( + <> +
+ +
+

+ {selectedToken.symbol} + + {selectedToken.name} + +

+

+ {isNativeToken(selectedToken) ? 'Native token' : selectedToken.address} +

+
+
+ +

+ Routes to {chainNames[pair.destinationChainId]} +

+ + {selectedRoutes.length === 0 && ( +

No routes available.

+ )} + +
    + {selectedRoutes.map((route, index) => ( +
  • +
    + + {providerLabels[route.provider]} + + +
    + {route.provider === 'layerzero' && ( +

    + OFT adapter {truncateAddress(route.oftAdapter)} · endpoint{' '} + {route.destinationEndpointId} +

    + )} +
  • + ))} +
+ + {selectedHasSwapRoute && !isDestinationPickerOpen && ( + + )} + + {selectedHasSwapRoute && isDestinationPickerOpen && !swapDestinationAddresses && ( +

Loading swap destinations…

+ )} + + {selectedHasSwapRoute && isDestinationPickerOpen && swapDestinationAddresses && ( +
+

+ {allDestinationTokens.length} destination token + {allDestinationTokens.length === 1 ? '' : 's'} +

+ setDestinationSearch(event.target.value)} + placeholder="Search by symbol, name or address" + className="mt-2 w-full rounded-md border border-white/20 bg-white/5 px-3 py-2 text-sm outline-none placeholder:text-white/40 focus:border-white/50" + /> +
+ +
+
+ )} + + )} +
+
+ )} +
+ ); +} diff --git a/packages/app/src/app/token-registry-poc/page.tsx b/packages/app/src/app/token-registry-poc/page.tsx new file mode 100644 index 000000000..bfae45957 --- /dev/null +++ b/packages/app/src/app/token-registry-poc/page.tsx @@ -0,0 +1,12 @@ +import { Metadata } from 'next'; + +import { TokenRegistryPoc } from './TokenRegistryPoc'; + +export const metadata: Metadata = { + title: 'Token Registry PoC', + robots: { index: false }, +}; + +export default function TokenRegistryPocPage() { + return ; +} diff --git a/packages/app/src/token-registry/__tests__/fixtures.ts b/packages/app/src/token-registry/__tests__/fixtures.ts new file mode 100644 index 000000000..b61ef238a --- /dev/null +++ b/packages/app/src/token-registry/__tests__/fixtures.ts @@ -0,0 +1,115 @@ +import { + Address, + ChainTokens, + ImportResolution, + NATIVE_TOKEN_ADDRESS, + RouteMapArtifact, + Token, + TokenId, + toTokenId, +} from '../types'; +import { RegistryView, buildRegistryView } from '../view'; + +export function addr(byte: string): Address { + return `0x${byte.repeat(20)}` as Address; +} + +export const USDC_ETHEREUM = addr('01'); +export const USDC_ARBITRUM = addr('02'); +export const USDCE_ARBITRUM = addr('03'); +export const USDT_ETHEREUM = addr('04'); +export const USDT0_ARBITRUM = addr('05'); +export const DAI_ETHEREUM = addr('06'); +export const DAI_ARBITRUM = addr('07'); +export const PEPE_ETHEREUM = addr('08'); +export const ARB_ARBITRUM = addr('09'); +/** present in the artifact but missing from the token registry */ +export const ORPHAN_ETHEREUM = addr('0a'); +export const OFT_ADAPTER = addr('0c'); + +export function getFixtureToken(id: TokenId): Token { + const fixtureToken = tokens[id]; + if (!fixtureToken) { + throw new Error(`Missing fixture token: ${id}`); + } + return fixtureToken; +} + +function token(chainId: number, address: Address, symbol: string, decimals = 18): Token { + return { + id: toTokenId(chainId, address), + chainId, + address, + symbol, + name: `${symbol} Token`, + decimals, + }; +} + +export const tokens: ChainTokens = Object.fromEntries( + [ + token(1, NATIVE_TOKEN_ADDRESS, 'ETH'), + token(1, USDC_ETHEREUM, 'USDC', 6), + token(1, USDT_ETHEREUM, 'USDT', 6), + token(1, DAI_ETHEREUM, 'DAI'), + token(1, PEPE_ETHEREUM, 'PEPE'), + token(42161, NATIVE_TOKEN_ADDRESS, 'ETH'), + token(42161, USDC_ARBITRUM, 'USDC', 6), + token(42161, USDCE_ARBITRUM, 'USDC.e', 6), + token(42161, USDT0_ARBITRUM, 'USDT0', 6), + token(42161, DAI_ARBITRUM, 'DAI'), + token(42161, ARB_ARBITRUM, 'ARB'), + ].map((entry) => [entry.id, entry]), +); + +export const artifact: RouteMapArtifact = { + sourceChainId: 1, + destinationChainId: 42161, + providers: [ + { + provider: 'canonical', + routes: { + [NATIVE_TOKEN_ADDRESS]: NATIVE_TOKEN_ADDRESS, + [USDC_ETHEREUM]: USDCE_ARBITRUM, + [DAI_ETHEREUM]: DAI_ARBITRUM, + [ORPHAN_ETHEREUM]: addr('0b'), + }, + }, + { + provider: 'cctp', + routes: { [USDC_ETHEREUM]: USDC_ARBITRUM }, + }, + { + provider: 'layerzero', + routes: { + [USDT_ETHEREUM]: { + destination: USDT0_ARBITRUM, + oftAdapter: OFT_ADAPTER, + endpointId: 30110, + }, + }, + }, + { + provider: 'lifi', + routes: { + [USDC_ETHEREUM]: USDC_ARBITRUM, + [NATIVE_TOKEN_ADDRESS]: NATIVE_TOKEN_ADDRESS, + // swap-only: no same-asset counterpart on the destination chain + [PEPE_ETHEREUM]: null, + }, + }, + ], +}; + +/** the pair's lazily-fetched swap destination set */ +export const swapDestinations: Address[] = [USDC_ARBITRUM, NATIVE_TOKEN_ADDRESS, ARB_ARBITRUM]; + +export function buildFixtureView(overlays?: ImportResolution[]): RegistryView { + const allTokens = Object.values(tokens).filter((entry): entry is Token => Boolean(entry)); + return buildRegistryView({ + artifact, + sourceChainTokens: allTokens.filter((entry) => entry.chainId === 1), + destinationChainTokens: allTokens.filter((entry) => entry.chainId === 42161), + overlays, + }); +} diff --git a/packages/app/src/token-registry/__tests__/generate.test.ts b/packages/app/src/token-registry/__tests__/generate.test.ts new file mode 100644 index 000000000..1307ae389 --- /dev/null +++ b/packages/app/src/token-registry/__tests__/generate.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getCuratedCoinKey, isExcludedToken } from '../constants'; +import { CanonicalEntry, generateCanonical } from '../server/generate'; +import { NATIVE_TOKEN_ADDRESS, Token, toTokenId } from '../types'; +import { DAI_ARBITRUM, DAI_ETHEREUM } from './fixtures'; + +vi.mock('next/cache', () => ({ + unstable_cache: (fn: (...args: never[]) => unknown) => fn, +})); + +// real addresses — PYUSD deposits are excluded via canonicalRouteExclusions +const PYUSD_ETHEREUM = '0x6c3ea9036406852006290770bedfcaba0e23a0e8' as Token['address']; +const PYUSD_CANONICAL_ARBITRUM = '0x327006c8712fe0abdbbd55b7999db39b0967342e' as Token['address']; + +function token(chainId: number, address: string, symbol: string): Token { + return { + id: toTokenId(chainId, address), + chainId, + address: address.toLowerCase() as Token['address'], + symbol, + name: symbol, + decimals: 18, + }; +} + +const entries: CanonicalEntry[] = [ + { + parent: token(1, PYUSD_ETHEREUM, 'PYUSD'), + child: token(42161, PYUSD_CANONICAL_ARBITRUM, 'PYUSD'), + }, + { + parent: token(1, DAI_ETHEREUM, 'DAI'), + child: token(42161, DAI_ARBITRUM, 'DAI'), + }, +]; + +describe('isExcludedToken', () => { + it('excludes the PEPE import-demo token on both chains, case-insensitively', () => { + expect(isExcludedToken(1, '0x6982508145454ce325ddbe47a25d4ec3d2311933')).toBe(true); + expect(isExcludedToken(1, '0x6982508145454Ce325dDbE47a25d4ec3d2311933')).toBe(true); + expect(isExcludedToken(42161, '0x35e6a59f786d9266c7961ea28c7b768b33959cbb')).toBe(true); + }); + + it('leaves every other token alone', () => { + expect(isExcludedToken(1, DAI_ETHEREUM)).toBe(false); + expect(isExcludedToken(42161, PYUSD_CANONICAL_ARBITRUM)).toBe(false); + }); +}); + +describe('getCuratedCoinKey', () => { + it('assigns coinKeys LiFi does not provide (PYUSD, ENA)', () => { + expect(getCuratedCoinKey(1, PYUSD_ETHEREUM)).toBe('PYUSD'); + expect(getCuratedCoinKey(42161, '0x46850ad61c2b7d64d08c9c754f45254596696984')).toBe('PYUSD'); + expect(getCuratedCoinKey(1, '0x57e114B691Db790C35207b2e685D4A43181e6061')).toBe('ENA'); + expect(getCuratedCoinKey(42161, '0x58538e6a46e07434d7e7375bc268d3cb839c0133')).toBe('ENA'); + }); + + it('returns undefined for uncurated tokens', () => { + expect(getCuratedCoinKey(1, DAI_ETHEREUM)).toBeUndefined(); + // the canonical-bridged PYUSD is intentionally NOT curated — its route is canonical + expect(getCuratedCoinKey(42161, PYUSD_CANONICAL_ARBITRUM)).toBeUndefined(); + }); +}); + +describe('generateCanonical', () => { + it('always routes native to native', () => { + const deposit = generateCanonical(entries, { sourceChainId: 1, destinationChainId: 42161 }); + expect(deposit.routes[NATIVE_TOKEN_ADDRESS]).toBe(NATIVE_TOKEN_ADDRESS); + }); + + it('excludes PYUSD from deposit routes but keeps other tokens', () => { + const deposit = generateCanonical(entries, { sourceChainId: 1, destinationChainId: 42161 }); + + expect(deposit.routes[PYUSD_ETHEREUM]).toBeUndefined(); + expect(deposit.routes[DAI_ETHEREUM]).toBe(DAI_ARBITRUM); + }); + + it('keeps the PYUSD canonical withdrawal route', () => { + const withdrawal = generateCanonical(entries, { sourceChainId: 42161, destinationChainId: 1 }); + + expect(withdrawal.routes[PYUSD_CANONICAL_ARBITRUM]).toBe(PYUSD_ETHEREUM); + expect(withdrawal.routes[DAI_ARBITRUM]).toBe(DAI_ETHEREUM); + }); +}); diff --git a/packages/app/src/token-registry/__tests__/measure.test.ts b/packages/app/src/token-registry/__tests__/measure.test.ts new file mode 100644 index 000000000..6d639db4b --- /dev/null +++ b/packages/app/src/token-registry/__tests__/measure.test.ts @@ -0,0 +1,70 @@ +import { gzipSync } from 'node:zlib'; +import { describe, expect, it, vi } from 'vitest'; + +import { getChainTokens, getRouteMap, getSwapDestinations } from '../server/generate'; + +// run generation directly, without Next's cache layer +vi.mock('next/cache', () => ({ + unstable_cache: (fn: (...args: never[]) => unknown) => fn, +})); + +function sizeOf(value: unknown) { + const json = JSON.stringify(value); + const raw = Buffer.byteLength(json); + const gzip = gzipSync(Buffer.from(json)).length; + return `${(raw / 1024).toFixed(0)} KB raw / ${(gzip / 1024).toFixed(0)} KB gzip`; +} + +/** + * Measurement harness, not a CI test — hits the real token list and LiFi + * APIs. Run with: MEASURE=1 pnpm vitest --run src/token-registry + */ +describe.runIf(process.env.MEASURE === '1')('token registry generation (live network)', () => { + it('measures size and timing for Ethereum <-> Arbitrum One', async () => { + const start = performance.now(); + const [ethereumTokens, arbitrumTokens] = await Promise.all([ + getChainTokens(1), + getChainTokens(42161), + ]); + const tokensDone = performance.now(); + + const deposit = await getRouteMap({ sourceChainId: 1, destinationChainId: 42161 }); + const depositDone = performance.now(); + + const withdrawal = await getRouteMap({ sourceChainId: 42161, destinationChainId: 1 }); + const withdrawalDone = performance.now(); + + expect(deposit).not.toBeNull(); + expect(withdrawal).not.toBeNull(); + + console.log(`tokens/1: ${ethereumTokens.length} tokens, ${sizeOf(ethereumTokens)}`); + console.log(`tokens/42161: ${arbitrumTokens.length} tokens, ${sizeOf(arbitrumTokens)}`); + console.log(` generated in ${(tokensDone - start).toFixed(0)}ms (both, parallel)`); + + for (const [label, artifact, ms] of [ + ['routes/1/42161', deposit, depositDone - tokensDone], + ['routes/42161/1', withdrawal, withdrawalDone - depositDone], + ] as const) { + if (!artifact) { + continue; + } + const counts = artifact.providers + .map((section) => `${section.provider}=${Object.keys(section.routes).length}`) + .join(', '); + console.log(`${label}: ${counts}`); + console.log(` ${sizeOf(artifact)}, generated in ${ms.toFixed(0)}ms (cold, no cache)`); + } + + const swapPairs = [ + { sourceChainId: 1, destinationChainId: 42161 }, + { sourceChainId: 42161, destinationChainId: 1 }, + ]; + const swapSets = await Promise.all(swapPairs.map((pair) => getSwapDestinations(pair))); + swapPairs.forEach((pair, index) => { + const swapDestinations = swapSets[index]; + console.log( + `swap-destinations/${pair.sourceChainId}/${pair.destinationChainId}: ${swapDestinations?.length ?? 0} tokens, ${sizeOf(swapDestinations)}`, + ); + }); + }, 180_000); +}); diff --git a/packages/app/src/token-registry/__tests__/normalize.test.ts b/packages/app/src/token-registry/__tests__/normalize.test.ts new file mode 100644 index 000000000..cdd4d05fd --- /dev/null +++ b/packages/app/src/token-registry/__tests__/normalize.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeToken } from '../normalize'; + +describe('normalizeToken', () => { + it('lowercases the address and derives the token id', () => { + const token = normalizeToken({ + chainId: 1, + address: '0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }); + + expect(token.address).toBe('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + expect(token.id).toBe('1:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + expect(token.symbol).toBe('USDC'); + }); + + it('applies curated metadata while generating the token', () => { + // USDT0 on Arbitrum One has a curated symbol/name override + const token = normalizeToken({ + chainId: 42161, + address: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + }); + + expect(token.symbol).toBe('USDT0'); + expect(token.name).toBe('USDT0'); + expect(token.decimals).toBe(6); + }); + + it('keeps the upstream metadata when no curated entry exists', () => { + const token = normalizeToken({ + chainId: 1, + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + logoURI: 'https://example.com/dai.png', + }); + + expect(token.symbol).toBe('DAI'); + expect(token.name).toBe('Dai Stablecoin'); + expect(token.logoURI).toBe('https://example.com/dai.png'); + }); +}); diff --git a/packages/app/src/token-registry/__tests__/resolveImport.test.ts b/packages/app/src/token-registry/__tests__/resolveImport.test.ts new file mode 100644 index 000000000..8fef1216b --- /dev/null +++ b/packages/app/src/token-registry/__tests__/resolveImport.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveImport } from '../server/resolveImport'; + +const readContract = vi.fn(); + +vi.mock('@/bridge/util/networks', () => ({ + rpcURLs: { 1: 'http://ethereum.test', 42161: 'http://arbitrum.test' }, +})); + +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: () => ({ readContract }), + }; +}); + +const PARENT = '0x6982508145454ce325ddbe47a25d4ec3d2311933'; +const CHILD = '0x35e6a59f786d9266c7961ea28c7b768b33959cbb'; +const NORMAL_GATEWAY = '0xa3a7b6f88361f48403514059f1f16c8e78d60eec'; +const DISABLED_GATEWAY = '0x0000000000000000000000000000000000000001'; +const PYUSD_ETHEREUM = '0x6c3ea9036406852006290770bedfcaba0e23a0e8'; + +const depositPair = { sourceChainId: 1, destinationChainId: 42161 }; + +function mockContracts({ + gateway, + childDeployed = true, +}: { + gateway: string; + childDeployed?: boolean; +}) { + readContract.mockImplementation( + async ({ address, functionName }: { address: string; functionName: string }) => { + switch (functionName) { + case 'symbol': + case 'name': + case 'decimals': { + if (!childDeployed && address === CHILD) { + throw new Error('no contract code'); + } + return functionName === 'decimals' ? 18 : 'PEPE'; + } + case 'l1TokenToGateway': + return gateway; + case 'calculateL2TokenAddress': + return CHILD; + default: + throw new Error(`unexpected call: ${functionName}`); + } + }, + ); +} + +beforeEach(() => { + readContract.mockReset(); +}); + +describe('resolveImport (deposit)', () => { + it('resolves a registered token, falling back to parent metadata for an undeployed child', async () => { + mockContracts({ gateway: NORMAL_GATEWAY, childDeployed: false }); + + const resolution = await resolveImport(depositPair, PARENT); + + expect(resolution?.routes).toEqual([ + { + provider: 'canonical', + sourceTokenId: `1:${PARENT}`, + destinationTokenId: `42161:${CHILD}`, + }, + ]); + const child = resolution?.tokens.find((token) => token.chainId === 42161); + expect(child?.symbol).toBe('PEPE'); + }); + + it('rejects tokens whose deposits are disabled on the router', async () => { + mockContracts({ gateway: DISABLED_GATEWAY }); + + expect(await resolveImport(depositPair, PARENT)).toBeNull(); + }); + + it('rejects canonical-excluded tokens without touching the chain', async () => { + mockContracts({ gateway: NORMAL_GATEWAY }); + + expect(await resolveImport(depositPair, PYUSD_ETHEREUM)).toBeNull(); + expect(readContract).not.toHaveBeenCalled(); + }); + + it('rejects invalid addresses', async () => { + expect(await resolveImport(depositPair, 'not-an-address')).toBeNull(); + expect(readContract).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/token-registry/__tests__/selectors.test.ts b/packages/app/src/token-registry/__tests__/selectors.test.ts new file mode 100644 index 000000000..92670a27c --- /dev/null +++ b/packages/app/src/token-registry/__tests__/selectors.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterTokens, + getDefaultDestinationToken, + getDestinationTokens, + getRoutes, + getRoutesForPair, + getSourceTokens, + getSwapDestinationTokens, + hasSwapRoute, +} from '../selectors'; +import { NATIVE_TOKEN_ADDRESS, toTokenId } from '../types'; +import { + ARB_ARBITRUM, + DAI_ARBITRUM, + DAI_ETHEREUM, + OFT_ADAPTER, + PEPE_ETHEREUM, + USDCE_ARBITRUM, + USDC_ARBITRUM, + USDC_ETHEREUM, + USDT0_ARBITRUM, + USDT_ETHEREUM, + buildFixtureView, + swapDestinations, +} from './fixtures'; + +const usdcId = toTokenId(1, USDC_ETHEREUM); +const usdtId = toTokenId(1, USDT_ETHEREUM); +const daiId = toTokenId(1, DAI_ETHEREUM); +const pepeId = toTokenId(1, PEPE_ETHEREUM); +const nativeId = toTokenId(1, NATIVE_TOKEN_ADDRESS); + +const view = buildFixtureView(); + +describe('getSourceTokens', () => { + it('returns the union of provider section keys, joined against the registry', () => { + const result = getSourceTokens(view); + + // ETH, USDC, USDT, DAI, PEPE — the orphan address has no token record + expect(result.map((token) => token.id)).toEqual( + expect.arrayContaining([usdcId, usdtId, daiId, pepeId, nativeId]), + ); + expect(result).toHaveLength(5); + }); + + it('sorts by symbol', () => { + const symbols = getSourceTokens(view).map((token) => token.symbol); + expect(symbols).toEqual([...symbols].sort((a, b) => a.localeCompare(b))); + }); + + it('filters by search term, case-insensitively', () => { + const symbols = getSourceTokens(view, 'usd').map((token) => token.symbol); + expect(symbols).toEqual(['USDC', 'USDT']); + expect(getSourceTokens(view, 'USDC Tok').map((token) => token.id)).toEqual([usdcId]); + }); + + it('finds a token by address', () => { + expect(getSourceTokens(view, PEPE_ETHEREUM).map((token) => token.id)).toEqual([pepeId]); + expect(getSourceTokens(view, PEPE_ETHEREUM.toUpperCase()).map((token) => token.id)).toEqual([ + pepeId, + ]); + }); +}); + +describe('getRoutes', () => { + it('materializes one route per matching provider section', () => { + const routes = getRoutes(view, usdcId); + + expect(routes).toEqual([ + { + provider: 'canonical', + sourceTokenId: usdcId, + destinationTokenId: toTokenId(42161, USDCE_ARBITRUM), + }, + { + provider: 'cctp', + sourceTokenId: usdcId, + destinationTokenId: toTokenId(42161, USDC_ARBITRUM), + }, + { + provider: 'lifi', + sourceTokenId: usdcId, + destinationTokenId: toTokenId(42161, USDC_ARBITRUM), + }, + ]); + }); + + it('carries layerzero-specific fields on the route', () => { + expect(getRoutes(view, usdtId)).toEqual([ + { + provider: 'layerzero', + sourceTokenId: usdtId, + destinationTokenId: toTokenId(42161, USDT0_ARBITRUM), + oftAdapter: OFT_ADAPTER, + destinationEndpointId: 30110, + }, + ]); + }); + + it('materializes a swap-only lifi route without a destination', () => { + expect(getRoutes(view, pepeId)).toEqual([ + { provider: 'lifi', sourceTokenId: pepeId, destinationTokenId: undefined }, + ]); + }); + + it('returns no routes for an unknown token', () => { + expect(getRoutes(view, toTokenId(1, '0xdead'))).toEqual([]); + }); +}); + +describe('hasSwapRoute', () => { + it('is true only for tokens with a lifi route', () => { + expect(hasSwapRoute(view, pepeId)).toBe(true); + expect(hasSwapRoute(view, usdcId)).toBe(true); + expect(hasSwapRoute(view, daiId)).toBe(false); + }); +}); + +describe('getSwapDestinationTokens', () => { + it('resolves the pair swap set for tokens with a lifi route', () => { + const result = getSwapDestinationTokens({ view, sourceTokenId: pepeId, swapDestinations }); + expect(result.map((token) => token.id)).toEqual([ + toTokenId(42161, USDC_ARBITRUM), + toTokenId(42161, NATIVE_TOKEN_ADDRESS), + toTokenId(42161, ARB_ARBITRUM), + ]); + }); + + it('returns nothing for tokens without a lifi route', () => { + expect(getSwapDestinationTokens({ view, sourceTokenId: daiId, swapDestinations })).toEqual([]); + }); +}); + +describe('getDestinationTokens', () => { + it('unions fixed bridge outputs with the swap set, deduplicated', () => { + const result = getDestinationTokens({ view, sourceTokenId: usdcId, swapDestinations }); + + // fixed: USDC.e (canonical), USDC (cctp + lifi counterpart) + // swap set: USDC, ETH, ARB — USDC deduplicates + expect(result.map((token) => token.id).sort()).toEqual( + [ + toTokenId(42161, USDCE_ARBITRUM), + toTokenId(42161, USDC_ARBITRUM), + toTokenId(42161, NATIVE_TOKEN_ADDRESS), + toTokenId(42161, ARB_ARBITRUM), + ].sort(), + ); + }); +}); + +describe('filterTokens', () => { + it('searches destination-chain tokens through the same index', () => { + const destinations = getDestinationTokens({ view, sourceTokenId: usdcId, swapDestinations }); + + expect(filterTokens(view, destinations, 'arb').map((token) => token.symbol)).toEqual(['ARB']); + expect(filterTokens(view, destinations, ARB_ARBITRUM).map((token) => token.symbol)).toEqual([ + 'ARB', + ]); + expect(filterTokens(view, destinations)).toEqual(destinations); + }); +}); + +describe('getDefaultDestinationToken', () => { + it('prefers layerzero over other providers', () => { + expect(getDefaultDestinationToken(view, usdtId)?.id).toBe(toTokenId(42161, USDT0_ARBITRUM)); + }); + + it('prefers cctp over canonical and lifi', () => { + expect(getDefaultDestinationToken(view, usdcId)?.id).toBe(toTokenId(42161, USDC_ARBITRUM)); + }); + + it('falls back to canonical', () => { + expect(getDefaultDestinationToken(view, daiId)?.id).toBe(toTokenId(42161, DAI_ARBITRUM)); + }); + + it('returns null when the only route is swap-only', () => { + expect(getDefaultDestinationToken(view, pepeId)).toBeNull(); + }); +}); + +describe('getRoutesForPair', () => { + it('returns fixed routes matching the destination plus lifi when in the swap set', () => { + const providers = getRoutesForPair({ + view, + sourceTokenId: usdcId, + destinationTokenId: toTokenId(42161, USDC_ARBITRUM), + swapDestinations, + }).map((route) => route.provider); + expect(providers).toEqual(['cctp', 'lifi']); + }); + + it('excludes lifi when the destination is outside the swap set', () => { + const providers = getRoutesForPair({ + view, + sourceTokenId: usdcId, + destinationTokenId: toTokenId(42161, USDCE_ARBITRUM), + swapDestinations, + }).map((route) => route.provider); + expect(providers).toEqual(['canonical']); + }); + + it('returns lifi alone for a pure swap destination', () => { + const providers = getRoutesForPair({ + view, + sourceTokenId: usdcId, + destinationTokenId: toTokenId(42161, ARB_ARBITRUM), + swapDestinations, + }).map((route) => route.provider); + expect(providers).toEqual(['lifi']); + }); + + it('returns nothing when the source token cannot reach the destination', () => { + expect( + getRoutesForPair({ + view, + sourceTokenId: daiId, + destinationTokenId: toTokenId(42161, ARB_ARBITRUM), + swapDestinations, + }), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/token-registry/__tests__/types.test.ts b/packages/app/src/token-registry/__tests__/types.test.ts new file mode 100644 index 000000000..61849cd54 --- /dev/null +++ b/packages/app/src/token-registry/__tests__/types.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + NATIVE_TOKEN_ADDRESS, + addressFromTokenId, + isNativeToken, + pairKey, + toTokenId, +} from '../types'; +import { getFixtureToken } from './fixtures'; + +describe('toTokenId', () => { + it('lowercases the address', () => { + expect(toTokenId(1, '0xABCDEF')).toBe('1:0xabcdef'); + }); +}); + +describe('addressFromTokenId', () => { + it('round-trips with toTokenId', () => { + const id = toTokenId(42161, '0xaf88d065e77c8cc2239327c5edb3a432268e5831'); + expect(addressFromTokenId(id)).toBe('0xaf88d065e77c8cc2239327c5edb3a432268e5831'); + }); +}); + +describe('isNativeToken', () => { + it('identifies the zero address as native', () => { + const native = getFixtureToken(toTokenId(1, NATIVE_TOKEN_ADDRESS)); + const erc20 = getFixtureToken(toTokenId(1, '0x' + '01'.repeat(20))); + + expect(isNativeToken(native)).toBe(true); + expect(isNativeToken(erc20)).toBe(false); + }); +}); + +describe('pairKey', () => { + it('encodes the ordered pair', () => { + expect(pairKey({ sourceChainId: 1, destinationChainId: 42161 })).toBe('1->42161'); + expect(pairKey({ sourceChainId: 42161, destinationChainId: 1 })).toBe('42161->1'); + }); +}); diff --git a/packages/app/src/token-registry/__tests__/view.test.ts b/packages/app/src/token-registry/__tests__/view.test.ts new file mode 100644 index 000000000..f6b69d766 --- /dev/null +++ b/packages/app/src/token-registry/__tests__/view.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { getRoutes, getSourceTokens } from '../selectors'; +import { ImportResolution, Token, toTokenId } from '../types'; +import { hydrateTokens } from '../view'; +import { DAI_ARBITRUM, addr, buildFixtureView } from './fixtures'; + +describe('hydrateTokens', () => { + it('re-derives id and chainId from the wire format', () => { + const [token] = hydrateTokens(42161, [ + { address: DAI_ARBITRUM, symbol: 'DAI', name: 'Dai', decimals: 18 }, + ]); + + expect(token).toEqual({ + id: toTokenId(42161, DAI_ARBITRUM), + chainId: 42161, + address: DAI_ARBITRUM, + symbol: 'DAI', + name: 'Dai', + decimals: 18, + }); + }); +}); + +describe('buildRegistryView with a session overlay', () => { + const importedSource: Token = { + id: toTokenId(1, addr('dd')), + chainId: 1, + address: addr('dd'), + symbol: 'DEAD', + name: 'Dead Token', + decimals: 18, + }; + const importedDestination: Token = { + id: toTokenId(42161, addr('ee')), + chainId: 42161, + address: addr('ee'), + symbol: 'DEAD', + name: 'Dead Token', + decimals: 18, + }; + const overlay: ImportResolution = { + tokens: [importedSource, importedDestination], + routes: [ + { + provider: 'canonical', + sourceTokenId: importedSource.id, + destinationTokenId: importedDestination.id, + }, + ], + }; + + const view = buildFixtureView([overlay]); + + it('lists the imported token first in the source tokens', () => { + expect(getSourceTokens(view)[0]?.id).toBe(importedSource.id); + expect(getSourceTokens(view, 'dead').map((token) => token.id)).toEqual([importedSource.id]); + }); + + it('returns the overlay route alongside generated routes', () => { + expect(getRoutes(view, importedSource.id)).toEqual(overlay.routes); + }); + + it('resolves overlay tokens through the view token map', () => { + expect(view.tokens.get(importedDestination.id)).toEqual(importedDestination); + }); +}); diff --git a/packages/app/src/token-registry/constants.ts b/packages/app/src/token-registry/constants.ts new file mode 100644 index 000000000..812429f7a --- /dev/null +++ b/packages/app/src/token-registry/constants.ts @@ -0,0 +1,212 @@ +import { CommonAddress } from '@/bridge/util/CommonAddressUtils'; + +import { + Address, + ChainPair, + LayerZeroRouteData, + NATIVE_TOKEN_ADDRESS, + Token, + pairKey, + toTokenId, +} from './types'; + +function addr(value: string): Address { + return value.toLowerCase() as Address; +} + +export const ChainIds = { + Ethereum: 1, + ArbitrumOne: 42161, +} as const; + +export const chainNames: Record = { + [ChainIds.Ethereum]: 'Ethereum', + [ChainIds.ArbitrumOne]: 'Arbitrum One', +}; + +export const supportedPairs: ChainPair[] = [ + { sourceChainId: ChainIds.Ethereum, destinationChainId: ChainIds.ArbitrumOne }, + { sourceChainId: ChainIds.ArbitrumOne, destinationChainId: ChainIds.Ethereum }, +]; + +export const supportedChainIds: number[] = [ChainIds.Ethereum, ChainIds.ArbitrumOne]; + +export function isSupportedPair(pair: ChainPair): boolean { + return supportedPairs.some( + (supported) => + supported.sourceChainId === pair.sourceChainId && + supported.destinationChainId === pair.destinationChainId, + ); +} + +const USDC_ETHEREUM = addr(CommonAddress.Ethereum.USDC); +const USDC_ARBITRUM_ONE = addr(CommonAddress.ArbitrumOne.USDC); + +const USDT_ETHEREUM = addr(CommonAddress.Ethereum.USDT); +const USDT0_ARBITRUM_ONE = addr(CommonAddress.ArbitrumOne.USDT); + +const OFT_ADAPTER_ETHEREUM = '0x6c96de32cea08842dcc4058c14d3aaad7fa41dee' as Address; +const OFT_ADAPTER_ARBITRUM_ONE = '0x14e4a1b13bf7f943c8ff7c51fb60fa964a298d92' as Address; + +const LZ_ENDPOINT_ID_ETHEREUM = 30101; +const LZ_ENDPOINT_ID_ARBITRUM_ONE = 30110; + +export const cctpConfig: Record> = { + [pairKey({ sourceChainId: 1, destinationChainId: 42161 })]: { + [USDC_ETHEREUM]: USDC_ARBITRUM_ONE, + }, + [pairKey({ sourceChainId: 42161, destinationChainId: 1 })]: { + [USDC_ARBITRUM_ONE]: USDC_ETHEREUM, + }, +}; + +export const layerZeroConfig: Record> = { + [pairKey({ sourceChainId: 1, destinationChainId: 42161 })]: { + [USDT_ETHEREUM]: { + destination: USDT0_ARBITRUM_ONE, + oftAdapter: OFT_ADAPTER_ETHEREUM, + endpointId: LZ_ENDPOINT_ID_ARBITRUM_ONE, + }, + }, + [pairKey({ sourceChainId: 42161, destinationChainId: 1 })]: { + [USDT0_ARBITRUM_ONE]: { + destination: USDT_ETHEREUM, + oftAdapter: OFT_ADAPTER_ARBITRUM_ONE, + endpointId: LZ_ENDPOINT_ID_ETHEREUM, + }, + }, +}; + +/** + * Canonical routes that must NOT be generated, keyed by ordered pair — + * source addresses whose canonical transfer is disabled. PYUSD deposits are + * LiFi-only; its withdrawal (PYUSD_CANONICAL → Ethereum PYUSD) stays + * canonical. + */ +export const canonicalRouteExclusions: Record = { + [pairKey({ sourceChainId: 1, destinationChainId: 42161 })]: [addr(CommonAddress.Ethereum.PYUSD)], +}; + +/** + * Tokens deliberately excluded from ALL generation (token registry, canonical + * routes, lifi sections) so the import flow can be demoed: PEPE has a + * canonical route but never appears in the picker — paste its address + * (0x6982508145454ce325ddbe47a25d4ec3d2311933 on Ethereum) to import it for + * the session. Import (canonical.resolve) intentionally does NOT consult + * this list. + */ +const excludedTokenIds = new Set([ + toTokenId(ChainIds.Ethereum, '0x6982508145454ce325ddbe47a25d4ec3d2311933'), // PEPE + toTokenId(ChainIds.ArbitrumOne, '0x35e6a59f786d9266c7961ea28c7b768b33959cbb'), // PEPE (bridged) +]); + +export function isExcludedToken(chainId: number, address: string): boolean { + return excludedTokenIds.has(toTokenId(chainId, address)); +} + +type CuratedCoinKey = { + coinKey: string; + addresses: Partial>; +}; + +/** + * LiFi assigns no coinKey to some tokens, so coinKey-based counterpart + * matching misses them. Mirrors CUSTOM_TOKENS in + * app/api/crosschain-transfers/lifi/tokens/registry.ts. + */ +const curatedCoinKeys: CuratedCoinKey[] = [ + { + coinKey: 'PYUSD', + addresses: { + [ChainIds.Ethereum]: addr(CommonAddress.Ethereum.PYUSD), + [ChainIds.ArbitrumOne]: addr(CommonAddress.ArbitrumOne.PYUSD), + }, + }, + { + coinKey: 'ENA', + addresses: { + [ChainIds.Ethereum]: addr('0x57e114b691db790c35207b2e685d4a43181e6061'), + [ChainIds.ArbitrumOne]: addr('0x58538e6a46e07434d7e7375bc268d3cb839c0133'), + }, + }, +]; + +const curatedCoinKeyByTokenId = new Map(); +for (const { coinKey, addresses } of curatedCoinKeys) { + for (const [chainId, address] of Object.entries(addresses)) { + if (address) { + curatedCoinKeyByTokenId.set(toTokenId(Number(chainId), address), coinKey); + } + } +} + +export function getCuratedCoinKey(chainId: number, address: string): string | undefined { + return curatedCoinKeyByTokenId.get(toTokenId(chainId, address)); +} + +/** + * Curated per-chain metadata fixes, applied while generating the final Token. + * The emitted Token is the single source of truth — nothing downstream + * re-applies these. + */ +export const curatedTokenMetadata: Partial< + Record>> +> = { + [toTokenId(ChainIds.ArbitrumOne, USDT0_ARBITRUM_ONE)]: { + symbol: 'USDT0', + name: 'USDT0', + }, +}; + +function nativeEther(chainId: number): Token { + return { + id: toTokenId(chainId, NATIVE_TOKEN_ADDRESS), + chainId, + address: NATIVE_TOKEN_ADDRESS, + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }; +} + +/** + * Tokens referenced by the hardcoded providers (and natives) — emitted into + * the registry so route artifacts always resolve, even if the generated + * lists miss them. + */ +export const hardcodedTokens: Token[] = [ + nativeEther(ChainIds.Ethereum), + nativeEther(ChainIds.ArbitrumOne), + { + id: toTokenId(ChainIds.Ethereum, USDC_ETHEREUM), + chainId: ChainIds.Ethereum, + address: USDC_ETHEREUM, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + { + id: toTokenId(ChainIds.ArbitrumOne, USDC_ARBITRUM_ONE), + chainId: ChainIds.ArbitrumOne, + address: USDC_ARBITRUM_ONE, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + { + id: toTokenId(ChainIds.Ethereum, USDT_ETHEREUM), + chainId: ChainIds.Ethereum, + address: USDT_ETHEREUM, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + }, + { + id: toTokenId(ChainIds.ArbitrumOne, USDT0_ARBITRUM_ONE), + chainId: ChainIds.ArbitrumOne, + address: USDT0_ARBITRUM_ONE, + symbol: 'USDT0', + name: 'USDT0', + decimals: 6, + }, +]; diff --git a/packages/app/src/token-registry/normalize.ts b/packages/app/src/token-registry/normalize.ts new file mode 100644 index 000000000..80aedff13 --- /dev/null +++ b/packages/app/src/token-registry/normalize.ts @@ -0,0 +1,30 @@ +import { curatedTokenMetadata } from './constants'; +import { Address, Token, toTokenId } from './types'; + +type RawToken = { + chainId: number; + address: string; + symbol: string; + name: string; + decimals: number; + logoURI?: string; +}; + +/** + * Produces the final, UI-ready Token: lowercase address, curated metadata + * applied. The result is the single source of truth for this token. + */ +export function normalizeToken(raw: RawToken): Token { + const id = toTokenId(raw.chainId, raw.address); + const curated = curatedTokenMetadata[id]; + + return { + id, + chainId: raw.chainId, + address: raw.address.toLowerCase() as Address, + symbol: curated?.symbol ?? raw.symbol, + name: curated?.name ?? raw.name, + decimals: raw.decimals, + logoURI: curated?.logoURI ?? raw.logoURI, + }; +} diff --git a/packages/app/src/token-registry/selectors.ts b/packages/app/src/token-registry/selectors.ts new file mode 100644 index 000000000..56ca58079 --- /dev/null +++ b/packages/app/src/token-registry/selectors.ts @@ -0,0 +1,169 @@ +import { + Address, + ProviderId, + RouteOption, + Token, + TokenId, + addressFromTokenId, + toTokenId, +} from './types'; +import { RegistryView } from './view'; + +/** Filters any token list by address / symbol / name via the view's search index */ +export function filterTokens(view: RegistryView, tokens: Token[], search?: string): Token[] { + const term = search?.trim().toLowerCase(); + if (!term) { + return tokens; + } + return tokens.filter((token) => view.searchText.get(token.id)?.includes(term)); +} + +/** Transferable source tokens, filtered by address / symbol / name search */ +export function getSourceTokens(view: RegistryView, search?: string): Token[] { + return filterTokens(view, view.sourceTokens, search); +} + +/** Materializes the route options for a source token, on demand */ +export function getRoutes(view: RegistryView, sourceTokenId: TokenId): RouteOption[] { + const address = addressFromTokenId(sourceTokenId); + const { canonical, cctp, layerzero, lifi } = view.sections; + const routes: RouteOption[] = []; + + const canonicalDestination = canonical?.get(address); + if (canonicalDestination) { + routes.push({ + provider: 'canonical', + sourceTokenId, + destinationTokenId: toTokenId(view.destinationChainId, canonicalDestination), + }); + } + + const cctpDestination = cctp?.get(address); + if (cctpDestination) { + routes.push({ + provider: 'cctp', + sourceTokenId, + destinationTokenId: toTokenId(view.destinationChainId, cctpDestination), + }); + } + + const layerzeroData = layerzero?.get(address); + if (layerzeroData) { + routes.push({ + provider: 'layerzero', + sourceTokenId, + destinationTokenId: toTokenId(view.destinationChainId, layerzeroData.destination), + oftAdapter: layerzeroData.oftAdapter, + destinationEndpointId: layerzeroData.endpointId, + }); + } + + if (lifi?.has(address)) { + const counterpart = lifi.get(address); + routes.push({ + provider: 'lifi', + sourceTokenId, + destinationTokenId: counterpart ? toTokenId(view.destinationChainId, counterpart) : undefined, + }); + } + + routes.push(...(view.overlayRoutes.get(sourceTokenId) ?? [])); + + return routes; +} + +/** Can this source token be swapped (i.e. does it have a lifi route)? */ +export function hasSwapRoute(view: RegistryView, sourceTokenId: TokenId): boolean { + return view.sections.lifi?.has(addressFromTokenId(sourceTokenId)) ?? false; +} + +/** + * Which tokens can this source token be swapped to? `swapDestinations` is + * the pair's lazily-fetched swap destination set. + */ +export function getSwapDestinationTokens({ + view, + sourceTokenId, + swapDestinations, +}: { + view: RegistryView; + sourceTokenId: TokenId; + swapDestinations: Address[]; +}): Token[] { + if (!hasSwapRoute(view, sourceTokenId)) { + return []; + } + + return swapDestinations + .map((destination) => view.tokens.get(toTokenId(view.destinationChainId, destination))) + .filter((token): token is Token => Boolean(token)); +} + +/** Fixed bridge outputs ∪ swap destination set (when provided) */ +export function getDestinationTokens({ + view, + sourceTokenId, + swapDestinations = [], +}: { + view: RegistryView; + sourceTokenId: TokenId; + swapDestinations?: Address[]; +}): Token[] { + const byId = new Map(); + + for (const route of getRoutes(view, sourceTokenId)) { + if (route.destinationTokenId) { + const token = view.tokens.get(route.destinationTokenId); + if (token) { + byId.set(token.id, token); + } + } + } + + for (const token of getSwapDestinationTokens({ view, sourceTokenId, swapDestinations })) { + byId.set(token.id, token); + } + + return [...byId.values()]; +} + +const defaultDestinationPriority: ProviderId[] = ['layerzero', 'cctp', 'canonical', 'lifi']; + +export function getDefaultDestinationToken( + view: RegistryView, + sourceTokenId: TokenId, +): Token | null { + const routes = getRoutes(view, sourceTokenId); + + for (const provider of defaultDestinationPriority) { + const route = routes.find((option) => option.provider === provider); + if (route?.destinationTokenId) { + return view.tokens.get(route.destinationTokenId) ?? null; + } + } + + return null; +} + +/** Routes able to deliver the chosen destination token */ +export function getRoutesForPair({ + view, + sourceTokenId, + destinationTokenId, + swapDestinations = [], +}: { + view: RegistryView; + sourceTokenId: TokenId; + destinationTokenId: TokenId; + swapDestinations?: Address[]; +}): RouteOption[] { + const destinationAddress = addressFromTokenId(destinationTokenId); + const inSwapSet = swapDestinations.includes(destinationAddress); + + return getRoutes(view, sourceTokenId).filter((route) => { + if (route.destinationTokenId === destinationTokenId) { + return true; + } + return route.provider === 'lifi' && inSwapSet; + }); +} diff --git a/packages/app/src/token-registry/server/generate.ts b/packages/app/src/token-registry/server/generate.ts new file mode 100644 index 000000000..46dae870b --- /dev/null +++ b/packages/app/src/token-registry/server/generate.ts @@ -0,0 +1,418 @@ +import { unstable_cache } from 'next/cache'; + +import { + ChainIds, + canonicalRouteExclusions, + cctpConfig, + getCuratedCoinKey, + hardcodedTokens, + isExcludedToken, + isSupportedPair, + layerZeroConfig, + supportedPairs, +} from '../constants'; +import { normalizeToken } from '../normalize'; +import { + Address, + ChainPair, + ChainTokens, + NATIVE_TOKEN_ADDRESS, + ProviderRoutes, + RouteMapArtifact, + Token, + TokenPayload, + pairKey, +} from '../types'; + +// in priority order — the first list defining a token wins +const ARBED_TOKEN_LIST_URLS = [ + 'https://tokenlist.arbitrum.io/ArbTokenLists/arbitrum_token_token_list.json', + 'https://tokenlist.arbitrum.io/ArbTokenLists/arbed_uniswap_labs_default.json', + 'https://tokenlist.arbitrum.io/ArbTokenLists/arbed_coingecko.json', + 'https://tokenlist.arbitrum.io/ArbTokenLists/arbed_coinmarketcap.json', + 'https://tokenlist.arbitrum.io/ArbTokenLists/arbed_arb_whitelist_era.json', +]; + +// bump to invalidate all unstable_cache entries when generation logic changes +const CACHE_VERSION = 5; + +const LIFI_CONNECTIONS_URL = 'https://li.quest/v1/connections'; +const LIFI_TOKENS_URL = 'https://li.quest/v1/tokens'; + +// --------------------------------------------------------------------------- +// canonical (generated from the arbified token lists) +// --------------------------------------------------------------------------- + +type TokenListEntry = { + chainId: number; + address: string; + name: string; + symbol: string; + decimals: number; + logoURI?: string; + extensions?: { + bridgeInfo?: Record; + }; +}; + +async function fetchArbedTokenList(url: string): Promise { + const response = await fetch(url, { + next: { revalidate: 86_400 }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch arbed token list ${url}: ${response.status}`); + } + const list = (await response.json()) as { tokens: TokenListEntry[] }; + return list.tokens; +} + +/** all lists concatenated in priority order; a failing list is skipped */ +async function fetchArbedTokenLists(): Promise { + const lists = await Promise.all( + ARBED_TOKEN_LIST_URLS.map(async (url) => { + try { + return await fetchArbedTokenList(url); + } catch (error) { + console.warn(`[token-registry] skipping token list: ${error}`); + return []; + } + }), + ); + return lists.flat(); +} + +export type CanonicalEntry = { parent: Token; child: Token }; + +function getCanonicalEntries(listTokens: TokenListEntry[]): CanonicalEntry[] { + const entries: CanonicalEntry[] = []; + const seenChildAddresses = new Set(); + + for (const entry of listTokens) { + if (entry.chainId !== ChainIds.ArbitrumOne) { + continue; + } + const parentAddress = entry.extensions?.bridgeInfo?.[String(ChainIds.Ethereum)]?.tokenAddress; + if (!parentAddress) { + continue; + } + const childAddress = entry.address.toLowerCase(); + if (seenChildAddresses.has(childAddress)) { + continue; + } + seenChildAddresses.add(childAddress); + if ( + isExcludedToken(ChainIds.Ethereum, parentAddress) || + isExcludedToken(ChainIds.ArbitrumOne, childAddress) + ) { + continue; + } + + const metadata = { + symbol: entry.symbol, + name: entry.name, + decimals: entry.decimals, + logoURI: entry.logoURI, + }; + entries.push({ + parent: normalizeToken({ + chainId: ChainIds.Ethereum, + address: parentAddress, + ...metadata, + }), + child: normalizeToken({ + chainId: ChainIds.ArbitrumOne, + address: entry.address, + ...metadata, + }), + }); + } + + return entries; +} + +export function generateCanonical( + entries: CanonicalEntry[], + pair: ChainPair, +): Extract { + const excluded = new Set(canonicalRouteExclusions[pairKey(pair)] ?? []); + const routes: Record = { + // native ETH bridges canonically in both directions + [NATIVE_TOKEN_ADDRESS]: NATIVE_TOKEN_ADDRESS, + }; + + for (const { parent, child } of entries) { + const [source, destination]: [Token, Token] = + pair.sourceChainId === ChainIds.Ethereum ? [parent, child] : [child, parent]; + if (excluded.has(source.address)) { + continue; + } + routes[source.address] = destination.address; + } + + return { provider: 'canonical', routes }; +} + +// --------------------------------------------------------------------------- +// lifi (generated from the connections + tokens APIs; coinKey stays +// server-side) +// --------------------------------------------------------------------------- + +// the connections API only returns membership (address + chainId); +// metadata and coinKey come from the tokens API +type LifiConnectionToken = { + address: string; + chainId: number; +}; + +type LifiConnection = { + fromChainId: number; + toChainId: number; + fromTokens: LifiConnectionToken[]; + toTokens: LifiConnectionToken[]; +}; + +type LifiTokenMetadata = { + address: string; + chainId: number; + symbol: string; + decimals: number; + name: string; + coinKey?: string; + logoURI?: string; +}; + +function lifiHeaders(): Headers { + const headers = new Headers(); + const apiKey = process.env.LIFI_KEY; + if (apiKey) { + headers.set('x-lifi-api-key', apiKey); + } + return headers; +} + +async function fetchLifiConnection(pair: ChainPair): Promise { + const url = new URL(LIFI_CONNECTIONS_URL); + url.searchParams.set('fromChain', String(pair.sourceChainId)); + url.searchParams.set('toChain', String(pair.destinationChainId)); + + const response = await fetch(url.toString(), { + headers: lifiHeaders(), + next: { revalidate: 3_600 }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch LiFi connections: ${response.status}`); + } + + const data = (await response.json()) as { connections: LifiConnection[] }; + return ( + data.connections.find( + (connection) => + connection.fromChainId === pair.sourceChainId && + connection.toChainId === pair.destinationChainId, + ) ?? null + ); +} + +// one request per chain: each response fits Next's 2MB fetch-cache limit +// (the combined one does not) and is reused across every pair touching the +// chain +async function fetchLifiChainTokens(chainId: number): Promise { + const url = new URL(LIFI_TOKENS_URL); + url.searchParams.set('chains', String(chainId)); + + const response = await fetch(url.toString(), { + headers: lifiHeaders(), + next: { revalidate: 3_600 }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch LiFi tokens for chain ${chainId}: ${response.status}`); + } + + const data = (await response.json()) as { + tokens: Record; + }; + return data.tokens[String(chainId)] ?? []; +} + +/** metadata + coinKey for LiFi tokens on the given chains, keyed by `${chainId}:${address}` */ +async function fetchLifiTokenMetadata(chainIds: number[]): Promise> { + const perChain = await Promise.all(chainIds.map(fetchLifiChainTokens)); + + const byTokenId = new Map(); + for (const chainTokens of perChain) { + for (const metadata of chainTokens) { + byTokenId.set(`${metadata.chainId}:${metadata.address.toLowerCase()}`, metadata); + } + } + return byTokenId; +} + +async function generateLifi(pair: ChainPair): Promise<{ + providerRoutes: ProviderRoutes | null; + swapDestinations: Address[]; + tokens: Token[]; +}> { + const [connection, metadataByTokenId] = await Promise.all([ + fetchLifiConnection(pair), + fetchLifiTokenMetadata([pair.sourceChainId, pair.destinationChainId]), + ]); + if (!connection) { + return { providerRoutes: null, swapDestinations: [], tokens: [] }; + } + + const toLifiToken = (raw: LifiConnectionToken): { token: Token; coinKey?: string } | null => { + if (isExcludedToken(raw.chainId, raw.address)) { + return null; + } + const metadata = metadataByTokenId.get(`${raw.chainId}:${raw.address.toLowerCase()}`); + if (!metadata) { + // without metadata the token cannot be displayed — leave it out + return null; + } + return { + token: normalizeToken({ + chainId: raw.chainId, + address: raw.address, + symbol: metadata.symbol, + name: metadata.name, + decimals: metadata.decimals, + logoURI: metadata.logoURI, + }), + // curated assignments cover tokens LiFi gives no coinKey (PYUSD, ENA) + coinKey: getCuratedCoinKey(raw.chainId, raw.address) ?? metadata.coinKey, + }; + }; + + const tokens: Token[] = []; + const counterpartByCoinKey = new Map(); + const swapDestinations: Address[] = []; + + for (const raw of connection.toTokens) { + const resolved = toLifiToken(raw); + if (!resolved) { + continue; + } + tokens.push(resolved.token); + swapDestinations.push(resolved.token.address); + if (resolved.coinKey && !counterpartByCoinKey.has(resolved.coinKey)) { + counterpartByCoinKey.set(resolved.coinKey, resolved.token.address); + } + } + + const routes: Record = {}; + for (const raw of connection.fromTokens) { + const resolved = toLifiToken(raw); + if (!resolved) { + continue; + } + tokens.push(resolved.token); + routes[resolved.token.address] = resolved.coinKey + ? (counterpartByCoinKey.get(resolved.coinKey) ?? null) + : null; + } + + return { providerRoutes: { provider: 'lifi', routes }, swapDestinations, tokens }; +} + +// --------------------------------------------------------------------------- +// registry assembly +// --------------------------------------------------------------------------- + +async function buildChainTokens(chainId: number): Promise { + const listTokens = await fetchArbedTokenLists(); + const lifiResults = await Promise.all(supportedPairs.map((pair) => generateLifi(pair))); + + const tokens: ChainTokens = {}; + const add = (token: Token) => { + if (isExcludedToken(token.chainId, token.address)) { + return; + } + if (token.chainId === chainId && !tokens[token.id]) { + tokens[token.id] = token; + } + }; + + for (const token of hardcodedTokens) { + add(token); + } + for (const { parent, child } of getCanonicalEntries(listTokens)) { + add(parent); + add(child); + } + for (const result of lifiResults) { + for (const token of result.tokens) { + add(token); + } + } + + // wire format: id and chainId are derivable from the endpoint — stripped + // here, re-derived during client hydration + return Object.values(tokens).map(({ id: _id, chainId: _chainId, ...payload }) => payload); +} + +async function buildRouteMap(pair: ChainPair): Promise { + const providers: ProviderRoutes[] = []; + + const listTokens = await fetchArbedTokenLists(); + providers.push(generateCanonical(getCanonicalEntries(listTokens), pair)); + + const cctp = cctpConfig[pairKey(pair)]; + if (cctp) { + providers.push({ provider: 'cctp', routes: cctp }); + } + + const layerzero = layerZeroConfig[pairKey(pair)]; + if (layerzero) { + providers.push({ provider: 'layerzero', routes: layerzero }); + } + + const { providerRoutes } = await generateLifi(pair); + if (providerRoutes) { + providers.push(providerRoutes); + } + + return { + sourceChainId: pair.sourceChainId, + destinationChainId: pair.destinationChainId, + providers, + }; +} + +export function getChainTokens(chainId: number): Promise { + return unstable_cache( + () => buildChainTokens(chainId), + [`token-registry-tokens-${chainId}-v${CACHE_VERSION}`], + { + revalidate: 3_600, + tags: [`token-registry-tokens-${chainId}-v${CACHE_VERSION}`], + }, + )(); +} + +export function getSwapDestinations(pair: ChainPair): Promise { + if (!isSupportedPair(pair)) { + return Promise.resolve(null); + } + return unstable_cache( + async () => (await generateLifi(pair)).swapDestinations, + [`token-registry-swap-destinations-${pairKey(pair)}-v${CACHE_VERSION}`], + { + revalidate: 3_600, + tags: [`token-registry-swap-destinations-${pairKey(pair)}-v${CACHE_VERSION}`], + }, + )(); +} + +export function getRouteMap(pair: ChainPair): Promise { + if (!isSupportedPair(pair)) { + return Promise.resolve(null); + } + return unstable_cache( + () => buildRouteMap(pair), + [`token-registry-routes-${pairKey(pair)}-v${CACHE_VERSION}`], + { + revalidate: 3_600, + tags: [`token-registry-routes-${pairKey(pair)}-v${CACHE_VERSION}`], + }, + )(); +} diff --git a/packages/app/src/token-registry/server/resolveImport.ts b/packages/app/src/token-registry/server/resolveImport.ts new file mode 100644 index 000000000..ed9901e90 --- /dev/null +++ b/packages/app/src/token-registry/server/resolveImport.ts @@ -0,0 +1,255 @@ +import { createPublicClient, http, isAddress } from 'viem'; + +import { rpcURLs } from '@/bridge/util/networks'; + +import { ChainIds, canonicalRouteExclusions } from '../constants'; +import { normalizeToken } from '../normalize'; +import { Address, ChainPair, ImportResolution, pairKey } from '../types'; + +// L1GatewayRouter for Arbitrum One +const L1_GATEWAY_ROUTER = '0x72ce9c846789fdb6fc1f34ac4ad25dd9ef7031ef' as Address; + +const erc20Abi = [ + { + type: 'function', + name: 'symbol', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'string' }], + }, + { + type: 'function', + name: 'name', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'string' }], + }, + { + type: 'function', + name: 'decimals', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint8' }], + }, +] as const; + +// the router maps deposit-disabled tokens to this sentinel gateway +const DISABLED_GATEWAY = '0x0000000000000000000000000000000000000001'; + +const gatewayRouterAbi = [ + { + type: 'function', + name: 'calculateL2TokenAddress', + stateMutability: 'view', + inputs: [{ name: 'l1ERC20', type: 'address' }], + outputs: [{ type: 'address' }], + }, + { + type: 'function', + name: 'l1TokenToGateway', + stateMutability: 'view', + inputs: [{ name: 'l1ERC20', type: 'address' }], + outputs: [{ type: 'address' }], + }, +] as const; + +const arbStandardTokenAbi = [ + { + type: 'function', + name: 'l1Address', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'address' }], + }, +] as const; + +function getPublicClient(chainId: number) { + const url = rpcURLs[chainId]; + if (!url) { + throw new Error(`No RPC URL configured for chain ${chainId}`); + } + return createPublicClient({ transport: http(url) }); +} + +type Erc20Metadata = { symbol: string; name: string; decimals: number }; + +async function readErc20Metadata(chainId: number, address: Address): Promise { + const client = getPublicClient(chainId); + try { + const [symbol, name, decimals] = await Promise.all([ + client.readContract({ address, abi: erc20Abi, functionName: 'symbol' }), + client.readContract({ address, abi: erc20Abi, functionName: 'name' }), + client.readContract({ + address, + abi: erc20Abi, + functionName: 'decimals', + }), + ]); + return { symbol, name, decimals }; + } catch { + return null; + } +} + +async function deriveChildAddress(parentAddress: Address): Promise
{ + try { + const child = await getPublicClient(ChainIds.Ethereum).readContract({ + address: L1_GATEWAY_ROUTER, + abi: gatewayRouterAbi, + functionName: 'calculateL2TokenAddress', + args: [parentAddress], + }); + return child.toLowerCase() as Address; + } catch { + return null; + } +} + +/** + * Registration check: calculateL2TokenAddress derives an address for ANY + * input, so a token whose deposits are disabled on the router would + * otherwise yield a false-positive canonical route. + */ +async function isDepositDisabled(parentAddress: Address): Promise { + try { + const gateway = await getPublicClient(ChainIds.Ethereum).readContract({ + address: L1_GATEWAY_ROUTER, + abi: gatewayRouterAbi, + functionName: 'l1TokenToGateway', + args: [parentAddress], + }); + return gateway.toLowerCase() === DISABLED_GATEWAY; + } catch { + // cannot verify → do not import + return true; + } +} + +async function resolveDeposit(parentAddress: Address): Promise { + const parentMetadata = await readErc20Metadata(ChainIds.Ethereum, parentAddress); + if (!parentMetadata) { + return null; + } + + if (await isDepositDisabled(parentAddress)) { + return null; + } + + const childAddress = await deriveChildAddress(parentAddress); + if (!childAddress) { + return null; + } + + // the child contract only deploys on first deposit — fall back to the + // parent's metadata when it doesn't exist yet + const childMetadata = + (await readErc20Metadata(ChainIds.ArbitrumOne, childAddress)) ?? parentMetadata; + + const parentToken = normalizeToken({ + chainId: ChainIds.Ethereum, + address: parentAddress, + ...parentMetadata, + }); + const childToken = normalizeToken({ + chainId: ChainIds.ArbitrumOne, + address: childAddress, + ...childMetadata, + }); + + return { + tokens: [parentToken, childToken], + routes: [ + { + provider: 'canonical', + sourceTokenId: parentToken.id, + destinationTokenId: childToken.id, + }, + ], + }; +} + +async function resolveWithdrawal(childAddress: Address): Promise { + const childMetadata = await readErc20Metadata(ChainIds.ArbitrumOne, childAddress); + if (!childMetadata) { + return null; + } + + let parentAddress: Address; + try { + const result = await getPublicClient(ChainIds.ArbitrumOne).readContract({ + address: childAddress, + abi: arbStandardTokenAbi, + functionName: 'l1Address', + }); + parentAddress = result.toLowerCase() as Address; + } catch { + // not a standard bridged token — no canonical withdrawal route + return null; + } + + // confirm the parent → child derivation round-trips to the pasted token + const derivedChild = await deriveChildAddress(parentAddress); + if (derivedChild !== childAddress) { + return null; + } + + const parentMetadata = + (await readErc20Metadata(ChainIds.Ethereum, parentAddress)) ?? childMetadata; + + const childToken = normalizeToken({ + chainId: ChainIds.ArbitrumOne, + address: childAddress, + ...childMetadata, + }); + const parentToken = normalizeToken({ + chainId: ChainIds.Ethereum, + address: parentAddress, + ...parentMetadata, + }); + + return { + tokens: [childToken, parentToken], + routes: [ + { + provider: 'canonical', + sourceTokenId: childToken.id, + destinationTokenId: parentToken.id, + }, + ], + }; +} + +/** + * canonical.resolve() — import only considers the canonical provider: CCTP + * and LayerZero are hardcoded (complete by definition) and the LiFi snapshot + * is exhaustive by construction. + */ +export async function resolveImport( + pair: ChainPair, + rawAddress: string, +): Promise { + if (!isAddress(rawAddress)) { + return null; + } + const address = rawAddress.toLowerCase() as Address; + + // tokens whose canonical transfer is disabled on this pair (e.g. PYUSD + // deposits) must not be importable through canonical either + if (canonicalRouteExclusions[pairKey(pair)]?.includes(address)) { + return null; + } + + if ( + pair.sourceChainId === ChainIds.Ethereum && + pair.destinationChainId === ChainIds.ArbitrumOne + ) { + return resolveDeposit(address); + } + if ( + pair.sourceChainId === ChainIds.ArbitrumOne && + pair.destinationChainId === ChainIds.Ethereum + ) { + return resolveWithdrawal(address); + } + return null; +} diff --git a/packages/app/src/token-registry/types.ts b/packages/app/src/token-registry/types.ts new file mode 100644 index 000000000..17441ca24 --- /dev/null +++ b/packages/app/src/token-registry/types.ts @@ -0,0 +1,102 @@ +export type Address = `0x${string}`; + +/** `${chainId}:${lowercaseAddress}` — native tokens use the zero address */ +export type TokenId = `${number}:${Address}`; + +export const NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +export type Token = { + id: TokenId; + chainId: number; + address: Address; + symbol: string; + name: string; + decimals: number; + logoURI?: string; +}; + +export type ChainTokens = Record; + +/** + * Wire format of /api/tokens/[chainId]: `id` and `chainId` are derivable + * from the endpoint, so they are stripped server-side and re-derived during + * client hydration (see view.ts). + */ +export type TokenPayload = Omit; + +export type ProviderId = 'canonical' | 'cctp' | 'layerzero' | 'lifi'; + +export type LayerZeroRouteData = { + destination: Address; + oftAdapter: Address; + endpointId: number; +}; + +export type ProviderRoutes = + | { provider: 'canonical'; routes: Record } + | { provider: 'cctp'; routes: Record } + | { provider: 'layerzero'; routes: Record } + | { + provider: 'lifi'; + /** + * source → same-asset counterpart (coinKey match); null → swap-only. + * The pair's swap destination set is a separate, lazily-fetched + * resource (see /api/token-registry/swap-destinations). + */ + routes: Record; + }; + +export type RouteMapArtifact = { + sourceChainId: number; + destinationChainId: number; + /** only providers that serve this pair */ + providers: ProviderRoutes[]; +}; + +export type RouteOption = + | { + provider: 'canonical'; + sourceTokenId: TokenId; + destinationTokenId: TokenId; + } + | { provider: 'cctp'; sourceTokenId: TokenId; destinationTokenId: TokenId } + | { + provider: 'layerzero'; + sourceTokenId: TokenId; + destinationTokenId: TokenId; + oftAdapter: Address; + destinationEndpointId: number; + } + | { + provider: 'lifi'; + sourceTokenId: TokenId; + /** same-asset counterpart; undefined → swap-only */ + destinationTokenId?: TokenId; + }; + +/** Result of canonical.resolve(); also the import API response */ +export type ImportResolution = { + tokens: Token[]; + routes: RouteOption[]; +}; + +export type ChainPair = { + sourceChainId: number; + destinationChainId: number; +}; + +export function toTokenId(chainId: number, address: string): TokenId { + return `${chainId}:${address.toLowerCase() as Address}`; +} + +export function addressFromTokenId(id: TokenId): Address { + return id.slice(id.indexOf(':') + 1) as Address; +} + +export function isNativeToken(token: Token): boolean { + return token.address === NATIVE_TOKEN_ADDRESS; +} + +export function pairKey({ sourceChainId, destinationChainId }: ChainPair) { + return `${sourceChainId}->${destinationChainId}`; +} diff --git a/packages/app/src/token-registry/view.ts b/packages/app/src/token-registry/view.ts new file mode 100644 index 000000000..0b6bf99aa --- /dev/null +++ b/packages/app/src/token-registry/view.ts @@ -0,0 +1,143 @@ +import { + Address, + ImportResolution, + LayerZeroRouteData, + RouteMapArtifact, + RouteOption, + Token, + TokenId, + TokenPayload, + toTokenId, +} from './types'; + +export type RegistrySections = { + canonical?: Map; + cctp?: Map; + layerzero?: Map; + lifi?: Map; +}; + +/** + * Hydrated, per-pair view of the registry — built once when a pair loads + * (artifact + both chains' token payloads + session overlay), then read by + * every selector. Joining, sorting and search-text lowercasing happen here, + * not per keystroke. + */ +export type RegistryView = { + sourceChainId: number; + destinationChainId: number; + tokens: Map; + /** transferable source tokens — session-imported first, then sorted by symbol */ + sourceTokens: Token[]; + /** lowercase `${symbol} ${name} ${address}` per token, both chains + overlay */ + searchText: Map; + sections: RegistrySections; + /** session-imported routes, keyed by source token id */ + overlayRoutes: Map; +}; + +/** Re-derives the fields stripped from the wire format */ +export function hydrateTokens(chainId: number, payload: TokenPayload[]): Token[] { + return payload.map((token) => ({ + ...token, + chainId, + id: toTokenId(chainId, token.address), + })); +} + +export function buildRegistryView({ + artifact, + sourceChainTokens, + destinationChainTokens, + overlays = [], +}: { + artifact: RouteMapArtifact; + sourceChainTokens: Token[]; + destinationChainTokens: Token[]; + overlays?: ImportResolution[]; +}): RegistryView { + const tokens = new Map(); + for (const token of sourceChainTokens) { + tokens.set(token.id, token); + } + for (const token of destinationChainTokens) { + tokens.set(token.id, token); + } + for (const resolution of overlays) { + for (const token of resolution.tokens) { + tokens.set(token.id, token); + } + } + + const sections: RegistrySections = {}; + for (const section of artifact.providers) { + switch (section.provider) { + case 'canonical': + sections.canonical = new Map(Object.entries(section.routes) as [Address, Address][]); + break; + case 'cctp': + sections.cctp = new Map(Object.entries(section.routes) as [Address, Address][]); + break; + case 'layerzero': + sections.layerzero = new Map( + Object.entries(section.routes) as [Address, LayerZeroRouteData][], + ); + break; + case 'lifi': + sections.lifi = new Map(Object.entries(section.routes) as [Address, Address | null][]); + break; + } + } + + const overlayRoutes = new Map(); + for (const resolution of overlays) { + for (const route of resolution.routes) { + overlayRoutes.set(route.sourceTokenId, [ + ...(overlayRoutes.get(route.sourceTokenId) ?? []), + route, + ]); + } + } + + const seen = new Set(); + const imported: Token[] = []; + for (const sourceTokenId of overlayRoutes.keys()) { + const token = tokens.get(sourceTokenId); + if (token && token.chainId === artifact.sourceChainId && !seen.has(token.id)) { + seen.add(token.id); + imported.push(token); + } + } + + const base: Token[] = []; + for (const sectionMap of [sections.canonical, sections.cctp, sections.layerzero, sections.lifi]) { + if (!sectionMap) { + continue; + } + for (const address of sectionMap.keys()) { + const token = tokens.get(toTokenId(artifact.sourceChainId, address)); + if (token && !seen.has(token.id)) { + seen.add(token.id); + base.push(token); + } + } + } + base.sort((a, b) => a.symbol.localeCompare(b.symbol)); + + const sourceTokens = [...imported, ...base]; + + const searchText = new Map(); + for (const token of tokens.values()) { + searchText.set(token.id, `${token.symbol} ${token.name} ${token.address}`.toLowerCase()); + } + + return { + sourceChainId: artifact.sourceChainId, + destinationChainId: artifact.destinationChainId, + tokens, + sourceTokens, + searchText, + sections, + overlayRoutes, + }; +} diff --git a/packages/arb-token-bridge-ui/docs/token-registry.md b/packages/arb-token-bridge-ui/docs/token-registry.md new file mode 100644 index 000000000..45871e428 --- /dev/null +++ b/packages/arb-token-bridge-ui/docs/token-registry.md @@ -0,0 +1,689 @@ +# Token Registry & Routes — Design + +## Requirements + +- one source token to multiple destination tokens +- multiple routes for the same source token +- per-chain normalization of token metadata, baked directly into the token (no override layer) +- native tokens, represented with the zero address (`0x0000...0000`) +- answer "which tokens on the source chain can be transferred to the destination chain?" +- LiFi swap: source and destination tokens can be unrelated assets; some tokens + are only transferable via swap (e.g. APE on ApeChain → USDC/ETH on Superposition) +- list which destination tokens a `(token, sourceChain, destinationChain)` can be swapped to +- LiFi fastest / cheapest information, with per-route data (gas estimates, fees, ...) +- user-imported tokens, session-only, going through the same architecture +- CCTP and LayerZero hardcoded; canonical and LiFi generated programmatically +- scale to 5-digit token counts per chain pair +- no rebuild to refresh data — served through Next.js API routes with caching + +## Architecture overview + +Two strictly separated layers: + +1. **Tokens** — normalized per-chain metadata. One record per `(chainId, address)`. +2. **Routes** — transferability facts, stored compactly per ordered chain pair, + materialized into rich route objects on demand for the selected token only. + +On top of those, a **quote layer** handles amount-specific runtime data +(received amount, gas, fees, fastest/cheapest variants). + +The UI only ever sees normalized `Token`s, materialized `RouteOption`s, and +`RouteQuote`s. + +## Token model + +```ts +type Address = `0x${string}`; // always lowercase +type TokenId = `${number}:${Address}`; // `${chainId}:${address}` + +const NATIVE = '0x0000000000000000000000000000000000000000'; + +type Token = { + id: TokenId; + chainId: number; + address: Address; // NATIVE for the chain's native token + symbol: string; + name: string; + decimals: number; + logoURI?: string; +}; +``` + +- Native tokens are ordinary `Token`s with the zero address. No sentinel, no + special type. `isNative` is derived (`address === NATIVE`). This matches the + LiFi API directly and handles chains whose native token is not ETH (APE on + ApeChain). +- Normalization (USDT0 naming, APE logo, ETH logo, ...) is applied while + generating the token record. The emitted `Token` is final — no override + object exists anywhere at runtime. +- Provider-internal concepts (LiFi's `coinKey`, ...) never appear on `Token`. + They are consumed server-side during generation only. + +### Token registry + +Global registry, sharded per chain: + +``` +GET /api/tokens/[chainId] → TokenPayload[] // Omit +``` + +Token metadata exists once per chain, shared by every chain pair touching that +chain — not duplicated per pair. The wire format strips `id` and `chainId` +(both derivable from the endpoint, ~30% of the payload); the client re-derives +them during hydration. + +## Route storage (compact, per ordered chain pair) + +Routes are stored per **ordered** pair. Pairs are only generated for supported +directions (some pairs are deposit-only or withdrawal-only). An unsupported +direction is a 404, not an empty artifact. + +A provider that does not serve a pair is **absent** — never an empty +placeholder. + +```ts +type ProviderRoutes = + | { provider: 'canonical'; routes: Record } // source → destination address + | { provider: 'cctp'; routes: Record } // source → destination address + | { + provider: 'layerzero'; + routes: Record< + Address, + { + destination: Address; + oftAdapter: Address; + endpointId: number; + } + >; + } + | { + provider: 'lifi'; + // source → same-asset counterpart (resolved at generation via LiFi's + // coinKey, server-side); null → swap-only + routes: Record; + }; + +type RouteMapArtifact = { + sourceChainId: number; + destinationChainId: number; + providers: ProviderRoutes[]; // only providers that serve this pair +}; +``` + +``` +GET /api/routes/[sourceChainId]/[destinationChainId] → RouteMapArtifact +GET /api/swap-destinations/[sourceChainId]/[destinationChainId] → Address[] +``` + +The pair's **swap destination set** (LiFi `toTokens`) is not in the artifact: +most sessions are regular bridges or same-asset LiFi transfers and never need +it, and it is the single largest block of pair data (it more than halved the +withdrawal artifact when split out). It is its own lazily-fetched resource, +requested only when the user opens the destination picker on a swap-capable +token. Whether a token _can_ swap stays answerable from the artifact alone +(`address in lifi.routes`). + +Why this shape scales to 10k+ tokens: + +- the provider tag is stored once per section, not once per route +- chain ids are implied by the artifact, so entries are bare addresses +- every section is just addresses keyed by source address; the bulky swap + destination set ships separately, on demand +- nothing derivable client-side is persisted + +Escape hatches if size still hurts (not needed initially): + +- reference tokens by integer index into a per-chain token array instead of + address strings +- compute standard-gateway canonical child addresses offline (CREATE2 + derivation) and store only a source-address list plus a small + custom-gateway exceptions map + +### Integrity rule + +Every address emitted into a route artifact must resolve in the corresponding +chain's token registry. When the canonical generator derives USDC.e's address +on Arbitrum One, it also emits USDC.e's `Token` record into the Arbitrum One +registry. A route must never point at a token the registry cannot resolve. + +## Materialized routes (runtime API) + +`RouteOption[]` is never stored or shipped in bulk. When the user selects a +source token, selectors check it against each provider section and build the +2–3 route objects for that token only. + +```ts +type RouteOption = + | { provider: 'canonical'; sourceTokenId: TokenId; destinationTokenId: TokenId } + | { provider: 'cctp'; sourceTokenId: TokenId; destinationTokenId: TokenId } + | { + provider: 'layerzero'; + sourceTokenId: TokenId; + destinationTokenId: TokenId; + oftAdapter: Address; + destinationEndpointId: number; + } + | { + provider: 'lifi'; + sourceTokenId: TokenId; + // from lifi.routes; undefined → swap-only + destinationTokenId?: TokenId; + }; +``` + +### LiFi same-asset counterpart + +The counterpart mapping is resolved **at generation time, server-side**, using +LiFi's `coinKey` from its tokens API: a source token whose coinKey has a match +on the destination chain gets that match as its stored value in `lifi.routes`; +otherwise the value is `null`. The client never sees coinKey — it just reads +the stored counterpart (or `null` → the user must pick from the swap set). + +LiFi assigns no coinKey to some tokens (PYUSD, ENA, ...), so a small curated +coinKey table fills the gaps before matching — same approach as +`CUSTOM_TOKENS` in the old LiFi tokens API +(`app/api/crosschain-transfers/lifi/tokens/registry.ts`). + +### Swap is derived, not stored + +"Can this token be swapped?" is not a flag on the token or the route: + +```ts +const canSwap = (sourceToken) => sourceToken.address in lifi.routes; +// possible outputs = the pair's lazily-fetched swap destination set +``` + +## Quotes (runtime, amount-specific) + +The registry answers "can it move"; quotes answer "how well" once an amount is +known. `lifi-fastest` / `lifi-cheapest` are quote results, not registry routes. + +```ts +type QuoteRequest = { + sourceTokenId: TokenId; + destinationTokenId: TokenId; + amount: bigint; + slippage?: number; +}; + +type QuoteCommon = { + amountReceived: bigint; + gasEstimate: { amount: bigint; token: TokenId; amountUSD?: number }; + fees?: { amount: bigint; token: TokenId; amountUSD?: number }[]; + durationMs?: number; +}; + +type RouteQuote = QuoteCommon & + ( + | { provider: 'canonical' } + | { provider: 'cctp' } + | { provider: 'layerzero'; nativeFee: bigint; lzTokenFee: bigint } + | { + provider: 'lifi'; + variants: ('fastest' | 'cheapest')[]; // merged when identical + step: LifiStep; + tool: ToolDetails; + } + ); +``` + +Quoting a LiFi route returns up to two quotes (fastest and cheapest), collapsed +into one with `variants: ['fastest', 'cheapest']` when they are the same +underlying route. Gas estimates, fees and duration come from the LiFi routes +API onto the quote. + +## Selectors + +```ts +// hydration — once per pair load (view.ts) +hydrateTokens(chainId, payload): Token[] +buildRegistryView({ artifact, sourceChainTokens, destinationChainTokens, overlays? }): RegistryView + +// selectors — all read the hydrated view (selectors.ts) +getSourceTokens(view, search?): Token[] +// the precomputed source list, filtered via the search index + +filterTokens(view, tokens, search?): Token[] +// generic search primitive (also used by the destination picker) + +getRoutes(view, sourceTokenId): RouteOption[] +// section-map lookups + session-overlay routes + +hasSwapRoute(view, sourceTokenId): boolean +// lifi section membership — answerable without the swap set + +getDestinationTokens({ view, sourceTokenId, swapDestinations? }): Token[] +// fixed outputs ∪ swap set (when the swap set has been fetched) + +getSwapDestinationTokens({ view, sourceTokenId, swapDestinations }): Token[] + +getDefaultDestinationToken(view, sourceTokenId): Token | null +// priority: layerzero > cctp > canonical > lifi counterpart + +getRoutesForPair({ view, sourceTokenId, destinationTokenId, swapDestinations? }): RouteOption[] +// routes whose destinationTokenId matches +// + the lifi route if destinationTokenId ∈ swapDestinations +``` + +`getRoutesForPair` feeds the quote layer once a destination is chosen: +pick USDC.e → canonical (+ lifi); pick native USDC → cctp (+ lifi); +pick ETH → lifi swap only. + +## Provider interface — one machinery for generation and quoting + +```ts +interface RouteProvider { + id: ProviderId; + + // server: everything this provider supports for an ordered pair + generate(pair: ChainPair): Promise<{ + tokens: Token[]; + providerRoutes: ProviderRoutes | null; // null → absent from artifact + }>; + + // runtime: quote a materialized route + quote(route: RouteOption, request: QuoteRequest): Promise; +} + +// canonical only — used by the import flow +interface CanonicalProvider extends RouteProvider { + resolve( + pair: ChainPair, + sourceTokenAddress: Address, + ): Promise<{ + tokens: Token[]; // source + destination Token records + routes: RouteOption[]; + } | null>; +} +``` + +| Provider | generate | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| canonical | merge the arbed token lists (priority-ordered, first list wins, deduped by child address; a failing list is skipped) → one route per entry per direction | +| cctp | hardcoded entries (native USDC, Ethereum↔ArbitrumOne, Sepolia↔ArbitrumSepolia) | +| layerzero | hardcoded entries (USDT@Ethereum ↔ USDT0@ArbitrumOne, adapter addresses) | +| lifi | connections API (membership only — bare addresses) joined with **per-chain** tokens API calls (metadata + coinKey; one request per chain so each response fits Next's 2MB fetch-cache entry limit and is reused across pairs) → normalized tokens, counterpart mapping, swap set | + +Only canonical implements `resolve`: CCTP and LayerZero are hardcoded and +therefore complete, and the LiFi snapshot is exhaustive by construction — a +token missing from the artifact is definitively unsupported by those +providers, so import never probes them. + +### Canonical exclusions + +Some tokens must not move through the canonical bridge even though the token +lists would generate a route for them — e.g. PYUSD deposits are LiFi-only +(its withdrawal, `PYUSD_CANONICAL` → Ethereum PYUSD, stays canonical). This +is data, not code: a per-ordered-pair exclusion list of source addresses +(`canonicalRouteExclusions`), applied by the canonical generator **and** by +`canonical.resolve`, so an excluded token cannot come back through import. +The token records themselves are still emitted — the token stays visible for +its remaining routes. + +## Serving & caching (no build-time JSON) + +Generation runs inside Next.js route handlers; freshness comes from caching, +not rebuilds: + +- handlers wrap their compute in `unstable_cache` with a tag + (`tokens-42161`, `routes-1-42161`) and a `revalidate` window — long for + token registries (~24h), shorter for LiFi-backed route artifacts (~1h) +- upstream fetches (token lists, LiFi connections API) use + `fetch` with `next: { revalidate }` and are cached independently +- `revalidateTag` allows on-demand invalidation +- responses send `Cache-Control: s-maxage, stale-while-revalidate` so the CDN + absorbs traffic; the client consumes through SWR +- CCTP / LayerZero entries are constants in handler code — changing them is a + deploy, by design +- all cache keys embed a `CACHE_VERSION` constant — bump it whenever + generation logic changes, so stale entries can never serve old shapes + (`unstable_cache` entries survive dev-server restarts and even `.next` + deletion is not always enough) + +New LiFi tokens appear when the cache revalidates. No rebuild, no deploy. + +## Client data flow + +The bridge always has an active ordered pair (source chain, destination +chain). When the pair is set — initial load, direction flip, or chain change — +the client fetches (via SWR): + +- `/api/tokens/[sourceChainId]` and `/api/tokens/[destinationChainId]` +- `/api/routes/[sourceChainId]/[destinationChainId]` + +Those three payloads are hydrated **once** into a `RegistryView` — token +`Map`s, the joined + sorted source-token list, a precomputed lowercase search +index, per-provider section `Map`s, and the merged session overlay. All +selectors read the view, so searching and selecting cost an `includes()` / +`Map.get` instead of re-joining 6k tokens per keystroke. + +The view is the single availability source for the pair: it powers the token +picker list, search, route materialization on token select, fixed destination +token options, and the default destination. Token registries are shared +across pairs, so flipping direction only fetches the reverse route artifact. + +`/api/swap-destinations/[src]/[dst]` is fetched lazily — only when the user +opens the destination picker on a token with a lifi route. Quotes +(`amount`-specific) and import resolution are separate calls — the routes +endpoint only answers "what exists on this pair". + +## User-imported tokens (session-only) + +The user pastes an address on the source chain — typically a token our lists +missed. Import only considers **canonical** (the other providers are complete +by construction, see above): + +``` +1. lowercase; if already resolvable in base or overlay → just select it +2. run canonical.resolve(pair, address): derive the child (or parent) address + on-chain, check registration, fetch metadata for both Token records +3. null → "not transferable to " +4. otherwise merge the returned tokens + route into an in-memory session + overlay (same shapes as registry + artifact fragments) +``` + +Selectors read base + overlay, so imported tokens get search, route listing, +swap destinations and quoting identically to generated ones. Nothing is +persisted — the overlay lives for the browser session only. + +## Example — USDC, Ethereum → Arbitrum One + +Stored (artifact, abridged): + +```jsonc +{ + "sourceChainId": 1, + "destinationChainId": 42161, + "providers": [ + { "provider": "canonical", "routes": { "0xa0b8...": "0xff97..." } }, // → USDC.e + { "provider": "cctp", "routes": { "0xa0b8...": "0xaf88..." } }, // → native USDC + { + "provider": "lifi", + "routes": { "0xa0b8...": "0xaf88...", "0x9876...": null }, // null → swap-only + }, + ], +} +``` + +Materialized when the user selects USDC (`1:0xa0b8...`): + +```ts +[ + { provider: 'canonical', sourceTokenId: '1:0xa0b8...', destinationTokenId: '42161:0xff97...' }, + { provider: 'cctp', sourceTokenId: '1:0xa0b8...', destinationTokenId: '42161:0xaf88...' }, + { provider: 'lifi', sourceTokenId: '1:0xa0b8...', destinationTokenId: '42161:0xaf88...' }, // stored counterpart +]; +``` + +One source token, three routes, two destination tokens, plus the full LiFi +swap set (lazily fetched from `/api/swap-destinations`) if the user wants +something else entirely. + +## Migration plan + +1. **Types + trivial providers** — token/route/quote types, selectors, cctp + and layerzero providers (hardcoded data proves the whole shape end to end) +2. **API routes + caching** — `/api/tokens/[chainId]` and + `/api/routes/[source]/[dest]` with `unstable_cache` +3. **Canonical + LiFi generators** — address derivation, connections snapshot, + coinKey counterpart mapping +4. **Quote layer** — per-provider `quote()`, LiFi fastest/cheapest collapse +5. **UI adoption** — source token search → `getSourceTokens`; route + eligibility → `getRoutes` / `getRoutesForPair`; destination picker → + `getDestinationTokens` / swap set +6. **Import** — `canonical.resolve` + session overlay; remove the legacy + parent-address-keyed token storage + +## End-to-end example — generation to usage + +The full lifecycle for **Ethereum (1) → Arbitrum One (42161)**, with USDC as +the main character. "Generation" runs server-side inside our Next.js route +handlers, lazily on first request, then cached — there is no build step. +Everything else is the frontend consuming cached endpoints. + +### Phase 1 — Generation (server, lazy, cached) + +Nothing happens at deploy. The first request to an endpoint after a cold +cache triggers its compute inside `unstable_cache`; subsequent requests hit +the cache until `revalidate` expires. + +#### `GET /api/tokens/42161` (and `/api/tokens/1`) + +Server-side, the handler: + +1. Fetches upstream token lists (`fetch` with `next: { revalidate: 86400 }`) + — the existing arbified lists give us canonical tokens like USDC.e. +2. Fetches LiFi's tokens API for the chain — + `GET li.quest/v1/tokens?chains=42161` — metadata for LiFi-supported + tokens, **including `coinKey`, which stays server-side**. +3. Normalizes everything: lowercase addresses, curated fixes applied directly + (USDT0 naming, ETH logo), native token added under the zero address. + +Response (cached with tag `tokens-42161`, ~24h) — slim array, `id`/`chainId` +re-derived during client hydration: + +```jsonc +[ + { + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18, + }, + { + "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6, + }, + { + "address": "0xff970a61a04b1ca14834a43f5de4533ebddb5cc8", + "symbol": "USDC.e", + "name": "Bridged USDC", + "decimals": 6, + }, + // ... ~1.5k entries +] +``` + +#### `GET /api/routes/1/42161` + +Server-side, the handler checks the supported-pairs config (1→42161 +supported — if not, **404**, no empty artifact), then runs each provider's +`generate(pair)`: + +| Provider | What runs server-side | Upstream called | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| canonical | for each parent-chain token, derive the child address | arbified token list `bridgeInfo` / gateway router via RPC | +| cctp | emit hardcoded entry | none | +| layerzero | emit hardcoded entry | none | +| lifi | build counterpart map by matching `coinKey` between from/to sides (curated coinKeys fill LiFi's gaps: PYUSD, ENA); collect swap set | `li.quest/v1/connections?fromChain=1&toChain=42161` + per-chain `li.quest/v1/tokens?chains=N` | + +Providers returning `null` are omitted from the array. Response (cached with +tag `routes-1-42161`, ~1h): + +```jsonc +{ + "sourceChainId": 1, + "destinationChainId": 42161, + "providers": [ + { + "provider": "canonical", + "routes": { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": "0xff970a61...", // USDC → USDC.e + "0xdac17f958d2ee523a2206206994597c13d831ec7": "0xfd086bc7...", // USDT → bridged USDT + // ... ~10k entries + }, + }, + { + "provider": "cctp", + "routes": { + "0xa0b86991...": "0xaf88d065...", // USDC → native USDC + }, + }, + { + "provider": "layerzero", + "routes": { + "0xdac17f95...": { + "destination": "0xfd086bc7...", + "oftAdapter": "0x6c96de32...", + "endpointId": 30110, + }, + }, + }, + { + "provider": "lifi", + "routes": { + "0xa0b86991...": "0xaf88d065...", // coinKey USDC matched → counterpart + "0x1234abcd...": null, // some token: swap-only, no counterpart + }, + }, + ], +} +``` + +The pair's swap destination set (LiFi `toTokens`, same metadata join) is +generated alongside but served separately: +`GET /api/swap-destinations/1/42161 → ["0xaf88d065...", "0x0000...0000", ...]` + +### Phase 2 — Pair load (frontend, runtime) + +User opens the bridge; the pair defaults to 1→42161. Three SWR fetches fire: + +``` +GET /api/tokens/1 (CDN/SWR cached) +GET /api/tokens/42161 +GET /api/routes/1/42161 +``` + +No further availability calls happen for this pair. If the user flips +direction, only `GET /api/routes/42161/1` is new — both token registries are +already cached. + +The three payloads are hydrated once into the `RegistryView` (token maps, +joined + sorted source list, lowercase search index over all tokens, +per-provider section maps, merged session overlay). + +### Phase 3 — Token picking & route materialization (frontend, pure functions, no network) + +**Token picker opens** → `getSourceTokens(view)`: the precomputed source +list, rendered in full through a fixed-row-height virtualized list (~14 DOM +rows regardless of token count). User types "USDC" → one `includes()` per +token against the search index. + +**User selects USDC** → `getRoutes(view, '1:0xa0b8...')` materializes by +checking that address against each section map: + +```ts +[ + { provider: 'canonical', sourceTokenId: '1:0xa0b8...', destinationTokenId: '42161:0xff97...' }, + { provider: 'cctp', sourceTokenId: '1:0xa0b8...', destinationTokenId: '42161:0xaf88...' }, + { provider: 'lifi', sourceTokenId: '1:0xa0b8...', destinationTokenId: '42161:0xaf88...' }, +]; +``` + +`getDefaultDestinationToken` applies priority (layerzero > cctp > canonical > +lifi) → **native USDC** preselected. Because USDC has a lifi route, +`GET /api/swap-destinations/1/42161` is fetched (the one lazy call) and the +destination picker shows `getDestinationTokens` = {USDC.e, native USDC} ∪ +swap set (so also ETH, ARB, ...) — searchable via `filterTokens` and +virtualized exactly like the source list. + +### Phase 4 — Quoting (runtime, on amount entry, per route) + +User keeps native USDC as destination and types **100**. +`getRoutesForPair(artifact, USDC, nativeUSDC)` → `[cctp, lifi]` (canonical is +excluded — its destination is USDC.e). Each is quoted: + +- **cctp** → frontend, via RPC: `estimateGas` on the TokenMessenger contract + → `RouteQuote { provider: 'cctp', amountReceived: 100000000n, gasEstimate: {...} }` +- **lifi** → frontend calls **our server proxy** + `GET /api/crosschain-transfers/lifi?fromToken=...&toToken=...&fromAmount=100000000`, + which calls LiFi's routes API server-side and returns fastest + cheapest. + Same underlying route → collapsed: + `RouteQuote { provider: 'lifi', variants: ['fastest','cheapest'], amountReceived: 99850000n, gasEstimate, fees, durationMs: 30000, step, tool }` +- (layerzero, when applicable, quotes frontend-side via `quoteSend()` RPC) + +UI renders two route cards from the quotes. User picks one and executes — +past this point we're out of registry territory. + +### Phase 5 — Import (runtime, the miss path) + +User pastes `0xdead...beef` (chain 1) — not in `tokens/1`, not in the session +overlay. Frontend calls: + +``` +GET /api/import/1/42161/0xdeadbeef... +``` + +Server-side, `canonical.resolve(pair, address)` runs — and only canonical +(CCTP/LayerZero are hardcoded, the LiFi snapshot is exhaustive; a miss there +is definitive). It derives the child address via the gateway router (RPC), +confirms registration, and fetches symbol/name/decimals for both sides: + +```jsonc +{ + "tokens": [ + { "id": "1:0xdead...", "symbol": "DEAD", "decimals": 18, ... }, + { "id": "42161:0xbabe...", "symbol": "DEAD", "decimals": 18, ... } + ], + "routes": [ + { "provider": "canonical", "sourceTokenId": "1:0xdead...", "destinationTokenId": "42161:0xbabe..." } + ] +} +``` + +Frontend merges this into the **in-memory session overlay**; selectors read +base + overlay, so DEAD is now searchable, selectable, and quotable like any +generated token. `null` would instead surface "not transferable to Arbitrum +One". Nothing persisted — gone on tab close. + +### Swap-only contrast, briefly + +ApeChain → Superposition, user selects native APE (`33139:0x0000...`): the +artifact has only a lifi section, `routes["0x0000..."] = null`. The +materialized route has no `destinationTokenId`, so the UI fetches the pair's +swap destinations and requires a choice from `getSwapDestinationTokens` +(USDC, ETH, ...). Quoting then proceeds identically through the LiFi proxy. + +## PoC implementation (Ethereum ↔ Arbitrum One) + +Implemented in `packages/app`: + +| Piece | Location | +| ----------------------------------------------------------------------------------- | --------------------------------------------------- | +| Types, constants (hardcoded providers, exclusions, curated coinKeys), normalization | `src/token-registry/{types,constants,normalize}.ts` | +| Selectors + hydration (`RegistryView`) | `src/token-registry/{selectors,view}.ts` | +| Generation (canonical, cctp, layerzero, lifi) + caching | `src/token-registry/server/generate.ts` | +| Import (`canonical.resolve`) | `src/token-registry/server/resolveImport.ts` | +| API routes (tokens, routes, swap-destinations, import) | `src/app/api/token-registry/**` | +| UI (virtualized source + destination pickers, session import) | `src/app/token-registry-poc/` | +| Unit tests (selectors, view, generation, normalization) | `src/token-registry/__tests__/` | + +Measured (live data, cold generation, no cache): + +| Payload | Size | +| --------------------------------------------------------- | ------------------------ | +| `tokens/1` — 4.9k tokens | 850 KB raw / 224 KB gzip | +| `tokens/42161` — 1.5k tokens | 288 KB raw / 80 KB gzip | +| `routes/1/42161` — canonical 940, cctp 1, lz 1, lifi 4.7k | 321 KB raw / 155 KB gzip | +| `routes/42161/1` | 150 KB raw / 74 KB gzip | +| `swap-destinations/1/42161` — 1.2k addresses | 54 KB raw / 28 KB gzip | +| `swap-destinations/42161/1` — 4.7k addresses | 209 KB raw / 108 KB gzip | + +Cold generation: ~1–3s per endpoint (upstream-fetch bound), then cached. +Re-measure anytime with the network-gated harness: + +```bash +MEASURE=1 pnpm exec vitest --config vitest.config.ts --run --reporter=verbose src/token-registry +``` + +**Import demo:** PEPE is deliberately excluded from all generation +(`isExcludedToken` in `constants.ts`) even though it has a canonical route — +paste `0x6982508145454ce325ddbe47a25d4ec3d2311933` (Ethereum → Arbitrum One) +into the picker to watch the import flow resolve it on-chain and add it for +the session. + +Not in the PoC yet: the quote layer (per-provider `quote()`, LiFi +fastest/cheapest), testnet pairs (Sepolia ↔ ArbitrumSepolia CCTP), and chains +beyond Ethereum/Arbitrum One.