diff --git a/bridge_ui/src/components/Attest/Source.tsx b/bridge_ui/src/components/Attest/Source.tsx index 1105d5c98..f9a63cdaf 100644 --- a/bridge_ui/src/components/Attest/Source.tsx +++ b/bridge_ui/src/components/Attest/Source.tsx @@ -15,6 +15,7 @@ import { import { CHAINS } from "../../utils/consts"; import ButtonWithLoader from "../ButtonWithLoader"; import KeyAndBalance from "../KeyAndBalance"; +import LowBalanceWarning from "../LowBalanceWarning"; const useStyles = makeStyles((theme) => ({ transferField: { @@ -68,6 +69,7 @@ function Source() { onChange={handleAssetChange} disabled={shouldLockFields} /> + ({ + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, +})); function Target() { + const classes = useStyles(); const dispatch = useDispatch(); const sourceChain = useSelector(selectAttestSourceChain); const chains = useMemo( @@ -47,6 +57,11 @@ function Target() { ))} + + You will have to pay transaction fees on{" "} + {CHAINS_BY_ID[targetChain].name} to attest this token. + + ({ + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, +})); + +function LowBalanceWarning({ chainId }: { chainId: ChainId }) { + const classes = useStyles(); + const { isReady } = useIsWalletReady(chainId); + const transactionFeeWarning = useTransactionFees(chainId); + const displayWarning = + isReady && + transactionFeeWarning.balanceString && + transactionFeeWarning.isSufficientBalance === false; + const warningMessage = `This wallet has a very low ${ + chainId === CHAIN_ID_SOLANA ? "SOL" : chainId === CHAIN_ID_ETH ? "ETH" : "" + } balance and may not be able to pay for the upcoming transaction fees.`; + + const content = ( + + {warningMessage} + + {"Current balance: " + transactionFeeWarning.balanceString} + + + ); + + return displayWarning ? content : null; +} + +export default LowBalanceWarning; diff --git a/bridge_ui/src/components/Migration/Workflow.tsx b/bridge_ui/src/components/Migration/Workflow.tsx index 875e06346..663fb4997 100644 --- a/bridge_ui/src/components/Migration/Workflow.tsx +++ b/bridge_ui/src/components/Migration/Workflow.tsx @@ -29,6 +29,7 @@ import { signSendAndConfirm, } from "../../utils/solana"; import ButtonWithLoader from "../ButtonWithLoader"; +import LowBalanceWarning from "../LowBalanceWarning"; import ShowTx from "../ShowTx"; import SolanaCreateAssociatedAddress, { useAssociatedAccountExistsState, @@ -402,6 +403,7 @@ export default function Workflow({ + {fromTokenAccount && toTokenAccount && fromTokenAccountBalance ? ( <> diff --git a/bridge_ui/src/components/NFT/Source.tsx b/bridge_ui/src/components/NFT/Source.tsx index d83624745..3c336dc0c 100644 --- a/bridge_ui/src/components/NFT/Source.tsx +++ b/bridge_ui/src/components/NFT/Source.tsx @@ -18,6 +18,7 @@ import KeyAndBalance from "../KeyAndBalance"; import StepDescription from "../StepDescription"; import { TokenSelector } from "../TokenSelectors/SourceTokenSelector"; import { Alert } from "@material-ui/lab"; +import LowBalanceWarning from "../LowBalanceWarning"; const useStyles = makeStyles((theme) => ({ transferField: { @@ -89,6 +90,7 @@ function Source({ ) : null} + ({ transferField: { marginTop: theme.spacing(5), }, + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, })); function Target() { @@ -104,6 +110,11 @@ function Target() { ) : null} ) : null} + + You will have to pay transaction fees on{" "} + {CHAINS_BY_ID[targetChain].name} to redeem your NFT. + + + {hasParsedTokenAccount ? ( ({ transferField: { marginTop: theme.spacing(5), }, + alert: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, })); function Target() { @@ -105,6 +111,11 @@ function Target() { value={targetAsset || ""} disabled={true} /> + + You will have to pay transaction fees on{" "} + {CHAINS_BY_ID[targetChain].name} to redeem your tokens. + + (undefined); const [covalentLoading, setCovalentLoading] = useState(false); diff --git a/bridge_ui/src/hooks/useTransactionFees.ts b/bridge_ui/src/hooks/useTransactionFees.ts new file mode 100644 index 000000000..e36e69332 --- /dev/null +++ b/bridge_ui/src/hooks/useTransactionFees.ts @@ -0,0 +1,133 @@ +import { + ChainId, + CHAIN_ID_ETH, + CHAIN_ID_SOLANA, + CHAIN_ID_TERRA, +} from "@certusone/wormhole-sdk"; +import { Provider } from "@certusone/wormhole-sdk/node_modules/@ethersproject/abstract-provider"; +import { formatUnits } from "@ethersproject/units"; +import { Connection, PublicKey } from "@solana/web3.js"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEthereumProvider } from "../contexts/EthereumProviderContext"; +import { SOLANA_HOST } from "../utils/consts"; +import { getMultipleAccountsRPC } from "../utils/solana"; +import useIsWalletReady from "./useIsWalletReady"; + +//It's difficult to project how many fees the user will accrue during the +//workflow, as a variable number of transactions can be sent, and different +//execution paths can be hit in the smart contracts, altering gas used. +//As such, for the moment it is best to just check for a reasonable 'low balance' threshold. +//Still it would be good to calculate a reasonable value at runtime based off current gas prices, +//rather than a hardcoded value. +const SOLANA_THRESHOLD_LAMPORTS: bigint = BigInt(300000); +const ETHEREUM_THRESHOLD_WEI: bigint = BigInt(35000000000000000); + +const isSufficientBalance = (chainId: ChainId, balance: bigint | undefined) => { + if (balance === undefined || !chainId) { + return true; + } + if (CHAIN_ID_SOLANA === chainId) { + return balance > SOLANA_THRESHOLD_LAMPORTS; + } + if (CHAIN_ID_ETH === chainId) { + return balance > ETHEREUM_THRESHOLD_WEI; + } + if (CHAIN_ID_TERRA === chainId) { + //Terra is complicated because the fees can be paid in multiple currencies. + return true; + } + + return true; +}; + +//TODO move to more generic location +const getBalanceSolana = async (walletAddress: string) => { + const connection = new Connection(SOLANA_HOST); + return getMultipleAccountsRPC(connection, [ + new PublicKey(walletAddress), + ]).then( + (results) => { + if (results.length && results[0]) { + return BigInt(results[0].lamports); + } + }, + (error) => { + return BigInt(0); + } + ); +}; + +const getBalanceEth = async (walletAddress: string, provider: Provider) => { + return provider.getBalance(walletAddress).then((result) => result.toBigInt()); +}; + +const toBalanceString = (balance: bigint | undefined, chainId: ChainId) => { + if (!chainId || balance === undefined) { + return ""; + } + if (chainId === CHAIN_ID_ETH) { + return formatUnits(balance, 18); //wei decimals + } else if (chainId === CHAIN_ID_SOLANA) { + return formatUnits(balance, 9); //lamports to sol decmals + } else return ""; +}; + +export default function useTransactionFees(chainId: ChainId) { + const { walletAddress, isReady } = useIsWalletReady(chainId); + const { provider } = useEthereumProvider(); + const [balance, setBalance] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const loadStart = useCallback(() => { + setBalance(undefined); + setIsLoading(true); + setError(""); + }, []); + + useEffect(() => { + if (chainId === CHAIN_ID_SOLANA && isReady && walletAddress) { + loadStart(); + getBalanceSolana(walletAddress).then( + (result) => { + const adjustedresult = + result === undefined || result === null ? BigInt(0) : result; + setIsLoading(false); + setBalance(adjustedresult); + }, + (error) => { + setIsLoading(false); + setError("Cannot load wallet balance"); + } + ); + } else if (chainId === CHAIN_ID_ETH && isReady && walletAddress) { + if (provider) { + loadStart(); + getBalanceEth(walletAddress, provider).then( + (result) => { + const adjustedresult = + result === undefined || result === null ? BigInt(0) : result; + setIsLoading(false); + setBalance(adjustedresult); + }, + (error) => { + setIsLoading(false); + setError("Cannot load wallet balance"); + } + ); + } + } + }, [provider, walletAddress, isReady, chainId, loadStart]); + + const results = useMemo(() => { + return { + isSufficientBalance: isSufficientBalance(chainId, balance), + balance, + balanceString: toBalanceString(balance, chainId), + isLoading, + error, + }; + }, [balance, chainId, isLoading, error]); + + return results; +}