bridge_ui: fix solana nft filter

Change-Id: Ie1fab3839cf7cf91f65dcbb9e1b0a3ad8ca261f5
This commit is contained in:
Evan Gray 2021-10-21 14:50:49 -04:00
parent 7fede406a4
commit 0fc7b06b8d
5 changed files with 10 additions and 1391 deletions

View File

@ -1,3 +1,4 @@
import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import { Button, makeStyles } from "@material-ui/core"; import { Button, makeStyles } from "@material-ui/core";
import { VerifiedUser } from "@material-ui/icons"; import { VerifiedUser } from "@material-ui/icons";
import { Alert } from "@material-ui/lab"; import { Alert } from "@material-ui/lab";
@ -79,6 +80,11 @@ function Source() {
Only NFTs which implement ERC-721 are supported. Only NFTs which implement ERC-721 are supported.
</Alert> </Alert>
) : null} ) : null}
{sourceChain === CHAIN_ID_SOLANA ? (
<Alert severity="info" variant="outlined">
Only NFTs with a supply of 1 are supported.
</Alert>
) : null}
<KeyAndBalance chainId={sourceChain} /> <KeyAndBalance chainId={sourceChain} />
{isReady || uiAmountString ? ( {isReady || uiAmountString ? (
<div className={classes.transferField}> <div className={classes.transferField}>

View File

@ -1,643 +0,0 @@
import { WormholeAbi__factory } from "@certusone/wormhole-sdk/lib/ethers-contracts/abi";
import {
CircularProgress,
createStyles,
makeStyles,
TextField,
Typography,
} from "@material-ui/core";
import { Alert, Autocomplete, createFilterOptions } from "@material-ui/lab";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useEthereumProvider } from "../../contexts/EthereumProviderContext";
import { CovalentData } from "../../hooks/useGetSourceParsedTokenAccounts";
import { DataWrapper } from "../../store/helpers";
import { ParsedTokenAccount } from "../../store/transferSlice";
import {
getMigrationAssetMap,
WORMHOLE_V1_ETH_ADDRESS,
} from "../../utils/consts";
import {
ethNFTToNFTParsedTokenAccount,
ethTokenToParsedTokenAccount,
getEthereumNFT,
getEthereumToken,
isNFT,
isValidEthereumAddress,
} from "../../utils/ethereum";
import { shortenAddress } from "../../utils/solana";
import OffsetButton from "./OffsetButton";
import { NFTParsedTokenAccount } from "../../store/nftSlice";
import NFTViewer from "./NFTViewer";
import { useDebounce } from "use-debounce/lib";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
import { ChainId, CHAIN_ID_ETH } from "@certusone/wormhole-sdk";
import { sortParsedTokenAccounts } from "../../utils/sort";
import { getAddress } from "@ethersproject/address";
const useStyles = makeStyles((theme) =>
createStyles({
selectInput: { minWidth: "10rem" },
tokenOverviewContainer: {
display: "flex",
width: "100%",
alignItems: "center",
"& div": {
margin: theme.spacing(1),
flexBasis: "33%",
"&$tokenImageContainer": {
maxWidth: 40,
},
"&:last-child": {
textAlign: "right",
},
},
},
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%",
},
},
})
);
const getSymbol = (account: ParsedTokenAccount | null) => {
if (!account) {
return undefined;
}
return account.symbol;
};
const getLogo = (account: ParsedTokenAccount | null) => {
if (!account) {
return undefined;
}
return account.logo;
};
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);
};
const isMigrationEligible = (chainId: ChainId, address: string) => {
const assetMap = getMigrationAssetMap(chainId);
return !!assetMap.get(getAddress(address));
};
type EthereumSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void;
covalent: DataWrapper<CovalentData[]> | undefined;
tokenAccounts: DataWrapper<ParsedTokenAccount[]> | undefined;
disabled: boolean;
resetAccounts: (() => void) | undefined;
chainId: ChainId;
nft?: boolean;
};
const renderAccount = (
chainId: ChainId,
account: ParsedTokenAccount,
covalentData: CovalentData | undefined,
classes: any
) => {
const mintPrettyString = shortenAddress(account.mintKey);
const uri = getLogo(account);
const symbol = getSymbol(account) || "Unknown";
const content = (
<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">{account.uiAmountString}</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>{content}</div>
</Alert>
</div>
);
return isMigrationEligible(chainId, account.mintKey)
? migrationRender
: content;
};
const renderNFTAccount = (
account: NFTParsedTokenAccount,
covalentData: CovalentData | undefined,
classes: any
) => {
const mintPrettyString = shortenAddress(account.mintKey);
const tokenId = account.tokenId;
const uri = account.image_256;
const symbol = covalentData?.contract_ticker_symbol || "Unknown";
const name = account.name || "Unknown";
return (
<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>
);
};
export default function EthereumSourceTokenSelector(
props: EthereumSourceTokenSelectorProps
) {
const {
value,
onChange,
covalent,
tokenAccounts,
disabled,
resetAccounts,
chainId,
nft,
} = props;
const classes = useStyles();
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeLoading, setAdvancedModeLoading] = useState(false);
const [advancedModeSymbol, setAdvancedModeSymbol] = useState("");
const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeHolderTokenIdRaw, setAdvancedModeHolderTokenId] =
useState("");
const [advancedModeHolderTokenId] = useDebounce(
advancedModeHolderTokenIdRaw,
500
);
const [advancedModeError, setAdvancedModeError] = useState("");
const [autocompleteHolder, setAutocompleteHolder] =
useState<ParsedTokenAccount | null>(null);
const [autocompleteError, setAutocompleteError] = useState("");
const { provider, signerAddress } = useEthereumProvider();
// const wrappedTestToken = "0x8bf3c393b588bb6ad021e154654493496139f06d";
// const notWrappedTestToken = "0xaaaebe6fe48e54f431b0c390cfaf0b017d09d42d";
const resetAccountWrapper = useCallback(() => {
setAdvancedModeHolderString("");
setAutocompleteHolder(null);
setAdvancedModeError("");
setAutocompleteError("");
resetAccounts && resetAccounts();
}, [resetAccounts]);
useEffect(() => {
//If we receive a push from our parent, usually on component mount, we set our internal value to synchronize
//This also kicks off the metadata load.
if (advancedMode && value && advancedModeHolderString !== value.mintKey) {
setAdvancedModeHolderString(value.mintKey);
// @ts-ignore // TODO: could be NFTParsedTokenAccount which has a tokenId, nicer way to represent this?
if (nft && advancedModeHolderTokenId !== value.tokenId) {
// @ts-ignore
setAdvancedModeHolderTokenId(value.tokenId || "");
}
}
if (!advancedMode && value && !autocompleteHolder) {
setAutocompleteHolder(value);
}
}, [
value,
advancedMode,
advancedModeHolderString,
autocompleteHolder,
nft,
advancedModeHolderTokenId,
]);
//This effect is watching the autocomplete selection.
//It checks to make sure the token is a valid choice before putting it on the state.
//At present, that just means it can't be wormholev1
useEffect(() => {
if (advancedMode || !autocompleteHolder || !provider) {
return;
} else {
let cancelled = false;
setAutocompleteError("");
if (nft) {
onChange(autocompleteHolder);
return;
}
if (autocompleteHolder.isNativeAsset) {
onChange(autocompleteHolder);
return;
}
isWormholev1(provider, autocompleteHolder.mintKey, chainId).then(
(result) => {
if (!cancelled) {
result
? setAutocompleteError(
"Wormhole v1 tokens cannot be transferred with this bridge."
)
: onChange(autocompleteHolder);
}
},
(error) => {
console.log(error);
if (!cancelled) {
setAutocompleteError(
"Warning: please verify if this is a Wormhole v1 token address. V1 tokens should not be transferred with this bridge"
);
onChange(autocompleteHolder);
}
}
);
return () => {
cancelled = true;
};
}
}, [autocompleteHolder, provider, advancedMode, onChange, nft, chainId]);
//This effect watches the advancedModeString, and checks that the selected asset is valid before putting
// it on the state.
useEffect(() => {
let cancelled = false;
if (!advancedMode || !isValidEthereumAddress(advancedModeHolderString)) {
return;
} else {
//TODO get a bit smarter about setting & clearing errors
if (provider === undefined || signerAddress === undefined) {
!cancelled &&
setAdvancedModeError("Your Ethereum wallet is no longer connected.");
return;
}
!cancelled && setAdvancedModeLoading(true);
!cancelled && setAdvancedModeError("");
!cancelled && setAdvancedModeSymbol("");
try {
if (nft) {
getEthereumNFT(advancedModeHolderString, provider)
.then((token) => {
isNFT(token)
.then((result) => {
if (result) {
ethNFTToNFTParsedTokenAccount(
token,
advancedModeHolderTokenId,
signerAddress
)
.then((parsedTokenAccount) => {
!cancelled && onChange(parsedTokenAccount);
!cancelled && setAdvancedModeLoading(false);
})
.catch((error) => {
!cancelled &&
setAdvancedModeError(
"Failed to find the specified tokenId"
);
!cancelled && setAdvancedModeLoading(false);
});
} else {
console.error("no NFT result");
!cancelled &&
setAdvancedModeError(
"This token does not support ERC-165, ERC-721, and ERC-721 metadata"
);
!cancelled && setAdvancedModeLoading(false);
}
})
.catch((error) => {
console.error("isNFT", error);
!cancelled &&
setAdvancedModeError(
"This token does not support ERC-165, ERC-721, and ERC-721 metadata"
);
!cancelled && setAdvancedModeLoading(false);
});
})
.catch((error) => {
console.error("getEthereumNFT", error);
!cancelled &&
setAdvancedModeError(
"This token does not support ERC-165, ERC-721, and ERC-721 metadata"
);
!cancelled && setAdvancedModeLoading(false);
});
} else {
//Validate that the token is not a wormhole v1 asset
const isWormholePromise = isWormholev1(
provider,
advancedModeHolderString,
chainId
).then(
(result) => {
if (result && !cancelled) {
setAdvancedModeError(
"Wormhole v1 assets are not eligible for transfer."
);
setAdvancedModeLoading(false);
return Promise.reject();
} else {
return Promise.resolve();
}
},
(error) => {
!cancelled &&
setAdvancedModeError(
"Warning: please verify if this is a Wormhole v1 token address. V1 tokens should not be transferred with this bridge"
);
!cancelled && setAdvancedModeLoading(false);
return Promise.resolve(); //Don't allow an error here to tank the workflow
}
);
//Then fetch the asset's information & transform to a parsed token account
isWormholePromise.then(() =>
getEthereumToken(advancedModeHolderString, provider).then(
(token) => {
ethTokenToParsedTokenAccount(token, signerAddress).then(
(parsedTokenAccount) => {
!cancelled && onChange(parsedTokenAccount);
!cancelled && setAdvancedModeLoading(false);
},
(error) => {
//These errors can maybe be consolidated
!cancelled &&
setAdvancedModeError(
"Failed to find the specified address"
);
!cancelled && setAdvancedModeLoading(false);
}
);
//Also attempt to store off the symbol
token.symbol().then(
(result) => {
!cancelled && setAdvancedModeSymbol(result);
},
(error) => {
!cancelled &&
setAdvancedModeError(
"Failed to find the specified address"
);
!cancelled && setAdvancedModeLoading(false);
}
);
},
(error) => {}
)
);
}
} catch (e) {
!cancelled &&
setAdvancedModeError("Failed to find the specified address");
!cancelled && setAdvancedModeLoading(false);
}
}
return () => {
cancelled = true;
};
}, [
advancedModeHolderString,
advancedMode,
provider,
signerAddress,
onChange,
nft,
advancedModeHolderTokenId,
chainId,
]);
const handleClick = useCallback(() => {
onChange(null);
setAdvancedModeHolderString("");
setAdvancedModeHolderTokenId("");
}, [onChange]);
const handleOnChange = useCallback(
(event) => setAdvancedModeHolderString(event.target.value),
[]
);
const handleTokenIdOnChange = useCallback(
(event) => setAdvancedModeHolderTokenId(event.target.value),
[]
);
const filterConfig = createFilterOptions({
matchFrom: "any",
stringify: (option: ParsedTokenAccount) => {
const symbol = getSymbol(option) + " " || "";
const mint = option.mintKey + " ";
return symbol + mint;
},
});
const filterConfigNFT = createFilterOptions({
matchFrom: "any",
stringify: (option: NFTParsedTokenAccount) => {
const symbol = getSymbol(option) + " " || "";
const mint = option.mintKey + " ";
const name = option.name ? option.name + " " : "";
const id = option.tokenId ? option.tokenId + " " : "";
return symbol + mint + name + id;
},
});
const toggleAdvancedMode = () => {
setAdvancedModeHolderString("");
setAdvancedModeError("");
setAdvancedModeSymbol("");
setAutocompleteHolder(null);
setAutocompleteError("");
setAdvancedMode(!advancedMode);
};
const handleAutocompleteChange = useCallback(
(event, newValue: ParsedTokenAccount | null) => {
setAutocompleteHolder(newValue);
},
[]
);
const tokenAccountsData = tokenAccounts?.data;
const sortedOptions = useMemo(() => {
const options = tokenAccountsData || [];
options.sort(sortParsedTokenAccounts);
return options;
}, [tokenAccountsData]);
const isLoading =
props.covalent?.isFetching || props.tokenAccounts?.isFetching;
const autoComplete = (
<>
<Autocomplete
autoComplete
autoHighlight
blurOnSelect
clearOnBlur
fullWidth={true}
filterOptions={nft ? filterConfigNFT : filterConfig}
value={autocompleteHolder}
onChange={handleAutocompleteChange}
disabled={disabled}
noOptionsText={
nft
? "No ERC-721 tokens found at the moment."
: "No ERC-20 tokens found at the moment."
}
options={sortedOptions}
renderInput={(params) => (
<TextField {...params} label="Token Account" variant="outlined" />
)}
renderOption={(option) => {
return nft
? renderNFTAccount(
option,
covalent?.data?.find(
(x) => x.contract_address === option.mintKey
),
classes
)
: renderAccount(
chainId,
option,
covalent?.data?.find(
(x) => x.contract_address === option.mintKey
),
classes
);
}}
getOptionLabel={(option) => {
const symbol = getSymbol(option);
return `${symbol ? symbol : "Unknown"} ${
nft && option.name ? option.name : ""
} (Address: ${shortenAddress(option.mintKey)}${
nft ? `, ID: ${option.tokenId}` : ""
})`;
}}
/>
</>
);
const advancedModeToggleButton = (
<OffsetButton onClick={toggleAdvancedMode} disabled={disabled}>
{advancedMode ? "Toggle Token Picker" : "Toggle Manual Entry"}
</OffsetButton>
);
const clearButton = (
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
);
const symbol = getSymbol(value) || advancedModeSymbol;
const content = value ? (
<>
{nft ? (
<NFTViewer value={value} chainId={chainId} />
) : (
<RefreshButtonWrapper callback={resetAccountWrapper}>
<Typography>
{value.isNativeAsset
? value.symbol
: (symbol ? symbol + " " : "") + value.mintKey}
</Typography>
</RefreshButtonWrapper>
)}
</>
) : advancedMode ? (
<>
<TextField
variant="outlined"
fullWidth
label="Enter an asset address"
value={advancedModeHolderString}
onChange={handleOnChange}
error={
(advancedModeHolderString !== "" &&
!isValidEthereumAddress(advancedModeHolderString)) ||
!!advancedModeError
}
helperText={
advancedModeHolderString &&
!isValidEthereumAddress(advancedModeHolderString) &&
"Invalid Ethereum address"
}
disabled={disabled || advancedModeLoading}
/>
{nft ? (
<TextField
variant="outlined"
fullWidth
label="Enter a tokenId"
value={advancedModeHolderTokenIdRaw}
onChange={handleTokenIdOnChange}
disabled={disabled || advancedModeLoading}
/>
) : null}
</>
) : isLoading ? (
<Typography component="div">
<CircularProgress size={"1em"} />{" "}
{nft ? "Loading (this may take a while)..." : "Loading..."}
</Typography>
) : (
<RefreshButtonWrapper callback={resetAccountWrapper}>
{autoComplete}
</RefreshButtonWrapper>
);
return (
<>
{content}
{!advancedMode && autocompleteError ? (
<Typography color="error">{autocompleteError}</Typography>
) : advancedMode && advancedModeError ? (
<Typography color="error">{advancedModeError}</Typography>
) : null}
{value ? clearButton : advancedModeToggleButton}
</>
);
}

View File

@ -1,393 +0,0 @@
import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk";
import {
CircularProgress,
TextField,
Typography,
createStyles,
makeStyles,
} from "@material-ui/core";
import { Alert, Autocomplete } from "@material-ui/lab";
import { createFilterOptions } from "@material-ui/lab/Autocomplete";
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, shortenAddress } from "../../utils/solana";
import { sortParsedTokenAccounts } from "../../utils/sort";
import NFTViewer from "./NFTViewer";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
const useStyles = makeStyles((theme) =>
createStyles({
selectInput: { minWidth: "10rem" },
tokenOverviewContainer: {
display: "flex",
width: "100%",
alignItems: "center",
"& div": {
margin: theme.spacing(1),
flexBasis: "33%",
"&$tokenImageContainer": {
maxWidth: 40,
},
"&:last-child": {
textAlign: "right",
},
},
},
tokenImageContainer: {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
},
tokenImage: {
maxHeight: "2.5rem", //Eyeballing this based off the text size
},
v1Warning: {
width: "100%",
},
migrationAlert: {
width: "100%",
"& .MuiAlert-message": {
width: "100%",
},
},
})
);
type SolanaSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: NFTParsedTokenAccount | null) => void;
accounts: ParsedTokenAccount[];
disabled: boolean;
mintAccounts:
| DataWrapper<Map<string, ExtractedMintInfo | null> | undefined>
| undefined;
resetAccounts: (() => void) | undefined;
nft?: boolean;
};
const getOptionSelected = (
option: ParsedTokenAccount,
value: ParsedTokenAccount
) => option.mintKey === value.mintKey && option.publicKey === value.publicKey;
export default function SolanaSourceTokenSelector(
props: SolanaSourceTokenSelectorProps
) {
const { value, onChange, disabled, resetAccounts, nft, mintAccounts } = props;
const classes = useStyles();
const resetAccountWrapper = resetAccounts || (() => {}); //This should never happen.
const solanaTokenMap = 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 (solanaTokenMap.data) {
for (const data of solanaTokenMap.data) {
if (data && data.address) {
output.set(data.address, data);
}
}
}
return output;
}, [solanaTokenMap]);
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]
);
//I wish there was a way to make this more intelligent,
//but the autocomplete filterConfig options seem pretty limiting.
const filterConfig = createFilterOptions({
matchFrom: "any",
stringify: (option: ParsedTokenAccount) => {
const symbol = getSymbol(option) + " " || "";
const name = getName(option) + " " || "";
const mint = option.mintKey + " ";
const pubkey = option.publicKey + " ";
return symbol + name + mint + pubkey;
},
});
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 isMigrationEligible = useCallback((address: string) => {
return !!MIGRATION_ASSET_MAP.get(address);
}, []);
const renderAccount = useCallback(
(account: ParsedTokenAccount) => {
const mintPrettyString = shortenAddress(account.mintKey);
const accountAddressPrettyString = shortenAddress(account.publicKey);
const uri = getLogo(account);
const symbol = getSymbol(account) || "Unknown";
const name = getName(account) || "--";
const content = (
<div className={classes.tokenOverviewContainer}>
<div className={classes.tokenImageContainer}>
{uri && <img alt="" className={classes.tokenImage} src={uri} />}
</div>
<div>
<Typography variant="subtitle1">{symbol}</Typography>
<Typography variant="subtitle2">{name}</Typography>
</div>
<div>
{account.isNativeAsset ? (
<Typography>{"Native"}</Typography>
) : (
<>
<Typography variant="body1">
{"Mint : " + mintPrettyString}
</Typography>
<Typography variant="body1">
{"Account :" + accountAddressPrettyString}
</Typography>
</>
)}
</div>
{nft ? null : (
<div>
<Typography variant="body2">{"Balance"}</Typography>
<Typography variant="h6">{account.uiAmountString}</Typography>
</div>
)}
</div>
);
const v1Warning = (
<div className={classes.v1Warning}>
<Typography variant="body2">
Wormhole v1 tokens are not eligible for transfer.
</Typography>
<div>{content}</div>
</div>
);
const migrationRender = (
<div className={classes.migrationAlert}>
<Alert severity="warning" variant="outlined">
<Typography variant="body2">
This is a legacy asset eligible for migration.
</Typography>
<div>{content}</div>
</Alert>
</div>
);
return isMigrationEligible(account.mintKey)
? migrationRender
: isWormholev1(account.mintKey)
? v1Warning
: content;
},
[
getLogo,
getSymbol,
getName,
classes,
isWormholev1,
isMigrationEligible,
nft,
]
);
//The autocomplete doesn't rerender the option label unless the value changes.
//Thus we should wait for the metadata to arrive before rendering it.
//TODO This can flicker dependent on how fast the useEffects in the getSourceAccounts hook complete.
const isLoading =
metaplex.isFetching ||
solanaTokenMap.isFetching ||
props.mintAccounts?.isFetching;
const accountLoadError =
props.mintAccounts?.error && "Unable to retrieve your token accounts";
const error = accountLoadError;
//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 tokenList = props.accounts
.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;
})
.map((account) => ({
...account,
symbol: account.symbol || getSymbol(account) || undefined,
}));
tokenList.sort(sortParsedTokenAccounts);
return tokenList;
}, [mintAccounts?.data, metaplex.data, nft, props.accounts, getSymbol]);
const isOptionDisabled = useMemo(() => {
return (value: ParsedTokenAccount) => {
return isWormholev1(value.mintKey) && !isMigrationEligible(value.mintKey);
};
}, [isWormholev1, isMigrationEligible]);
const onAutocompleteChange = useCallback(
(event, newValue: NFTParsedTokenAccount | null) => {
if (!newValue) {
onChange(null);
return;
}
const symbol = getSymbol(newValue);
const name = getName(newValue);
const logo = getLogo(newValue);
// TODO: more nft data
onChange({
...newValue,
symbol,
name,
logo: nft ? undefined : logo,
uri: nft ? logo : undefined,
});
},
[getSymbol, getName, getLogo, onChange, nft]
);
const renderInput = useCallback(
(params) => (
<TextField
{...params}
label={nft ? "NFT Account" : "Token Account"}
variant="outlined"
/>
),
[nft]
);
const getOptionLabel = useCallback(
(option: ParsedTokenAccount) => {
const symbol = getSymbol(option);
const label = option.isNativeAsset
? symbol || "" //default for non-null guarantee
: `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress(
option.publicKey
)}, Mint: ${shortenAddress(option.mintKey)})`;
return label;
},
[getSymbol]
);
const autoComplete = (
<Autocomplete
autoComplete
autoHighlight
blurOnSelect
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={value}
onChange={onAutocompleteChange}
disabled={disabled}
options={filteredOptions}
renderInput={renderInput}
renderOption={renderAccount}
getOptionDisabled={isOptionDisabled}
getOptionLabel={getOptionLabel}
getOptionSelected={getOptionSelected}
/>
);
const wrappedContent = (
<RefreshButtonWrapper callback={resetAccountWrapper}>
{autoComplete}
</RefreshButtonWrapper>
);
return (
<React.Fragment>
{isLoading ? <CircularProgress /> : wrappedContent}
{error && <Typography color="error">{error}</Typography>}
{nft && value ? (
<NFTViewer value={value} chainId={CHAIN_ID_SOLANA} />
) : null}
</React.Fragment>
);
}

View File

@ -112,10 +112,10 @@ export default function SolanaSourceTokenSelector(
return false; return false;
} }
const isNFT = const isNFT =
x.decimals === 0 && x.decimals === 0 && metaplex.data?.get(x.mintKey)?.data?.uri;
metaplex.data?.get(x.mintKey)?.data?.uri && const is721CompatibleNFT =
mintAccounts?.data?.get(x.mintKey)?.supply === "1"; isNFT && mintAccounts?.data?.get(x.mintKey)?.supply === "1";
return nft ? isNFT : !isNFT; return nft ? is721CompatibleNFT : !isNFT;
}); });
tokenList.sort(sortParsedTokenAccounts); tokenList.sort(sortParsedTokenAccounts);
return tokenList; return tokenList;

View File

@ -1,351 +0,0 @@
import { isNativeDenom } from "@certusone/wormhole-sdk";
import {
CircularProgress,
createStyles,
makeStyles,
TextField,
Typography,
} from "@material-ui/core";
import { Autocomplete, createFilterOptions } from "@material-ui/lab";
import { LCDClient } from "@terra-money/terra.js";
import {
ConnectedWallet,
useConnectedWallet,
} from "@terra-money/wallet-provider";
import { formatUnits } from "ethers/lib/utils";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createParsedTokenAccount } from "../../hooks/useGetSourceParsedTokenAccounts";
import useTerraNativeBalances from "../../hooks/useTerraNativeBalances";
import { TerraTokenMetadata } from "../../hooks/useTerraTokenMap";
import { ParsedTokenAccount } from "../../store/transferSlice";
import { SUPPORTED_TERRA_TOKENS, TERRA_HOST } from "../../utils/consts";
import { shortenAddress } from "../../utils/solana";
import {
formatNativeDenom,
formatTerraNativeBalance,
getNativeTerraIcon,
NATIVE_TERRA_DECIMALS,
} from "../../utils/terra";
import OffsetButton from "./OffsetButton";
import RefreshButtonWrapper from "./RefreshButtonWrapper";
const useStyles = makeStyles((theme) =>
createStyles({
selectInput: { minWidth: "10rem" },
tokenOverviewContainer: {
display: "flex",
width: "100%",
alignItems: "center",
"& div": {
margin: theme.spacing(1),
"&$tokenImageContainer": {
maxWidth: 40,
},
},
},
tokenImageContainer: {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
},
tokenImage: {
maxHeight: "2.5rem", //Eyeballing this based off the text size
},
tokenSymbolContainer: {
flexBasis: 112,
},
})
);
type TerraSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void;
disabled: boolean;
resetAccounts: (() => void) | undefined;
};
//TODO move elsewhere
//TODO async
const lookupTerraAddress = (
lookupAsset: string,
terraWallet: ConnectedWallet
) => {
const lcd = new LCDClient(TERRA_HOST);
return lcd.wasm
.contractQuery(lookupAsset, {
token_info: {},
})
.then((info: any) =>
lcd.wasm
.contractQuery(lookupAsset, {
balance: {
address: terraWallet.walletAddress,
},
})
.then((balance: any) => {
if (balance && info) {
return createParsedTokenAccount(
terraWallet.walletAddress,
lookupAsset,
balance.balance.toString(),
info.decimals,
Number(formatUnits(balance.balance, info.decimals)),
formatUnits(balance.balance, info.decimals)
);
} else {
throw new Error("Failed to retrieve Terra account.");
}
})
)
.catch(() => {
return Promise.reject();
});
};
export default function TerraSourceTokenSelector(
props: TerraSourceTokenSelectorProps
) {
const classes = useStyles();
const { onChange, value, disabled, resetAccounts } = props;
// const tokenMap = useTerraTokenMap();
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState("");
const terraWallet = useConnectedWallet();
const [autocompleteString, setAutocompleteString] = useState("");
const handleAutocompleteChange = useCallback(
(event) => {
setAutocompleteString(event?.target?.value);
},
[setAutocompleteString]
);
const nativeRefresh = useRef<() => void>(() => {});
const { balances, isLoading: nativeIsLoading } = useTerraNativeBalances(
terraWallet?.walletAddress,
nativeRefresh
);
const resetAccountWrapper = useCallback(() => {
setAdvancedModeHolderString("");
setAdvancedModeError("");
setAutocompleteString("");
resetAccounts && resetAccounts();
nativeRefresh.current();
}, [resetAccounts]);
const isLoading = nativeIsLoading; // || (tokenMap?.isFetching || false);
const terraTokenArray = useMemo(() => {
const balancesItems = balances
? Object.keys(balances).map(
(denom) =>
({
protocol: "native",
symbol: formatNativeDenom(denom),
token: denom,
icon: getNativeTerraIcon(formatNativeDenom(denom)),
balance: balances[denom],
} as TerraTokenMetadata)
)
: [];
return balancesItems.filter((metadata) =>
SUPPORTED_TERRA_TOKENS.includes(metadata.token)
);
// const values = tokenMap.data?.mainnet;
// const tokenMapItems = Object.values(values || {}) || [];
// return [...balancesItems, ...tokenMapItems];
}, [
balances,
// tokenMap
]);
const valueToOption = (fromProps: ParsedTokenAccount | undefined | null) => {
if (!fromProps) return null;
else {
return terraTokenArray.find((x) => x.token === fromProps.mintKey);
}
};
const handleClick = useCallback(() => {
onChange(null);
setAdvancedModeHolderString("");
}, [onChange]);
const handleOnChange = useCallback(
(event) => setAdvancedModeHolderString(event?.target?.value),
[]
);
const handleConfirm = (address: string | undefined) => {
if (terraWallet === undefined || address === undefined) {
setAdvancedModeError("Terra wallet not connected.");
return;
}
setAdvancedModeError("");
if (isNativeDenom(address)) {
if (balances && balances[address]) {
onChange(
createParsedTokenAccount(
terraWallet.walletAddress,
address,
balances[address],
NATIVE_TERRA_DECIMALS,
Number(formatUnits(balances[address], NATIVE_TERRA_DECIMALS)),
formatUnits(balances[address], NATIVE_TERRA_DECIMALS),
formatNativeDenom(address)
)
);
} else {
setAdvancedModeError("Unable to retrieve that address.");
}
} else {
lookupTerraAddress(address, terraWallet).then(
(result) => {
onChange(result);
},
(error) => {
setAdvancedModeError("Unable to retrieve that address.");
}
);
}
};
const filterConfig = createFilterOptions({
matchFrom: "any",
stringify: (option: TerraTokenMetadata) => {
const symbol = option.symbol + " " || "";
const mint = option.token + " " || "";
const name = option.protocol + " " || "";
return symbol + mint + name;
},
});
const renderOptionLabel = (option: TerraTokenMetadata) => {
return option.symbol + " (" + shortenAddress(option.token) + ")";
};
const renderOption = (option: TerraTokenMetadata) => {
return (
<div className={classes.tokenOverviewContainer}>
<div className={classes.tokenImageContainer}>
<img alt="" className={classes.tokenImage} src={option.icon} />
</div>
<div className={classes.tokenSymbolContainer}>
<Typography variant="h6">{option.symbol}</Typography>
<Typography variant="body2">{option.protocol}</Typography>
</div>
<div>
<Typography variant="body1">{option.token}</Typography>
{option.balance ? (
<Typography variant="h6">
{formatTerraNativeBalance(option.balance)}
</Typography>
) : null}
</div>
</div>
);
};
const toggleAdvancedMode = () => {
setAdvancedMode(!advancedMode);
setAdvancedModeError("");
};
const advancedModeToggleButton = (
<OffsetButton onClick={toggleAdvancedMode} disabled={disabled}>
{advancedMode ? "Toggle Token Picker" : "Toggle Manual Entry"}
</OffsetButton>
);
const selectedValue = valueToOption(value);
const autoComplete = (
<>
<Autocomplete
autoComplete
autoHighlight
blurOnSelect
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={selectedValue}
onChange={(event, newValue) => {
handleConfirm(newValue?.token);
}}
inputValue={autocompleteString}
onInputChange={handleAutocompleteChange}
disabled={disabled}
noOptionsText={"No CW20 tokens found at the moment."}
options={terraTokenArray}
renderInput={(params) => (
<TextField {...params} label="Token" variant="outlined" />
)}
renderOption={renderOption}
getOptionLabel={renderOptionLabel}
/>
</>
);
const clearButton = (
<OffsetButton onClick={handleClick} disabled={disabled}>
Clear
</OffsetButton>
);
const content = value ? (
<>
<Typography>{value.mintKey}</Typography>
</>
) : !advancedMode ? (
autoComplete
) : (
<>
<TextField
fullWidth
variant="outlined"
label="Enter an asset address"
value={advancedModeHolderString}
onChange={handleOnChange}
disabled={disabled}
error={advancedModeHolderString !== "" && !!advancedModeError}
/>
</>
);
const wrappedContent = (
<RefreshButtonWrapper callback={resetAccountWrapper}>
{content}
</RefreshButtonWrapper>
);
const confirmButton = (
<OffsetButton
onClick={() => handleConfirm(advancedModeHolderString)}
disabled={disabled}
>
Confirm
</OffsetButton>
);
return (
<React.Fragment>
{isLoading && !value && !advancedMode ? (
<CircularProgress />
) : (
wrappedContent
)}
{advancedModeError && (
<Typography color="error">{advancedModeError}</Typography>
)}
<div>
{advancedMode && !value && confirmButton}
{!value && !isLoading && advancedModeToggleButton}
{value && clearButton}
</div>
</React.Fragment>
);
}