diff --git a/package.json b/package.json index b3180f4caa..927e0f644b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@safe-global/protocol-kit": "^4.1.3", "@safe-global/safe-apps-sdk": "^9.1.0", "@safe-global/safe-client-gateway-sdk": "v1.60.1", - "@safe-global/safe-gateway-typescript-sdk": "3.22.4", + "@safe-global/safe-gateway-typescript-sdk": "3.22.5-beta.0", "@safe-global/safe-modules-deployments": "^2.2.1", "@sentry/react": "^7.91.0", "@spindl-xyz/attribution-lite": "^1.4.0", diff --git a/src/components/common/ExternalLink/index.tsx b/src/components/common/ExternalLink/index.tsx index b70a61b347..c32b9f8004 100644 --- a/src/components/common/ExternalLink/index.tsx +++ b/src/components/common/ExternalLink/index.tsx @@ -22,6 +22,7 @@ const ExternalLink = ({ display: 'inline-flex', alignItems: 'center', gap: 0.2, + cursor: 'pointer', }} > {children} diff --git a/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx b/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx index bba2e2f360..23119e62aa 100644 --- a/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx +++ b/src/components/common/NetworkSelector/__tests__/NetworkMultiSelector.test.tsx @@ -32,31 +32,36 @@ describe('NetworkMultiSelector', () => { .with({ chainId: '1' }) .with({ chainName: 'Ethereum' }) .with({ shortName: 'eth' }) - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .build(), chainBuilder() .with({ chainId: '10' }) .with({ chainName: 'Optimism' }) .with({ shortName: 'oeth' }) - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .build(), chainBuilder() .with({ chainId: '100' }) .with({ chainName: 'Gnosis Chain' }) .with({ shortName: 'gno' }) - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .build(), chainBuilder() .with({ chainId: '324' }) .with({ chainName: 'ZkSync Era' }) .with({ shortName: 'zksync' }) - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .build(), chainBuilder() .with({ chainId: '480' }) .with({ chainName: 'Worldchain' }) .with({ shortName: 'wc' }) - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .build(), ] diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index eaab210a00..4870901e89 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -22,7 +22,6 @@ import { getReadOnlyProxyFactoryContract, } from '@/services/contracts/safeContracts' import { FEATURES, getLatestSafeVersion } from '@/utils/chains' -import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-typescript-sdk' import { chainBuilder } from '@/tests/builders/chains' import { type ReplayedSafeProps } from '@/store/slices' import { faker } from '@faker-js/faker' @@ -39,9 +38,7 @@ import { Safe_to_l2_setup__factory } from '@/types/contracts' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) const latestSafeVersion = getLatestSafeVersion( - chainBuilder() - .with({ chainId: '1', features: [FEATURES.SAFE_141 as unknown as GatewayFeatures] }) - .build(), + chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build(), ) const safeToL2SetupDeployment = getSafeToL2SetupDeployment() @@ -57,7 +54,7 @@ describe('create/logic', () => { .with({ chainId: '1', l2: false, - features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + recommendedMasterCopyVersion: '1.4.1', }) .build() @@ -209,7 +206,7 @@ describe('create/logic', () => { chainBuilder() .with({ chainId: '1' }) // Multichain creation is toggled off - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL] as any }) .with({ l2: false }) .build(), ), @@ -239,7 +236,8 @@ describe('create/logic', () => { chainBuilder() .with({ chainId: '137' }) // Multichain creation is toggled off - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .with({ l2: true }) .build(), ), @@ -269,7 +267,8 @@ describe('create/logic', () => { chainBuilder() .with({ chainId: '137' }) // Multichain creation is toggled on - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.3.0' }) .with({ l2: true }) .build(), ), @@ -303,7 +302,8 @@ describe('create/logic', () => { chainBuilder() .with({ chainId: '137' }) // Multichain creation is toggled on - .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ features: [FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ recommendedMasterCopyVersion: '1.4.1' }) .with({ l2: true }) .build(), ), @@ -336,6 +336,7 @@ describe('create/logic', () => { .with({ chainId: '324' }) // Multichain and 1.4.1 creation is toggled off .with({ features: [FEATURES.COUNTERFACTUAL] as any }) + .with({ recommendedMasterCopyVersion: '1.3.0' }) .with({ l2: true }) .build(), ), diff --git a/src/components/settings/ContractVersion/index.tsx b/src/components/settings/ContractVersion/index.tsx index 151548f3dc..63450e4dc1 100644 --- a/src/components/settings/ContractVersion/index.tsx +++ b/src/components/settings/ContractVersion/index.tsx @@ -57,12 +57,14 @@ export const ContractVersion = () => { sx={{ mt: 2, borderRadius: '2px', borderColor: '#B0FFC9' }} icon={} > - New version is available: {latestSafeVersion} + + New version is available: {latestSafeVersion} ( + changelog) + - + Update now to take advantage of new features and the highest security standards available. You will need to - confirm this update just like any other transaction.{' '} - GitHub + confirm this update just like any other transaction. diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx index a67e483598..574a1eea1a 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx @@ -12,7 +12,6 @@ import MethodCall from './MethodCall' import useSafeAddress from '@/hooks/useSafeAddress' import { sameAddress } from '@/utils/addresses' import { DelegateCallWarning } from '@/components/transactions/Warning' -import { isMigrateToL2TxData } from '@/utils/transaction-guards' interface Props { txData: TransactionDetails['txData'] @@ -58,11 +57,9 @@ export const DecodedData = ({ txData, toInfo }: Props): ReactElement | null => { decodedData = } - const isL2Migration = isMigrateToL2TxData(txData, chainInfo?.chainId) - return ( - {isDelegateCall && } + {isDelegateCall && } {method ? ( diff --git a/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx index 2d4a106297..835c9eb835 100644 --- a/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx @@ -12,7 +12,7 @@ import { zeroPadValue } from 'ethers' import DecodedData from '../DecodedData' import ErrorMessage from '@/components/tx/ErrorMessage' import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' -import { MigrateToL2Information } from '@/components/tx/SignOrExecuteForm/MigrateToL2Information' +import { MigrateToL2Information } from '@/components/tx/confirmation-views/MigrateToL2Information' import { Box } from '@mui/material' import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards' @@ -73,7 +73,8 @@ export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetai return ( - + + {realSafeTxError ? ( {realSafeTxError.message} ) : decodedRealTxError ? ( diff --git a/src/components/transactions/TxDetails/TxData/SafeUpdate/index.tsx b/src/components/transactions/TxDetails/TxData/SafeUpdate/index.tsx new file mode 100644 index 0000000000..8907df4b90 --- /dev/null +++ b/src/components/transactions/TxDetails/TxData/SafeUpdate/index.tsx @@ -0,0 +1,25 @@ +import { Box, Stack } from '@mui/material' +import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import DecodedData from '../DecodedData' + +function SafeUpdate({ txData }: { txData?: TransactionData }) { + return ( + + + Safe version update + + + + + ) +} + +export default SafeUpdate diff --git a/src/components/transactions/TxDetails/TxData/index.tsx b/src/components/transactions/TxDetails/TxData/index.tsx index 21ed9d064a..fc2bd276de 100644 --- a/src/components/transactions/TxDetails/TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/index.tsx @@ -1,6 +1,11 @@ import SettingsChangeTxInfo from '@/components/transactions/TxDetails/TxData/SettingsChange' import type { SpendingLimitMethods } from '@/utils/transaction-guards' -import { isExecTxData, isOnChainConfirmationTxData, isStakingTxWithdrawInfo } from '@/utils/transaction-guards' +import { + isExecTxData, + isOnChainConfirmationTxData, + isSafeUpdateTxData, + isStakingTxWithdrawInfo, +} from '@/utils/transaction-guards' import { isStakingTxExitInfo } from '@/utils/transaction-guards' import { isCancellationTxInfo, @@ -28,6 +33,7 @@ import StakingTxExitDetails from '@/features/stake/components/StakingTxExitDetai import StakingTxWithdrawDetails from '@/features/stake/components/StakingTxWithdrawDetails' import { OnChainConfirmation } from './NestedTransaction/OnChainConfirmation' import { ExecTransaction } from './NestedTransaction/ExecTransaction' +import SafeUpdate from './SafeUpdate' const TxData = ({ txDetails, @@ -87,6 +93,10 @@ const TxData = ({ return } + if (isSafeUpdateTxData(txDetails.txData)) { + return + } + return } diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index e46ec0a846..63dd9d9a9d 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -7,7 +7,7 @@ import { Errors, logError } from '@/services/exceptions' import type { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' import useSafeInfo from '@/hooks/useSafeInfo' import { useCurrentChain } from '@/hooks/useChains' -import { prependSafeToL2Migration } from '@/utils/transactions' +import { prependSafeToL2Migration } from '@/utils/safe-migrations' import { useSelectAvailableSigner } from '@/hooks/wallets/useSelectAvailableSigner' export type SafeTxContextParams = { diff --git a/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx b/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx index 4fe31a6fda..14e8652231 100644 --- a/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx +++ b/src/components/tx-flow/flows/UpdateSafe/UpdateSafeReview.tsx @@ -1,55 +1,25 @@ import { useContext } from 'react' -import { Typography } from '@mui/material' - -import ExternalLink from '@/components/common/ExternalLink' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams' -import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' +import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' import { SafeTxContext } from '../../SafeTxProvider' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import useAsync from '@/hooks/useAsync' -import { getLatestSafeVersion } from '@/utils/chains' export const UpdateSafeReview = () => { const { safe, safeLoaded } = useSafeInfo() const chain = useCurrentChain() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) - const latestSafeVersion = getLatestSafeVersion(chain) - useAsync(async () => { - if (!chain || !safeLoaded) { - return - } + if (!chain || !safeLoaded) return const txs = await createUpdateSafeTxs(safe, chain) - createMultiSendCallOnlyTx(txs).then(setSafeTx).catch(setSafeTxError) - }, [safe, safeLoaded, chain, setSafeTx, setSafeTxError]) + const safeTxPromise = txs.length > 1 ? createMultiSendCallOnlyTx(txs) : createTx(txs[0]) - return ( - - - Update now to take advantage of new features and the highest security standards available. - - - - To check details about updates added by this smart contract version please visit{' '} - - latest Safe Account contracts changelog - - - - - You will need to confirm this update just like any other transaction. This means other signers will have to - confirm the update in case more than one confirmation is required for this Safe Account. - + safeTxPromise.then(setSafeTx).catch(setSafeTxError) + }, [safe, safeLoaded, chain, setSafeTx, setSafeTxError]) - - Warning: this upgrade will invalidate all unexecuted transactions. This means you will be unable to - access or execute them after the upgrade. Please make sure to execute any remaining transactions before - upgrading. - - - ) + return } diff --git a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx index f5944f1a52..e7d4fe888a 100644 --- a/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx +++ b/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx @@ -19,28 +19,20 @@ import UnknownContractError from './UnknownContractError' import { ErrorBoundary } from '@sentry/react' import ApprovalEditor from '../ApprovalEditor' import { isDelegateCall } from '@/services/tx/tx-sender/sdk' -import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' -import { TX_EVENTS } from '@/services/analytics/events/transactions' -import { trackEvent } from '@/services/analytics' import useChainId from '@/hooks/useChainId' import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' import { Blockaid } from '../security/blockaid' - -import { MigrateToL2Information } from './MigrateToL2Information' -import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' - import { useLazyGetTransactionDetailsQuery } from '@/store/api/gateway' import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' - import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import ConfirmationView from '../confirmation-views' import { SignerForm } from './SignerForm' import { useSigner } from '@/hooks/wallets/useWallet' -import { isNestedConfirmationTxInfo } from '@/utils/transaction-guards' +import { trackTxEvents } from './tracking' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void @@ -58,76 +50,6 @@ export type SignOrExecuteProps = { showMethodCall?: boolean } -const trackTxEvents = ( - details: TransactionDetails | undefined, - isCreation: boolean, - isExecuted: boolean, - isRoleExecution: boolean, - isProposerCreation: boolean, - isParentSigner: boolean, - origin?: string, -) => { - const isNestedConfirmation = !!details && isNestedConfirmationTxInfo(details.txInfo) - - const creationEvent = getCreationEvent({ isParentSigner, isRoleExecution, isProposerCreation }) - const confirmationEvent = getConfirmationEvent({ isParentSigner, isNestedConfirmation }) - const executionEvent = getExecutionEvent({ isParentSigner, isNestedConfirmation, isRoleExecution }) - - const event = (() => { - if (isCreation) { - return creationEvent - } - if (isExecuted) { - return executionEvent - } - return confirmationEvent - })() - - const txType = getTransactionTrackingType(details, origin) - trackEvent({ ...event, label: txType }) - - // Immediate execution on creation - if (isCreation && isExecuted) { - trackEvent({ ...executionEvent, label: txType }) - } -} - -function getCreationEvent(args: { isParentSigner: boolean; isRoleExecution: boolean; isProposerCreation: boolean }) { - if (args.isParentSigner) { - return TX_EVENTS.CREATE_VIA_PARENT - } - if (args.isRoleExecution) { - return TX_EVENTS.CREATE_VIA_ROLE - } - if (args.isProposerCreation) { - return TX_EVENTS.CREATE_VIA_PROPOSER - } - return TX_EVENTS.CREATE -} - -function getConfirmationEvent(args: { isParentSigner: boolean; isNestedConfirmation: boolean }) { - if (args.isParentSigner) { - return TX_EVENTS.CONFIRM_VIA_PARENT - } - if (args.isNestedConfirmation) { - return TX_EVENTS.CONFIRM_IN_PARENT - } - return TX_EVENTS.CONFIRM -} - -function getExecutionEvent(args: { isParentSigner: boolean; isNestedConfirmation: boolean; isRoleExecution: boolean }) { - if (args.isParentSigner) { - return TX_EVENTS.EXECUTE_VIA_PARENT - } - if (args.isNestedConfirmation) { - return TX_EVENTS.EXECUTE_IN_PARENT - } - if (args.isRoleExecution) { - return TX_EVENTS.EXECUTE_VIA_ROLE - } - return TX_EVENTS.EXECUTE -} - export const SignOrExecuteForm = ({ chainId, safeTx, @@ -157,8 +79,6 @@ export const SignOrExecuteForm = ({ const isProposer = useIsWalletProposer() const isProposing = isProposer && !isSafeOwner && isCreation const isCounterfactualSafe = !safe.deployed - const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(safeTx) - const isMultiChainMigration = !!multiChainMigrationTarget // Check if a Zodiac Roles mod is enabled and if the user is a member of any role that allows the transaction const roles = useRoles( @@ -208,7 +128,6 @@ export const SignOrExecuteForm = ({ <> {props.children} - {isMultiChainMigration && } - {!isMultiChainMigration && } + diff --git a/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx b/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx index 931a7dd378..47b4de55cd 100644 --- a/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx +++ b/src/components/tx/SignOrExecuteForm/UnknownContractError.tsx @@ -1,19 +1,24 @@ -import { type ReactElement } from 'react' +import { useMemo, type ReactElement } from 'react' +import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import ExternalLink from '@/components/common/ExternalLink' import { useCurrentChain } from '@/hooks/useChains' import useSafeInfo from '@/hooks/useSafeInfo' import { getExplorerLink } from '@/utils/gateway' import ErrorMessage from '../ErrorMessage' import { isValidMasterCopy } from '@/services/contracts/safeContracts' +import { extractMigrationL2MasterCopyAddress } from '@/utils/safe-migrations' -const UnknownContractError = (): ReactElement | null => { +const UnknownContractError = ({ txData }: { txData: TransactionData | undefined }): ReactElement | null => { const { safe, safeAddress } = useSafeInfo() const currentChain = useCurrentChain() + const newMasterCopy = useMemo(() => { + return txData && extractMigrationL2MasterCopyAddress(txData) + }, [txData]) // Unsupported base contract const isUnknown = !isValidMasterCopy(safe.implementationVersionState) - if (!isUnknown) return null + if (!isUnknown || !newMasterCopy) return null return ( diff --git a/src/components/tx/SignOrExecuteForm/tracking.ts b/src/components/tx/SignOrExecuteForm/tracking.ts new file mode 100644 index 0000000000..50c6a6144c --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/tracking.ts @@ -0,0 +1,75 @@ +import { trackEvent } from '@/services/analytics' +import { TX_EVENTS } from '@/services/analytics/events/transactions' +import { getTransactionTrackingType } from '@/services/analytics/tx-tracking' +import { isNestedConfirmationTxInfo } from '@/utils/transaction-guards' +import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' + +function getCreationEvent(args: { isParentSigner: boolean; isRoleExecution: boolean; isProposerCreation: boolean }) { + if (args.isParentSigner) { + return TX_EVENTS.CREATE_VIA_PARENT + } + if (args.isRoleExecution) { + return TX_EVENTS.CREATE_VIA_ROLE + } + if (args.isProposerCreation) { + return TX_EVENTS.CREATE_VIA_PROPOSER + } + return TX_EVENTS.CREATE +} + +function getConfirmationEvent(args: { isParentSigner: boolean; isNestedConfirmation: boolean }) { + if (args.isParentSigner) { + return TX_EVENTS.CONFIRM_VIA_PARENT + } + if (args.isNestedConfirmation) { + return TX_EVENTS.CONFIRM_IN_PARENT + } + return TX_EVENTS.CONFIRM +} + +function getExecutionEvent(args: { isParentSigner: boolean; isNestedConfirmation: boolean; isRoleExecution: boolean }) { + if (args.isParentSigner) { + return TX_EVENTS.EXECUTE_VIA_PARENT + } + if (args.isNestedConfirmation) { + return TX_EVENTS.EXECUTE_IN_PARENT + } + if (args.isRoleExecution) { + return TX_EVENTS.EXECUTE_VIA_ROLE + } + return TX_EVENTS.EXECUTE +} + +export function trackTxEvents( + details: TransactionDetails | undefined, + isCreation: boolean, + isExecuted: boolean, + isRoleExecution: boolean, + isProposerCreation: boolean, + isParentSigner: boolean, + origin?: string, +) { + const isNestedConfirmation = !!details && isNestedConfirmationTxInfo(details.txInfo) + + const creationEvent = getCreationEvent({ isParentSigner, isRoleExecution, isProposerCreation }) + const confirmationEvent = getConfirmationEvent({ isParentSigner, isNestedConfirmation }) + const executionEvent = getExecutionEvent({ isParentSigner, isNestedConfirmation, isRoleExecution }) + + const event = (() => { + if (isCreation) { + return creationEvent + } + if (isExecuted) { + return executionEvent + } + return confirmationEvent + })() + + const txType = getTransactionTrackingType(details, origin) + trackEvent({ ...event, label: txType }) + + // Immediate execution on creation + if (isCreation && isExecuted) { + trackEvent({ ...executionEvent, label: txType }) + } +} diff --git a/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx b/src/components/tx/confirmation-views/MigrateToL2Information/index.tsx similarity index 79% rename from src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx rename to src/components/tx/confirmation-views/MigrateToL2Information/index.tsx index 6240d7b4de..431b862c83 100644 --- a/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx +++ b/src/components/tx/confirmation-views/MigrateToL2Information/index.tsx @@ -1,14 +1,21 @@ +import { useMemo } from 'react' import { Alert, AlertTitle, Box, SvgIcon, Typography } from '@mui/material' +import type { TransactionData } from '@safe-global/safe-gateway-typescript-sdk' import InfoOutlinedIcon from '@/public/images/notifications/info.svg' import NamedAddressInfo from '@/components/common/NamedAddressInfo' +import { extractMigrationL2MasterCopyAddress } from '@/utils/safe-migrations' export const MigrateToL2Information = ({ variant, - newMasterCopy, + txData, }: { variant: 'history' | 'queue' - newMasterCopy?: string + txData?: TransactionData }) => { + const newMasterCopy = useMemo(() => { + return txData && extractMigrationL2MasterCopyAddress(txData) + }, [txData]) + return ( }> diff --git a/src/components/tx/confirmation-views/UpdateSafe/index.tsx b/src/components/tx/confirmation-views/UpdateSafe/index.tsx new file mode 100644 index 0000000000..6dec5f3949 --- /dev/null +++ b/src/components/tx/confirmation-views/UpdateSafe/index.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import { Alert, AlertTitle, Box, Divider, Stack, Typography } from '@mui/material' +import { LATEST_SAFE_VERSION } from '@/config/constants' +import { useCurrentChain } from '@/hooks/useChains' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useQueuedTxsLength } from '@/hooks/useTxQueue' +import ExternalLink from '@/components/common/ExternalLink' +import { maybePlural } from '@/utils/formatters' + +function BgBox({ children, light }: { children: ReactNode; light?: boolean }) { + return ( + + {children} + + ) +} + +function UpdateSafe() { + const { safe } = useSafeInfo() + const chain = useCurrentChain() + const queueSize = useQueuedTxsLength() + const latestSafeVersion = chain?.recommendedMasterCopyVersion || LATEST_SAFE_VERSION + + return ( + <> + + Current version: {safe.version} + + New version: {latestSafeVersion} + + + + Read about the updates in the new Safe contracts version in the{' '} + + version {latestSafeVersion} changelog + + + + {queueSize && ( + + This upgrade will invalidate all queued transactions! + You have {queueSize} unexecuted transaction{maybePlural(parseInt(queueSize))}. Please make sure to execute or + delete them before upgrading, otherwise you'll have to reject or replace them after the upgrade. + + )} + + + + ) +} + +export default UpdateSafe diff --git a/src/components/tx/confirmation-views/index.tsx b/src/components/tx/confirmation-views/index.tsx index 367b10123d..849ba5dab6 100644 --- a/src/components/tx/confirmation-views/index.tsx +++ b/src/components/tx/confirmation-views/index.tsx @@ -7,6 +7,8 @@ import { isExecTxData, isOnChainConfirmationTxData, isOrderTxInfo, + isSafeToL2MigrationTxData, + isSafeUpdateTxData, isSwapOrderTxInfo, } from '@/utils/transaction-guards' import { type ReactNode, useContext, useMemo } from 'react' @@ -22,6 +24,8 @@ import { ExecTransaction } from '@/components/transactions/TxDetails/TxData/Nest import { type ReactElement } from 'react' import SwapOrder from './SwapOrder' import StakingTx from './StakingTx' +import UpdateSafe from './UpdateSafe' +import { MigrateToL2Information } from './MigrateToL2Information' type ConfirmationViewProps = { txDetails?: TransactionDetails @@ -55,6 +59,12 @@ const getConfirmationViewComponent = ({ if (isAnyStakingTxInfo(txInfo)) return + if (isCustomTxInfo(txInfo) && isSafeUpdateTxData(txDetails.txData)) return + + if (isCustomTxInfo(txInfo) && isSafeToL2MigrationTxData(txDetails.txData)) { + return + } + return null } diff --git a/src/features/multichain/utils/utils.ts b/src/features/multichain/utils/utils.ts index a9d549d50b..be50ff10f4 100644 --- a/src/features/multichain/utils/utils.ts +++ b/src/features/multichain/utils/utils.ts @@ -1,16 +1,17 @@ -import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' +import type { DecodedDataResponse, ChainInfo, SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import semverSatisfies from 'semver/functions/satisfies' +import memoize from 'lodash/memoize' +import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } from 'ethers' -import { type ChainInfo, type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' import { type UndeployedSafesState, type ReplayedSafeProps } from '@/store/slices' import { sameAddress } from '@/utils/addresses' import { Safe_proxy_factory__factory } from '@/types/contracts' -import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } from 'ethers' import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' -import memoize from 'lodash/memoize' import { FEATURES, hasFeature } from '@/utils/chains' import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import { type MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { LATEST_SAFE_VERSION } from '@/config/constants' type SafeSetup = { owners: string[] @@ -121,14 +122,14 @@ export const predictAddressBasedOnReplayData = async (safeCreationData: Replayed } export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { - return ( - hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && - hasFeature(chain, FEATURES.COUNTERFACTUAL) && - hasFeature(chain, FEATURES.SAFE_141) - ) + return hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && hasFeature(chain, FEATURES.COUNTERFACTUAL) } export const hasMultiChainAddNetworkFeature = (chain: ChainInfo | undefined): boolean => { if (!chain) return false - return hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_ADD_NETWORK) && hasFeature(chain, FEATURES.COUNTERFACTUAL) + return ( + hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_ADD_NETWORK) && + hasFeature(chain, FEATURES.COUNTERFACTUAL) && + semverSatisfies(chain.recommendedMasterCopyVersion || LATEST_SAFE_VERSION, '>=1.4.1') + ) } diff --git a/src/features/recovery/services/__tests__/recovery-state.test.ts b/src/features/recovery/services/__tests__/recovery-state.test.ts index 869a90d09f..793289834f 100644 --- a/src/features/recovery/services/__tests__/recovery-state.test.ts +++ b/src/features/recovery/services/__tests__/recovery-state.test.ts @@ -15,8 +15,7 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { encodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils/transactions/utils' import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments' import { Interface } from 'ethers' -import { FEATURES, getLatestSafeVersion } from '@/utils/chains' -import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-typescript-sdk' +import { getLatestSafeVersion } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' jest.mock('@/hooks/wallets/web3') @@ -24,9 +23,7 @@ jest.mock('@/hooks/wallets/web3') const mockUseWeb3ReadOnly = useWeb3ReadOnly as jest.MockedFunction const latestSafeVersion = getLatestSafeVersion( - chainBuilder() - .with({ chainId: '1', features: [FEATURES.SAFE_141 as unknown as GatewayFeatures] }) - .build(), + chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build(), ) const PRE_MULTI_SEND_CALL_ONLY_VERSIONS = ['1.0.0', '1.1.1'] const SUPPORTED_MULTI_SEND_CALL_ONLY_VERSIONS = [ diff --git a/src/services/contracts/__tests__/deployments.test.ts b/src/services/contracts/__tests__/deployments.test.ts index 209250ee4c..38912aa1c7 100644 --- a/src/services/contracts/__tests__/deployments.test.ts +++ b/src/services/contracts/__tests__/deployments.test.ts @@ -2,21 +2,17 @@ import * as safeDeployments from '@safe-global/safe-deployments' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import * as deployments from '../deployments' -import { FEATURES, getLatestSafeVersion } from '@/utils/chains' +import { getLatestSafeVersion } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' -const mainnetInfo = chainBuilder() - .with({ chainId: '1', features: [FEATURES.SAFE_141 as any], l2: false }) - .build() -const l2ChainInfo = chainBuilder() - .with({ chainId: '137', features: [FEATURES.SAFE_141 as any], l2: true }) - .build() +const mainnetInfo = chainBuilder().with({ chainId: '1', l2: false, recommendedMasterCopyVersion: '1.4.1' }).build() +const l2ChainInfo = chainBuilder().with({ chainId: '137', l2: true, recommendedMasterCopyVersion: '1.4.1' }).build() const unsupportedChainInfo = chainBuilder() - .with({ chainId: '69420', features: [FEATURES.SAFE_141 as any], l2: false }) + .with({ chainId: '69420', l2: false, recommendedMasterCopyVersion: '1.3.0' }) .build() const unsupportedL2ChainInfo = chainBuilder() - .with({ chainId: '69420', features: [FEATURES.SAFE_141 as any], l2: true }) + .with({ chainId: '69420', l2: true, recommendedMasterCopyVersion: '1.3.0' }) .build() const latestSafeVersion = getLatestSafeVersion(mainnetInfo) diff --git a/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx b/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx index 65264bc24f..1ac75f5bb2 100644 --- a/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx +++ b/src/services/safe-wallet-provider/useSafeWalletProvider.test.tsx @@ -17,7 +17,6 @@ import { Interface } from 'ethers' import { getCreateCallDeployment } from '@safe-global/safe-deployments' import { useCurrentChain } from '@/hooks/useChains' import { chainBuilder } from '@/tests/builders/chains' -import { FEATURES } from '@/utils/chains' const appInfo = { id: 1, @@ -47,9 +46,7 @@ describe('useSafeWalletProvider', () => { jest.clearAllMocks() mockUseCurrentChain.mockReturnValue( - chainBuilder() - .with({ chainId: '1', features: [FEATURES.SAFE_141 as any] }) - .build(), + chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build(), ) }) diff --git a/src/services/tx/__tests__/safeUpdateParams.test.ts b/src/services/tx/__tests__/safeUpdateParams.test.ts index d3e74623e2..314f9cfd5a 100644 --- a/src/services/tx/__tests__/safeUpdateParams.test.ts +++ b/src/services/tx/__tests__/safeUpdateParams.test.ts @@ -10,7 +10,7 @@ import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { Interface, JsonRpcProvider } from 'ethers' import { createUpdateSafeTxs } from '../safeUpdateParams' import * as web3 from '@/hooks/wallets/web3' -import { FEATURES, getLatestSafeVersion } from '@/utils/chains' +import { getLatestSafeVersion } from '@/utils/chains' import { chainBuilder } from '@/tests/builders/chains' const MOCK_SAFE_ADDRESS = '0x0000000000000000000000000000000000005AFE' @@ -39,7 +39,7 @@ describe('safeUpgradeParams', () => { } as SafeInfo const mockChainInfo = chainBuilder() - .with({ chainId: '1', l2: false, features: [FEATURES.SAFE_141 as any] }) + .with({ chainId: '1', l2: false, recommendedMasterCopyVersion: '1.4.1' }) .build() const txs = await createUpdateSafeTxs(mockSafe, mockChainInfo) const [masterCopyTx, fallbackHandlerTx] = txs @@ -74,7 +74,7 @@ describe('safeUpgradeParams', () => { version: '1.1.1', } as SafeInfo const mockChainInfo = chainBuilder() - .with({ chainId: '1', l2: false, features: [FEATURES.SAFE_141 as any] }) + .with({ chainId: '1', l2: false, recommendedMasterCopyVersion: '1.4.1' }) .build() const txs = await createUpdateSafeTxs(mockSafe, mockChainInfo) const [masterCopyTx, fallbackHandlerTx] = txs @@ -111,7 +111,7 @@ describe('safeUpgradeParams', () => { version: '1.1.1', } as SafeInfo const mockChainInfo = chainBuilder() - .with({ chainId: '100', l2: true, features: [FEATURES.SAFE_141 as any] }) + .with({ chainId: '100', l2: true, recommendedMasterCopyVersion: '1.4.1' }) .build() const txs = await createUpdateSafeTxs(mockSafe, mockChainInfo) diff --git a/src/services/tx/safeUpdateParams.ts b/src/services/tx/safeUpdateParams.ts index 9b0fc5f320..36fc3b7ef1 100644 --- a/src/services/tx/safeUpdateParams.ts +++ b/src/services/tx/safeUpdateParams.ts @@ -2,11 +2,13 @@ import type { SafeContractImplementationType } from '@safe-global/protocol-kit/d import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' import { OperationType } from '@safe-global/safe-core-sdk-types' import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import semverSatisfies from 'semver/functions/satisfies' import { getReadOnlyFallbackHandlerContract, getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' import { assertValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' import { SAFE_FEATURES } from '@safe-global/protocol-kit/dist/src/utils/safeVersions' import { hasSafeFeature } from '@/utils/safe-versions' import { getLatestSafeVersion } from '@/utils/chains' +import { createUpdateMigration } from '@/utils/safe-migrations' const getChangeFallbackHandlerCallData = async ( safeContractInstance: SafeContractImplementationType, @@ -24,14 +26,21 @@ const getChangeFallbackHandlerCallData = async ( } /** - * Creates two transactions: + * For 1.3.0 Safes, does a delegate call to a migration contract. + * + * For older Safes, creates two transactions: * - change the mastercopy address * - set the fallback handler address - * Only works for safes < 1.3.0 as the changeMasterCopy function was removed */ export const createUpdateSafeTxs = async (safe: SafeInfo, chain: ChainInfo): Promise => { assertValidSafeVersion(safe.version) + // 1.3.0 Safes are updated using a delegate call to a migration contract + if (semverSatisfies(safe.version, '1.3.0')) { + return [createUpdateMigration(chain)] + } + + // For older Safes, we need to create two transactions const latestMasterCopyAddress = await ( await getReadOnlyGnosisSafeContract(chain, getLatestSafeVersion(chain)) ).getAddress() diff --git a/src/tests/builders/chains.ts b/src/tests/builders/chains.ts index 0f8f033340..ed3b333f0e 100644 --- a/src/tests/builders/chains.ts +++ b/src/tests/builders/chains.ts @@ -110,6 +110,7 @@ export const chainBuilder = (): IBuilder => { disabledWallets: generateRandomArray(() => faker.word.sample(), { min: 1, max: 10 }), // @ts-expect-error - we are using a local FEATURES enum features: generateRandomArray(() => faker.helpers.enumValue(FEATURES), { min: 1, max: 10 }), + recommendedMasterCopyVersion: faker.system.semver(), }) } diff --git a/src/tests/mocks/chains.ts b/src/tests/mocks/chains.ts index 8d9c6c346b..b194c3cd7b 100644 --- a/src/tests/mocks/chains.ts +++ b/src/tests/mocks/chains.ts @@ -68,6 +68,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.xdai.gnosis.io', @@ -122,6 +123,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.polygon.gnosis.io', @@ -182,6 +184,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.bsc.gnosis.io', @@ -238,6 +241,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.ewc.gnosis.io', @@ -292,6 +296,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.arbitrum.gnosis.io', @@ -339,6 +344,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.aurora.gnosis.io', @@ -387,6 +393,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.avalanche.gnosis.io', @@ -446,6 +453,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.optimism.gnosis.io', @@ -493,6 +501,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.goerli.gnosis.io/', @@ -548,6 +557,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.rinkeby.gnosis.io', @@ -593,6 +603,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, { transactionService: 'https://safe-transaction.volta.gnosis.io', @@ -647,6 +658,7 @@ const CONFIG_SERVICE_CHAINS: ChainInfo[] = [ chainName: null, enabled: false, }, + recommendedMasterCopyVersion: '1.4.1', }, ] diff --git a/src/utils/__tests__/chains.test.ts b/src/utils/__tests__/chains.test.ts index baebc5009f..38ec32d031 100644 --- a/src/utils/__tests__/chains.test.ts +++ b/src/utils/__tests__/chains.test.ts @@ -41,46 +41,20 @@ describe('chains', () => { describe('chains', () => { describe('getLatestSafeVersion', () => { - it('should return 1.4.1 on supported networks', () => { + it('should return the version from recommendedMasterCopyVersion', () => { expect( - getLatestSafeVersion( - chainBuilder() - .with({ chainId: '1', features: [FEATURES.SAFE_141 as any] }) - .build(), - ), - ).toEqual('1.4.1') - expect( - getLatestSafeVersion( - chainBuilder() - .with({ chainId: '137', features: [FEATURES.SAFE_141 as any] }) - .build(), - ), + getLatestSafeVersion(chainBuilder().with({ chainId: '1', recommendedMasterCopyVersion: '1.4.1' }).build()), ).toEqual('1.4.1') expect( - getLatestSafeVersion( - chainBuilder() - .with({ chainId: '11155111', features: [FEATURES.SAFE_141 as any] }) - .build(), - ), - ).toEqual('1.4.1') + getLatestSafeVersion(chainBuilder().with({ chainId: '137', recommendedMasterCopyVersion: '1.3.0' }).build()), + ).toEqual('1.3.0') }) - - it('should return 1.3.0 on networks where 1.4.1 is not released', () => { + it('should fall back to LATEST_VERSION', () => { expect( getLatestSafeVersion( - chainBuilder() - .with({ chainId: '324', features: [FEATURES.SAFE_141 as any] }) - .build(), + chainBuilder().with({ chainId: '11155111', recommendedMasterCopyVersion: null }).build(), ), - ).toEqual('1.3.0') - }) - - it('should return 1.3.0 if the feature is off', () => { - expect(getLatestSafeVersion(chainBuilder().with({ chainId: '1', features: [] }).build())).toEqual('1.3.0') - expect(getLatestSafeVersion(chainBuilder().with({ chainId: '137', features: [] }).build())).toEqual('1.3.0') - expect(getLatestSafeVersion(chainBuilder().with({ chainId: '11155111', features: [] }).build())).toEqual( - '1.3.0', - ) + ).toEqual('1.4.1') }) }) }) diff --git a/src/utils/__tests__/safe-migrations.test.ts b/src/utils/__tests__/safe-migrations.test.ts new file mode 100644 index 0000000000..6ebdc3330b --- /dev/null +++ b/src/utils/__tests__/safe-migrations.test.ts @@ -0,0 +1,353 @@ +import { + type ChainInfo, + ImplementationVersionState, + type TransactionData, +} from '@safe-global/safe-gateway-typescript-sdk' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import { extractMigrationL2MasterCopyAddress, prependSafeToL2Migration } from '../safe-migrations' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import { chainBuilder } from '@/tests/builders/chains' +import { safeSignatureBuilder, safeTxBuilder, safeTxDataBuilder } from '@/tests/builders/safeTx' +import { + getMultiSendCallOnlyDeployment, + getMultiSendDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, + getSafeToL2MigrationDeployment, +} from '@safe-global/safe-deployments' +import type Safe from '@safe-global/protocol-kit' +import { encodeMultiSendData } from '@safe-global/protocol-kit' +import { Multi_send__factory, Safe_to_l2_migration__factory } from '@/types/contracts' +import { faker } from '@faker-js/faker' +import { getAndValidateSafeSDK } from '@/services/tx/tx-sender/sdk' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { checksumAddress } from '../addresses' +import { createUpdateMigration } from '../safe-migrations' + +jest.mock('@/services/tx/tx-sender/sdk') + +const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() +const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress +const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() +const multisendInterface = Multi_send__factory.createInterface() + +describe('prependSafeToL2Migration', () => { + const mockGetAndValidateSdk = getAndValidateSafeSDK as jest.MockedFunction + + beforeEach(() => { + // Mock create Tx + mockGetAndValidateSdk.mockReturnValue({ + createTransaction: ({ transactions, onlyCalls }) => { + return Promise.resolve( + safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + to: onlyCalls + ? (getMultiSendCallOnlyDeployment()?.defaultAddress ?? faker.finance.ethereumAddress()) + : (getMultiSendDeployment()?.defaultAddress ?? faker.finance.ethereumAddress()), + value: '0', + data: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ + encodeMultiSendData(transactions), + ]), + nonce: 0, + operation: 1, + }) + .build(), + }) + .build(), + ) + }, + } as Safe) + }) + + it('should return undefined for undefined safeTx', () => { + expect( + prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), chainBuilder().build()), + ).resolves.toBeUndefined() + }) + + it('should throw if chain is undefined', () => { + expect(() => prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), undefined)).toThrowError() + }) + + it('should not modify tx if the chain is L1', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: false }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if the nonce is > 0', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 1 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if implementationState is correct', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UP_TO_DATE }) + .build() + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if the tx is already signed', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + safeTx.addSignature(safeSignatureBuilder().build()) + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( + safeTx, + ) + }) + + it('should not modify tx if the chain has no migration lib deployed', () => { + const safeTx = safeTxBuilder() + .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) + .build() + + expect( + prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '69420' }).build()), + ).resolves.toEqual(safeTx) + }) + + it('should not modify tx if the tx already migrates', () => { + const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress + + const safeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: safeToL2MigrationAddress, + data: + safeL2SingletonDeployment && + safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), + }) + .build(), + }) + .build() + const safeInfo = extendedSafeInfoBuilder() + .with({ + implementationVersionState: ImplementationVersionState.UNKNOWN, + implementation: { + name: '1.3.0', + value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), + }, + }) + .build() + expect( + prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), + ).resolves.toEqual(safeTx) + const multiSendSafeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: getMultiSendDeployment()?.defaultAddress, + data: + safeToL2MigrationAddress && + safeL2SingletonDeployment && + Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + value: '0', + operation: 1, + to: safeToL2MigrationAddress, + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), + }, + ]), + ]), + }) + .build(), + }) + .build() + expect( + prependSafeToL2Migration(multiSendSafeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), + ).resolves.toEqual(multiSendSafeTx) + }) + + it('should modify single txs if applicable', async () => { + const safeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 10 }), + value: '0', + }) + .build(), + }) + .build() + + const safeInfo = extendedSafeInfoBuilder() + .with({ + implementationVersionState: ImplementationVersionState.UNKNOWN, + implementation: { + name: '1.3.0', + value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), + }, + }) + .build() + + const modifiedTx = await prependSafeToL2Migration( + safeTx, + safeInfo, + chainBuilder().with({ l2: true, chainId: '10' }).build(), + ) + + expect(modifiedTx).not.toEqual(safeTx) + expect(modifiedTx?.data.to).toEqual(getMultiSendDeployment()?.defaultAddress) + const decodedMultiSend = decodeMultiSendData(modifiedTx!.data.data) + expect(decodedMultiSend).toHaveLength(2) + const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress + + expect(decodedMultiSend).toEqual([ + { + to: safeToL2MigrationAddress, + value: '0', + operation: 1, + data: + safeL2SingletonDeployment && + safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), + }, + { + to: checksumAddress(safeTx.data.to), + value: safeTx.data.value, + operation: safeTx.data.operation, + data: safeTx.data.data.toLowerCase(), + }, + ]) + }) +}) + +describe('extractMigrationL2MasterCopyAddress', () => { + it('should return undefined for non multisend safeTx', () => { + expect( + extractMigrationL2MasterCopyAddress({ + hexData: + '0xf8dc5dd9000000000000000000000000000000000000000000000000000000000000000100000000000000000000000065f8236309e5a99ff0d129d04e486ebce20dc7b00000000000000000000000000000000000000000000000000000000000000001', + } as TransactionData), + ).toBeUndefined() + }) + + it('should return undefined for multisend without migration', () => { + expect( + extractMigrationL2MasterCopyAddress({ + hexData: multisendInterface.encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + value: '0', + operation: 0, + }, + { + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + value: '0', + operation: 0, + }, + ]), + ]), + } as TransactionData), + ).toBeUndefined() + }) + + it('should return migration address for multisend with migration as first tx', () => { + const l2SingletonAddress = getSafeL2SingletonDeployment()?.defaultAddress! + expect( + extractMigrationL2MasterCopyAddress({ + hexData: multisendInterface.encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + to: safeToL2MigrationAddress!, + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [l2SingletonAddress]), + value: '0', + operation: 1, + }, + { + to: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + value: '0', + operation: 0, + }, + ]), + ]), + } as TransactionData), + ).toEqual(l2SingletonAddress) + }) + + describe('createUpdateMigration', () => { + const mockChain = { + chainId: '1', + l2: false, + recommendedMasterCopyVersion: '1.4.1', + } as unknown as ChainInfo + + const mockChainOld = { + chainId: '1', + l2: false, + recommendedMasterCopyVersion: '1.3.0', + } as unknown as ChainInfo + + it('should create a migration transaction for L1 chain', () => { + const result = createUpdateMigration(mockChain) + + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0xed007fc6', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', + }) + }) + + it('should create a migration transaction for L2 chain', () => { + const l2Chain = { ...mockChain, l2: true } + const result = createUpdateMigration(l2Chain) + + expect(result).toEqual({ + operation: OperationType.DelegateCall, + data: '0x68cb3d94', + to: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + value: '0', + }) + }) + + it('should throw an error if deployment is not found', () => { + expect(() => createUpdateMigration(mockChainOld)).toThrow('Migration deployment not found') + }) + }) +}) diff --git a/src/utils/__tests__/transaction-guards.test.ts b/src/utils/__tests__/transaction-guards.test.ts index d5d6453614..3443e57019 100644 --- a/src/utils/__tests__/transaction-guards.test.ts +++ b/src/utils/__tests__/transaction-guards.test.ts @@ -3,6 +3,7 @@ import { isExecTxInfo, isOnChainConfirmationTxData, isOnChainConfirmationTxInfo, + isSafeUpdateTxData, } from '../transaction-guards' import { faker } from '@faker-js/faker' import { Safe__factory } from '@/types/contracts' @@ -164,4 +165,117 @@ describe('transaction-guards', () => { ).toBeTruthy() }) }) + + describe('isSafeUpdateTxData', () => { + it('should return true for 1.3.0+ migrations', () => { + const mockTxData = { + hexData: '0xed007fc6', + to: { + value: '0x526643F69b81B008F46d95CD5ced5eC0edFFDaC6', + name: 'SafeMigration 1.4.1', + logoUri: '', + }, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + } + expect(isSafeUpdateTxData(mockTxData)).toBeTruthy() + }) + + it('should return false for arbitrary txData', () => { + expect( + isSafeUpdateTxData({ + operation: 0, + to: { value: faker.finance.ethereumAddress() }, + trustedDelegateCallTarget: false, + addressInfoIndex: undefined, + dataDecoded: undefined, + hexData: faker.string.hexadecimal({ length: 64 }), + }), + ).toBeFalsy() + }) + + it('should return true for older Safe masterCopyChange calls', () => { + const mockTxData = { + hexData: + '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000f20085c9f5aa0f82a531087a356a55623cf05e7bb895000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000247de7edef00000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0085c9f5aa0f82a531087a356a55623cf05e7bb89500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f08a0323000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec990000000000000000000000000000', + dataDecoded: { + method: 'multiSend', + parameters: [ + { + name: 'transactions', + type: 'bytes', + value: + '0x0085c9f5aa0f82a531087a356a55623cf05e7bb895000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000247de7edef00000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0085c9f5aa0f82a531087a356a55623cf05e7bb89500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f08a0323000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99', + valueDecoded: [ + { + operation: 0, + to: '0x85C9f5aA0F82A531087a356a55623Cf05E7Bb895', + value: '0', + data: '0x7de7edef00000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a', + dataDecoded: { + method: 'changeMasterCopy', + parameters: [ + { + name: '_masterCopy', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + }, + ], + }, + }, + { + operation: 0, + to: '0x85C9f5aA0F82A531087a356a55623Cf05E7Bb895', + value: '0', + data: '0xf08a0323000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99', + dataDecoded: { + method: 'setFallbackHandler', + parameters: [ + { + name: 'handler', + type: 'address', + value: '0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99', + }, + ], + }, + }, + ], + }, + ], + }, + to: { + value: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + name: 'Safe: MultiSendCallOnly 1.3.0', + logoUri: '', + }, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + } + + expect(isSafeUpdateTxData(mockTxData)).toBeTruthy() + }) + + it('should return false for arbitrary multisends', () => { + const mockTxData = { + hexData: + '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000f20085c9f5aa0f82a531087a356a55623cf05e7bb895000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000247de7edef00000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0085c9f5aa0f82a531087a356a55623cf05e7bb89500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f08a0323000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec990000000000000000000000000000', + dataDecoded: { + method: 'multiSend', + parameters: [], + }, + to: { + value: '0x40A2aCCbd92BCA938b02010E17A5b8929b49130D', + name: 'Safe: MultiSendCallOnly 1.3.0', + logoUri: '', + }, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + } + + expect(isSafeUpdateTxData(mockTxData)).toBeFalsy() + }) + }) }) diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index fb96d9c00a..b2c47a7083 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -5,43 +5,13 @@ import type { SafeAppData, Transaction, } from '@safe-global/safe-gateway-typescript-sdk' -import { TransactionInfoType, ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' import { isMultiSendTxInfo } from '../transaction-guards' -import { - extractMigrationL2MasterCopyAddress, - getQueuedTransactionCount, - getTxOrigin, - prependSafeToL2Migration, -} from '../transactions' -import { extendedSafeInfoBuilder } from '@/tests/builders/safe' -import { chainBuilder } from '@/tests/builders/chains' -import { safeSignatureBuilder, safeTxBuilder, safeTxDataBuilder } from '@/tests/builders/safeTx' -import { - getMultiSendCallOnlyDeployment, - getMultiSendDeployment, - getSafeL2SingletonDeployment, - getSafeSingletonDeployment, - getSafeToL2MigrationDeployment, -} from '@safe-global/safe-deployments' -import type Safe from '@safe-global/protocol-kit' -import { encodeMultiSendData } from '@safe-global/protocol-kit' -import { Multi_send__factory, Safe_to_l2_migration__factory } from '@/types/contracts' -import { faker } from '@faker-js/faker' -import { getAndValidateSafeSDK } from '@/services/tx/tx-sender/sdk' -import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -import { checksumAddress } from '../addresses' +import { getQueuedTransactionCount, getTxOrigin } from '../transactions' jest.mock('@/services/tx/tx-sender/sdk') -const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() -const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress -const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() - -const multisendInterface = Multi_send__factory.createInterface() - describe('transactions', () => { - const mockGetAndValidateSdk = getAndValidateSafeSDK as jest.MockedFunction - describe('getQueuedTransactionCount', () => { it('should return 0 if no txPage is provided', () => { expect(getQueuedTransactionCount()).toBe('0') @@ -226,297 +196,4 @@ describe('transactions', () => { ).toBe(false) }) }) - - describe('prependSafeToL2Migration', () => { - beforeEach(() => { - // Mock create Tx - mockGetAndValidateSdk.mockReturnValue({ - createTransaction: ({ transactions, onlyCalls }) => { - return Promise.resolve( - safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - to: onlyCalls - ? (getMultiSendCallOnlyDeployment()?.defaultAddress ?? faker.finance.ethereumAddress()) - : (getMultiSendDeployment()?.defaultAddress ?? faker.finance.ethereumAddress()), - value: '0', - data: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ - encodeMultiSendData(transactions), - ]), - nonce: 0, - operation: 1, - }) - .build(), - }) - .build(), - ) - }, - } as Safe) - }) - - it('should return undefined for undefined safeTx', () => { - expect( - prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), chainBuilder().build()), - ).resolves.toBeUndefined() - }) - - it('should throw if chain is undefined', () => { - expect(() => prependSafeToL2Migration(undefined, extendedSafeInfoBuilder().build(), undefined)).toThrowError() - }) - - it('should not modify tx if the chain is L1', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: false }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if the nonce is > 0', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 1 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if implementationState is correct', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UP_TO_DATE }) - .build() - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if the tx is already signed', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - safeTx.addSignature(safeSignatureBuilder().build()) - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect(prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true }).build())).resolves.toEqual( - safeTx, - ) - }) - - it('should not modify tx if the chain has no migration lib deployed', () => { - const safeTx = safeTxBuilder() - .with({ data: safeTxDataBuilder().with({ nonce: 0 }).build() }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ implementationVersionState: ImplementationVersionState.UNKNOWN }) - .build() - - expect( - prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '69420' }).build()), - ).resolves.toEqual(safeTx) - }) - - it('should not modify tx if the tx already migrates', () => { - const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress - - const safeTx = safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - nonce: 0, - to: safeToL2MigrationAddress, - data: - safeL2SingletonDeployment && - safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), - }) - .build(), - }) - .build() - const safeInfo = extendedSafeInfoBuilder() - .with({ - implementationVersionState: ImplementationVersionState.UNKNOWN, - implementation: { - name: '1.3.0', - value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), - }, - }) - .build() - expect( - prependSafeToL2Migration(safeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), - ).resolves.toEqual(safeTx) - const multiSendSafeTx = safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - nonce: 0, - to: getMultiSendDeployment()?.defaultAddress, - data: - safeToL2MigrationAddress && - safeL2SingletonDeployment && - Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - value: '0', - operation: 1, - to: safeToL2MigrationAddress, - data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), - }, - ]), - ]), - }) - .build(), - }) - .build() - expect( - prependSafeToL2Migration(multiSendSafeTx, safeInfo, chainBuilder().with({ l2: true, chainId: '10' }).build()), - ).resolves.toEqual(multiSendSafeTx) - }) - - it('should modify single txs if applicable', async () => { - const safeTx = safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - nonce: 0, - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 10 }), - value: '0', - }) - .build(), - }) - .build() - - const safeInfo = extendedSafeInfoBuilder() - .with({ - implementationVersionState: ImplementationVersionState.UNKNOWN, - implementation: { - name: '1.3.0', - value: getSafeSingletonDeployment()?.defaultAddress ?? faker.finance.ethereumAddress(), - }, - }) - .build() - - const modifiedTx = await prependSafeToL2Migration( - safeTx, - safeInfo, - chainBuilder().with({ l2: true, chainId: '10' }).build(), - ) - - expect(modifiedTx).not.toEqual(safeTx) - expect(modifiedTx?.data.to).toEqual(getMultiSendDeployment()?.defaultAddress) - const decodedMultiSend = decodeMultiSendData(modifiedTx!.data.data) - expect(decodedMultiSend).toHaveLength(2) - const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress - - expect(decodedMultiSend).toEqual([ - { - to: safeToL2MigrationAddress, - value: '0', - operation: 1, - data: - safeL2SingletonDeployment && - safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), - }, - { - to: checksumAddress(safeTx.data.to), - value: safeTx.data.value, - operation: safeTx.data.operation, - data: safeTx.data.data.toLowerCase(), - }, - ]) - }) - }) - - describe('extractMigrationL2MasterCopyAddress', () => { - it('should return undefined for undefined safeTx', () => { - expect(extractMigrationL2MasterCopyAddress(undefined)).toBeUndefined() - }) - - it('should return undefined for non multisend safeTx', () => { - expect(extractMigrationL2MasterCopyAddress(safeTxBuilder().build())).toBeUndefined() - }) - - it('should return undefined for multisend without migration', () => { - expect( - extractMigrationL2MasterCopyAddress( - safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - data: multisendInterface.encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - value: '0', - operation: 0, - }, - { - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - value: '0', - operation: 0, - }, - ]), - ]), - }) - .build(), - }) - .build(), - ), - ).toBeUndefined() - }) - - it('should return migration address for multisend with migration as first tx', () => { - const l2SingletonAddress = getSafeL2SingletonDeployment()?.defaultAddress! - expect( - extractMigrationL2MasterCopyAddress( - safeTxBuilder() - .with({ - data: safeTxDataBuilder() - .with({ - data: multisendInterface.encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - to: safeToL2MigrationAddress!, - data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [l2SingletonAddress]), - value: '0', - operation: 1, - }, - { - to: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - value: '0', - operation: 0, - }, - ]), - ]), - }) - .build(), - }) - .build(), - ), - ).toEqual(l2SingletonAddress) - }) - }) }) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index d1aa5bcf27..971212b90d 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -32,7 +32,6 @@ export enum FEATURES { NATIVE_SWAPS_USE_COW_STAGING_SERVER = 'NATIVE_SWAPS_USE_COW_STAGING_SERVER', NATIVE_SWAPS_FEE_ENABLED = 'NATIVE_SWAPS_FEE_ENABLED', ZODIAC_ROLES = 'ZODIAC_ROLES', - SAFE_141 = 'SAFE_141', STAKING = 'STAKING', STAKING_BANNER = 'STAKING_BANNER', MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', @@ -71,7 +70,7 @@ export const isRouteEnabled = (route: string, chain?: ChainInfo) => { } export const getLatestSafeVersion = (chain: ChainInfo | undefined): SafeVersion => { - const latestSafeVersion = chain && hasFeature(chain, FEATURES.SAFE_141) ? LATEST_SAFE_VERSION : FALLBACK_SAFE_VERSION + const latestSafeVersion = chain?.recommendedMasterCopyVersion || LATEST_SAFE_VERSION // Without version filter it will always return the LATEST_SAFE_VERSION constant to avoid automatically updating to the newest version if the deployments change const latestDeploymentVersion = (getSafeSingletonDeployment({ network: chain?.chainId, released: true })?.version ?? FALLBACK_SAFE_VERSION) as SafeVersion diff --git a/src/utils/safe-migrations.ts b/src/utils/safe-migrations.ts new file mode 100644 index 0000000000..10fd10df5f --- /dev/null +++ b/src/utils/safe-migrations.ts @@ -0,0 +1,147 @@ +import { Safe_to_l2_migration__factory, Safe_migration__factory } from '@/types/contracts' +import { type ExtendedSafeInfo } from '@/store/safeInfoSlice' +import { getSafeContractDeployment } from '@/services/contracts/deployments' +import { sameAddress } from './addresses' +import { getSafeToL2MigrationDeployment, getSafeMigrationDeployment } from '@safe-global/safe-deployments' +import { type MetaTransactionData, OperationType, type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { ChainInfo, TransactionData } from '@safe-global/safe-gateway-typescript-sdk' +import { isValidMasterCopy } from '@/services/contracts/safeContracts' +import { isMultiSendCalldata } from './transaction-calldata' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' +import { __unsafe_createMultiSendTx } from '@/services/tx/tx-sender' +import { LATEST_SAFE_VERSION } from '@/config/constants' + +/** + * + * If the Safe is using a invalid masterCopy this function will modify the passed in `safeTx` by making it a MultiSend that migrates the Safe to L2 as the first action. + * + * This only happens under the conditions that + * - The Safe's nonce is 0 + * - The SafeTx's nonce is 0 + * - The Safe is using an invalid masterCopy + * - The SafeTx is not already including a Migration + * + * @param safeTx original SafeTx + * @param safe + * @param chain + * @returns + */ +export const prependSafeToL2Migration = ( + safeTx: SafeTransaction | undefined, + safe: ExtendedSafeInfo, + chain: ChainInfo | undefined, +): Promise => { + if (!chain) { + throw new Error('No Network information available') + } + + const safeL2Deployment = getSafeContractDeployment(chain, safe.version) + const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment({ network: chain.chainId }) + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.networkAddresses[chain.chainId] + + if ( + !safeTx || + safeTx.signatures.size > 0 || + !chain.l2 || + safeTx.data.nonce > 0 || + isValidMasterCopy(safe.implementationVersionState) || + !safeToL2MigrationAddress || + !safeL2DeploymentAddress + ) { + // We do not migrate on L1s + // We cannot migrate if the nonce is > 0 + // We do not modify already signed txs + // We do not modify supported masterCopies + // We cannot migrate if no migration contract or L2 contract exists + return Promise.resolve(safeTx) + } + + const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + + if (sameAddress(safe.implementation.value, safeL2DeploymentAddress)) { + // Safe already has the correct L2 masterCopy + // This should in theory never happen if the implementationState is valid + return Promise.resolve(safeTx) + } + + // If the Safe is a L1 masterCopy on a L2 network and still has nonce 0, we prepend a call to the migration contract to the safeTx. + const txData = safeTx.data.data + + let internalTxs: MetaTransactionData[] + if (isMultiSendCalldata(txData)) { + // Check if the first tx is already a call to the migration contract + internalTxs = decodeMultiSendData(txData) + } else { + internalTxs = [{ to: safeTx.data.to, operation: safeTx.data.operation, value: safeTx.data.value, data: txData }] + } + + if (sameAddress(internalTxs[0]?.to, safeToL2MigrationAddress)) { + // We already migrate. Nothing to do. + return Promise.resolve(safeTx) + } + + // Prepend the migration tx + const newTxs: MetaTransactionData[] = [ + { + operation: OperationType.DelegateCall, // DELEGATE CALL REQUIRED + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), + to: safeToL2MigrationAddress, + value: '0', + }, + ...internalTxs, + ] + + return __unsafe_createMultiSendTx(newTxs) +} + +export const extractMigrationL2MasterCopyAddress = (txData: TransactionData): string | undefined => { + if (!isMultiSendCalldata(txData.hexData || '')) { + return undefined + } + + const innerTxs = decodeMultiSendData(txData.hexData || '') + const firstInnerTx = innerTxs[0] + if (!firstInnerTx) { + return undefined + } + + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + + if ( + firstInnerTx.data.startsWith(safeToL2MigrationInterface.getFunction('migrateToL2').selector) && + sameAddress(firstInnerTx.to, safeToL2MigrationAddress) + ) { + const callParams = safeToL2MigrationInterface.decodeFunctionData('migrateToL2', firstInnerTx.data) + return callParams[0] + } + + return undefined +} + +export const createUpdateMigration = (chain: ChainInfo): MetaTransactionData => { + const interfce = Safe_migration__factory.createInterface() + + const deployment = getSafeMigrationDeployment({ + version: chain.recommendedMasterCopyVersion || LATEST_SAFE_VERSION, + released: true, + network: chain.chainId, + }) + + if (!deployment) { + throw new Error('Migration deployment not found') + } + + const tx: MetaTransactionData = { + operation: OperationType.DelegateCall, // DELEGATE CALL REQUIRED + data: chain.l2 + ? interfce.encodeFunctionData('migrateL2WithFallbackHandler') + : interfce.encodeFunctionData('migrateWithFallbackHandler'), + to: deployment.defaultAddress, + value: '0', + } + + return tx +} diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index f126ebf5b0..7e63765c4e 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -46,6 +46,7 @@ import { ConfirmationViewTypes, ConflictType, DetailedExecutionInfoType, + Operation, TransactionInfoType, TransactionListItemType, TransactionStatus, @@ -57,12 +58,18 @@ import { sameAddress } from '@/utils/addresses' import type { NamedAddress } from '@/components/new-safe/create/types' import type { RecoveryQueueItem } from '@/features/recovery/services/recovery-state' import { ethers } from 'ethers' -import { getSafeToL2MigrationDeployment, getMultiSendDeployments } from '@safe-global/safe-deployments' +import { + getSafeToL2MigrationDeployment, + getSafeMigrationDeployment, + getMultiSendDeployments, +} from '@safe-global/safe-deployments' import { Safe__factory, Safe_to_l2_migration__factory } from '@/types/contracts' import { hasMatchingDeployment } from '@/services/contracts/deployments' import { isMultiSendCalldata } from './transaction-calldata' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' import { OperationType } from '@safe-global/safe-core-sdk-types' +import { LATEST_SAFE_VERSION } from '@/config/constants' +import { extractMigrationL2MasterCopyAddress } from './safe-migrations' export const isTxQueued = (value: TransactionStatus): boolean => { return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value) @@ -447,3 +454,31 @@ export const isExecTxInfo = (info: TransactionInfo): info is Custom => { export const isNestedConfirmationTxInfo = (info: TransactionInfo): boolean => { return isCustomTxInfo(info) && (isOnChainConfirmationTxInfo(info) || isExecTxInfo(info)) } + +export const isSafeUpdateTxData = (data?: TransactionData): boolean => { + if (!data) return false + + // Must be a trusted delegate call + if (!(data.trustedDelegateCallTarget && data.operation === Operation.DELEGATE)) { + return false + } + + // For 1.3.0+ Safes + const migrationContract = getSafeMigrationDeployment({ version: LATEST_SAFE_VERSION }) + if (migrationContract && sameAddress(data.to.value, migrationContract.defaultAddress)) { + return true + } + + // For older Safes + return ( + isMultiSendCalldata(data.hexData || '') && + Boolean( + data.dataDecoded?.parameters?.[0]?.valueDecoded?.some((tx) => tx.dataDecoded?.method === 'changeMasterCopy'), + ) + ) +} + +export const isSafeToL2MigrationTxData = (data?: TransactionData): boolean => { + if (!data) return false + return !!extractMigrationL2MasterCopyAddress(data) +} diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 4348270044..fda843b7d3 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -21,24 +21,18 @@ import { } from './transaction-guards' import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types/dist/src/types' import { OperationType } from '@safe-global/safe-core-sdk-types/dist/src/types' -import { getReadOnlyGnosisSafeContract, isValidMasterCopy } from '@/services/contracts/safeContracts' +import { getReadOnlyGnosisSafeContract } from '@/services/contracts/safeContracts' import extractTxInfo from '@/services/tx/extractTxInfo' import type { AdvancedParameters } from '@/components/tx/AdvancedParams' import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' import { FEATURES, hasFeature } from '@/utils/chains' import uniqBy from 'lodash/uniqBy' import { Errors, logError } from '@/services/exceptions' -import { Safe_to_l2_migration__factory } from '@/types/contracts' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' import { isEmptyHexData } from '@/utils/hex' -import { type ExtendedSafeInfo } from '@/store/safeInfoSlice' -import { getSafeContractDeployment } from '@/services/contracts/deployments' -import { sameAddress } from './addresses' import { isMultiSendCalldata } from './transaction-calldata' import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' -import { __unsafe_createMultiSendTx } from '@/services/tx/tx-sender' import { getOriginPath } from './url' -import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => { const getMissingSigners = ({ @@ -237,120 +231,6 @@ export const isImitation = ({ txInfo }: TransactionSummary): boolean => { return isTransferTxInfo(txInfo) && isERC20Transfer(txInfo.transferInfo) && Boolean(txInfo.transferInfo.imitation) } -/** - * - * If the Safe is using a invalid masterCopy this function will modify the passed in `safeTx` by making it a MultiSend that migrates the Safe to L2 as the first action. - * - * This only happens under the conditions that - * - The Safe's nonce is 0 - * - The SafeTx's nonce is 0 - * - The Safe is using an invalid masterCopy - * - The SafeTx is not already including a Migration - * - * @param safeTx original SafeTx - * @param safe - * @param chain - * @returns - */ -export const prependSafeToL2Migration = ( - safeTx: SafeTransaction | undefined, - safe: ExtendedSafeInfo, - chain: ChainInfo | undefined, -): Promise => { - if (!chain) { - throw new Error('No Network information available') - } - - const safeL2Deployment = getSafeContractDeployment(chain, safe.version) - const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] - const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment({ network: chain.chainId }) - const safeToL2MigrationAddress = safeToL2MigrationDeployment?.networkAddresses[chain.chainId] - - if ( - !safeTx || - safeTx.signatures.size > 0 || - !chain.l2 || - safeTx.data.nonce > 0 || - isValidMasterCopy(safe.implementationVersionState) || - !safeToL2MigrationAddress || - !safeL2DeploymentAddress - ) { - // We do not migrate on L1s - // We cannot migrate if the nonce is > 0 - // We do not modify already signed txs - // We do not modify supported masterCopies - // We cannot migrate if no migration contract or L2 contract exists - return Promise.resolve(safeTx) - } - - const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() - - if (sameAddress(safe.implementation.value, safeL2DeploymentAddress)) { - // Safe already has the correct L2 masterCopy - // This should in theory never happen if the implementationState is valid - return Promise.resolve(safeTx) - } - - // If the Safe is a L1 masterCopy on a L2 network and still has nonce 0, we prepend a call to the migration contract to the safeTx. - const txData = safeTx.data.data - - let internalTxs: MetaTransactionData[] - if (isMultiSendCalldata(txData)) { - // Check if the first tx is already a call to the migration contract - internalTxs = decodeMultiSendData(txData) - } else { - internalTxs = [{ to: safeTx.data.to, operation: safeTx.data.operation, value: safeTx.data.value, data: txData }] - } - - if (sameAddress(internalTxs[0]?.to, safeToL2MigrationAddress)) { - // We already migrate. Nothing to do. - return Promise.resolve(safeTx) - } - - // Prepend the migration tx - const newTxs: MetaTransactionData[] = [ - { - operation: 1, // DELEGATE CALL REQUIRED - data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), - to: safeToL2MigrationAddress, - value: '0', - }, - ...internalTxs, - ] - - return __unsafe_createMultiSendTx(newTxs) -} - -export const extractMigrationL2MasterCopyAddress = (safeTx: SafeTransaction | undefined): string | undefined => { - if (!safeTx) { - return undefined - } - - if (!isMultiSendCalldata(safeTx.data.data)) { - return undefined - } - - const innerTxs = decodeMultiSendData(safeTx.data.data) - const firstInnerTx = innerTxs[0] - if (!firstInnerTx) { - return undefined - } - - const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() - const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress - const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() - - if ( - firstInnerTx.data.startsWith(safeToL2MigrationInterface.getFunction('migrateToL2').selector) && - sameAddress(firstInnerTx.to, safeToL2MigrationAddress) - ) { - const callParams = safeToL2MigrationInterface.decodeFunctionData('migrateToL2', firstInnerTx.data) - return callParams[0] - } - - return undefined -} - export const getSafeTransaction = async (safeTxHash: string, chainId: string, safeAddress: string) => { const txId = `multisig_${safeAddress}_${safeTxHash}` diff --git a/yarn.lock b/yarn.lock index 12931d965b..a9abaeb7a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5820,7 +5820,14 @@ __metadata: languageName: node linkType: hard -"@safe-global/safe-gateway-typescript-sdk@npm:3.22.4, @safe-global/safe-gateway-typescript-sdk@npm:^3.5.3": +"@safe-global/safe-gateway-typescript-sdk@npm:3.22.5-beta.0": + version: 3.22.5-beta.0 + resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.22.5-beta.0" + checksum: 10/503bfeb717a23bcd6fffd087c3365b24497f8712b772a006134dd032bc6f36e9681be3cfb8e52ee915fe1d5c1945e746bbcdd8f456a8ad25194695d4bdfc13d4 + languageName: node + linkType: hard + +"@safe-global/safe-gateway-typescript-sdk@npm:^3.5.3": version: 3.22.4 resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.22.4" checksum: 10/5b088499a01a0d0190b4ab6828bfb2df779b603bbcee7645c23ad8e420670aab4ce7ca39b858fc62ee03fded77b322c3f8a9b0203f41ecb779d08f47bd4bfe0c @@ -21976,7 +21983,7 @@ __metadata: "@safe-global/safe-apps-sdk": "npm:^9.1.0" "@safe-global/safe-client-gateway-sdk": "npm:v1.60.1" "@safe-global/safe-core-sdk-types": "npm:^5.0.1" - "@safe-global/safe-gateway-typescript-sdk": "npm:3.22.4" + "@safe-global/safe-gateway-typescript-sdk": "npm:3.22.5-beta.0" "@safe-global/safe-modules-deployments": "npm:^2.2.1" "@sentry/react": "npm:^7.91.0" "@sentry/types": "npm:^7.74.0"