Skip to content
This repository was archived by the owner on Nov 10, 2023. It is now read-only.
Merged
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
5 changes: 5 additions & 0 deletions src/logic/notifications/notificationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +99,10 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
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',
Expand Down
4 changes: 2 additions & 2 deletions src/logic/safe/store/actions/createTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export class TxSender {
}

async onError(err: Error & { code: number }, errorCallback?: ErrorEventHandler): Promise<void> {
const { txArgs, isFinalization, from, txProps, dispatch, notifications, safeInstance, txId, txHash } = this
const { txArgs, isFinalization, from, txProps, dispatch, notifications, safeInstance, txId } = this

errorCallback?.()

Expand Down Expand Up @@ -159,7 +159,7 @@ export class TxSender {
txArgs.sigs,
)
.encodeABI()
: txHash && safeInstance.methods.approveHash(txHash).encodeABI()
: this.txHash && safeInstance.methods.approveHash(this.txHash).encodeABI()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change? I see that before we were destructuring txHash from this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

txHash wasn't updating when destructured


if (!executeData) {
return
Expand Down
11 changes: 9 additions & 2 deletions src/logic/safe/store/middleware/pendingTransactionsMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,10 +46,11 @@ if (channel) {
}

export const pendingTransactionsMiddleware =
({ getState }: typeof reduxStore) =>
(store: typeof reduxStore) =>
(next: Dispatch) =>
async (action: Action<PendingTransactionPayloads>): Promise<Action<PendingTransactionPayloads>> => {
const handledAction = next(action)
const state = store.getState()

switch (action.type) {
case PENDING_TRANSACTIONS_ACTIONS.ADD:
Expand All @@ -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
}
Expand Down
10 changes: 8 additions & 2 deletions src/logic/safe/store/reducer/pendingTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { _getChainId } from 'src/config'

export const PENDING_TRANSACTIONS_ID = 'pendingTransactions'

export type PendingTransactionsState = Record<ChainId, Record<string, string | boolean>>
export type PendingTransactionsState = Record<ChainId, Record<string, string>>

const initialPendingTxsState = session.getItem<PendingTransactionsState>(PENDING_TRANSACTIONS_ID) || {}

Expand All @@ -17,7 +17,7 @@ export type RemovePendingTransactionPayload = {
}

export type AddPendingTransactionPayload = RemovePendingTransactionPayload & {
txHash: string | boolean
txHash: string
}

export type PendingTransactionPayloads = AddPendingTransactionPayload | RemovePendingTransactionPayload
Expand Down Expand Up @@ -46,6 +46,12 @@ export const pendingTransactionsReducer = handleActions<PendingTransactionsState
// Omit id from the pending transactions on current chain
const { [id]: _, ...newChainState } = state[chainId] || {}

if (Object.keys(newChainState[chainId] || {}).length === 0) {
// Omit chainId from the pending transactions if no pending transactions on chain
const { [chainId]: _, ...newState } = state
return newState
}

return {
...state,
[chainId]: newChainState,
Expand Down
161 changes: 161 additions & 0 deletions src/logic/safe/transactions/__tests__/pendingTxMonitor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Transaction } from 'web3-core'

import * as store from 'src/store'
import * as web3 from 'src/logic/wallets/getWeb3'
import { PendingTxMonitor } from 'src/logic/safe/transactions/pendingTxMonitor'

const originalIsTxMined = PendingTxMonitor._isTxMined

describe('PendingTxMonitor', () => {
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'],
])
})
})
})
72 changes: 72 additions & 0 deletions src/logic/safe/transactions/pendingTxMonitor.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<IBackOffOptions> = {
startingDelay: INITIAL_TIMEOUT,
timeMultiple: TIMEOUT_MULTIPLIER,
numOfAttempts: MAX_ATTEMPTS,
},
): Promise<void> => {
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<void> => {
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 }