diff --git a/src/components/dashboard/RecoveryInProgress/index.test.tsx b/src/components/dashboard/RecoveryInProgress/index.test.tsx index bd48c980bd..dd7c7d2584 100644 --- a/src/components/dashboard/RecoveryInProgress/index.test.tsx +++ b/src/components/dashboard/RecoveryInProgress/index.test.tsx @@ -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() diff --git a/src/components/dashboard/RecoveryInProgress/index.tsx b/src/components/dashboard/RecoveryInProgress/index.tsx index 342503768a..9409a84f1d 100644 --- a/src/components/dashboard/RecoveryInProgress/index.tsx +++ b/src/components/dashboard/RecoveryInProgress/index.tsx @@ -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, @@ -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 ( diff --git a/src/components/settings/Recovery/index.tsx b/src/components/settings/Recovery/index.tsx index fd355b49b7..b4e34399b6 100644 --- a/src/components/settings/Recovery/index.tsx +++ b/src/components/settings/Recovery/index.tsx @@ -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 ( @@ -36,9 +42,18 @@ export function Recovery(): ReactElement { - + + {recovery ? ( + // TODO: Move to correct location when widget is ready + + ) : ( + + )} + diff --git a/src/components/tx-flow/common/NewOwnerList/index.tsx b/src/components/tx-flow/common/NewOwnerList/index.tsx new file mode 100644 index 0000000000..2164ba756a --- /dev/null +++ b/src/components/tx-flow/common/NewOwnerList/index.tsx @@ -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 }): ReactElement { + return ( + + + + New owner{newOwners.length > 1 ? 's' : ''} + + {newOwners.map((newOwner) => ( + + ))} + + ) +} diff --git a/src/components/tx-flow/common/NewOwnerList/styles.module.css b/src/components/tx-flow/common/NewOwnerList/styles.module.css new file mode 100644 index 0000000000..dcf6189778 --- /dev/null +++ b/src/components/tx-flow/common/NewOwnerList/styles.module.css @@ -0,0 +1,7 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); + background-color: var(--color-success-background); +} diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index d2c18301b3..f8ca7bbe70 100644 --- a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -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' @@ -68,13 +68,7 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn /> )} - palette.success.background, p: 2 }}> - - - New owner - - - + Any transaction requires the confirmation of: diff --git a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx index bd727faf11..ef28ee30b3 100644 --- a/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx +++ b/src/components/tx-flow/flows/EnableRecovery/EnableRecoveryFlowIntro.tsx @@ -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' @@ -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', diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx new file mode 100644 index 0000000000..e1b7c96976 --- /dev/null +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowReview.tsx @@ -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(true) + const [submitError, setSubmitError] = useState() + + // 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 ( + <> + + + This transaction will reset the Account setup, changing the owners + {newThreshold !== safe.threshold ? ' and threshold' : ''}. + + + + + + + + + After recovery, Safe Account transactions will require: + + + {params.threshold} out of {params[RecoverAccountFlowFields.owners].length} owners. + + + + + + + + + + + + + + + + + + {safeTxError && ( + + This recovery will most likely fail. To save gas costs, avoid executing the transaction. + + )} + + {submitError && ( + Error submitting the transaction. Please try again. + )} + + + + + Recovery will be{' '} + {txCooldown === 0 + ? 'immediately possible' + : `possible ${txCooldownCountdown?.days} day${txCooldownCountdown?.days === 1 ? '' : 's'}`}{' '} + after this transaction is executed. + + + + + + + {(isOk) => ( + + )} + + + + + ) +} diff --git a/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx new file mode 100644 index 0000000000..a11d7ba3d7 --- /dev/null +++ b/src/components/tx-flow/flows/RecoverAccount/RecoverAccountFlowSetup.tsx @@ -0,0 +1,170 @@ +import { + Typography, + Divider, + CardActions, + Button, + SvgIcon, + Grid, + MenuItem, + TextField, + IconButton, + Tooltip, +} from '@mui/material' +import { useForm, FormProvider, useFieldArray, Controller } from 'react-hook-form' +import type { ReactElement } from 'react' + +import TxCard from '../../common/TxCard' +import AddIcon from '@/public/images/common/add.svg' +import DeleteIcon from '@/public/images/common/delete.svg' +import { RecoverAccountFlowFields } from '.' +import AddressBookInput from '@/components/common/AddressBookInput' +import { TOOLTIP_TITLES } from '../../common/constants' +import InfoIcon from '@/public/images/notifications/info.svg' +import useSafeInfo from '@/hooks/useSafeInfo' +import { sameAddress } from '@/utils/addresses' +import type { RecoverAccountFlowProps } from '.' + +import commonCss from '@/components/tx-flow/common/styles.module.css' + +export function RecoverAccountFlowSetup({ + params, + onSubmit, +}: { + params: RecoverAccountFlowProps + onSubmit: (formData: RecoverAccountFlowProps) => void +}): ReactElement { + const { safeAddress } = useSafeInfo() + + const formMethods = useForm({ + defaultValues: params, + mode: 'all', + }) + + const { fields, append, remove } = useFieldArray({ + control: formMethods.control, + name: RecoverAccountFlowFields.owners, + }) + + const owners = formMethods.watch(RecoverAccountFlowFields.owners) + + return ( + +
+ +
+ + Add owner(s) + + + + Set the new owner wallet(s) of this Safe Account and how many need to confirm a transaction before it can + be executed. + +
+ + + {fields.map((field, index) => ( + <> + + { + if (sameAddress(value, safeAddress)) { + return 'The Safe Account cannot own itself' + } + + const isDuplicate = owners.filter((owner) => owner.value === value).length > 1 + if (isDuplicate) { + return 'Already designated to be an owner' + } + }} + /> + + + + {index > 0 && ( + remove(index)}> + + + )} + + + ))} + + + + + + +
+ + Threshold + + + + + + + + + After recovery, Safe Account transactions will require: + +
+ + + + ( + + {fields.map((_, index) => { + const value = index + 1 + return ( + + {value} + + ) + })} + + )} + /> + + + + out of {fields.length} owner(s) + + + + + + + + +
+
+
+ ) +} diff --git a/src/components/tx-flow/flows/RecoverAccount/index.tsx b/src/components/tx-flow/flows/RecoverAccount/index.tsx new file mode 100644 index 0000000000..af075b6e77 --- /dev/null +++ b/src/components/tx-flow/flows/RecoverAccount/index.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from 'react' +import type { AddressEx } from '@safe-global/safe-gateway-typescript-sdk' + +import TxLayout from '@/components/tx-flow/common/TxLayout' +import SaveAddressIcon from '@/public/images/common/save-address.svg' +import useTxStepper from '../../useTxStepper' +import { RecoverAccountFlowReview } from './RecoverAccountFlowReview' +import { RecoverAccountFlowSetup } from './RecoverAccountFlowSetup' + +export enum RecoverAccountFlowFields { + owners = 'owners', + threshold = 'threshold', +} + +export type RecoverAccountFlowProps = { + // RHF accept primitive field arrays + [RecoverAccountFlowFields.owners]: Array + [RecoverAccountFlowFields.threshold]: string +} + +export function RecoverAccountFlow(): ReactElement { + const { data, step, nextStep, prevStep } = useTxStepper({ + [RecoverAccountFlowFields.owners]: [{ value: '' }], + [RecoverAccountFlowFields.threshold]: '1', + }) + + const steps = [ + nextStep({ ...data, ...formData })} />, + , + ] + + return ( + + {steps} + + ) +} diff --git a/src/components/tx/SignOrExecuteForm/TxChecks.tsx b/src/components/tx/SignOrExecuteForm/TxChecks.tsx index d1fb2444fa..9ad3f069f0 100644 --- a/src/components/tx/SignOrExecuteForm/TxChecks.tsx +++ b/src/components/tx/SignOrExecuteForm/TxChecks.tsx @@ -6,14 +6,14 @@ import { Redefine, RedefineMessage } from '@/components/tx/security/redefine' import css from './styles.module.css' -const TxChecks = (): ReactElement => { +const TxChecks = ({ isRecovery = false }: { isRecovery?: boolean }): ReactElement => { const { safeTx } = useContext(SafeTxContext) return ( <> Transaction checks - + diff --git a/src/components/tx/security/tenderly/index.tsx b/src/components/tx/security/tenderly/index.tsx index 4f1487205e..fa4ce7c6a3 100644 --- a/src/components/tx/security/tenderly/index.tsx +++ b/src/components/tx/security/tenderly/index.tsx @@ -23,12 +23,18 @@ export type TxSimulationProps = { transactions?: SimulationTxParams['transactions'] gasLimit?: number disabled: boolean + isRecovery?: boolean } // TODO: Investigate resetting on gasLimit change as we are not simulating with the gasLimit of the tx // otherwise remove all usage of gasLimit in simulation. Note: this was previously being done. // TODO: Test this component -const TxSimulationBlock = ({ transactions, disabled, gasLimit }: TxSimulationProps): ReactElement => { +const TxSimulationBlock = ({ + transactions, + disabled, + gasLimit, + isRecovery = false, +}: TxSimulationProps): ReactElement => { const { safe } = useSafeInfo() const wallet = useWallet() const isDarkMode = useDarkMode() @@ -45,7 +51,7 @@ const TxSimulationBlock = ({ transactions, disabled, gasLimit }: TxSimulationPro simulateTransaction({ safe, - executionOwner: wallet.address, + executionOwner: isRecovery ? safe.owners[0].value : wallet.address, transactions, gasLimit, } as SimulationTxParams) diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index 90993a46ef..8319df5f75 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -61,6 +61,7 @@ enum ErrorCodes { _807 = '807: Failed to remove guard', _808 = '808: Failed to get transaction origin', _809 = '809: Failed decoding transaction', + _810 = '810: Error executing a recovery proposal transaction', _900 = '900: Error loading Safe App', _901 = '901: Error processing Safe Apps SDK request', diff --git a/src/services/recovery/__tests__/transaction.test.ts b/src/services/recovery/__tests__/transaction.test.ts new file mode 100644 index 0000000000..a3d5b56ed9 --- /dev/null +++ b/src/services/recovery/__tests__/transaction.test.ts @@ -0,0 +1,712 @@ +import { faker } from '@faker-js/faker' +import { Interface } from 'ethers/lib/utils' +import { SENTINEL_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import * as deployments from '@safe-global/safe-deployments' +import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' + +import { getRecoveryProposalTransaction, getRecoveryProposalTransactions } from '../transaction' + +describe('transaction', () => { + describe('getRecoveryTransactions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const encodeFunctionDataSpy = jest.spyOn(Interface.prototype, 'encodeFunctionData') + + describe('when recovering with the same number of owner(s) as the current Safe owner(s)', () => { + describe('with unique owners', () => { + describe('should swap all owners when the threshold remains the same', () => { + it('for singular owners', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(1) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(1) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + }) + + it('for multiple owners', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'swapOwner', [newOwner2, oldOwner3, newOwner3]) + }) + }) + + it('should swap all owners and finally change the threshold if it changes', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + const newThreshold = oldThreshold + 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(4) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(4) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'swapOwner', [newOwner2, oldOwner3, newOwner3]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(4, 'changeThreshold', [newThreshold]) + }) + }) + + describe('with duplicate owners', () => { + describe('should swap all differing owners when the threshold remains the same', () => { + it('for singular owners it should return nothing', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: oldOwner1 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(0) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(0) + }) + + it('for multiple owners', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = oldOwner3 + + const oldThreshold = 2 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(2) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(2) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + }) + }) + + it('should swap all differing owners and finally change the threshold if it changes', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + const newThreshold = oldThreshold + 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: oldOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'changeThreshold', [newThreshold]) + }) + }) + + it('should change the threshold with the same owners', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + const newThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(1) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(1) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'changeThreshold', [newThreshold]) + }) + }) + + describe('when recovering with more owner(s) than the current Safe owner(s)', () => { + describe('with unique owners', () => { + describe('should swap as many owners as possible then add the rest when the threshold remains the same', () => { + it('for singular owners', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'addOwnerWithThreshold', [newOwner2, 1]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'addOwnerWithThreshold', [newOwner3, oldThreshold]) + }) + + it('for multiple owners', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'addOwnerWithThreshold', [newOwner3, oldThreshold]) + }) + }) + + it('should swap as many owners as possible then add the rest when with a final threshold change if the threshold changes', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + const newThreshold = oldThreshold + 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'addOwnerWithThreshold', [newOwner3, newThreshold]) + }) + }) + + describe('with duplicates owners', () => { + it('should swap as many differing owners as possible then add the rest when the threshold remains the same', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: oldOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(2) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(2) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'addOwnerWithThreshold', [newOwner2, oldThreshold]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'addOwnerWithThreshold', [newOwner3, oldThreshold]) + }) + + it('should swap as many differing owners as possible then add the rest when with a final threshold change if the threshold changes', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + const newOwner4 = faker.finance.ethereumAddress() + const newOwner5 = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + const newThreshold = 4 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [ + { value: newOwner1 }, + { value: newOwner2 }, + { value: newOwner3 }, + { value: newOwner4 }, + { value: newOwner5 }, + ] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(5) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(5) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'addOwnerWithThreshold', [ + newOwner2, + 2, // Intemediary threshold - length of current owners + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'addOwnerWithThreshold', [ + newOwner3, + 3, // Intemediary threshold - length of current owners + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(4, 'addOwnerWithThreshold', [newOwner4, newThreshold]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(5, 'addOwnerWithThreshold', [newOwner5, newThreshold]) + }) + }) + }) + + describe('when recovering with less owner(s) than the current Safe owner(s)', () => { + describe('with unique owners', () => { + it('should swap as many owners as possible then remove the rest when the threshold remains the same', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }] + + const transactions = getRecoveryProposalTransactions({ + safe, + newThreshold: oldThreshold, + newOwners, + }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'removeOwner', [newOwner2, oldOwner3, oldThreshold]) + }) + + it('should swap as many owners as possible then remove the rest when with a final threshold change if the threshold changes', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + const newThreshold = oldThreshold + 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(3) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(3) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [ + SENTINEL_ADDRESS, + oldOwner1, + newOwner1, + ]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'swapOwner', [newOwner1, oldOwner2, newOwner2]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(3, 'removeOwner', [newOwner2, oldOwner3, newThreshold]) + }) + }) + + describe('with duplicates owners', () => { + it('should swap as many differing owners as possible then remove the rest when the threshold remains the same', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: oldOwner1 }, { value: newOwner1 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold: oldThreshold, newOwners }) + + expect(transactions).toHaveLength(2) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(2) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [oldOwner1, oldOwner2, newOwner1]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'removeOwner', [newOwner1, oldOwner3, oldThreshold]) + }) + + it('should swap as many differing owners as possible then remove the rest when with a final threshold change if the threshold changes', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + const newThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: oldOwner1 }, { value: newOwner1 }] + + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + expect(transactions).toHaveLength(2) + + expect(encodeFunctionDataSpy).toHaveBeenCalledTimes(2) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(1, 'swapOwner', [oldOwner1, oldOwner2, newOwner1]) + expect(encodeFunctionDataSpy).toHaveBeenNthCalledWith(2, 'removeOwner', [newOwner1, oldOwner3, newThreshold]) + }) + }) + }) + + it('should throw if the new threshold is higher than the final owner output', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + const newThreshold = 10 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }] + + expect(() => getRecoveryProposalTransactions({ safe, newThreshold, newOwners })).toThrow( + 'New threshold is higher than desired owners', + ) + }) + }) + + describe('getRecoveryProposalTransaction', () => { + it('should throw an error when no recovery transactions are found', () => { + const safe = { + address: { value: faker.finance.ethereumAddress() }, + owners: [{ value: faker.finance.ethereumAddress() }], + threshold: 1, + } as SafeInfo + + expect(() => + getRecoveryProposalTransaction({ + safe, + newThreshold: safe.threshold, + newOwners: safe.owners, + }), + ).toThrow('No recovery transactions found') + }) + + it('should return the transaction when a single recovery transaction is found', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const newOwner1 = faker.finance.ethereumAddress() + + const oldThreshold = 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }] + + const transaction = getRecoveryProposalTransaction({ + safe, + newThreshold: oldThreshold, + newOwners, + }) + + expect(transaction).toEqual({ + to: safeAddresss, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + describe('when multiple recovery transactions are found', () => { + it('should return a MetaTransactionData object ', () => { + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + const oldOwner3 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + const newThreshold = oldThreshold + 1 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }, { value: oldOwner3 }], + threshold: oldThreshold, + } as SafeInfo + + const multiSendDeployment = deployments.getMultiSendCallOnlyDeployment()! + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + const transaction = getRecoveryProposalTransaction({ + safe, + newThreshold, + newOwners, + }) + + expect(transaction).toEqual({ + to: multiSendDeployment.defaultAddress, + value: '0', + data: expect.any(String), + operation: OperationType.Call, + }) + }) + + it('should throw an error when MultiSend deployment is not found', () => { + jest.spyOn(deployments, 'getMultiSendCallOnlyDeployment').mockReturnValue(undefined) + + const safeAddresss = faker.finance.ethereumAddress() + + const oldOwner1 = faker.finance.ethereumAddress() + const oldOwner2 = faker.finance.ethereumAddress() + + const newOwner1 = faker.finance.ethereumAddress() + const newOwner2 = faker.finance.ethereumAddress() + const newOwner3 = faker.finance.ethereumAddress() + + const oldThreshold = 2 + + const safe = { + address: { value: safeAddresss }, + owners: [{ value: oldOwner1 }, { value: oldOwner2 }], + threshold: oldThreshold, + } as SafeInfo + + const newOwners = [{ value: newOwner1 }, { value: newOwner2 }, { value: newOwner3 }] + + expect(() => + getRecoveryProposalTransaction({ + safe, + newThreshold: oldThreshold, + newOwners, + }), + ).toThrow('MultiSend deployment not found') + }) + }) + }) +}) diff --git a/src/services/recovery/transaction.ts b/src/services/recovery/transaction.ts new file mode 100644 index 0000000000..a4dd3f227e --- /dev/null +++ b/src/services/recovery/transaction.ts @@ -0,0 +1,142 @@ +import { Interface } from 'ethers/lib/utils' +import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import { SENTINEL_ADDRESS } from '@safe-global/safe-core-sdk/dist/src/utils/constants' +import { encodeMultiSendData } from '@safe-global/safe-core-sdk/dist/src/utils/transactions/utils' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { sameAddress } from '@/utils/addresses' + +export function getRecoveryProposalTransactions({ + safe, + newThreshold, + newOwners, +}: { + safe: SafeInfo + newThreshold: number + newOwners: Array +}): Array { + const safeDeployment = getSafeSingletonDeployment({ network: safe.chainId, version: safe.version ?? undefined }) + + if (!safeDeployment) { + throw new Error('Safe deployment not found') + } + + const safeInterface = new Interface(safeDeployment.abi) + + // Cache owner changes to determine prevOwner + let _owners = safe.owners.map((owner) => owner.value) + + // Don't add/remove same owners + const ownersToAdd = newOwners + .filter((newOwner) => !_owners.some((oldOwner) => sameAddress(oldOwner, newOwner.value))) + .map((owner) => owner.value) + const ownersToRemove = _owners.filter( + (oldOwner) => !newOwners.some((newOwner) => sameAddress(newOwner.value, oldOwner)), + ) + + // Check whether threshold should be changed after owner management + let changeThreshold = newThreshold !== safe.threshold + + // Owner management transaction data + const txData: Array = [] + + // Iterate of existing/new owners and swap, add, remove accordingly + for (let index = 0; index < Math.max(ownersToAdd.length, ownersToRemove.length); index++) { + const ownerToAdd = ownersToAdd[index] + const ownerToRemove = ownersToRemove[index] + + const prevOwner = (() => { + const ownerIndex = _owners.findIndex((owner) => sameAddress(owner, ownerToRemove)) + return ownerIndex === 0 ? SENTINEL_ADDRESS : _owners[ownerIndex - 1] + })() + + // Swap existing owner with new one + if (ownerToRemove && ownerToAdd) { + const swapOwner = safeInterface.encodeFunctionData('swapOwner', [prevOwner, ownerToRemove, ownerToAdd]) + txData.push(swapOwner) + + // Swap owner in cache + _owners = _owners.map((owner) => (sameAddress(owner, ownerToRemove) ? ownersToAdd[index] : owner)) + } + // Add new owner and set threshold + else if (ownerToAdd) { + const threshold = Math.min(newThreshold, _owners.length + 1) + + const addOwnerWithThreshold = safeInterface.encodeFunctionData('addOwnerWithThreshold', [ownerToAdd, threshold]) + txData.push(addOwnerWithThreshold) + + changeThreshold = false + + // Add owner to cache + _owners.push(ownerToAdd) + } + // Remove existing owner and set threshold + else if (ownerToRemove) { + const threshold = Math.min(newThreshold, _owners.length - 1) + + const removeOwner = safeInterface.encodeFunctionData('removeOwner', [prevOwner, ownerToRemove, threshold]) + txData.push(removeOwner) + + changeThreshold = false + + // Remove owner from cache + _owners = _owners.filter((owner) => !sameAddress(owner, ownerToRemove)) + } + } + + // Only swapOwner will be called + if (changeThreshold) { + txData.push(safeInterface.encodeFunctionData('changeThreshold', [newThreshold])) + } + + if (newThreshold > _owners.length) { + throw new Error('New threshold is higher than desired owners') + } + + return txData.map((data) => ({ + to: safe.address.value, + value: '0', + operation: OperationType.Call, + data, + })) +} + +export function getRecoveryProposalTransaction({ + safe, + newThreshold, + newOwners, +}: { + safe: SafeInfo + newThreshold: number + newOwners: Array +}): MetaTransactionData { + const transactions = getRecoveryProposalTransactions({ safe, newThreshold, newOwners }) + + if (transactions.length === 0) { + throw new Error('No recovery transactions found') + } + + if (transactions.length === 1) { + return transactions[0] + } + + const multiSendDeployment = getMultiSendCallOnlyDeployment({ + network: safe.chainId, + version: safe.version ?? undefined, + }) + + if (!multiSendDeployment) { + throw new Error('MultiSend deployment not found') + } + + const multiSendInterface = new Interface(multiSendDeployment.abi) + const multiSendData = encodeMultiSendData(transactions) + + return { + to: multiSendDeployment.networkAddresses[safe.chainId] ?? multiSendDeployment.defaultAddress, + value: '0', + operation: OperationType.Call, + data: multiSendInterface.encodeFunctionData('multiSend', [multiSendData]), + } +} diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 570aa0e0ea..98b9ce244c 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -1,4 +1,5 @@ -import type { SafeInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import type { AddressEx, SafeInfo, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { OperationType } from '@safe-global/safe-core-sdk-types' import type { SafeTransaction, TransactionOptions, TransactionResult } from '@safe-global/safe-core-sdk-types' import type { EthersError } from '@/utils/ethers-utils' import { didReprice, didRevert } from '@/utils/ethers-utils' @@ -22,6 +23,8 @@ import { import { createWeb3 } from '@/hooks/wallets/web3' import { type OnboardAPI } from '@web3-onboard/core' import { asError } from '@/services/exceptions/utils' +import { getRecoveryProposalTransaction } from '@/services/recovery/transaction' +import { getModuleInstance, KnownContracts } from '@gnosis.pm/zodiac' /** * Propose a transaction @@ -400,3 +403,31 @@ export const dispatchBatchExecutionRelay = async ( groupKey, ) } + +export async function dispatchRecoveryProposal({ + onboard, + safe, + newThreshold, + newOwners, + delayModifierAddress, +}: { + onboard: OnboardAPI + safe: SafeInfo + newThreshold: number + newOwners: Array + delayModifierAddress: string +}) { + const wallet = await assertWalletChain(onboard, safe.chainId) + const provider = createWeb3(wallet.provider) + + const { to, value, data } = getRecoveryProposalTransaction({ + safe, + newThreshold, + newOwners, + }) + + const delayModifier = getModuleInstance(KnownContracts.DELAY, delayModifierAddress, provider) + + const signer = provider.getSigner() + await delayModifier.connect(signer).execTransactionFromModule(to, value, data, OperationType.Call) +} diff --git a/src/store/recoverySlice.ts b/src/store/recoverySlice.ts index 1755799cf3..4a30b615ac 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 @@ -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(({ modules }) => modules.some((module) => sameAddress(module, walletAddress))) + }, +) diff --git a/src/utils/__tests__/date.test.ts b/src/utils/__tests__/date.test.ts new file mode 100644 index 0000000000..71213cfee9 --- /dev/null +++ b/src/utils/__tests__/date.test.ts @@ -0,0 +1,23 @@ +import { getCountdown } from '../date' + +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 }) + }) +}) diff --git a/src/utils/date.ts b/src/utils/date.ts index d54cffbb85..0c66c1065d 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -21,3 +21,17 @@ export const formatTime = (timestamp: number): string => formatWithSchema(timest export const formatDateTime = (timestamp: number): string => formatWithSchema(timestamp, 'MMM d, yyyy - h:mm:ss a') export const formatTimeInWords = (timestamp: number): string => formatDistanceToNow(timestamp, { addSuffix: true }) + +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 } +}