}] 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)
}