diff --git a/packages/arb-token-bridge-ui/src/util/TokenUtils.ts b/packages/arb-token-bridge-ui/src/util/TokenUtils.ts index 0e17179dcf..9b69eaaa67 100644 --- a/packages/arb-token-bridge-ui/src/util/TokenUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/TokenUtils.ts @@ -388,6 +388,9 @@ export const isTokenSepoliaUSDC = (tokenAddress: string | undefined) => export const isTokenArbitrumSepoliaUSDCe = (tokenAddress: string | undefined) => addressesEqual(tokenAddress, CommonAddress.ArbitrumSepolia['USDC.e']) +export const isTokenArbitrumOneUSDT = (tokenAddress: string | undefined) => + addressesEqual(tokenAddress, CommonAddress.ArbitrumOne.USDT) + export const isTokenArbitrumOneNativeUSDC = ( tokenAddress: string | undefined ) => addressesEqual(tokenAddress, CommonAddress.ArbitrumOne.USDC) @@ -408,6 +411,9 @@ export const isTokenNativeUSDC = (tokenAddress: string | undefined) => { export const isTokenEthereumUSDT = (tokenAddress: string | undefined) => addressesEqual(tokenAddress, CommonAddress.Ethereum.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, diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index 0a96588efa..8d099bf335 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -3,12 +3,13 @@ import { ethers } from 'ethers' import { getProviderForChainId } from '@/token-bridge-sdk/utils' - +import axios from 'axios' import { isNetwork } from '../util/networks' import { ChainId } from '../types/ChainId' import { isTokenArbitrumOneUSDCe, - isTokenArbitrumSepoliaUSDCe + isTokenArbitrumSepoliaUSDCe, + isTokenUSDT } from './TokenUtils' import { CommonAddress } from './CommonAddressUtils' @@ -281,29 +282,119 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = { ] } -async function isLayerZeroToken( +/** + * 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 -) { - const parentProvider = getProviderForChainId(parentChainId) +): Promise { + const chainIdToLzName: Record = { + [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}`) + } - // 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 + // 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 + // } + // } + // } + + const response = await axios.get( + 'https://metadata.layerzero-api.com/v1/metadata' ) + 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 { 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 @@ -335,7 +426,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 } diff --git a/packages/arb-token-bridge-ui/src/util/__tests__/WithdrawOnlyUtils.test.ts b/packages/arb-token-bridge-ui/src/util/__tests__/WithdrawOnlyUtils.test.ts new file mode 100644 index 0000000000..87e4fbbf55 --- /dev/null +++ b/packages/arb-token-bridge-ui/src/util/__tests__/WithdrawOnlyUtils.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect } from 'vitest' +import { isWithdrawOnlyToken } from '../WithdrawOnlyUtils' +import { ChainId } from '../../types/ChainId' +import { CommonAddress } from '../CommonAddressUtils' + +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, + 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 } + ) +})