From 08b4e8c7b31c4807a48de31b4f01bf5c9f62fa6c Mon Sep 17 00:00:00 2001 From: Chase Moran Date: Mon, 11 Oct 2021 18:14:48 -0400 Subject: [PATCH] token picker overhaul Change-Id: I641d5b5ff17bf45249e4729c9581a931fda9f204 --- bridge_ui/src/components/KeyAndBalance.tsx | 2 - .../TokenSelectors/EvmTokenPicker.tsx | 157 +++++++ .../components/TokenSelectors/NFTViewer.tsx | 1 - .../TokenSelectors/SolanaTokenPicker.tsx | 214 +++++++++ .../TokenSelectors/SourceTokenSelector.tsx | 11 +- .../components/TokenSelectors/TokenPicker.tsx | 438 ++++++++++++++++++ .../hooks/useGetSourceParsedTokenAccounts.ts | 6 +- bridge_ui/src/icons/bnb.svg | 1 + 8 files changed, 819 insertions(+), 11 deletions(-) create mode 100644 bridge_ui/src/components/TokenSelectors/EvmTokenPicker.tsx create mode 100644 bridge_ui/src/components/TokenSelectors/SolanaTokenPicker.tsx create mode 100644 bridge_ui/src/components/TokenSelectors/TokenPicker.tsx create mode 100644 bridge_ui/src/icons/bnb.svg diff --git a/bridge_ui/src/components/KeyAndBalance.tsx b/bridge_ui/src/components/KeyAndBalance.tsx index af00eaf2..cc1d01e9 100644 --- a/bridge_ui/src/components/KeyAndBalance.tsx +++ b/bridge_ui/src/components/KeyAndBalance.tsx @@ -21,7 +21,6 @@ function KeyAndBalance({ return ( <> - {balanceString} ); } @@ -29,7 +28,6 @@ function KeyAndBalance({ return ( <> - {balanceString} ); } diff --git a/bridge_ui/src/components/TokenSelectors/EvmTokenPicker.tsx b/bridge_ui/src/components/TokenSelectors/EvmTokenPicker.tsx new file mode 100644 index 00000000..5bac9a31 --- /dev/null +++ b/bridge_ui/src/components/TokenSelectors/EvmTokenPicker.tsx @@ -0,0 +1,157 @@ +import { + ChainId, + CHAIN_ID_ETH, + NFTImplementation, + TokenImplementation, +} from "@certusone/wormhole-sdk"; +import { WormholeAbi__factory } from "@certusone/wormhole-sdk/lib/ethers-contracts/abi"; +import { getAddress as getEthAddress } from "@ethersproject/address"; +import React, { useCallback } from "react"; +import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; +import useIsWalletReady from "../../hooks/useIsWalletReady"; +import { DataWrapper } from "../../store/helpers"; +import { NFTParsedTokenAccount } from "../../store/nftSlice"; +import { ParsedTokenAccount } from "../../store/transferSlice"; +import { + getMigrationAssetMap, + WORMHOLE_V1_ETH_ADDRESS, +} from "../../utils/consts"; +import { + ethNFTToNFTParsedTokenAccount, + ethTokenToParsedTokenAccount, + getEthereumNFT, + getEthereumToken, + isValidEthereumAddress, +} from "../../utils/ethereum"; +import TokenPicker, { BasicAccountRender } from "./TokenPicker"; + +const isWormholev1 = (provider: any, address: string, chainId: ChainId) => { + if (chainId !== CHAIN_ID_ETH) { + return Promise.resolve(false); + } + const connection = WormholeAbi__factory.connect( + WORMHOLE_V1_ETH_ADDRESS, + provider + ); + return connection.isWrappedAsset(address); +}; + +type EthereumSourceTokenSelectorProps = { + value: ParsedTokenAccount | null; + onChange: (newValue: ParsedTokenAccount | null) => void; + tokenAccounts: DataWrapper | undefined; + disabled: boolean; + resetAccounts: (() => void) | undefined; + chainId: ChainId; + nft?: boolean; +}; + +export default function EvmTokenPicker( + props: EthereumSourceTokenSelectorProps +) { + const { + value, + onChange, + tokenAccounts, + disabled, + resetAccounts, + chainId, + nft, + } = props; + const { provider, signerAddress } = useEthereumProvider(); + const { isReady } = useIsWalletReady(chainId); + + const isMigrationEligible = useCallback( + (address: string) => { + const assetMap = getMigrationAssetMap(chainId); + return !!assetMap.get(getEthAddress(address)); + }, + [chainId] + ); + + const getAddress: ( + address: string, + tokenId?: string + ) => Promise = useCallback( + async (address: string, tokenId?: string) => { + if (provider && signerAddress && isReady) { + try { + const tokenAccount = await (nft + ? getEthereumNFT(address, provider) + : getEthereumToken(address, provider)); + if (!tokenAccount) { + return Promise.reject("Could not find the specified token."); + } + if (nft && !tokenId) { + return Promise.reject("Token ID is required."); + } else if (nft && tokenId) { + return ethNFTToNFTParsedTokenAccount( + tokenAccount as NFTImplementation, + tokenId, + signerAddress + ); + } else { + return ethTokenToParsedTokenAccount( + tokenAccount as TokenImplementation, + signerAddress + ); + } + } catch (e) { + return Promise.reject("Unable to retrive the specific token."); + } + } else { + return Promise.reject({ error: "Wallet is not connected." }); + } + }, + [isReady, nft, provider, signerAddress] + ); + + const onChangeWrapper = useCallback( + async (account: NFTParsedTokenAccount | null) => { + if (account === null) { + onChange(null); + return Promise.resolve(); + } + let v1 = false; + try { + v1 = await isWormholev1(provider, account.publicKey, chainId); + } catch (e) { + //For now, just swallow this one. + } + const migration = isMigrationEligible(account.publicKey); + if (v1 === true && !migration) { + return Promise.reject( + "Wormhole v1 assets cannot be transferred with this bridge." + ); + } + onChange(account); + return Promise.resolve(); + }, + [chainId, onChange, provider, isMigrationEligible] + ); + + const RenderComp = useCallback( + ({ account }: { account: NFTParsedTokenAccount }) => { + return BasicAccountRender(account, isMigrationEligible, nft || false); + }, + [nft, isMigrationEligible] + ); + + return ( + + ); +} diff --git a/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx b/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx index ed066c42..fb5dd7e5 100644 --- a/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx +++ b/bridge_ui/src/components/TokenSelectors/NFTViewer.tsx @@ -183,7 +183,6 @@ export default function NFTViewer({ (async () => { const result = await axios.get(uri); if (!cancelled && result && result.data) { - console.log(result.data); setMetadata({ image: result.data.image, animation_url: result.data.animation_url, diff --git a/bridge_ui/src/components/TokenSelectors/SolanaTokenPicker.tsx b/bridge_ui/src/components/TokenSelectors/SolanaTokenPicker.tsx new file mode 100644 index 00000000..a5b1e35c --- /dev/null +++ b/bridge_ui/src/components/TokenSelectors/SolanaTokenPicker.tsx @@ -0,0 +1,214 @@ +import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; +import { TokenInfo } from "@solana/spl-token-registry"; +import React, { useCallback, useMemo } from "react"; +import useMetaplexData from "../../hooks/useMetaplexData"; +import useSolanaTokenMap from "../../hooks/useSolanaTokenMap"; +import { DataWrapper } from "../../store/helpers"; +import { NFTParsedTokenAccount } from "../../store/nftSlice"; +import { ParsedTokenAccount } from "../../store/transferSlice"; +import { + MIGRATION_ASSET_MAP, + WORMHOLE_V1_MINT_AUTHORITY, +} from "../../utils/consts"; +import { ExtractedMintInfo } from "../../utils/solana"; +import { sortParsedTokenAccounts } from "../../utils/sort"; +import TokenPicker, { BasicAccountRender } from "./TokenPicker"; + +type SolanaSourceTokenSelectorProps = { + value: ParsedTokenAccount | null; + onChange: (newValue: NFTParsedTokenAccount | null) => void; + accounts: DataWrapper | null | undefined; + disabled: boolean; + mintAccounts: + | DataWrapper | undefined> + | undefined; + resetAccounts: (() => void) | undefined; + nft?: boolean; +}; + +const isMigrationEligible = (address: string) => { + return !!MIGRATION_ASSET_MAP.get(address); +}; + +export default function SolanaSourceTokenSelector( + props: SolanaSourceTokenSelectorProps +) { + const { + value, + onChange, + disabled, + resetAccounts, + nft, + accounts, + mintAccounts, + } = props; + const tokenMap = useSolanaTokenMap(); + const mintAddresses = useMemo(() => { + const output: string[] = []; + mintAccounts?.data?.forEach( + (mintAuth, mintAddress) => mintAddress && output.push(mintAddress) + ); + return output; + }, [mintAccounts?.data]); + const metaplex = useMetaplexData(mintAddresses); + + const memoizedTokenMap: Map = useMemo(() => { + const output = new Map(); + + if (tokenMap.data) { + for (const data of tokenMap.data) { + if (data && data.address) { + output.set(data.address, data); + } + } + } + + return output; + }, [tokenMap]); + + const getLogo = useCallback( + (account: ParsedTokenAccount) => { + return ( + (account.isNativeAsset && account.logo) || + memoizedTokenMap.get(account.mintKey)?.logoURI || + metaplex.data?.get(account.mintKey)?.data?.uri || + undefined + ); + }, + [memoizedTokenMap, metaplex] + ); + + const getSymbol = useCallback( + (account: ParsedTokenAccount) => { + return ( + (account.isNativeAsset && account.symbol) || + memoizedTokenMap.get(account.mintKey)?.symbol || + metaplex.data?.get(account.mintKey)?.data?.symbol || + undefined + ); + }, + [memoizedTokenMap, metaplex] + ); + + const getName = useCallback( + (account: ParsedTokenAccount) => { + return ( + (account.isNativeAsset && account.name) || + memoizedTokenMap.get(account.mintKey)?.name || + metaplex.data?.get(account.mintKey)?.data?.name || + undefined + ); + }, + [memoizedTokenMap, metaplex] + ); + + //This exists to remove NFTs from the list of potential options. It requires reading the metaplex data, so it would be + //difficult to do before this point. + const filteredOptions = useMemo(() => { + const array = accounts?.data || []; + const tokenList = array.filter((x) => { + const zeroBalance = x.amount === "0"; + if (zeroBalance) { + return false; + } + const isNFT = + x.decimals === 0 && + metaplex.data?.get(x.mintKey)?.data?.uri && + mintAccounts?.data?.get(x.mintKey)?.supply === "1"; + return nft ? isNFT : !isNFT; + }); + tokenList.sort(sortParsedTokenAccounts); + return tokenList; + }, [mintAccounts?.data, metaplex.data, nft, accounts]); + + const accountsWithMetadata = useMemo(() => { + return filteredOptions.map((account) => { + const logo = getLogo(account); + const symbol = getSymbol(account); + const name = getName(account); + + const uri = getLogo(account); + + return { + ...account, + name, + symbol, + logo, + uri, + }; + }); + }, [filteredOptions, getLogo, getName, getSymbol]); + + const isLoading = + accounts?.isFetching || metaplex.isFetching || tokenMap.isFetching; + + const isWormholev1 = useCallback( + (address: string) => { + //This is a v1 wormhole token on testnet + //const testAddress = "4QixXecTZ4zdZGa39KH8gVND5NZ2xcaB12wiBhE4S7rn"; + + if (!props.mintAccounts?.data) { + return true; //These should never be null by this point + } + const mintAuthority = props.mintAccounts.data.get(address)?.mintAuthority; + + if (!mintAuthority) { + return true; //We should never fail to pull the mint of an account. + } + + if (mintAuthority === WORMHOLE_V1_MINT_AUTHORITY) { + return true; //This means the mint was created by the wormhole v1 contract, and we want to disallow its transfer. + } + + return false; + }, + [props.mintAccounts] + ); + + const onChangeWrapper = useCallback( + async (newValue: NFTParsedTokenAccount | null) => { + let v1 = false; + if (newValue === null) { + onChange(null); + return Promise.resolve(); + } + try { + v1 = isWormholev1(newValue.mintKey); + } catch (e) { + //swallow for now + } + + if (v1) { + Promise.reject( + "Wormhole v1 assets should not be transferred with this bridge." + ); + } + + onChange(newValue); + return Promise.resolve(); + }, + [isWormholev1, onChange] + ); + + const RenderComp = useCallback( + ({ account }: { account: NFTParsedTokenAccount }) => { + return BasicAccountRender(account, isMigrationEligible, nft || false); + }, + [nft] + ); + + return ( + + ); +} diff --git a/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx index 79e4e569..3348696b 100644 --- a/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx @@ -21,9 +21,9 @@ import { setSourceWalletAddress as setTransferSourceWalletAddress, } from "../../store/transferSlice"; import { isEVMChain } from "../../utils/ethereum"; -import EthereumSourceTokenSelector from "./EthereumSourceTokenSelector"; +import EvmTokenPicker from "./EvmTokenPicker"; import RefreshButtonWrapper from "./RefreshButtonWrapper"; -import SolanaSourceTokenSelector from "./SolanaSourceTokenSelector"; +import SolanaTokenPicker from "./SolanaTokenPicker"; import TerraSourceTokenSelector from "./TerraSourceTokenSelector"; type TokenSelectorProps = { @@ -84,21 +84,20 @@ export const TokenSelector = (props: TokenSelectorProps) => { {fatalError} ) : lookupChain === CHAIN_ID_SOLANA ? ( - ) : isEVMChain(lookupChain) ? ( - + createStyles({ + alignCenter: { + textAlign: "center", + }, + optionContainer: { + padding: 0, + }, + optionContent: { + padding: theme.spacing(1), + }, + tokenList: { + maxHeight: theme.spacing(80), //TODO smarter + height: theme.spacing(80), + overflow: "auto", + }, + dialogContent: { + overflowX: "hidden", + }, + selectionButtonContainer: { + //display: "flex", + textAlign: "center", + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, + selectionButton: { + maxWidth: "100%", + width: theme.breakpoints.values.sm, + }, + tokenOverviewContainer: { + display: "flex", + width: "100%", + alignItems: "center", + "& div": { + margin: theme.spacing(1), + flexBasis: "25%", + "&$tokenImageContainer": { + maxWidth: 40, + }, + "&:last-child": { + textAlign: "right", + }, + flexShrink: 1, + }, + flexWrap: "wrap", + }, + tokenImageContainer: { + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 40, + }, + tokenImage: { + maxHeight: "2.5rem", //Eyeballing this based off the text size + }, + migrationAlert: { + width: "100%", + "& .MuiAlert-message": { + width: "100%", + }, + }, + flexTitle: { + display: "flex", + flexDirection: "row", + alignItems: "center", + }, + grower: { + flexGrow: 1, + }, + }) +); + +const balancePretty = (uiString: string) => { + const numberString = uiString.split(".")[0]; + const bignum = BigNumber.from(numberString); + if (bignum.gte(1000000)) { + return numberString.substring(0, numberString.length - 6) + " M"; + } else if (uiString.length > 8) { + return uiString.substr(0, 8); + } else { + return uiString; + } +}; + +export const BasicAccountRender = ( + account: NFTParsedTokenAccount, + isMigrationEligible: (address: string) => boolean, + nft: boolean +) => { + const classes = useStyles(); + const mintPrettyString = shortenAddress(account.mintKey); + const uri = nft ? account.image_256 : account.logo || account.uri; + const symbol = account.symbol || "Unknown"; + const name = account.name || "Unknown"; + const tokenId = account.tokenId; + const balancePrettyString = balancePretty(account.uiAmountString); + + const nftContent = ( +
+
+ {uri && } +
+
+ {symbol} + {name} +
+
+ {mintPrettyString} + {tokenId} +
+
+ ); + + const tokenContent = ( +
+
+ {uri && } +
+
+ {symbol} +
+
+ { + + {account.isNativeAsset ? "Native" : mintPrettyString} + + } +
+
+ {"Balance"} + {balancePrettyString} +
+
+ ); + + const migrationRender = ( +
+ + + This is a legacy asset eligible for migration. + +
{tokenContent}
+
+
+ ); + + return nft + ? nftContent + : isMigrationEligible(account.mintKey) + ? migrationRender + : tokenContent; +}; + +export default function TokenPicker({ + value, + options, + RenderOption, + onChange, + isValidAddress, + getAddress, + disabled, + resetAccounts, + nft, + chainId, + error, + showLoader, + useTokenId, +}: { + value: NFTParsedTokenAccount | null; + options: NFTParsedTokenAccount[]; + RenderOption: ({ + account, + }: { + account: NFTParsedTokenAccount; + }) => JSX.Element; + onChange: (newValue: NFTParsedTokenAccount | null) => Promise; + isValidAddress?: (address: string) => boolean; + getAddress?: ( + address: string, + tokenId?: string + ) => Promise; + disabled: boolean; + resetAccounts: (() => void) | undefined; + nft: boolean; + chainId: ChainId; + error?: string; + showLoader?: boolean; + useTokenId?: boolean; +}) { + const classes = useStyles(); + const [holderString, setHolderString] = useState(""); + const [tokenIdHolderString, setTokenIdHolderString] = useState(""); + const [loadingError, setLoadingError] = useState(""); + const [isLocalLoading, setLocalLoading] = useState(false); + const [dialogIsOpen, setDialogIsOpen] = useState(false); + const [selectionError, setSelectionError] = useState(""); + + const openDialog = useCallback(() => { + setHolderString(""); + setDialogIsOpen(true); + }, []); + + const closeDialog = useCallback(() => { + setDialogIsOpen(false); + }, []); + + const handleSelectOption = useCallback( + async (option: NFTParsedTokenAccount) => { + setSelectionError(""); + onChange(option).then( + () => { + closeDialog(); + }, + (error) => { + setSelectionError(error?.message || "Error verifying the token."); + } + ); + }, + [onChange, closeDialog] + ); + + const filteredOptions = useMemo(() => { + return options.filter((option: NFTParsedTokenAccount) => { + if (!holderString) { + return true; + } + const optionString = ( + (option.publicKey || "") + + " " + + (option.mintKey || "") + + " " + + (option.symbol || "") + + " " + + (option.name || " ") + ).toLowerCase(); + const searchString = holderString.toLowerCase(); + return optionString.includes(searchString); + }); + }, [holderString, options]); + + const localFind = useCallback( + (address: string, tokenIdHolderString: string) => { + return options.find( + (x) => + x.mintKey === address && + (!tokenIdHolderString || x.tokenId === tokenIdHolderString) + ); + }, + [options] + ); + + //This is the effect which allows pasting an address in directly + useEffect(() => { + if (!isValidAddress || !getAddress) { + return; + } + if (useTokenId && !tokenIdHolderString) { + return; + } + let cancelled = false; + if (isValidAddress(holderString)) { + const option = localFind(holderString, tokenIdHolderString); + if (option) { + handleSelectOption(option); + return; + } + setLocalLoading(true); + setLoadingError(""); + getAddress( + holderString, + useTokenId ? tokenIdHolderString : undefined + ).then( + (result) => { + if (!cancelled) { + setLocalLoading(false); + if (result) { + handleSelectOption(result); + } + } + }, + (error) => { + if (!cancelled) { + setLocalLoading(false); + setLoadingError("Could not find the specified address."); + } + } + ); + } + }, [ + holderString, + isValidAddress, + getAddress, + handleSelectOption, + localFind, + tokenIdHolderString, + useTokenId, + ]); + + //TODO reset button + //TODO debounce & save hotloaded options as an option before automatically selecting + //TODO sigfigs function on the balance strings + + const localLoader = ( +
+ + + {showLoader ? "Loading available tokens" : "Searching for results"} + +
+ ); + + const displayLocalError = ( +
+ + + {loadingError || selectionError} + +
+ ); + + const dialog = ( + + +
+ Select a token +
+ + + + + +
+ + + setHolderString(event.target.value)} + fullWidth + margin="normal" + /> + {useTokenId ? ( + setTokenIdHolderString(event.target.value)} + fullWidth + margin="normal" + /> + ) : null} + {isLocalLoading || showLoader ? ( + localLoader + ) : loadingError || selectionError ? ( + displayLocalError + ) : filteredOptions.length ? ( + + {filteredOptions.map((option) => { + return ( + handleSelectOption(option)} + key={ + option.publicKey + option.mintKey + (option.tokenId || "") + } + > + + + ); + })} + + ) : ( +
+ No results found +
+ )} +
+
+ ); + + const selectionChip = ( +
+ +
+ ); + + return ( + <> + {dialog} + {value && nft ? : null} + {selectionChip} + + ); +} diff --git a/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts b/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts index 0f2da4f7..0b5670bf 100644 --- a/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts +++ b/bridge_ui/src/hooks/useGetSourceParsedTokenAccounts.ts @@ -66,6 +66,8 @@ import { extractMintInfo, getMultipleAccountsRPC, } from "../utils/solana"; +import bnbIcon from "../icons/bnb.svg"; +import ethIcon from "../icons/eth.svg"; export function createParsedTokenAccount( publicKey: string, @@ -205,7 +207,7 @@ const createNativeEthParsedTokenAccount = ( balanceInEth.toString(), //This is the actual display field, which has full precision. "ETH", //A white lie for display purposes "Ethereum", //A white lie for display purposes - undefined, //TODO logo + ethIcon, //TODO logo true //isNativeAsset ); }); @@ -228,7 +230,7 @@ const createNativeBscParsedTokenAccount = ( balanceInEth.toString(), //This is the actual display field, which has full precision. "BNB", //A white lie for display purposes "Binance Coin", //A white lie for display purposes - undefined, //TODO logo + bnbIcon, //TODO logo true //isNativeAsset ); }); diff --git a/bridge_ui/src/icons/bnb.svg b/bridge_ui/src/icons/bnb.svg new file mode 100644 index 00000000..91a66e05 --- /dev/null +++ b/bridge_ui/src/icons/bnb.svg @@ -0,0 +1 @@ +bi \ No newline at end of file