bridge_ui: added metadata hooks

Change-Id: Ia256ea5b84b6dc21238b4808d607c253b4e9dc87
This commit is contained in:
Chase Moran 2021-09-10 20:50:13 -04:00 committed by Evan Gray
parent d6640e2b3d
commit b550a7c47b
9 changed files with 255 additions and 232 deletions

View File

@ -4,6 +4,8 @@ import { 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 { ParsedTokenAccount } from "../../store/transferSlice";
import { WORMHOLE_V1_MINT_AUTHORITY } from "../../utils/consts";
@ -30,10 +32,8 @@ type SolanaSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void;
accounts: ParsedTokenAccount[];
solanaTokenMap: DataWrapper<TokenInfo[]> | undefined;
metaplexData: any; //DataWrapper<(Metadata | undefined)[]> | undefined | null;
disabled: boolean;
mintAccounts: DataWrapper<Map<String, string | null>> | undefined;
mintAccounts: DataWrapper<Map<string, string | null>> | undefined;
resetAccounts: (() => void) | undefined;
nft?: boolean;
};
@ -41,16 +41,27 @@ type SolanaSourceTokenSelectorProps = {
export default function SolanaSourceTokenSelector(
props: SolanaSourceTokenSelectorProps
) {
const { value, onChange, disabled, resetAccounts, nft } = props;
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 (props.solanaTokenMap?.data) {
for (const data of props.solanaTokenMap.data) {
if (solanaTokenMap.data) {
for (const data of solanaTokenMap.data) {
if (data && data.address) {
output.set(data.address, data);
}
@ -58,26 +69,12 @@ export default function SolanaSourceTokenSelector(
}
return output;
}, [props.solanaTokenMap]);
const memoizedMetaplex: Map<String, Metadata> = useMemo(() => {
const output = new Map<String, Metadata>();
if (props.metaplexData.data) {
for (const data of props.metaplexData.data) {
if (data && data.mint) {
output.set(data.mint, data);
}
}
}
return output;
}, [props.metaplexData]);
}, [solanaTokenMap]);
const getSymbol = (account: ParsedTokenAccount) => {
return (
memoizedTokenMap.get(account.mintKey)?.symbol ||
memoizedMetaplex.get(account.mintKey)?.data?.symbol ||
metaplex.data?.get(account.mintKey)?.data?.symbol ||
undefined
);
};
@ -85,7 +82,7 @@ export default function SolanaSourceTokenSelector(
const getName = (account: ParsedTokenAccount) => {
return (
memoizedTokenMap.get(account.mintKey)?.name ||
memoizedMetaplex.get(account.mintKey)?.data?.name ||
metaplex.data?.get(account.mintKey)?.data?.name ||
undefined
);
};
@ -130,11 +127,11 @@ export default function SolanaSourceTokenSelector(
const renderAccount = (
account: ParsedTokenAccount,
solanaTokenMap: Map<String, TokenInfo>,
metaplexData: Map<String, Metadata>,
metaplexData: Map<String, Metadata | undefined> | null | undefined,
classes: any
) => {
const tokenMapData = solanaTokenMap.get(account.mintKey);
const metaplexValue = metaplexData.get(account.mintKey);
const metaplexValue = metaplexData?.get(account.mintKey);
const mintPrettyString = shortenAddress(account.mintKey);
const accountAddressPrettyString = shortenAddress(account.publicKey);
@ -185,13 +182,12 @@ export default function SolanaSourceTokenSelector(
//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 =
props.metaplexData.isFetching ||
props.solanaTokenMap?.isFetching ||
metaplex.isFetching ||
solanaTokenMap.isFetching ||
props.mintAccounts?.isFetching;
const accountLoadError =
!(props.mintAccounts?.isFetching || props.mintAccounts?.data) &&
"Unable to retrieve your token accounts";
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
@ -200,10 +196,10 @@ export default function SolanaSourceTokenSelector(
return props.accounts.filter((x) => {
//TODO, do a better check which likely involves supply or checking masterEdition.
const isNFT =
x.decimals === 0 && memoizedMetaplex.get(x.mintKey)?.data?.uri;
x.decimals === 0 && metaplex.data?.get(x.mintKey)?.data?.uri;
return nft ? isNFT : !isNFT;
});
}, [memoizedMetaplex, nft, props.accounts]);
}, [metaplex.data, nft, props.accounts]);
const isOptionDisabled = useMemo(() => {
return (value: ParsedTokenAccount) => isWormholev1(value.mintKey);
@ -232,12 +228,7 @@ export default function SolanaSourceTokenSelector(
/>
)}
renderOption={(option) => {
return renderAccount(
option,
memoizedTokenMap,
memoizedMetaplex,
classes
);
return renderAccount(option, memoizedTokenMap, metaplex.data, classes);
}}
getOptionDisabled={isOptionDisabled}
getOptionLabel={(option) => {

View File

@ -88,8 +88,6 @@ export const TokenSelector = (props: TokenSelectorProps) => {
onChange={handleOnChange}
disabled={disabled}
accounts={maps?.tokenAccounts?.data || []}
solanaTokenMap={maps?.tokenMap}
metaplexData={maps?.metaplex}
mintAccounts={maps?.mintAccounts}
resetAccounts={maps?.resetAccounts}
nft={nft}
@ -109,7 +107,6 @@ export const TokenSelector = (props: TokenSelectorProps) => {
value={sourceParsedTokenAccount || null}
disabled={disabled}
onChange={handleOnChange}
tokenMap={maps?.terraTokenMap}
resetAccounts={maps?.resetAccounts}
/>
) : (

View File

@ -13,12 +13,10 @@ import {
} from "@terra-money/wallet-provider";
import { formatUnits } from "ethers/lib/utils";
import React, { useCallback, useMemo, useState } from "react";
import {
createParsedTokenAccount,
TerraTokenMap,
import { createParsedTokenAccount } from "../../hooks/useGetSourceParsedTokenAccounts";
import useTerraTokenMap, {
TerraTokenMetadata,
} from "../../hooks/useGetSourceParsedTokenAccounts";
import { DataWrapper } from "../../store/helpers";
} from "../../hooks/useTerraTokenMap";
import { ParsedTokenAccount } from "../../store/transferSlice";
import { TERRA_HOST } from "../../utils/consts";
import { shortenAddress } from "../../utils/solana";
@ -44,7 +42,6 @@ type TerraSourceTokenSelectorProps = {
value: ParsedTokenAccount | null;
onChange: (newValue: ParsedTokenAccount | null) => void;
disabled: boolean;
tokenMap: DataWrapper<TerraTokenMap> | undefined; //TODO better type
resetAccounts: (() => void) | undefined;
};
@ -90,7 +87,8 @@ export default function TerraSourceTokenSelector(
props: TerraSourceTokenSelectorProps
) {
const classes = useStyles();
const { onChange, value, disabled, tokenMap, resetAccounts } = props;
const { onChange, value, disabled, resetAccounts } = props;
const tokenMap = useTerraTokenMap();
const [advancedMode, setAdvancedMode] = useState(false);
const [advancedModeHolderString, setAdvancedModeHolderString] = useState("");
const [advancedModeError, setAdvancedModeError] = useState("");
@ -115,10 +113,10 @@ export default function TerraSourceTokenSelector(
const isLoading = tokenMap?.isFetching || false;
const terraTokenArray = useMemo(() => {
const values = props.tokenMap?.data?.mainnet;
const values = tokenMap.data?.mainnet;
const items = Object.values(values || {});
return items || [];
}, [props.tokenMap]);
}, [tokenMap]);
const valueToOption = (fromProps: ParsedTokenAccount | undefined | null) => {
if (!fromProps) return null;

View File

@ -5,7 +5,6 @@ import {
} from "@certusone/wormhole-sdk";
import { Dispatch } from "@reduxjs/toolkit";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { ENV, TokenListProvider } from "@solana/spl-token-registry";
import {
AccountInfo,
Connection,
@ -18,7 +17,6 @@ import { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useEthereumProvider } from "../contexts/EthereumProviderContext";
import { useSolanaWallet } from "../contexts/SolanaWalletContext";
import { DataWrapper } from "../store/helpers";
import {
errorSourceParsedTokenAccounts as errorSourceParsedTokenAccountsNFT,
fetchSourceParsedTokenAccounts as fetchSourceParsedTokenAccountsNFT,
@ -32,20 +30,10 @@ import {
selectNFTSourceChain,
selectNFTSourceParsedTokenAccounts,
selectNFTSourceWalletAddress,
selectSolanaTokenMap,
selectSourceWalletAddress,
selectTerraTokenMap,
selectTransferSourceChain,
selectTransferSourceParsedTokenAccounts,
} from "../store/selectors";
import {
errorSolanaTokenMap,
errorTerraTokenMap,
fetchSolanaTokenMap,
fetchTerraTokenMap,
receiveSolanaTokenMap,
receiveTerraTokenMap,
} from "../store/tokenSlice";
import {
errorSourceParsedTokenAccounts,
fetchSourceParsedTokenAccounts,
@ -56,17 +44,7 @@ import {
setSourceParsedTokenAccounts,
setSourceWalletAddress,
} from "../store/transferSlice";
import {
CLUSTER,
COVALENT_GET_TOKENS_URL,
SOLANA_HOST,
TERRA_TOKEN_METADATA_URL,
} from "../utils/consts";
import {
decodeMetadata,
getMetadataAddress,
Metadata,
} from "../utils/metaplex";
import { COVALENT_GET_TOKENS_URL, SOLANA_HOST } from "../utils/consts";
import {
extractMintAuthorityInfo,
getMultipleAccountsRPC,
@ -120,19 +98,6 @@ export function createNFTParsedTokenAccount(
};
}
export type TerraTokenMetadata = {
protocol: string;
symbol: string;
token: string;
icon: string;
};
export type TerraTokenMap = {
mainnet: {
[address: string]: TerraTokenMetadata;
};
};
const createParsedTokenAccountFromInfo = (
pubkey: PublicKey,
item: AccountInfo<ParsedAccountData>
@ -158,6 +123,9 @@ const createParsedTokenAccountFromCovalent = (
decimals: covalent.contract_decimals,
uiAmount: Number(formatUnits(covalent.balance, covalent.contract_decimals)),
uiAmountString: formatUnits(covalent.balance, covalent.contract_decimals),
symbol: covalent.contract_ticker_symbol,
name: covalent.contract_name,
logo: covalent.logo_url,
};
};
@ -243,46 +211,10 @@ const getEthereumAccountsCovalent = async (
return output;
} catch (error) {
console.error(error);
return Promise.reject("Unable to retrieve your Ethereum Tokens.");
}
};
const environment = CLUSTER === "testnet" ? ENV.Testnet : ENV.MainnetBeta;
const getMetaplexData = async (mintAddresses: string[]) => {
const promises = [];
for (const address of mintAddresses) {
promises.push(getMetadataAddress(address));
}
const metaAddresses = await Promise.all(promises);
const connection = new Connection(SOLANA_HOST, "finalized");
const results = await getMultipleAccountsRPC(
connection,
metaAddresses.map((pair) => pair && pair[0])
);
const output = results.map((account) => {
if (account === null) {
return undefined;
} else {
if (account.data) {
try {
const MetadataParsed = decodeMetadata(account.data);
return MetadataParsed;
} catch (e) {
console.error(e);
return undefined;
}
} else {
return undefined;
}
}
});
return output;
};
const getSolanaParsedTokenAccounts = (
walletAddress: string,
dispatch: Dispatch,
@ -317,20 +249,6 @@ const getSolanaParsedTokenAccounts = (
);
};
const getSolanaTokenMap = (dispatch: Dispatch) => {
dispatch(fetchSolanaTokenMap());
new TokenListProvider().resolve().then(
(tokens) => {
const tokenList = tokens.filterByChainId(environment).getList();
dispatch(receiveSolanaTokenMap(tokenList));
},
(error) => {
console.error(error);
dispatch(errorSolanaTokenMap("Failed to retrieve the Solana token map."));
}
);
};
/**
* Fetches the balance of an asset for the connected wallet
* This should handle every type of chain in the future, but only reads the Transfer state.
@ -343,21 +261,14 @@ function useGetAvailableTokens(nft: boolean = false) {
? selectNFTSourceParsedTokenAccounts
: selectTransferSourceParsedTokenAccounts
);
const solanaTokenMap = useSelector(selectSolanaTokenMap);
const terraTokenMap = useSelector(selectTerraTokenMap);
const lookupChain = useSelector(
nft ? selectNFTSourceChain : selectTransferSourceChain
);
const solanaWallet = useSolanaWallet();
const solPK = solanaWallet?.publicKey;
//const terraWallet = useConnectedWallet(); //TODO
const { provider, signerAddress } = useEthereumProvider();
const [metaplex, setMetaplex] = useState<any>(undefined);
const [metaplexLoading, setMetaplexLoading] = useState(false);
const [metaplexError, setMetaplexError] = useState(null);
const [covalent, setCovalent] = useState<any>(undefined);
const [covalentLoading, setCovalentLoading] = useState(false);
const [covalentError, setCovalentError] = useState<string | undefined>(
@ -422,39 +333,7 @@ function useGetAvailableTokens(nft: boolean = false) {
resetSourceAccounts,
]);
// Solana metaplex load
useEffect(() => {
let cancelled = false;
if (tokenAccounts.data && lookupChain === CHAIN_ID_SOLANA) {
setMetaplexLoading(true);
const accounts = tokenAccounts.data.map((account) => account.mintKey);
accounts.filter((x) => !!x);
getMetaplexData(accounts as string[]).then(
(results) => {
if (!cancelled) {
setMetaplex(results);
setMetaplexLoading(false);
} else {
}
},
(error) => {
if (!cancelled) {
console.error(error);
setMetaplexLoading(false);
setMetaplexError(error);
} else {
}
}
);
} else {
}
return () => {
cancelled = true;
};
}, [tokenAccounts, lookupChain]);
//Solana token map & accountinfos load
//Solana accountinfos load
useEffect(() => {
if (lookupChain === CHAIN_ID_SOLANA && solPK) {
if (
@ -462,27 +341,10 @@ function useGetAvailableTokens(nft: boolean = false) {
) {
getSolanaParsedTokenAccounts(solPK.toString(), dispatch, nft);
}
if (
!(
solanaTokenMap.data ||
solanaTokenMap.isFetching ||
solanaTokenMap.error
)
) {
getSolanaTokenMap(dispatch);
}
}
return () => {};
}, [
dispatch,
solanaWallet,
lookupChain,
solPK,
tokenAccounts,
solanaTokenMap,
nft,
]);
}, [dispatch, solanaWallet, lookupChain, solPK, tokenAccounts, nft]);
//Solana Mint Accounts lookup
useEffect(() => {
@ -605,46 +467,9 @@ function useGetAvailableTokens(nft: boolean = false) {
//At present, we don't have any mechanism for doing this.
useEffect(() => {}, []);
//Terra metadata load
useEffect(() => {
let cancelled = false;
if (terraTokenMap.data || lookupChain !== CHAIN_ID_TERRA) {
return; //So we don't fetch the whole list on every mount.
}
dispatch(fetchTerraTokenMap());
axios.get(TERRA_TOKEN_METADATA_URL).then(
(response) => {
if (!cancelled) {
//TODO parse this in a safer manner
dispatch(receiveTerraTokenMap(response.data as TerraTokenMap));
}
},
(error) => {
if (!cancelled) {
dispatch(
errorTerraTokenMap("Failed to retrieve the Terra Token List.")
);
}
}
);
return () => {
cancelled = true;
};
}, [lookupChain, terraTokenMap.data, dispatch]);
return lookupChain === CHAIN_ID_SOLANA
? {
tokenMap: solanaTokenMap,
tokenAccounts: tokenAccounts,
metaplex: {
data: metaplex,
isFetching: metaplexLoading,
error: metaplexError,
receivedAt: null, //TODO
} as DataWrapper<Metadata[]>,
mintAccounts: {
data: solanaMintAccounts,
isFetching: solanaMintAccountsLoading,
@ -666,7 +491,6 @@ function useGetAvailableTokens(nft: boolean = false) {
}
: lookupChain === CHAIN_ID_TERRA
? {
terraTokenMap: terraTokenMap,
resetAccounts: resetSourceAccounts,
}
: undefined;

View File

@ -0,0 +1,108 @@
import { Connection } from "@solana/web3.js";
import { useLayoutEffect, useState } from "react";
import { DataWrapper } from "../store/helpers";
import { SOLANA_HOST } from "../utils/consts";
import {
decodeMetadata,
getMetadataAddress,
Metadata,
} from "../utils/metaplex";
import { getMultipleAccountsRPC } from "../utils/solana";
const getMetaplexData = async (mintAddresses: string[]) => {
const promises = [];
for (const address of mintAddresses) {
promises.push(getMetadataAddress(address));
}
const metaAddresses = await Promise.all(promises);
const connection = new Connection(SOLANA_HOST, "finalized");
const results = await getMultipleAccountsRPC(
connection,
metaAddresses.map((pair) => pair && pair[0])
);
const output = results.map((account) => {
if (account === null) {
return undefined;
} else {
if (account.data) {
try {
const MetadataParsed = decodeMetadata(account.data);
return MetadataParsed;
} catch (e) {
console.error(e);
return undefined;
}
} else {
return undefined;
}
}
});
return output;
};
const createResultMap = (
addresses: string[],
metadatas: (Metadata | undefined)[]
) => {
const output = new Map<string, Metadata | undefined>();
addresses.forEach((address) => {
const metadata = metadatas.find((x) => x?.mint === address);
if (metadata) {
output.set(address, metadata);
} else {
output.set(address, undefined);
}
});
return output;
};
const useMetaplexData = (
addresses: string[]
): DataWrapper<Map<string, Metadata | undefined> | undefined> => {
const [results, setResults] = useState<
Map<string, Metadata | undefined> | undefined
>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [receivedAt, setReceivedAt] = useState<string | null>(null);
useLayoutEffect(() => {
let cancelled = false;
setIsLoading(true);
getMetaplexData(addresses).then(
(results) => {
if (!cancelled) {
setResults(createResultMap(addresses, results));
setIsLoading(false);
setError("");
setReceivedAt(new Date().toISOString());
}
},
(error) => {
if (!cancelled) {
setResults(undefined);
setIsLoading(false);
setError("Failed to fetch Metaplex data.");
setReceivedAt(new Date().toISOString());
}
}
);
return () => {
cancelled = true;
};
}, [addresses, setResults, setIsLoading, setError]);
return {
data: results,
isFetching: isLoading,
error,
receivedAt,
};
};
export default useMetaplexData;

View File

@ -0,0 +1,47 @@
import { Dispatch } from "@reduxjs/toolkit";
import { ENV, TokenInfo, TokenListProvider } from "@solana/spl-token-registry";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { DataWrapper } from "../store/helpers";
import { selectSolanaTokenMap } from "../store/selectors";
import {
errorSolanaTokenMap,
fetchSolanaTokenMap,
receiveSolanaTokenMap,
} from "../store/tokenSlice";
import { CLUSTER } from "../utils/consts";
const environment = CLUSTER === "testnet" ? ENV.Testnet : ENV.MainnetBeta;
const useSolanaTokenMap = (): DataWrapper<TokenInfo[]> => {
const tokenMap = useSelector(selectSolanaTokenMap);
const dispatch = useDispatch();
const shouldFire =
tokenMap.data === undefined ||
(tokenMap.data === null && !tokenMap.isFetching);
useEffect(() => {
if (shouldFire) {
getSolanaTokenMap(dispatch);
}
}, [dispatch, shouldFire]);
return tokenMap;
};
const getSolanaTokenMap = (dispatch: Dispatch) => {
dispatch(fetchSolanaTokenMap());
new TokenListProvider().resolve().then(
(tokens) => {
const tokenList = tokens.filterByChainId(environment).getList();
dispatch(receiveSolanaTokenMap(tokenList));
},
(error) => {
console.error(error);
dispatch(errorSolanaTokenMap("Failed to retrieve the Solana token map."));
}
);
};
export default useSolanaTokenMap;

View File

@ -0,0 +1,55 @@
import { Dispatch } from "@reduxjs/toolkit";
import axios from "axios";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { DataWrapper } from "../store/helpers";
import { selectTerraTokenMap } from "../store/selectors";
import {
errorTerraTokenMap,
fetchTerraTokenMap,
receiveTerraTokenMap,
} from "../store/tokenSlice";
import { TERRA_TOKEN_METADATA_URL } from "../utils/consts";
export type TerraTokenMetadata = {
protocol: string;
symbol: string;
token: string;
icon: string;
};
export type TerraTokenMap = {
mainnet: {
[address: string]: TerraTokenMetadata;
};
};
const useTerraTokenMap = (): DataWrapper<TerraTokenMap> => {
const terraTokenMap = useSelector(selectTerraTokenMap);
const dispatch = useDispatch();
const shouldFire =
terraTokenMap.data === undefined ||
(terraTokenMap.data === null && !terraTokenMap.isFetching);
useEffect(() => {
if (shouldFire) {
getTerraTokenMap(dispatch);
}
}, [shouldFire, dispatch]);
return terraTokenMap;
};
const getTerraTokenMap = (dispatch: Dispatch) => {
dispatch(fetchTerraTokenMap());
axios.get(TERRA_TOKEN_METADATA_URL).then(
(response) => {
dispatch(receiveTerraTokenMap(response.data as TerraTokenMap));
},
(error) => {
dispatch(errorTerraTokenMap("Failed to retrieve the Terra Token List."));
}
);
};
export default useTerraTokenMap;

View File

@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TokenInfo } from "@solana/spl-token-registry";
import { TerraTokenMap } from "../hooks/useGetSourceParsedTokenAccounts";
import { TerraTokenMap } from "../hooks/useTerraTokenMap";
import {
DataWrapper,
errorDataWrapper,

View File

@ -24,6 +24,9 @@ export interface ParsedTokenAccount {
decimals: number;
uiAmount: number;
uiAmountString: string;
symbol?: string;
name?: string;
logo?: string;
}
export interface Transaction {