diff --git a/src/components/SearchModal/TokenList.tsx b/src/components/SearchModal/TokenList.tsx index d1402a87731..33b1760e32f 100644 --- a/src/components/SearchModal/TokenList.tsx +++ b/src/components/SearchModal/TokenList.tsx @@ -28,7 +28,8 @@ export default function TokenList({ otherToken, showSendWithSwap, onRemoveAddedToken, - otherSelectedText + otherSelectedText, + hideRemove }: { tokens: Token[] selectedToken: string @@ -38,6 +39,7 @@ export default function TokenList({ otherToken: string showSendWithSwap?: boolean otherSelectedText: string + hideRemove?: boolean }) { const { t } = useTranslation() const { account, chainId } = useActiveWeb3React() @@ -80,15 +82,16 @@ export default function TokenList({ {customAdded && 'Added by user'} - {customAdded && ( -
{ event.stopPropagation() onRemoveAddedToken(chainId, address) }} + style={{ marginLeft: '4px', fontWeight: 400 }} > - (Remove) -
+ (Remove) + )}
diff --git a/src/components/SearchModal/index.tsx b/src/components/SearchModal/index.tsx index abf8b3e7872..11bfaa91d25 100644 --- a/src/components/SearchModal/index.tsx +++ b/src/components/SearchModal/index.tsx @@ -189,6 +189,7 @@ function SearchModal({ otherToken={otherSelectedTokenAddress} selectedToken={hiddenToken} showSendWithSwap={showSendWithSwap} + hideRemove={Boolean(isAddress(searchQuery))} /> ) : ( 0 + ? str + : bytes32 && BYTES32_REGEX.test(bytes32) + ? parseBytes32String(bytes32) + : defaultValue +} + +// undefined if invalid or does not exist +// null if loading +// otherwise returns the token +export function useToken(tokenAddress?: string): Token | undefined | null { + const { chainId } = useActiveWeb3React() const tokens = useAllTokens() + + const address = isAddress(tokenAddress) + + const tokenContract = useTokenContract(address ? address : undefined, false) + const tokenContractBytes32 = useBytes32TokenContract(address ? address : undefined, false) + const token: Token | undefined = address ? tokens[address] : undefined + + const tokenName = useSingleCallResult(token ? undefined : tokenContract, 'name', undefined, NEVER_RELOAD) + const tokenNameBytes32 = useSingleCallResult( + token ? undefined : tokenContractBytes32, + 'name', + undefined, + NEVER_RELOAD + ) + const symbol = useSingleCallResult(token ? undefined : tokenContract, 'symbol', undefined, NEVER_RELOAD) + const symbolBytes32 = useSingleCallResult(token ? undefined : tokenContractBytes32, 'symbol', undefined, NEVER_RELOAD) + const decimals = useSingleCallResult(token ? undefined : tokenContract, 'decimals', undefined, NEVER_RELOAD) + return useMemo(() => { - const validatedAddress = isAddress(tokenAddress) - if (!validatedAddress) return - return tokens[validatedAddress] - }, [tokens, tokenAddress]) + if (token) return token + if (!chainId || !address) return undefined + if (decimals.loading || symbol.loading || tokenName.loading) return null + if (decimals.result) { + return new Token( + chainId, + address, + decimals.result[0], + parseStringOrBytes32(symbol.result?.[0], symbolBytes32.result?.[0], 'UNKNOWN'), + parseStringOrBytes32(tokenName.result?.[0], tokenNameBytes32.result?.[0], 'Unknown Token') + ) + } + return undefined + }, [ + address, + chainId, + decimals.loading, + decimals.result, + symbol.loading, + symbol.result, + symbolBytes32.result, + token, + tokenName.loading, + tokenName.result, + tokenNameBytes32.result + ]) } // gets token information by address (typically user input) and -// automatically adds it for the user if the token address is valid -export function useTokenByAddressAndAutomaticallyAdd(tokenAddress?: string): Token | undefined { - const fetchTokenByAddress = useFetchTokenByAddress() +// automatically adds it for the user if it's a valid token address +export function useTokenByAddressAndAutomaticallyAdd(tokenAddress?: string): Token | undefined | null { const addToken = useAddUserToken() const token = useToken(tokenAddress) const { chainId } = useActiveWeb3React() + const allTokens = useAllTokens() useEffect(() => { - if (!chainId || !isAddress(tokenAddress)) return - const weth = WETH[chainId as ChainId] - if (weth && weth.address === isAddress(tokenAddress)) return - - if (tokenAddress && !token) { - fetchTokenByAddress(tokenAddress).then(token => { - if (token !== null) { - addToken(token) - } - }) - } - }, [tokenAddress, token, fetchTokenByAddress, addToken, chainId]) + if (!chainId || !token) return + if (WETH[chainId as ChainId]?.address === token.address) return + if (allTokens[token.address]) return + addToken(token) + }, [token, addToken, chainId, allTokens]) return token } diff --git a/src/hooks/useContract.ts b/src/hooks/useContract.ts index 4394e72e7b8..6069874ed05 100644 --- a/src/hooks/useContract.ts +++ b/src/hooks/useContract.ts @@ -2,6 +2,7 @@ import { Contract } from '@ethersproject/contracts' import { ChainId } from '@uniswap/sdk' import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json' import { useMemo } from 'react' +import { ERC20_BYTES32_ABI } from '../constants/abis/erc20' import ERC20_ABI from '../constants/abis/erc20.json' import { MIGRATOR_ABI, MIGRATOR_ADDRESS } from '../constants/abis/migrator' import { V1_EXCHANGE_ABI, V1_FACTORY_ABI, V1_FACTORY_ADDRESSES } from '../constants/v1' @@ -41,6 +42,10 @@ export function useTokenContract(tokenAddress?: string, withSignerIfPossible = t return useContract(tokenAddress, ERC20_ABI, withSignerIfPossible) } +export function useBytes32TokenContract(tokenAddress?: string, withSignerIfPossible = true): Contract | null { + return useContract(tokenAddress, ERC20_BYTES32_ABI, withSignerIfPossible) +} + export function usePairContract(pairAddress?: string, withSignerIfPossible = true): Contract | null { return useContract(pairAddress, IUniswapV2PairABI, withSignerIfPossible) } diff --git a/src/state/application/updater.ts b/src/state/application/updater.ts index 14643e4bab4..48f3756eb8d 100644 --- a/src/state/application/updater.ts +++ b/src/state/application/updater.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { useActiveWeb3React } from '../../hooks' +import useDebounce from '../../hooks/useDebounce' import useIsWindowVisible from '../../hooks/useIsWindowVisible' import { updateBlockNumber } from './actions' import { useDispatch } from 'react-redux' @@ -45,10 +46,12 @@ export default function Updater() { } }, [dispatch, chainId, library, blockNumberCallback, windowVisible]) + const debouncedState = useDebounce(state, 100) + useEffect(() => { - if (!state.chainId || !state.blockNumber || !windowVisible) return - dispatch(updateBlockNumber({ chainId: state.chainId, blockNumber: state.blockNumber })) - }, [windowVisible, dispatch, state.blockNumber, state.chainId]) + if (!debouncedState.chainId || !debouncedState.blockNumber || !windowVisible) return + dispatch(updateBlockNumber({ chainId: debouncedState.chainId, blockNumber: debouncedState.blockNumber })) + }, [windowVisible, dispatch, debouncedState.blockNumber, debouncedState.chainId]) return null } diff --git a/src/state/multicall/actions.test.ts b/src/state/multicall/actions.test.ts new file mode 100644 index 00000000000..d639ca3a873 --- /dev/null +++ b/src/state/multicall/actions.test.ts @@ -0,0 +1,51 @@ +import { parseCallKey, toCallKey } from './actions' + +describe('actions', () => { + describe('#parseCallKey', () => { + it('throws for invalid address', () => { + expect(() => parseCallKey('0x-0x')).toThrow('Invalid address: 0x') + }) + it('throws for invalid calldata', () => { + expect(() => parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-abc')).toThrow('Invalid hex: abc') + }) + it('throws for invalid format', () => { + expect(() => parseCallKey('abc')).toThrow('Invalid call key: abc') + }) + it('throws for uppercase hex', () => { + expect(() => parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcD')).toThrow('Invalid hex: 0xabcD') + }) + it('parses pieces into address', () => { + expect(parseCallKey('0x6b175474e89094c44da98b954eedeac495271d0f-0xabcd')).toEqual({ + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + callData: '0xabcd' + }) + }) + }) + + describe('#toCallKey', () => { + it('throws for invalid address', () => { + expect(() => toCallKey({ callData: '0x', address: '0x' })).toThrow('Invalid address: 0x') + }) + it('throws for invalid calldata', () => { + expect(() => + toCallKey({ + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + callData: 'abc' + }) + ).toThrow('Invalid hex: abc') + }) + it('throws for uppercase hex', () => { + expect(() => + toCallKey({ + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + callData: '0xabcD' + }) + ).toThrow('Invalid hex: 0xabcD') + }) + it('concatenates address to data', () => { + expect(toCallKey({ address: '0x6b175474e89094c44da98b954eedeac495271d0f', callData: '0xabcd' })).toEqual( + '0x6B175474E89094C44Da98b954EedeAC495271d0F-0xabcd' + ) + }) + }) +}) diff --git a/src/state/multicall/actions.ts b/src/state/multicall/actions.ts index f4ea95c2e8c..9789530c625 100644 --- a/src/state/multicall/actions.ts +++ b/src/state/multicall/actions.ts @@ -6,8 +6,16 @@ export interface Call { callData: string } +const LOWER_HEX_REGEX = /^0x[a-f0-9]*$/ export function toCallKey(call: Call): string { - return `${call.address}-${call.callData}` + const addr = isAddress(call.address) + if (!addr) { + throw new Error(`Invalid address: ${call.address}`) + } + if (!LOWER_HEX_REGEX.test(call.callData)) { + throw new Error(`Invalid hex: ${call.callData}`) + } + return `${addr}-${call.callData}` } export function parseCallKey(callKey: string): Call { @@ -20,12 +28,12 @@ export function parseCallKey(callKey: string): Call { throw new Error(`Invalid address: ${pcs[0]}`) } - if (!pcs[1].match(/^0x[a-fA-F0-9]*$/)) { + if (!LOWER_HEX_REGEX.test(pcs[1])) { throw new Error(`Invalid hex: ${pcs[1]}`) } return { - address: pcs[0], + address: addr, callData: pcs[1] } } diff --git a/src/state/multicall/hooks.ts b/src/state/multicall/hooks.ts index efc91c23300..01b204a0021 100644 --- a/src/state/multicall/hooks.ts +++ b/src/state/multicall/hooks.ts @@ -121,22 +121,38 @@ const INVALID_CALL_STATE: CallState = { valid: false, result: undefined, loading const LOADING_CALL_STATE: CallState = { valid: true, result: undefined, loading: true, syncing: true, error: false } function toCallState( - result: CallResult | undefined, + callResult: CallResult | undefined, contractInterface: Interface | undefined, fragment: FunctionFragment | undefined, latestBlockNumber: number | undefined ): CallState { - if (!result) return INVALID_CALL_STATE - const { valid, data, blockNumber } = result + if (!callResult) return INVALID_CALL_STATE + const { valid, data, blockNumber } = callResult if (!valid) return INVALID_CALL_STATE if (valid && !blockNumber) return LOADING_CALL_STATE if (!contractInterface || !fragment || !latestBlockNumber) return LOADING_CALL_STATE const success = data && data.length > 2 + const syncing = (blockNumber ?? 0) < latestBlockNumber + let result: Result | undefined = undefined + if (success && data) { + try { + result = contractInterface.decodeFunctionResult(fragment, data) + } catch (error) { + console.debug('Result data parsing failed', fragment, data) + return { + valid: true, + loading: false, + error: true, + syncing, + result + } + } + } return { valid: true, loading: false, - syncing: (blockNumber ?? 0) < latestBlockNumber, - result: success && data ? contractInterface.decodeFunctionResult(fragment, data) : undefined, + syncing, + result: result, error: !success } } diff --git a/src/state/multicall/reducer.test.ts b/src/state/multicall/reducer.test.ts index e0f0647f194..c3fab746aca 100644 --- a/src/state/multicall/reducer.test.ts +++ b/src/state/multicall/reducer.test.ts @@ -2,6 +2,9 @@ import { addMulticallListeners, removeMulticallListeners, updateMulticallResults import reducer, { MulticallState } from './reducer' import { Store, createStore } from '@reduxjs/toolkit' +const DAI_ADDRESS = '0x6b175474e89094c44da98b954eedeac495271d0f' +const CHECKSUMMED_DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F' + describe('multicall reducer', () => { let store: Store beforeEach(() => { @@ -20,7 +23,7 @@ describe('multicall reducer', () => { chainId: 1, calls: [ { - address: '0x', + address: DAI_ADDRESS, callData: '0x' } ] @@ -29,7 +32,7 @@ describe('multicall reducer', () => { expect(store.getState()).toEqual({ callListeners: { [1]: { - '0x-0x': { + [`${CHECKSUMMED_DAI_ADDRESS}-0x`]: { [1]: 1 } } @@ -45,7 +48,7 @@ describe('multicall reducer', () => { removeMulticallListeners({ calls: [ { - address: '0x', + address: DAI_ADDRESS, callData: '0x' } ], @@ -60,7 +63,7 @@ describe('multicall reducer', () => { chainId: 1, calls: [ { - address: '0x', + address: DAI_ADDRESS, callData: '0x' } ] @@ -70,14 +73,17 @@ describe('multicall reducer', () => { removeMulticallListeners({ calls: [ { - address: '0x', + address: DAI_ADDRESS, callData: '0x' } ], chainId: 1 }) ) - expect(store.getState()).toEqual({ callResults: {}, callListeners: { [1]: { '0x-0x': {} } } }) + expect(store.getState()).toEqual({ + callResults: {}, + callListeners: { [1]: { [`${CHECKSUMMED_DAI_ADDRESS}-0x`]: {} } } + }) }) }) diff --git a/src/state/user/hooks.tsx b/src/state/user/hooks.tsx index 89bb4c72c06..d9d0e064857 100644 --- a/src/state/user/hooks.tsx +++ b/src/state/user/hooks.tsx @@ -5,7 +5,6 @@ import { shallowEqual, useDispatch, useSelector } from 'react-redux' import { useActiveWeb3React } from '../../hooks' import { useAllTokens } from '../../hooks/Tokens' -import { getTokenInfoWithFallback, isAddress } from '../../utils' import { AppDispatch, AppState } from '../index' import { addSerializedPair, @@ -64,26 +63,6 @@ export function useDarkModeManager(): [boolean, () => void] { return [darkMode, toggleSetDarkMode] } -export function useFetchTokenByAddress(): (address: string) => Promise { - const { library, chainId } = useActiveWeb3React() - - return useCallback( - async (address: string): Promise => { - if (!library || !chainId) return null - const validatedAddress = isAddress(address) - if (!validatedAddress) return null - const { name, symbol, decimals } = await getTokenInfoWithFallback(validatedAddress, library) - - if (decimals === null) { - return null - } else { - return new Token(chainId, validatedAddress, decimals, symbol, name) - } - }, - [library, chainId] - ) -} - export function useAddUserToken(): (token: Token) => void { const dispatch = useDispatch() return useCallback( diff --git a/src/utils/index.ts b/src/utils/index.ts index 38cd6e62a94..e5ab8095234 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,15 +2,10 @@ import { Contract } from '@ethersproject/contracts' import { getAddress } from '@ethersproject/address' import { AddressZero } from '@ethersproject/constants' import { JsonRpcSigner, Web3Provider } from '@ethersproject/providers' -import { parseBytes32String } from '@ethersproject/strings' import { BigNumber } from '@ethersproject/bignumber' - import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json' import { abi as IUniswapV2Router01ABI } from '@uniswap/v2-periphery/build/IUniswapV2Router01.json' import { ROUTER_ADDRESS } from '../constants' - -import ERC20_ABI from '../constants/abis/erc20.json' -import ERC20_BYTES32_ABI from '../constants/abis/erc20_bytes32.json' import { ChainId, JSBI, Percent, TokenAmount } from '@uniswap/sdk' // returns the checksummed address if the address is valid, otherwise returns false @@ -102,51 +97,6 @@ export function getExchangeContract(pairAddress: string, library: Web3Provider, return getContract(pairAddress, IUniswapV2PairABI, library, account) } -// get token info and fall back to unknown if not available, except for the -// decimals which falls back to null -export async function getTokenInfoWithFallback( - tokenAddress: string, - library: Web3Provider -): Promise<{ name: string; symbol: string; decimals: null | number }> { - if (!isAddress(tokenAddress)) { - throw Error(`Invalid 'tokenAddress' parameter '${tokenAddress}'.`) - } - - const token = getContract(tokenAddress, ERC20_ABI, library) - - const namePromise: Promise = token.name().catch(() => - getContract(tokenAddress, ERC20_BYTES32_ABI, library) - .name() - .then(parseBytes32String) - .catch((e: Error) => { - console.debug('Failed to get name for token address', e, tokenAddress) - return 'Unknown' - }) - ) - - const symbolPromise: Promise = token.symbol().catch(() => { - const contractBytes32 = getContract(tokenAddress, ERC20_BYTES32_ABI, library) - return contractBytes32 - .symbol() - .then(parseBytes32String) - .catch((e: Error) => { - console.debug('Failed to get symbol for token address', e, tokenAddress) - return 'UNKNOWN' - }) - }) - const decimalsPromise: Promise = token.decimals().catch((e: Error) => { - console.debug('Failed to get decimals for token address', e, tokenAddress) - return null - }) - - const [name, symbol, decimals]: [string, string, number | null] = (await Promise.all([ - namePromise, - symbolPromise, - decimalsPromise - ])) as [string, string, number | null] - return { name, symbol, decimals } -} - export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string }