diff --git a/packages/app/package.json b/packages/app/package.json index ae148c6b3..764ef8ca5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -32,6 +32,8 @@ "css:build": "tailwindcss -c ./tailwind.config.js -i ./src/app.css -o ./src/styles/common.css --minify", "css:watch": "tailwindcss -c ./tailwind.config.js -i ./src/app.css -o ./src/styles/common.css --watch", "css:build:all": "yarn css:build && yarn workspace arb-token-bridge-ui css:build && yarn workspace portal css:build", - "css:watch:all": "yarn css:watch && yarn workspace arb-token-bridge-ui css:watch && yarn workspace portal css:watch" + "css:watch:all": "yarn css:watch && yarn workspace arb-token-bridge-ui css:watch && yarn workspace portal css:watch", + "test": "vitest --config vitest.config.ts --watch", + "test:ci": "vitest --config vitest.config.ts --run" } } diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/__tests__/mapParentTokensToTokens.test.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/__tests__/mapParentTokensToTokens.test.ts new file mode 100644 index 000000000..2bcf8f90b --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/__tests__/mapParentTokensToTokens.test.ts @@ -0,0 +1,177 @@ +import { CoinKey, ChainId as LiFiChainId } from '@lifi/sdk'; +import { describe, expect, it } from 'vitest'; + +import { ChainId } from '@/bridge/types/ChainId'; + +import { mapParentTokensToTokens } from '../mapParentTokensToTokens'; +import type { LifiTokenWithCoinKey } from '../registry'; + +const buildToken = ( + overrides: Partial & Pick, +): LifiTokenWithCoinKey => ({ + address: '0x0000000000000000000000000000000000000001', + name: 'Token', + symbol: overrides.coinKey, + decimals: 18, + priceUSD: '1', + chainId: LiFiChainId.ARB, + logoURI: 'https://example.com/logo.png', + ...overrides, +}); + +type BridgeInfo = Record< + string, + { + tokenAddress: string; + name?: string; + symbol?: string; + decimals?: number; + logoURI?: string; + } +>; + +describe('mapParentTokensToTokens', () => { + it('maps ETH and WETH deposits to WETH on ApeChain', () => { + const parentTokens = [ + buildToken({ coinKey: CoinKey.ETH, chainId: LiFiChainId.ARB }), + buildToken({ coinKey: CoinKey.WETH, chainId: LiFiChainId.ARB }), + ]; + const childTokensByCoinKey = { + [CoinKey.WETH]: buildToken({ + chainId: LiFiChainId.APE, + coinKey: CoinKey.WETH, + logoURI: 'https://example.com/weth.png', + }), + }; + + const tokens = mapParentTokensToTokens({ + parentTokens, + childTokensByCoinKey, + parentChainId: ChainId.ArbitrumOne, + childChainId: ChainId.ApeChain, + }); + + expect(tokens).toHaveLength(2); + tokens.forEach((token, index) => { + const parentToken = parentTokens[index]; + expect(token).toMatchObject({ + chainId: ChainId.ApeChain, + address: childTokensByCoinKey[CoinKey.WETH].address, + symbol: childTokensByCoinKey[CoinKey.WETH].symbol, + logoURI: 'https://example.com/weth.png', + }); + + const bridgeInfo = (token.extensions?.bridgeInfo as BridgeInfo | undefined)?.[ + ChainId.ArbitrumOne.toString() + ]; + expect(bridgeInfo).toEqual({ + tokenAddress: parentToken?.address, + name: parentToken?.name, + symbol: parentToken?.symbol, + decimals: parentToken?.decimals, + logoURI: parentToken?.logoURI, + }); + }); + }); + + it('maps WETH and ETH to their corresponding child tokens when both exist', () => { + const parentTokens = [ + buildToken({ + coinKey: CoinKey.WETH, + chainId: LiFiChainId.ETH, + address: '0x0000000000000000000000000000000000000002', + }), + buildToken({ + coinKey: CoinKey.ETH, + chainId: LiFiChainId.ETH, + address: '0x0000000000000000000000000000000000000003', + }), + ]; + const childTokensByCoinKey = { + [CoinKey.WETH]: buildToken({ + coinKey: CoinKey.WETH, + chainId: LiFiChainId.ARB, + address: '0x0000000000000000000000000000000000000010', + }), + [CoinKey.ETH]: buildToken({ + coinKey: CoinKey.ETH, + chainId: LiFiChainId.ARB, + address: '0x0000000000000000000000000000000000000011', + }), + }; + + const tokens = mapParentTokensToTokens({ + parentTokens, + childTokensByCoinKey, + parentChainId: ChainId.Ethereum, + childChainId: ChainId.ArbitrumOne, + }); + + expect(tokens).toEqual([ + expect.objectContaining({ + chainId: ChainId.ArbitrumOne, + address: childTokensByCoinKey[CoinKey.WETH].address, + symbol: CoinKey.WETH, + }), + expect.objectContaining({ + chainId: ChainId.ArbitrumOne, + address: childTokensByCoinKey[CoinKey.ETH].address, + symbol: CoinKey.ETH, + }), + ]); + }); + + it('uses parent logo when child token logo is missing', () => { + const parentLogo = 'https://example.com/parent.png'; + const parentTokens = [ + buildToken({ + coinKey: CoinKey.ETH, + chainId: LiFiChainId.ETH, + logoURI: parentLogo, + }), + ]; + const childTokensByCoinKey = { + [CoinKey.ETH]: buildToken({ + coinKey: CoinKey.ETH, + chainId: LiFiChainId.ARB, + logoURI: undefined, + }), + }; + + const tokens = mapParentTokensToTokens({ + parentTokens, + childTokensByCoinKey, + parentChainId: ChainId.Ethereum, + childChainId: ChainId.ArbitrumOne, + }); + + expect(tokens[0]?.logoURI).toBe(parentLogo); + }); + + it('uses child logo when parent token logo is missing', () => { + const childLogo = 'https://example.com/child.png'; + const parentTokens = [ + buildToken({ + coinKey: CoinKey.WETH, + chainId: LiFiChainId.ARB, + logoURI: undefined, + }), + ]; + const childTokensByCoinKey = { + [CoinKey.WETH]: buildToken({ + coinKey: CoinKey.WETH, + chainId: LiFiChainId.SUP, + logoURI: childLogo, + }), + }; + + const tokens = mapParentTokensToTokens({ + parentTokens, + childTokensByCoinKey, + parentChainId: ChainId.ArbitrumOne, + childChainId: ChainId.Superposition, + }); + + expect(tokens[0]?.logoURI).toBe(childLogo); + }); +}); diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/mapParentTokensToTokens.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/mapParentTokensToTokens.ts new file mode 100644 index 000000000..d6767de3b --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/mapParentTokensToTokens.ts @@ -0,0 +1,54 @@ +import { CoinKey } from '@lifi/sdk'; +import { TokenList } from '@uniswap/token-lists'; + +import { ChainId } from '@/bridge/types/ChainId'; + +import { LifiTokenWithCoinKey } from './registry'; + +type MapTokensParams = { + parentTokens: LifiTokenWithCoinKey[]; + childTokensByCoinKey: Record; + parentChainId: number; + childChainId: number; +}; + +export const mapParentTokensToTokens = ({ + parentTokens, + childTokensByCoinKey, + parentChainId, + childChainId, +}: MapTokensParams): TokenList['tokens'] => { + return parentTokens.reduce((acc, token) => { + const childToken = + childChainId === ChainId.ApeChain && token.coinKey === CoinKey.ETH + ? childTokensByCoinKey[CoinKey.WETH] + : childTokensByCoinKey[token.coinKey]; + + if (!childToken) { + return acc; + } + + // Some tokens on Lifi are missing logoURIs, so we fallback to the other token's logoURI if available + const fallbackLogoURI = childToken.logoURI ?? token.logoURI; + acc.push({ + chainId: childChainId, + address: childToken.address, + name: childToken.name, + symbol: childToken.symbol, + decimals: childToken.decimals, + logoURI: fallbackLogoURI, + extensions: { + bridgeInfo: { + [parentChainId]: { + tokenAddress: token.address, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + logoURI: token.logoURI, + }, + }, + }, + }); + return acc; + }, []); +}; diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts new file mode 100644 index 000000000..b3b341329 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts @@ -0,0 +1,104 @@ +import { CoinKey, ChainId as LiFiChainId, type Token as LiFiToken, getTokens } from '@lifi/sdk'; +import { unstable_cache } from 'next/cache'; + +import { allowedLifiSourceChainIds } from '@/bridge/app/api/crosschain-transfers/constants'; +import { ChainId } from '@/bridge/types/ChainId'; + +export const LIFI_TOKENS_REVALIDATE_SECONDS = 60 * 60; + +type CustomTokenConfig = { + coinKey: string; + addresses: Partial>; +}; + +const CUSTOM_TOKENS: CustomTokenConfig[] = [ + { + coinKey: 'ENA', + addresses: { + [ChainId.Ethereum]: '0x57e114B691Db790C35207b2e685D4A43181e6061', + [ChainId.Base]: '0x58538e6A46E07434d7E7375Bc268D3cb839C0133', + [ChainId.ArbitrumOne]: '0x58538e6A46E07434d7E7375Bc268D3cb839C0133', + }, + }, +]; + +const EXCLUDED_ADDRESSES: Partial>> = { + [ChainId.ArbitrumOne]: new Set([ + '0x74885b4d524d497261259b38900f54e6dbad2210', // Old Ape token + '0xB9C8F0d3254007eE4b98970b94544e473Cd610EC', // Old QiDao token + ]), +}; + +export type LifiTokenWithCoinKey = LiFiToken & { coinKey: CoinKey }; + +function isExcludedToken(token: LiFiToken, chainId: number): boolean { + return EXCLUDED_ADDRESSES[chainId]?.has(token.address.toLowerCase()) ?? false; +} + +/** + * Assigns a custom CoinKey to tokens that don't have one but are configured in CUSTOM_TOKENS. + * Returns null if token has no coinKey and isn't in CUSTOM_TOKENS. + */ +function assignCustomCoinKey(token: LiFiToken, chainId: number): LifiTokenWithCoinKey | null { + const normalizedAddress = token.address.toLowerCase(); + for (const customToken of CUSTOM_TOKENS) { + const configuredAddress = customToken.addresses[chainId]?.toLowerCase(); + if (configuredAddress === normalizedAddress) { + return { ...token, coinKey: customToken.coinKey as CoinKey }; + } + } + + if (token.coinKey) { + return token as LifiTokenWithCoinKey; + } + + return null; +} + +export interface LifiTokenRegistry { + tokensByChain: Record; + tokensByChainAndCoinKey: Record>; +} + +const fetchRegistry = async (): Promise => { + const response = await getTokens({ + chains: allowedLifiSourceChainIds as unknown as LiFiChainId[], + }); + if (!response.tokens) { + return { + tokensByChain: {}, + tokensByChainAndCoinKey: {}, + }; + } + + const tokensByChain: LifiTokenRegistry['tokensByChain'] = {}; + const tokensByChainAndCoinKey: LifiTokenRegistry['tokensByChainAndCoinKey'] = {}; + + for (const chainId of allowedLifiSourceChainIds) { + const tokensGroupedByCoinKey: Partial> = {}; + + const filteredTokens = (response.tokens[chainId] ?? []).reduce( + (acc, token) => { + // Exclude tokens on the exclude list + if (isExcludedToken(token, chainId)) return acc; + + const tokenWithCoinKey = assignCustomCoinKey(token, chainId); + if (!tokenWithCoinKey) return acc; + + tokensGroupedByCoinKey[tokenWithCoinKey.coinKey] ??= tokenWithCoinKey; + acc.push(tokenWithCoinKey); + return acc; + }, + [], + ); + + tokensByChain[chainId] = filteredTokens; + tokensByChainAndCoinKey[chainId] = tokensGroupedByCoinKey; + } + + return { tokensByChain, tokensByChainAndCoinKey }; +}; + +export const getLifiTokenRegistry = unstable_cache(fetchRegistry, ['lifi-token-registry'], { + revalidate: LIFI_TOKENS_REVALIDATE_SECONDS, +}); diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/route.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/route.ts new file mode 100644 index 000000000..8879528d4 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/route.ts @@ -0,0 +1,156 @@ +import { TokenList } from '@uniswap/token-lists'; +import { NextRequest, NextResponse } from 'next/server'; + +import { + allowedLifiDestinationChainIds, + allowedLifiSourceChainIds, + lifiDestinationChainIds, +} from '@/bridge/app/api/crosschain-transfers/constants'; + +import { mapParentTokensToTokens } from './mapParentTokensToTokens'; +import { getLifiTokenRegistry } from './registry'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 60 * 60; // 1 hour + +const STATIC_TIMESTAMP = '2025-10-20T00:00:00.000Z'; +const TOKEN_LIST_NAME = 'LiFi Transfer Tokens'; +const TOKEN_LIST_VERSION = { major: 1, minor: 0, patch: 0 }; + +const BASE_TOKEN_LIST = { + name: TOKEN_LIST_NAME, + timestamp: STATIC_TIMESTAMP, + version: TOKEN_LIST_VERSION, + logoURI: '/icons/lifi.svg', +} as const; + +const parseChainParam = (value: string | null) => { + if (!value) return null; + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +}; + +export async function GET(request: NextRequest): Promise> { + const { searchParams } = new URL(request.url); + const parentChainId = parseChainParam(searchParams.get('parentChainId')); + const childChainId = parseChainParam(searchParams.get('childChainId')); + + if (parentChainId === null || childChainId === null) { + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens: [], + }, + { + status: 400, + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }, + ); + } + + if (!allowedLifiSourceChainIds.includes(parentChainId)) { + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens: [], + }, + { + status: 400, + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }, + ); + } + + if (!allowedLifiDestinationChainIds.includes(childChainId)) { + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens: [], + }, + { + status: 400, + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }, + ); + } + + if (!lifiDestinationChainIds[parentChainId]?.includes(childChainId)) { + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens: [], + }, + { + status: 400, + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }, + ); + } + + try { + const { tokensByChain, tokensByChainAndCoinKey } = await getLifiTokenRegistry(); + + const parentTokens = tokensByChain[parentChainId] ?? []; + const childTokensByCoinKey = tokensByChainAndCoinKey[childChainId]; + + if ( + !parentTokens.length || + !childTokensByCoinKey || + Object.keys(childTokensByCoinKey).length === 0 + ) { + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens: [], + }, + { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400', + }, + }, + ); + } + + const tokens = mapParentTokensToTokens({ + parentTokens, + childTokensByCoinKey, + parentChainId, + childChainId, + }); + + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens, + }, + { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400', + }, + }, + ); + } catch (error: any) { + return NextResponse.json( + { + ...BASE_TOKEN_LIST, + tokens: [], + }, + { + status: 500, + headers: { + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }, + ); + } +} diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts new file mode 100644 index 000000000..528005582 --- /dev/null +++ b/packages/app/vitest.config.ts @@ -0,0 +1,13 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + alias: { + '@/bridge': path.resolve(__dirname, '../arb-token-bridge-ui/src'), + }, + }, +});