From 926e531e4b11426f984f42ff47cb4b157439b27b Mon Sep 17 00:00:00 2001 From: Christophe Date: Tue, 14 Oct 2025 22:01:50 +0000 Subject: [PATCH] feat(bridge): add token lists for lifi --- packages/app/package.json | 4 +- ...pSourceTokensToTransferTokens.test.ts.snap | 96 +++++++++++++++ .../mapSourceTokensToTransferTokens.test.ts | 92 +++++++++++++++ .../[destinationChainId]/route.ts | 109 ++++++++++++++++++ .../lifi/tokens/registry.test.ts | 61 ++++++++++ .../lifi/tokens/registry.ts | 99 ++++++++++++++++ packages/app/vitest.config.ts | 13 +++ .../api/crosschain-transfers/lifiTokens.ts | 22 ++++ 8 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/__snapshots__/mapSourceTokensToTransferTokens.test.ts.snap create mode 100644 packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/mapSourceTokensToTransferTokens.test.ts create mode 100644 packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/route.ts create mode 100644 packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.test.ts create mode 100644 packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts create mode 100644 packages/app/vitest.config.ts create mode 100644 packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifiTokens.ts 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/[sourceChainId]/[destinationChainId]/__snapshots__/mapSourceTokensToTransferTokens.test.ts.snap b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/__snapshots__/mapSourceTokensToTransferTokens.test.ts.snap new file mode 100644 index 000000000..5f1ac29d9 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/__snapshots__/mapSourceTokensToTransferTokens.test.ts.snap @@ -0,0 +1,96 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`mapSourceTokensToTransferTokens > For other chain, map WETH to WETH and ETH to ETH 1`] = ` +[ + { + "address": "0x0000000000000000000000000000000000000002", + "chainId": 1, + "decimals": 18, + "destinationToken": { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 55244, + "decimals": 18, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, + { + "address": "0x0000000000000000000000000000000000000002", + "chainId": 1, + "decimals": 18, + "destinationToken": { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 55244, + "decimals": 18, + "name": "Token", + "priceUSD": "1", + "symbol": "ETH", + }, + "name": "Token", + "priceUSD": "1", + "symbol": "ETH", + }, +] +`; + +exports[`mapSourceTokensToTransferTokens > from ApeChain to ArbitrumOne, withdraw WETH to WETH 1`] = ` +[ + { + "address": "0x0000000000000000000000000000000000000002", + "chainId": 33139, + "decimals": 18, + "destinationToken": { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 42161, + "decimals": 18, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, +] +`; + +exports[`mapSourceTokensToTransferTokens > maps ETH deposits into ApeChain to WETH destination token 1`] = ` +[ + { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 42161, + "decimals": 18, + "destinationToken": { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 33139, + "decimals": 18, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, + "name": "Token", + "priceUSD": "1", + "symbol": "ETH", + }, + { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 42161, + "decimals": 18, + "destinationToken": { + "address": "0x0000000000000000000000000000000000000001", + "chainId": 33139, + "decimals": 18, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, + "name": "Token", + "priceUSD": "1", + "symbol": "WETH", + }, +] +`; diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/mapSourceTokensToTransferTokens.test.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/mapSourceTokensToTransferTokens.test.ts new file mode 100644 index 000000000..4646d90e3 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/mapSourceTokensToTransferTokens.test.ts @@ -0,0 +1,92 @@ +import { CoinKey } from '@lifi/sdk'; +import { describe, expect, it } from 'vitest'; + +import { ChainId } from '@/bridge/types/ChainId'; + +import type { LifiTokenWithCoinKey } from '../../registry'; +import { mapSourceTokensToTransferTokens } from './route'; + +const buildToken = ( + overrides: Partial & Pick, +): LifiTokenWithCoinKey => ({ + address: '0x0000000000000000000000000000000000000001', + name: 'Token', + symbol: overrides.coinKey, + decimals: 18, + priceUSD: '1', + chainId: 42161, + ...overrides, +}); + +describe('mapSourceTokensToTransferTokens', () => { + it('maps ETH deposits into ApeChain to WETH destination token', () => { + const sourceTokens = [ + buildToken({ coinKey: CoinKey.ETH, chainId: 42161 }), + buildToken({ coinKey: CoinKey.WETH, chainId: 42161 }), + ]; + const destinationTokensByCoinKey = { + WETH: buildToken({ chainId: 33139, coinKey: CoinKey.WETH }), + }; + + const result = mapSourceTokensToTransferTokens({ + sourceTokens, + destinationTokensByCoinKey, + destinationChainId: ChainId.ApeChain, + }); + + expect(result).toHaveLength(2); + // Both ETH and WETH on Arbitrum map to WETH on ApeChain + expect(result[0]?.destinationToken).toEqual(result[1]?.destinationToken); + expect(result).toMatchSnapshot(); + }); + + it('from ApeChain to ArbitrumOne, withdraw WETH to WETH', () => { + const sourceTokens = [ + buildToken({ + coinKey: CoinKey.WETH, + chainId: 33139, + address: '0x0000000000000000000000000000000000000002', + }), + ]; + const destinationTokensByCoinKey = { + WETH: buildToken({ chainId: 42161, coinKey: CoinKey.WETH }), + }; + + const result = mapSourceTokensToTransferTokens({ + sourceTokens, + destinationTokensByCoinKey, + destinationChainId: ChainId.ArbitrumOne, + }); + + expect(result).toHaveLength(1); + expect(result).toMatchSnapshot(); + }); + + it('For other chain, map WETH to WETH and ETH to ETH', () => { + const sourceTokens = [ + buildToken({ + coinKey: CoinKey.WETH, + chainId: 1, + address: '0x0000000000000000000000000000000000000002', + }), + buildToken({ + coinKey: CoinKey.ETH, + chainId: 1, + address: '0x0000000000000000000000000000000000000002', + }), + ]; + const destinationTokensByCoinKey = { + WETH: buildToken({ chainId: 55244, coinKey: CoinKey.WETH }), + ETH: buildToken({ chainId: 55244, coinKey: CoinKey.ETH }), + }; + + const result = mapSourceTokensToTransferTokens({ + sourceTokens, + destinationTokensByCoinKey, + destinationChainId: ChainId.ArbitrumOne, + }); + + expect(result).toHaveLength(2); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/route.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/route.ts new file mode 100644 index 000000000..e1b7abe13 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/[sourceChainId]/[destinationChainId]/route.ts @@ -0,0 +1,109 @@ +import { CoinKey } from '@lifi/sdk'; +import { NextResponse } from 'next/server'; + +import { + type LifiTokenInfo, + type LifiTokensApiResponse, + type LifiTransferToken, +} from '@/bridge/app/api/crosschain-transfers/lifiTokens'; +import { ChainId } from '@/bridge/types/ChainId'; + +import { + ALLOWED_DESTINATION_CHAIN_IDS, + ALLOWED_SOURCE_CHAIN_IDS, + LifiTokenWithCoinKey, + getLifiTokenRegistry, +} from '../../registry'; + +export const dynamic = 'force-static'; +export const revalidate = 60 * 60; // 1 hour + +type RouteParams = { + sourceChainId: string; + destinationChainId: string; +}; + +const stripCoinKey = ({ coinKey, ...token }: LifiTokenWithCoinKey): LifiTokenInfo => token; + +type MapTokensParams = { + sourceTokens: LifiTokenWithCoinKey[]; + destinationTokensByCoinKey: Record; + destinationChainId: number; +}; + +export const mapSourceTokensToTransferTokens = ({ + sourceTokens, + destinationTokensByCoinKey, + destinationChainId, +}: MapTokensParams): LifiTransferToken[] => { + return sourceTokens.reduce((acc, token) => { + const destinationToken = + destinationChainId === ChainId.ApeChain && token.coinKey === CoinKey.ETH + ? destinationTokensByCoinKey[CoinKey.WETH] + : destinationTokensByCoinKey[token.coinKey]; + + if (!destinationToken) { + return acc; + } + + acc.push({ + ...stripCoinKey(token), + destinationToken: stripCoinKey(destinationToken), + }); + + return acc; + }, []); +}; + +export async function GET( + _request: Request, + { params }: { params: RouteParams }, +): Promise> { + const sourceChainId = Number(params.sourceChainId); + const destinationChainId = Number(params.destinationChainId); + + if ( + !ALLOWED_SOURCE_CHAIN_IDS.includes(sourceChainId) || + !ALLOWED_DESTINATION_CHAIN_IDS.includes(destinationChainId) + ) { + return NextResponse.json( + { message: 'Chain pair not supported for LiFi tokens' }, + { status: 400 }, + ); + } + + try { + const { tokensByChain, tokensByChainAndCoinKey } = await getLifiTokenRegistry(); + + const sourceTokens = tokensByChain[sourceChainId] ?? []; + const destinationTokensByCoinKey = tokensByChainAndCoinKey[destinationChainId]; + + if ( + !sourceTokens.length || + !destinationTokensByCoinKey || + Object.keys(destinationTokensByCoinKey).length === 0 + ) { + return NextResponse.json( + { + data: [], + }, + { status: 200 }, + ); + } + + const tokens = mapSourceTokensToTransferTokens({ + sourceTokens, + destinationTokensByCoinKey, + destinationChainId, + }); + + return NextResponse.json({ data: tokens }, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { + message: error?.message ?? 'Unable to load LiFi tokens', + }, + { status: 500 }, + ); + } +} diff --git a/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.test.ts b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.test.ts new file mode 100644 index 000000000..c6b113182 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.test.ts @@ -0,0 +1,61 @@ +import { CoinKey } from '@lifi/sdk'; +import { ChainId } from '@lifi/sdk'; +import { describe, expect, it } from 'vitest'; + +import { handleUSDC } from './registry'; + +const placeholderToken = { + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + priceUSD: '0', +}; + +describe('handleUSDC', () => { + it('drops USDCe on chains where native USDC exist', () => { + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.ARB, coinKey: CoinKey.USDCe }), + ).toBeNull(); + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.ETH, coinKey: CoinKey.USDCe }), + ).toBeNull(); + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.BAS, coinKey: CoinKey.USDCe }), + ).toBeNull(); + }); + + it('keeps USDCe on chains without native USDC', () => { + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.APE, coinKey: CoinKey.USDCe }), + ).toEqual({ + ...placeholderToken, + chainId: ChainId.APE, + coinKey: CoinKey.USDC, + }); + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.SUP, coinKey: CoinKey.USDCe }), + ).toEqual({ + ...placeholderToken, + chainId: ChainId.SUP, + coinKey: CoinKey.USDC, + }); + }); + + it('returns original token for non USDC tokens', () => { + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.BAS, coinKey: CoinKey.WBTC }), + ).toEqual({ + ...placeholderToken, + chainId: ChainId.BAS, + coinKey: CoinKey.WBTC, + }); + expect( + handleUSDC({ ...placeholderToken, chainId: ChainId.APE, coinKey: CoinKey.WETH }), + ).toEqual({ + ...placeholderToken, + chainId: ChainId.APE, + coinKey: CoinKey.WETH, + }); + }); +}); 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..d25f7a126 --- /dev/null +++ b/packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts @@ -0,0 +1,99 @@ +import { CoinKey, ChainId as LiFiChainId, type Token as LiFiToken, getTokens } from '@lifi/sdk'; +import { unstable_cache } from 'next/cache'; + +import { ChainId } from '@/bridge/types/ChainId'; + +export const LIFI_TOKENS_REVALIDATE_SECONDS = 60 * 60; + +export const ALLOWED_CHAIN_IDS = [ + ChainId.Ethereum, + ChainId.Base, + ChainId.ArbitrumOne, + ChainId.ApeChain, + ChainId.Superposition, +]; + +export const ALLOWED_SOURCE_CHAIN_IDS = ALLOWED_CHAIN_IDS; +export const ALLOWED_DESTINATION_CHAIN_IDS = ALLOWED_CHAIN_IDS.filter( + (chainId) => chainId !== ChainId.Base, +); + +const EXCLUDED_ADDRESSES: Partial>> = { + [ChainId.ArbitrumOne]: new Set(['0x74885b4d524d497261259b38900f54e6dbad2210']), +}; + +export type LifiTokenWithCoinKey = LiFiToken & { coinKey: CoinKey }; +/** + * Normalizes USDC tokens: + * - Drops USDC.e on chains that have native USDC (Arbitrum, Base, Ethereum) + * - Remaps USDC.e to USDC on ApeChain and Superposition + * - Keeps all other tokens unchanged + */ +export function handleUSDC(token: LifiTokenWithCoinKey): LifiTokenWithCoinKey | null { + if (token.coinKey !== CoinKey.USDCe) { + return token; + } + + // Drop USDC.e on chains that have native USDC. + if ( + token.chainId === LiFiChainId.ARB || + token.chainId === LiFiChainId.BAS || + token.chainId === LiFiChainId.ETH + ) { + return null; + } + + // Remap USDC.e to USDC + if (token.chainId === LiFiChainId.APE || token.chainId === LiFiChainId.SUP) { + return { ...token, coinKey: CoinKey.USDC }; + } + + return token; +} + +export interface LifiTokenRegistry { + tokensByChain: Record; + tokensByChainAndCoinKey: Record>; +} + +const fetchRegistry = async (): Promise => { + const response = await getTokens({ chains: ALLOWED_CHAIN_IDS as unknown as LiFiChainId[] }); + if (!response.tokens) { + return { + tokensByChain: {}, + tokensByChainAndCoinKey: {}, + }; + } + + const tokensByChain: LifiTokenRegistry['tokensByChain'] = {}; + const tokensByChainAndCoinKey: LifiTokenRegistry['tokensByChainAndCoinKey'] = {}; + + for (const chainId of ALLOWED_CHAIN_IDS) { + const tokensGroupedByCoinKey: Partial> = {}; + + const filteredTokens = (response.tokens[chainId] ?? []).reduce( + (acc, token) => { + // Exclude tokens on the exclude list or tokens without coinKey + if (EXCLUDED_ADDRESSES[chainId]?.has(token.address)) return acc; + if (!token.coinKey) return acc; + + const normalizedToken = handleUSDC(token as LifiTokenWithCoinKey); + if (!normalizedToken) return acc; + + tokensGroupedByCoinKey[normalizedToken.coinKey] ??= normalizedToken; + acc.push(normalizedToken); + 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/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'), + }, + }, +}); diff --git a/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifiTokens.ts b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifiTokens.ts new file mode 100644 index 000000000..3d176c232 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/app/api/crosschain-transfers/lifiTokens.ts @@ -0,0 +1,22 @@ +export const LIFI_TRANSFER_LIST_ID = 'lifi-transfer'; + +export interface LifiTokenInfo { + address: string; + chainId: number; + symbol: string; + name: string; + decimals: number; + logoURI?: string; +} + +export interface LifiTransferToken extends LifiTokenInfo { + destinationToken: LifiTokenInfo; +} + +export type LifiTokensApiResponse = + | { + message: string; + } + | { + data: LifiTransferToken[]; + };