From 8baedf948e44db940b275c9b9601eb89a68ed001 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 20 Nov 2023 16:43:37 +0100 Subject: [PATCH] feat: edit recovery flow --- src/components/dashboard/Recovery/index.tsx | 4 +- src/components/settings/Recovery/index.tsx | 22 +- .../tx-flow/common/TxLayout/index.tsx | 4 +- .../UpsertRecoveryFlowEmailHint.tsx} | 2 +- .../UpsertRecoveryFlowIntro.tsx} | 2 +- .../UpsertRecoveryFlowReview.tsx} | 79 ++++- .../UpsertRecoveryFlowSettings.tsx} | 24 +- .../index.tsx | 39 +-- .../styles.module.css | 0 src/services/recovery/__tests__/setup.test.ts | 288 +++++++++++++++++- src/services/recovery/recovery-state.ts | 8 +- src/services/recovery/setup.ts | 85 +++++- src/store/recoverySlice.ts | 11 +- 13 files changed, 502 insertions(+), 66 deletions(-) rename src/components/tx-flow/flows/{EnableRecovery/EnableRecoveryFlowEmailHint.tsx => UpsertRecovery/UpsertRecoveryFlowEmailHint.tsx} (93%) rename src/components/tx-flow/flows/{EnableRecovery/EnableRecoveryFlowIntro.tsx => UpsertRecovery/UpsertRecoveryFlowIntro.tsx} (97%) rename src/components/tx-flow/flows/{EnableRecovery/EnableRecoveryFlowReview.tsx => UpsertRecovery/UpsertRecoveryFlowReview.tsx} (59%) rename src/components/tx-flow/flows/{EnableRecovery/EnableRecoveryFlowSettings.tsx => UpsertRecovery/UpsertRecoveryFlowSettings.tsx} (89%) rename src/components/tx-flow/flows/{EnableRecovery => UpsertRecovery}/index.tsx (50%) rename src/components/tx-flow/flows/{EnableRecovery => UpsertRecovery}/styles.module.css (100%) diff --git a/src/components/dashboard/Recovery/index.tsx b/src/components/dashboard/Recovery/index.tsx index 0227367f34..efc15d7640 100644 --- a/src/components/dashboard/Recovery/index.tsx +++ b/src/components/dashboard/Recovery/index.tsx @@ -6,7 +6,7 @@ import RecoveryLogo from '@/public/images/common/recovery.svg' import { WidgetBody, WidgetContainer } from '@/components/dashboard/styled' import { Chip } from '@/components/common/Chip' import { TxModalContext } from '@/components/tx-flow' -import { EnableRecoveryFlow } from '@/components/tx-flow/flows/EnableRecovery' +import { UpsertRecoveryFlow } from '@/components/tx-flow/flows/UpsertRecovery' import css from './styles.module.css' @@ -14,7 +14,7 @@ export function Recovery(): ReactElement { const { setTxFlow } = useContext(TxModalContext) const onClick = () => { - setTxFlow() + setTxFlow() } return ( diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index fd355b49b7..6ff4885756 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -2,13 +2,19 @@ import { Alert, Box, Button, Grid, Paper, Typography } from '@mui/material' import { useContext } from 'react' import type { ReactElement } from 'react' -import { EnableRecoveryFlow } from '@/components/tx-flow/flows/EnableRecovery' +import { UpsertRecoveryFlow } from '@/components/tx-flow/flows/UpsertRecovery' import { TxModalContext } from '@/components/tx-flow' import { Chip } from '@/components/common/Chip' import ExternalLink from '@/components/common/ExternalLink' +import useWallet from '@/hooks/wallets/useWallet' +import { useAppSelector } from '@/store' +import { selectRecoveryByGuardian } from '@/store/recoverySlice' +// TODO: Migrate section export function Recovery(): ReactElement { const { setTxFlow } = useContext(TxModalContext) + const wallet = useWallet() + const recovery = useAppSelector((state) => selectRecoveryByGuardian(state, wallet?.address ?? '')) return ( @@ -36,9 +42,17 @@ export function Recovery(): ReactElement { - + + {recovery ? ( + + ) : ( + + )} + diff --git a/src/components/tx-flow/common/TxLayout/index.tsx b/src/components/tx-flow/common/TxLayout/index.tsx index 528ca90041..befc417355 100644 --- a/src/components/tx-flow/common/TxLayout/index.tsx +++ b/src/components/tx-flow/common/TxLayout/index.tsx @@ -13,7 +13,7 @@ import SafeLogo from '@/public/images/logo-no-text.svg' import { TxSecurityProvider } from '@/components/tx/security/shared/TxSecurityContext' import ChainIndicator from '@/components/common/ChainIndicator' import SecurityWarnings from '@/components/tx/security/SecurityWarnings' -import { EnableRecoveryFlowEmailHint } from '../../flows/EnableRecovery/EnableRecoveryFlowEmailHint' +import { UpsertRecoveryFlowEmailHint } from '../../flows/UpsertRecovery/UpsertRecoveryFlowEmailHint' const TxLayoutHeader = ({ hideNonce, @@ -158,7 +158,7 @@ const TxLayout = ({ - {isRecovery && } + {isRecovery && } diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowEmailHint.tsx b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowEmailHint.tsx similarity index 93% rename from src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowEmailHint.tsx rename to src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowEmailHint.tsx index 4f5c81551e..114ba5c3e6 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowEmailHint.tsx +++ b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowEmailHint.tsx @@ -5,7 +5,7 @@ import LightbulbIcon from '@/public/images/common/lightbulb.svg' import infoWidgetCss from '@/components/new-safe/create/InfoWidget/styles.module.css' -export function EnableRecoveryFlowEmailHint(): ReactElement { +export function UpsertRecoveryFlowEmailHint(): ReactElement { return ( palette.info.main }}> diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx similarity index 97% rename from src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx rename to src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx index bd727faf11..943b624fa5 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx +++ b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowIntro.tsx @@ -40,7 +40,7 @@ const RecoverySteps: Array<{ Icon: ReactElement; title: string; subtitle: ReactN }, ] -export function EnableRecoveryFlowIntro({ onSubmit }: { onSubmit: () => void }): ReactElement { +export function UpsertRecoveryFlowIntro({ onSubmit }: { onSubmit: () => void }): ReactElement { return ( diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx similarity index 59% rename from src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx rename to src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx index 0b5322c5c9..c7695d2276 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowReview.tsx +++ b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowReview.tsx @@ -3,45 +3,90 @@ 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 { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import { getRecoverySetup } from '@/services/recovery/setup' +import { getEditRecoveryTransactions, getRecoverySetupTransactions } from '@/services/recovery/setup' import { useWeb3 } from '@/hooks/wallets/web3' import useSafeInfo from '@/hooks/useSafeInfo' import { SvgIcon, Tooltip, Typography } from '@mui/material' -import { EnableRecoveryFlowFields, RecoveryDelayPeriods, RecoveryExpirationPeriods } from '.' +import { UpsertRecoveryFlowFields, RecoveryDelayPeriods, RecoveryExpirationPeriods } from '.' import { TxDataRow } from '@/components/transactions/TxDetails/Summary/TxDataRow' import InfoIcon from '@/public/images/notifications/info.svg' import EthHashInfo from '@/components/common/EthHashInfo' -import type { EnableRecoveryFlowProps } from '.' +import type { UpsertRecoveryFlowProps } from '.' +import type { Web3Provider } from '@ethersproject/providers' -export function EnableRecoveryFlowReview({ params }: { params: EnableRecoveryFlowProps }): ReactElement { +const getSafeTx = async ({ + txCooldown, + txExpiration, + guardian, + provider, + moduleAddress, + chainId, + safeAddress, +}: UpsertRecoveryFlowProps & { + moduleAddress?: string + provider: Web3Provider + chainId: string + safeAddress: string +}) => { + if (moduleAddress) { + return getEditRecoveryTransactions({ + moduleAddress, + newTxCooldown: txCooldown, + newTxExpiration: txExpiration, + newGuardians: [guardian], + provider, + }) + } + + const { transactions } = getRecoverySetupTransactions({ + txCooldown, + txExpiration, + guardians: [guardian], + chainId, + safeAddress, + provider, + }) + + return transactions +} + +export function UpsertRecoveryFlowReview({ + params, + moduleAddress, +}: { + params: UpsertRecoveryFlowProps + moduleAddress?: string +}): ReactElement { const web3 = useWeb3() const { safe, safeAddress } = useSafeInfo() const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) - const guardian = params[EnableRecoveryFlowFields.guardians] - const delay = RecoveryDelayPeriods.find(({ value }) => value === params[EnableRecoveryFlowFields.txCooldown])!.label + const guardian = params[UpsertRecoveryFlowFields.guardian] + const delay = RecoveryDelayPeriods.find(({ value }) => value === params[UpsertRecoveryFlowFields.txCooldown])!.label const expiration = RecoveryExpirationPeriods.find( - ({ value }) => value === params[EnableRecoveryFlowFields.txExpiration], + ({ value }) => value === params[UpsertRecoveryFlowFields.txExpiration], )!.label - const emailAddress = params[EnableRecoveryFlowFields.emailAddress] + const emailAddress = params[UpsertRecoveryFlowFields.emailAddress] useEffect(() => { if (!web3) { return } - const { transactions } = getRecoverySetup({ + getSafeTx({ ...params, - guardians: [guardian], + provider: web3, chainId: safe.chainId, safeAddress, - provider: web3, - }) + moduleAddress, + }).then((transactions) => { + const promise = transactions.length > 1 ? createMultiSendCallOnlyTx(transactions) : createTx(transactions[0]) - createMultiSendCallOnlyTx(transactions).then(setSafeTx).catch(setSafeTxError) - }, [guardian, params, safe.chainId, safeAddress, setSafeTx, setSafeTxError, web3]) + promise.then(setSafeTx).catch(setSafeTxError) + }) + }, [guardian, moduleAddress, params, safe.chainId, safeAddress, setSafeTx, setSafeTxError, web3]) useEffect(() => { if (safeTxError) { @@ -51,7 +96,9 @@ export function EnableRecoveryFlowReview({ params }: { params: EnableRecoveryFlo return ( null}> - This transaction will enable the Account recovery feature once executed. + + This transaction will {moduleAddress ? 'update' : 'enable'} the Account recovery feature once executed. + diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowSettings.tsx b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx similarity index 89% rename from src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowSettings.tsx rename to src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx index 8ea926cf03..0e7053c7a0 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowSettings.tsx +++ b/src/components/tx-flow/flows/UpsertRecovery/UpsertRecoveryFlowSettings.tsx @@ -18,32 +18,32 @@ import type { TextFieldProps } from '@mui/material' import type { ReactElement } from 'react' import TxCard from '../../common/TxCard' -import { EnableRecoveryFlowFields, RecoveryDelayPeriods, RecoveryExpirationPeriods } from '.' +import { UpsertRecoveryFlowFields, RecoveryDelayPeriods, RecoveryExpirationPeriods } from '.' import AddressBookInput from '@/components/common/AddressBookInput' import CircleCheckIcon from '@/public/images/common/circle-check.svg' import { useDarkMode } from '@/hooks/useDarkMode' -import type { EnableRecoveryFlowProps } from '.' +import type { UpsertRecoveryFlowProps } from '.' import commonCss from '@/components/tx-flow/common/styles.module.css' import css from './styles.module.css' -export function EnableRecoveryFlowSettings({ +export function UpsertRecoveryFlowSettings({ params, onSubmit, }: { - params: EnableRecoveryFlowProps - onSubmit: (formData: EnableRecoveryFlowProps) => void + params: UpsertRecoveryFlowProps + onSubmit: (formData: UpsertRecoveryFlowProps) => void }): ReactElement { - const [showAdvanced, setShowAdvanced] = useState(params[EnableRecoveryFlowFields.txExpiration] !== '0') + const [showAdvanced, setShowAdvanced] = useState(params[UpsertRecoveryFlowFields.txExpiration] !== '0') const [understandsRisk, setUnderstandsRisk] = useState(false) const isDarkMode = useDarkMode() - const formMethods = useForm({ + const formMethods = useForm({ defaultValues: params, mode: 'onChange', }) - const emailAddress = formMethods.watch(EnableRecoveryFlowFields.emailAddress) + const emailAddress = formMethods.watch(UpsertRecoveryFlowFields.emailAddress) const onShowAdvanced = () => setShowAdvanced((prev) => !prev) @@ -63,7 +63,7 @@ export function EnableRecoveryFlowSettings({ - +
@@ -77,7 +77,7 @@ export function EnableRecoveryFlowSettings({ ( {RecoveryDelayPeriods.map(({ label, value }, index) => ( @@ -100,7 +100,7 @@ export function EnableRecoveryFlowSettings({ ( @@ -134,7 +134,7 @@ export function EnableRecoveryFlowSettings({ ( ({ - [EnableRecoveryFlowFields.guardians]: '', - [EnableRecoveryFlowFields.txCooldown]: `${DAY_SECONDS * 28}`, // 28 days in seconds - [EnableRecoveryFlowFields.txExpiration]: '0', - [EnableRecoveryFlowFields.emailAddress]: '', +export function UpsertRecoveryFlow({ recovery }: { recovery?: RecoveryState[number] }): ReactElement { + const { data, step, nextStep, prevStep } = useTxStepper({ + [UpsertRecoveryFlowFields.guardian]: recovery?.guardians?.[0] ?? '', + [UpsertRecoveryFlowFields.txCooldown]: recovery?.txCooldown?.toString() ?? `${DAY_SECONDS * 28}`, // 28 days in seconds + [UpsertRecoveryFlowFields.txExpiration]: recovery?.txExpiration?.toString() ?? '0', + [UpsertRecoveryFlowFields.emailAddress]: '', }) const steps = [ - nextStep(data)} />, - nextStep({ ...data, ...formData })} />, - , + nextStep(data)} />, + nextStep({ ...data, ...formData })} />, + , ] const isIntro = step === 0 diff --git a/src/components/tx-flow/flows/EnableRecovery/styles.module.css b/src/components/tx-flow/flows/UpsertRecovery/styles.module.css similarity index 100% rename from src/components/tx-flow/flows/EnableRecovery/styles.module.css rename to src/components/tx-flow/flows/UpsertRecovery/styles.module.css diff --git a/src/services/recovery/__tests__/setup.test.ts b/src/services/recovery/__tests__/setup.test.ts index 3b2ea34373..38a32a3696 100644 --- a/src/services/recovery/__tests__/setup.test.ts +++ b/src/services/recovery/__tests__/setup.test.ts @@ -1,9 +1,11 @@ import { getModuleInstance, KnownContracts, deployAndSetUpModule } from '@gnosis.pm/zodiac' import { faker } from '@faker-js/faker' import { BigNumber } from 'ethers' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import { SENTINEL_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' import type { Web3Provider } from '@ethersproject/providers' -import { getRecoverySetup } from '@/services/recovery/setup' +import { getEditRecoveryTransactions, getRecoverySetupTransactions } from '@/services/recovery/setup' jest.mock('@gnosis.pm/zodiac', () => ({ ...jest.requireActual('@gnosis.pm/zodiac'), @@ -14,7 +16,7 @@ jest.mock('@gnosis.pm/zodiac', () => ({ const mockGetModuleInstance = getModuleInstance as jest.MockedFunction const mockDeployAndSetUpModule = deployAndSetUpModule as jest.MockedFunction -describe('getRecoverySetup', () => { +describe('getRecoverySetupTransactions', () => { beforeEach(() => { jest.clearAllMocks() }) @@ -43,7 +45,7 @@ describe('getRecoverySetup', () => { transaction: deployDelayModifierTx, }) - const result = getRecoverySetup({ + const result = getRecoverySetupTransactions({ txCooldown, txExpiration, guardians, @@ -92,3 +94,283 @@ describe('getRecoverySetup', () => { }) }) }) + +describe('getEditRecoveryTransactions', () => { + it('should return a setTxExpiration transaction if a new txExpiration is provided', async () => { + const moduleAddress = faker.finance.ethereumAddress() + + const txCooldown = faker.string.numeric() + const txExpiration = faker.string.numeric() + const guardians = [faker.finance.ethereumAddress()] + + const newTxExpiration = faker.string.numeric({ exclude: txExpiration }) + + const mockEncodeFunctionData = jest.fn() + mockGetModuleInstance.mockReturnValue({ + txCooldown: () => Promise.resolve(BigNumber.from(txCooldown)), + txExpiration: () => Promise.resolve(BigNumber.from(txExpiration)), + getModulesPaginated: () => Promise.resolve([guardians]), + interface: { + encodeFunctionData: mockEncodeFunctionData.mockReturnValue('0x'), + }, + } as any) + + const transactions = await getEditRecoveryTransactions({ + provider: {} as Web3Provider, + newTxCooldown: txCooldown, + newTxExpiration, + newGuardians: guardians, + moduleAddress, + }) + + expect(transactions).toHaveLength(1) + + expect(mockEncodeFunctionData).toHaveBeenCalledTimes(1) + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(1, 'setTxExpiration', [newTxExpiration]) + + expect(transactions[0]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + it('should return a setTxCooldown transaction if a new txCooldown is provided', async () => { + const moduleAddress = faker.finance.ethereumAddress() + + const txCooldown = faker.string.numeric() + const txExpiration = faker.string.numeric() + const guardians = [faker.finance.ethereumAddress()] + + const newTxCooldown = faker.string.numeric({ exclude: txCooldown }) + + const mockEncodeFunctionData = jest.fn() + mockGetModuleInstance.mockReturnValue({ + txCooldown: () => Promise.resolve(BigNumber.from(txCooldown)), + txExpiration: () => Promise.resolve(BigNumber.from(txExpiration)), + getModulesPaginated: () => Promise.resolve([guardians]), + interface: { + encodeFunctionData: mockEncodeFunctionData.mockReturnValue('0x'), + }, + } as any) + + const transactions = await getEditRecoveryTransactions({ + provider: {} as Web3Provider, + newTxCooldown, + newTxExpiration: txExpiration, + newGuardians: guardians, + moduleAddress, + }) + + expect(transactions).toHaveLength(1) + + expect(mockEncodeFunctionData).toHaveBeenCalledTimes(1) + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(1, 'setTxCooldown', [newTxCooldown]) + + expect(transactions[0]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + it('should return an enableModule transaction if a new guardian is provided', async () => { + const moduleAddress = faker.finance.ethereumAddress() + + const txCooldown = faker.string.numeric() + const txExpiration = faker.string.numeric() + const guardians = [faker.finance.ethereumAddress()] + + const newGuardians = [guardians[0], faker.finance.ethereumAddress(), faker.finance.ethereumAddress()] + + const mockEncodeFunctionData = jest.fn() + mockGetModuleInstance.mockReturnValue({ + txCooldown: () => Promise.resolve(BigNumber.from(txCooldown)), + txExpiration: () => Promise.resolve(BigNumber.from(txExpiration)), + getModulesPaginated: () => Promise.resolve([guardians]), + interface: { + encodeFunctionData: mockEncodeFunctionData.mockReturnValue('0x'), + }, + } as any) + + const transactions = await getEditRecoveryTransactions({ + provider: {} as Web3Provider, + newTxCooldown: txCooldown, + newTxExpiration: txExpiration, + newGuardians, + moduleAddress, + }) + + expect(transactions).toHaveLength(2) + + expect(mockEncodeFunctionData).toHaveBeenCalledTimes(2) + + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(1, 'enableModule', [newGuardians[1]]) + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(2, 'enableModule', [newGuardians[2]]) + + expect(transactions[0]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + expect(transactions[1]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + it('should return a disableModule transaction if an existing guardian is provided', async () => { + const moduleAddress = faker.finance.ethereumAddress() + + const txCooldown = faker.string.numeric() + const txExpiration = faker.string.numeric() + const guardians = [faker.finance.ethereumAddress()] + + const mockEncodeFunctionData = jest.fn() + mockGetModuleInstance.mockReturnValue({ + txCooldown: () => Promise.resolve(BigNumber.from(txCooldown)), + txExpiration: () => Promise.resolve(BigNumber.from(txExpiration)), + getModulesPaginated: () => Promise.resolve([guardians]), + interface: { + encodeFunctionData: mockEncodeFunctionData.mockReturnValue('0x'), + }, + } as any) + + const transactions = await getEditRecoveryTransactions({ + provider: {} as Web3Provider, + newTxCooldown: txCooldown, + newTxExpiration: txExpiration, + newGuardians: [], + moduleAddress, + }) + + expect(transactions).toHaveLength(1) + + expect(mockEncodeFunctionData).toHaveBeenCalledTimes(1) + + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(1, 'disableModule', [SENTINEL_ADDRESS, guardians[0]]) + + expect(transactions[0]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + describe('existing guardians', () => { + it('should skip existing guardians provided', async () => { + const moduleAddress = faker.finance.ethereumAddress() + + const txCooldown = faker.string.numeric() + const txExpiration = faker.string.numeric() + const guardians = [faker.finance.ethereumAddress()] + + const newTxCooldown = faker.string.numeric({ exclude: txCooldown }) + const newTxExpiration = faker.string.numeric({ exclude: txExpiration }) + const newGuardians = [guardians[0], faker.finance.ethereumAddress()] + + const mockEncodeFunctionData = jest.fn() + mockGetModuleInstance.mockReturnValue({ + txCooldown: () => Promise.resolve(BigNumber.from(txCooldown)), + txExpiration: () => Promise.resolve(BigNumber.from(txExpiration)), + getModulesPaginated: () => Promise.resolve([guardians]), + interface: { + encodeFunctionData: mockEncodeFunctionData.mockReturnValue('0x'), + }, + } as any) + + const transactions = await getEditRecoveryTransactions({ + provider: {} as Web3Provider, + newTxCooldown, + newTxExpiration, + newGuardians, + moduleAddress, + }) + + expect(transactions).toHaveLength(3) + + expect(mockEncodeFunctionData).toHaveBeenCalledTimes(3) + + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(1, 'setTxCooldown', [newTxCooldown]) + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(2, 'setTxExpiration', [newTxExpiration]) + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(3, 'enableModule', [newGuardians[1]]) // Skip existing guardian + + expect(transactions[0]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + expect(transactions[1]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + expect(transactions[2]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + it('should handle complex guardian mappings', async () => { + const moduleAddress = faker.finance.ethereumAddress() + + const txCooldown = faker.string.numeric() + const txExpiration = faker.string.numeric() + const guardians = [ + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + ] + + const newGuardians = [guardians[0], faker.finance.ethereumAddress(), guardians[1]] + + const mockEncodeFunctionData = jest.fn() + mockGetModuleInstance.mockReturnValue({ + txCooldown: () => Promise.resolve(BigNumber.from(txCooldown)), + txExpiration: () => Promise.resolve(BigNumber.from(txExpiration)), + getModulesPaginated: () => Promise.resolve([guardians]), + interface: { + encodeFunctionData: mockEncodeFunctionData.mockReturnValue('0x'), + }, + } as any) + + const transactions = await getEditRecoveryTransactions({ + provider: {} as Web3Provider, + newTxCooldown: txCooldown, + newTxExpiration: txExpiration, + newGuardians, + moduleAddress, + }) + + expect(transactions).toHaveLength(2) + + expect(mockEncodeFunctionData).toHaveBeenCalledTimes(2) + + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(1, 'enableModule', [newGuardians[1]]) + expect(mockEncodeFunctionData).toHaveBeenNthCalledWith(2, 'disableModule', [guardians[1], guardians[2]]) + + expect(transactions[0]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + expect(transactions[1]).toEqual({ + to: moduleAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + }) +}) diff --git a/src/services/recovery/recovery-state.ts b/src/services/recovery/recovery-state.ts index ac0f343497..bcff6ac935 100644 --- a/src/services/recovery/recovery-state.ts +++ b/src/services/recovery/recovery-state.ts @@ -10,7 +10,7 @@ import type { RecoveryQueueItem, RecoveryState } from '@/store/recoverySlice' import { hexZeroPad } from 'ethers/lib/utils' import { trimTrailingSlash } from '@/utils/url' -const MAX_PAGE_SIZE = 100 +export const MAX_GUARDIAN_PAGE_SIZE = 100 export const _getRecoveryQueueItem = async ( transactionAdded: TransactionAddedEvent, @@ -95,9 +95,9 @@ export const getRecoveryState = async ({ transactionService: string safeAddress: string provider: JsonRpcProvider -}): Promise => { +}): Promise> => { const [[modules], txExpiration, txCooldown, txNonce, queueNonce] = await Promise.all([ - delayModifier.getModulesPaginated(SENTINEL_ADDRESS, MAX_PAGE_SIZE), + delayModifier.getModulesPaginated(SENTINEL_ADDRESS, MAX_GUARDIAN_PAGE_SIZE), delayModifier.txExpiration(), delayModifier.txCooldown(), delayModifier.txNonce(), @@ -121,7 +121,7 @@ export const getRecoveryState = async ({ return { address: delayModifier.address, - modules, + guardians: modules, txExpiration, txCooldown, txNonce, diff --git a/src/services/recovery/setup.ts b/src/services/recovery/setup.ts index 643b3708e9..9308d146ad 100644 --- a/src/services/recovery/setup.ts +++ b/src/services/recovery/setup.ts @@ -1,9 +1,14 @@ +import { OperationType } from '@safe-global/safe-core-sdk-types' +import { SENTINEL_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' import { getModuleInstance, KnownContracts, deployAndSetUpModule } from '@gnosis.pm/zodiac' import { Interface } from 'ethers/lib/utils' import type { Web3Provider } from '@ethersproject/providers' import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' -export function getRecoverySetup({ +import { sameAddress } from '@/utils/addresses' +import { MAX_GUARDIAN_PAGE_SIZE } from './recovery-state' + +export function getRecoverySetupTransactions({ txCooldown, txExpiration, guardians, @@ -82,3 +87,81 @@ export function getRecoverySetup({ transactions, } } + +export async function getEditRecoveryTransactions({ + newTxCooldown, + newTxExpiration, + newGuardians, + moduleAddress, + provider, +}: { + newTxCooldown: string + newTxExpiration: string + newGuardians: Array + moduleAddress: string + provider: Web3Provider +}): Promise> { + const delayModifierContract = getModuleInstance(KnownContracts.DELAY, moduleAddress, provider) + + const [txExpiration, txCooldown, [guardians]] = await Promise.all([ + delayModifierContract.txExpiration(), + delayModifierContract.txCooldown(), + delayModifierContract.getModulesPaginated(SENTINEL_ADDRESS, MAX_GUARDIAN_PAGE_SIZE), + ]) + + // Recovery management transaction data + const txData: Array = [] + + // Update cooldown + if (!txCooldown.eq(newTxCooldown)) { + const setTxCooldown = delayModifierContract.interface.encodeFunctionData('setTxCooldown', [newTxCooldown]) + txData.push(setTxCooldown) + } + + // Update expiration + if (!txExpiration.eq(newTxExpiration)) { + const setTxExpiration = delayModifierContract.interface.encodeFunctionData('setTxExpiration', [newTxExpiration]) + txData.push(setTxExpiration) + } + + // Cache guardian changes to determine prevModule + let _guardians = [...guardians] + + // Don't add/remove same owners + const guardiansToAdd = newGuardians.filter( + (newGuardian) => !_guardians.some((oldGuardian) => sameAddress(oldGuardian, newGuardian)), + ) + const guardiansToRemove = _guardians.filter( + (oldGuardian) => !newGuardians.some((newGuardian) => sameAddress(newGuardian, oldGuardian)), + ) + + for (const guardianToAdd of guardiansToAdd) { + const enableModule = delayModifierContract.interface.encodeFunctionData('enableModule', [guardianToAdd]) + txData.push(enableModule) + + // Add guardian to cache + _guardians.push(guardianToAdd) + } + + for (const guardianToRemove of guardiansToRemove) { + const prevModule = (() => { + const guardianIndex = _guardians.findIndex((guardian) => sameAddress(guardian, guardianToRemove)) + return guardianIndex === 0 ? SENTINEL_ADDRESS : _guardians[guardianIndex - 1] + })() + const disableModule = delayModifierContract.interface.encodeFunctionData('disableModule', [ + prevModule, + guardianToRemove, + ]) + txData.push(disableModule) + + // Remove guardian from cache + _guardians = _guardians.filter((guardian) => !sameAddress(guardian, guardianToRemove)) + } + + return txData.map((data) => ({ + to: moduleAddress, + value: '0', + operation: OperationType.Call, + data, + })) +} diff --git a/src/store/recoverySlice.ts b/src/store/recoverySlice.ts index 1755799cf3..9dae088195 100644 --- a/src/store/recoverySlice.ts +++ b/src/store/recoverySlice.ts @@ -3,6 +3,8 @@ import type { TransactionAddedEvent } from '@gnosis.pm/zodiac/dist/cjs/types/Del import type { BigNumber } from 'ethers' import { makeLoadableSlice } from './common' +import { sameAddress } from '@/utils/addresses' +import type { RootState } from '.' export type RecoveryQueueItem = TransactionAddedEvent & { timestamp: number @@ -12,7 +14,7 @@ export type RecoveryQueueItem = TransactionAddedEvent & { export type RecoveryState = Array<{ address: string - modules: Array + guardians: Array txExpiration: BigNumber txCooldown: BigNumber txNonce: BigNumber @@ -27,3 +29,10 @@ const { slice, selector } = makeLoadableSlice('recovery', initialState) export const recoverySlice = slice export const selectRecovery = createSelector(selector, (recovery) => recovery.data) + +export const selectRecoveryByGuardian = createSelector( + [selectRecovery, (_: RootState, walletAddress: string) => walletAddress], + (recovery, walletAddress) => { + return recovery.find(({ guardians }) => guardians.some((guardian) => sameAddress(guardian, walletAddress))) + }, +)