-
Notifications
You must be signed in to change notification settings - Fork 0
feat: enhance OFT token detection to block deposits #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
3b37d4e
f6f1df4
6d05883
1ff3489
7967eb1
fc0313d
11a454f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||||||||||||||
| 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; | ||||||||||||||
|
|
@@ -259,6 +258,12 @@ export const withdrawOnlyTokens: { [chainId: number]: WithdrawOnlyToken[] } = { | |||||||||||||
| l1Address: '0x607f4c5bb672230e8672085532f7e901544a7375', | ||||||||||||||
| l2Address: '0xe575586566b02a16338c199c23ca6d295d794e66', | ||||||||||||||
| }, | ||||||||||||||
| { | ||||||||||||||
| symbol: 'pyUSD', | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. where do these come from?? the
Suggested change
|
||||||||||||||
| }, | ||||||||||||||
| ], | ||||||||||||||
| [ChainId.ArbitrumNova]: [], | ||||||||||||||
| // Plume | ||||||||||||||
|
|
@@ -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) { | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||||||||||
|
|
@@ -316,7 +458,6 @@ export async function isWithdrawOnlyToken({ | |||||||||||||
| parentChainId: number; | ||||||||||||||
| childChainId: number; | ||||||||||||||
| }) { | ||||||||||||||
| // disable USDC.e deposits for Orbit chains | ||||||||||||||
| if ( | ||||||||||||||
| (isTokenArbitrumOneUSDCe(parentChainErc20Address) || | ||||||||||||||
| isTokenArbitrumSepoliaUSDCe(parentChainErc20Address)) && | ||||||||||||||
|
|
@@ -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; | ||||||||||||||
|
|
||||||||||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }, | ||
| ); | ||
| }); | ||
There was a problem hiding this comment.
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?