diff --git a/src/modules/transaction/sagas.spec.ts b/src/modules/transaction/sagas.spec.ts new file mode 100644 index 00000000..42fb5b06 --- /dev/null +++ b/src/modules/transaction/sagas.spec.ts @@ -0,0 +1,213 @@ +// Mock the network provider +jest.mock('../../lib/eth', () => ({ + getNetworkProvider: () => ({ + send: jest.fn(), + on: jest.fn(), + removeListener: jest.fn() + }) +})) + +// Mock the getFibonacciDelay function +jest.mock('./sagas', () => { + const actual = jest.requireActual('./sagas') + return { + ...actual, + BACKOFF_DELAY_MULTIPLIER: 0.01, + getFibonacciDelay: function*(attempt: number) { + const fib = [1, 1] + for (let i = 2; i <= attempt + 1; i++) { + fib[i] = fib[i - 1] + fib[i - 2] + } + if (attempt <= 1) { + return 100 + } + return 100 * fib[attempt] + } + } +}) + +import { expectSaga } from 'redux-saga-test-plan' +import * as matchers from 'redux-saga-test-plan/matchers' +import { call, delay, select } from 'redux-saga/effects' +import { TransactionStatus, Transaction } from './types' +import { ChainId } from '@dcl/schemas/dist/dapps/chain-id' +import { + getFibonacciDelay, + handleRegularTransactionRequest, + handleWatchRevertedTransaction +} from './sagas' +import { fetchTransactionSuccess, watchRevertedTransaction } from './actions' +import { getTransaction as getTransactionInState } from './selectors' +import { buildTransactionPayload } from './utils' +import { getTransaction as getTransactionFromChain } from './txUtils' + +describe('when using fibonacci backoff for transaction polling', () => { + const MOCK_DELAY_MULTIPLIER = 100 // 100ms for testing + jest.setTimeout(20000) // Increase global timeout + + describe('when calculating fibonacci delay', () => { + const cases = [ + { attempt: 0, expected: MOCK_DELAY_MULTIPLIER }, + { attempt: 1, expected: MOCK_DELAY_MULTIPLIER }, + { attempt: 2, expected: MOCK_DELAY_MULTIPLIER * 2 }, + { attempt: 3, expected: MOCK_DELAY_MULTIPLIER * 3 }, + { attempt: 4, expected: MOCK_DELAY_MULTIPLIER * 5 }, + { attempt: 5, expected: MOCK_DELAY_MULTIPLIER * 8 }, + { attempt: 6, expected: MOCK_DELAY_MULTIPLIER * 13 } + ] + + cases.forEach(({ attempt, expected }) => { + it(`should return ${expected}ms for attempt ${attempt}`, () => { + return expectSaga(getFibonacciDelay, attempt) + .returns(expected) + .run() + }) + }) + }) + + describe('when polling regular transaction status', () => { + let transaction: Transaction + let address: string + let hash: string + let chainId: ChainId + + beforeEach(() => { + jest.setTimeout(20000) // Set timeout for each test in this suite + address = '0x123' + hash = '0x456' + chainId = ChainId.ETHEREUM_MAINNET + transaction = { + ...buildTransactionPayload(chainId, hash, {}, chainId), + events: [], + hash, + from: address, + chainId, + status: null, + timestamp: Date.now(), + nonce: 0, + withReceipt: false, + isCrossChain: false, + actionType: 'SOME_ACTION', + url: '', + replacedBy: null + } + }) + + describe('and the transaction becomes confirmed', () => { + it('should use fibonacci backoff until confirmation and fix the transaction', async () => { + const { hash } = transaction + const mockReceipt = { logs: [] } + const revertedTx = { + ...transaction, + status: TransactionStatus.REVERTED + } + const action = { + type: '[Request] Fetch Transaction' as const, + payload: { + hash, + address: '0x123', + action: { + type: 'SOME_ACTION', + payload: buildTransactionPayload( + transaction.chainId, + hash, + {}, + transaction.chainId + ) + } + } + } + + return expectSaga(handleRegularTransactionRequest, action) + .provide([ + [select(getTransactionInState, hash), revertedTx], + [ + call(getTransactionFromChain, '0x123', transaction.chainId, hash), + { + ...revertedTx, + status: TransactionStatus.CONFIRMED, + receipt: mockReceipt + } + ] + ]) + .withState({ + transaction: { + data: [revertedTx], + loading: [], + error: null + }, + wallet: { + data: { + address: '0x123' + } + } + }) + .put( + fetchTransactionSuccess({ + ...revertedTx, + status: TransactionStatus.CONFIRMED, + receipt: { logs: [] } + }) + ) + .run({ timeout: 15000 }) + }) + }) + }) + + describe('when watching reverted transaction', () => { + let transaction: Transaction + let address: string + let hash: string + let chainId: ChainId + + beforeEach(() => { + address = '0x123' + hash = '0x456' + chainId = ChainId.ETHEREUM_MAINNET + transaction = { + ...buildTransactionPayload(chainId, hash, {}, chainId), + events: [], + hash, + from: address, + chainId, + status: TransactionStatus.REVERTED, + timestamp: Date.now(), + nonce: 0, + withReceipt: false, + isCrossChain: false, + replacedBy: null, + actionType: 'SOME_ACTION', + url: '' + } + }) + + describe('and the transaction expires', () => { + it('should stop polling after expiration threshold', () => { + const expiredTransaction = { + ...transaction, + timestamp: Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago + } + + return expectSaga( + handleWatchRevertedTransaction, + watchRevertedTransaction(hash) + ) + .provide([ + [matchers.select(getTransactionInState, hash), expiredTransaction] + ]) + .withState({ + transaction: { + data: [expiredTransaction] + }, + wallet: { + data: { + address + } + } + }) + .not.call(getTransactionFromChain) + .run() + }) + }) + }) +}) diff --git a/src/modules/transaction/sagas.ts b/src/modules/transaction/sagas.ts index 21fbfa41..c0cdc5fa 100644 --- a/src/modules/transaction/sagas.ts +++ b/src/modules/transaction/sagas.ts @@ -79,11 +79,12 @@ export function* transactionSaga( } } -const BLOCKS_DEPTH = 100 -const TRANSACTION_FETCH_RETIES = 120 -const PENDING_TRANSACTION_THRESHOLD = 72 * 60 * 60 * 1000 // 72 hours -const REVERTED_TRANSACTION_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours -const TRANSACTION_FETCH_DELAY = 2 * 1000 // 2 seconds +export const BLOCKS_DEPTH = 100 +export const TRANSACTION_FETCH_RETIES = 120 +export const PENDING_TRANSACTION_THRESHOLD = 72 * 60 * 60 * 1000 // 72 hours +export const REVERTED_TRANSACTION_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours +export const DROPPED_TRANSACTION_THRESHOLD = 24 * 60 * 60 * 1000 // 24 hours +export const BACKOFF_DELAY_MULTIPLIER = 1 const isExpired = (transaction: Transaction, threshold: number) => Date.now() - transaction.timestamp > threshold @@ -107,7 +108,7 @@ export class FailedTransactionError extends Error { } } -function* handleCrossChainTransactionRequest( +export function* handleCrossChainTransactionRequest( action: FetchTransactionRequestAction, config?: TransactionsConfig ) { @@ -128,6 +129,7 @@ function* handleCrossChainTransactionRequest( let statusResponse: StatusResponse | undefined let txInState: Transaction + let attempt = 0 let squidNotFoundRetries: number = config.crossChainProviderNotFoundRetries ?? TRANSACTION_FETCH_RETIES while ( @@ -175,7 +177,10 @@ function* handleCrossChainTransactionRequest( ) return } - yield delay(config.crossChainProviderRetryDelay ?? TRANSACTION_FETCH_DELAY) + + const fibonacciDelay: number = yield call(getFibonacciDelay, attempt) + yield delay(config.crossChainProviderRetryDelay ?? fibonacciDelay) + attempt++ } txInState = yield select(state => @@ -208,7 +213,7 @@ function* handleCrossChainTransactionRequest( } } -function* handleRegularTransactionRequest( +export function* handleRegularTransactionRequest( action: FetchTransactionRequestAction ) { const { hash, address } = action.payload @@ -222,6 +227,7 @@ function* handleRegularTransactionRequest( try { watchPendingIndex[hash] = true + let attempt = 0 let tx: AnyTransaction = yield call( getTransactionFromChain, @@ -266,8 +272,10 @@ function* handleRegularTransactionRequest( yield put(updateTransactionStatus(hash, statusInNetwork)) } - // sleep - yield delay(TRANSACTION_FETCH_DELAY) + // Apply fibonacci backoff delay before next iteration + const fibonacciDelay: number = yield call(getFibonacciDelay, attempt) + yield delay(fibonacciDelay) + attempt++ // update tx status from network tx = yield call( @@ -309,7 +317,20 @@ function* handleRegularTransactionRequest( } } -function* handleReplaceTransactionRequest( +export function* getFibonacciDelay(attempt: number) { + if (attempt <= 1) return 1000 * BACKOFF_DELAY_MULTIPLIER + + let prev = 1 + let current = 1 + for (let i = 2; i <= attempt; i++) { + const next = prev + current + prev = current + current = next + } + return current * 1000 * BACKOFF_DELAY_MULTIPLIER +} + +export function* handleReplaceTransactionRequest( action: ReplaceTransactionRequestAction ) { const { hash, nonce, address: account } = action.payload @@ -321,10 +342,25 @@ function* handleReplaceTransactionRequest( return } + // Check if transaction is already expired before starting to poll + if (isExpired(transaction, DROPPED_TRANSACTION_THRESHOLD)) { + yield put(updateTransactionStatus(hash, TransactionStatus.DROPPED)) + return + } + let checkpoint = null + let attempt = 0 watchDroppedIndex[hash] = true + const startTime = Date.now() + while (true) { + // Check if we've exceeded the time threshold during polling + if (Date.now() - startTime > DROPPED_TRANSACTION_THRESHOLD) { + yield put(updateTransactionStatus(hash, TransactionStatus.DROPPED)) + break + } + const eth: ethers.providers.Web3Provider = yield call( getNetworkWeb3Provider, transaction.chainId @@ -398,7 +434,7 @@ function* handleReplaceTransactionRequest( break } - // if there was nonce higher to than the one in the tx, we can mark it as replaced (altough we don't know which tx replaced it) + // if there was nonce higher to than the one in the tx, we can mark it as replaced (although we don't know which tx replaced it) if (highestNonce >= nonce) { yield put( updateTransactionStatus(action.payload.hash, TransactionStatus.REPLACED) @@ -406,14 +442,16 @@ function* handleReplaceTransactionRequest( break } - // sleep - yield delay(TRANSACTION_FETCH_DELAY) + // Apply fibonacci backoff delay before next iteration + const fibonacciDelay: number = yield call(getFibonacciDelay, attempt) + yield delay(fibonacciDelay) + attempt++ } delete watchDroppedIndex[action.payload.hash] } -function* handleWatchPendingTransactions() { +export function* handleWatchPendingTransactions() { const transactions: Transaction[] = yield select(getData) const pendingTransactions = transactions.filter(transaction => isPending(transaction.status) @@ -435,12 +473,13 @@ function* handleWatchPendingTransactions() { } } -function* handleWatchDroppedTransactions() { +export function* handleWatchDroppedTransactions() { const transactions: Transaction[] = yield select(getData) const droppedTransactions = transactions.filter( transaction => transaction.status === TransactionStatus.DROPPED && - transaction.nonce != null + transaction.nonce != null && + !isExpired(transaction, DROPPED_TRANSACTION_THRESHOLD) ) for (const tx of droppedTransactions) { @@ -453,7 +492,7 @@ function* handleWatchDroppedTransactions() { } } -function* handleWatchRevertedTransaction( +export function* handleWatchRevertedTransaction( action: WatchRevertedTransactionAction ) { const { hash } = action.payload @@ -468,9 +507,13 @@ function* handleWatchRevertedTransaction( } const address: string = yield select(state => getAddress(state)) + let attempt = 0 do { - yield delay(TRANSACTION_FETCH_DELAY) + const fibonacciDelay: number = yield call(getFibonacciDelay, attempt) + yield delay(fibonacciDelay) + attempt++ + const txInNetwork: AnyTransaction | null = yield call(() => getTransactionFromChain(address, txInState.chainId, hash) ) @@ -489,7 +532,7 @@ function* handleWatchRevertedTransaction( } while (!isExpired(txInState, REVERTED_TRANSACTION_THRESHOLD)) } -function* handleConnectWalletSuccess(_: ConnectWalletSuccessAction) { +export function* handleConnectWalletSuccess(_: ConnectWalletSuccessAction) { yield put(watchPendingTransactions()) yield put(watchDroppedTransactions())