diff --git a/src/components/earn/StakingModal.tsx b/src/components/earn/StakingModal.tsx index 2cc80ae376..92c34d56c3 100644 --- a/src/components/earn/StakingModal.tsx +++ b/src/components/earn/StakingModal.tsx @@ -3,12 +3,12 @@ import { Trans } from '@lingui/macro' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { Pair } from '@uniswap/v2-sdk' import useActiveWeb3React from 'hooks/useActiveWeb3React' +import { useV2LiquidityTokenPermit } from 'hooks/useV2LiquidityTokenPermit' import { useCallback, useState } from 'react' import styled from 'styled-components/macro' import { ApprovalState, useApproveCallback } from '../../hooks/useApproveCallback' import { usePairContract, useStakingContract, useV2RouterContract } from '../../hooks/useContract' -import { useV2LiquidityTokenPermit } from '../../hooks/useERC20Permit' import useTransactionDeadline from '../../hooks/useTransactionDeadline' import { StakingInfo, useDerivedStakeInfo } from '../../state/stake/hooks' import { TransactionType } from '../../state/transactions/actions' diff --git a/src/hooks/useERC20Permit.ts b/src/hooks/useERC20Permit.ts index f406dc8407..d8ccfe5773 100644 --- a/src/hooks/useERC20Permit.ts +++ b/src/hooks/useERC20Permit.ts @@ -1,6 +1,7 @@ +import { BigNumber } from '@ethersproject/bignumber' import { splitSignature } from '@ethersproject/bytes' import { Trade } from '@uniswap/router-sdk' -import { Currency, CurrencyAmount, Percent, Token, TradeType } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, Percent, TradeType } from '@uniswap/sdk-core' import { Trade as V2Trade } from '@uniswap/v2-sdk' import { Trade as V3Trade } from '@uniswap/v3-sdk' import useActiveWeb3React from 'hooks/useActiveWeb3React' @@ -12,9 +13,8 @@ import { SWAP_ROUTER_ADDRESSES, V3_ROUTER_ADDRESS } from '../constants/addresses import { DAI, UNI, USDC } from '../constants/tokens' import { useEIP2612Contract } from './useContract' import useIsArgentWallet from './useIsArgentWallet' -import useTransactionDeadline from './useTransactionDeadline' -enum PermitType { +export enum PermitType { AMOUNT = 1, ALLOWED = 2, } @@ -22,7 +22,7 @@ enum PermitType { // 20 minutes to submit after signing const PERMIT_VALIDITY_BUFFER = 20 * 60 -interface PermitInfo { +export interface PermitInfo { type: PermitType name: string // version is optional, and if omitted, will not be included in the domain @@ -116,9 +116,10 @@ const PERMIT_ALLOWED_TYPE = [ { name: 'allowed', type: 'bool' }, ] -function useERC20Permit( +export function useERC20Permit( currencyAmount: CurrencyAmount | null | undefined, spender: string | null | undefined, + transactionDeadline: BigNumber | undefined, overridePermitInfo: PermitInfo | undefined | null ): { signatureData: SignatureData | null @@ -126,7 +127,6 @@ function useERC20Permit( gatherPermitSignature: null | (() => Promise) } { const { account, chainId, library } = useActiveWeb3React() - const transactionDeadline = useTransactionDeadline() const tokenAddress = currencyAmount?.currency?.isToken ? currencyAmount.currency.address : undefined const eip2612Contract = useEIP2612Contract(tokenAddress) const isArgentWallet = useIsArgentWallet() @@ -259,26 +259,14 @@ function useERC20Permit( ]) } -const REMOVE_V2_LIQUIDITY_PERMIT_INFO: PermitInfo = { - version: '1', - name: 'Uniswap V2', - type: PermitType.AMOUNT, -} - -export function useV2LiquidityTokenPermit( - liquidityAmount: CurrencyAmount | null | undefined, - spender: string | null | undefined -) { - return useERC20Permit(liquidityAmount, spender, REMOVE_V2_LIQUIDITY_PERMIT_INFO) -} - export function useERC20PermitFromTrade( trade: | V2Trade | V3Trade | Trade | undefined, - allowedSlippage: Percent + allowedSlippage: Percent, + transactionDeadline: BigNumber | undefined ) { const { chainId } = useActiveWeb3React() const swapRouterAddress = chainId @@ -294,5 +282,5 @@ export function useERC20PermitFromTrade( [trade, allowedSlippage] ) - return useERC20Permit(amountToApprove, swapRouterAddress, null) + return useERC20Permit(amountToApprove, swapRouterAddress, transactionDeadline, null) } diff --git a/src/hooks/useSwapCallArguments.tsx b/src/hooks/useSwapCallArguments.tsx new file mode 100644 index 0000000000..dcb3c3d0b5 --- /dev/null +++ b/src/hooks/useSwapCallArguments.tsx @@ -0,0 +1,181 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { SwapRouter, Trade } from '@uniswap/router-sdk' +import { Currency, Percent, TradeType } from '@uniswap/sdk-core' +import { Router as V2SwapRouter, Trade as V2Trade } from '@uniswap/v2-sdk' +import { SwapRouter as V3SwapRouter, Trade as V3Trade } from '@uniswap/v3-sdk' +import { SWAP_ROUTER_ADDRESSES, V3_ROUTER_ADDRESS } from 'constants/addresses' +import useActiveWeb3React from 'hooks/useActiveWeb3React' +import { useMemo } from 'react' +import approveAmountCalldata from 'utils/approveAmountCalldata' + +import { useArgentWalletContract } from './useArgentWalletContract' +import { useV2RouterContract } from './useContract' +import useENS from './useENS' +import { SignatureData } from './useERC20Permit' + +export type AnyTrade = + | V2Trade + | V3Trade + | Trade + +interface SwapCall { + address: string + calldata: string + value: string +} + +/** + * Returns the swap calls that can be used to make the trade + * @param trade trade to execute + * @param allowedSlippage user allowed slippage + * @param recipientAddressOrName the ENS name or address of the recipient of the swap output + * @param signatureData the signature data of the permit of the input token amount, if available + */ +export function useSwapCallArguments( + trade: AnyTrade | undefined, + allowedSlippage: Percent, + recipientAddressOrName: string | null | undefined, + signatureData: SignatureData | null | undefined, + deadline: BigNumber | undefined +): SwapCall[] { + const { account, chainId, library } = useActiveWeb3React() + + const { address: recipientAddress } = useENS(recipientAddressOrName) + const recipient = recipientAddressOrName === null ? account : recipientAddress + const routerContract = useV2RouterContract() + const argentWalletContract = useArgentWalletContract() + + return useMemo(() => { + if (!trade || !recipient || !library || !account || !chainId || !deadline) return [] + + if (trade instanceof V2Trade) { + if (!routerContract) return [] + const swapMethods = [] + + swapMethods.push( + V2SwapRouter.swapCallParameters(trade, { + feeOnTransfer: false, + allowedSlippage, + recipient, + deadline: deadline.toNumber(), + }) + ) + + if (trade.tradeType === TradeType.EXACT_INPUT) { + swapMethods.push( + V2SwapRouter.swapCallParameters(trade, { + feeOnTransfer: true, + allowedSlippage, + recipient, + deadline: deadline.toNumber(), + }) + ) + } + return swapMethods.map(({ methodName, args, value }) => { + if (argentWalletContract && trade.inputAmount.currency.isToken) { + return { + address: argentWalletContract.address, + calldata: argentWalletContract.interface.encodeFunctionData('wc_multiCall', [ + [ + approveAmountCalldata(trade.maximumAmountIn(allowedSlippage), routerContract.address), + { + to: routerContract.address, + value, + data: routerContract.interface.encodeFunctionData(methodName, args), + }, + ], + ]), + value: '0x0', + } + } else { + return { + address: routerContract.address, + calldata: routerContract.interface.encodeFunctionData(methodName, args), + value, + } + } + }) + } else { + // swap options shared by v3 and v2+v3 swap routers + const sharedSwapOptions = { + recipient, + slippageTolerance: allowedSlippage, + ...(signatureData + ? { + inputTokenPermit: + 'allowed' in signatureData + ? { + expiry: signatureData.deadline, + nonce: signatureData.nonce, + s: signatureData.s, + r: signatureData.r, + v: signatureData.v as any, + } + : { + deadline: signatureData.deadline, + amount: signatureData.amount, + s: signatureData.s, + r: signatureData.r, + v: signatureData.v as any, + }, + } + : {}), + } + + const swapRouterAddress = chainId + ? trade instanceof V3Trade + ? V3_ROUTER_ADDRESS[chainId] + : SWAP_ROUTER_ADDRESSES[chainId] + : undefined + if (!swapRouterAddress) return [] + + const { value, calldata } = + trade instanceof V3Trade + ? V3SwapRouter.swapCallParameters(trade, { + ...sharedSwapOptions, + deadline: deadline.toString(), + }) + : SwapRouter.swapCallParameters(trade, { + ...sharedSwapOptions, + deadlineOrPreviousBlockhash: deadline.toString(), + }) + + if (argentWalletContract && trade.inputAmount.currency.isToken) { + return [ + { + address: argentWalletContract.address, + calldata: argentWalletContract.interface.encodeFunctionData('wc_multiCall', [ + [ + approveAmountCalldata(trade.maximumAmountIn(allowedSlippage), swapRouterAddress), + { + to: swapRouterAddress, + value, + data: calldata, + }, + ], + ]), + value: '0x0', + }, + ] + } + return [ + { + address: swapRouterAddress, + calldata, + value, + }, + ] + } + }, [ + trade, + recipient, + library, + account, + chainId, + deadline, + routerContract, + allowedSlippage, + argentWalletContract, + signatureData, + ]) +} diff --git a/src/hooks/useSwapCallback.tsx b/src/hooks/useSwapCallback.tsx index 7aaaf9e4f7..338de52872 100644 --- a/src/hooks/useSwapCallback.tsx +++ b/src/hooks/useSwapCallback.tsx @@ -1,289 +1,17 @@ -import { BigNumber } from '@ethersproject/bignumber' // eslint-disable-next-line no-restricted-imports -import { t, Trans } from '@lingui/macro' -import { SwapRouter, Trade } from '@uniswap/router-sdk' -import { Currency, Percent, TradeType } from '@uniswap/sdk-core' -import { Router as V2SwapRouter, Trade as V2Trade } from '@uniswap/v2-sdk' -import { SwapRouter as V3SwapRouter, Trade as V3Trade } from '@uniswap/v3-sdk' +import { Percent, TradeType } from '@uniswap/sdk-core' import useActiveWeb3React from 'hooks/useActiveWeb3React' +import { SwapCallbackState, useSwapCallback as useLibSwapCallBack } from 'lib/hooks/swap/useSwapCallback' import { ReactNode, useMemo } from 'react' -import { SWAP_ROUTER_ADDRESSES, V3_ROUTER_ADDRESS } from '../constants/addresses' import { TransactionType } from '../state/transactions/actions' import { useTransactionAdder } from '../state/transactions/hooks' -import approveAmountCalldata from '../utils/approveAmountCalldata' -import { calculateGasMargin } from '../utils/calculateGasMargin' import { currencyId } from '../utils/currencyId' -import isZero from '../utils/isZero' -import { useArgentWalletContract } from './useArgentWalletContract' -import { useV2RouterContract } from './useContract' import useENS from './useENS' import { SignatureData } from './useERC20Permit' +import { AnyTrade } from './useSwapCallArguments' import useTransactionDeadline from './useTransactionDeadline' -type AnyTrade = - | V2Trade - | V3Trade - | Trade - -enum SwapCallbackState { - INVALID, - LOADING, - VALID, -} - -interface SwapCall { - address: string - calldata: string - value: string -} - -interface SwapCallEstimate { - call: SwapCall -} - -interface SuccessfulCall extends SwapCallEstimate { - call: SwapCall - gasEstimate: BigNumber -} - -interface FailedCall extends SwapCallEstimate { - call: SwapCall - error: Error -} -/** - * Returns the swap calls that can be used to make the trade - * @param trade trade to execute - * @param allowedSlippage user allowed slippage - * @param recipientAddressOrName the ENS name or address of the recipient of the swap output - * @param signatureData the signature data of the permit of the input token amount, if available - */ -function useSwapCallArguments( - trade: AnyTrade | undefined, // trade to execute, required - allowedSlippage: Percent, // in bips - recipientAddressOrName: string | null, // the ENS name or address of the recipient of the trade, or null if swap should be returned to sender - signatureData: SignatureData | null | undefined -): SwapCall[] { - const { account, chainId, library } = useActiveWeb3React() - - const { address: recipientAddress } = useENS(recipientAddressOrName) - const recipient = recipientAddressOrName === null ? account : recipientAddress - const deadline = useTransactionDeadline() - const routerContract = useV2RouterContract() - const argentWalletContract = useArgentWalletContract() - - return useMemo(() => { - if (!trade || !recipient || !library || !account || !chainId || !deadline) return [] - - if (trade instanceof V2Trade) { - if (!routerContract) return [] - const swapMethods = [] - - swapMethods.push( - V2SwapRouter.swapCallParameters(trade, { - feeOnTransfer: false, - allowedSlippage, - recipient, - deadline: deadline.toNumber(), - }) - ) - - if (trade.tradeType === TradeType.EXACT_INPUT) { - swapMethods.push( - V2SwapRouter.swapCallParameters(trade, { - feeOnTransfer: true, - allowedSlippage, - recipient, - deadline: deadline.toNumber(), - }) - ) - } - return swapMethods.map(({ methodName, args, value }) => { - if (argentWalletContract && trade.inputAmount.currency.isToken) { - return { - address: argentWalletContract.address, - calldata: argentWalletContract.interface.encodeFunctionData('wc_multiCall', [ - [ - approveAmountCalldata(trade.maximumAmountIn(allowedSlippage), routerContract.address), - { - to: routerContract.address, - value, - data: routerContract.interface.encodeFunctionData(methodName, args), - }, - ], - ]), - value: '0x0', - } - } else { - return { - address: routerContract.address, - calldata: routerContract.interface.encodeFunctionData(methodName, args), - value, - } - } - }) - } else { - // swap options shared by v3 and v2+v3 swap routers - const sharedSwapOptions = { - recipient, - slippageTolerance: allowedSlippage, - ...(signatureData - ? { - inputTokenPermit: - 'allowed' in signatureData - ? { - expiry: signatureData.deadline, - nonce: signatureData.nonce, - s: signatureData.s, - r: signatureData.r, - v: signatureData.v as any, - } - : { - deadline: signatureData.deadline, - amount: signatureData.amount, - s: signatureData.s, - r: signatureData.r, - v: signatureData.v as any, - }, - } - : {}), - } - - const swapRouterAddress = chainId - ? trade instanceof V3Trade - ? V3_ROUTER_ADDRESS[chainId] - : SWAP_ROUTER_ADDRESSES[chainId] - : undefined - if (!swapRouterAddress) return [] - - const { value, calldata } = - trade instanceof V3Trade - ? V3SwapRouter.swapCallParameters(trade, { - ...sharedSwapOptions, - deadline: deadline.toString(), - }) - : SwapRouter.swapCallParameters(trade, { - ...sharedSwapOptions, - deadlineOrPreviousBlockhash: deadline.toString(), - }) - - if (argentWalletContract && trade.inputAmount.currency.isToken) { - return [ - { - address: argentWalletContract.address, - calldata: argentWalletContract.interface.encodeFunctionData('wc_multiCall', [ - [ - approveAmountCalldata(trade.maximumAmountIn(allowedSlippage), swapRouterAddress), - { - to: swapRouterAddress, - value, - data: calldata, - }, - ], - ]), - value: '0x0', - }, - ] - } - return [ - { - address: swapRouterAddress, - calldata, - value, - }, - ] - } - }, [ - trade, - recipient, - library, - account, - chainId, - deadline, - routerContract, - allowedSlippage, - argentWalletContract, - signatureData, - ]) -} - -/** - * This is hacking out the revert reason from the ethers provider thrown error however it can. - * This object seems to be undocumented by ethers. - * @param error an error from the ethers provider - */ -function swapErrorToUserReadableMessage(error: any): ReactNode { - let reason: string | undefined - while (Boolean(error)) { - reason = error.reason ?? error.message ?? reason - error = error.error ?? error.data?.originalError - } - - if (reason?.indexOf('execution reverted: ') === 0) reason = reason.substr('execution reverted: '.length) - - switch (reason) { - case 'UniswapV2Router: EXPIRED': - return ( - - The transaction could not be sent because the deadline has passed. Please check that your transaction deadline - is not too low. - - ) - case 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT': - case 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT': - return ( - - This transaction will not succeed either due to price movement or fee on transfer. Try increasing your - slippage tolerance. - - ) - case 'TransferHelper: TRANSFER_FROM_FAILED': - return The input token cannot be transferred. There may be an issue with the input token. - case 'UniswapV2: TRANSFER_FAILED': - return The output token cannot be transferred. There may be an issue with the output token. - case 'UniswapV2: K': - return ( - - The Uniswap invariant x*y=k was not satisfied by the swap. This usually means one of the tokens you are - swapping incorporates custom behavior on transfer. - - ) - case 'Too little received': - case 'Too much requested': - case 'STF': - return ( - - This transaction will not succeed due to price movement. Try increasing your slippage tolerance. Note: fee on - transfer and rebase tokens are incompatible with Uniswap V3. - - ) - case 'TF': - return ( - - The output token cannot be transferred. There may be an issue with the output token. Note: fee on transfer and - rebase tokens are incompatible with Uniswap V3. - - ) - default: - if (reason?.indexOf('undefined is not an object') !== -1) { - console.error(error, reason) - return ( - - An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If - that does not work, there may be an incompatibility with the token you are trading. Note: fee on transfer - and rebase tokens are incompatible with Uniswap V3. - - ) - } - return ( - - Unknown error{reason ? `: "${reason}"` : ''}. Try increasing your slippage tolerance. Note: fee on transfer - and rebase tokens are incompatible with Uniswap V3. - - ) - } -} - // returns a function that will execute a swap, if the parameters are all valid // and the user has approved the slippage adjusted input amount for the trade export function useSwapCallback( @@ -292,139 +20,56 @@ export function useSwapCallback( recipientAddressOrName: string | null, // the ENS name or address of the recipient of the trade, or null if swap should be returned to sender signatureData: SignatureData | undefined | null ): { state: SwapCallbackState; callback: null | (() => Promise); error: ReactNode | null } { - const { account, chainId, library } = useActiveWeb3React() + const { account } = useActiveWeb3React() - const swapCalls = useSwapCallArguments(trade, allowedSlippage, recipientAddressOrName, signatureData) + const deadline = useTransactionDeadline() const addTransaction = useTransactionAdder() const { address: recipientAddress } = useENS(recipientAddressOrName) const recipient = recipientAddressOrName === null ? account : recipientAddress - return useMemo(() => { - if (!trade || !library || !account || !chainId) { - return { state: SwapCallbackState.INVALID, callback: null, error: Missing dependencies } - } - if (!recipient) { - if (recipientAddressOrName !== null) { - return { state: SwapCallbackState.INVALID, callback: null, error: Invalid recipient } - } else { - return { state: SwapCallbackState.LOADING, callback: null, error: null } - } - } - - return { - state: SwapCallbackState.VALID, - callback: async function onSwap(): Promise { - const estimatedCalls: SwapCallEstimate[] = await Promise.all( - swapCalls.map((call) => { - const { address, calldata, value } = call - - const tx = - !value || isZero(value) - ? { from: account, to: address, data: calldata } - : { - from: account, - to: address, - data: calldata, - value, - } + const { + state, + callback: libCallback, + error, + } = useLibSwapCallBack(trade, allowedSlippage, recipient, signatureData, deadline) - return library - .estimateGas(tx) - .then((gasEstimate) => { - return { - call, - gasEstimate, - } - }) - .catch((gasError) => { - console.debug('Gas estimate failed, trying eth_call to extract error', call) - - return library - .call(tx) - .then((result) => { - console.debug('Unexpected successful call after failed estimate gas', call, gasError, result) - return { call, error: Unexpected issue with estimating the gas. Please try again. } - }) - .catch((callError) => { - console.debug('Call threw error', call, callError) - return { call, error: swapErrorToUserReadableMessage(callError) } - }) - }) - }) - ) - - // a successful estimation is a bignumber gas estimate and the next call is also a bignumber gas estimate - let bestCallOption: SuccessfulCall | SwapCallEstimate | undefined = estimatedCalls.find( - (el, ix, list): el is SuccessfulCall => - 'gasEstimate' in el && (ix === list.length - 1 || 'gasEstimate' in list[ix + 1]) + const callback = useMemo(() => { + if (!libCallback || !trade) { + return null + } + return () => + libCallback().then((response) => { + addTransaction( + response, + trade.tradeType === TradeType.EXACT_INPUT + ? { + type: TransactionType.SWAP, + tradeType: TradeType.EXACT_INPUT, + inputCurrencyId: currencyId(trade.inputAmount.currency), + inputCurrencyAmountRaw: trade.inputAmount.quotient.toString(), + expectedOutputCurrencyAmountRaw: trade.outputAmount.quotient.toString(), + outputCurrencyId: currencyId(trade.outputAmount.currency), + minimumOutputCurrencyAmountRaw: trade.minimumAmountOut(allowedSlippage).quotient.toString(), + } + : { + type: TransactionType.SWAP, + tradeType: TradeType.EXACT_OUTPUT, + inputCurrencyId: currencyId(trade.inputAmount.currency), + maximumInputCurrencyAmountRaw: trade.maximumAmountIn(allowedSlippage).quotient.toString(), + outputCurrencyId: currencyId(trade.outputAmount.currency), + outputCurrencyAmountRaw: trade.outputAmount.quotient.toString(), + expectedInputCurrencyAmountRaw: trade.inputAmount.quotient.toString(), + } ) + return response.hash + }) + }, [addTransaction, allowedSlippage, libCallback, trade]) - // check if any calls errored with a recognizable error - if (!bestCallOption) { - const errorCalls = estimatedCalls.filter((call): call is FailedCall => 'error' in call) - if (errorCalls.length > 0) throw errorCalls[errorCalls.length - 1].error - const firstNoErrorCall = estimatedCalls.find( - (call): call is SwapCallEstimate => !('error' in call) - ) - if (!firstNoErrorCall) throw new Error(t`Unexpected error. Could not estimate gas for the swap.`) - bestCallOption = firstNoErrorCall - } - - const { - call: { address, calldata, value }, - } = bestCallOption - - return library - .getSigner() - .sendTransaction({ - from: account, - to: address, - data: calldata, - // let the wallet try if we can't estimate the gas - ...('gasEstimate' in bestCallOption ? { gasLimit: calculateGasMargin(bestCallOption.gasEstimate) } : {}), - ...(value && !isZero(value) ? { value } : {}), - }) - .then((response) => { - addTransaction( - response, - trade.tradeType === TradeType.EXACT_INPUT - ? { - type: TransactionType.SWAP, - tradeType: TradeType.EXACT_INPUT, - inputCurrencyId: currencyId(trade.inputAmount.currency), - inputCurrencyAmountRaw: trade.inputAmount.quotient.toString(), - expectedOutputCurrencyAmountRaw: trade.outputAmount.quotient.toString(), - outputCurrencyId: currencyId(trade.outputAmount.currency), - minimumOutputCurrencyAmountRaw: trade.minimumAmountOut(allowedSlippage).quotient.toString(), - } - : { - type: TransactionType.SWAP, - tradeType: TradeType.EXACT_OUTPUT, - inputCurrencyId: currencyId(trade.inputAmount.currency), - maximumInputCurrencyAmountRaw: trade.maximumAmountIn(allowedSlippage).quotient.toString(), - outputCurrencyId: currencyId(trade.outputAmount.currency), - outputCurrencyAmountRaw: trade.outputAmount.quotient.toString(), - expectedInputCurrencyAmountRaw: trade.inputAmount.quotient.toString(), - } - ) - - return response.hash - }) - .catch((error) => { - // if the user rejected the tx, pass this along - if (error?.code === 4001) { - throw new Error(t`Transaction rejected.`) - } else { - // otherwise, the error was unexpected and we need to convey that - console.error(`Swap failed`, error, address, calldata, value) - - throw new Error(t`Swap failed: ${swapErrorToUserReadableMessage(error)}`) - } - }) - }, - error: null, - } - }, [trade, library, account, chainId, recipient, recipientAddressOrName, swapCalls, addTransaction, allowedSlippage]) + return { + state, + callback, + error, + } } diff --git a/src/hooks/useV2LiquidityTokenPermit.ts b/src/hooks/useV2LiquidityTokenPermit.ts new file mode 100644 index 0000000000..2fa9b257a2 --- /dev/null +++ b/src/hooks/useV2LiquidityTokenPermit.ts @@ -0,0 +1,18 @@ +import { CurrencyAmount, Token } from '@uniswap/sdk-core' + +import { PermitInfo, PermitType, useERC20Permit } from './useERC20Permit' +import useTransactionDeadline from './useTransactionDeadline' + +const REMOVE_V2_LIQUIDITY_PERMIT_INFO: PermitInfo = { + version: '1', + name: 'Uniswap V2', + type: PermitType.AMOUNT, +} + +export function useV2LiquidityTokenPermit( + liquidityAmount: CurrencyAmount | null | undefined, + spender: string | null | undefined +) { + const transactionDeadline = useTransactionDeadline() + return useERC20Permit(liquidityAmount, spender, transactionDeadline, REMOVE_V2_LIQUIDITY_PERMIT_INFO) +} diff --git a/src/lib/components/Swap/SwapButton.tsx b/src/lib/components/Swap/SwapButton.tsx index 1f2d991ca9..5a3a31bfa1 100644 --- a/src/lib/components/Swap/SwapButton.tsx +++ b/src/lib/components/Swap/SwapButton.tsx @@ -1,16 +1,22 @@ +import { BigNumber } from '@ethersproject/bignumber' import { Trans } from '@lingui/macro' import { Token } from '@uniswap/sdk-core' import { CHAIN_INFO } from 'constants/chainInfo' +import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' +import { useERC20PermitFromTrade } from 'hooks/useERC20Permit' +import { useAtomValue } from 'jotai/utils' import { useSwapInfo } from 'lib/hooks/swap' import useSwapApproval, { ApprovalState, useSwapApprovalOptimizedTrade, useSwapRouterAddress, } from 'lib/hooks/swap/useSwapApproval' +import { useSwapCallback } from 'lib/hooks/swap/useSwapCallback' import { useAddTransaction } from 'lib/hooks/transactions' import { usePendingApproval } from 'lib/hooks/transactions' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import { Link, Spinner } from 'lib/icons' +import { transactionTtlAtom } from 'lib/state/settings' import { Field } from 'lib/state/swap' import { TransactionType } from 'lib/state/transactions' import styled from 'lib/theme' @@ -35,7 +41,8 @@ function useIsPendingApproval(token?: Token, spender?: string): boolean { } export default function SwapButton({ disabled }: SwapButtonProps) { - const { chainId } = useActiveWeb3React() + const { account, chainId } = useActiveWeb3React() + const { trade, allowedSlippage, @@ -103,9 +110,35 @@ export default function SwapButton({ disabled }: SwapButtonProps) { return { disabled: true } }, [approval, approvalHash, chainId, disabled, inputCurrencyAmount, inputCurrencyBalance]) + // @TODO(ianlapham): connect deadline from state instead of passing undefined. + const { signatureData } = useERC20PermitFromTrade(optimizedTrade, allowedSlippage, undefined) + + const currentBlockTimestamp = useCurrentBlockTimestamp() + const userDeadline = useAtomValue(transactionTtlAtom) + const deadline = currentBlockTimestamp?.add(BigNumber.from(userDeadline)) + + // the callback to execute the swap + const { callback: swapCallback } = useSwapCallback( + optimizedTrade, + allowedSlippage, + account ?? null, + signatureData, + deadline + ) + + //@TODO(ianlapham): add a loading state, process errors const onConfirm = useCallback(() => { - // TODO(zzmp): Transact the trade. - }, []) + swapCallback?.() + .then((transactionResponse) => { + // TODO(ianlapham): Add the swap tx to transactionsAtom + // TODO(ianlapham): Add the pending swap tx to a new swap state + console.log(transactionResponse) + }) + .catch((error) => { + //@TODO(ianlapham): add error handling + console.log(error) + }) + }, [swapCallback]) return ( <> diff --git a/src/lib/hooks/swap/useSendSwapTransaction.tsx b/src/lib/hooks/swap/useSendSwapTransaction.tsx new file mode 100644 index 0000000000..51cb717d0d --- /dev/null +++ b/src/lib/hooks/swap/useSendSwapTransaction.tsx @@ -0,0 +1,140 @@ +import { BigNumber } from '@ethersproject/bignumber' +import { TransactionResponse, Web3Provider } from '@ethersproject/providers' +// eslint-disable-next-line no-restricted-imports +import { t, Trans } from '@lingui/macro' +import { Trade } from '@uniswap/router-sdk' +import { Currency, TradeType } from '@uniswap/sdk-core' +import { Trade as V2Trade } from '@uniswap/v2-sdk' +import { Trade as V3Trade } from '@uniswap/v3-sdk' +import { useMemo } from 'react' +import { calculateGasMargin } from 'utils/calculateGasMargin' +import isZero from 'utils/isZero' +import { swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' + +type AnyTrade = + | V2Trade + | V3Trade + | Trade + +interface SwapCall { + address: string + calldata: string + value: string +} + +interface SwapCallEstimate { + call: SwapCall +} + +interface SuccessfulCall extends SwapCallEstimate { + call: SwapCall + gasEstimate: BigNumber +} + +interface FailedCall extends SwapCallEstimate { + call: SwapCall + error: Error +} + +// returns a function that will execute a swap, if the parameters are all valid +export default function useSendSwapTransaction( + account: string | null | undefined, + chainId: number | undefined, + library: Web3Provider | undefined, + trade: AnyTrade | undefined, // trade to execute, required + swapCalls: SwapCall[] +): { callback: null | (() => Promise) } { + return useMemo(() => { + if (!trade || !library || !account || !chainId) { + return { callback: null } + } + return { + callback: async function onSwap(): Promise { + const estimatedCalls: SwapCallEstimate[] = await Promise.all( + swapCalls.map((call) => { + const { address, calldata, value } = call + + const tx = + !value || isZero(value) + ? { from: account, to: address, data: calldata } + : { + from: account, + to: address, + data: calldata, + value, + } + + return library + .estimateGas(tx) + .then((gasEstimate) => { + return { + call, + gasEstimate, + } + }) + .catch((gasError) => { + console.debug('Gas estimate failed, trying eth_call to extract error', call) + + return library + .call(tx) + .then((result) => { + console.debug('Unexpected successful call after failed estimate gas', call, gasError, result) + return { call, error: Unexpected issue with estimating the gas. Please try again. } + }) + .catch((callError) => { + console.debug('Call threw error', call, callError) + return { call, error: swapErrorToUserReadableMessage(callError) } + }) + }) + }) + ) + + // a successful estimation is a bignumber gas estimate and the next call is also a bignumber gas estimate + let bestCallOption: SuccessfulCall | SwapCallEstimate | undefined = estimatedCalls.find( + (el, ix, list): el is SuccessfulCall => + 'gasEstimate' in el && (ix === list.length - 1 || 'gasEstimate' in list[ix + 1]) + ) + + // check if any calls errored with a recognizable error + if (!bestCallOption) { + const errorCalls = estimatedCalls.filter((call): call is FailedCall => 'error' in call) + if (errorCalls.length > 0) throw errorCalls[errorCalls.length - 1].error + const firstNoErrorCall = estimatedCalls.find( + (call): call is SwapCallEstimate => !('error' in call) + ) + if (!firstNoErrorCall) throw new Error(t`Unexpected error. Could not estimate gas for the swap.`) + bestCallOption = firstNoErrorCall + } + + const { + call: { address, calldata, value }, + } = bestCallOption + + return library + .getSigner() + .sendTransaction({ + from: account, + to: address, + data: calldata, + // let the wallet try if we can't estimate the gas + ...('gasEstimate' in bestCallOption ? { gasLimit: calculateGasMargin(bestCallOption.gasEstimate) } : {}), + ...(value && !isZero(value) ? { value } : {}), + }) + .then((response) => { + return response + }) + .catch((error) => { + // if the user rejected the tx, pass this along + if (error?.code === 4001) { + throw new Error(t`Transaction rejected.`) + } else { + // otherwise, the error was unexpected and we need to convey that + console.error(`Swap failed`, error, address, calldata, value) + + throw new Error(t`Swap failed: ${swapErrorToUserReadableMessage(error)}`) + } + }) + }, + } + }, [account, chainId, library, swapCalls, trade]) +} diff --git a/src/lib/hooks/swap/useSwapCallback.tsx b/src/lib/hooks/swap/useSwapCallback.tsx new file mode 100644 index 0000000000..c1e6938d93 --- /dev/null +++ b/src/lib/hooks/swap/useSwapCallback.tsx @@ -0,0 +1,59 @@ +// eslint-disable-next-line no-restricted-imports +import { BigNumber } from '@ethersproject/bignumber' +import { TransactionResponse } from '@ethersproject/providers' +import { Trans } from '@lingui/macro' +import { Percent } from '@uniswap/sdk-core' +import useActiveWeb3React from 'hooks/useActiveWeb3React' +import useENS from 'hooks/useENS' +import { SignatureData } from 'hooks/useERC20Permit' +import { AnyTrade, useSwapCallArguments } from 'hooks/useSwapCallArguments' +import { ReactNode, useMemo } from 'react' + +import useSendSwapTransaction from './useSendSwapTransaction' + +export enum SwapCallbackState { + INVALID, + LOADING, + VALID, +} + +// returns a function that will execute a swap, if the parameters are all valid +// and the user has approved the slippage adjusted input amount for the trade +export function useSwapCallback( + trade: AnyTrade | undefined, // trade to execute, required + allowedSlippage: Percent, // in bips + recipientAddressOrName: string | null | undefined, // the ENS name or address of the recipient of the trade, or null if swap should be returned to sender + signatureData: SignatureData | null | undefined, + deadline: BigNumber | undefined +): { state: SwapCallbackState; callback: null | (() => Promise); error: ReactNode | null } { + const { account, chainId, library } = useActiveWeb3React() + + const swapCalls = useSwapCallArguments(trade, allowedSlippage, recipientAddressOrName, signatureData, deadline) + const { callback } = useSendSwapTransaction(account, chainId, library, trade, swapCalls) + + const { address: recipientAddress } = useENS(recipientAddressOrName) + const recipient = recipientAddressOrName === null ? account : recipientAddress + + return useMemo(() => { + if (!trade || !library || !account || !chainId || !callback) { + return { state: SwapCallbackState.INVALID, callback: null, error: Missing dependencies } + } + if (!recipient) { + if (recipientAddressOrName !== null) { + return { state: SwapCallbackState.INVALID, callback: null, error: Invalid recipient } + } else { + return { state: SwapCallbackState.LOADING, callback: null, error: null } + } + } + + return { + state: SwapCallbackState.VALID, + callback: async function onSwap(): Promise { + return callback().then((response) => { + return response + }) + }, + error: null, + } + }, [trade, library, account, chainId, callback, recipient, recipientAddressOrName]) +} diff --git a/src/lib/state/settings.ts b/src/lib/state/settings.ts index dff634a9d3..9c19a610ac 100644 --- a/src/lib/state/settings.ts +++ b/src/lib/state/settings.ts @@ -14,7 +14,7 @@ interface Settings { const initialSettings: Settings = { maxSlippage: 'auto', - transactionTtl: undefined, + transactionTtl: TRANSACTION_TTL_DEFAULT, mockTogglable: true, clientSideRouter: false, } diff --git a/src/pages/MigrateV2/MigrateV2Pair.tsx b/src/pages/MigrateV2/MigrateV2Pair.tsx index c78f126edc..8292efa24b 100644 --- a/src/pages/MigrateV2/MigrateV2Pair.tsx +++ b/src/pages/MigrateV2/MigrateV2Pair.tsx @@ -18,6 +18,7 @@ import useCurrentBlockTimestamp from 'hooks/useCurrentBlockTimestamp' import { PoolState, usePool } from 'hooks/usePools' import useTheme from 'hooks/useTheme' import useTransactionDeadline from 'hooks/useTransactionDeadline' +import { useV2LiquidityTokenPermit } from 'hooks/useV2LiquidityTokenPermit' import JSBI from 'jsbi' import { NEVER_RELOAD, useSingleCallResult } from 'lib/hooks/multicall' import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' @@ -41,7 +42,6 @@ import { V2_FACTORY_ADDRESSES } from '../../constants/addresses' import { WRAPPED_NATIVE_CURRENCY } from '../../constants/tokens' import { useToken } from '../../hooks/Tokens' import { usePairContract, useV2MigratorContract } from '../../hooks/useContract' -import { useV2LiquidityTokenPermit } from '../../hooks/useERC20Permit' import useIsArgentWallet from '../../hooks/useIsArgentWallet' import { useTotalSupply } from '../../hooks/useTotalSupply' import { TransactionType } from '../../state/transactions/actions' diff --git a/src/pages/RemoveLiquidity/index.tsx b/src/pages/RemoveLiquidity/index.tsx index 2801bc42b2..6c00ab7f36 100644 --- a/src/pages/RemoveLiquidity/index.tsx +++ b/src/pages/RemoveLiquidity/index.tsx @@ -4,6 +4,7 @@ import { TransactionResponse } from '@ethersproject/providers' import { Trans } from '@lingui/macro' import { Currency, Percent } from '@uniswap/sdk-core' import useActiveWeb3React from 'hooks/useActiveWeb3React' +import { useV2LiquidityTokenPermit } from 'hooks/useV2LiquidityTokenPermit' import { useCallback, useContext, useMemo, useState } from 'react' import { ArrowDown, Plus } from 'react-feather' import ReactGA from 'react-ga' @@ -28,7 +29,6 @@ import { useCurrency } from '../../hooks/Tokens' import { ApprovalState, useApproveCallback } from '../../hooks/useApproveCallback' import { usePairContract, useV2RouterContract } from '../../hooks/useContract' import useDebouncedChangeHandler from '../../hooks/useDebouncedChangeHandler' -import { useV2LiquidityTokenPermit } from '../../hooks/useERC20Permit' import useTransactionDeadline from '../../hooks/useTransactionDeadline' import { useWalletModalToggle } from '../../state/application/hooks' import { Field } from '../../state/burn/actions' diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 1ac4e45af1..a626cbe82a 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -8,6 +8,8 @@ import SwapDetailsDropdown from 'components/swap/SwapDetailsDropdown' import UnsupportedCurrencyFooter from 'components/swap/UnsupportedCurrencyFooter' import { MouseoverTooltip } from 'components/Tooltip' import useActiveWeb3React from 'hooks/useActiveWeb3React' +import { useSwapCallback } from 'hooks/useSwapCallback' +import useTransactionDeadline from 'hooks/useTransactionDeadline' import JSBI from 'jsbi' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { ArrowDown, CheckCircle, HelpCircle } from 'react-feather' @@ -37,7 +39,6 @@ import useENSAddress from '../../hooks/useENSAddress' import { useERC20PermitFromTrade, UseERC20PermitState } from '../../hooks/useERC20Permit' import useIsArgentWallet from '../../hooks/useIsArgentWallet' import { useIsSwapUnsupported } from '../../hooks/useIsSwapUnsupported' -import { useSwapCallback } from '../../hooks/useSwapCallback' import { useUSDCValue } from '../../hooks/useUSDCPrice' import useWrapCallback, { WrapErrorText, WrapType } from '../../hooks/useWrapCallback' import { useWalletModalToggle } from '../../state/application/hooks' @@ -204,11 +205,12 @@ export default function Swap({ history }: RouteComponentProps) { // check whether the user has approved the router on the input token const [approvalState, approveCallback] = useApproveCallbackFromTrade(approvalOptimizedTrade, allowedSlippage) + const transactionDeadline = useTransactionDeadline() const { state: signatureState, signatureData, gatherPermitSignature, - } = useERC20PermitFromTrade(approvalOptimizedTrade, allowedSlippage) + } = useERC20PermitFromTrade(approvalOptimizedTrade, allowedSlippage, transactionDeadline) const handleApprove = useCallback(async () => { if (signatureState === UseERC20PermitState.NOT_SIGNED && gatherPermitSignature) { diff --git a/src/utils/swapErrorToUserReadableMessage.tsx b/src/utils/swapErrorToUserReadableMessage.tsx new file mode 100644 index 0000000000..6f202cf49e --- /dev/null +++ b/src/utils/swapErrorToUserReadableMessage.tsx @@ -0,0 +1,78 @@ +import { Trans } from '@lingui/macro' +import { ReactNode } from 'react' +/** + * This is hacking out the revert reason from the ethers provider thrown error however it can. + * This object seems to be undocumented by ethers. + * @param error an error from the ethers provider + */ +export function swapErrorToUserReadableMessage(error: any): ReactNode { + let reason: string | undefined + while (Boolean(error)) { + reason = error.reason ?? error.message ?? reason + error = error.error ?? error.data?.originalError + } + + if (reason?.indexOf('execution reverted: ') === 0) reason = reason.substr('execution reverted: '.length) + + switch (reason) { + case 'UniswapV2Router: EXPIRED': + return ( + + The transaction could not be sent because the deadline has passed. Please check that your transaction deadline + is not too low. + + ) + case 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT': + case 'UniswapV2Router: EXCESSIVE_INPUT_AMOUNT': + return ( + + This transaction will not succeed either due to price movement or fee on transfer. Try increasing your + slippage tolerance. + + ) + case 'TransferHelper: TRANSFER_FROM_FAILED': + return The input token cannot be transferred. There may be an issue with the input token. + case 'UniswapV2: TRANSFER_FAILED': + return The output token cannot be transferred. There may be an issue with the output token. + case 'UniswapV2: K': + return ( + + The Uniswap invariant x*y=k was not satisfied by the swap. This usually means one of the tokens you are + swapping incorporates custom behavior on transfer. + + ) + case 'Too little received': + case 'Too much requested': + case 'STF': + return ( + + This transaction will not succeed due to price movement. Try increasing your slippage tolerance. Note: fee on + transfer and rebase tokens are incompatible with Uniswap V3. + + ) + case 'TF': + return ( + + The output token cannot be transferred. There may be an issue with the output token. Note: fee on transfer and + rebase tokens are incompatible with Uniswap V3. + + ) + default: + if (reason?.indexOf('undefined is not an object') !== -1) { + console.error(error, reason) + return ( + + An error occurred when trying to execute this swap. You may need to increase your slippage tolerance. If + that does not work, there may be an incompatibility with the token you are trading. Note: fee on transfer + and rebase tokens are incompatible with Uniswap V3. + + ) + } + return ( + + Unknown error{reason ? `: "${reason}"` : ''}. Try increasing your slippage tolerance. Note: fee on transfer + and rebase tokens are incompatible with Uniswap V3. + + ) + } +}