Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
111 changes: 101 additions & 10 deletions packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// 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.
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 @@ -284,24 +285,111 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = {
],
};

async function isLayerZeroToken(parentChainErc20Address: string, parentChainId: number) {
const parentProvider = getProviderForChainId(parentChainId);
/**
* Fetches LayerZero's off-chain metadata (https://metadata.layerzero-api.com/v1/metadata) to identify OFT tokens.
* If found in the metadata, it means the token supports OFT transfers - hence shouldn't be deposited through Arbitrum's canonical bridge.
* @param parentChainErc20Address
* @param parentChainId
* @returns boolean - true if the token is an OFT token, false otherwise
*/
async function isLayerZeroTokenViaAPI(
parentChainErc20Address: string,
parentChainId: number,
): Promise<boolean> {
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];

if (!parentChainName) {
// We can't check via the API if the chain is not supported
throw new Error(`No LayerZero chain name for chain ID ${parentChainId}`);
}

// Fetches LayerZero's off-chain metadata to identify OFT tokens.
// If found in the metadata, it means the token supports OFT transfers - hence shouldn't be deposited through Arbitrum's canonical bridge.
// Schema:
// {
// "ethereum": { <-- parent chain name
// "tokens": {
// "0x57e114b691db790c35207b2e685d4a43181e6061": { <--- parent chain erc20 address
// "id": "ena",
// "symbol": "ENA",
// "decimals": 18
// }
// ...more tokens
// }
// }
// }

// 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');
Comment thread
dewanshparashar marked this conversation as resolved.
Outdated
const metadata = response.data;
const chainData = metadata[parentChainName];

if (chainData && chainData.tokens) {
const tokenInfo = Object.keys(chainData.tokens).find(
(tokenAddr) => tokenAddr.toLowerCase() === parentChainErc20Address.toLowerCase(),
);
return !!tokenInfo;
}

return false;
}

/**
* Checks if a token is an OFT token by querying the oftVersion function on the token contract.
* @param parentChainErc20Address
* @param parentChainId
* @returns boolean - true if the token is an OFT token, false otherwise
*/
async function isLayerZeroTokenOnChain(
parentChainErc20Address: string,
parentChainId: number,
): Promise<boolean> {
try {
const parentProvider = getProviderForChainId(parentChainId);
// 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 _isLayerZeroToken = await layerZeroTokenOftContract.oftVersion();
return !!_isLayerZeroToken;
} catch (error) {
// Assuming error means it's not an OFT token
return false;
}
}

/**
* Checks if a token is an OFT token by first trying the API check and then falling back to the on-chain check.
* @param parentChainErc20Address
* @param parentChainId
* @returns boolean - true if the token is an OFT token, false otherwise
*/
async function isLayerZeroToken(parentChainErc20Address: string, parentChainId: number) {
try {
if (await isLayerZeroTokenViaAPI(parentChainErc20Address, parentChainId)) {
return true;
}
} catch (e) {
console.error(
`Error checking LayerZero API for ${parentChainErc20Address} on chain ${parentChainId}. Falling back to on-chain check.`,
e,
);
// Fallback to on-chain check in case of API error
return isLayerZeroTokenOnChain(parentChainErc20Address, parentChainId);
}

return false;
}

/**
*
* @param erc20L1Address
Expand Down Expand Up @@ -333,7 +421,10 @@ export async function isWithdrawOnlyToken({
return true;
}

if (await isLayerZeroToken(parentChainErc20Address, parentChainId)) {
if (
!isTokenUSDT(parentChainErc20Address) && // USDT is a special case - it's bridged via OFT, but we still want to allow transfers coz of our OftV2 Integration
(await isLayerZeroToken(parentChainErc20Address, parentChainId))
) {
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest';

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

const networkTestTimeout = 10000;

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 a non-USDT LayerZero token',
async () => {
const result = await isWithdrawOnlyToken({
parentChainErc20Address: '0x57e114b691db790c35207b2e685d4a43181e6061', // ENA
parentChainId: ChainId.Ethereum,
childChainId: ChainId.ArbitrumOne,
});
expect(result).toBe(true);
},
{ 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