From b762666616bbe2250cec3aed5ed6e514bb335656 Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Thu, 15 Dec 2022 10:27:09 -0800 Subject: [PATCH] fix: await syncing allowance to update permit state (#339) * fix: await syncing allowance to update permit state * fix: clarify pending permit2 usage --- .../Swap/SwapActionButton/Permit2Button.tsx | 38 ++++---- .../Swap/SwapActionButton/SwapButton.tsx | 2 +- src/hooks/useApproval.ts | 10 +-- src/hooks/usePermit2.ts | 89 ++++++++++++------- src/hooks/useTokenAllowance.ts | 19 ++-- 5 files changed, 97 insertions(+), 61 deletions(-) diff --git a/src/components/Swap/SwapActionButton/Permit2Button.tsx b/src/components/Swap/SwapActionButton/Permit2Button.tsx index fd4a70ae1..3b9bcedb2 100644 --- a/src/components/Swap/SwapActionButton/Permit2Button.tsx +++ b/src/components/Swap/SwapActionButton/Permit2Button.tsx @@ -3,42 +3,42 @@ import { PERMIT2_ADDRESS } from '@uniswap/permit2-sdk' import ActionButton from 'components/ActionButton' import EtherscanLink from 'components/EtherscanLink' import { usePendingApproval } from 'hooks/transactions' +import { Permit } from 'hooks/usePermit2' import { Spinner } from 'icons' import { useCallback, useEffect, useMemo, useState } from 'react' -import { InterfaceTrade } from 'state/routing/types' import { ApprovalTransactionInfo } from 'state/transactions' import { Colors } from 'theme' import { ExplorerDataType } from 'utils/getExplorerLink' +interface PermitButtonProps extends Permit { + color: keyof Colors + onSubmit: (submit: () => Promise) => Promise +} + /** * An approving PermitButton. * Should only be rendered if a valid trade exists that is not yet permitted. */ export default function PermitButton({ - color, - trade, + token, + isSyncing: isApprovalPending, callback, + color, onSubmit, -}: { - color: keyof Colors - trade?: InterfaceTrade - callback?: (isPendingApproval: boolean) => Promise - onSubmit: (submit: () => Promise) => Promise -}) { - const currency = trade?.inputAmount?.currency +}: PermitButtonProps) { const [isPending, setIsPending] = useState(false) const [isFailed, setIsFailed] = useState(false) - const pendingApproval = usePendingApproval(currency?.isToken ? currency : undefined, PERMIT2_ADDRESS) + const pendingApproval = usePendingApproval(token, PERMIT2_ADDRESS) useEffect(() => { // Reset pending/failed state if currency changes. setIsPending(false) setIsFailed(false) - }, [currency]) + }, [token]) const onClick = useCallback(async () => { setIsPending(true) try { - await onSubmit(async () => await callback?.(Boolean(pendingApproval))) + await onSubmit(async () => await callback?.()) setIsFailed(false) } catch (e) { console.error(e) @@ -46,7 +46,7 @@ export default function PermitButton({ } finally { setIsPending(false) } - }, [callback, onSubmit, pendingApproval]) + }, [callback, onSubmit]) const action = useMemo(() => { if (isPending) { @@ -54,13 +54,15 @@ export default function PermitButton({ icon: Spinner, message: t`Approve in your wallet`, } - } else if (pendingApproval) { + } else if (isApprovalPending) { return { icon: Spinner, - message: ( + message: pendingApproval ? ( Approval pending + ) : ( + Approval pending ), } } else if (isFailed) { @@ -71,11 +73,11 @@ export default function PermitButton({ } else { return { tooltipContent: t`Permission is required for Uniswap to swap each token. This will expire after one month for your security.`, - message: t`Approve use of ${currency?.symbol ?? 'token'}`, + message: t`Approve use of ${token ?? 'token'}`, onClick, } } - }, [currency?.symbol, isFailed, isPending, onClick, pendingApproval]) + }, [isApprovalPending, isFailed, isPending, onClick, pendingApproval, token]) return ( diff --git a/src/components/Swap/SwapActionButton/SwapButton.tsx b/src/components/Swap/SwapActionButton/SwapButton.tsx index 3de2852b1..33c5156ed 100644 --- a/src/components/Swap/SwapActionButton/SwapButton.tsx +++ b/src/components/Swap/SwapActionButton/SwapButton.tsx @@ -108,7 +108,7 @@ export default function SwapButton({ if (permit2Enabled) { if (!disabled && permit.state === PermitState.PERMIT_NEEDED) { - return + return } } else { if (!disabled && approval.state !== SwapApprovalState.APPROVED) { diff --git a/src/hooks/useApproval.ts b/src/hooks/useApproval.ts index 96eebdb20..10f3b85fa 100644 --- a/src/hooks/useApproval.ts +++ b/src/hooks/useApproval.ts @@ -22,22 +22,22 @@ export function useApprovalStateForSpender( const { account } = useWeb3React() const token = amountToApprove?.currency?.isToken ? amountToApprove.currency : undefined - const currentAllowance = useTokenAllowance(token, account ?? undefined, spender) + const { tokenAllowance } = useTokenAllowance(token, account ?? undefined, spender) const pendingApproval = useIsPendingApproval(token, spender) return useMemo(() => { if (!amountToApprove || !spender) return ApprovalState.UNKNOWN if (amountToApprove.currency.isNative) return ApprovalState.APPROVED // we might not have enough data to know whether or not we need to approve - if (!currentAllowance) return ApprovalState.UNKNOWN + if (!tokenAllowance) return ApprovalState.UNKNOWN - // amountToApprove will be defined if currentAllowance is - return currentAllowance.lessThan(amountToApprove) + // amountToApprove will be defined if tokenAllowance is + return tokenAllowance.lessThan(amountToApprove) ? pendingApproval ? ApprovalState.PENDING : ApprovalState.NOT_APPROVED : ApprovalState.APPROVED - }, [amountToApprove, currentAllowance, pendingApproval, spender]) + }, [amountToApprove, pendingApproval, spender, tokenAllowance]) } export function useApproval( diff --git a/src/hooks/usePermit2.ts b/src/hooks/usePermit2.ts index 04d904ad8..e6712fc1e 100644 --- a/src/hooks/usePermit2.ts +++ b/src/hooks/usePermit2.ts @@ -5,10 +5,17 @@ import { STANDARD_L1_BLOCK_TIME } from 'constants/chainInfo' import { useCallback, useEffect, useMemo, useState } from 'react' import { ApprovalTransactionInfo } from '..' +import { usePendingApproval } from './transactions' import useInterval from './useInterval' import { PermitSignature, usePermitAllowance, useUpdatePermitAllowance } from './usePermitAllowance' import { useTokenAllowance, useUpdateTokenAllowance } from './useTokenAllowance' +enum SyncState { + PENDING, + SYNCING, + SYNCED, +} + export enum PermitState { INVALID, LOADING, @@ -17,21 +24,24 @@ export enum PermitState { } export interface Permit { + token?: Token state: PermitState + isSyncing?: boolean signature?: PermitSignature - callback?: (isPendingApproval: boolean) => Promise + callback?: () => Promise } export default function usePermit(amount?: CurrencyAmount, spender?: string): Permit { const { account } = useWeb3React() - const tokenAllowance = useTokenAllowance(amount?.currency, account, PERMIT2_ADDRESS) + const token = amount?.currency + const { tokenAllowance, isSyncing: isApprovalSyncing } = useTokenAllowance(token, account, PERMIT2_ADDRESS) const updateTokenAllowance = useUpdateTokenAllowance(amount, PERMIT2_ADDRESS) const isAllowed = useMemo( () => amount && (tokenAllowance?.greaterThan(amount) || tokenAllowance?.equalTo(amount)), [amount, tokenAllowance] ) - const permitAllowance = usePermitAllowance(amount?.currency, spender) + const permitAllowance = usePermitAllowance(token, spender) const [permitAllowanceAmount, setPermitAllowanceAmount] = useState(permitAllowance?.amount) useEffect(() => setPermitAllowanceAmount(permitAllowance?.amount), [permitAllowance?.amount]) const isPermitted = useMemo( @@ -40,15 +50,10 @@ export default function usePermit(amount?: CurrencyAmount, spender?: stri ) const [signature, setSignature] = useState() - const updatePermitAllowance = useUpdatePermitAllowance( - amount?.currency, - spender, - permitAllowance?.nonce, - setSignature - ) + const updatePermitAllowance = useUpdatePermitAllowance(token, spender, permitAllowance?.nonce, setSignature) const isSigned = useMemo( - () => amount && signature?.details.token === amount?.currency.address && signature?.spender === spender, - [amount, signature?.details.token, signature?.spender, spender] + () => amount && signature?.details.token === token?.address && signature?.spender === spender, + [amount, signature?.details.token, signature?.spender, spender, token?.address] ) // Trigger a re-render if either tokenAllowance or signature expire. @@ -67,32 +72,54 @@ export default function usePermit(amount?: CurrencyAmount, spender?: stri true ) - const callback = useCallback( - async (isPendingApproval: boolean) => { - let info: ApprovalTransactionInfo | undefined - if (!isAllowed && !isPendingApproval) { - info = await updateTokenAllowance() - } - if (!isPermitted && !isSigned) { - await updatePermitAllowance() - } - return info - }, - [isAllowed, isPermitted, isSigned, updatePermitAllowance, updateTokenAllowance] - ) + // Permit2 should be marked syncing from the time approval is submitted (pending) until it is + // synced in tokenAllowance, to avoid re-prompting the user for an already-submitted approval. + // It should *not* be marked syncing if not permitted, because the user must still take action. + const [syncState, setSyncState] = useState(SyncState.SYNCED) + const isSyncing = isPermitted || isSigned ? false : syncState !== SyncState.SYNCED + const hasPendingApproval = Boolean(usePendingApproval(token, PERMIT2_ADDRESS)) + useEffect(() => { + if (hasPendingApproval) { + setSyncState(SyncState.PENDING) + } else { + setSyncState((state) => { + if (state === SyncState.PENDING && isApprovalSyncing) { + return SyncState.SYNCING + } else if (state === SyncState.SYNCING && !isApprovalSyncing) { + return SyncState.SYNCED + } else { + return state + } + }) + } + }, [hasPendingApproval, isApprovalSyncing]) + + const callback = useCallback(async () => { + let info: ApprovalTransactionInfo | undefined + if (!isAllowed && !hasPendingApproval) { + info = await updateTokenAllowance() + } + if (!isPermitted && !isSigned) { + await updatePermitAllowance() + } + return info + }, [hasPendingApproval, isAllowed, isPermitted, isSigned, updatePermitAllowance, updateTokenAllowance]) return useMemo(() => { - if (!amount) { + if (!token) { return { state: PermitState.INVALID } - } else if (!tokenAllowance || !permitAllowance) { - return { state: PermitState.LOADING } + } + + if (!tokenAllowance || !permitAllowance) { + return { token, state: PermitState.LOADING } } else if (isAllowed) { if (isPermitted) { - return { state: PermitState.PERMITTED } + return { token, state: PermitState.PERMITTED } } else if (isSigned) { - return { state: PermitState.PERMITTED, signature } + return { token, state: PermitState.PERMITTED, signature } } } - return { state: PermitState.PERMIT_NEEDED, callback } - }, [amount, callback, isAllowed, isPermitted, isSigned, permitAllowance, signature, tokenAllowance]) + + return { token, state: PermitState.PERMIT_NEEDED, isSyncing, callback } + }, [callback, isAllowed, isPermitted, isSigned, isSyncing, permitAllowance, signature, token, tokenAllowance]) } diff --git a/src/hooks/useTokenAllowance.ts b/src/hooks/useTokenAllowance.ts index 798a0c317..b0ba10de6 100644 --- a/src/hooks/useTokenAllowance.ts +++ b/src/hooks/useTokenAllowance.ts @@ -7,16 +7,23 @@ import { calculateGasMargin } from 'utils/calculateGasMargin' import { useTokenContract } from './useContract' -export function useTokenAllowance(token?: Token, owner?: string, spender?: string): CurrencyAmount | undefined { +export function useTokenAllowance( + token?: Token, + owner?: string, + spender?: string +): { + tokenAllowance: CurrencyAmount | undefined + isSyncing: boolean +} { const contract = useTokenContract(token?.address, false) const inputs = useMemo(() => [owner, spender], [owner, spender]) - const allowance = useSingleCallResult(contract, 'allowance', inputs).result + const { result, syncing: isSyncing } = useSingleCallResult(contract, 'allowance', inputs) - return useMemo( - () => (token && allowance ? CurrencyAmount.fromRawAmount(token, allowance.toString()) : undefined), - [token, allowance] - ) + return useMemo(() => { + const tokenAllowance = token && result && CurrencyAmount.fromRawAmount(token, result.toString()) + return { tokenAllowance, isSyncing } + }, [isSyncing, result, token]) } export function useUpdateTokenAllowance(amount: CurrencyAmount | undefined, spender: string) {