token picker overhaul

Change-Id: I641d5b5ff17bf45249e4729c9581a931fda9f204
This commit is contained in:
Chase Moran 2021-10-11 18:14:48 -04:00 committed by Evan Gray
parent e5642a788d
commit 08b4e8c7b3
8 changed files with 819 additions and 11 deletions

View File

@ -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>
</> </>
); );
} }

View File

@ -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}
/>
);
}

View File

@ -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,

View File

@ -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}
/>
);
}

View File

@ -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}

View File

@ -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}
</>
);
}

View File

@ -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
); );
}); });

View File

@ -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