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 - } -}