Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/cowswap-frontend/src/common/hooks/useNeedsApproval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances'
import { CurrencyAmount, Token } from '@cowprotocol/currency'

import { renderHook } from '@testing-library/react'
import { SWRResponse } from 'swr'

import { useNeedsApproval } from './useNeedsApproval'
import { useTokenAllowance } from './useTokenAllowance'

jest.mock('@cowprotocol/balances-and-allowances', () => ({
useTradeSpenderAddress: jest.fn(),
}))

jest.mock('./useTokenAllowance', () => ({
useTokenAllowance: jest.fn(),
}))

const mockUseTradeSpenderAddress = useTradeSpenderAddress as jest.MockedFunction<typeof useTradeSpenderAddress>
const mockUseTokenAllowance = useTokenAllowance as jest.MockedFunction<typeof useTokenAllowance>

describe('useNeedsApproval', () => {
const spender = '0x0000000000000000000000000000000000000001'
const token = new Token(1, '0x1234567890123456789012345678901234567890', 18, 'TEST', 'Test Token')
const amount = CurrencyAmount.fromRawAmount(token, '100')

function mockAllowance(data: bigint | undefined): void {
mockUseTokenAllowance.mockReturnValue({ data } as SWRResponse<bigint | undefined>)
}

beforeEach(() => {
jest.clearAllMocks()
mockUseTradeSpenderAddress.mockReturnValue(spender)
mockAllowance(0n)
})

it('returns false when amount is missing', () => {
const { result } = renderHook(() => useNeedsApproval(null))

expect(result.current).toBe(false)
})

it('returns false when spender is missing', () => {
mockUseTradeSpenderAddress.mockReturnValue(undefined)

const { result } = renderHook(() => useNeedsApproval(amount))

expect(result.current).toBe(false)
})

it('returns true when allowance is not loaded yet', () => {
mockAllowance(undefined)

const { result } = renderHook(() => useNeedsApproval(amount))

expect(result.current).toBe(true)
})

it('returns true when allowance is insufficient', () => {
mockAllowance(99n)

const { result } = renderHook(() => useNeedsApproval(amount))

expect(result.current).toBe(true)
})

it('returns false when allowance is sufficient', () => {
mockAllowance(100n)

const { result } = renderHook(() => useNeedsApproval(amount))

expect(result.current).toBe(false)
})
})
17 changes: 9 additions & 8 deletions apps/cowswap-frontend/src/common/hooks/useNeedsApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ import { useTokenAllowance } from './useTokenAllowance'
* @param amount
* @returns {boolean}
*/
export function useNeedsApproval(amount: Nullish<CurrencyAmount<Currency>>): boolean {
const spender = useTradeSpenderAddress()
export function useNeedsApproval(amount: Nullish<CurrencyAmount<Currency>>, spender?: string): boolean {
const tradeSpender = useTradeSpenderAddress()
const token = amount ? getWrappedToken(amount.currency) : undefined
const allowance = useTokenAllowance(token)
const approvalSpender = spender ?? tradeSpender
const allowance = useTokenAllowance(token, undefined, approvalSpender)

if (typeof allowance === 'undefined') {
return true
if (!token || !amount || !approvalSpender) {
return false
}

if (!token || !amount || !spender) {
return false
if (allowance.data === undefined) {
return true
}

return isEnoughAmount(amount, allowance?.data) === false
return isEnoughAmount(amount, allowance.data) === false
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,26 @@ export function useAllTransactions(): { [txHash: string]: EnhancedTransactionDet
}

// returns whether a token has a pending approval transaction
export function useHasPendingApproval(tokenAddress: string | undefined): boolean {
export function useHasPendingApproval(tokenAddress: string | undefined, approvalSpender?: string): boolean {
const allTransactions = useAllTransactions()
const spender = useTradeSpenderAddress()
const targetSpender = approvalSpender ?? spender

return useMemo(
() =>
typeof tokenAddress === 'string' &&
typeof spender === 'string' &&
typeof targetSpender === 'string' &&
Object.keys(allTransactions).some((hash) => {
const tx = allTransactions[hash]
if (!tx || tx.receipt || tx.replacementType || tx.errorMessage) return false

const approval = tx.approval
if (!approval) return false

return areAddressesEqual(approval.spender, spender) && areAddressesEqual(approval.tokenAddress, tokenAddress)
return (
areAddressesEqual(approval.spender, targetSpender) && areAddressesEqual(approval.tokenAddress, tokenAddress)
)
}),
[allTransactions, spender, tokenAddress],
[allTransactions, targetSpender, tokenAddress],
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function useApprovalStateForSpender(
const token = currency && !getIsNativeToken(currency) ? currency : undefined

const currentAllowance = useTokenAllowance(token, account ?? undefined, spender)
const { state: approvalState } = useApproveState(amountToApprove)
const { state: approvalState } = useApproveState(amountToApprove, spender)

return useMemo(() => {
return { approvalState, currentAllowance: currentAllowance?.data }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useWalletInfo } from '@cowprotocol/wallet'
import { renderHook, waitFor } from '@testing-library/react'

import { useTradeApproveCallback } from 'modules/erc20Approve'
import { callWidgetHook } from 'modules/injectedWidget'
import { callOnBeforeApprovalWidgetHook } from 'modules/injectedWidget'
import { useShouldZeroApprove, useZeroApprove } from 'modules/zeroApproval'

import { useApproveCurrency } from './useApproveCurrency'
Expand All @@ -23,7 +23,7 @@ jest.mock('modules/erc20Approve', () => ({
}))

jest.mock('modules/injectedWidget', () => ({
callWidgetHook: jest.fn(),
callOnBeforeApprovalWidgetHook: jest.fn(),
}))

jest.mock('modules/zeroApproval', () => ({
Expand All @@ -34,7 +34,9 @@ jest.mock('modules/zeroApproval', () => ({
const mockUseTradeSpenderAddress = useTradeSpenderAddress as jest.MockedFunction<typeof useTradeSpenderAddress>
const mockUseWalletInfo = useWalletInfo as jest.MockedFunction<typeof useWalletInfo>
const mockUseTradeApproveCallback = useTradeApproveCallback as jest.MockedFunction<typeof useTradeApproveCallback>
const mockCallWidgetHook = callWidgetHook as jest.MockedFunction<typeof callWidgetHook>
const mockCallOnBeforeApprovalWidgetHook = callOnBeforeApprovalWidgetHook as jest.MockedFunction<
typeof callOnBeforeApprovalWidgetHook
>
const mockUseShouldZeroApprove = useShouldZeroApprove as jest.MockedFunction<typeof useShouldZeroApprove>
const mockUseZeroApprove = useZeroApprove as jest.MockedFunction<typeof useZeroApprove>

Expand All @@ -54,7 +56,7 @@ describe('useApproveCurrency', () => {
mockUseTradeSpenderAddress.mockReturnValue(spenderAddress)
mockUseWalletInfo.mockReturnValue({ account } as ReturnType<typeof useWalletInfo>)
mockUseTradeApproveCallback.mockReturnValue(tradeApproveCallback)
mockCallWidgetHook.mockResolvedValue(true)
mockCallOnBeforeApprovalWidgetHook.mockResolvedValue(true)
shouldZeroApprove.mockResolvedValue(false)
mockUseShouldZeroApprove.mockReturnValue(shouldZeroApprove)
mockUseZeroApprove.mockReturnValue(zeroApprove)
Expand All @@ -66,18 +68,11 @@ describe('useApproveCurrency', () => {
await result.current(approveAmount)

await waitFor(() => {
expect(mockCallWidgetHook).toHaveBeenCalledWith('ON_BEFORE_APPROVAL', {
chainId: mockToken.chainId,
sellToken: expect.objectContaining({
address: mockToken.address,
chainId: mockToken.chainId,
decimals: mockToken.decimals,
name: mockToken.name,
symbol: mockToken.symbol,
}),
sellAmount: approveAmount.toString(),
walletAddress: account,
expect(mockCallOnBeforeApprovalWidgetHook).toHaveBeenCalledWith({
account,
amountToApprove,
spenderAddress,
approvalAmount: approveAmount,
})
expect(tradeApproveCallback).toHaveBeenCalledWith(approveAmount, {
useModals: true,
Expand All @@ -87,14 +82,14 @@ describe('useApproveCurrency', () => {
})

it('does not run on-chain approval when widget hook blocks it', async () => {
mockCallWidgetHook.mockResolvedValue(false)
mockCallOnBeforeApprovalWidgetHook.mockResolvedValue(false)

const { result } = renderHook(() => useApproveCurrency(amountToApprove, true))

await result.current(approveAmount)

await waitFor(() => {
expect(mockCallWidgetHook).toHaveBeenCalled()
expect(mockCallOnBeforeApprovalWidgetHook).toHaveBeenCalled()
expect(shouldZeroApprove).not.toHaveBeenCalled()
expect(zeroApprove).not.toHaveBeenCalled()
expect(tradeApproveCallback).not.toHaveBeenCalled()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { useCallback } from 'react'

import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances'
import { currencyAmountToTokenAmount } from '@cowprotocol/common-utils'
import { Currency, CurrencyAmount } from '@cowprotocol/currency'
import { Nullish } from '@cowprotocol/types'
import { useWalletInfo } from '@cowprotocol/wallet'
import { WidgetHookEvents } from '@cowprotocol/widget-lib'
import type { SafeMultisigTransactionResponse } from '@safe-global/types-kit'

import { GenerecTradeApproveResult, useTradeApproveCallback } from 'modules/erc20Approve'
import { callWidgetHook } from 'modules/injectedWidget'
import { callOnBeforeApprovalWidgetHook } from 'modules/injectedWidget'
import { useShouldZeroApprove, useZeroApprove } from 'modules/zeroApproval'

export type ApproveCurrencyCallback = (
Expand All @@ -32,17 +30,11 @@ export function useApproveCurrency(
async (amount: bigint) => {
if (!account || !tradeSpenderAddress || !amountToApprove) return null

const tokenAmount = currencyAmountToTokenAmount(amountToApprove)
const isWidgetHookPassed = await callWidgetHook(WidgetHookEvents.ON_BEFORE_APPROVAL, {
chainId: tokenAmount.currency.chainId,
sellToken: {
...tokenAmount.currency,
name: tokenAmount.currency.name || '',
symbol: tokenAmount.currency.symbol || '',
},
sellAmount: amount.toString(),
walletAddress: account,
const isWidgetHookPassed = await callOnBeforeApprovalWidgetHook({
account,
amountToApprove,
spenderAddress: tradeSpenderAddress,
approvalAmount: amount,
})

if (!isWidgetHookPassed) return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ import { useTokenAllowance } from 'common/hooks/useTokenAllowance'
import { ApprovalState } from '../types'
import { getApprovalState } from '../utils'

export function useApproveState(amountToApprove: Nullish<CurrencyAmount<Currency>>): {
export function useApproveState(
amountToApprove: Nullish<CurrencyAmount<Currency>>,
spender?: string,
): {
state: ApprovalState
currentAllowance: Nullish<bigint>
} {
const token = getCurrencyToApprove(amountToApprove)
const tokenAddress = token?.address ? getAddressKey(token.address) : undefined
const currentAllowance = useTokenAllowance(token).data
const pendingApproval = useHasPendingApproval(tokenAddress)
const currentAllowance = useTokenAllowance(token, undefined, spender).data
const pendingApproval = useHasPendingApproval(tokenAddress, spender)

const approvalStateBase = useSafeMemo(() => {
return getApprovalState(amountToApprove, currentAllowance, pendingApproval)
Expand Down
Loading
Loading