Skip to content

Commit

Permalink
fix: narrow WidgetPromise typings (Uniswap#565)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zzmp committed Mar 17, 2023
1 parent 107ff7c commit 9d4c776
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 187 deletions.
58 changes: 43 additions & 15 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,51 @@ export class WidgetError extends Error {
}
}

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 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<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>
/**
* 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<V, R extends WidgetError = WidgetError> extends Promise<V> {
static from<
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],
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<WidgetValue, WidgetReason & UnknownError> {
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<WidgetValue, WidgetReason>
}

catch<T = never>(onrejected?: ((reason: R) => T | Promise<T>) | undefined | null): Promise<V | T> {
return super.catch(onrejected)
}
}

/** Integration errors are considered fatal. They are caused by invalid integrator configuration. */
Expand Down
61 changes: 32 additions & 29 deletions src/hooks/swap/useWrapCallback.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -45,35 +45,38 @@ export default function useWrapCallback(): UseWrapCallbackReturns {
[inputCurrency, amount]
)

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,
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)
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/usePerfEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ export function usePerfEventHandler<
Key extends keyof PerfEventHandlers,
Params extends Parameters<NonNullable<PerfEventHandlers[Key]>>,
Args extends Params[0],
Event extends Awaited<Params[1]>,
Handler extends PerfEventHandlers[Key] & ((args: Args, event: Promise<Event>) => void)
>(name: Key, args: Args | undefined, callback: () => Promise<Event>): () => Promise<Event> {
Event extends Params[1] & Promise<Awaited<Params[1]>>,
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)
}
Expand Down
71 changes: 37 additions & 34 deletions src/hooks/usePermitAllowance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
73 changes: 37 additions & 36 deletions src/hooks/useTokenAllowance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -41,45 +41,46 @@ export function useTokenAllowance(
return useMemo(() => ({ tokenAllowance: allowance, isSyncing }), [allowance, isSyncing])
}

export function useUpdateTokenAllowance(
amount: CurrencyAmount<Token> | undefined,
spender: string
): () => Promise<ApprovalTransactionInfo> {
export function useUpdateTokenAllowance(amount: CurrencyAmount<Token> | undefined, spender: string) {
const contract = useTokenContract(amount?.currency.address)

const updateTokenAllowance = useCallback(async (): Promise<ApprovalTransactionInfo> => {
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)
Expand Down
Loading

0 comments on commit 9d4c776

Please sign in to comment.