Skip to content
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 @@ -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)
Expand All @@ -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,
Expand Down
120 changes: 107 additions & 13 deletions packages/arb-token-bridge-ui/src/util/WithdrawOnlyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<boolean> {
const chainIdToLzName: Record<number, string | undefined> = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like all values are defined, we can narrow down the type here

[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(
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason we use axios over fetch here? We don't seem to leverage any of the utilities of axios

'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<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 @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
)
})
Loading