Skip to content

Commit

Permalink
feat: recovery proposal flow (#2810)
Browse files Browse the repository at this point in the history
* feat: enable recovery flow structure

* feat: intro step

* feat: basic settings template

* feat: settings + review step

* fix: add test coverage + remove comments

* feat: recovery proposal flow

* fix: only reference owners cache

* fix: owner management transaction

* fix: move error

* fix: encode `multiSend` `data`

* fix: cleanup code + rename test

* fix: test

* fix: spacing + add connector

* refactor: extract `Chip` component

* fix: spacing

* fix: lint + types

* fix: countdown

* refactor: code clarity
  • Loading branch information
iamacook authored Nov 20, 2023
1 parent a759ff4 commit d4f415f
Show file tree
Hide file tree
Showing 19 changed files with 1,381 additions and 56 deletions.
24 changes: 1 addition & 23 deletions src/components/dashboard/RecoveryInProgress/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,9 @@
import { render } from '@testing-library/react'
import { BigNumber } from 'ethers'

import { _getCountdown, _RecoveryInProgress } from '.'
import { _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()
Expand Down
17 changes: 2 additions & 15 deletions src/components/dashboard/RecoveryInProgress/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FEATURES } from '@/utils/chains'
import { selectRecovery } from '@/store/recoverySlice'
import type { RecoveryState } from '@/store/recoverySlice'
import madProps from '@/utils/mad-props'
import { getCountdown } from '@/utils/date'

export function _RecoveryInProgress({
blockTimestamp,
Expand Down Expand Up @@ -80,26 +81,12 @@ export function _RecoveryInProgress({
)
}

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)
const { days, hours, minutes } = getCountdown(seconds)

return (
<Box display="flex" gap={1}>
Expand Down
21 changes: 18 additions & 3 deletions src/components/settings/Recovery/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import { EnableRecoveryFlow } from '@/components/tx-flow/flows/EnableRecovery'
import { TxModalContext } from '@/components/tx-flow'
import { Chip } from '@/components/common/Chip'
import ExternalLink from '@/components/common/ExternalLink'
import { RecoverAccountFlow } from '@/components/tx-flow/flows/RecoverAccount'
import useWallet from '@/hooks/wallets/useWallet'
import { useAppSelector } from '@/store'
import { selectRecoveryByGuardian } from '@/store/recoverySlice'

export function Recovery(): ReactElement {
const { setTxFlow } = useContext(TxModalContext)
const wallet = useWallet()
const recovery = useAppSelector((state) => selectRecoveryByGuardian(state, wallet?.address ?? ''))

return (
<Paper sx={{ p: 4 }}>
Expand Down Expand Up @@ -36,9 +42,18 @@ export function Recovery(): ReactElement {
</ExternalLink>
</Alert>

<Button variant="contained" onClick={() => setTxFlow(<EnableRecoveryFlow />)} sx={{ mt: 2 }}>
Set up recovery
</Button>
<Box mt={2}>
{recovery ? (
// TODO: Move to correct location when widget is ready
<Button variant="contained" onClick={() => setTxFlow(<RecoverAccountFlow />)}>
Propose recovery
</Button>
) : (
<Button variant="contained" onClick={() => setTxFlow(<EnableRecoveryFlow />)}>
Set up recovery
</Button>
)}
</Box>
</Grid>
</Grid>
</Paper>
Expand Down
30 changes: 30 additions & 0 deletions src/components/tx-flow/common/NewOwnerList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Paper, Typography, SvgIcon } from '@mui/material'
import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk'
import type { ReactElement } from 'react'

import PlusIcon from '@/public/images/common/plus.svg'
import EthHashInfo from '@/components/common/EthHashInfo'

import css from './styles.module.css'

export function NewOwnerList({ newOwners }: { newOwners: Array<AddressEx> }): ReactElement {
return (
<Paper className={css.container}>
<Typography color="text.secondary" display="flex" alignItems="center">
<SvgIcon component={PlusIcon} inheritViewBox fontSize="small" sx={{ mr: 1 }} />
New owner{newOwners.length > 1 ? 's' : ''}
</Typography>
{newOwners.map((newOwner) => (
<EthHashInfo
key={newOwner.value}
address={newOwner.value}
name={newOwner.name}
shortAddress={false}
showCopyButton
hasExplorer
avatarSize={32}
/>
))}
</Paper>
)
}
7 changes: 7 additions & 0 deletions src/components/tx-flow/common/NewOwnerList/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
background-color: var(--color-success-background);
}
10 changes: 2 additions & 8 deletions src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { upsertAddressBookEntry } from '@/store/addressBookSlice'
import { SafeTxContext } from '../../SafeTxProvider'
import type { AddOwnerFlowProps } from '.'
import type { ReplaceOwnerFlowProps } from '../ReplaceOwner'
import PlusIcon from '@/public/images/common/plus.svg'
import { NewOwnerList } from '../../common/NewOwnerList'
import MinusIcon from '@/public/images/common/minus.svg'
import EthHashInfo from '@/components/common/EthHashInfo'
import commonCss from '@/components/tx-flow/common/styles.module.css'
Expand Down Expand Up @@ -68,13 +68,7 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn
/>
</Paper>
)}
<Paper sx={{ backgroundColor: ({ palette }) => palette.success.background, p: 2 }}>
<Typography color="text.secondary" mb={2} display="flex" alignItems="center">
<SvgIcon component={PlusIcon} inheritViewBox fontSize="small" sx={{ mr: 1 }} />
New owner
</Typography>
<EthHashInfo name={newOwner.name} address={newOwner.address} shortAddress={false} showCopyButton hasExplorer />
</Paper>
<NewOwnerList newOwners={[{ name: newOwner.name, value: newOwner.address }]} />
<Divider className={commonCss.nestedDivider} />
<Box>
<Typography variant="body2">Any transaction requires the confirmation of:</Typography>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button, CardActions, Divider, Grid, Typography } from '@mui/material'
import type { ReactElement, ReactNode } from 'react'
import type { ReactElement } from 'react'

import TxCard from '../../common/TxCard'
import RecoveryGuardians from '@/public/images/settings/spending-limit/beneficiary.svg'
Expand All @@ -10,7 +10,7 @@ import RecoveryExecution from '@/public/images/transactions/recovery-execution.s
import css from './styles.module.css'
import commonCss from '@/components/tx-flow/common/styles.module.css'

const RecoverySteps: Array<{ Icon: ReactElement; title: string; subtitle: ReactNode }> = [
const RecoverySteps = [
{
Icon: RecoveryGuardians,
title: 'Choose a guardian and set a delay',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { CardActions, Button, Typography, Divider, Box } from '@mui/material'
import { useContext, useEffect, useState } from 'react'
import type { ReactElement } from 'react'

import useSafeInfo from '@/hooks/useSafeInfo'
import { getRecoveryProposalTransactions } from '@/services/recovery/transaction'
import DecodedTx from '@/components/tx/DecodedTx'
import ErrorMessage from '@/components/tx/ErrorMessage'
import { RedefineBalanceChanges } from '@/components/tx/security/redefine/RedefineBalanceChange'
import ConfirmationTitle, { ConfirmationTitleTypes } from '@/components/tx/SignOrExecuteForm/ConfirmationTitle'
import TxChecks from '@/components/tx/SignOrExecuteForm/TxChecks'
import { WrongChainWarning } from '@/components/tx/WrongChainWarning'
import useDecodeTx from '@/hooks/useDecodeTx'
import TxCard from '../../common/TxCard'
import { SafeTxContext } from '../../SafeTxProvider'
import CheckWallet from '@/components/common/CheckWallet'
import { createMultiSendCallOnlyTx, createTx, dispatchRecoveryProposal } from '@/services/tx/tx-sender'
import { RecoverAccountFlowFields } from '.'
import { NewOwnerList } from '../../common/NewOwnerList'
import { useAppSelector } from '@/store'
import { selectRecoveryByGuardian } from '@/store/recoverySlice'
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 type { RecoverAccountFlowProps } from '.'

import commonCss from '@/components/tx-flow/common/styles.module.css'

export function RecoverAccountFlowReview({ params }: { params: RecoverAccountFlowProps }): ReactElement | null {
// Form state
const [isSubmittable, setIsSubmittable] = useState<boolean>(true)
const [submitError, setSubmitError] = useState<Error | undefined>()

// Hooks
const { setTxFlow } = useContext(TxModalContext)
const { safeTx, safeTxError, setSafeTx, setSafeTxError } = useContext(SafeTxContext)
const [decodedData, decodedDataError, decodedDataLoading] = useDecodeTx(safeTx)
const { safe } = useSafeInfo()
const wallet = useWallet()
const onboard = useOnboard()
const recovery = useAppSelector((state) => selectRecoveryByGuardian(state, wallet?.address ?? ''))

// Proposal
const txCooldown = recovery?.txCooldown?.toNumber()
const txCooldownCountdown = txCooldown ? getCountdown(txCooldown) : undefined
const newThreshold = Number(params[RecoverAccountFlowFields.threshold])
const newOwners = params[RecoverAccountFlowFields.owners]

useEffect(() => {
const transactions = getRecoveryProposalTransactions({
safe,
newThreshold,
newOwners,
})

const promise = transactions.length > 1 ? createMultiSendCallOnlyTx(transactions) : createTx(transactions[0])

promise.then(setSafeTx).catch(setSafeTxError)
}, [newThreshold, newOwners, safe, setSafeTx, setSafeTxError])

// On modal submit
const onSubmit = async () => {
if (!recovery || !onboard) {
return
}

setIsSubmittable(false)
setSubmitError(undefined)

try {
await dispatchRecoveryProposal({ onboard, safe, newThreshold, newOwners, delayModifierAddress: recovery.address })
} catch (_err) {
const err = asError(_err)
trackError(Errors._810, err)
setIsSubmittable(true)
setSubmitError(err)
return
}

setTxFlow(undefined)
}

const submitDisabled = !safeTx || !isSubmittable || !recovery

return (
<>
<TxCard>
<Typography mb={1}>
This transaction will reset the Account setup, changing the owners
{newThreshold !== safe.threshold ? ' and threshold' : ''}.
</Typography>

<NewOwnerList newOwners={newOwners} />

<Divider className={commonCss.nestedDivider} sx={{ mt: 'var(--space-2) !important' }} />

<Box my={1}>
<Typography variant="body2" color="text.secondary" gutterBottom>
After recovery, Safe Account transactions will require:
</Typography>
<Typography>
<b>{params.threshold}</b> out of <b>{params[RecoverAccountFlowFields.owners].length} owners.</b>
</Typography>
</Box>

<Divider className={commonCss.nestedDivider} />

<DecodedTx
tx={safeTx}
decodedData={decodedData}
decodedDataError={decodedDataError}
decodedDataLoading={decodedDataLoading}
/>

<RedefineBalanceChanges />
</TxCard>

<TxCard>
<TxChecks isRecovery />
</TxCard>

<TxCard>
<ConfirmationTitle variant={ConfirmationTitleTypes.execute} />

{safeTxError && (
<ErrorMessage error={safeTxError}>
This recovery will most likely fail. To save gas costs, avoid executing the transaction.
</ErrorMessage>
)}

{submitError && (
<ErrorMessage error={submitError}>Error submitting the transaction. Please try again.</ErrorMessage>
)}

<WrongChainWarning />

<ErrorMessage level="info">
Recovery will be{' '}
{txCooldown === 0
? 'immediately possible'
: `possible ${txCooldownCountdown?.days} day${txCooldownCountdown?.days === 1 ? '' : 's'}`}{' '}
after this transaction is executed.
</ErrorMessage>

<Divider className={commonCss.nestedDivider} />

<CardActions sx={{ mt: 'var(--space-1) !important' }}>
<CheckWallet allowNonOwner>
{(isOk) => (
<Button variant="contained" disabled={!isOk || submitDisabled} onClick={onSubmit}>
Execute
</Button>
)}
</CheckWallet>
</CardActions>
</TxCard>
</>
)
}
Loading

0 comments on commit d4f415f

Please sign in to comment.