token picker overhaul
Change-Id: I641d5b5ff17bf45249e4729c9581a931fda9f204
This commit is contained in:
parent
e5642a788d
commit
08b4e8c7b3
|
@ -21,7 +21,6 @@ function KeyAndBalance({
|
|||
return (
|
||||
<>
|
||||
<EthereumSignerKey />
|
||||
<Typography>{balanceString}</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -29,7 +28,6 @@ function KeyAndBalance({
|
|||
return (
|
||||
<>
|
||||
<SolanaWalletKey />
|
||||
<Typography>{balanceString}</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<ParsedTokenAccount[]> | 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<NFTParsedTokenAccount> = 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 (
|
||||
<TokenPicker
|
||||
value={value}
|
||||
options={tokenAccounts?.data || []}
|
||||
RenderOption={RenderComp}
|
||||
useTokenId={nft}
|
||||
onChange={onChangeWrapper}
|
||||
isValidAddress={isValidEthereumAddress}
|
||||
getAddress={getAddress}
|
||||
disabled={disabled}
|
||||
resetAccounts={resetAccounts}
|
||||
error={""}
|
||||
showLoader={tokenAccounts?.isFetching}
|
||||
nft={nft || false}
|
||||
chainId={chainId}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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<NFTParsedTokenAccount[]> | null | undefined;
|
||||
disabled: boolean;
|
||||
mintAccounts:
|
||||
| DataWrapper<Map<string, ExtractedMintInfo | null> | 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<String, TokenInfo> = useMemo(() => {
|
||||
const output = new Map<String, TokenInfo>();
|
||||
|
||||
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 (
|
||||
<TokenPicker
|
||||
value={value}
|
||||
options={accountsWithMetadata}
|
||||
RenderOption={RenderComp}
|
||||
onChange={onChangeWrapper}
|
||||
disabled={disabled}
|
||||
resetAccounts={resetAccounts}
|
||||
error={""}
|
||||
showLoader={isLoading}
|
||||
nft={nft || false}
|
||||
chainId={CHAIN_ID_SOLANA}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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) => {
|
|||
<Typography>{fatalError}</Typography>
|
||||
</RefreshButtonWrapper>
|
||||
) : lookupChain === CHAIN_ID_SOLANA ? (
|
||||
<SolanaSourceTokenSelector
|
||||
<SolanaTokenPicker
|
||||
value={sourceParsedTokenAccount || null}
|
||||
onChange={handleOnChange}
|
||||
disabled={disabled}
|
||||
accounts={maps?.tokenAccounts?.data || []}
|
||||
accounts={maps?.tokenAccounts}
|
||||
mintAccounts={maps?.mintAccounts}
|
||||
resetAccounts={maps?.resetAccounts}
|
||||
nft={nft}
|
||||
/>
|
||||
) : isEVMChain(lookupChain) ? (
|
||||
<EthereumSourceTokenSelector
|
||||
<EvmTokenPicker
|
||||
value={sourceParsedTokenAccount || null}
|
||||
disabled={disabled}
|
||||
onChange={handleOnChange}
|
||||
covalent={maps?.covalent || undefined}
|
||||
tokenAccounts={maps?.tokenAccounts}
|
||||
resetAccounts={maps?.resetAccounts}
|
||||
chainId={lookupChain}
|
||||
|
|
|
@ -0,0 +1,438 @@
|
|||
import { ChainId } from "@certusone/wormhole-sdk";
|
||||
import { BigNumber } from "@ethersproject/bignumber";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
createStyles,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
makeStyles,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
|
||||
import RefreshIcon from "@material-ui/icons/Refresh";
|
||||
import { Alert } from "@material-ui/lab";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NFTParsedTokenAccount } from "../../store/nftSlice";
|
||||
import { shortenAddress } from "../../utils/solana";
|
||||
import NFTViewer from "./NFTViewer";
|
||||
|
||||
const useStyles = makeStyles((theme) =>
|
||||
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 = (
|
||||
<div className={classes.tokenOverviewContainer}>
|
||||
<div className={classes.tokenImageContainer}>
|
||||
{uri && <img alt="" className={classes.tokenImage} src={uri} />}
|
||||
</div>
|
||||
<div>
|
||||
<Typography>{symbol}</Typography>
|
||||
<Typography>{name}</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Typography>{mintPrettyString}</Typography>
|
||||
<Typography>{tokenId}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tokenContent = (
|
||||
<div className={classes.tokenOverviewContainer}>
|
||||
<div className={classes.tokenImageContainer}>
|
||||
{uri && <img alt="" className={classes.tokenImage} src={uri} />}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="subtitle1">{symbol}</Typography>
|
||||
</div>
|
||||
<div>
|
||||
{
|
||||
<Typography variant="body1">
|
||||
{account.isNativeAsset ? "Native" : mintPrettyString}
|
||||
</Typography>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<Typography variant="body2">{"Balance"}</Typography>
|
||||
<Typography variant="h6">{balancePrettyString}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const migrationRender = (
|
||||
<div className={classes.migrationAlert}>
|
||||
<Alert severity="warning">
|
||||
<Typography variant="body2">
|
||||
This is a legacy asset eligible for migration.
|
||||
</Typography>
|
||||
<div>{tokenContent}</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
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<void>;
|
||||
isValidAddress?: (address: string) => boolean;
|
||||
getAddress?: (
|
||||
address: string,
|
||||
tokenId?: string
|
||||
) => Promise<NFTParsedTokenAccount>;
|
||||
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 = (
|
||||
<div className={classes.alignCenter}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2">
|
||||
{showLoader ? "Loading available tokens" : "Searching for results"}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
|
||||
const displayLocalError = (
|
||||
<div className={classes.alignCenter}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="error">
|
||||
{loadingError || selectionError}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
|
||||
const dialog = (
|
||||
<Dialog
|
||||
onClose={closeDialog}
|
||||
aria-labelledby="simple-dialog-title"
|
||||
open={dialogIsOpen}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
<div id="simple-dialog-title" className={classes.flexTitle}>
|
||||
<Typography variant="h5">Select a token</Typography>
|
||||
<div className={classes.grower} />
|
||||
<Tooltip title="Reload tokens">
|
||||
<IconButton onClick={resetAccounts}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.dialogContent}>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Search"
|
||||
value={holderString}
|
||||
onChange={(event) => setHolderString(event.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
{useTokenId ? (
|
||||
<TextField
|
||||
variant="outlined"
|
||||
label="Token Id"
|
||||
value={tokenIdHolderString}
|
||||
onChange={(event) => setTokenIdHolderString(event.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
) : null}
|
||||
{isLocalLoading || showLoader ? (
|
||||
localLoader
|
||||
) : loadingError || selectionError ? (
|
||||
displayLocalError
|
||||
) : filteredOptions.length ? (
|
||||
<List className={classes.tokenList}>
|
||||
{filteredOptions.map((option) => {
|
||||
return (
|
||||
<ListItem
|
||||
button
|
||||
onClick={() => handleSelectOption(option)}
|
||||
key={
|
||||
option.publicKey + option.mintKey + (option.tokenId || "")
|
||||
}
|
||||
>
|
||||
<RenderOption account={option} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
) : (
|
||||
<div className={classes.alignCenter}>
|
||||
<Typography>No results found</Typography>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const selectionChip = (
|
||||
<div className={classes.selectionButtonContainer}>
|
||||
<Button
|
||||
onClick={openDialog}
|
||||
disabled={disabled}
|
||||
variant="outlined"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
className={classes.selectionButton}
|
||||
>
|
||||
{value ? (
|
||||
<RenderOption account={value} />
|
||||
) : (
|
||||
<Typography color="textSecondary">Select a token</Typography>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialog}
|
||||
{value && nft ? <NFTViewer value={value} chainId={chainId} /> : null}
|
||||
{selectionChip}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2500.01 2500"><defs><style>.cls-1{fill:#f3ba2f;}</style></defs><title>bi</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M764.48,1050.52,1250,565l485.75,485.73,282.5-282.5L1250,0,482,768l282.49,282.5M0,1250,282.51,967.45,565,1249.94,282.49,1532.45Zm764.48,199.51L1250,1935l485.74-485.72,282.65,282.35-.14.15L1250,2500,482,1732l-.4-.4,282.91-282.12M1935,1250.12l282.51-282.51L2500,1250.1,2217.5,1532.61Z"/><path class="cls-1" d="M1536.52,1249.85h.12L1250,963.19,1038.13,1175h0l-24.34,24.35-50.2,50.21-.4.39.4.41L1250,1536.81l286.66-286.66.14-.16-.26-.14"/></g></g></svg>
|
After Width: | Height: | Size: 678 B |
Loading…
Reference in New Issue