From 0dc9f28bfd3fb2cc0b9534c0b1262559e1ad3b52 Mon Sep 17 00:00:00 2001 From: Chase Moran Date: Tue, 14 Sep 2021 00:45:26 -0400 Subject: [PATCH] approve unlimited functionality in eth Change-Id: Id268205f540cad6b3936570c650e209fe0220339 --- bridge_ui/src/components/Transfer/Send.tsx | 118 ++++++++++++++++-- .../Transfer/WaitingForWalletMessage.tsx | 13 +- bridge_ui/src/hooks/useAllowance.ts | 98 +++++++++++++++ bridge_ui/src/store/selectors.ts | 2 + bridge_ui/src/store/transferSlice.ts | 6 + sdk/js/src/token_bridge/transfer.ts | 29 +++-- 6 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 bridge_ui/src/hooks/useAllowance.ts diff --git a/bridge_ui/src/components/Transfer/Send.tsx b/bridge_ui/src/components/Transfer/Send.tsx index 913b6189..750e4f2e 100644 --- a/bridge_ui/src/components/Transfer/Send.tsx +++ b/bridge_ui/src/components/Transfer/Send.tsx @@ -1,10 +1,19 @@ +import { CHAIN_ID_ETH } from "@certusone/wormhole-sdk"; +import { Checkbox, FormControlLabel } from "@material-ui/core"; import { Alert } from "@material-ui/lab"; +import { ethers } from "ethers"; +import { parseUnits } from "ethers/lib/utils"; +import { useCallback, useMemo, useState } from "react"; import { useSelector } from "react-redux"; +import useAllowance from "../../hooks/useAllowance"; import { useHandleTransfer } from "../../hooks/useHandleTransfer"; import useIsWalletReady from "../../hooks/useIsWalletReady"; import { selectSourceWalletAddress, + selectTransferAmount, + selectTransferSourceAsset, selectTransferSourceChain, + selectTransferSourceParsedTokenAccount, selectTransferTargetError, } from "../../store/selectors"; import { CHAINS_BY_ID } from "../../utils/consts"; @@ -16,8 +25,25 @@ import WaitingForWalletMessage from "./WaitingForWalletMessage"; function Send() { const { handleClick, disabled, showLoader } = useHandleTransfer(); + const sourceChain = useSelector(selectTransferSourceChain); + const sourceAsset = useSelector(selectTransferSourceAsset); + const sourceAmount = useSelector(selectTransferAmount); + const sourceDecimals = useSelector( + selectTransferSourceParsedTokenAccount + )?.decimals; + const sourceAmountParsed = + sourceDecimals !== undefined && + sourceDecimals !== null && + sourceAmount && + parseUnits(sourceAmount, sourceDecimals).toBigInt(); + const oneParsed = + sourceDecimals !== undefined && + sourceDecimals !== null && + parseUnits("1", sourceDecimals).toBigInt(); + const error = useSelector(selectTransferTargetError); + const [allowanceError, setAllowanceError] = useState(""); const { isReady, statusMessage, walletAddress } = useIsWalletReady(sourceChain); const sourceWalletAddress = useSelector(selectSourceWalletAddress); @@ -26,10 +52,55 @@ function Send() { sourceWalletAddress && walletAddress && sourceWalletAddress !== walletAddress; - const isDisabled = !isReady || isWrongWallet || disabled; + const [shouldApproveUnlimited, setShouldApproveUnlimited] = useState(false); + const toggleShouldApproveUnlimited = useCallback( + () => setShouldApproveUnlimited(!shouldApproveUnlimited), + [shouldApproveUnlimited] + ); + + const { + sufficientAllowance, + isAllowanceFetching, + isApproveProcessing, + approveAmount, + } = useAllowance(sourceChain, sourceAsset, sourceAmountParsed || undefined); + + const approveButtonNeeded = + sourceChain === CHAIN_ID_ETH && !sufficientAllowance; + const notOne = shouldApproveUnlimited || sourceAmountParsed !== oneParsed; + const isDisabled = + !isReady || + isWrongWallet || + disabled || + isAllowanceFetching || + isApproveProcessing; const errorMessage = isWrongWallet ? "A different wallet is connected than in Step 1." - : statusMessage || error || undefined; + : statusMessage || error || allowanceError || undefined; + + const approveExactAmount = useMemo(() => { + return () => { + setAllowanceError(""); + approveAmount(BigInt(sourceAmountParsed)).then( + () => { + setAllowanceError(""); + }, + (error) => setAllowanceError("Failed to approve the token transfer.") + ); + }; + }, [approveAmount, sourceAmountParsed]); + const approveUnlimited = useMemo(() => { + return () => { + setAllowanceError(""); + approveAmount(ethers.constants.MaxUint256.toBigInt()).then( + () => { + setAllowanceError(""); + }, + (error) => setAllowanceError("Failed to approve the token transfer.") + ); + }; + }, [approveAmount]); + return ( <> @@ -42,14 +113,41 @@ function Send() { completing Step 4, you will have to perform the recovery workflow to complete the transfer. - - Transfer - + {approveButtonNeeded ? ( + <> + + } + label="Approve Unlimited Tokens" + /> + + {"Approve " + + (shouldApproveUnlimited ? "Unlimited" : sourceAmount) + + ` Token${notOne ? "s" : ""}`} + + + ) : ( + + Transfer + + )} diff --git a/bridge_ui/src/components/Transfer/WaitingForWalletMessage.tsx b/bridge_ui/src/components/Transfer/WaitingForWalletMessage.tsx index c60c193c..7f37b9ad 100644 --- a/bridge_ui/src/components/Transfer/WaitingForWalletMessage.tsx +++ b/bridge_ui/src/components/Transfer/WaitingForWalletMessage.tsx @@ -1,11 +1,11 @@ -import { CHAIN_ID_ETH, CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; -import { Typography, makeStyles } from "@material-ui/core"; +import { CHAIN_ID_SOLANA } from "@certusone/wormhole-sdk"; +import { makeStyles, Typography } from "@material-ui/core"; import { useSelector } from "react-redux"; import { + selectTransferIsApproving, selectTransferIsRedeeming, selectTransferIsSending, selectTransferRedeemTx, - selectTransferSourceChain, selectTransferTargetChain, selectTransferTransferTx, } from "../../store/selectors"; @@ -22,20 +22,19 @@ const WAITING_FOR_WALLET = "Waiting for wallet approval (likely in a popup)..."; export default function WaitingForWalletMessage() { const classes = useStyles(); - const sourceChain = useSelector(selectTransferSourceChain); + const isApproving = useSelector(selectTransferIsApproving); const isSending = useSelector(selectTransferIsSending); const transferTx = useSelector(selectTransferTransferTx); const targetChain = useSelector(selectTransferTargetChain); const isRedeeming = useSelector(selectTransferIsRedeeming); const redeemTx = useSelector(selectTransferRedeemTx); - const showWarning = (isSending && !transferTx) || (isRedeeming && !redeemTx); + const showWarning = + isApproving || (isSending && !transferTx) || (isRedeeming && !redeemTx); return showWarning ? ( {WAITING_FOR_WALLET}{" "} {targetChain === CHAIN_ID_SOLANA && isRedeeming ? "Note: there will be several transactions" - : sourceChain === CHAIN_ID_ETH && isSending - ? "Note: there will be two transactions" : null} ) : null; diff --git a/bridge_ui/src/hooks/useAllowance.ts b/bridge_ui/src/hooks/useAllowance.ts new file mode 100644 index 00000000..a5e2a10a --- /dev/null +++ b/bridge_ui/src/hooks/useAllowance.ts @@ -0,0 +1,98 @@ +import { + approveEth, + ChainId, + CHAIN_ID_ETH, + getAllowanceEth, +} from "@certusone/wormhole-sdk"; +import { BigNumber } from "ethers"; +import { useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useEthereumProvider } from "../contexts/EthereumProviderContext"; +import { selectTransferIsApproving } from "../store/selectors"; +import { setIsApproving } from "../store/transferSlice"; +import { ETH_TOKEN_BRIDGE_ADDRESS } from "../utils/consts"; + +export default function useAllowance( + chainId: ChainId, + tokenAddress?: string, + transferAmount?: BigInt +) { + const dispatch = useDispatch(); + const [allowance, setAllowance] = useState(null); + const [isAllowanceFetching, setIsAllowanceFetching] = useState(false); + const isApproveProcessing = useSelector(selectTransferIsApproving); + const { signer } = useEthereumProvider(); + const sufficientAllowance = + chainId !== CHAIN_ID_ETH || + (allowance && transferAmount && allowance >= transferAmount); + + useEffect(() => { + let cancelled = false; + if ( + chainId === CHAIN_ID_ETH && + tokenAddress && + signer && + !isApproveProcessing + ) { + setIsAllowanceFetching(true); + getAllowanceEth(ETH_TOKEN_BRIDGE_ADDRESS, tokenAddress, signer).then( + (result) => { + if (!cancelled) { + setIsAllowanceFetching(false); + setAllowance(result.toBigInt()); + } + }, + (error) => { + if (!cancelled) { + setIsAllowanceFetching(false); + //setError("Unable to retrieve allowance"); //TODO set an error + } + } + ); + } + + return () => { + cancelled = true; + }; + }, [chainId, tokenAddress, signer, isApproveProcessing]); + + const approveAmount: (amount: BigInt) => Promise = useMemo(() => { + return chainId !== CHAIN_ID_ETH || !tokenAddress || !signer + ? (amount: BigInt) => { + return Promise.resolve(); + } + : (amount: BigInt) => { + dispatch(setIsApproving(true)); + return approveEth( + ETH_TOKEN_BRIDGE_ADDRESS, + tokenAddress, + signer, + BigNumber.from(amount) + ).then( + () => { + dispatch(setIsApproving(false)); + return Promise.resolve(); + }, + () => { + dispatch(setIsApproving(false)); + return Promise.reject(); + } + ); + }; + }, [chainId, tokenAddress, signer, dispatch]); + + return useMemo( + () => ({ + sufficientAllowance, + approveAmount, + isAllowanceFetching, + isApproveProcessing, + }), + [ + sufficientAllowance, + approveAmount, + isAllowanceFetching, + isApproveProcessing, + ] + ); +} diff --git a/bridge_ui/src/store/selectors.ts b/bridge_ui/src/store/selectors.ts index d00eff72..e9d433f6 100644 --- a/bridge_ui/src/store/selectors.ts +++ b/bridge_ui/src/store/selectors.ts @@ -181,6 +181,8 @@ export const selectTransferIsRedeeming = (state: RootState) => state.transfer.isRedeeming; export const selectTransferRedeemTx = (state: RootState) => state.transfer.redeemTx; +export const selectTransferIsApproving = (state: RootState) => + state.transfer.isApproving; export const selectTransferSourceError = ( state: RootState ): string | undefined => { diff --git a/bridge_ui/src/store/transferSlice.ts b/bridge_ui/src/store/transferSlice.ts index 86c05258..9cb6293f 100644 --- a/bridge_ui/src/store/transferSlice.ts +++ b/bridge_ui/src/store/transferSlice.ts @@ -53,6 +53,7 @@ export interface TransferState { isSending: boolean; isRedeeming: boolean; redeemTx: Transaction | undefined; + isApproving: boolean; } const initialState: TransferState = { @@ -74,6 +75,7 @@ const initialState: TransferState = { isSending: false, isRedeeming: false, redeemTx: undefined, + isApproving: false, }; export const transferSlice = createSlice({ @@ -203,6 +205,9 @@ export const transferSlice = createSlice({ state.redeemTx = action.payload; state.isRedeeming = false; }, + setIsApproving: (state, action: PayloadAction) => { + state.isApproving = action.payload; + }, reset: (state) => ({ ...initialState, sourceChain: state.sourceChain, @@ -233,6 +238,7 @@ export const { setIsSending, setIsRedeeming, setRedeemTx, + setIsApproving, reset, } = transferSlice.actions; diff --git a/sdk/js/src/token_bridge/transfer.ts b/sdk/js/src/token_bridge/transfer.ts index 80be64f4..938dbb15 100644 --- a/sdk/js/src/token_bridge/transfer.ts +++ b/sdk/js/src/token_bridge/transfer.ts @@ -9,6 +9,28 @@ import { import { getBridgeFeeIx, ixFromRust } from "../solana"; import { ChainId, CHAIN_ID_SOLANA, createNonce } from "../utils"; +export async function getAllowanceEth( + tokenBridgeAddress: string, + tokenAddress: string, + signer: ethers.Signer +) { + const token = TokenImplementation__factory.connect(tokenAddress, signer); + const signerAddress = await signer.getAddress(); + const allowance = await token.allowance(signerAddress, tokenBridgeAddress); + + return allowance; +} + +export async function approveEth( + tokenBridgeAddress: string, + tokenAddress: string, + signer: ethers.Signer, + amount: ethers.BigNumberish +) { + const token = TokenImplementation__factory.connect(tokenAddress, signer); + return await (await token.approve(tokenBridgeAddress, amount)).wait(); +} + export async function transferFromEth( tokenBridgeAddress: string, signer: ethers.Signer, @@ -19,13 +41,6 @@ export async function transferFromEth( ) { //TODO: should we check if token attestation exists on the target chain const token = TokenImplementation__factory.connect(tokenAddress, signer); - //TODO: implement / separate allowance check - // const signerAddress = await signer.getAddress(); - // const allowance = await token.allowance( - // signerAddress, - // tokenBridgeAddress - // ); - await (await token.approve(tokenBridgeAddress, amount)).wait(); const fee = 0; // for now, this won't do anything, we may add later const bridge = Bridge__factory.connect(tokenBridgeAddress, signer); const v = await bridge.transferTokens(