From 9d4c776af51b225e408a40ceacd9186b3b327a4e Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Fri, 17 Mar 2023 09:53:50 -0700 Subject: [PATCH] fix: narrow WidgetPromise typings (#565) * fix: narrow WidgetPromise typings * refactor: toWidgetPromise->WidgetPromise.from * docs: improve WidgetPromise documentation * fix: enforce WidgetError mapping at runtime * fix: always include UnknownError in WidgetPromise * build: export UnknownError to fix build * chore: simplify usage --- src/errors.ts | 58 ++++++++++++----- src/hooks/swap/useWrapCallback.tsx | 61 +++++++++--------- src/hooks/usePerfEventHandler.ts | 8 +-- src/hooks/usePermitAllowance.ts | 71 ++++++++++---------- src/hooks/useTokenAllowance.ts | 73 ++++++++++----------- src/hooks/useUniversalRouter.ts | 100 +++++++++++++---------------- src/index.tsx | 2 +- src/state/routing/slice.ts | 32 +++++---- 8 files changed, 218 insertions(+), 187 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index d909a6c66..ba8f34d91 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -28,23 +28,51 @@ export class WidgetError extends Error { } } -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 class UnknownError extends WidgetError { + constructor(config: WidgetErrorConfig) { + super(config) + this.name = 'UnknownError' + } } -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 +/** + * A Promise which rejects with a known WidgetError. + * Although it is well-typed, this typing only works when using the Promise as a Thennable, not through async/await. + * @example widgetPromise.catch((reason: WidgetError) => console.error(reason.error)) + */ +export class WidgetPromise extends Promise { + static from< + P extends { then(onfulfilled: (value: any) => any): any; catch(onrejected: (reason: any) => any): any }, + V extends Parameters[0]>[0], + R extends Parameters[0]>[0], + WidgetValue = V, + WidgetReason extends WidgetError = WidgetError + >( + value: P | (() => P), + /** Synchronously maps the value to the WidgetPromise value. Any thrown reason must be mappable by onrejected. */ + onfulfilled: ((value: V) => WidgetValue) | null, + /** + * Synchronously maps the reason to the WidgetPromise reason. Must throw the mapped reason. + * @throws {@link WidgetReason} + */ + onrejected: (reason: R) => never + ): WidgetPromise { + return ('then' in value ? value : value()).then(onfulfilled ?? ((v) => v)).catch((reason: R) => { + try { + onrejected(reason) + } catch (error) { + // > Must throw the mapped reason. + // This cannot actually be enforced in TypeScript, so this bit is unsafe: + // the best we can do is check that it's a WidgetError at runtime and wrap it if it's not. + if (error instanceof WidgetError) throw error + throw new UnknownError({ message: `Unknown error: ${error.toString()}`, error }) + } + }) as WidgetPromise + } + + catch(onrejected?: ((reason: R) => T | Promise) | undefined | null): Promise { + return super.catch(onrejected) + } } /** Integration errors are considered fatal. They are caused by invalid integrator configuration. */ diff --git a/src/hooks/swap/useWrapCallback.tsx b/src/hooks/swap/useWrapCallback.tsx index 8063fea88..bccdbbd20 100644 --- a/src/hooks/swap/useWrapCallback.tsx +++ b/src/hooks/swap/useWrapCallback.tsx @@ -1,6 +1,6 @@ import { useWeb3React } from '@web3-react/core' import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens' -import { DismissableError, UserRejectedRequestError } from 'errors' +import { DismissableError, UserRejectedRequestError, WidgetPromise } from 'errors' import { useWETHContract } from 'hooks/useContract' import { usePerfEventHandler } from 'hooks/usePerfEventHandler' import { useAtomValue } from 'jotai/utils' @@ -45,35 +45,38 @@ export default function useWrapCallback(): UseWrapCallbackReturns { [inputCurrency, amount] ) - 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, + const wrapCallback = useCallback( + () => + WidgetPromise.from( + async () => { + if (!parsedAmountIn) throw new Error('missing amount') + if (!wrappedNativeCurrencyContract) throw new Error('missing contract') + if (wrapType === undefined) throw new Error('missing wrapType') + switch (wrapType) { + case TransactionType.WRAP: + return { + response: await wrappedNativeCurrencyContract.deposit({ + value: `0x${parsedAmountIn.quotient.toString(16)}`, + }), + type: TransactionType.WRAP, + amount: parsedAmountIn, + } as WrapTransactionInfo + case TransactionType.UNWRAP: + return { + response: await wrappedNativeCurrencyContract.withdraw(`0x${parsedAmountIn.quotient.toString(16)}`), + type: TransactionType.UNWRAP, + amount: parsedAmountIn, + } as UnwrapTransactionInfo } - 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]) + }, + null, + (error) => { + if (isUserRejection(error)) throw new UserRejectedRequestError() + throw new DismissableError({ message: (error as any)?.message ?? error, error }) + } + ), + [parsedAmountIn, wrappedNativeCurrencyContract, wrapType] + ) const args = useMemo(() => parsedAmountIn && { amount: parsedAmountIn }, [parsedAmountIn]) const callback = usePerfEventHandler('onWrapSend', args, wrapCallback) diff --git a/src/hooks/usePerfEventHandler.ts b/src/hooks/usePerfEventHandler.ts index 0af8cfc3a..5cca45cb2 100644 --- a/src/hooks/usePerfEventHandler.ts +++ b/src/hooks/usePerfEventHandler.ts @@ -11,14 +11,14 @@ export function usePerfEventHandler< Key extends keyof PerfEventHandlers, Params extends Parameters>, Args extends Params[0], - Event extends Awaited, - Handler extends PerfEventHandlers[Key] & ((args: Args, event: Promise) => void) ->(name: Key, args: Args | undefined, callback: () => Promise): () => Promise { + Event extends Params[1] & Promise>, + Handler extends PerfEventHandlers[Key] & ((args: Args, event: Event) => void) +>(name: Key, args: Args | undefined, callback: () => Event): () => Event { const perfHandler = useAtomValue(swapEventHandlersAtom)[name] as Handler return useCallback(() => { // Use Promise.resolve().then to defer the execution of the callback until after the perfHandler has executed. // This ensures that the perfHandler can capture the beginning of the callback's execution. - const event = Promise.resolve().then(callback) + const event = Promise.resolve().then(callback) as Event if (args) { perfHandler?.(args, event) } diff --git a/src/hooks/usePermitAllowance.ts b/src/hooks/usePermitAllowance.ts index 07e559b0a..39337aa59 100644 --- a/src/hooks/usePermitAllowance.ts +++ b/src/hooks/usePermitAllowance.ts @@ -5,7 +5,7 @@ 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 { UserRejectedRequestError, WidgetError, WidgetPromise } from 'errors' import { useSingleCallResult } from 'hooks/multicall' import { useContract } from 'hooks/useContract' import ms from 'ms.macro' @@ -61,42 +61,45 @@ export function useUpdatePermitAllowance( ) { const { account, chainId, provider } = useWeb3React() - const updatePermitAllowance = useCallback(async () => { - try { - if (!chainId) throw new Error('missing chainId') - if (!provider) throw new Error('missing provider') - if (!token) throw new Error('missing token') - if (!spender) throw new Error('missing spender') - if (nonce === undefined) throw new Error('missing nonce') + const updatePermitAllowance = useCallback( + () => + WidgetPromise.from( + async () => { + if (!chainId) throw new Error('missing chainId') + if (!provider) throw new Error('missing provider') + if (!token) throw new Error('missing token') + if (!spender) throw new Error('missing spender') + if (nonce === undefined) throw new Error('missing nonce') - const permit: Permit = { - details: { - token: token.address, - amount: MaxAllowanceTransferAmount, - expiration: toDeadline(PERMIT_EXPIRATION), - nonce, + const permit: Permit = { + details: { + token: token.address, + amount: MaxAllowanceTransferAmount, + expiration: toDeadline(PERMIT_EXPIRATION), + nonce, + }, + spender, + sigDeadline: toDeadline(PERMIT_SIG_EXPIRATION), + } + + const { domain, types, values } = AllowanceTransfer.getPermitData(permit, PERMIT2_ADDRESS, chainId) + // Use conedison's signTypedData for better x-wallet compatibility. + const signature = await signTypedData(provider.getSigner(account), domain, types, values) + onPermitSignature?.({ ...permit, signature }) }, - spender, - sigDeadline: toDeadline(PERMIT_SIG_EXPIRATION), - } + null, + (error) => { + if (isUserRejection(error)) throw new UserRejectedRequestError() - const { domain, types, values } = AllowanceTransfer.getPermitData(permit, PERMIT2_ADDRESS, chainId) - // Use conedison's signTypedData for better x-wallet compatibility. - const signature = await signTypedData(provider.getSigner(account), domain, types, values) - onPermitSignature?.({ ...permit, signature }) - return - } 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]) + 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] + ) const args = useMemo(() => (token && spender ? { token, spender } : undefined), [spender, token]) return usePerfEventHandler('onPermit2Allowance', args, updatePermitAllowance) diff --git a/src/hooks/useTokenAllowance.ts b/src/hooks/useTokenAllowance.ts index 172e2e7b0..3ef283a17 100644 --- a/src/hooks/useTokenAllowance.ts +++ b/src/hooks/useTokenAllowance.ts @@ -2,7 +2,7 @@ import { BigNumberish } from '@ethersproject/bignumber' import { t } from '@lingui/macro' import { CurrencyAmount, MaxUint256, Token } from '@uniswap/sdk-core' import { Erc20 } from 'abis/types' -import { UserRejectedRequestError, WidgetError } from 'errors' +import { UserRejectedRequestError, WidgetError, WidgetPromise } from 'errors' import { useSingleCallResult } from 'hooks/multicall' import { useTokenContract } from 'hooks/useContract' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -41,45 +41,46 @@ export function useTokenAllowance( return useMemo(() => ({ tokenAllowance: allowance, isSyncing }), [allowance, isSyncing]) } -export function useUpdateTokenAllowance( - amount: CurrencyAmount | undefined, - spender: string -): () => Promise { +export function useUpdateTokenAllowance(amount: CurrencyAmount | undefined, spender: string) { const contract = useTokenContract(amount?.currency.address) - const updateTokenAllowance = useCallback(async (): Promise => { - try { - if (!amount) throw new Error('missing amount') - if (!contract) throw new Error('missing contract') - if (!spender) throw new Error('missing spender') + const updateTokenAllowance = useCallback( + () => + WidgetPromise.from( + async () => { + if (!amount) throw new Error('missing amount') + if (!contract) throw new Error('missing contract') + if (!spender) throw new Error('missing spender') - let allowance: BigNumberish = MaxUint256.toString() - const estimatedGas = await contract.estimateGas.approve(spender, allowance).catch(() => { - // Fallback for tokens which restrict approval amounts: - allowance = amount.quotient.toString() - return contract.estimateGas.approve(spender, allowance) - }) + let allowance: BigNumberish = MaxUint256.toString() + const estimatedGas = await contract.estimateGas.approve(spender, allowance).catch(() => { + // Fallback for tokens which restrict approval amounts: + allowance = amount.quotient.toString() + return contract.estimateGas.approve(spender, allowance) + }) - const gasLimit = calculateGasMargin(estimatedGas) - const response = await contract.approve(spender, allowance, { gasLimit }) - return { - type: TransactionType.APPROVAL, - response, - tokenAddress: contract.address, - spenderAddress: spender, - } - } catch (error: unknown) { - if (isUserRejection(error)) { - throw new UserRejectedRequestError() - } else { - const symbol = amount?.currency.symbol ?? 'Token' - throw new WidgetError({ - message: t`${symbol} token allowance failed: ${(error as any)?.message ?? error}`, - error, - }) - } - } - }, [amount, contract, spender]) + const gasLimit = calculateGasMargin(estimatedGas) + const response = await contract.approve(spender, allowance, { gasLimit }) + return { + type: TransactionType.APPROVAL, + response, + tokenAddress: contract.address, + spenderAddress: spender, + } as ApprovalTransactionInfo + }, + null, + (error) => { + if (isUserRejection(error)) throw new UserRejectedRequestError() + + const symbol = amount?.currency.symbol ?? 'Token' + throw new WidgetError({ + message: t`${symbol} token allowance failed: ${(error as any)?.message ?? error}`, + error, + }) + } + ), + [amount, contract, spender] + ) const args = useMemo(() => (amount && spender ? { token: amount.currency, spender } : undefined), [amount, spender]) return usePerfEventHandler('onTokenAllowance', args, updateTokenAllowance) diff --git a/src/hooks/useUniversalRouter.ts b/src/hooks/useUniversalRouter.ts index 99dfdc91f..946bb3555 100644 --- a/src/hooks/useUniversalRouter.ts +++ b/src/hooks/useUniversalRouter.ts @@ -1,5 +1,4 @@ import { BigNumber } from '@ethersproject/bignumber' -import { TransactionRequest, TransactionResponse } from '@ethersproject/providers' import { t } from '@lingui/macro' import { sendTransaction } from '@uniswap/conedison/provider/index' import { Percent } from '@uniswap/sdk-core' @@ -7,7 +6,7 @@ import { SwapRouter, UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router- import { FeeOptions, toHex } from '@uniswap/v3-sdk' import { useWeb3React } from '@web3-react/core' import { TX_GAS_MARGIN } from 'constants/misc' -import { DismissableError, UserRejectedRequestError } from 'errors' +import { DismissableError, UserRejectedRequestError, WidgetPromise } from 'errors' import { useCallback, useMemo } from 'react' import { InterfaceTrade } from 'state/routing/types' import { SwapTransactionInfo, TransactionType } from 'state/transactions' @@ -34,61 +33,54 @@ interface SwapOptions { export function useUniversalRouterSwapCallback(trade: InterfaceTrade | undefined, options: SwapOptions) { const { account, chainId, provider } = useWeb3React() - const swapCallback = useCallback(async (): Promise => { - let tx: TransactionRequest - let response: TransactionResponse - try { - if (!account) throw new Error('missing account') - if (!chainId) throw new Error('missing chainId') - if (!provider) throw new Error('missing provider') - if (!trade) throw new Error('missing trade') + const swapCallback = useCallback( + () => + WidgetPromise.from( + async () => { + if (!account) throw new Error('missing account') + if (!chainId) throw new Error('missing chainId') + if (!provider) throw new Error('missing provider') + if (!trade) throw new Error('missing trade') - const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, { - slippageTolerance: options.slippageTolerance, - deadlineOrPreviousBlockhash: options.deadline?.toString(), - inputTokenPermit: options.permit, - fee: options.feeOptions, - }) - tx = { - from: account, - to: UNIVERSAL_ROUTER_ADDRESS(chainId), - data, - // TODO: universal-router-sdk returns a non-hexlified value. - ...(value && !isZero(value) ? { value: toHex(value) } : {}), - } + const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, { + slippageTolerance: options.slippageTolerance, + deadlineOrPreviousBlockhash: options.deadline?.toString(), + inputTokenPermit: options.permit, + fee: options.feeOptions, + }) + const tx = { + from: account, + to: UNIVERSAL_ROUTER_ADDRESS(chainId), + data, + // TODO: universal-router-sdk returns a non-hexlified value. + ...(value && !isZero(value) ? { value: toHex(value) } : {}), + } - response = await sendTransaction(provider, tx, TX_GAS_MARGIN) - } catch (error: unknown) { - if (isUserRejection(error)) { - throw new UserRejectedRequestError() - } else { - throw new DismissableError({ message: swapErrorToUserReadableMessage(error), error }) - } - } - if (tx.data !== response.data) { - 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.', - }) - } + const response = await sendTransaction(provider, tx, TX_GAS_MARGIN) + if (tx.data !== response.data) { + 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.', + }) + } - return { - type: TransactionType.SWAP, - response, - tradeType: trade.tradeType, - trade, - slippageTolerance: options.slippageTolerance, - } - }, [ - account, - chainId, - options.deadline, - options.feeOptions, - options.permit, - options.slippageTolerance, - provider, - trade, - ]) + return { + type: TransactionType.SWAP, + response, + tradeType: trade.tradeType, + trade, + slippageTolerance: options.slippageTolerance, + } as SwapTransactionInfo + }, + null, + (error) => { + if (error instanceof DismissableError) throw error + if (isUserRejection(error)) throw new UserRejectedRequestError() + throw new DismissableError({ message: swapErrorToUserReadableMessage(error), error }) + } + ), + [account, chainId, options.deadline, options.feeOptions, options.permit, options.slippageTolerance, provider, trade] + ) const args = useMemo(() => trade && { trade }, [trade]) return usePerfEventHandler('onSwapSend', args, swapCallback) diff --git a/src/index.tsx b/src/index.tsx index 4e51a9570..23ff1b246 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,7 +18,7 @@ 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 { UnknownError, 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 b507d1223..347bff042 100644 --- a/src/state/routing/slice.ts +++ b/src/state/routing/slice.ts @@ -1,6 +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 { WidgetError, WidgetPromise } from 'errors' import { RouterPreference } from 'hooks/routing/types' import ms from 'ms.macro' import qs from 'qs' @@ -32,21 +32,25 @@ export const routing = createApi({ args.onQuote?.( JSON.parse(serializeGetQuoteArgs(args)), - 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 }) + WidgetPromise.from( + queryFulfilled, + ({ data }) => data, + (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 new WidgetError({ message: 'Unknown error', error }) } - throw new WidgetError({ message: 'Unknown error', error }) - }).then(({ data }) => data as TradeResult) + ) ) }, // Explicitly typing the return type enables typechecking of return values.