token picker overhaul
Change-Id: I641d5b5ff17bf45249e4729c9581a931fda9f204
This commit is contained in:
parent
e5642a788d
commit
08b4e8c7b3
|
@ -21,7 +21,6 @@ function KeyAndBalance({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EthereumSignerKey />
|
<EthereumSignerKey />
|
||||||
<Typography>{balanceString}</Typography>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -29,7 +28,6 @@ function KeyAndBalance({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SolanaWalletKey />
|
<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 () => {
|
(async () => {
|
||||||
const result = await axios.get(uri);
|
const result = await axios.get(uri);
|
||||||
if (!cancelled && result && result.data) {
|
if (!cancelled && result && result.data) {
|
||||||
console.log(result.data);
|
|
||||||
setMetadata({
|
setMetadata({
|
||||||
image: result.data.image,
|
image: result.data.image,
|
||||||
animation_url: result.data.animation_url,
|
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,
|
setSourceWalletAddress as setTransferSourceWalletAddress,
|
||||||
} from "../../store/transferSlice";
|
} from "../../store/transferSlice";
|
||||||
import { isEVMChain } from "../../utils/ethereum";
|
import { isEVMChain } from "../../utils/ethereum";
|
||||||
import EthereumSourceTokenSelector from "./EthereumSourceTokenSelector";
|
import EvmTokenPicker from "./EvmTokenPicker";
|
||||||
import RefreshButtonWrapper from "./RefreshButtonWrapper";
|
import RefreshButtonWrapper from "./RefreshButtonWrapper";
|
||||||
import SolanaSourceTokenSelector from "./SolanaSourceTokenSelector";
|
import SolanaTokenPicker from "./SolanaTokenPicker";
|
||||||
import TerraSourceTokenSelector from "./TerraSourceTokenSelector";
|
import TerraSourceTokenSelector from "./TerraSourceTokenSelector";
|
||||||
|
|
||||||
type TokenSelectorProps = {
|
type TokenSelectorProps = {
|
||||||
|
@ -84,21 +84,20 @@ export const TokenSelector = (props: TokenSelectorProps) => {
|
||||||
<Typography>{fatalError}</Typography>
|
<Typography>{fatalError}</Typography>
|
||||||
</RefreshButtonWrapper>
|
</RefreshButtonWrapper>
|
||||||
) : lookupChain === CHAIN_ID_SOLANA ? (
|
) : lookupChain === CHAIN_ID_SOLANA ? (
|
||||||
<SolanaSourceTokenSelector
|
<SolanaTokenPicker
|
||||||
value={sourceParsedTokenAccount || null}
|
value={sourceParsedTokenAccount || null}
|
||||||
onChange={handleOnChange}
|
onChange={handleOnChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
accounts={maps?.tokenAccounts?.data || []}
|
accounts={maps?.tokenAccounts}
|
||||||
mintAccounts={maps?.mintAccounts}
|
mintAccounts={maps?.mintAccounts}
|
||||||
resetAccounts={maps?.resetAccounts}
|
resetAccounts={maps?.resetAccounts}
|
||||||
nft={nft}
|
nft={nft}
|
||||||
/>
|
/>
|
||||||
) : isEVMChain(lookupChain) ? (
|
) : isEVMChain(lookupChain) ? (
|
||||||
<EthereumSourceTokenSelector
|
<EvmTokenPicker
|
||||||
value={sourceParsedTokenAccount || null}
|
value={sourceParsedTokenAccount || null}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={handleOnChange}
|
onChange={handleOnChange}
|
||||||
covalent={maps?.covalent || undefined}
|
|
||||||
tokenAccounts={maps?.tokenAccounts}
|
tokenAccounts={maps?.tokenAccounts}
|
||||||
resetAccounts={maps?.resetAccounts}
|
resetAccounts={maps?.resetAccounts}
|
||||||
chainId={lookupChain}
|
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,
|
extractMintInfo,
|
||||||
getMultipleAccountsRPC,
|
getMultipleAccountsRPC,
|
||||||
} from "../utils/solana";
|
} from "../utils/solana";
|
||||||
|
import bnbIcon from "../icons/bnb.svg";
|
||||||
|
import ethIcon from "../icons/eth.svg";
|
||||||
|
|
||||||
export function createParsedTokenAccount(
|
export function createParsedTokenAccount(
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
|
@ -205,7 +207,7 @@ const createNativeEthParsedTokenAccount = (
|
||||||
balanceInEth.toString(), //This is the actual display field, which has full precision.
|
balanceInEth.toString(), //This is the actual display field, which has full precision.
|
||||||
"ETH", //A white lie for display purposes
|
"ETH", //A white lie for display purposes
|
||||||
"Ethereum", //A white lie for display purposes
|
"Ethereum", //A white lie for display purposes
|
||||||
undefined, //TODO logo
|
ethIcon, //TODO logo
|
||||||
true //isNativeAsset
|
true //isNativeAsset
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -228,7 +230,7 @@ const createNativeBscParsedTokenAccount = (
|
||||||
balanceInEth.toString(), //This is the actual display field, which has full precision.
|
balanceInEth.toString(), //This is the actual display field, which has full precision.
|
||||||
"BNB", //A white lie for display purposes
|
"BNB", //A white lie for display purposes
|
||||||
"Binance Coin", //A white lie for display purposes
|
"Binance Coin", //A white lie for display purposes
|
||||||
undefined, //TODO logo
|
bnbIcon, //TODO logo
|
||||||
true //isNativeAsset
|
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