diff --git a/src/logic/notifications/notificationTypes.ts b/src/logic/notifications/notificationTypes.ts index 80d3f70bd7..5d4df23ef1 100644 --- a/src/logic/notifications/notificationTypes.ts +++ b/src/logic/notifications/notificationTypes.ts @@ -27,6 +27,7 @@ enum NOTIFICATION_IDS { TX_CANCELLATION_EXECUTED_MSG, TX_FAILED_MSG, TX_PENDING_MSG, + TX_PENDING_FAILED_MSG, TX_WAITING_MSG, TX_CONFIRMATION_EXECUTED_MSG, TX_CONFIRMATION_FAILED_MSG, @@ -98,6 +99,10 @@ export const NOTIFICATIONS: Record = { message: 'Transaction still pending. Consider resubmitting with a higher gas price.', options: { variant: ERROR, persist: true, autoHideDuration: shortDuration }, }, + TX_PENDING_FAILED_MSG: { + message: 'Transaction wasn’t mined, please make sure it was properly sent. Be aware that it might still be mined.', + options: { variant: ERROR, persist: true, autoHideDuration: shortDuration }, + }, TX_WAITING_MSG: { message: 'A transaction requires your confirmation', key: 'TX_WAITING_MSG', diff --git a/src/logic/safe/store/actions/createTransaction.ts b/src/logic/safe/store/actions/createTransaction.ts index 271c843e3e..dfb6ae6d56 100644 --- a/src/logic/safe/store/actions/createTransaction.ts +++ b/src/logic/safe/store/actions/createTransaction.ts @@ -126,7 +126,7 @@ export class TxSender { } async onError(err: Error & { code: number }, errorCallback?: ErrorEventHandler): Promise { - const { txArgs, isFinalization, from, txProps, dispatch, notifications, safeInstance, txId, txHash } = this + const { txArgs, isFinalization, from, txProps, dispatch, notifications, safeInstance, txId } = this errorCallback?.() @@ -159,7 +159,7 @@ export class TxSender { txArgs.sigs, ) .encodeABI() - : txHash && safeInstance.methods.approveHash(txHash).encodeABI() + : this.txHash && safeInstance.methods.approveHash(this.txHash).encodeABI() if (!executeData) { return diff --git a/src/logic/safe/store/middleware/pendingTransactionsMiddleware.ts b/src/logic/safe/store/middleware/pendingTransactionsMiddleware.ts index f64cb912b5..ecf0f13b96 100644 --- a/src/logic/safe/store/middleware/pendingTransactionsMiddleware.ts +++ b/src/logic/safe/store/middleware/pendingTransactionsMiddleware.ts @@ -10,6 +10,8 @@ import { import { PENDING_TRANSACTIONS_ID, PendingTransactionPayloads } from 'src/logic/safe/store/reducer/pendingTransactions' import { Dispatch } from 'src/logic/safe/store/actions/types' import { allPendingTxIds } from 'src/logic/safe/store/selectors/pendingTransactions' +import { PendingTxMonitor } from 'src/logic/safe/transactions/pendingTxMonitor' +import { PROVIDER_ACTIONS } from 'src/logic/wallets/store/actions' // Share updated statuses between tabs/windows // Test env and Safari don't support BroadcastChannel @@ -44,10 +46,11 @@ if (channel) { } export const pendingTransactionsMiddleware = - ({ getState }: typeof reduxStore) => + (store: typeof reduxStore) => (next: Dispatch) => async (action: Action): Promise> => { const handledAction = next(action) + const state = store.getState() switch (action.type) { case PENDING_TRANSACTIONS_ACTIONS.ADD: @@ -56,10 +59,14 @@ export const pendingTransactionsMiddleware = channel.postMessage(action) } - const state = getState() session.setItem(PENDING_TRANSACTIONS_ID, allPendingTxIds(state)) break } + + case PROVIDER_ACTIONS.WALLET: { + PendingTxMonitor.monitorAllTxs() + break + } default: break } diff --git a/src/logic/safe/store/reducer/pendingTransactions.ts b/src/logic/safe/store/reducer/pendingTransactions.ts index 72a4de2044..34de06170a 100644 --- a/src/logic/safe/store/reducer/pendingTransactions.ts +++ b/src/logic/safe/store/reducer/pendingTransactions.ts @@ -7,7 +7,7 @@ import { _getChainId } from 'src/config' export const PENDING_TRANSACTIONS_ID = 'pendingTransactions' -export type PendingTransactionsState = Record> +export type PendingTransactionsState = Record> const initialPendingTxsState = session.getItem(PENDING_TRANSACTIONS_ID) || {} @@ -17,7 +17,7 @@ export type RemovePendingTransactionPayload = { } export type AddPendingTransactionPayload = RemovePendingTransactionPayload & { - txHash: string | boolean + txHash: string } export type PendingTransactionPayloads = AddPendingTransactionPayload | RemovePendingTransactionPayload @@ -46,6 +46,12 @@ export const pendingTransactionsReducer = handleActions { + beforeEach(() => { + jest.restoreAllMocks() + }) + + afterEach(() => { + PendingTxMonitor._isTxMined = originalIsTxMined + }) + describe('_isTxMined', () => { + it("doesn't throw if a transaction receipt exists", async () => { + jest.spyOn(web3.getWeb3().eth, 'getTransactionReceipt').mockImplementationOnce(() => + Promise.resolve({ + blockHash: '0x123', + blockNumber: 1, + transactionHash: 'fakeTxHash', + transactionIndex: 0, + from: '0x123', + to: '0x123', + cumulativeGasUsed: 1, + gasUsed: 1, + contractAddress: '0x123', + logs: [], + status: true, + logsBloom: '0x123', + }), + ) + + expect(async () => await PendingTxMonitor._isTxMined(0, 'fakeTxHash')).not.toThrow() + }) + it('throws if no transaction receipt exists within 50 blocks', async () => { + jest + .spyOn(web3.getWeb3().eth, 'getTransactionReceipt') + // Returns `null` if transaction is pending: https://web3js.readthedocs.io/en/v1.2.11/web3-eth.html#gettransactionreceipt + .mockImplementation(() => Promise.resolve(null as any)) + jest.spyOn(web3.getWeb3().eth, 'getBlockNumber').mockImplementation(() => Promise.resolve(50)) + + try { + await PendingTxMonitor._isTxMined(0, 'fakeTxHash') + + // Fail test if above expression doesn't throw anything + expect(true).toBe(false) + } catch (e) { + expect(e.message).toEqual('Pending transaction not found') + } + }) + it("doesn't throw if no transaction receipt exists and after 50 blocks", async () => { + jest + .spyOn(web3.getWeb3().eth, 'getTransactionReceipt') + // Returns `null` if transaction is pending: https://web3js.readthedocs.io/en/v1.2.11/web3-eth.html#gettransactionreceipt + .mockImplementation(() => Promise.resolve(null as any)) + jest.spyOn(web3.getWeb3().eth, 'getBlockNumber').mockImplementation(() => Promise.resolve(51)) + + expect(async () => await PendingTxMonitor._isTxMined(0, 'fakeTxHash')).not.toThrow() + }) + }) + + describe('monitorTx', () => { + it("doesn't clear the pending transaction if it was mined", async () => { + PendingTxMonitor._isTxMined = jest.fn(() => Promise.resolve()) + + const dispatchSpy = jest.spyOn(store.store, 'dispatch').mockImplementation(() => jest.fn()) + + await PendingTxMonitor.monitorTx(0, 'fakeTxId', 'fakeTxHash', { + numOfAttempts: 1, + startingDelay: 0, + timeMultiple: 0, + }) + + expect(dispatchSpy).not.toBeCalled() + }) + + it('clears the pending transaction it the tx was not mined within 50 blocks', async () => { + PendingTxMonitor._isTxMined = jest.fn(() => Promise.reject()) + + const dispatchSpy = jest.spyOn(store.store, 'dispatch').mockImplementation(() => jest.fn()) + + await PendingTxMonitor.monitorTx(0, 'fakeTxId', 'fakeTxHash', { + numOfAttempts: 1, + startingDelay: 0, + timeMultiple: 0, + }) + + expect(dispatchSpy).toHaveBeenCalledTimes(2) + }) + }) + + describe('monitorAllPendingTxs', () => { + it('breaks if there are no pending txs', async () => { + jest.spyOn(store.store, 'getState').mockImplementation(() => ({ + pendingTransactions: {}, + config: { + chainId: '4', + }, + })) + + const getWeb3Spy = jest.spyOn(web3, 'getWeb3') + + await PendingTxMonitor.monitorAllTxs() + + expect(getWeb3Spy).not.toHaveBeenCalled() + }) + it('breaks if no block number returns', async () => { + jest.spyOn(store.store, 'getState').mockImplementation(() => ({ + pendingTransactions: { + '4': { fakeTxId: 'fakeTxHash' }, + }, + config: { + chainId: '4', + }, + })) + + jest.spyOn(web3.getWeb3().eth, 'getBlockNumber').mockImplementation(() => Promise.reject()) + + const isPendingSpy = jest.spyOn(PendingTxMonitor, '_isTxMined').mockImplementation(jest.fn()) + + try { + await PendingTxMonitor.monitorAllTxs() + + // Fail test if above expression doesn't throw anything + expect(true).toBe(false) + } catch (e) { + expect(isPendingSpy).not.toHaveBeenCalled() + } + }) + + it('checks each pending tx', async () => { + jest.spyOn(store.store, 'getState').mockImplementation(() => ({ + pendingTransactions: { + '4': { + fakeTxId: 'fakeTxHash', + fakeTxId2: 'fakeTxHash2', + fakeTxId3: 'fakeTxHash3', + }, + }, + config: { + chainId: '4', + }, + })) + + jest.spyOn(web3.getWeb3().eth, 'getBlockNumber').mockImplementation(() => Promise.resolve(0)) + + PendingTxMonitor._isTxMined = jest.fn(() => Promise.resolve()) + + await PendingTxMonitor.monitorAllTxs() + + expect((PendingTxMonitor._isTxMined as jest.Mock).mock.calls).toEqual([ + [0, 'fakeTxHash'], + [0, 'fakeTxHash2'], + [0, 'fakeTxHash3'], + ]) + }) + }) +}) diff --git a/src/logic/safe/transactions/pendingTxMonitor.ts b/src/logic/safe/transactions/pendingTxMonitor.ts new file mode 100644 index 0000000000..881523f6c5 --- /dev/null +++ b/src/logic/safe/transactions/pendingTxMonitor.ts @@ -0,0 +1,72 @@ +import { backOff, IBackOffOptions } from 'exponential-backoff' + +import { NOTIFICATIONS } from 'src/logic/notifications' +import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar' +import { getWeb3 } from 'src/logic/wallets/getWeb3' +import { store } from 'src/store' +import { removePendingTransaction } from 'src/logic/safe/store/actions/pendingTransactions' +import { pendingTxIdsByChain } from 'src/logic/safe/store/selectors/pendingTransactions' + +const _isTxMined = async (sessionBlockNumber: number, txHash: string): Promise => { + const MAX_WAITING_BLOCK = sessionBlockNumber + 50 + + const web3 = getWeb3() + + if ( + // Transaction hasn't yet been mined + !(await web3.eth.getTransactionReceipt(txHash)) && + // The current block is within waiting window + (await web3.eth.getBlockNumber()) <= MAX_WAITING_BLOCK + ) { + throw new Error('Pending transaction not found') + } +} + +// Progressively after 10s, 20s, 40s, 80s, 160s, 320s - total of 6.5 minutes +const INITIAL_TIMEOUT = 10_000 +const TIMEOUT_MULTIPLIER = 2 +const MAX_ATTEMPTS = 6 + +const monitorTx = async ( + sessionBlockNumber: number, + txId: string, + txHash: string, + options: Partial = { + startingDelay: INITIAL_TIMEOUT, + timeMultiple: TIMEOUT_MULTIPLIER, + numOfAttempts: MAX_ATTEMPTS, + }, +): Promise => { + return backOff(() => PendingTxMonitor._isTxMined(sessionBlockNumber, txHash), options).catch(() => { + // Unsuccessfully mined (threw in last backOff attempt) + store.dispatch(removePendingTransaction({ id: txId })) + store.dispatch(enqueueSnackbar(NOTIFICATIONS.TX_PENDING_FAILED_MSG)) + }) + // If mined, pending status is removed in the transaction middleware + // when a transaction is added to historical transactions list +} + +const monitorAllTxs = async (): Promise => { + const pendingTxsOnChain = pendingTxIdsByChain(store.getState()) + const pendingTxs = Object.entries(pendingTxsOnChain || {}) + + // Don't check pending transactions if there are none + if (pendingTxs.length === 0) { + return + } + + const web3 = getWeb3() + + try { + const sessionBlockNumber = await web3.eth.getBlockNumber() + await Promise.all( + pendingTxs.map(([txId, txHash]) => { + return PendingTxMonitor.monitorTx(sessionBlockNumber, txId, txHash) + }), + ) + } catch { + // Ignore + } +} + +export const PendingTxMonitor = { _isTxMined, monitorTx, monitorAllTxs }