diff --git a/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx b/src/components/dashboard/PendingTxs/PendingRecoveryListItem.tsx new file mode 100644 index 0000000000..54732e42c2 --- /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/RecoveryLoaderContext' + +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..012c0d4fdc --- /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/RecoveryLoaderContext' + +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..459c304223 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/RecoveryLoaderContext' 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/recovery/SkipRecoveryButton/index.tsx b/src/components/recovery/CancelRecoveryButton/index.tsx similarity index 85% rename from src/components/recovery/SkipRecoveryButton/index.tsx rename to src/components/recovery/CancelRecoveryButton/index.tsx index 7a65ee0859..70b928e28c 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 { CancelRecoveryFlow } from '@/components/tx-flow/flows/CancelRecovery' import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' -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({ ) : ( ) } diff --git a/src/components/recovery/RecoveryDetails/index.tsx b/src/components/recovery/RecoveryDetails/index.tsx index 56a6adc496..b95fccbc0a 100644 --- a/src/components/recovery/RecoveryDetails/index.tsx +++ b/src/components/recovery/RecoveryDetails/index.tsx @@ -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/RecoverySigners/index.tsx b/src/components/recovery/RecoverySigners/index.tsx index 6ee6d56cf1..25a32490ee 100644 --- a/src/components/recovery/RecoverySigners/index.tsx +++ b/src/components/recovery/RecoverySigners/index.tsx @@ -6,7 +6,7 @@ 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 '@/components/recovery/RecoveryLoaderContext' @@ -69,7 +69,7 @@ export function RecoverySigners({ item }: { item: RecoveryQueueItem }): ReactEle - + ) diff --git a/src/components/recovery/RecoverySummary/index.tsx b/src/components/recovery/RecoverySummary/index.tsx index 4f76eb8277..0f58cb93ec 100644 --- a/src/components/recovery/RecoverySummary/index.tsx +++ b/src/components/recovery/RecoverySummary/index.tsx @@ -6,7 +6,7 @@ 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 '@/components/recovery/RecoveryLoaderContext' @@ -22,16 +22,14 @@ export function RecoverySummary({ item }: { item: RecoveryQueueItem }): ReactEle
- {isMalicious && ( - - - - )} + + + {wallet && ( - + )} 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. + + + + + + + + + + + ) +} 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} + +
+
+ +
+ + +
+
+ + + + + + + + + ) +} 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/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. + + + + + + + + + + ) +} 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..2794f82c66 --- /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/RecoveryLoaderContext' + +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/SkipRecovery/index.tsx b/src/components/tx-flow/flows/SkipRecovery/index.tsx deleted file mode 100644 index c508826a4c..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 '@/components/recovery/RecoveryLoaderContext' - -export function SkipRecoveryFlow({ recovery }: { recovery: RecoveryQueueItem }): ReactElement { - return ( - - - - ) -} 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__/useRecoveryTxState.test.ts b/src/hooks/__tests__/useRecoveryTxState.test.ts deleted file mode 100644 index a169927c65..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 '@/components/recovery/RecoveryLoaderContext' - -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..a5fe06e8bf --- /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 { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' + +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/useRecoveryTxState.ts b/src/hooks/useRecoveryTxState.ts index efa117840c..e5e3813fdf 100644 --- a/src/hooks/useRecoveryTxState.ts +++ b/src/hooks/useRecoveryTxState.ts @@ -3,9 +3,10 @@ import { useContext } from 'react' import { useClock } from './useClock' import { selectDelayModifierByTxHash } from '@/services/recovery/selectors' import { RecoveryLoaderContext } from '@/components/recovery/RecoveryLoaderContext' +import { sameAddress } from '@/utils/addresses' import type { RecoveryQueueItem } from '@/components/recovery/RecoveryLoaderContext' -export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args }: RecoveryQueueItem): { +export function useRecoveryTxState({ validFrom, expiresAt, transactionHash, args, address }: RecoveryQueueItem): { isNext: boolean isExecutable: boolean isExpired: boolean @@ -20,7 +21,9 @@ 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 = recovery ? sameAddress(recovery.address, address) && args.queueNonce.eq(recovery.txNonce) : false const isExecutable = isNext && isValid && !isExpired const remainingSeconds = isValid ? 0 : Math.ceil(remainingMs.div(1_000).toNumber()) 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 && }
)