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
121 changes: 104 additions & 17 deletions packages/app/src/app/api/crosschain-transfers/lifi/tokens/registry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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 {
allowedLifiSourceChainIds,
lifiDestinationChainIds,
} from '@/bridge/app/api/crosschain-transfers/constants';
import { ChainId } from '@/bridge/types/ChainId';
import { CommonAddress } from '@/bridge/util/CommonAddressUtils';

Expand Down Expand Up @@ -182,25 +185,89 @@ export interface LifiTokenRegistry {
tokensByChainAndCoinKey: Record<number, Record<string, LifiTokenWithCoinKey>>;
}

const fetchRegistry = async (): Promise<LifiTokenRegistry> => {
// LiFi returns extra metadata (e.g. verification status breakdowns) that nothing downstream reads
function toSlimToken(token: LifiTokenWithCoinKey): LifiTokenWithCoinKey {
return {
chainId: token.chainId,
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
name: token.name,
coinKey: token.coinKey,
logoURI: token.logoURI,
priceUSD: token.priceUSD,
};
}

// Chains reachable from each chain, in either direction
const PARTNER_CHAIN_IDS: Record<number, Set<number>> = {};
for (const [sourceChainId, destinationChainIds] of Object.entries(lifiDestinationChainIds)) {
const sourceChainIdNum = Number(sourceChainId);
for (const destinationChainId of destinationChainIds) {
(PARTNER_CHAIN_IDS[sourceChainIdNum] ??= new Set()).add(destinationChainId);
(PARTNER_CHAIN_IDS[destinationChainId] ??= new Set()).add(sourceChainIdNum);
}
}

/**
* Tokens are matched across chains by coinKey (see groupChildTokensAndParentTokens),
* so a token whose coinKey doesn't exist on any partner chain can never appear
* in a route and only bloats the cache. LiFi returns thousands of such tokens
* (e.g. Ethereum-only meme tokens).
*/
Comment thread
dewanshparashar marked this conversation as resolved.
function keepRoutableTokens(
tokensByChain: LifiTokenRegistry['tokensByChain'],
): LifiTokenRegistry['tokensByChain'] {
const coinKeysByChain: Record<number, Set<CoinKey>> = {};
for (const chainId of allowedLifiSourceChainIds) {
coinKeysByChain[chainId] = new Set(
(tokensByChain[chainId] ?? []).map((token) => token.coinKey),
);
}

const routableTokensByChain: LifiTokenRegistry['tokensByChain'] = {};

for (const chainId of allowedLifiSourceChainIds) {
const partnerCoinKeys = new Set<CoinKey>();
for (const partnerChainId of PARTNER_CHAIN_IDS[chainId] ?? []) {
for (const coinKey of coinKeysByChain[partnerChainId] ?? []) {
partnerCoinKeys.add(coinKey);
}

// ETH on a parent chain routes to WETH on ApeChain (see groupChildTokensAndParentTokens)
if (
partnerChainId === ChainId.ApeChain &&
coinKeysByChain[partnerChainId]?.has(CoinKey.WETH)
) {
partnerCoinKeys.add(CoinKey.ETH);
}
}

if (chainId === ChainId.ApeChain && partnerCoinKeys.has(CoinKey.ETH)) {
partnerCoinKeys.add(CoinKey.WETH);
}

routableTokensByChain[chainId] = (tokensByChain[chainId] ?? []).filter((token) =>
partnerCoinKeys.has(token.coinKey),
);
}

return routableTokensByChain;
}

const fetchTokensByChain = async (): Promise<LifiTokenRegistry['tokensByChain']> => {
const response = await getTokens({
chains: allowedLifiSourceChainIds as unknown as LiFiChainId[],
});

if (!response.tokens) {
return {
tokensByChain: {},
tokensByChainAndCoinKey: {},
};
return {};
}
Comment thread
dewanshparashar marked this conversation as resolved.

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[]>(
tokensByChain[chainId] = (response.tokens[chainId] ?? []).reduce<LifiTokenWithCoinKey[]>(
(acc, token) => {
// Exclude tokens on the exclude list
if (isExcludedToken(token, chainId)) return acc;
Expand All @@ -211,20 +278,40 @@ const fetchRegistry = async (): Promise<LifiTokenRegistry> => {
const tokenWithLogoURI = assignLogoURI(tokenWithCoinKey);
const normalizedToken = normalizeTokenMetadata(tokenWithLogoURI);

tokensGroupedByCoinKey[normalizedToken.coinKey] ??= normalizedToken;
acc.push(normalizedToken);
acc.push(toSlimToken(normalizedToken));
return acc;
},
[],
);

tokensByChain[chainId] = filteredTokens;
tokensByChainAndCoinKey[chainId] = tokensGroupedByCoinKey;
}

return { tokensByChain, tokensByChainAndCoinKey };
return keepRoutableTokens(tokensByChain);
};

export const getLifiTokenRegistry = unstable_cache(fetchRegistry, ['lifi-token-registry'], {
/**
* The serialized value must stay under Next.js' 2MB unstable_cache item limit,
* otherwise every cache write fails and every request refetches from LiFi.
* So we cache only the slimmed per-chain token arrays and rebuild the
* coinKey lookup (which duplicates almost every token) on read.
*/
const getCachedTokensByChain = unstable_cache(fetchTokensByChain, ['lifi-tokens-by-chain'], {
revalidate: 30,
});

export async function getLifiTokenRegistry(): Promise<LifiTokenRegistry> {
const tokensByChain = await getCachedTokensByChain();

const tokensByChainAndCoinKey: LifiTokenRegistry['tokensByChainAndCoinKey'] = {};

for (const chainId of allowedLifiSourceChainIds) {
const tokensGroupedByCoinKey: Partial<Record<CoinKey, LifiTokenWithCoinKey>> = {};

for (const token of tokensByChain[chainId] ?? []) {
tokensGroupedByCoinKey[token.coinKey] ??= token;
}

tokensByChainAndCoinKey[chainId] = tokensGroupedByCoinKey;
}

return { tokensByChain, tokensByChainAndCoinKey };
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,21 @@ export function useTokensFromLists() {
const { childChain, parentChain } = useNetworksRelationship(networks);
const { data: tokenLists, isLoading: isLoadingTokenLists } = useTokenLists(childChain.id);

const { data = emptyData, isLoading } = useSWRImmutable(
[tokenLists ?? [], parentChain.id, childChain.id, 'useTokensFromLists'],
([_tokenLists, _parentChainId, _childChainId]) =>
tokenListsToSearchableTokenStorage(
_tokenLists,
String(_parentChainId),
String(_childChainId),
),
);

return { data, isLoading: isLoadingTokenLists || isLoading };
// Derived synchronously so the tokens can never lag behind the fetched lists,
// which an extra async hook allowed under heavy re-rendering
Comment on lines +20 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we used SWR because otherwise each caller of this hook would fetch and return their own instances, this was causing performance issues

@dewanshparashar dewanshparashar Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Understood. Worth noting that this is only a PoC which tries to get E2Es passing again by fixing some erroring parts of the bridge. If removing token-list SWR got the E2Es passing, then it doesn't necessarily mean we will remove SWR, but that the current implementation is worth going over and optimizing a bit to find out what's wrong with it. Just gives us a starting point of where to poke.

const data = useMemo(() => {
if (!tokenLists) {
return emptyData;
}

return tokenListsToSearchableTokenStorage(
tokenLists,
String(parentChain.id),
String(childChain.id),
);
}, [tokenLists, parentChain.id, childChain.id]);

return { data, isLoading: isLoadingTokenLists };
}

export function useTokensFromUser(): ContractStorage<ERC20BridgeToken> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function useTokenListPriceUpdater({
...data,
};
});
}, false);
});
}, [arbTokenBridgeLoaded, childChain.id, parentChain.id, mutate]);

useInterval(refreshLifiTokenList, intervalMs);
Expand Down
93 changes: 83 additions & 10 deletions packages/arb-token-bridge-ui/src/hooks/useTokenLists.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { SWRResponse } from 'swr';
import useSWRImmutable from 'swr/immutable';
import { useCallback, useEffect, useSyncExternalStore } from 'react';

import {
TokenListWithId,
Expand All @@ -10,6 +9,51 @@ import { isNetwork } from '../util/networks';
import { useNetworks } from './useNetworks';
import { useNetworksRelationship } from './useNetworksRelationship';

/**
* Token lists are kept in a plain module-level store instead of SWR: the lists
* are fetched once per chain pair and reading the store during render makes it
* impossible for subscribers to miss the result, which happened with SWR when
* components remounted while the fetch was in flight.
*/
const tokenListsCache = new Map<string, TokenListWithId[]>();
const tokenListsInFlight = new Map<string, Promise<void>>();
const tokenListsListeners = new Set<() => void>();

function notifyTokenListsListeners() {
tokenListsListeners.forEach((listener) => listener());
}

function subscribeToTokenLists(listener: () => void) {
tokenListsListeners.add(listener);
return () => {
tokenListsListeners.delete(listener);
};
}

function fetchAndCacheTokenLists({
cacheKey,
forL2ChainId,
parentChainId,
}: {
cacheKey: string;
forL2ChainId: number;
parentChainId: number;
}): Promise<void> {
const pending = tokenListsInFlight.get(cacheKey);
if (pending) {
return pending;
}

const promise = fetchTokenLists(forL2ChainId, parentChainId).then((tokenLists) => {
tokenListsCache.set(cacheKey, tokenLists);
tokenListsInFlight.delete(cacheKey);
notifyTokenListsListeners();
});

tokenListsInFlight.set(cacheKey, promise);
return promise;
}

function fetchTokenLists(forL2ChainId: number, parentChainId: number): Promise<TokenListWithId[]> {
return new Promise((resolve) => {
const { isOrbitChain } = isNetwork(forL2ChainId);
Expand Down Expand Up @@ -60,16 +104,45 @@ function fetchTokenLists(forL2ChainId: number, parentChainId: number): Promise<T
});
}

export function useTokenLists(forL2ChainId: number): SWRResponse<TokenListWithId[]> {
export type UseTokenListsResult = {
data: TokenListWithId[] | undefined;
isLoading: boolean;
isValidating: boolean;
mutate: (
updater: (
current: TokenListWithId[] | undefined,
) => Promise<TokenListWithId[] | undefined> | TokenListWithId[] | undefined,
) => Promise<void>;
};

export function useTokenLists(forL2ChainId: number): UseTokenListsResult {
const [networks] = useNetworks();
const { parentChain } = useNetworksRelationship(networks);
return useSWRImmutable(
['useTokenLists', forL2ChainId, parentChain.id],
([, _forL2ChainId, _parentChainId]) => fetchTokenLists(_forL2ChainId, _parentChainId),
{
shouldRetryOnError: true,
errorRetryCount: 2,
errorRetryInterval: 1_000,
const parentChainId = parentChain.id;
const cacheKey = `${forL2ChainId}:${parentChainId}`;

const data = useSyncExternalStore(
subscribeToTokenLists,
() => tokenListsCache.get(cacheKey),
() => undefined,
);

useEffect(() => {
if (!tokenListsCache.has(cacheKey)) {
void fetchAndCacheTokenLists({ cacheKey, forL2ChainId, parentChainId });
}
}, [cacheKey, forL2ChainId, parentChainId]);

const mutate = useCallback<UseTokenListsResult['mutate']>(
async (updater) => {
const updated = await updater(tokenListsCache.get(cacheKey));
if (updated) {
tokenListsCache.set(cacheKey, updated);
notifyTokenListsListeners();
}
},
[cacheKey],
);

return { data, isLoading: !data, isValidating: !data, mutate };
}
11 changes: 10 additions & 1 deletion packages/arb-token-bridge-ui/src/util/wagmi/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ const defaultChains: AppKitNetworkList = [

function getChainList(): AppKitNetworkList {
if (isE2eTestingEnvironment) {
return asAppKitNetworkList([local, arbitrumLocal, l3Local, sepolia, arbitrumSepolia, mainnet]);
return asAppKitNetworkList([
local,
arbitrumLocal,
l3Local,
sepolia,
arbitrumSepolia,
// mainnet chains are required for the import token test
mainnet,
arbitrum,
Comment thread
dewanshparashar marked this conversation as resolved.
]);
}

if (isDevelopmentEnvironment) {
Expand Down
7 changes: 5 additions & 2 deletions packages/arb-token-bridge-ui/tests/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ export function login({
function _startWebApp() {
const sourceChain = networkNameWithDefault.toLowerCase().replace(/ /g, '-');

// when testing Orbit chains we want to set destination chain to L3
// when testing Orbit chains we want to set destination chain to L3,
// unless the test explicitly connects to another network (e.g. mainnet)
const defaultDestinationChain =
networkType === 'parentChain' && network.chainId === 412346 ? 'nitro-testnode-l3' : '';
networkType === 'parentChain' && network.chainId === 412346 && !networkName
? 'nitro-testnode-l3'
: '';
startWebApp(url, {
query: {
...query,
Expand Down
Loading