diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx new file mode 100644 index 0000000000..7181add121 --- /dev/null +++ b/src/components/settings/Recovery/index.tsx @@ -0,0 +1,38 @@ +import { Box, Button, Chip, Grid, Paper, Typography } from '@mui/material' +import { useContext } from 'react' +import type { ReactElement } from 'react' + +import { EnableRecoveryFlow } from '@/components/tx-flow/flows/EnableRecovery' +import { TxModalContext } from '@/components/tx-flow' + +export function Recovery(): ReactElement { + const { setTxFlow } = useContext(TxModalContext) + + return ( + + + + + + Account recovery + + + {/* TODO: Extract when widget is merged https://github.com/safe-global/safe-wallet-web/pull/2768 */} + + + + + + + Choose a trusted guardian to recover your Safe Account, in case you should ever lose access to your Account. + Enabling the Account recovery module will require a transactions. + + + + + + + ) +} diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index 9f8dbde18f..67f870101f 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/src/components/sidebar/SidebarNavigation/config.tsx @@ -84,6 +84,10 @@ export const settingsNavItems = [ label: 'Appearance', href: AppRoutes.settings.appearance, }, + { + label: 'Recovery', + href: AppRoutes.settings.recovery, + }, { label: 'Notifications', href: AppRoutes.settings.notifications, diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx new file mode 100644 index 0000000000..6d8296c2c8 --- /dev/null +++ b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx @@ -0,0 +1,7 @@ +import type { ReactElement } from 'react' + +import TxCard from '../../common/TxCard' + +export function EnableRecoveryFlowIntro(): ReactElement { + return EnableRecoveryFlowIntro +} diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx new file mode 100644 index 0000000000..4b2beab26a --- /dev/null +++ b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx @@ -0,0 +1,43 @@ +import { useContext, useEffect, useMemo } from 'react' +import type { ReactElement } from 'react' + +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { Errors, logError } from '@/services/exceptions' +import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { getRecoverySetup } from '@/services/recovery/setup' +import { useWeb3 } from '@/hooks/wallets/web3' +import useSafeInfo from '@/hooks/useSafeInfo' +import type { EnableRecoveryFlowProps } from '.' + +export function EnableRecoveryFlowReview({ params }: { params: EnableRecoveryFlowProps }): ReactElement { + const web3 = useWeb3() + const { safe } = useSafeInfo() + const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) + + const recoverySetup = useMemo(() => { + if (!web3) { + return + } + + return getRecoverySetup({ + ...params, + safe, + provider: web3, + }) + }, [params, safe, web3]) + + useEffect(() => { + if (recoverySetup) { + createMultiSendCallOnlyTx(recoverySetup.transactions).then(setSafeTx).catch(setSafeTxError) + } + }, [recoverySetup, setSafeTx, setSafeTxError]) + + useEffect(() => { + if (safeTxError) { + logError(Errors._809, safeTxError.message) + } + }, [safeTxError]) + + return null} /> +} diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowSettings.tsx b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowSettings.tsx new file mode 100644 index 0000000000..79709c965d --- /dev/null +++ b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowSettings.tsx @@ -0,0 +1,14 @@ +import type { ReactElement } from 'react' + +import TxCard from '../../common/TxCard' +import type { EnableRecoveryFlowProps } from '.' + +export function EnableRecoveryFlowSettings({ + params, + onSubmit, +}: { + params: EnableRecoveryFlowProps + onSubmit: (formData: EnableRecoveryFlowProps) => void +}): ReactElement { + return EnableRecoveryFlowSettings +} diff --git a/src/components/tx-flow/flows/EnableRecovery/index.tsx b/src/components/tx-flow/flows/EnableRecovery/index.tsx new file mode 100644 index 0000000000..2f092732c7 --- /dev/null +++ b/src/components/tx-flow/flows/EnableRecovery/index.tsx @@ -0,0 +1,58 @@ +import type { ReactElement } from 'react' + +import TxLayout from '@/components/tx-flow/common/TxLayout' +import CodeIcon from '@/public/images/apps/code-icon.svg' +import useTxStepper from '../../useTxStepper' +import { EnableRecoveryFlowReview } from './EnableRecoveryFlowReview' +import { EnableRecoveryFlowSettings } from './EnableRecoveryFlowSettings' +import { EnableRecoveryFlowIntro } from './EnableRecoveryFlowIntro' + +export enum EnableRecoveryFlowFields { + guardians = 'guardians', + txCooldown = 'txCooldown', + txExpiration = 'txExpiration', +} + +export type EnableRecoveryFlowProps = { + [EnableRecoveryFlowFields.guardians]: Array + [EnableRecoveryFlowFields.txCooldown]: string + [EnableRecoveryFlowFields.txExpiration]: string +} + +export function EnableRecoveryFlow(): ReactElement { + const { data, step, nextStep, prevStep } = useTxStepper({ + [EnableRecoveryFlowFields.guardians]: [], + [EnableRecoveryFlowFields.txCooldown]: '0', + [EnableRecoveryFlowFields.txExpiration]: '0', + }) + + const steps = [ + , + nextStep({ ...data, ...formData })} />, + , + ] + + const isIntro = step === 0 + const isSettings = step === 1 + + const subtitle = isIntro + ? 'How does recovery work?' + : isSettings + ? 'Set up account settings' + : 'Set up account recovery' + + const icon = isIntro ? undefined : CodeIcon + + return ( + + {steps} + + ) +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 6090dbb7ad..a3f245cd3b 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -28,6 +28,7 @@ export const AppRoutes = { settings: { spendingLimits: '/settings/spending-limits', setup: '/settings/setup', + recovery: '/settings/recovery', notifications: '/settings/notifications', modules: '/settings/modules', index: '/settings', diff --git a/src/pages/settings/recovery.tsx b/src/pages/settings/recovery.tsx new file mode 100644 index 0000000000..a5aa677b9f --- /dev/null +++ b/src/pages/settings/recovery.tsx @@ -0,0 +1,24 @@ +import Head from 'next/head' +import type { NextPage } from 'next' + +import SettingsHeader from '@/components/settings/SettingsHeader' +import { Recovery } from '@/components/settings/Recovery' + +// TODO: Condense to other setting section once confirmed +const RecoveryPage: NextPage = () => { + return ( + <> + + {'Safe{Wallet} – Settings – Recovery'} + + + + +
+ +
+ + ) +} + +export default RecoveryPage diff --git a/src/services/recovery/setup.ts b/src/services/recovery/setup.ts new file mode 100644 index 0000000000..6e8291468a --- /dev/null +++ b/src/services/recovery/setup.ts @@ -0,0 +1,85 @@ +import { getModuleInstance, KnownContracts, deployAndSetUpModule } from '@gnosis.pm/zodiac' +import { Interface } from 'ethers/lib/utils' +import type { Web3Provider } from '@ethersproject/providers' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' + +export function getRecoverySetup({ + txCooldown, + txExpiration, + guardians, + safe, + provider, +}: { + txCooldown: string + txExpiration: string + guardians: Array + safe: SafeInfo + provider: Web3Provider +}): { + expectedModuleAddress: string + transactions: Array +} { + const safeAddress = safe.address.value + + const setupArgs: Parameters[1] = { + types: ['address', 'address', 'address', 'uint256', 'uint256'], + values: [ + safeAddress, // address _owner + safeAddress, // address _avatar + safeAddress, // address _target + txCooldown, // uint256 _cooldown + txExpiration, // uint256 _expiration + ], + } + + const saltNonce: Parameters[4] = Date.now().toString() + + const { transaction, expectedModuleAddress } = deployAndSetUpModule( + KnownContracts.DELAY, + setupArgs, + provider, + Number(safe.chainId), + saltNonce, + ) + + const transactions: Array = [] + + // Deploy Delay Modifier + const deployDeplayModifier: MetaTransactionData = { + ...transaction, + value: transaction.value.toString(), + } + + transactions.push(deployDeplayModifier) + + const safeAbi = ['function enableModule(address module)'] + const safeInterface = new Interface(safeAbi) + + // Enable Delay Modifier on Safe + const enableDelayModifier: MetaTransactionData = { + to: safeAddress, + value: '0', + data: safeInterface.encodeFunctionData('enableModule', [expectedModuleAddress]), + } + + transactions.push(enableDelayModifier) + + const delayModifierContract = getModuleInstance(KnownContracts.DELAY, expectedModuleAddress, provider) + + // Add guardians to Delay Modifier + const enableDelayModifierModules: Array = guardians.map((guardian) => { + return { + to: expectedModuleAddress, + data: delayModifierContract.interface.encodeFunctionData('enableModule', [guardian]), + value: '0', + } + }) + + transactions.push(...enableDelayModifierModules) + + return { + expectedModuleAddress, + transactions, + } +}