Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: recovery proposal flow #2810

Merged
merged 26 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
17e31e6
feat: enable recovery flow structure
iamacook Nov 8, 2023
3021932
feat: intro step
iamacook Nov 8, 2023
5d9ac7a
feat: basic settings template
iamacook Nov 8, 2023
e89e0f0
feat: settings + review step
iamacook Nov 13, 2023
5b709d9
fix: add test coverage + remove comments
iamacook Nov 13, 2023
4468fea
Merge branch 'recovery-epic' into enable-recovery
iamacook Nov 13, 2023
b326d52
feat: recovery proposal flow
iamacook Nov 14, 2023
e513ebc
Merge branch 'recovery-epic' into enable-recovery
iamacook Nov 14, 2023
3a70af5
Merge branch 'enable-recovery' into propose-recovery
iamacook Nov 14, 2023
76ee0df
fix: only reference owners cache
iamacook Nov 14, 2023
6b338f2
fix: owner management transaction
iamacook Nov 15, 2023
6f3d3c7
fix: move error
iamacook Nov 16, 2023
127a934
fix: encode `multiSend` `data`
iamacook Nov 18, 2023
0fb3786
fix: cleanup code + rename test
iamacook Nov 18, 2023
54f2aea
fix: test
iamacook Nov 20, 2023
2a78391
fix: spacing + add connector
iamacook Nov 20, 2023
b643d31
Merge branch 'recovery-epic' into enable-recovery
iamacook Nov 20, 2023
5acef7a
refactor: extract `Chip` component
iamacook Nov 20, 2023
0de9b0c
Merge branch 'recovery-epic' into propose-recovery
iamacook Nov 20, 2023
fb4813e
Merge branch 'enable-recovery' into propose-recovery
iamacook Nov 20, 2023
2dfdec1
Merge branch 'propose-recovery' of github.com:safe-global/web-core in…
iamacook Nov 20, 2023
4a9bc6e
fix: spacing
iamacook Nov 20, 2023
29c3013
Merge branch 'recovery-epic' into propose-recovery
iamacook Nov 20, 2023
56c7910
fix: lint + types
iamacook Nov 20, 2023
cf66d57
fix: countdown
iamacook Nov 20, 2023
365c3da
refactor: code clarity
iamacook Nov 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading