From ae6d1072b19e2e1ca5b2b9b1cda82692ac269b95 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 22 Nov 2023 18:15:44 +0100 Subject: [PATCH] refactor: recovery loading + trigger --- .../recovery/ExecuteRecoveryButton/index.tsx | 4 + .../RecoveryLoaderContext/index.test.tsx | 321 ++++++++ .../recovery/RecoveryLoaderContext/index.tsx | 151 ++++ .../sidebar/SidebarNavigation/index.tsx | 4 +- .../RecoverAccountFlowReview.tsx | 11 +- src/hooks/__tests__/useLoadRecovery.test.ts | 714 ------------------ src/hooks/loadables/useLoadRecovery.ts | 72 -- src/hooks/useLoadableStores.ts | 5 +- src/pages/_app.tsx | 9 +- src/services/tx/tx-sender/dispatch.ts | 51 +- src/services/tx/txEvents.ts | 2 +- src/tests/builders/safe.ts | 2 +- 12 files changed, 540 insertions(+), 806 deletions(-) create mode 100644 src/components/recovery/RecoveryLoaderContext/index.test.tsx create mode 100644 src/components/recovery/RecoveryLoaderContext/index.tsx delete mode 100644 src/hooks/__tests__/useLoadRecovery.test.ts delete mode 100644 src/hooks/loadables/useLoadRecovery.ts diff --git a/src/components/recovery/ExecuteRecoveryButton/index.tsx b/src/components/recovery/ExecuteRecoveryButton/index.tsx index a548946f62..c226921d2f 100644 --- a/src/components/recovery/ExecuteRecoveryButton/index.tsx +++ b/src/components/recovery/ExecuteRecoveryButton/index.tsx @@ -1,4 +1,5 @@ import { Button, SvgIcon, Tooltip } from '@mui/material' +import { useContext } from 'react' import type { SyntheticEvent, ReactElement } from 'react' import RocketIcon from '@/public/images/transactions/rocket.svg' @@ -10,6 +11,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' import { Errors, logError } from '@/services/exceptions' import type { RecoveryQueueItem } from '@/store/recoverySlice' +import { RecoveryLoaderContext } from '../RecoveryLoaderContext' export function ExecuteRecoveryButton({ recovery, @@ -21,6 +23,7 @@ export function ExecuteRecoveryButton({ const { isExecutable } = useRecoveryTxState(recovery) const onboard = useOnboard() const { safe } = useSafeInfo() + const { refetch } = useContext(RecoveryLoaderContext) const onClick = async (e: SyntheticEvent) => { e.stopPropagation() @@ -36,6 +39,7 @@ export function ExecuteRecoveryButton({ chainId: safe.chainId, args: recovery.args, delayModifierAddress: recovery.address, + refetchRecoveryData: refetch, }) } catch (e) { logError(Errors._812, e) diff --git a/src/components/recovery/RecoveryLoaderContext/index.test.tsx b/src/components/recovery/RecoveryLoaderContext/index.test.tsx new file mode 100644 index 0000000000..a16a5c5729 --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/index.test.tsx @@ -0,0 +1,321 @@ +import { faker } from '@faker-js/faker' +import { useContext } from 'react' + +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' +import { getDelayModifiers } from '@/services/recovery/delay-modifier' +import { getRecoveryState } from '@/services/recovery/recovery-state' +import { txDispatch, TxEvent } from '@/services/tx/txEvents' +import { chainBuilder } from '@/tests/builders/chains' +import { addressExBuilder, safeInfoBuilder } from '@/tests/builders/safe' +import { act, fireEvent, render, renderHook, waitFor } from '@/tests/test-utils' +import { RecoveryLoaderContext, RecoveryLoaderProvider, _useDelayModifiers, _useRecoveryState } from '.' + +jest.mock('@/services/recovery/delay-modifier') +jest.mock('@/services/recovery/recovery-state') + +const mockGetDelayModifiers = getDelayModifiers as jest.MockedFunction +const mockGetRecoveryState = getRecoveryState as jest.MockedFunction + +jest.mock('@/hooks/useSafeInfo') +jest.mock('@/hooks/wallets/web3') +jest.mock('@/hooks/useChains') + +const mockUseSafeInfo = useSafeInfo as jest.MockedFunction +const mockUseWeb3ReadOnly = useWeb3ReadOnly as jest.MockedFunction +const mockUseCurrentChain = useCurrentChain as jest.MockedFunction +const mockUseHasFeature = useHasFeature as jest.MockedFunction + +describe('RecoveryLoaderContext', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('RecoveryLoaderProvider', () => { + it('should refetch manually calling it', async () => { + mockUseHasFeature.mockReturnValue(true) + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const chainId = '5' + const safe = safeInfoBuilder() + .with({ chainId, modules: [addressExBuilder().build()] }) + .build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + const chain = chainBuilder().build() + mockUseCurrentChain.mockReturnValue(chain) + const delayModifiers = [{}] + mockGetDelayModifiers.mockResolvedValue(delayModifiers as any) + + function Test() { + const { refetch } = useContext(RecoveryLoaderContext) + + return + } + + const { queryByText } = render( + + + , + ) + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + expect(mockGetRecoveryState).toHaveBeenCalledTimes(1) + }) + + act(() => { + fireEvent.click(queryByText('Refetch')!) + }) + + await waitFor(() => { + expect(mockGetRecoveryState).toHaveBeenCalledTimes(2) + }) + + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + }) + + it('should refetch when interacting with a Delay Modifier', async () => { + mockUseHasFeature.mockReturnValue(true) + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const chainId = '5' + const safe = safeInfoBuilder() + .with({ chainId, modules: [addressExBuilder().build()] }) + .build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + const chain = chainBuilder().build() + mockUseCurrentChain.mockReturnValue(chain) + const delayModifierAddress = faker.finance.ethereumAddress() + mockGetDelayModifiers.mockResolvedValue([{ address: delayModifierAddress } as any]) + + render( + + <> + , + ) + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + expect(mockGetRecoveryState).toHaveBeenCalledTimes(1) + }) + + act(() => { + txDispatch(TxEvent.PROCESSED, { + txId: faker.string.alphanumeric(), + safeAddress: faker.finance.ethereumAddress(), + to: delayModifierAddress, + }) + }) + + await waitFor(() => { + expect(mockGetRecoveryState).toHaveBeenCalledTimes(2) + }) + }) + }) + + describe('useDelayModifiers', () => { + it('should not fetch if the current chain does not support Delay Modifiers', async () => { + jest.useFakeTimers() + + mockUseHasFeature.mockReturnValue(false) + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const safe = safeInfoBuilder().build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + const { result } = renderHook(() => _useDelayModifiers()) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current).toEqual([undefined, undefined, false]) + expect(mockGetDelayModifiers).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should not fetch is there is no provider', async () => { + jest.useFakeTimers() + + mockUseHasFeature.mockReturnValue(true) + mockUseWeb3ReadOnly.mockReturnValue(undefined) + const safe = safeInfoBuilder().build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + const { result } = renderHook(() => _useDelayModifiers()) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current).toEqual([undefined, undefined, false]) + expect(mockGetDelayModifiers).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should not fetch if there is no Safe modules enabled', async () => { + jest.useFakeTimers() + + mockUseHasFeature.mockReturnValue(true) + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const safe = safeInfoBuilder().with({ modules: [] }).build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + const { result } = renderHook(() => _useDelayModifiers()) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current).toEqual([undefined, undefined, false]) + expect(mockGetDelayModifiers).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should not fetch if only the spending limit is enabled', async () => { + jest.useFakeTimers() + + mockUseHasFeature.mockReturnValue(true) + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const chainId = '5' + const safe = safeInfoBuilder() + .with({ chainId, modules: [{ value: getSpendingLimitModuleAddress(chainId)! }] }) + .build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + const { result } = renderHook(() => _useDelayModifiers()) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current).toEqual([undefined, undefined, false]) + expect(mockGetDelayModifiers).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should otherwise fetch', async () => { + mockUseHasFeature.mockReturnValue(true) + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const chainId = '5' + const safe = safeInfoBuilder() + .with({ chainId, modules: [addressExBuilder().build()] }) + .build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + renderHook(() => _useDelayModifiers()) + + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + }) + }) + + describe('useRecoveryState', () => { + it('should not fetch if there are no Delay Modifiers', async () => { + jest.useFakeTimers() + + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const chain = chainBuilder().build() + mockUseCurrentChain.mockReturnValue(chain) + const safe = safeInfoBuilder().build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + const { result } = renderHook(() => _useRecoveryState()) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current.data).toEqual([undefined, undefined, false]) + expect(mockGetRecoveryState).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should not fetch if there is no Transaction Service', async () => { + jest.useFakeTimers() + + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + mockUseCurrentChain.mockReturnValue(undefined) + const safe = safeInfoBuilder().build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + const delayModifiers = [{}] + + const { result } = renderHook(() => _useRecoveryState(delayModifiers as any)) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current.data).toEqual([undefined, undefined, false]) + expect(mockGetRecoveryState).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should not fetch is there is no provider', async () => { + jest.useFakeTimers() + + mockUseWeb3ReadOnly.mockReturnValue(undefined) + const chain = chainBuilder().build() + mockUseCurrentChain.mockReturnValue(chain) + const safe = safeInfoBuilder().build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + + const { result } = renderHook(() => _useRecoveryState([{} as any])) + + // Give enough time for loading to occur, if it will + await act(async () => { + jest.advanceTimersByTime(10) + }) + + expect(result.current.data).toEqual([undefined, undefined, false]) + expect(mockGetRecoveryState).not.toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('should otherwise fetch', async () => { + const provider = {} + mockUseWeb3ReadOnly.mockReturnValue(provider as any) + const chain = chainBuilder().build() + mockUseCurrentChain.mockReturnValue(chain) + const safe = safeInfoBuilder().build() + const safeInfo = { safe, safeAddress: safe.address.value } + mockUseSafeInfo.mockReturnValue(safeInfo as any) + const delayModifiers = [{}] + + renderHook(() => _useRecoveryState(delayModifiers as any)) + + await waitFor(() => { + expect(mockGetRecoveryState).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/src/components/recovery/RecoveryLoaderContext/index.tsx b/src/components/recovery/RecoveryLoaderContext/index.tsx new file mode 100644 index 0000000000..f0fe333336 --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/index.tsx @@ -0,0 +1,151 @@ +import { createContext, useCallback, useEffect, useState } from 'react' +import type { ReactElement, ReactNode } from 'react' +import type { Delay } from '@gnosis.pm/zodiac' + +import { getDelayModifiers } from '@/services/recovery/delay-modifier' +import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' +import { FEATURES } from '@/utils/chains' +import useAsync from '@/hooks/useAsync' +import { useCurrentChain, useHasFeature } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import useIntervalCounter from '@/hooks/useIntervalCounter' +import { getRecoveryState } from '@/services/recovery/recovery-state' +import { recoverySlice } from '@/store/recoverySlice' +import { TxEvent, txSubscribe } from '@/services/tx/txEvents' +import { sameAddress } from '@/utils/addresses' +import { useUpdateStore } from '@/hooks/useLoadableStores' +import type { AsyncResult } from '@/hooks/useAsync' +import type { RecoveryState } from '@/store/recoverySlice' + +const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes + +export const RecoveryLoaderContext = createContext<{ + refetch: () => void +}>({ + refetch: () => {}, +}) + +export function RecoveryLoaderProvider({ children }: { children: ReactNode }): ReactElement { + const [delayModifiers, delayModifiersError, delayModifiersLoading] = _useDelayModifiers() + const { + data: [recoveryState, recoveryStateError, recoveryStateLoading], + refetch, + } = _useRecoveryState(delayModifiers) + + // Reload recovery data when a Delay Modifier is interacted with + useEffect(() => { + if (!delayModifiers) { + return + } + + return txSubscribe(TxEvent.PROCESSED, (detail) => { + // TODO: Disabling Delay Modifier should also reload recovery data + // after https://github.com/safe-global/safe-wallet-web/pull/2848 is merged + + // TODO: This won't pick up relayed transactions as we don't dispatch `to` with them + // May require complex refactor of txEvents service as we don't have `to` readily available + const isDelayModifierTx = delayModifiers.some((delayModifier) => sameAddress(delayModifier.address, detail.to)) + if (isDelayModifierTx) { + refetch() + } + }) + }, [delayModifiers, refetch]) + + // Update store with latest recovery data + const useLoadHook = useCallback( + (): AsyncResult => [ + recoveryState, + delayModifiersError ?? recoveryStateError, + delayModifiersLoading ?? recoveryStateLoading, + ], + [delayModifiersError, delayModifiersLoading, recoveryState, recoveryStateError, recoveryStateLoading], + ) + + useUpdateStore(recoverySlice, useLoadHook) + + return {children} +} + +export function _useDelayModifiers() { + const supportsRecovery = useHasFeature(FEATURES.RECOVERY) + const web3ReadOnly = useWeb3ReadOnly() + const { safe, safeAddress } = useSafeInfo() + + return useAsync>( + () => { + if (!supportsRecovery || !web3ReadOnly || !safe.modules || safe.modules.length === 0) { + return + } + + // Don't fetch if only spending limit module is enabled + const isOnlySpendingLimit = + safe.modules.length === 1 && safe.modules[0].value === getSpendingLimitModuleAddress(safe.chainId) + + if (isOnlySpendingLimit) { + return + } + + // TODO: Don't fetch _every_ Delay Modifier, but only those which _don't_ have Zodiac + // contracts as guardians. Zodiac only use the Delay Modifier with their contracts enabled + return getDelayModifiers(safe.chainId, safe.modules, web3ReadOnly) + }, + // Need to check length of modules array to prevent new request every time Safe info polls + // eslint-disable-next-line react-hooks/exhaustive-deps + [safeAddress, safe.chainId, safe.modules?.length, web3ReadOnly, supportsRecovery], + false, + ) +} + +export function _useRecoveryState(delayModifiers?: Array): { + data: AsyncResult + refetch: () => void +} { + const web3ReadOnly = useWeb3ReadOnly() + const chain = useCurrentChain() + const { safe, safeAddress } = useSafeInfo() + + // Reload recovery data every REFRESH_DELAY + const [counter] = useIntervalCounter(REFRESH_DELAY) + + // Reload recovery data when manually triggered + const [refetchDep, setRefetchDep] = useState(false) + const refetch = useCallback(() => { + setRefetchDep((prev) => !prev) + }, []) + + const data = useAsync( + () => { + if (!delayModifiers || delayModifiers.length === 0 || !chain?.transactionService || !web3ReadOnly) { + return + } + + return Promise.all( + delayModifiers.map((delayModifier) => + getRecoveryState({ + delayModifier, + 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, + ) + + return { data, refetch } +} diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index 3ab0044252..b08b26428d 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -14,7 +14,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { AppRoutes } from '@/config/routes' import useTxQueue from '@/hooks/useTxQueue' import { useAppSelector } from '@/store' -import { selectAllRecoveryQueues } from '@/store/recoverySlice' +import { selectRecoveryQueues } from '@/store/recoverySlice' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] @@ -25,7 +25,7 @@ const Navigation = (): ReactElement => { const { safe } = useSafeInfo() const currentSubdirectory = getSubdirectory(router.pathname) const hasQueuedTxs = Boolean(useTxQueue().page?.results.length) - const hasRecoveryTxs = Boolean(useAppSelector(selectAllRecoveryQueues).length) + const hasRecoveryTxs = Boolean(useAppSelector(selectRecoveryQueues).length) // Indicate whether the current Safe needs an upgrade const setupItem = navItems.find((item) => item.href === AppRoutes.settings.setup) diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index 4bd5aaabf6..761bbc019b 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -25,6 +25,7 @@ import { TxModalContext } from '../..' import { asError } from '@/services/exceptions/utils' import { trackError, Errors } from '@/services/exceptions' import { getCountdown } from '@/utils/date' +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' import type { RecoverAccountFlowProps } from '.' import commonCss from '@/components/tx-flow/common/styles.module.css' @@ -42,6 +43,7 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const wallet = useWallet() const onboard = useOnboard() const recovery = useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) + const { refetch } = useContext(RecoveryLoaderContext) // Proposal const txCooldown = recovery?.txCooldown?.toNumber() @@ -71,7 +73,14 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo setSubmitError(undefined) try { - await dispatchRecoveryProposal({ onboard, safe, newThreshold, newOwners, delayModifierAddress: recovery.address }) + await dispatchRecoveryProposal({ + onboard, + safe, + newThreshold, + newOwners, + delayModifierAddress: recovery.address, + refetchRecoveryData: refetch, + }) } catch (_err) { const err = asError(_err) trackError(Errors._810, err) diff --git a/src/hooks/__tests__/useLoadRecovery.test.ts b/src/hooks/__tests__/useLoadRecovery.test.ts deleted file mode 100644 index 6b8c8e6fcb..0000000000 --- a/src/hooks/__tests__/useLoadRecovery.test.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { getDelayModifiers } from '@/services/recovery/delay-modifier' -import { faker } from '@faker-js/faker' -import { BigNumber } from 'ethers' -import type { JsonRpcProvider } from '@ethersproject/providers' -import type { Delay, TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' -import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' - -import useLoadRecovery from '../loadables/useLoadRecovery' -import { useCurrentChain, useHasFeature } from '../useChains' -import useSafeInfo from '../useSafeInfo' -import { useWeb3ReadOnly } from '../wallets/web3' -import { renderHook, waitFor } from '@testing-library/react' -import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' -import { _getSafeCreationReceipt } from '@/services/recovery/recovery-state' - -const setupFetchStub = (data: any) => (_url: string) => { - return Promise.resolve({ - json: () => Promise.resolve(data), - status: 200, - ok: true, - }) -} - -// TODO: Condense test to only check loading logic as `recovery-state.test.ts` covers most - -jest.mock('@/hooks/useSafeInfo') -jest.mock('@/hooks/wallets/web3') -jest.mock('@/hooks/useChains') -jest.mock('@/services/recovery/delay-modifier') - -const mockUseSafeInfo = useSafeInfo as jest.MockedFunction -const mockUseCurrentChain = useCurrentChain as jest.MockedFunction -const mockUseWeb3ReadOnly = useWeb3ReadOnly as jest.MockedFunction -const mockUseHasFeature = useHasFeature as jest.MockedFunction -const mockGetDelayModifiers = getDelayModifiers as jest.MockedFunction - -describe('useLoadRecovery', () => { - beforeEach(() => { - jest.clearAllMocks() - - // _getSafeCreationReceipt - _getSafeCreationReceipt.cache.clear?.() - - global.fetch = jest.fn().mockImplementation(setupFetchStub({ transactionHash: `0x${faker.string.hexadecimal()}` })) - }) - - it('should return the recovery state', async () => { - const safeAddress = faker.finance.ethereumAddress() - - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress, - safe: { - chainId: faker.string.numeric(), - modules: [ - { - value: faker.finance.ethereumAddress(), - }, - ], - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const from = faker.finance.ethereumAddress() - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - const delayModules = [faker.finance.ethereumAddress()] - const txExpiration = BigNumber.from(0) - const txCooldown = BigNumber.from(69420) - const txNonce = BigNumber.from(2) - const queueNonce = BigNumber.from(3) - const transactionsAdded = [ - { - args: { - to: safeAddress, - queueNonce: BigNumber.from(1), - data: '0x', - }, - } as unknown, - { - args: { - to: safeAddress, - queueNonce: BigNumber.from(2), - data: '0x', - }, - } as unknown, - { - args: { - to: faker.finance.ethereumAddress(), - queueNonce: BigNumber.from(3), - data: '0x', - }, - } as unknown, - ] as Array - const delayModifier = { - filters: { - TransactionAdded: () => ({}), - }, - address: faker.finance.ethereumAddress(), - getModulesPaginated: () => Promise.resolve([delayModules]), - txExpiration: () => Promise.resolve(txExpiration), - txCooldown: () => Promise.resolve(txCooldown), - txNonce: () => Promise.resolve(txNonce), - txCreatedAt: jest - .fn() - .mockResolvedValueOnce(BigNumber.from(69)) - .mockResolvedValueOnce(BigNumber.from(420)) - .mockResolvedValueOnce(BigNumber.from(69420)), - queueNonce: () => Promise.resolve(queueNonce), - queryFilter: () => Promise.resolve(transactionsAdded), - } as unknown as Delay - mockGetDelayModifiers.mockResolvedValue([delayModifier]) - - const { result } = renderHook(() => useLoadRecovery()) - - // Loading - expect(result.current).toStrictEqual([undefined, undefined, true]) - - // Loaded - await waitFor(() => { - expect(result.current).toStrictEqual([ - [ - { - address: delayModifier.address, - guardians: delayModules, - txExpiration, - txCooldown, - txNonce, - queueNonce, - queue: [ - { - ...transactionsAdded[0], - timestamp: BigNumber.from(69).mul(1_000), - validFrom: BigNumber.from(69).add(txCooldown).mul(1_000), - expiresAt: null, - isMalicious: false, - executor: from, - }, - { - ...transactionsAdded[1], - timestamp: BigNumber.from(420).mul(1_000), - validFrom: BigNumber.from(420).add(txCooldown).mul(1_000), - expiresAt: null, - isMalicious: false, - executor: from, - }, - { - ...transactionsAdded[2], - timestamp: BigNumber.from(69420).mul(1_000), - validFrom: BigNumber.from(69420).add(txCooldown).mul(1_000), - expiresAt: null, - isMalicious: true, - executor: from, - }, - ], - }, - ], - undefined, - false, - ]) - }) - }) - - it('should fetch the recovery state again if the Safe address changes', async () => { - // useSafeInfo - const safeAddress1 = faker.finance.ethereumAddress() - const chainId = faker.string.numeric() - const modules = [ - { - value: faker.finance.ethereumAddress(), - }, - ] - mockUseSafeInfo.mockReturnValue({ - safeAddress: safeAddress1, - safe: { - chainId, - modules, - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - mockGetDelayModifiers.mockImplementation(jest.fn()) - - const { rerender } = renderHook(() => useLoadRecovery()) - - expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) - - // Safe address changes - const safeAddress2 = faker.finance.ethereumAddress() - mockUseSafeInfo.mockReturnValue({ - safeAddress: safeAddress2, - safe: { - chainId, - modules, - }, - } as ReturnType) - - rerender() - - await waitFor(() => { - expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) - }) - }) - - it('should fetch the recovery state again if the chain changes', async () => { - // useSafeInfo - const safeAddress = faker.finance.ethereumAddress() - const chainId1 = faker.string.numeric() - const modules = [ - { - value: faker.finance.ethereumAddress(), - }, - ] - mockUseSafeInfo.mockReturnValue({ - safeAddress, - safe: { - chainId: chainId1, - modules, - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - mockGetDelayModifiers.mockImplementation(jest.fn()) - - const { rerender } = renderHook(() => useLoadRecovery()) - - expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) - - // Chain changes - const chainId2 = faker.string.numeric({ exclude: chainId1 }) - mockUseSafeInfo.mockReturnValue({ - safeAddress, - safe: { - chainId: chainId2, - modules, - }, - } as ReturnType) - - rerender() - - await waitFor(() => { - expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) - }) - }) - - it('should fetch the recovery state again if the enabled modules change', async () => { - // useSafeInfo - const safeAddress = faker.finance.ethereumAddress() - const chainId = faker.string.numeric() - const modules1 = [ - { - value: faker.finance.ethereumAddress(), - }, - ] - mockUseSafeInfo.mockReturnValue({ - safeAddress, - safe: { - chainId, - modules: modules1, - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - mockGetDelayModifiers.mockImplementation(jest.fn()) - - const { rerender } = renderHook(() => useLoadRecovery()) - - expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) - - // Modules changes (module is added) - const modules2 = [ - ...modules1, - { - value: faker.finance.ethereumAddress(), - }, - ] - mockUseSafeInfo.mockReturnValue({ - safeAddress, - safe: { - chainId, - modules: modules2, - }, - } as ReturnType) - - rerender() - - await waitFor(() => { - expect(mockGetDelayModifiers).toHaveBeenCalledTimes(2) - }) - }) - - it.skip('should poll the recovery state every 5 minutes', async () => { - jest.useFakeTimers() - - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId: faker.string.numeric(), - modules: [ - { - value: faker.finance.ethereumAddress(), - }, - ], - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: () => - jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - const delayModules = [faker.finance.ethereumAddress()] - const txExpiration = BigNumber.from(0) - const txCooldown = BigNumber.from(69420) - const txNonce = BigNumber.from(2) - const queueNonce = BigNumber.from(3) - const transactionsAdded = [ - { - args: { - queueNonce: BigNumber.from(1), - data: '0x', - }, - } as unknown, - { - args: { - queueNonce: BigNumber.from(2), - data: '0x', - }, - } as unknown, - { - args: { - queueNonce: BigNumber.from(3), - data: '0x', - }, - } as unknown, - ] as Array - const delayModifier = { - filters: { - TransactionAdded: () => ({}), - }, - address: faker.finance.ethereumAddress(), - getModulesPaginated: () => Promise.resolve([delayModules]), - txExpiration: () => Promise.resolve(txExpiration), - txCooldown: () => Promise.resolve(txCooldown), - txNonce: () => Promise.resolve(txNonce), - txCreatedAt: jest - .fn() - .mockResolvedValueOnce(BigNumber.from(69)) - .mockResolvedValueOnce(BigNumber.from(420)) - .mockResolvedValueOnce(BigNumber.from(69420)), - queueNonce: () => Promise.resolve(queueNonce), - queryFilter: () => Promise.resolve(transactionsAdded), - } as unknown as Delay - mockGetDelayModifiers.mockResolvedValue([delayModifier]) - - const { result } = renderHook(() => useLoadRecovery()) - - await waitFor(() => { - expect(result.current[0]).toBeDefined() - }) - - const firstPoll = result.current[0] - - jest.advanceTimersByTime(5 * 60 * 1_000) // 5m - - await waitFor(() => { - expect(result.current[0] === firstPoll).toBe(false) - }) - - jest.useRealTimers() - }) - - it('should not return the recovery state if the chain does not support recovery', async () => { - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId: faker.string.numeric(), - modules: [ - { - value: faker.finance.ethereumAddress(), - }, - ], - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(false) // Does not support recovery - - renderHook(() => useLoadRecovery()) - - await waitFor(() => { - expect(mockGetDelayModifiers).not.toHaveBeenCalled() - }) - }) - - it('should not return the recovery state if there is no provider', async () => { - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId: faker.string.numeric(), - modules: [ - { - value: faker.finance.ethereumAddress(), - }, - ], - }, - } as ReturnType) - - // useWeb3ReadOnly - mockUseWeb3ReadOnly.mockReturnValue(undefined) // No provider - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - renderHook(() => useLoadRecovery()) - - await waitFor(() => { - expect(mockGetDelayModifiers).not.toHaveBeenCalled() - }) - }) - - it('should not return the recovery state if the Safe has no modules', async () => { - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId: faker.string.numeric(), - modules: [], // No modules enabled - }, - } as unknown as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - renderHook(() => useLoadRecovery()) - - await waitFor(() => { - expect(mockGetDelayModifiers).not.toHaveBeenCalled() - }) - }) - - it('should not check for delay modifiers if only the spending limit module is enabled', async () => { - const chainId = faker.string.numeric() - - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId, - modules: [ - { - value: getSpendingLimitModuleAddress(chainId), - }, - ], // Only spending limit module enabled - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - renderHook(() => useLoadRecovery()) - - await waitFor(() => { - expect(mockGetDelayModifiers).not.toHaveBeenCalled() - }) - }) - - it('should not return the recovery state if no delay modifier is enabled', async () => { - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId: faker.string.numeric(), - modules: [ - { - value: faker.finance.ethereumAddress(), - }, - ], - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue({ - transactionService: faker.internet.url({ appendSlash: false }), - } as ChainInfo) - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - mockGetDelayModifiers.mockResolvedValue([]) // No Delay Modifiers - - const { result } = renderHook(() => useLoadRecovery()) - - // Loading - expect(result.current).toStrictEqual([undefined, undefined, true]) - - // Loaded - await waitFor(() => { - expect(result.current).toStrictEqual([undefined, undefined, false]) - }) - }) - - it('should not fetch the recovery state if no transaction service is available', async () => { - // useSafeInfo - mockUseSafeInfo.mockReturnValue({ - safeAddress: faker.finance.ethereumAddress(), - safe: { - chainId: faker.string.numeric(), - modules: [ - { - value: faker.finance.ethereumAddress(), - }, - ], - }, - } as ReturnType) - - // useCurrentChain - mockUseCurrentChain.mockReturnValue(undefined) // No transaction service - - // useWeb3ReadOnly - const provider = { - getTransactionReceipt: jest - .fn() - .mockResolvedValueOnce({ blockHash: `0x${faker.string.hexadecimal}` }) - .mockResolvedValue({ from: faker.finance.ethereumAddress() }), - } as unknown as JsonRpcProvider - mockUseWeb3ReadOnly.mockReturnValue(provider) - - // useHasFeature - mockUseHasFeature.mockReturnValue(true) - - // getDelayModifiers - const delayModules = [faker.finance.ethereumAddress()] - const txExpiration = BigNumber.from(0) - const txCooldown = BigNumber.from(69420) - const txNonce = BigNumber.from(2) - const queueNonce = BigNumber.from(3) - const transactionsAdded = [ - { - args: { - queueNonce: BigNumber.from(1), - }, - } as unknown, - { - args: { - queueNonce: BigNumber.from(2), - }, - } as unknown, - { - args: { - queueNonce: BigNumber.from(3), - }, - } as unknown, - ] as Array - const delayModifier = { - filters: { - TransactionAdded: () => ({}), - }, - address: faker.finance.ethereumAddress(), - getModulesPaginated: () => Promise.resolve([delayModules]), - txExpiration: () => Promise.resolve(txExpiration), - txCooldown: () => Promise.resolve(txCooldown), - txNonce: () => Promise.resolve(txNonce), - queueNonce: () => Promise.resolve(queueNonce), - queryFilter: () => Promise.resolve(transactionsAdded), - } as unknown as Delay - mockGetDelayModifiers.mockResolvedValue([delayModifier]) - - const { result } = renderHook(() => useLoadRecovery()) - - // Loading - expect(result.current).toStrictEqual([undefined, undefined, true]) - - // Loaded - await waitFor(() => { - expect(result.current).toStrictEqual([undefined, undefined, false]) - }) - }) -}) diff --git a/src/hooks/loadables/useLoadRecovery.ts b/src/hooks/loadables/useLoadRecovery.ts deleted file mode 100644 index 530903a3ef..0000000000 --- a/src/hooks/loadables/useLoadRecovery.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Delay } from '@gnosis.pm/zodiac' - -import { getDelayModifiers } from '@/services/recovery/delay-modifier' -import { getRecoveryState } from '@/services/recovery/recovery-state' -import useAsync from '../useAsync' -import useSafeInfo from '../useSafeInfo' -import { useWeb3ReadOnly } from '../wallets/web3' -import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' -import useIntervalCounter from '../useIntervalCounter' -import { useCurrentChain, useHasFeature } from '../useChains' -import { FEATURES } from '@/utils/chains' -import type { AsyncResult } from '../useAsync' -import type { RecoveryState } from '@/store/recoverySlice' - -const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes - -const useLoadRecovery = (): AsyncResult => { - const { safe, safeAddress } = useSafeInfo() - const chain = useCurrentChain() - const web3ReadOnly = useWeb3ReadOnly() - const [counter] = useIntervalCounter(REFRESH_DELAY) - const supportsRecovery = useHasFeature(FEATURES.RECOVERY) - - const [delayModifiers, delayModifiersError, delayModifiersLoading] = useAsync>( - () => { - if (!supportsRecovery || !web3ReadOnly || !safe.modules || safe.modules.length === 0) { - return - } - - const isOnlySpendingLimit = - safe.modules.length === 1 && safe.modules[0].value === getSpendingLimitModuleAddress(safe.chainId) - - if (isOnlySpendingLimit) { - return - } - - return getDelayModifiers(safe.chainId, safe.modules, web3ReadOnly) - }, - // Need to check length of modules array to prevent new request every time Safe info polls - // eslint-disable-next-line react-hooks/exhaustive-deps - [safeAddress, safe.chainId, safe.modules?.length, web3ReadOnly, supportsRecovery], - false, - ) - - const [recoveryState, recoveryStateError, recoveryStateLoading] = useAsync( - () => { - if (!delayModifiers || delayModifiers.length === 0 || !chain?.transactionService || !web3ReadOnly) { - return - } - - return Promise.all( - delayModifiers.map((delayModifier) => - getRecoveryState({ - delayModifier, - transactionService: chain.transactionService, - safeAddress, - provider: web3ReadOnly, - chainId: safe.chainId, - version: safe.version, - }), - ), - ) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [delayModifiers, counter, chain?.transactionService, web3ReadOnly, safeAddress, safe.chainId, safe.version], - false, - ) - - return [recoveryState, delayModifiersError || recoveryStateError, delayModifiersLoading || recoveryStateLoading] -} - -export default useLoadRecovery diff --git a/src/hooks/useLoadableStores.ts b/src/hooks/useLoadableStores.ts index b49a02de03..95c3045502 100644 --- a/src/hooks/useLoadableStores.ts +++ b/src/hooks/useLoadableStores.ts @@ -10,7 +10,6 @@ import useLoadBalances from './loadables/useLoadBalances' import useLoadTxHistory from './loadables/useLoadTxHistory' import useLoadTxQueue from './loadables/useLoadTxQueue' import useLoadMessages from './loadables/useLoadSafeMessages' -import useLoadRecovery from './loadables/useLoadRecovery' // Import all the loadable slices import { chainsSlice } from '@/store/chainsSlice' @@ -21,10 +20,9 @@ import { txQueueSlice } from '@/store/txQueueSlice' import { spendingLimitSlice } from '@/store/spendingLimitsSlice' import useLoadSpendingLimits from '@/hooks/loadables/useLoadSpendingLimits' import { safeMessagesSlice } from '@/store/safeMessagesSlice' -import { recoverySlice } from '@/store/recoverySlice' // Dispatch into the corresponding store when the loadable is loaded -const useUpdateStore = (slice: Slice, useLoadHook: () => AsyncResult): void => { +export const useUpdateStore = (slice: Slice, useLoadHook: () => AsyncResult): void => { const dispatch = useAppDispatch() const [data, error, loading] = useLoadHook() const setAction = slice.actions.set @@ -48,7 +46,6 @@ const useLoadableStores = () => { useUpdateStore(txQueueSlice, useLoadTxQueue) useUpdateStore(safeMessagesSlice, useLoadMessages) useUpdateStore(spendingLimitSlice, useLoadSpendingLimits) - useUpdateStore(recoverySlice, useLoadRecovery) } export default useLoadableStores diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e7b3d6d3d7..c2fb947cef 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -44,6 +44,7 @@ import useABTesting from '@/services/tracking/useAbTesting' import { AbTest } from '@/services/tracking/abTesting' import { useNotificationTracking } from '@/components/settings/PushNotifications/hooks/useNotificationTracking' import MobilePairingModal from '@/services/pairing/QRModal' +import { RecoveryLoaderProvider } from '@/components/recovery/RecoveryLoaderContext' const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING @@ -83,9 +84,11 @@ export const AppProviders = ({ children }: { children: ReactNode | ReactNode[] } {(safeTheme: Theme) => ( - - {children} - + + + {children} + + )} diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 72dee23f94..549fe9d602 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -163,14 +163,14 @@ export const dispatchTxExecution = async ( if (didRevert(receipt)) { txDispatch(TxEvent.REVERTED, { ...eventParams, error: new Error('Transaction reverted by EVM') }) } else { - txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) + txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress, to: safeTx.data.to }) } }) .catch((err) => { const error = err as EthersError if (didReprice(error)) { - txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) + txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress, to: safeTx.data.to }) } else { txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) } @@ -224,11 +224,12 @@ export const dispatchBatchExecution = async ( }) }) } else { - txs.forEach(({ txId }) => { + txs.forEach(({ txId, txData }) => { txDispatch(TxEvent.PROCESSED, { txId, groupKey, safeAddress, + to: txData?.to.value, }) }) } @@ -237,8 +238,12 @@ export const dispatchBatchExecution = async ( const error = err as EthersError if (didReprice(error)) { - txs.forEach(({ txId }) => { - txDispatch(TxEvent.PROCESSED, { txId, safeAddress }) + txs.forEach(({ txId, txData }) => { + txDispatch(TxEvent.PROCESSED, { + txId, + safeAddress, + to: txData?.to.value, + }) }) } else { txs.forEach(({ txId }) => { @@ -300,7 +305,7 @@ export const dispatchSpendingLimitTxExecution = async ( error: new Error('Transaction reverted by EVM'), }) } else { - txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress }) + txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress, to: txParams.to }) } }) .catch((error) => { @@ -405,18 +410,34 @@ export const dispatchBatchExecutionRelay = async ( ) } +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, newThreshold, newOwners, delayModifierAddress, + refetchRecoveryData, }: { onboard: OnboardAPI safe: SafeInfo newThreshold: number newOwners: Array delayModifierAddress: string + refetchRecoveryData: () => void }) { const wallet = await assertWalletChain(onboard, safe.chainId) const provider = createWeb3(wallet.provider) @@ -430,7 +451,13 @@ export async function dispatchRecoveryProposal({ const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider) const signer = provider.getSigner() - await delayModifier.connect(signer).execTransactionFromModule(to, value, data, OperationType.Call) + + delayModifier + .connect(signer) + .execTransactionFromModule(to, value, data, OperationType.Call) + .then((result) => { + reloadRecoveryDataAfterProcessed(result, refetchRecoveryData) + }) } export async function dispatchRecoveryExecution({ @@ -438,11 +465,13 @@ export async function dispatchRecoveryExecution({ 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) @@ -450,5 +479,11 @@ export async function dispatchRecoveryExecution({ const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider) const signer = provider.getSigner() - await delayModifier.connect(signer).executeNextTx(args.to, args.value, args.data, args.operation) + + delayModifier + .connect(signer) + .executeNextTx(args.to, args.value, args.data, args.operation) + .then((result) => { + reloadRecoveryDataAfterProcessed(result, refetchRecoveryData) + }) } diff --git a/src/services/tx/txEvents.ts b/src/services/tx/txEvents.ts index 2de1612d31..aeef8aeef8 100644 --- a/src/services/tx/txEvents.ts +++ b/src/services/tx/txEvents.ts @@ -38,7 +38,7 @@ interface TxEvents { [TxEvent.EXECUTING]: Id [TxEvent.PROCESSING]: Id & { txHash: string } [TxEvent.PROCESSING_MODULE]: Id & { txHash: string } - [TxEvent.PROCESSED]: Id & { safeAddress: string } + [TxEvent.PROCESSED]: Id & { safeAddress: string; to?: string } [TxEvent.REVERTED]: Id & { error: Error } [TxEvent.RELAYING]: Id & { taskId: string } [TxEvent.FAILED]: Id & { error: Error } diff --git a/src/tests/builders/safe.ts b/src/tests/builders/safe.ts index f4f3a1b21e..d1b6fbf6c5 100644 --- a/src/tests/builders/safe.ts +++ b/src/tests/builders/safe.ts @@ -10,7 +10,7 @@ import type { IBuilder } from '../Builder' const MAX_OWNERS_LENGTH = 10 -function addressExBuilder(): IBuilder { +export function addressExBuilder(): IBuilder { return Builder.new().with({ value: checksumAddress(faker.finance.ethereumAddress()), name: faker.word.words(),