From 57bd1179c88647ec7c735a917f8da8a9060e3e63 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 20 Jun 2025 13:43:38 +0400 Subject: [PATCH 1/8] feat: enhance oft token detection to block deposits --- .../src/util/WithdrawOnlyUtils.ts | 69 +++++++++++++++---- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index 0a96588efa..a127774a9e 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -1,9 +1,7 @@ // 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 { 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 { @@ -281,27 +279,68 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = { ] } +/** + * 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. + * @param parentChainErc20Address + * @param parentChainId + * @returns boolean - true if the token is an OFT token, false otherwise + */ async function isLayerZeroToken( parentChainErc20Address: string, parentChainId: number ) { - const parentProvider = getProviderForChainId(parentChainId) + const chainIdToLzName: Record = { + [ChainId.Ethereum]: 'ethereum', + [ChainId.ArbitrumOne]: 'arbitrum', + [ChainId.ArbitrumNova]: 'nova', + [ChainId.Sepolia]: 'ethereum-sepolia', + [ChainId.ArbitrumSepolia]: 'arbitrum-sepolia' + } - // 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 parentChainName = chainIdToLzName[parentChainId] + + if (!parentChainName) { + return false + } try { - const _isLayerZeroToken = await layerZeroTokenOftContract.oftVersion() - return !!_isLayerZeroToken + // 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. + // { + // "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 + } } catch (error) { - return false + console.error( + `Error fetching or processing LayerZero metadata for ${parentChainErc20Address} on chain ${parentChainId}:`, + error + ) } + + return false } /** From 2e248332694a0857f15057c2327c0ef114fa4be4 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 20 Jun 2025 13:44:30 +0400 Subject: [PATCH 2/8] comment --- packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index a127774a9e..4c81d3545d 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -307,6 +307,7 @@ async function isLayerZeroToken( try { // 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": { From e0faf2dd3b956504b402a10ab3e577f706d968df Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 20 Jun 2025 13:50:22 +0400 Subject: [PATCH 3/8] dev: enhanced comment --- packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index 4c81d3545d..7d7b59d662 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -305,7 +305,7 @@ async function isLayerZeroToken( } try { - // Fetches LayerZero's off-chain metadata to identify OFT tokens. + // 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. // Schema: // { From 29f1d935df4d0aa3dd135fbf6e99e76b6aa4bdca Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 20 Jun 2025 13:51:52 +0400 Subject: [PATCH 4/8] comment --- packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index 7d7b59d662..d8a8b692f8 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -280,7 +280,7 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = { } /** - * Fetches LayerZero's off-chain metadata to identify OFT tokens. + * 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 @@ -305,7 +305,7 @@ async function isLayerZeroToken( } try { - // Fetches LayerZero's off-chain metadata (https://metadata.layerzero-api.com/v1/metadata) to identify OFT tokens. + // 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: // { From f96b21c11e4163cefed0fdd27ce874ffe8d1d7d6 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 20 Jun 2025 14:01:13 +0400 Subject: [PATCH 5/8] dev: allow USDT transfers even if OFT --- packages/arb-token-bridge-ui/src/util/TokenUtils.ts | 6 ++++++ .../arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) 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 d8a8b692f8..3386ec5739 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -6,7 +6,8 @@ import { isNetwork } from '../util/networks' import { ChainId } from '../types/ChainId' import { isTokenArbitrumOneUSDCe, - isTokenArbitrumSepoliaUSDCe + isTokenArbitrumSepoliaUSDCe, + isTokenUSDT } from './TokenUtils' import { CommonAddress } from './CommonAddressUtils' @@ -375,7 +376,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 } From eebc90889793d4a4b73e3d3297953448e6faecb3 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Fri, 20 Jun 2025 14:19:02 +0400 Subject: [PATCH 6/8] dev: add tests --- .../util/__tests__/WithdrawOnlyUtils.test.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/arb-token-bridge-ui/src/util/__tests__/WithdrawOnlyUtils.test.ts 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 } + ) +}) From f9706ac111b092371b75a4da4e7d35d46491bec5 Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Mon, 23 Jun 2025 16:39:32 +0400 Subject: [PATCH 7/8] dev: add fallback detection of oftVersion check --- .../src/util/WithdrawOnlyUtils.ts | 115 +++++++++++++----- 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index 3386ec5739..1530e6e027 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -1,6 +1,8 @@ // 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 { ethers } from 'ethers' +import { getProviderForChainId } from '@/token-bridge-sdk/utils' import axios from 'axios' import { isNetwork } from '../util/networks' import { ChainId } from '../types/ChainId' @@ -287,10 +289,10 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = { * @param parentChainId * @returns boolean - true if the token is an OFT token, false otherwise */ -async function isLayerZeroToken( +async function isLayerZeroTokenViaAPI( parentChainErc20Address: string, parentChainId: number -) { +): Promise { const chainIdToLzName: Record = { [ChainId.Ethereum]: 'ethereum', [ChainId.ArbitrumOne]: 'arbitrum', @@ -302,44 +304,93 @@ async function isLayerZeroToken( const parentChainName = chainIdToLzName[parentChainId] if (!parentChainName) { - return false + // We can't check via the API if the chain is not supported + throw new Error(`No LayerZero chain name for chain ID ${parentChainId}`) } - try { - // 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 - // } - // } - // } + // 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] - const response = await axios.get( - 'https://metadata.layerzero-api.com/v1/metadata' + if (chainData && chainData.tokens) { + const tokenInfo = Object.keys(chainData.tokens).find( + tokenAddr => + tokenAddr.toLowerCase() === parentChainErc20Address.toLowerCase() ) - const metadata = response.data - const chainData = metadata[parentChainName] + return !!tokenInfo + } - 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 { + // We prefer the API check as it's faster and less resource intensive + if (await isLayerZeroTokenViaAPI(parentChainErc20Address, parentChainId)) { + return true + } + } catch (e) { console.error( - `Error fetching or processing LayerZero metadata for ${parentChainErc20Address} on chain ${parentChainId}:`, - 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 From 830623e7b78d59b4051469492931470598588dfa Mon Sep 17 00:00:00 2001 From: dewanshparashar Date: Mon, 23 Jun 2025 16:41:22 +0400 Subject: [PATCH 8/8] dev: comms --- packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts index 1530e6e027..8d099bf335 100644 --- a/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts @@ -380,7 +380,6 @@ async function isLayerZeroToken( parentChainId: number ) { try { - // We prefer the API check as it's faster and less resource intensive if (await isLayerZeroTokenViaAPI(parentChainErc20Address, parentChainId)) { return true }