Commit 79a72d6f authored by eddie's avatar eddie Committed by GitHub

feat: update permit2 hook to allow revoking (#6727)

* feat: update permit2 hook to allow revoking

* fix: types

* fix: rename reset to revoke

* fix: re-use useUpdateTokenAllowance

* fix: add tests

* fix: improve code style

* fix: undefined nit
parent f3bfd4ad
...@@ -3,10 +3,10 @@ import { CurrencyAmount, Token } from '@uniswap/sdk-core' ...@@ -3,10 +3,10 @@ import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { AVERAGE_L1_BLOCK_TIME } from 'constants/chainInfo' import { AVERAGE_L1_BLOCK_TIME } from 'constants/chainInfo'
import { PermitSignature, usePermitAllowance, useUpdatePermitAllowance } from 'hooks/usePermitAllowance' import { PermitSignature, usePermitAllowance, useUpdatePermitAllowance } from 'hooks/usePermitAllowance'
import { useTokenAllowance, useUpdateTokenAllowance } from 'hooks/useTokenAllowance' import { useRevokeTokenAllowance, useTokenAllowance, useUpdateTokenAllowance } from 'hooks/useTokenAllowance'
import useInterval from 'lib/hooks/useInterval' import useInterval from 'lib/hooks/useInterval'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useHasPendingApproval, useTransactionAdder } from 'state/transactions/hooks' import { useHasPendingApproval, useHasPendingRevocation, useTransactionAdder } from 'state/transactions/hooks'
enum ApprovalState { enum ApprovalState {
PENDING, PENDING,
...@@ -25,11 +25,14 @@ interface AllowanceRequired { ...@@ -25,11 +25,14 @@ interface AllowanceRequired {
token: Token token: Token
isApprovalLoading: boolean isApprovalLoading: boolean
isApprovalPending: boolean isApprovalPending: boolean
isRevocationPending: boolean
approveAndPermit: () => Promise<void> approveAndPermit: () => Promise<void>
approve: () => Promise<void> approve: () => Promise<void>
permit: () => Promise<void> permit: () => Promise<void>
revoke: () => Promise<void>
needsPermit2Approval: boolean needsPermit2Approval: boolean
needsSignature: boolean needsSignature: boolean
allowedAmount: CurrencyAmount<Token>
} }
export type Allowance = export type Allowance =
...@@ -46,6 +49,7 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -46,6 +49,7 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
const { tokenAllowance, isSyncing: isApprovalSyncing } = useTokenAllowance(token, account, PERMIT2_ADDRESS) const { tokenAllowance, isSyncing: isApprovalSyncing } = useTokenAllowance(token, account, PERMIT2_ADDRESS)
const updateTokenAllowance = useUpdateTokenAllowance(amount, PERMIT2_ADDRESS) const updateTokenAllowance = useUpdateTokenAllowance(amount, PERMIT2_ADDRESS)
const revokeTokenAllowance = useRevokeTokenAllowance(token, PERMIT2_ADDRESS)
const isApproved = useMemo(() => { const isApproved = useMemo(() => {
if (!amount || !tokenAllowance) return false if (!amount || !tokenAllowance) return false
return tokenAllowance.greaterThan(amount) || tokenAllowance.equalTo(amount) return tokenAllowance.greaterThan(amount) || tokenAllowance.equalTo(amount)
...@@ -57,6 +61,8 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -57,6 +61,8 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
const [approvalState, setApprovalState] = useState(ApprovalState.SYNCED) const [approvalState, setApprovalState] = useState(ApprovalState.SYNCED)
const isApprovalLoading = approvalState !== ApprovalState.SYNCED const isApprovalLoading = approvalState !== ApprovalState.SYNCED
const isApprovalPending = useHasPendingApproval(token, PERMIT2_ADDRESS) const isApprovalPending = useHasPendingApproval(token, PERMIT2_ADDRESS)
const isRevocationPending = useHasPendingRevocation(token, PERMIT2_ADDRESS)
useEffect(() => { useEffect(() => {
if (isApprovalPending) { if (isApprovalPending) {
setApprovalState(ApprovalState.PENDING) setApprovalState(ApprovalState.PENDING)
...@@ -107,17 +113,14 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -107,17 +113,14 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
}, [addTransaction, shouldRequestApproval, shouldRequestSignature, updatePermitAllowance, updateTokenAllowance]) }, [addTransaction, shouldRequestApproval, shouldRequestSignature, updatePermitAllowance, updateTokenAllowance])
const approve = useCallback(async () => { const approve = useCallback(async () => {
if (shouldRequestApproval) {
const { response, info } = await updateTokenAllowance() const { response, info } = await updateTokenAllowance()
addTransaction(response, info) addTransaction(response, info)
} }, [addTransaction, updateTokenAllowance])
}, [addTransaction, shouldRequestApproval, updateTokenAllowance])
const permit = useCallback(async () => { const revoke = useCallback(async () => {
if (shouldRequestSignature) { const { response, info } = await revokeTokenAllowance()
await updatePermitAllowance() addTransaction(response, info)
} }, [addTransaction, revokeTokenAllowance])
}, [shouldRequestSignature, updatePermitAllowance])
return useMemo(() => { return useMemo(() => {
if (token) { if (token) {
...@@ -129,11 +132,14 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -129,11 +132,14 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
state: AllowanceState.REQUIRED, state: AllowanceState.REQUIRED,
isApprovalLoading: false, isApprovalLoading: false,
isApprovalPending, isApprovalPending,
isRevocationPending,
approveAndPermit, approveAndPermit,
approve, approve,
permit, permit: updatePermitAllowance,
revoke,
needsPermit2Approval: !isApproved, needsPermit2Approval: !isApproved,
needsSignature: shouldRequestSignature, needsSignature: shouldRequestSignature,
allowedAmount: tokenAllowance,
} }
} else if (!isApproved) { } else if (!isApproved) {
return { return {
...@@ -141,11 +147,14 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -141,11 +147,14 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
state: AllowanceState.REQUIRED, state: AllowanceState.REQUIRED,
isApprovalLoading, isApprovalLoading,
isApprovalPending, isApprovalPending,
isRevocationPending,
approveAndPermit, approveAndPermit,
approve, approve,
permit, permit: updatePermitAllowance,
revoke,
needsPermit2Approval: true, needsPermit2Approval: true,
needsSignature: shouldRequestSignature, needsSignature: shouldRequestSignature,
allowedAmount: tokenAllowance,
} }
} }
} }
...@@ -153,8 +162,8 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -153,8 +162,8 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
token, token,
state: AllowanceState.ALLOWED, state: AllowanceState.ALLOWED,
permitSignature: !isPermitted && isSigned ? signature : undefined, permitSignature: !isPermitted && isSigned ? signature : undefined,
needsPermit2Approval: false, needsSetupApproval: false,
needsSignature: false, needsPermitSignature: false,
} }
}, [ }, [
approve, approve,
...@@ -164,8 +173,10 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen ...@@ -164,8 +173,10 @@ export default function usePermit2Allowance(amount?: CurrencyAmount<Token>, spen
isApproved, isApproved,
isPermitted, isPermitted,
isSigned, isSigned,
permit, updatePermitAllowance,
permitAllowance, permitAllowance,
revoke,
isRevocationPending,
shouldRequestSignature, shouldRequestSignature,
signature, signature,
token, token,
......
...@@ -68,3 +68,10 @@ export function useUpdateTokenAllowance( ...@@ -68,3 +68,10 @@ export function useUpdateTokenAllowance(
} }
}, [amount, contract, spender]) }, [amount, contract, spender])
} }
export function useRevokeTokenAllowance(
token: Token | undefined,
spender: string
): () => Promise<{ response: ContractTransaction; info: ApproveTransactionInfo }> {
return useUpdateTokenAllowance(token ? CurrencyAmount.fromRawAmount(token, 0) : undefined, spender)
}
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { parseEther } from '@ethersproject/units' import { parseEther } from '@ethersproject/units'
import { Percent, SupportedChainId } from '@uniswap/sdk-core' import { CurrencyAmount, Percent, SupportedChainId } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { nativeOnChain } from 'constants/tokens' import { nativeOnChain } from 'constants/tokens'
import { useNftUniversalRouterAddress } from 'graphql/data/nft/NftUniversalRouterAddress' import { useNftUniversalRouterAddress } from 'graphql/data/nft/NftUniversalRouterAddress'
...@@ -347,8 +347,11 @@ describe('BagFooter.tsx', () => { ...@@ -347,8 +347,11 @@ describe('BagFooter.tsx', () => {
approveAndPermit: () => Promise.resolve(), approveAndPermit: () => Promise.resolve(),
approve: () => Promise.resolve(), approve: () => Promise.resolve(),
permit: () => Promise.resolve(), permit: () => Promise.resolve(),
revoke: () => Promise.resolve(),
needsPermit2Approval: false, needsPermit2Approval: false,
needsSignature: false, needsSignature: false,
isRevocationPending: false,
allowedAmount: CurrencyAmount.fromRawAmount(TEST_TOKEN_1, 0),
}, },
isAllowancePending: false, isAllowancePending: false,
isApprovalLoading: true, isApprovalLoading: true,
...@@ -372,8 +375,11 @@ describe('BagFooter.tsx', () => { ...@@ -372,8 +375,11 @@ describe('BagFooter.tsx', () => {
approveAndPermit: () => Promise.resolve(), approveAndPermit: () => Promise.resolve(),
approve: () => Promise.resolve(), approve: () => Promise.resolve(),
permit: () => Promise.resolve(), permit: () => Promise.resolve(),
revoke: () => Promise.resolve(),
needsPermit2Approval: false, needsPermit2Approval: false,
needsSignature: false, needsSignature: false,
isRevocationPending: false,
allowedAmount: CurrencyAmount.fromRawAmount(TEST_TOKEN_1, 0),
}, },
isAllowancePending: true, isAllowancePending: true,
isApprovalLoading: false, isApprovalLoading: false,
...@@ -397,8 +403,11 @@ describe('BagFooter.tsx', () => { ...@@ -397,8 +403,11 @@ describe('BagFooter.tsx', () => {
approveAndPermit: () => Promise.resolve(), approveAndPermit: () => Promise.resolve(),
approve: () => Promise.resolve(), approve: () => Promise.resolve(),
permit: () => Promise.resolve(), permit: () => Promise.resolve(),
revoke: () => Promise.resolve(),
needsPermit2Approval: false, needsPermit2Approval: false,
needsSignature: false, needsSignature: false,
isRevocationPending: false,
allowedAmount: CurrencyAmount.fromRawAmount(TEST_TOKEN_1, 0),
}, },
isAllowancePending: false, isAllowancePending: false,
isApprovalLoading: false, isApprovalLoading: false,
......
import { BigNumber } from '@ethersproject/bignumber'
import { PERMIT2_ADDRESS } from '@uniswap/permit2-sdk'
import { useWeb3React } from '@web3-react/core'
import { SupportedChainId } from 'constants/chains'
import { USDC_MAINNET } from 'constants/tokens'
import store from 'state'
import { mocked } from 'test-utils/mocked'
import { act, renderHook } from 'test-utils/render'
import { useHasPendingApproval, useHasPendingRevocation, useTransactionAdder, useTransactionRemover } from './hooks'
import { clearAllTransactions, finalizeTransaction } from './reducer'
import { ApproveTransactionInfo, TransactionInfo, TransactionType } from './types'
const pendingTransactionResponse = {
hash: '0x123',
timestamp: 1000,
from: '0x123',
wait: jest.fn(),
nonce: 1,
gasLimit: BigNumber.from(1000),
data: '0x',
value: BigNumber.from(0),
chainId: SupportedChainId.MAINNET,
confirmations: 0,
blockNumber: undefined,
blockHash: undefined,
}
const mockApprovalTransactionInfo: ApproveTransactionInfo = {
type: TransactionType.APPROVAL,
tokenAddress: USDC_MAINNET.address,
spender: PERMIT2_ADDRESS,
amount: '10000',
}
const mockRevocationTransactionInfo: TransactionInfo = {
...mockApprovalTransactionInfo,
amount: '0',
}
describe('Transactions hooks', () => {
beforeEach(() => {
mocked(useWeb3React).mockReturnValue({ chainId: 1, account: '0x123' } as ReturnType<typeof useWeb3React>)
jest.useFakeTimers()
store.dispatch(clearAllTransactions({ chainId: SupportedChainId.MAINNET }))
})
function addPendingTransaction(txInfo: TransactionInfo) {
const { result } = renderHook(() => useTransactionAdder())
act(() => {
result.current(pendingTransactionResponse, txInfo)
})
}
function addConfirmedTransaction(txInfo: TransactionInfo) {
addPendingTransaction(txInfo)
act(() => {
store.dispatch(
finalizeTransaction({
chainId: SupportedChainId.MAINNET,
hash: pendingTransactionResponse.hash,
receipt: {
status: 1,
transactionIndex: 1,
transactionHash: pendingTransactionResponse.hash,
to: '0x0',
from: '0x0',
contractAddress: '0x0',
blockHash: '0x0',
blockNumber: 1,
},
})
)
})
}
it('useTransactionAdder adds a transaction', () => {
addPendingTransaction(mockApprovalTransactionInfo)
expect(store.getState().transactions[SupportedChainId.MAINNET][pendingTransactionResponse.hash]).toEqual({
hash: pendingTransactionResponse.hash,
info: mockApprovalTransactionInfo,
from: pendingTransactionResponse.from,
addedTime: Date.now(),
nonce: pendingTransactionResponse.nonce,
deadline: undefined,
})
})
it('useTransactionRemover removes a transaction', () => {
addPendingTransaction(mockApprovalTransactionInfo)
const { result: remover } = renderHook(() => useTransactionRemover())
act(() => {
remover.current(pendingTransactionResponse.hash)
})
expect(store.getState().transactions[SupportedChainId.MAINNET][pendingTransactionResponse.hash]).toBeUndefined()
})
describe('useHasPendingApproval', () => {
it('returns true when there is a pending transaction', () => {
addPendingTransaction(mockApprovalTransactionInfo)
const { result } = renderHook(() => useHasPendingApproval(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(true)
})
it('returns false when there is a pending transaction but it is not an approval', () => {
addPendingTransaction({
type: TransactionType.SUBMIT_PROPOSAL,
})
const { result } = renderHook(() => useHasPendingApproval(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there is a pending approval but it is not for the current chain', () => {
mocked(useWeb3React).mockReturnValue({ chainId: 2 } as ReturnType<typeof useWeb3React>)
addPendingTransaction(mockApprovalTransactionInfo)
const { result } = renderHook(() => useHasPendingApproval(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there is a confirmed approval transaction', () => {
addConfirmedTransaction(mockApprovalTransactionInfo)
const { result } = renderHook(() => useHasPendingApproval(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there are no pending transactions', () => {
const { result } = renderHook(() => useHasPendingApproval(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there is a pending revocation', () => {
addPendingTransaction(mockRevocationTransactionInfo)
const { result } = renderHook(() => useHasPendingApproval(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
})
describe('useHasPendingRevocation', () => {
it('returns true when there is a pending revocation', () => {
addPendingTransaction(mockRevocationTransactionInfo)
const { result } = renderHook(() => useHasPendingRevocation(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(true)
})
it('returns false when there is a pending transaction but it is not a revocation', () => {
addPendingTransaction({
type: TransactionType.SUBMIT_PROPOSAL,
})
const { result } = renderHook(() => useHasPendingRevocation(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there is a pending revocation but it is not for the current chain', () => {
mocked(useWeb3React).mockReturnValue({ chainId: 2 } as ReturnType<typeof useWeb3React>)
addPendingTransaction(mockRevocationTransactionInfo)
const { result } = renderHook(() => useHasPendingRevocation(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there is a confirmed approval transaction', () => {
addConfirmedTransaction(mockRevocationTransactionInfo)
const { result } = renderHook(() => useHasPendingRevocation(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there are no pending transactions', () => {
const { result } = renderHook(() => useHasPendingRevocation(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
it('returns false when there is a pending approval', () => {
addPendingTransaction(mockApprovalTransactionInfo)
const { result } = renderHook(() => useHasPendingRevocation(USDC_MAINNET, PERMIT2_ADDRESS))
expect(result.current).toBe(false)
})
})
})
import { BigNumber } from '@ethersproject/bignumber'
import type { TransactionResponse } from '@ethersproject/providers' import type { TransactionResponse } from '@ethersproject/providers'
import { Token } from '@uniswap/sdk-core' import { Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
...@@ -99,23 +100,28 @@ export function isTransactionRecent(tx: TransactionDetails): boolean { ...@@ -99,23 +100,28 @@ export function isTransactionRecent(tx: TransactionDetails): boolean {
return new Date().getTime() - tx.addedTime < 86_400_000 return new Date().getTime() - tx.addedTime < 86_400_000
} }
// returns whether a token has a pending approval transaction function usePendingApprovalAmount(token?: Token, spender?: string): BigNumber | undefined {
export function useHasPendingApproval(token?: Token, spender?: string): boolean {
const allTransactions = useAllTransactions() const allTransactions = useAllTransactions()
return useMemo( return useMemo(() => {
() => if (typeof token?.address !== 'string' || typeof spender !== 'string') {
typeof token?.address === 'string' && return undefined
typeof spender === 'string' &&
Object.keys(allTransactions).some((hash) => {
const tx = allTransactions[hash]
if (!tx) return false
if (tx.receipt) {
return false
} else {
if (tx.info.type !== TransactionType.APPROVAL) return false
return tx.info.spender === spender && tx.info.tokenAddress === token.address && isTransactionRecent(tx)
} }
}), for (const txHash in allTransactions) {
[allTransactions, spender, token?.address] const tx = allTransactions[txHash]
) if (!tx || tx.receipt || tx.info.type !== TransactionType.APPROVAL) continue
if (tx.info.spender === spender && tx.info.tokenAddress === token.address && isTransactionRecent(tx)) {
return BigNumber.from(tx.info.amount)
}
}
return undefined
}, [allTransactions, spender, token?.address])
}
// returns whether a token has a pending approval transaction
export function useHasPendingApproval(token?: Token, spender?: string): boolean {
return usePendingApprovalAmount(token, spender)?.gt(0) ?? false
}
export function useHasPendingRevocation(token?: Token, spender?: string): boolean {
return usePendingApprovalAmount(token, spender)?.eq(0) ?? false
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment