diff --git a/public/images/common/recovery-pending.svg b/public/images/common/recovery-pending.svg new file mode 100644 index 0000000000..750adb1f13 --- /dev/null +++ b/public/images/common/recovery-pending.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/components/dashboard/RecoveryInProgress/index.test.tsx b/src/components/dashboard/RecoveryInProgress/index.test.tsx new file mode 100644 index 0000000000..bd48c980bd --- /dev/null +++ b/src/components/dashboard/RecoveryInProgress/index.test.tsx @@ -0,0 +1,175 @@ +import { render } from '@testing-library/react' +import { BigNumber } from 'ethers' + +import { _getCountdown, _RecoveryInProgress } from '.' +import type { RecoveryQueueItem, RecoveryState } from '@/store/recoverySlice' + +describe('getCountdown', () => { + it('should convert 0 seconds to 0 days, 0 hours, and 0 minutes', () => { + const result = _getCountdown(0) + expect(result).toEqual({ days: 0, hours: 0, minutes: 0 }) + }) + + it('should convert 3600 seconds to 0 days, 1 hour, and 0 minutes', () => { + const result = _getCountdown(3600) + expect(result).toEqual({ days: 0, hours: 1, minutes: 0 }) + }) + + it('should convert 86400 seconds to 1 day, 0 hours, and 0 minutes', () => { + const result = _getCountdown(86400) + expect(result).toEqual({ days: 1, hours: 0, minutes: 0 }) + }) + + it('should convert 123456 seconds to 1 day, 10 hours, and 17 minutes', () => { + const result = _getCountdown(123456) + expect(result).toEqual({ days: 1, hours: 10, minutes: 17 }) + }) +}) + +describe('RecoveryInProgress', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('should return null if the chain does not support recovery', () => { + const result = render( + <_RecoveryInProgress + supportsRecovery={false} + blockTimestamp={0} + recovery={[{ queue: [{ timestamp: 0 } as RecoveryQueueItem] }] as RecoveryState} + />, + ) + + expect(result.container).toBeEmptyDOMElement() + }) + + it('should return a loader if there is no block timestamp', () => { + const result = render( + <_RecoveryInProgress + supportsRecovery={false} + blockTimestamp={undefined} + recovery={[{ queue: [{ timestamp: 0 } as RecoveryQueueItem] }] as RecoveryState} + />, + ) + + expect(result.container).toBeEmptyDOMElement() + }) + + it('should return null if there are no delayed transactions', () => { + const result = render( + <_RecoveryInProgress + supportsRecovery={true} + blockTimestamp={69420} + recovery={[{ queue: [] as Array }] as RecoveryState} + />, + ) + + expect(result.container).toBeEmptyDOMElement() + }) + + it('should return null if all the delayed transactions are expired and invalid', () => { + const result = render( + <_RecoveryInProgress + supportsRecovery={true} + blockTimestamp={69420} + recovery={ + [ + { + queue: [ + { + timestamp: 0, + validFrom: BigNumber.from(69), + expiresAt: BigNumber.from(420), + } as RecoveryQueueItem, + ], + }, + ] as RecoveryState + } + />, + ) + + expect(result.container).toBeEmptyDOMElement() + }) + + it('should return the countdown of the latest non-expired/invalid transactions if none are non-expired/valid', () => { + const mockBlockTimestamp = 69420 + + const { queryByText } = render( + <_RecoveryInProgress + supportsRecovery={true} + blockTimestamp={mockBlockTimestamp} + recovery={ + [ + { + queue: [ + { + timestamp: mockBlockTimestamp + 1, + validFrom: BigNumber.from(mockBlockTimestamp + 1), // Invalid + expiresAt: BigNumber.from(mockBlockTimestamp + 1), // Non-expired + } as RecoveryQueueItem, + { + // Older - should render this + timestamp: mockBlockTimestamp, + validFrom: BigNumber.from(mockBlockTimestamp * 4), // Invalid + expiresAt: null, // Non-expired + } as RecoveryQueueItem, + ], + }, + ] as RecoveryState + } + />, + ) + + expect(queryByText('Account recovery in progress')).toBeInTheDocument() + expect( + queryByText('The recovery process has started. This Account will be ready to recover in:'), + ).toBeInTheDocument() + ;['day', 'hr', 'min'].forEach((unit) => { + // May be pluralised + expect(queryByText(unit, { exact: false })).toBeInTheDocument() + }) + // Days + expect(queryByText('2')).toBeInTheDocument() + // Hours + expect(queryByText('9')).toBeInTheDocument() + // Mins + expect(queryByText('51')).toBeInTheDocument() + }) + + it('should return the info of the latest non-expired/valid transactions', () => { + const mockBlockTimestamp = 69420 + + const { queryByText } = render( + <_RecoveryInProgress + supportsRecovery={true} + blockTimestamp={mockBlockTimestamp} + recovery={ + [ + { + queue: [ + { + timestamp: mockBlockTimestamp - 1, + validFrom: BigNumber.from(mockBlockTimestamp - 1), // Invalid + expiresAt: BigNumber.from(mockBlockTimestamp - 1), // Non-expired + } as RecoveryQueueItem, + { + // Older - should render this + timestamp: mockBlockTimestamp - 2, + validFrom: BigNumber.from(mockBlockTimestamp - 1), // Invalid + expiresAt: null, // Non-expired + } as RecoveryQueueItem, + ], + }, + ] as RecoveryState + } + />, + ) + + expect(queryByText('Account recovery possible')).toBeInTheDocument() + expect(queryByText('The recovery process is possible. This Account can be recovered.')).toBeInTheDocument() + ;['day', 'hr', 'min'].forEach((unit) => { + // May be pluralised + expect(queryByText(unit, { exact: false })).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/components/dashboard/RecoveryInProgress/index.tsx b/src/components/dashboard/RecoveryInProgress/index.tsx new file mode 100644 index 0000000000..342503768a --- /dev/null +++ b/src/components/dashboard/RecoveryInProgress/index.tsx @@ -0,0 +1,139 @@ +import { Box, Card, Grid, Typography } from '@mui/material' +import { useMemo } from 'react' +import type { ReactElement } from 'react' + +import { useAppSelector } from '@/store' +import { useBlockTimestamp } from '@/hooks/useBlockTimestamp' +import { WidgetContainer, WidgetBody } from '../styled' +import RecoveryPending from '@/public/images/common/recovery-pending.svg' +import ExternalLink from '@/components/common/ExternalLink' +import { useHasFeature } from '@/hooks/useChains' +import { FEATURES } from '@/utils/chains' +import { selectRecovery } from '@/store/recoverySlice' +import type { RecoveryState } from '@/store/recoverySlice' +import madProps from '@/utils/mad-props' + +export function _RecoveryInProgress({ + blockTimestamp, + supportsRecovery, + recovery, +}: { + blockTimestamp?: number + supportsRecovery: boolean + recovery: RecoveryState +}): ReactElement | null { + const allRecoveryTxs = useMemo(() => { + return recovery.flatMap(({ queue }) => queue).sort((a, b) => a.timestamp - b.timestamp) + }, [recovery]) + + if (!supportsRecovery || !blockTimestamp) { + return null + } + + const nonExpiredTxs = allRecoveryTxs.filter((delayedTx) => { + return delayedTx.expiresAt ? delayedTx.expiresAt.gt(blockTimestamp) : true + }) + + if (nonExpiredTxs.length === 0) { + return null + } + + const nextTx = nonExpiredTxs[0] + + // TODO: Migrate `isValid` components when https://github.com/safe-global/safe-wallet-web/issues/2758 is done + const isValid = nextTx.validFrom.lte(blockTimestamp) + const secondsUntilValid = nextTx.validFrom.sub(blockTimestamp).toNumber() + + return ( + + + + + + + + + + + {isValid ? 'Account recovery possible' : 'Account recovery in progress'} + + + {isValid + ? 'The recovery process is possible. This Account can be recovered.' + : 'The recovery process has started. This Account will be ready to recover in:'} + + + + + + Learn more + + + + + + + + ) +} + +export function _getCountdown(seconds: number): { days: number; hours: number; minutes: number } { + const MINUTE_IN_SECONDS = 60 + const HOUR_IN_SECONDS = 60 * MINUTE_IN_SECONDS + const DAY_IN_SECONDS = 24 * HOUR_IN_SECONDS + + const days = Math.floor(seconds / DAY_IN_SECONDS) + + const remainingSeconds = seconds % DAY_IN_SECONDS + const hours = Math.floor(remainingSeconds / HOUR_IN_SECONDS) + const minutes = Math.floor((remainingSeconds % HOUR_IN_SECONDS) / MINUTE_IN_SECONDS) + + return { days, hours, minutes } +} + +function Countdown({ seconds }: { seconds: number }): ReactElement | null { + if (seconds <= 0) { + return null + } + + const { days, hours, minutes } = _getCountdown(seconds) + + return ( + + + + + + ) +} + +function TimeLeft({ value, unit }: { value: number; unit: string }): ReactElement | null { + if (value === 0) { + return null + } + + return ( +
+ + {value} + {' '} + + {value === 1 ? unit : `${unit}s`} + +
+ ) +} + +// Appease React TypeScript warnings +const _useBlockTimestamp = () => useBlockTimestamp(60_000) // Countdown does not display +const _useSupportsRecovery = () => useHasFeature(FEATURES.RECOVERY) +const _useRecovery = () => useAppSelector(selectRecovery) + +export const RecoveryInProgress = madProps(_RecoveryInProgress, { + blockTimestamp: _useBlockTimestamp, + supportsRecovery: _useSupportsRecovery, + recovery: _useRecovery, +}) diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 497aed2b7e..2e216b477d 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -11,6 +11,7 @@ import Relaying from '@/components/dashboard/Relaying' import { FEATURES } from '@/utils/chains' import { useHasFeature } from '@/hooks/useChains' import { CREATION_MODAL_QUERY_PARM } from '../new-safe/create/logic' +import { RecoveryInProgress } from './RecoveryInProgress' const Dashboard = (): ReactElement => { const router = useRouter() @@ -20,6 +21,8 @@ const Dashboard = (): ReactElement => { return ( <> + + diff --git a/src/hooks/__tests__/useLoadRecovery.test.ts b/src/hooks/__tests__/useLoadRecovery.test.ts index fb77e655c7..f4b693b873 100644 --- a/src/hooks/__tests__/useLoadRecovery.test.ts +++ b/src/hooks/__tests__/useLoadRecovery.test.ts @@ -127,6 +127,12 @@ describe('useLoadRecovery', () => { txNonce, queueNonce, queue: [ + { + ...transactionsAdded[0], + timestamp: 69, + validFrom: BigNumber.from(69).add(txCooldown), + expiresAt: null, + }, { ...transactionsAdded[1], timestamp: 420, diff --git a/src/hooks/useBlockTimestamp.test.ts b/src/hooks/useBlockTimestamp.test.ts new file mode 100644 index 0000000000..8c05176e09 --- /dev/null +++ b/src/hooks/useBlockTimestamp.test.ts @@ -0,0 +1,85 @@ +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' + +import { useBlockTimestamp } from '@/hooks/useBlockTimestamp' +import { renderHook, waitFor } from '@/tests/test-utils' + +jest.mock('@/hooks/wallets/web3') + +const mockUseWeb3ReadOnly = useWeb3ReadOnly as jest.MockedFunction + +describe('useBlockTimestamp', () => { + const mockGetBlock = jest.fn() + + beforeEach(() => { + mockUseWeb3ReadOnly.mockReturnValue({ + getBlock: mockGetBlock, + } as any) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should return undefined if web3ReadOnly is not available', () => { + mockUseWeb3ReadOnly.mockReturnValue(undefined) + + const { result } = renderHook(() => useBlockTimestamp()) + + expect(result.current).toBeUndefined() + + expect(mockGetBlock).not.toHaveBeenCalled() + }) + + it('should return the latest block timestamp', async () => { + const timestamp = 69420 + + mockGetBlock.mockResolvedValue({ + timestamp, + } as any) + + const { result } = renderHook(() => useBlockTimestamp()) + + expect(result.current).toBeUndefined() + + await waitFor(() => { + expect(result.current).toBe(timestamp) + }) + + expect(mockGetBlock).toHaveBeenCalledTimes(1) + }) + + it('should update the timestamp every INTERVAL', async () => { + jest.useFakeTimers() + + const timestamp = 69420 + + mockGetBlock.mockResolvedValue({ + timestamp, + } as any) + + const { result } = renderHook(() => useBlockTimestamp()) + + expect(result.current).toBeUndefined() + + await waitFor(() => { + expect(result.current).toBe(timestamp) + }) + + jest.advanceTimersByTime(1_000) + + await waitFor(() => { + expect(result.current).toBe(timestamp + 1) + }) + + jest.advanceTimersByTime(1_000) + + await waitFor(() => { + expect(result.current).toBe(timestamp + 2) + }) + + // Interval is used to update the timestamp after initial getBlock call + expect(mockGetBlock).toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) +}) diff --git a/src/hooks/useBlockTimestamp.ts b/src/hooks/useBlockTimestamp.ts new file mode 100644 index 0000000000..ee6aee2014 --- /dev/null +++ b/src/hooks/useBlockTimestamp.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react' + +import useAsync from './useAsync' + +import { useWeb3ReadOnly } from './wallets/web3' + +export function useBlockTimestamp(interval = 1_000): number | undefined { + const web3ReadOnly = useWeb3ReadOnly() + const [timestamp, setTimestamp] = useState() + + const [block] = useAsync(() => { + return web3ReadOnly?.getBlock('latest') + }, [web3ReadOnly]) + + useEffect(() => { + if (!block) { + return + } + + setTimestamp(block.timestamp) + + const timeout = setInterval(() => { + setTimestamp((prev) => { + return prev ? prev + 1 : block.timestamp + }) + }, interval) + + return () => { + clearInterval(timeout) + } + }, [interval, block]) + + return timestamp +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 91c240e053..f57bf7c902 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -61,6 +61,10 @@ input[type='number'] { fill: var(--color-logo-background); } +.illustration-background-warning-fill { + fill: var(--color-warning-background); +} + /* Note: a fallback `stroke` property must be on the svg to work */ .illustration-main-stroke { stroke: var(--color-primary-main); diff --git a/src/utils/transaction-calldata.ts b/src/utils/transaction-calldata.ts index 5a4caff286..e2236a4718 100644 --- a/src/utils/transaction-calldata.ts +++ b/src/utils/transaction-calldata.ts @@ -7,7 +7,7 @@ import { ERC20__factory } from '@/types/contracts/factories/@openzeppelin/contra import { ERC721__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC721__factory' import { decodeMultiSendTxs } from '@/utils/transactions' -const isCalldata = (data: string, fragment: FunctionFragment): boolean => { +export const isCalldata = (data: string, fragment: FunctionFragment): boolean => { const signature = fragment.format() const signatureId = id(signature).slice(0, 10) return data.startsWith(signatureId) @@ -40,7 +40,7 @@ const isErc721SafeTransferFromWithBytesCalldata = (data: string): boolean => { // MultiSend const multiSendInterface = Multi_send__factory.createInterface() const multiSendFragment = multiSendInterface.getFunction('multiSend') -const isMultiSendCalldata = (data: string): boolean => { +export const isMultiSendCalldata = (data: string): boolean => { return isCalldata(data, multiSendFragment) }