From b6771f291dea6afa42e3ffe7ff59da0ebbe9e76a Mon Sep 17 00:00:00 2001 From: chase-45 Date: Tue, 31 Aug 2021 20:38:27 -0400 Subject: [PATCH] bridge_ui: ethereum token selector utilizes covalent Change-Id: I2f9fdebb9e80c414281005c2659ba47c7ef4b75d --- bridge_ui/package-lock.json | 59 ++++--- bridge_ui/package.json | 1 + .../EthereumSourceTokenSelector.tsx | 145 +++++++++++++++- .../TokenSelectors/SourceTokenSelector.tsx | 7 +- .../hooks/useGetSourceParsedTokenAccounts.ts | 161 ++++++++++++------ bridge_ui/src/utils/consts.ts | 18 ++ 6 files changed, 304 insertions(+), 87 deletions(-) diff --git a/bridge_ui/package-lock.json b/bridge_ui/package-lock.json index 714207664..3061c7161 100644 --- a/bridge_ui/package-lock.json +++ b/bridge_ui/package-lock.json @@ -31,6 +31,7 @@ "@solana/wallet-base": "^0.0.1", "@solana/web3.js": "^1.24.1", "@terra-money/wallet-provider": "^1.4.0-alpha.1", + "axios": "^0.21.1", "bech32": "^1.1.4", "bn.js": "^5.1.3", "borsh": "^0.4.0", @@ -65,6 +66,7 @@ "@solana/web3.js": "^1.24.0", "@terra-money/terra.js": "^1.8.10", "@terra-money/wallet-provider": "^1.2.4", + "bech32": "^2.0.0", "js-base64": "^3.6.1", "protobufjs": "^6.11.2", "rxjs": "^7.3.0" @@ -6402,14 +6404,6 @@ "node": ">=12" } }, - "node_modules/@terra-money/terra.js/node_modules/axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", - "dependencies": { - "follow-redirects": "^1.10.0" - } - }, "node_modules/@terra-money/terra.js/node_modules/bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", @@ -8818,6 +8812,17 @@ "secp256k1": "^4.0.1" } }, + "node_modules/@zondax/filecoin-signing-tools/node_modules/axios": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", + "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", + "deprecated": "Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410", + "dev": true, + "optional": true, + "dependencies": { + "follow-redirects": "^1.10.0" + } + }, "node_modules/@zxing/text-encoding": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", @@ -9983,12 +9988,9 @@ } }, "node_modules/axios": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", - "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", - "deprecated": "Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410", - "dev": true, - "optional": true, + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", "dependencies": { "follow-redirects": "^1.10.0" } @@ -41073,6 +41075,7 @@ "@types/long": "^4.0.1", "@types/node": "^16.6.1", "@types/react": "^17.0.19", + "bech32": "^2.0.0", "copy-dir": "^1.3.0", "ethers": "^5.4.4", "js-base64": "^3.6.1", @@ -44375,14 +44378,6 @@ "ws": "^7.4.2" }, "dependencies": { - "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", - "requires": { - "follow-redirects": "^1.10.0" - } - }, "bech32": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", @@ -46618,6 +46613,18 @@ "ipld-dag-cbor": "^0.17.0", "leb128": "0.0.5", "secp256k1": "^4.0.1" + }, + "dependencies": { + "axios": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", + "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", + "dev": true, + "optional": true, + "requires": { + "follow-redirects": "^1.10.0" + } + } } }, "@zxing/text-encoding": { @@ -47517,11 +47524,9 @@ "integrity": "sha512-3WVgVPs/7OnKU3s+lqMtkv3wQlg3WxK1YifmpJSDO0E1aPBrZWlrrTO6cxRqCXLuX2aYgCljqXIQd0VnRidV0g==" }, "axios": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", - "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", - "dev": true, - "optional": true, + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", "requires": { "follow-redirects": "^1.10.0" } diff --git a/bridge_ui/package.json b/bridge_ui/package.json index fd3dd29b1..c6778bb6c 100644 --- a/bridge_ui/package.json +++ b/bridge_ui/package.json @@ -25,6 +25,7 @@ "@solana/wallet-base": "^0.0.1", "@solana/web3.js": "^1.24.1", "@terra-money/wallet-provider": "^1.4.0-alpha.1", + "axios": "^0.21.1", "bech32": "^1.1.4", "bn.js": "^5.1.3", "borsh": "^0.4.0", diff --git a/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx index 27e72f560..3719e1508 100644 --- a/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/EthereumSourceTokenSelector.tsx @@ -1,23 +1,75 @@ -import { Button, TextField, Typography } from "@material-ui/core"; +import { + Button, + CircularProgress, + createStyles, + makeStyles, + TextField, + Typography, +} from "@material-ui/core"; +import { Autocomplete, createFilterOptions } from "@material-ui/lab"; import React, { useCallback, useEffect, useState } from "react"; import { useEthereumProvider } from "../../contexts/EthereumProviderContext"; +import { CovalentData } from "../../hooks/useGetSourceParsedTokenAccounts"; +import { DataWrapper } from "../../store/helpers"; import { ParsedTokenAccount } from "../../store/transferSlice"; import { ethTokenToParsedTokenAccount, getEthereumToken, isValidEthereumAddress, } from "../../utils/ethereum"; +import { shortenAddress } from "../../utils/solana"; + +const useStyles = makeStyles(() => + createStyles({ + selectInput: { minWidth: "10rem" }, + tokenOverviewContainer: { + display: "flex", + "& div": { + margin: ".5rem", + }, + }, + tokenImage: { + maxHeight: "2.5rem", //Eyeballing this based off the text size + }, + }) +); type EthereumSourceTokenSelectorProps = { value: ParsedTokenAccount | null; onChange: (newValue: ParsedTokenAccount | null) => void; + covalent: DataWrapper | undefined; + tokenAccounts: DataWrapper | undefined; +}; + +const renderAccount = ( + account: ParsedTokenAccount, + covalentData: CovalentData | undefined, + classes: any +) => { + const mintPrettyString = shortenAddress(account.mintKey); + const uri = covalentData?.logo_url; + const symbol = covalentData?.contract_ticker_symbol || "Unknown"; + return ( +
+
+ {uri && } +
+
+ {symbol} +
+
+ {mintPrettyString} +
+
+ ); }; export default function EthereumSourceTokenSelector( props: EthereumSourceTokenSelectorProps ) { - const { onChange, value } = props; - const advancedMode = true; //const [advancedMode, setAdvancedMode] = useState(true); + const { value, onChange, covalent, tokenAccounts } = props; + const classes = useStyles(); + const [advancedMode, setAdvancedMode] = useState(false); const [advancedModeLoading, setAdvancedModeLoading] = useState(false); const [advancedModeSymbol, setAdvancedModeSymbol] = useState(""); const [advancedModeHolderString, setAdvancedModeHolderString] = useState(""); @@ -91,8 +143,6 @@ export default function EthereumSourceTokenSelector( onChange, ]); - const symbolString = advancedModeSymbol ? advancedModeSymbol + " " : ""; - const handleClick = useCallback(() => { onChange(null); setAdvancedModeHolderString(""); @@ -103,16 +153,88 @@ export default function EthereumSourceTokenSelector( [] ); + const getSymbol = (account: ParsedTokenAccount | null) => { + if (!account) { + return undefined; + } + return covalent?.data?.find((x) => x.contract_address === account.mintKey); + }; + + const filterConfig = createFilterOptions({ + matchFrom: "any", + stringify: (option: ParsedTokenAccount) => { + const symbol = getSymbol(option) + " " || ""; + const mint = option.mintKey + " "; + + return symbol + mint; + }, + }); + + const toggleAdvancedMode = () => { + setAdvancedMode(!advancedMode); + }; + + const isLoading = + props.covalent?.isFetching || props.tokenAccounts?.isFetching; + + const symbolString = advancedModeSymbol + ? advancedModeSymbol + " " + : getSymbol(value) + ? getSymbol(value)?.contract_ticker_symbol + " " + : ""; + + const autoComplete = ( + { + onChange(newValue); + }} + noOptionsText={"No ERC20 tokens found at the moment."} + options={tokenAccounts?.data || []} + renderInput={(params) => ( + + )} + renderOption={(option) => { + return renderAccount( + option, + covalent?.data?.find((x) => x.contract_address === option.mintKey), + classes + ); + }} + getOptionLabel={(option) => { + const symbol = getSymbol(option); + return `${symbol ? symbol : "Unknown"} (Account: ${shortenAddress( + option.publicKey + )}, Address: ${shortenAddress(option.mintKey)})`; + }} + /> + ); + + const advancedModeToggleButton = ( + + ); + const content = value ? ( <> {symbolString + value.mintKey} - ) : ( + ) : isLoading ? ( + + ) : advancedMode ? ( <> + ) : ( + autoComplete ); - return {content}; + return ( + + {content} + {!value && !isLoading && advancedModeToggleButton} + + ); } diff --git a/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx b/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx index 2100ae3ac..1e15d7baf 100644 --- a/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx +++ b/bridge_ui/src/components/TokenSelectors/SourceTokenSelector.tsx @@ -43,7 +43,10 @@ export const TokenSelector = (props: TokenSelectorProps) => { const maps = useGetSourceParsedTokens(); //This is only for errors so bad that we shouldn't even mount the component - const fatalError = maps?.tokenAccounts?.error; + const fatalError = + maps?.tokenAccounts?.error && + !(lookupChain === CHAIN_ID_ETH) && + !(lookupChain === CHAIN_ID_TERRA); //Terra & ETH can proceed because it has advanced mode const content = fatalError ? ( {fatalError} @@ -59,6 +62,8 @@ export const TokenSelector = (props: TokenSelectorProps) => { ) : lookupChain === CHAIN_ID_TERRA ? ( { + return { + publicKey: walletAddress, + mintKey: covalent.contract_address, + amount: covalent.balance, + decimals: covalent.contract_decimals, + uiAmount: Number(formatUnits(covalent.balance, covalent.contract_decimals)), + uiAmountString: formatUnits(covalent.balance, covalent.contract_decimals), + }; +}; + +export type CovalentData = { + contract_decimals: number; + contract_ticker_symbol: string; + contract_name: string; + contract_address: string; + logo_url: string | undefined; + balance: string; + quote: number | undefined; + quote_rate: number | undefined; +}; + +const getEthereumAccountsCovalent = async ( + walletAddress: string +): Promise => { + const url = COVALENT_GET_TOKENS_URL(CHAIN_ID_ETH, walletAddress); + + try { + const output = [] as CovalentData[]; + const response = await axios.get(url); + const tokens = response.data.data.items; + + if (tokens instanceof Array && tokens.length) { + for (const item of tokens) { + if ( + item.contract_decimals && + item.contract_ticker_symbol && + item.contract_address && + item.balance && + item.supports_erc?.includes("erc20") + ) { + output.push({ ...item } as CovalentData); + } + } + } + + return output; + } catch (error) { + console.error(error); + return Promise.reject("Unable to retrieve your Ethereum Tokens."); + } +}; + const environment = process.env.REACT_APP_CLUSTER === "testnet" ? ENV.Testnet : ENV.MainnetBeta; @@ -166,10 +218,16 @@ function useGetAvailableTokens() { //const terraWallet = useConnectedWallet(); //TODO const { provider, signerAddress } = useEthereumProvider(); - const [metaplex, setMetaplex] = useState(undefined as any); + const [metaplex, setMetaplex] = useState(undefined); const [metaplexLoading, setMetaplexLoading] = useState(false); const [metaplexError, setMetaplexError] = useState(null); + const [covalent, setCovalent] = useState(undefined); + const [covalentLoading, setCovalentLoading] = useState(false); + const [covalentError, setCovalentError] = useState( + undefined + ); + // Solana metaplex load useEffect(() => { let cancelled = false; @@ -234,51 +292,42 @@ function useGetAvailableTokens() { //Ethereum accounts load //TODO actual load from covalent. This is just a hardcoded testing load useEffect(() => { + //const testWallet = "0xf60c2ea62edbfe808163751dd0d8693dcb30019c"; let cancelled = false; - if (lookupChain === CHAIN_ID_ETH && provider && signerAddress) { - const token = TokenImplementation__factory.connect( - ETH_TEST_TOKEN_ADDRESS, - provider - ); - token - .decimals() - .then((decimals) => { - token.balanceOf(signerAddress).then((n) => { - if (!cancelled) { - dispatch( - setSourceParsedTokenAccount( - createParsedTokenAccount( - signerAddress, - ETH_TEST_TOKEN_ADDRESS, - n.toString(), - decimals, - Number(formatUnits(n, decimals)), - formatUnits(n, decimals) - ) - ) - ); - dispatch( - receiveSourceParsedTokenAccounts([ - createParsedTokenAccount( - signerAddress, - ETH_TEST_TOKEN_ADDRESS, - n.toString(), - decimals, - Number(formatUnits(n, decimals)), - formatUnits(n, decimals) - ), - ]) - ); - } - }); - }) - .catch((e) => { - if (!cancelled) { - // TODO: error state - console.error(e); - } - }); + const walletAddress = signerAddress; + if (!walletAddress) { + return; } + //TODO less cancel + !cancelled && setCovalentLoading(true); + !cancelled && dispatch(fetchSourceParsedTokenAccounts()); + getEthereumAccountsCovalent(walletAddress).then( + (accounts) => { + !cancelled && setCovalentLoading(false); + !cancelled && setCovalentError(undefined); + !cancelled && setCovalent(accounts); + !cancelled && + dispatch( + receiveSourceParsedTokenAccounts( + accounts.map((x) => + createParsedTokenAccountFromCovalent(walletAddress, x) + ) + ) + ); + }, + () => { + !cancelled && + dispatch( + errorSourceParsedTokenAccounts( + "Cannot load your Ethereum tokens at the moment." + ) + ); + !cancelled && + setCovalentError("Cannot load your Ethereum tokens at the moment."); + !cancelled && setCovalentLoading(false); + } + ); + return () => { cancelled = true; }; @@ -301,6 +350,16 @@ function useGetAvailableTokens() { receivedAt: null, //TODO } as DataWrapper, } + : lookupChain === CHAIN_ID_ETH + ? { + tokenAccounts: tokenAccounts, + covalent: { + data: covalent, + isFetching: covalentLoading, + error: covalentError, + receivedAt: null, //TODO + }, + } : undefined; } diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index b0b1a9787..c112169e2 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -93,3 +93,21 @@ export const TERRA_BRIDGE_ADDRESS = "terra18vd8fpwxzck93qlwghaj6arh4p7c5n896xzem5"; export const TERRA_TOKEN_BRIDGE_ADDRESS = "terra10pyejy66429refv3g35g2t7am0was7ya7kz2a4"; + +export const COVALENT_API_KEY = process.env.REACT_APP_COVALENT_API_KEY + ? process.env.REACT_APP_COVALENT_API_KEY + : ""; + +export const COVALENT_GET_TOKENS_URL = ( + chainId: ChainId, + walletAddress: string +) => { + let chainNum = ""; + if (chainId === CHAIN_ID_ETH) { + chainNum = COVALENT_ETHEREUM_MAINNET; + } + + return `https://api.covalenthq.com/v1/${chainNum}/address/${walletAddress}/balances_v2/?key=${COVALENT_API_KEY}`; +}; + +export const COVALENT_ETHEREUM_MAINNET = "1";