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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/cowswap-frontend/src/common/hooks/useGetReceipt.test.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionReceipt>) => ({
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<Promise<TransactionReceipt | null>, [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()
})
})
56 changes: 38 additions & 18 deletions apps/cowswap-frontend/src/common/hooks/useGetReceipt.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionReceipt>

interface RetryResult<T> {
promise: Promise<T>
cancel: Command
}

export type GetReceipt = (hash: string) => RetryResult<TransactionReceipt>

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<GetReceipt>(
(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<TransactionReceipt> {
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))
}
Original file line number Diff line number Diff line change
@@ -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<Promise<TransactionReceipt | null>, [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<typeof useGetReceipt>, 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)
})
})
Loading