From f982e6804315266b205f0cd3fde2c469f574774a Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 23 Nov 2023 10:07:36 +0100 Subject: [PATCH] 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)) - }, -)