bridge_ui: ethereum token selector utilizes covalent

Change-Id: I2f9fdebb9e80c414281005c2659ba47c7ef4b75d
This commit is contained in:
chase-45 2021-08-31 20:38:27 -04:00
parent 49d41733a7
commit b6771f291d
6 changed files with 304 additions and 87 deletions

View File

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

View File

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

View File

@ -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<CovalentData[]> | undefined;
tokenAccounts: DataWrapper<ParsedTokenAccount[]> | 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 (
<div className={classes.tokenOverviewContainer}>
<div>
{uri && <img alt="" className={classes.tokenImage} src={uri} />}
</div>
<div>
<Typography variant="subtitle1">{symbol}</Typography>
</div>
<div>
<Typography variant="body1">{mintPrettyString}</Typography>
</div>
</div>
);
};
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 = (
<Autocomplete
autoComplete
autoHighlight
autoSelect
blurOnSelect
clearOnBlur
fullWidth={false}
filterOptions={filterConfig}
value={value}
onChange={(event, newValue) => {
onChange(newValue);
}}
noOptionsText={"No ERC20 tokens found at the moment."}
options={tokenAccounts?.data || []}
renderInput={(params) => (
<TextField {...params} label="Token Account" variant="outlined" />
)}
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 = (
<Button onClick={toggleAdvancedMode}>
{advancedMode ? "Toggle Token Picker" : "Toggle Override"}
</Button>
);
const content = value ? (
<>
<Typography>{symbolString + value.mintKey}</Typography>
<Button onClick={handleClick}>Clear</Button>
</>
) : (
) : isLoading ? (
<CircularProgress />
) : advancedMode ? (
<>
<TextField
fullWidth
label="Asset Address"
label="Enter an asset address"
value={advancedModeHolderString}
onChange={handleOnChange}
error={
@ -124,7 +246,14 @@ export default function EthereumSourceTokenSelector(
disabled={advancedModeLoading}
/>
</>
) : (
autoComplete
);
return <React.Fragment>{content}</React.Fragment>;
return (
<React.Fragment>
{content}
{!value && !isLoading && advancedModeToggleButton}
</React.Fragment>
);
}

View File

@ -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 ? (
<Typography>{fatalError}</Typography>
@ -59,6 +62,8 @@ export const TokenSelector = (props: TokenSelectorProps) => {
<EthereumSourceTokenSelector
value={sourceParsedTokenAccount || null}
onChange={handleSolanaOnChange}
covalent={maps?.covalent || undefined}
tokenAccounts={maps?.tokenAccounts} //TODO standardize
/>
) : lookupChain === CHAIN_ID_TERRA ? (
<TerraSourceTokenSelector

View File

@ -1,8 +1,4 @@
import {
CHAIN_ID_ETH,
CHAIN_ID_SOLANA,
TokenImplementation__factory,
} from "@certusone/wormhole-sdk";
import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } 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";
@ -12,6 +8,7 @@ import {
ParsedAccountData,
PublicKey,
} from "@solana/web3.js";
import axios from "axios";
import { formatUnits } from "ethers/lib/utils";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
@ -33,9 +30,8 @@ import {
fetchSourceParsedTokenAccounts,
ParsedTokenAccount,
receiveSourceParsedTokenAccounts,
setSourceParsedTokenAccount,
} from "../store/transferSlice";
import { ETH_TEST_TOKEN_ADDRESS, SOLANA_HOST } from "../utils/consts";
import { COVALENT_GET_TOKENS_URL, SOLANA_HOST } from "../utils/consts";
import {
decodeMetadata,
getMetadataAddress,
@ -75,6 +71,62 @@ const createParsedTokenAccountFromInfo = (
};
};
const createParsedTokenAccountFromCovalent = (
walletAddress: string,
covalent: CovalentData
): ParsedTokenAccount => {
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<CovalentData[]> => {
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<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>(
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<Metadata[]>,
}
: lookupChain === CHAIN_ID_ETH
? {
tokenAccounts: tokenAccounts,
covalent: {
data: covalent,
isFetching: covalentLoading,
error: covalentError,
receivedAt: null, //TODO
},
}
: undefined;
}

View File

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