diff --git a/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx new file mode 100644 index 0000000000..a133870e26 --- /dev/null +++ b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link' +import { useMemo } from 'react' +import { useRouter } from 'next/router' +import { ChevronRight } from '@mui/icons-material' +import { Box } from '@mui/material' +import type { ReactElement } from 'react' + +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/RecoveryContext' + +import css from './styles.module.css' + +export function PendingRecoveryListItem({ transaction }: { transaction: RecoveryQueueItem }): ReactElement { + const router = useRouter() + const { isMalicious } = transaction + + const url = useMemo( + () => ({ + pathname: AppRoutes.transactions.queue, + query: router.query, + }), + [router.query], + ) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/dashboard/PendingTxs/PendingTxList.test.ts b/src/components/dashboard/PendingTxs/PendingTxList.test.ts new file mode 100644 index 0000000000..14f6cca3b9 --- /dev/null +++ b/src/components/dashboard/PendingTxs/PendingTxList.test.ts @@ -0,0 +1,71 @@ +import { BigNumber } from 'ethers' +import { faker } from '@faker-js/faker' +import { DetailedExecutionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import type { MultisigExecutionInfo, Transaction } from '@safe-global/safe-gateway-typescript-sdk' + +import { safeInfoBuilder } from '@/tests/builders/safe' +import { _getTransactionsToDisplay } from './PendingTxsList' +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', () => { + const walletAddress = faker.finance.ethereumAddress() + const safe = safeInfoBuilder().build() + const recoveryQueue = [ + { timestamp: BigNumber.from(1) }, + { timestamp: BigNumber.from(2) }, + { timestamp: BigNumber.from(3) }, + { timestamp: BigNumber.from(4) }, + { timestamp: BigNumber.from(5) }, + ] as Array + const queue = [] as Array + + const result = _getTransactionsToDisplay({ recoveryQueue, queue, walletAddress, safe }) + expect(result).toStrictEqual(recoveryQueue.slice(0, 4)) + }) + + it('should return the recovery queue followed by the actionable transactions from the queue', () => { + const walletAddress = faker.finance.ethereumAddress() + const safe = safeInfoBuilder().build() + const recoveryQueue = [ + { timestamp: BigNumber.from(1) }, + { timestamp: BigNumber.from(2) }, + { timestamp: BigNumber.from(3) }, + ] as Array + const actionableQueue = [ + { + transaction: { id: '1' }, + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + missingSigners: [walletAddress], + } as unknown as MultisigExecutionInfo, + } as unknown as Transaction, + { + transaction: { id: '2' }, + executionInfo: { + type: DetailedExecutionInfoType.MULTISIG, + missingSigners: [walletAddress], + } as unknown as MultisigExecutionInfo, + } as unknown as Transaction, + ] + + const expected = [...recoveryQueue, actionableQueue[0]] + const result = _getTransactionsToDisplay({ recoveryQueue, queue: actionableQueue, walletAddress, safe }) + expect(result).toEqual(expected) + }) + + it('should return the recovery queue followed by the transactions from the queue if there are no actionable transactions', () => { + const walletAddress = faker.finance.ethereumAddress() + const safe = safeInfoBuilder().build() + const recoveryQueue = [ + { timestamp: BigNumber.from(1) }, + { timestamp: BigNumber.from(2) }, + { timestamp: BigNumber.from(3) }, + ] as Array + const queue = [{ transaction: { id: '1' } }, { transaction: { id: '2' } }] as Array + + const expected = [...recoveryQueue, queue[0]] + const result = _getTransactionsToDisplay({ recoveryQueue, queue, walletAddress, safe }) + expect(result).toEqual(expected) + }) +}) diff --git a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx index 639822fb08..4014e5d1af 100644 --- a/src/components/dashboard/PendingTxs/PendingTxListItem.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxListItem.tsx @@ -42,34 +42,38 @@ const PendingTx = ({ transaction }: PendingTxType): ReactElement => { return ( - {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} + + {isMultisigExecutionInfo(transaction.executionInfo) && transaction.executionInfo.nonce} + - + - + - {isMultisigExecutionInfo(transaction.executionInfo) ? ( - - - - {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`} - - - ) : ( - - )} + + {isMultisigExecutionInfo(transaction.executionInfo) && ( + + + + {`${transaction.executionInfo.confirmationsSubmitted}/${transaction.executionInfo.confirmationsRequired}`} + + + )} + - {canExecute ? ( - - ) : canSign ? ( - - ) : ( - - )} + + {canExecute ? ( + + ) : canSign ? ( + + ) : ( + + )} + ) diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx index 263b5a726b..07130bd344 100644 --- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx @@ -12,6 +12,10 @@ import css from './styles.module.css' import { isSignableBy, isExecutable } from '@/utils/transaction-guards' import useWallet from '@/hooks/wallets/useWallet' 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/RecoveryContext' const MAX_TXS = 4 @@ -37,23 +41,58 @@ const LoadingState = () => ( ) +function getActionableTransactions(txs: Transaction[], safe: SafeInfo, walletAddress?: string): Transaction[] { + if (!walletAddress) { + return txs + } + + return txs.filter((tx) => { + return isSignableBy(tx.transaction, walletAddress) || isExecutable(tx.transaction, walletAddress, safe) + }) +} + +export function _getTransactionsToDisplay({ + recoveryQueue, + queue, + walletAddress, + safe, +}: { + recoveryQueue: RecoveryQueueItem[] + queue: Transaction[] + walletAddress?: string + safe: SafeInfo +}): (Transaction | RecoveryQueueItem)[] { + if (recoveryQueue.length >= MAX_TXS) { + return recoveryQueue.slice(0, MAX_TXS) + } + + const actionableQueue = getActionableTransactions(queue, safe, walletAddress) + const _queue = actionableQueue.length > 0 ? actionableQueue : queue + const queueToDisplay = _queue.slice(0, MAX_TXS - recoveryQueue.length) + + return [...recoveryQueue, ...queueToDisplay] +} + +function isRecoveryQueueItem(tx: Transaction | RecoveryQueueItem): tx is RecoveryQueueItem { + return 'args' in tx +} + const PendingTxsList = (): ReactElement | null => { const router = useRouter() const { page, loading } = useTxQueue() const { safe } = useSafeInfo() const wallet = useWallet() const queuedTxns = useMemo(() => getLatestTransactions(page?.results), [page?.results]) + const recoveryQueue = useRecoveryQueue() - const actionableTxs = useMemo(() => { - return wallet - ? queuedTxns.filter( - (tx) => isSignableBy(tx.transaction, wallet.address) || isExecutable(tx.transaction, wallet.address, safe), - ) - : queuedTxns - }, [wallet, queuedTxns, safe]) - - const txs = actionableTxs.length ? actionableTxs : queuedTxns - const txsToDisplay = txs.slice(0, MAX_TXS) + const txsToDisplay = useMemo(() => { + return _getTransactionsToDisplay({ + recoveryQueue, + queue: queuedTxns, + walletAddress: wallet?.address, + safe, + }) + }, [recoveryQueue, queuedTxns, wallet?.address, safe]) const queueUrl = useMemo( () => ({ @@ -76,11 +115,14 @@ const PendingTxsList = (): ReactElement | null => { {loading ? ( - ) : queuedTxns.length ? ( + ) : txsToDisplay.length > 0 ? ( - {txsToDisplay.map((tx) => ( - - ))} + {txsToDisplay.map((tx) => { + if (isRecoveryQueueItem(tx)) { + return + } + return + })} ) : ( diff --git a/src/components/dashboard/PendingTxs/styles.module.css b/src/components/dashboard/PendingTxs/styles.module.css index ad2a6fe17e..8b0091619f 100644 --- a/src/components/dashboard/PendingTxs/styles.module.css +++ b/src/components/dashboard/PendingTxs/styles.module.css @@ -1,11 +1,13 @@ .container { width: 100%; + min-height: 50px; padding: 8px 16px; background-color: var(--color-background-paper); border: 1px solid var(--color-border-light); border-radius: 8px; - flex-wrap: wrap; - display: flex; + display: grid; + grid-template-columns: minmax(30px, min-content) 0.5fr 1fr min-content min-content; + grid-template-areas: 'nonce type info confirmations action'; align-items: center; gap: var(--space-2); } @@ -44,12 +46,3 @@ color: var(--color-static-main); text-align: center; } - -@media (max-width: 599.95px) { - .txInfo { - width: 100%; - order: 1; - flex: auto; - margin-top: calc(var(--space-1) * -1); - } -} diff --git a/src/components/dashboard/Recovery/index.tsx b/src/components/dashboard/Recovery/index.tsx index d4c26d2d1b..00c1fa9a1f 100644 --- a/src/components/dashboard/Recovery/index.tsx +++ b/src/components/dashboard/Recovery/index.tsx @@ -7,8 +7,7 @@ import { WidgetBody, WidgetContainer } from '@/components/dashboard/styled' import { Chip } from '@/components/common/Chip' import { TxModalContext } from '@/components/tx-flow' import { UpsertRecoveryFlow } from '@/components/tx-flow/flows/UpsertRecovery' -import { useAppSelector } from '@/store' -import { selectRecovery } from '@/store/recoverySlice' +import { useRecovery } from '@/components/recovery/RecoveryContext' import { useRouter } from 'next/router' import { AppRoutes } from '@/config/routes' import CheckWallet from '@/components/common/CheckWallet' @@ -20,7 +19,7 @@ import css from './styles.module.css' export function Recovery(): ReactElement { const router = useRouter() const { setTxFlow } = useContext(TxModalContext) - const recovery = useAppSelector(selectRecovery) + const [recovery] = useRecovery() const supportsRecovery = useHasFeature(FEATURES.RECOVERY) const onEnable = () => { @@ -59,7 +58,7 @@ export function Recovery(): ReactElement { {supportsRecovery && ( {(isOk) => { - if (recovery.length === 0) { + if (!recovery || recovery.length === 0) { return ( Set up recovery diff --git a/src/components/dashboard/RecoveryHeader/index.test.tsx b/src/components/dashboard/RecoveryHeader/index.test.tsx index fe89e17587..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 '@/store/recoverySlice' +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 533b8aae2a..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 '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function _RecoveryHeader({ isGuardian, diff --git a/src/components/recovery/SkipRecoveryButton/index.tsx b/src/components/recovery/CancelRecoveryButton/index.tsx similarity index 79% rename from src/components/recovery/SkipRecoveryButton/index.tsx rename to src/components/recovery/CancelRecoveryButton/index.tsx index dfb01d92d6..218d586d64 100644 --- a/src/components/recovery/SkipRecoveryButton/index.tsx +++ b/src/components/recovery/CancelRecoveryButton/index.tsx @@ -6,10 +6,10 @@ import ErrorIcon from '@/public/images/notifications/error.svg' 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 { CancelRecoveryFlow } from '@/components/tx-flow/flows/CancelRecovery' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' -export function SkipRecoveryButton({ +export function CancelRecoveryButton({ recovery, compact = false, }: { @@ -22,7 +22,7 @@ export function SkipRecoveryButton({ e.stopPropagation() e.preventDefault() - setTxFlow() + setTxFlow() } return ( @@ -34,7 +34,7 @@ export function SkipRecoveryButton({ ) : ( - Skip + Cancel ) } diff --git a/src/components/recovery/ExecuteRecoveryButton/index.tsx b/src/components/recovery/ExecuteRecoveryButton/index.tsx index a548946f62..5e7f13ed18 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' @@ -9,7 +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 { RecoveryContext } from '../RecoveryContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function ExecuteRecoveryButton({ recovery, @@ -21,6 +23,7 @@ export function ExecuteRecoveryButton({ const { isExecutable } = useRecoveryTxState(recovery) const onboard = useOnboard() const { safe } = useSafeInfo() + const { refetch } = useContext(RecoveryContext) 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/RecoveryCards/RecoveryInProgressCard.tsx b/src/components/recovery/RecoveryCards/RecoveryInProgressCard.tsx index 2598a8b222..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 '@/store/recoverySlice' +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 91b0d95cbe..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 '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' jest.mock('@/hooks/useRecoveryTxState') diff --git a/src/components/recovery/RecoveryContext/__tests__/index.test.tsx b/src/components/recovery/RecoveryContext/__tests__/index.test.tsx new file mode 100644 index 0000000000..9565e9650f --- /dev/null +++ b/src/components/recovery/RecoveryContext/__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 { RecoveryContext, RecoveryProvider } 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('RecoveryContext', () => { + 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(RecoveryContext) + + return Refetch + } + + 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/RecoveryContext/__tests__/useDelayModifier.test.ts b/src/components/recovery/RecoveryContext/__tests__/useDelayModifier.test.ts new file mode 100644 index 0000000000..b875e2dced --- /dev/null +++ b/src/components/recovery/RecoveryContext/__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/RecoveryContext/__tests__/useRecoveryState.test.ts b/src/components/recovery/RecoveryContext/__tests__/useRecoveryState.test.ts new file mode 100644 index 0000000000..4a9934d77a --- /dev/null +++ b/src/components/recovery/RecoveryContext/__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/RecoveryContext/index.tsx b/src/components/recovery/RecoveryContext/index.tsx new file mode 100644 index 0000000000..99e3382838 --- /dev/null +++ b/src/components/recovery/RecoveryContext/index.tsx @@ -0,0 +1,90 @@ +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' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useRecoveryState } from './useRecoveryState' +import { useDelayModifiers } from './useDelayModifiers' +import type { AsyncResult } from '@/hooks/useAsync' + +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 RecoveryContext = createContext<{ + state: AsyncResult + refetch: () => void +}>({ + state: [undefined, undefined, false], + refetch: () => {}, +}) + +export function RecoveryProvider({ children }: { children: ReactNode }): ReactElement { + const { safe } = useSafeInfo() + + 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 || delayModifiers.length === 0) { + return + } + + return txSubscribe(TxEvent.PROCESSED, async (detail) => { + if (!detail.txId) { + return + } + + const { txData } = await getTxDetails(detail.txId, safe.chainId) + + if (!txData) { + return + } + + const isDelayModifierTx = delayModifiers.some((delayModifier) => { + return sameAddress(delayModifier.address, txData.to.value) + }) + + if (isDelayModifierTx) { + refetch() + } + }) + }, [safe.chainId, delayModifiers, refetch]) + + const data = recoveryState + const error = delayModifiersError || recoveryStateError + const loading = delayModifiersLoading || recoveryStateLoading + + return ( + {children} + ) +} + +export function useRecovery(): AsyncResult { + return useContext(RecoveryContext).state +} diff --git a/src/components/recovery/RecoveryContext/useDelayModifiers.ts b/src/components/recovery/RecoveryContext/useDelayModifiers.ts new file mode 100644 index 0000000000..8016c2267a --- /dev/null +++ b/src/components/recovery/RecoveryContext/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/RecoveryContext/useRecoveryState.ts b/src/components/recovery/RecoveryContext/useRecoveryState.ts new file mode 100644 index 0000000000..47933e537f --- /dev/null +++ b/src/components/recovery/RecoveryContext/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/RecoveryDetails/index.tsx b/src/components/recovery/RecoveryDetails/index.tsx index 46a2773e03..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 '@/store/recoverySlice' +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' @@ -61,7 +61,9 @@ export function RecoveryDetails({ item }: { item: RecoveryQueueItem }): ReactEle ) : ( - This transaction potentially calls malicious actions. We recommend skipping it. + + This transaction potentially calls malicious actions. We recommend cancelling it. + )} diff --git a/src/components/recovery/RecoveryInfo/index.tsx b/src/components/recovery/RecoveryInfo/index.tsx index 5f2fa6f281..f657cb5b91 100644 --- a/src/components/recovery/RecoveryInfo/index.tsx +++ b/src/components/recovery/RecoveryInfo/index.tsx @@ -3,7 +3,11 @@ import type { ReactElement } from 'react' import WarningIcon from '@/public/images/notifications/warning.svg' -export const RecoveryInfo = (): ReactElement => { +export const RecoveryInfo = ({ isMalicious }: { isMalicious: boolean }): ReactElement | null => { + if (!isMalicious) { + return null + } + return ( 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..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 '@/store/recoverySlice' +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 5e8c2a0e95..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 '@/store/recoverySlice' +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 703865d947..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 '@/store/recoverySlice' +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 1133ab7d45..38e31fc129 100644 --- a/src/components/recovery/RecoverySigners/index.tsx +++ b/src/components/recovery/RecoverySigners/index.tsx @@ -6,9 +6,9 @@ import CheckIcon from '@/public/images/common/circle-check.svg' import EthHashInfo from '@/components/common/EthHashInfo' import { Countdown } from '@/components/common/Countdown' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' -import { SkipRecoveryButton } from '../SkipRecoveryButton' +import { CancelRecoveryButton } from '../CancelRecoveryButton' import { useRecoveryTxState } from '@/hooks/useRecoveryTxState' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import txSignersCss from '@/components/transactions/TxSigners/styles.module.css' import { formatDate } from '@/utils/date' @@ -69,7 +69,7 @@ export function RecoverySigners({ item }: { item: RecoveryQueueItem }): ReactEle - + > ) diff --git a/src/components/recovery/RecoveryStatus/index.tsx b/src/components/recovery/RecoveryStatus/index.tsx index 3cc690bbd0..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 '@/store/recoverySlice' +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 4003b3b6a1..a017611ade 100644 --- a/src/components/recovery/RecoverySummary/index.tsx +++ b/src/components/recovery/RecoverySummary/index.tsx @@ -6,9 +6,9 @@ import { RecoveryType } from '../RecoveryType' import { RecoveryInfo } from '../RecoveryInfo' import { RecoveryStatus } from '../RecoveryStatus' import { ExecuteRecoveryButton } from '../ExecuteRecoveryButton' -import { SkipRecoveryButton } from '../SkipRecoveryButton' +import { CancelRecoveryButton } from '../CancelRecoveryButton' import useWallet from '@/hooks/wallets/useWallet' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' import txSummaryCss from '@/components/transactions/TxSummary/styles.module.css' @@ -22,16 +22,14 @@ export function RecoverySummary({ item }: { item: RecoveryQueueItem }): ReactEle - {isMalicious && ( - - - - )} + + + {wallet && ( - + )} diff --git a/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx b/src/components/settings/Recovery/ConfirmRemoveRecoveryModal.tsx index aceed86f39..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 { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryContext' 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 b241bfcfca..9a0e77c98d 100644 --- a/src/components/settings/Recovery/DelayModifierRow.tsx +++ b/src/components/settings/Recovery/DelayModifierRow.tsx @@ -9,9 +9,9 @@ import EditIcon from '@/public/images/common/edit.svg' import CheckWallet from '@/components/common/CheckWallet' import { ConfirmRemoveRecoveryModal } from './ConfirmRemoveRecoveryModal' import { UpsertRecoveryFlow } from '@/components/tx-flow/flows/UpsertRecovery' -import type { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryStateItem } from '@/components/recovery/RecoveryContext' -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 e31899b837..e26432a0be 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -7,9 +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 { useAppSelector } from '@/store' -import { selectRecovery } from '@/store/recoverySlice' +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' @@ -70,11 +68,10 @@ const headCells = [ // TODO: Combine section with spending limits under "Security & Login" as per design export function Recovery(): ReactElement { const { setTxFlow } = useContext(TxModalContext) - const recovery = useAppSelector(selectRecovery) - const isOwner = useIsSafeOwner() + const [recovery] = useRecovery() const rows = useMemo(() => { - return recovery.flatMap((delayModifier) => { + return recovery?.flatMap((delayModifier) => { const { guardians, txCooldown, txExpiration } = delayModifier return guardians.map((guardian) => { @@ -139,7 +136,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 */} @@ -160,9 +157,9 @@ export function Recovery(): ReactElement { )} > - ) : ( + ) : rows ? ( - )} + ) : null} diff --git a/src/components/settings/RecoveryEmail/AddEmailDialog.tsx b/src/components/settings/RecoveryEmail/AddEmailDialog.tsx new file mode 100644 index 0000000000..ca3e0879dd --- /dev/null +++ b/src/components/settings/RecoveryEmail/AddEmailDialog.tsx @@ -0,0 +1,48 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + TextField, +} from '@mui/material' +import type { ReactElement } from 'react' + +import CloseIcon from '@/public/images/common/close.svg' + +import css from './styles.module.css' + +export default function AddEmailDialog({ open, onClose }: { open: boolean; onClose: () => void }): ReactElement { + const onConfirm = () => { + // TODO: Implement + onClose() + } + + return ( + + + Add email address + + + + + + + + You will need to sign a message to verify that you are the owner of this Safe Account. + + + + + + + Cancel + + Continue + + + + ) +} diff --git a/src/components/settings/RecoveryEmail/index.tsx b/src/components/settings/RecoveryEmail/index.tsx new file mode 100644 index 0000000000..f174aeaf34 --- /dev/null +++ b/src/components/settings/RecoveryEmail/index.tsx @@ -0,0 +1,89 @@ +import { Button, Grid, Paper, SvgIcon, Typography } from '@mui/material' +import { VisibilityOutlined } from '@mui/icons-material' +import { useState } from 'react' +import type { ReactElement } from 'react' + +import ExternalLink from '@/components/common/ExternalLink' +import AddEmailDialog from './AddEmailDialog' +import EditIcon from '@/public/images/common/edit.svg' + +import css from './styles.module.css' + +export function RecoveryEmail(): ReactElement { + const [addEmail, setAddEmail] = useState(false) + + const onAdd = () => { + setAddEmail(true) + } + + const onReveal = () => { + // TODO: Implement + } + + const onChange = () => {} + + const onClose = () => {} + + const randomString = Math.random().toString(36) + + return ( + <> + + + + + Recovery email + + + + + + Receive important notifications about recovery attempts and their status. No spam. We promise!{' '} + {/* TODO: Add link */} + Learn more + + + + + + + {randomString + randomString} + + + + + + } + className={css.button} + disableElevation + > + Reveal + + } + className={css.button} + disableElevation + > + Change + + + + + + Add email address + + + + + + + > + ) +} diff --git a/src/components/settings/RecoveryEmail/styles.module.css b/src/components/settings/RecoveryEmail/styles.module.css new file mode 100644 index 0000000000..5c6208787a --- /dev/null +++ b/src/components/settings/RecoveryEmail/styles.module.css @@ -0,0 +1,72 @@ +/* Settings */ + +.display { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--color-background-main); + border: 1px solid var(--color-border-light); + border-radius: 6px; + padding: var(--space-1); + padding-left: var(--space-2); + margin-bottom: var(--space-2); +} + +.email { + display: flex; + align-items: center; + gap: var(--space-1); + position: relative; +} + +.blur { + backdrop-filter: blur(6px); + border-radius: 6px; + position: absolute; + top: 0; + left: 0; + height: calc(100% + var(--space-3)); + width: calc(100% + var(--space-6)); + transform: translate(calc(var(--space-2) * -1), calc(calc(var(--space-1) * 1.5) * -1)); +} + +.buttons { + display: flex; + gap: var(--space-1); +} + +.button { + color: var(--color-text-main); + border: 1px solid var(--color-border-light); + background-color: var(--color-background-paper); + padding-left: var(--space-2); + padding-right: var(--space-2); +} + +/* Dialog */ + +.dialog :global(.MuiDialog-paper) { + max-width: 500px; +} + +.title { + display: flex; + align-items: center; + font-weight: 700; + padding-top: var(--space-3); +} + +.close { + color: var(--color-text-secondary); + margin-left: auto; +} + +.content { + padding: var(--space-2) var(--space-3) var(--space-4); +} + +.actions { + display: flex; + justify-content: space-between; + padding: var(--space-3); +} diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx index 3f8a937d32..e2b7dc9909 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 { selectDelayModifierByAddress } from '@/services/recovery/selectors' import { ConfirmRemoveRecoveryModal } from '../Recovery/ConfirmRemoveRecoveryModal' +import { useRecovery } from '@/components/recovery/RecoveryContext' + 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 [recovery] = useRecovery() + const delayModifier = recovery && selectDelayModifierByAddress(recovery, 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 39c7eddecb..21e249509d 100644 --- a/src/components/tx-flow/common/OwnerList/index.tsx +++ b/src/components/tx-flow/common/OwnerList/index.tsx @@ -21,7 +21,7 @@ export function OwnerList({ - {title ?? `New owner{owners.length > 1 ? 's' : ''}`} + {title ?? `New owner${owners.length > 1 ? 's' : ''}`} {owners.map((newOwner) => ( null} isBatchable={false}> - - To reject the recovery attempt, a separate transaction will be created to increase the nonce beyond the - proposal. + + This transaction will initiate the cancellation of the{' '} + {recovery.isMalicious ? 'malicious transaction' : 'recovery attempt'}. It requires other owner signatures in + order to be complete. - - Queue nonce: {recovery.args.queueNonce.toNumber()} - - - You will need to confirm the transaction with your currently connected wallet. + + All actions initiated by the guardian will be skipped. The current owners will remain the owners of the Safe + Account. + ) } diff --git a/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx b/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx new file mode 100644 index 0000000000..ca95fb326f --- /dev/null +++ b/src/components/tx-flow/flows/CancelRecovery/CancelRecoveryOverview.tsx @@ -0,0 +1,43 @@ +import { Box, Button, Typography } from '@mui/material' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import ReplaceTxIcon from '@/public/images/transactions/replace-tx.svg' +import { TxModalContext } from '../..' +import TxCard from '../../common/TxCard' + +export function CancelRecoveryOverview({ onSubmit }: { onSubmit: () => void }): ReactElement { + const { setTxFlow } = useContext(TxModalContext) + + const onClose = () => { + setTxFlow(undefined) + } + + return ( + + + {/* TODO: Replace with correct icon when provided */} + + + + Do you want to cancel the Account recovery? + + + + If it is was an unwanted recovery attempt or you've noticed something suspicious, you can cancel it by + increasing the nonce of the recovery module. + + + + + Go back + + + + Yes, cancel recovery + + + + + ) +} diff --git a/src/components/tx-flow/flows/CancelRecovery/index.tsx b/src/components/tx-flow/flows/CancelRecovery/index.tsx new file mode 100644 index 0000000000..87274909bf --- /dev/null +++ b/src/components/tx-flow/flows/CancelRecovery/index.tsx @@ -0,0 +1,30 @@ +import type { ReactElement } from 'react' + +import TxLayout from '../../common/TxLayout' +import { CancelRecoveryFlowReview } from './CancelRecoveryFlowReview' +import { CancelRecoveryOverview } from './CancelRecoveryOverview' +import useTxStepper from '../../useTxStepper' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' + +export function CancelRecoveryFlow({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { + const { step, nextStep, prevStep } = useTxStepper(undefined) + + const steps = [ + nextStep(undefined)} />, + , + ] + + const isIntro = step === 0 + + return ( + + {steps} + + ) +} diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx index 7f8cc7edc0..63245f06e3 100644 --- a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -17,14 +17,14 @@ 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 '../..' import { asError } from '@/services/exceptions/utils' import { trackError, Errors } from '@/services/exceptions' import { getCountdown } from '@/utils/date' +import { RecoveryContext } from '@/components/recovery/RecoveryContext' import type { RecoverAccountFlowProps } from '.' import commonCss from '@/components/tx-flow/common/styles.module.css' @@ -41,7 +41,11 @@ export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlo const { safe } = useSafeInfo() const wallet = useWallet() const onboard = useOnboard() - const recovery = useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) + const { + refetch, + state: [data], + } = useContext(RecoveryContext) + const recovery = data && selectDelayModifierByGuardian(data, wallet?.address ?? '') // Proposal const txCooldown = recovery?.txCooldown?.toNumber() @@ -71,7 +75,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/components/tx-flow/flows/RemoveRecovery/index.tsx b/src/components/tx-flow/flows/RemoveRecovery/index.tsx index c173cdc583..cfcc056d8c 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/RecoveryContext' export type RecoveryFlowProps = { - delayModifier: RecoveryState[number] + delayModifier: RecoveryStateItem } export function RemoveRecoveryFlow({ delayModifier }: RecoveryFlowProps): ReactElement { diff --git a/src/components/tx-flow/flows/SkipRecovery/index.tsx b/src/components/tx-flow/flows/SkipRecovery/index.tsx deleted file mode 100644 index 6da4a1f3ef..0000000000 --- a/src/components/tx-flow/flows/SkipRecovery/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactElement } from 'react' - -import TxLayout from '../../common/TxLayout' -import { SkipRecoveryFlowReview } from './SkipRecoveryFlowReview' -import type { RecoveryQueueItem } from '@/store/recoverySlice' - -export function SkipRecoveryFlow({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { - return ( - - - - ) -} diff --git a/src/components/tx-flow/flows/UpsertRecovery/index.tsx b/src/components/tx-flow/flows/UpsertRecovery/index.tsx index 89ea1c79d2..aeb49f7daa 100644 --- a/src/components/tx-flow/flows/UpsertRecovery/index.tsx +++ b/src/components/tx-flow/flows/UpsertRecovery/index.tsx @@ -7,7 +7,7 @@ import { UpsertRecoveryFlowReview as UpsertRecoveryFlowReview } from './UpsertRe import { UpsertRecoveryFlowSettings as UpsertRecoveryFlowSettings } from './UpsertRecoveryFlowSettings' import { UpsertRecoveryFlowIntro as UpsertRecoveryFlowIntro } from './UpsertRecoveryFlowIntro' import { DAY_IN_SECONDS } from './useRecoveryPeriods' -import type { RecoveryState } from '@/store/recoverySlice' +import type { RecoveryState } from '@/components/recovery/RecoveryContext' const Subtitles = ['How does recovery work?', 'Set up recovery settings', 'Set up account recovery'] diff --git a/src/components/tx/ErrorMessage/index.tsx b/src/components/tx/ErrorMessage/index.tsx index 5f6cc07932..bbae5fc712 100644 --- a/src/components/tx/ErrorMessage/index.tsx +++ b/src/components/tx/ErrorMessage/index.tsx @@ -26,7 +26,12 @@ const ErrorMessage = ({ return ( - + `${palette[level].main} !important` }} + /> 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/__tests__/useRecoveryTxState.test.ts b/src/hooks/__tests__/useRecoveryTxState.test.ts deleted file mode 100644 index c71d276ac5..0000000000 --- a/src/hooks/__tests__/useRecoveryTxState.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -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' - -describe('useRecoveryTxState', () => { - beforeEach(() => { - jest.useFakeTimers() - }) - - describe('Next', () => { - it('should return correct values when validFrom is in the future and expiresAt is in the future', () => { - jest.setSystemTime(0) - - jest.spyOn(store, 'useAppSelector').mockReturnValue({ - txNonce: BigNumber.from(0), - } as unknown as RecoveryQueueItem) - - const validFrom = BigNumber.from(1_000) - const expiresAt = BigNumber.from(1_000) - const recoveryQueueItem = { validFrom, expiresAt, args: { queueNonce: BigNumber.from(0) } } as RecoveryQueueItem - - const { result } = renderHook(() => useRecoveryTxState(recoveryQueueItem)) - - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(1) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(true) - }) - - it('should return correct values when validFrom is in the past and expiresAt is in the future', () => { - jest.setSystemTime(1_000) - - jest.spyOn(store, 'useAppSelector').mockReturnValue({ - txNonce: BigNumber.from(0), - } as unknown as RecoveryQueueItem) - - const validFrom = BigNumber.from(0) - const expiresAt = BigNumber.from(2_000) - const recoveryQueueItem = { validFrom, expiresAt, args: { queueNonce: BigNumber.from(0) } } as RecoveryQueueItem - - const { result } = renderHook(() => useRecoveryTxState(recoveryQueueItem)) - - expect(result.current.isExecutable).toBe(true) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(true) - }) - - it('should return correct values when validFrom is in the past and expiresAt is in the past', () => { - jest.setSystemTime(1_000) - - jest.spyOn(store, 'useAppSelector').mockReturnValue({ - txNonce: BigNumber.from(0), - } as unknown as RecoveryQueueItem) - - const validFrom = BigNumber.from(0) - const expiresAt = BigNumber.from(0) - const recoveryQueueItem = { validFrom, expiresAt, args: { queueNonce: BigNumber.from(0) } } as RecoveryQueueItem - - const { result } = renderHook(() => useRecoveryTxState(recoveryQueueItem)) - - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(true) - expect(result.current.isNext).toBe(true) - }) - }) - - describe('Queue', () => { - it('should return correct values when validFrom is in the future and expiresAt is in the future', () => { - jest.setSystemTime(0) - - jest.spyOn(store, 'useAppSelector').mockReturnValue({ - txNonce: BigNumber.from(1), - } as unknown as RecoveryQueueItem) - - const validFrom = BigNumber.from(1_000) - const expiresAt = BigNumber.from(1_000) - const recoveryQueueItem = { validFrom, expiresAt, args: { queueNonce: BigNumber.from(0) } } as RecoveryQueueItem - - const { result } = renderHook(() => useRecoveryTxState(recoveryQueueItem)) - - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(1) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(false) - }) - - it('should return correct values when validFrom is in the past and expiresAt is in the future', () => { - jest.setSystemTime(1_000) - - jest.spyOn(store, 'useAppSelector').mockReturnValue({ - txNonce: BigNumber.from(1), - } as unknown as RecoveryQueueItem) - - const validFrom = BigNumber.from(0) - const expiresAt = BigNumber.from(2_000) - const recoveryQueueItem = { validFrom, expiresAt, args: { queueNonce: BigNumber.from(0) } } as RecoveryQueueItem - - const { result } = renderHook(() => useRecoveryTxState(recoveryQueueItem)) - - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(false) - expect(result.current.isNext).toBe(false) - }) - - it('should return correct values when validFrom is in the past and expiresAt is in the past', () => { - jest.setSystemTime(1_000) - - jest.spyOn(store, 'useAppSelector').mockReturnValue({ - txNonce: BigNumber.from(1), - } as unknown as RecoveryQueueItem) - - const validFrom = BigNumber.from(0) - const expiresAt = BigNumber.from(0) - const recoveryQueueItem = { validFrom, expiresAt, args: { queueNonce: BigNumber.from(0) } } as RecoveryQueueItem - - const { result } = renderHook(() => useRecoveryTxState(recoveryQueueItem)) - - expect(result.current.isExecutable).toBe(false) - expect(result.current.remainingSeconds).toBe(0) - expect(result.current.isExpired).toBe(true) - expect(result.current.isNext).toBe(false) - }) - }) -}) diff --git a/src/hooks/__tests__/useRecoveryTxState.test.tsx b/src/hooks/__tests__/useRecoveryTxState.test.tsx new file mode 100644 index 0000000000..3ee6a0ec47 --- /dev/null +++ b/src/hooks/__tests__/useRecoveryTxState.test.tsx @@ -0,0 +1,363 @@ +import { BigNumber } from 'ethers' +import { faker } from '@faker-js/faker' + +import { useRecoveryTxState } from '../useRecoveryTxState' +import { renderHook } from '@/tests/test-utils' +import { RecoveryContext } from '@/components/recovery/RecoveryContext' + +describe('useRecoveryTxState', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + describe('Next', () => { + it('should handle multiple Delay Modifiers', () => { + jest.setSystemTime(0) + + const delayModifierAddress1 = faker.finance.ethereumAddress() + const nextTxHash1 = faker.string.hexadecimal() + + const delayModifierAddress2 = faker.finance.ethereumAddress() + const nextTxHash2 = faker.string.hexadecimal() + + const validFrom = BigNumber.from(1_000) + const expiresAt = BigNumber.from(1_000) + + const data = [ + { + address: delayModifierAddress1, + txNonce: BigNumber.from(0), + queue: [{ address: delayModifierAddress1, transactionHash: nextTxHash1 }], + }, + { + address: delayModifierAddress2, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress2, + transactionHash: nextTxHash2, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(0) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[1].queue[0] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(1) + expect(result.current.isExpired).toBe(false) + expect(result.current.isNext).toBe(true) + }) + + it('should return correct values when validFrom is in the future and expiresAt is in the future', () => { + jest.setSystemTime(0) + + const delayModifierAddress = faker.finance.ethereumAddress() + const nextTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(1_000) + const expiresAt = BigNumber.from(1_000) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(0) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(1) + expect(result.current.isExpired).toBe(false) + expect(result.current.isNext).toBe(true) + }) + + it('should return correct values when validFrom is in the past and expiresAt is in the future', () => { + jest.setSystemTime(1_000) + + const delayModifierAddress = faker.finance.ethereumAddress() + const nextTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(0) + const expiresAt = BigNumber.from(2_000) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(0) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(true) + expect(result.current.remainingSeconds).toBe(0) + expect(result.current.isExpired).toBe(false) + expect(result.current.isNext).toBe(true) + }) + + it('should return correct values when validFrom is in the past and expiresAt is in the past', () => { + jest.setSystemTime(1_000) + + const delayModifierAddress = faker.finance.ethereumAddress() + const nextTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(0) + const expiresAt = BigNumber.from(0) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(0) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[0] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(0) + expect(result.current.isExpired).toBe(true) + expect(result.current.isNext).toBe(true) + }) + }) + + describe('Queue', () => { + it('should handle multiple Delay Modifiers', () => { + jest.setSystemTime(0) + + const delayModifierAddress1 = faker.finance.ethereumAddress() + + const nextTxHash1 = faker.string.hexadecimal() + const queueTxHash1 = faker.string.hexadecimal() + + const delayModifierAddress2 = faker.finance.ethereumAddress() + + const nextTxHash2 = faker.string.hexadecimal() + const queueTxHash2 = faker.string.hexadecimal() + + const validFrom = BigNumber.from(1_000) + const expiresAt = BigNumber.from(1_000) + + const data = [ + { + address: delayModifierAddress1, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress1, + transactionHash: nextTxHash1, + }, + { + address: delayModifierAddress1, + transactionHash: queueTxHash1, + }, + ], + }, + { + address: delayModifierAddress2, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress2, + transactionHash: nextTxHash2, + }, + { + address: delayModifierAddress2, + transactionHash: queueTxHash2, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(1) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[1].queue[1] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(1) + expect(result.current.isExpired).toBe(false) + expect(result.current.isNext).toBe(false) + }) + + it('should return correct values when validFrom is in the future and expiresAt is in the future', () => { + jest.setSystemTime(0) + + const delayModifierAddress = faker.finance.ethereumAddress() + + const nextTxHash = faker.string.hexadecimal() + const queueTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(1_000) + const expiresAt = BigNumber.from(1_000) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + }, + { + address: delayModifierAddress, + transactionHash: queueTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(1) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[1] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(1) + expect(result.current.isExpired).toBe(false) + expect(result.current.isNext).toBe(false) + }) + + it('should return correct values when validFrom is in the past and expiresAt is in the future', () => { + jest.setSystemTime(1_000) + + const delayModifierAddress = faker.finance.ethereumAddress() + + const nextTxHash = faker.string.hexadecimal() + const queueTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(0) + const expiresAt = BigNumber.from(2_000) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + }, + { + address: delayModifierAddress, + transactionHash: queueTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(1) }, + }, + ], + }, + ] + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[1] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(0) + expect(result.current.isExpired).toBe(false) + expect(result.current.isNext).toBe(false) + }) + + it('should return correct values when validFrom is in the past and expiresAt is in the past', () => { + jest.setSystemTime(1_000) + + const delayModifierAddress = faker.finance.ethereumAddress() + + const nextTxHash = faker.string.hexadecimal() + const queueTxHash = faker.string.hexadecimal() + + const validFrom = BigNumber.from(0) + const expiresAt = BigNumber.from(0) + + const data = [ + { + address: delayModifierAddress, + txNonce: BigNumber.from(0), + queue: [ + { + address: delayModifierAddress, + transactionHash: nextTxHash, + }, + { + address: delayModifierAddress, + transactionHash: queueTxHash, + validFrom, + expiresAt, + args: { queueNonce: BigNumber.from(1) }, + }, + ], + }, + ] as const + + const { result } = renderHook(() => useRecoveryTxState(data[0].queue[1] as any), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isExecutable).toBe(false) + expect(result.current.remainingSeconds).toBe(0) + expect(result.current.isExpired).toBe(true) + expect(result.current.isNext).toBe(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/useIsGuardian.ts b/src/hooks/useIsGuardian.ts index eb2ae9eb4e..8ba0ee89e1 100644 --- a/src/hooks/useIsGuardian.ts +++ b/src/hooks/useIsGuardian.ts @@ -1,8 +1,9 @@ -import { useAppSelector } from '@/store' -import { selectDelayModifierByGuardian } from '@/store/recoverySlice' +import { selectDelayModifierByGuardian } from '@/services/recovery/selectors' import useWallet from './wallets/useWallet' +import { useRecovery } from '@/components/recovery/RecoveryContext' export function useIsGuardian() { + const [recovery] = useRecovery() const wallet = useWallet() - return !!useAppSelector((state) => selectDelayModifierByGuardian(state, wallet?.address ?? '')) + return !wallet?.address || !recovery || !selectDelayModifierByGuardian(recovery, wallet.address) } diff --git a/src/hooks/useIsRecoveryEnabled.ts b/src/hooks/useIsRecoveryEnabled.ts new file mode 100644 index 0000000000..90862befa6 --- /dev/null +++ b/src/hooks/useIsRecoveryEnabled.ts @@ -0,0 +1,6 @@ +import { useRecovery } from '@/components/recovery/RecoveryContext' + +export function useIsRecoveryEnabled(): boolean { + const [recovery] = useRecovery() + return !!recovery && recovery.length > 0 +} diff --git a/src/hooks/useLoadableStores.ts b/src/hooks/useLoadableStores.ts index b49a02de03..0a0ed24fda 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,7 +20,6 @@ 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 => { @@ -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/hooks/useRecoveryQueue.ts b/src/hooks/useRecoveryQueue.ts index 69bf04458c..f81b4486ae 100644 --- a/src/hooks/useRecoveryQueue.ts +++ b/src/hooks/useRecoveryQueue.ts @@ -1,12 +1,17 @@ -import { useAppSelector } from '@/store' -import { selectRecoveryQueues } from '@/store/recoverySlice' +import { selectRecoveryQueues } from '@/services/recovery/selectors' import { useClock } from './useClock' -import type { RecoveryQueueItem } from '@/store/recoverySlice' +import { useRecovery } from '@/components/recovery/RecoveryContext' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' export function useRecoveryQueue(): Array { - const queue = useAppSelector(selectRecoveryQueues) + const [recovery] = useRecovery() + const queue = recovery && selectRecoveryQueues(recovery) 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..d34f6b7da9 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -1,15 +1,17 @@ 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 { useRecovery } from '@/components/recovery/RecoveryContext' +import { sameAddress } from '@/utils/addresses' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' -export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args }: RecoveryQueueItem): { +export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args, address }: RecoveryQueueItem): { isNext: boolean isExecutable: boolean isExpired: boolean remainingSeconds: number } { - const recovery = useAppSelector((state) => selectDelayModifierByTxHash(state, 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) @@ -17,7 +19,10 @@ export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args const isValid = remainingMs.lte(0) const isExpired = expiresAt ? expiresAt.toNumber() <= Date.now() : false - const isNext = recovery ? args.queueNonce.eq(recovery.txNonce) : false + + // Check module address in case multiple Delay Modifiers enabled + 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 e7b3d6d3d7..a849581113 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 { RecoveryProvider } from '@/components/recovery/RecoveryContext' 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/pages/settings/notifications.tsx b/src/pages/settings/notifications.tsx index 66dc3cda37..b3b52cc7d8 100644 --- a/src/pages/settings/notifications.tsx +++ b/src/pages/settings/notifications.tsx @@ -5,11 +5,13 @@ import SettingsHeader from '@/components/settings/SettingsHeader' import { PushNotifications } from '@/components/settings/PushNotifications' import { useHasFeature } from '@/hooks/useChains' import { FEATURES } from '@/utils/chains' +import { RecoveryEmail } from '@/components/settings/RecoveryEmail' const NotificationsPage: NextPage = () => { const isNotificationFeatureEnabled = useHasFeature(FEATURES.PUSH_NOTIFICATIONS) + const isRecoveryEnabled = useHasFeature(FEATURES.RECOVERY) - if (!isNotificationFeatureEnabled) { + if (!isNotificationFeatureEnabled || !isRecoveryEnabled) { return null } @@ -22,7 +24,9 @@ const NotificationsPage: NextPage = () => { - + {isRecoveryEnabled && } + + {isNotificationFeatureEnabled && } > ) 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 60% rename from src/store/__tests__/recoverySlice.test.ts rename to src/services/recovery/__tests__/selectors.test.ts index 857c48568b..1db00f65e2 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/RecoveryContext' -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,26 +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 address', () => { + const delayModifier1 = { + address: faker.finance.ethereumAddress(), + } as unknown as RecoveryStateItem + + const delayModifier2 = { + address: faker.finance.ethereumAddress(), + } as unknown as RecoveryStateItem + + const delayModifier3 = { + address: faker.finance.ethereumAddress(), + } as unknown as RecoveryStateItem + + const data = [delayModifier1, delayModifier2, delayModifier3] + + expect(selectDelayModifierByAddress(data, delayModifier3.address)).toStrictEqual(delayModifier3) }) }) @@ -103,26 +104,19 @@ describe('recoverySlice', () => { it('should return the Delay Modifier for the given txHash', () => { 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, delayModifier2.address)).toStrictEqual(delayModifier2) }) }) }) diff --git a/src/services/recovery/recovery-state.ts b/src/services/recovery/recovery-state.ts index 07961e3456..d557609293 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/RecoveryContext' export const MAX_GUARDIAN_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_GUARDIAN_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..ebeabaa962 --- /dev/null +++ b/src/services/recovery/selectors.ts @@ -0,0 +1,34 @@ +import { createSelector } from '@reduxjs/toolkit' + +import type { RecoveryState } from '@/components/recovery/RecoveryContext' +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..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 '@/store/recoverySlice' +import type { RecoveryQueueItem } from '@/components/recovery/RecoveryContext' 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 72dee23f94..0aaddfb471 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -238,7 +238,10 @@ export const dispatchBatchExecution = async ( if (didReprice(error)) { txs.forEach(({ txId }) => { - txDispatch(TxEvent.PROCESSED, { txId, safeAddress }) + txDispatch(TxEvent.PROCESSED, { + txId, + safeAddress, + }) }) } else { txs.forEach(({ txId }) => { @@ -405,18 +408,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 +449,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 +463,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 +477,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/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)) - }, -) 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(),