From ae6d1072b19e2e1ca5b2b9b1cda82692ac269b95 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 22 Nov 2023 18:15:44 +0100 Subject: [PATCH 1/3] 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(), From f982e6804315266b205f0cd3fde2c469f574774a Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 23 Nov 2023 10:07:36 +0100 Subject: [PATCH 2/3] refactor: remove slice + move state to context --- .../dashboard/RecoveryHeader/index.test.tsx | 2 +- .../dashboard/RecoveryHeader/index.tsx | 2 +- .../recovery/ExecuteRecoveryButton/index.tsx | 2 +- .../RecoveryCards/RecoveryInProgressCard.tsx | 2 +- .../__tests__/RecoveryInProgressCard.test.tsx | 2 +- .../recovery/RecoveryDetails/index.tsx | 2 +- .../recovery/RecoveryList/index.tsx | 5 +- .../recovery/RecoveryListItem/index.tsx | 2 +- .../__tests__/index.test.tsx | 127 +++++++ .../__tests__/useDelayModifier.test.ts | 136 ++++++++ .../__tests__/useRecoveryState.test.ts | 114 +++++++ .../RecoveryLoaderContext/index.test.tsx | 321 ------------------ .../recovery/RecoveryLoaderContext/index.tsx | 170 +++------- .../useDelayModifiers.ts | 42 +++ .../RecoveryLoaderContext/useRecoveryState.ts | 60 ++++ .../recovery/RecoveryModal/index.test.tsx | 2 +- .../recovery/RecoveryModal/index.tsx | 2 +- .../recovery/RecoverySigners/index.tsx | 2 +- .../recovery/RecoveryStatus/index.tsx | 2 +- .../recovery/RecoverySummary/index.tsx | 2 +- .../recovery/SkipRecoveryButton/index.tsx | 2 +- .../Recovery/ConfirmRemoveRecoveryModal.tsx | 4 +- .../settings/Recovery/DelayModifierRow.tsx | 4 +- src/components/settings/Recovery/index.tsx | 13 +- src/components/settings/SafeModules/index.tsx | 8 +- .../sidebar/SidebarNavigation/index.tsx | 5 +- .../tx-flow/common/OwnerList/index.tsx | 4 +- .../RecoverAccountFlowReview.tsx | 6 +- .../tx-flow/flows/RemoveRecovery/index.tsx | 4 +- .../SkipRecovery/SkipRecoveryFlowReview.tsx | 2 +- .../tx-flow/flows/SkipRecovery/index.tsx | 2 +- .../__tests__/useRecoveryTxState.test.ts | 2 +- src/hooks/useIsGuardian.ts | 9 +- src/hooks/useIsRecoveryEnabled.ts | 9 +- src/hooks/useLoadableStores.ts | 2 +- src/hooks/useRecoveryQueue.ts | 15 +- src/hooks/useRecoveryTxState.ts | 11 +- .../recovery/__tests__/recovery-state.test.ts | 6 +- .../recovery/__tests__/selectors.test.ts} | 66 ++-- src/services/recovery/recovery-state.ts | 20 +- src/services/recovery/selectors.ts | 34 ++ src/services/recovery/transaction.ts | 2 +- src/services/tx/tx-sender/dispatch.ts | 12 +- src/services/tx/txEvents.ts | 2 +- src/store/index.ts | 2 - src/store/recoverySlice.ts | 59 ---- 46 files changed, 685 insertions(+), 619 deletions(-) create mode 100644 src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx create mode 100644 src/components/recovery/RecoveryLoaderContext/__tests__/useDelayModifier.test.ts create mode 100644 src/components/recovery/RecoveryLoaderContext/__tests__/useRecoveryState.test.ts delete mode 100644 src/components/recovery/RecoveryLoaderContext/index.test.tsx create mode 100644 src/components/recovery/RecoveryLoaderContext/useDelayModifiers.ts create mode 100644 src/components/recovery/RecoveryLoaderContext/useRecoveryState.ts rename src/{store/__tests__/recoverySlice.test.ts => services/recovery/__tests__/selectors.test.ts} (61%) create mode 100644 src/services/recovery/selectors.ts delete mode 100644 src/store/recoverySlice.ts diff --git a/src/components/dashboard/RecoveryHeader/index.test.tsx b/src/components/dashboard/RecoveryHeader/index.test.tsx index fe89e17587..7ea4106633 100644 --- a/src/components/dashboard/RecoveryHeader/index.test.tsx +++ b/src/components/dashboard/RecoveryHeader/index.test.tsx @@ -2,7 +2,7 @@ import { BigNumber } from 'ethers' import { _RecoveryHeader } from '.' import { render } from '@/tests/test-utils' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' describe('RecoveryHeader', () => { it('should not render a widget if the chain does not support recovery', () => { diff --git a/src/components/dashboard/RecoveryHeader/index.tsx b/src/components/dashboard/RecoveryHeader/index.tsx index 533b8aae2a..6320797ccc 100644 --- a/src/components/dashboard/RecoveryHeader/index.tsx +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -10,7 +10,7 @@ import { RecoveryProposalCard } from '@/components/recovery/RecoveryCards/Recove import { RecoveryInProgressCard } from '@/components/recovery/RecoveryCards/RecoveryInProgressCard' import { WidgetContainer, WidgetBody } from '../styled' import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function _RecoveryHeader({ isGuardian, diff --git a/src/components/recovery/ExecuteRecoveryButton/index.tsx b/src/components/recovery/ExecuteRecoveryButton/index.tsx index c226921d2f..e86a55be87 100644 --- a/src/components/recovery/ExecuteRecoveryButton/index.tsx +++ b/src/components/recovery/ExecuteRecoveryButton/index.tsx @@ -10,8 +10,8 @@ import useOnboard from '@/hooks/wallets/useOnboard' 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' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function ExecuteRecoveryButton({ recovery, diff --git a/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx index 2598a8b222..29cb1f9090 100644 --- a/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx +++ b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx @@ -7,7 +7,7 @@ import { Countdown } from '@/components/common/Countdown' import RecoveryPending from '@/public/images/common/recovery-pending.svg' import ExternalLink from '@/components/common/ExternalLink' import { AppRoutes } from '@/config/routes' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' import css from './styles.module.css' diff --git a/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx index 91b0d95cbe..a80a85dad9 100644 --- a/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx +++ b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react' import { render } from '@/tests/test-utils' import { RecoveryInProgressCard } from '../RecoveryInProgressCard' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' jest.mock('@/hooks/useRecoveryTxState') diff --git a/src/components/recovery/RecoveryDetails/index.tsx b/src/components/recovery/RecoveryDetails/index.tsx index 46a2773e03..56a6adc496 100644 --- a/src/components/recovery/RecoveryDetails/index.tsx +++ b/src/components/recovery/RecoveryDetails/index.tsx @@ -12,7 +12,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import ErrorMessage from '@/components/tx/ErrorMessage' import { RecoverySigners } from '../RecoverySigners' import { Errors, logError } from '@/services/exceptions' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' import txDetailsCss from '@/components/transactions/TxDetails/styles.module.css' import summaryCss from '@/components/transactions/TxDetails/Summary/styles.module.css' diff --git a/src/components/recovery/RecoveryList/index.tsx b/src/components/recovery/RecoveryList/index.tsx index 99be7c464f..8430286a4b 100644 --- a/src/components/recovery/RecoveryList/index.tsx +++ b/src/components/recovery/RecoveryList/index.tsx @@ -2,13 +2,12 @@ import type { ReactElement } from 'react' import { TxListGrid } from '@/components/transactions/TxList' import { RecoveryListItem } from '@/components/recovery/RecoveryListItem' -import { selectRecoveryQueues } from '@/store/recoverySlice' -import { useAppSelector } from '@/store' +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' import labelCss from '@/components/transactions/GroupLabel/styles.module.css' export function RecoveryList(): ReactElement | null { - const queue = useAppSelector(selectRecoveryQueues) + const queue = useRecoveryQueue() if (queue.length === 0) { return null diff --git a/src/components/recovery/RecoveryListItem/index.tsx b/src/components/recovery/RecoveryListItem/index.tsx index 0613ff3502..7f56492d70 100644 --- a/src/components/recovery/RecoveryListItem/index.tsx +++ b/src/components/recovery/RecoveryListItem/index.tsx @@ -5,7 +5,7 @@ import type { ReactElement } from 'react' import txListItemCss from '@/components/transactions/TxListItem/styles.module.css' import { RecoverySummary } from '../RecoverySummary' import { RecoveryDetails } from '../RecoveryDetails' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function RecoveryListItem({ item }: { item: RecoveryQueueItem }): ReactElement { return ( diff --git a/src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx b/src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx new file mode 100644 index 0000000000..35dc8c2b99 --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx @@ -0,0 +1,127 @@ +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 { 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, waitFor } from '@/tests/test-utils' +import { RecoveryLoaderContext, RecoveryLoaderProvider } from '..' +import { getTxDetails } from '@/services/tx/txDetails' + +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') +jest.mock('@/services/tx/txDetails') + +const mockUseSafeInfo = useSafeInfo as jest.MockedFunction +const mockUseWeb3ReadOnly = useWeb3ReadOnly as jest.MockedFunction +const mockUseCurrentChain = useCurrentChain as jest.MockedFunction +const mockUseHasFeature = useHasFeature as jest.MockedFunction +const mockGetTxDetails = getTxDetails as jest.MockedFunction + +describe('RecoveryLoaderContext', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Clear memoization cache + getTxDetails.cache.clear?.() + }) + + 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]) + mockGetTxDetails.mockResolvedValue({ txData: { to: { value: delayModifierAddress } } } as any) + + render( + + <> + , + ) + + await waitFor(() => { + expect(mockGetDelayModifiers).toHaveBeenCalledTimes(1) + expect(mockGetRecoveryState).toHaveBeenCalledTimes(1) + }) + + const txId = faker.string.alphanumeric() + + act(() => { + txDispatch(TxEvent.PROCESSED, { + txId, + safeAddress: faker.finance.ethereumAddress(), + }) + }) + + await waitFor(() => { + expect(mockGetTxDetails).toHaveBeenCalledTimes(1) + expect(mockGetTxDetails).toHaveBeenNthCalledWith(1, txId, safe.chainId) + + expect(mockGetRecoveryState).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/components/recovery/RecoveryLoaderContext/__tests__/useDelayModifier.test.ts b/src/components/recovery/RecoveryLoaderContext/__tests__/useDelayModifier.test.ts new file mode 100644 index 0000000000..b875e2dced --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/__tests__/useDelayModifier.test.ts @@ -0,0 +1,136 @@ +import { 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 { addressExBuilder, safeInfoBuilder } from '@/tests/builders/safe' +import { act, renderHook } from '@/tests/test-utils' +import { useDelayModifiers } from '../useDelayModifiers' + +jest.mock('@/services/recovery/delay-modifier') + +const mockGetDelayModifiers = getDelayModifiers 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 mockUseHasFeature = useHasFeature as jest.MockedFunction + +describe('useDelayModifiers', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + 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) + }) +}) diff --git a/src/components/recovery/RecoveryLoaderContext/__tests__/useRecoveryState.test.ts b/src/components/recovery/RecoveryLoaderContext/__tests__/useRecoveryState.test.ts new file mode 100644 index 0000000000..4a9934d77a --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/__tests__/useRecoveryState.test.ts @@ -0,0 +1,114 @@ +import { useCurrentChain } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { getRecoveryState } from '@/services/recovery/recovery-state' +import { chainBuilder } from '@/tests/builders/chains' +import { safeInfoBuilder } from '@/tests/builders/safe' +import { act, renderHook, waitFor } from '@/tests/test-utils' +import { useRecoveryState } from '../useRecoveryState' + +jest.mock('@/services/recovery/recovery-state') + +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 + +describe('useRecoveryState', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + 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.test.tsx b/src/components/recovery/RecoveryLoaderContext/index.test.tsx deleted file mode 100644 index a16a5c5729..0000000000 --- a/src/components/recovery/RecoveryLoaderContext/index.test.tsx +++ /dev/null @@ -1,321 +0,0 @@ -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 index f0fe333336..821def4732 100644 --- a/src/components/recovery/RecoveryLoaderContext/index.tsx +++ b/src/components/recovery/RecoveryLoaderContext/index.tsx @@ -1,151 +1,87 @@ -import { createContext, useCallback, useEffect, useState } from 'react' +import { createContext, useEffect } 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 type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' +import type { BigNumber } from 'ethers' import { TxEvent, txSubscribe } from '@/services/tx/txEvents' import { sameAddress } from '@/utils/addresses' -import { useUpdateStore } from '@/hooks/useLoadableStores' +import { getTxDetails } from '@/services/tx/txDetails' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useRecoveryState } from './useRecoveryState' +import { useDelayModifiers } from './useDelayModifiers' import type { AsyncResult } from '@/hooks/useAsync' -import type { RecoveryState } from '@/store/recoverySlice' -const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes +export type RecoveryQueueItem = TransactionAddedEvent & { + timestamp: BigNumber + validFrom: BigNumber + expiresAt: BigNumber | null + isMalicious: boolean + executor: string +} + +export type RecoveryStateItem = { + address: string + guardians: Array + txExpiration: BigNumber + txCooldown: BigNumber + txNonce: BigNumber + queueNonce: BigNumber + queue: Array +} + +export type RecoveryState = Array +// State of current Safe, populated on load export const RecoveryLoaderContext = createContext<{ + state: AsyncResult refetch: () => void }>({ + state: [undefined, undefined, false], refetch: () => {}, }) export function RecoveryLoaderProvider({ children }: { children: ReactNode }): ReactElement { - const [delayModifiers, delayModifiersError, delayModifiersLoading] = _useDelayModifiers() + const { safe } = useSafeInfo() + + const [delayModifiers, delayModifiersError, delayModifiersLoading] = useDelayModifiers() const { data: [recoveryState, recoveryStateError, recoveryStateLoading], refetch, - } = _useRecoveryState(delayModifiers) + } = useRecoveryState(delayModifiers) // Reload recovery data when a Delay Modifier is interacted with useEffect(() => { - if (!delayModifiers) { + if (!delayModifiers || delayModifiers.length === 0) { 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 txSubscribe(TxEvent.PROCESSED, async (detail) => { + if (!detail.txId) { return } - // Don't fetch if only spending limit module is enabled - const isOnlySpendingLimit = - safe.modules.length === 1 && safe.modules[0].value === getSpendingLimitModuleAddress(safe.chainId) + const { txData } = await getTxDetails(detail.txId, safe.chainId) - if (isOnlySpendingLimit) { + if (!txData) { 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 isDelayModifierTx = delayModifiers.some((delayModifier) => { + return sameAddress(delayModifier.address, txData.to.value) + }) - const data = useAsync( - () => { - if (!delayModifiers || delayModifiers.length === 0 || !chain?.transactionService || !web3ReadOnly) { - return + if (isDelayModifierTx) { + refetch() } + }) + }, [safe.chainId, delayModifiers, refetch]) - 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, - ) + const data = recoveryState + const error = delayModifiersError || recoveryStateError + const loading = delayModifiersLoading || recoveryStateLoading - return { data, refetch } + return ( + + {children} + + ) } diff --git a/src/components/recovery/RecoveryLoaderContext/useDelayModifiers.ts b/src/components/recovery/RecoveryLoaderContext/useDelayModifiers.ts new file mode 100644 index 0000000000..8016c2267a --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/useDelayModifiers.ts @@ -0,0 +1,42 @@ +import type { Delay } from '@gnosis.pm/zodiac' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { getDelayModifiers } from '@/services/recovery/delay-modifier' +import { FEATURES } from '@/utils/chains' +import useAsync from '@/hooks/useAsync' +import { useHasFeature } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { getSpendingLimitModuleAddress } from '@/services/contracts/spendingLimitContracts' +import type { AsyncResult } from '@/hooks/useAsync' + +function isOnlySpendingLimitEnabled(chainId: string, modules: SafeInfo['modules']) { + return modules?.length === 1 && modules[0].value === getSpendingLimitModuleAddress(chainId) +} + +export function useDelayModifiers(): AsyncResult { + const supportsRecovery = useHasFeature(FEATURES.RECOVERY) + const web3ReadOnly = useWeb3ReadOnly() + const { safe, safeAddress } = useSafeInfo() + + return useAsync>( + () => { + // Don't fetch if only spending limit module is enabled + if ( + supportsRecovery && + web3ReadOnly && + safe.modules && + safe.modules.length > 0 && + !isOnlySpendingLimitEnabled(safe.chainId, safe.modules) + ) { + // 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, + ) +} diff --git a/src/components/recovery/RecoveryLoaderContext/useRecoveryState.ts b/src/components/recovery/RecoveryLoaderContext/useRecoveryState.ts new file mode 100644 index 0000000000..47933e537f --- /dev/null +++ b/src/components/recovery/RecoveryLoaderContext/useRecoveryState.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react' +import type { Delay } from '@gnosis.pm/zodiac' + +import useAsync from '@/hooks/useAsync' +import { useCurrentChain } 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 type { AsyncResult } from '@/hooks/useAsync' +import type { RecoveryState } from '.' + +const REFRESH_DELAY = 5 * 60 * 1_000 // 5 minutes + +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 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, + ) + + return { data, refetch } +} diff --git a/src/components/recovery/RecoveryModal/index.test.tsx b/src/components/recovery/RecoveryModal/index.test.tsx index 5e8c2a0e95..6ffaf6987f 100644 --- a/src/components/recovery/RecoveryModal/index.test.tsx +++ b/src/components/recovery/RecoveryModal/index.test.tsx @@ -8,7 +8,7 @@ import { safeInfoBuilder } from '@/tests/builders/safe' import { connectedWalletBuilder } from '@/tests/builders/wallet' import * as safeInfo from '@/hooks/useSafeInfo' import { _useDidDismissProposal } from './index' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' describe('RecoveryModal', () => { describe('component', () => { diff --git a/src/components/recovery/RecoveryModal/index.tsx b/src/components/recovery/RecoveryModal/index.tsx index 703865d947..4e36630687 100644 --- a/src/components/recovery/RecoveryModal/index.tsx +++ b/src/components/recovery/RecoveryModal/index.tsx @@ -13,7 +13,7 @@ import useLocalStorage from '@/services/local-storage/useLocalStorage' import useWallet from '@/hooks/wallets/useWallet' import useSafeInfo from '@/hooks/useSafeInfo' import { sameAddress } from '@/utils/addresses' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function _RecoveryModal({ children, diff --git a/src/components/recovery/RecoverySigners/index.tsx b/src/components/recovery/RecoverySigners/index.tsx index 1133ab7d45..6ee6d56cf1 100644 --- a/src/components/recovery/RecoverySigners/index.tsx +++ b/src/components/recovery/RecoverySigners/index.tsx @@ -8,7 +8,7 @@ import { Countdown } from '@/components/common/Countdown' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' import { SkipRecoveryButton } from '../SkipRecoveryButton' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' import txSignersCss from '@/components/transactions/TxSigners/styles.module.css' import { formatDate } from '@/utils/date' diff --git a/src/components/recovery/RecoveryStatus/index.tsx b/src/components/recovery/RecoveryStatus/index.tsx index 3cc690bbd0..a4008ecee6 100644 --- a/src/components/recovery/RecoveryStatus/index.tsx +++ b/src/components/recovery/RecoveryStatus/index.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react' import ClockIcon from '@/public/images/common/clock.svg' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): ReactElement => { const { isExecutable, isExpired } = useRecoveryTxState(recovery) diff --git a/src/components/recovery/RecoverySummary/index.tsx b/src/components/recovery/RecoverySummary/index.tsx index 4003b3b6a1..4f76eb8277 100644 --- a/src/components/recovery/RecoverySummary/index.tsx +++ b/src/components/recovery/RecoverySummary/index.tsx @@ -8,7 +8,7 @@ import { RecoveryStatus } from '../RecoveryStatus' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' import { SkipRecoveryButton } from '../SkipRecoveryButton' import useWallet from '@/hooks/wallets/useWallet' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' import txSummaryCss from '@/components/transactions/TxSummary/styles.module.css' diff --git a/src/components/recovery/SkipRecoveryButton/index.tsx b/src/components/recovery/SkipRecoveryButton/index.tsx index dfb01d92d6..7a65ee0859 100644 --- a/src/components/recovery/SkipRecoveryButton/index.tsx +++ b/src/components/recovery/SkipRecoveryButton/index.tsx @@ -7,7 +7,7 @@ import IconButton from '@mui/material/IconButton' import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' import { SkipRecoveryFlow } from '@/components/tx-flow/flows/SkipRecovery' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function SkipRecoveryButton({ recovery, diff --git a/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx index aceed86f39..0881d7abb5 100644 --- a/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx +++ b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx @@ -15,7 +15,7 @@ import type { ReactElement } from 'react' import AlertIcon from '@/public/images/notifications/alert.svg' import { TxModalContext } from '@/components/tx-flow' import { RemoveRecoveryFlow } from '@/components/tx-flow/flows/RemoveRecovery' -import type { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' export function ConfirmRemoveRecoveryModal({ open, @@ -24,7 +24,7 @@ export function ConfirmRemoveRecoveryModal({ }: { open: boolean onClose: () => void - delayModifier: RecoveryState[number] + delayModifier: RecoveryStateItem }): ReactElement { const { setTxFlow } = useContext(TxModalContext) diff --git a/src/components/settings/Recovery/DelayModifierRow.tsx b/src/components/settings/Recovery/DelayModifierRow.tsx index 2a653a75eb..3bab0bc6d5 100644 --- a/src/components/settings/Recovery/DelayModifierRow.tsx +++ b/src/components/settings/Recovery/DelayModifierRow.tsx @@ -8,9 +8,9 @@ import DeleteIcon from '@/public/images/common/delete.svg' import EditIcon from '@/public/images/common/edit.svg' import CheckWallet from '@/components/common/CheckWallet' import { ConfirmRemoveRecoveryModal } from './ConfirmRemoveRecoveryModal' -import type { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' -export function DelayModifierRow({ delayModifier }: { delayModifier: RecoveryState[number] }): ReactElement | null { +export function DelayModifierRow({ delayModifier }: { delayModifier: RecoveryStateItem }): ReactElement | null { const { setTxFlow } = useContext(TxModalContext) const isOwner = useIsSafeOwner() const [confirm, setConfirm] = useState(false) diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index 4eac06d4f5..4b259e915b 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -8,8 +8,7 @@ import { Chip } from '@/components/common/Chip' import ExternalLink from '@/components/common/ExternalLink' import { DelayModifierRow } from './DelayModifierRow' import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import { useAppSelector } from '@/store' -import { selectRecovery } from '@/store/recoverySlice' +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' import EthHashInfo from '@/components/common/EthHashInfo' import EnhancedTable from '@/components/common/EnhancedTable' import InfoIcon from '@/public/images/notifications/info.svg' @@ -69,11 +68,11 @@ const headCells = [ export function Recovery(): ReactElement { const { setTxFlow } = useContext(TxModalContext) - const recovery = useAppSelector(selectRecovery) + const [recovery] = useContext(RecoveryLoaderContext).state const isOwner = useIsSafeOwner() const rows = useMemo(() => { - return recovery.flatMap((delayModifier) => { + return recovery?.flatMap((delayModifier) => { const { guardians, txCooldown, txExpiration } = delayModifier return guardians.map((guardian) => { @@ -138,7 +137,7 @@ export function Recovery(): ReactElement { Enabling the Account recovery module will require a transactions. - {recovery.length === 0 ? ( + {recovery?.length === 0 ? ( <> Unhappy with the provided option? {/* TODO: Add link */} @@ -159,9 +158,9 @@ export function Recovery(): ReactElement { )} - ) : ( + ) : rows ? ( - )} + ) : null} diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx index 3f8a937d32..b71f1d6d71 100644 --- a/src/components/settings/SafeModules/index.tsx +++ b/src/components/settings/SafeModules/index.tsx @@ -8,9 +8,10 @@ import DeleteIcon from '@/public/images/common/delete.svg' import CheckWallet from '@/components/common/CheckWallet' import { useContext, useState } from 'react' import { TxModalContext } from '@/components/tx-flow' -import { useAppSelector } from '@/store' -import { selectDelayModifierByAddress } from '@/store/recoverySlice' +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { selectDelayModifierByAddress } from '@/services/recovery/selectors' import { ConfirmRemoveRecoveryModal } from '../Recovery/ConfirmRemoveRecoveryModal' + import css from '../TransactionGuards/styles.module.css' const NoModules = () => { @@ -24,7 +25,8 @@ const NoModules = () => { const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => { const { setTxFlow } = useContext(TxModalContext) const [confirmRemoveRecovery, setConfirmRemoveRecovery] = useState(false) - const delayModifier = useAppSelector((state) => selectDelayModifierByAddress(state, moduleAddress)) + const [data] = useContext(RecoveryLoaderContext).state + const delayModifier = data && selectDelayModifierByAddress(data, moduleAddress) const onRemove = () => { if (delayModifier) { diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index b08b26428d..77c09844fa 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -13,8 +13,7 @@ import { navItems } from './config' import useSafeInfo from '@/hooks/useSafeInfo' import { AppRoutes } from '@/config/routes' import useTxQueue from '@/hooks/useTxQueue' -import { useAppSelector } from '@/store' -import { selectRecoveryQueues } from '@/store/recoverySlice' +import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] @@ -25,7 +24,7 @@ const Navigation = (): ReactElement => { const { safe } = useSafeInfo() const currentSubdirectory = getSubdirectory(router.pathname) const hasQueuedTxs = Boolean(useTxQueue().page?.results.length) - const hasRecoveryTxs = Boolean(useAppSelector(selectRecoveryQueues).length) + const hasRecoveryTxs = Boolean(useRecoveryQueue().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/common/OwnerList/index.tsx b/src/components/tx-flow/common/OwnerList/index.tsx index f177b62e67..39c7eddecb 100644 --- a/src/components/tx-flow/common/OwnerList/index.tsx +++ b/src/components/tx-flow/common/OwnerList/index.tsx @@ -1,5 +1,5 @@ import { Paper, Typography, SvgIcon } from '@mui/material' -import type { SxProps } from '@mui/material' +import type { PaperProps } from '@mui/material' import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' import type { ReactElement } from 'react' @@ -15,7 +15,7 @@ export function OwnerList({ }: { owners: Array title?: string - sx?: SxProps + sx?: PaperProps['sx'] }): ReactElement { return ( diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index ba7d4a3d08..7ced4ac21c 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -17,8 +17,7 @@ import CheckWallet from '@/components/common/CheckWallet' import { createMultiSendCallOnlyTx, createTx, dispatchRecoveryProposal } from '@/services/tx/tx-sender' import { RecoverAccountFlowFields } from '.' import { OwnerList } from '../../common/OwnerList' -import { useAppSelector } from '@/store' -import { selectDelayModifierByGuardian } from '@/store/recoverySlice' +import { selectDelayModifierByGuardian } from '@/services/recovery/selectors' import useWallet from '@/hooks/wallets/useWallet' import useOnboard from '@/hooks/wallets/useOnboard' import { TxModalContext } from '../..' @@ -42,7 +41,8 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const { safe } = useSafeInfo() const wallet = useWallet() const onboard = useOnboard() - const recovery = useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) + const [data] = useContext(RecoveryLoaderContext).state + const recovery = data && selectDelayModifierByGuardian(data, wallet?.address ?? '') const { refetch } = useContext(RecoveryLoaderContext) // Proposal diff --git a/src/components/tx-flow/flows/RemoveRecovery/index.tsx b/src/components/tx-flow/flows/RemoveRecovery/index.tsx index c173cdc583..c9868cb919 100644 --- a/src/components/tx-flow/flows/RemoveRecovery/index.tsx +++ b/src/components/tx-flow/flows/RemoveRecovery/index.tsx @@ -5,10 +5,10 @@ import RecoveryPlus from '@/public/images/common/recovery-plus.svg' import useTxStepper from '../../useTxStepper' import { RemoveRecoveryFlowOverview } from './RemoveRecoveryFlowOverview' import { RemoveRecoveryFlowReview } from './RemoveRecoveryFlowReview' -import type { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' export type RecoveryFlowProps = { - delayModifier: RecoveryState[number] + delayModifier: RecoveryStateItem } export function RemoveRecoveryFlow({ delayModifier }: RecoveryFlowProps): ReactElement { diff --git a/src/components/tx-flow/flows/SkipRecovery/SkipRecoveryFlowReview.tsx b/src/components/tx-flow/flows/SkipRecovery/SkipRecoveryFlowReview.tsx index 13f810983a..e0c2cb4cd3 100644 --- a/src/components/tx-flow/flows/SkipRecovery/SkipRecoveryFlowReview.tsx +++ b/src/components/tx-flow/flows/SkipRecovery/SkipRecoveryFlowReview.tsx @@ -7,7 +7,7 @@ import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { getRecoverySkipTransaction } from '@/services/recovery/transaction' import { createTx } from '@/services/tx/tx-sender' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function SkipRecoveryFlowReview({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { const web3ReadOnly = useWeb3ReadOnly() diff --git a/src/components/tx-flow/flows/SkipRecovery/index.tsx b/src/components/tx-flow/flows/SkipRecovery/index.tsx index 6da4a1f3ef..c508826a4c 100644 --- a/src/components/tx-flow/flows/SkipRecovery/index.tsx +++ b/src/components/tx-flow/flows/SkipRecovery/index.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react' import TxLayout from '../../common/TxLayout' import { SkipRecoveryFlowReview } from './SkipRecoveryFlowReview' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function SkipRecoveryFlow({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { return ( diff --git a/src/hooks/__tests__/useRecoveryTxState.test.ts b/src/hooks/__tests__/useRecoveryTxState.test.ts index c71d276ac5..a169927c65 100644 --- a/src/hooks/__tests__/useRecoveryTxState.test.ts +++ b/src/hooks/__tests__/useRecoveryTxState.test.ts @@ -3,7 +3,7 @@ import { BigNumber } from 'ethers' import { useRecoveryTxState } from '../useRecoveryTxState' import { renderHook } from '@/tests/test-utils' import * as store from '@/store' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' describe('useRecoveryTxState', () => { beforeEach(() => { diff --git a/src/hooks/useIsGuardian.ts b/src/hooks/useIsGuardian.ts index eb2ae9eb4e..16b987a3a7 100644 --- a/src/hooks/useIsGuardian.ts +++ b/src/hooks/useIsGuardian.ts @@ -1,8 +1,11 @@ -import { useAppSelector } from '@/store' -import { selectDelayModifierByGuardian } from '@/store/recoverySlice' +import { useContext } from 'react' + +import { selectDelayModifierByGuardian } from '@/services/recovery/selectors' import useWallet from './wallets/useWallet' +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' export function useIsGuardian() { + const [data] = useContext(RecoveryLoaderContext).state const wallet = useWallet() - return !!useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) + return !wallet?.address || !data || !selectDelayModifierByGuardian(data, wallet.address) } diff --git a/src/hooks/useIsRecoveryEnabled.ts b/src/hooks/useIsRecoveryEnabled.ts index e82fe3a476..f6b67320ad 100644 --- a/src/hooks/useIsRecoveryEnabled.ts +++ b/src/hooks/useIsRecoveryEnabled.ts @@ -1,7 +1,8 @@ -import { useAppSelector } from '@/store' -import { selectRecovery } from '@/store/recoverySlice' +import { useContext } from 'react' + +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' export function useIsRecoveryEnabled(): boolean { - const recovery = useAppSelector(selectRecovery) - return recovery.length === 0 + const [data] = useContext(RecoveryLoaderContext).state + return !!data && data.length > 0 } diff --git a/src/hooks/useLoadableStores.ts b/src/hooks/useLoadableStores.ts index 95c3045502..0a0ed24fda 100644 --- a/src/hooks/useLoadableStores.ts +++ b/src/hooks/useLoadableStores.ts @@ -22,7 +22,7 @@ import useLoadSpendingLimits from '@/hooks/loadables/useLoadSpendingLimits' import { safeMessagesSlice } from '@/store/safeMessagesSlice' // Dispatch into the corresponding store when the loadable is loaded -export const useUpdateStore = (slice: Slice, useLoadHook: () => AsyncResult): void => { +const useUpdateStore = (slice: Slice, useLoadHook: () => AsyncResult): void => { const dispatch = useAppDispatch() const [data, error, loading] = useLoadHook() const setAction = slice.actions.set diff --git a/src/hooks/useRecoveryQueue.ts b/src/hooks/useRecoveryQueue.ts index 69bf04458c..33574a782b 100644 --- a/src/hooks/useRecoveryQueue.ts +++ b/src/hooks/useRecoveryQueue.ts @@ -1,12 +1,19 @@ -import { useAppSelector } from '@/store' -import { selectRecoveryQueues } from '@/store/recoverySlice' +import { useContext } from 'react' + +import { selectRecoveryQueues } from '@/services/recovery/selectors' import { useClock } from './useClock' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function useRecoveryQueue(): Array { - const queue = useAppSelector(selectRecoveryQueues) + const [data] = useContext(RecoveryLoaderContext).state + const queue = data && selectRecoveryQueues(data) const clock = useClock() + if (!queue) { + return [] + } + return queue.filter(({ expiresAt }) => { return expiresAt ? expiresAt.gt(clock) : true }) diff --git a/src/hooks/useRecoveryTxState.ts b/src/hooks/useRecoveryTxState.ts index 4c39fe56a3..efa117840c 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -1,7 +1,9 @@ +import { useContext } from 'react' + import { useClock } from './useClock' -import { useAppSelector } from '@/store' -import { selectDelayModifierByTxHash } from '@/store/recoverySlice' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import { selectDelayModifierByTxHash } from '@/services/recovery/selectors' +import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args }: RecoveryQueueItem): { isNext: boolean @@ -9,7 +11,8 @@ export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args isExpired: boolean remainingSeconds: number } { - const recovery = useAppSelector((state) => selectDelayModifierByTxHash(state, transactionHash)) + const [data] = useContext(RecoveryLoaderContext).state + const recovery = data && selectDelayModifierByTxHash(data, transactionHash) // We don't display seconds in the interface, so we can use a 60s interval const timestamp = useClock(60_000) diff --git a/src/services/recovery/__tests__/recovery-state.test.ts b/src/services/recovery/__tests__/recovery-state.test.ts index dbe6c3043e..170aaaae6f 100644 --- a/src/services/recovery/__tests__/recovery-state.test.ts +++ b/src/services/recovery/__tests__/recovery-state.test.ts @@ -6,7 +6,7 @@ import type { Delay, TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/ty import type { TransactionReceipt } from '@ethersproject/abstract-provider' import { - getRecoveryState, + _getRecoveryStateItem, _getRecoveryQueueItemTimestamps, _getSafeCreationReceipt, _isMaliciousRecovery, @@ -389,7 +389,7 @@ describe('recovery-state', () => { queryFilter: queryFilterMock.mockImplementation(() => Promise.resolve(transactionsAdded)), } - const recoveryState = await getRecoveryState({ + const recoveryState = await _getRecoveryStateItem({ delayModifier: delayModifier as unknown as Delay, safeAddress, transactionService, @@ -468,7 +468,7 @@ describe('recovery-state', () => { queryFilter: queryFilterMock.mockRejectedValue('Not required'), } - const recoveryState = await getRecoveryState({ + const recoveryState = await _getRecoveryStateItem({ delayModifier: delayModifier as unknown as Delay, safeAddress, transactionService, diff --git a/src/store/__tests__/recoverySlice.test.ts b/src/services/recovery/__tests__/selectors.test.ts similarity index 61% rename from src/store/__tests__/recoverySlice.test.ts rename to src/services/recovery/__tests__/selectors.test.ts index 857c48568b..df24bce6ed 100644 --- a/src/store/__tests__/recoverySlice.test.ts +++ b/src/services/recovery/__tests__/selectors.test.ts @@ -6,36 +6,28 @@ import { selectRecoveryQueues, selectDelayModifierByTxHash, selectDelayModifierByAddress, -} from '../recoverySlice' -import type { RecoveryState } from '../recoverySlice' -import type { RootState } from '..' +} from '../selectors' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' -describe('recoverySlice', () => { +describe('selectors', () => { describe('selectDelayModifierByGuardian', () => { it('should return the Delay Modifier for the given guardian', () => { const delayModifier1 = { guardians: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], queue: [{ timestamp: BigNumber.from(1) }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier2 = { guardians: [faker.finance.ethereumAddress()], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier3 = { guardians: [faker.finance.ethereumAddress()], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const data = [delayModifier1, delayModifier2, delayModifier3] - expect( - selectDelayModifierByGuardian( - { - recovery: { data }, - } as unknown as RootState, - delayModifier1.guardians[0], - ), - ).toStrictEqual(delayModifier1) + expect(selectDelayModifierByGuardian(data, delayModifier1.guardians[0])).toStrictEqual(delayModifier1) }) }) @@ -43,23 +35,19 @@ describe('recoverySlice', () => { it('should return all recovery queues sorted by timestamp', () => { const delayModifier1 = { queue: [{ timestamp: BigNumber.from(1) }, { timestamp: BigNumber.from(3) }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier2 = { queue: [{ timestamp: BigNumber.from(2) }, { timestamp: BigNumber.from(5) }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier3 = { queue: [{ timestamp: BigNumber.from(4) }, { timestamp: BigNumber.from(6) }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const data = [delayModifier1, delayModifier2, delayModifier3] - expect( - selectRecoveryQueues({ - recovery: { data }, - } as unknown as RootState), - ).toStrictEqual([ + expect(selectRecoveryQueues(data)).toStrictEqual([ { timestamp: BigNumber.from(1) }, { timestamp: BigNumber.from(2) }, { timestamp: BigNumber.from(3) }, @@ -76,53 +64,39 @@ describe('recoverySlice', () => { const delayModifier1 = { queue: [{ transactionHash: txHash }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier2 = { queue: [{ transactionHash: faker.string.hexadecimal() }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier3 = { queue: [{ transactionHash: faker.string.hexadecimal() }], - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const data = [delayModifier1, delayModifier2, delayModifier3] - expect( - selectDelayModifierByTxHash( - { - recovery: { data }, - } as unknown as RootState, - txHash, - ), - ).toStrictEqual(delayModifier1) + expect(selectDelayModifierByTxHash(data, txHash)).toStrictEqual(delayModifier1) }) }) describe('selectDelayModifierByAddress', () => { - it('should return the Delay Modifier for the given txHash', () => { + it('should return the Delay Modifier for the given address', () => { const delayModifier1 = { address: faker.finance.ethereumAddress(), - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier2 = { address: faker.finance.ethereumAddress(), - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const delayModifier3 = { address: faker.finance.ethereumAddress(), - } as unknown as RecoveryState[number] + } as unknown as RecoveryStateItem const data = [delayModifier1, delayModifier2, delayModifier3] - expect( - selectDelayModifierByAddress( - { - recovery: { data }, - } as unknown as RootState, - delayModifier2.address, - ), - ).toStrictEqual(delayModifier2) + expect(selectDelayModifierByAddress(data, delayModifier3.address)).toStrictEqual(delayModifier3) }) }) }) diff --git a/src/services/recovery/recovery-state.ts b/src/services/recovery/recovery-state.ts index 8af45e5b79..8ab265ca56 100644 --- a/src/services/recovery/recovery-state.ts +++ b/src/services/recovery/recovery-state.ts @@ -13,7 +13,7 @@ import { trimTrailingSlash } from '@/utils/url' import { sameAddress } from '@/utils/addresses' import { isMultiSendCalldata } from '@/utils/transaction-calldata' import { decodeMultiSendTxs } from '@/utils/transactions' -import type { RecoveryQueueItem, RecoveryState } from '@/store/recoverySlice' +import type { RecoveryQueueItem, RecoveryState, RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' const MAX_PAGE_SIZE = 100 @@ -173,7 +173,7 @@ const getRecoveryQueueItem = async ({ } } -export const getRecoveryState = async ({ +export const _getRecoveryStateItem = async ({ delayModifier, transactionService, safeAddress, @@ -187,7 +187,7 @@ export const getRecoveryState = async ({ provider: JsonRpcProvider chainId: string version: SafeInfo['version'] -}): Promise => { +}): Promise => { const [[guardians], txExpiration, txCooldown, txNonce, queueNonce] = await Promise.all([ delayModifier.getModulesPaginated(SENTINEL_ADDRESS, MAX_PAGE_SIZE), delayModifier.txExpiration(), @@ -230,3 +230,17 @@ export const getRecoveryState = async ({ queue: queue.filter((item) => !item.removed), } } + +export function getRecoveryState({ + delayModifiers, + ...rest +}: { + delayModifiers: Array + transactionService: string + safeAddress: string + provider: JsonRpcProvider + chainId: string + version: SafeInfo['version'] +}): Promise { + return Promise.all(delayModifiers.map((delayModifier) => _getRecoveryStateItem({ delayModifier, ...rest }))) +} diff --git a/src/services/recovery/selectors.ts b/src/services/recovery/selectors.ts new file mode 100644 index 0000000000..e4f3af6122 --- /dev/null +++ b/src/services/recovery/selectors.ts @@ -0,0 +1,34 @@ +import { createSelector } from '@reduxjs/toolkit' + +import type { RecoveryState } from '@/components/recovery/RecoveryLoaderContext' +import { sameAddress } from '@/utils/addresses' + +// Identity function to help with type inference +function selectRecovery(state: T): T { + return state +} + +export const selectDelayModifierByGuardian = createSelector( + [selectRecovery, (_: RecoveryState, walletAddress: string) => walletAddress], + (recovery, walletAddress) => { + return recovery?.find(({ guardians }) => guardians.some((guardian) => sameAddress(guardian, walletAddress))) + }, +) + +export const selectRecoveryQueues = createSelector([selectRecovery], (recovery) => { + return recovery?.flatMap(({ queue }) => queue).sort((a, b) => a.timestamp.sub(b.timestamp).toNumber()) +}) + +export const selectDelayModifierByTxHash = createSelector( + [selectRecovery, (_: RecoveryState, txHash: string) => txHash], + (recovery, txHash) => { + return recovery?.find(({ queue }) => queue.some((item) => item.transactionHash === txHash)) + }, +) + +export const selectDelayModifierByAddress = createSelector( + [selectRecovery, (_: RecoveryState, moduleAddress: string) => moduleAddress], + (recovery, moduleAddress) => { + return recovery?.find(({ address }) => sameAddress(address, moduleAddress)) + }, +) diff --git a/src/services/recovery/transaction.ts b/src/services/recovery/transaction.ts index bc389cdf01..b3ecc063fb 100644 --- a/src/services/recovery/transaction.ts +++ b/src/services/recovery/transaction.ts @@ -7,7 +7,7 @@ import { sameAddress } from '@/utils/addresses' import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' import type { JsonRpcProvider } from '@ethersproject/providers' export function getRecoveryProposalTransactions({ diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 549fe9d602..0aaddfb471 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, to: safeTx.data.to }) + txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) } }) .catch((err) => { const error = err as EthersError if (didReprice(error)) { - txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress, to: safeTx.data.to }) + txDispatch(TxEvent.PROCESSED, { ...eventParams, safeAddress }) } else { txDispatch(TxEvent.FAILED, { ...eventParams, error: asError(error) }) } @@ -224,12 +224,11 @@ export const dispatchBatchExecution = async ( }) }) } else { - txs.forEach(({ txId, txData }) => { + txs.forEach(({ txId }) => { txDispatch(TxEvent.PROCESSED, { txId, groupKey, safeAddress, - to: txData?.to.value, }) }) } @@ -238,11 +237,10 @@ export const dispatchBatchExecution = async ( const error = err as EthersError if (didReprice(error)) { - txs.forEach(({ txId, txData }) => { + txs.forEach(({ txId }) => { txDispatch(TxEvent.PROCESSED, { txId, safeAddress, - to: txData?.to.value, }) }) } else { @@ -305,7 +303,7 @@ export const dispatchSpendingLimitTxExecution = async ( error: new Error('Transaction reverted by EVM'), }) } else { - txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress, to: txParams.to }) + txDispatch(TxEvent.PROCESSED, { groupKey: id, safeAddress }) } }) .catch((error) => { diff --git a/src/services/tx/txEvents.ts b/src/services/tx/txEvents.ts index aeef8aeef8..2de1612d31 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; to?: string } + [TxEvent.PROCESSED]: Id & { safeAddress: string } [TxEvent.REVERTED]: Id & { error: Error } [TxEvent.RELAYING]: Id & { taskId: string } [TxEvent.FAILED]: Id & { error: Error } diff --git a/src/store/index.ts b/src/store/index.ts index 126f5adce5..5053c84f8a 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -29,7 +29,6 @@ import { safeAppsSlice } from './safeAppsSlice' import { safeMessagesListener, safeMessagesSlice } from './safeMessagesSlice' import { pendingSafeMessagesSlice } from './pendingSafeMessagesSlice' import { batchSlice } from './batchSlice' -import { recoverySlice } from './recoverySlice' const rootReducer = combineReducers({ [chainsSlice.name]: chainsSlice.reducer, @@ -50,7 +49,6 @@ const rootReducer = combineReducers({ [safeMessagesSlice.name]: safeMessagesSlice.reducer, [pendingSafeMessagesSlice.name]: pendingSafeMessagesSlice.reducer, [batchSlice.name]: batchSlice.reducer, - [recoverySlice.name]: recoverySlice.reducer, }) const persistedSlices: (keyof PreloadedState)[] = [ diff --git a/src/store/recoverySlice.ts b/src/store/recoverySlice.ts deleted file mode 100644 index f6fb92f19c..0000000000 --- a/src/store/recoverySlice.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit' -import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' -import type { BigNumber } from 'ethers' - -import { makeLoadableSlice } from './common' -import { sameAddress } from '@/utils/addresses' -import type { RootState } from '.' - -export type RecoveryQueueItem = TransactionAddedEvent & { - timestamp: BigNumber - validFrom: BigNumber - expiresAt: BigNumber | null - isMalicious: boolean - executor: string -} - -// State of current Safe, populated on load -export type RecoveryState = Array<{ - address: string - guardians: Array - txExpiration: BigNumber - txCooldown: BigNumber - txNonce: BigNumber - queueNonce: BigNumber - queue: Array -}> - -const initialState: RecoveryState = [] - -const { slice, selector } = makeLoadableSlice('recovery', initialState) - -export const recoverySlice = slice - -export const selectRecovery = createSelector(selector, (recovery) => recovery.data) - -export const selectDelayModifierByGuardian = createSelector( - [selectRecovery, (_: RootState, walletAddress: string) => walletAddress], - (recovery, walletAddress) => { - return recovery.find(({ guardians }) => guardians.some((guardian) => sameAddress(guardian, walletAddress))) - }, -) - -export const selectRecoveryQueues = createSelector(selectRecovery, (recovery) => { - return recovery.flatMap(({ queue }) => queue).sort((a, b) => a.timestamp.sub(b.timestamp).toNumber()) -}) - -export const selectDelayModifierByTxHash = createSelector( - [selectRecovery, (_: RootState, txHash: string) => txHash], - (recovery, txHash) => { - return recovery.find(({ queue }) => queue.some((item) => item.transactionHash === txHash)) - }, -) - -export const selectDelayModifierByAddress = createSelector( - [selectRecovery, (_: RootState, moduleAddress: string) => moduleAddress], - (recovery, moduleAddress) => { - return recovery.find(({ address }) => sameAddress(address, moduleAddress)) - }, -) From fbccae45bb0bb870f2920fa541160fb1d88ebbb1 Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 23 Nov 2023 11:34:42 +0100 Subject: [PATCH 3/3] fix: rename context + create helper hook --- .../PendingTxs/PendingRecoveryListItem.tsx | 2 +- .../dashboard/PendingTxs/PendingTxList.test.ts | 2 +- .../dashboard/PendingTxs/PendingTxsList.tsx | 2 +- .../dashboard/RecoveryHeader/index.test.tsx | 2 +- .../dashboard/RecoveryHeader/index.tsx | 2 +- .../recovery/CancelRecoveryButton/index.tsx | 2 +- .../recovery/ExecuteRecoveryButton/index.tsx | 6 +++--- .../RecoveryCards/RecoveryInProgressCard.tsx | 2 +- .../__tests__/RecoveryInProgressCard.test.tsx | 2 +- .../__tests__/index.test.tsx | 14 +++++++------- .../__tests__/useDelayModifier.test.ts | 0 .../__tests__/useRecoveryState.test.ts | 0 .../index.tsx | 15 +++++++++------ .../useDelayModifiers.ts | 0 .../useRecoveryState.ts | 0 .../recovery/RecoveryDetails/index.tsx | 2 +- .../recovery/RecoveryListItem/index.tsx | 2 +- .../recovery/RecoveryModal/index.test.tsx | 2 +- .../recovery/RecoveryModal/index.tsx | 2 +- .../recovery/RecoverySigners/index.tsx | 2 +- .../recovery/RecoveryStatus/index.tsx | 2 +- .../recovery/RecoverySummary/index.tsx | 2 +- .../Recovery/ConfirmRemoveRecoveryModal.tsx | 2 +- .../settings/Recovery/DelayModifierRow.tsx | 2 +- src/components/settings/Recovery/index.tsx | 6 ++---- src/components/settings/SafeModules/index.tsx | 6 +++--- .../CancelRecoveryFlowReview.tsx | 2 +- .../tx-flow/flows/CancelRecovery/index.tsx | 2 +- .../RecoverAccountFlowReview.tsx | 8 +++++--- .../tx-flow/flows/RemoveRecovery/index.tsx | 2 +- .../__tests__/useRecoveryTxState.test.tsx | 18 +++++++++--------- src/hooks/useIsGuardian.ts | 8 +++----- src/hooks/useIsRecoveryEnabled.ts | 8 +++----- src/hooks/useRecoveryQueue.ts | 10 ++++------ src/hooks/useRecoveryTxState.ts | 13 ++++++------- src/pages/_app.tsx | 6 +++--- .../recovery/__tests__/selectors.test.ts | 2 +- src/services/recovery/recovery-state.ts | 2 +- src/services/recovery/selectors.ts | 2 +- src/services/recovery/transaction.ts | 2 +- 40 files changed, 81 insertions(+), 85 deletions(-) rename src/components/recovery/{RecoveryLoaderContext => RecoveryContext}/__tests__/index.test.tsx (93%) rename src/components/recovery/{RecoveryLoaderContext => RecoveryContext}/__tests__/useDelayModifier.test.ts (100%) rename src/components/recovery/{RecoveryLoaderContext => RecoveryContext}/__tests__/useRecoveryState.test.ts (100%) rename src/components/recovery/{RecoveryLoaderContext => RecoveryContext}/index.tsx (84%) rename src/components/recovery/{RecoveryLoaderContext => RecoveryContext}/useDelayModifiers.ts (100%) rename src/components/recovery/{RecoveryLoaderContext => RecoveryContext}/useRecoveryState.ts (100%) diff --git a/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx index 54732e42c2..a133870e26 100644 --- a/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx +++ b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx @@ -9,7 +9,7 @@ import { RecoveryInfo } from '@/components/recovery/RecoveryInfo' import { RecoveryStatus } from '@/components/recovery/RecoveryStatus' import { RecoveryType } from '@/components/recovery/RecoveryType' import { AppRoutes } from '@/config/routes' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import css from './styles.module.css' diff --git a/src/components/dashboard/PendingTxs/PendingTxList.test.ts b/src/components/dashboard/PendingTxs/PendingTxList.test.ts index 012c0d4fdc..14f6cca3b9 100644 --- a/src/components/dashboard/PendingTxs/PendingTxList.test.ts +++ b/src/components/dashboard/PendingTxs/PendingTxList.test.ts @@ -5,7 +5,7 @@ import type { MultisigExecutionInfo, Transaction } from '@safe-global/safe-gatew import { safeInfoBuilder } from '@/tests/builders/safe' import { _getTransactionsToDisplay } from './PendingTxsList' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' describe('_getTransactionsToDisplay', () => { it('should return the recovery queue if it has more than or equal to MAX_TXS items', () => { diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx index 459c304223..07130bd344 100644 --- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx @@ -15,7 +15,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { useRecoveryQueue } from '@/hooks/useRecoveryQueue' import { PendingRecoveryListItem } from './PendingRecoveryListItem' import type { SafeInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' const MAX_TXS = 4 diff --git a/src/components/dashboard/RecoveryHeader/index.test.tsx b/src/components/dashboard/RecoveryHeader/index.test.tsx index 7ea4106633..c1dc830935 100644 --- a/src/components/dashboard/RecoveryHeader/index.test.tsx +++ b/src/components/dashboard/RecoveryHeader/index.test.tsx @@ -2,7 +2,7 @@ import { BigNumber } from 'ethers' import { _RecoveryHeader } from '.' import { render } from '@/tests/test-utils' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' describe('RecoveryHeader', () => { it('should not render a widget if the chain does not support recovery', () => { diff --git a/src/components/dashboard/RecoveryHeader/index.tsx b/src/components/dashboard/RecoveryHeader/index.tsx index 6320797ccc..a474f194e1 100644 --- a/src/components/dashboard/RecoveryHeader/index.tsx +++ b/src/components/dashboard/RecoveryHeader/index.tsx @@ -10,7 +10,7 @@ import { RecoveryProposalCard } from '@/components/recovery/RecoveryCards/Recove import { RecoveryInProgressCard } from '@/components/recovery/RecoveryCards/RecoveryInProgressCard' import { WidgetContainer, WidgetBody } from '../styled' import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function _RecoveryHeader({ isGuardian, diff --git a/src/components/recovery/CancelRecoveryButton/index.tsx b/src/components/recovery/CancelRecoveryButton/index.tsx index 70b928e28c..218d586d64 100644 --- a/src/components/recovery/CancelRecoveryButton/index.tsx +++ b/src/components/recovery/CancelRecoveryButton/index.tsx @@ -7,7 +7,7 @@ import IconButton from '@mui/material/IconButton' import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' import { CancelRecoveryFlow } from '@/components/tx-flow/flows/CancelRecovery' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function CancelRecoveryButton({ recovery, diff --git a/src/components/recovery/ExecuteRecoveryButton/index.tsx b/src/components/recovery/ExecuteRecoveryButton/index.tsx index e86a55be87..5e7f13ed18 100644 --- a/src/components/recovery/ExecuteRecoveryButton/index.tsx +++ b/src/components/recovery/ExecuteRecoveryButton/index.tsx @@ -10,8 +10,8 @@ import useOnboard from '@/hooks/wallets/useOnboard' import useSafeInfo from '@/hooks/useSafeInfo' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' import { Errors, logError } from '@/services/exceptions' -import { RecoveryLoaderContext } from '../RecoveryLoaderContext' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import { RecoveryContext } from '../RecoveryContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function ExecuteRecoveryButton({ recovery, @@ -23,7 +23,7 @@ export function ExecuteRecoveryButton({ const { isExecutable } = useRecoveryTxState(recovery) const onboard = useOnboard() const { safe } = useSafeInfo() - const { refetch } = useContext(RecoveryLoaderContext) + const { refetch } = useContext(RecoveryContext) const onClick = async (e: SyntheticEvent) => { e.stopPropagation() diff --git a/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx index 29cb1f9090..579860fd6f 100644 --- a/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx +++ b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx @@ -7,7 +7,7 @@ import { Countdown } from '@/components/common/Countdown' import RecoveryPending from '@/public/images/common/recovery-pending.svg' import ExternalLink from '@/components/common/ExternalLink' import { AppRoutes } from '@/config/routes' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import css from './styles.module.css' diff --git a/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx index a80a85dad9..6f7a59d1e1 100644 --- a/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx +++ b/src/components/recovery/RecoveryCards/__tests__/RecoveryInProgressCard.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, waitFor } from '@testing-library/react' import { render } from '@/tests/test-utils' import { RecoveryInProgressCard } from '../RecoveryInProgressCard' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' jest.mock('@/hooks/useRecoveryTxState') diff --git a/src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx b/src/components/recovery/RecoveryContext/__tests__/index.test.tsx similarity index 93% rename from src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx rename to src/components/recovery/RecoveryContext/__tests__/index.test.tsx index 35dc8c2b99..9565e9650f 100644 --- a/src/components/recovery/RecoveryLoaderContext/__tests__/index.test.tsx +++ b/src/components/recovery/RecoveryContext/__tests__/index.test.tsx @@ -10,7 +10,7 @@ import { txDispatch, TxEvent } from '@/services/tx/txEvents' import { chainBuilder } from '@/tests/builders/chains' import { addressExBuilder, safeInfoBuilder } from '@/tests/builders/safe' import { act, fireEvent, render, waitFor } from '@/tests/test-utils' -import { RecoveryLoaderContext, RecoveryLoaderProvider } from '..' +import { RecoveryContext, RecoveryProvider } from '..' import { getTxDetails } from '@/services/tx/txDetails' jest.mock('@/services/recovery/delay-modifier') @@ -30,7 +30,7 @@ const mockUseCurrentChain = useCurrentChain as jest.MockedFunction const mockGetTxDetails = getTxDetails as jest.MockedFunction -describe('RecoveryLoaderContext', () => { +describe('RecoveryContext', () => { beforeEach(() => { jest.clearAllMocks() @@ -54,15 +54,15 @@ describe('RecoveryLoaderContext', () => { mockGetDelayModifiers.mockResolvedValue(delayModifiers as any) function Test() { - const { refetch } = useContext(RecoveryLoaderContext) + const { refetch } = useContext(RecoveryContext) return } const { queryByText } = render( - + - , + , ) await waitFor(() => { @@ -98,9 +98,9 @@ describe('RecoveryLoaderContext', () => { mockGetTxDetails.mockResolvedValue({ txData: { to: { value: delayModifierAddress } } } as any) render( - + <> - , + , ) await waitFor(() => { diff --git a/src/components/recovery/RecoveryLoaderContext/__tests__/useDelayModifier.test.ts b/src/components/recovery/RecoveryContext/__tests__/useDelayModifier.test.ts similarity index 100% rename from src/components/recovery/RecoveryLoaderContext/__tests__/useDelayModifier.test.ts rename to src/components/recovery/RecoveryContext/__tests__/useDelayModifier.test.ts diff --git a/src/components/recovery/RecoveryLoaderContext/__tests__/useRecoveryState.test.ts b/src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.ts similarity index 100% rename from src/components/recovery/RecoveryLoaderContext/__tests__/useRecoveryState.test.ts rename to src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.ts diff --git a/src/components/recovery/RecoveryLoaderContext/index.tsx b/src/components/recovery/RecoveryContext/index.tsx similarity index 84% rename from src/components/recovery/RecoveryLoaderContext/index.tsx rename to src/components/recovery/RecoveryContext/index.tsx index 821def4732..99e3382838 100644 --- a/src/components/recovery/RecoveryLoaderContext/index.tsx +++ b/src/components/recovery/RecoveryContext/index.tsx @@ -1,7 +1,8 @@ -import { createContext, useEffect } from 'react' +import { createContext, useContext, useEffect } from 'react' import type { ReactElement, ReactNode } from 'react' import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Delay' import type { BigNumber } from 'ethers' + import { TxEvent, txSubscribe } from '@/services/tx/txEvents' import { sameAddress } from '@/utils/addresses' import { getTxDetails } from '@/services/tx/txDetails' @@ -31,7 +32,7 @@ export type RecoveryStateItem = { export type RecoveryState = Array // State of current Safe, populated on load -export const RecoveryLoaderContext = createContext<{ +export const RecoveryContext = createContext<{ state: AsyncResult refetch: () => void }>({ @@ -39,7 +40,7 @@ export const RecoveryLoaderContext = createContext<{ refetch: () => {}, }) -export function RecoveryLoaderProvider({ children }: { children: ReactNode }): ReactElement { +export function RecoveryProvider({ children }: { children: ReactNode }): ReactElement { const { safe } = useSafeInfo() const [delayModifiers, delayModifiersError, delayModifiersLoading] = useDelayModifiers() @@ -80,8 +81,10 @@ export function RecoveryLoaderProvider({ children }: { children: ReactNode }): R const loading = delayModifiersLoading || recoveryStateLoading return ( - - {children} - + {children} ) } + +export function useRecovery(): AsyncResult { + return useContext(RecoveryContext).state +} diff --git a/src/components/recovery/RecoveryLoaderContext/useDelayModifiers.ts b/src/components/recovery/RecoveryContext/useDelayModifiers.ts similarity index 100% rename from src/components/recovery/RecoveryLoaderContext/useDelayModifiers.ts rename to src/components/recovery/RecoveryContext/useDelayModifiers.ts diff --git a/src/components/recovery/RecoveryLoaderContext/useRecoveryState.ts b/src/components/recovery/RecoveryContext/useRecoveryState.ts similarity index 100% rename from src/components/recovery/RecoveryLoaderContext/useRecoveryState.ts rename to src/components/recovery/RecoveryContext/useRecoveryState.ts diff --git a/src/components/recovery/RecoveryDetails/index.tsx b/src/components/recovery/RecoveryDetails/index.tsx index b95fccbc0a..fc0193b10a 100644 --- a/src/components/recovery/RecoveryDetails/index.tsx +++ b/src/components/recovery/RecoveryDetails/index.tsx @@ -12,7 +12,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import ErrorMessage from '@/components/tx/ErrorMessage' import { RecoverySigners } from '../RecoverySigners' import { Errors, logError } from '@/services/exceptions' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import txDetailsCss from '@/components/transactions/TxDetails/styles.module.css' import summaryCss from '@/components/transactions/TxDetails/Summary/styles.module.css' diff --git a/src/components/recovery/RecoveryListItem/index.tsx b/src/components/recovery/RecoveryListItem/index.tsx index 7f56492d70..8905d392a8 100644 --- a/src/components/recovery/RecoveryListItem/index.tsx +++ b/src/components/recovery/RecoveryListItem/index.tsx @@ -5,7 +5,7 @@ import type { ReactElement } from 'react' import txListItemCss from '@/components/transactions/TxListItem/styles.module.css' import { RecoverySummary } from '../RecoverySummary' import { RecoveryDetails } from '../RecoveryDetails' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function RecoveryListItem({ item }: { item: RecoveryQueueItem }): ReactElement { return ( diff --git a/src/components/recovery/RecoveryModal/index.test.tsx b/src/components/recovery/RecoveryModal/index.test.tsx index 6ffaf6987f..c17a10f27d 100644 --- a/src/components/recovery/RecoveryModal/index.test.tsx +++ b/src/components/recovery/RecoveryModal/index.test.tsx @@ -8,7 +8,7 @@ import { safeInfoBuilder } from '@/tests/builders/safe' import { connectedWalletBuilder } from '@/tests/builders/wallet' import * as safeInfo from '@/hooks/useSafeInfo' import { _useDidDismissProposal } from './index' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' describe('RecoveryModal', () => { describe('component', () => { diff --git a/src/components/recovery/RecoveryModal/index.tsx b/src/components/recovery/RecoveryModal/index.tsx index 4e36630687..13d0c396b7 100644 --- a/src/components/recovery/RecoveryModal/index.tsx +++ b/src/components/recovery/RecoveryModal/index.tsx @@ -13,7 +13,7 @@ import useLocalStorage from '@/services/local-storage/useLocalStorage' import useWallet from '@/hooks/wallets/useWallet' import useSafeInfo from '@/hooks/useSafeInfo' import { sameAddress } from '@/utils/addresses' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function _RecoveryModal({ children, diff --git a/src/components/recovery/RecoverySigners/index.tsx b/src/components/recovery/RecoverySigners/index.tsx index 25a32490ee..38e31fc129 100644 --- a/src/components/recovery/RecoverySigners/index.tsx +++ b/src/components/recovery/RecoverySigners/index.tsx @@ -8,7 +8,7 @@ import { Countdown } from '@/components/common/Countdown' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' import { CancelRecoveryButton } from '../CancelRecoveryButton' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import txSignersCss from '@/components/transactions/TxSigners/styles.module.css' import { formatDate } from '@/utils/date' diff --git a/src/components/recovery/RecoveryStatus/index.tsx b/src/components/recovery/RecoveryStatus/index.tsx index a4008ecee6..03db49acf7 100644 --- a/src/components/recovery/RecoveryStatus/index.tsx +++ b/src/components/recovery/RecoveryStatus/index.tsx @@ -3,7 +3,7 @@ import type { ReactElement } from 'react' import ClockIcon from '@/public/images/common/clock.svg' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export const RecoveryStatus = ({ recovery }: { recovery: RecoveryQueueItem }): ReactElement => { const { isExecutable, isExpired } = useRecoveryTxState(recovery) diff --git a/src/components/recovery/RecoverySummary/index.tsx b/src/components/recovery/RecoverySummary/index.tsx index 0f58cb93ec..a017611ade 100644 --- a/src/components/recovery/RecoverySummary/index.tsx +++ b/src/components/recovery/RecoverySummary/index.tsx @@ -8,7 +8,7 @@ import { RecoveryStatus } from '../RecoveryStatus' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' import { CancelRecoveryButton } from '../CancelRecoveryButton' import useWallet from '@/hooks/wallets/useWallet' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import txSummaryCss from '@/components/transactions/TxSummary/styles.module.css' diff --git a/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx index 0881d7abb5..da16bb6867 100644 --- a/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx +++ b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx @@ -15,7 +15,7 @@ import type { ReactElement } from 'react' import AlertIcon from '@/public/images/notifications/alert.svg' import { TxModalContext } from '@/components/tx-flow' import { RemoveRecoveryFlow } from '@/components/tx-flow/flows/RemoveRecovery' -import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryContext' export function ConfirmRemoveRecoveryModal({ open, diff --git a/src/components/settings/Recovery/DelayModifierRow.tsx b/src/components/settings/Recovery/DelayModifierRow.tsx index 3bab0bc6d5..44a7c205ed 100644 --- a/src/components/settings/Recovery/DelayModifierRow.tsx +++ b/src/components/settings/Recovery/DelayModifierRow.tsx @@ -8,7 +8,7 @@ import DeleteIcon from '@/public/images/common/delete.svg' import EditIcon from '@/public/images/common/edit.svg' import CheckWallet from '@/components/common/CheckWallet' import { ConfirmRemoveRecoveryModal } from './ConfirmRemoveRecoveryModal' -import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryContext' export function DelayModifierRow({ delayModifier }: { delayModifier: RecoveryStateItem }): ReactElement | null { const { setTxFlow } = useContext(TxModalContext) diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index 4b259e915b..76811a9fc5 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -7,8 +7,7 @@ import { TxModalContext } from '@/components/tx-flow' import { Chip } from '@/components/common/Chip' import ExternalLink from '@/components/common/ExternalLink' import { DelayModifierRow } from './DelayModifierRow' -import useIsSafeOwner from '@/hooks/useIsSafeOwner' -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { useRecovery } from '@/components/recovery/RecoveryContext' import EthHashInfo from '@/components/common/EthHashInfo' import EnhancedTable from '@/components/common/EnhancedTable' import InfoIcon from '@/public/images/notifications/info.svg' @@ -68,8 +67,7 @@ const headCells = [ export function Recovery(): ReactElement { const { setTxFlow } = useContext(TxModalContext) - const [recovery] = useContext(RecoveryLoaderContext).state - const isOwner = useIsSafeOwner() + const [recovery] = useRecovery() const rows = useMemo(() => { return recovery?.flatMap((delayModifier) => { diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx index b71f1d6d71..77e6ff8d1d 100644 --- a/src/components/settings/SafeModules/index.tsx +++ b/src/components/settings/SafeModules/index.tsx @@ -8,7 +8,7 @@ import DeleteIcon from '@/public/images/common/delete.svg' import CheckWallet from '@/components/common/CheckWallet' import { useContext, useState } from 'react' import { TxModalContext } from '@/components/tx-flow' -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { useRecovery } from '@/components/recovery/RecoveryContext' import { selectDelayModifierByAddress } from '@/services/recovery/selectors' import { ConfirmRemoveRecoveryModal } from '../Recovery/ConfirmRemoveRecoveryModal' @@ -25,8 +25,8 @@ const NoModules = () => { const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => { const { setTxFlow } = useContext(TxModalContext) const [confirmRemoveRecovery, setConfirmRemoveRecovery] = useState(false) - const [data] = useContext(RecoveryLoaderContext).state - const delayModifier = data && selectDelayModifierByAddress(data, moduleAddress) + const [recovery] = useRecovery() + const delayModifier = recovery && selectDelayModifierByAddress(recovery, moduleAddress) const onRemove = () => { if (delayModifier) { diff --git a/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx b/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx index a138e3afa5..87809bd9c1 100644 --- a/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx +++ b/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryFlowReview.tsx @@ -8,7 +8,7 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { getRecoverySkipTransaction } from '@/services/recovery/transaction' import { createTx } from '@/services/tx/tx-sender' import ErrorMessage from '@/components/tx/ErrorMessage' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function CancelRecoveryFlowReview({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { const web3ReadOnly = useWeb3ReadOnly() diff --git a/src/components/tx-flow/flows/CancelRecovery/index.tsx b/src/components/tx-flow/flows/CancelRecovery/index.tsx index 2794f82c66..87274909bf 100644 --- a/src/components/tx-flow/flows/CancelRecovery/index.tsx +++ b/src/components/tx-flow/flows/CancelRecovery/index.tsx @@ -4,7 +4,7 @@ import TxLayout from '../../common/TxLayout' import { CancelRecoveryFlowReview } from './CancelRecoveryFlowReview' import { CancelRecoveryOverview } from './CancelRecoveryOverview' import useTxStepper from '../../useTxStepper' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function CancelRecoveryFlow({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { const { step, nextStep, prevStep } = useTxStepper(undefined) diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index 7ced4ac21c..63245f06e3 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -24,7 +24,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 { RecoveryContext } from '@/components/recovery/RecoveryContext' import type { RecoverAccountFlowProps } from '.' import commonCss from '@/components/tx-flow/common/styles.module.css' @@ -41,9 +41,11 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const { safe } = useSafeInfo() const wallet = useWallet() const onboard = useOnboard() - const [data] = useContext(RecoveryLoaderContext).state + const { + refetch, + state: [data], + } = useContext(RecoveryContext) const recovery = data && selectDelayModifierByGuardian(data, wallet?.address ?? '') - const { refetch } = useContext(RecoveryLoaderContext) // Proposal const txCooldown = recovery?.txCooldown?.toNumber() diff --git a/src/components/tx-flow/flows/RemoveRecovery/index.tsx b/src/components/tx-flow/flows/RemoveRecovery/index.tsx index c9868cb919..cfcc056d8c 100644 --- a/src/components/tx-flow/flows/RemoveRecovery/index.tsx +++ b/src/components/tx-flow/flows/RemoveRecovery/index.tsx @@ -5,7 +5,7 @@ import RecoveryPlus from '@/public/images/common/recovery-plus.svg' import useTxStepper from '../../useTxStepper' import { RemoveRecoveryFlowOverview } from './RemoveRecoveryFlowOverview' import { RemoveRecoveryFlowReview } from './RemoveRecoveryFlowReview' -import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryContext' export type RecoveryFlowProps = { delayModifier: RecoveryStateItem diff --git a/src/hooks/__tests__/useRecoveryTxState.test.tsx b/src/hooks/__tests__/useRecoveryTxState.test.tsx index a5fe06e8bf..3ee6a0ec47 100644 --- a/src/hooks/__tests__/useRecoveryTxState.test.tsx +++ b/src/hooks/__tests__/useRecoveryTxState.test.tsx @@ -3,7 +3,7 @@ import { faker } from '@faker-js/faker' import { useRecoveryTxState } from '../useRecoveryTxState' import { renderHook } from '@/tests/test-utils' -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { RecoveryContext } from '@/components/recovery/RecoveryContext' describe('useRecoveryTxState', () => { beforeEach(() => { @@ -46,7 +46,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[1].queue[0] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -83,7 +83,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -120,7 +120,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -157,7 +157,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -221,7 +221,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[1].queue[1] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -264,7 +264,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[0].queue[1] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -307,7 +307,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[0].queue[1] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) @@ -350,7 +350,7 @@ describe('useRecoveryTxState', () => { const { result } = renderHook(() => useRecoveryTxState(data[0].queue[1] as any), { wrapper: ({ children }) => ( - {children} + {children} ), }) diff --git a/src/hooks/useIsGuardian.ts b/src/hooks/useIsGuardian.ts index 16b987a3a7..8ba0ee89e1 100644 --- a/src/hooks/useIsGuardian.ts +++ b/src/hooks/useIsGuardian.ts @@ -1,11 +1,9 @@ -import { useContext } from 'react' - import { selectDelayModifierByGuardian } from '@/services/recovery/selectors' import useWallet from './wallets/useWallet' -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { useRecovery } from '@/components/recovery/RecoveryContext' export function useIsGuardian() { - const [data] = useContext(RecoveryLoaderContext).state + const [recovery] = useRecovery() const wallet = useWallet() - return !wallet?.address || !data || !selectDelayModifierByGuardian(data, wallet.address) + return !wallet?.address || !recovery || !selectDelayModifierByGuardian(recovery, wallet.address) } diff --git a/src/hooks/useIsRecoveryEnabled.ts b/src/hooks/useIsRecoveryEnabled.ts index f6b67320ad..90862befa6 100644 --- a/src/hooks/useIsRecoveryEnabled.ts +++ b/src/hooks/useIsRecoveryEnabled.ts @@ -1,8 +1,6 @@ -import { useContext } from 'react' - -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { useRecovery } from '@/components/recovery/RecoveryContext' export function useIsRecoveryEnabled(): boolean { - const [data] = useContext(RecoveryLoaderContext).state - return !!data && data.length > 0 + const [recovery] = useRecovery() + return !!recovery && recovery.length > 0 } diff --git a/src/hooks/useRecoveryQueue.ts b/src/hooks/useRecoveryQueue.ts index 33574a782b..f81b4486ae 100644 --- a/src/hooks/useRecoveryQueue.ts +++ b/src/hooks/useRecoveryQueue.ts @@ -1,13 +1,11 @@ -import { useContext } from 'react' - import { selectRecoveryQueues } from '@/services/recovery/selectors' import { useClock } from './useClock' -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import { useRecovery } from '@/components/recovery/RecoveryContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function useRecoveryQueue(): Array { - const [data] = useContext(RecoveryLoaderContext).state - const queue = data && selectRecoveryQueues(data) + const [recovery] = useRecovery() + const queue = recovery && selectRecoveryQueues(recovery) const clock = useClock() if (!queue) { diff --git a/src/hooks/useRecoveryTxState.ts b/src/hooks/useRecoveryTxState.ts index e5e3813fdf..d34f6b7da9 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -1,10 +1,8 @@ -import { useContext } from 'react' - import { useClock } from './useClock' import { selectDelayModifierByTxHash } from '@/services/recovery/selectors' -import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { useRecovery } from '@/components/recovery/RecoveryContext' import { sameAddress } from '@/utils/addresses' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args, address }: RecoveryQueueItem): { isNext: boolean @@ -12,8 +10,8 @@ export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args isExpired: boolean remainingSeconds: number } { - const [data] = useContext(RecoveryLoaderContext).state - const recovery = data && selectDelayModifierByTxHash(data, transactionHash) + const [recovery] = useRecovery() + const delayModifier = recovery && selectDelayModifierByTxHash(recovery, transactionHash) // We don't display seconds in the interface, so we can use a 60s interval const timestamp = useClock(60_000) @@ -23,7 +21,8 @@ export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args const isExpired = expiresAt ? expiresAt.toNumber() <= Date.now() : false // Check module address in case multiple Delay Modifiers enabled - const isNext = recovery ? sameAddress(recovery.address, address) && args.queueNonce.eq(recovery.txNonce) : false + const isNext = + !delayModifier || (sameAddress(delayModifier.address, address) && args.queueNonce.eq(delayModifier.txNonce)) const isExecutable = isNext && isValid && !isExpired const remainingSeconds = isValid ? 0 : Math.ceil(remainingMs.div(1_000).toNumber()) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c2fb947cef..a849581113 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -44,7 +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' +import { RecoveryProvider } from '@/components/recovery/RecoveryContext' const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING @@ -84,11 +84,11 @@ export const AppProviders = ({ children }: { children: ReactNode | ReactNode[] } {(safeTheme: Theme) => ( - + {children} - + )} diff --git a/src/services/recovery/__tests__/selectors.test.ts b/src/services/recovery/__tests__/selectors.test.ts index df24bce6ed..65967c6efd 100644 --- a/src/services/recovery/__tests__/selectors.test.ts +++ b/src/services/recovery/__tests__/selectors.test.ts @@ -7,7 +7,7 @@ import { selectDelayModifierByTxHash, selectDelayModifierByAddress, } from '../selectors' -import type { RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryContext' describe('selectors', () => { describe('selectDelayModifierByGuardian', () => { diff --git a/src/services/recovery/recovery-state.ts b/src/services/recovery/recovery-state.ts index 8ab265ca56..919c60fcc3 100644 --- a/src/services/recovery/recovery-state.ts +++ b/src/services/recovery/recovery-state.ts @@ -13,7 +13,7 @@ import { trimTrailingSlash } from '@/utils/url' import { sameAddress } from '@/utils/addresses' import { isMultiSendCalldata } from '@/utils/transaction-calldata' import { decodeMultiSendTxs } from '@/utils/transactions' -import type { RecoveryQueueItem, RecoveryState, RecoveryStateItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem, RecoveryState, RecoveryStateItem } from '@/components/recovery/RecoveryContext' const MAX_PAGE_SIZE = 100 diff --git a/src/services/recovery/selectors.ts b/src/services/recovery/selectors.ts index e4f3af6122..ebeabaa962 100644 --- a/src/services/recovery/selectors.ts +++ b/src/services/recovery/selectors.ts @@ -1,6 +1,6 @@ import { createSelector } from '@reduxjs/toolkit' -import type { RecoveryState } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryState } from '@/components/recovery/RecoveryContext' import { sameAddress } from '@/utils/addresses' // Identity function to help with type inference diff --git a/src/services/recovery/transaction.ts b/src/services/recovery/transaction.ts index b3ecc063fb..de94c7d8bd 100644 --- a/src/services/recovery/transaction.ts +++ b/src/services/recovery/transaction.ts @@ -7,7 +7,7 @@ import { sameAddress } from '@/utils/addresses' import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import type { JsonRpcProvider } from '@ethersproject/providers' export function getRecoveryProposalTransactions({