Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In later PR, we will need to run those tests in CI

"test:ci": "vitest --config vitest.config.ts --run"
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add native USDC on Ethereum, native USDC on Arb One, and wrapped USDC.e on Arb One as test cases?

Original file line number Diff line number Diff line change
@@ -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<LifiTokenWithCoinKey> & Pick<LifiTokenWithCoinKey, 'coinKey'>,
): 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);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, LifiTokenWithCoinKey>;
parentChainId: number;
childChainId: number;
};

export const mapParentTokensToTokens = ({
parentTokens,
childTokensByCoinKey,
parentChainId,
childChainId,
}: MapTokensParams): TokenList['tokens'] => {
return parentTokens.reduce<TokenList['tokens']>((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;
}, []);
};
104 changes: 104 additions & 0 deletions packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts
Original file line number Diff line number Diff line change
@@ -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<Record<number, string>>;
};

const CUSTOM_TOKENS: CustomTokenConfig[] = [
{
coinKey: 'ENA',
addresses: {
[ChainId.Ethereum]: '0x57e114B691Db790C35207b2e685D4A43181e6061',
[ChainId.Base]: '0x58538e6A46E07434d7E7375Bc268D3cb839C0133',
[ChainId.ArbitrumOne]: '0x58538e6A46E07434d7E7375Bc268D3cb839C0133',
},
},
];

const EXCLUDED_ADDRESSES: Partial<Record<number, Set<string>>> = {
[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<number, LifiTokenWithCoinKey[]>;
tokensByChainAndCoinKey: Record<number, Record<string, LifiTokenWithCoinKey>>;
}

const fetchRegistry = async (): Promise<LifiTokenRegistry> => {
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<Record<CoinKey, LifiTokenWithCoinKey>> = {};

const filteredTokens = (response.tokens[chainId] ?? []).reduce<LifiTokenWithCoinKey[]>(
(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,
});
Loading
Loading