diff --git a/src/components/recovery/CancelRecoveryButton/index.tsx b/src/components/recovery/CancelRecoveryButton/index.tsx
index e922d57d19..754cb9bf5c 100644
--- a/src/components/recovery/CancelRecoveryButton/index.tsx
+++ b/src/components/recovery/CancelRecoveryButton/index.tsx
@@ -8,12 +8,11 @@ import CheckWallet from '@/components/common/CheckWallet'
import { TxModalContext } from '@/components/tx-flow'
import { CancelRecoveryFlow } from '@/components/tx-flow/flows/CancelRecovery'
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
-import { dispatchRecoverySkipExpired } from '@/services/tx/tx-sender'
+import { dispatchRecoverySkipExpired } from '@/services/recovery/recovery-sender'
import useSafeInfo from '@/hooks/useSafeInfo'
import useOnboard from '@/hooks/wallets/useOnboard'
import { trackError, Errors } from '@/services/exceptions'
import { asError } from '@/services/exceptions/utils'
-import { RecoveryContext } from '../RecoveryContext'
import { useIsGuardian } from '@/hooks/useIsGuardian'
import { useRecoveryTxState } from '@/hooks/useRecoveryTxState'
import { RecoveryListItemContext } from '../RecoveryListItem/RecoveryListItemContext'
@@ -33,7 +32,6 @@ export function CancelRecoveryButton({
const { setTxFlow } = useContext(TxModalContext)
const onboard = useOnboard()
const { safe } = useSafeInfo()
- const { refetch } = useContext(RecoveryContext)
const onClick = async (e: SyntheticEvent) => {
e.stopPropagation()
@@ -47,7 +45,7 @@ export function CancelRecoveryButton({
onboard,
chainId: safe.chainId,
delayModifierAddress: recovery.address,
- refetchRecoveryData: refetch,
+ recoveryTxHash: recovery.args.txHash,
})
} catch (_err) {
const err = asError(_err)
diff --git a/src/components/recovery/ExecuteRecoveryButton/index.tsx b/src/components/recovery/ExecuteRecoveryButton/index.tsx
index 610d1a49cc..18d7eca2ef 100644
--- a/src/components/recovery/ExecuteRecoveryButton/index.tsx
+++ b/src/components/recovery/ExecuteRecoveryButton/index.tsx
@@ -5,13 +5,12 @@ import type { SyntheticEvent, ReactElement } from 'react'
import RocketIcon from '@/public/images/transactions/rocket.svg'
import IconButton from '@mui/material/IconButton'
import CheckWallet from '@/components/common/CheckWallet'
-import { dispatchRecoveryExecution } from '@/services/tx/tx-sender'
+import { dispatchRecoveryExecution } from '@/services/recovery/recovery-sender'
import useOnboard from '@/hooks/wallets/useOnboard'
import useSafeInfo from '@/hooks/useSafeInfo'
import { useRecoveryTxState } from '@/hooks/useRecoveryTxState'
import { Errors, trackError } from '@/services/exceptions'
import { asError } from '@/services/exceptions/utils'
-import { RecoveryContext } from '../RecoveryContext'
import { RecoveryListItemContext } from '../RecoveryListItem/RecoveryListItemContext'
import type { RecoveryQueueItem } from '@/services/recovery/recovery-state'
@@ -24,10 +23,8 @@ export function ExecuteRecoveryButton({
}): ReactElement {
const { setSubmitError } = useContext(RecoveryListItemContext)
const { isExecutable, isPending } = useRecoveryTxState(recovery)
- const { setPending } = useContext(RecoveryContext)
const onboard = useOnboard()
const { safe } = useSafeInfo()
- const { refetch } = useContext(RecoveryContext)
const onClick = async (e: SyntheticEvent) => {
e.stopPropagation()
@@ -37,26 +34,18 @@ export function ExecuteRecoveryButton({
return
}
- setPending((prev) => ({ ...prev, [recovery.transactionHash]: true }))
-
try {
await dispatchRecoveryExecution({
onboard,
chainId: safe.chainId,
args: recovery.args,
delayModifierAddress: recovery.address,
- refetchRecoveryData: refetch,
})
} catch (_err) {
const err = asError(_err)
trackError(Errors._812, e)
setSubmitError(err)
- } finally {
- setPending((prev) => {
- const { [recovery.transactionHash]: _, ...rest } = prev
- return rest
- })
}
}
diff --git a/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts b/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts
new file mode 100644
index 0000000000..3439ae0dac
--- /dev/null
+++ b/src/components/recovery/RecoveryContext/__tests__/useRecoveryPendingTxs.test.ts
@@ -0,0 +1,75 @@
+import { RecoveryEvent, recoveryDispatch } from '@/services/recovery/recoveryEvents'
+import { PendingStatus } from '@/store/pendingTxsSlice'
+import { act, renderHook } from '@/tests/test-utils'
+import { faker } from '@faker-js/faker'
+import { useRecoveryPendingTxs } from '../useRecoveryPendingTxs'
+
+describe('useRecoveryPendingTxs', () => {
+ it('should set pending status to SUBMITTING when EXECUTING event is emitted', () => {
+ const delayModifierAddress = faker.finance.ethereumAddress()
+ const transactionHash = faker.string.hexadecimal()
+ const { result } = renderHook(() => useRecoveryPendingTxs())
+
+ expect(result.current).toStrictEqual({})
+
+ act(() => {
+ recoveryDispatch(RecoveryEvent.EXECUTING, { moduleAddress: delayModifierAddress, transactionHash })
+ })
+
+ expect(result.current).toStrictEqual({
+ [transactionHash]: PendingStatus.SUBMITTING,
+ })
+ })
+
+ it('should set pending status to PROCESSING when PROCESSING event is emitted', () => {
+ const delayModifierAddress = faker.finance.ethereumAddress()
+ const transactionHash = faker.string.hexadecimal()
+ const { result } = renderHook(() => useRecoveryPendingTxs())
+
+ expect(result.current).toStrictEqual({})
+
+ act(() => {
+ recoveryDispatch(RecoveryEvent.PROCESSING, { moduleAddress: delayModifierAddress, transactionHash })
+ })
+
+ expect(result.current).toStrictEqual({
+ [transactionHash]: PendingStatus.PROCESSING,
+ })
+ })
+
+ it('should set remove the pending status when REVERTED event is emitted', () => {
+ const delayModifierAddress = faker.finance.ethereumAddress()
+ const transactionHash = faker.string.hexadecimal()
+ const { result } = renderHook(() => useRecoveryPendingTxs())
+
+ expect(result.current).toStrictEqual({})
+
+ act(() => {
+ recoveryDispatch(RecoveryEvent.EXECUTING, { moduleAddress: delayModifierAddress, transactionHash })
+ recoveryDispatch(RecoveryEvent.REVERTED, {
+ moduleAddress: delayModifierAddress,
+ transactionHash,
+ error: new Error(),
+ })
+ })
+
+ expect(result.current).toStrictEqual({})
+ })
+
+ it('should set remove the pending status when PROCESSED event is emitted', () => {
+ const delayModifierAddress = faker.finance.ethereumAddress()
+ const transactionHash = faker.string.hexadecimal()
+ const { result } = renderHook(() => useRecoveryPendingTxs())
+
+ expect(result.current).toStrictEqual({})
+
+ act(() => {
+ recoveryDispatch(RecoveryEvent.EXECUTING, { moduleAddress: delayModifierAddress, transactionHash })
+ recoveryDispatch(RecoveryEvent.PROCESSED, { moduleAddress: delayModifierAddress, transactionHash })
+ })
+
+ expect(result.current).toStrictEqual({})
+ })
+
+ // No need to test RecoveryEvent.FAILED as pending status is not set before it is dispatched
+})
diff --git a/src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.tsx b/src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.tsx
index 99e66df535..a79211ab3a 100644
--- a/src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.tsx
+++ b/src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.tsx
@@ -1,5 +1,4 @@
import { faker } from '@faker-js/faker'
-import { useContext } from 'react'
import { useCurrentChain, useHasFeature } from '@/hooks/useChains'
import useSafeInfo from '@/hooks/useSafeInfo'
@@ -13,7 +12,8 @@ import useTxHistory from '@/hooks/useTxHistory'
import { getRecoveryDelayModifiers } from '@/services/recovery/delay-modifier'
import { useAppDispatch } from '@/store'
import { txHistorySlice } from '@/store/txHistorySlice'
-import { RecoveryProvider, RecoveryContext } from '..'
+import { RecoveryProvider } from '..'
+import { recoveryDispatch, RecoveryEvent } from '@/services/recovery/recoveryEvents'
jest.mock('@/services/recovery/delay-modifier')
jest.mock('@/services/recovery/recovery-state')
@@ -76,7 +76,7 @@ describe('useRecoveryState', () => {
jest.advanceTimersByTime(10)
})
- expect(result.current.data).toEqual([undefined, undefined, false])
+ expect(result.current).toEqual([undefined, undefined, false])
expect(mockGetRecoveryState).not.toHaveBeenCalledTimes(1)
jest.useRealTimers()
@@ -120,7 +120,7 @@ describe('useRecoveryState', () => {
jest.advanceTimersByTime(10)
})
- expect(result.current.data).toEqual([undefined, undefined, false])
+ expect(result.current).toEqual([undefined, undefined, false])
expect(mockGetRecoveryState).not.toHaveBeenCalledTimes(1)
jest.useRealTimers()
@@ -163,7 +163,7 @@ describe('useRecoveryState', () => {
})
})
- it('should refetch when interacting with a Delay Modifier', async () => {
+ it('should refetch when interacting with a Delay Modifier via the Safe', async () => {
mockUseHasFeature.mockReturnValue(true)
const provider = {}
mockUseWeb3ReadOnly.mockReturnValue(provider as any)
@@ -227,7 +227,7 @@ describe('useRecoveryState', () => {
})
})
- it('should refetch manually calling it', async () => {
+ it('should refetch when interacting with a Delay Modifier as a Guardian', async () => {
mockUseHasFeature.mockReturnValue(true)
const provider = {}
mockUseWeb3ReadOnly.mockReturnValue(provider as any)
@@ -239,18 +239,12 @@ describe('useRecoveryState', () => {
mockUseSafeInfo.mockReturnValue(safeInfo as any)
const chain = chainBuilder().build()
mockUseCurrentChain.mockReturnValue(chain)
- const delayModifiers = [{}]
- mockGetRecoveryDelayModifiers.mockResolvedValue(delayModifiers as any)
-
- function Test() {
- const { refetch } = useContext(RecoveryContext)
-
- return
- }
+ const delayModifierAddress = faker.finance.ethereumAddress()
+ mockGetRecoveryDelayModifiers.mockResolvedValue([{ address: delayModifierAddress } as any])
- const { queryByText } = render(
+ render(
-
+ <>>
,
)
@@ -259,14 +253,13 @@ describe('useRecoveryState', () => {
expect(mockGetRecoveryState).toHaveBeenCalledTimes(1)
})
- act(() => {
- fireEvent.click(queryByText('Refetch')!)
+ recoveryDispatch(RecoveryEvent.PROCESSED, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash: faker.string.hexadecimal(),
})
await waitFor(() => {
expect(mockGetRecoveryState).toHaveBeenCalledTimes(2)
})
-
- expect(mockGetRecoveryDelayModifiers).toHaveBeenCalledTimes(1)
})
})
diff --git a/src/components/recovery/RecoveryContext/index.tsx b/src/components/recovery/RecoveryContext/index.tsx
index 1b52c5c41c..1a76c86301 100644
--- a/src/components/recovery/RecoveryContext/index.tsx
+++ b/src/components/recovery/RecoveryContext/index.tsx
@@ -1,42 +1,32 @@
-import { createContext, useContext, useState } from 'react'
-import type { ReactElement, ReactNode, Dispatch, SetStateAction } from 'react'
+import { createContext, useContext } from 'react'
+import type { ReactElement, ReactNode } from 'react'
import { useRecoveryState } from './useRecoveryState'
import { useRecoveryDelayModifiers } from './useRecoveryDelayModifiers'
import type { AsyncResult } from '@/hooks/useAsync'
import type { RecoveryState } from '@/services/recovery/recovery-state'
-
-type PendingRecoveryTransactions = { [txHash: string]: boolean }
+import { useRecoveryPendingTxs } from './useRecoveryPendingTxs'
// State of current Safe, populated on load
export const RecoveryContext = createContext<{
state: AsyncResult
- refetch: () => void
- pending: PendingRecoveryTransactions
- setPending: Dispatch>
+ pending: ReturnType
}>({
state: [undefined, undefined, false],
- refetch: () => {},
pending: {},
- setPending: () => {},
})
export function RecoveryProvider({ children }: { children: ReactNode }): ReactElement {
const [delayModifiers, delayModifiersError, delayModifiersLoading] = useRecoveryDelayModifiers()
- const {
- data: [recoveryState, recoveryStateError, recoveryStateLoading],
- refetch,
- } = useRecoveryState(delayModifiers)
- const [pending, setPending] = useState({})
+ const [recoveryState, recoveryStateError, recoveryStateLoading] = useRecoveryState(delayModifiers)
+ const pending = useRecoveryPendingTxs()
const data = recoveryState
const error = delayModifiersError || recoveryStateError
const loading = delayModifiersLoading || recoveryStateLoading
return (
-
- {children}
-
+ {children}
)
}
diff --git a/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts b/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts
new file mode 100644
index 0000000000..2e4abbcb2c
--- /dev/null
+++ b/src/components/recovery/RecoveryContext/useRecoveryPendingTxs.ts
@@ -0,0 +1,44 @@
+import { RecoveryEvent, recoverySubscribe } from '@/services/recovery/recoveryEvents'
+import { PendingStatus } from '@/store/pendingTxsSlice'
+import { useEffect, useState } from 'react'
+
+type PendingRecoveryTransactions = { [recoveryTxHash: string]: PendingStatus }
+
+const pendingStatuses: { [key in RecoveryEvent]: PendingStatus | null } = {
+ [RecoveryEvent.EXECUTING]: PendingStatus.SUBMITTING,
+ [RecoveryEvent.PROCESSING]: PendingStatus.PROCESSING,
+ [RecoveryEvent.REVERTED]: null,
+ [RecoveryEvent.PROCESSED]: null,
+ [RecoveryEvent.FAILED]: null,
+}
+
+export function useRecoveryPendingTxs(): PendingRecoveryTransactions {
+ const [pending, setPending] = useState({})
+
+ useEffect(() => {
+ const unsubFns = Object.entries(pendingStatuses).map(([event, status]) =>
+ recoverySubscribe(event as RecoveryEvent, (detail) => {
+ const recoveryTxHash = 'recoveryTxHash' in detail && detail.recoveryTxHash
+
+ if (!recoveryTxHash) {
+ return
+ }
+
+ setPending((prev) => {
+ if (status === null) {
+ const { [recoveryTxHash]: _, ...rest } = prev
+ return rest
+ }
+
+ return { ...prev, [recoveryTxHash]: status }
+ })
+ }),
+ )
+
+ return () => {
+ unsubFns.forEach((unsub) => unsub())
+ }
+ }, [])
+
+ return pending
+}
diff --git a/src/components/recovery/RecoveryContext/useRecoveryState.ts b/src/components/recovery/RecoveryContext/useRecoveryState.ts
index 2cf5fc2bc1..df58442bc5 100644
--- a/src/components/recovery/RecoveryContext/useRecoveryState.ts
+++ b/src/components/recovery/RecoveryContext/useRecoveryState.ts
@@ -12,15 +12,13 @@ import { isCustomTxInfo, isMultiSendTxInfo, isTransactionListItem } from '@/util
import { sameAddress } from '@/utils/addresses'
import { addListener } from '@reduxjs/toolkit'
import { txHistorySlice } from '@/store/txHistorySlice'
+import { RecoveryEvent, recoverySubscribe } from '@/services/recovery/recoveryEvents'
import type { AsyncResult } from '@/hooks/useAsync'
import type { RecoveryState } from '@/services/recovery/recovery-state'
const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes
-export function useRecoveryState(delayModifiers?: Array): {
- data: AsyncResult
- refetch: () => void
-} {
+export function useRecoveryState(delayModifiers?: Array): AsyncResult {
const web3ReadOnly = useWeb3ReadOnly()
const chain = useCurrentChain()
const { safe, safeAddress } = useSafeInfo()
@@ -35,34 +33,10 @@ export function useRecoveryState(delayModifiers?: Array): {
setRefetchDep((prev) => !prev)
}, [])
- const data = useAsync(
- () => {
- if (!delayModifiers || delayModifiers.length === 0 || !chain?.transactionService || !web3ReadOnly) {
- return
- }
-
- return getRecoveryState({
- delayModifiers,
- transactionService: chain.transactionService,
- safeAddress,
- provider: web3ReadOnly,
- chainId: safe.chainId,
- version: safe.version,
- })
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [
- delayModifiers,
- counter,
- refetchDep,
- chain?.transactionService,
- web3ReadOnly,
- safeAddress,
- safe.chainId,
- safe.version,
- ],
- false,
- )
+ // Reload recovery data when a Guardian transaction occurs
+ useEffect(() => {
+ return recoverySubscribe(RecoveryEvent.PROCESSED, refetch)
+ }, [refetch])
// Reload recovery data when a Delay Modifier is interacted with
useEffect(() => {
@@ -108,5 +82,32 @@ export function useRecoveryState(delayModifiers?: Array): {
return unsubscribe
}, [safe.chainId, delayModifiers, refetch, dispatch])
- return { data, refetch }
+ return useAsync(
+ () => {
+ if (!delayModifiers || delayModifiers.length === 0 || !chain?.transactionService || !web3ReadOnly) {
+ return
+ }
+
+ return getRecoveryState({
+ delayModifiers,
+ transactionService: chain.transactionService,
+ safeAddress,
+ provider: web3ReadOnly,
+ chainId: safe.chainId,
+ version: safe.version,
+ })
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ delayModifiers,
+ counter,
+ refetchDep,
+ chain?.transactionService,
+ web3ReadOnly,
+ safeAddress,
+ safe.chainId,
+ safe.version,
+ ],
+ false,
+ )
}
diff --git a/src/components/recovery/RecoveryStatus/index.tsx b/src/components/recovery/RecoveryStatus/index.tsx
index 407bab644f..52872b8f6f 100644
--- a/src/components/recovery/RecoveryStatus/index.tsx
+++ b/src/components/recovery/RecoveryStatus/index.tsx
@@ -1,12 +1,34 @@
import { SvgIcon, Typography } from '@mui/material'
+import { useContext } from 'react'
import type { ReactElement } from 'react'
import ClockIcon from '@/public/images/common/clock.svg'
import { useRecoveryTxState } from '@/hooks/useRecoveryTxState'
import type { RecoveryQueueItem } from '@/services/recovery/recovery-state'
+import { PendingStatus } from '@/store/pendingTxsSlice'
+import { RecoveryContext } from '../RecoveryContext'
+
+const STATUS_LABELS: Partial> = {
+ [PendingStatus.SUBMITTING]: 'Submitting',
+ [PendingStatus.PROCESSING]: 'Processing',
+}
export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): ReactElement => {
- const { isExecutable, isExpired, isPending } = useRecoveryTxState(recovery)
+ const { isExecutable, isExpired } = useRecoveryTxState(recovery)
+ const { pending } = useContext(RecoveryContext)
+
+ const status = pending?.[recovery.transactionHash] ? (
+ STATUS_LABELS[pending[recovery.transactionHash]]
+ ) : isExecutable ? (
+ 'Awaiting execution'
+ ) : isExpired ? (
+ 'Expired'
+ ) : (
+ <>
+
+ Pending
+ >
+ )
return (
<>
@@ -17,18 +39,7 @@ export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): R
display="inline-flex"
alignItems="center"
>
- {isPending ? (
- 'Processing...'
- ) : isExecutable ? (
- 'Awaiting execution'
- ) : isExpired ? (
- 'Expired'
- ) : (
- <>
-
- Pending
- >
- )}
+ {status}
>
)
diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx
index 8ad2d4c3b6..584a594e81 100644
--- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx
+++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx
@@ -14,7 +14,8 @@ import useDecodeTx from '@/hooks/useDecodeTx'
import TxCard from '../../common/TxCard'
import { SafeTxContext } from '../../SafeTxProvider'
import CheckWallet from '@/components/common/CheckWallet'
-import { createMultiSendCallOnlyTx, createTx, dispatchRecoveryProposal } from '@/services/tx/tx-sender'
+import { dispatchRecoveryProposal } from '@/services/recovery/recovery-sender'
+import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender'
import { RecoverAccountFlowFields } from '.'
import { OwnerList } from '../../common/OwnerList'
import { selectDelayModifierByGuardian } from '@/services/recovery/selectors'
@@ -24,7 +25,7 @@ import { TxModalContext } from '../..'
import { asError } from '@/services/exceptions/utils'
import { trackError, Errors } from '@/services/exceptions'
import { getPeriod } from '@/utils/date'
-import { RecoveryContext } from '@/components/recovery/RecoveryContext'
+import { useRecovery } from '@/components/recovery/RecoveryContext'
import type { RecoverAccountFlowProps } from '.'
import commonCss from '@/components/tx-flow/common/styles.module.css'
@@ -41,10 +42,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo
const { safe } = useSafeInfo()
const wallet = useWallet()
const onboard = useOnboard()
- const {
- refetch,
- state: [data],
- } = useContext(RecoveryContext)
+ const [data] = useRecovery()
const recovery = data && selectDelayModifierByGuardian(data, wallet?.address ?? '')
// Proposal
@@ -78,7 +76,6 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo
safe,
safeTx,
delayModifierAddress: recovery.address,
- refetchRecoveryData: refetch,
})
} catch (_err) {
const err = asError(_err)
diff --git a/src/services/recovery/recovery-sender.ts b/src/services/recovery/recovery-sender.ts
new file mode 100644
index 0000000000..85df531ff2
--- /dev/null
+++ b/src/services/recovery/recovery-sender.ts
@@ -0,0 +1,188 @@
+import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac'
+import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk'
+import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
+import type { ContractTransaction } from 'ethers'
+import type { OnboardAPI } from '@web3-onboard/core'
+import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay'
+import { createWeb3 } from '@/hooks/wallets/web3'
+
+import { didReprice, didRevert } from '@/utils/ethers-utils'
+import { recoveryDispatch, RecoveryEvent } from './recoveryEvents'
+import { asError } from '@/services/exceptions/utils'
+import { assertWalletChain } from '../tx/tx-sender/sdk'
+
+function waitForRecoveryTx(moduleAddress: string, recoveryTxHash: string, tx: ContractTransaction) {
+ const payload = {
+ moduleAddress,
+ recoveryTxHash,
+ }
+
+ recoveryDispatch(RecoveryEvent.PROCESSING, payload)
+
+ tx.wait()
+ .then((receipt) => {
+ if (didRevert(receipt)) {
+ recoveryDispatch(RecoveryEvent.REVERTED, {
+ ...payload,
+ error: new Error('Transaction reverted by EVM'),
+ })
+ } else {
+ recoveryDispatch(RecoveryEvent.PROCESSED, payload)
+ }
+ })
+ .catch((error) => {
+ if (didReprice(error)) {
+ recoveryDispatch(RecoveryEvent.PROCESSED, payload)
+ } else {
+ recoveryDispatch(RecoveryEvent.FAILED, {
+ ...payload,
+ error: asError(error),
+ })
+ }
+ })
+}
+
+export async function dispatchRecoveryProposal({
+ onboard,
+ safe,
+ safeTx,
+ delayModifierAddress,
+}: {
+ onboard: OnboardAPI
+ safe: SafeInfo
+ safeTx: SafeTransaction
+ delayModifierAddress: string
+}) {
+ const wallet = await assertWalletChain(onboard, safe.chainId)
+ const provider = createWeb3(wallet.provider)
+
+ const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider)
+
+ const signer = provider.getSigner()
+
+ let recoveryTxHash: string | undefined
+ let tx: ContractTransaction | undefined
+
+ const contract = delayModifier.connect(signer)
+
+ try {
+ // Get recovery tx hash as a form of ID for event bus
+ recoveryTxHash = await contract.getTransactionHash(
+ safeTx.data.to,
+ safeTx.data.value,
+ safeTx.data.data,
+ safeTx.data.operation,
+ )
+
+ recoveryDispatch(RecoveryEvent.EXECUTING, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash: recoveryTxHash,
+ })
+ } catch (error) {
+ recoveryDispatch(RecoveryEvent.FAILED, {
+ moduleAddress: delayModifierAddress,
+ error: asError(error),
+ })
+
+ throw error
+ }
+
+ try {
+ tx = await contract.execTransactionFromModule(
+ safeTx.data.to,
+ safeTx.data.value,
+ safeTx.data.data,
+ safeTx.data.operation,
+ )
+ } catch (error) {
+ recoveryDispatch(RecoveryEvent.FAILED, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash,
+ error: asError(error),
+ })
+
+ throw error
+ }
+
+ waitForRecoveryTx(delayModifierAddress, recoveryTxHash, tx)
+}
+
+export async function dispatchRecoveryExecution({
+ onboard,
+ chainId,
+ args,
+ delayModifierAddress,
+}: {
+ onboard: OnboardAPI
+ chainId: string
+ args: TransactionAddedEvent['args']
+ delayModifierAddress: string
+}) {
+ const wallet = await assertWalletChain(onboard, chainId)
+ const provider = createWeb3(wallet.provider)
+
+ const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider)
+
+ const signer = provider.getSigner()
+
+ let tx: ContractTransaction | undefined
+
+ try {
+ tx = await delayModifier.connect(signer).executeNextTx(args.to, args.value, args.data, args.operation)
+
+ recoveryDispatch(RecoveryEvent.EXECUTING, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash: args.txHash,
+ })
+ } catch (error) {
+ recoveryDispatch(RecoveryEvent.FAILED, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash: args.txHash,
+ error: asError(error),
+ })
+
+ throw error
+ }
+
+ waitForRecoveryTx(delayModifierAddress, args.txHash, tx)
+}
+
+export async function dispatchRecoverySkipExpired({
+ onboard,
+ chainId,
+ delayModifierAddress,
+ recoveryTxHash,
+}: {
+ onboard: OnboardAPI
+ chainId: string
+ delayModifierAddress: string
+ recoveryTxHash: string
+}) {
+ const wallet = await assertWalletChain(onboard, chainId)
+ const provider = createWeb3(wallet.provider)
+
+ const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider)
+
+ const signer = provider.getSigner()
+
+ let tx: ContractTransaction | undefined
+
+ try {
+ tx = await delayModifier.connect(signer).skipExpired()
+
+ recoveryDispatch(RecoveryEvent.EXECUTING, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash,
+ })
+ } catch (error) {
+ recoveryDispatch(RecoveryEvent.FAILED, {
+ moduleAddress: delayModifierAddress,
+ recoveryTxHash,
+ error: asError(error),
+ })
+
+ throw error
+ }
+
+ waitForRecoveryTx(delayModifierAddress, recoveryTxHash, tx)
+}
diff --git a/src/services/recovery/recoveryEvents.ts b/src/services/recovery/recoveryEvents.ts
new file mode 100644
index 0000000000..968d884262
--- /dev/null
+++ b/src/services/recovery/recoveryEvents.ts
@@ -0,0 +1,30 @@
+import EventBus from '../EventBus'
+
+export enum RecoveryEvent {
+ EXECUTING = 'EXECUTING',
+ PROCESSING = 'PROCESSING',
+ REVERTED = 'REVERTED',
+ PROCESSED = 'PROCESSED',
+ FAILED = 'FAILED',
+}
+
+interface RecoveryEvents {
+ [RecoveryEvent.EXECUTING]: { moduleAddress: string; recoveryTxHash: string }
+ [RecoveryEvent.PROCESSING]: { moduleAddress: string; recoveryTxHash: string }
+ [RecoveryEvent.REVERTED]: { moduleAddress: string; recoveryTxHash: string; error: Error }
+ [RecoveryEvent.PROCESSED]: { moduleAddress: string; recoveryTxHash: string }
+ [RecoveryEvent.FAILED]: { moduleAddress: string; recoveryTxHash?: string; error: Error }
+}
+
+const recoveryEventBus = new EventBus()
+
+export const recoveryDispatch = recoveryEventBus.dispatch.bind(recoveryEventBus)
+
+export const recoverySubscribe = recoveryEventBus.subscribe.bind(recoveryEventBus)
+
+// Log all events
+Object.values(RecoveryEvent).forEach((event: RecoveryEvent) => {
+ recoverySubscribe(event, (detail) => {
+ console.info(`Recovery ${event} event received`, detail)
+ })
+})
diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts
index ecf6f236dc..7b1ab004e2 100644
--- a/src/services/tx/tx-sender/dispatch.ts
+++ b/src/services/tx/tx-sender/dispatch.ts
@@ -22,8 +22,6 @@ import {
import { createWeb3 } from '@/hooks/wallets/web3'
import { type OnboardAPI } from '@web3-onboard/core'
import { asError } from '@/services/exceptions/utils'
-import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac'
-import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay'
/**
* Propose a transaction
@@ -406,101 +404,3 @@ export const dispatchBatchExecutionRelay = async (
groupKey,
)
}
-
-function reloadRecoveryDataAfterProcessed(tx: ContractTransaction, refetchRecoveryData: () => void) {
- tx.wait()
- .then((receipt) => {
- if (!didRevert(receipt)) {
- refetchRecoveryData()
- }
- })
- .catch((error) => {
- if (didReprice(error)) {
- refetchRecoveryData()
- }
- })
-}
-
-export async function dispatchRecoveryProposal({
- onboard,
- safe,
- safeTx,
- delayModifierAddress,
- refetchRecoveryData,
-}: {
- onboard: OnboardAPI
- safe: SafeInfo
- safeTx: SafeTransaction
- delayModifierAddress: string
- refetchRecoveryData: () => void
-}) {
- const wallet = await assertWalletChain(onboard, safe.chainId)
- const provider = createWeb3(wallet.provider)
-
- const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider)
-
- const signer = provider.getSigner()
-
- try {
- const tx = await delayModifier
- .connect(signer)
- .execTransactionFromModule(safeTx.data.to, safeTx.data.value, safeTx.data.data, safeTx.data.operation)
- reloadRecoveryDataAfterProcessed(tx, refetchRecoveryData)
- } catch (error) {
- throw error
- }
-}
-
-export async function dispatchRecoveryExecution({
- onboard,
- chainId,
- args,
- delayModifierAddress,
- refetchRecoveryData,
-}: {
- onboard: OnboardAPI
- chainId: string
- args: TransactionAddedEvent['args']
- delayModifierAddress: string
- refetchRecoveryData: () => void
-}) {
- const wallet = await assertWalletChain(onboard, chainId)
- const provider = createWeb3(wallet.provider)
-
- const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider)
-
- const signer = provider.getSigner()
-
- try {
- const tx = await delayModifier.connect(signer).executeNextTx(args.to, args.value, args.data, args.operation)
- reloadRecoveryDataAfterProcessed(tx, refetchRecoveryData)
- } catch (error) {
- throw error
- }
-}
-
-export async function dispatchRecoverySkipExpired({
- onboard,
- chainId,
- delayModifierAddress,
- refetchRecoveryData,
-}: {
- onboard: OnboardAPI
- chainId: string
- delayModifierAddress: string
- refetchRecoveryData: () => void
-}) {
- const wallet = await assertWalletChain(onboard, chainId)
- const provider = createWeb3(wallet.provider)
-
- const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider)
-
- const signer = provider.getSigner()
-
- try {
- const tx = await delayModifier.connect(signer).skipExpired()
- reloadRecoveryDataAfterProcessed(tx, refetchRecoveryData)
- } catch (error) {
- throw error
- }
-}