From 77ecc035a3e2dd7d6c86fb0ecedda5e1dbc66cda Mon Sep 17 00:00:00 2001 From: Chase Moran Date: Thu, 23 Sep 2021 17:29:48 -0400 Subject: [PATCH] bridge_ui: rework of target section on transfer screen fixes https://github.com/certusone/wormhole/issues/404 Change-Id: I405802f3a6e1695e8d1517bd834e820d6369fa05 --- bridge_ui/src/components/Transfer/Target.tsx | 60 ++++++-- bridge_ui/src/hooks/useEthMetadata.ts | 102 +++++++++++++ bridge_ui/src/hooks/useMetadata.ts | 150 +++++++++++++++++++ 3 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 bridge_ui/src/hooks/useEthMetadata.ts create mode 100644 bridge_ui/src/hooks/useMetadata.ts diff --git a/bridge_ui/src/components/Transfer/Target.tsx b/bridge_ui/src/components/Transfer/Target.tsx index 497b9f0f9..6c1ffdfe5 100644 --- a/bridge_ui/src/components/Transfer/Target.tsx +++ b/bridge_ui/src/components/Transfer/Target.tsx @@ -8,6 +8,7 @@ import { Alert } from "@material-ui/lab"; import { useCallback, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; import useIsWalletReady from "../../hooks/useIsWalletReady"; +import useMetadata from "../../hooks/useMetadata"; import useSyncTargetAddress from "../../hooks/useSyncTargetAddress"; import { EthGasEstimateSummary } from "../../hooks/useTransactionFees"; import { @@ -20,12 +21,14 @@ import { selectTransferTargetChain, selectTransferTargetError, UNREGISTERED_ERROR_MESSAGE, + selectTransferAmount, } from "../../store/selectors"; import { incrementStep, setTargetChain } from "../../store/transferSlice"; import { CHAINS, CHAINS_BY_ID } from "../../utils/consts"; import ButtonWithLoader from "../ButtonWithLoader"; import KeyAndBalance from "../KeyAndBalance"; import LowBalanceWarning from "../LowBalanceWarning"; +import SmartAddress from "../SmartAddress"; import SolanaCreateAssociatedAddress, { useAssociatedAccountExistsState, } from "../SolanaCreateAssociatedAddress"; @@ -53,9 +56,21 @@ function Target() { const targetChain = useSelector(selectTransferTargetChain); const targetAddressHex = useSelector(selectTransferTargetAddressHex); const targetAsset = useSelector(selectTransferTargetAsset); + const targetAssetArrayed = useMemo( + () => (targetAsset ? [targetAsset] : []), + [targetAsset] + ); + const metadata = useMetadata(targetChain, targetAssetArrayed); + const tokenName = + (targetAsset && metadata.data?.get(targetAsset)?.tokenName) || undefined; + const symbol = + (targetAsset && metadata.data?.get(targetAsset)?.symbol) || undefined; + const logo = + (targetAsset && metadata.data?.get(targetAsset)?.logo) || undefined; const readableTargetAddress = hexToNativeString(targetAddressHex, targetChain) || ""; const uiAmountString = useSelector(selectTransferTargetBalanceString); + const transferAmount = useSelector(selectTransferAmount); const error = useSelector(selectTransferTargetError); const isTargetComplete = useSelector(selectTransferIsTargetComplete); const shouldLockFields = useSelector(selectTransferShouldLockFields); @@ -93,13 +108,37 @@ function Target() { ))} - + {readableTargetAddress ? ( + <> + {targetAsset ? ( +
+ Bridged tokens: + + + {`(Amount: ${transferAmount})`} + +
+ ) : null} +
+ Sent to: + + + {`(Current balance: ${uiAmountString || "0"})`} + +
+ + ) : null} {targetChain === CHAIN_ID_SOLANA && targetAsset ? ( ) : null} - You will have to pay transaction fees on{" "} diff --git a/bridge_ui/src/hooks/useEthMetadata.ts b/bridge_ui/src/hooks/useEthMetadata.ts new file mode 100644 index 000000000..0eeec3aad --- /dev/null +++ b/bridge_ui/src/hooks/useEthMetadata.ts @@ -0,0 +1,102 @@ +import { CHAIN_ID_ETH } from "@certusone/wormhole-sdk"; +import { ethers } from "@certusone/wormhole-sdk/node_modules/ethers"; +import { useEffect, useMemo, useState } from "react"; +import { + Provider, + useEthereumProvider, +} from "../contexts/EthereumProviderContext"; +import { DataWrapper } from "../store/helpers"; +import useIsWalletReady from "./useIsWalletReady"; + +export type EthMetadata = { + symbol?: string; + logo?: string; + tokenName?: string; + decimals?: number; +}; + +const ERC20_BASIC_ABI = [ + "function name() view returns (string name)", + "function symbol() view returns (string symbol)", + "function decimals() view returns (uint8 decimals)", +]; + +const handleError = () => { + return undefined; +}; + +const fetchSingleMetadata = async ( + address: string, + provider: Provider +): Promise => { + const contract = new ethers.Contract(address, ERC20_BASIC_ABI, provider); + const [name, symbol, decimals] = await Promise.all([ + contract.name().catch(handleError), + contract.symbol().catch(handleError), + contract.decimals().catch(handleError), + ]); + return { tokenName: name, symbol, decimals }; +}; + +const fetchEthMetadata = async (addresses: string[], provider: Provider) => { + const promises: Promise[] = []; + addresses.forEach((address) => { + promises.push(fetchSingleMetadata(address, provider)); + }); + const resultsArray = await Promise.all(promises); + const output = new Map(); + addresses.forEach((address, index) => { + output.set(address, resultsArray[index]); + }); + + return output; +}; + +function useEthMetadata( + addresses: string[] +): DataWrapper> { + const { isReady } = useIsWalletReady(CHAIN_ID_ETH); + const { provider } = useEthereumProvider(); + + const [isFetching, setIsFetching] = useState(false); + const [error, setError] = useState(""); + const [data, setData] = useState | null>(null); + + useEffect(() => { + let cancelled = false; + if (addresses.length && provider && isReady) { + setIsFetching(true); + setError(""); + setData(null); + fetchEthMetadata(addresses, provider).then( + (results) => { + if (!cancelled) { + setData(results); + setIsFetching(false); + } + }, + () => { + if (!cancelled) { + setError("Could not retrieve contract metadata"); + setIsFetching(false); + } + } + ); + } + return () => { + cancelled = true; + }; + }, [addresses, provider, isReady]); + + return useMemo( + () => ({ + data, + isFetching, + error, + receivedAt: null, + }), + [data, isFetching, error] + ); +} + +export default useEthMetadata; diff --git a/bridge_ui/src/hooks/useMetadata.ts b/bridge_ui/src/hooks/useMetadata.ts new file mode 100644 index 000000000..aceff30b5 --- /dev/null +++ b/bridge_ui/src/hooks/useMetadata.ts @@ -0,0 +1,150 @@ +import { + ChainId, + CHAIN_ID_ETH, + CHAIN_ID_SOLANA, + CHAIN_ID_TERRA, +} from "@certusone/wormhole-sdk"; +import { TokenInfo } from "@solana/spl-token-registry"; +import { useMemo } from "react"; +import { DataWrapper, getEmptyDataWrapper } from "../store/helpers"; +import { Metadata } from "../utils/metaplex"; +import useEthMetadata, { EthMetadata } from "./useEthMetadata"; +import useMetaplexData from "./useMetaplexData"; +import useSolanaTokenMap from "./useSolanaTokenMap"; +import useTerraTokenMap, { TerraTokenMap } from "./useTerraTokenMap"; + +export type GenericMetadata = { + symbol?: string; + logo?: string; + tokenName?: string; + decimals?: number; + //TODO more items +}; + +const constructSolanaMetadata = ( + addresses: string[], + solanaTokenMap: DataWrapper, + metaplexData: DataWrapper | undefined> +) => { + const isFetching = solanaTokenMap.isFetching || metaplexData?.isFetching; + const error = solanaTokenMap.error || metaplexData?.isFetching; + const receivedAt = solanaTokenMap.receivedAt && metaplexData?.receivedAt; + const data = new Map(); + addresses.forEach((address) => { + const metaplex = metaplexData?.data?.get(address); + const tokenInfo = solanaTokenMap.data?.find((x) => x.address === address); + //Both this and the token picker, at present, give priority to the tokenmap + const obj = { + symbol: tokenInfo?.symbol || metaplex?.data.symbol || undefined, + logo: tokenInfo?.logoURI || metaplex?.data.uri || undefined, //TODO is URI on metaplex actually the logo? If not, where is it? + tokenName: tokenInfo?.name || metaplex?.data.name || undefined, + decimals: tokenInfo?.decimals || undefined, //TODO decimals are actually on the mint, not the metaplex account. + }; + data.set(address, obj); + }); + + return { + isFetching, + error, + receivedAt, + data, + }; +}; + +const constructTerraMetadata = ( + addresses: string[], + tokenMap: DataWrapper +) => { + const isFetching = tokenMap.isFetching; + const error = tokenMap.error; + const receivedAt = tokenMap.receivedAt; + const data = new Map(); + addresses.forEach((address) => { + const meta = tokenMap.data?.mainnet[address]; + const obj = { + symbol: meta?.symbol || undefined, + logo: meta?.icon || undefined, + tokenName: meta?.token || undefined, + decimals: undefined, //TODO find a way to get this on terra + }; + data.set(address, obj); + }); + + return { + isFetching, + error, + receivedAt, + data, + }; +}; + +const constructEthMetadata = ( + addresses: string[], + metadataMap: DataWrapper | null> +) => { + const isFetching = metadataMap.isFetching; + const error = metadataMap.error; + const receivedAt = metadataMap.receivedAt; + const data = new Map(); + addresses.forEach((address) => { + const meta = metadataMap.data?.get(address); + const obj = { + symbol: meta?.symbol || undefined, + logo: meta?.logo || undefined, + tokenName: meta?.tokenName || undefined, + decimals: meta?.decimals, + }; + data.set(address, obj); + }); + + return { + isFetching, + error, + receivedAt, + data, + }; +}; + +export default function useMetadata( + chainId: ChainId, + addresses: string[] +): DataWrapper> { + const terraTokenMap = useTerraTokenMap(); + const solanaTokenMap = useSolanaTokenMap(); + + const solanaAddresses = useMemo(() => { + return chainId === CHAIN_ID_SOLANA ? addresses : []; + }, [chainId, addresses]); + const terraAddresses = useMemo(() => { + return chainId === CHAIN_ID_TERRA ? addresses : []; + }, [chainId, addresses]); + const ethereumAddresses = useMemo(() => { + return chainId === CHAIN_ID_ETH ? addresses : []; + }, [chainId, addresses]); + + const metaplexData = useMetaplexData(solanaAddresses); + const ethMetadata = useEthMetadata(ethereumAddresses); + + const output: DataWrapper> = useMemo( + () => + chainId === CHAIN_ID_SOLANA + ? constructSolanaMetadata(solanaAddresses, solanaTokenMap, metaplexData) + : chainId === CHAIN_ID_ETH + ? constructEthMetadata(ethereumAddresses, ethMetadata) + : chainId === CHAIN_ID_TERRA + ? constructTerraMetadata(terraAddresses, terraTokenMap) + : getEmptyDataWrapper(), + [ + chainId, + solanaAddresses, + solanaTokenMap, + metaplexData, + ethereumAddresses, + ethMetadata, + terraAddresses, + terraTokenMap, + ] + ); + + return output; +}