diff --git a/bridge_ui/src/App.js b/bridge_ui/src/App.js index a000164c4..d52552f83 100644 --- a/bridge_ui/src/App.js +++ b/bridge_ui/src/App.js @@ -41,6 +41,7 @@ import { useBetaContext } from "./contexts/BetaContext"; import { COLORS } from "./muiTheme"; import { CLUSTER } from "./utils/consts"; import Stats from "./components/Stats"; +import TokenOriginVerifier from "./components/TokenOriginVerifier"; const useStyles = makeStyles((theme) => ({ appBar: { @@ -270,6 +271,9 @@ function App() { + + + diff --git a/bridge_ui/src/components/Attest/index.tsx b/bridge_ui/src/components/Attest/index.tsx index 5a5fa4b97..ba0abbdf9 100644 --- a/bridge_ui/src/components/Attest/index.tsx +++ b/bridge_ui/src/components/Attest/index.tsx @@ -45,7 +45,9 @@ function Attest() { }, [preventNavigation]); return ( - Token Registration + + Token Registration + This form allows you to register a token on a new foreign chain. Tokens must be registered before they can be transferred. diff --git a/bridge_ui/src/components/HeaderText.tsx b/bridge_ui/src/components/HeaderText.tsx index 0beb5e729..9e1ad8bf5 100644 --- a/bridge_ui/src/components/HeaderText.tsx +++ b/bridge_ui/src/components/HeaderText.tsx @@ -25,13 +25,22 @@ const useStyles = makeStyles((theme) => ({ }, })); -export default function HeaderText({ children }: { children: ReactChild }) { +export default function HeaderText({ + children, + white, + small, +}: { + children: ReactChild; + white?: boolean; + small?: boolean; +}) { const classes = useStyles(); return (
{children} diff --git a/bridge_ui/src/components/NFTOriginVerifier.tsx b/bridge_ui/src/components/NFTOriginVerifier.tsx index 8d976c23b..bc0443745 100644 --- a/bridge_ui/src/components/NFTOriginVerifier.tsx +++ b/bridge_ui/src/components/NFTOriginVerifier.tsx @@ -215,7 +215,9 @@ export default function NFTOriginVerifier() { return (
- NFT Origin Verifier + + NFT Origin Verifier + diff --git a/bridge_ui/src/components/TokenOriginVerifier.tsx b/bridge_ui/src/components/TokenOriginVerifier.tsx new file mode 100644 index 000000000..1fd2a2e4c --- /dev/null +++ b/bridge_ui/src/components/TokenOriginVerifier.tsx @@ -0,0 +1,372 @@ +import { + ChainId, + CHAIN_ID_SOLANA, + CHAIN_ID_TERRA, + nativeToHexString, +} from "@certusone/wormhole-sdk"; +import { + Card, + CircularProgress, + Container, + makeStyles, + MenuItem, + TextField, + Typography, +} from "@material-ui/core"; +import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown"; +import { useCallback, useMemo, useState } from "react"; +import { useBetaContext } from "../contexts/BetaContext"; +import useFetchForeignAsset, { + ForeignAssetInfo, +} from "../hooks/useFetchForeignAsset"; +import useIsWalletReady from "../hooks/useIsWalletReady"; +import useMetadata from "../hooks/useMetadata"; +import useOriginalAsset, { OriginalAssetInfo } from "../hooks/useOriginalAsset"; +import { COLORS } from "../muiTheme"; +import { BETA_CHAINS, CHAINS, CHAINS_BY_ID } from "../utils/consts"; +import { isEVMChain } from "../utils/ethereum"; +import HeaderText from "./HeaderText"; +import KeyAndBalance from "./KeyAndBalance"; +import SmartAddress from "./SmartAddress"; +import { RegisterNowButtonCore } from "./Transfer/RegisterNowButton"; + +const useStyles = makeStyles((theme) => ({ + flexBox: { + display: "flex", + width: "100%", + justifyContent: "center", + "& > *": { + margin: theme.spacing(2), + }, + }, + mainCard: { + padding: theme.spacing(2), + backgroundColor: COLORS.nearBlackWithMinorTransparency, + }, + spacer: { + height: theme.spacing(3), + }, + centered: { + textAlign: "center", + }, + arrowIcon: { + margin: "0 auto", + fontSize: "70px", + }, + resultContainer: { + margin: theme.spacing(2), + }, +})); + +function PrimaryAssetInfomation({ + lookupChain, + lookupAsset, + originChain, + originAsset, + showLoader, +}: { + lookupChain: ChainId; + lookupAsset: string; + originChain: ChainId; + originAsset: string; + showLoader: boolean; +}) { + const classes = useStyles(); + const tokenArray = useMemo(() => [originAsset], [originAsset]); + const metadata = useMetadata(originChain, tokenArray); + const nativeContent = ( +
+ {`This is not a Wormhole wrapped token.`} +
+ ); + const wrapped = ( +
+ {`This is wrapped by Wormhole! Here is the original token: `} +
+ {`Chain: ${CHAINS_BY_ID[originChain].name}`} +
+ + {"Token: "} + + +
+
+
+ ); + return lookupChain === originChain ? nativeContent : wrapped; +} + +function SecondaryAssetInformation({ + chainId, + foreignAssetInfo, + originAssetInfo, +}: { + chainId: ChainId; + foreignAssetInfo?: ForeignAssetInfo; + originAssetInfo?: OriginalAssetInfo; +}) { + const classes = useStyles(); + const tokenArray: string[] = useMemo(() => { + //Saved to a variable to help typescript cope + const originAddress = originAssetInfo?.originAddress; + return originAddress && chainId === originAssetInfo?.originChain + ? [originAddress] + : foreignAssetInfo?.address + ? [foreignAssetInfo?.address] + : []; + }, [foreignAssetInfo, originAssetInfo, chainId]); + const metadata = useMetadata(chainId, tokenArray); + //TODO when this is the origin chain + return !originAssetInfo ? null : chainId === originAssetInfo.originChain ? ( +
+ {`Transferring to ${CHAINS_BY_ID[chainId].name} will unwrap the token:`} +
+ +
+
+ ) : !foreignAssetInfo ? null : foreignAssetInfo.doesExist === false ? ( +
+ {`This token has not yet been registered on ${CHAINS_BY_ID[chainId].name}`} + +
+ ) : ( +
+ When bridged, this asset becomes: +
+ +
+
+ ); +} + +export default function TokenOriginVerifier() { + const classes = useStyles(); + const isBeta = useBetaContext(); + + const [primaryLookupChain, setPrimaryLookupChain] = useState(CHAIN_ID_SOLANA); + const [primaryLookupAsset, setPrimaryLookupAsset] = useState(""); + + const [secondaryLookupChain, setSecondaryLookupChain] = + useState(CHAIN_ID_TERRA); + + const primaryLookupChainOptions = useMemo( + () => (isBeta ? CHAINS.filter((x) => !BETA_CHAINS.includes(x.id)) : CHAINS), + [isBeta] + ); + const secondaryLookupChainOptions = useMemo( + () => + isBeta + ? CHAINS.filter( + (x) => !BETA_CHAINS.includes(x.id) && x.id !== primaryLookupChain + ) + : CHAINS.filter((x) => x.id !== primaryLookupChain), + [isBeta, primaryLookupChain] + ); + + const handlePrimaryLookupChainChange = useCallback( + (e) => { + setPrimaryLookupChain(e.target.value); + if (secondaryLookupChain === e.target.value) { + setSecondaryLookupChain( + e.target.value === CHAIN_ID_SOLANA ? CHAIN_ID_TERRA : CHAIN_ID_SOLANA + ); + } + setPrimaryLookupAsset(""); + }, + [secondaryLookupChain] + ); + const handleSecondaryLookupChainChange = useCallback((e) => { + setSecondaryLookupChain(e.target.value); + }, []); + const handlePrimaryLookupAssetChange = useCallback((event) => { + setPrimaryLookupAsset(event.target.value); + }, []); + + const originInfo = useOriginalAsset( + primaryLookupChain, + primaryLookupAsset, + false + ); + const foreignAssetInfo = useFetchForeignAsset( + originInfo.data?.originChain || 1, + originInfo.data?.originAddress || "", + secondaryLookupChain + ); + + const primaryWalletIsActive = !originInfo.data; + const secondaryWalletIsActive = !primaryWalletIsActive; + + const primaryWallet = useIsWalletReady( + primaryLookupChain, + primaryWalletIsActive + ); + const secondaryWallet = useIsWalletReady( + secondaryLookupChain, + secondaryWalletIsActive + ); + + const primaryWalletError = + isEVMChain(primaryLookupChain) && + primaryLookupAsset && + !originInfo.data && + !originInfo.error && + (!primaryWallet.isReady ? primaryWallet.statusMessage : ""); + const originError = originInfo.error; + const primaryError = primaryWalletError || originError; + + const secondaryWalletError = + isEVMChain(secondaryLookupChain) && + originInfo.data?.originAddress && + originInfo.data?.originChain && + !foreignAssetInfo.data && + (!secondaryWallet.isReady ? secondaryWallet.statusMessage : ""); + const foreignError = foreignAssetInfo.error; + const secondaryError = secondaryWalletError || foreignError; + + const primaryContent = ( + <> + Source Information + + Enter a token from any supported chain to get started. + +
+ + {primaryLookupChainOptions.map(({ id, name }) => ( + + {name} + + ))} + + +
+ {isEVMChain(primaryLookupChain) ? ( + + ) : null} + {primaryError ? ( + {primaryError} + ) : null} +
+ {originInfo.isFetching ? ( + + ) : originInfo.data?.originChain && originInfo.data.originAddress ? ( + + ) : null} +
+ + ); + + const secondaryContent = originInfo.data ? ( + <> + Bridge Results + + Select a chain to see the result of bridging this token. + +
+ + {secondaryLookupChainOptions.map(({ id, name }) => ( + + {name} + + ))} + +
+ {isEVMChain(secondaryLookupChain) ? ( + + ) : null} + {secondaryError ? ( + {secondaryError} + ) : null} +
+ {foreignAssetInfo.isFetching ? ( + + ) : originInfo.data?.originChain && originInfo.data.originAddress ? ( + + ) : null} +
+ + ) : null; + + const content = ( +
+ + + Token Origin Verifier + +
+ + + {primaryContent} + {secondaryContent ? ( + <> +
+ +
+ {secondaryContent} + + ) : null} +
+
+ ); + + return content; +} diff --git a/bridge_ui/src/components/Transfer/RegisterNowButton.tsx b/bridge_ui/src/components/Transfer/RegisterNowButton.tsx index c7cab1844..7f191aa9c 100644 --- a/bridge_ui/src/components/Transfer/RegisterNowButton.tsx +++ b/bridge_ui/src/components/Transfer/RegisterNowButton.tsx @@ -14,14 +14,19 @@ import { selectTransferOriginChain, selectTransferTargetChain, } from "../../store/selectors"; -import { hexToNativeString } from "@certusone/wormhole-sdk"; +import { ChainId, hexToNativeString } from "@certusone/wormhole-sdk"; -export default function RegisterNowButton() { +export function RegisterNowButtonCore({ + originChain, + originAsset, + targetChain, +}: { + originChain: ChainId | undefined; + originAsset: string | undefined; + targetChain: ChainId; +}) { const dispatch = useDispatch(); const history = useHistory(); - const originChain = useSelector(selectTransferOriginChain); - const originAsset = useSelector(selectTransferOriginAsset); - const targetChain = useSelector(selectTransferTargetChain); // user might be in the middle of a different attest const signedVAAHex = useSelector(selectAttestSignedVAAHex); const canSwitch = originChain && originAsset && !signedVAAHex; @@ -48,3 +53,16 @@ export default function RegisterNowButton() { ); } + +export default function RegisterNowButton() { + const originChain = useSelector(selectTransferOriginChain); + const originAsset = useSelector(selectTransferOriginAsset); + const targetChain = useSelector(selectTransferTargetChain); + return ( + + ); +} diff --git a/bridge_ui/src/components/Transfer/Source.tsx b/bridge_ui/src/components/Transfer/Source.tsx index 7cccecb77..a04dbb6bc 100644 --- a/bridge_ui/src/components/Transfer/Source.tsx +++ b/bridge_ui/src/components/Transfer/Source.tsx @@ -5,6 +5,8 @@ import { } from "@certusone/wormhole-sdk"; import { getAddress } from "@ethersproject/address"; import { Button, makeStyles } from "@material-ui/core"; +import { Link } from "react-router-dom"; +import { VerifiedUser } from "@material-ui/icons"; import { useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useHistory } from "react-router"; @@ -106,7 +108,21 @@ function Source() { return ( <> - Select tokens to send through the Wormhole Token Bridge. +
+ Select tokens to send through the Wormhole Bridge. +
+
+ +
+
> { - const { isReady } = useIsWalletReady(chainId); + const { isReady } = useIsWalletReady(chainId, false); const { provider } = useEthereumProvider(); const [isFetching, setIsFetching] = useState(false); diff --git a/bridge_ui/src/hooks/useFetchForeignAsset.ts b/bridge_ui/src/hooks/useFetchForeignAsset.ts index 504352e27..a620beac5 100644 --- a/bridge_ui/src/hooks/useFetchForeignAsset.ts +++ b/bridge_ui/src/hooks/useFetchForeignAsset.ts @@ -10,7 +10,7 @@ import { import { Connection } from "@solana/web3.js"; import { LCDClient } from "@terra-money/terra.js"; import { ethers } from "ethers"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useEthereumProvider } from "../contexts/EthereumProviderContext"; import { DataWrapper } from "../store/helpers"; import { @@ -35,112 +35,156 @@ function useFetchForeignAsset( foreignChain: ChainId ): DataWrapper { const { provider, chainId: evmChainId } = useEthereumProvider(); - const { isReady, statusMessage } = useIsWalletReady(foreignChain); + const { isReady } = useIsWalletReady(foreignChain, false); const correctEvmNetwork = getEvmChainId(foreignChain); const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork; const [assetAddress, setAssetAddress] = useState(null); - const [doesExist, setDoesExist] = useState(false); + const [doesExist, setDoesExist] = useState(null); const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); - const originAssetHex = useMemo( - () => nativeToHexString(originAsset, originChain), - [originAsset, originChain] - ); + const originAssetHex = useMemo(() => { + try { + return nativeToHexString(originAsset, originChain); + } catch (e) { + return null; + } + }, [originAsset, originChain]); + const [previousArgs, setPreviousArgs] = useState<{ + originChain: ChainId; + originAsset: string; + foreignChain: ChainId; + } | null>(null); + const argsEqual = + !!previousArgs && + previousArgs.originChain === originChain && + previousArgs.originAsset === originAsset && + previousArgs.foreignChain === foreignChain; + const setArgs = useCallback(() => { + setPreviousArgs({ foreignChain, originChain, originAsset }); + }, [foreignChain, originChain, originAsset]); const argumentError = useMemo( () => + !originChain || + !originAsset || !foreignChain || !originAssetHex || foreignChain === originChain || (isEVMChain(foreignChain) && !isReady) || - (isEVMChain(foreignChain) && !hasCorrectEvmNetwork), - [isReady, foreignChain, originChain, hasCorrectEvmNetwork, originAssetHex] + (isEVMChain(foreignChain) && !hasCorrectEvmNetwork) || + argsEqual, + [ + isReady, + foreignChain, + originAsset, + originChain, + hasCorrectEvmNetwork, + originAssetHex, + argsEqual, + ] ); useEffect(() => { + if (!argsEqual) { + setAssetAddress(null); + setError(""); + setDoesExist(null); + setPreviousArgs(null); + } if (argumentError || !originAssetHex) { return; } let cancelled = false; setIsLoading(true); - setAssetAddress(null); - setError(""); - setDoesExist(false); - const getterFunc: () => Promise = isEVMChain(foreignChain) - ? () => - getForeignAssetEth( - getTokenBridgeAddressForChain(foreignChain), - provider as any, //why does this typecheck work elsewhere? - originChain, - hexToUint8Array(originAssetHex) - ) - : foreignChain === CHAIN_ID_TERRA - ? () => { - const lcd = new LCDClient(TERRA_HOST); - return getForeignAssetTerra( - TERRA_TOKEN_BRIDGE_ADDRESS, - lcd, - originChain, - hexToUint8Array(originAssetHex) - ); - } - : () => { - const connection = new Connection(SOLANA_HOST, "confirmed"); - return getForeignAssetSolana( - connection, - SOL_TOKEN_BRIDGE_ADDRESS, - originChain, - hexToUint8Array(originAssetHex) - ); - }; - - const promise = getterFunc(); - - promise - .then((result) => { - if (!cancelled) { - if ( - result && - !( - isEVMChain(foreignChain) && - result === ethers.constants.AddressZero + try { + const getterFunc: () => Promise = isEVMChain(foreignChain) + ? () => + getForeignAssetEth( + getTokenBridgeAddressForChain(foreignChain), + provider as any, //why does this typecheck work elsewhere? + originChain, + hexToUint8Array(originAssetHex) ) - ) { - setDoesExist(true); - setIsLoading(false); - setAssetAddress(result); - } else { - setDoesExist(false); - setIsLoading(false); - setAssetAddress(null); + : foreignChain === CHAIN_ID_TERRA + ? () => { + const lcd = new LCDClient(TERRA_HOST); + return getForeignAssetTerra( + TERRA_TOKEN_BRIDGE_ADDRESS, + lcd, + originChain, + hexToUint8Array(originAssetHex) + ); } - } - }) - .catch((e) => { - if (!cancelled) { - setError("Could not retrieve the foreign asset."); - setIsLoading(false); - } - }); - }, [argumentError, foreignChain, originAssetHex, originChain, provider]); + : () => { + const connection = new Connection(SOLANA_HOST, "confirmed"); + return getForeignAssetSolana( + connection, + SOL_TOKEN_BRIDGE_ADDRESS, + originChain, + hexToUint8Array(originAssetHex) + ); + }; + + getterFunc() + .then((result) => { + if (!cancelled) { + if ( + result && + !( + isEVMChain(foreignChain) && + result === ethers.constants.AddressZero + ) + ) { + setArgs(); + setDoesExist(true); + setIsLoading(false); + setAssetAddress(result); + } else { + setArgs(); + setDoesExist(false); + setIsLoading(false); + setAssetAddress(null); + } + } + }) + .catch((e) => { + if (!cancelled) { + setError("Could not retrieve the foreign asset."); + setIsLoading(false); + } + }); + } catch (e) { + //This catch mostly just detects poorly formatted addresses + if (!cancelled) { + setError("Could not retrieve the foreign asset."); + setIsLoading(false); + } + } + }, [ + argumentError, + foreignChain, + originAssetHex, + originChain, + provider, + setArgs, + argsEqual, + ]); const compoundError = useMemo(() => { - return error - ? error - : !isReady - ? statusMessage - : argumentError - ? "Invalid arguments." - : ""; - }, [error, isReady, statusMessage, argumentError]); + return error ? error : ""; + }, [error]); //now swallows wallet errors const output: DataWrapper = useMemo( () => ({ error: compoundError, isFetching: isLoading, - data: { address: assetAddress, doesExist }, + data: + (assetAddress !== null && assetAddress !== undefined) || + (doesExist !== null && doesExist !== undefined) + ? { address: assetAddress, doesExist: !!doesExist } + : null, receivedAt: null, }), [compoundError, isLoading, assetAddress, doesExist] diff --git a/bridge_ui/src/hooks/useFetchTargetAsset.ts b/bridge_ui/src/hooks/useFetchTargetAsset.ts index 176ee912f..cd2cd5685 100644 --- a/bridge_ui/src/hooks/useFetchTargetAsset.ts +++ b/bridge_ui/src/hooks/useFetchTargetAsset.ts @@ -1,4 +1,5 @@ import { + ChainId, CHAIN_ID_SOLANA, CHAIN_ID_TERRA, getForeignAssetEth, @@ -16,7 +17,7 @@ import { arrayify } from "@ethersproject/bytes"; import { Connection } from "@solana/web3.js"; import { LCDClient } from "@terra-money/terra.js"; import { ethers } from "ethers"; -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useEthereumProvider } from "../contexts/EthereumProviderContext"; import { @@ -76,10 +77,47 @@ function useFetchTargetAsset(nft?: boolean) { const isRecovery = useSelector( nft ? selectNFTIsRecovery : selectTransferIsRecovery ); + const [lastSuccessfulArgs, setLastSuccessfulArgs] = useState<{ + isSourceAssetWormholeWrapped: boolean | undefined; + originChain: ChainId | undefined; + originAsset: string | undefined; + targetChain: ChainId; + nft?: boolean; + tokenId?: string; + } | null>(null); + const argsMatchLastSuccess = + !!lastSuccessfulArgs && + lastSuccessfulArgs.isSourceAssetWormholeWrapped === + isSourceAssetWormholeWrapped && + lastSuccessfulArgs.originChain === originChain && + lastSuccessfulArgs.originAsset === originAsset && + lastSuccessfulArgs.targetChain === targetChain && + lastSuccessfulArgs.nft === nft && + lastSuccessfulArgs.tokenId === tokenId; + const setArgs = useCallback( + () => + setLastSuccessfulArgs({ + isSourceAssetWormholeWrapped, + originChain, + originAsset, + targetChain, + nft, + tokenId, + }), + [ + isSourceAssetWormholeWrapped, + originChain, + originAsset, + targetChain, + nft, + tokenId, + ] + ); useEffect(() => { - if (isRecovery) { + if (isRecovery || argsMatchLastSuccess) { return; } + setLastSuccessfulArgs(null); if (isSourceAssetWormholeWrapped && originChain === targetChain) { dispatch( setTargetAsset( @@ -89,6 +127,7 @@ function useFetchTargetAsset(nft?: boolean) { }) ) ); + setArgs(); return; } let cancelled = false; @@ -124,6 +163,7 @@ function useFetchTargetAsset(nft?: boolean) { }) ) ); + setArgs(); } } catch (e) { if (!cancelled) { @@ -160,6 +200,7 @@ function useFetchTargetAsset(nft?: boolean) { receiveDataWrapper({ doesExist: !!asset, address: asset }) ) ); + setArgs(); } } catch (e) { if (!cancelled) { @@ -189,6 +230,7 @@ function useFetchTargetAsset(nft?: boolean) { receiveDataWrapper({ doesExist: !!asset, address: asset }) ) ); + setArgs(); } } catch (e) { if (!cancelled) { @@ -218,6 +260,8 @@ function useFetchTargetAsset(nft?: boolean) { setTargetAsset, tokenId, hasCorrectEvmNetwork, + argsMatchLastSuccess, + setArgs, ]); } diff --git a/bridge_ui/src/hooks/useIsWalletReady.ts b/bridge_ui/src/hooks/useIsWalletReady.ts index 4ee0e8c34..4b5d4dd5f 100644 --- a/bridge_ui/src/hooks/useIsWalletReady.ts +++ b/bridge_ui/src/hooks/useIsWalletReady.ts @@ -5,7 +5,7 @@ import { } from "@certusone/wormhole-sdk"; import { hexlify, hexStripZeros } from "@ethersproject/bytes"; import { useConnectedWallet } from "@terra-money/wallet-provider"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useEthereumProvider } from "../contexts/EthereumProviderContext"; import { useSolanaWallet } from "../contexts/SolanaWalletContext"; import { CLUSTER, getEvmChainId } from "../utils/consts"; @@ -14,18 +14,25 @@ import { isEVMChain } from "../utils/ethereum"; const createWalletStatus = ( isReady: boolean, statusMessage: string = "", + forceNetworkSwitch: () => void, walletAddress?: string ) => ({ isReady, statusMessage, + forceNetworkSwitch, walletAddress, }); -function useIsWalletReady(chainId: ChainId): { +function useIsWalletReady( + chainId: ChainId, + enableNetworkAutoswitch: boolean = true +): { isReady: boolean; statusMessage: string; walletAddress?: string; + forceNetworkSwitch: () => void; } { + const autoSwitch = enableNetworkAutoswitch; const solanaWallet = useSolanaWallet(); const solPK = solanaWallet?.publicKey; const terraWallet = useConnectedWallet(); @@ -39,6 +46,19 @@ function useIsWalletReady(chainId: ChainId): { const correctEvmNetwork = getEvmChainId(chainId); const hasCorrectEvmNetwork = evmChainId === correctEvmNetwork; + const forceNetworkSwitch = useCallback(() => { + if (provider && correctEvmNetwork) { + if (!isEVMChain(chainId)) { + return; + } + try { + provider.send("wallet_switchEthereumChain", [ + { chainId: hexStripZeros(hexlify(correctEvmNetwork)) }, + ]); + } catch (e) {} + } + }, [provider, correctEvmNetwork, chainId]); + return useMemo(() => { if ( chainId === CHAIN_ID_TERRA && @@ -46,33 +66,52 @@ function useIsWalletReady(chainId: ChainId): { terraWallet?.walletAddress ) { // TODO: terraWallet does not update on wallet changes - return createWalletStatus(true, undefined, terraWallet.walletAddress); + return createWalletStatus( + true, + undefined, + forceNetworkSwitch, + terraWallet.walletAddress + ); } if (chainId === CHAIN_ID_SOLANA && solPK) { - return createWalletStatus(true, undefined, solPK.toString()); + return createWalletStatus( + true, + undefined, + forceNetworkSwitch, + solPK.toString() + ); } if (isEVMChain(chainId) && hasEthInfo && signerAddress) { if (hasCorrectEvmNetwork) { - return createWalletStatus(true, undefined, signerAddress); + return createWalletStatus( + true, + undefined, + forceNetworkSwitch, + signerAddress + ); } else { - if (provider && correctEvmNetwork) { - try { - provider.send("wallet_switchEthereumChain", [ - { chainId: hexStripZeros(hexlify(correctEvmNetwork)) }, - ]); - } catch (e) {} + if (provider && correctEvmNetwork && autoSwitch) { + forceNetworkSwitch(); } return createWalletStatus( false, `Wallet is not connected to ${CLUSTER}. Expected Chain ID: ${correctEvmNetwork}`, + forceNetworkSwitch, undefined ); } } - //TODO bsc - return createWalletStatus(false, "Wallet not connected"); + + return createWalletStatus( + false, + "Wallet not connected", + forceNetworkSwitch, + undefined + ); }, [ chainId, + autoSwitch, + forceNetworkSwitch, hasTerraWallet, solPK, hasEthInfo, diff --git a/bridge_ui/src/hooks/useOriginalAsset.ts b/bridge_ui/src/hooks/useOriginalAsset.ts new file mode 100644 index 000000000..5e7f23db4 --- /dev/null +++ b/bridge_ui/src/hooks/useOriginalAsset.ts @@ -0,0 +1,254 @@ +import { + ChainId, + CHAIN_ID_SOLANA, + CHAIN_ID_TERRA, + getOriginalAssetEth, + getOriginalAssetSol, + getOriginalAssetTerra, + hexToNativeString, + uint8ArrayToHex, + uint8ArrayToNative, +} from "@certusone/wormhole-sdk"; +import { + getOriginalAssetEth as getOriginalAssetEthNFT, + getOriginalAssetSol as getOriginalAssetSolNFT, + WormholeWrappedNFTInfo, +} from "@certusone/wormhole-sdk/lib/nft_bridge"; +import { Web3Provider } from "@certusone/wormhole-sdk/node_modules/@ethersproject/providers"; +import { ethers } from "@certusone/wormhole-sdk/node_modules/ethers"; +import { Connection } from "@solana/web3.js"; +import { LCDClient } from "@terra-money/terra.js"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Provider, + useEthereumProvider, +} from "../contexts/EthereumProviderContext"; +import { DataWrapper } from "../store/helpers"; +import { + getNFTBridgeAddressForChain, + getTokenBridgeAddressForChain, + SOLANA_HOST, + SOLANA_SYSTEM_PROGRAM_ADDRESS, + SOL_NFT_BRIDGE_ADDRESS, + SOL_TOKEN_BRIDGE_ADDRESS, + TERRA_HOST, +} from "../utils/consts"; +import { isEVMChain } from "../utils/ethereum"; +import useIsWalletReady from "./useIsWalletReady"; + +export type OriginalAssetInfo = { + originChain: ChainId | null; + originAddress: string | null; + originTokenId: string | null; +}; + +export async function getOriginalAssetToken( + foreignChain: ChainId, + foreignNativeStringAddress: string, + provider?: Web3Provider +) { + let promise = null; + try { + if (isEVMChain(foreignChain) && provider) { + promise = await getOriginalAssetEth( + getTokenBridgeAddressForChain(foreignChain), + provider, + foreignNativeStringAddress, + foreignChain + ); + } else if (foreignChain === CHAIN_ID_SOLANA) { + const connection = new Connection(SOLANA_HOST, "confirmed"); + promise = await getOriginalAssetSol( + connection, + SOL_TOKEN_BRIDGE_ADDRESS, + foreignNativeStringAddress + ); + } else if (foreignChain === CHAIN_ID_TERRA) { + const lcd = new LCDClient(TERRA_HOST); + promise = await getOriginalAssetTerra(lcd, foreignNativeStringAddress); + } + } catch (e) { + promise = Promise.reject("Invalid foreign arguments."); + } + if (!promise) { + promise = Promise.reject("Invalid foreign arguments."); + } + return promise; +} + +export async function getOriginalAssetNFT( + foreignChain: ChainId, + foreignNativeStringAddress: string, + tokenId?: string, + provider?: Provider +) { + let promise = null; + try { + if (isEVMChain(foreignChain) && provider && tokenId) { + promise = getOriginalAssetEthNFT( + getNFTBridgeAddressForChain(foreignChain), + provider, + foreignNativeStringAddress, + tokenId, + foreignChain + ); + } else if (foreignChain === CHAIN_ID_SOLANA) { + const connection = new Connection(SOLANA_HOST, "confirmed"); + promise = getOriginalAssetSolNFT( + connection, + SOL_NFT_BRIDGE_ADDRESS, + foreignNativeStringAddress + ); + } + } catch (e) { + promise = Promise.reject("Invalid foreign arguments."); + } + if (!promise) { + promise = Promise.reject("Invalid foreign arguments."); + } + return promise; +} + +//TODO refactor useCheckIfWormholeWrapped to use this function, and probably move to SDK +export async function getOriginalAsset( + foreignChain: ChainId, + foreignNativeStringAddress: string, + nft: boolean, + tokenId?: string, + provider?: Provider +): Promise { + const result = nft + ? await getOriginalAssetNFT( + foreignChain, + foreignNativeStringAddress, + tokenId, + provider + ) + : await getOriginalAssetToken( + foreignChain, + foreignNativeStringAddress, + provider + ); + + if ( + isEVMChain(result.chainId) && + uint8ArrayToNative(result.assetAddress, result.chainId) === + ethers.constants.AddressZero + ) { + throw new Error("Unable to find address."); + } + if ( + result.chainId === CHAIN_ID_SOLANA && + uint8ArrayToNative(result.assetAddress, result.chainId) === + SOLANA_SYSTEM_PROGRAM_ADDRESS + ) { + throw new Error("Unable to find address."); + } + + return result; +} + +//This potentially returns the same chain as the foreign chain, in the case where the asset is native +function useOriginalAsset( + foreignChain: ChainId, + foreignAddress: string, + nft: boolean, + tokenId?: string +): DataWrapper { + const { provider } = useEthereumProvider(); + const { isReady } = useIsWalletReady(foreignChain, false); + const [originAddress, setOriginAddress] = useState(null); + const [originTokenId, setOriginTokenId] = useState(null); + const [originChain, setOriginChain] = useState(null); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [previousArgs, setPreviousArgs] = useState<{ + foreignChain: ChainId; + foreignAddress: string; + nft: boolean; + tokenId?: string; + } | null>(null); + const argsEqual = + !!previousArgs && + previousArgs.foreignChain === foreignChain && + previousArgs.foreignAddress === foreignAddress && + previousArgs.nft === nft && + previousArgs.tokenId === tokenId; + const setArgs = useCallback( + () => setPreviousArgs({ foreignChain, foreignAddress, nft, tokenId }), + [foreignChain, foreignAddress, nft, tokenId] + ); + + const argumentError = useMemo( + () => + !foreignChain || + !foreignAddress || + (isEVMChain(foreignChain) && !isReady) || + (isEVMChain(foreignChain) && nft && !tokenId) || + argsEqual, + [isReady, nft, tokenId, argsEqual, foreignChain, foreignAddress] + ); + + useEffect(() => { + if (!argsEqual) { + setError(""); + setOriginAddress(null); + setOriginTokenId(null); + setOriginChain(null); + setPreviousArgs(null); + } + if (argumentError) { + return; + } + let cancelled = false; + setIsLoading(true); + + getOriginalAsset(foreignChain, foreignAddress, nft, tokenId, provider) + .then((result) => { + if (!cancelled) { + setIsLoading(false); + setArgs(); + setOriginAddress( + hexToNativeString( + uint8ArrayToHex(result.assetAddress), + result.chainId + ) || null + ); + setOriginTokenId(result.tokenId || null); + setOriginChain(result.chainId); + } + }) + .catch((e) => { + if (!cancelled) { + setIsLoading(false); + setError("Unable to determine original asset."); + } + }); + }, [ + foreignChain, + foreignAddress, + nft, + provider, + setArgs, + argumentError, + tokenId, + argsEqual, + ]); + + const output: DataWrapper = useMemo( + () => ({ + error: error, + isFetching: isLoading, + data: + originChain || originAddress || originTokenId + ? { originChain, originAddress, originTokenId } + : null, + receivedAt: null, + }), + [isLoading, originAddress, originChain, originTokenId, error] + ); + + return output; +} + +export default useOriginalAsset; diff --git a/bridge_ui/src/muiTheme.js b/bridge_ui/src/muiTheme.js index 28882b85d..a6aac41d6 100644 --- a/bridge_ui/src/muiTheme.js +++ b/bridge_ui/src/muiTheme.js @@ -44,7 +44,7 @@ export const theme = responsiveFontSizes( fontWeight: "200", }, h2: { - fontWeight: "300", + fontWeight: "200", }, h4: { fontWeight: "500", diff --git a/bridge_ui/src/utils/consts.ts b/bridge_ui/src/utils/consts.ts index 29dd41841..2d993af4c 100644 --- a/bridge_ui/src/utils/consts.ts +++ b/bridge_ui/src/utils/consts.ts @@ -661,3 +661,5 @@ export const MULTI_CHAIN_TOKENS: { export const AVAILABLE_MARKETS_URL = "https://docs.wormholenetwork.com/wormhole/overview-liquid-markets"; + +export const SOLANA_SYSTEM_PROGRAM_ADDRESS = "11111111111111111111111111111111"; diff --git a/sdk/js/CHANGELOG.md b/sdk/js/CHANGELOG.md index a397d14ef..ea2c98a50 100644 --- a/sdk/js/CHANGELOG.md +++ b/sdk/js/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.10 + +uint8ArrayToNative utility function for converting to native addresses from the uint8 format + ## 0.0.9 ### Added diff --git a/sdk/js/src/utils/array.ts b/sdk/js/src/utils/array.ts index 53bcb1564..3e8e21e13 100644 --- a/sdk/js/src/utils/array.ts +++ b/sdk/js/src/utils/array.ts @@ -67,3 +67,6 @@ export const nativeToHexString = ( return null; } }; + +export const uint8ArrayToNative = (a: Uint8Array, chainId: ChainId) => + hexToNativeString(uint8ArrayToHex(a), chainId);