Skip to content

Commit

Permalink
fix: use standardized WidgetPromise for perf event handlers (#560)
Browse files Browse the repository at this point in the history
* fix: use UserRejectedRequestError for all rejections

* fix: ignore appropriate errors

* fix: ignore user rejections at the error boundary

* test: user rejections do not throw

* feat: WidgetError/WidgetPromise

* fix: nits

* fix: tighten router typings
  • Loading branch information
zzmp authored Mar 16, 2023
1 parent f7b7602 commit 107ff7c
Show file tree
Hide file tree
Showing 15 changed files with 208 additions and 121 deletions.
26 changes: 26 additions & 0 deletions src/components/Error/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
7 changes: 5 additions & 2 deletions src/components/Error/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -28,10 +28,13 @@ type ErrorBoundaryState = {
* }, [throwError])
*/
export function useAsyncError() {
const [, setError] = useState()
const [, setError] = useState<void>()
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)
}),
Expand Down
6 changes: 4 additions & 2 deletions src/components/Swap/SwapActionButton/ApproveButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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 || ''
Expand Down
6 changes: 4 additions & 2 deletions src/components/Swap/SwapActionButton/WrapButton.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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(
() =>
Expand Down
63 changes: 42 additions & 21 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,53 +9,66 @@ 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<T> extends Omit<Promise<T>, 'then' | 'catch'> {
then: <V>(
/** @throws {@link WidgetError} */
onfulfilled: (value: T) => V
) => WidgetPromise<V>
catch: <V>(
/** @throws {@link WidgetError} */
onrejected: (reason: WidgetError) => V
) => WidgetPromise<V>
}

export function toWidgetPromise<
P extends { then(onfulfilled: (value: any) => any): any; catch(onrejected: (reason: any) => any): any },
V extends Parameters<Parameters<P['then']>[0]>[0],
R extends Parameters<Parameters<P['catch']>[0]>[0]
>(promise: P, mapRejection: (reason: R) => WidgetError): WidgetPromise<V> {
return promise.catch(mapRejection) as WidgetPromise<V>
}

/** Integration errors are considered fatal. They are caused by invalid integrator configuration. */
export class IntegrationError extends WidgetError {
constructor(message: string) {
super({ message })
this.name = 'IntegrationError'
}
}

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`,
Expand All @@ -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({
Expand Down
10 changes: 5 additions & 5 deletions src/hooks/swap/useSendSwapTransaction.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)}`,
})
}
Expand Down
46 changes: 29 additions & 17 deletions src/hooks/swap/useWrapCallback.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -43,23 +45,33 @@ export default function useWrapCallback(): UseWrapCallbackReturns {
[inputCurrency, amount]
)

const wrapCallback = useCallback(async (): Promise<WrapTransactionInfo | UnwrapTransactionInfo | void> => {
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<WrapTransactionInfo | UnwrapTransactionInfo> => {
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])

Expand Down
16 changes: 13 additions & 3 deletions src/hooks/usePermitAllowance.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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])

Expand Down
16 changes: 10 additions & 6 deletions src/hooks/useTokenAllowance.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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])
Expand Down
Loading

1 comment on commit 107ff7c

@vercel
Copy link

@vercel vercel bot commented on 107ff7c Mar 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

widgets – ./

widgets-seven-tau.vercel.app
widgets-git-main-uniswap.vercel.app
widgets-uniswap.vercel.app

Please sign in to comment.