diff --git a/src/components/Error/ErrorBoundary.test.tsx b/src/components/Error/ErrorBoundary.test.tsx new file mode 100644 index 000000000..009621588 --- /dev/null +++ b/src/components/Error/ErrorBoundary.test.tsx @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react' +import { UserRejectedRequestError } from 'errors' + +import { useAsyncError } from './ErrorBoundary' + +describe('useAsyncError', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + it('throws an Error', () => { + const error = new Error() + const { result } = renderHook(useAsyncError) + expect(() => act(() => result.current(error))).toThrowError(error) + }) + + it('throws a string as a wrapped Error', () => { + const { result } = renderHook(useAsyncError) + expect(() => act(() => result.current('error'))).toThrowError('error') + }) + + it('does not throw a UserRejectedRequestError', () => { + const { result } = renderHook(useAsyncError) + expect(() => act(() => result.current(new UserRejectedRequestError()))).not.toThrow() + }) +}) diff --git a/src/components/Error/ErrorBoundary.tsx b/src/components/Error/ErrorBoundary.tsx index 3d0272db1..4bf376c9f 100644 --- a/src/components/Error/ErrorBoundary.tsx +++ b/src/components/Error/ErrorBoundary.tsx @@ -1,5 +1,5 @@ import { t } from '@lingui/macro' -import { DEFAULT_ERROR_HEADER, WidgetError } from 'errors' +import { DEFAULT_ERROR_HEADER, UserRejectedRequestError, WidgetError } from 'errors' import { Component, ErrorInfo, PropsWithChildren, useCallback, useState } from 'react' import ErrorView from './ErrorView' @@ -28,10 +28,13 @@ type ErrorBoundaryState = { * }, [throwError]) */ export function useAsyncError() { - const [, setError] = useState() + const [, setError] = useState() return useCallback( (error: unknown) => setError(() => { + // Ignore user rejections - they should not trigger the ErrorBoundary + if (error instanceof UserRejectedRequestError) return + if (error instanceof Error) throw error throw new Error(error as string) }), diff --git a/src/components/Swap/SwapActionButton/ApproveButton.tsx b/src/components/Swap/SwapActionButton/ApproveButton.tsx index d251c8280..05f393b55 100644 --- a/src/components/Swap/SwapActionButton/ApproveButton.tsx +++ b/src/components/Swap/SwapActionButton/ApproveButton.tsx @@ -2,6 +2,7 @@ import { TransactionResponse } from '@ethersproject/providers' import { Trans } from '@lingui/macro' import { useWeb3React } from '@web3-react/core' import ActionButton from 'components/ActionButton' +import { useAsyncError } from 'components/Error/ErrorBoundary' import EtherscanLink from 'components/EtherscanLink' import { SWAP_ROUTER_ADDRESSES } from 'constants/addresses' import { SwapApprovalState } from 'hooks/swap/useSwapApproval' @@ -33,6 +34,7 @@ export default function ApproveButton({ } | void> }) { const [isPending, setIsPending] = useState(false) + const throwAsync = useAsyncError() const onSubmit = useOnSubmit() const onApprove = useCallback(async () => { setIsPending(true) @@ -44,11 +46,11 @@ export default function ApproveButton({ return { type: TransactionType.APPROVAL, ...info } }) } catch (e) { - console.error(e) // ignore error + throwAsync(e) } finally { setIsPending(false) } - }, [approve, onSubmit]) + }, [approve, onSubmit, throwAsync]) const currency = trade?.inputAmount?.currency const symbol = currency?.symbol || '' diff --git a/src/components/Swap/SwapActionButton/WrapButton.tsx b/src/components/Swap/SwapActionButton/WrapButton.tsx index 9e7a67776..04d7ac010 100644 --- a/src/components/Swap/SwapActionButton/WrapButton.tsx +++ b/src/components/Swap/SwapActionButton/WrapButton.tsx @@ -1,4 +1,5 @@ import { Trans } from '@lingui/macro' +import { useAsyncError } from 'components/Error/ErrorBoundary' import useWrapCallback from 'hooks/swap/useWrapCallback' import useNativeCurrency from 'hooks/useNativeCurrency' import useTokenColorExtraction from 'hooks/useTokenColorExtraction' @@ -25,16 +26,17 @@ export default function WrapButton({ disabled }: { disabled: boolean }) { const inputCurrency = wrapType === TransactionType.WRAP ? native : native.wrapped const onSubmit = useOnSubmit() + const throwAsync = useAsyncError() const onWrap = useCallback(async () => { setIsPending(true) try { await onSubmit(wrapCallback) } catch (e) { - console.error(e) // ignore error + throwAsync(e) } finally { setIsPending(false) } - }, [onSubmit, wrapCallback]) + }, [onSubmit, throwAsync, wrapCallback]) const actionProps = useMemo( () => diff --git a/src/errors.ts b/src/errors.ts index ff9a218f9..d909a6c66 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -9,31 +9,45 @@ interface WidgetErrorConfig { header?: string action?: string message?: string + error?: unknown } -export abstract class WidgetError extends Error { +export class WidgetError extends Error { header: string action: string + /** The original error, if this is a wrapped error. */ + error: unknown dismissable = false constructor(config: WidgetErrorConfig) { super(config.message) this.header = config.header ?? DEFAULT_ERROR_HEADER this.action = config.action ?? DEFAULT_ERROR_ACTION + this.error = config.error + this.name = 'WidgetError' } } -abstract class DismissableWidgetError extends WidgetError { - constructor(config: WidgetErrorConfig) { - super({ - ...config, - action: config.action ?? DEFAULT_DISMISSABLE_ERROR_ACTION, - header: config.header ?? DEFAULT_ERROR_HEADER, - }) - this.dismissable = true - } +export interface WidgetPromise extends Omit, 'then' | 'catch'> { + then: ( + /** @throws {@link WidgetError} */ + onfulfilled: (value: T) => V + ) => WidgetPromise + catch: ( + /** @throws {@link WidgetError} */ + onrejected: (reason: WidgetError) => V + ) => WidgetPromise } +export function toWidgetPromise< + P extends { then(onfulfilled: (value: any) => any): any; catch(onrejected: (reason: any) => any): any }, + V extends Parameters[0]>[0], + R extends Parameters[0]>[0] +>(promise: P, mapRejection: (reason: R) => WidgetError): WidgetPromise { + return promise.catch(mapRejection) as WidgetPromise +} + +/** Integration errors are considered fatal. They are caused by invalid integrator configuration. */ export class IntegrationError extends WidgetError { constructor(message: string) { super({ message }) @@ -41,21 +55,20 @@ export class IntegrationError extends WidgetError { } } -class ConnectionError extends WidgetError { +/** Dismissable errors are not be considered fatal by the ErrorBoundary. */ +export class DismissableError extends WidgetError { constructor(config: WidgetErrorConfig) { - super(config) - this.name = 'ConnectionError' - } -} - -export class SwapError extends DismissableWidgetError { - constructor(config: WidgetErrorConfig) { - super(config) - this.name = 'SwapError' + super({ + ...config, + action: config.action ?? DEFAULT_DISMISSABLE_ERROR_ACTION, + header: config.header ?? DEFAULT_ERROR_HEADER, + }) + this.name = 'DismissableError' + this.dismissable = true } } -export class UserRejectedRequestError extends DismissableWidgetError { +export class UserRejectedRequestError extends DismissableError { constructor() { super({ header: t`Request rejected`, @@ -65,6 +78,14 @@ export class UserRejectedRequestError extends DismissableWidgetError { } } +/** Connection errors are considered fatal. They are caused by wallet integrations. */ +abstract class ConnectionError extends WidgetError { + constructor(config: WidgetErrorConfig) { + super(config) + this.name = 'ConnectionError' + } +} + export class MetaMaskConnectionError extends ConnectionError { constructor() { super({ diff --git a/src/hooks/swap/useSendSwapTransaction.tsx b/src/hooks/swap/useSendSwapTransaction.tsx index e475c7a87..65fdef714 100644 --- a/src/hooks/swap/useSendSwapTransaction.tsx +++ b/src/hooks/swap/useSendSwapTransaction.tsx @@ -1,12 +1,12 @@ import { BigNumber } from '@ethersproject/bignumber' import { JsonRpcProvider, TransactionResponse } from '@ethersproject/providers' import { t, Trans } from '@lingui/macro' -import { ErrorCode } from 'constants/eip1193' -import { SwapError } from 'errors' +import { DismissableError, UserRejectedRequestError } from 'errors' import { useMemo } from 'react' import { InterfaceTrade } from 'state/routing/types' import { calculateGasMargin } from 'utils/calculateGasMargin' import isZero from 'utils/isZero' +import { isUserRejection } from 'utils/jsonRpcError' import { swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' interface SwapCall { @@ -118,12 +118,12 @@ export default function useSendSwapTransaction( }) .catch((error) => { // if the user rejected the tx, pass this along - if (error?.code === ErrorCode.USER_REJECTED_REQUEST) { - throw new Error(t`Transaction rejected.`) + if (isUserRejection(error)) { + throw new UserRejectedRequestError() } else { // otherwise, the error was unexpected and we need to convey that console.error(`Swap failed`, error, calldata, value) - throw new SwapError({ + throw new DismissableError({ message: t`Swap failed: ${swapErrorToUserReadableMessage(error)}`, }) } diff --git a/src/hooks/swap/useWrapCallback.tsx b/src/hooks/swap/useWrapCallback.tsx index 486bc4961..8063fea88 100644 --- a/src/hooks/swap/useWrapCallback.tsx +++ b/src/hooks/swap/useWrapCallback.tsx @@ -1,11 +1,13 @@ import { useWeb3React } from '@web3-react/core' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' +import { DismissableError, UserRejectedRequestError } from 'errors' import { useWETHContract } from 'hooks/useContract' import { usePerfEventHandler } from 'hooks/usePerfEventHandler' import { useAtomValue } from 'jotai/utils' import { useCallback, useMemo } from 'react' import { Field, swapAtom } from 'state/swap' import { TransactionType, UnwrapTransactionInfo, WrapTransactionInfo } from 'state/transactions' +import { isUserRejection } from 'utils/jsonRpcError' import tryParseCurrencyAmount from 'utils/tryParseCurrencyAmount' interface UseWrapCallbackReturns { @@ -43,23 +45,33 @@ export default function useWrapCallback(): UseWrapCallbackReturns { [inputCurrency, amount] ) - const wrapCallback = useCallback(async (): Promise => { - if (!parsedAmountIn || !wrappedNativeCurrencyContract) return - switch (wrapType) { - case TransactionType.WRAP: - return { - response: await wrappedNativeCurrencyContract.deposit({ value: `0x${parsedAmountIn.quotient.toString(16)}` }), - type: TransactionType.WRAP, - amount: parsedAmountIn, - } - case TransactionType.UNWRAP: - return { - response: await wrappedNativeCurrencyContract.withdraw(`0x${parsedAmountIn.quotient.toString(16)}`), - type: TransactionType.WRAP, - amount: parsedAmountIn, - } - case undefined: - return undefined + const wrapCallback = useCallback(async (): Promise => { + if (!parsedAmountIn) throw new Error('missing amount') + if (!wrappedNativeCurrencyContract) throw new Error('missing contract') + if (wrapType === undefined) throw new Error('missing wrapType') + try { + switch (wrapType) { + case TransactionType.WRAP: + return { + response: await wrappedNativeCurrencyContract.deposit({ + value: `0x${parsedAmountIn.quotient.toString(16)}`, + }), + type: TransactionType.WRAP, + amount: parsedAmountIn, + } + case TransactionType.UNWRAP: + return { + response: await wrappedNativeCurrencyContract.withdraw(`0x${parsedAmountIn.quotient.toString(16)}`), + type: TransactionType.WRAP, + amount: parsedAmountIn, + } + } + } catch (error: unknown) { + if (isUserRejection(error)) { + throw new UserRejectedRequestError() + } else { + throw new DismissableError({ message: (error as any)?.message ?? error, error }) + } } }, [parsedAmountIn, wrappedNativeCurrencyContract, wrapType]) diff --git a/src/hooks/usePermitAllowance.ts b/src/hooks/usePermitAllowance.ts index f5150e111..07e559b0a 100644 --- a/src/hooks/usePermitAllowance.ts +++ b/src/hooks/usePermitAllowance.ts @@ -1,13 +1,16 @@ +import { t } from '@lingui/macro' import { signTypedData } from '@uniswap/conedison/provider/signing' import { AllowanceTransfer, MaxAllowanceTransferAmount, PERMIT2_ADDRESS, PermitSingle } from '@uniswap/permit2-sdk' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { useWeb3React } from '@web3-react/core' import PERMIT2_ABI from 'abis/permit2.json' import { Permit2 } from 'abis/types' +import { UserRejectedRequestError, WidgetError } from 'errors' import { useSingleCallResult } from 'hooks/multicall' import { useContract } from 'hooks/useContract' import ms from 'ms.macro' import { useCallback, useEffect, useMemo, useState } from 'react' +import { isUserRejection } from 'utils/jsonRpcError' import { usePerfEventHandler } from './usePerfEventHandler' @@ -82,9 +85,16 @@ export function useUpdatePermitAllowance( const signature = await signTypedData(provider.getSigner(account), domain, types, values) onPermitSignature?.({ ...permit, signature }) return - } catch (e: unknown) { - const symbol = token?.symbol ?? 'Token' - throw new Error(`${symbol} permit allowance failed: ${e instanceof Error ? e.message : e}`) + } catch (error: unknown) { + if (isUserRejection(error)) { + throw new UserRejectedRequestError() + } else { + const symbol = token?.symbol ?? 'Token' + throw new WidgetError({ + message: t`${symbol} permit allowance failed: ${(error as any)?.message ?? error}`, + error, + }) + } } }, [account, chainId, nonce, onPermitSignature, provider, spender, token]) diff --git a/src/hooks/useTokenAllowance.ts b/src/hooks/useTokenAllowance.ts index 93e4e9641..172e2e7b0 100644 --- a/src/hooks/useTokenAllowance.ts +++ b/src/hooks/useTokenAllowance.ts @@ -1,13 +1,14 @@ import { BigNumberish } from '@ethersproject/bignumber' +import { t } from '@lingui/macro' import { CurrencyAmount, MaxUint256, Token } from '@uniswap/sdk-core' import { Erc20 } from 'abis/types' -import { ErrorCode } from 'constants/eip1193' -import { UserRejectedRequestError } from 'errors' +import { UserRejectedRequestError, WidgetError } from 'errors' import { useSingleCallResult } from 'hooks/multicall' import { useTokenContract } from 'hooks/useContract' import { useCallback, useEffect, useMemo, useState } from 'react' import { ApprovalTransactionInfo, TransactionType } from 'state/transactions' import { calculateGasMargin } from 'utils/calculateGasMargin' +import { isUserRejection } from 'utils/jsonRpcError' import { usePerfEventHandler } from './usePerfEventHandler' @@ -67,12 +68,15 @@ export function useUpdateTokenAllowance( tokenAddress: contract.address, spenderAddress: spender, } - } catch (e: unknown) { - const symbol = amount?.currency.symbol ?? 'Token' - if ((e as any)?.code === ErrorCode.USER_REJECTED_REQUEST) { + } catch (error: unknown) { + if (isUserRejection(error)) { throw new UserRejectedRequestError() } else { - throw new Error(`${symbol} token allowance failed: ${(e as any)?.message ?? e}`) + const symbol = amount?.currency.symbol ?? 'Token' + throw new WidgetError({ + message: t`${symbol} token allowance failed: ${(error as any)?.message ?? error}`, + error, + }) } } }, [amount, contract, spender]) diff --git a/src/hooks/useUniversalRouter.ts b/src/hooks/useUniversalRouter.ts index e2bbc1c93..99dfdc91f 100644 --- a/src/hooks/useUniversalRouter.ts +++ b/src/hooks/useUniversalRouter.ts @@ -6,14 +6,14 @@ import { Percent } from '@uniswap/sdk-core' import { SwapRouter, UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' import { FeeOptions, toHex } from '@uniswap/v3-sdk' import { useWeb3React } from '@web3-react/core' -import { ErrorCode } from 'constants/eip1193' import { TX_GAS_MARGIN } from 'constants/misc' -import { SwapError } from 'errors' +import { DismissableError, UserRejectedRequestError } from 'errors' import { useCallback, useMemo } from 'react' import { InterfaceTrade } from 'state/routing/types' import { SwapTransactionInfo, TransactionType } from 'state/transactions' import isZero from 'utils/isZero' -import { getReason, swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' +import { isUserRejection } from 'utils/jsonRpcError' +import { swapErrorToUserReadableMessage } from 'utils/swapErrorToUserReadableMessage' import { usePerfEventHandler } from './usePerfEventHandler' import { PermitSignature } from './usePermitAllowance' @@ -25,21 +25,6 @@ interface SwapOptions { feeOptions?: FeeOptions } -function didUserReject(error: any): boolean { - const reason = getReason(error) - if ( - error?.code === ErrorCode.USER_REJECTED_REQUEST || - error?.code === 'ACTION_REJECTED' || - (reason?.match(/request/i) && reason?.match(/reject/i)) || // For Rainbow - reason?.match(/declined/i) || // For Frame - reason?.match(/cancelled by user/i) || // For SafePal - reason?.match(/user denied/i) // For Coinbase - ) { - return true - } - return false -} - /** * Returns a callback to submit a transaction to the universal router. * @@ -49,7 +34,7 @@ function didUserReject(error: any): boolean { export function useUniversalRouterSwapCallback(trade: InterfaceTrade | undefined, options: SwapOptions) { const { account, chainId, provider } = useWeb3React() - const swapCallback = useCallback(async (): Promise => { + const swapCallback = useCallback(async (): Promise => { let tx: TransactionRequest let response: TransactionResponse try { @@ -73,18 +58,17 @@ export function useUniversalRouterSwapCallback(trade: InterfaceTrade | undefined } response = await sendTransaction(provider, tx, TX_GAS_MARGIN) - } catch (swapError) { - if (didUserReject(swapError)) { - return + } catch (error: unknown) { + if (isUserRejection(error)) { + throw new UserRejectedRequestError() + } else { + throw new DismissableError({ message: swapErrorToUserReadableMessage(error), error }) } - const message = swapErrorToUserReadableMessage(swapError) - throw new SwapError({ - message, - }) } if (tx.data !== response.data) { - throw new SwapError({ + throw new DismissableError({ message: t`Your swap was modified through your wallet. If this was a mistake, please cancel immediately or risk losing your funds.`, + error: 'Swap was modified in wallet.', }) } diff --git a/src/index.tsx b/src/index.tsx index 29647afcf..4e51a9570 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,8 @@ export { SwapWidgetSkeleton } from 'components/Swap/Skeleton' export { SupportedChainId } from 'constants/chains' export type { SupportedLocale } from 'constants/locales' export { DEFAULT_LOCALE, SUPPORTED_LOCALES } from 'constants/locales' +export type { WidgetPromise } from 'errors' +export { UserRejectedRequestError, WidgetError } from 'errors' export { RouterPreference } from 'hooks/routing/types' export type { SwapController } from 'hooks/swap/useSyncController' export type { FeeOptions } from 'hooks/swap/useSyncConvenienceFee' diff --git a/src/state/routing/slice.ts b/src/state/routing/slice.ts index f33494f94..b507d1223 100644 --- a/src/state/routing/slice.ts +++ b/src/state/routing/slice.ts @@ -1,5 +1,6 @@ import { BaseQueryFn, createApi, FetchBaseQueryError, SkipToken, skipToken } from '@reduxjs/toolkit/query/react' import { Protocol } from '@uniswap/router-sdk' +import { toWidgetPromise, WidgetError } from 'errors' import { RouterPreference } from 'hooks/routing/types' import ms from 'ms.macro' import qs from 'qs' @@ -16,8 +17,8 @@ const DEFAULT_QUERY_PARAMS = { protocols: protocols.map((p) => p.toLowerCase()).join(','), } -const baseQuery: BaseQueryFn = () => { - return { error: { status: 'CUSTOM_ERROR', error: 'Unimplemented baseQuery' } as FetchBaseQueryError } +const baseQuery: BaseQueryFn = () => { + return { error: { status: 'CUSTOM_ERROR', error: 'Unimplemented baseQuery' } } } export const routing = createApi({ @@ -25,33 +26,31 @@ export const routing = createApi({ baseQuery, serializeQueryArgs: serializeGetQuoteQueryArgs, endpoints: (build) => ({ - getTradeQuote: build.query({ - async onQueryStarted(args: GetQuoteArgs | SkipToken, { queryFulfilled }) { + getTradeQuote: build.query({ + async onQueryStarted(args, { queryFulfilled }) { if (args === skipToken) return args.onQuote?.( JSON.parse(serializeGetQuoteArgs(args)), - queryFulfilled - .catch((error) => { - const { error: queryError } = error - if (queryError && typeof queryError === 'object' && 'status' in queryError) { - const parsedError = queryError as FetchBaseQueryError - switch (parsedError.status) { - case 'CUSTOM_ERROR': - case 'FETCH_ERROR': - case 'PARSING_ERROR': - throw parsedError.error - default: - throw parsedError.status - } + toWidgetPromise(queryFulfilled, (error) => { + const { error: queryError } = error + if (queryError && typeof queryError === 'object' && 'status' in queryError) { + const parsedError = queryError as FetchBaseQueryError + switch (parsedError.status) { + case 'CUSTOM_ERROR': + case 'FETCH_ERROR': + case 'PARSING_ERROR': + throw new WidgetError({ message: parsedError.error, error: parsedError }) + default: + throw new WidgetError({ message: parsedError.status.toString(), error: parsedError }) } - throw queryError - }) - .then(({ data }) => data as TradeResult) + } + throw new WidgetError({ message: 'Unknown error', error }) + }).then(({ data }) => data as TradeResult) ) }, // Explicitly typing the return type enables typechecking of return values. - async queryFn(args: GetQuoteArgs | SkipToken): Promise<{ data: TradeResult } | { error: FetchBaseQueryError }> { + async queryFn(args: GetQuoteArgs | SkipToken) { if (args === skipToken) return { error: { status: 'CUSTOM_ERROR', error: 'Skipped' } } if ( diff --git a/src/state/swap/perf.ts b/src/state/swap/perf.ts index fd1b2084c..eba18909a 100644 --- a/src/state/swap/perf.ts +++ b/src/state/swap/perf.ts @@ -1,4 +1,5 @@ import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' +import { WidgetPromise } from 'errors' import { GetQuoteArgs, InterfaceTrade, TradeResult } from 'state/routing/types' import { ApprovalTransactionInfo, @@ -11,7 +12,7 @@ import { * An integration hook called when a new quote is fetched. * @param quote resolves with the quote when it is available. */ -export type OnSwapQuote = (args: Omit, quote: Promise) => void +export type OnSwapQuote = (args: Omit, quote: WidgetPromise) => void /** * An integration hook called when requesting a token allowance from the user. @@ -20,32 +21,30 @@ export type OnSwapQuote = (args: Omit, quo */ export type OnTokenAllowance = ( args: { token: Token; spender: string }, - transaction: Promise + transaction: WidgetPromise ) => void /** * An integration hook called when requesting a Permit2 token allowance from the user. * @param signed resolves when the permit is signed. */ -export type OnPermit2Allowance = (args: { token: Token; spender: string }, signed: Promise) => void +export type OnPermit2Allowance = (args: { token: Token; spender: string }, signed: WidgetPromise) => void /** * An integration hook called when sending a swap transaction to the mempool through the user. * NB: You may instrument the time-to-confirmation by calling ransaction.response.wait(). * @param transaction resolves with the swap transaction info when it is sent to the mempool. - * A void transaction indicates user rejection. */ -export type OnSwapSend = (args: { trade: InterfaceTrade }, transaction: Promise) => void +export type OnSwapSend = (args: { trade: InterfaceTrade }, transaction: WidgetPromise) => void /** * An integration hook called when sending a swap transaction to the mempool through the user. * NB: You may instrument the time-to-confirmation by calling ransaction.response.wait(). * @param transaction resolves with the swap transaction info when it is sent to the mempool. - * A void transaction indicates user rejection. */ export type OnWrapSend = ( args: { amount: CurrencyAmount }, - transaction: Promise + transaction: WidgetPromise ) => void export interface PerfEventHandlers { diff --git a/src/utils/jsonRpcError.ts b/src/utils/jsonRpcError.ts new file mode 100644 index 000000000..8721bbcdf --- /dev/null +++ b/src/utils/jsonRpcError.ts @@ -0,0 +1,30 @@ +import { ErrorCode } from 'constants/eip1193' + +export function getReason(error: any): string | undefined { + let reason: string | undefined + while (Boolean(error)) { + reason = error.reason ?? error.message ?? reason + error = error.error ?? error.data?.originalError + } + return reason +} + +export function isUserRejection(error: any): boolean { + const reason = getReason(error) + if ( + // EIP-1193 + error?.code === ErrorCode.USER_REJECTED_REQUEST || + // Ethers v5 (https://github.com/ethers-io/ethers.js/commit/d9897e0fdb5f9ca34822929c95a478634cc2a460) + error?.code === 'ACTION_REJECTED' || + // These error messages have been observed in the listed wallets: + (reason?.match(/request/i) && reason?.match(/reject/i)) || // Rainbow + reason?.match(/declined/i) || // Frame + reason?.match(/cancell?ed by user/i) || // SafePal + reason?.match(/user cancell?ed/i) || // Trust + reason?.match(/user denied/i) || // Coinbase + reason?.match(/user rejected/i) // Fireblocks + ) { + return true + } + return false +} diff --git a/src/utils/swapErrorToUserReadableMessage.tsx b/src/utils/swapErrorToUserReadableMessage.tsx index eb708cf91..08c8e0b57 100644 --- a/src/utils/swapErrorToUserReadableMessage.tsx +++ b/src/utils/swapErrorToUserReadableMessage.tsx @@ -1,14 +1,7 @@ import { t } from '@lingui/macro' import { ErrorCode } from 'constants/eip1193' -export function getReason(error: any): string | undefined { - let reason: string | undefined - while (Boolean(error)) { - reason = error.reason ?? error.message ?? reason - error = error.error ?? error.data?.originalError - } - return reason -} +import { getReason } from './jsonRpcError' /** * This is hacking out the revert reason from the ethers provider thrown error however it can.