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
6 changes: 6 additions & 0 deletions packages/arb-token-bridge-ui/src/util/TokenUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,12 @@ export const isTokenNativeUSDC = (tokenAddress: string | undefined) => {
export const isTokenEthereumUSDT = (tokenAddress: string | undefined) =>
addressesEqual(tokenAddress, CommonAddress.Ethereum.USDT);

export const isTokenArbitrumOneUSDT = (tokenAddress: string | undefined) =>
addressesEqual(tokenAddress, CommonAddress.ArbitrumOne.USDT);

export const isTokenUSDT = (tokenAddress: string | undefined) =>
isTokenEthereumUSDT(tokenAddress) || isTokenArbitrumOneUSDT(tokenAddress);

// get the exact token symbol for a particular chain
export function sanitizeTokenSymbol(tokenSymbol: string, options: SanitizeTokenOptions) {
if (!options.erc20L1Address) {
Expand Down
189 changes: 169 additions & 20 deletions packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// tokens that can't be bridged to Arbitrum (maybe coz they have their native protocol bridges and custom implementation or they are being discontinued)
// the UI doesn't let users deposit such tokens. If bridged already, these can only be withdrawn.
Comment on lines -1 to -2
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

these comments should be useful to keep?

import axios from 'axios';
import { ethers } from 'ethers';

import { getProviderForChainId } from '@/token-bridge-sdk/utils';

import { ChainId } from '../types/ChainId';
import { isNetwork } from '../util/networks';
import { CommonAddress } from './CommonAddressUtils';
import { isTokenArbitrumOneUSDCe, isTokenArbitrumSepoliaUSDCe } from './TokenUtils';
import { isTokenArbitrumOneUSDCe, isTokenArbitrumSepoliaUSDCe, isTokenUSDT } from './TokenUtils';

export type WithdrawOnlyToken = {
symbol: string;
Expand Down Expand Up @@ -259,6 +258,12 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = {
l1Address: '0x607f4c5bb672230e8672085532f7e901544a7375',
l2Address: '0xe575586566b02a16338c199c23ca6d295d794e66',
},
{
symbol: 'pyUSD',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: PYUSD

l2CustomAddr: '0xfab5891ed867a1195303251912013b92c4fc3a1d',
l1Address: '0xa2c323fe5a74adffad2bf3e007e36bb029606444',
l2Address: '0x327006c8712fe0abdbbd55b7999db39b0967342e',
Comment on lines +263 to +265
Copy link
Copy Markdown
Member

@fionnachan fionnachan Feb 4, 2026

Choose a reason for hiding this comment

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

where do these come from??

the l2CustomAddr you gave is their OFT adapter address
we should use the innerTokenAddress

Suggested change
l2CustomAddr: '0xfab5891ed867a1195303251912013b92c4fc3a1d',
l1Address: '0xa2c323fe5a74adffad2bf3e007e36bb029606444',
l2Address: '0x327006c8712fe0abdbbd55b7999db39b0967342e',
l2CustomAddr: '0x46850ad61c2b7d64d08c9c754f45254596696984',
l1Address: '0x6c3ea9036406852006290770bedfcaba0e23a0e8',
l2Address: '0x327006c8712fe0abdbbd55b7999db39b0967342e',

},
],
[ChainId.ArbitrumNova]: [],
// Plume
Expand All @@ -284,29 +289,166 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = {
],
};

async function isLayerZeroToken(parentChainErc20Address: string, parentChainId: number) {
const parentProvider = getProviderForChainId(parentChainId);
type OFTCache = {
addressMap: Map<string, Map<string, string>>;
symbolSet: Map<string, Set<string>>;
};
let oftAddressesCache: OFTCache | null = null;

async function fetchOFTAddressesMap(): Promise<OFTCache> {
if (oftAddressesCache) {
return oftAddressesCache;
}

// https://github.com/LayerZero-Labs/LayerZero-v2/blob/592625b9e5967643853476445ffe0e777360b906/packages/layerzero-v2/evm/oapp/contracts/oft/OFT.sol#L37
const layerZeroTokenOftContract = new ethers.Contract(
parentChainErc20Address,
['function oftVersion() external pure virtual returns (bytes4 interfaceId, uint64 version)'],
parentProvider,
const response = await axios.get(
'https://metadata.layerzero-api.com/v1/metadata/experiment/ofts/list?chainNames=ethereum,arbitrum,base',
);
const metadata = response.data;

const chainAddressMap = new Map<string, Map<string, string>>();
const chainSymbolSet = new Map<string, Set<string>>();

for (const tokenSymbol in metadata) {
const tokenEntries = metadata[tokenSymbol];
if (!Array.isArray(tokenEntries)) continue;

for (const tokenEntry of tokenEntries) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the content in this for loop seems very repetitive and can be simplified with some helper functions.

const deployments = tokenEntry.deployments;
if (!deployments) continue;

for (const chainName in deployments) {
const deployment = deployments[chainName];
if (!deployment) continue;

let chainAddressMapForChain = chainAddressMap.get(chainName);
if (!chainAddressMapForChain) {
chainAddressMapForChain = new Map<string, string>();
chainAddressMap.set(chainName, chainAddressMapForChain);
}

let chainSymbolSetForChain = chainSymbolSet.get(chainName);
if (!chainSymbolSetForChain) {
chainSymbolSetForChain = new Set<string>();
chainSymbolSet.set(chainName, chainSymbolSetForChain);
}

chainSymbolSetForChain.add(tokenSymbol);

if (deployment.address) {
chainAddressMapForChain.set(deployment.address.toLowerCase(), tokenSymbol);
}

// OFT_ADAPTER wraps existing tokens, so we need to map the inner token address too
if (deployment.type === 'OFT_ADAPTER' && deployment.innerTokenAddress) {
chainAddressMapForChain.set(deployment.innerTokenAddress.toLowerCase(), tokenSymbol);
}
}
}
}

oftAddressesCache = {
addressMap: chainAddressMap,
symbolSet: chainSymbolSet,
};

return oftAddressesCache;
}

async function isLayerZeroTokenViaAPI(
parentChainErc20Address: string,
parentChainId: number,
childChainId: number,
): Promise<{ oft: boolean; symbol: string | null }> {
const chainIdToLzName: Record<number, string | undefined> = {
[ChainId.Ethereum]: 'ethereum',
[ChainId.ArbitrumOne]: 'arbitrum',
[ChainId.ArbitrumNova]: 'nova',
[ChainId.Sepolia]: 'ethereum-sepolia',
[ChainId.ArbitrumSepolia]: 'arbitrum-sepolia',
};

const parentChainName = chainIdToLzName[parentChainId];
const childChainName = chainIdToLzName[childChainId];

if (!parentChainName) {
throw new Error(`No LayerZero chain name for chain ID ${parentChainId}`);
}

if (!childChainName) {
return { oft: false, symbol: null };
}

try {
const oftCache = await fetchOFTAddressesMap();
const parentChainAddressMap = oftCache.addressMap.get(parentChainName);
const childChainSymbolSet = oftCache.symbolSet.get(childChainName);

if (!parentChainAddressMap || !childChainSymbolSet) {
return { oft: false, symbol: null };
}

const lowercasedAddress = parentChainErc20Address.toLowerCase();
const parentSymbol = parentChainAddressMap.get(lowercasedAddress);

// Only block if OFT exists on BOTH parent and child chains
if (parentSymbol && childChainSymbolSet.has(parentSymbol)) {
return {
oft: true,
symbol: parentSymbol,
};
}

return { oft: false, symbol: null };
} catch (error) {
throw new Error(`Failed to fetch LayerZero OFT metadata: ${error}`);
}
}

async function isLayerZeroTokenOnChain(
parentChainErc20Address: string,
parentChainId: number,
): Promise<{ oft: boolean; symbol: string | null }> {
try {
const parentProvider = getProviderForChainId(parentChainId);
const layerZeroTokenOftContract = new ethers.Contract(
parentChainErc20Address,
['function oftVersion() external pure virtual returns (bytes4 interfaceId, uint64 version)'],
parentProvider,
);
const _isLayerZeroToken = await layerZeroTokenOftContract.oftVersion();
return !!_isLayerZeroToken;
return {
oft: !!_isLayerZeroToken,
symbol: null,
};
} catch (error) {
return false;
return { oft: false, symbol: null };
}
}

async function isLayerZeroToken(
parentChainErc20Address: string,
parentChainId: number,
childChainId: number,
): Promise<{ oft: boolean; symbol: string | null }> {
try {
const result = await isLayerZeroTokenViaAPI(
parentChainErc20Address,
parentChainId,
childChainId,
);
if (result.oft) {
return result;
}
return { oft: false, symbol: null };
} catch (e) {
console.error(
`Error checking LayerZero API for ${parentChainErc20Address} on chain ${parentChainId}. Falling back to on-chain check.`,
e,
);
return isLayerZeroTokenOnChain(parentChainErc20Address, parentChainId);
}
}

/**
*
* @param erc20L1Address
* @param childChainId
*/
export async function isWithdrawOnlyToken({
parentChainErc20Address,
parentChainId,
Expand All @@ -316,7 +458,6 @@ export async function isWithdrawOnlyToken({
parentChainId: number;
childChainId: number;
}) {
// disable USDC.e deposits for Orbit chains
if (
(isTokenArbitrumOneUSDCe(parentChainErc20Address) ||
isTokenArbitrumSepoliaUSDCe(parentChainErc20Address)) &&
Expand All @@ -333,8 +474,16 @@ export async function isWithdrawOnlyToken({
return true;
}

if (await isLayerZeroToken(parentChainErc20Address, parentChainId)) {
return true;
// USDT is bridged via OFT but we allow it due to OftV2 integration
if (!isTokenUSDT(parentChainErc20Address)) {
const layerZeroResult = await isLayerZeroToken(
parentChainErc20Address,
parentChainId,
childChainId,
);
if (layerZeroResult.oft) {
return true;
}
}

return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @vitest-environment node
*/
import { registerCustomArbitrumNetwork } from '@arbitrum/sdk';
import { beforeAll, describe, expect, it } from 'vitest';

import { ChainId } from '../../types/ChainId';
import { CommonAddress } from '../CommonAddressUtils';
import { isWithdrawOnlyToken } from '../WithdrawOnlyUtils';
import { orbitMainnets } from '../orbitChainsList';

const networkTestTimeout = 10000;

beforeAll(() => {
const xaiChain = orbitMainnets[660279];
if (!xaiChain) {
throw new Error('Could not find Xai chain in the Orbit chains list.');
}
registerCustomArbitrumNetwork(xaiChain);
});

describe('isWithdrawOnlyToken', () => {
const orbitChainId = 660279; // Xai

it('should allow deposits for a standard token', async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0x1234567890123456789012345678901234567890',
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(false);
});

it('should block deposits for a token in the withdraw-only list', async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0x99D8a9C45b2ecA8864373A26D1459e3Dff1e17F3', // MIM on Arbitrum One
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(true);
});

it('should block deposits for USDC.e on an Orbit chain', async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: CommonAddress.ArbitrumOne['USDC.e'],
parentChainId: ChainId.ArbitrumOne,
childChainId: orbitChainId,
});
expect(result).toBe(true);
});

it('should allow deposits for USDC.e on a non-Orbit chain', async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: CommonAddress.ArbitrumOne['USDC.e'],
parentChainId: ChainId.Ethereum,
Comment on lines +54 to +55
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this doesn't make any sense because the parent chain is Ethereum so the ERC20 address should be the one on Ethereum instead of Arbitrum One

childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(false);
});

it(
'should block deposits for ENA token (has OFT on both Ethereum and Arbitrum)',
async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0x57e114b691db790c35207b2e685d4a43181e6061', // ENA innerTokenAddress on Ethereum
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(true);
},
{ timeout: networkTestTimeout },
);

it(
'should allow deposits for DAI token (has OFT only on Ethereum, not on Arbitrum)',
async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI innerTokenAddress on Ethereum
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(false);
},
{ timeout: networkTestTimeout },
);

it(
'should allow deposits for ARB token (no OFT implementation)',
async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1', // ARB token on Ethereum
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(false);
},
{ timeout: networkTestTimeout },
);

it(
'should allow deposits for USDT as a special case',
async () => {
const usdtAddress = CommonAddress.Ethereum.USDT;

const result = await isWithdrawOnlyToken({
parentChainErc20Address: usdtAddress,
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(false);
},
{ timeout: networkTestTimeout },
);

it(
'should allow deposits for a token not in LayerZero metadata',
async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0x9876543210987654321098765432109876543210',
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(false);
},
{ timeout: networkTestTimeout },
);
});
Loading