bridge_ui: fix solana nft filter
Change-Id: Ie1fab3839cf7cf91f65dcbb9e1b0a3ad8ca261f5
This commit is contained in:
parent
7fede406a4
commit
0fc7b06b8d
|
@ -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}>
|
||||||
|
|
|
@ -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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
Loading…
Reference in New Issue