diff --git a/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts b/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts index a781ad032c..52e8d13d10 100644 --- a/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts +++ b/apps/cowswap-frontend/src/common/hooks/useTokenAllowance.ts @@ -1,23 +1,20 @@ -import { atom, useAtom, useSetAtom } from 'jotai' -import { useCallback, useEffect } from 'react' +import { useAtom } from 'jotai' +import { useEffect, useMemo } from 'react' import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' import { useWalletInfo } from '@cowprotocol/wallet' import { Token } from '@uniswap/sdk-core' +import { optimisticAllowancesAtom } from 'entities/optimisticAllowance/optimisticAllowancesAtom' import ms from 'ms.macro' import useSWR, { SWRConfiguration, SWRResponse } from 'swr' import { useTokenContract } from 'common/hooks/useContract' +import { getOptimisticAllowanceKey } from '../../entities/optimisticAllowance/getOptimisticAllowanceKey' -interface LastApproveParams { - blockNumber: number - tokenAddress: string -} - -const lastApproveTxBlockNumberAtom = atom>({}) +const OPTIMISTIC_ALLOWANCE_TTL = ms`30s` const SWR_OPTIONS: SWRConfiguration = { ...SWR_NO_REFRESH_OPTIONS, @@ -35,38 +32,57 @@ export function useTokenAllowance( const { chainId, account } = useWalletInfo() const { contract: erc20Contract } = useTokenContract(tokenAddress) const tradeSpender = useTradeSpenderAddress() - const [lastApproveTx, setLastApproveTx] = useAtom(lastApproveTxBlockNumberAtom) + const [optimisticAllowances, setOptimisticAllowances] = useAtom(optimisticAllowancesAtom) const targetOwner = owner ?? account const targetSpender = spender ?? tradeSpender - const lastApproveBlockNumber = tokenAddress ? lastApproveTx[tokenAddress.toLowerCase()] : undefined - // Reset lastApproveTxBlockNumberAtom on network changes - useEffect(() => { - setLastApproveTx({}) - }, [chainId, setLastApproveTx]) + const optimisticAllowanceKey = useMemo(() => { + if (!tokenAddress || !targetOwner || !targetSpender) return null + return getOptimisticAllowanceKey({ chainId, tokenAddress, owner: targetOwner, spender: targetSpender }) + }, [chainId, tokenAddress, targetOwner, targetSpender]) - return useSWR( + const optimisticAllowance = optimisticAllowanceKey ? optimisticAllowances[optimisticAllowanceKey] : undefined + + // Important! Do not add erc20Contract to SWR deps, otherwise it will do unwanted node RPC calls! + const swrResponse = useSWR( erc20Contract && targetOwner && targetSpender - ? [erc20Contract, targetOwner, targetSpender, chainId, lastApproveBlockNumber, 'useTokenAllowance'] + ? [targetOwner, targetSpender, chainId, tokenAddress, 'useTokenAllowance'] : null, - ([erc20Contract, targetOwner, targetSpender]) => { + ([targetOwner, targetSpender]) => { + if (!erc20Contract) return undefined + return erc20Contract.allowance(targetOwner, targetSpender).then((result) => result.toBigInt()) }, SWR_OPTIONS, ) -} -export function useUpdateLastApproveTxBlockNumber(): (params: LastApproveParams) => void { - const setState = useSetAtom(lastApproveTxBlockNumberAtom) + // Reset state on network changes + useEffect(() => { + setOptimisticAllowances({}) + }, [chainId, setOptimisticAllowances]) - return useCallback( - (params: LastApproveParams) => { - setState((state) => ({ - ...state, - [params.tokenAddress.toLowerCase()]: params.blockNumber, - })) - }, - [setState], + // Clean up expired optimistic allowances + useEffect(() => { + const now = Date.now() + const expiredKeys = Object.keys(optimisticAllowances).filter( + (key) => now - optimisticAllowances[key].timestamp > OPTIMISTIC_ALLOWANCE_TTL, + ) + + if (expiredKeys.length > 0) { + setOptimisticAllowances((state) => { + const newState = { ...state } + expiredKeys.forEach((key) => delete newState[key]) + return newState + }) + } + }, [optimisticAllowances, setOptimisticAllowances, swrResponse.data]) + + return useMemo( + () => ({ + ...swrResponse, + data: optimisticAllowance?.amount ?? swrResponse.data, + }), + [optimisticAllowance?.amount, swrResponse], ) } diff --git a/apps/cowswap-frontend/src/entities/optimisticAllowance/getOptimisticAllowanceKey.ts b/apps/cowswap-frontend/src/entities/optimisticAllowance/getOptimisticAllowanceKey.ts new file mode 100644 index 0000000000..28a67a1a39 --- /dev/null +++ b/apps/cowswap-frontend/src/entities/optimisticAllowance/getOptimisticAllowanceKey.ts @@ -0,0 +1,7 @@ +import { SetOptimisticAllowanceParams } from './useSetOptimisticAllowance' + +export function getOptimisticAllowanceKey( + params: Omit, +): string { + return `${params.chainId}-${params.tokenAddress.toLowerCase()}-${params.owner.toLowerCase()}-${params.spender.toLowerCase()}` +} diff --git a/apps/cowswap-frontend/src/entities/optimisticAllowance/optimisticAllowancesAtom.ts b/apps/cowswap-frontend/src/entities/optimisticAllowance/optimisticAllowancesAtom.ts new file mode 100644 index 0000000000..47363d77b0 --- /dev/null +++ b/apps/cowswap-frontend/src/entities/optimisticAllowance/optimisticAllowancesAtom.ts @@ -0,0 +1,5 @@ +import { atom } from 'jotai' + +import { OptimisticAllowance } from './types' + +export const optimisticAllowancesAtom = atom>({}) diff --git a/apps/cowswap-frontend/src/entities/optimisticAllowance/types.ts b/apps/cowswap-frontend/src/entities/optimisticAllowance/types.ts new file mode 100644 index 0000000000..3ef1ea8856 --- /dev/null +++ b/apps/cowswap-frontend/src/entities/optimisticAllowance/types.ts @@ -0,0 +1,5 @@ +export interface OptimisticAllowance { + amount: bigint + blockNumber: number + timestamp: number +} diff --git a/apps/cowswap-frontend/src/entities/optimisticAllowance/useSetOptimisticAllowance.ts b/apps/cowswap-frontend/src/entities/optimisticAllowance/useSetOptimisticAllowance.ts new file mode 100644 index 0000000000..7d33cec039 --- /dev/null +++ b/apps/cowswap-frontend/src/entities/optimisticAllowance/useSetOptimisticAllowance.ts @@ -0,0 +1,35 @@ +import { useSetAtom } from 'jotai' +import { useCallback } from 'react' + +import { getOptimisticAllowanceKey } from './getOptimisticAllowanceKey' +import { optimisticAllowancesAtom } from './optimisticAllowancesAtom' + +export interface SetOptimisticAllowanceParams { + tokenAddress: string + owner: string + spender: string + amount: bigint + blockNumber: number + chainId: number +} + +export function useSetOptimisticAllowance(): (params: SetOptimisticAllowanceParams) => void { + const setOptimisticAllowances = useSetAtom(optimisticAllowancesAtom) + + return useCallback( + (params: SetOptimisticAllowanceParams) => { + const key = getOptimisticAllowanceKey(params) + + // Set optimistic allowance immediately + setOptimisticAllowances((state) => ({ + ...state, + [key]: { + amount: params.amount, + blockNumber: params.blockNumber, + timestamp: Date.now(), + }, + })) + }, + [setOptimisticAllowances], + ) +} diff --git a/apps/cowswap-frontend/src/modules/account/containers/OrderPartialApprove/OrderPartialApprove.tsx b/apps/cowswap-frontend/src/modules/account/containers/OrderPartialApprove/OrderPartialApprove.tsx index b52d66742d..b2afbc976b 100644 --- a/apps/cowswap-frontend/src/modules/account/containers/OrderPartialApprove/OrderPartialApprove.tsx +++ b/apps/cowswap-frontend/src/modules/account/containers/OrderPartialApprove/OrderPartialApprove.tsx @@ -48,7 +48,6 @@ export function OrderPartialApprove({ )} void - ignorePermit?: boolean - label: string + label?: string buttonSize?: ButtonSize useModals?: boolean } @@ -37,31 +35,18 @@ export function TradeApproveButton(props: TradeApproveButtonProps): ReactNode { amountToApprove, children, enablePartialApprove, - onApproveConfirm, - label, - ignorePermit, isDisabled, buttonSize = ButtonSize.DEFAULT, useModals = true, } = props - const isPartialApproveEnabledByUser = useIsPartialApproveSelectedByUser() const handleApprove = useApproveCurrency(amountToApprove, useModals) const spender = useTradeSpenderAddress() + const isCurrentTradeBridging = useIsCurrentTradeBridging() const { approvalState } = useApprovalStateForSpender(amountToApprove, spender) - const isPermitSupported = useTokenSupportsPermit(amountToApprove.currency, TradeType.SWAP) && !ignorePermit - const generatePermitToTrade = useGeneratePermitInAdvanceToTrade(amountToApprove) - - const approveAndSwap = useOnApproveClick({ - isPermitSupported, - onApproveConfirm, - isPartialApproveEnabledByUser, - amountToApprove, - handleApprove, - generatePermitToTrade, - }) - + const approveAndSwap = useApproveAndSwap(props) const approveWithPreventedDoubleExecution = usePreventDoubleExecution(approveAndSwap) + const { data: cachedPermit, isLoading: cachedPermitLoading } = useHasCachedPermit(amountToApprove) if (!enablePartialApprove) { return ( @@ -78,6 +63,10 @@ export function TradeApproveButton(props: TradeApproveButtonProps): ReactNode { } const isPending = approvalState === ApprovalState.PENDING + const noCachedPermit = !cachedPermitLoading && !cachedPermit + + const label = + props.label || (noCachedPermit ? (isCurrentTradeBridging ? 'Approve, Swap & Bridge' : 'Approve and Swap') : 'Swap') return ( {label}{' '} - - You must give the CoW Protocol smart contracts permission to use your{' '} - . If you approve the default amount, you will only have to - do this once per token. - - } - > - {isPending ? : } - + {noCachedPermit ? ( + + You must give the CoW Protocol smart contracts permission to use your{' '} + . If you approve the default amount, you will only have + to do this once per token. + + } + > + {isPending ? : } + + ) : null} ) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/approveUtils.test.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/approveUtils.test.ts new file mode 100644 index 0000000000..155012d97c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/approveUtils.test.ts @@ -0,0 +1,244 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { defaultAbiCoder } from '@ethersproject/abi' +import { TransactionReceipt } from '@ethersproject/abstract-provider' +import { id } from '@ethersproject/hash' +import { Token } from '@uniswap/sdk-core' + +import { processApprovalTransaction } from './approveUtils' + +const APPROVAL_EVENT_TOPIC = id('Approval(address,address,uint256)') + +describe('processApprovalTransaction', () => { + const mockChainId = SupportedChainId.MAINNET + const mockTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + const mockAccount = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' + const mockSpender = '0x9008D19f58AAbD9eD0D60971565AA8510560ab41' + const mockAmount = BigInt('1000000000000000000') + const mockBlockNumber = 123456 + + const mockToken = new Token(mockChainId, mockTokenAddress, 18, 'TEST', 'Test Token') + + // Helper to create padded address topic + const createAddressTopic = (address: string): string => { + return '0x' + '0'.repeat(24) + address.slice(2).toLowerCase() + } + + // Helper to encode approval amount + const encodeAmount = (amount: bigint): string => { + return defaultAbiCoder.encode(['uint256'], [amount.toString()]) + } + + const createMockTransactionReceipt = (status: number, logs: TransactionReceipt['logs'] = []): TransactionReceipt => { + return { + to: mockSpender, + from: mockAccount, + contractAddress: mockTokenAddress, + transactionIndex: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gasUsed: { toString: () => '21000' } as any, + logsBloom: '0x', + blockHash: '0xblockhash', + transactionHash: '0xtxhash', + logs, + blockNumber: mockBlockNumber, + confirmations: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cumulativeGasUsed: { toString: () => '21000' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + effectiveGasPrice: { toString: () => '1000000000' } as any, + byzantium: true, + type: 2, + status, + } + } + + const createApprovalLog = ( + tokenAddress: string, + owner: string, + spender: string, + amount: bigint, + ): TransactionReceipt['logs'][0] => { + return { + blockNumber: mockBlockNumber, + blockHash: '0xblockhash', + transactionIndex: 1, + removed: false, + address: tokenAddress, + data: encodeAmount(amount), + topics: [APPROVAL_EVENT_TOPIC, createAddressTopic(owner), createAddressTopic(spender)], + transactionHash: '0xtxhash', + logIndex: 0, + } + } + + describe('successful approval extraction', () => { + it('should extract approval data from valid transaction receipt', () => { + const approvalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, mockAmount) + const txReceipt = createMockTransactionReceipt(1, [approvalLog]) + + const result = processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ) + + expect(result).toEqual({ + tokenAddress: mockTokenAddress.toLowerCase(), + owner: mockAccount, + spender: mockSpender, + amount: mockAmount, + blockNumber: mockBlockNumber, + chainId: mockChainId, + }) + }) + + it('should handle zero approval amount (revoke approval)', () => { + const zeroAmount = BigInt('0') + const approvalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, zeroAmount) + const txReceipt = createMockTransactionReceipt(1, [approvalLog]) + + const result = processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ) + + expect(result).toEqual({ + tokenAddress: mockTokenAddress.toLowerCase(), + owner: mockAccount, + spender: mockSpender, + amount: zeroAmount, + blockNumber: mockBlockNumber, + chainId: mockChainId, + }) + }) + + it('should find correct approval log among multiple logs', () => { + const otherLog = { + blockNumber: mockBlockNumber, + blockHash: '0xblockhash', + transactionIndex: 1, + removed: false, + address: '0xOtherAddress000000000000000000000000000000', + data: '0x', + topics: ['0xothertopic'], + transactionHash: '0xtxhash', + logIndex: 0, + } + const approvalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, mockAmount) + const txReceipt = createMockTransactionReceipt(1, [otherLog, approvalLog, otherLog]) + + const result = processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ) + + expect(result).toEqual({ + tokenAddress: mockTokenAddress.toLowerCase(), + owner: mockAccount, + spender: mockSpender, + amount: mockAmount, + blockNumber: mockBlockNumber, + chainId: mockChainId, + }) + }) + }) + + describe('failed transaction handling', () => { + it('should throw error when transaction status is not 1', () => { + const approvalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, mockAmount) + const txReceipt = createMockTransactionReceipt(0, [approvalLog]) + + expect(() => + processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ), + ).toThrow('Approval transaction failed') + }) + + it('should throw error when transaction status is undefined', () => { + const approvalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, mockAmount) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const txReceipt = createMockTransactionReceipt(undefined as any, [approvalLog]) + + expect(() => + processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ), + ).toThrow('Approval transaction failed') + }) + }) + + describe('real-world scenarios', () => { + it('should handle user changing approval amount in wallet', () => { + // User was asked to approve 1 token but changed to 5 in wallet + const requestedAmount = BigInt('1000000000000000000') + const actualAmount = BigInt('5000000000000000000') + + const approvalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, actualAmount) + const txReceipt = createMockTransactionReceipt(1, [approvalLog]) + + const result = processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ) + + expect(result?.amount).toBe(actualAmount) + expect(result?.amount).not.toBe(requestedAmount) + }) + + it('should handle multiple approval events and select correct one', () => { + const otherSpender = '0x1234567890123456789012345678901234567890' + const otherAmount = BigInt('2000000000000000000') + + // Create two approval logs - one for the target spender and one for another + const wrongApprovalLog = createApprovalLog(mockTokenAddress, mockAccount, otherSpender, otherAmount) + const correctApprovalLog = createApprovalLog(mockTokenAddress, mockAccount, mockSpender, mockAmount) + + const txReceipt = createMockTransactionReceipt(1, [wrongApprovalLog, correctApprovalLog]) + + const result = processApprovalTransaction( + { + chainId: mockChainId, + currency: mockToken, + account: mockAccount, + spender: mockSpender, + }, + txReceipt, + ) + + expect(result?.amount).toBe(mockAmount) + expect(result?.spender).toBe(mockSpender) + }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/approveUtils.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/approveUtils.ts new file mode 100644 index 0000000000..a5d99463ff --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/approveUtils.ts @@ -0,0 +1,91 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { Nullish } from '@cowprotocol/types' +import { defaultAbiCoder } from '@ethersproject/abi' +import type { TransactionReceipt } from '@ethersproject/abstract-provider' +import { getAddress } from '@ethersproject/address' +import { id } from '@ethersproject/hash' +import { Currency, Token } from '@uniswap/sdk-core' + +import { SetOptimisticAllowanceParams } from 'entities/optimisticAllowance/useSetOptimisticAllowance' + +// ERC20 Approval event signature: Approval(address indexed owner, address indexed spender, uint256 value) +const APPROVAL_EVENT_TOPIC = id('Approval(address,address,uint256)') + +interface ApprovalTransactionParams { + chainId: SupportedChainId + account: string | undefined + spender: string | undefined + currency: Nullish +} + +export function processApprovalTransaction( + { chainId, currency, account, spender }: ApprovalTransactionParams, + txResponse: TransactionReceipt, +): SetOptimisticAllowanceParams | null { + if (txResponse.status !== 1) { + throw new Error('Approval transaction failed') + } + + // Set optimistic allowance immediately after transaction is mined + // Extract the actual approved amount from transaction logs + if (currency && account && spender && chainId) { + const tokenAddress = (currency as Token).address + + if (tokenAddress) { + const approvedAmount = extractApprovalAmountFromLogs(txResponse, tokenAddress, account, spender) + + if (approvedAmount !== undefined) { + return { + tokenAddress: tokenAddress.toLowerCase(), + owner: account, + spender, + amount: approvedAmount, + blockNumber: txResponse.blockNumber, + chainId, + } + } + } + } + + return null +} + +/** + * Extracts the approved amount from the Approval event in transaction logs + * @param txReceipt Transaction receipt containing logs + * @param tokenAddress Token contract address + * @param owner Address of the token owner + * @param spender Address of the spender + * @returns The approved amount as bigint, or undefined if not found + */ +function extractApprovalAmountFromLogs( + txReceipt: TransactionReceipt, + tokenAddress: string, + owner: string, + spender: string, +): bigint | undefined { + try { + // Find the Approval event log + const approvalLog = txReceipt.logs.find((log) => { + // Check if it's from the token contract and has the Approval event signature + if (log.address.toLowerCase() !== tokenAddress.toLowerCase()) return false + if (log.topics[0] !== APPROVAL_EVENT_TOPIC) return false + + // Verify owner and spender match (topics[1] = owner, topics[2] = spender) + const logOwner = getAddress('0x' + log.topics[1].slice(26)) + const logSpender = getAddress('0x' + log.topics[2].slice(26)) + + return logOwner.toLowerCase() === owner.toLowerCase() && logSpender.toLowerCase() === spender.toLowerCase() + }) + + if (!approvalLog) return undefined + + // Parse the value from log data (3rd parameter of Approval event) + const value = defaultAbiCoder.decode(['uint256'], approvalLog.data)[0] + + return BigInt(value.toString()) + } catch (error) { + console.error('Error extracting approval amount from logs:', error) + return undefined + } +} diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useApprovalAnalytics.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useApprovalAnalytics.ts new file mode 100644 index 0000000000..87524350c0 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useApprovalAnalytics.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' + +import { CowSwapAnalyticsCategory } from 'common/analytics/types' + +export function useApprovalAnalytics(): (action: string, symbol?: string, errorCode?: number | null) => void { + const cowAnalytics = useCowAnalytics() + + return useCallback( + (action: string, symbol?: string, errorCode?: number | null) => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.TRADE, + action, + label: symbol, + ...(errorCode && { value: errorCode }), + }) + }, + [cowAnalytics], + ) +} diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useHandleApprovalError.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useHandleApprovalError.ts new file mode 100644 index 0000000000..9cd17da37c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useHandleApprovalError.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react' + +import { errorToString, isRejectRequestProviderError } from '@cowprotocol/common-utils' + +import { useApprovalAnalytics } from './useApprovalAnalytics' + +import { useUpdateApproveProgressModalState } from '../../state' + +export function useHandleApprovalError(symbol: string | undefined): (error: unknown) => void { + const updateApproveProgressModalState = useUpdateApproveProgressModalState() + const approvalAnalytics = useApprovalAnalytics() + + return useCallback( + (error: unknown) => { + console.error('Error setting the allowance for token', error) + + if (isRejectRequestProviderError(error)) { + updateApproveProgressModalState({ error: 'User rejected approval transaction' }) + } else { + const errorCode = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? error.code : null + approvalAnalytics('Error', symbol, errorCode) + updateApproveProgressModalState({ error: errorToString(error) }) + } + }, + [updateApproveProgressModalState, approvalAnalytics, symbol], + ) +} diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.test.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.test.ts index 2a8c61ef6e..66f976e3d4 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.test.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.test.ts @@ -1,332 +1,573 @@ +import { useCowAnalytics } from '@cowprotocol/analytics' +import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' +import { useFeatureFlags } from '@cowprotocol/common-hooks' +import { useWalletInfo } from '@cowprotocol/wallet' import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider' -import { Token, CurrencyAmount } from '@uniswap/sdk-core' +import { Token } from '@uniswap/sdk-core' -import { renderHook } from '@testing-library/react' +import { renderHook, waitFor } from '@testing-library/react' +import { useSetOptimisticAllowance } from 'entities/optimisticAllowance/useSetOptimisticAllowance' +import { CowSwapAnalyticsCategory } from 'common/analytics/types' + +import { processApprovalTransaction } from './approveUtils' import { useTradeApproveCallback } from './useTradeApproveCallback' -const mockUpdateApproveProgressModalState = jest.fn() -const mockResetApproveProgressModalState = jest.fn() -const mockApproveCallback = jest.fn() -const mockSendEvent = jest.fn() -const mockUseTradeSpenderAddress = jest.fn() -const mockUseFeatureFlags = jest.fn() +import { useApproveCallback } from '../../hooks' +import { useResetApproveProgressModalState, useUpdateApproveProgressModalState } from '../../state' -jest.mock('@cowprotocol/balances-and-allowances', () => ({ - useTradeSpenderAddress: () => mockUseTradeSpenderAddress(), +jest.mock('@cowprotocol/analytics', () => ({ + useCowAnalytics: jest.fn(), + __resetGtmInstance: jest.fn(), })) -jest.mock('@cowprotocol/common-hooks', () => ({ - useFeatureFlags: () => mockUseFeatureFlags(), +jest.mock('@cowprotocol/balances-and-allowances', () => ({ + useTradeSpenderAddress: jest.fn(), })) -jest.mock('@cowprotocol/common-utils', () => ({ - errorToString: (error: unknown) => `Error: ${error}`, - isRejectRequestProviderError: (error: unknown) => error === 'reject', +jest.mock('@cowprotocol/wallet', () => ({ + useWalletInfo: jest.fn(), })) -jest.mock('../../hooks', () => ({ - useApproveCallback: () => mockApproveCallback, +jest.mock('entities/optimisticAllowance/useSetOptimisticAllowance', () => ({ + useSetOptimisticAllowance: jest.fn(), })) -jest.mock('../../state', () => ({ - useUpdateApproveProgressModalState: () => mockUpdateApproveProgressModalState, - useResetApproveProgressModalState: () => mockResetApproveProgressModalState, +jest.mock('./approveUtils', () => ({ + processApprovalTransaction: jest.fn(), })) -jest.mock('./useApproveCowAnalytics', () => ({ - useApproveCowAnalytics: () => mockSendEvent, +jest.mock('@cowprotocol/common-hooks', () => ({ + useFeatureFlags: jest.fn(), })) -const mockCurrency = new Token(1, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 6, 'USDC', 'USD Coin') - -const mockTransactionResponse = { - hash: '0x123', - wait: jest.fn(), -} as unknown as TransactionResponse +jest.mock('../../hooks', () => ({ + useApproveCallback: jest.fn(), +})) -const mockTransactionReceipt = { - transactionHash: '0x123', - blockNumber: 12345, -} as unknown as TransactionReceipt +jest.mock('../../state', () => ({ + useUpdateApproveProgressModalState: jest.fn(), + useResetApproveProgressModalState: jest.fn(), +})) +const mockUseCowAnalytics = useCowAnalytics as jest.MockedFunction +const mockUseTradeSpenderAddress = useTradeSpenderAddress as jest.MockedFunction +const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction +const mockUseApproveCallback = useApproveCallback as jest.MockedFunction +const mockUseUpdateTradeApproveState = useUpdateApproveProgressModalState as jest.MockedFunction< + typeof useUpdateApproveProgressModalState +> +const mockUseResetApproveProgressModalState = useResetApproveProgressModalState as jest.MockedFunction< + typeof useResetApproveProgressModalState +> +const mockUseWalletInfo = useWalletInfo as jest.MockedFunction +const mockUseSetOptimisticAllowance = useSetOptimisticAllowance as jest.MockedFunction +const mockProcessApprovalTransaction = processApprovalTransaction as jest.MockedFunction< + typeof processApprovalTransaction +> + +// eslint-disable-next-line max-lines-per-function describe('useTradeApproveCallback', () => { + const mockToken = new Token(1, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 18, 'TEST', 'Test Token') + const mockSpenderAddress = '0x9008D19f58AAbD9eD0D60971565AA8510560ab41' + const mockAmount = BigInt('1000000000000000000') + + const mockSendEvent = jest.fn() + const mockUpdateTradeApproveState = jest.fn() + const mockResetApproveProgressModalState = jest.fn() + const mockApproveCallback = jest.fn() + const mockSetOptimisticAllowance = jest.fn() + const mockAccount = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' // Valid address + const mockChainId = 1 + + const createMockTransactionReceipt = (status: number): TransactionReceipt => { + return { + to: mockSpenderAddress, + from: '0xfrom1234567890123456789012345678901234567890', + contractAddress: mockToken.address, + transactionIndex: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gasUsed: { toString: () => '21000' } as any, + logsBloom: '0x', + blockHash: '0xblockhash', + transactionHash: '0xtxhash', + logs: [], + blockNumber: 123456, + confirmations: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cumulativeGasUsed: { toString: () => '21000' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + effectiveGasPrice: { toString: () => '1000000000' } as any, + byzantium: true, + type: 2, + status, + } + } + + const createMockTransactionResponse = (status: number): TransactionResponse => + ({ + hash: '0xtxhash', + confirmations: 0, + from: '0xfrom1234567890123456789012345678901234567890', + wait: jest.fn().mockResolvedValue(createMockTransactionReceipt(status)), + nonce: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gasLimit: { toString: () => '21000' } as any, + data: '0x', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: { toString: () => '0' } as any, + chainId: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any + beforeEach(() => { jest.clearAllMocks() - mockUseTradeSpenderAddress.mockReturnValue('0x123') - mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) - mockApproveCallback.mockResolvedValue(mockTransactionResponse) - mockTransactionResponse.wait = jest.fn().mockResolvedValue(mockTransactionReceipt) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUseCowAnalytics.mockReturnValue({ sendEvent: mockSendEvent } as any) + mockUseTradeSpenderAddress.mockReturnValue(mockSpenderAddress) + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: false }) + mockUseApproveCallback.mockReturnValue(mockApproveCallback) + mockUseUpdateTradeApproveState.mockReturnValue(mockUpdateTradeApproveState) + mockUseResetApproveProgressModalState.mockReturnValue(mockResetApproveProgressModalState) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUseWalletInfo.mockReturnValue({ chainId: mockChainId, account: mockAccount } as any) + mockUseSetOptimisticAllowance.mockReturnValue(mockSetOptimisticAllowance) }) - describe('basic functionality', () => { - it('should return a function', () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) - expect(typeof result.current).toBe('function') - }) + describe('successful approval flow', () => { + it('should extract and set optimistic allowance from transaction logs', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue({ + tokenAddress: mockToken.address.toLowerCase(), + owner: mockAccount, + spender: mockSpenderAddress, + amount: mockAmount, + blockNumber: 123456, + chainId: mockChainId, + }) + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) - it('should call updateApproveProgressModalState with correct initial state', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - await result.current(BigInt(1000)) + await result.current(mockAmount, { useModals: true, waitForTxConfirmation: true }) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - currency: mockCurrency, - approveInProgress: true, - amountToApprove: expect.any(CurrencyAmount), + await waitFor(() => { + expect(mockProcessApprovalTransaction).toHaveBeenCalled() + expect(mockSetOptimisticAllowance).toHaveBeenCalledWith({ + tokenAddress: mockToken.address.toLowerCase(), + owner: mockAccount, + spender: mockSpenderAddress, + amount: mockAmount, + blockNumber: 123456, + chainId: mockChainId, + }) }) }) - it('should call analytics with correct parameters', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should use actual approved amount from logs when user changes amount in wallet', async () => { + const userChangedAmount = BigInt('5000000000000000000') // User changed to 5 tokens instead of 1 + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue({ + tokenAddress: mockToken.address.toLowerCase(), + owner: mockAccount, + spender: mockSpenderAddress, + amount: userChangedAmount, + blockNumber: 123456, + chainId: mockChainId, + }) + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - await result.current(BigInt(1000)) + await result.current(mockAmount, { useModals: true, waitForTxConfirmation: true }) // Request 1 token - expect(mockSendEvent).toHaveBeenCalledWith('Send', 'USDC') - expect(mockSendEvent).toHaveBeenCalledWith('Sign', 'USDC') + await waitFor(() => { + expect(mockProcessApprovalTransaction).toHaveBeenCalled() + expect(mockSetOptimisticAllowance).toHaveBeenCalledWith({ + tokenAddress: mockToken.address.toLowerCase(), + owner: mockAccount, + spender: mockSpenderAddress, + amount: userChangedAmount, // Should use the actual amount from logs, not mockAmount + blockNumber: 123456, + chainId: mockChainId, + }) + }) }) - it('should call approveCallback with correct amount', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should not set optimistic allowance when logs are missing', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) // No approval data found + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) - await result.current(BigInt(1000)) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - expect(mockApproveCallback).toHaveBeenCalledWith(BigInt(1000)) - }) - }) + await result.current(mockAmount, { useModals: true, waitForTxConfirmation: true }) - describe('waitForTxConfirmation parameter', () => { - it('should return TransactionResponse when waitForTxConfirmation is false', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + await waitFor(() => { + expect(mockProcessApprovalTransaction).toHaveBeenCalled() + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) + }) - const response = await result.current(BigInt(1000), { useModals: true, waitForTxConfirmation: false }) + expect(mockSetOptimisticAllowance).not.toHaveBeenCalled() + }) - expect(response).toBe(mockTransactionResponse) - expect(mockTransactionResponse.wait).not.toHaveBeenCalled() + it('should successfully approve tokens and track analytics', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) + + await result.current(mockAmount, { useModals: true, waitForTxConfirmation: true }) + + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: true, + amountToApprove: expect.any(Object), + }) + expect(mockApproveCallback).toHaveBeenCalledWith(mockAmount) + expect(mockSendEvent).toHaveBeenCalledWith({ + category: CowSwapAnalyticsCategory.TRADE, + action: 'Send', + label: mockToken.symbol, + }) + expect(mockSendEvent).toHaveBeenCalledWith({ + category: CowSwapAnalyticsCategory.TRADE, + action: 'Sign', + label: mockToken.symbol, + }) + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) + }) }) - it('should return TransactionResponse when waitForTxConfirmation is undefined', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should return transaction receipt on successful approval', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) - const response = await result.current(BigInt(1000)) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - expect(response).toBe(mockTransactionResponse) - expect(mockTransactionResponse.wait).not.toHaveBeenCalled() + await result.current(mockAmount) + + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) + }) }) - it('should return TransactionReceipt when waitForTxConfirmation is true', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should wait for transaction to be mined', async () => { + const mockTxResponse = createMockTransactionResponse(1) + const mockWait = jest.fn().mockResolvedValue(createMockTransactionReceipt(1)) + mockTxResponse.wait = mockWait + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) - const receipt = await result.current(BigInt(1000), { useModals: true, waitForTxConfirmation: true }) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - expect(receipt).toBe(mockTransactionReceipt) - expect(mockTransactionResponse.wait).toHaveBeenCalled() + await result.current(mockAmount, { useModals: true, waitForTxConfirmation: true }) + + await waitFor(() => { + expect(mockWait).toHaveBeenCalled() + }) }) }) - describe('useModals parameter', () => { - it('should not call updateApproveProgressModalState when useModals is false', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + describe('failed approval flow', () => { + it('should handle transaction failure (status !== 1)', async () => { + const mockTxResponse = createMockTransactionResponse(0) + mockApproveCallback.mockResolvedValue(mockTxResponse) + // processApprovalTransaction will throw an error for status !== 1 + mockProcessApprovalTransaction.mockImplementation(() => { + throw new Error('Approval transaction failed') + }) + mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) + + const receipt = await result.current(mockAmount, { useModals: true, waitForTxConfirmation: true }) + + await waitFor(() => { + expect(receipt).toBeUndefined() + // Error should be set first + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + error: expect.stringContaining('Approval transaction failed'), + }) + // Then cleanup in finally block + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) + }) + }) - await result.current(BigInt(1000), { useModals: false }) + it('should handle user rejection', async () => { + const rejectError = { code: 4001, message: 'User rejected the request' } + mockApproveCallback.mockRejectedValue(rejectError) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) + + const receipt = await result.current(mockAmount) + + await waitFor(() => { + expect(receipt).toBeUndefined() + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + error: 'User rejected approval transaction', + }) + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) + }) + }) - expect(mockUpdateApproveProgressModalState).not.toHaveBeenCalledWith({ - currency: mockCurrency, - approveInProgress: true, - amountToApprove: expect.any(CurrencyAmount), + it('should handle generic errors and track analytics', async () => { + const genericError = { code: 500, message: 'Network error' } + mockApproveCallback.mockRejectedValue(genericError) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) + + const receipt = await result.current(mockAmount) + + await waitFor(() => { + expect(receipt).toBeUndefined() + expect(mockSendEvent).toHaveBeenCalledWith({ + category: CowSwapAnalyticsCategory.TRADE, + action: 'Error', + label: mockToken.symbol, + value: 500, + }) + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + error: expect.any(String), + }) }) }) - it('should call updateApproveProgressModalState when useModals is true', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should handle errors without error codes', async () => { + const errorWithoutCode = new Error('Generic error') + mockApproveCallback.mockRejectedValue(errorWithoutCode) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - await result.current(BigInt(1000), { useModals: true }) + const receipt = await result.current(mockAmount) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - currency: mockCurrency, - approveInProgress: true, - amountToApprove: expect.any(CurrencyAmount), + await waitFor(() => { + expect(receipt).toBeUndefined() + expect(mockSendEvent).toHaveBeenCalledWith({ + category: CowSwapAnalyticsCategory.TRADE, + action: 'Error', + label: mockToken.symbol, + }) }) }) }) - describe('partial approve feature flag', () => { - it('should hide modal when partial approve is disabled and waitForTxConfirmation is false', async () => { + describe('partial approval feature flag behavior', () => { + it('should hide modal immediately when feature is disabled and transaction is sent', async () => { mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: false }) - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) - - await result.current(BigInt(1000), { useModals: true, waitForTxConfirmation: false }) - - expect(mockResetApproveProgressModalState).toHaveBeenCalled() + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) + + await result.current(mockAmount) + + await waitFor(() => { + // When feature is disabled, only "Send" event is tracked, not "Sign" + expect(mockSendEvent).toHaveBeenCalledWith({ + category: CowSwapAnalyticsCategory.TRADE, + action: 'Send', + label: mockToken.symbol, + }) + // Modal should be hidden immediately after response + expect(mockResetApproveProgressModalState).toHaveBeenCalled() + }) }) - it('should not hide modal early when partial approve is enabled', async () => { + it('should not hide modal early when feature is enabled', async () => { mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) - - await result.current(BigInt(1000), { useModals: true, waitForTxConfirmation: false }) - - expect(mockResetApproveProgressModalState).not.toHaveBeenCalled() + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) + + await result.current(mockAmount) + + await waitFor(() => { + expect(mockSendEvent).toHaveBeenCalledWith({ + category: CowSwapAnalyticsCategory.TRADE, + action: 'Sign', + label: mockToken.symbol, + }) + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + isPendingInProgress: true, + }) + }) }) + }) - it('should set isPendingInProgress to true when partial approve is enabled', async () => { - mockUseFeatureFlags.mockReturnValue({ isPartialApproveEnabled: true }) - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + describe('useModals parameter', () => { + it('should show modal when useModals is true (default)', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) - await result.current(BigInt(1000), { useModals: true, waitForTxConfirmation: false }) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - isPendingInProgress: true, + await result.current(mockAmount) + + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: true, + amountToApprove: expect.any(Object), + }) }) }) - }) - describe('error handling', () => { - it('should handle approveCallback returning undefined', async () => { - mockApproveCallback.mockResolvedValue(undefined) - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should not show modal when useModals is false', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) - const response = await result.current(BigInt(1000)) - - expect(response).toBeUndefined() - expect(mockResetApproveProgressModalState).toHaveBeenCalled() - }) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - it('should handle reject request provider error', async () => { - mockApproveCallback.mockRejectedValue('reject') - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + await result.current(mockAmount, { useModals: false }) - const response = await result.current(BigInt(1000)) - expect(response).toBeUndefined() - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - error: 'User rejected approval transaction', + await waitFor(() => { + const modalCalls = mockUpdateTradeApproveState.mock.calls.filter((call) => call[0].approveInProgress === true) + expect(modalCalls).toHaveLength(0) }) }) - it('should handle generic error with error code', async () => { - const errorWithCode = { code: 4001, message: 'User rejected' } - mockApproveCallback.mockRejectedValue(errorWithCode) - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should still cleanup state even when useModals is false', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) - const response = await result.current(BigInt(1000)) - expect(response).toBeUndefined() - expect(mockSendEvent).toHaveBeenCalledWith('Error', 'USDC', 4001) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - error: 'Error: [object Object]', - }) - }) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - it('should handle generic error without error code', async () => { - const errorWithoutCode = { message: 'Network error' } - mockApproveCallback.mockRejectedValue(errorWithoutCode) - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + await result.current(mockAmount, { useModals: false }) - const response = await result.current(BigInt(1000)) - expect(response).toBeUndefined() - expect(mockSendEvent).toHaveBeenCalledWith('Error', 'USDC', null) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - error: 'Error: [object Object]', + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) }) }) + }) + + describe('state cleanup', () => { + it('should always reset state in finally block', async () => { + const mockTxResponse = createMockTransactionResponse(1) + mockApproveCallback.mockResolvedValue(mockTxResponse) + mockProcessApprovalTransaction.mockReturnValue(null) - it('should handle error during waitForTxConfirmation', async () => { - mockTransactionResponse.wait = jest.fn().mockRejectedValue('wait error') - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - const response = await result.current(BigInt(1000), { useModals: true, waitForTxConfirmation: true }) - expect(response).toBeUndefined() - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - error: 'Error: wait error', + await result.current(mockAmount) + + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) }) }) - }) - describe('finally block behavior', () => { - it('should always call updateApproveProgressModalState to reset state on success', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should reset state even on error', async () => { + mockApproveCallback.mockRejectedValue(new Error('Test error')) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - await result.current(BigInt(1000)) + await result.current(mockAmount) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - currency: mockCurrency, - approveInProgress: false, - amountToApprove: undefined, - isPendingInProgress: false, + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) }) }) - it('should always call updateApproveProgressModalState to reset state on error', async () => { - mockApproveCallback.mockRejectedValue('error') - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) + it('should reset state even when transaction fails', async () => { + const mockTxResponse = createMockTransactionResponse(0) + mockApproveCallback.mockResolvedValue(mockTxResponse) + // processApprovalTransaction will throw an error for status !== 1 + mockProcessApprovalTransaction.mockImplementation(() => { + throw new Error('Approval transaction failed') + }) + + const { result } = renderHook(() => useTradeApproveCallback(mockToken)) - const response = await result.current(BigInt(1000)) - expect(response).toBeUndefined() + await result.current(mockAmount) - expect(mockUpdateApproveProgressModalState).toHaveBeenCalledWith({ - currency: mockCurrency, - approveInProgress: false, - amountToApprove: undefined, - isPendingInProgress: false, + await waitFor(() => { + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ + currency: mockToken, + approveInProgress: false, + amountToApprove: undefined, + isPendingInProgress: false, + }) }) }) }) - describe('hook dependencies', () => { - it('should recreate callback when currency changes', () => { - const { result, rerender } = renderHook(({ currency }) => useTradeApproveCallback(currency), { - initialProps: { currency: mockCurrency }, - }) + describe('memoization and re-renders', () => { + it('should return stable callback reference', () => { + const { result, rerender } = renderHook(() => useTradeApproveCallback(mockToken)) const firstCallback = result.current - rerender({ currency: new Token(1, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI', 'Dai Stablecoin') }) + rerender() - expect(result.current).not.toBe(firstCallback) + expect(result.current).toBe(firstCallback) }) - it('should recreate callback when symbol changes', () => { + it('should update callback when dependencies change', () => { const { result, rerender } = renderHook(({ currency }) => useTradeApproveCallback(currency), { - initialProps: { currency: mockCurrency }, + initialProps: { currency: mockToken }, }) const firstCallback = result.current - rerender({ currency: new Token(1, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 18, 'WETH', 'Wrapped Ether') }) + const newToken = new Token(1, '0x9876543210987654321098765432109876543210', 18, 'NEW', 'New Token') + rerender({ currency: newToken }) expect(result.current).not.toBe(firstCallback) }) }) - - describe('type safety', () => { - it('should accept bigint amount parameter', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) - - await expect(result.current(BigInt(1000))).resolves.toBeDefined() - await expect(result.current(BigInt(0))).resolves.toBeDefined() - await expect(result.current(BigInt(999999999))).resolves.toBeDefined() - }) - - it('should handle different parameter combinations', async () => { - const { result } = renderHook(() => useTradeApproveCallback(mockCurrency)) - - await expect( - result.current(BigInt(1000), { - useModals: true, - waitForTxConfirmation: false, - }), - ).resolves.toBeDefined() - await expect( - result.current(BigInt(1000), { - useModals: false, - waitForTxConfirmation: true, - }), - ).resolves.toBeDefined() - await expect( - result.current(BigInt(1000), { - useModals: true, - waitForTxConfirmation: true, - }), - ).resolves.toBeDefined() - }) - }) }) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts index 15157af87b..cc8bae52dd 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveModal/useTradeApproveCallback.ts @@ -2,73 +2,105 @@ import { useCallback } from 'react' import { useTradeSpenderAddress } from '@cowprotocol/balances-and-allowances' import { useFeatureFlags } from '@cowprotocol/common-hooks' -import { errorToString, isRejectRequestProviderError } from '@cowprotocol/common-utils' -import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider' +import { useWalletInfo } from '@cowprotocol/wallet' +import type { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import { useApproveCowAnalytics } from './useApproveCowAnalytics' +import { useSetOptimisticAllowance } from 'entities/optimisticAllowance/useSetOptimisticAllowance' + +import { processApprovalTransaction } from './approveUtils' +import { useApprovalAnalytics } from './useApprovalAnalytics' +import { useHandleApprovalError } from './useHandleApprovalError' import { useApproveCallback } from '../../hooks' import { useResetApproveProgressModalState, useUpdateApproveProgressModalState } from '../../state' +interface ProcessTransactionConfirmationParams { + response: TransactionResponse + currency: Currency | undefined + account: string | undefined + spender: string | undefined + chainId: number | undefined + setOptimisticAllowance: (data: { tokenAddress: string; owner: string; spender: string; amount: bigint; blockNumber: number; chainId: number }) => void +} + +async function processTransactionConfirmation({ + response, + currency, + account, + spender, + chainId, + setOptimisticAllowance, +}: ProcessTransactionConfirmationParams): Promise> { + const txResponse = await response.wait() + + if (!chainId) { + return { txResponse, approvedAmount: undefined } + } + + const approvedAmount = processApprovalTransaction( + { + currency, + account, + spender, + chainId, + }, + txResponse, + ) + + if (approvedAmount) { + setOptimisticAllowance(approvedAmount) + } + + return { txResponse, approvedAmount: approvedAmount?.amount } +} + interface TradeApproveCallbackParams { useModals: boolean waitForTxConfirmation?: boolean } +const DEFAULT_APPROVE_PARAMS: TradeApproveCallbackParams = { + useModals: true, + waitForTxConfirmation: false, +} + +export type TradeApproveResult = { txResponse: R; approvedAmount: bigint | undefined } + +export type GenerecTradeApproveResult = TradeApproveResult | TradeApproveResult + export interface TradeApproveCallback { ( amount: bigint, params?: TradeApproveCallbackParams & { waitForTxConfirmation?: false }, - ): Promise + ): Promise | undefined> ( amount: bigint, params: TradeApproveCallbackParams & { waitForTxConfirmation: true }, - ): Promise -} - -function getErrorCode(error: unknown): number | null { - return error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? error.code : null + ): Promise | undefined> } export function useTradeApproveCallback(currency: Currency | undefined): TradeApproveCallback { + const symbol = currency?.symbol + const updateApproveProgressModalState = useUpdateApproveProgressModalState() const resetApproveProgressModalState = useResetApproveProgressModalState() const spender = useTradeSpenderAddress() - const symbol = currency?.symbol const { isPartialApproveEnabled } = useFeatureFlags() + const { chainId, account } = useWalletInfo() + const setOptimisticAllowance = useSetOptimisticAllowance() const approveCallback = useApproveCallback(currency, spender) - const approvalAnalytics = useApproveCowAnalytics() - - const handleApprovalError = useCallback( - (error: unknown) => { - console.error('Error setting the allowance for token', error) - - if (isRejectRequestProviderError(error)) { - updateApproveProgressModalState({ error: 'User rejected approval transaction' }) - } else { - const errorCode = getErrorCode(error) - approvalAnalytics('Error', symbol, errorCode) - updateApproveProgressModalState({ error: errorToString(error) }) - } - }, - [updateApproveProgressModalState, approvalAnalytics, symbol], - ) + const approvalAnalytics = useApprovalAnalytics() + const handleApprovalError = useHandleApprovalError(symbol) return useCallback( - async ( - amount, - { useModals = true, waitForTxConfirmation = false } = { - useModals: true, - waitForTxConfirmation: false, - }, - ) => { + async (amount, { useModals = true, waitForTxConfirmation } = DEFAULT_APPROVE_PARAMS) => { if (useModals) { const amountToApprove = currency ? CurrencyAmount.fromRawAmount(currency, amount.toString()) : undefined updateApproveProgressModalState({ currency, approveInProgress: true, amountToApprove }) @@ -79,25 +111,27 @@ export function useTradeApproveCallback(currency: Currency | undefined): TradeAp try { const response = await approveCallback(amount) - if (!response) { + // if ff is disabled - use old flow, hide modal when tx is sent + if (!response || !isPartialApproveEnabled) { resetApproveProgressModalState() return undefined + } else { + updateApproveProgressModalState({ isPendingInProgress: true }) } approvalAnalytics('Sign', symbol) - // if ff is disabled - use old flow, hide modal when tx is sent - if (isPartialApproveEnabled) { - updateApproveProgressModalState({ isPendingInProgress: true }) - } else { - resetApproveProgressModalState() - } if (waitForTxConfirmation) { - // need to wait response to run finally clause after that - const resp = await response.wait() - return resp + return await processTransactionConfirmation({ + response, + currency, + account, + spender, + chainId, + setOptimisticAllowance, + }) } else { - return response + return { txResponse: response, approvedAmount: undefined } } } catch (error) { handleApprovalError(error) @@ -119,6 +153,10 @@ export function useTradeApproveCallback(currency: Currency | undefined): TradeAp approveCallback, isPartialApproveEnabled, resetApproveProgressModalState, + account, + spender, + chainId, + setOptimisticAllowance, handleApprovalError, ], ) as TradeApproveCallback diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveToggle/TradeApproveToggle.tsx b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveToggle/TradeApproveToggle.tsx index c1796fac0b..cf4b0bd1c9 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveToggle/TradeApproveToggle.tsx +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveToggle/TradeApproveToggle.tsx @@ -15,13 +15,11 @@ export function TradeApproveToggle({ amountToApprove, updateModalState }: TradeA const setIsPartialApproveSelectedByUser = useSetIsPartialApproveSelectedByUser() return ( - <> - - + ) } diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveWithAffectedOrderList/TradeApproveWithAffectedOrderList.tsx b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveWithAffectedOrderList/TradeApproveWithAffectedOrderList.tsx index 272087a64f..4026275822 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveWithAffectedOrderList/TradeApproveWithAffectedOrderList.tsx +++ b/apps/cowswap-frontend/src/modules/erc20Approve/containers/TradeApproveWithAffectedOrderList/TradeApproveWithAffectedOrderList.tsx @@ -6,13 +6,17 @@ import { useGetPartialAmountToSignApprove, useIsApprovalOrPermitRequired, } from '../../hooks' +import { TradeAllowanceDisplay } from '../../pure/TradeAllowanceDisplay' import { useSetUserApproveAmountModalState } from '../../state' import { isMaxAmountToApprove } from '../../utils' import { ActiveOrdersWithAffectedPermit } from '../ActiveOrdersWithAffectedPermit' import { TradeApproveToggle } from '../TradeApproveToggle' export function TradeApproveWithAffectedOrderList(): ReactNode { - const isApproveRequired = useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: false }) + const { + reason: isApproveRequired, + currentAllowance + } = useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: false }) const setUserApproveAmountModalState = useSetUserApproveAmountModalState() @@ -26,17 +30,24 @@ export function TradeApproveWithAffectedOrderList(): ReactNode { isApproveRequired === ApproveRequiredReason.Required || isApproveRequired === ApproveRequiredReason.Eip2612PermitRequired + if (!partialAmountToApprove) return null + + const currencyToApprove = partialAmountToApprove.currency + return ( <> - {partialAmountToApprove && isApproveOrPartialPermitRequired && ( - setUserApproveAmountModalState({ isModalOpen: true })} - amountToApprove={partialAmountToApprove} - /> - )} - {showAffectedOrders && partialAmountToApprove && ( - + {isApproveOrPartialPermitRequired && ( + <> + setUserApproveAmountModalState({ isModalOpen: true })} + amountToApprove={partialAmountToApprove} + /> + {typeof currentAllowance === 'bigint' && currencyToApprove && ( + + )} + )} + {showAffectedOrders && currencyToApprove && } ) } diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveAndSwap.test.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveAndSwap.test.ts new file mode 100644 index 0000000000..f4d0cd4138 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveAndSwap.test.ts @@ -0,0 +1,453 @@ +import { TransactionReceipt } from '@ethersproject/abstract-provider' +import { Token } from '@uniswap/sdk-core' + +import { renderHook, waitFor } from '@testing-library/react' + +import { useApproveAndSwap } from './useApproveAndSwap' +import { useApproveCurrency } from './useApproveCurrency' +import { useGeneratePermitInAdvanceToTrade } from './useGeneratePermitInAdvanceToTrade' + +import { useTokenSupportsPermit } from '../../permit' +import { MAX_APPROVE_AMOUNT } from '../constants' +import { TradeApproveResult } from '../containers' +import { useIsPartialApproveSelectedByUser, useUpdateApproveProgressModalState } from '../state' + +jest.mock('./useApproveCurrency') +jest.mock('./useGeneratePermitInAdvanceToTrade') +jest.mock('../../permit') +jest.mock('../state') + +const mockUseApproveCurrency = useApproveCurrency as jest.MockedFunction +const mockUseGeneratePermitInAdvanceToTrade = useGeneratePermitInAdvanceToTrade as jest.MockedFunction< + typeof useGeneratePermitInAdvanceToTrade +> +const mockUseTokenSupportsPermit = useTokenSupportsPermit as jest.MockedFunction +const mockUseIsPartialApproveSelectedByUser = useIsPartialApproveSelectedByUser as jest.MockedFunction< + typeof useIsPartialApproveSelectedByUser +> +const mockUseUpdateTradeApproveState = useUpdateApproveProgressModalState as jest.MockedFunction< + typeof useUpdateApproveProgressModalState +> + +// eslint-disable-next-line max-lines-per-function +describe('useApproveAndSwap', () => { + const mockToken = new Token(1, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 18, 'TEST', 'Test Token') + const mockAmount = BigInt('1000000000000000000') + const mockAmountToApprove = { + currency: mockToken, + quotient: { toString: () => mockAmount.toString() }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + + const mockOnApproveConfirm = jest.fn() + const mockHandleApprove = jest.fn() + const mockGeneratePermitToTrade = jest.fn() + const mockUpdateTradeApproveState = jest.fn() + + const createMockTransactionReceipt = (): TransactionReceipt => { + return { + to: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41', + from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + contractAddress: mockToken.address, + transactionIndex: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gasUsed: { toString: () => '21000' } as any, + logsBloom: '0x', + blockHash: '0xblockhash', + transactionHash: '0xtxhash', + logs: [], + blockNumber: 123456, + confirmations: 1, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cumulativeGasUsed: { toString: () => '21000' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + effectiveGasPrice: { toString: () => '1000000000' } as any, + byzantium: true, + type: 2, + status: 1, + } + } + + beforeEach(() => { + jest.clearAllMocks() + + mockUseApproveCurrency.mockReturnValue(mockHandleApprove) + mockUseGeneratePermitInAdvanceToTrade.mockReturnValue(mockGeneratePermitToTrade) + mockUseTokenSupportsPermit.mockReturnValue(false) + mockUseIsPartialApproveSelectedByUser.mockReturnValue(false) + mockUseUpdateTradeApproveState.mockReturnValue(mockUpdateTradeApproveState) + }) + + describe('permit flow', () => { + it('should handle successful permit signing and call onApproveConfirm', async () => { + mockUseTokenSupportsPermit.mockReturnValue(true) + mockGeneratePermitToTrade.mockResolvedValue(true) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockGeneratePermitToTrade).toHaveBeenCalled() + expect(mockOnApproveConfirm).toHaveBeenCalled() + expect(mockHandleApprove).not.toHaveBeenCalled() + }) + }) + + it('should not call onApproveConfirm if permit signing fails', async () => { + mockUseTokenSupportsPermit.mockReturnValue(true) + mockGeneratePermitToTrade.mockResolvedValue(false) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockGeneratePermitToTrade).toHaveBeenCalled() + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + expect(mockHandleApprove).not.toHaveBeenCalled() + }) + }) + + it('should not call onApproveConfirm if onApproveConfirm callback is not provided', async () => { + mockUseTokenSupportsPermit.mockReturnValue(true) + mockGeneratePermitToTrade.mockResolvedValue(true) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: undefined, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockGeneratePermitToTrade).not.toHaveBeenCalled() + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) + + it('should skip permit flow when ignorePermit is true', async () => { + mockUseTokenSupportsPermit.mockReturnValue(true) + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: BigInt('2000000000000000000'), + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: true, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockGeneratePermitToTrade).not.toHaveBeenCalled() + expect(mockHandleApprove).toHaveBeenCalled() + expect(mockOnApproveConfirm).toHaveBeenCalled() + }) + }) + }) + + describe('approval flow with TradeApproveResult', () => { + it('should approve and call confirmSwap when approved amount is sufficient', async () => { + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: BigInt('2000000000000000000'), // More than required + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockOnApproveConfirm).toHaveBeenCalled() + }) + }) + + it('should approve and call confirmSwap when approved amount equals required amount', async () => { + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: mockAmount, // Exactly what's required + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockOnApproveConfirm).toHaveBeenCalled() + }) + }) + + it('should set error state when approved amount is insufficient', async () => { + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: BigInt('500000000000000000'), // Less than required + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ error: 'Approved amount is not sufficient!' }) + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) + + it('should set error state when approved amount is undefined', async () => { + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: undefined, + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockUpdateTradeApproveState).toHaveBeenCalledWith({ error: 'Approved amount is not sufficient!' }) + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) + + it('should use partial approve amount when user has enabled it', async () => { + mockUseIsPartialApproveSelectedByUser.mockReturnValue(true) + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: mockAmount, + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(mockAmount) + expect(mockOnApproveConfirm).toHaveBeenCalled() + }) + }) + }) + + describe('no transaction or confirmSwap', () => { + it('should not call confirmSwap when transaction is null', async () => { + mockHandleApprove.mockResolvedValue(null) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) + + it('should not call confirmSwap when transaction is undefined', async () => { + mockHandleApprove.mockResolvedValue(undefined) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) + + it('should not call confirmSwap when confirmSwap callback is not provided', async () => { + const mockTxReceipt = createMockTransactionReceipt() + const mockResult: TradeApproveResult = { + txResponse: mockTxReceipt, + approvedAmount: mockAmount, + } + mockHandleApprove.mockResolvedValue(mockResult) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: undefined, + ignorePermit: false, + useModals: true, + }), + ) + + await result.current() + + await waitFor(() => { + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) + }) + + describe('useModals parameter', () => { + it('should pass useModals=true to useApproveCurrency', () => { + renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + expect(mockUseApproveCurrency).toHaveBeenCalledWith(mockAmountToApprove, true) + }) + + it('should pass useModals=false to useApproveCurrency', () => { + renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: false, + }), + ) + + expect(mockUseApproveCurrency).toHaveBeenCalledWith(mockAmountToApprove, false) + }) + + it('should pass useModals=undefined to useApproveCurrency when not specified', () => { + renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + }), + ) + + expect(mockUseApproveCurrency).toHaveBeenCalledWith(mockAmountToApprove, undefined) + }) + }) + + describe('error handling', () => { + it('should propagate errors from handleApprove', async () => { + const mockError = new Error('Approval failed') + mockHandleApprove.mockRejectedValue(mockError) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await expect(result.current()).rejects.toThrow('Approval failed') + + expect(mockHandleApprove).toHaveBeenCalledWith(MAX_APPROVE_AMOUNT) + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + + it('should propagate errors from generatePermitToTrade', async () => { + mockUseTokenSupportsPermit.mockReturnValue(true) + const mockError = new Error('Permit generation failed') + mockGeneratePermitToTrade.mockRejectedValue(mockError) + + const { result } = renderHook(() => + useApproveAndSwap({ + amountToApprove: mockAmountToApprove, + onApproveConfirm: mockOnApproveConfirm, + ignorePermit: false, + useModals: true, + }), + ) + + await expect(result.current()).rejects.toThrow('Permit generation failed') + + expect(mockGeneratePermitToTrade).toHaveBeenCalled() + expect(mockHandleApprove).not.toHaveBeenCalled() + expect(mockOnApproveConfirm).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveAndSwap.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveAndSwap.ts new file mode 100644 index 0000000000..4c60df5510 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveAndSwap.ts @@ -0,0 +1,84 @@ +import { useCallback } from 'react' + +import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' + +import { useApproveCurrency } from './useApproveCurrency' +import { useGeneratePermitInAdvanceToTrade } from './useGeneratePermitInAdvanceToTrade' + +import { useTokenSupportsPermit } from '../../permit' +import { TradeType } from '../../trade' +import { MAX_APPROVE_AMOUNT } from '../constants' +import { useIsPartialApproveSelectedByUser, useUpdateApproveProgressModalState } from '../state' +import { getIsTradeApproveResult } from '../utils/getIsTradeApproveResult' + +export interface ApproveAndSwapProps { + amountToApprove: CurrencyAmount + onApproveConfirm?: (transactionHash?: string) => void + ignorePermit?: boolean + useModals?: boolean +} + +export function useApproveAndSwap({ + amountToApprove, + useModals, + ignorePermit, + onApproveConfirm, +}: ApproveAndSwapProps): () => Promise { + const isPartialApproveEnabledByUser = useIsPartialApproveSelectedByUser() + const handleApprove = useApproveCurrency(amountToApprove, useModals) + const updateTradeApproveState = useUpdateApproveProgressModalState() + + const isPermitSupported = useTokenSupportsPermit(amountToApprove.currency, TradeType.SWAP) && !ignorePermit + const generatePermitToTrade = useGeneratePermitInAdvanceToTrade(amountToApprove) + + const handlePermit = useCallback(async () => { + if (isPermitSupported && onApproveConfirm) { + const isPermitSigned = await generatePermitToTrade() + if (isPermitSigned) { + onApproveConfirm() + } + + return true + } + + return false + }, [isPermitSupported, onApproveConfirm, generatePermitToTrade]) + + return useCallback(async (): Promise => { + const isPermitFlow = await handlePermit() + + if (isPermitFlow) { + return + } + + const amountToApproveBig = BigInt(amountToApprove.quotient.toString()) + const toApprove = isPartialApproveEnabledByUser ? amountToApproveBig : MAX_APPROVE_AMOUNT + const tx = await handleApprove(toApprove) + + if (tx && onApproveConfirm) { + if (getIsTradeApproveResult(tx)) { + const approvedAmount = tx.approvedAmount + const isApprovedAmountSufficient = Boolean(approvedAmount && approvedAmount >= amountToApproveBig) + + if (isApprovedAmountSufficient) { + const hash = + (tx.txResponse as TransactionReceipt).transactionHash || (tx.txResponse as TransactionResponse).hash + + onApproveConfirm(hash) + } else { + updateTradeApproveState({ error: 'Approved amount is not sufficient!' }) + } + } else { + onApproveConfirm(tx.transactionHash) + } + } + }, [ + onApproveConfirm, + isPartialApproveEnabledByUser, + amountToApprove.quotient, + handleApprove, + updateTradeApproveState, + handlePermit, + ]) +} diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveCurrency.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveCurrency.ts index bc327bff08..9eb0427d61 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveCurrency.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useApproveCurrency.ts @@ -1,22 +1,22 @@ import { useCallback } from 'react' import { Nullish } from '@cowprotocol/types' -import { TransactionReceipt } from '@ethersproject/abstract-provider' import { SafeMultisigTransactionResponse } from '@safe-global/safe-core-sdk-types' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' -import { useTradeApproveCallback } from 'modules/erc20Approve' +import { GenerecTradeApproveResult, useTradeApproveCallback } from 'modules/erc20Approve' import { useShouldZeroApprove, useZeroApprove } from 'modules/zeroApproval' export function useApproveCurrency( amountToApprove: CurrencyAmount | undefined, useModals = true, -): (amount: bigint) => Promise> { +): (amount: bigint) => Promise> { const currency = amountToApprove?.currency const tradeApproveCallback = useTradeApproveCallback(currency) const shouldZeroApprove = useShouldZeroApprove(amountToApprove, true) const zeroApprove = useZeroApprove(currency) + return useCallback( async (amount: bigint) => { if (await shouldZeroApprove()) { diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.test.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.test.ts index a35f06c245..fc07444524 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.test.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.test.ts @@ -93,7 +93,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.Required) + expect(result.current.reason).toBe(ApproveRequiredReason.Required) }) it('should return Required when approval state is PENDING', () => { @@ -106,7 +106,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.Required) + expect(result.current.reason).toBe(ApproveRequiredReason.Required) }) it('should return NotRequired when approval state is APPROVED', () => { @@ -119,7 +119,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -135,7 +135,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should return NotRequired for ADVANCED_ORDERS', () => { @@ -149,7 +149,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should return NotRequired for YIELD', () => { @@ -163,7 +163,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -175,7 +175,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -188,7 +188,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should return NotRequired for native token even with zero amount', () => { @@ -199,7 +199,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should return NotRequired for native token even when bundling is enabled', () => { @@ -210,7 +210,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: true }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -226,7 +226,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: true }), ) - expect(result.current).toBe(ApproveRequiredReason.BundleApproveRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.BundleApproveRequired) }) it('should return BundleApproveRequired when bundling is enabled regardless of permit support', () => { @@ -236,7 +236,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: true }), ) - expect(result.current).toBe(ApproveRequiredReason.BundleApproveRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.BundleApproveRequired) }) it('should return BundleApproveRequired when bundling is enabled and permit is not supported', () => { @@ -250,7 +250,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: true }), ) - expect(result.current).toBe(ApproveRequiredReason.BundleApproveRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.BundleApproveRequired) }) }) @@ -262,7 +262,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.Eip2612PermitRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.Eip2612PermitRequired) }) it('should return DaiLikePermitRequired for dai-like permit type', () => { @@ -272,7 +272,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.DaiLikePermitRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.DaiLikePermitRequired) }) it('should return NotRequired for unsupported permit type', () => { @@ -282,7 +282,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should return NotRequired for undefined permit type', () => { @@ -292,7 +292,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should return NotRequired for null permit type', () => { @@ -302,7 +302,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -314,7 +314,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle undefined permit info', () => { @@ -324,7 +324,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle null amount to approve', () => { @@ -338,7 +338,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle zero amount to approve', () => { @@ -352,7 +352,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle undefined input currency', () => { @@ -366,7 +366,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle undefined trade type', () => { @@ -380,7 +380,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -396,7 +396,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.Eip2612PermitRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.Eip2612PermitRequired) }) it('should fall back to approval when permit is not supported but approval is needed', () => { @@ -410,7 +410,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.Required) + expect(result.current.reason).toBe(ApproveRequiredReason.Required) }) it('should return NotRequired when both permit and approval are not needed', () => { @@ -424,7 +424,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -434,25 +434,25 @@ describe('useIsApprovalOrPermitRequired', () => { const { result: result1 } = renderHook(() => useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result1.current).toBe(ApproveRequiredReason.Eip2612PermitRequired) + expect(result1.current.reason).toBe(ApproveRequiredReason.Eip2612PermitRequired) mockUsePermitInfo.mockReturnValue({ type: 'dai-like' }) const { result: result2 } = renderHook(() => useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result2.current).toBe(ApproveRequiredReason.DaiLikePermitRequired) + expect(result2.current.reason).toBe(ApproveRequiredReason.DaiLikePermitRequired) mockUsePermitInfo.mockReturnValue({ type: 'unsupported' }) const { result: result3 } = renderHook(() => useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result3.current).toBe(ApproveRequiredReason.NotRequired) + expect(result3.current.reason).toBe(ApproveRequiredReason.NotRequired) mockUsePermitInfo.mockReturnValue(undefined) const { result: result4 } = renderHook(() => useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result4.current).toBe(ApproveRequiredReason.NotRequired) + expect(result4.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) @@ -471,7 +471,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.Eip2612PermitRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.Eip2612PermitRequired) }) it('should handle SWAP trade type with partial approve disabled', () => { @@ -488,7 +488,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle non-SWAP trade type with partial approve enabled', () => { @@ -505,7 +505,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) it('should handle approval state UNKNOWN', () => { @@ -519,7 +519,7 @@ describe('useIsApprovalOrPermitRequired', () => { useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: null }), ) - expect(result.current).toBe(ApproveRequiredReason.NotRequired) + expect(result.current.reason).toBe(ApproveRequiredReason.NotRequired) }) }) }) diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.ts b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.ts index 3cc664ffb6..cd3be2af02 100644 --- a/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.ts +++ b/apps/cowswap-frontend/src/modules/erc20Approve/hooks/useIsApprovalOrPermitRequired.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { useFeatureFlags } from '@cowprotocol/common-hooks' import { getIsNativeToken } from '@cowprotocol/common-utils' import { PermitType } from '@cowprotocol/permit-utils' @@ -26,34 +28,39 @@ type AdditionalParams = { isBundlingSupportedOrEnabledForContext: boolean | null } -export function useIsApprovalOrPermitRequired({ - isBundlingSupportedOrEnabledForContext, -}: AdditionalParams): ApproveRequiredReason { +export function useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext }: AdditionalParams): { + reason: ApproveRequiredReason + currentAllowance: Nullish +} { const amountToApprove = useGetAmountToSignApprove() const { isPartialApproveEnabled } = useFeatureFlags() - const { state: approvalState } = useApproveState(amountToApprove) + const { state: approvalState, currentAllowance } = useApproveState(amountToApprove) const { inputCurrency, tradeType } = useDerivedTradeState() || {} const { type } = usePermitInfo(inputCurrency, tradeType) || {} - if (!checkIsAmountAndCurrencyRequireApprove(amountToApprove)) { - return ApproveRequiredReason.NotRequired - } + const reason = (() => { + if (!checkIsAmountAndCurrencyRequireApprove(amountToApprove)) { + return ApproveRequiredReason.NotRequired + } - const isPermitSupported = type && type !== 'unsupported' + const isPermitSupported = type && type !== 'unsupported' - if (!isPermitSupported && isApprovalRequired(approvalState)) { - return isBundlingSupportedOrEnabledForContext - ? ApproveRequiredReason.BundleApproveRequired - : ApproveRequiredReason.Required - } + if (!isPermitSupported && isApprovalRequired(approvalState)) { + return isBundlingSupportedOrEnabledForContext + ? ApproveRequiredReason.BundleApproveRequired + : ApproveRequiredReason.Required + } - if (isBundlingSupportedOrEnabledForContext) return ApproveRequiredReason.BundleApproveRequired + if (isBundlingSupportedOrEnabledForContext) return ApproveRequiredReason.BundleApproveRequired - if (!isNewApproveFlowEnabled(tradeType, isPartialApproveEnabled)) { - return ApproveRequiredReason.NotRequired - } + if (!isNewApproveFlowEnabled(tradeType, isPartialApproveEnabled)) { + return ApproveRequiredReason.NotRequired + } + + return getPermitRequirements(type) + })() - return getPermitRequirements(type) + return useMemo(() => ({ reason, currentAllowance }), [reason, currentAllowance]) } function checkIsAmountAndCurrencyRequireApprove(amountToApprove: CurrencyAmount | null): boolean { diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/pure/TradeAllowanceDisplay/index.tsx b/apps/cowswap-frontend/src/modules/erc20Approve/pure/TradeAllowanceDisplay/index.tsx new file mode 100644 index 0000000000..9dfa7eb5fb --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/pure/TradeAllowanceDisplay/index.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react' + +import { TokenAmount } from '@cowprotocol/ui' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' + +import * as styledEl from './styled' + +interface TradeAllowanceDisplayProps { + currencyToApprove: Currency + currentAllowance: bigint +} + +export function TradeAllowanceDisplay({ currentAllowance, currencyToApprove }: TradeAllowanceDisplayProps): ReactNode { + const allowanceAmount = CurrencyAmount.fromRawAmount(currencyToApprove, currentAllowance.toString()) + + return ( + + Current allowance + + + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/pure/TradeAllowanceDisplay/styled.ts b/apps/cowswap-frontend/src/modules/erc20Approve/pure/TradeAllowanceDisplay/styled.ts new file mode 100644 index 0000000000..7fe8a54894 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/pure/TradeAllowanceDisplay/styled.ts @@ -0,0 +1,30 @@ +import { UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +export const AllowanceWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + background: var(${UI.COLOR_BACKGROUND}); + color: var(${UI.COLOR_TEXT_PAPER}); + font-size: 13px; +` + +export const AllowanceLabel = styled.div` + font-size: 12px; + font-weight: 500; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + letter-spacing: 0.03em; +` + +export const AllowanceAmount = styled.div` + font-size: 15px; + font-weight: 600; + color: var(${UI.COLOR_TEXT}); + display: flex; + align-items: center; + gap: 4px; +` diff --git a/apps/cowswap-frontend/src/modules/erc20Approve/utils/getIsTradeApproveResult.ts b/apps/cowswap-frontend/src/modules/erc20Approve/utils/getIsTradeApproveResult.ts new file mode 100644 index 0000000000..f14d7ad661 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/erc20Approve/utils/getIsTradeApproveResult.ts @@ -0,0 +1,13 @@ +import { Nullish } from '@cowprotocol/types' +import { SafeMultisigTransactionResponse } from '@safe-global/safe-core-sdk-types' + +import { GenerecTradeApproveResult } from '../containers' + +export function getIsTradeApproveResult( + result: Nullish, +): result is GenerecTradeApproveResult { + if (!result) return false + const tradeApprove = result as GenerecTradeApproveResult + + return !!tradeApprove.txResponse +} diff --git a/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts b/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts index c85c945e9c..91b404db24 100644 --- a/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts +++ b/apps/cowswap-frontend/src/modules/ethFlow/containers/EthFlow/hooks/useEthFlowActions.ts @@ -74,7 +74,7 @@ export function useEthFlowActions(callbacks: EthFlowActionCallbacks, amountToApp ? amountToApprove || MAX_APPROVE_AMOUNT : MAX_APPROVE_AMOUNT return sendTransaction('approve', () => { - return callbacks.approve(unitsToApprove).then((res) => res?.hash) + return callbacks.approve(unitsToApprove).then((res) => res?.txResponse.hash) }) } diff --git a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/hooks/usePendingTransactionsContext.ts b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/hooks/usePendingTransactionsContext.ts index 4f7163c130..bf3992eabf 100644 --- a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/hooks/usePendingTransactionsContext.ts +++ b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/hooks/usePendingTransactionsContext.ts @@ -11,7 +11,6 @@ import { useGetTwapOrderById } from 'modules/twap/hooks/useGetTwapOrderById' import { useBlockNumber } from 'common/hooks/useBlockNumber' import { useGetReceipt } from 'common/hooks/useGetReceipt' -import { useUpdateLastApproveTxBlockNumber } from 'common/hooks/useTokenAllowance' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import { CheckEthereumTransactions } from '../types' @@ -23,7 +22,6 @@ export function usePendingTransactionsContext(): CheckEthereumTransactions | nul const isSafeWallet = !!safeInfo const lastBlockNumber = useBlockNumber() - const updateLastApproveTxBlockNumber = useUpdateLastApproveTxBlockNumber() const dispatch = useAppDispatch() const cancelOrdersBatch = useCancelOrdersBatch() const getReceipt = useGetReceipt(chainId) @@ -50,7 +48,6 @@ export function usePendingTransactionsContext(): CheckEthereumTransactions | nul getTwapOrderById, transactionsCount, safeInfo, - updateLastApproveTxBlockNumber, } return params @@ -68,7 +65,6 @@ export function usePendingTransactionsContext(): CheckEthereumTransactions | nul cancelOrdersBatch, getTwapOrderById, safeInfo, - updateLastApproveTxBlockNumber, ], null, ) diff --git a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/finalizeEthereumTransaction.ts b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/finalizeEthereumTransaction.ts index 5be5b84a2b..7aa5ef91de 100644 --- a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/finalizeEthereumTransaction.ts +++ b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/finalizeEthereumTransaction.ts @@ -16,21 +16,13 @@ export function finalizeEthereumTransaction( params: CheckEthereumTransactions, safeTransactionHash?: string, ): void { - const { chainId, dispatch, updateLastApproveTxBlockNumber } = params + const { chainId, dispatch } = params const { hash } = transaction console.log(`[FinalizeTxUpdater] Transaction ${receipt.transactionHash} has been mined`, receipt, transaction) ONCHAIN_TRANSACTIONS_EVENTS.emit(OnchainTxEvents.BEFORE_TX_FINALIZE, { transaction, receipt }) - // Once approval tx is mined, we add the priority allowance to immediately allow the user to place orders - if (transaction.approval) { - updateLastApproveTxBlockNumber({ - blockNumber: receipt.blockNumber, - tokenAddress: transaction.approval.tokenAddress, - }) - } - dispatch( finalizeTransaction({ chainId, diff --git a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/types.ts b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/types.ts index 0053d6c3b8..33bc021ea1 100644 --- a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/types.ts +++ b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/types.ts @@ -22,5 +22,4 @@ export interface CheckEthereumTransactions { nativeCurrencySymbol: string cancelOrdersBatch: CancelOrdersBatchCallback safeInfo: GnosisSafeInfo | undefined - updateLastApproveTxBlockNumber: (params: { blockNumber: number; tokenAddress: string }) => void } diff --git a/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts b/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts index 22a2c8a080..6d036a71e3 100644 --- a/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts +++ b/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts @@ -29,6 +29,7 @@ export function useGetCachedPermit(): ( try { const eip2612Utils = getPermitUtilsInstance(chainId, provider, account) + // TODO: it might add unwanted node RPC requests // Always get the nonce for the real account, to know whether the cache should be invalidated // Static account should never need to pre-check the nonce as it'll never change once cached const nonce = account ? await eip2612Utils.getTokenNonce(tokenAddress, account) : undefined diff --git a/apps/cowswap-frontend/src/modules/permit/hooks/useHasCachedPermit.ts b/apps/cowswap-frontend/src/modules/permit/hooks/useHasCachedPermit.ts new file mode 100644 index 0000000000..683149cf4e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/permit/hooks/useHasCachedPermit.ts @@ -0,0 +1,18 @@ +import { getWrappedToken } from '@cowprotocol/common-utils' +import { PermitHookData } from '@cowprotocol/permit-utils' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' + +import useSWR, { SWRResponse } from 'swr' + +import { useGetCachedPermit } from './useGetCachedPermit' + +export function useHasCachedPermit(amountToApprove: CurrencyAmount): SWRResponse { + const getCachedPermit = useGetCachedPermit() + const token = getWrappedToken(amountToApprove.currency) + const tokenAddress = token.address + const amount = BigInt(amountToApprove.quotient.toString()) + + return useSWR([tokenAddress, amount, token.chainId, 'useHasCachedPermit'], ([tokenAddress, amount]) => { + return getCachedPermit(tokenAddress, amount) + }) +} diff --git a/apps/cowswap-frontend/src/modules/permit/index.ts b/apps/cowswap-frontend/src/modules/permit/index.ts index cb734a99e6..a6cca91903 100644 --- a/apps/cowswap-frontend/src/modules/permit/index.ts +++ b/apps/cowswap-frontend/src/modules/permit/index.ts @@ -3,6 +3,7 @@ export * from './hooks/useGeneratePermitHook' export * from './hooks/usePermitInfo' export * from './hooks/usePermitCompatibleTokens' export * from './hooks/useTokenSupportsPermit' +export * from './hooks/useHasCachedPermit' export { useGetCachedPermit } from './hooks/useGetCachedPermit' export * from './types' export * from './utils/handlePermit' diff --git a/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts b/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts index c4d4c88b3d..3215d3bde2 100644 --- a/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts +++ b/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts @@ -6,7 +6,7 @@ import { GetPermitCacheParams, PermitCache, PermitCacheKeyParams, - StorePermitCacheParams + StorePermitCacheParams, } from '../types' /** @@ -14,14 +14,18 @@ import { * Should never change once it has been created. * Used exclusively for quote requests */ -export const staticPermitCacheAtom = atomWithStorage('staticPermitCache:v3', {}) +export const staticPermitCacheAtom = atomWithStorage('staticPermitCache:v3', {}, undefined, { + unstable_getOnInit: true, +}) /** * Atom that stores permit data for user permit requests. * Should be updated whenever the permit nonce is updated. * Used exclusively for order requests */ -export const userPermitCacheAtom = atomWithStorage('userPermitCache:v1', {}) +export const userPermitCacheAtom = atomWithStorage('userPermitCache:v1', {}, undefined, { + unstable_getOnInit: true, +}) /** * Atom to add/update permit cache data diff --git a/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx b/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx index 0011f2c2ba..f22d23504f 100644 --- a/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/containers/TradeButtons/index.tsx @@ -4,7 +4,12 @@ import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' import { AddIntermediateToken } from 'modules/tokensList' -import { useIsNoImpactWarningAccepted, useTradeConfirmActions, useWrappedToken } from 'modules/trade' +import { + useIsCurrentTradeBridging, + useIsNoImpactWarningAccepted, + useTradeConfirmActions, + useWrappedToken, +} from 'modules/trade' import { TradeFormButtons, TradeFormValidation, @@ -17,7 +22,6 @@ import { useSafeMemoObject } from 'common/hooks/useSafeMemo' import { swapTradeButtonsMap } from './swapTradeButtonsMap' -import { useGetConfirmButtonLabel } from '../../hooks/useGetConfirmButtonLabel' import { useOnCurrencySelection } from '../../hooks/useOnCurrencySelection' import { useSwapDerivedState } from '../../hooks/useSwapDerivedState' import { useSwapFormState } from '../../hooks/useSwapFormState' @@ -50,10 +54,11 @@ export function TradeButtons({ const localFormValidation = useSwapFormState() const wrappedToken = useWrappedToken() const onCurrencySelection = useOnCurrencySelection() + const isCurrentTradeBridging = useIsCurrentTradeBridging() const confirmTrade = tradeConfirmActions.onOpen - const confirmText = useGetConfirmButtonLabel() + const confirmText = isCurrentTradeBridging ? 'Swap and Bridge' : 'Swap' const { isPartialApproveEnabled } = useFeatureFlags() // enable partial approve only for swap diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useGetConfirmButtonLabel.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useGetConfirmButtonLabel.ts deleted file mode 100644 index af59e7c9fd..0000000000 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useGetConfirmButtonLabel.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useFeatureFlags } from '@cowprotocol/common-hooks' - -import { useIsApprovalOrPermitRequired } from 'modules/erc20Approve' -import { useIsCurrentTradeBridging } from 'modules/trade' - -function getSwapLabel(isBridging: boolean): string { - return isBridging ? 'Swap and Bridge' : 'Swap' -} - -export function useGetConfirmButtonLabel(): string { - const isCurrentTradeBridging = useIsCurrentTradeBridging() - const isPermitOrApproveRequired = useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: false }) - const { isPartialApproveEnabled } = useFeatureFlags() - - if (!isPartialApproveEnabled) { - return getSwapLabel(isCurrentTradeBridging) - } - - if (isPermitOrApproveRequired) { - return isCurrentTradeBridging ? 'Approve, Swap & Bridge' : 'Approve and Swap' - } - - return getSwapLabel(isCurrentTradeBridging) -} diff --git a/apps/cowswap-frontend/src/modules/trade/hooks/useAmountsToSignFromQuote.ts b/apps/cowswap-frontend/src/modules/trade/hooks/useAmountsToSignFromQuote.ts index da2066c442..6f6c146fb7 100644 --- a/apps/cowswap-frontend/src/modules/trade/hooks/useAmountsToSignFromQuote.ts +++ b/apps/cowswap-frontend/src/modules/trade/hooks/useAmountsToSignFromQuote.ts @@ -1,10 +1,13 @@ import { useMemo } from 'react' -import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { FractionUtils } from '@cowprotocol/common-utils' +import { Currency, CurrencyAmount, Percent } from '@uniswap/sdk-core' import { useDerivedTradeState } from './useDerivedTradeState' import { useGetReceiveAmountInfo } from './useGetReceiveAmountInfo' +const BUY_ORDER_APPROVE_AMOUNT_THRESHOLD = new Percent(1, 100) // 1% + export interface AmountsToSign { maximumSendSellAmount: CurrencyAmount minimumReceiveBuyAmount: CurrencyAmount @@ -16,7 +19,7 @@ export interface AmountsToSign { */ export function useAmountsToSignFromQuote(): AmountsToSign | null { const { isQuoteBasedOrder, inputCurrencyAmount, outputCurrencyAmount } = useDerivedTradeState() || {} - const { afterSlippage } = useGetReceiveAmountInfo() || {} + const { isSell, afterSlippage } = useGetReceiveAmountInfo() || {} return useMemo(() => { const maximumSendSellAmount = isQuoteBasedOrder ? afterSlippage?.sellAmount : inputCurrencyAmount @@ -24,6 +27,12 @@ export function useAmountsToSignFromQuote(): AmountsToSign | null { if (!maximumSendSellAmount || !minimumReceiveBuyAmount) return null - return { maximumSendSellAmount, minimumReceiveBuyAmount } - }, [isQuoteBasedOrder, inputCurrencyAmount, outputCurrencyAmount, afterSlippage]) + return { + // Add 1% threshold for buy orders to level out price/gas fluctuations + maximumSendSellAmount: isSell + ? maximumSendSellAmount + : FractionUtils.addPercent(maximumSendSellAmount, BUY_ORDER_APPROVE_AMOUNT_THRESHOLD), + minimumReceiveBuyAmount, + } + }, [isSell, isQuoteBasedOrder, inputCurrencyAmount, outputCurrencyAmount, afterSlippage]) } diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts b/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts index f997404be1..ced29b0027 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/hooks/useTradeFormValidationContext.ts @@ -43,7 +43,7 @@ export function useTradeFormValidationContext(): TradeFormValidationCommonContex const isApproveRequired = useIsApprovalOrPermitRequired({ isBundlingSupportedOrEnabledForContext: isBundlingSupported, - }) + }).reason const isInsufficientBalanceOrderAllowed = tradeType === TradeType.LIMIT_ORDER diff --git a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx index 6cb84d05f8..676772938e 100644 --- a/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx +++ b/apps/cowswap-frontend/src/modules/tradeFormValidation/pure/TradeFormButtons/tradeButtonsMap.tsx @@ -246,7 +246,6 @@ export const tradeButtonsMap: Record {defaultText} diff --git a/libs/common-utils/src/fractionUtils.ts b/libs/common-utils/src/fractionUtils.ts index c29bbe0568..d68f895408 100644 --- a/libs/common-utils/src/fractionUtils.ts +++ b/libs/common-utils/src/fractionUtils.ts @@ -1,6 +1,6 @@ import { FULL_PRICE_PRECISION } from '@cowprotocol/common-const' import { Nullish } from '@cowprotocol/types' -import { BigintIsh, Currency, CurrencyAmount, Fraction, Price, Rounding, Token } from '@uniswap/sdk-core' +import { BigintIsh, Currency, CurrencyAmount, Fraction, Percent, Price, Rounding, Token } from '@uniswap/sdk-core' import { BigNumber } from 'bignumber.js' import JSBI from 'jsbi' @@ -179,6 +179,10 @@ export class FractionUtils { static simplify(fraction: Fraction): Fraction { return reduce(trimZeros(fraction)) } + + static addPercent(amount: CurrencyAmount, percent: Percent): typeof amount { + return amount.add(amount.multiply(percent)) + } } const ZERO = JSBI.BigInt(0)