diff --git a/apps/cowswap-frontend/src/common/hooks/useGetReceipt.test.ts b/apps/cowswap-frontend/src/common/hooks/useGetReceipt.test.ts new file mode 100644 index 00000000000..b6ee6b959d3 --- /dev/null +++ b/apps/cowswap-frontend/src/common/hooks/useGetReceipt.test.ts @@ -0,0 +1,102 @@ +import { getRpcProvider } from '@cowprotocol/common-const' +import { RetryableError, retry } from '@cowprotocol/common-utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletProvider } from '@cowprotocol/wallet-provider' +import { TransactionReceipt } from '@ethersproject/abstract-provider' +import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers' + +import { renderHook } from '@testing-library/react' + +import { useGetReceipt } from './useGetReceipt' + +jest.mock('@cowprotocol/common-const', () => ({ + ...jest.requireActual('@cowprotocol/common-const'), + getRpcProvider: jest.fn(), +})) + +jest.mock('@cowprotocol/common-utils', () => ({ + ...jest.requireActual('@cowprotocol/common-utils'), + retry: jest.fn((fn: () => Promise) => ({ + promise: fn(), + cancel: jest.fn(), + })), +})) + +jest.mock('@cowprotocol/wallet-provider', () => ({ + ...jest.requireActual('@cowprotocol/wallet-provider'), + useWalletProvider: jest.fn(), +})) + +const mockedGetRpcProvider = jest.mocked(getRpcProvider) +const mockedRetry = jest.mocked(retry) +const mockedUseWalletProvider = jest.mocked(useWalletProvider) + +interface ReceiptProviderMock { + getTransactionReceipt: jest.Mock, [string]> +} + +function createProviderMock(): ReceiptProviderMock { + return { + getTransactionReceipt: jest.fn(), + } +} + +describe('useGetReceipt', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'debug').mockImplementation(() => undefined) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('falls back to the public RPC provider when the wallet provider receipt lookup fails', async () => { + const walletProvider = createProviderMock() + const rpcProvider = createProviderMock() + const receipt = { status: 1 } as TransactionReceipt + + walletProvider.getTransactionReceipt.mockRejectedValue(new Error('wallet rate limited')) + rpcProvider.getTransactionReceipt.mockResolvedValue(receipt) + + mockedUseWalletProvider.mockReturnValue(walletProvider as unknown as Web3Provider) + mockedGetRpcProvider.mockReturnValue(rpcProvider as unknown as JsonRpcProvider) + + const { result } = renderHook(() => useGetReceipt(SupportedChainId.BASE)) + + await expect(result.current('0xhash').promise).resolves.toBe(receipt) + expect(walletProvider.getTransactionReceipt).toHaveBeenCalledWith('0xhash') + expect(rpcProvider.getTransactionReceipt).toHaveBeenCalledWith('0xhash') + }) + + it('uses the public RPC provider when the wallet provider is unavailable', async () => { + const rpcProvider = createProviderMock() + const receipt = { status: 1 } as TransactionReceipt + + rpcProvider.getTransactionReceipt.mockResolvedValue(receipt) + + mockedUseWalletProvider.mockReturnValue(undefined) + mockedGetRpcProvider.mockReturnValue(rpcProvider as unknown as JsonRpcProvider) + + const { result } = renderHook(() => useGetReceipt(SupportedChainId.BASE)) + + await expect(result.current('0xhash').promise).resolves.toBe(receipt) + expect(rpcProvider.getTransactionReceipt).toHaveBeenCalledWith('0xhash') + }) + + it('requests a retry when no provider can return a receipt yet', async () => { + const walletProvider = createProviderMock() + const rpcProvider = createProviderMock() + + walletProvider.getTransactionReceipt.mockResolvedValue(null) + rpcProvider.getTransactionReceipt.mockResolvedValue(null) + + mockedUseWalletProvider.mockReturnValue(walletProvider as unknown as Web3Provider) + mockedGetRpcProvider.mockReturnValue(rpcProvider as unknown as JsonRpcProvider) + + const { result } = renderHook(() => useGetReceipt(SupportedChainId.BASE)) + + await expect(result.current('0xhash').promise).rejects.toBeInstanceOf(RetryableError) + expect(mockedRetry).toHaveBeenCalled() + }) +}) diff --git a/apps/cowswap-frontend/src/common/hooks/useGetReceipt.ts b/apps/cowswap-frontend/src/common/hooks/useGetReceipt.ts index e5dbd90817f..54a16dc5345 100644 --- a/apps/cowswap-frontend/src/common/hooks/useGetReceipt.ts +++ b/apps/cowswap-frontend/src/common/hooks/useGetReceipt.ts @@ -1,47 +1,67 @@ import { useCallback } from 'react' +import { getRpcProvider } from '@cowprotocol/common-const' import { retry, RetryableError, RetryOptions } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Command } from '@cowprotocol/types' import { useWalletProvider } from '@cowprotocol/wallet-provider' import { TransactionReceipt } from '@ethersproject/abstract-provider' - -import { useLingui } from '@lingui/react/macro' +import { JsonRpcProvider, Provider } from '@ethersproject/providers' const DEFAULT_RETRY_OPTIONS: RetryOptions = { n: 3, minWait: 1000, maxWait: 3000 } const RETRY_OPTIONS_BY_CHAIN_ID: { [chainId: number]: RetryOptions } = {} +export type GetReceipt = (hash: string) => RetryResult + interface RetryResult { promise: Promise cancel: Command } -export type GetReceipt = (hash: string) => RetryResult - export function useGetReceipt(chainId: SupportedChainId): GetReceipt { // TODO M-6 COW-573 // This flow will be reviewed and updated later, to include a wagmi alternative - const provider = useWalletProvider() - const { t } = useLingui() + const walletProvider = useWalletProvider() + const rpcProvider = getRpcProvider(chainId) const getReceipt = useCallback( (hash) => { const retryOptions = RETRY_OPTIONS_BY_CHAIN_ID[chainId] || DEFAULT_RETRY_OPTIONS + const providers = getReceiptProviders(walletProvider, rpcProvider) - return retry(() => { - if (!provider) throw new Error(t`No provider yet`) - - return provider.getTransactionReceipt(hash).then((receipt) => { - if (receipt === null) { - console.debug('[useGetReceipt] Retrying for hash', hash) - throw new RetryableError() - } - return receipt - }) - }, retryOptions) + return retry(() => fetchTransactionReceipt(hash, providers), retryOptions) }, - [chainId, provider, t], + [chainId, rpcProvider, walletProvider], ) return getReceipt } + +async function fetchTransactionReceipt(hash: string, providers: Provider[]): Promise { + if (!providers.length) { + throw new Error('No provider yet') + } + + for (const provider of providers) { + try { + const receipt = await provider.getTransactionReceipt(hash) + + if (receipt) { + return receipt + } + } catch (error) { + console.debug('[useGetReceipt] Receipt lookup failed, trying next provider', { hash, error }) + } + } + + console.debug('[useGetReceipt] Retrying for hash', hash) + throw new RetryableError() +} + +function getReceiptProviders(walletProvider: Provider | undefined, rpcProvider: JsonRpcProvider | null): Provider[] { + if (walletProvider && rpcProvider && walletProvider === rpcProvider) { + return [walletProvider] + } + + return [walletProvider, rpcProvider].filter((provider): provider is Provider => Boolean(provider)) +} diff --git a/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/checkOnChainTransaction.test.ts b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/checkOnChainTransaction.test.ts new file mode 100644 index 00000000000..1242d76eae5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/onchainTransactions/updaters/FinalizeTxUpdater/services/checkOnChainTransaction.test.ts @@ -0,0 +1,156 @@ +import { getRpcProvider } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useWalletProvider } from '@cowprotocol/wallet-provider' +import { TransactionReceipt } from '@ethersproject/abstract-provider' +import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers' + +import { renderHook, waitFor } from '@testing-library/react' + +import { finalizeTransaction } from 'legacy/state/enhancedTransactions/actions' +import { EnhancedTransactionDetails, HashType } from 'legacy/state/enhancedTransactions/reducer' +import { invalidateOrdersBatch } from 'legacy/state/orders/actions' + +import { useGetReceipt } from 'common/hooks/useGetReceipt' + +import { checkOnChainTransaction } from './checkOnChainTransaction' + +import { emitOnchainTransactionEvent } from '../../../utils/emitOnchainTransactionEvent' +import { CheckEthereumTransactions } from '../types' + +jest.mock('@cowprotocol/common-const', () => ({ + ...jest.requireActual('@cowprotocol/common-const'), + getRpcProvider: jest.fn(), +})) + +jest.mock('@cowprotocol/wallet-provider', () => ({ + ...jest.requireActual('@cowprotocol/wallet-provider'), + useWalletProvider: jest.fn(), +})) + +jest.mock('../../../utils/emitOnchainTransactionEvent', () => ({ + emitOnchainTransactionEvent: jest.fn(), +})) + +jest.mock('./finalizeOnChainCancellation', () => ({ + finalizeOnChainCancellation: jest.fn(), +})) + +const mockedGetRpcProvider = jest.mocked(getRpcProvider) +const mockedUseWalletProvider = jest.mocked(useWalletProvider) +const mockedEmitOnchainTransactionEvent = jest.mocked(emitOnchainTransactionEvent) + +interface ReceiptProviderMock { + getTransactionReceipt: jest.Mock, [string]> +} + +function createEthFlowTransaction(): EnhancedTransactionDetails { + return { + addedTime: Date.now(), + ethFlow: { + orderId: '0xorder', + subType: 'creation', + }, + from: '0xfrom', + hash: '0xhash', + hashType: HashType.ETHEREUM_TX, + nonce: 5, + transactionHash: '0xhash', + } +} + +function createParams(getReceipt: ReturnType, dispatch: jest.Mock): CheckEthereumTransactions { + return { + account: '0xfrom', + cancelOrdersBatch: jest.fn() as unknown as CheckEthereumTransactions['cancelOrdersBatch'], + chainId: SupportedChainId.BASE, + dispatch: dispatch as unknown as CheckEthereumTransactions['dispatch'], + getReceipt, + getTwapOrderById: jest.fn() as unknown as CheckEthereumTransactions['getTwapOrderById'], + getTxSafeInfo: jest.fn() as unknown as CheckEthereumTransactions['getTxSafeInfo'], + isSafeWallet: false, + lastBlockNumber: 456, + nativeCurrencySymbol: 'ETH', + safeInfo: undefined, + transactionsCount: 5, + } +} + +function createProviderMock(): ReceiptProviderMock { + return { + getTransactionReceipt: jest.fn(), + } +} + +function createReceipt(status: number): TransactionReceipt { + return { + blockHash: '0xblock', + blockNumber: 123, + contractAddress: '0xcontract', + from: '0xfrom', + status, + to: '0xto', + transactionHash: '0xhash', + transactionIndex: 7, + } as TransactionReceipt +} + +describe('checkOnChainTransaction', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.spyOn(console, 'debug').mockImplementation(() => undefined) + jest.spyOn(console, 'log').mockImplementation(() => undefined) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('invalidates an ETH-flow order when the wallet receipt lookup fails but public RPC returns a failed receipt', async () => { + const walletProvider = createProviderMock() + const rpcProvider = createProviderMock() + const receipt = createReceipt(0) + const dispatch = jest.fn() + + walletProvider.getTransactionReceipt.mockRejectedValue(new Error('wallet rate limited')) + rpcProvider.getTransactionReceipt.mockResolvedValue(receipt) + + mockedUseWalletProvider.mockReturnValue(walletProvider as unknown as Web3Provider) + mockedGetRpcProvider.mockReturnValue(rpcProvider as unknown as JsonRpcProvider) + + const { result } = renderHook(() => useGetReceipt(SupportedChainId.BASE)) + const params = createParams(result.current, dispatch) + const transaction = createEthFlowTransaction() + + checkOnChainTransaction(transaction, params) + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith( + finalizeTransaction({ + chainId: SupportedChainId.BASE, + hash: '0xhash', + receipt: { + blockHash: '0xblock', + blockNumber: 123, + contractAddress: '0xcontract', + from: '0xfrom', + status: 0, + to: '0xto', + transactionHash: '0xhash', + transactionIndex: 7, + }, + }), + ) + expect(dispatch).toHaveBeenCalledWith( + invalidateOrdersBatch({ + chainId: SupportedChainId.BASE, + ids: ['0xorder'], + isSafeWallet: false, + }), + ) + }) + + expect(walletProvider.getTransactionReceipt).toHaveBeenCalledWith('0xhash') + expect(rpcProvider.getTransactionReceipt).toHaveBeenCalledWith('0xhash') + expect(mockedEmitOnchainTransactionEvent).toHaveBeenCalledTimes(1) + }) +})