Skip to content

Commit 7ee57da

Browse files
committed
feat(bridge): add token lists for lifi
1 parent 09a5236 commit 7ee57da

File tree

9 files changed

+712
-95
lines changed

9 files changed

+712
-95
lines changed

packages/app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
"css:build": "tailwindcss -c ./tailwind.config.js -i ./src/app.css -o ./src/styles/common.css --minify",
3333
"css:watch": "tailwindcss -c ./tailwind.config.js -i ./src/app.css -o ./src/styles/common.css --watch",
3434
"css:build:all": "yarn css:build && yarn workspace arb-token-bridge-ui css:build && yarn workspace portal css:build",
35-
"css:watch:all": "yarn css:watch && yarn workspace arb-token-bridge-ui css:watch && yarn workspace portal css:watch"
35+
"css:watch:all": "yarn css:watch && yarn workspace arb-token-bridge-ui css:watch && yarn workspace portal css:watch",
36+
"test": "vitest --config vitest.config.ts --watch",
37+
"test:ci": "vitest --config vitest.config.ts --run"
3638
}
3739
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { CoinKey, ChainId as LiFiChainId } from '@lifi/sdk';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { ChainId } from '@/bridge/types/ChainId';
5+
6+
import { mapParentTokensToTokens } from '../mapParentTokensToTokens';
7+
import type { LifiTokenWithCoinKey } from '../registry';
8+
9+
const buildToken = (
10+
overrides: Partial<LifiTokenWithCoinKey> & Pick<LifiTokenWithCoinKey, 'coinKey'>,
11+
): LifiTokenWithCoinKey => ({
12+
address: '0x0000000000000000000000000000000000000001',
13+
name: 'Token',
14+
symbol: overrides.coinKey,
15+
decimals: 18,
16+
priceUSD: '1',
17+
chainId: LiFiChainId.ARB,
18+
logoURI: 'https://example.com/logo.png',
19+
...overrides,
20+
});
21+
22+
type BridgeInfo = Record<
23+
string,
24+
{
25+
tokenAddress: string;
26+
name?: string;
27+
symbol?: string;
28+
decimals?: number;
29+
logoURI?: string;
30+
}
31+
>;
32+
33+
describe('mapParentTokensToTokens', () => {
34+
it('maps ETH and WETH deposits to WETH on ApeChain', () => {
35+
const parentTokens = [
36+
buildToken({ coinKey: CoinKey.ETH, chainId: LiFiChainId.ARB }),
37+
buildToken({ coinKey: CoinKey.WETH, chainId: LiFiChainId.ARB }),
38+
];
39+
const childTokensByCoinKey = {
40+
[CoinKey.WETH]: buildToken({
41+
chainId: LiFiChainId.APE,
42+
coinKey: CoinKey.WETH,
43+
logoURI: 'https://example.com/weth.png',
44+
}),
45+
};
46+
47+
const tokens = mapParentTokensToTokens({
48+
parentTokens,
49+
childTokensByCoinKey,
50+
parentChainId: ChainId.ArbitrumOne,
51+
childChainId: ChainId.ApeChain,
52+
});
53+
54+
expect(tokens).toHaveLength(2);
55+
tokens.forEach((token, index) => {
56+
const parentToken = parentTokens[index];
57+
expect(token).toMatchObject({
58+
chainId: ChainId.ApeChain,
59+
address: childTokensByCoinKey[CoinKey.WETH].address,
60+
symbol: childTokensByCoinKey[CoinKey.WETH].symbol,
61+
logoURI: 'https://example.com/weth.png',
62+
});
63+
64+
const bridgeInfo = (token.extensions?.bridgeInfo as BridgeInfo | undefined)?.[
65+
ChainId.ArbitrumOne.toString()
66+
];
67+
expect(bridgeInfo).toEqual({
68+
tokenAddress: parentToken?.address,
69+
name: parentToken?.name,
70+
symbol: parentToken?.symbol,
71+
decimals: parentToken?.decimals,
72+
logoURI: parentToken?.logoURI,
73+
});
74+
});
75+
});
76+
77+
it('maps WETH and ETH to their corresponding child tokens when both exist', () => {
78+
const parentTokens = [
79+
buildToken({
80+
coinKey: CoinKey.WETH,
81+
chainId: LiFiChainId.ETH,
82+
address: '0x0000000000000000000000000000000000000002',
83+
}),
84+
buildToken({
85+
coinKey: CoinKey.ETH,
86+
chainId: LiFiChainId.ETH,
87+
address: '0x0000000000000000000000000000000000000003',
88+
}),
89+
];
90+
const childTokensByCoinKey = {
91+
[CoinKey.WETH]: buildToken({
92+
coinKey: CoinKey.WETH,
93+
chainId: LiFiChainId.ARB,
94+
address: '0x0000000000000000000000000000000000000010',
95+
}),
96+
[CoinKey.ETH]: buildToken({
97+
coinKey: CoinKey.ETH,
98+
chainId: LiFiChainId.ARB,
99+
address: '0x0000000000000000000000000000000000000011',
100+
}),
101+
};
102+
103+
const tokens = mapParentTokensToTokens({
104+
parentTokens,
105+
childTokensByCoinKey,
106+
parentChainId: ChainId.Ethereum,
107+
childChainId: ChainId.ArbitrumOne,
108+
});
109+
110+
expect(tokens).toEqual([
111+
expect.objectContaining({
112+
chainId: ChainId.ArbitrumOne,
113+
address: childTokensByCoinKey[CoinKey.WETH].address,
114+
symbol: CoinKey.WETH,
115+
}),
116+
expect.objectContaining({
117+
chainId: ChainId.ArbitrumOne,
118+
address: childTokensByCoinKey[CoinKey.ETH].address,
119+
symbol: CoinKey.ETH,
120+
}),
121+
]);
122+
});
123+
124+
it('uses parent logo when child token logo is missing', () => {
125+
const parentLogo = 'https://example.com/parent.png';
126+
const parentTokens = [
127+
buildToken({
128+
coinKey: CoinKey.ETH,
129+
chainId: LiFiChainId.ETH,
130+
logoURI: parentLogo,
131+
}),
132+
];
133+
const childTokensByCoinKey = {
134+
[CoinKey.ETH]: buildToken({
135+
coinKey: CoinKey.ETH,
136+
chainId: LiFiChainId.ARB,
137+
logoURI: undefined,
138+
}),
139+
};
140+
141+
const tokens = mapParentTokensToTokens({
142+
parentTokens,
143+
childTokensByCoinKey,
144+
parentChainId: ChainId.Ethereum,
145+
childChainId: ChainId.ArbitrumOne,
146+
});
147+
148+
expect(tokens[0]?.logoURI).toBe(parentLogo);
149+
});
150+
151+
it('uses child logo when parent token logo is missing', () => {
152+
const childLogo = 'https://example.com/child.png';
153+
const parentTokens = [
154+
buildToken({
155+
coinKey: CoinKey.WETH,
156+
chainId: LiFiChainId.ARB,
157+
logoURI: undefined,
158+
}),
159+
];
160+
const childTokensByCoinKey = {
161+
[CoinKey.WETH]: buildToken({
162+
coinKey: CoinKey.WETH,
163+
chainId: LiFiChainId.SUP,
164+
logoURI: childLogo,
165+
}),
166+
};
167+
168+
const tokens = mapParentTokensToTokens({
169+
parentTokens,
170+
childTokensByCoinKey,
171+
parentChainId: ChainId.ArbitrumOne,
172+
childChainId: ChainId.Superposition,
173+
});
174+
175+
expect(tokens[0]?.logoURI).toBe(childLogo);
176+
});
177+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { CoinKey } from '@lifi/sdk';
2+
import { TokenList } from '@uniswap/token-lists';
3+
4+
import { ChainId } from '@/bridge/types/ChainId';
5+
6+
import { LifiTokenWithCoinKey } from './registry';
7+
8+
type MapTokensParams = {
9+
parentTokens: LifiTokenWithCoinKey[];
10+
childTokensByCoinKey: Record<string, LifiTokenWithCoinKey>;
11+
parentChainId: number;
12+
childChainId: number;
13+
};
14+
15+
export const mapParentTokensToTokens = ({
16+
parentTokens,
17+
childTokensByCoinKey,
18+
parentChainId,
19+
childChainId,
20+
}: MapTokensParams): TokenList['tokens'] => {
21+
return parentTokens.reduce<TokenList['tokens']>((acc, token) => {
22+
const childToken =
23+
childChainId === ChainId.ApeChain && token.coinKey === CoinKey.ETH
24+
? childTokensByCoinKey[CoinKey.WETH]
25+
: childTokensByCoinKey[token.coinKey];
26+
27+
if (!childToken) {
28+
return acc;
29+
}
30+
31+
// Some tokens on Lifi are missing logoURIs, so we fallback to the other token's logoURI if available
32+
const fallbackLogoURI = childToken.logoURI ?? token.logoURI;
33+
acc.push({
34+
chainId: childChainId,
35+
address: childToken.address,
36+
name: childToken.name,
37+
symbol: childToken.symbol,
38+
decimals: childToken.decimals,
39+
logoURI: fallbackLogoURI,
40+
extensions: {
41+
bridgeInfo: {
42+
[parentChainId]: {
43+
tokenAddress: token.address,
44+
name: token.name,
45+
symbol: token.symbol,
46+
decimals: token.decimals,
47+
logoURI: token.logoURI,
48+
},
49+
},
50+
},
51+
});
52+
53+
return acc;
54+
}, []);
55+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { CoinKey } from '@lifi/sdk';
2+
import { ChainId } from '@lifi/sdk';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { handleUSDC } from './registry';
6+
7+
const placeholderToken = {
8+
address: '0x0000000000000000000000000000000000000000',
9+
name: 'Ether',
10+
symbol: 'ETH',
11+
decimals: 18,
12+
priceUSD: '0',
13+
};
14+
15+
describe('handleUSDC', () => {
16+
it('drops USDCe on chains where native USDC exist', () => {
17+
expect(
18+
handleUSDC({ ...placeholderToken, chainId: ChainId.ARB, coinKey: CoinKey.USDCe }),
19+
).toBeNull();
20+
expect(
21+
handleUSDC({ ...placeholderToken, chainId: ChainId.ETH, coinKey: CoinKey.USDCe }),
22+
).toBeNull();
23+
expect(
24+
handleUSDC({ ...placeholderToken, chainId: ChainId.BAS, coinKey: CoinKey.USDCe }),
25+
).toBeNull();
26+
});
27+
28+
it('keeps USDCe on chains without native USDC', () => {
29+
expect(
30+
handleUSDC({ ...placeholderToken, chainId: ChainId.APE, coinKey: CoinKey.USDCe }),
31+
).toEqual({
32+
...placeholderToken,
33+
chainId: ChainId.APE,
34+
coinKey: CoinKey.USDC,
35+
});
36+
expect(
37+
handleUSDC({ ...placeholderToken, chainId: ChainId.SUP, coinKey: CoinKey.USDCe }),
38+
).toEqual({
39+
...placeholderToken,
40+
chainId: ChainId.SUP,
41+
coinKey: CoinKey.USDC,
42+
});
43+
});
44+
45+
it('returns original token for non USDC tokens', () => {
46+
expect(
47+
handleUSDC({ ...placeholderToken, chainId: ChainId.BAS, coinKey: CoinKey.WBTC }),
48+
).toEqual({
49+
...placeholderToken,
50+
chainId: ChainId.BAS,
51+
coinKey: CoinKey.WBTC,
52+
});
53+
expect(
54+
handleUSDC({ ...placeholderToken, chainId: ChainId.APE, coinKey: CoinKey.WETH }),
55+
).toEqual({
56+
...placeholderToken,
57+
chainId: ChainId.APE,
58+
coinKey: CoinKey.WETH,
59+
});
60+
});
61+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { CoinKey, ChainId as LiFiChainId, type Token as LiFiToken, getTokens } from '@lifi/sdk';
2+
import { unstable_cache } from 'next/cache';
3+
4+
import { allowedLifiSourceChainIds } from '@/bridge/app/api/crosschain-transfers/constants';
5+
import { ChainId } from '@/bridge/types/ChainId';
6+
7+
export const LIFI_TOKENS_REVALIDATE_SECONDS = 60 * 60;
8+
9+
const EXCLUDED_ADDRESSES: Partial<Record<number, Set<string>>> = {
10+
[ChainId.ArbitrumOne]: new Set([
11+
'0x74885b4d524d497261259b38900f54e6dbad2210', // Old Ape token
12+
'0xB9C8F0d3254007eE4b98970b94544e473Cd610EC', // Old QiDao token
13+
]),
14+
};
15+
16+
export type LifiTokenWithCoinKey = LiFiToken & { coinKey: CoinKey };
17+
/**
18+
* Normalizes USDC tokens:
19+
* - Drops USDC.e on chains that have native USDC (Arbitrum, Base, Ethereum)
20+
* - Remaps USDC.e to USDC on ApeChain and Superposition
21+
* - Keeps all other tokens unchanged
22+
*/
23+
export function handleUSDC(token: LifiTokenWithCoinKey): LifiTokenWithCoinKey | null {
24+
if (token.coinKey !== CoinKey.USDCe) {
25+
return token;
26+
}
27+
28+
// Drop USDC.e on chains that have native USDC.
29+
if (
30+
token.chainId === LiFiChainId.ARB ||
31+
token.chainId === LiFiChainId.BAS ||
32+
token.chainId === LiFiChainId.ETH
33+
) {
34+
return null;
35+
}
36+
37+
// Remap USDC.e to USDC
38+
if (token.chainId === LiFiChainId.APE || token.chainId === LiFiChainId.SUP) {
39+
return { ...token, coinKey: CoinKey.USDC };
40+
}
41+
42+
return token;
43+
}
44+
45+
export interface LifiTokenRegistry {
46+
tokensByChain: Record<number, LifiTokenWithCoinKey[]>;
47+
tokensByChainAndCoinKey: Record<number, Record<string, LifiTokenWithCoinKey>>;
48+
}
49+
50+
const fetchRegistry = async (): Promise<LifiTokenRegistry> => {
51+
const response = await getTokens({
52+
chains: allowedLifiSourceChainIds as unknown as LiFiChainId[],
53+
});
54+
if (!response.tokens) {
55+
return {
56+
tokensByChain: {},
57+
tokensByChainAndCoinKey: {},
58+
};
59+
}
60+
61+
const tokensByChain: LifiTokenRegistry['tokensByChain'] = {};
62+
const tokensByChainAndCoinKey: LifiTokenRegistry['tokensByChainAndCoinKey'] = {};
63+
64+
for (const chainId of allowedLifiSourceChainIds) {
65+
const tokensGroupedByCoinKey: Partial<Record<CoinKey, LifiTokenWithCoinKey>> = {};
66+
67+
const filteredTokens = (response.tokens[chainId] ?? []).reduce<LifiTokenWithCoinKey[]>(
68+
(acc, token) => {
69+
// Exclude tokens on the exclude list or tokens without coinKey
70+
if (EXCLUDED_ADDRESSES[chainId]?.has(token.address.toLowerCase())) return acc;
71+
if (!token.coinKey) return acc;
72+
73+
const normalizedToken = handleUSDC(token as LifiTokenWithCoinKey);
74+
if (!normalizedToken) return acc;
75+
76+
tokensGroupedByCoinKey[normalizedToken.coinKey] ??= normalizedToken;
77+
acc.push(normalizedToken);
78+
return acc;
79+
},
80+
[],
81+
);
82+
83+
tokensByChain[chainId] = filteredTokens;
84+
tokensByChainAndCoinKey[chainId] = tokensGroupedByCoinKey;
85+
}
86+
87+
return { tokensByChain, tokensByChainAndCoinKey };
88+
};
89+
90+
export const getLifiTokenRegistry = unstable_cache(fetchRegistry, ['lifi-token-registry'], {
91+
revalidate: LIFI_TOKENS_REVALIDATE_SECONDS,
92+
});

0 commit comments

Comments
 (0)