Skip to content
Open
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
1 change: 0 additions & 1 deletion apps/cowswap-frontend/src/locales/en-US.po
Original file line number Diff line number Diff line change
Expand Up @@ -1956,7 +1956,6 @@ msgstr "Pick your own code or generate one. Once saved, it becomes permanently l
#~ msgstr "By clicking Confirm, you expressly represent and warrant that you are NOT:"

#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/ReceiptModal.modal.tsx
#: apps/cowswap-frontend/src/modules/trade/pure/TotalFeeRow/index.tsx
msgid "Total fee"
msgstr "Total fee"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useAtomValue } from 'jotai'

import { COW_PROTOCOL_VAULT_RELAYER_ADDRESS } from '@cowprotocol/common-utils'
import { getAddressKey } from '@cowprotocol/cow-sdk'
import { PermitInfo } from '@cowprotocol/permit-utils'
import { useWalletInfo } from '@cowprotocol/wallet'

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

import { useIsPermitEnabled } from 'common/hooks/featureFlags/useIsPermitEnabled'

import { usePermitCompatibleTokens } from './usePermitCompatibleTokens'
import { usePreGeneratedPermitInfo } from './usePreGeneratedPermitInfo'

import { getPermittableTokenKey } from '../state/permittableTokensAtom'

jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
}))

jest.mock('@cowprotocol/wallet', () => ({
useWalletInfo: jest.fn(),
}))

jest.mock('common/hooks/featureFlags/useIsPermitEnabled', () => ({
useIsPermitEnabled: jest.fn(),
}))

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

const mockedUseAtomValue = useAtomValue as jest.MockedFunction<typeof useAtomValue>
const mockedUseWalletInfo = useWalletInfo as jest.MockedFunction<typeof useWalletInfo>
const mockedUseIsPermitEnabled = useIsPermitEnabled as jest.MockedFunction<typeof useIsPermitEnabled>
const mockedUsePreGeneratedPermitInfo = usePreGeneratedPermitInfo as jest.MockedFunction<
typeof usePreGeneratedPermitInfo
>

describe('usePermitCompatibleTokens', () => {
const chainId = 1
const tokenAddress = '0x1234567890123456789012345678901234567890'
const spender = COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId]
const customSpender = '0x2222222222222222222222222222222222222222'
const permitInfo: PermitInfo = { type: 'eip-2612', name: 'Test Token', version: '1' }

beforeEach(() => {
jest.clearAllMocks()

mockedUseWalletInfo.mockReturnValue({ chainId } as ReturnType<typeof useWalletInfo>)
mockedUseIsPermitEnabled.mockReturnValue(true)
mockedUsePreGeneratedPermitInfo.mockReturnValue({ allPermitInfo: {}, isLoading: false })
})

it('maps local token-spender permit info back to the token address', () => {
const permitTokenKey = getPermittableTokenKey(tokenAddress, spender)

mockedUseAtomValue.mockReturnValue({
[chainId]: {
[permitTokenKey]: permitInfo,
},
})

const { result } = renderHook(() => usePermitCompatibleTokens())

expect(result.current[getAddressKey(tokenAddress)]).toBe(true)
expect(result.current[permitTokenKey]).toBeUndefined()
})

it('ignores custom-spender results when mapping default permit compatibility', () => {
mockedUsePreGeneratedPermitInfo.mockReturnValue({
allPermitInfo: { [getAddressKey(tokenAddress)]: permitInfo },
isLoading: false,
})
mockedUseAtomValue.mockReturnValue({
[chainId]: {
[getPermittableTokenKey(tokenAddress, customSpender)]: { type: 'unsupported' },
},
})

const { result } = renderHook(() => usePermitCompatibleTokens())

expect(result.current[getAddressKey(tokenAddress)]).toBe(true)
})

it('allows the default-spender result to override pre-generated compatibility', () => {
mockedUsePreGeneratedPermitInfo.mockReturnValue({
allPermitInfo: { [getAddressKey(tokenAddress)]: permitInfo },
isLoading: false,
})
mockedUseAtomValue.mockReturnValue({
[chainId]: {
[getPermittableTokenKey(tokenAddress, spender)]: { type: 'unsupported' },
},
})

const { result } = renderHook(() => usePermitCompatibleTokens())

expect(result.current[getAddressKey(tokenAddress)]).toBe(false)
})

it('does not advertise permit compatibility from a custom-spender result alone', () => {
mockedUseAtomValue.mockReturnValue({
[chainId]: {
[getPermittableTokenKey(tokenAddress, customSpender)]: permitInfo,
},
})

const { result } = renderHook(() => usePermitCompatibleTokens())

expect(result.current[getAddressKey(tokenAddress)]).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useAtomValue } from 'jotai'
import { useMemo, useRef } from 'react'

import { COW_PROTOCOL_VAULT_RELAYER_ADDRESS } from '@cowprotocol/common-utils'
import { getAddressKey } from '@cowprotocol/cow-sdk'
import { isSupportedPermitInfo } from '@cowprotocol/permit-utils'
import { useWalletInfo } from '@cowprotocol/wallet'
Expand All @@ -9,14 +10,19 @@ import { useIsPermitEnabled } from 'common/hooks/featureFlags/useIsPermitEnabled

import { usePreGeneratedPermitInfo } from './usePreGeneratedPermitInfo'

import { permittableTokensAtom } from '../state/permittableTokensAtom'
import {
getPermittableTokenKey,
getTokenAddressFromPermittableTokenKey,
permittableTokensAtom,
} from '../state/permittableTokensAtom'
import { PermitCompatibleTokens } from '../types'

export function usePermitCompatibleTokens(): PermitCompatibleTokens {
const { chainId } = useWalletInfo()
const permitInfoAllChains = useAtomValue(permittableTokensAtom)
const localPermitInfo = permitInfoAllChains[chainId] || {}
const { allPermitInfo } = usePreGeneratedPermitInfo()
const defaultSpender = COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId]

const isPermitEnabled = useIsPermitEnabled()

Expand All @@ -43,14 +49,18 @@ export function usePermitCompatibleTokens(): PermitCompatibleTokens {
)
}

for (const address of Object.keys(localPermitInfoRef.current)) {
const addressLowerCased = getAddressKey(address)
for (const permitTokenKey of Object.keys(localPermitInfoRef.current)) {
const tokenAddress = getTokenAddressFromPermittableTokenKey(permitTokenKey)

if (!defaultSpender || permitTokenKey !== getPermittableTokenKey(tokenAddress, defaultSpender)) continue

const addressLowerCased = getAddressKey(tokenAddress)

permitCompatibleTokens[addressLowerCased] = isSupportedPermitInfo(localPermitInfoRef.current[addressLowerCased])
permitCompatibleTokens[addressLowerCased] = isSupportedPermitInfo(localPermitInfoRef.current[permitTokenKey])
}

return permitCompatibleTokens
// Reducing unnecessary re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stableRef, isPermitEnabled])
}, [defaultSpender, stableRef, isPermitEnabled])
}
176 changes: 176 additions & 0 deletions apps/cowswap-frontend/src/modules/permit/hooks/usePermitInfo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useAtomValue, useSetAtom } from 'jotai'

import { Token } from '@cowprotocol/currency'
import { getTokenPermitInfo } from '@cowprotocol/permit-utils'
import type { PermitInfo } from '@cowprotocol/permit-utils'
import { useWalletInfo } from '@cowprotocol/wallet'

import { renderHook, waitFor } from '@testing-library/react'
import { useConfig, usePublicClient } from 'wagmi'

import { TradeType } from 'modules/trade'

import { useIsPermitEnabled } from 'common/hooks/featureFlags/useIsPermitEnabled'

import { usePermitInfo } from './usePermitInfo'
import { usePreGeneratedPermitInfoForToken } from './usePreGeneratedPermitInfoForToken'

import { getPermittableTokenKey } from '../state/permittableTokensAtom'

const defaultSpender = '0x9008D19f58AAbD9eD0D60971565AA8510560ab41'
const customSpender = '0x1111111111111111111111111111111111111111'

jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
}))

jest.mock('@cowprotocol/common-utils', () => ({
COW_PROTOCOL_VAULT_RELAYER_ADDRESS: { 1: defaultSpender },
getIsNativeToken: jest.fn().mockReturnValue(false),
getWrappedToken: jest.fn((token) => token),
isAddress: jest.fn(),
}))

jest.mock('@cowprotocol/permit-utils', () => ({
DEFAULT_MIN_GAS_LIMIT: 50000n,
getTokenPermitInfo: jest.fn(),
}))

jest.mock('@cowprotocol/wallet', () => ({
useWalletInfo: jest.fn(),
}))

jest.mock('wagmi', () => ({
useConfig: jest.fn(),
usePublicClient: jest.fn(),
}))

jest.mock('common/hooks/featureFlags/useIsPermitEnabled', () => ({
useIsPermitEnabled: jest.fn(),
}))

jest.mock('modules/trade', () => ({
TradeType: {
SWAP: 'SWAP',
LIMIT_ORDER: 'LIMIT_ORDER',
ADVANCED_ORDERS: 'ADVANCED_ORDERS',
YIELD: 'YIELD',
},
}))

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

const mockedUseAtomValue = useAtomValue as jest.MockedFunction<typeof useAtomValue>
const mockedUseSetAtom = useSetAtom as jest.MockedFunction<typeof useSetAtom>
const mockedGetTokenPermitInfo = getTokenPermitInfo as jest.MockedFunction<typeof getTokenPermitInfo>
const mockedUseWalletInfo = useWalletInfo as jest.MockedFunction<typeof useWalletInfo>
const mockedUseConfig = useConfig as jest.MockedFunction<typeof useConfig>
const mockedUsePublicClient = usePublicClient as jest.MockedFunction<typeof usePublicClient>
const mockedUseIsPermitEnabled = useIsPermitEnabled as jest.MockedFunction<typeof useIsPermitEnabled>
const mockedUsePreGeneratedPermitInfoForToken = usePreGeneratedPermitInfoForToken as jest.MockedFunction<
typeof usePreGeneratedPermitInfoForToken
>

const commonUtils = jest.requireMock('@cowprotocol/common-utils') as {
isAddress: jest.MockedFunction<(value: string | undefined | null) => string | false>
}

describe('usePermitInfo', () => {
const token = new Token(1, '0x1234567890123456789012345678901234567890', 18, 'TEST', 'Test Token')
const addPermitInfo = jest.fn()
const defaultPermitInfo: PermitInfo = { type: 'eip-2612', name: 'Test Token', version: '1' }
const fetchedPermitInfo: PermitInfo = { type: 'unsupported', name: 'Test Token' }

beforeEach(() => {
jest.clearAllMocks()

mockedUseAtomValue.mockReturnValue({})
mockedUseSetAtom.mockReturnValue(addPermitInfo)
mockedUseWalletInfo.mockReturnValue({ chainId: 1 } as ReturnType<typeof useWalletInfo>)
mockedUseConfig.mockReturnValue({} as ReturnType<typeof useConfig>)
mockedUsePublicClient.mockReturnValue({} as ReturnType<typeof usePublicClient>)
mockedUseIsPermitEnabled.mockReturnValue(true)
mockedUsePreGeneratedPermitInfoForToken.mockImplementation(() => ({ permitInfo: undefined, isLoading: false }))
mockedGetTokenPermitInfo.mockResolvedValue(fetchedPermitInfo)
commonUtils.isAddress.mockImplementation((value) => {
if (!value || !/^0x[a-fA-F0-9]{40}$/.test(value)) return false

return value
})
})

it('does not reuse cached permit info that belongs to a different spender', async () => {
mockedUseAtomValue.mockReturnValue({
1: {
[getPermittableTokenKey(token.address, defaultSpender)]: defaultPermitInfo,
},
})

const { result } = renderHook(() => usePermitInfo(token, TradeType.SWAP, customSpender))

expect(result.current).toBeUndefined()

await waitFor(() => {
expect(mockedGetTokenPermitInfo).toHaveBeenCalledWith(
expect.objectContaining({
spender: customSpender,
tokenAddress: token.address,
}),
)
})
})

it('revalidates custom spenders instead of reusing pre-generated permit info', async () => {
mockedUsePreGeneratedPermitInfoForToken.mockImplementation((tokenToCheck) => ({
permitInfo: tokenToCheck ? defaultPermitInfo : undefined,
isLoading: false,
}))

const { result } = renderHook(() => usePermitInfo(token, TradeType.SWAP, customSpender))

expect(result.current).toBeUndefined()
expect(mockedUsePreGeneratedPermitInfoForToken).toHaveBeenCalledWith(undefined)

await waitFor(() => {
expect(mockedGetTokenPermitInfo).toHaveBeenCalledWith(
expect.objectContaining({
spender: customSpender,
tokenAddress: token.address,
}),
)
})

await waitFor(() => {
expect(addPermitInfo).toHaveBeenCalledWith({
chainId: 1,
tokenAddress: token.address,
spender: customSpender,
permitInfo: fetchedPermitInfo,
})
})
})

it('reuses pre-generated permit info for the default spender', () => {
mockedUsePreGeneratedPermitInfoForToken.mockImplementation((tokenToCheck) => ({
permitInfo: tokenToCheck ? defaultPermitInfo : undefined,
isLoading: false,
}))

const { result } = renderHook(() => usePermitInfo(token, TradeType.SWAP))

expect(result.current).toEqual(defaultPermitInfo)
expect(mockedGetTokenPermitInfo).not.toHaveBeenCalled()
})

it('skips permit lookup for an invalid custom spender', () => {
const { result } = renderHook(() => usePermitInfo(token, TradeType.SWAP, '0x1'))

expect(result.current).toBeUndefined()
expect(mockedUsePreGeneratedPermitInfoForToken).toHaveBeenCalledWith(undefined)
expect(mockedGetTokenPermitInfo).not.toHaveBeenCalled()
})
})
Loading
Loading