From 4069d343fe880c2775e5389cc381604149665eb9 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 28 Aug 2024 10:29:40 +0200 Subject: [PATCH 01/74] feat: show multichain accounts in sidebar (#4090) - Sidebar groups Safes with the same address and shows them first. - New designs for safe groups --- public/images/sidebar/multichain-account.svg | 8 + src/components/common/SafeIcon/index.tsx | 37 +- .../welcome/MyAccounts/AccountItem.tsx | 28 +- .../welcome/MyAccounts/MultiAccountItem.tsx | 145 ++++++ .../welcome/MyAccounts/PaginatedSafeList.tsx | 79 +++- .../welcome/MyAccounts/SubAccountItem.tsx | 125 +++++ src/components/welcome/MyAccounts/index.tsx | 33 +- .../welcome/MyAccounts/styles.module.css | 54 +++ .../welcome/MyAccounts/useAllSafesGrouped.ts | 40 ++ .../welcome/MyAccounts/useGetHref.ts | 24 + .../MyAccounts/useTrackedSafesCount.ts | 20 +- .../MyAccounts/utils/multiChainSafe.test.ts | 447 ++++++++++++++++++ .../MyAccounts/utils/multiChainSafe.ts | 84 ++++ .../store/undeployedSafesSlice.ts | 11 +- src/services/analytics/events/overview.ts | 6 + src/store/common.ts | 6 + 16 files changed, 1088 insertions(+), 59 deletions(-) create mode 100644 public/images/sidebar/multichain-account.svg create mode 100644 src/components/welcome/MyAccounts/MultiAccountItem.tsx create mode 100644 src/components/welcome/MyAccounts/SubAccountItem.tsx create mode 100644 src/components/welcome/MyAccounts/useAllSafesGrouped.ts create mode 100644 src/components/welcome/MyAccounts/useGetHref.ts create mode 100644 src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts create mode 100644 src/components/welcome/MyAccounts/utils/multiChainSafe.ts diff --git a/public/images/sidebar/multichain-account.svg b/public/images/sidebar/multichain-account.svg new file mode 100644 index 0000000000..bb6ebeafdc --- /dev/null +++ b/public/images/sidebar/multichain-account.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/common/SafeIcon/index.tsx b/src/components/common/SafeIcon/index.tsx index 4ecfdddbbb..271db349ef 100644 --- a/src/components/common/SafeIcon/index.tsx +++ b/src/components/common/SafeIcon/index.tsx @@ -1,8 +1,9 @@ import type { ReactElement } from 'react' -import { Box } from '@mui/material' +import { Box, Skeleton } from '@mui/material' import css from './styles.module.css' import Identicon, { type IdenticonProps } from '../Identicon' +import { useChain } from '@/hooks/useChains' interface ThresholdProps { threshold: number | string @@ -18,13 +19,35 @@ interface SafeIconProps extends IdenticonProps { threshold?: ThresholdProps['threshold'] owners?: ThresholdProps['owners'] size?: number + chainId?: string + isSubItem?: boolean } -const SafeIcon = ({ address, threshold, owners, size }: SafeIconProps): ReactElement => ( -
- {threshold && owners ? : null} - -
-) +const ChainIcon = ({ chainId }: { chainId: string }) => { + const chainConfig = useChain(chainId) + + if (!chainConfig) { + return + } + + return ( + {`${chainConfig.chainName} + ) +} + +const SafeIcon = ({ address, threshold, owners, size, chainId, isSubItem = false }: SafeIconProps): ReactElement => { + return ( +
+ {threshold && owners ? : null} + {isSubItem && chainId ? : } +
+ ) +} export default SafeIcon diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index cd162f0d65..bb9e06ce81 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -1,7 +1,7 @@ import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import type { ChainInfo, SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' -import { useCallback, useMemo } from 'react' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' import { ListItemButton, Box, Typography, Chip, Skeleton } from '@mui/material' import Link from 'next/link' import SafeIcon from '@/components/common/SafeIcon' @@ -24,6 +24,7 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import type { SafeItem } from './useAllSafes' import FiatValue from '@/components/common/FiatValue' import QueueActions from './QueueActions' +import { useGetHref } from './useGetHref' type AccountItemProps = { safeItem: SafeItem @@ -40,24 +41,10 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) const router = useRouter() const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address) const isWelcomePage = router.pathname === AppRoutes.welcome.accounts - const isSingleTxPage = router.pathname === AppRoutes.transactions.tx const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar - /** - * Navigate to the dashboard when selecting a safe on the welcome page, - * navigate to the history when selecting a safe on a single tx page, - * otherwise keep the current route - */ - const getHref = useCallback( - (chain: ChainInfo, address: string) => { - return { - pathname: isWelcomePage ? AppRoutes.home : isSingleTxPage ? AppRoutes.transactions.history : router.pathname, - query: { ...router.query, safe: `${chain.shortName}:${address}` }, - } - }, - [isWelcomePage, isSingleTxPage, router.pathname, router.query], - ) + const getHref = useGetHref(router) const href = useMemo(() => { return chain ? getHref(chain, address) : '' @@ -76,7 +63,12 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) - + diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx new file mode 100644 index 0000000000..bcf2e53b67 --- /dev/null +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -0,0 +1,145 @@ +import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo, useState } from 'react' +import { + ListItemButton, + Box, + Typography, + Skeleton, + Accordion, + AccordionDetails, + AccordionSummary, + Divider, + Button, +} from '@mui/material' +import SafeIcon from '@/components/common/SafeIcon' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import { useAppSelector } from '@/store' +import css from './styles.module.css' +import { selectAllAddressBooks } from '@/store/addressBookSlice' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@/utils/addresses' +import classnames from 'classnames' +import { useRouter } from 'next/router' +import FiatValue from '@/components/common/FiatValue' +import { type MultiChainSafeItem } from './useAllSafesGrouped' +import MultiChainIcon from '@/public/images/sidebar/multichain-account.svg' +import { shortenAddress } from '@/utils/formatters' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { type SafeItem } from './useAllSafes' +import SubAccountItem from './SubAccountItem' +import { getSharedSetup } from './utils/multiChainSafe' + +type MultiAccountItemProps = { + multiSafeAccountItem: MultiChainSafeItem + safeOverviews?: SafeOverview[] + onLinkClick?: () => void +} + +const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: MultiAccountItemProps) => { + const { address, safes } = multiSafeAccountItem + const undeployedSafes = useAppSelector(selectUndeployedSafes) + const safeAddress = useSafeAddress() + const router = useRouter() + const isCurrentSafe = sameAddress(safeAddress, address) + const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + const [expanded, setExpanded] = useState(isCurrentSafe) + + const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const toggleExpand = () => { + trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: trackingLabel }) + setExpanded((prev) => !prev) + } + + const allAddressBooks = useAppSelector(selectAllAddressBooks) + const name = useMemo(() => { + return Object.values(allAddressBooks).find((ab) => ab[address] !== undefined)?.[address] + }, [address, allAddressBooks]) + + const sharedSetup = useMemo( + () => getSharedSetup(safes, safeOverviews ?? [], undeployedSafes), + [safeOverviews, safes, undeployedSafes], + ) + + const totalFiatValue = useMemo( + () => safeOverviews?.reduce((prev, current) => prev + Number(current.fiatTotal), 0), + [safeOverviews], + ) + + const findOverview = (item: SafeItem) => { + return safeOverviews?.find( + (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address), + ) + } + + return ( + + + } + sx={{ + pl: 0, + '& .MuiAccordionSummary-content': { m: 0 }, + '&.Mui-expanded': { backgroundColor: 'transparent !important' }, + }} + > + + + + + + + {name && ( + + {name} + + )} + + {shortenAddress(address)} + + + + + {totalFiatValue !== undefined ? ( + + ) : ( + + )} + + + + + + + + {safes.map((safeItem) => ( + + ))} + + + + {/* TODO: Trigger Safe creation flow with a new network */} + + + + + + ) +} + +export default MultiAccountItem diff --git a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx index da910de584..93332bd5b5 100644 --- a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx +++ b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, type ReactNode, useState, useCallback, useEffect } from 'react' +import { type ReactElement, type ReactNode, useState, useCallback, useEffect, useMemo } from 'react' import { Paper, Typography } from '@mui/material' import AccountItem from './AccountItem' import { type SafeItem } from './useAllSafes' @@ -6,9 +6,12 @@ import css from './styles.module.css' import useSafeOverviews from './useSafeOverviews' import { sameAddress } from '@/utils/addresses' import InfiniteScroll from '@/components/common/InfiniteScroll' +import { type MultiChainSafeItem } from './useAllSafesGrouped' +import MultiAccountItem from './MultiAccountItem' +import { isMultiChainSafeItem } from './utils/multiChainSafe' type PaginatedSafeListProps = { - safes?: SafeItem[] + safes?: (SafeItem | MultiChainSafeItem)[] title: ReactNode noSafesMessage?: ReactNode action?: ReactElement @@ -16,14 +19,18 @@ type PaginatedSafeListProps = { } type SafeListPageProps = { - safes: SafeItem[] + safes: (SafeItem | MultiChainSafeItem)[] onLinkClick: PaginatedSafeListProps['onLinkClick'] } -const PAGE_SIZE = 10 +const DEFAULT_PAGE_SIZE = 10 -const SafeListPage = ({ safes, onLinkClick }: SafeListPageProps) => { - const [overviews] = useSafeOverviews(safes) +export const SafeListPage = ({ safes, onLinkClick }: SafeListPageProps) => { + const flattenedSafes = useMemo( + () => safes.flatMap((safe) => (isMultiChainSafeItem(safe) ? safe.safes : safe)), + [safes], + ) + const [overviews] = useSafeOverviews(flattenedSafes) const findOverview = (item: SafeItem) => { return overviews?.find( @@ -33,33 +40,48 @@ const SafeListPage = ({ safes, onLinkClick }: SafeListPageProps) => { return ( <> - {safes.map((item) => ( - - ))} + {safes.map((item) => + isMultiChainSafeItem(item) ? ( + sameAddress(overview.address.value, item.address))} + /> + ) : ( + + ), + )} ) } -const AllSafeListPages = ({ safes, onLinkClick }: SafeListPageProps) => { - const totalPages = Math.ceil(safes.length / PAGE_SIZE) - const [pages, setPages] = useState([]) +const AllSafeListPages = ({ + safes, + onLinkClick, + pageSize = DEFAULT_PAGE_SIZE, +}: SafeListPageProps & { pageSize?: number }) => { + const totalPages = Math.ceil(safes.length / pageSize) + const [pages, setPages] = useState<(SafeItem | MultiChainSafeItem)[][]>([]) const onNextPage = useCallback(() => { setPages((prev) => { const pageIndex = prev.length - const nextPage = safes.slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE) + const nextPage = safes.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize) return prev.concat([nextPage]) }) - }, [safes]) + }, [safes, pageSize]) useEffect(() => { - setPages([safes.slice(0, PAGE_SIZE)]) - }, [safes]) + if (safes.length > 0) { + setPages([safes.slice(0, pageSize)]) + } + }, [safes, pageSize]) return ( <> @@ -73,6 +95,10 @@ const AllSafeListPages = ({ safes, onLinkClick }: SafeListPageProps) => { } const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick }: PaginatedSafeListProps) => { + const multiChainSafes = useMemo(() => safes?.filter(isMultiChainSafeItem), [safes]) + const singleChainSafes = useMemo(() => safes?.filter((safe) => !isMultiChainSafeItem(safe)), [safes]) + + const totalSafes = !safes ? 0 : multiChainSafes?.length ?? 0 + (singleChainSafes?.length ?? 0) return (
@@ -90,8 +116,15 @@ const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick } {action}
- {safes && safes.length > 0 ? ( - + {totalSafes > 0 ? ( + <> + {multiChainSafes && multiChainSafes.length > 0 && ( + + )} + {singleChainSafes && singleChainSafes.length > 0 && ( + + )} + ) : ( {safes ? noSafesMessage : 'Loading...'} diff --git a/src/components/welcome/MyAccounts/SubAccountItem.tsx b/src/components/welcome/MyAccounts/SubAccountItem.tsx new file mode 100644 index 0000000000..7857d442d4 --- /dev/null +++ b/src/components/welcome/MyAccounts/SubAccountItem.tsx @@ -0,0 +1,125 @@ +import { LoopIcon } from '@/features/counterfactual/CounterfactualStatusButton' +import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo } from 'react' +import { ListItemButton, Box, Typography, Chip, Skeleton } from '@mui/material' +import Link from 'next/link' +import SafeIcon from '@/components/common/SafeIcon' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { AppRoutes } from '@/config/routes' +import { useAppSelector } from '@/store' +import { selectChainById } from '@/store/chainsSlice' +import css from './styles.module.css' +import { selectAllAddressBooks } from '@/store/addressBookSlice' +import SafeListContextMenu from '@/components/sidebar/SafeListContextMenu' +import useSafeAddress from '@/hooks/useSafeAddress' +import useChainId from '@/hooks/useChainId' +import { sameAddress } from '@/utils/addresses' +import classnames from 'classnames' +import { useRouter } from 'next/router' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import type { SafeItem } from './useAllSafes' +import FiatValue from '@/components/common/FiatValue' +import QueueActions from './QueueActions' +import { useGetHref } from './useGetHref' + +type SubAccountItem = { + safeItem: SafeItem + safeOverview?: SafeOverview + onLinkClick?: () => void +} + +const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) => { + const { chainId, address } = safeItem + const chain = useAppSelector((state) => selectChainById(state, chainId)) + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chainId, address)) + const safeAddress = useSafeAddress() + const currChainId = useChainId() + const router = useRouter() + const isCurrentSafe = chainId === currChainId && sameAddress(safeAddress, address) + const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + + const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const getHref = useGetHref(router) + + const href = useMemo(() => { + return chain ? getHref(chain, address) : '' + }, [chain, getHref, address]) + + const name = useAppSelector(selectAllAddressBooks)[chainId]?.[address] + + const isActivating = undeployedSafe?.status.status !== 'AWAITING_EXECUTION' + + return ( + + + + + + + + + {name && ( + + {name} + + )} + + {chain?.chainName} + + {undeployedSafe && ( +
+ + ) : ( + + ) + } + className={classnames(css.chip, { + [css.pendingAccount]: isActivating, + })} + /> +
+ )} +
+ + + {safeOverview ? ( + + ) : undeployedSafe ? null : ( + + )} + + + + + + + +
+ + ) +} + +export default SubAccountItem diff --git a/src/components/welcome/MyAccounts/index.tsx b/src/components/welcome/MyAccounts/index.tsx index 193d65fac9..8eb8491f3a 100644 --- a/src/components/welcome/MyAccounts/index.tsx +++ b/src/components/welcome/MyAccounts/index.tsx @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { Box, Button, Link, SvgIcon, Typography } from '@mui/material' import madProps from '@/utils/mad-props' import CreateButton from './CreateButton' -import useAllSafes, { type SafeItems } from './useAllSafes' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { DataWidget } from '@/components/welcome/MyAccounts/DataWidget' @@ -15,20 +14,44 @@ import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWallet import useWallet from '@/hooks/wallets/useWallet' import { useRouter } from 'next/router' import useTrackSafesCount from './useTrackedSafesCount' +import { type AllSafesGrouped, useAllSafesGrouped, type MultiChainSafeItem } from './useAllSafesGrouped' +import { type SafeItem } from './useAllSafes' const NO_SAFES_MESSAGE = "You don't have any Safe Accounts yet" const NO_WATCHED_MESSAGE = 'Watch any Safe Account to keep an eye on its activity' type AccountsListProps = { - safes?: SafeItems | undefined + safes: AllSafesGrouped onLinkClick?: () => void } const AccountsList = ({ safes, onLinkClick }: AccountsListProps) => { const wallet = useWallet() const router = useRouter() - const ownedSafes = useMemo(() => safes?.filter(({ isWatchlist }) => !isWatchlist), [safes]) - const watchlistSafes = useMemo(() => safes?.filter(({ isWatchlist }) => isWatchlist), [safes]) + // We consider a multiChain account owned if at least one of the multiChain accounts is not on the watchlist + const ownedMultiChainSafes = useMemo( + () => safes.allMultiChainSafes?.filter((account) => account.safes.some(({ isWatchlist }) => !isWatchlist)), + [safes], + ) + + // If all safes of a multichain account are on the watchlist we put the entire account on the watchlist + const watchlistMultiChainSafes = useMemo( + () => safes.allMultiChainSafes?.filter((account) => !account.safes.some(({ isWatchlist }) => !isWatchlist)), + [safes], + ) + + const ownedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [...(ownedMultiChainSafes ?? []), ...(safes.allSingleSafes?.filter(({ isWatchlist }) => !isWatchlist) ?? [])], + [safes, ownedMultiChainSafes], + ) + const watchlistSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + () => [ + ...(watchlistMultiChainSafes ?? []), + ...(safes.allSingleSafes?.filter(({ isWatchlist }) => isWatchlist) ?? []), + ], + [safes, watchlistMultiChainSafes], + ) + useTrackSafesCount(ownedSafes, watchlistSafes, wallet) const isLoginPage = router.pathname === AppRoutes.welcome.accounts @@ -97,7 +120,7 @@ const AccountsList = ({ safes, onLinkClick }: AccountsListProps) => { } const MyAccounts = madProps(AccountsList, { - safes: useAllSafes, + safes: useAllSafesGrouped, }) export default MyAccounts diff --git a/src/components/welcome/MyAccounts/styles.module.css b/src/components/welcome/MyAccounts/styles.module.css index 4b97a15341..e43b0d0b0f 100644 --- a/src/components/welcome/MyAccounts/styles.module.css +++ b/src/components/welcome/MyAccounts/styles.module.css @@ -44,6 +44,60 @@ background-color: var(--color-background-light) !important; } +.listItem :global .MuiAccordion-root, +.listItem :global .MuiAccordion-root:hover > .MuiAccordionSummary-root { + background-color: transparent; +} + +.listItem :global .MuiAccordion-root.Mui-expanded { + background-color: var(--color-background-paper); +} + +.listItem.subItem { + border: none; + margin-bottom: 0px; + border-radius: 0px; +} + +.subItem:before { + content: ''; + display: block; + width: 8px; + height: 1px; + background: var(--color-border-light); + left: 0; + top: 50%; + position: absolute; +} + +.subItem.currentListItem { + border: none; +} + +.subItem.currentListItem:before { + background: var(--color-secondary-light); + height: 1px; +} + +.subItem .borderLeft { + top: 0; + bottom: 0; + position: absolute; + border-left: 1px solid var(--color-border-light); +} +.subItem.currentListItem .borderLeft { + border-left: 1px solid var(--color-secondary-light); +} + +.subItem:last-child { + border-left: none; +} + +.subItem:last-child .borderLeft { + top: 0%; + bottom: 50%; +} + .listItem > :first-child { flex: 1; width: 90%; diff --git a/src/components/welcome/MyAccounts/useAllSafesGrouped.ts b/src/components/welcome/MyAccounts/useAllSafesGrouped.ts new file mode 100644 index 0000000000..8877a7a1c0 --- /dev/null +++ b/src/components/welcome/MyAccounts/useAllSafesGrouped.ts @@ -0,0 +1,40 @@ +import { groupBy } from 'lodash' +import useAllSafes, { type SafeItem, type SafeItems } from './useAllSafes' +import { useMemo } from 'react' +import { sameAddress } from '@/utils/addresses' + +export type MultiChainSafeItem = { address: string; safes: SafeItem[] } + +export type AllSafesGrouped = { + allSingleSafes: SafeItems | undefined + allMultiChainSafes: MultiChainSafeItem[] | undefined +} + +const getMultiChainAccounts = (safes: SafeItems): MultiChainSafeItem[] => { + const groupedByAddress = groupBy(safes, (safe) => safe.address) + const multiChainSafeItems = Object.entries(groupedByAddress) + .filter((entry) => entry[1].length > 1) + .map((entry): MultiChainSafeItem => ({ address: entry[0], safes: entry[1] })) + + return multiChainSafeItems +} + +export const useAllSafesGrouped = () => { + const allSafes = useAllSafes() + + return useMemo(() => { + if (!allSafes) { + return { allMultiChainSafes: undefined, allSingleSafes: undefined } + } + // Extract all multichain Accounts and single Safes + const allMultiChainSafes = getMultiChainAccounts(allSafes) + const allSingleSafes = allSafes.filter( + (safe) => !allMultiChainSafes.some((multiSafe) => sameAddress(multiSafe.address, safe.address)), + ) + + return { + allMultiChainSafes, + allSingleSafes, + } + }, [allSafes]) +} diff --git a/src/components/welcome/MyAccounts/useGetHref.ts b/src/components/welcome/MyAccounts/useGetHref.ts new file mode 100644 index 0000000000..939cbb22d2 --- /dev/null +++ b/src/components/welcome/MyAccounts/useGetHref.ts @@ -0,0 +1,24 @@ +import { AppRoutes } from '@/config/routes' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type NextRouter } from 'next/router' +import { useCallback } from 'react' + +/** + * Navigate to the dashboard when selecting a safe on the welcome page, + * navigate to the history when selecting a safe on a single tx page, + * otherwise keep the current route + */ +export const useGetHref = (router: NextRouter) => { + const isWelcomePage = router.pathname === AppRoutes.welcome.accounts + const isSingleTxPage = router.pathname === AppRoutes.transactions.tx + + return useCallback( + (chain: ChainInfo, address: string) => { + return { + pathname: isWelcomePage ? AppRoutes.home : isSingleTxPage ? AppRoutes.transactions.history : router.pathname, + query: { ...router.query, safe: `${chain.shortName}:${address}` }, + } + }, + [isSingleTxPage, isWelcomePage, router.pathname, router.query], + ) +} diff --git a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts b/src/components/welcome/MyAccounts/useTrackedSafesCount.ts index 06289dac02..45c8c0f3c5 100644 --- a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts +++ b/src/components/welcome/MyAccounts/useTrackedSafesCount.ts @@ -2,15 +2,17 @@ import { AppRoutes } from '@/config/routes' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { useRouter } from 'next/router' import { useEffect } from 'react' -import type { SafeItems } from './useAllSafes' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' +import { type SafeItem } from './useAllSafes' +import { type MultiChainSafeItem } from './useAllSafesGrouped' +import { isMultiChainSafeItem } from './utils/multiChainSafe' let isOwnedSafesTracked = false let isWatchlistTracked = false const useTrackSafesCount = ( - ownedSafes: SafeItems | undefined, - watchlistSafes: SafeItems | undefined, + ownedSafes: (MultiChainSafeItem | SafeItem)[] | undefined, + watchlistSafes: (MultiChainSafeItem | SafeItem)[] | undefined, wallet: ConnectedWallet | null, ) => { const router = useRouter() @@ -22,15 +24,23 @@ const useTrackSafesCount = ( }, [wallet?.address]) useEffect(() => { + const totalSafesOwned = ownedSafes?.reduce( + (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), + 0, + ) if (wallet && !isOwnedSafesTracked && ownedSafes && ownedSafes.length > 0 && isLoginPage) { - trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_OWNED, label: ownedSafes.length }) + trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_OWNED, label: totalSafesOwned }) isOwnedSafesTracked = true } }, [isLoginPage, ownedSafes, wallet]) useEffect(() => { + const totalSafesWatched = watchlistSafes?.reduce( + (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), + 0, + ) if (watchlistSafes && isLoginPage && watchlistSafes.length > 0 && !isWatchlistTracked) { - trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_WATCHLIST, label: watchlistSafes.length }) + trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_WATCHLIST, label: totalSafesWatched }) isWatchlistTracked = true } }, [isLoginPage, watchlistSafes]) diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts new file mode 100644 index 0000000000..64c5202dae --- /dev/null +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts @@ -0,0 +1,447 @@ +import { faker } from '@faker-js/faker/locale/af_ZA' +import { getSharedSetup, isMultiChainSafeItem } from './multiChainSafe' +import { PendingSafeStatus } from '@/store/slices' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' + +describe('multiChainSafe', () => { + describe('isMultiChainSafeItem', () => { + it('should return true for MultiChainSafeIem', () => { + expect( + isMultiChainSafeItem({ + address: faker.finance.ethereumAddress(), + safes: [ + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }, + ], + }), + ).toBeTruthy() + }) + + it('should return false for SafeItem', () => { + expect( + isMultiChainSafeItem({ + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }), + ).toBeFalsy() + }) + }) + + describe('getSharedSetup', () => { + it('should return undefined if no setup infos available', () => { + expect( + getSharedSetup( + [ + { + address: faker.finance.ethereumAddress(), + chainId: '1', + isWatchlist: false, + }, + ], + [], + undefined, + ), + ).toBeUndefined() + }) + + it('should return undefined if the owners do not match', () => { + const address = faker.finance.ethereumAddress() + + // 2 Safes. One with 1 and one with 2 owners. + const owners1 = [ + { + value: faker.finance.ethereumAddress(), + }, + ] + const owners2 = [...owners1, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: owners1, + queued: 0, + threshold: 1, + }, + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '100', + fiatTotal: '0', + owners: owners2, + queued: 0, + threshold: 1, + }, + ], + undefined, + ), + ).toBeUndefined() + }) + it('should return undefined if the threshold does not match', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners, + queued: 0, + threshold: 1, + }, + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '100', + fiatTotal: '0', + owners, + queued: 0, + threshold: 2, + }, + ], + undefined, + ), + ).toBeUndefined() + }) + + it('should return the shared setup if owners and threshold matches', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners, + queued: 0, + threshold: 2, + }, + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '100', + fiatTotal: '0', + owners, + queued: 0, + threshold: 2, + }, + ], + undefined, + ), + ).toEqual({ owners: owners.map((owner) => owner.value), threshold: 2 }) + }) + + it('should return undefined if owners do not match and some Safes are undeployed', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners, + queued: 0, + threshold: 2, + }, + ], + { + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: owners.map((owner) => owner.value), + threshold: 1, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toBeUndefined() + }) + + it('should return undefined if some owner data is missing', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners, + queued: 0, + threshold: 2, + }, + ], + {}, + ), + ).toBeUndefined() + }) + + it('should return undefined if threshold does not match and some Safes are undeployed', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners: owners.slice(0, 1), + queued: 0, + threshold: 1, + }, + ], + { + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: owners.map((owner) => owner.value), + threshold: 1, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toBeUndefined() + }) + + it('should return the shared setup if owners and threshold matches and some Safes are undeployed', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [ + { + address: { + value: address, + }, + awaitingConfirmation: null, + chainId: '1', + fiatTotal: '0', + owners, + queued: 0, + threshold: 2, + }, + ], + { + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: owners.map((owner) => owner.value), + threshold: 2, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toEqual({ owners: owners.map((owner) => owner.value), threshold: 2 }) + }) + + it('should return the shared setup if owners and threshold matches and all Safes are undeployed', () => { + const address = faker.finance.ethereumAddress() + + const owners = [{ value: faker.finance.ethereumAddress() }, { value: faker.finance.ethereumAddress() }] + + expect( + getSharedSetup( + [ + { + address, + chainId: '1', + isWatchlist: false, + }, + { + address, + chainId: '100', + isWatchlist: false, + }, + ], + [], + { + ['1']: { + [address]: { + props: { + safeAccountConfig: { + owners: owners.map((owner) => owner.value), + threshold: 2, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + ['100']: { + [address]: { + props: { + safeAccountConfig: { + owners: owners.map((owner) => owner.value), + threshold: 2, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + }, + }, + ), + ).toEqual({ owners: owners.map((owner) => owner.value), threshold: 2 }) + }) + }) +}) diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts new file mode 100644 index 0000000000..a8023e8d7c --- /dev/null +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts @@ -0,0 +1,84 @@ +import { type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { type SafeItem } from '../useAllSafes' +import { type UndeployedSafesState, type UndeployedSafe } from '@/store/slices' +import { sameAddress } from '@/utils/addresses' +import { type MultiChainSafeItem } from '../useAllSafesGrouped' + +export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { + if ('safes' in safe && 'address' in safe) { + return true + } + return false +} + +const areOwnersMatching = (owners1: string[], owners2: string[]) => + owners1.length === owners2.length && owners1.every((owner) => owners2.some((owner2) => sameAddress(owner, owner2))) + +export const getSharedSetup = ( + safes: SafeItem[], + safeOverviews: SafeOverview[], + undeployedSafes: UndeployedSafesState | undefined, +): { owners: string[]; threshold: number } | undefined => { + // We fetch one example setup and check that all other Safes have the same threshold and owners + let comparisonSetup: { threshold: number; owners: string[] } | undefined = undefined + + const undeployedSafesWithData = safes + .map((safeItem) => ({ + chainId: safeItem.chainId, + address: safeItem.address, + undeployedSafe: undeployedSafes?.[safeItem.chainId]?.[safeItem.address], + })) + .filter((value) => Boolean(value.undeployedSafe)) as { + chainId: string + address: string + undeployedSafe: UndeployedSafe + }[] + + if (safeOverviews && safeOverviews.length > 0) { + comparisonSetup = { + threshold: safeOverviews[0].threshold, + owners: safeOverviews[0].owners.map((owner) => owner.value), + } + } else if (undeployedSafesWithData.length > 0) { + const undeployedSafe = undeployedSafesWithData[0].undeployedSafe + // Use first undeployed Safe + comparisonSetup = { + threshold: undeployedSafe.props.safeAccountConfig.threshold, + owners: undeployedSafe.props.safeAccountConfig.owners, + } + } + if (!comparisonSetup) { + return undefined + } + + if ( + safes.every((safeItem) => { + // Find overview or undeployed Safe + const foundOverview = safeOverviews?.find( + (overview) => overview.chainId === safeItem.chainId && sameAddress(overview.address.value, safeItem.address), + ) + if (foundOverview) { + return ( + areOwnersMatching( + comparisonSetup.owners, + foundOverview.owners.map((owner) => owner.value), + ) && foundOverview.threshold === comparisonSetup.threshold + ) + } + // Check if the Safe is counterfactual + const undeployedSafe = undeployedSafesWithData.find( + (value) => value.chainId === safeItem.chainId && sameAddress(value.address, safeItem.address), + )?.undeployedSafe + if (!undeployedSafe) { + return false + } + return ( + areOwnersMatching(undeployedSafe.props.safeAccountConfig.owners, comparisonSetup.owners) && + undeployedSafe.props.safeAccountConfig.threshold === comparisonSetup.threshold + ) + }) + ) { + return comparisonSetup + } + return undefined +} diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts index de3a66c532..8335ec30b7 100644 --- a/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -2,7 +2,7 @@ import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { type RootState } from '@/store' import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { PredictedSafeProps } from '@safe-global/protocol-kit' -import { selectChainIdAndSafeAddress } from '@/store/common' +import { selectChainIdAndSafeAddress, selectSafeAddress } from '@/store/common' export enum PendingSafeStatus { AWAITING_EXECUTION = 'AWAITING_EXECUTION', @@ -103,6 +103,15 @@ export const selectUndeployedSafe = createSelector( }, ) +export const selectUndeployedSafesByAddress = createSelector( + [selectUndeployedSafes, selectSafeAddress], + (undeployedSafes, [address]): UndeployedSafe[] => { + return Object.values(undeployedSafes) + .flatMap((value) => value[address]) + .filter(Boolean) + }, +) + export const selectIsUndeployedSafe = createSelector([selectUndeployedSafe], (undeployedSafe) => { return !!undeployedSafe }) diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index c6d5152f8f..246ccb199b 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -117,6 +117,12 @@ export const OVERVIEW_EVENTS = { category: OVERVIEW_CATEGORY, //label: OPEN_SAFE_LABELS }, + // Track clicks on links to Safe Accounts + EXPAND_MULTI_SAFE: { + action: 'Expand multi Safe', + category: OVERVIEW_CATEGORY, + //label: OPEN_SAFE_LABELS + }, // Track actual Safe views SAFE_VIEWED: { event: EventType.SAFE_OPENED, diff --git a/src/store/common.ts b/src/store/common.ts index e2b9776276..514882315c 100644 --- a/src/store/common.ts +++ b/src/store/common.ts @@ -39,3 +39,9 @@ export const selectChainIdAndSafeAddress = createSelector( [(_: RootState, chainId: string) => chainId, (_: RootState, _chainId: string, safeAddress: string) => safeAddress], (chainId, safeAddress) => [chainId, safeAddress] as const, ) + +// Memoized selector for safeAddress +export const selectSafeAddress = createSelector( + [(_: RootState, safeAddress: string) => safeAddress], + (safeAddress) => [safeAddress] as const, +) From 957f236331ccf81d795453d6dab1aa6721dead97 Mon Sep 17 00:00:00 2001 From: schmanu Date: Wed, 28 Aug 2024 12:29:56 +0200 Subject: [PATCH 02/74] fix: counting of total Safes in the sidebar --- src/components/welcome/MyAccounts/PaginatedSafeList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx index 93332bd5b5..53a925c906 100644 --- a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx +++ b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx @@ -98,7 +98,10 @@ const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick } const multiChainSafes = useMemo(() => safes?.filter(isMultiChainSafeItem), [safes]) const singleChainSafes = useMemo(() => safes?.filter((safe) => !isMultiChainSafeItem(safe)), [safes]) - const totalSafes = !safes ? 0 : multiChainSafes?.length ?? 0 + (singleChainSafes?.length ?? 0) + const totalMultiChainSafes = multiChainSafes?.length ?? 0 + const totalSingleChainSafes = singleChainSafes?.length ?? 0 + const totalSafes = totalMultiChainSafes + totalSingleChainSafes + return (
From a0116da0b13f7a5b7958f88c44c3710a293abd8f Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 29 Aug 2024 17:29:53 +0200 Subject: [PATCH 03/74] Feat: replay safe creation with same address (#4116) - Implements replaying a Safe creation on another compatible network --- jest.setup.js | 7 + package.json | 3 +- src/components/common/NetworkInput/index.tsx | 85 +++ .../common/NetworkInput/styles.module.css | 54 ++ src/components/dashboard/FirstSteps/index.tsx | 4 +- src/components/new-safe/create/index.tsx | 2 +- src/components/new-safe/create/logic/index.ts | 27 + .../steps/AdvancedOptionsStep/index.tsx | 1 + .../create/steps/ReviewStep/index.tsx | 3 +- .../create/steps/StatusStep/index.tsx | 3 +- .../sidebar/SafeListContextMenu/index.tsx | 26 +- .../welcome/MyAccounts/AccountItem.tsx | 11 +- .../welcome/MyAccounts/AddNetworkButton.tsx | 34 + .../welcome/MyAccounts/MultiAccountItem.tsx | 26 +- .../welcome/MyAccounts/SubAccountItem.tsx | 9 +- .../MyAccounts/utils/multiChainSafe.ts | 63 +- .../counterfactual/ActivateAccountFlow.tsx | 63 +- .../counterfactual/__tests__/utils.test.ts | 17 +- .../store/undeployedSafesSlice.ts | 16 +- src/features/counterfactual/utils.ts | 173 ++++- .../components/CreateSafeOnNewChain/index.tsx | 125 ++++ .../__tests__/useReplayableNetworks.test.ts | 302 +++++++++ .../__tests__/useSafeCreationData.test.ts | 619 ++++++++++++++++++ .../multichain/hooks/useReplayableNetworks.ts | 60 ++ .../multichain/hooks/useSafeCreationData.ts | 160 +++++ src/hooks/coreSDK/safeCoreSDK.ts | 15 +- src/hooks/loadables/useLoadSafeInfo.ts | 2 +- src/pages/_app.tsx | 2 + src/services/analytics/events/overview.ts | 4 + src/services/contracts/safeContracts.ts | 3 +- src/services/exceptions/ErrorCodes.ts | 1 + src/store/addedSafesSlice.ts | 21 - yarn.lock | 18 + 33 files changed, 1868 insertions(+), 91 deletions(-) create mode 100644 src/components/common/NetworkInput/index.tsx create mode 100644 src/components/common/NetworkInput/styles.module.css create mode 100644 src/components/welcome/MyAccounts/AddNetworkButton.tsx create mode 100644 src/features/multichain/components/CreateSafeOnNewChain/index.tsx create mode 100644 src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts create mode 100644 src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts create mode 100644 src/features/multichain/hooks/useReplayableNetworks.ts create mode 100644 src/features/multichain/hooks/useSafeCreationData.ts diff --git a/jest.setup.js b/jest.setup.js index 702bee6130..305e942810 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -5,6 +5,7 @@ // Learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom/extend-expect' import { TextEncoder, TextDecoder } from 'util' +import { Headers, Request, Response } from 'node-fetch' jest.mock('@web3-onboard/coinbase', () => jest.fn()) jest.mock('@web3-onboard/injected-wallets', () => ({ ProviderLabel: { MetaMask: 'MetaMask' } })) @@ -12,6 +13,7 @@ jest.mock('@web3-onboard/keystone/dist/index', () => jest.fn()) jest.mock('@web3-onboard/ledger/dist/index', () => jest.fn()) jest.mock('@web3-onboard/trezor', () => jest.fn()) jest.mock('@web3-onboard/walletconnect', () => jest.fn()) +jest.mock('safe-client-gateway-sdk') const mockOnboardState = { chains: [], @@ -68,3 +70,8 @@ Object.defineProperty(Uint8Array, Symbol.hasInstance, { : Uint8Array[Symbol.hasInstance].call(this, potentialInstance) }, }) + +// These are required for safe-client-gateway-sdk +globalThis.Request = Request +globalThis.Response = Response +globalThis.Headers = Headers diff --git a/package.json b/package.json index ef8930c3b1..27f5744833 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,8 @@ "@safe-global/protocol-kit": "^4.0.4", "@safe-global/safe-apps-sdk": "^9.1.0", "@safe-global/safe-deployments": "^1.37.3", - "@safe-global/safe-gateway-typescript-sdk": "3.22.1", "@safe-global/safe-modules-deployments": "^1.2.0", + "@safe-global/safe-gateway-typescript-sdk": "3.22.1", "@sentry/react": "^7.91.0", "@spindl-xyz/attribution-lite": "^1.4.0", "@walletconnect/utils": "^2.13.1", @@ -91,6 +91,7 @@ "react-hook-form": "7.41.1", "react-papaparse": "^4.0.2", "react-redux": "^9.1.2", + "safe-client-gateway-sdk": "git+https://github.com/safe-global/safe-client-gateway-sdk.git#v1.53.0-next-7344903", "semver": "^7.5.2", "zodiac-roles-deployments": "^2.2.5" }, diff --git a/src/components/common/NetworkInput/index.tsx b/src/components/common/NetworkInput/index.tsx new file mode 100644 index 0000000000..9064e460e0 --- /dev/null +++ b/src/components/common/NetworkInput/index.tsx @@ -0,0 +1,85 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import { useDarkMode } from '@/hooks/useDarkMode' +import { useTheme } from '@mui/material/styles' +import { FormControl, InputLabel, ListSubheader, MenuItem, Select, Skeleton } from '@mui/material' +import partition from 'lodash/partition' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import css from './styles.module.css' +import { type ReactElement, useMemo } from 'react' +import { useCallback } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +const NetworkInput = ({ + name, + required = false, + chainConfigs, +}: { + name: string + required?: boolean + chainConfigs: ChainInfo[] +}): ReactElement => { + const isDarkMode = useDarkMode() + const theme = useTheme() + const [testNets, prodNets] = useMemo(() => partition(chainConfigs, (config) => config.isTestnet), [chainConfigs]) + const { control } = useFormContext() || {} + + const renderMenuItem = useCallback( + (chainId: string, isSelected: boolean) => { + const chain = chainConfigs.find((chain) => chain.chainId === chainId) + if (!chain) return null + return ( + + + + ) + }, + [chainConfigs], + ) + + return chainConfigs.length ? ( + ( + + Network + + + )} + /> + ) : ( + + ) +} + +export default NetworkInput diff --git a/src/components/common/NetworkInput/styles.module.css b/src/components/common/NetworkInput/styles.module.css new file mode 100644 index 0000000000..1703446ac1 --- /dev/null +++ b/src/components/common/NetworkInput/styles.module.css @@ -0,0 +1,54 @@ +.select { + height: 100%; +} + +.select:after, +.select:before { + display: none; +} + +.select *:focus-visible { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; +} + +.select :global .MuiSelect-select { + padding-right: 40px !important; + padding-left: 16px; + height: 100%; + display: flex; + align-items: center; +} + +.select :global .MuiSelect-icon { + margin-right: var(--space-2); +} + +.select :global .Mui-disabled { + pointer-events: none; +} + +.select :global .MuiMenuItem-root { + padding: 0; +} + +.listSubHeader { + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + line-height: 32px; +} + +.newChip { + font-weight: bold; + letter-spacing: -0.1px; + margin-top: -18px; + margin-left: -14px; + transform: scale(0.7); +} + +.item { + display: flex; + align-items: center; + gap: var(--space-1); +} diff --git a/src/components/dashboard/FirstSteps/index.tsx b/src/components/dashboard/FirstSteps/index.tsx index cf4dde16b3..6d70dbd95a 100644 --- a/src/components/dashboard/FirstSteps/index.tsx +++ b/src/components/dashboard/FirstSteps/index.tsx @@ -25,6 +25,7 @@ import CheckCircleOutlineRoundedIcon from '@mui/icons-material/CheckCircleOutlin import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined' import css from './styles.module.css' import ActivateAccountButton from '@/features/counterfactual/ActivateAccountButton' +import { isReplayedSafeProps } from '@/features/counterfactual/utils' const calculateProgress = (items: boolean[]) => { const totalNumberOfItems = items.length @@ -304,6 +305,7 @@ const FirstSteps = () => { const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, safe.chainId, safeAddress)) const isMultiSig = safe.threshold > 1 + const isReplayedSafe = undeployedSafe && isReplayedSafeProps(undeployedSafe?.props) const hasNonZeroBalance = balances && (balances.items.length > 1 || BigInt(balances.items[0]?.balance || 0) > 0) const hasOutgoingTransactions = !!outgoingTransactions && outgoingTransactions.length > 0 @@ -383,7 +385,7 @@ const FirstSteps = () => { {isActivating ? ( - ) : isMultiSig ? ( + ) : isMultiSig || isReplayedSafe ? ( ) : ( diff --git a/src/components/new-safe/create/index.tsx b/src/components/new-safe/create/index.tsx index 7683691eb1..2063745d70 100644 --- a/src/components/new-safe/create/index.tsx +++ b/src/components/new-safe/create/index.tsx @@ -160,7 +160,7 @@ const CreateSafe = () => { name: '', owners: [], threshold: 1, - saltNonce: Date.now(), + saltNonce: 0, safeVersion: getLatestSafeVersion(chain) as SafeVersion, } diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 1be9d299bd..d4dbc60253 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -19,6 +19,7 @@ import { backOff } from 'exponential-backoff' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getLatestSafeVersion } from '@/utils/chains' import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import { type ReplayedSafeProps } from '@/store/slices' export type SafeCreationProps = { owners: string[] @@ -232,3 +233,29 @@ export const relaySafeCreation = async ( return relayResponse.taskId } + +export const relayReplayedSafeCreation = async ( + chain: ChainInfo, + replayedSafe: ReplayedSafeProps, + safeVersion: SafeVersion | undefined, +) => { + const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) + const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion, replayedSafe.factoryAddress) + + if (!replayedSafe.masterCopy || !replayedSafe.setupData) { + throw Error('Cannot replay Safe without deployment info') + } + const createProxyWithNonceCallData = readOnlyProxyContract.encode('createProxyWithNonce', [ + replayedSafe.masterCopy, + replayedSafe.setupData, + BigInt(replayedSafe.saltNonce), + ]) + + const relayResponse = await relayTransaction(chain.chainId, { + to: replayedSafe.factoryAddress, + data: createProxyWithNonceCallData, + version: usedSafeVersion, + }) + + return relayResponse.taskId +} diff --git a/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx b/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx index f7627ef664..49212e42fe 100644 --- a/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx +++ b/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx @@ -54,6 +54,7 @@ const AdvancedOptionsStep = ({ onSubmit, onBack, data, setStep }: StepRenderProp if (!chain || !readOnlyFallbackHandlerContract || !wallet) { return undefined } + return computeNewSafeAddress( wallet.provider, { diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 846813120d..3723808847 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -173,10 +173,11 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { trackEvent(CREATE_SAFE_EVENTS.RETRY_CREATE_SAFE) - if (!pendingSafe) { + if (!pendingSafe || !isPredictedSafeProps(pendingSafe.props)) { setStep(0) return } diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index e019e5ee83..2f1761c1ed 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -12,28 +12,33 @@ import { useAppSelector } from '@/store' import { selectAddedSafes } from '@/store/addedSafesSlice' import EditIcon from '@/public/images/common/edit.svg' import DeleteIcon from '@/public/images/common/delete.svg' +import PlusIcon from '@/public/images/common/plus.svg' import ContextMenu from '@/components/common/ContextMenu' import { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { SvgIcon } from '@mui/material' import useAddressBook from '@/hooks/useAddressBook' import { AppRoutes } from '@/config/routes' import router from 'next/router' +import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSafeOnNewChain' enum ModalType { RENAME = 'rename', REMOVE = 'remove', + ADD_CHAIN = 'add_chain', } -const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false } +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false, [ModalType.ADD_CHAIN]: false } const SafeListContextMenu = ({ name, address, chainId, + addNetwork, }: { name: string address: string chainId: string + addNetwork: boolean }): ReactElement => { const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) const isAdded = !!addedSafes?.[address] @@ -88,6 +93,15 @@ const SafeListContextMenu = ({ Remove )} + + {addNetwork && ( + + + + + Add another network + + )} {open[ModalType.RENAME] && ( @@ -102,6 +116,16 @@ const SafeListContextMenu = ({ {open[ModalType.REMOVE] && ( )} + + {open[ModalType.ADD_CHAIN] && ( + + )} ) } diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index bb9e06ce81..6b08bfea5b 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -25,6 +25,7 @@ import type { SafeItem } from './useAllSafes' import FiatValue from '@/components/common/FiatValue' import QueueActions from './QueueActions' import { useGetHref } from './useGetHref' +import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' type AccountItemProps = { safeItem: SafeItem @@ -54,6 +55,10 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) const isActivating = undeployedSafe?.status.status !== 'AWAITING_EXECUTION' + const counterfactualSetup = undeployedSafe + ? extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) + : undefined + return ( @@ -113,7 +118,7 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) - + { + const [open, setOpen] = useState(false) + + return ( + <> + + + {open && ( + setOpen(false)} + currentName={currentName} + safeAddress={safeAddress} + deployedChainIds={deployedChains} + /> + )} + + ) +} diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index bcf2e53b67..f13a19ea59 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -10,7 +10,6 @@ import { AccordionDetails, AccordionSummary, Divider, - Button, } from '@mui/material' import SafeIcon from '@/components/common/SafeIcon' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' @@ -30,6 +29,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { type SafeItem } from './useAllSafes' import SubAccountItem from './SubAccountItem' import { getSharedSetup } from './utils/multiChainSafe' +import { AddNetworkButton } from './AddNetworkButton' type MultiAccountItemProps = { multiSafeAccountItem: MultiChainSafeItem @@ -46,6 +46,11 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: const isWelcomePage = router.pathname === AppRoutes.welcome.accounts const [expanded, setExpanded] = useState(isCurrentSafe) + const isWatchlist = useMemo( + () => multiSafeAccountItem.safes.every((safe) => safe.isWatchlist), + [multiSafeAccountItem.safes], + ) + const trackingLabel = isWelcomePage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar const toggleExpand = () => { @@ -129,13 +134,18 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: /> ))} - - - {/* TODO: Trigger Safe creation flow with a new network */} - - + {!isWatchlist && ( + <> + + + safe.chainId)} + /> + + + )} diff --git a/src/components/welcome/MyAccounts/SubAccountItem.tsx b/src/components/welcome/MyAccounts/SubAccountItem.tsx index 7857d442d4..3d84959bb9 100644 --- a/src/components/welcome/MyAccounts/SubAccountItem.tsx +++ b/src/components/welcome/MyAccounts/SubAccountItem.tsx @@ -23,6 +23,7 @@ import type { SafeItem } from './useAllSafes' import FiatValue from '@/components/common/FiatValue' import QueueActions from './QueueActions' import { useGetHref } from './useGetHref' +import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' type SubAccountItem = { safeItem: SafeItem @@ -52,6 +53,8 @@ const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) const isActivating = undeployedSafe?.status.status !== 'AWAITING_EXECUTION' + const cfSafeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) + return ( @@ -109,7 +112,7 @@ const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) - + { if ('safes' in safe && 'address' in safe) { @@ -41,11 +44,14 @@ export const getSharedSetup = ( } } else if (undeployedSafesWithData.length > 0) { const undeployedSafe = undeployedSafesWithData[0].undeployedSafe + const undeployedSafeSetup = extractCounterfactualSafeSetup(undeployedSafe, undeployedSafesWithData[0].chainId) // Use first undeployed Safe - comparisonSetup = { - threshold: undeployedSafe.props.safeAccountConfig.threshold, - owners: undeployedSafe.props.safeAccountConfig.owners, - } + comparisonSetup = undeployedSafeSetup + ? { + threshold: undeployedSafeSetup.threshold, + owners: undeployedSafeSetup.owners, + } + : undefined } if (!comparisonSetup) { return undefined @@ -66,15 +72,24 @@ export const getSharedSetup = ( ) } // Check if the Safe is counterfactual - const undeployedSafe = undeployedSafesWithData.find( + const undeployedSafeItem = undeployedSafesWithData.find( (value) => value.chainId === safeItem.chainId && sameAddress(value.address, safeItem.address), - )?.undeployedSafe - if (!undeployedSafe) { + ) + if (!undeployedSafeItem) { + return false + } + + const undeployedSafeSetup = extractCounterfactualSafeSetup( + undeployedSafeItem.undeployedSafe, + undeployedSafeItem.chainId, + ) + if (!undeployedSafeSetup) { return false } + return ( - areOwnersMatching(undeployedSafe.props.safeAccountConfig.owners, comparisonSetup.owners) && - undeployedSafe.props.safeAccountConfig.threshold === comparisonSetup.threshold + areOwnersMatching(undeployedSafeSetup.owners, comparisonSetup.owners) && + undeployedSafeSetup.threshold === comparisonSetup.threshold ) }) ) { @@ -82,3 +97,31 @@ export const getSharedSetup = ( } return undefined } + +export const predictAddressBasedOnReplayData = async ( + safeCreationData: ReplayedSafeProps, + provider: JsonRpcProvider, +) => { + if (!safeCreationData.setupData) { + throw new Error('Cannot predict address without setupData') + } + + // Step 1: Hash the initializer + const initializerHash = keccak256(safeCreationData.setupData) + + // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent + const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) + + // Step 3: Hash the encoded value to get the final salt + const salt = keccak256(encoded) + + // Get Proxy creation code + const proxyCreationCode = await Safe_proxy_factory__factory.connect( + safeCreationData.factoryAddress, + provider, + ).proxyCreationCode() + + const constructorData = safeCreationData.masterCopy + const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) + return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) +} diff --git a/src/features/counterfactual/ActivateAccountFlow.tsx b/src/features/counterfactual/ActivateAccountFlow.tsx index a8a09dc56c..bd2b67317d 100644 --- a/src/features/counterfactual/ActivateAccountFlow.tsx +++ b/src/features/counterfactual/ActivateAccountFlow.tsx @@ -1,4 +1,4 @@ -import { createNewSafe, relaySafeCreation } from '@/components/new-safe/create/logic' +import { createNewSafe, relayReplayedSafeCreation, relaySafeCreation } from '@/components/new-safe/create/logic' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import { NetworkFee, SafeSetupOverview } from '@/components/new-safe/create/steps/ReviewStep' import ReviewRow from '@/components/new-safe/ReviewRow' @@ -11,7 +11,12 @@ import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/Execut import useDeployGasLimit from '@/features/counterfactual/hooks/useDeployGasLimit' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' import { selectUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import { CF_TX_GROUP_KEY } from '@/features/counterfactual/utils' +import { + activateReplayedSafe, + CF_TX_GROUP_KEY, + extractCounterfactualSafeSetup, + isPredictedSafeProps, +} from '@/features/counterfactual/utils' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice' @@ -29,8 +34,9 @@ import { hasRemainingRelays } from '@/utils/relaying' import { Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material' import type { DeploySafeProps } from '@safe-global/protocol-kit' import { FEATURES } from '@/utils/chains' -import React, { useContext, useState } from 'react' +import React, { useContext, useMemo, useState } from 'react' import { getLatestSafeVersion } from '@/utils/chains' +import { createWeb3 } from '@/hooks/wallets/web3' const useActivateAccount = () => { const chain = useCurrentChain() @@ -69,17 +75,22 @@ const ActivateAccountFlow = () => { const wallet = useWallet() const { options, totalFee, walletCanPay } = useActivateAccount() - const ownerAddresses = undeployedSafe?.props.safeAccountConfig.owners || [] + const undeployedSafeSetup = useMemo( + () => extractCounterfactualSafeSetup(undeployedSafe, chainId), + [undeployedSafe, chainId], + ) + + const ownerAddresses = undeployedSafeSetup?.owners || [] const [minRelays] = useLeastRemainingRelays(ownerAddresses) // Every owner has remaining relays and relay method is selected const canRelay = hasRemainingRelays(minRelays) const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY - if (!undeployedSafe) return null + if (!undeployedSafe || !undeployedSafeSetup) return null - const { owners, threshold } = undeployedSafe.props.safeAccountConfig - const { saltNonce, safeVersion } = undeployedSafe.props.safeDeploymentConfig || {} + const { owners, threshold } = undeployedSafeSetup + const { saltNonce, safeVersion } = undeployedSafeSetup const onSubmit = (txHash?: string) => { trackEvent({ ...TX_EVENTS.CREATE, label: TX_TYPES.activate_without_tx }) @@ -102,21 +113,37 @@ const ActivateAccountFlow = () => { try { if (willRelay) { - const taskId = await relaySafeCreation(chain, owners, threshold, Number(saltNonce!), safeVersion) + let taskId: string + if (isPredictedSafeProps(undeployedSafe.props)) { + taskId = await relaySafeCreation(chain, owners, threshold, Number(saltNonce!), safeVersion) + } else { + taskId = await relayReplayedSafeCreation(chain, undeployedSafe.props, safeVersion) + } safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) onSubmit() } else { - await createNewSafe( - wallet.provider, - { - safeAccountConfig: undeployedSafe.props.safeAccountConfig, - saltNonce, - options, - callback: onSubmit, - }, - safeVersion ?? getLatestSafeVersion(chain), - ) + if (isPredictedSafeProps(undeployedSafe.props)) { + await createNewSafe( + wallet.provider, + { + safeAccountConfig: undeployedSafe.props.safeAccountConfig, + saltNonce, + options, + callback: onSubmit, + }, + safeVersion ?? getLatestSafeVersion(chain), + ) + } else { + // Deploy replayed Safe Creation + const txResponse = await activateReplayedSafe( + safeVersion ?? getLatestSafeVersion(chain), + chain, + undeployedSafe.props, + createWeb3(wallet.provider), + ) + onSubmit(txResponse.hash) + } } } catch (_err) { const err = asError(_err) diff --git a/src/features/counterfactual/__tests__/utils.test.ts b/src/features/counterfactual/__tests__/utils.test.ts index fc033c167f..56b223028b 100644 --- a/src/features/counterfactual/__tests__/utils.test.ts +++ b/src/features/counterfactual/__tests__/utils.test.ts @@ -11,11 +11,13 @@ import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { TokenType } from '@safe-global/safe-gateway-typescript-sdk' import { type BrowserProvider, type JsonRpcProvider } from 'ethers' +import { PendingSafeStatus } from '../store/undeployedSafesSlice' +import { PayMethod } from '../PayNowPayLater' describe('Counterfactual utils', () => { describe('getUndeployedSafeInfo', () => { it('should return undeployed safe info', () => { - const undeployedSafe: PredictedSafeProps = { + const undeployedSafeProps: PredictedSafeProps = { safeAccountConfig: { owners: [faker.finance.ethereumAddress()], threshold: 1, @@ -25,14 +27,21 @@ describe('Counterfactual utils', () => { const mockAddress = faker.finance.ethereumAddress() const mockChainId = '1' - const result = getUndeployedSafeInfo(undeployedSafe, mockAddress, chainBuilder().with({ chainId: '1' }).build()) + const result = getUndeployedSafeInfo( + { + props: undeployedSafeProps, + status: { status: PendingSafeStatus.AWAITING_EXECUTION, type: PayMethod.PayLater }, + }, + mockAddress, + chainBuilder().with({ chainId: '1' }).build(), + ) expect(result.nonce).toEqual(0) expect(result.deployed).toEqual(false) expect(result.address.value).toEqual(mockAddress) expect(result.chainId).toEqual(mockChainId) - expect(result.threshold).toEqual(undeployedSafe.safeAccountConfig.threshold) - expect(result.owners[0].value).toEqual(undeployedSafe.safeAccountConfig.owners[0]) + expect(result.threshold).toEqual(undeployedSafeProps.safeAccountConfig.threshold) + expect(result.owners[0].value).toEqual(undeployedSafeProps.safeAccountConfig.owners[0]) }) }) diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts index 8335ec30b7..60bea32ef4 100644 --- a/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -3,6 +3,7 @@ import { type RootState } from '@/store' import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { selectChainIdAndSafeAddress, selectSafeAddress } from '@/store/common' +import { type CreationTransaction } from 'safe-client-gateway-sdk' export enum PendingSafeStatus { AWAITING_EXECUTION = 'AWAITING_EXECUTION', @@ -21,9 +22,15 @@ type UndeployedSafeStatus = { signerNonce?: number | null } +export type ReplayedSafeProps = Pick & { + saltNonce: string +} + +export type UndeployedSafeProps = PredictedSafeProps | ReplayedSafeProps + export type UndeployedSafe = { status: UndeployedSafeStatus - props: PredictedSafeProps + props: UndeployedSafeProps } type UndeployedSafesSlice = { [address: string]: UndeployedSafe } @@ -38,7 +45,12 @@ export const undeployedSafesSlice = createSlice({ reducers: { addUndeployedSafe: ( state, - action: PayloadAction<{ chainId: string; address: string; type: PayMethod; safeProps: PredictedSafeProps }>, + action: PayloadAction<{ + chainId: string + address: string + type: PayMethod + safeProps: PredictedSafeProps | ReplayedSafeProps + }>, ) => { const { chainId, address, type, safeProps } = action.payload diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index e4ba6d7dc3..2193664360 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -2,9 +2,15 @@ import type { NewSafeFormData } from '@/components/new-safe/create' import { getLatestSafeVersion } from '@/utils/chains' import { POLLING_INTERVAL } from '@/config/constants' import { AppRoutes } from '@/config/routes' -import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' -import { addUndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' +import { + addUndeployedSafe, + type UndeployedSafeProps, + type ReplayedSafeProps, + type UndeployedSafe, + PendingSafeStatus, +} from '@/features/counterfactual/store/undeployedSafesSlice' import { type ConnectedWallet } from '@/hooks/wallets/useOnboard' import { getWeb3ReadOnly } from '@/hooks/wallets/web3' import { asError } from '@/services/exceptions/utils' @@ -17,9 +23,9 @@ import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' import { didRevert, type EthersError } from '@/utils/ethers-utils' import { assertProvider, assertTx, assertWallet } from '@/utils/helpers' -import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' +import { type DeploySafeProps, type PredictedSafeProps } from '@safe-global/protocol-kit' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core-sdk-types' +import type { SafeTransaction, SafeVersion, TransactionOptions } from '@safe-global/safe-core-sdk-types' import { type ChainInfo, ImplementationVersionState, @@ -28,20 +34,30 @@ import { } from '@safe-global/safe-gateway-typescript-sdk' import type { BrowserProvider, ContractTransactionResponse, Eip1193Provider, Provider } from 'ethers' import type { NextRouter } from 'next/router' +import { Safe__factory } from '@/types/contracts' +import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' +import { sameAddress } from '@/utils/addresses' + +import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContracts' -export const getUndeployedSafeInfo = (undeployedSafe: PredictedSafeProps, address: string, chain: ChainInfo) => { +export const getUndeployedSafeInfo = (undeployedSafe: UndeployedSafe, address: string, chain: ChainInfo) => { + const safeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain.chainId) + + if (!safeSetup) { + throw Error('Could not determine Safe Setup.') + } const latestSafeVersion = getLatestSafeVersion(chain) return { ...defaultSafeInfo, address: { value: address }, chainId: chain.chainId, - owners: undeployedSafe.safeAccountConfig.owners.map((owner) => ({ value: owner })), + owners: safeSetup.owners.map((owner) => ({ value: owner })), nonce: 0, - threshold: undeployedSafe.safeAccountConfig.threshold, + threshold: safeSetup.threshold, implementationVersionState: ImplementationVersionState.UP_TO_DATE, - fallbackHandler: { value: undeployedSafe.safeAccountConfig.fallbackHandler! }, - version: undeployedSafe.safeDeploymentConfig?.safeVersion || latestSafeVersion, + fallbackHandler: { value: safeSetup.fallbackHandler! }, + version: safeSetup?.safeVersion || latestSafeVersion, deployed: false, } } @@ -179,6 +195,52 @@ export const createCounterfactualSafe = ( }) } +export const replayCounterfactualSafeDeployment = ( + chainId: string, + safeAddress: string, + replayedSafeProps: ReplayedSafeProps, + name: string, + dispatch: AppDispatch, +) => { + const undeployedSafe = { + chainId, + address: safeAddress, + type: PayMethod.PayLater, + safeProps: replayedSafeProps, + } + + const setup = extractCounterfactualSafeSetup( + { + props: replayedSafeProps, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + }, + chainId, + ) + if (!setup) { + throw Error('Safe Setup could not be decoded') + } + + dispatch(addUndeployedSafe(undeployedSafe)) + dispatch(upsertAddressBookEntry({ chainId, address: safeAddress, name })) + dispatch( + addOrUpdateSafe({ + safe: { + ...defaultSafeInfo, + address: { value: safeAddress, name }, + threshold: setup.threshold, + owners: setup.owners.map((owner) => ({ + value: owner, + name: undefined, + })), + chainId, + }, + }), + ) +} + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) /** @@ -312,3 +374,96 @@ export const checkSafeActionViaRelay = (taskId: string, safeAddress: string, typ clearInterval(intervalId) }, TIMEOUT_TIME) } + +export const isReplayedSafeProps = (props: UndeployedSafeProps): props is ReplayedSafeProps => { + if ('setupData' in props && 'masterCopy' in props && 'factoryAddress' in props && 'saltNonce' in props) { + return true + } + return false +} + +export const isPredictedSafeProps = (props: UndeployedSafeProps): props is PredictedSafeProps => { + if ('safeAccountConfig' in props) { + return true + } + return false +} + +const determineFallbackHandlerVersion = (fallbackHandler: string, chainId: string): SafeVersion | undefined => { + const SAFE_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] + return SAFE_VERSIONS.find((version) => { + const deployments = getCompatibilityFallbackHandlerDeployments({ version })?.networkAddresses[chainId] + + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(fallbackHandler, deployment)) + } + return sameAddress(fallbackHandler, deployments) + }) +} + +export const extractCounterfactualSafeSetup = ( + undeployedSafe: UndeployedSafe | undefined, + chainId: string | undefined, +): + | { + owners: string[] + threshold: number + fallbackHandler: string | undefined + safeVersion: SafeVersion | undefined + saltNonce: string | undefined + } + | undefined => { + if (!undeployedSafe || !chainId) { + return undefined + } + if (isPredictedSafeProps(undeployedSafe.props)) { + return { + owners: undeployedSafe.props.safeAccountConfig.owners, + threshold: undeployedSafe.props.safeAccountConfig.threshold, + fallbackHandler: undeployedSafe.props.safeAccountConfig.fallbackHandler, + safeVersion: undeployedSafe.props.safeDeploymentConfig?.safeVersion, + saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce, + } + } else { + if (!undeployedSafe.props.setupData) { + return undefined + } + const [owners, threshold, to, data, fallbackHandler, ...setupParams] = + Safe__factory.createInterface().decodeFunctionData('setup', undeployedSafe.props.setupData) + + const safeVersion = determineFallbackHandlerVersion(fallbackHandler, chainId) + + return { + owners, + threshold: Number(threshold), + fallbackHandler, + safeVersion, + saltNonce: undeployedSafe.props.saltNonce, + } + } +} + +export const activateReplayedSafe = async ( + safeVersion: SafeVersion, + chain: ChainInfo, + props: ReplayedSafeProps, + provider: BrowserProvider, +) => { + const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) + const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion, props.factoryAddress) + + if (!props.masterCopy || !props.setupData) { + throw Error('Cannot replay Safe without deployment info') + } + const data = readOnlyProxyContract.encode('createProxyWithNonce', [ + props.masterCopy, + props.setupData, + BigInt(props.saltNonce), + ]) + + return (await provider.getSigner()).sendTransaction({ + to: props.factoryAddress, + data, + value: '0', + }) +} diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx new file mode 100644 index 0000000000..7a8b737713 --- /dev/null +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -0,0 +1,125 @@ +import NameInput from '@/components/common/NameInput' +import NetworkInput from '@/components/common/NetworkInput' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import { useSafeCreationData } from '../../hooks/useSafeCreationData' +import { useReplayableNetworks } from '../../hooks/useReplayableNetworks' +import { useMemo } from 'react' +import { replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' + +import useChains from '@/hooks/useChains' +import { useAppDispatch, useAppSelector } from '@/store' +import { selectRpc } from '@/store/settingsSlice' +import { createWeb3ReadOnly } from '@/hooks/wallets/web3' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { sameAddress } from '@/utils/addresses' + +type CreateSafeOnNewChainForm = { + name: string + chainId: string +} + +export const CreateSafeOnNewChain = ({ + safeAddress, + deployedChainIds, + currentName, + open, + onClose, +}: { + safeAddress: string + deployedChainIds: string[] + currentName: string | undefined + open: boolean + onClose: () => void +}) => { + const formMethods = useForm({ + mode: 'all', + defaultValues: { + name: currentName, + }, + }) + + const { handleSubmit } = formMethods + const { configs } = useChains() + + const chain = configs.find((config) => config.chainId === deployedChainIds[0]) + + const customRpc = useAppSelector(selectRpc) + const dispatch = useAppDispatch() + + // Load some data + const [safeCreationData, safeCreationDataError] = useSafeCreationData(safeAddress, chain) + + const onFormSubmit = handleSubmit(async (data) => { + const selectedChain = configs.find((config) => config.chainId === data.chainId) + if (!safeCreationData || !safeCreationData.setupData || !selectedChain || !safeCreationData.masterCopy) { + return + } + + // We need to create a readOnly provider of the deployed chain + const customRpcUrl = selectedChain ? customRpc?.[selectedChain.chainId] : undefined + const provider = createWeb3ReadOnly(selectedChain, customRpcUrl) + if (!provider) { + return + } + + // 1. Double check that the creation Data will lead to the correct address + const predictedAddress = await predictAddressBasedOnReplayData(safeCreationData, provider) + if (!sameAddress(safeAddress, predictedAddress)) { + throw new Error('The replayed Safe leads to an unexpected address') + } + + // 2. Replay Safe creation and add it to the counterfactual Safes + replayCounterfactualSafeDeployment(selectedChain.chainId, safeAddress, safeCreationData, data.name, dispatch) + + // Close modal + onClose() + }) + + const replayableChains = useReplayableNetworks(safeCreationData) + + const newReplayableChains = useMemo( + () => replayableChains.filter((chain) => !deployedChainIds.includes(chain.chainId)), + [deployedChainIds, replayableChains], + ) + + const submitDisabled = !!safeCreationDataError + + return ( + +
+ Add another network + + {safeCreationDataError ? ( + + Could not determine the Safe creation parameters. + + ) : ( + + + This action re-deploys a Safe to another network with the same address. + + The Safe will use the initial setup of the copied Safe. Any changes to owners, threshold, modules or + the Safe's version will not be reflected in the copy. + + + + + + + + )} + + + + + +
+
+ ) +} diff --git a/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts b/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts new file mode 100644 index 0000000000..fc9f155530 --- /dev/null +++ b/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts @@ -0,0 +1,302 @@ +import { renderHook } from '@/tests/test-utils' +import { useReplayableNetworks } from '../useReplayableNetworks' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' +import { Safe__factory } from '@/types/contracts' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import { chainBuilder } from '@/tests/builders/chains' +import { + getSafeSingletonDeployments, + getSafeL2SingletonDeployments, + getProxyFactoryDeployments, +} from '@safe-global/safe-deployments' +import * as useChains from '@/hooks/useChains' + +const safeInterface = Safe__factory.createInterface() + +const L1_111_MASTERCOPY_DEPLOYMENTS = getSafeSingletonDeployments({ version: '1.1.1' })?.deployments +const L1_130_MASTERCOPY_DEPLOYMENTS = getSafeSingletonDeployments({ version: '1.3.0' })?.deployments +const L1_141_MASTERCOPY_DEPLOYMENTS = getSafeSingletonDeployments({ version: '1.4.1' })?.deployments + +const L2_130_MASTERCOPY_DEPLOYMENTS = getSafeL2SingletonDeployments({ version: '1.3.0' })?.deployments +const L2_141_MASTERCOPY_DEPLOYMENTS = getSafeL2SingletonDeployments({ version: '1.4.1' })?.deployments + +const PROXY_FACTORY_111_DEPLOYMENTS = getProxyFactoryDeployments({ version: '1.1.1' })?.deployments +const PROXY_FACTORY_130_DEPLOYMENTS = getProxyFactoryDeployments({ version: '1.3.0' })?.deployments +const PROXY_FACTORY_141_DEPLOYMENTS = getProxyFactoryDeployments({ version: '1.4.1' })?.deployments + +describe('useReplayableNetworks', () => { + beforeAll(() => { + jest.spyOn(useChains, 'default').mockReturnValue({ + configs: [ + chainBuilder().with({ chainId: '1' }).build(), + chainBuilder().with({ chainId: '10' }).build(), // This has the eip155 and then the canonical addresses + chainBuilder().with({ chainId: '100' }).build(), // This has the canonical and then the eip155 addresses + chainBuilder().with({ chainId: '324' }).build(), // ZkSync has different addresses for all versions + chainBuilder().with({ chainId: '480' }).build(), // Worldchain has 1.4.1 but not 1.1.1 + ], + }) + }) + it('should return empty list without any creation data', () => { + const { result } = renderHook(() => useReplayableNetworks(undefined)) + expect(result.current).toHaveLength(0) + }) + + it('should return empty list for incomplete creation data', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + const creationData: ReplayedSafeProps = { + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: null, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(0) + }) + + it('should return empty list for unknown masterCopies', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + const creationData: ReplayedSafeProps = { + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(0) + }) + + it('should return empty list for unknown masterCopies', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + const creationData: ReplayedSafeProps = { + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(0) + }) + + it('should return everything but zkSync for 1.4.1 Safes', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(4) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) + } + + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, + masterCopy: L2_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(4) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) + } + }) + + it('should return correct chains for 1.3.0 Safes', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + // 1.3.0, L1 and canonical + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(4) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) + } + + // 1.3.0, L2 and canonical + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, + masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(4) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) + } + + // 1.3.0, L1 and EIP155 is not available on Worldchain + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, + masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(3) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100']) + } + + // 1.3.0, L2 and EIP155 + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, + masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(3) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100']) + } + }) + + it('should return correct chains for 1.1.1 Safes', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_111_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_111_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData)) + expect(result.current).toHaveLength(2) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '100']) + }) +}) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts new file mode 100644 index 0000000000..52255df5e3 --- /dev/null +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -0,0 +1,619 @@ +import { renderHook, waitFor } from '@/tests/test-utils' +import { SAFE_CREATION_DATA_ERRORS, useSafeCreationData } from '../useSafeCreationData' +import { faker } from '@faker-js/faker' +import { PendingSafeStatus, type ReplayedSafeProps, type UndeployedSafe } from '@/store/slices' +import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { chainBuilder } from '@/tests/builders/chains' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import * as sdk from '@/services/tx/tx-sender/sdk' +import * as cgwSdk from 'safe-client-gateway-sdk' +import * as web3 from '@/hooks/wallets/web3' +import { encodeMultiSendData, type SafeProvider } from '@safe-global/protocol-kit' +import { + getCompatibilityFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { type JsonRpcProvider } from 'ethers' +import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' + +describe('useSafeCreationData', () => { + beforeAll(() => { + jest.spyOn(sdk, 'getSafeProvider').mockReturnValue({ + getChainId: jest.fn().mockReturnValue('1'), + getExternalProvider: jest.fn(), + getExternalSigner: jest.fn(), + } as unknown as SafeProvider) + }) + it('should return undefined without chain info', async () => { + const safeAddress = faker.finance.ethereumAddress() + const { result } = renderHook(() => useSafeCreationData(safeAddress, undefined)) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, undefined, false]) + }) + }) + + it('should return the replayedSafe when copying one', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainIndo = chainBuilder().with({ chainId: '1' }).build() + const undeployedSafe: UndeployedSafe = { + props: { + factoryAddress: faker.finance.ethereumAddress(), + saltNonce: '420', + masterCopy: faker.finance.ethereumAddress(), + setupData: faker.string.hexadecimal({ length: 64 }), + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainIndo), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe, + }, + }, + }, + }) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undeployedSafe.props, undefined, false]) + }) + }) + + it('should extract replayedSafe data from an predictedSafe', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const undeployedSafe = { + props: { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + }, + safeDeploymentConfig: { + saltNonce: '69', + safeVersion: '1.3.0', + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe as UndeployedSafe, + }, + }, + }, + }) + + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + undeployedSafe.props.safeAccountConfig.owners, + undeployedSafe.props.safeAccountConfig.threshold, + ZERO_ADDRESS, + EMPTY_DATA, + getCompatibilityFallbackHandlerDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, + ZERO_ADDRESS, + 0, + ZERO_ADDRESS, + ]) + + // Should return replayedSafeProps + const expectedProps: ReplayedSafeProps = { + factoryAddress: getProxyFactoryDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, + saltNonce: '69', + masterCopy: getSafeSingletonDeployment({ network: '1', version: '1.3.0' })?.networkAddresses['1'], + setupData, + } + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([expectedProps, undefined, false]) + }) + }) + + it('should extract replayedSafe data from an predictedSafe which has a custom Setup', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const undeployedSafe = { + props: { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 2, + fallbackHandler: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + payment: 123, + paymentReceiver: faker.finance.ethereumAddress(), + paymentToken: faker.finance.ethereumAddress(), + }, + safeDeploymentConfig: { + saltNonce: '69', + safeVersion: '1.3.0', + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + undeployedSafe.props.safeAccountConfig.owners, + undeployedSafe.props.safeAccountConfig.threshold, + undeployedSafe.props.safeAccountConfig.to, + undeployedSafe.props.safeAccountConfig.data, + undeployedSafe.props.safeAccountConfig.fallbackHandler, + undeployedSafe.props.safeAccountConfig.paymentToken, + undeployedSafe.props.safeAccountConfig.payment, + undeployedSafe.props.safeAccountConfig.paymentReceiver, + ]) + + // Should return replayedSafeProps + const expectedProps: ReplayedSafeProps = { + factoryAddress: getProxyFactoryDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, + saltNonce: '69', + masterCopy: getSafeSingletonDeployment({ network: '1', version: '1.3.0' })?.defaultAddress, + setupData, + } + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe as UndeployedSafe, + }, + }, + }, + }) + + // Expectations + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([expectedProps, undefined, false]) + }) + }) + + it('should throw error if creation data cannot be found', async () => { + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + response: new Response(), + data: undefined, + } as any) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) + }) + }) + + it('should throw error if Safe creation data is incomplete', async () => { + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: null, + setupData: null, + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) + }) + }) + + it('should throw error if RPC could not be created', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: faker.finance.ethereumAddress(), + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue(undefined) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER), false]) + }) + }) + + it('should throw error if RPC cannot find the tx hash', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = faker.finance.ethereumAddress() + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => Promise.resolve(null), + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND), false]) + }) + }) + + it('should throw an Error if an unsupported creation method is found', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = faker.finance.ethereumAddress() + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithCallback', [ + mockMasterCopyAddress, + setupData, + 69, + faker.finance.ethereumAddress(), + ]), + }) + } + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) + }) + }) + + it('should throw error if the setup data does not match', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const nonMatchingSetupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = faker.finance.ethereumAddress() + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + mockMasterCopyAddress, + nonMatchingSetupData, + 69, + ]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) + }) + }) + + it('should throw error if the masterCopies do not match', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = faker.finance.ethereumAddress() + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + faker.finance.ethereumAddress(), + setupData, + 69, + ]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) + }) + }) + + it('should return transaction data for direct Safe creation txs', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = faker.finance.ethereumAddress() + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (mockTxHash === txHash) { + return Promise.resolve({ + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + mockMasterCopyAddress, + setupData, + 69, + ]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([ + { + factoryAddress: mockFactoryAddress, + masterCopy: mockMasterCopyAddress, + setupData, + saltNonce: '69', + }, + undefined, + false, + ]) + }) + }) + + it('should return transaction data for creation bundles', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + faker.finance.ethereumAddress(), + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + 0, + faker.finance.ethereumAddress(), + ]) + + const mockTxHash = faker.string.hexadecimal({ length: 64 }) + const mockFactoryAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = faker.finance.ethereumAddress() + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: mockFactoryAddress, + transactionHash: mockTxHash, + masterCopy: mockMasterCopyAddress, + setupData, + }, + response: new Response(), + }) + + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue({ + getTransaction: (txHash: string) => { + if (txHash === mockTxHash) { + const deploymentTx = { + to: mockFactoryAddress, + data: Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + mockMasterCopyAddress, + setupData, + 69, + ]), + value: '0', + operation: 0, + } + const someOtherTx = { + to: faker.finance.ethereumAddress(), + value: '0', + operation: 0, + data: faker.string.hexadecimal({ length: 64 }), + } + + const multiSendData = encodeMultiSendData([deploymentTx, someOtherTx]) + return Promise.resolve({ + to: faker.finance.ethereumAddress(), + data: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [multiSendData]), + }) + } + return Promise.resolve(null) + }, + } as JsonRpcProvider) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + + await waitFor(() => { + expect(result.current).toEqual([ + { + factoryAddress: mockFactoryAddress, + masterCopy: mockMasterCopyAddress, + setupData, + saltNonce: '69', + }, + undefined, + false, + ]) + }) + }) +}) diff --git a/src/features/multichain/hooks/useReplayableNetworks.ts b/src/features/multichain/hooks/useReplayableNetworks.ts new file mode 100644 index 0000000000..0872f3b784 --- /dev/null +++ b/src/features/multichain/hooks/useReplayableNetworks.ts @@ -0,0 +1,60 @@ +import { type ReplayedSafeProps } from '@/features/counterfactual/store/undeployedSafesSlice' +import useChains from '@/hooks/useChains' +import { sameAddress } from '@/utils/addresses' +import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { + type SingletonDeploymentV2, + getProxyFactoryDeployments, + getSafeL2SingletonDeployments, + getSafeSingletonDeployments, +} from '@safe-global/safe-deployments' + +const SUPPORTED_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.1.1'] + +const hasDeployment = (chainId: string, contractAddress: string, deployments: SingletonDeploymentV2[]) => { + return deployments.some((deployment) => { + // Check that deployment contains the contract Address on given chain + const networkDeployments = deployment.networkAddresses[chainId] + return Array.isArray(networkDeployments) + ? networkDeployments.some((networkDeployment) => sameAddress(networkDeployment, contractAddress)) + : sameAddress(networkDeployments, contractAddress) + }) +} + +/** + * Returns all chains where the transaction can be replayed successfully. + * Therefore the creation's masterCopy and factory need to be deployed to that network. + * @param creation + */ +export const useReplayableNetworks = (creation: ReplayedSafeProps | undefined) => { + const { configs } = useChains() + + if (!creation) { + return [] + } + + const { masterCopy, factoryAddress } = creation + + if (!masterCopy) { + return [] + } + + const allL1SingletonDeployments = SUPPORTED_VERSIONS.map((version) => + getSafeSingletonDeployments({ version }), + ).filter(Boolean) as SingletonDeploymentV2[] + + const allL2SingletonDeployments = SUPPORTED_VERSIONS.map((version) => + getSafeL2SingletonDeployments({ version }), + ).filter(Boolean) as SingletonDeploymentV2[] + + const allProxyFactoryDeployments = SUPPORTED_VERSIONS.map((version) => + getProxyFactoryDeployments({ version }), + ).filter(Boolean) as SingletonDeploymentV2[] + + return configs.filter( + (config) => + (hasDeployment(config.chainId, masterCopy, allL1SingletonDeployments) || + hasDeployment(config.chainId, masterCopy, allL2SingletonDeployments)) && + hasDeployment(config.chainId, factoryAddress, allProxyFactoryDeployments), + ) +} diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts new file mode 100644 index 0000000000..967109bdfe --- /dev/null +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -0,0 +1,160 @@ +import useAsync, { type AsyncResult } from '@/hooks/useAsync' +import { createWeb3ReadOnly } from '@/hooks/wallets/web3' +import { type UndeployedSafe, selectRpc, selectUndeployedSafe, type ReplayedSafeProps } from '@/store/slices' +import { Safe_proxy_factory__factory } from '@/types/contracts' +import { sameAddress } from '@/utils/addresses' +import { getCreationTransaction } from 'safe-client-gateway-sdk' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useAppSelector } from '@/store' +import { isPredictedSafeProps } from '@/features/counterfactual/utils' +import { + getReadOnlyGnosisSafeContract, + getReadOnlyProxyFactoryContract, + getReadOnlyFallbackHandlerContract, +} from '@/services/contracts/safeContracts' +import { getLatestSafeVersion } from '@/utils/chains' +import { ZERO_ADDRESS, EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { logError } from '@/services/exceptions' +import ErrorCodes from '@/services/exceptions/ErrorCodes' + +const getUndeployedSafeCreationData = async ( + undeployedSafe: UndeployedSafe, + chain: ChainInfo, +): Promise => { + if (isPredictedSafeProps(undeployedSafe.props)) { + // Copy predicted safe + // Encode Safe creation and determine the addresses the Safe creation would use + const { owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver } = + undeployedSafe.props.safeAccountConfig + const usedSafeVersion = undeployedSafe.props.safeDeploymentConfig?.safeVersion ?? getLatestSafeVersion(chain) + const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion) + const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) + const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) + + const callData = { + owners, + threshold, + to: to ?? ZERO_ADDRESS, + data: data ?? EMPTY_DATA, + fallbackHandler: fallbackHandler ?? (await readOnlyFallbackHandlerContract.getAddress()), + paymentToken: paymentToken ?? ZERO_ADDRESS, + payment: payment ?? 0, + paymentReceiver: paymentReceiver ?? ZERO_ADDRESS, + } + + // @ts-ignore union type is too complex + const setupData = readOnlySafeContract.encode('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + return { + factoryAddress: await readOnlyProxyFactoryContract.getAddress(), + masterCopy: await readOnlySafeContract.getAddress(), + saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce ?? '0', + setupData, + } + } + + // We already have a replayed Safe. In this case we can return the identical data + return undeployedSafe.props +} + +const proxyFactoryInterface = Safe_proxy_factory__factory.createInterface() +const createProxySelector = proxyFactoryInterface.getFunction('createProxyWithNonce').selector + +export const SAFE_CREATION_DATA_ERRORS = { + TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', + NO_CREATION_DATA: 'The Safe creation information for this Safe could be found or is incomplete.', + UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported yet.', + NO_PROVIDER: 'The RPC provider for the origin network is not available.', +} +/** + * Fetches the data with which the given Safe was originally created. + * Useful to replay a Safe creation. + */ +export const useSafeCreationData = ( + safeAddress: string, + chain: ChainInfo | undefined, +): AsyncResult => { + const customRpc = useAppSelector(selectRpc) + + const undeployedSafe = useAppSelector((selector) => + selectUndeployedSafe(selector, chain?.chainId ?? '1', safeAddress), + ) + + return useAsync(async () => { + try { + if (!chain) { + return undefined + } + + // 1. The safe is counterfactual + if (undeployedSafe) { + return getUndeployedSafeCreationData(undeployedSafe, chain) + } + + const { data: creation } = await getCreationTransaction({ + path: { + chainId: chain.chainId, + safeAddress, + }, + }) + + if (!creation || !creation.masterCopy || !creation.setupData) { + throw new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA) + } + + // We need to create a readOnly provider of the deployed chain + const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined + const provider = createWeb3ReadOnly(chain, customRpcUrl) + + if (!provider) { + throw new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER) + } + + // Fetch saltNonce by fetching the transaction from the RPC. + const tx = await provider.getTransaction(creation.transactionHash) + if (!tx) { + throw new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND) + } + const txData = tx.data + const startOfTx = txData.indexOf(createProxySelector.slice(2, 10)) + if (startOfTx === -1) { + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + } + + // decode tx + + const [masterCopy, initializer, saltNonce] = proxyFactoryInterface.decodeFunctionData( + 'createProxyWithNonce', + `0x${txData.slice(startOfTx)}`, + ) + + const txMatches = + sameAddress(masterCopy, creation.masterCopy) && + (initializer as string)?.toLowerCase().includes(creation.setupData?.toLowerCase()) + + if (!txMatches) { + // We found the wrong tx. This tx seems to deploy multiple Safes at once. This is not supported yet. + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + } + + return { + factoryAddress: creation.factoryAddress, + masterCopy: creation.masterCopy, + setupData: creation.setupData, + saltNonce: saltNonce.toString(), + } + } catch (err) { + logError(ErrorCodes._816, err) + throw err + } + }, [chain, customRpc, safeAddress, undeployedSafe]) +} diff --git a/src/hooks/coreSDK/safeCoreSDK.ts b/src/hooks/coreSDK/safeCoreSDK.ts index 87a6f130b6..1637ed0300 100644 --- a/src/hooks/coreSDK/safeCoreSDK.ts +++ b/src/hooks/coreSDK/safeCoreSDK.ts @@ -11,6 +11,7 @@ import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import semverSatisfies from 'semver/functions/satisfies' import { isValidMasterCopy } from '@/services/contracts/safeContracts' import { sameAddress } from '@/utils/addresses' +import { isPredictedSafeProps } from '@/features/counterfactual/utils' export const isLegacyVersion = (safeVersion: string): boolean => { const LEGACY_VERSION = '<1.3.0' @@ -81,11 +82,15 @@ export const initSafeSDK = async ({ } if (undeployedSafe) { - return Safe.init({ - provider: provider._getConnection().url, - isL1SafeSingleton, - predictedSafe: undeployedSafe.props, - }) + if (isPredictedSafeProps(undeployedSafe.props)) { + return Safe.init({ + provider: provider._getConnection().url, + isL1SafeSingleton, + predictedSafe: undeployedSafe.props, + }) + } + // We cannot initialize a Core SDK for replayed Safes yet. + return } return Safe.init({ provider: provider._getConnection().url, diff --git a/src/hooks/loadables/useLoadSafeInfo.ts b/src/hooks/loadables/useLoadSafeInfo.ts index 2b8f55b6b5..2f0d5c75ff 100644 --- a/src/hooks/loadables/useLoadSafeInfo.ts +++ b/src/hooks/loadables/useLoadSafeInfo.ts @@ -28,7 +28,7 @@ export const useLoadSafeInfo = (): AsyncResult => { * This is the one place where we can't check for `safe.deployed` as we want to update that value * when the local storage is cleared, so we have to check undeployedSafe */ - if (undeployedSafe) return getUndeployedSafeInfo(undeployedSafe.props, address, chain) + if (undeployedSafe) return getUndeployedSafeInfo(undeployedSafe, address, chain) const safeInfo = await getSafeInfo(chainId, address) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e7dac080d1..6cff8119da 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,6 +8,7 @@ import CssBaseline from '@mui/material/CssBaseline' import type { Theme } from '@mui/material/styles' import { ThemeProvider } from '@mui/material/styles' import { setBaseUrl as setGatewayBaseUrl } from '@safe-global/safe-gateway-typescript-sdk' +import { setBaseUrl as setNewGatewayBaseUrl } from 'safe-client-gateway-sdk' import { CacheProvider, type EmotionCache } from '@emotion/react' import SafeThemeProvider from '@/components/theme/SafeThemeProvider' import '@/styles/globals.css' @@ -51,6 +52,7 @@ const reduxStore = makeStore() const InitApp = (): null => { setGatewayBaseUrl(GATEWAY_URL) + setNewGatewayBaseUrl(GATEWAY_URL) useHydrateStore(reduxStore) useAdjustUrl() useGtm() diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index 246ccb199b..f4a456600a 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -31,6 +31,10 @@ export const OVERVIEW_EVENTS = { action: 'Remove from watchlist', category: OVERVIEW_CATEGORY, }, + ADD_NEW_NETWORK: { + action: 'Add new network', + category: OVERVIEW_CATEGORY, + }, DELETED_FROM_WATCHLIST: { action: 'Deleted from watchlist', category: OVERVIEW_CATEGORY, diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index 0352409483..e9c423c4e7 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -105,13 +105,14 @@ export const getReadOnlyMultiSendCallOnlyContract = async (safeVersion: SafeInfo // GnosisSafeProxyFactory -export const getReadOnlyProxyFactoryContract = async (safeVersion: SafeInfo['version']) => { +export const getReadOnlyProxyFactoryContract = async (safeVersion: SafeInfo['version'], contractAddress?: string) => { const safeProvider = getSafeProvider() return getSafeProxyFactoryContractInstance( _getValidatedGetContractProps(safeVersion).safeVersion, safeProvider, safeProvider.getExternalProvider(), + contractAddress, ) } diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts index b2b380d7db..5c6769589f 100644 --- a/src/services/exceptions/ErrorCodes.ts +++ b/src/services/exceptions/ErrorCodes.ts @@ -69,6 +69,7 @@ enum ErrorCodes { _813 = '813: Failed to cancel recovery', _814 = '814: Failed to speed up transaction', _815 = '815: Error executing a transaction through a role', + _816 = '816: Error computing replay Safe creation data', _900 = '900: Error loading Safe App', _901 = '901: Error processing Safe Apps SDK request', diff --git a/src/store/addedSafesSlice.ts b/src/store/addedSafesSlice.ts index 94d97c290a..eefde3d421 100644 --- a/src/store/addedSafesSlice.ts +++ b/src/store/addedSafesSlice.ts @@ -1,7 +1,6 @@ import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { AddressEx, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import type { RootState } from '.' -import { safeInfoSlice } from '@/store/safeInfoSlice' export type AddedSafesOnChain = { [safeAddress: string]: { @@ -17,10 +16,6 @@ export type AddedSafesState = { const initialState: AddedSafesState = {} -const isAddedSafe = (state: AddedSafesState, chainId: string, safeAddress: string) => { - return !!state[chainId]?.[safeAddress] -} - export const addedSafesSlice = createSlice({ name: 'addedSafes', initialState, @@ -55,22 +50,6 @@ export const addedSafesSlice = createSlice({ } }, }, - extraReducers(builder) { - builder.addCase(safeInfoSlice.actions.set, (state, { payload }) => { - if (!payload.data) { - return - } - - const { chainId, address } = payload.data - - if (isAddedSafe(state, chainId, address.value)) { - addedSafesSlice.caseReducers.addOrUpdateSafe(state, { - type: addOrUpdateSafe.type, - payload: { safe: payload.data }, - }) - } - }) - }, }) export const { addOrUpdateSafe, removeSafe } = addedSafesSlice.actions diff --git a/yarn.lock b/yarn.lock index 20a45822ae..2e7c25d80b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14293,6 +14293,18 @@ open@^8.0.4, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openapi-fetch@^0.10.5: + version "0.10.6" + resolved "https://registry.yarnpkg.com/openapi-fetch/-/openapi-fetch-0.10.6.tgz#255017e3e609c5e7be16bc1ed7a973977c085cdc" + integrity sha512-6xXfvIEL/POtLGOaFPsp3O+pDe+J3DZYxbD9BrsQHXOTeNK8z/gsWHT6adUy1KcpQOhmkerMzlQrJM6DbN55dQ== + dependencies: + openapi-typescript-helpers "^0.0.11" + +openapi-typescript-helpers@^0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.11.tgz#d05e88216b8f3771d5df41c863ebc5c9d10e2954" + integrity sha512-xofUHlVFq+BMquf3nh9I8N2guHckW6mrDO/F3kaFgrL7MGbjldDnQ9TIT+rkH/+H0LiuO+RuZLnNmsJwsjwUKg== + opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -15954,6 +15966,12 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +"safe-client-gateway-sdk@git+https://github.com/safe-global/safe-client-gateway-sdk.git#v1.53.0-next-7344903": + version "1.53.0-next-7344903" + resolved "git+https://github.com/safe-global/safe-client-gateway-sdk.git#b0b91319b8753b41edd117bb6425729634250e15" + dependencies: + openapi-fetch "^0.10.5" + safe-regex-test@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" From 6d44e2f7715ffee3fd2e640f5622307f074aa9e1 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 11 Sep 2024 15:03:05 +0200 Subject: [PATCH 04/74] [Multichain] feat: automatically migrate incompatible Safes from L1 to L2 if possible (#4069) --- .../DecodedData/SingleTxDecoded/index.tsx | 4 +- .../TxData/MigrationToL2TxData/index.tsx | 80 ++++++ .../transactions/TxDetails/TxData/index.tsx | 5 + src/components/transactions/TxInfo/index.tsx | 9 + src/components/tx-flow/SafeTxProvider.tsx | 26 +- .../MigrateToL2Information.tsx | 37 +++ src/components/tx/SignOrExecuteForm/index.tsx | 10 +- src/config/constants.ts | 5 + .../__tests__/useSafeNotifications.test.ts | 28 +++ src/hooks/useSafeNotifications.ts | 22 +- src/services/contracts/deployments.ts | 5 + src/services/contracts/safeContracts.ts | 4 + src/services/tx/tx-sender/create.ts | 11 + src/tests/builders/safeTx.ts | 30 ++- src/utils/__tests__/transactions.test.ts | 230 +++++++++++++++++- src/utils/transaction-guards.ts | 32 +++ src/utils/transactions.ts | 110 ++++++++- 17 files changed, 619 insertions(+), 29 deletions(-) create mode 100644 src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx create mode 100644 src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx index 8b6cfb3024..f8c0252eb4 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx @@ -7,6 +7,8 @@ import css from './styles.module.css' import accordionCss from '@/styles/accordion.module.css' import CodeIcon from '@mui/icons-material/Code' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' +import { sameAddress } from '@/utils/addresses' +import { SAFE_TO_L2_MIGRATION_ADDRESS } from '@/config/constants' type SingleTxDecodedProps = { tx: InternalTransaction @@ -31,7 +33,7 @@ export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, on dataDecoded: tx.dataDecoded, hexData: tx.data ?? undefined, addressInfoIndex: txData.addressInfoIndex, - trustedDelegateCallTarget: false, // Nested delegate calls are always untrusted + trustedDelegateCallTarget: sameAddress(tx.to, SAFE_TO_L2_MIGRATION_ADDRESS), // We only trusted a nested Migration } return ( diff --git a/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx new file mode 100644 index 0000000000..6989286724 --- /dev/null +++ b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx @@ -0,0 +1,80 @@ +import DecodedTx from '@/components/tx/DecodedTx' +import useAsync from '@/hooks/useAsync' +import { useCurrentChain } from '@/hooks/useChains' +import useDecodeTx from '@/hooks/useDecodeTx' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { getMultiSendContractDeployment } from '@/services/contracts/deployments' +import { createTx } from '@/services/tx/tx-sender/create' +import { Safe__factory } from '@/types/contracts' +import { type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +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 { Box } from '@mui/material' + +export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetails }) => { + const readOnlyProvider = useWeb3ReadOnly() + const chain = useCurrentChain() + const { safe } = useSafeInfo() + const sdk = useSafeSDK() + // Reconstruct real tx + const [realSafeTx, realSafeTxError, realSafeTxLoading] = useAsync(async () => { + // Fetch tx receipt from backend + if (!txDetails.txHash || !chain || !sdk) { + return undefined + } + const txResult = await readOnlyProvider?.getTransaction(txDetails.txHash) + const txData = txResult?.data + + // Search for a Safe Tx to MultiSend contract + const safeInterface = Safe__factory.createInterface() + const execTransactionSelector = safeInterface.getFunction('execTransaction').selector.slice(2, 10) + const multiSendDeployment = getMultiSendContractDeployment(chain, safe.version) + const multiSendAddress = multiSendDeployment?.networkAddresses[chain.chainId] + if (!multiSendAddress) { + return undefined + } + const searchString = `${execTransactionSelector}${zeroPadValue(multiSendAddress, 32).slice(2)}` + const indexOfTx = txData?.indexOf(searchString) + if (indexOfTx && txData) { + // Now we need to find the tx Data + const parsedTx = safeInterface.parseTransaction({ data: `0x${txData.slice(indexOfTx)}` }) + + const execTxArgs = parsedTx?.args + if (!execTxArgs || execTxArgs.length < 10) { + return undefined + } + return createTx({ + to: execTxArgs[0], + value: execTxArgs[1], + data: execTxArgs[2], + operation: execTxArgs[3], + safeTxGas: execTxArgs[4], + baseGas: execTxArgs[5], + gasPrice: execTxArgs[6], + gasToken: execTxArgs[7], + refundReceiver: execTxArgs[8], + }) + } + }, [readOnlyProvider, txDetails.txHash, chain, safe.version, sdk]) + + const [decodedRealTx] = useDecodeTx(realSafeTx) + + const decodedDataUnavailable = !realSafeTx && !realSafeTxLoading + + return ( + + + {realSafeTxError ? ( + {realSafeTxError.message} + ) : decodedDataUnavailable ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/transactions/TxDetails/TxData/index.tsx b/src/components/transactions/TxDetails/TxData/index.tsx index a19e55bc69..3a7758d11d 100644 --- a/src/components/transactions/TxDetails/TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/index.tsx @@ -3,6 +3,7 @@ import type { SpendingLimitMethods } from '@/utils/transaction-guards' import { isCancellationTxInfo, isCustomTxInfo, + isMigrateToL2TxData, isMultisigDetailedExecutionInfo, isSettingsChangeTxInfo, isSpendingLimitMethod, @@ -16,6 +17,7 @@ import RejectionTxInfo from '@/components/transactions/TxDetails/TxData/Rejectio import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import TransferTxInfo from '@/components/transactions/TxDetails/TxData/Transfer' import useChainId from '@/hooks/useChainId' +import { MigrationToL2TxData } from './MigrationToL2TxData' const TxData = ({ txDetails, @@ -47,6 +49,9 @@ const TxData = ({ return } + if (isMigrateToL2TxData(txDetails.txData)) { + return + } return } diff --git a/src/components/transactions/TxInfo/index.tsx b/src/components/transactions/TxInfo/index.tsx index 8dbd055ddd..5d2e2a1360 100644 --- a/src/components/transactions/TxInfo/index.tsx +++ b/src/components/transactions/TxInfo/index.tsx @@ -19,6 +19,7 @@ import { isNativeTokenTransfer, isSettingsChangeTxInfo, isTransferTxInfo, + isMigrateToL2TxInfo, } from '@/utils/transaction-guards' import { ellipsis, shortenAddress } from '@/utils/formatters' import { useCurrentChain } from '@/hooks/useChains' @@ -112,6 +113,10 @@ const SettingsChangeTx = ({ info }: { info: SettingsChange }): ReactElement => { return <> } +const MigrationToL2Tx = (): ReactElement => { + return <>Migrate base contract +} + const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; withLogo?: boolean }): ReactElement => { if (isSettingsChangeTxInfo(info)) { return @@ -125,6 +130,10 @@ const TxInfo = ({ info, ...rest }: { info: TransactionInfo; omitSign?: boolean; return } + if (isMigrateToL2TxInfo(info)) { + return + } + if (isCustomTxInfo(info)) { return } diff --git a/src/components/tx-flow/SafeTxProvider.tsx b/src/components/tx-flow/SafeTxProvider.tsx index b4864a1f4a..b964f7546c 100644 --- a/src/components/tx-flow/SafeTxProvider.tsx +++ b/src/components/tx-flow/SafeTxProvider.tsx @@ -1,10 +1,13 @@ -import { createContext, useState, useEffect } from 'react' +import { createContext, useState, useEffect, useCallback } from 'react' import type { Dispatch, ReactNode, SetStateAction, ReactElement } from 'react' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { createTx } from '@/services/tx/tx-sender' import { useRecommendedNonce, useSafeTxGas } from '../tx/SignOrExecuteForm/hooks' 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' export const SafeTxContext = createContext<{ safeTx?: SafeTransaction @@ -42,6 +45,25 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => const [nonceNeeded, setNonceNeeded] = useState(true) const [safeTxGas, setSafeTxGas] = useState() + const { safe } = useSafeInfo() + const chain = useCurrentChain() + + const setAndMigrateSafeTx: Dispatch> = useCallback( + ( + value: SafeTransaction | undefined | ((prevState: SafeTransaction | undefined) => SafeTransaction | undefined), + ) => { + let safeTx: SafeTransaction | undefined + if (typeof value === 'function') { + safeTx = value(safeTx) + } else { + safeTx = value + } + + prependSafeToL2Migration(safeTx, safe, chain).then(setSafeTx) + }, + [chain, safe], + ) + // Signed txs cannot be updated const isSigned = safeTx && safeTx.signatures.size > 0 @@ -73,7 +95,7 @@ const SafeTxProvider = ({ children }: { children: ReactNode }): ReactElement => value={{ safeTx, safeTxError, - setSafeTx, + setSafeTx: setAndMigrateSafeTx, setSafeTxError, safeMessage, setSafeMessage, diff --git a/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx b/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx new file mode 100644 index 0000000000..6240d7b4de --- /dev/null +++ b/src/components/tx/SignOrExecuteForm/MigrateToL2Information.tsx @@ -0,0 +1,37 @@ +import { Alert, AlertTitle, Box, SvgIcon, Typography } from '@mui/material' +import InfoOutlinedIcon from '@/public/images/notifications/info.svg' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' + +export const MigrateToL2Information = ({ + variant, + newMasterCopy, +}: { + variant: 'history' | 'queue' + newMasterCopy?: string +}) => { + return ( + + }> + + + Migration to compatible base contract + + + + {variant === 'history' + ? 'This Safe was using an incompatible base contract. This transaction includes the migration to a supported base contract.' + : 'This Safe is currently using an incompatible base contract. The transaction was automatically modified to first migrate to a supported base contract.'} + + + {newMasterCopy && ( + + + New contract + + + + )} + + + ) +} diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 03bcc49df6..333c5d07b9 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -30,13 +30,15 @@ import { trackEvent } from '@/services/analytics' import useChainId from '@/hooks/useChainId' import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' -import { isConfirmationViewOrder, isCustomTxInfo } from '@/utils/transaction-guards' +import { isConfirmationViewOrder, isCustomTxInfo, isMigrateToL2MultiSend } from '@/utils/transaction-guards' import SwapOrderConfirmationView from '@/features/swap/components/SwapOrderConfirmationView' import { isSettingTwapFallbackHandler } from '@/features/swap/helpers/utils' import { TwapFallbackHandlerWarning } from '@/features/swap/components/TwapFallbackHandlerWarning' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import TxData from '@/components/transactions/TxDetails/TxData' import { useApprovalInfos } from '../ApprovalEditor/hooks/useApprovalInfos' +import { MigrateToL2Information } from './MigrateToL2Information' +import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway' @@ -114,6 +116,8 @@ export const SignOrExecuteForm = ({ const isSafeOwner = useIsSafeOwner() const isCounterfactualSafe = !safe.deployed const isChangingFallbackHandler = isSettingTwapFallbackHandler(decodedData) + const isMultiChainMigration = isMigrateToL2MultiSend(decodedData) + const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(decodedData) // 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( @@ -153,6 +157,8 @@ export const SignOrExecuteForm = ({ {isChangingFallbackHandler && } + {isMultiChainMigration && } + {isSwapOrder && ( }> @@ -198,7 +204,7 @@ export const SignOrExecuteForm = ({ - + {!isMultiChainMigration && } diff --git a/src/config/constants.ts b/src/config/constants.ts index b8a20080f1..6c42b2266c 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,3 +1,4 @@ +import { Interface } from 'ethers' import chains from './chains' export const IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' @@ -108,5 +109,9 @@ export const REDEFINE_ARTICLE = 'https://safe.mirror.xyz/rInLWZwD_sf7enjoFerj6FI export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139addac8fb' +// TODO: Get from safe-deployments once available +export const SAFE_TO_L2_MIGRATION_ADDRESS = '0x7Baec386CAF8e02B0BB4AFc98b4F9381EEeE283C' +export const SAFE_TO_L2_INTERFACE = new Interface(['function migrateToL2(address l2Singleton)']) + export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' diff --git a/src/hooks/__tests__/useSafeNotifications.test.ts b/src/hooks/__tests__/useSafeNotifications.test.ts index e361781943..d10ba16ed5 100644 --- a/src/hooks/__tests__/useSafeNotifications.test.ts +++ b/src/hooks/__tests__/useSafeNotifications.test.ts @@ -130,6 +130,7 @@ describe('useSafeNotifications', () => { implementation: { value: '0x123' }, implementationVersionState: 'UNKNOWN', version: '1.3.0', + nonce: 1, address: { value: '0x1', }, @@ -154,6 +155,33 @@ describe('useSafeNotifications', () => { }, }) }) + it('should show a notification when the mastercopy is invalid but can be migrated', () => { + ;(useSafeInfo as jest.Mock).mockReturnValue({ + safe: { + implementation: { value: '0x123' }, + implementationVersionState: 'UNKNOWN', + version: '1.3.0', + nonce: 0, + address: { + value: '0x1', + }, + chainId: '5', + }, + }) + + // render the hook + const { result } = renderHook(() => useSafeNotifications()) + + // check that the notification was shown + expect(result.current).toBeUndefined() + expect(showNotification).toHaveBeenCalledWith({ + variant: 'info', + message: `This Safe Account was created with an unsupported base contract. + It is possible to migrate it to a compatible base contract. This migration will be automatically included with your first transaction.`, + groupKey: 'invalid-mastercopy', + link: undefined, + }) + }) it('should not show a notification when the mastercopy is valid', async () => { ;(useSafeInfo as jest.Mock).mockReturnValue({ safe: { diff --git a/src/hooks/useSafeNotifications.ts b/src/hooks/useSafeNotifications.ts index ccde77d687..0797a7a730 100644 --- a/src/hooks/useSafeNotifications.ts +++ b/src/hooks/useSafeNotifications.ts @@ -4,7 +4,7 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript import useSafeInfo from './useSafeInfo' import { useAppDispatch } from '@/store' import { AppRoutes } from '@/config/routes' -import { isValidMasterCopy } from '@/services/contracts/safeContracts' +import { isMigrationToL2Possible, isValidMasterCopy } from '@/services/contracts/safeContracts' import { useRouter } from 'next/router' import useIsSafeOwner from './useIsSafeOwner' import { isValidSafeVersion } from './coreSDK/safeCoreSDK' @@ -131,25 +131,31 @@ const useSafeNotifications = (): void => { /** * Show a notification when the Safe master copy is not supported */ - useEffect(() => { if (isValidMasterCopy(safe.implementationVersionState)) return + const isMigrationPossible = isMigrationToL2Possible(safe) + + const message = isMigrationPossible + ? `This Safe Account was created with an unsupported base contract. + It is possible to migrate it to a compatible base contract. This migration will be automatically included with your first transaction.` + : `This Safe Account was created with an unsupported base contract. + The web interface might not work correctly. + We recommend using the command line interface instead.` + const id = dispatch( showNotification({ - variant: 'warning', - message: `This Safe Account was created with an unsupported base contract. - The web interface might not work correctly. - We recommend using the command line interface instead.`, + variant: isMigrationPossible ? 'info' : 'warning', + message, groupKey: 'invalid-mastercopy', - link: CLI_LINK, + link: isMigrationPossible ? undefined : CLI_LINK, }), ) return () => { dispatch(closeNotification({ id })) } - }, [dispatch, safe.implementationVersionState]) + }, [dispatch, safe, safe.implementationVersionState]) } export default useSafeNotifications diff --git a/src/services/contracts/deployments.ts b/src/services/contracts/deployments.ts index cfec7b48bc..b4b2ee8682 100644 --- a/src/services/contracts/deployments.ts +++ b/src/services/contracts/deployments.ts @@ -3,6 +3,7 @@ import { getSafeSingletonDeployment, getSafeL2SingletonDeployment, getMultiSendCallOnlyDeployment, + getMultiSendDeployment, getFallbackHandlerDeployment, getProxyFactoryDeployment, getSignMessageLibDeployment, @@ -68,6 +69,10 @@ export const getMultiSendCallOnlyContractDeployment = (chain: ChainInfo, safeVer return _tryDeploymentVersions(getMultiSendCallOnlyDeployment, chain, safeVersion) } +export const getMultiSendContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { + return _tryDeploymentVersions(getMultiSendDeployment, chain, safeVersion) +} + export const getFallbackHandlerContractDeployment = (chain: ChainInfo, safeVersion: SafeInfo['version']) => { return _tryDeploymentVersions(getFallbackHandlerDeployment, chain, safeVersion) } diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index e9c423c4e7..91d4293386 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -23,6 +23,10 @@ export const isValidMasterCopy = (implementationVersionState: SafeInfo['implemen return implementationVersionState !== ImplementationVersionState.UNKNOWN } +export const isMigrationToL2Possible = (safe: SafeInfo): boolean => { + return safe.nonce === 0 +} + export const _getValidatedGetContractProps = ( safeVersion: SafeInfo['version'], ): Pick => { diff --git a/src/services/tx/tx-sender/create.ts b/src/services/tx/tx-sender/create.ts index 4d348a0356..7152601bde 100644 --- a/src/services/tx/tx-sender/create.ts +++ b/src/services/tx/tx-sender/create.ts @@ -25,6 +25,17 @@ export const createMultiSendCallOnlyTx = async (txParams: MetaTransactionData[]) return safeSDK.createTransaction({ transactions: txParams, onlyCalls: true }) } +/** + * Create a multiSend transaction from an array of MetaTransactionData and options + * If only one tx is passed it will be created without multiSend and without onlyCalls. + * + * This function can create delegateCalls, which is usually not necessary + */ +export const __unsafe_createMultiSendTx = async (txParams: MetaTransactionData[]): Promise => { + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createTransaction({ transactions: txParams, onlyCalls: false }) +} + export const createRemoveOwnerTx = async (txParams: RemoveOwnerTxParams): Promise => { const safeSDK = getAndValidateSafeSDK() return safeSDK.createRemoveOwnerTx(txParams) diff --git a/src/tests/builders/safeTx.ts b/src/tests/builders/safeTx.ts index dda345fbb3..1e8d4eb2ca 100644 --- a/src/tests/builders/safeTx.ts +++ b/src/tests/builders/safeTx.ts @@ -1,6 +1,6 @@ import { Builder, type IBuilder } from '@/tests/Builder' import { faker } from '@faker-js/faker' -import type { SafeSignature, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type SafeTransactionData, type SafeSignature, type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' // TODO: Convert to builder @@ -29,18 +29,7 @@ export const createSafeTx = (data = '0x'): SafeTransaction => { export function safeTxBuilder(): IBuilder { return Builder.new().with({ - data: { - to: faker.finance.ethereumAddress(), - value: '0x0', - data: faker.string.hexadecimal({ length: faker.number.int({ max: 500 }) }), - operation: 0, - nonce: faker.number.int(), - safeTxGas: faker.number.toString(), - gasPrice: faker.number.toString(), - gasToken: ZERO_ADDRESS, - baseGas: faker.number.toString(), - refundReceiver: faker.finance.ethereumAddress(), - }, + data: safeTxDataBuilder().build(), signatures: new Map([]), addSignature: function (sig: SafeSignature): void { this.signatures!.set(sig.signer, sig) @@ -55,6 +44,21 @@ export function safeTxBuilder(): IBuilder { }) } +export function safeTxDataBuilder(): IBuilder { + return Builder.new().with({ + to: faker.finance.ethereumAddress(), + value: '0x0', + data: faker.string.hexadecimal({ length: faker.number.int({ max: 500 }) }), + operation: 0, + nonce: faker.number.int(), + safeTxGas: faker.number.toString(), + gasPrice: faker.number.toString(), + gasToken: ZERO_ADDRESS, + baseGas: faker.number.toString(), + refundReceiver: faker.finance.ethereumAddress(), + }) +} + export function safeSignatureBuilder(): IBuilder { return Builder.new().with({ signer: faker.finance.ethereumAddress(), diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index 52c47123ac..913c6d530c 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -5,11 +5,32 @@ import type { SafeAppData, Transaction, } from '@safe-global/safe-gateway-typescript-sdk' -import { TransactionInfoType } from '@safe-global/safe-gateway-typescript-sdk' +import { TransactionInfoType, ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { isMultiSendTxInfo } from '../transaction-guards' -import { getQueuedTransactionCount, getTxOrigin } from '../transactions' +import { 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, +} from '@safe-global/safe-deployments' +import type Safe from '@safe-global/protocol-kit' +import { encodeMultiSendData } from '@safe-global/protocol-kit' +import { Multi_send__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 { SAFE_TO_L2_MIGRATION_ADDRESS, SAFE_TO_L2_INTERFACE } from '@/config/constants' + +jest.mock('@/services/tx/tx-sender/sdk') describe('transactions', () => { + const mockGetAndValidateSdk = getAndValidateSafeSDK as jest.MockedFunction + describe('getQueuedTransactionCount', () => { it('should return 0 if no txPage is provided', () => { expect(getQueuedTransactionCount()).toBe('0') @@ -185,4 +206,209 @@ 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 tx already migrates', () => { + const safeTx = safeTxBuilder() + .with({ + data: safeTxDataBuilder() + .with({ + nonce: 0, + to: SAFE_TO_L2_MIGRATION_ADDRESS, + data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [ + getSafeL2SingletonDeployment()?.defaultAddress, + ]), + }) + .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: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ + encodeMultiSendData([ + { + value: '0', + operation: 1, + to: SAFE_TO_L2_MIGRATION_ADDRESS, + data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [ + getSafeL2SingletonDeployment()?.defaultAddress, + ]), + }, + ]), + ]), + }) + .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) + expect(decodedMultiSend).toEqual([ + { + to: SAFE_TO_L2_MIGRATION_ADDRESS, + value: '0', + operation: 1, + data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [ + getSafeL2SingletonDeployment()?.defaultAddress, + ]), + }, + { + to: checksumAddress(safeTx.data.to), + value: safeTx.data.value, + operation: safeTx.data.operation, + data: safeTx.data.data.toLowerCase(), + }, + ]) + }) + }) }) diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index a92fe19a12..9dc1b73046 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -48,6 +48,8 @@ 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 { type TransactionData } from '@safe-global/safe-apps-sdk' +import { SAFE_TO_L2_MIGRATION_ADDRESS, SAFE_TO_L2_INTERFACE } from '@/config/constants' export const isTxQueued = (value: TransactionStatus): boolean => { return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value) @@ -76,6 +78,32 @@ export const isModuleDetailedExecutionInfo = (value?: DetailedExecutionInfo): va return value?.type === DetailedExecutionInfoType.MODULE } +export const isMigrateToL2TxData = (value: TransactionData | undefined): boolean => { + if (sameAddress(value?.to.value, SAFE_TO_L2_MIGRATION_ADDRESS)) { + const migrateToL2Selector = SAFE_TO_L2_INTERFACE.getFunction('migrateToL2')?.selector + return migrateToL2Selector && value?.hexData ? value.hexData?.startsWith(migrateToL2Selector) : false + } + return false +} + +export const isMigrateToL2MultiSend = (decodedData: DecodedDataResponse | undefined) => { + if (decodedData?.method === 'multiSend' && Array.isArray(decodedData.parameters[0].valueDecoded)) { + const innerTxs = decodedData.parameters[0].valueDecoded + const firstInnerTx = innerTxs[0] + if (firstInnerTx) { + return ( + firstInnerTx.dataDecoded?.method === 'migrateToL2' && + firstInnerTx.dataDecoded.parameters.length === 1 && + firstInnerTx.dataDecoded?.parameters?.[0]?.type === 'address' && + typeof firstInnerTx.dataDecoded?.parameters[0].value === 'string' && + sameAddress(firstInnerTx.to, SAFE_TO_L2_MIGRATION_ADDRESS) + ) + } + } + + return false +} + // TransactionInfo type guards export const isTransferTxInfo = (value: TransactionInfo): value is Transfer => { return value.type === TransactionInfoType.TRANSFER || isSwapTransferOrderTxInfo(value) @@ -111,6 +139,10 @@ export const isOrderTxInfo = (value: TransactionInfo): value is Order => { return isSwapOrderTxInfo(value) || isTwapOrderTxInfo(value) } +export const isMigrateToL2TxInfo = (value: TransactionInfo): value is Custom => { + return isCustomTxInfo(value) && sameAddress(value.to.value, SAFE_TO_L2_MIGRATION_ADDRESS) +} + export const isSwapOrderTxInfo = (value: TransactionInfo): value is SwapOrder => { return value.type === TransactionInfoType.SWAP_ORDER } diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index a3e151e739..7c1b21f0f6 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -1,5 +1,6 @@ import type { ChainInfo, + DecodedDataResponse, ExecutionInfo, MultisigExecutionDetails, MultisigExecutionInfo, @@ -22,7 +23,7 @@ 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 } from '@/services/contracts/safeContracts' +import { getReadOnlyGnosisSafeContract, isValidMasterCopy } 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' @@ -34,6 +35,13 @@ import { toBeHex, AbiCoder } from 'ethers' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' import { id } from 'ethers' 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 { SAFE_TO_L2_MIGRATION_ADDRESS, SAFE_TO_L2_INTERFACE } from '@/config/constants' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => { const getMissingSigners = ({ @@ -296,6 +304,106 @@ 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') + } + + if ( + !safeTx || + safeTx.signatures.size > 0 || + !chain.l2 || + safeTx.data.nonce > 0 || + isValidMasterCopy(safe.implementationVersionState) + ) { + // 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 + return Promise.resolve(safeTx) + } + + const safeL2Deployment = getSafeContractDeployment(chain, safe.version) + const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] + + if (!safeL2DeploymentAddress) { + throw new Error('No L2 MasterCopy found') + } + + 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[] + let firstTx: 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, SAFE_TO_L2_MIGRATION_ADDRESS)) { + // We already migrate. Nothing to do. + return Promise.resolve(safeTx) + } + + // Prepend the migration tx + const newTxs: MetaTransactionData[] = [ + { + operation: 1, // DELEGATE CALL REQUIRED + data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), + to: SAFE_TO_L2_MIGRATION_ADDRESS, + value: '0', + }, + ...internalTxs, + ] + + return __unsafe_createMultiSendTx(newTxs) +} + +export const extractMigrationL2MasterCopyAddress = ( + decodedData: DecodedDataResponse | undefined, +): string | undefined => { + if (decodedData?.method === 'multiSend' && Array.isArray(decodedData.parameters[0].valueDecoded)) { + const innerTxs = decodedData.parameters[0].valueDecoded + const firstInnerTx = innerTxs[0] + if (firstInnerTx) { + return firstInnerTx.dataDecoded?.method === 'migrateToL2' && + firstInnerTx.dataDecoded.parameters.length === 1 && + firstInnerTx.dataDecoded?.parameters?.[0]?.type === 'address' + ? firstInnerTx.dataDecoded.parameters?.[0].value.toString() + : undefined + } + } + + return undefined +} + export const getSafeTransaction = async (safeTxHash: string, chainId: string, safeAddress: string) => { const txId = `multisig_${safeAddress}_${safeTxHash}` From bb820439a63a5040d808f912d0e28a31d2a4abae Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 11 Sep 2024 16:14:11 +0200 Subject: [PATCH 05/74] [Multichain] Feat: new network select (#4124) --- .../common/ChainIndicator/index.tsx | 15 +- src/components/common/Header/index.tsx | 2 +- src/components/common/NetworkInput/index.tsx | 42 +-- .../common/NetworkSelector/index.tsx | 339 +++++++++++++++--- .../common/NetworkSelector/styles.module.css | 24 ++ .../components/CreateSafeOnNewChain/index.tsx | 123 +++++-- .../__tests__/useReplayableNetworks.test.ts | 70 +++- .../__tests__/useSafeCreationData.test.ts | 52 +-- .../multichain/hooks/useReplayableNetworks.ts | 16 +- .../multichain/hooks/useSafeCreationData.ts | 143 ++++---- 10 files changed, 623 insertions(+), 203 deletions(-) diff --git a/src/components/common/ChainIndicator/index.tsx b/src/components/common/ChainIndicator/index.tsx index 13902e92ec..26625f59cb 100644 --- a/src/components/common/ChainIndicator/index.tsx +++ b/src/components/common/ChainIndicator/index.tsx @@ -5,8 +5,9 @@ import { useAppSelector } from '@/store' import { selectChainById, selectChains } from '@/store/chainsSlice' import css from './styles.module.css' import useChainId from '@/hooks/useChainId' -import { Skeleton } from '@mui/material' +import { Skeleton, Stack, Typography } from '@mui/material' import isEmpty from 'lodash/isEmpty' +import FiatValue from '../FiatValue' type ChainIndicatorProps = { chainId?: string @@ -15,6 +16,7 @@ type ChainIndicatorProps = { showUnknown?: boolean showLogo?: boolean responsive?: boolean + fiatValue?: string } const fallbackChainConfig = { @@ -29,6 +31,7 @@ const fallbackChainConfig = { const ChainIndicator = ({ chainId, + fiatValue, className, inline = false, showUnknown = true, @@ -74,8 +77,14 @@ const ChainIndicator = ({ loading="lazy" /> )} - - {chainConfig.chainName} + + {chainConfig.chainName} + {fiatValue && ( + + + + )} + ) : null } diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index 629c62f399..0f5bdb5b02 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -111,7 +111,7 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
- +
) diff --git a/src/components/common/NetworkInput/index.tsx b/src/components/common/NetworkInput/index.tsx index 9064e460e0..529ea42c8e 100644 --- a/src/components/common/NetworkInput/index.tsx +++ b/src/components/common/NetworkInput/index.tsx @@ -1,15 +1,26 @@ import ChainIndicator from '@/components/common/ChainIndicator' import { useDarkMode } from '@/hooks/useDarkMode' import { useTheme } from '@mui/material/styles' -import { FormControl, InputLabel, ListSubheader, MenuItem, Select, Skeleton } from '@mui/material' +import { FormControl, InputLabel, ListSubheader, MenuItem, Select } from '@mui/material' import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import css from './styles.module.css' import { type ReactElement, useMemo } from 'react' -import { useCallback } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +const NetworkMenuItem = ({ chainId, chainConfigs }: { chainId: string; chainConfigs: ChainInfo[] }) => { + const chain = useMemo(() => chainConfigs.find((chain) => chain.chainId === chainId), [chainConfigs, chainId]) + + if (!chain) return null + + return ( + + + + ) +} + const NetworkInput = ({ name, required = false, @@ -24,20 +35,7 @@ const NetworkInput = ({ const [testNets, prodNets] = useMemo(() => partition(chainConfigs, (config) => config.isTestnet), [chainConfigs]) const { control } = useFormContext() || {} - const renderMenuItem = useCallback( - (chainId: string, isSelected: boolean) => { - const chain = chainConfigs.find((chain) => chain.chainId === chainId) - if (!chain) return null - return ( - - - - ) - }, - [chainConfigs], - ) - - return chainConfigs.length ? ( + return ( renderMenuItem(value, true)} + renderValue={(value) => } MenuProps={{ sx: { '& .MuiPaper-root': { @@ -68,17 +66,19 @@ const NetworkInput = ({ }, }} > - {prodNets.map((chain) => renderMenuItem(chain.chainId, false))} + {prodNets.map((chain) => ( + + ))} {testNets.length > 0 && Testnets} - {testNets.map((chain) => renderMenuItem(chain.chainId, false))} + {testNets.map((chain) => ( + + ))} )} /> - ) : ( - ) } diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 22ea173099..8b4dc7f28e 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -3,47 +3,283 @@ import { useDarkMode } from '@/hooks/useDarkMode' import { useTheme } from '@mui/material/styles' import Link from 'next/link' import type { SelectChangeEvent } from '@mui/material' -import { ListSubheader, MenuItem, Select, Skeleton } from '@mui/material' +import { + Box, + ButtonBase, + Collapse, + Divider, + ListSubheader, + MenuItem, + Select, + Skeleton, + Stack, + Typography, +} from '@mui/material' import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import useChains from '@/hooks/useChains' import { useRouter } from 'next/router' import css from './styles.module.css' import { useChainId } from '@/hooks/useChainId' -import { type ReactElement, useMemo } from 'react' +import { type ReactElement, useMemo, useState } from 'react' import { useCallback } from 'react' -import { AppRoutes } from '@/config/routes' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' -import useWallet from '@/hooks/wallets/useWallet' -import { useAppSelector } from '@/store' -import { selectChains } from '@/store/chainsSlice' -const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement => { +import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@/utils/addresses' +import uniq from 'lodash/uniq' +import useSafeOverviews from '@/components/welcome/MyAccounts/useSafeOverviews' +import { useReplayableNetworks } from '@/features/multichain/hooks/useReplayableNetworks' +import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData' +import { type SafeOverview, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import PlusIcon from '@/public/images/common/plus.svg' +import useAddressBook from '@/hooks/useAddressBook' +import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' + +const UndeployedNetworkMenuItem = ({ + chainId, + chainConfigs, + isSelected = false, + onSelect, +}: { + chainId: string + chainConfigs: ChainInfo[] + isSelected?: boolean + onSelect: (chain: ChainInfo) => void +}) => { + const chain = useMemo(() => chainConfigs.find((chain) => chain.chainId === chainId), [chainConfigs, chainId]) + + if (!chain) return null + + return ( + onSelect(chain)}> + + + + + + ) +} + +const NetworkSkeleton = () => { + return ( + + + + + ) +} + +const TestnetDivider = () => { + return ( + + + Testnets + + + ) +} + +const UndeployedNetworks = ({ + deployedChains, + chains, + safeAddress, +}: { + deployedChains: string[] + chains: ChainInfo[] + safeAddress: string +}) => { + const [open, setOpen] = useState(false) + const [replayOnChain, setReplayOnChain] = useState() + const addressBook = useAddressBook() + const safeName = addressBook[safeAddress] + const deployedChainInfos = useMemo( + () => chains.filter((chain) => deployedChains.includes(chain.chainId)), + [chains, deployedChains], + ) + const safeCreationResult = useSafeCreationData(safeAddress, deployedChainInfos) + const [safeCreationData, safeCreationDataError] = safeCreationResult + + const availableNetworks = useReplayableNetworks(safeCreationData, deployedChains) + + const [testNets, prodNets] = useMemo( + () => partition(availableNetworks, (config) => config.isTestnet), + [availableNetworks], + ) + + const onSelect = (chain: ChainInfo) => { + setReplayOnChain(chain) + } + + if (safeCreationDataError) { + return ( + + + Adding another network is not possible for this Safe + + + ) + } + + return ( + <> + + setOpen((prev) => !prev)}> + +
Show all networks
+ +
+
+
+ + {!safeCreationData ? ( + + + + + ) : ( + <> + {prodNets.map((chain) => ( + + ))} + {testNets.length > 0 && } + {testNets.map((chain) => ( + + ))} + + )} + + {replayOnChain && safeCreationData && ( + setReplayOnChain(undefined)} + currentName={safeName ?? ''} + safeCreationResult={safeCreationResult} + /> + )} + + ) +} + +const DeployedNetworkMenuItem = ({ + chainId, + chainConfigs, + isSelected = false, + onClick, + safeOverviews, + getNetworkLink, +}: { + chainId: string + chainConfigs: ChainInfo[] + isSelected?: boolean + onClick?: () => void + safeOverviews?: SafeOverview[] + getNetworkLink: (shortName: string) => { + pathname: string + query: { + safe?: string | undefined + chain?: string | undefined + safeViewRedirectURL?: string | undefined + } + } +}) => { + const chain = chainConfigs.find((chain) => chain.chainId === chainId) + const safeOverview = safeOverviews?.find((overview) => chainId === overview.chainId) + + if (!chain) return null + return ( + + + + + + ) +} + +const NetworkSelector = ({ + onChainSelect, + offerSafeCreation = false, +}: { + onChainSelect?: () => void + offerSafeCreation?: boolean +}): ReactElement => { const isDarkMode = useDarkMode() const theme = useTheme() const { configs } = useChains() const chainId = useChainId() const router = useRouter() - const isWalletConnected = !!useWallet() - const [testNets, prodNets] = useMemo(() => partition(configs, (config) => config.isTestnet), [configs]) - const chains = useAppSelector(selectChains) + const safeAddress = useSafeAddress() + + const isSafeOpened = safeAddress !== '' + + const safesGrouped = useAllSafesGrouped() + const availableChainIds = useMemo(() => { + if (!isSafeOpened) { + // Offer all chains + return configs.map((config) => config.chainId) + } + return uniq([ + chainId, + ...(safesGrouped.allMultiChainSafes + ?.find((item) => sameAddress(item.address, safeAddress)) + ?.safes.map((safe) => safe.chainId) ?? []), + ]) + }, [chainId, configs, isSafeOpened, safeAddress, safesGrouped.allMultiChainSafes]) + + const [testNets, prodNets] = useMemo( + () => + partition( + configs.filter((config) => availableChainIds.includes(config.chainId)), + (config) => config.isTestnet, + ), + [availableChainIds, configs], + ) + + const multiChainSafes = useMemo( + () => availableChainIds.map((chain) => ({ address: safeAddress, chainId: chain })), + [availableChainIds, safeAddress], + ) + const [safeOverviews] = useSafeOverviews(multiChainSafes) const getNetworkLink = useCallback( (shortName: string) => { - const shouldKeepPath = !router.query.safe - + const query = ( + isSafeOpened + ? { + safe: `${shortName}:${safeAddress}`, + } + : { chain: shortName } + ) as { + safe?: string + chain?: string + safeViewRedirectURL?: string + } const route = { - pathname: shouldKeepPath - ? router.pathname - : isWalletConnected - ? AppRoutes.welcome.accounts - : AppRoutes.welcome.index, - query: { - chain: shortName, - } as { - chain: string - safeViewRedirectURL?: string - }, + pathname: router.pathname, + query, } if (router.query?.safeViewRedirectURL) { @@ -52,7 +288,7 @@ const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement => return route }, - [router, isWalletConnected], + [isSafeOpened, router.pathname, router.query?.safeViewRedirectURL, safeAddress], ) const onChange = (event: SelectChangeEvent) => { @@ -67,21 +303,6 @@ const NetworkSelector = (props: { onChainSelect?: () => void }): ReactElement => } } - const renderMenuItem = useCallback( - (chainId: string, isSelected: boolean) => { - const chain = chains.data.find((chain) => chain.chainId === chainId) - if (!chain) return null - return ( - - - - - - ) - }, - [chains.data, getNetworkLink, props.onChainSelect], - ) - return configs.length ? ( ) : ( diff --git a/src/components/common/NetworkSelector/styles.module.css b/src/components/common/NetworkSelector/styles.module.css index 1703446ac1..b9c6575a00 100644 --- a/src/components/common/NetworkSelector/styles.module.css +++ b/src/components/common/NetworkSelector/styles.module.css @@ -37,6 +37,29 @@ font-size: 11px; font-weight: bold; line-height: 32px; + background-color: var(--color-background-main); + text-align: center; + letter-spacing: 1px; +} + +[data-theme='dark'] .undeployedNetworksHeader { + background-color: var(--color-secondary-background); +} + +.undeployedNetworksHeader { + background-color: var(--color-background-main); + text-align: center; + line-height: 32px; +} + +.plusIcon { + background-color: var(--color-background-main); + color: var(--color-border-main); + border-radius: 100%; + height: 20px; + width: 20px; + padding: 4px; + margin-left: auto; } .newChip { @@ -51,4 +74,5 @@ display: flex; align-items: center; gap: var(--space-1); + width: 100%; } diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 7a8b737713..e95e3c0c1e 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -1,11 +1,19 @@ import NameInput from '@/components/common/NameInput' import NetworkInput from '@/components/common/NetworkInput' import ErrorMessage from '@/components/tx/ErrorMessage' -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, Typography } from '@mui/material' +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' import { useSafeCreationData } from '../../hooks/useSafeCreationData' import { useReplayableNetworks } from '../../hooks/useReplayableNetworks' -import { useMemo } from 'react' import { replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' import useChains from '@/hooks/useChains' @@ -14,45 +22,55 @@ import { selectRpc } from '@/store/settingsSlice' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' import { sameAddress } from '@/utils/addresses' +import { useRouter } from 'next/router' +import ChainIndicator from '@/components/common/ChainIndicator' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { useMemo, useState } from 'react' type CreateSafeOnNewChainForm = { name: string chainId: string } -export const CreateSafeOnNewChain = ({ - safeAddress, - deployedChainIds, - currentName, - open, - onClose, -}: { +type ReplaySafeDialogProps = { safeAddress: string - deployedChainIds: string[] + safeCreationResult: ReturnType + replayableChains?: ReturnType + chain?: ChainInfo currentName: string | undefined open: boolean onClose: () => void -}) => { +} + +const ReplaySafeDialog = ({ + safeAddress, + chain, + currentName, + open, + onClose, + safeCreationResult, + replayableChains, +}: ReplaySafeDialogProps) => { const formMethods = useForm({ mode: 'all', defaultValues: { name: currentName, + chainId: chain?.chainId, }, }) const { handleSubmit } = formMethods - const { configs } = useChains() - - const chain = configs.find((config) => config.chainId === deployedChainIds[0]) + const router = useRouter() const customRpc = useAppSelector(selectRpc) const dispatch = useAppDispatch() + const [creationError, setCreationError] = useState() // Load some data - const [safeCreationData, safeCreationDataError] = useSafeCreationData(safeAddress, chain) + const [safeCreationData, safeCreationDataError, safeCreationDataLoading] = safeCreationResult const onFormSubmit = handleSubmit(async (data) => { - const selectedChain = configs.find((config) => config.chainId === data.chainId) + const selectedChain = chain ?? replayableChains?.find((config) => config.chainId === data.chainId) if (!safeCreationData || !safeCreationData.setupData || !selectedChain || !safeCreationData.masterCopy) { return } @@ -67,24 +85,24 @@ export const CreateSafeOnNewChain = ({ // 1. Double check that the creation Data will lead to the correct address const predictedAddress = await predictAddressBasedOnReplayData(safeCreationData, provider) if (!sameAddress(safeAddress, predictedAddress)) { - throw new Error('The replayed Safe leads to an unexpected address') + setCreationError(new Error('The replayed Safe leads to an unexpected address')) + return } // 2. Replay Safe creation and add it to the counterfactual Safes replayCounterfactualSafeDeployment(selectedChain.chainId, safeAddress, safeCreationData, data.name, dispatch) + router.push({ + query: { + safe: `${selectedChain.shortName}:${safeAddress}`, + }, + }) + // Close modal onClose() }) - const replayableChains = useReplayableNetworks(safeCreationData) - - const newReplayableChains = useMemo( - () => replayableChains.filter((chain) => !deployedChainIds.includes(chain.chainId)), - [deployedChainIds, replayableChains], - ) - - const submitDisabled = !!safeCreationDataError + const submitDisabled = !!safeCreationDataError || safeCreationDataLoading || !formMethods.formState.isValid return ( @@ -104,9 +122,28 @@ export const CreateSafeOnNewChain = ({ the Safe's version will not be reflected in the copy. - - - + {safeCreationDataLoading ? ( + + + Loading Safe data + + ) : ( + <> + + + {chain ? ( + + ) : ( + + )} + + )} + + {creationError && ( + + The Safe could not be created with the same address. + + )} )} @@ -123,3 +160,33 @@ export const CreateSafeOnNewChain = ({ ) } + +export const CreateSafeOnNewChain = ({ + safeAddress, + deployedChainIds, + ...props +}: Omit & { + deployedChainIds: string[] +}) => { + const { configs } = useChains() + const deployedChains = useMemo( + () => configs.filter((config) => config.chainId === deployedChainIds[0]), + [configs, deployedChainIds], + ) + + const safeCreationResult = useSafeCreationData(safeAddress, deployedChains) + const replayableChains = useReplayableNetworks(safeCreationResult[0], deployedChainIds) + + return ( + + ) +} + +export const CreateSafeOnSpecificChain = ({ ...props }: Omit) => { + return +} diff --git a/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts b/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts index fc9f155530..f75acb3f4a 100644 --- a/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts +++ b/src/features/multichain/hooks/__tests__/useReplayableNetworks.test.ts @@ -39,7 +39,7 @@ describe('useReplayableNetworks', () => { }) }) it('should return empty list without any creation data', () => { - const { result } = renderHook(() => useReplayableNetworks(undefined)) + const { result } = renderHook(() => useReplayableNetworks(undefined, [])) expect(result.current).toHaveLength(0) }) @@ -72,7 +72,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(0) }) @@ -105,7 +105,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(0) }) @@ -138,7 +138,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(0) }) @@ -172,7 +172,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(4) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) } @@ -184,7 +184,55 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) + expect(result.current).toHaveLength(4) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) + } + }) + + it('should remove already deployed chains from result', () => { + const callData = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + fallbackHandler: faker.finance.ethereumAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + } + + const setupData = safeInterface.encodeFunctionData('setup', [ + callData.owners, + callData.threshold, + callData.to, + callData.data, + callData.fallbackHandler, + callData.paymentToken, + callData.payment, + callData.paymentReceiver, + ]) + + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, + masterCopy: L1_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData, ['10', '100'])) + expect(result.current).toHaveLength(2) + expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '480']) + } + + { + const creationData: ReplayedSafeProps = { + factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, + masterCopy: L2_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, + saltNonce: '0', + setupData, + } + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(4) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) } @@ -221,7 +269,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(4) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) } @@ -234,7 +282,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(4) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100', '480']) } @@ -247,7 +295,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(3) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100']) } @@ -260,7 +308,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(3) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '10', '100']) } @@ -295,7 +343,7 @@ describe('useReplayableNetworks', () => { saltNonce: '0', setupData, } - const { result } = renderHook(() => useReplayableNetworks(creationData)) + const { result } = renderHook(() => useReplayableNetworks(creationData, [])) expect(result.current).toHaveLength(2) expect(result.current.map((chain) => chain.chainId)).toEqual(['1', '100']) }) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index 52255df5e3..e0f4ab2489 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -17,6 +17,7 @@ import { import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { type JsonRpcProvider } from 'ethers' import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' describe('useSafeCreationData', () => { beforeAll(() => { @@ -28,7 +29,8 @@ describe('useSafeCreationData', () => { }) it('should return undefined without chain info', async () => { const safeAddress = faker.finance.ethereumAddress() - const { result } = renderHook(() => useSafeCreationData(safeAddress, undefined)) + const chainInfos: ChainInfo[] = [] + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(async () => { await Promise.resolve() expect(result.current).toEqual([undefined, undefined, false]) @@ -37,7 +39,7 @@ describe('useSafeCreationData', () => { it('should return the replayedSafe when copying one', async () => { const safeAddress = faker.finance.ethereumAddress() - const chainIndo = chainBuilder().with({ chainId: '1' }).build() + const chainInfos = [chainBuilder().with({ chainId: '1' }).build()] const undeployedSafe: UndeployedSafe = { props: { factoryAddress: faker.finance.ethereumAddress(), @@ -51,7 +53,7 @@ describe('useSafeCreationData', () => { }, } - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainIndo), { + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { initialReduxState: { undeployedSafes: { '1': { @@ -68,7 +70,7 @@ describe('useSafeCreationData', () => { it('should extract replayedSafe data from an predictedSafe', async () => { const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] const undeployedSafe = { props: { safeAccountConfig: { @@ -86,7 +88,7 @@ describe('useSafeCreationData', () => { }, } - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo), { + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { initialReduxState: { undeployedSafes: { '1': { @@ -122,7 +124,7 @@ describe('useSafeCreationData', () => { it('should extract replayedSafe data from an predictedSafe which has a custom Setup', async () => { const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] const undeployedSafe = { props: { safeAccountConfig: { @@ -166,7 +168,7 @@ describe('useSafeCreationData', () => { } // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo), { + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { initialReduxState: { undeployedSafes: { '1': { @@ -190,10 +192,10 @@ describe('useSafeCreationData', () => { } as any) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) @@ -214,10 +216,10 @@ describe('useSafeCreationData', () => { }) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) @@ -251,10 +253,10 @@ describe('useSafeCreationData', () => { jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue(undefined) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER), false]) @@ -293,10 +295,10 @@ describe('useSafeCreationData', () => { } as JsonRpcProvider) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND), false]) @@ -347,10 +349,10 @@ describe('useSafeCreationData', () => { } as JsonRpcProvider) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) @@ -412,10 +414,10 @@ describe('useSafeCreationData', () => { } as JsonRpcProvider) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) @@ -466,10 +468,10 @@ describe('useSafeCreationData', () => { } as JsonRpcProvider) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION), false]) @@ -520,10 +522,10 @@ describe('useSafeCreationData', () => { } as JsonRpcProvider) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([ @@ -598,10 +600,10 @@ describe('useSafeCreationData', () => { } as JsonRpcProvider) const safeAddress = faker.finance.ethereumAddress() - const chainInfo = chainBuilder().with({ chainId: '1', l2: false }).build() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfo)) + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) await waitFor(() => { expect(result.current).toEqual([ diff --git a/src/features/multichain/hooks/useReplayableNetworks.ts b/src/features/multichain/hooks/useReplayableNetworks.ts index 0872f3b784..d184f8a788 100644 --- a/src/features/multichain/hooks/useReplayableNetworks.ts +++ b/src/features/multichain/hooks/useReplayableNetworks.ts @@ -26,7 +26,7 @@ const hasDeployment = (chainId: string, contractAddress: string, deployments: Si * Therefore the creation's masterCopy and factory need to be deployed to that network. * @param creation */ -export const useReplayableNetworks = (creation: ReplayedSafeProps | undefined) => { +export const useReplayableNetworks = (creation: ReplayedSafeProps | undefined, deployedChainIds: string[]) => { const { configs } = useChains() if (!creation) { @@ -51,10 +51,12 @@ export const useReplayableNetworks = (creation: ReplayedSafeProps | undefined) = getProxyFactoryDeployments({ version }), ).filter(Boolean) as SingletonDeploymentV2[] - return configs.filter( - (config) => - (hasDeployment(config.chainId, masterCopy, allL1SingletonDeployments) || - hasDeployment(config.chainId, masterCopy, allL2SingletonDeployments)) && - hasDeployment(config.chainId, factoryAddress, allProxyFactoryDeployments), - ) + return configs + .filter((config) => !deployedChainIds.includes(config.chainId)) + .filter( + (config) => + (hasDeployment(config.chainId, masterCopy, allL1SingletonDeployments) || + hasDeployment(config.chainId, masterCopy, allL2SingletonDeployments)) && + hasDeployment(config.chainId, factoryAddress, allProxyFactoryDeployments), + ) } diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index 967109bdfe..b3286baf2e 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -1,6 +1,6 @@ import useAsync, { type AsyncResult } from '@/hooks/useAsync' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' -import { type UndeployedSafe, selectRpc, selectUndeployedSafe, type ReplayedSafeProps } from '@/store/slices' +import { type UndeployedSafe, selectRpc, type ReplayedSafeProps, selectUndeployedSafes } from '@/store/slices' import { Safe_proxy_factory__factory } from '@/types/contracts' import { sameAddress } from '@/utils/addresses' import { getCreationTransaction } from 'safe-client-gateway-sdk' @@ -16,6 +16,7 @@ import { getLatestSafeVersion } from '@/utils/chains' import { ZERO_ADDRESS, EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' +import { asError } from '@/services/exceptions/utils' const getUndeployedSafeCreationData = async ( undeployedSafe: UndeployedSafe, @@ -75,86 +76,100 @@ export const SAFE_CREATION_DATA_ERRORS = { UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported yet.', NO_PROVIDER: 'The RPC provider for the origin network is not available.', } -/** - * Fetches the data with which the given Safe was originally created. - * Useful to replay a Safe creation. - */ -export const useSafeCreationData = ( + +const getCreationDataForChain = async ( + chain: ChainInfo, + undeployedSafe: UndeployedSafe, safeAddress: string, - chain: ChainInfo | undefined, -): AsyncResult => { - const customRpc = useAppSelector(selectRpc) + customRpc: { [chainId: string]: string }, +): Promise => { + // 1. The safe is counterfactual + if (undeployedSafe) { + return getUndeployedSafeCreationData(undeployedSafe, chain) + } - const undeployedSafe = useAppSelector((selector) => - selectUndeployedSafe(selector, chain?.chainId ?? '1', safeAddress), - ) + const { data: creation } = await getCreationTransaction({ + path: { + chainId: chain.chainId, + safeAddress, + }, + }) - return useAsync(async () => { - try { - if (!chain) { - return undefined - } + if (!creation || !creation.masterCopy || !creation.setupData) { + throw new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA) + } - // 1. The safe is counterfactual - if (undeployedSafe) { - return getUndeployedSafeCreationData(undeployedSafe, chain) - } + // We need to create a readOnly provider of the deployed chain + const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined + const provider = createWeb3ReadOnly(chain, customRpcUrl) - const { data: creation } = await getCreationTransaction({ - path: { - chainId: chain.chainId, - safeAddress, - }, - }) + if (!provider) { + throw new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER) + } - if (!creation || !creation.masterCopy || !creation.setupData) { - throw new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA) - } + // Fetch saltNonce by fetching the transaction from the RPC. + const tx = await provider.getTransaction(creation.transactionHash) + if (!tx) { + throw new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND) + } + const txData = tx.data + const startOfTx = txData.indexOf(createProxySelector.slice(2, 10)) + if (startOfTx === -1) { + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + } - // We need to create a readOnly provider of the deployed chain - const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined - const provider = createWeb3ReadOnly(chain, customRpcUrl) + // decode tx - if (!provider) { - throw new Error(SAFE_CREATION_DATA_ERRORS.NO_PROVIDER) - } + const [masterCopy, initializer, saltNonce] = proxyFactoryInterface.decodeFunctionData( + 'createProxyWithNonce', + `0x${txData.slice(startOfTx)}`, + ) - // Fetch saltNonce by fetching the transaction from the RPC. - const tx = await provider.getTransaction(creation.transactionHash) - if (!tx) { - throw new Error(SAFE_CREATION_DATA_ERRORS.TX_NOT_FOUND) - } - const txData = tx.data - const startOfTx = txData.indexOf(createProxySelector.slice(2, 10)) - if (startOfTx === -1) { - throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) - } + const txMatches = + sameAddress(masterCopy, creation.masterCopy) && + (initializer as string)?.toLowerCase().includes(creation.setupData?.toLowerCase()) + + if (!txMatches) { + // We found the wrong tx. This tx seems to deploy multiple Safes at once. This is not supported yet. + throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + } - // decode tx + return { + factoryAddress: creation.factoryAddress, + masterCopy: creation.masterCopy, + setupData: creation.setupData, + saltNonce: saltNonce.toString(), + } +} - const [masterCopy, initializer, saltNonce] = proxyFactoryInterface.decodeFunctionData( - 'createProxyWithNonce', - `0x${txData.slice(startOfTx)}`, - ) +/** + * Fetches the data with which the given Safe was originally created. + * Useful to replay a Safe creation. + */ +export const useSafeCreationData = (safeAddress: string, chains: ChainInfo[]): AsyncResult => { + const customRpc = useAppSelector(selectRpc) - const txMatches = - sameAddress(masterCopy, creation.masterCopy) && - (initializer as string)?.toLowerCase().includes(creation.setupData?.toLowerCase()) + const undeployedSafes = useAppSelector(selectUndeployedSafes) - if (!txMatches) { - // We found the wrong tx. This tx seems to deploy multiple Safes at once. This is not supported yet. - throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) + return useAsync(async () => { + let lastError: Error | undefined = undefined + try { + for (const chain of chains) { + const undeployedSafe = undeployedSafes[chain.chainId]?.[safeAddress] + try { + const creationData = await getCreationDataForChain(chain, undeployedSafe, safeAddress, customRpc) + return creationData + } catch (err) { + lastError = asError(err) + } } - - return { - factoryAddress: creation.factoryAddress, - masterCopy: creation.masterCopy, - setupData: creation.setupData, - saltNonce: saltNonce.toString(), + if (lastError) { + // We want to know why the creation was not possible by throwing one of the errors + throw lastError } } catch (err) { logError(ErrorCodes._816, err) throw err } - }, [chain, customRpc, safeAddress, undeployedSafe]) + }, [chains, customRpc, safeAddress, undeployedSafes]) } From ee2257e00eea309f32732945ea3e332e43932bac Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 12 Sep 2024 16:29:57 +0200 Subject: [PATCH 06/74] [Multichain] feat: SetupToL2 during Safe creation [SW-95] (#4075) * feat: SetupToL2 during Safe creation * feat: use setupToL2 when relaying safe creation * Feat: add network selector to safe setup * feat: deploy CF safes on selected networks * feat: require at least on network to be selected * fix: creation overview network styles * Fix: only show wrong chain warning when relevant * fix: unit tests for ReviewStep and relay creation * fix: load safe cypress tests * remove unecessary check in safe creation * feat: simplify mnemonic safe name * fix: network multiselector chip styles * fix: update safe name unit test * refactor: change multiselector to uncontrolled component with RHF * fix: remove references to pay later option when it is not available * feat: exclude xksync from multichain and only offer chains of the same version * fix: dont allow older safes to be deployed as multichain group * only allow selecting a single network in the advanced safe creation flow * fix: check across chains for the next available saltNonce * fix: wrong chain warning on activation flow * allow CF deployment for m/n safes * feat: show success modal on multichain safe creation * fix: only allow networks with canonical 1.4.1 deployments to be multichain safes * fix: dismiss CF creation modal in cypress tests * feat: modify fee info text for multi chain safe creation * fix: remove unecessary promise.all and account for undefined numberOfOwners in setup hints * fix: remove unused code and use router.replace for network multi selector * refactor: move setup contract address to constants.ts * fix: check chains are compatible with first selected network and wrap multiselector functions in useCallbacl * fix: remove commented code --------- Co-authored-by: James Mealy --- cypress/e2e/pages/create_wallet.pages.js | 7 + cypress/e2e/smoke/create_safe_cf.cy.js | 1 + .../common/ChainIndicator/index.tsx | 21 ++- .../common/ChainIndicator/styles.module.css | 4 + .../NetworkSelector/NetworkMultiSelector.tsx | 139 ++++++++++++++++ .../common/NetworkSelector/index.tsx | 75 ++++----- .../common/NetworkSelector/styles.module.css | 5 + .../NetworkSelector/useChangeNetworkLink.ts | 27 +++ .../new-safe/create/AdvancedCreateSafe.tsx | 14 +- .../new-safe/create/OverviewWidget/index.tsx | 16 +- .../__tests__/useSyncSafeCreationStep.test.ts | 10 +- src/components/new-safe/create/index.tsx | 21 ++- .../new-safe/create/logic/index.test.ts | 11 +- src/components/new-safe/create/logic/index.ts | 45 +++-- .../new-safe/create/logic/utils.test.ts | 13 +- src/components/new-safe/create/logic/utils.ts | 43 ++++- .../steps/AdvancedOptionsStep/index.tsx | 2 +- .../create/steps/OwnerPolicyStep/index.tsx | 9 +- .../OwnerPolicyStep/useSafeSetupHints.ts | 23 ++- .../create/steps/ReviewStep/index.test.tsx | 43 +++++ .../create/steps/ReviewStep/index.tsx | 154 ++++++++++++++---- .../create/steps/SetNameStep/index.tsx | 72 +++++--- .../create/steps/StatusStep/StatusMessage.tsx | 5 + .../create/steps/StatusStep/index.tsx | 1 + .../create/useEstimateSafeCreationGas.ts | 4 +- .../create/useSyncSafeCreationStep.ts | 13 +- src/config/constants.ts | 2 + .../counterfactual/ActivateAccountFlow.tsx | 58 +++++-- .../counterfactual/CounterfactualHint.tsx | 15 -- .../CounterfactualSuccessScreen.tsx | 45 ++++- .../counterfactual/PayNowPayLater.tsx | 110 +++++++------ .../hooks/usePendingSafeStatuses.ts | 1 + .../services/safeCreationEvents.ts | 7 + src/features/counterfactual/utils.ts | 6 - .../components/NetworkLogosList/index.tsx | 16 ++ .../NetworkLogosList/styles.module.css | 12 ++ src/hooks/useMnemonicName/index.ts | 16 +- .../useMnemonicName/useMnemonicName.test.ts | 36 ++-- src/services/contracts/safeContracts.ts | 8 +- src/utils/wallets.ts | 9 +- 40 files changed, 799 insertions(+), 320 deletions(-) create mode 100644 src/components/common/NetworkSelector/NetworkMultiSelector.tsx create mode 100644 src/components/common/NetworkSelector/useChangeNetworkLink.ts delete mode 100644 src/features/counterfactual/CounterfactualHint.tsx create mode 100644 src/features/multichain/components/NetworkLogosList/index.tsx create mode 100644 src/features/multichain/components/NetworkLogosList/styles.module.css diff --git a/cypress/e2e/pages/create_wallet.pages.js b/cypress/e2e/pages/create_wallet.pages.js index ce62d6a7a4..2ad1b35474 100644 --- a/cypress/e2e/pages/create_wallet.pages.js +++ b/cypress/e2e/pages/create_wallet.pages.js @@ -17,6 +17,7 @@ const googleSignedinBtn = '[data-testid="signed-in-account-btn"]' export const accountInfoHeader = '[data-testid="open-account-center"]' export const reviewStepOwnerInfo = '[data-testid="review-step-owner-info"]' const reviewStepNextBtn = '[data-testid="review-step-next-btn"]' +const creationModalLetsGoBtn = '[data-testid="cf-creation-lets-go-btn"]' const safeCreationStatusInfo = '[data-testid="safe-status-info"]' const startUsingSafeBtn = '[data-testid="start-using-safe-btn"]' const sponsorIcon = '[data-testid="sponsor-icon"]' @@ -122,6 +123,12 @@ export function clickOnReviewStepNextBtn() { cy.get(reviewStepNextBtn).click() cy.get(reviewStepNextBtn, { timeout: 60000 }).should('not.exist') } + +export function clickOnLetsGoBtn() { + cy.get(creationModalLetsGoBtn).click() + cy.get(creationModalLetsGoBtn, { timeout: 60000 }).should('not.exist') +} + export function verifyOwnerInfoIsPresent() { return cy.get(reviewStepOwnerInfo).shoul('exist') } diff --git a/cypress/e2e/smoke/create_safe_cf.cy.js b/cypress/e2e/smoke/create_safe_cf.cy.js index d7fc4cafa4..fffebd1d26 100644 --- a/cypress/e2e/smoke/create_safe_cf.cy.js +++ b/cypress/e2e/smoke/create_safe_cf.cy.js @@ -23,6 +23,7 @@ describe('[SMOKE] CF Safe creation tests', () => { createwallet.clickOnNextBtn() createwallet.selectPayLaterOption() createwallet.clickOnReviewStepNextBtn() + createwallet.clickOnLetsGoBtn() createwallet.verifyCFSafeCreated() }) }) diff --git a/src/components/common/ChainIndicator/index.tsx b/src/components/common/ChainIndicator/index.tsx index 26625f59cb..57c9fa0a94 100644 --- a/src/components/common/ChainIndicator/index.tsx +++ b/src/components/common/ChainIndicator/index.tsx @@ -15,6 +15,7 @@ type ChainIndicatorProps = { className?: string showUnknown?: boolean showLogo?: boolean + onlyLogo?: boolean responsive?: boolean fiatValue?: string } @@ -37,6 +38,7 @@ const ChainIndicator = ({ showUnknown = true, showLogo = true, responsive = false, + onlyLogo = false, }: ChainIndicatorProps): ReactElement | null => { const currentChainId = useChainId() const id = chainId || currentChainId @@ -66,6 +68,7 @@ const ChainIndicator = ({ [css.indicator]: !inline, [css.withLogo]: showLogo, [css.responsive]: responsive, + [css.onlyLogo]: onlyLogo, })} > {showLogo && ( @@ -77,14 +80,16 @@ const ChainIndicator = ({ loading="lazy" /> )} - - {chainConfig.chainName} - {fiatValue && ( - - - - )} - + {!onlyLogo && ( + + {chainConfig.chainName} + {fiatValue && ( + + + + )} + + )} ) : null } diff --git a/src/components/common/ChainIndicator/styles.module.css b/src/components/common/ChainIndicator/styles.module.css index e1ed054b6c..bf05c7ff13 100644 --- a/src/components/common/ChainIndicator/styles.module.css +++ b/src/components/common/ChainIndicator/styles.module.css @@ -26,6 +26,10 @@ justify-content: flex-start; } +.onlyLogo { + min-width: 0; +} + @media (max-width: 899.95px) { .indicator { min-width: 35px; diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx new file mode 100644 index 0000000000..a54ecabf24 --- /dev/null +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -0,0 +1,139 @@ +import useChains from '@/hooks/useChains' +import { useCallback, type ReactElement } from 'react' +import { Checkbox, Autocomplete, TextField, Chip } from '@mui/material' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import ChainIndicator from '../ChainIndicator' +import css from './styles.module.css' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +import { useRouter } from 'next/router' +import { getNetworkLink } from '.' +import useWallet from '@/hooks/wallets/useWallet' +import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameStep' +import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import { getLatestSafeVersion } from '@/utils/chains' + +const NetworkMultiSelector = ({ + name, + isAdvancedFlow = false, +}: { + name: string + isAdvancedFlow?: boolean +}): ReactElement => { + const { configs } = useChains() + const router = useRouter() + const isWalletConnected = !!useWallet() + + const { + formState: { errors }, + control, + getValues, + setValue, + } = useFormContext() + + const selectedNetworks: ChainInfo[] = useWatch({ control, name: SetNameStepFields.networks }) + + const updateSelectedNetwork = useCallback( + (chains: ChainInfo[]) => { + if (chains.length !== 1) return + const shortName = chains[0].shortName + const networkLink = getNetworkLink(router, shortName, isWalletConnected) + router.replace(networkLink) + }, + [isWalletConnected, router], + ) + + const handleDelete = useCallback( + (deletedChainId: string) => { + const currentValues: ChainInfo[] = getValues(name) || [] + const updatedValues = currentValues.filter((chain) => chain.chainId !== deletedChainId) + updateSelectedNetwork(updatedValues) + setValue(name, updatedValues) + }, + [getValues, name, setValue, updateSelectedNetwork], + ) + + const isOptionDisabled = useCallback( + (optionNetwork: ChainInfo) => { + if (selectedNetworks.length === 0) return false + const firstSelectedNetwork = selectedNetworks[0] + + // do not allow multi chain safes for advanced setup flow. + if (isAdvancedFlow) return optionNetwork.chainId != firstSelectedNetwork.chainId + + const optionHasCanonicalSingletonDeployment = Boolean( + getSafeSingletonDeployment({ + network: optionNetwork.chainId, + version: getLatestSafeVersion(firstSelectedNetwork), + })?.deployments.canonical, + ) + const selectedHasCanonicalSingletonDeployment = Boolean( + getSafeSingletonDeployment({ + network: firstSelectedNetwork.chainId, + version: getLatestSafeVersion(firstSelectedNetwork), + })?.deployments.canonical, + ) + + // Only 1.4.1 safes with canonical deployment addresses can be deployed as part of a multichain group + if (!selectedHasCanonicalSingletonDeployment) return firstSelectedNetwork.chainId !== optionNetwork.chainId + return !optionHasCanonicalSingletonDeployment + }, + [isAdvancedFlow, selectedNetworks], + ) + + return ( + <> + ( + + selectedOptions.map((chain) => ( + } + label={chain.chainName} + onDelete={() => handleDelete(chain.chainId)} + className={css.multiChainChip} + > + )) + } + renderOption={(props, chain, { selected }) => ( +
  • + + +
  • + )} + getOptionLabel={(option) => option.chainName} + getOptionDisabled={isOptionDisabled} + renderInput={(params) => ( + + )} + filterOptions={(options, { inputValue }) => + options.filter((option) => option.chainName.toLowerCase().includes(inputValue.toLowerCase())) + } + isOptionEqualToValue={(option, value) => option.chainId === value.chainId} + onChange={(_, data) => { + updateSelectedNetwork(data) + return field.onChange(data) + }} + /> + )} + rules={{ required: true }} + /> + + ) +} + +export default NetworkMultiSelector diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 8b4dc7f28e..01599e7152 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -18,11 +18,11 @@ import { import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import useChains from '@/hooks/useChains' +import type { NextRouter } from 'next/router' import { useRouter } from 'next/router' import css from './styles.module.css' import { useChainId } from '@/hooks/useChainId' import { type ReactElement, useMemo, useState } from 'react' -import { useCallback } from 'react' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' @@ -36,6 +36,32 @@ import { type SafeOverview, type ChainInfo } from '@safe-global/safe-gateway-typ import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' +import { AppRoutes } from '@/config/routes' +import useWallet from '@/hooks/wallets/useWallet' + +export const getNetworkLink = (router: NextRouter, networkShortName: string, isWalletConnected: boolean) => { + const shouldKeepPath = !router.query.safe + + const route = { + pathname: shouldKeepPath + ? router.pathname + : isWalletConnected + ? AppRoutes.welcome.accounts + : AppRoutes.welcome.index, + query: { + chain: networkShortName, + } as { + chain: string + safeViewRedirectURL?: string + }, + } + + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } + + return route +} const UndeployedNetworkMenuItem = ({ chainId, @@ -185,29 +211,22 @@ const DeployedNetworkMenuItem = ({ isSelected = false, onClick, safeOverviews, - getNetworkLink, }: { chainId: string chainConfigs: ChainInfo[] isSelected?: boolean onClick?: () => void safeOverviews?: SafeOverview[] - getNetworkLink: (shortName: string) => { - pathname: string - query: { - safe?: string | undefined - chain?: string | undefined - safeViewRedirectURL?: string | undefined - } - } }) => { const chain = chainConfigs.find((chain) => chain.chainId === chainId) const safeOverview = safeOverviews?.find((overview) => chainId === overview.chainId) + const isWalletConnected = !!useWallet() + const router = useRouter() if (!chain) return null return ( - + { - const query = ( - isSafeOpened - ? { - safe: `${shortName}:${safeAddress}`, - } - : { chain: shortName } - ) as { - safe?: string - chain?: string - safeViewRedirectURL?: string - } - const route = { - pathname: router.pathname, - query, - } - - if (router.query?.safeViewRedirectURL) { - route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() - } - - return route - }, - [isSafeOpened, router.pathname, router.query?.safeViewRedirectURL, safeAddress], - ) - const onChange = (event: SelectChangeEvent) => { event.preventDefault() // Prevent the link click @@ -299,7 +292,8 @@ const NetworkSelector = ({ if (shortName) { trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: newChainId }) - router.push(getNetworkLink(shortName)) + const networkLink = getNetworkLink(router, shortName, isWalletConnected) + router.push(networkLink) } } @@ -315,7 +309,6 @@ const NetworkSelector = ({ @@ -361,7 +353,6 @@ const NetworkSelector = ({ key={chain.chainId} chainConfigs={configs} chainId={chain.chainId} - getNetworkLink={getNetworkLink} onClick={onChainSelect} safeOverviews={safeOverviews} /> diff --git a/src/components/common/NetworkSelector/styles.module.css b/src/components/common/NetworkSelector/styles.module.css index b9c6575a00..e4429bdf3d 100644 --- a/src/components/common/NetworkSelector/styles.module.css +++ b/src/components/common/NetworkSelector/styles.module.css @@ -76,3 +76,8 @@ gap: var(--space-1); width: 100%; } + +.multiChainChip { + padding: var(--space-2) 0; + margin: 2px; +} diff --git a/src/components/common/NetworkSelector/useChangeNetworkLink.ts b/src/components/common/NetworkSelector/useChangeNetworkLink.ts new file mode 100644 index 0000000000..be1a7368ed --- /dev/null +++ b/src/components/common/NetworkSelector/useChangeNetworkLink.ts @@ -0,0 +1,27 @@ +import { AppRoutes } from '@/config/routes' +import useWallet from '@/hooks/wallets/useWallet' +import { useRouter } from 'next/router' + +export const useChangeNetworkLink = (networkShortName: string) => { + const router = useRouter() + const isWalletConnected = !!useWallet() + const pathname = router.pathname + + const shouldKeepPath = !router.query.safe + + const route = { + pathname: shouldKeepPath ? pathname : isWalletConnected ? AppRoutes.welcome.accounts : AppRoutes.welcome.index, + query: { + chain: networkShortName, + } as { + chain: string + safeViewRedirectURL?: string + }, + } + + if (router.query?.safeViewRedirectURL) { + route.query.safeViewRedirectURL = router.query?.safeViewRedirectURL.toString() + } + + return route +} diff --git a/src/components/new-safe/create/AdvancedCreateSafe.tsx b/src/components/new-safe/create/AdvancedCreateSafe.tsx index 3c434e3bfd..963c8961d3 100644 --- a/src/components/new-safe/create/AdvancedCreateSafe.tsx +++ b/src/components/new-safe/create/AdvancedCreateSafe.tsx @@ -33,7 +33,16 @@ const AdvancedCreateSafe = () => { title: 'Select network and name of your Safe Account', subtitle: 'Select the network on which to create your Safe Account', render: (data, onSubmit, onBack, setStep) => ( - + {}} + setDynamicHint={() => {}} + /> ), }, { @@ -84,6 +93,7 @@ const AdvancedCreateSafe = () => { const initialStep = 0 const initialData: NewSafeFormData = { name: '', + networks: [], owners: [], threshold: 1, saltNonce: 0, @@ -115,7 +125,7 @@ const AdvancedCreateSafe = () => { - {activeStep < 2 && } + {activeStep < 2 && } {wallet?.address && } diff --git a/src/components/new-safe/create/OverviewWidget/index.tsx b/src/components/new-safe/create/OverviewWidget/index.tsx index a9eb6f8aca..a6119321e9 100644 --- a/src/components/new-safe/create/OverviewWidget/index.tsx +++ b/src/components/new-safe/create/OverviewWidget/index.tsx @@ -1,6 +1,4 @@ -import ChainIndicator from '@/components/common/ChainIndicator' import WalletOverview from 'src/components/common/WalletOverview' -import { useCurrentChain } from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' import { Box, Card, Grid, Typography } from '@mui/material' import type { ReactElement } from 'react' @@ -8,16 +6,24 @@ import SafeLogo from '@/public/images/logo-no-text.svg' import css from '@/components/new-safe/create/OverviewWidget/styles.module.css' import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import NetworkLogosList from '@/features/multichain/components/NetworkLogosList' const LOGO_DIMENSIONS = '22px' -const OverviewWidget = ({ safeName }: { safeName: string }): ReactElement | null => { +const OverviewWidget = ({ safeName, networks }: { safeName: string; networks: ChainInfo[] }): ReactElement | null => { const wallet = useWallet() - const chain = useCurrentChain() const rows = [ ...(wallet ? [{ title: 'Wallet', component: }] : []), - ...(chain ? [{ title: 'Network', component: }] : []), ...(safeName !== '' ? [{ title: 'Name', component: {safeName} }] : []), + ...(networks.length + ? [ + { + title: 'Network(s)', + component: , + }, + ] + : []), ] return ( diff --git a/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts b/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts index ff9ff360d1..5cd1317202 100644 --- a/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts +++ b/src/components/new-safe/create/__tests__/useSyncSafeCreationStep.test.ts @@ -1,11 +1,13 @@ import { renderHook } from '@/tests/test-utils' import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCreationStep' import * as wallet from '@/hooks/wallets/useWallet' +import * as currentChain from '@/hooks/useChains' import * as localStorage from '@/services/local-storage/useLocalStorage' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import * as useIsWrongChain from '@/hooks/useIsWrongChain' import * as useRouter from 'next/router' import { type NextRouter } from 'next/router' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' describe('useSyncSafeCreationStep', () => { beforeEach(() => { @@ -20,7 +22,7 @@ describe('useSyncSafeCreationStep', () => { } as unknown as NextRouter) const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep, [])) expect(mockSetStep).toHaveBeenCalledWith(0) }) @@ -28,11 +30,11 @@ describe('useSyncSafeCreationStep', () => { it('should go to the first step if the wrong chain is connected', async () => { jest.spyOn(localStorage, 'default').mockReturnValue([{}, jest.fn()]) jest.spyOn(wallet, 'default').mockReturnValue({ address: '0x1' } as ConnectedWallet) - jest.spyOn(useIsWrongChain, 'default').mockReturnValue(true) + jest.spyOn(currentChain, 'useCurrentChain').mockReturnValue({ chainId: '100' } as ChainInfo) const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep, [{ chainId: '4' } as ChainInfo])) expect(mockSetStep).toHaveBeenCalledWith(0) }) @@ -44,7 +46,7 @@ describe('useSyncSafeCreationStep', () => { const mockSetStep = jest.fn() - renderHook(() => useSyncSafeCreationStep(mockSetStep)) + renderHook(() => useSyncSafeCreationStep(mockSetStep, [])) expect(mockSetStep).not.toHaveBeenCalled() }) diff --git a/src/components/new-safe/create/index.tsx b/src/components/new-safe/create/index.tsx index 2063745d70..53409fb77a 100644 --- a/src/components/new-safe/create/index.tsx +++ b/src/components/new-safe/create/index.tsx @@ -21,9 +21,11 @@ import { HelpCenterArticle } from '@/config/constants' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' import { getLatestSafeVersion } from '@/utils/chains' import { useCurrentChain } from '@/hooks/useChains' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' export type NewSafeFormData = { name: string + networks: ChainInfo[] threshold: number owners: NamedAddress[] saltNonce: number @@ -104,15 +106,25 @@ const CreateSafe = () => { const chain = useCurrentChain() const [safeName, setSafeName] = useState('') + const [overviewNetworks, setOverviewNetworks] = useState() + const [dynamicHint, setDynamicHint] = useState() const [activeStep, setActiveStep] = useState(0) const CreateSafeSteps: TxStepperProps['steps'] = [ { - title: 'Select network and name of your Safe Account', - subtitle: 'Select the network on which to create your Safe Account', + title: 'Set up the basics', + subtitle: 'Give a name to your account and select which networks to deploy it on.', render: (data, onSubmit, onBack, setStep) => ( - + ), }, { @@ -158,6 +170,7 @@ const CreateSafe = () => { const initialStep = 0 const initialData: NewSafeFormData = { name: '', + networks: [], owners: [], threshold: 1, saltNonce: 0, @@ -189,7 +202,7 @@ const CreateSafe = () => { - {activeStep < 2 && } + {activeStep < 2 && } {wallet?.address && } diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 8d93077d34..4c94a353a6 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -2,10 +2,10 @@ import { JsonRpcProvider } from 'ethers' import * as contracts from '@/services/contracts/safeContracts' import type { SafeProvider } from '@safe-global/protocol-kit' import type { CompatibilityFallbackHandlerContractImplementationType } from '@safe-global/protocol-kit/dist/src/types' -import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3 from '@/hooks/wallets/web3' import * as sdkHelpers from '@/services/tx/tx-sender/sdk' -import { getRedirect, relaySafeCreation } from '@/components/new-safe/create/logic/index' +import { SAFE_TO_L2_SETUP_INTERFACE, relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -21,6 +21,8 @@ import * as gateway from '@safe-global/safe-gateway-typescript-sdk' 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 { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' +import { SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -68,12 +70,13 @@ describe('createNewSafeViaRelayer', () => { const safeContractAddress = await ( await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) ).getAddress() + const l2Deployment = getSafeL2SingletonDeployment({ version: latestSafeVersion, network: mockChainInfo.chainId }) const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ [owner1, owner2], expectedThreshold, - ZERO_ADDRESS, - EMPTY_DATA, + SAFE_TO_L2_SETUP_ADDRESS, + SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), await readOnlyFallbackHandlerContract.getAddress(), ZERO_ADDRESS, 0, diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 92ac57d52b..f6ffa70ece 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -1,5 +1,5 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' -import { type Eip1193Provider, type Provider } from 'ethers' +import { Interface, type Eip1193Provider, type Provider } from 'ethers' import { getSafeInfo, type SafeInfo, type ChainInfo, relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { @@ -16,9 +16,10 @@ import type { DeploySafeProps } from '@safe-global/protocol-kit' import { isValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' import { backOff } from 'exponential-backoff' -import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getLatestSafeVersion } from '@/utils/chains' -import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' +import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' import { type ReplayedSafeProps } from '@/store/slices' export type SafeCreationProps = { @@ -27,11 +28,15 @@ export type SafeCreationProps = { saltNonce: number } -const getSafeFactory = async (provider: Eip1193Provider, safeVersion: SafeVersion): Promise => { +const getSafeFactory = async ( + provider: Eip1193Provider, + safeVersion: SafeVersion, + isL1SafeSingleton?: boolean, +): Promise => { if (!isValidSafeVersion(safeVersion)) { throw new Error('Invalid Safe version') } - return SafeFactory.init({ provider, safeVersion }) + return SafeFactory.init({ provider, safeVersion, isL1SafeSingleton }) } /** @@ -41,8 +46,9 @@ export const createNewSafe = async ( provider: Eip1193Provider, props: DeploySafeProps, safeVersion: SafeVersion, + isL1SafeSingleton?: boolean, ): Promise => { - const safeFactory = await getSafeFactory(provider, safeVersion) + const safeFactory = await getSafeFactory(provider, safeVersion, isL1SafeSingleton) return safeFactory.deploySafe(props) } @@ -50,7 +56,7 @@ export const createNewSafe = async ( * Compute the new counterfactual Safe address before it is actually created */ export const computeNewSafeAddress = async ( - provider: Eip1193Provider, + provider: Eip1193Provider | string, props: DeploySafeProps, chain: ChainInfo, safeVersion?: SafeVersion, @@ -65,9 +71,12 @@ export const computeNewSafeAddress = async ( saltNonce: props.saltNonce, safeVersion: safeVersion ?? getLatestSafeVersion(chain), }, + isL1SafeSingleton: true, }) } +export const SAFE_TO_L2_SETUP_INTERFACE = new Interface(['function setupToL2(address l2Singleton)']) + /** * Encode a Safe creation transaction NOT using the Core SDK because it doesn't support that * This is used for gas estimation. @@ -78,17 +87,18 @@ export const encodeSafeCreationTx = async ({ saltNonce, chain, safeVersion, -}: SafeCreationProps & { chain: ChainInfo; safeVersion?: SafeVersion }) => { +}: SafeCreationProps & { chain: ChainInfo; safeVersion?: SafeVersion; to?: string; data?: string }) => { const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion) + const readOnlyL1SafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion, true) + const l2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) const callData = { owners, threshold, - to: ZERO_ADDRESS, - data: EMPTY_DATA, + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), paymentToken: ZERO_ADDRESS, payment: 0, @@ -108,7 +118,7 @@ export const encodeSafeCreationTx = async ({ ]) return readOnlyProxyContract.encode('createProxyWithNonce', [ - await readOnlySafeContract.getAddress(), + await readOnlyL1SafeContract.getAddress(), // always L1 Mastercopy setupData, BigInt(saltNonce), ]) @@ -190,14 +200,15 @@ export const relaySafeCreation = async ( const proxyFactoryAddress = await readOnlyProxyFactoryContract.getAddress() const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(safeVersion) const fallbackHandlerAddress = await readOnlyFallbackHandlerContract.getAddress() - const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, safeVersion) - const safeContractAddress = await readOnlySafeContract.getAddress() + const readOnlyL1SafeContract = await getReadOnlyGnosisSafeContract(chain, safeVersion, true) + const safeContractAddress = await readOnlyL1SafeContract.getAddress() + const l2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) const callData = { owners, threshold, - to: ZERO_ADDRESS, - data: EMPTY_DATA, + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), fallbackHandler: fallbackHandlerAddress, paymentToken: ZERO_ADDRESS, payment: 0, @@ -205,7 +216,7 @@ export const relaySafeCreation = async ( } // @ts-ignore - const initializer = readOnlySafeContract.encode('setup', [ + const initializer = readOnlyL1SafeContract.encode('setup', [ callData.owners, callData.threshold, callData.to, diff --git a/src/components/new-safe/create/logic/utils.test.ts b/src/components/new-safe/create/logic/utils.test.ts index 0a9f543785..3185464f47 100644 --- a/src/components/new-safe/create/logic/utils.test.ts +++ b/src/components/new-safe/create/logic/utils.test.ts @@ -3,7 +3,6 @@ import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' import * as walletUtils from '@/utils/wallets' import { faker } from '@faker-js/faker' import type { DeploySafeProps } from '@safe-global/protocol-kit' -import { MockEip1193Provider } from '@/tests/mocks/providers' import { chainBuilder } from '@/tests/builders/chains' describe('getAvailableSaltNonce', () => { @@ -30,11 +29,7 @@ describe('getAvailableSaltNonce', () => { const initialNonce = faker.string.numeric() const mockChain = chainBuilder().build() - const result = await getAvailableSaltNonce( - MockEip1193Provider, - { ...mockDeployProps, saltNonce: initialNonce }, - mockChain, - ) + const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) expect(result).toEqual(initialNonce) }) @@ -44,11 +39,7 @@ describe('getAvailableSaltNonce', () => { const initialNonce = faker.string.numeric() const mockChain = chainBuilder().build() - const result = await getAvailableSaltNonce( - MockEip1193Provider, - { ...mockDeployProps, saltNonce: initialNonce }, - mockChain, - ) + const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(false)) diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts index ff63058759..4a8f254234 100644 --- a/src/components/new-safe/create/logic/utils.ts +++ b/src/components/new-safe/create/logic/utils.ts @@ -3,23 +3,50 @@ import { isSmartContract } from '@/utils/wallets' import type { DeploySafeProps } from '@safe-global/protocol-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' -import type { Eip1193Provider } from 'ethers' +import { sameAddress } from '@/utils/addresses' +import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' export const getAvailableSaltNonce = async ( - provider: Eip1193Provider, + customRpcs: { + [chainId: string]: string + }, props: DeploySafeProps, - chain: ChainInfo, + chains: ChainInfo[], + // All addresses from the sidebar disregarding the chain. This is an optimization to reduce RPC calls + knownSafeAddresses: string[], safeVersion?: SafeVersion, ): Promise => { - const safeAddress = await computeNewSafeAddress(provider, props, chain, safeVersion) - const isContractDeployed = await isSmartContract(safeAddress) + let isAvailableOnAllChains = true + const allRPCs = chains.map((chain) => { + const rpcUrl = customRpcs?.[chain.chainId] || getRpcServiceUrl(chain.rpcUri) + // Turn into Eip1993Provider + return { + rpcUrl, + chainId: chain.chainId, + } + }) + + for (const chain of chains) { + const rpcUrl = allRPCs.find((rpc) => chain.chainId === rpc.chainId)?.rpcUrl + if (!rpcUrl) { + throw new Error(`No RPC available for ${chain.chainName}`) + } + const safeAddress = await computeNewSafeAddress(rpcUrl, props, chain, safeVersion) + const isKnown = knownSafeAddresses.some((knownAddress) => sameAddress(knownAddress, safeAddress)) + if (isKnown || (await isSmartContract(safeAddress, createWeb3ReadOnly(chain, rpcUrl)))) { + // We found a chain where the nonce is used up + isAvailableOnAllChains = false + break + } + } // Safe is already deployed so we try the next saltNonce - if (isContractDeployed) { + if (!isAvailableOnAllChains) { return getAvailableSaltNonce( - provider, + customRpcs, { ...props, saltNonce: (Number(props.saltNonce) + 1).toString() }, - chain, + chains, + knownSafeAddresses, safeVersion, ) } diff --git a/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx b/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx index 49212e42fe..46aa38cba9 100644 --- a/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx +++ b/src/components/new-safe/create/steps/AdvancedOptionsStep/index.tsx @@ -32,7 +32,7 @@ const ADVANCED_OPTIONS_STEP_FORM_ID = 'create-safe-advanced-options-step-form' const AdvancedOptionsStep = ({ onSubmit, onBack, data, setStep }: StepRenderProps): ReactElement => { const wallet = useWallet() - useSyncSafeCreationStep(setStep) + useSyncSafeCreationStep(setStep, data.networks) const chain = useCurrentChain() const formMethods = useForm({ diff --git a/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx b/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx index cdedec2bd6..0ff9bb8760 100644 --- a/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx +++ b/src/components/new-safe/create/steps/OwnerPolicyStep/index.tsx @@ -1,4 +1,3 @@ -import CounterfactualHint from '@/features/counterfactual/CounterfactualHint' import useAddressBook from '@/hooks/useAddressBook' import useWallet from '@/hooks/wallets/useWallet' import { Button, SvgIcon, MenuItem, Tooltip, Typography, Divider, Box, Grid, TextField } from '@mui/material' @@ -46,7 +45,7 @@ const OwnerPolicyStep = ({ name: defaultOwnerAddressBookName || wallet?.ens || '', address: wallet?.address || '', } - useSyncSafeCreationStep(setStep) + useSyncSafeCreationStep(setStep, data.networks) const formMethods = useForm({ mode: 'onChange', @@ -75,11 +74,11 @@ const OwnerPolicyStep = ({ const isDisabled = !formState.isValid - useSafeSetupHints(threshold, ownerFields.length, setDynamicHint) + useSafeSetupHints(setDynamicHint, threshold, ownerFields.length) const handleBack = () => { const formData = getValues() - onBack(formData) + onBack({ ...data, ...formData }) } const onFormSubmit = handleSubmit((data) => { @@ -157,8 +156,6 @@ const OwnerPolicyStep = ({ out of {ownerFields.length} signer(s) - - {ownerFields.length > 1 && } diff --git a/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts b/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts index ec431b533f..6cd05c182b 100644 --- a/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts +++ b/src/components/new-safe/create/steps/OwnerPolicyStep/useSafeSetupHints.ts @@ -2,34 +2,43 @@ import { useEffect } from 'react' import type { CreateSafeInfoItem } from '@/components/new-safe/create/CreateSafeInfos' export const useSafeSetupHints = ( - threshold: number, - noOwners: number, setHint: (hint: CreateSafeInfoItem | undefined) => void, + threshold?: number, + numberOfOwners?: number, + multiChain?: boolean, ) => { useEffect(() => { const safeSetupWarningSteps: { title: string; text: string }[] = [] // 1/n warning - if (threshold === 1) { + if (numberOfOwners && threshold === 1) { safeSetupWarningSteps.push({ - title: `1/${noOwners} policy`, + title: `1/${numberOfOwners} policy`, text: 'Use a threshold higher than one to prevent losing access to your Safe Account in case a signer key is lost or compromised.', }) } // n/n warning - if (threshold === noOwners && noOwners > 1) { + if (threshold === numberOfOwners && numberOfOwners && numberOfOwners > 1) { safeSetupWarningSteps.push({ - title: `${noOwners}/${noOwners} policy`, + title: `${numberOfOwners}/${numberOfOwners} policy`, text: 'Use a threshold which is lower than the total number of signers of your Safe Account in case a signer loses access to their account and needs to be replaced.', }) } + // n/n warning + if (multiChain) { + safeSetupWarningSteps.push({ + title: `Same address. Many networks.`, + text: 'You can choose which networks to deploy your account on and will need to deploy them one by one after creation.', + }) + } + setHint({ title: 'Safe Account setup', variant: 'info', steps: safeSetupWarningSteps }) // Clear dynamic hints when the step / hook unmounts return () => { setHint(undefined) } - }, [threshold, noOwners, setHint]) + }, [threshold, numberOfOwners, setHint, multiChain]) } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx index 23a7a67032..a677f08cb6 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.test.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.test.tsx @@ -38,6 +38,7 @@ describe('ReviewStep', () => { it('should display a pay now pay later option for counterfactual safe setups', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -55,6 +56,7 @@ describe('ReviewStep', () => { it('should display a pay later option as selected by default for counterfactual safe setups', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -71,6 +73,7 @@ describe('ReviewStep', () => { it('should not display the network fee for counterfactual safes', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -88,6 +91,7 @@ describe('ReviewStep', () => { it('should not display the execution method for counterfactual safes', () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -105,6 +109,7 @@ describe('ReviewStep', () => { it('should display the network fee for counterfactual safes if the user selects pay now', async () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -128,6 +133,7 @@ describe('ReviewStep', () => { it('should display the execution method for counterfactual safes if the user selects pay now and there is relaying', async () => { const mockData: NewSafeFormData = { name: 'Test', + networks: [mockChainInfo], threshold: 1, owners: [{ name: '', address: '0x1' }], saltNonce: 0, @@ -148,4 +154,41 @@ describe('ReviewStep', () => { expect(getByText(/Who will pay gas fees:/)).toBeInTheDocument() }) + + it('should display the execution method for counterfactual safes if the user selects pay now and there is relaying', async () => { + const mockMultiChainInfo = [ + { + chainId: '100', + chainName: 'Gnosis Chain', + l2: false, + nativeCurrency: { + symbol: 'ETH', + }, + }, + { + chainId: '1', + chainName: 'Ethereum', + l2: false, + nativeCurrency: { + symbol: 'ETH', + }, + }, + ] as ChainInfo[] + const mockData: NewSafeFormData = { + name: 'Test', + networks: mockMultiChainInfo, + threshold: 1, + owners: [{ name: '', address: '0x1' }], + saltNonce: 0, + safeVersion: LATEST_SAFE_VERSION as SafeVersion, + } + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + jest.spyOn(relay, 'hasRemainingRelays').mockReturnValue(true) + + const { getByText } = render( + , + ) + + expect(getByText(/activate your account/)).toBeInTheDocument() + }) }) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index d8cb8eb4c4..d180837351 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,13 +1,16 @@ -import ChainIndicator from '@/components/common/ChainIndicator' import type { NamedAddress } from '@/components/new-safe/create/types' import EthHashInfo from '@/components/common/EthHashInfo' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' -import { computeNewSafeAddress, createNewSafe, relaySafeCreation } from '@/components/new-safe/create/logic' +import { + computeNewSafeAddress, + createNewSafe, + relaySafeCreation, + SAFE_TO_L2_SETUP_INTERFACE, +} from '@/components/new-safe/create/logic' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' -import NetworkWarning from '@/components/new-safe/create/NetworkWarning' import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css' import layoutCss from '@/components/new-safe/create/styles.module.css' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' @@ -27,7 +30,7 @@ import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } import { gtmSetSafeAddress } from '@/services/analytics/gtm' import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' import { asError } from '@/services/exceptions/utils' -import { useAppDispatch } from '@/store' +import { useAppDispatch, useAppSelector } from '@/store' import { FEATURES, hasFeature } from '@/utils/chains' import { hasRemainingRelays } from '@/utils/relaying' import { isWalletRejection } from '@/utils/wallets' @@ -38,7 +41,14 @@ import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' +import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' +import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import ChainIndicator from '@/components/common/ChainIndicator' +import NetworkWarning from '../../NetworkWarning' +import useAllSafes from '@/components/welcome/MyAccounts/useAllSafes' +import { uniq } from 'lodash' +import { selectRpc } from '@/store/settingsSlice' +import { AppRoutes } from '@/config/routes' export const NetworkFee = ({ totalFee, @@ -66,16 +76,27 @@ export const SafeSetupOverview = ({ name, owners, threshold, + networks, }: { name?: string owners: NamedAddress[] threshold: number + networks: ChainInfo[] }) => { const chain = useCurrentChain() return ( - } /> + 1 ? 'Networks' : 'Network'} + value={ + + {networks.map((network) => ( + + ))} + + } + /> {name && {name}} />} ) => { const isWrongChain = useIsWrongChain() - useSyncSafeCreationStep(setStep) + useSyncSafeCreationStep(setStep, data.networks) const chain = useCurrentChain() const wallet = useWallet() const dispatch = useAppDispatch() @@ -126,6 +147,8 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps data.owners.map((owner) => owner.address), [data.owners]) const [minRelays] = useLeastRemainingRelays(ownerAddresses) + const isMultiChainDeployment = data.networks.length > 1 + // Every owner has remaining relays and relay method is selected const canRelay = hasRemainingRelays(minRelays) const willRelay = canRelay && executionMethod === ExecutionMethod.RELAY @@ -147,40 +170,82 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps uniq(allSafes?.map((safe) => safe.address)), [allSafes]) + + const customRPCs = useAppSelector(selectRpc) const handleBack = () => { onBack(data) } - const createSafe = async () => { - if (!wallet || !chain) return + const handleCreateSafeClick = async () => { + try { + if (!wallet || !chain) return - setIsCreating(true) + setIsCreating(true) - try { + // Create universal deployment Data across chains: const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(data.safeVersion) + const safeL2Deployment = getSafeL2SingletonDeployment({ version: data.safeVersion, network: chain.chainId }) + const safeL2Address = safeL2Deployment?.defaultAddress + if (!safeL2Address) { + throw new Error('No Safe deployment found') + } const props: DeploySafeProps = { safeAccountConfig: { threshold: data.threshold, owners: data.owners.map((owner) => owner.address), fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), paymentReceiver: ECOSYSTEM_ID_ADDRESS, }, } + // Figure out the shared available nonce across chains + const nextAvailableNonce = await getAvailableSaltNonce( + customRPCs, + { ...props, saltNonce: data.saltNonce.toString() }, + data.networks, + knownAddresses, + data.safeVersion, + ) - const saltNonce = await getAvailableSaltNonce( + const safeAddress = await computeNewSafeAddress( wallet.provider, - { ...props, saltNonce: data.saltNonce.toString() }, + { ...props, saltNonce: nextAvailableNonce }, chain, data.safeVersion, ) - const safeAddress = await computeNewSafeAddress(wallet.provider, { ...props, saltNonce }, chain, data.safeVersion) + for (const network of data.networks) { + createSafe(network, props, safeAddress, nextAvailableNonce) + } - if (isCounterfactual && payMethod === PayMethod.PayLater) { + if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { + router?.push({ + pathname: AppRoutes.home, + query: { safe: `${data.networks[0].shortName}:${safeAddress}` }, + }) + safeCreationDispatch(SafeCreationEvent.AWAITING_EXECUTION, { + groupKey: CF_TX_GROUP_KEY, + safeAddress, + networks: data.networks, + }) + } + } catch (err) { + console.error(err) + } finally { + setIsCreating(false) + } + } + + const createSafe = async (chain: ChainInfo, props: DeploySafeProps, safeAddress: string, saltNonce: string) => { + if (!wallet) return + + try { + if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { gtmSetSafeAddress(safeAddress) trackEvent({ ...OVERVIEW_EVENTS.PROCEED_WITH_TX, label: 'counterfactual', category: CREATE_SAFE_CATEGORY }) @@ -240,6 +305,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps - + - {isCounterfactual && ( + {isCounterfactualEnabled && ( <> - + {canRelay && payMethod === PayMethod.PayNow && ( - - - } - /> - + <> + + + } + /> + + + )} + + {showNetworkWarning && ( + + + )} {payMethod === PayMethod.PayNow && ( @@ -298,7 +382,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps )} - {!isCounterfactual && ( + {!isCounterfactualEnabled && ( <> @@ -334,7 +418,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps - + {showNetworkWarning && } {!walletCanPay && !willRelay && ( @@ -361,7 +445,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps diff --git a/src/features/counterfactual/PayNowPayLater.tsx b/src/features/counterfactual/PayNowPayLater.tsx index 78e2cecece..1b9b847885 100644 --- a/src/features/counterfactual/PayNowPayLater.tsx +++ b/src/features/counterfactual/PayNowPayLater.tsx @@ -14,6 +14,7 @@ import { } from '@mui/material' import css from './styles.module.css' +import ErrorMessage from '@/components/tx/ErrorMessage' export const enum PayMethod { PayNow = 'PayNow', @@ -23,11 +24,13 @@ export const enum PayMethod { const PayNowPayLater = ({ totalFee, canRelay, + isMultiChain, payMethod, setPayMethod, }: { totalFee: string canRelay: boolean + isMultiChain: boolean payMethod: PayMethod setPayMethod: Dispatch> }) => { @@ -42,23 +45,32 @@ const PayNowPayLater = ({ Before you continue + {isMultiChain && ( + + You will need to activate your account separately, on each network. + + )} - There will be a one-time network fee to activate your smart account wallet. - - - - - - - - If you choose to pay later, the fee will be included with the first transaction you make. + {`There will be a one-time network fee to activate your smart account wallet ${ + isMultiChain ? 'on each network' : '' + }.`} + {!isMultiChain && ( + + + + + + If you choose to pay later, the fee will be included with the first transaction you make. + + + )} @@ -66,46 +78,48 @@ const PayNowPayLater = ({ Safe doesn't profit from the fees. - - - - Pay now - - {canRelay ? ( - 'Sponsored free transaction' - ) : ( - <> - ≈ {totalFee} {chain?.nativeCurrency.symbol} - - )} - - - } - control={} - /> + {!isMultiChain && ( + + + + Pay now + + {canRelay ? ( + 'Sponsored free transaction' + ) : ( + <> + ≈ {totalFee} {chain?.nativeCurrency.symbol} + + )} + + + } + control={} + /> - - Pay later - - with the first transaction - - - } - control={} - /> - - + + Pay later + + with the first transaction + + + } + control={} + /> + + + )} ) } diff --git a/src/features/counterfactual/hooks/usePendingSafeStatuses.ts b/src/features/counterfactual/hooks/usePendingSafeStatuses.ts index 296765c48f..fc58dc4ff5 100644 --- a/src/features/counterfactual/hooks/usePendingSafeStatuses.ts +++ b/src/features/counterfactual/hooks/usePendingSafeStatuses.ts @@ -22,6 +22,7 @@ import { isSmartContract } from '@/utils/wallets' import { gtmSetSafeAddress } from '@/services/analytics/gtm' export const safeCreationPendingStatuses: Partial> = { + [SafeCreationEvent.AWAITING_EXECUTION]: PendingSafeStatus.AWAITING_EXECUTION, [SafeCreationEvent.PROCESSING]: PendingSafeStatus.PROCESSING, [SafeCreationEvent.RELAYING]: PendingSafeStatus.RELAYING, [SafeCreationEvent.SUCCESS]: null, diff --git a/src/features/counterfactual/services/safeCreationEvents.ts b/src/features/counterfactual/services/safeCreationEvents.ts index 1e65d807de..1305b72a25 100644 --- a/src/features/counterfactual/services/safeCreationEvents.ts +++ b/src/features/counterfactual/services/safeCreationEvents.ts @@ -1,7 +1,9 @@ import type { PayMethod } from '@/features/counterfactual/PayNowPayLater' import EventBus from '@/services/EventBus' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' export enum SafeCreationEvent { + AWAITING_EXECUTION = 'AWAITING_EXECUTION', PROCESSING = 'PROCESSING', RELAYING = 'RELAYING', SUCCESS = 'SUCCESS', @@ -11,6 +13,11 @@ export enum SafeCreationEvent { } export interface SafeCreationEvents { + [SafeCreationEvent.AWAITING_EXECUTION]: { + groupKey: string + safeAddress: string + networks: ChainInfo[] + } [SafeCreationEvent.PROCESSING]: { groupKey: string txHash: string diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index 2193664360..d25905cb85 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -1,7 +1,6 @@ import type { NewSafeFormData } from '@/components/new-safe/create' import { getLatestSafeVersion } from '@/utils/chains' import { POLLING_INTERVAL } from '@/config/constants' -import { AppRoutes } from '@/config/routes' import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' import { @@ -188,11 +187,6 @@ export const createCounterfactualSafe = ( }, }), ) - - router?.push({ - pathname: AppRoutes.home, - query: { safe: `${chain.shortName}:${safeAddress}` }, - }) } export const replayCounterfactualSafeDeployment = ( diff --git a/src/features/multichain/components/NetworkLogosList/index.tsx b/src/features/multichain/components/NetworkLogosList/index.tsx new file mode 100644 index 0000000000..ca3bb74345 --- /dev/null +++ b/src/features/multichain/components/NetworkLogosList/index.tsx @@ -0,0 +1,16 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import { Box } from '@mui/material' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import css from './styles.module.css' + +const NetworkLogosList = ({ networks }: { networks: ChainInfo[] }) => { + return ( + + {networks.map((chain) => ( + + ))} + + ) +} + +export default NetworkLogosList diff --git a/src/features/multichain/components/NetworkLogosList/styles.module.css b/src/features/multichain/components/NetworkLogosList/styles.module.css new file mode 100644 index 0000000000..1ff0475ccc --- /dev/null +++ b/src/features/multichain/components/NetworkLogosList/styles.module.css @@ -0,0 +1,12 @@ +.networks { + display: flex; + flex-wrap: wrap; + margin-left: 12px; +} + +.networks img { + margin-left: -12px; + background-color: var(--color-background-main); + padding: 1px; + border-radius: 12px; +} diff --git a/src/hooks/useMnemonicName/index.ts b/src/hooks/useMnemonicName/index.ts index ab01de0c0f..eef797194b 100644 --- a/src/hooks/useMnemonicName/index.ts +++ b/src/hooks/useMnemonicName/index.ts @@ -11,16 +11,12 @@ const getRandomItem = (arr: T[]): T => { return arr[Math.floor(arr.length * Math.random())] } -export const getRandomName = (noun = capitalize(getRandomItem(animals))): string => { - const adj = capitalize(getRandomItem(adjectives)) - return `${adj} ${noun}` +export const getRandomAdjective = (): string => { + return capitalize(getRandomItem(adjectives)) } -export const useMnemonicName = (noun?: string): string => { - return useMemo(() => getRandomName(noun), [noun]) -} - -export const useMnemonicSafeName = (): string => { - const networkName = useCurrentChain()?.chainName - return useMnemonicName(`${networkName} Safe`) +export const useMnemonicSafeName = (multiChain?: boolean): string => { + const currentNetwork = useCurrentChain()?.chainName + const adjective = useMemo(() => getRandomAdjective(), []) + return `${adjective} ${multiChain ? 'Multi-Chain' : currentNetwork} Safe` } diff --git a/src/hooks/useMnemonicName/useMnemonicName.test.ts b/src/hooks/useMnemonicName/useMnemonicName.test.ts index 7dbb3bc3dc..783f0aad9e 100644 --- a/src/hooks/useMnemonicName/useMnemonicName.test.ts +++ b/src/hooks/useMnemonicName/useMnemonicName.test.ts @@ -1,4 +1,4 @@ -import { getRandomName, useMnemonicName, useMnemonicSafeName } from '.' +import { getRandomAdjective, useMnemonicSafeName } from '.' import { renderHook } from '@/tests/test-utils' import { chainBuilder } from '@/tests/builders/chains' @@ -11,34 +11,20 @@ jest.mock('@/hooks/useChains', () => ({ describe('useMnemonicName tests', () => { it('should generate a random name', () => { - expect(getRandomName()).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) - expect(getRandomName()).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) - expect(getRandomName()).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) + expect(getRandomAdjective()).toMatch(/^[A-Z][a-z-]+/) + expect(getRandomAdjective()).toMatch(/^[A-Z][a-z-]+/) + expect(getRandomAdjective()).toMatch(/^[A-Z][a-z-]+/) }) - it('should work as a hook', () => { - const { result } = renderHook(() => useMnemonicName()) - expect(result.current).toMatch(/^[A-Z][a-z-]+ [A-Z][a-z]+$/) - }) - - it('should work as a hook with a noun param', () => { - const { result } = renderHook(() => useMnemonicName('test')) - expect(result.current).toMatch(/^[A-Z][a-z-]+ test$/) - }) - - it('should change if the noun changes', () => { - let noun = 'test' - const { result, rerender } = renderHook(() => useMnemonicName(noun)) - expect(result.current).toMatch(/^[A-Z][a-z-]+ test$/) - - noun = 'changed' - rerender() - expect(result.current).toMatch(/^[A-Z][a-z-]+ changed$/) - }) - - it('should return a random safe name', () => { + it('should return a random safe name with current chain', () => { const { result } = renderHook(() => useMnemonicSafeName()) const regex = new RegExp(`^[A-Z][a-z-]+ ${mockChain.chainName} Safe$`) expect(result.current).toMatch(regex) }) + + it('should return a random safe name indicating a multichain safe', () => { + const { result } = renderHook(() => useMnemonicSafeName(true)) + const regex = new RegExp(`^[A-Z][a-z-]+ Multi-Chain Safe$`) + expect(result.current).toMatch(regex) + }) }) diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index 91d4293386..044186a0ec 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -68,12 +68,16 @@ export const getCurrentGnosisSafeContract = async (safe: SafeInfo, provider: str return getGnosisSafeContract(safe, safeProvider) } -export const getReadOnlyGnosisSafeContract = async (chain: ChainInfo, safeVersion: SafeInfo['version']) => { +export const getReadOnlyGnosisSafeContract = async ( + chain: ChainInfo, + safeVersion: SafeInfo['version'], + isL1?: boolean, +) => { const version = safeVersion ?? getLatestSafeVersion(chain) const safeProvider = getSafeProvider() - const isL1SafeSingleton = !_isL2(chain, _getValidatedGetContractProps(version).safeVersion) + const isL1SafeSingleton = isL1 ?? !_isL2(chain, _getValidatedGetContractProps(version).safeVersion) return getSafeContractInstance( _getValidatedGetContractProps(version).safeVersion, diff --git a/src/utils/wallets.ts b/src/utils/wallets.ts index ccfd007ee4..9596de55d9 100644 --- a/src/utils/wallets.ts +++ b/src/utils/wallets.ts @@ -5,6 +5,7 @@ import { WALLET_KEYS } from '@/hooks/wallets/consts' import { EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' import memoize from 'lodash/memoize' import { PRIVATE_KEY_MODULE_LABEL } from '@/services/private-key-module' +import { type JsonRpcProvider } from 'ethers' const WALLETCONNECT = 'WalletConnect' @@ -34,14 +35,14 @@ export const isHardwareWallet = (wallet: ConnectedWallet): boolean => { ) } -export const isSmartContract = async (address: string): Promise => { - const provider = getWeb3ReadOnly() +export const isSmartContract = async (address: string, provider?: JsonRpcProvider): Promise => { + const web3 = provider ?? getWeb3ReadOnly() - if (!provider) { + if (!web3) { throw new Error('Provider not found') } - const code = await provider.getCode(address) + const code = await web3.getCode(address) return code !== EMPTY_DATA } From 0cf633e6f2699b7bd0630199dc05993dbbeb1396 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Thu, 12 Sep 2024 16:30:30 +0200 Subject: [PATCH 07/74] [Multichain] Feat: show warning when changing signer setup in a multichain safe [SW-150] (#4151) * feat: add warning when adding or removing an owner to multichain safes * fix: change owner to signer in text and fix typos * feat: use current chain name in warning message --- src/components/tx/SignOrExecuteForm/index.tsx | 5 +++++ .../ChangeOwnerSetupWarning.tsx | 19 +++++++++++++++++++ src/features/multichain/helpers/utils.ts | 5 +++++ .../multichain/hooks/useIsMultichainSafe.ts | 18 ++++++++++++++++++ 4 files changed, 47 insertions(+) create mode 100644 src/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning.tsx create mode 100644 src/features/multichain/helpers/utils.ts create mode 100644 src/features/multichain/hooks/useIsMultichainSafe.ts diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 81f986a534..948397ea43 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -43,6 +43,8 @@ import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning' +import { isChangingSignerSetup } from '@/features/multichain/helpers/utils' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void @@ -118,6 +120,7 @@ export const SignOrExecuteForm = ({ const isSafeOwner = useIsSafeOwner() const isCounterfactualSafe = !safe.deployed const isChangingFallbackHandler = isSettingTwapFallbackHandler(decodedData) + const isChangingSigners = isChangingSignerSetup(decodedData) const isMultiChainMigration = isMigrateToL2MultiSend(decodedData) const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(decodedData) @@ -205,6 +208,8 @@ export const SignOrExecuteForm = ({ + {isChangingSigners && } + {!isMultiChainMigration && } diff --git a/src/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning.tsx b/src/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning.tsx new file mode 100644 index 0000000000..1708a07e5b --- /dev/null +++ b/src/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning.tsx @@ -0,0 +1,19 @@ +import { Box } from '@mui/material' +import { useIsMultichainSafe } from '../../hooks/useIsMultichainSafe' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { useCurrentChain } from '@/hooks/useChains' + +export const ChangeSignerSetupWarning = () => { + const isMultichainSafe = useIsMultichainSafe() + const currentChain = useCurrentChain() + + if (!isMultichainSafe) return + + return ( + + + {`Signers are not consistent across networks on this account. Changing signers will only affect the account on ${currentChain}`} + + + ) +} diff --git a/src/features/multichain/helpers/utils.ts b/src/features/multichain/helpers/utils.ts new file mode 100644 index 0000000000..6c55de8282 --- /dev/null +++ b/src/features/multichain/helpers/utils.ts @@ -0,0 +1,5 @@ +import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' + +export const isChangingSignerSetup = (decodedData: DecodedDataResponse | undefined) => { + return decodedData?.method === 'addOwnerWithThreshold' || decodedData?.method === 'removeOwner' +} diff --git a/src/features/multichain/hooks/useIsMultichainSafe.ts b/src/features/multichain/hooks/useIsMultichainSafe.ts new file mode 100644 index 0000000000..e3fafaa745 --- /dev/null +++ b/src/features/multichain/hooks/useIsMultichainSafe.ts @@ -0,0 +1,18 @@ +import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' +import useSafeAddress from '@/hooks/useSafeAddress' +import { sameAddress } from '@/utils/addresses' +import { useMemo } from 'react' + +export const useIsMultichainSafe = () => { + const safeAddress = useSafeAddress() + const { allMultiChainSafes } = useAllSafesGrouped() + + return useMemo( + () => + allMultiChainSafes?.some( + (account) => sameAddress(safeAddress, account.safes[0].address), + [allMultiChainSafes, safeAddress], + ), + [allMultiChainSafes, safeAddress], + ) +} From 09fc8a6fa8c52794d5fcacb3640cf6b981276ef0 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:22:31 +0200 Subject: [PATCH 08/74] [Multichain] fix: Hide header network selector on non-safe routes [SW-171] (#4163) * fix: Hide header network selector on non-safe routes * fix: Failing import export data e2e test * fix: Move MenuItem component back to be a function and fix e2e test * fix: Revert NetworkInput refactor * fix: Failing load_safe e2e test --- cypress/e2e/pages/load_safe.pages.js | 2 +- cypress/e2e/pages/owners.pages.js | 2 +- cypress/e2e/smoke/import_export_data.cy.js | 6 +- src/components/common/Header/index.tsx | 8 +- src/components/common/NetworkInput/index.tsx | 37 ++++--- .../common/NetworkSelector/index.tsx | 97 +++++++------------ 6 files changed, 60 insertions(+), 92 deletions(-) diff --git a/cypress/e2e/pages/load_safe.pages.js b/cypress/e2e/pages/load_safe.pages.js index 45626eebf2..503c9608c1 100644 --- a/cypress/e2e/pages/load_safe.pages.js +++ b/cypress/e2e/pages/load_safe.pages.js @@ -210,7 +210,7 @@ export function verifyDataInReviewSection(safeName, ownerName, threshold = null, cy.findByText(ownerName).should('be.visible') if (ownerAddress !== null) cy.get(safeDataForm).contains(ownerAddress).should('be.visible') if (threshold !== null) cy.get(safeDataForm).contains(threshold).should('be.visible') - if (network !== null) cy.get(sidebar.chainLogo).eq(1).contains(network).should('be.visible') + if (network !== null) cy.get(sidebar.chainLogo).eq(0).contains(network).should('be.visible') } export function clickOnAddBtn() { diff --git a/cypress/e2e/pages/owners.pages.js b/cypress/e2e/pages/owners.pages.js index 0e4100be15..cf42f816fc 100644 --- a/cypress/e2e/pages/owners.pages.js +++ b/cypress/e2e/pages/owners.pages.js @@ -80,7 +80,7 @@ export function verifyOwnerDeletionWindowDisplayed() { } function clickOnThresholdDropdown() { - cy.get(thresholdDropdown).eq(1).click() + cy.get(thresholdDropdown).eq(0).click() } export function getThresholdOptions() { diff --git a/cypress/e2e/smoke/import_export_data.cy.js b/cypress/e2e/smoke/import_export_data.cy.js index 6ecc9d6dd9..129a9101ea 100644 --- a/cypress/e2e/smoke/import_export_data.cy.js +++ b/cypress/e2e/smoke/import_export_data.cy.js @@ -3,7 +3,6 @@ import * as file from '../pages/import_export.pages' import * as main from '../pages/main.page' import * as constants from '../../support/constants' import * as ls from '../../support/localstorage_data.js' -import * as createwallet from '../pages/create_wallet.pages' import * as sideBar from '../pages/sidebar.pages' import { getSafes, CATEGORIES } from '../../support/safes/safesHandler.js' @@ -16,10 +15,7 @@ describe('[SMOKE] Import Export Data tests', () => { beforeEach(() => { cy.clearLocalStorage() - cy.visit(constants.dataSettingsUrl).then(() => { - main.acceptCookies() - createwallet.selectNetwork(constants.networks.sepolia) - }) + cy.visit(constants.dataSettingsUrl) }) it('[SMOKE] Verify Safe can be accessed after test file upload', () => { diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index d121352992..5d7cdbf2ff 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -109,9 +109,11 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
    -
    - -
    + {safeAddress && ( +
    + +
    + )}
    ) } diff --git a/src/components/common/NetworkInput/index.tsx b/src/components/common/NetworkInput/index.tsx index 529ea42c8e..03de70334e 100644 --- a/src/components/common/NetworkInput/index.tsx +++ b/src/components/common/NetworkInput/index.tsx @@ -5,22 +5,10 @@ import { FormControl, InputLabel, ListSubheader, MenuItem, Select } from '@mui/m import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import css from './styles.module.css' -import { type ReactElement, useMemo } from 'react' +import { type ReactElement, useCallback, useMemo } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -const NetworkMenuItem = ({ chainId, chainConfigs }: { chainId: string; chainConfigs: ChainInfo[] }) => { - const chain = useMemo(() => chainConfigs.find((chain) => chain.chainId === chainId), [chainConfigs, chainId]) - - if (!chain) return null - - return ( - - - - ) -} - const NetworkInput = ({ name, required = false, @@ -35,6 +23,19 @@ const NetworkInput = ({ const [testNets, prodNets] = useMemo(() => partition(chainConfigs, (config) => config.isTestnet), [chainConfigs]) const { control } = useFormContext() || {} + const renderMenuItem = useCallback( + (chainId: string, isSelected: boolean) => { + const chain = chainConfigs.find((chain) => chain.chainId === chainId) + if (!chain) return null + return ( + + + + ) + }, + [chainConfigs], + ) + return ( } + renderValue={(value) => renderMenuItem(value, true)} MenuProps={{ sx: { '& .MuiPaper-root': { @@ -66,15 +67,11 @@ const NetworkInput = ({ }, }} > - {prodNets.map((chain) => ( - - ))} + {prodNets.map((chain) => renderMenuItem(chain.chainId, false))} {testNets.length > 0 && Testnets} - {testNets.map((chain) => ( - - ))} + {testNets.map((chain) => renderMenuItem(chain.chainId, false))} )} diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 01599e7152..65b69aa80f 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -1,5 +1,7 @@ import ChainIndicator from '@/components/common/ChainIndicator' import { useDarkMode } from '@/hooks/useDarkMode' +import { useAppSelector } from '@/store' +import { selectChains } from '@/store/chainsSlice' import { useTheme } from '@mui/material/styles' import Link from 'next/link' import type { SelectChangeEvent } from '@mui/material' @@ -22,7 +24,7 @@ import type { NextRouter } from 'next/router' import { useRouter } from 'next/router' import css from './styles.module.css' import { useChainId } from '@/hooks/useChainId' -import { type ReactElement, useMemo, useState } from 'react' +import { type ReactElement, useCallback, useMemo, useState } from 'react' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' @@ -32,7 +34,7 @@ import uniq from 'lodash/uniq' import useSafeOverviews from '@/components/welcome/MyAccounts/useSafeOverviews' import { useReplayableNetworks } from '@/features/multichain/hooks/useReplayableNetworks' import { useSafeCreationData } from '@/features/multichain/hooks/useSafeCreationData' -import { type SafeOverview, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' @@ -205,39 +207,6 @@ const UndeployedNetworks = ({ ) } -const DeployedNetworkMenuItem = ({ - chainId, - chainConfigs, - isSelected = false, - onClick, - safeOverviews, -}: { - chainId: string - chainConfigs: ChainInfo[] - isSelected?: boolean - onClick?: () => void - safeOverviews?: SafeOverview[] -}) => { - const chain = chainConfigs.find((chain) => chain.chainId === chainId) - const safeOverview = safeOverviews?.find((overview) => chainId === overview.chainId) - const isWalletConnected = !!useWallet() - const router = useRouter() - - if (!chain) return null - return ( - - - - - - ) -} - const NetworkSelector = ({ onChainSelect, offerSafeCreation = false, @@ -251,6 +220,7 @@ const NetworkSelector = ({ const chainId = useChainId() const router = useRouter() const safeAddress = useSafeAddress() + const chains = useAppSelector(selectChains) const isWalletConnected = !!useWallet() const isSafeOpened = safeAddress !== '' @@ -297,6 +267,33 @@ const NetworkSelector = ({ } } + const renderMenuItem = useCallback( + (chainId: string, isSelected: boolean) => { + const chain = chains.data.find((chain) => chain.chainId === chainId) + const safeOverview = safeOverviews?.find((overview) => chainId === overview.chainId) + + if (!chain) return null + + return ( + + + + + + ) + }, + [chains.data, isWalletConnected, onChainSelect, router, safeOverviews], + ) + return configs.length ? ( )} diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 32d6781be0..abdf712b5e 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -109,7 +109,8 @@ const ReplaySafeDialog = ({ const submitDisabled = isUnsupportedSafeCreationVersion || !!safeCreationDataError || safeCreationDataLoading || !formState.isValid - const noChainsAvailable = !chain && safeCreationData && replayableChains && replayableChains.length === 0 + const noChainsAvailable = + !chain && safeCreationData && replayableChains && replayableChains.filter((chain) => chain.available).length === 0 return ( e.stopPropagation()}> From 99b0cb1cbf59b8eccf8a221e46f5c99fff4ce540 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 19 Sep 2024 10:15:03 +0200 Subject: [PATCH 15/74] fix: check for canonical deployments (#4193) --- .../NetworkSelector/NetworkMultiSelector.tsx | 17 ++++++++++------- src/services/contracts/deployments.ts | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx index 29683515e4..0eae24e1e8 100644 --- a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -9,8 +9,9 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form' import { useRouter } from 'next/router' import { getNetworkLink } from '.' import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameStep' -import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import { getSafeSingletonDeployments } from '@safe-global/safe-deployments' import { getLatestSafeVersion } from '@/utils/chains' +import { hasCanonicalDeployment } from '@/services/contracts/deployments' const NetworkMultiSelector = ({ name, @@ -60,17 +61,19 @@ const NetworkMultiSelector = ({ // do not allow multi chain safes for advanced setup flow. if (isAdvancedFlow) return optionNetwork.chainId != firstSelectedNetwork.chainId - const optionHasCanonicalSingletonDeployment = Boolean( - getSafeSingletonDeployment({ + const optionHasCanonicalSingletonDeployment = hasCanonicalDeployment( + getSafeSingletonDeployments({ network: optionNetwork.chainId, version: getLatestSafeVersion(firstSelectedNetwork), - })?.deployments.canonical, + }), + optionNetwork.chainId, ) - const selectedHasCanonicalSingletonDeployment = Boolean( - getSafeSingletonDeployment({ + const selectedHasCanonicalSingletonDeployment = hasCanonicalDeployment( + getSafeSingletonDeployments({ network: firstSelectedNetwork.chainId, version: getLatestSafeVersion(firstSelectedNetwork), - })?.deployments.canonical, + }), + firstSelectedNetwork.chainId, ) // Only 1.4.1 safes with canonical deployment addresses can be deployed as part of a multichain group diff --git a/src/services/contracts/deployments.ts b/src/services/contracts/deployments.ts index b4b2ee8682..6995cf6317 100644 --- a/src/services/contracts/deployments.ts +++ b/src/services/contracts/deployments.ts @@ -9,9 +9,24 @@ import { getSignMessageLibDeployment, getCreateCallDeployment, } from '@safe-global/safe-deployments' -import type { SingletonDeployment, DeploymentFilter } from '@safe-global/safe-deployments' +import type { SingletonDeployment, DeploymentFilter, SingletonDeploymentV2 } from '@safe-global/safe-deployments' import type { ChainInfo, SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' import { getLatestSafeVersion } from '@/utils/chains' +import { sameAddress } from '@/utils/addresses' + +const toNetworkAddressList = (addresses: string | string[]) => (Array.isArray(addresses) ? addresses : [addresses]) + +export const hasCanonicalDeployment = (deployment: SingletonDeploymentV2 | undefined, chainId: string) => { + const canonicalAddress = deployment?.deployments.canonical?.address + + if (!canonicalAddress) { + return false + } + + const networkAddresses = toNetworkAddressList(deployment.networkAddresses[chainId]) + + return networkAddresses.some((networkAddress) => sameAddress(canonicalAddress, networkAddress)) +} export const _tryDeploymentVersions = ( getDeployment: (filter?: DeploymentFilter) => SingletonDeployment | undefined, From c81e54dde86a2deb8c5e37300d5ab85331f488dd Mon Sep 17 00:00:00 2001 From: James Mealy Date: Thu, 19 Sep 2024 11:53:45 +0200 Subject: [PATCH 16/74] fix: show network when activating a safe (#4199) --- src/components/new-safe/create/steps/ReviewStep/index.tsx | 2 -- src/features/counterfactual/ActivateAccountFlow.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index d180837351..bb15d9380c 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -83,8 +83,6 @@ export const SafeSetupOverview = ({ threshold: number networks: ChainInfo[] }) => { - const chain = useCurrentChain() - return ( { ({ name: '', address: owner }))} threshold={threshold} - networks={[]} + networks={chain ? [chain] : []} /> From bf6ea57d6296638be768d16d20f9af5820b59773 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 19 Sep 2024 11:57:24 +0200 Subject: [PATCH 17/74] [Multichain] Fix: precise counterfactual safes (#4191) --- .../useEstimateSafeCreationGas.test.ts | 21 +- .../new-safe/create/logic/index.test.ts | 51 +++- src/components/new-safe/create/logic/index.ts | 272 ++++++++++-------- .../new-safe/create/logic/utils.test.ts | 142 ++++++++- src/components/new-safe/create/logic/utils.ts | 22 +- .../create/steps/ReviewStep/index.tsx | 102 +++---- .../create/useEstimateSafeCreationGas.ts | 11 +- src/components/tx/SignOrExecuteForm/index.tsx | 2 +- .../welcome/MyAccounts/AccountItem.tsx | 6 +- .../welcome/MyAccounts/MultiAccountItem.tsx | 13 +- .../MyAccounts/utils/multiChainSafe.ts | 27 +- .../counterfactual/ActivateAccountFlow.tsx | 67 ++--- .../store/undeployedSafesSlice.ts | 17 +- src/features/counterfactual/utils.ts | 73 ++--- .../components/CreateSafeOnNewChain/index.tsx | 2 +- .../__tests__/useCompatibleNetworks.test.ts | 100 ++----- .../__tests__/useSafeCreationData.test.ts | 178 ++++-------- .../multichain/hooks/useCompatibleNetworks.ts | 4 - .../multichain/hooks/useSafeCreationData.ts | 99 +++---- .../multichain/{helpers => utils}/utils.ts | 0 src/tests/test-utils.tsx | 4 + 21 files changed, 609 insertions(+), 604 deletions(-) rename src/features/multichain/{helpers => utils}/utils.ts (100%) diff --git a/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts b/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts index 172c33ebbe..42bb6210bb 100644 --- a/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts +++ b/src/components/new-safe/create/__tests__/useEstimateSafeCreationGas.test.ts @@ -12,11 +12,22 @@ import { JsonRpcProvider } from 'ethers' import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { waitFor } from '@testing-library/react' import { type EIP1193Provider } from '@web3-onboard/core' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' -const mockProps = { - owners: [], - threshold: 1, - saltNonce: 1, +const mockProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + }, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '0', + safeVersion: '1.3.0', } describe('useEstimateSafeCreationGas', () => { @@ -28,7 +39,7 @@ describe('useEstimateSafeCreationGas', () => { jest .spyOn(safeContracts, 'getReadOnlyProxyFactoryContract') .mockResolvedValue({ getAddress: () => ZERO_ADDRESS } as unknown as SafeProxyFactoryContractImplementationType) - jest.spyOn(sender, 'encodeSafeCreationTx').mockReturnValue(Promise.resolve(EMPTY_DATA)) + jest.spyOn(sender, 'encodeSafeCreationTx').mockReturnValue(EMPTY_DATA) jest.spyOn(wallet, 'default').mockReturnValue({} as ConnectedWallet) }) diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 4c94a353a6..b8a4545dd0 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -2,10 +2,10 @@ import { JsonRpcProvider } from 'ethers' import * as contracts from '@/services/contracts/safeContracts' import type { SafeProvider } from '@safe-global/protocol-kit' import type { CompatibilityFallbackHandlerContractImplementationType } from '@safe-global/protocol-kit/dist/src/types' -import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3 from '@/hooks/wallets/web3' import * as sdkHelpers from '@/services/tx/tx-sender/sdk' -import { SAFE_TO_L2_SETUP_INTERFACE, relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' +import { relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -21,8 +21,8 @@ import * as gateway from '@safe-global/safe-gateway-typescript-sdk' 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 { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' -import { SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import { type ReplayedSafeProps } from '@/store/slices' +import { faker } from '@faker-js/faker' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -70,13 +70,29 @@ describe('createNewSafeViaRelayer', () => { const safeContractAddress = await ( await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) ).getAddress() - const l2Deployment = getSafeL2SingletonDeployment({ version: latestSafeVersion, network: mockChainInfo.chainId }) + + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: proxyFactoryAddress, + masterCopy: safeContractAddress, + saltNonce: '69', + } const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ [owner1, owner2], expectedThreshold, - SAFE_TO_L2_SETUP_ADDRESS, - SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), + ZERO_ADDRESS, + EMPTY_DATA, await readOnlyFallbackHandlerContract.getAddress(), ZERO_ADDRESS, 0, @@ -89,7 +105,7 @@ describe('createNewSafeViaRelayer', () => { expectedSaltNonce, ]) - const taskId = await relaySafeCreation(mockChainInfo, [owner1, owner2], expectedThreshold, expectedSaltNonce) + const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) expect(taskId).toEqual('0x123') expect(relayTransaction).toHaveBeenCalledTimes(1) @@ -104,7 +120,24 @@ describe('createNewSafeViaRelayer', () => { const relayFailedError = new Error('Relay failed') jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) - expect(relaySafeCreation(mockChainInfo, [owner1, owner2], 1, 69)).rejects.toEqual(relayFailedError) + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '69', + } + + expect(relaySafeCreation(mockChainInfo, undeployedSafeProps)).rejects.toEqual(relayFailedError) }) describe('getRedirect', () => { diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index f6ffa70ece..47a8140d68 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -2,25 +2,29 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { Interface, type Eip1193Provider, type Provider } from 'ethers' import { getSafeInfo, type SafeInfo, type ChainInfo, relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' -import { - getReadOnlyFallbackHandlerContract, - getReadOnlyGnosisSafeContract, - getReadOnlyProxyFactoryContract, -} from '@/services/contracts/safeContracts' +import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContracts' import type { UrlObject } from 'url' import { AppRoutes } from '@/config/routes' import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' import { predictSafeAddress, SafeFactory, SafeProvider } from '@safe-global/protocol-kit' -import type Safe from '@safe-global/protocol-kit' -import type { DeploySafeProps } from '@safe-global/protocol-kit' +import type { DeploySafeProps, PredictedSafeProps } from '@safe-global/protocol-kit' import { isValidSafeVersion } from '@/hooks/coreSDK/safeCoreSDK' import { backOff } from 'exponential-backoff' -import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getLatestSafeVersion } from '@/utils/chains' -import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' +import { + getCompatibilityFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments' import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' -import { type ReplayedSafeProps } from '@/store/slices' +import type { ReplayedSafeProps, UndeployedSafeProps } from '@/store/slices' +import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfactual/utils' +import { getSafeContractDeployment } from '@/services/contracts/deployments' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { createWeb3 } from '@/hooks/wallets/web3' export type SafeCreationProps = { owners: string[] @@ -44,12 +48,20 @@ const getSafeFactory = async ( */ export const createNewSafe = async ( provider: Eip1193Provider, - props: DeploySafeProps, + undeployedSafeProps: UndeployedSafeProps, safeVersion: SafeVersion, + chain: ChainInfo, + callback: (txHash: string) => void, isL1SafeSingleton?: boolean, -): Promise => { +): Promise => { const safeFactory = await getSafeFactory(provider, safeVersion, isL1SafeSingleton) - return safeFactory.deploySafe(props) + + if (isPredictedSafeProps(undeployedSafeProps)) { + await safeFactory.deploySafe({ ...undeployedSafeProps, callback }) + } else { + const txResponse = await activateReplayedSafe(chain, undeployedSafeProps, createWeb3(provider)) + callback(txResponse.hash) + } } /** @@ -77,50 +89,30 @@ export const computeNewSafeAddress = async ( export const SAFE_TO_L2_SETUP_INTERFACE = new Interface(['function setupToL2(address l2Singleton)']) +export const encodeSafeSetupCall = (safeAccountConfig: ReplayedSafeProps['safeAccountConfig']) => { + return Safe__factory.createInterface().encodeFunctionData('setup', [ + safeAccountConfig.owners, + safeAccountConfig.threshold, + safeAccountConfig.to, + safeAccountConfig.data, + safeAccountConfig.fallbackHandler, + ZERO_ADDRESS, + 0, + safeAccountConfig.paymentReceiver, + ]) +} + /** * Encode a Safe creation transaction NOT using the Core SDK because it doesn't support that * This is used for gas estimation. */ -export const encodeSafeCreationTx = async ({ - owners, - threshold, - saltNonce, - chain, - safeVersion, -}: SafeCreationProps & { chain: ChainInfo; safeVersion?: SafeVersion; to?: string; data?: string }) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlyL1SafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion, true) - const l2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) - - const callData = { - owners, - threshold, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } +export const encodeSafeCreationTx = (undeployedSafe: UndeployedSafeProps, chain: ChainInfo) => { + const replayedSafeProps = assertNewUndeployedSafeProps(undeployedSafe, chain) - // @ts-ignore union type is too complex - const setupData = readOnlySafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - - return readOnlyProxyContract.encode('createProxyWithNonce', [ - await readOnlyL1SafeContract.getAddress(), // always L1 Mastercopy - setupData, - BigInt(saltNonce), + return Safe_proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + replayedSafeProps.masterCopy, + encodeSafeSetupCall(replayedSafeProps.safeAccountConfig), + BigInt(replayedSafeProps.saltNonce), ]) } @@ -128,11 +120,11 @@ export const estimateSafeCreationGas = async ( chain: ChainInfo, provider: Provider, from: string, - safeParams: SafeCreationProps, + undeployedSafe: UndeployedSafeProps, safeVersion?: SafeVersion, ): Promise => { const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(safeVersion ?? getLatestSafeVersion(chain)) - const encodedSafeCreationTx = await encodeSafeCreationTx({ ...safeParams, chain }) + const encodedSafeCreationTx = encodeSafeCreationTx(undeployedSafe, chain) const gas = await provider.estimateGas({ from, @@ -185,85 +177,119 @@ export const getRedirect = ( return redirectUrl + `${appendChar}safe=${address}` } -export const relaySafeCreation = async ( - chain: ChainInfo, - owners: string[], - threshold: number, - saltNonce: number, - version?: SafeVersion, -) => { - const latestSafeVersion = getLatestSafeVersion(chain) - - const safeVersion = version ?? latestSafeVersion - - const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(safeVersion) - const proxyFactoryAddress = await readOnlyProxyFactoryContract.getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(safeVersion) - const fallbackHandlerAddress = await readOnlyFallbackHandlerContract.getAddress() - const readOnlyL1SafeContract = await getReadOnlyGnosisSafeContract(chain, safeVersion, true) - const safeContractAddress = await readOnlyL1SafeContract.getAddress() - const l2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) - - const callData = { - owners, - threshold, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [l2Deployment?.defaultAddress]), - fallbackHandler: fallbackHandlerAddress, - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } +export const relaySafeCreation = async (chain: ChainInfo, undeployedSafeProps: UndeployedSafeProps) => { + const replayedSafeProps = assertNewUndeployedSafeProps(undeployedSafeProps, chain) + const encodedSafeCreationTx = encodeSafeCreationTx(replayedSafeProps, chain) - // @ts-ignore - const initializer = readOnlyL1SafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) + const relayResponse = await relayTransaction(chain.chainId, { + to: replayedSafeProps.factoryAddress, + data: encodedSafeCreationTx, + version: replayedSafeProps.safeVersion, + }) - const createProxyWithNonceCallData = readOnlyProxyFactoryContract.encode('createProxyWithNonce', [ - safeContractAddress, - initializer, - BigInt(saltNonce), - ]) + return relayResponse.taskId +} - const relayResponse = await relayTransaction(chain.chainId, { - to: proxyFactoryAddress, - data: createProxyWithNonceCallData, +export type UndeployedSafeWithoutSalt = Omit + +/** + * Creates a new undeployed Safe without default config: + * + * Always use the L1 MasterCopy and add a migration to L2 in to the setup. + * Use our ecosystem ID as paymentReceiver. + * + */ +export const createNewUndeployedSafeWithoutSalt = ( + safeVersion: SafeVersion, + safeAccountConfig: Pick, + chainId: string, +): UndeployedSafeWithoutSalt => { + // Create universal deployment Data across chains: + const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ version: safeVersion, + network: chainId, }) + const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress + const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chainId }) + const safeL2Address = safeL2Deployment?.defaultAddress - return relayResponse.taskId + const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chainId }) + const safeL1Address = safeL1Deployment?.defaultAddress + + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryAddress = safeFactoryDeployment?.defaultAddress + + if (!safeL2Address || !safeL1Address || !safeFactoryAddress || !fallbackHandlerAddress) { + throw new Error('No Safe deployment found') + } + + const replayedSafe: Omit = { + factoryAddress: safeFactoryAddress, + masterCopy: safeL1Address, + safeAccountConfig: { + threshold: safeAccountConfig.threshold, + owners: safeAccountConfig.owners, + fallbackHandler: fallbackHandlerAddress, + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion, + } + + return replayedSafe } -export const relayReplayedSafeCreation = async ( - chain: ChainInfo, - replayedSafe: ReplayedSafeProps, - safeVersion: SafeVersion | undefined, -) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion, replayedSafe.factoryAddress) - - if (!replayedSafe.masterCopy || !replayedSafe.setupData) { - throw Error('Cannot replay Safe without deployment info') +/** + * Migrates a counterfactual Safe from the pre multichain era to the new predicted Safe data + * @param predictedSafeProps + * @param chain + * @returns + */ +export const migrateLegacySafeProps = (predictedSafeProps: PredictedSafeProps, chain: ChainInfo): ReplayedSafeProps => { + const safeVersion = predictedSafeProps.safeDeploymentConfig?.safeVersion + const saltNonce = predictedSafeProps.safeDeploymentConfig?.saltNonce + const { chainId } = chain + if (!safeVersion || !saltNonce) { + throw new Error('Undeployed Safe with incomplete data.') } - const createProxyWithNonceCallData = readOnlyProxyContract.encode('createProxyWithNonce', [ - replayedSafe.masterCopy, - replayedSafe.setupData, - BigInt(replayedSafe.saltNonce), - ]) - const relayResponse = await relayTransaction(chain.chainId, { - to: replayedSafe.factoryAddress, - data: createProxyWithNonceCallData, - version: usedSafeVersion, + const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ + version: safeVersion, + network: chainId, }) + const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress - return relayResponse.taskId + const masterCopyDeployment = getSafeContractDeployment(chain, safeVersion) + const masterCopyAddress = masterCopyDeployment?.defaultAddress + + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryAddress = safeFactoryDeployment?.defaultAddress + + if (!masterCopyAddress || !safeFactoryAddress || !fallbackHandlerAddress) { + throw new Error('No Safe deployment found') + } + + return { + factoryAddress: safeFactoryAddress, + masterCopy: masterCopyAddress, + safeAccountConfig: { + threshold: predictedSafeProps.safeAccountConfig.threshold, + owners: predictedSafeProps.safeAccountConfig.owners, + fallbackHandler: predictedSafeProps.safeAccountConfig.fallbackHandler ?? fallbackHandlerAddress, + to: predictedSafeProps.safeAccountConfig.to ?? ZERO_ADDRESS, + data: predictedSafeProps.safeAccountConfig.data ?? EMPTY_DATA, + paymentReceiver: predictedSafeProps.safeAccountConfig.paymentReceiver ?? ZERO_ADDRESS, + }, + safeVersion, + saltNonce, + } +} + +export const assertNewUndeployedSafeProps = (props: UndeployedSafeProps, chain: ChainInfo): ReplayedSafeProps => { + if (isPredictedSafeProps(props)) { + return migrateLegacySafeProps(props, chain) + } + + return props } diff --git a/src/components/new-safe/create/logic/utils.test.ts b/src/components/new-safe/create/logic/utils.test.ts index 3185464f47..700c081b24 100644 --- a/src/components/new-safe/create/logic/utils.test.ts +++ b/src/components/new-safe/create/logic/utils.test.ts @@ -1,14 +1,22 @@ import * as creationUtils from '@/components/new-safe/create/logic/index' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' -import * as walletUtils from '@/utils/wallets' import { faker } from '@faker-js/faker' -import type { DeploySafeProps } from '@safe-global/protocol-kit' import { chainBuilder } from '@/tests/builders/chains' +import { type ReplayedSafeProps } from '@/store/slices' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import * as web3Hooks from '@/hooks/wallets/web3' +import { type JsonRpcProvider, id } from 'ethers' +import { Safe_proxy_factory__factory } from '@/types/contracts' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' + +// Proxy Factory 1.3.0 creation code +const mockProxyCreationCode = + '0x608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea2646970667358221220d1429297349653a4918076d650332de1a1068c5f3e07c5c82360c277770b955264736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564' describe('getAvailableSaltNonce', () => { jest.spyOn(creationUtils, 'computeNewSafeAddress').mockReturnValue(Promise.resolve(faker.finance.ethereumAddress())) - let mockDeployProps: DeploySafeProps + let mockDeployProps: ReplayedSafeProps beforeAll(() => { mockDeployProps = { @@ -16,7 +24,16 @@ describe('getAvailableSaltNonce', () => { threshold: 1, owners: [faker.finance.ethereumAddress()], fallbackHandler: faker.finance.ethereumAddress(), + data: faker.string.hexadecimal({ casing: 'lower', length: 64 }), + to: faker.finance.ethereumAddress(), + paymentReceiver: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, }, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.4.1', + saltNonce: '0', } }) @@ -25,9 +42,22 @@ describe('getAvailableSaltNonce', () => { }) it('should return initial nonce if no contract is deployed to the computed address', async () => { - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValue(Promise.resolve(false)) + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue({ + getCode: jest.fn().mockReturnValue('0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider) + const initialNonce = faker.string.numeric() - const mockChain = chainBuilder().build() + const mockChain = chainBuilder().with({ chainId: '1' }).build() const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) @@ -35,16 +65,108 @@ describe('getAvailableSaltNonce', () => { }) it('should return an increased nonce if a contract is deployed to the computed address', async () => { - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(true)) + let requiredTries = 3 + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue({ + getCode: jest + .fn() + .mockImplementation(() => (requiredTries-- > 0 ? faker.string.hexadecimal({ length: 64 }) : '0x')), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider) + const initialNonce = faker.string.numeric() + const mockChain = chainBuilder().with({ chainId: '1' }).build() + const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) + + expect(result).toEqual((Number(initialNonce) + 3).toString()) + }) + + it('should skip known addresses without checking getCode', async () => { + const mockProvider = { + getCode: jest.fn().mockImplementation(() => '0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider const initialNonce = faker.string.numeric() + + const replayedProps = { ...mockDeployProps, saltNonce: initialNonce } + const knownAddresses = [await predictAddressBasedOnReplayData(replayedProps, mockProvider)] + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockReturnValue(mockProvider) const mockChain = chainBuilder().build() + const result = await getAvailableSaltNonce({}, replayedProps, [mockChain], knownAddresses) - const result = await getAvailableSaltNonce({}, { ...mockDeployProps, saltNonce: initialNonce }, [mockChain], []) + // The known address (initialNonce) will be skipped + expect(result).toEqual((Number(initialNonce) + 1).toString()) + expect(mockProvider.getCode).toHaveBeenCalledTimes(1) + }) + + it('should check cross chain', async () => { + const mockMainnet = chainBuilder().with({ chainId: '1' }).build() + const mockGnosis = chainBuilder().with({ chainId: '100' }).build() + + // We mock that on GnosisChain the first nonce is already deployed + const mockGnosisProvider = { + getCode: jest.fn().mockImplementation(() => '0x'), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '100' }), + } as unknown as JsonRpcProvider + + // We Mock that on Mainnet the first two nonces are already deployed + let mainnetTriesRequired = 2 + const mockMainnetProvider = { + getCode: jest + .fn() + .mockImplementation(() => (mainnetTriesRequired-- > 0 ? faker.string.hexadecimal({ length: 64 }) : '0x')), + call: jest.fn().mockImplementation((tx: { data: string; to: string }) => { + if (tx.data.startsWith(id('proxyCreationCode()').slice(0, 10))) { + return Safe_proxy_factory__factory.createInterface().encodeFunctionResult('proxyCreationCode', [ + mockProxyCreationCode, + ]) + } else { + throw new Error('Unsupported Operation') + } + }), + getNetwork: jest.fn().mockReturnValue({ chainId: '1' }), + } as unknown as JsonRpcProvider + const initialNonce = faker.string.numeric() - jest.spyOn(walletUtils, 'isSmartContract').mockReturnValueOnce(Promise.resolve(false)) + const replayedProps = { ...mockDeployProps, saltNonce: initialNonce } + jest.spyOn(web3Hooks, 'createWeb3ReadOnly').mockImplementation((chain) => { + if (chain.chainId === '100') { + return mockGnosisProvider + } + if (chain.chainId === '1') { + return mockMainnetProvider + } + throw new Error('Web3Provider not found') + }) - const increasedNonce = (Number(initialNonce) + 1).toString() + const result = await getAvailableSaltNonce({}, replayedProps, [mockMainnet, mockGnosis], []) - expect(result).toEqual(increasedNonce) + // The known address (initialNonce) will be skipped + expect(result).toEqual((Number(initialNonce) + 2).toString()) }) }) diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts index 4a8f254234..fbc5a69bc3 100644 --- a/src/components/new-safe/create/logic/utils.ts +++ b/src/components/new-safe/create/logic/utils.ts @@ -1,20 +1,18 @@ -import { computeNewSafeAddress } from '@/components/new-safe/create/logic/index' import { isSmartContract } from '@/utils/wallets' -import type { DeploySafeProps } from '@safe-global/protocol-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { type SafeVersion } from '@safe-global/safe-core-sdk-types' import { sameAddress } from '@/utils/addresses' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' +import { type ReplayedSafeProps } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' export const getAvailableSaltNonce = async ( customRpcs: { [chainId: string]: string }, - props: DeploySafeProps, + replayedSafe: ReplayedSafeProps, chains: ChainInfo[], // All addresses from the sidebar disregarding the chain. This is an optimization to reduce RPC calls knownSafeAddresses: string[], - safeVersion?: SafeVersion, ): Promise => { let isAvailableOnAllChains = true const allRPCs = chains.map((chain) => { @@ -31,9 +29,13 @@ export const getAvailableSaltNonce = async ( if (!rpcUrl) { throw new Error(`No RPC available for ${chain.chainName}`) } - const safeAddress = await computeNewSafeAddress(rpcUrl, props, chain, safeVersion) + const web3ReadOnly = createWeb3ReadOnly(chain, rpcUrl) + if (!web3ReadOnly) { + throw new Error('Could not initiate RPC') + } + const safeAddress = await predictAddressBasedOnReplayData(replayedSafe, web3ReadOnly) const isKnown = knownSafeAddresses.some((knownAddress) => sameAddress(knownAddress, safeAddress)) - if (isKnown || (await isSmartContract(safeAddress, createWeb3ReadOnly(chain, rpcUrl)))) { + if (isKnown || (await isSmartContract(safeAddress, web3ReadOnly))) { // We found a chain where the nonce is used up isAvailableOnAllChains = false break @@ -44,13 +46,11 @@ export const getAvailableSaltNonce = async ( if (!isAvailableOnAllChains) { return getAvailableSaltNonce( customRpcs, - { ...props, saltNonce: (Number(props.saltNonce) + 1).toString() }, + { ...replayedSafe, saltNonce: (Number(replayedSafe.saltNonce) + 1).toString() }, chains, knownSafeAddresses, - safeVersion, ) } - // We know that there will be a saltNonce but the type has it as optional - return props.saltNonce! + return replayedSafe.saltNonce } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index bb15d9380c..4f149f0994 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -5,10 +5,9 @@ import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' import { - computeNewSafeAddress, createNewSafe, + createNewUndeployedSafeWithoutSalt, relaySafeCreation, - SAFE_TO_L2_SETUP_INTERFACE, } from '@/components/new-safe/create/logic' import { getAvailableSaltNonce } from '@/components/new-safe/create/logic/utils' import css from '@/components/new-safe/create/steps/ReviewStep/styles.module.css' @@ -19,7 +18,7 @@ import ReviewRow from '@/components/new-safe/ReviewRow' import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import PayNowPayLater, { PayMethod } from '@/features/counterfactual/PayNowPayLater' -import { CF_TX_GROUP_KEY, createCounterfactualSafe } from '@/features/counterfactual/utils' +import { CF_TX_GROUP_KEY, replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import useGasPrice from '@/hooks/useGasPrice' import useIsWrongChain from '@/hooks/useIsWrongChain' @@ -28,7 +27,6 @@ import useWalletCanPay from '@/hooks/useWalletCanPay' import useWallet from '@/hooks/wallets/useWallet' import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { gtmSetSafeAddress } from '@/services/analytics/gtm' -import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' import { asError } from '@/services/exceptions/utils' import { useAppDispatch, useAppSelector } from '@/store' import { FEATURES, hasFeature } from '@/utils/chains' @@ -36,19 +34,20 @@ import { hasRemainingRelays } from '@/utils/relaying' import { isWalletRejection } from '@/utils/wallets' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import { Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material' -import { type DeploySafeProps } from '@safe-global/protocol-kit' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments' -import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' import ChainIndicator from '@/components/common/ChainIndicator' import NetworkWarning from '../../NetworkWarning' import useAllSafes from '@/components/welcome/MyAccounts/useAllSafes' import { uniq } from 'lodash' import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' +import { type ReplayedSafeProps } from '@/store/slices' +import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { createWeb3 } from '@/hooks/wallets/web3' +import { type DeploySafeProps } from '@safe-global/protocol-kit' export const NetworkFee = ({ totalFee, @@ -151,15 +150,26 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { - return { - owners: data.owners.map((owner) => owner.address), - threshold: data.threshold, - saltNonce: Date.now(), // This is not the final saltNonce but easier to use and will only result in a slightly higher gas estimation - } - }, [data.owners, data.threshold]) + const newSafeProps = useMemo( + () => + chain + ? createNewUndeployedSafeWithoutSalt( + data.safeVersion, + { + owners: data.owners.map((owner) => owner.address), + threshold: data.threshold, + }, + chain.chainId, + ) + : undefined, + [chain, data.owners, data.safeVersion, data.threshold], + ) - const { gasLimit } = useEstimateSafeCreationGas(safeParams, data.safeVersion) + // We estimate with a random nonce as we'll just slightly overestimates like this + const { gasLimit } = useEstimateSafeCreationGas( + newSafeProps ? { ...newSafeProps, saltNonce: Date.now().toString() } : undefined, + data.safeVersion, + ) const maxFeePerGas = gasPrice?.maxFeePerGas const maxPriorityFeePerGas = gasPrice?.maxPriorityFeePerGas @@ -179,46 +189,24 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { try { - if (!wallet || !chain) return + if (!wallet || !chain || !newSafeProps) return setIsCreating(true) - // Create universal deployment Data across chains: - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(data.safeVersion) - const safeL2Deployment = getSafeL2SingletonDeployment({ version: data.safeVersion, network: chain.chainId }) - const safeL2Address = safeL2Deployment?.defaultAddress - if (!safeL2Address) { - throw new Error('No Safe deployment found') - } - - const props: DeploySafeProps = { - safeAccountConfig: { - threshold: data.threshold, - owners: data.owners.map((owner) => owner.address), - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - }, - } // Figure out the shared available nonce across chains const nextAvailableNonce = await getAvailableSaltNonce( customRPCs, - { ...props, saltNonce: data.saltNonce.toString() }, + { ...newSafeProps, saltNonce: '0' }, data.networks, knownAddresses, - data.safeVersion, ) - const safeAddress = await computeNewSafeAddress( - wallet.provider, - { ...props, saltNonce: nextAvailableNonce }, - chain, - data.safeVersion, - ) + const replayedSafeWithNonce = { ...newSafeProps, saltNonce: nextAvailableNonce } + + const safeAddress = await predictAddressBasedOnReplayData(replayedSafeWithNonce, createWeb3(wallet.provider)) for (const network of data.networks) { - createSafe(network, props, safeAddress, nextAvailableNonce) + createSafe(network, replayedSafeWithNonce, safeAddress) } if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { @@ -234,12 +222,13 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { + const createSafe = async (chain: ChainInfo, props: ReplayedSafeProps, safeAddress: string) => { if (!wallet) return try { @@ -247,7 +236,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { // Create a counterfactual Safe - createCounterfactualSafe(chain, safeAddress, saltNonce, data, dispatch, props, PayMethod.PayNow) + replayCounterfactualSafeDeployment(chain.chainId, safeAddress, props, data.name, dispatch) if (taskId) { safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) @@ -283,26 +272,17 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { - onSubmitCallback(undefined, txHash) - }, - }, + props, data.safeVersion, + chain, + (txHash) => { + onSubmitCallback(undefined, txHash) + }, true, ) } diff --git a/src/components/new-safe/create/useEstimateSafeCreationGas.ts b/src/components/new-safe/create/useEstimateSafeCreationGas.ts index e5870689b6..4adcef1f5b 100644 --- a/src/components/new-safe/create/useEstimateSafeCreationGas.ts +++ b/src/components/new-safe/create/useEstimateSafeCreationGas.ts @@ -2,11 +2,12 @@ import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import useWallet from '@/hooks/wallets/useWallet' import useAsync from '@/hooks/useAsync' import { useCurrentChain } from '@/hooks/useChains' -import { estimateSafeCreationGas, type SafeCreationProps } from '@/components/new-safe/create/logic' +import { estimateSafeCreationGas } from '@/components/new-safe/create/logic' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' +import { type UndeployedSafeProps } from '@/store/slices' export const useEstimateSafeCreationGas = ( - safeParams: SafeCreationProps | undefined, + undeployedSafe: UndeployedSafeProps | undefined, safeVersion?: SafeVersion, ): { gasLimit?: bigint @@ -18,10 +19,10 @@ export const useEstimateSafeCreationGas = ( const wallet = useWallet() const [gasLimit, gasLimitError, gasLimitLoading] = useAsync(() => { - if (!wallet?.address || !chain || !web3ReadOnly || !safeParams) return + if (!wallet?.address || !chain || !web3ReadOnly || !undeployedSafe) return - return estimateSafeCreationGas(chain, web3ReadOnly, wallet.address, safeParams, safeVersion) - }, [wallet, chain, web3ReadOnly, safeParams, safeVersion]) + return estimateSafeCreationGas(chain, web3ReadOnly, wallet.address, undeployedSafe, safeVersion) + }, [wallet?.address, chain, web3ReadOnly, undeployedSafe, safeVersion]) return { gasLimit, gasLimitError, gasLimitLoading } } diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index ed5df1e7df..e0afb30ec5 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -43,7 +43,7 @@ import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sd import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' import { ChangeSignerSetupWarning } from '@/features/multichain/components/ChangeOwnerSetupWarning/ChangeOwnerSetupWarning' -import { isChangingSignerSetup } from '@/features/multichain/helpers/utils' +import { isChangingSignerSetup } from '@/features/multichain/utils/utils' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index 6b08bfea5b..7c524d6252 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -25,7 +25,7 @@ import type { SafeItem } from './useAllSafes' import FiatValue from '@/components/common/FiatValue' import QueueActions from './QueueActions' import { useGetHref } from './useGetHref' -import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' +import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' type AccountItemProps = { safeItem: SafeItem @@ -59,6 +59,8 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) ? extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) : undefined + const isReplayable = !safeItem.isWatchlist && (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) + return ( - + + safes.some((safeItem) => { + const undeployedSafe = undeployedSafes[safeItem.chainId]?.[safeItem.address] + // We can only replay deployed Safes and new counterfactual Safes. + return !undeployedSafe || !isPredictedSafeProps(undeployedSafe.props) + }), + [safes, undeployedSafes], + ) + const findOverview = (item: SafeItem) => { return safeOverviews?.find( (overview) => item.chainId === overview.chainId && sameAddress(overview.address.value, item.address), @@ -157,7 +168,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: /> ))} - {!isWatchlist && ( + {!isWatchlist && hasReplayableSafe && ( <> diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts index dbbb6d9b84..107d2ad12a 100644 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts @@ -4,8 +4,10 @@ import { type UndeployedSafesState, type UndeployedSafe, type ReplayedSafeProps import { sameAddress } from '@/utils/addresses' import { type MultiChainSafeItem } from '../useAllSafesGrouped' import { Safe_proxy_factory__factory } from '@/types/contracts' -import { keccak256, ethers, solidityPacked, getCreate2Address, type JsonRpcProvider } from 'ethers' +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' export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { if ('safes' in safe && 'address' in safe) { @@ -98,16 +100,18 @@ export const getSharedSetup = ( return undefined } -export const predictAddressBasedOnReplayData = async ( - safeCreationData: ReplayedSafeProps, - provider: JsonRpcProvider, -) => { - if (!safeCreationData.setupData) { - throw new Error('Cannot predict address without setupData') - } +const memoizedGetProxyCreationCode = memoize( + async (factoryAddress: string, provider: Provider) => { + return Safe_proxy_factory__factory.connect(factoryAddress, provider).proxyCreationCode() + }, + async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`, +) + +export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { + const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig) // Step 1: Hash the initializer - const initializerHash = keccak256(safeCreationData.setupData) + const initializerHash = keccak256(setupData) // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) @@ -116,10 +120,7 @@ export const predictAddressBasedOnReplayData = async ( const salt = keccak256(encoded) // Get Proxy creation code - const proxyCreationCode = await Safe_proxy_factory__factory.connect( - safeCreationData.factoryAddress, - provider, - ).proxyCreationCode() + const proxyCreationCode = await memoizedGetProxyCreationCode(safeCreationData.factoryAddress, provider) const constructorData = safeCreationData.masterCopy const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) diff --git a/src/features/counterfactual/ActivateAccountFlow.tsx b/src/features/counterfactual/ActivateAccountFlow.tsx index c5467474aa..89a9d1aa09 100644 --- a/src/features/counterfactual/ActivateAccountFlow.tsx +++ b/src/features/counterfactual/ActivateAccountFlow.tsx @@ -1,4 +1,4 @@ -import { createNewSafe, relayReplayedSafeCreation, relaySafeCreation } from '@/components/new-safe/create/logic' +import { createNewSafe, relaySafeCreation } from '@/components/new-safe/create/logic' import { NetworkFee, SafeSetupOverview } from '@/components/new-safe/create/steps/ReviewStep' import ReviewRow from '@/components/new-safe/ReviewRow' import { TxModalContext } from '@/components/tx-flow' @@ -8,12 +8,7 @@ import ErrorMessage from '@/components/tx/ErrorMessage' import { ExecutionMethod, ExecutionMethodSelector } from '@/components/tx/ExecutionMethodSelector' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' import { selectUndeployedSafe, type UndeployedSafe } from '@/features/counterfactual/store/undeployedSafesSlice' -import { - activateReplayedSafe, - CF_TX_GROUP_KEY, - extractCounterfactualSafeSetup, - isPredictedSafeProps, -} from '@/features/counterfactual/utils' +import { CF_TX_GROUP_KEY, extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features/counterfactual/utils' import useChainId from '@/hooks/useChainId' import { useCurrentChain } from '@/hooks/useChains' import useGasPrice, { getTotalFeeFormatted } from '@/hooks/useGasPrice' @@ -36,30 +31,19 @@ import { sameAddress } from '@/utils/addresses' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' import useIsWrongChain from '@/hooks/useIsWrongChain' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' -import { createWeb3 } from '@/hooks/wallets/web3' import { SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' import CheckWallet from '@/components/common/CheckWallet' const useActivateAccount = (undeployedSafe: UndeployedSafe | undefined) => { const chain = useCurrentChain() const [gasPrice] = useGasPrice() - const deploymentProps = useMemo( - () => - undeployedSafe && isPredictedSafeProps(undeployedSafe.props) - ? { - owners: undeployedSafe.props.safeAccountConfig.owners, - saltNonce: Number(undeployedSafe.props.safeDeploymentConfig?.saltNonce ?? 0), - threshold: undeployedSafe.props.safeAccountConfig.threshold, - } - : undefined, - [undeployedSafe], - ) - const safeVersion = - undeployedSafe && isPredictedSafeProps(undeployedSafe?.props) + undeployedSafe && + (isPredictedSafeProps(undeployedSafe?.props) ? undeployedSafe?.props.safeDeploymentConfig?.safeVersion - : undefined - const { gasLimit } = useEstimateSafeCreationGas(deploymentProps, safeVersion) + : undeployedSafe?.props.safeVersion) + + const { gasLimit } = useEstimateSafeCreationGas(undeployedSafe?.props, safeVersion) const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) const maxFeePerGas = gasPrice?.maxFeePerGas @@ -134,38 +118,19 @@ const ActivateAccountFlow = () => { try { if (willRelay) { - let taskId: string - if (isPredictedSafeProps(undeployedSafe.props)) { - taskId = await relaySafeCreation(chain, owners, threshold, Number(saltNonce!), safeVersion) - } else { - taskId = await relayReplayedSafeCreation(chain, undeployedSafe.props, safeVersion) - } + const taskId = await relaySafeCreation(chain, undeployedSafe.props) safeCreationDispatch(SafeCreationEvent.RELAYING, { groupKey: CF_TX_GROUP_KEY, taskId, safeAddress }) onSubmit() } else { - if (isPredictedSafeProps(undeployedSafe.props)) { - await createNewSafe( - wallet.provider, - { - safeAccountConfig: undeployedSafe.props.safeAccountConfig, - saltNonce, - options, - callback: onSubmit, - }, - safeVersion ?? getLatestSafeVersion(chain), - isMultichainSafe ? true : undefined, - ) - } else { - // Deploy replayed Safe Creation - const txResponse = await activateReplayedSafe( - safeVersion ?? getLatestSafeVersion(chain), - chain, - undeployedSafe.props, - createWeb3(wallet.provider), - ) - onSubmit(txResponse.hash) - } + await createNewSafe( + wallet.provider, + undeployedSafe.props, + safeVersion ?? getLatestSafeVersion(chain), + chain, + onSubmit, + isMultichainSafe ? true : undefined, + ) } } catch (_err) { const err = asError(_err) diff --git a/src/features/counterfactual/store/undeployedSafesSlice.ts b/src/features/counterfactual/store/undeployedSafesSlice.ts index 60bea32ef4..d24f9909fe 100644 --- a/src/features/counterfactual/store/undeployedSafesSlice.ts +++ b/src/features/counterfactual/store/undeployedSafesSlice.ts @@ -3,7 +3,7 @@ import { type RootState } from '@/store' import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit' import type { PredictedSafeProps } from '@safe-global/protocol-kit' import { selectChainIdAndSafeAddress, selectSafeAddress } from '@/store/common' -import { type CreationTransaction } from 'safe-client-gateway-sdk' +import { type SafeVersion } from '@safe-global/safe-core-sdk-types' export enum PendingSafeStatus { AWAITING_EXECUTION = 'AWAITING_EXECUTION', @@ -22,8 +22,21 @@ type UndeployedSafeStatus = { signerNonce?: number | null } -export type ReplayedSafeProps = Pick & { +export type ReplayedSafeProps = { + factoryAddress: string + masterCopy: string + safeAccountConfig: { + threshold: number + owners: string[] + fallbackHandler: string + to: string + data: string + paymentToken?: string + payment?: number + paymentReceiver: string + } saltNonce: string + safeVersion: SafeVersion } export type UndeployedSafeProps = PredictedSafeProps | ReplayedSafeProps diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index d25905cb85..77a1fff5fa 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -33,11 +33,10 @@ import { } from '@safe-global/safe-gateway-typescript-sdk' import type { BrowserProvider, ContractTransactionResponse, Eip1193Provider, Provider } from 'ethers' import type { NextRouter } from 'next/router' -import { Safe__factory } from '@/types/contracts' -import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' +import { getSafeL2SingletonDeployments, getSafeSingletonDeployments } from '@safe-global/safe-deployments' import { sameAddress } from '@/utils/addresses' -import { getReadOnlyProxyFactoryContract } from '@/services/contracts/safeContracts' +import { encodeSafeCreationTx } from '@/components/new-safe/create/logic' export const getUndeployedSafeInfo = (undeployedSafe: UndeployedSafe, address: string, chain: ChainInfo) => { const safeSetup = extractCounterfactualSafeSetup(undeployedSafe, chain.chainId) @@ -369,29 +368,34 @@ export const checkSafeActionViaRelay = (taskId: string, safeAddress: string, typ }, TIMEOUT_TIME) } -export const isReplayedSafeProps = (props: UndeployedSafeProps): props is ReplayedSafeProps => { - if ('setupData' in props && 'masterCopy' in props && 'factoryAddress' in props && 'saltNonce' in props) { - return true - } - return false -} +export const isReplayedSafeProps = (props: UndeployedSafeProps): props is ReplayedSafeProps => + 'safeAccountConfig' in props && 'masterCopy' in props && 'factoryAddress' in props && 'saltNonce' in props -export const isPredictedSafeProps = (props: UndeployedSafeProps): props is PredictedSafeProps => { - if ('safeAccountConfig' in props) { - return true - } - return false -} +export const isPredictedSafeProps = (props: UndeployedSafeProps): props is PredictedSafeProps => + 'safeAccountConfig' in props && !('masterCopy' in props) -const determineFallbackHandlerVersion = (fallbackHandler: string, chainId: string): SafeVersion | undefined => { +export const determineMasterCopyVersion = (masterCopy: string, chainId: string): SafeVersion | undefined => { const SAFE_VERSIONS: SafeVersion[] = ['1.4.1', '1.3.0', '1.2.0', '1.1.1', '1.0.0'] return SAFE_VERSIONS.find((version) => { - const deployments = getCompatibilityFallbackHandlerDeployments({ version })?.networkAddresses[chainId] + const isL1Singleton = () => { + const deployments = getSafeSingletonDeployments({ version })?.networkAddresses[chainId] + + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(masterCopy, deployment)) + } + return sameAddress(masterCopy, deployments) + } + + const isL2Singleton = () => { + const deployments = getSafeL2SingletonDeployments({ version })?.networkAddresses[chainId] - if (Array.isArray(deployments)) { - return deployments.some((deployment) => sameAddress(fallbackHandler, deployment)) + if (Array.isArray(deployments)) { + return deployments.some((deployment) => sameAddress(masterCopy, deployment)) + } + return sameAddress(masterCopy, deployments) } - return sameAddress(fallbackHandler, deployments) + + return isL1Singleton() || isL2Singleton() }) } @@ -419,41 +423,20 @@ export const extractCounterfactualSafeSetup = ( saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce, } } else { - if (!undeployedSafe.props.setupData) { - return undefined - } - const [owners, threshold, to, data, fallbackHandler, ...setupParams] = - Safe__factory.createInterface().decodeFunctionData('setup', undeployedSafe.props.setupData) - - const safeVersion = determineFallbackHandlerVersion(fallbackHandler, chainId) + const { owners, threshold, fallbackHandler } = undeployedSafe.props.safeAccountConfig return { owners, threshold: Number(threshold), fallbackHandler, - safeVersion, + safeVersion: undeployedSafe.props.safeVersion, saltNonce: undeployedSafe.props.saltNonce, } } } -export const activateReplayedSafe = async ( - safeVersion: SafeVersion, - chain: ChainInfo, - props: ReplayedSafeProps, - provider: BrowserProvider, -) => { - const usedSafeVersion = safeVersion ?? getLatestSafeVersion(chain) - const readOnlyProxyContract = await getReadOnlyProxyFactoryContract(usedSafeVersion, props.factoryAddress) - - if (!props.masterCopy || !props.setupData) { - throw Error('Cannot replay Safe without deployment info') - } - const data = readOnlyProxyContract.encode('createProxyWithNonce', [ - props.masterCopy, - props.setupData, - BigInt(props.saltNonce), - ]) +export const activateReplayedSafe = async (chain: ChainInfo, props: ReplayedSafeProps, provider: BrowserProvider) => { + const data = await encodeSafeCreationTx(props, chain) return (await provider.getSigner()).sendTransaction({ to: props.factoryAddress, diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index abdf712b5e..beccedec52 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -75,7 +75,7 @@ const ReplaySafeDialog = ({ const onFormSubmit = handleSubmit(async (data) => { const selectedChain = chain ?? replayableChains?.find((config) => config.chainId === data.chainId) - if (!safeCreationData || !safeCreationData.setupData || !selectedChain || !safeCreationData.masterCopy) { + if (!safeCreationData || !selectedChain) { return } diff --git a/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts b/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts index 4875d4cc14..e03a8f392e 100644 --- a/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts +++ b/src/features/multichain/hooks/__tests__/useCompatibleNetworks.test.ts @@ -44,39 +44,6 @@ describe('useCompatibleNetworks', () => { expect(result.current).toHaveLength(0) }) - it('should return empty list for incomplete creation data', () => { - const callData = { - owners: [faker.finance.ethereumAddress()], - threshold: 1, - to: ZERO_ADDRESS, - data: EMPTY_DATA, - fallbackHandler: faker.finance.ethereumAddress(), - paymentToken: ZERO_ADDRESS, - payment: 0, - paymentReceiver: ECOSYSTEM_ID_ADDRESS, - } - - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - - const creationData: ReplayedSafeProps = { - factoryAddress: faker.finance.ethereumAddress(), - masterCopy: null, - saltNonce: '0', - setupData, - } - const { result } = renderHook(() => useCompatibleNetworks(creationData)) - expect(result.current).toHaveLength(0) - }) - it('should set available to false for unknown masterCopies', () => { const callData = { owners: [faker.finance.ethereumAddress()], @@ -89,22 +56,12 @@ describe('useCompatibleNetworks', () => { paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - const creationData: ReplayedSafeProps = { factoryAddress: faker.finance.ethereumAddress(), masterCopy: faker.finance.ethereumAddress(), saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.4.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current.every((config) => config.available)).toEqual(false) @@ -121,22 +78,13 @@ describe('useCompatibleNetworks', () => { payment: 0, paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) { const creationData: ReplayedSafeProps = { factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, masterCopy: L1_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.4.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -149,7 +97,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_141_DEPLOYMENTS?.canonical?.address!, masterCopy: L2_141_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.4.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -170,24 +119,14 @@ describe('useCompatibleNetworks', () => { paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - // 1.3.0, L1 and canonical { const creationData: ReplayedSafeProps = { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -201,7 +140,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.canonical?.address!, masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -215,7 +155,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, masterCopy: L1_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -229,7 +170,8 @@ describe('useCompatibleNetworks', () => { factoryAddress: PROXY_FACTORY_130_DEPLOYMENTS?.eip155?.address!, masterCopy: L2_130_MASTERCOPY_DEPLOYMENTS?.eip155?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.3.0', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) @@ -250,22 +192,12 @@ describe('useCompatibleNetworks', () => { paymentReceiver: ECOSYSTEM_ID_ADDRESS, } - const setupData = safeInterface.encodeFunctionData('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - const creationData: ReplayedSafeProps = { factoryAddress: PROXY_FACTORY_111_DEPLOYMENTS?.canonical?.address!, masterCopy: L1_111_MASTERCOPY_DEPLOYMENTS?.canonical?.address!, saltNonce: '0', - setupData, + safeAccountConfig: callData, + safeVersion: '1.1.1', } const { result } = renderHook(() => useCompatibleNetworks(creationData)) expect(result.current).toHaveLength(5) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index e0f4ab2489..8eec258755 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -1,23 +1,19 @@ -import { renderHook, waitFor } from '@/tests/test-utils' +import { fakerChecksummedAddress, renderHook, waitFor } from '@/tests/test-utils' import { SAFE_CREATION_DATA_ERRORS, useSafeCreationData } from '../useSafeCreationData' import { faker } from '@faker-js/faker' -import { PendingSafeStatus, type ReplayedSafeProps, type UndeployedSafe } from '@/store/slices' +import { PendingSafeStatus, type UndeployedSafe } from '@/store/slices' import { PayMethod } from '@/features/counterfactual/PayNowPayLater' import { chainBuilder } from '@/tests/builders/chains' -import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as sdk from '@/services/tx/tx-sender/sdk' import * as cgwSdk from 'safe-client-gateway-sdk' import * as web3 from '@/hooks/wallets/web3' import { encodeMultiSendData, type SafeProvider } from '@safe-global/protocol-kit' -import { - getCompatibilityFallbackHandlerDeployment, - getProxyFactoryDeployment, - getSafeSingletonDeployment, -} from '@safe-global/safe-deployments' import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { type JsonRpcProvider } from 'ethers' import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' describe('useSafeCreationData', () => { beforeAll(() => { @@ -45,7 +41,17 @@ describe('useSafeCreationData', () => { factoryAddress: faker.finance.ethereumAddress(), saltNonce: '420', masterCopy: faker.finance.ethereumAddress(), - setupData: faker.string.hexadecimal({ length: 64 }), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + fallbackHandler: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, + paymentReceiver: ZERO_ADDRESS, + }, }, status: { status: PendingSafeStatus.AWAITING_EXECUTION, @@ -68,7 +74,7 @@ describe('useSafeCreationData', () => { }) }) - it('should extract replayedSafe data from an predictedSafe', async () => { + it('should throw error for legacy counterfactual Safes', async () => { const safeAddress = faker.finance.ethereumAddress() const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] const undeployedSafe = { @@ -98,90 +104,9 @@ describe('useSafeCreationData', () => { }, }) - const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - undeployedSafe.props.safeAccountConfig.owners, - undeployedSafe.props.safeAccountConfig.threshold, - ZERO_ADDRESS, - EMPTY_DATA, - getCompatibilityFallbackHandlerDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, - ZERO_ADDRESS, - 0, - ZERO_ADDRESS, - ]) - - // Should return replayedSafeProps - const expectedProps: ReplayedSafeProps = { - factoryAddress: getProxyFactoryDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, - saltNonce: '69', - masterCopy: getSafeSingletonDeployment({ network: '1', version: '1.3.0' })?.networkAddresses['1'], - setupData, - } await waitFor(async () => { await Promise.resolve() - expect(result.current).toEqual([expectedProps, undefined, false]) - }) - }) - - it('should extract replayedSafe data from an predictedSafe which has a custom Setup', async () => { - const safeAddress = faker.finance.ethereumAddress() - const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] - const undeployedSafe = { - props: { - safeAccountConfig: { - owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], - threshold: 2, - fallbackHandler: faker.finance.ethereumAddress(), - data: faker.string.hexadecimal({ length: 64 }), - to: faker.finance.ethereumAddress(), - payment: 123, - paymentReceiver: faker.finance.ethereumAddress(), - paymentToken: faker.finance.ethereumAddress(), - }, - safeDeploymentConfig: { - saltNonce: '69', - safeVersion: '1.3.0', - }, - }, - status: { - status: PendingSafeStatus.AWAITING_EXECUTION, - type: PayMethod.PayLater, - }, - } - - const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - undeployedSafe.props.safeAccountConfig.owners, - undeployedSafe.props.safeAccountConfig.threshold, - undeployedSafe.props.safeAccountConfig.to, - undeployedSafe.props.safeAccountConfig.data, - undeployedSafe.props.safeAccountConfig.fallbackHandler, - undeployedSafe.props.safeAccountConfig.paymentToken, - undeployedSafe.props.safeAccountConfig.payment, - undeployedSafe.props.safeAccountConfig.paymentReceiver, - ]) - - // Should return replayedSafeProps - const expectedProps: ReplayedSafeProps = { - factoryAddress: getProxyFactoryDeployment({ network: '1', version: '1.3.0' })?.defaultAddress!, - saltNonce: '69', - masterCopy: getSafeSingletonDeployment({ network: '1', version: '1.3.0' })?.defaultAddress, - setupData, - } - - // Run hook - const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { - initialReduxState: { - undeployedSafes: { - '1': { - [safeAddress]: undeployedSafe as UndeployedSafe, - }, - }, - }, - }) - - // Expectations - await waitFor(async () => { - await Promise.resolve() - expect(result.current).toEqual([expectedProps, undefined, false]) + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.LEGACY_COUNTERFATUAL), false]) }) }) @@ -429,7 +354,7 @@ describe('useSafeCreationData', () => { [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), + faker.string.hexadecimal({ length: 64, casing: 'lower' }), faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), 0, @@ -479,24 +404,34 @@ describe('useSafeCreationData', () => { }) it('should return transaction data for direct Safe creation txs', async () => { + const safeProps = { + owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], + threshold: 1, + to: fakerChecksummedAddress(), + data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), + fallbackHandler: fakerChecksummedAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: fakerChecksummedAddress(), + } const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], - 1, - faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), - faker.finance.ethereumAddress(), - faker.finance.ethereumAddress(), - 0, - faker.finance.ethereumAddress(), + safeProps.owners, + safeProps.threshold, + safeProps.to, + safeProps.data, + safeProps.fallbackHandler, + safeProps.paymentToken, + safeProps.payment, + safeProps.paymentReceiver, ]) const mockTxHash = faker.string.hexadecimal({ length: 64 }) - const mockFactoryAddress = faker.finance.ethereumAddress() - const mockMasterCopyAddress = faker.finance.ethereumAddress() + const mockFactoryAddress = fakerChecksummedAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress! jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ data: { created: new Date(Date.now()).toISOString(), - creator: faker.finance.ethereumAddress(), + creator: fakerChecksummedAddress(), factoryAddress: mockFactoryAddress, transactionHash: mockTxHash, masterCopy: mockMasterCopyAddress, @@ -532,8 +467,9 @@ describe('useSafeCreationData', () => { { factoryAddress: mockFactoryAddress, masterCopy: mockMasterCopyAddress, - setupData, + safeAccountConfig: safeProps, saltNonce: '69', + safeVersion: '1.3.0', }, undefined, false, @@ -542,20 +478,31 @@ describe('useSafeCreationData', () => { }) it('should return transaction data for creation bundles', async () => { + const safeProps = { + owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], + threshold: 1, + to: fakerChecksummedAddress(), + data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), + fallbackHandler: fakerChecksummedAddress(), + paymentToken: ZERO_ADDRESS, + payment: 0, + paymentReceiver: fakerChecksummedAddress(), + } + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ - [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], - 1, - faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), - faker.finance.ethereumAddress(), - faker.finance.ethereumAddress(), - 0, - faker.finance.ethereumAddress(), + safeProps.owners, + safeProps.threshold, + safeProps.to, + safeProps.data, + safeProps.fallbackHandler, + safeProps.paymentToken, + safeProps.payment, + safeProps.paymentReceiver, ]) const mockTxHash = faker.string.hexadecimal({ length: 64 }) const mockFactoryAddress = faker.finance.ethereumAddress() - const mockMasterCopyAddress = faker.finance.ethereumAddress() + const mockMasterCopyAddress = getSafeSingletonDeployment({ version: '1.4.1' })?.defaultAddress! jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ data: { @@ -610,7 +557,8 @@ describe('useSafeCreationData', () => { { factoryAddress: mockFactoryAddress, masterCopy: mockMasterCopyAddress, - setupData, + safeAccountConfig: safeProps, + safeVersion: '1.4.1', saltNonce: '69', }, undefined, diff --git a/src/features/multichain/hooks/useCompatibleNetworks.ts b/src/features/multichain/hooks/useCompatibleNetworks.ts index 21b662774a..6e59a72fc0 100644 --- a/src/features/multichain/hooks/useCompatibleNetworks.ts +++ b/src/features/multichain/hooks/useCompatibleNetworks.ts @@ -37,10 +37,6 @@ export const useCompatibleNetworks = ( const { masterCopy, factoryAddress } = creation - if (!masterCopy) { - return [] - } - const allL1SingletonDeployments = SUPPORTED_VERSIONS.map((version) => getSafeSingletonDeployments({ version }), ).filter(Boolean) as SingletonDeploymentV2[] diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index b3286baf2e..a2df2ad8b2 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -1,66 +1,43 @@ import useAsync, { type AsyncResult } from '@/hooks/useAsync' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' import { type UndeployedSafe, selectRpc, type ReplayedSafeProps, selectUndeployedSafes } from '@/store/slices' -import { Safe_proxy_factory__factory } from '@/types/contracts' +import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { sameAddress } from '@/utils/addresses' import { getCreationTransaction } from 'safe-client-gateway-sdk' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useAppSelector } from '@/store' -import { isPredictedSafeProps } from '@/features/counterfactual/utils' -import { - getReadOnlyGnosisSafeContract, - getReadOnlyProxyFactoryContract, - getReadOnlyFallbackHandlerContract, -} from '@/services/contracts/safeContracts' -import { getLatestSafeVersion } from '@/utils/chains' -import { ZERO_ADDRESS, EMPTY_DATA } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { determineMasterCopyVersion, isPredictedSafeProps } from '@/features/counterfactual/utils' import { logError } from '@/services/exceptions' import ErrorCodes from '@/services/exceptions/ErrorCodes' import { asError } from '@/services/exceptions/utils' -const getUndeployedSafeCreationData = async ( - undeployedSafe: UndeployedSafe, - chain: ChainInfo, -): Promise => { - if (isPredictedSafeProps(undeployedSafe.props)) { - // Copy predicted safe - // Encode Safe creation and determine the addresses the Safe creation would use - const { owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver } = - undeployedSafe.props.safeAccountConfig - const usedSafeVersion = undeployedSafe.props.safeDeploymentConfig?.safeVersion ?? getLatestSafeVersion(chain) - const readOnlySafeContract = await getReadOnlyGnosisSafeContract(chain, usedSafeVersion) - const readOnlyProxyFactoryContract = await getReadOnlyProxyFactoryContract(usedSafeVersion) - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(usedSafeVersion) - - const callData = { - owners, - threshold, - to: to ?? ZERO_ADDRESS, - data: data ?? EMPTY_DATA, - fallbackHandler: fallbackHandler ?? (await readOnlyFallbackHandlerContract.getAddress()), - paymentToken: paymentToken ?? ZERO_ADDRESS, - payment: payment ?? 0, - paymentReceiver: paymentReceiver ?? ZERO_ADDRESS, - } +export const SAFE_CREATION_DATA_ERRORS = { + TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', + NO_CREATION_DATA: 'The Safe creation information for this Safe could not be found or is incomplete.', + UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported.', + NO_PROVIDER: 'The RPC provider for the origin network is not available.', + LEGACY_COUNTERFATUAL: 'This undeployed Safe cannot be replayed. Please activate the Safe first.', +} - // @ts-ignore union type is too complex - const setupData = readOnlySafeContract.encode('setup', [ - callData.owners, - callData.threshold, - callData.to, - callData.data, - callData.fallbackHandler, - callData.paymentToken, - callData.payment, - callData.paymentReceiver, - ]) - - return { - factoryAddress: await readOnlyProxyFactoryContract.getAddress(), - masterCopy: await readOnlySafeContract.getAddress(), - saltNonce: undeployedSafe.props.safeDeploymentConfig?.saltNonce ?? '0', - setupData, - } +export const decodeSetupData = (setupData: string): ReplayedSafeProps['safeAccountConfig'] => { + const [owners, threshold, to, data, fallbackHandler, paymentToken, payment, paymentReceiver] = + Safe__factory.createInterface().decodeFunctionData('setup', setupData) + + return { + owners: [...owners], + threshold: Number(threshold), + to, + data, + fallbackHandler, + paymentToken, + payment: Number(payment), + paymentReceiver, + } +} + +const getUndeployedSafeCreationData = async (undeployedSafe: UndeployedSafe): Promise => { + if (isPredictedSafeProps(undeployedSafe.props)) { + throw new Error(SAFE_CREATION_DATA_ERRORS.LEGACY_COUNTERFATUAL) } // We already have a replayed Safe. In this case we can return the identical data @@ -70,13 +47,6 @@ const getUndeployedSafeCreationData = async ( const proxyFactoryInterface = Safe_proxy_factory__factory.createInterface() const createProxySelector = proxyFactoryInterface.getFunction('createProxyWithNonce').selector -export const SAFE_CREATION_DATA_ERRORS = { - TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', - NO_CREATION_DATA: 'The Safe creation information for this Safe could be found or is incomplete.', - UNSUPPORTED_SAFE_CREATION: 'The method this Safe was created with is not supported yet.', - NO_PROVIDER: 'The RPC provider for the origin network is not available.', -} - const getCreationDataForChain = async ( chain: ChainInfo, undeployedSafe: UndeployedSafe, @@ -85,7 +55,7 @@ const getCreationDataForChain = async ( ): Promise => { // 1. The safe is counterfactual if (undeployedSafe) { - return getUndeployedSafeCreationData(undeployedSafe, chain) + return getUndeployedSafeCreationData(undeployedSafe) } const { data: creation } = await getCreationTransaction({ @@ -119,7 +89,6 @@ const getCreationDataForChain = async ( } // decode tx - const [masterCopy, initializer, saltNonce] = proxyFactoryInterface.decodeFunctionData( 'createProxyWithNonce', `0x${txData.slice(startOfTx)}`, @@ -133,12 +102,19 @@ const getCreationDataForChain = async ( // We found the wrong tx. This tx seems to deploy multiple Safes at once. This is not supported yet. throw new Error(SAFE_CREATION_DATA_ERRORS.UNSUPPORTED_SAFE_CREATION) } + const safeAccountConfig = decodeSetupData(creation.setupData) + const safeVersion = determineMasterCopyVersion(creation.masterCopy, chain.chainId) + + if (!safeVersion) { + throw new Error('Could not determine Safe version of used master copy') + } return { factoryAddress: creation.factoryAddress, masterCopy: creation.masterCopy, - setupData: creation.setupData, + safeAccountConfig, saltNonce: saltNonce.toString(), + safeVersion, } } @@ -156,6 +132,7 @@ export const useSafeCreationData = (safeAddress: string, chains: ChainInfo[]): A try { for (const chain of chains) { const undeployedSafe = undeployedSafes[chain.chainId]?.[safeAddress] + try { const creationData = await getCreationDataForChain(chain, undeployedSafe, safeAddress, customRpc) return creationData diff --git a/src/features/multichain/helpers/utils.ts b/src/features/multichain/utils/utils.ts similarity index 100% rename from src/features/multichain/helpers/utils.ts rename to src/features/multichain/utils/utils.ts diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx index 81a95277fc..95fc419813 100644 --- a/src/tests/test-utils.tsx +++ b/src/tests/test-utils.tsx @@ -10,6 +10,8 @@ import * as web3 from '@/hooks/wallets/web3' import { type JsonRpcProvider, AbiCoder } from 'ethers' import { id } from 'ethers' import { Provider } from 'react-redux' +import { checksumAddress } from '@/utils/addresses' +import { faker } from '@faker-js/faker' const mockRouter = (props: Partial = {}): NextRouter => ({ asPath: '/', @@ -134,6 +136,8 @@ const mockWeb3Provider = ( return mockWeb3ReadOnly } +export const fakerChecksummedAddress = () => checksumAddress(faker.finance.ethereumAddress()) + // re-export everything export * from '@testing-library/react' From 91f05660c8b33d769e52a9e7aa933c18e9a20130 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 19 Sep 2024 15:47:22 +0200 Subject: [PATCH 18/74] [Multichain] Feat: multichain feature toggle (#4209) - add multichain feature toggle - do not offer multichain creation with toggle off - do not include migration if feature toggle is off --- .../NetworkSelector/NetworkMultiSelector.tsx | 13 +- .../new-safe/create/logic/index.test.ts | 322 ++++++++++++------ src/components/new-safe/create/logic/index.ts | 22 +- .../create/steps/ReviewStep/index.tsx | 3 +- .../MyAccounts/utils/multiChainSafe.ts | 11 +- src/utils/chains.ts | 1 + 6 files changed, 260 insertions(+), 112 deletions(-) diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx index 0eae24e1e8..6cd39c69f3 100644 --- a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -12,6 +12,7 @@ import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameSte import { getSafeSingletonDeployments } from '@safe-global/safe-deployments' import { getLatestSafeVersion } from '@/utils/chains' import { hasCanonicalDeployment } from '@/services/contracts/deployments' +import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe' const NetworkMultiSelector = ({ name, @@ -55,12 +56,22 @@ const NetworkMultiSelector = ({ const isOptionDisabled = useCallback( (optionNetwork: ChainInfo) => { - if (selectedNetworks.length === 0) return false + // Initially all networks are always available + if (selectedNetworks.length === 0) { + return false + } + const firstSelectedNetwork = selectedNetworks[0] // do not allow multi chain safes for advanced setup flow. if (isAdvancedFlow) return optionNetwork.chainId != firstSelectedNetwork.chainId + // Check required feature toggles + if (!hasMultiChainCreationFeatures(optionNetwork) || !hasMultiChainCreationFeatures(firstSelectedNetwork)) { + return true + } + + // Check if required deployments are available const optionHasCanonicalSingletonDeployment = hasCanonicalDeployment( getSafeSingletonDeployments({ network: optionNetwork.chainId, diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index b8a4545dd0..eb0f5d3036 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -5,7 +5,12 @@ import type { CompatibilityFallbackHandlerContractImplementationType } from '@sa import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import * as web3 from '@/hooks/wallets/web3' import * as sdkHelpers from '@/services/tx/tx-sender/sdk' -import { relaySafeCreation, getRedirect } from '@/components/new-safe/create/logic/index' +import { + relaySafeCreation, + getRedirect, + createNewUndeployedSafeWithoutSalt, + SAFE_TO_L2_SETUP_INTERFACE, +} from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' import { @@ -23,6 +28,13 @@ import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-type import { chainBuilder } from '@/tests/builders/chains' import { type ReplayedSafeProps } from '@/store/slices' import { faker } from '@faker-js/faker' +import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import { + getFallbackHandlerDeployment, + getProxyFactoryDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -32,114 +44,115 @@ const latestSafeVersion = getLatestSafeVersion( .build(), ) -describe('createNewSafeViaRelayer', () => { - const owner1 = toBeHex('0x1', 20) - const owner2 = toBeHex('0x2', 20) +describe('create/logic', () => { + describe('createNewSafeViaRelayer', () => { + const owner1 = toBeHex('0x1', 20) + const owner2 = toBeHex('0x2', 20) + + const mockChainInfo = chainBuilder() + .with({ + chainId: '1', + l2: false, + features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + }) + .build() - const mockChainInfo = chainBuilder() - .with({ - chainId: '1', - l2: false, - features: [FEATURES.SAFE_141 as unknown as GatewayFeatures], + beforeAll(() => { + jest.resetAllMocks() + jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) }) - .build() - beforeAll(() => { - jest.resetAllMocks() - jest.spyOn(web3, 'getWeb3ReadOnly').mockImplementation(() => provider) - }) + it('returns taskId if create Safe successfully relayed', async () => { + const mockSafeProvider = { + getExternalProvider: jest.fn(), + getExternalSigner: jest.fn(), + getChainId: jest.fn().mockReturnValue(BigInt(1)), + } as unknown as SafeProvider - it('returns taskId if create Safe successfully relayed', async () => { - const mockSafeProvider = { - getExternalProvider: jest.fn(), - getExternalSigner: jest.fn(), - getChainId: jest.fn().mockReturnValue(BigInt(1)), - } as unknown as SafeProvider - - jest.spyOn(gateway, 'relayTransaction').mockResolvedValue({ taskId: '0x123' }) - jest.spyOn(sdkHelpers, 'getSafeProvider').mockImplementation(() => mockSafeProvider) - - jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ - getAddress: () => '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', - } as unknown as CompatibilityFallbackHandlerContractImplementationType) - - const expectedSaltNonce = 69 - const expectedThreshold = 1 - const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() - const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) - const safeContractAddress = await ( - await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) - ).getAddress() - - const undeployedSafeProps: ReplayedSafeProps = { - safeAccountConfig: { - owners: [owner1, owner2], - threshold: 1, - data: EMPTY_DATA, - to: ZERO_ADDRESS, - fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), - paymentReceiver: ZERO_ADDRESS, - payment: 0, - paymentToken: ZERO_ADDRESS, - }, - safeVersion: latestSafeVersion, - factoryAddress: proxyFactoryAddress, - masterCopy: safeContractAddress, - saltNonce: '69', - } - - const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ - [owner1, owner2], - expectedThreshold, - ZERO_ADDRESS, - EMPTY_DATA, - await readOnlyFallbackHandlerContract.getAddress(), - ZERO_ADDRESS, - 0, - ZERO_ADDRESS, - ]) - - const expectedCallData = Proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ - safeContractAddress, - expectedInitializer, - expectedSaltNonce, - ]) - - const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) - - expect(taskId).toEqual('0x123') - expect(relayTransaction).toHaveBeenCalledTimes(1) - expect(relayTransaction).toHaveBeenCalledWith('1', { - to: proxyFactoryAddress, - data: expectedCallData, - version: latestSafeVersion, + jest.spyOn(gateway, 'relayTransaction').mockResolvedValue({ taskId: '0x123' }) + jest.spyOn(sdkHelpers, 'getSafeProvider').mockImplementation(() => mockSafeProvider) + + jest.spyOn(contracts, 'getReadOnlyFallbackHandlerContract').mockResolvedValue({ + getAddress: () => '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4', + } as unknown as CompatibilityFallbackHandlerContractImplementationType) + + const expectedSaltNonce = 69 + const expectedThreshold = 1 + const proxyFactoryAddress = await (await getReadOnlyProxyFactoryContract(latestSafeVersion)).getAddress() + const readOnlyFallbackHandlerContract = await getReadOnlyFallbackHandlerContract(latestSafeVersion) + const safeContractAddress = await ( + await getReadOnlyGnosisSafeContract(mockChainInfo, latestSafeVersion) + ).getAddress() + + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: await readOnlyFallbackHandlerContract.getAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: proxyFactoryAddress, + masterCopy: safeContractAddress, + saltNonce: '69', + } + + const expectedInitializer = Gnosis_safe__factory.createInterface().encodeFunctionData('setup', [ + [owner1, owner2], + expectedThreshold, + ZERO_ADDRESS, + EMPTY_DATA, + await readOnlyFallbackHandlerContract.getAddress(), + ZERO_ADDRESS, + 0, + ZERO_ADDRESS, + ]) + + const expectedCallData = Proxy_factory__factory.createInterface().encodeFunctionData('createProxyWithNonce', [ + safeContractAddress, + expectedInitializer, + expectedSaltNonce, + ]) + + const taskId = await relaySafeCreation(mockChainInfo, undeployedSafeProps) + + expect(taskId).toEqual('0x123') + expect(relayTransaction).toHaveBeenCalledTimes(1) + expect(relayTransaction).toHaveBeenCalledWith('1', { + to: proxyFactoryAddress, + data: expectedCallData, + version: latestSafeVersion, + }) }) - }) - it('should throw an error if relaying fails', () => { - const relayFailedError = new Error('Relay failed') - jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) + it('should throw an error if relaying fails', () => { + const relayFailedError = new Error('Relay failed') + jest.spyOn(gateway, 'relayTransaction').mockRejectedValue(relayFailedError) - const undeployedSafeProps: ReplayedSafeProps = { - safeAccountConfig: { - owners: [owner1, owner2], - threshold: 1, - data: EMPTY_DATA, - to: ZERO_ADDRESS, - fallbackHandler: faker.finance.ethereumAddress(), - paymentReceiver: ZERO_ADDRESS, - payment: 0, - paymentToken: ZERO_ADDRESS, - }, - safeVersion: latestSafeVersion, - factoryAddress: faker.finance.ethereumAddress(), - masterCopy: faker.finance.ethereumAddress(), - saltNonce: '69', - } - - expect(relaySafeCreation(mockChainInfo, undeployedSafeProps)).rejects.toEqual(relayFailedError) - }) + const undeployedSafeProps: ReplayedSafeProps = { + safeAccountConfig: { + owners: [owner1, owner2], + threshold: 1, + data: EMPTY_DATA, + to: ZERO_ADDRESS, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + payment: 0, + paymentToken: ZERO_ADDRESS, + }, + safeVersion: latestSafeVersion, + factoryAddress: faker.finance.ethereumAddress(), + masterCopy: faker.finance.ethereumAddress(), + saltNonce: '69', + } + expect(relaySafeCreation(mockChainInfo, undeployedSafeProps)).rejects.toEqual(relayFailedError) + }) + }) describe('getRedirect', () => { it("should redirect to home for any redirect that doesn't start with /apps", () => { const expected = { @@ -162,4 +175,111 @@ describe('createNewSafeViaRelayer', () => { ) }) }) + + describe('createNewUndeployedSafeWithoutSalt', () => { + it('should throw errors if no deployments are found', () => { + expect(() => + createNewUndeployedSafeWithoutSalt( + '1.4.1', + { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + }, + chainBuilder().with({ chainId: 'NON_EXISTING' }).build(), + ), + ).toThrowError(new Error('No Safe deployment found')) + }) + + it('should use l1 masterCopy and no migration on l1s without multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '1' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: false }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeSingletonDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '1' })?.defaultAddress, + }) + }) + + it('should use l2 masterCopy and no migration on l2s without multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeL2SingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + }) + }) + + it('should use l1 masterCopy and migration on l2s with multichain feature', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.4.1', + safeSetup, + chainBuilder() + .with({ chainId: '137' }) + // Multichain creation is toggled off + .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + to: SAFE_TO_L2_SETUP_ADDRESS, + data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [ + getSafeL2SingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + ]), + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.4.1', + masterCopy: getSafeSingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, + }) + }) + }) }) diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 47a8140d68..8b03859cd6 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -25,6 +25,7 @@ import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfa import { getSafeContractDeployment } from '@/services/contracts/deployments' import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { createWeb3 } from '@/hooks/wallets/web3' +import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe' export type SafeCreationProps = { owners: string[] @@ -202,36 +203,41 @@ export type UndeployedSafeWithoutSalt = Omit export const createNewUndeployedSafeWithoutSalt = ( safeVersion: SafeVersion, safeAccountConfig: Pick, - chainId: string, + chain: ChainInfo, ): UndeployedSafeWithoutSalt => { // Create universal deployment Data across chains: const fallbackHandlerDeployment = getCompatibilityFallbackHandlerDeployment({ version: safeVersion, - network: chainId, + network: chain.chainId, }) const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress - const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chainId }) + const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) const safeL2Address = safeL2Deployment?.defaultAddress - const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chainId }) + const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chain.chainId }) const safeL1Address = safeL1Deployment?.defaultAddress - const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chainId }) + const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chain.chainId }) const safeFactoryAddress = safeFactoryDeployment?.defaultAddress if (!safeL2Address || !safeL1Address || !safeFactoryAddress || !fallbackHandlerAddress) { throw new Error('No Safe deployment found') } + // Only do migration if the chain supports multiChain deployments. + const includeMigration = hasMultiChainCreationFeatures(chain) + + const masterCopy = includeMigration ? safeL1Address : chain.l2 ? safeL2Address : safeL1Address + const replayedSafe: Omit = { factoryAddress: safeFactoryAddress, - masterCopy: safeL1Address, + masterCopy, safeAccountConfig: { threshold: safeAccountConfig.threshold, owners: safeAccountConfig.owners, fallbackHandler: fallbackHandlerAddress, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]), + to: includeMigration ? SAFE_TO_L2_SETUP_ADDRESS : ZERO_ADDRESS, + data: includeMigration ? SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]) : EMPTY_DATA, paymentReceiver: ECOSYSTEM_ID_ADDRESS, }, safeVersion, diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 4f149f0994..e3d87b916e 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -139,6 +139,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps(false) const [submitError, setSubmitError] = useState() const isCounterfactualEnabled = useHasFeature(FEATURES.COUNTERFACTUAL) + const isMultiChainDeploymentEnabled = useHasFeature(FEATURES.MULTI_CHAIN_SAFE_CREATION) const isEIP1559 = chain && hasFeature(chain, FEATURES.EIP1559) const ownerAddresses = useMemo(() => data.owners.map((owner) => owner.address), [data.owners]) @@ -159,7 +160,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps owner.address), threshold: data.threshold, }, - chain.chainId, + chain, ) : undefined, [chain, data.owners, data.safeVersion, data.threshold], diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts index 107d2ad12a..5d8d12ab1a 100644 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts @@ -1,4 +1,4 @@ -import { type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' +import { type ChainInfo, type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' import { type SafeItem } from '../useAllSafes' import { type UndeployedSafesState, type UndeployedSafe, type ReplayedSafeProps } from '@/store/slices' import { sameAddress } from '@/utils/addresses' @@ -8,6 +8,7 @@ import { keccak256, ethers, solidityPacked, getCreate2Address, type Provider } f import { extractCounterfactualSafeSetup } from '@/features/counterfactual/utils' import { encodeSafeSetupCall } from '@/components/new-safe/create/logic' import { memoize } from 'lodash' +import { FEATURES, hasFeature } from '@/utils/chains' export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { if ('safes' in safe && 'address' in safe) { @@ -126,3 +127,11 @@ export const predictAddressBasedOnReplayData = async (safeCreationData: Replayed const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) } + +export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { + return ( + hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && + hasFeature(chain, FEATURES.COUNTERFACTUAL) && + hasFeature(chain, FEATURES.SAFE_141) + ) +} diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 0e5179df62..74355ac115 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -35,6 +35,7 @@ export enum FEATURES { ZODIAC_ROLES = 'ZODIAC_ROLES', SAFE_141 = 'SAFE_141', STAKING = 'STAKING', + MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', } export const FeatureRoutes = { From 7c321fefe32c0acb75af64949ac18ea138fe1628 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 19 Sep 2024 16:21:05 +0200 Subject: [PATCH 19/74] Fix: multichain disable tx creation for undeployed Safes (#4208) --- .../common/CheckWallet/index.test.tsx | 86 +++++++++++++++++-- src/components/common/CheckWallet/index.tsx | 40 ++++++--- .../counterfactual/ActivateAccountButton.tsx | 2 +- .../counterfactual/ActivateAccountFlow.tsx | 2 +- 4 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/components/common/CheckWallet/index.test.tsx b/src/components/common/CheckWallet/index.test.tsx index 35378993b9..96e10fd376 100644 --- a/src/components/common/CheckWallet/index.test.tsx +++ b/src/components/common/CheckWallet/index.test.tsx @@ -1,10 +1,14 @@ -import { render } from '@/tests/test-utils' +import { getByLabelText, render } from '@/tests/test-utils' import CheckWallet from '.' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useIsWrongChain from '@/hooks/useIsWrongChain' import useWallet from '@/hooks/wallets/useWallet' import { chainBuilder } from '@/tests/builders/chains' +import { useIsWalletDelegate } from '@/hooks/useDelegates' +import { faker } from '@faker-js/faker' +import { extendedSafeInfoBuilder } from '@/tests/builders/safe' +import useSafeInfo from '@/hooks/useSafeInfo' // mock useWallet jest.mock('@/hooks/wallets/useWallet', () => ({ @@ -38,6 +42,25 @@ jest.mock('@/hooks/useIsWrongChain', () => ({ default: jest.fn(() => false), })) +jest.mock('@/hooks/useDelegates', () => ({ + __esModule: true, + useIsWalletDelegate: jest.fn(() => false), +})) + +jest.mock('@/hooks/useSafeInfo', () => ({ + __esModule: true, + default: jest.fn(() => { + const safeAddress = faker.finance.ethereumAddress() + return { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: true }) + .build(), + } + }), +})) + const renderButton = () => render({(isOk) => }) @@ -62,7 +85,7 @@ describe('CheckWallet', () => { expect(container.querySelector('button')).toBeDisabled() // Check the tooltip text - expect(container.querySelector('span[aria-label]')).toHaveAttribute('aria-label', 'Please connect your wallet') + getByLabelText(container, 'Please connect your wallet') }) it('should disable the button when the wallet is connected to the right chain but is not an owner', () => { @@ -98,10 +121,7 @@ describe('CheckWallet', () => { const { container } = renderButton() expect(container.querySelector('button')).toBeDisabled() - expect(container.querySelector('span[aria-label]')).toHaveAttribute( - 'aria-label', - 'Your connected wallet is not a signer of this Safe Account', - ) + getByLabelText(container, 'Your connected wallet is not a signer of this Safe Account') const { container: allowContainer } = render( {(isOk) => }, @@ -110,6 +130,60 @@ describe('CheckWallet', () => { expect(allowContainer.querySelector('button')).not.toBeDisabled() }) + it('should not disable the button for delegates', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;(useIsWalletDelegate as jest.MockedFunction).mockReturnValueOnce(true) + + const { container } = renderButton() + + expect(container.querySelector('button')).not.toBeDisabled() + }) + + it('should disable the button for counterfactual Safes', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { container } = renderButton() + + expect(container.querySelector('button')).toBeDisabled() + getByLabelText(container, 'You need to activate the Safe before transacting') + }) + + it('should enable the button for counterfactual Safes if allowed', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + + const safeAddress = faker.finance.ethereumAddress() + const mockSafeInfo = { + safeAddress, + safe: extendedSafeInfoBuilder() + .with({ address: { value: safeAddress } }) + .with({ deployed: false }) + .build(), + } + + ;(useSafeInfo as jest.MockedFunction).mockReturnValueOnce( + mockSafeInfo as unknown as ReturnType, + ) + + const { container } = render( + {(isOk) => }, + ) + + expect(container.querySelector('button')).toBeEnabled() + }) + it('should allow non-owners if specified', () => { ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx index b354fb8f56..e5660fb3bf 100644 --- a/src/components/common/CheckWallet/index.tsx +++ b/src/components/common/CheckWallet/index.tsx @@ -1,5 +1,5 @@ import { useIsWalletDelegate } from '@/hooks/useDelegates' -import { type ReactElement } from 'react' +import { useMemo, type ReactElement } from 'react' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useWallet from '@/hooks/wallets/useWallet' @@ -14,12 +14,13 @@ type CheckWalletProps = { allowNonOwner?: boolean noTooltip?: boolean checkNetwork?: boolean + allowUndeployedSafe?: boolean } enum Message { WalletNotConnected = 'Please connect your wallet', NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account', - CounterfactualMultisig = 'You need to activate the Safe before transacting', + SafeNotActivated = 'You need to activate the Safe before transacting', } const CheckWallet = ({ @@ -28,28 +29,39 @@ const CheckWallet = ({ allowNonOwner, noTooltip, checkNetwork = false, + allowUndeployedSafe = false, }: CheckWalletProps): ReactElement => { const wallet = useWallet() const isSafeOwner = useIsSafeOwner() - const isSpendingLimit = useIsOnlySpendingLimitBeneficiary() + const isOnlySpendingLimit = useIsOnlySpendingLimitBeneficiary() const connectWallet = useConnectWallet() const isWrongChain = useIsWrongChain() const isDelegate = useIsWalletDelegate() const { safe } = useSafeInfo() - const isCounterfactualMultiSig = !allowNonOwner && !safe.deployed && safe.threshold > 1 + const isUndeployedSafe = !safe.deployed - const message = - wallet && - (isSafeOwner || allowNonOwner || (isSpendingLimit && allowSpendingLimit) || isDelegate) && - !isCounterfactualMultiSig - ? '' - : !wallet - ? Message.WalletNotConnected - : isCounterfactualMultiSig - ? Message.CounterfactualMultisig - : Message.NotSafeOwner + const message = useMemo(() => { + if (!wallet) { + return Message.WalletNotConnected + } + if (isUndeployedSafe && !allowUndeployedSafe) { + return Message.SafeNotActivated + } + if ((!allowNonOwner && !isSafeOwner && !isDelegate) || (isOnlySpendingLimit && !allowSpendingLimit)) { + return Message.NotSafeOwner + } + }, [ + allowNonOwner, + allowSpendingLimit, + allowUndeployedSafe, + isDelegate, + isOnlySpendingLimit, + isSafeOwner, + isUndeployedSafe, + wallet, + ]) if (checkNetwork && isWrongChain) return children(false) if (!message) return children(true) diff --git a/src/features/counterfactual/ActivateAccountButton.tsx b/src/features/counterfactual/ActivateAccountButton.tsx index 7b2c4efa43..fe7670e4b7 100644 --- a/src/features/counterfactual/ActivateAccountButton.tsx +++ b/src/features/counterfactual/ActivateAccountButton.tsx @@ -25,7 +25,7 @@ const ActivateAccountButton = () => { return ( - + {(isOk) => ( + + + {open && ( { + trackEvent({ ...OVERVIEW_EVENTS.CANCEL_ADD_NEW_NETWORK }) + onClose() + } + const onFormSubmit = handleSubmit(async (data) => { setIsSubmitting(true) @@ -97,6 +93,8 @@ const ReplaySafeDialog = ({ return } + trackEvent({ ...OVERVIEW_EVENTS.SUBMIT_ADD_NEW_NETWORK, label: selectedChain.chainName }) + // 2. Replay Safe creation and add it to the counterfactual Safes replayCounterfactualSafeDeployment(selectedChain.chainId, safeAddress, safeCreationData, data.name, dispatch) @@ -126,10 +124,8 @@ const ReplaySafeDialog = ({ !chain && safeCreationData && replayableChains && replayableChains.filter((chain) => chain.available).length === 0 return ( - e.stopPropagation()}> + - Add another network - @@ -187,7 +183,7 @@ const ReplaySafeDialog = ({ ) : ( <> - + ) } diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index f4a456600a..1f8748d630 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -35,6 +35,14 @@ export const OVERVIEW_EVENTS = { action: 'Add new network', category: OVERVIEW_CATEGORY, }, + SUBMIT_ADD_NEW_NETWORK: { + action: 'Submit add new network', + category: OVERVIEW_CATEGORY, + }, + CANCEL_ADD_NEW_NETWORK: { + action: 'Cancel add new network', + category: OVERVIEW_CATEGORY, + }, DELETED_FROM_WATCHLIST: { action: 'Deleted from watchlist', category: OVERVIEW_CATEGORY, From 88a9549a6d38fb369e0885234249761188376057 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:31:16 +0200 Subject: [PATCH 31/74] fix: Minified react error because of DOM nesting (#4244) --- .../welcome/MyAccounts/PaginatedSafeList.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx index 53a925c906..9f67b7de0f 100644 --- a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx +++ b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx @@ -129,8 +129,16 @@ const PaginatedSafeList = ({ safes, title, action, noSafesMessage, onLinkClick } )} ) : ( - - {safes ? noSafesMessage : 'Loading...'} + + {noSafesMessage} )} From 11754e1184f07aaa22ecf788fc1d385539922467 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Tue, 24 Sep 2024 15:41:24 +0200 Subject: [PATCH 32/74] fix: use wallet network as default for safe creation (#4241) --- .../NetworkSelector/NetworkMultiSelector.tsx | 19 +++++++++++++------ .../create/steps/SetNameStep/index.tsx | 7 ++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx index c983c3670a..4863182265 100644 --- a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -1,6 +1,6 @@ -import useChains from '@/hooks/useChains' +import useChains, { useCurrentChain } from '@/hooks/useChains' import useSafeAddress from '@/hooks/useSafeAddress' -import { useCallback, type ReactElement } from 'react' +import { useCallback, useEffect, type ReactElement } from 'react' import { Checkbox, Autocomplete, TextField, Chip } from '@mui/material' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import ChainIndicator from '../ChainIndicator' @@ -24,6 +24,7 @@ const NetworkMultiSelector = ({ const { configs } = useChains() const router = useRouter() const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() const { formState: { errors }, @@ -34,7 +35,7 @@ const NetworkMultiSelector = ({ const selectedNetworks: ChainInfo[] = useWatch({ control, name: SetNameStepFields.networks }) - const updateSelectedNetwork = useCallback( + const updateCurrentNetwork = useCallback( (chains: ChainInfo[]) => { if (chains.length !== 1) return const shortName = chains[0].shortName @@ -48,10 +49,10 @@ const NetworkMultiSelector = ({ (deletedChainId: string) => { const currentValues: ChainInfo[] = getValues(name) || [] const updatedValues = currentValues.filter((chain) => chain.chainId !== deletedChainId) - updateSelectedNetwork(updatedValues) + updateCurrentNetwork(updatedValues) setValue(name, updatedValues) }, - [getValues, name, setValue, updateSelectedNetwork], + [getValues, name, setValue, updateCurrentNetwork], ) const isOptionDisabled = useCallback( @@ -95,6 +96,12 @@ const NetworkMultiSelector = ({ [isAdvancedFlow, selectedNetworks], ) + useEffect(() => { + if (selectedNetworks.length === 1 && selectedNetworks[0].chainId !== currentChain?.chainId) { + updateCurrentNetwork([selectedNetworks[0]]) + } + }, [selectedNetworks, currentChain, updateCurrentNetwork]) + return ( <> option.chainId === value.chainId} onChange={(_, data) => { - updateSelectedNetwork(data) + updateCurrentNetwork(data) return field.onChange(data) }} /> diff --git a/src/components/new-safe/create/steps/SetNameStep/index.tsx b/src/components/new-safe/create/steps/SetNameStep/index.tsx index 004693f63d..88df9c58c7 100644 --- a/src/components/new-safe/create/steps/SetNameStep/index.tsx +++ b/src/components/new-safe/create/steps/SetNameStep/index.tsx @@ -21,6 +21,9 @@ import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useSafeSetupHints } from '../OwnerPolicyStep/useSafeSetupHints' import type { CreateSafeInfoItem } from '../../CreateSafeInfos' import NetworkMultiSelector from '@/components/common/NetworkSelector/NetworkMultiSelector' +import { useAppSelector } from '@/store' +import { selectChainById } from '@/store/chainsSlice' +import useWallet from '@/hooks/wallets/useWallet' type SetNameStepForm = { name: string @@ -51,8 +54,10 @@ function SetNameStep({ }) { const router = useRouter() const currentChain = useCurrentChain() + const wallet = useWallet() + const walletChain = useAppSelector((state) => selectChainById(state, wallet?.chainId || '')) - const initialState = data.networks.length > 1 ? data.networks : currentChain ? [currentChain] : [] + const initialState = data.networks.length ? data.networks : walletChain ? [walletChain] : [] const formMethods = useForm({ mode: 'all', defaultValues: { From 5c9be782cea04a58a6d8ba8f9aff9cc82dc43509 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Tue, 24 Sep 2024 15:59:28 +0200 Subject: [PATCH 33/74] Fix(Multichain): Show owner setup warning also when replacing owners and changing threshold [SW-150] (#4198) * feat: show signer setup warning for swapping signer or changing threshold. * feat: move the change signer warning to the relavent block in the confirmation scree --- src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx | 3 +++ .../flows/ChangeThreshold/ReviewChangeThreshold.tsx | 3 +++ .../tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx | 3 +++ src/components/tx/SignOrExecuteForm/index.tsx | 7 ------- src/components/tx/security/blockaid/index.tsx | 4 +--- .../SignerSetupWarning/ChangeSignerSetupWarning.tsx | 11 ++++------- 6 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index e2913389c5..38fca21cc2 100644 --- a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -15,6 +15,7 @@ import { OwnerList } from '../../common/OwnerList' import MinusIcon from '@/public/images/common/minus.svg' import EthHashInfo from '@/components/common/EthHashInfo' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwnerFlowProps }) => { const dispatch = useAppDispatch() @@ -73,6 +74,8 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn )} + + Any transaction requires the confirmation of: diff --git a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx index 78d8958f5f..8fe4425152 100644 --- a/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx +++ b/src/components/tx-flow/flows/ChangeThreshold/ReviewChangeThreshold.tsx @@ -10,6 +10,7 @@ import { ChangeThresholdFlowFieldNames } from '@/components/tx-flow/flows/Change import type { ChangeThresholdFlowProps } from '@/components/tx-flow/flows/ChangeThreshold' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) => { const { safe } = useSafeInfo() @@ -28,6 +29,8 @@ const ReviewChangeThreshold = ({ params }: { params: ChangeThresholdFlowProps }) return ( + +
    Any transaction will require the confirmation of: diff --git a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx b/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx index 8477fb570d..4b15885928 100644 --- a/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx +++ b/src/components/tx-flow/flows/RemoveOwner/ReviewRemoveOwner.tsx @@ -13,6 +13,7 @@ import type { RemoveOwnerFlowProps } from '.' import EthHashInfo from '@/components/common/EthHashInfo' import commonCss from '@/components/tx-flow/common/styles.module.css' +import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): ReactElement => { const addressBook = useAddressBook() @@ -46,6 +47,8 @@ export const ReviewRemoveOwner = ({ params }: { params: RemoveOwnerFlowProps }): hasExplorer /> + + diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 5d1e15c466..1c2a0b4a53 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -27,7 +27,6 @@ import { trackEvent } from '@/services/analytics' import useChainId from '@/hooks/useChainId' import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' -import { isSettingTwapFallbackHandler } from '@/features/swap/helpers/utils' import { isCustomTxInfo, isGenericConfirmation, isMigrateToL2MultiSend } from '@/utils/transaction-guards' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' @@ -42,9 +41,7 @@ import { extractMigrationL2MasterCopyAddress } from '@/utils/transactions' import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' import { useGetTransactionDetailsQuery, useLazyGetTransactionDetailsQuery } from '@/store/gateway' import { skipToken } from '@reduxjs/toolkit/query/react' -import { isChangingSignerSetup } from '@/features/multichain/utils/utils' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' -import { ChangeSignerSetupWarning } from '@/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning' export type SubmitCallback = (txId: string, isExecuted?: boolean) => void @@ -118,8 +115,6 @@ export const SignOrExecuteForm = ({ const { safe } = useSafeInfo() const isSafeOwner = useIsSafeOwner() const isCounterfactualSafe = !safe.deployed - const isChangingFallbackHandler = isSettingTwapFallbackHandler(decodedData) - const isChangingSigners = isChangingSignerSetup(decodedData) const isMultiChainMigration = isMigrateToL2MultiSend(decodedData) const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(decodedData) @@ -207,8 +202,6 @@ export const SignOrExecuteForm = ({ - {isChangingSigners && } - {!isMultiChainMigration && } diff --git a/src/components/tx/security/blockaid/index.tsx b/src/components/tx/security/blockaid/index.tsx index f1c18f9736..7070f91edb 100644 --- a/src/components/tx/security/blockaid/index.tsx +++ b/src/components/tx/security/blockaid/index.tsx @@ -14,7 +14,6 @@ import BlockaidIcon from '@/public/images/transactions/blockaid-icon.svg' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' import { type SecurityWarningProps, mapSecuritySeverity } from '../utils' import { BlockaidHint } from './BlockaidHint' -import Warning from '@/public/images/notifications/alert.svg' import { SecuritySeverity } from '@/services/security/modules/types' export const REASON_MAPPING: Record = { @@ -65,7 +64,6 @@ const BlockaidResultWarning = ({ <> } className={css.customAlert} sx={ needsRiskConfirmation @@ -136,7 +134,7 @@ const ResultDescription = ({ const BlockaidError = () => { return ( - } className={css.customAlert}> + Proceed with caution diff --git a/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx b/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx index 1708a07e5b..70b3a809b2 100644 --- a/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx +++ b/src/features/multichain/components/SignerSetupWarning/ChangeSignerSetupWarning.tsx @@ -1,6 +1,5 @@ -import { Box } from '@mui/material' +import { Alert } from '@mui/material' import { useIsMultichainSafe } from '../../hooks/useIsMultichainSafe' -import ErrorMessage from '@/components/tx/ErrorMessage' import { useCurrentChain } from '@/hooks/useChains' export const ChangeSignerSetupWarning = () => { @@ -10,10 +9,8 @@ export const ChangeSignerSetupWarning = () => { if (!isMultichainSafe) return return ( - - - {`Signers are not consistent across networks on this account. Changing signers will only affect the account on ${currentChain}`} - - + + {`Signers are not consistent across networks on this account. Changing signers will only affect the account on ${currentChain?.chainName}`} + ) } From ffe91391de0704af79e24153b8872f5fb3dbd2b8 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:13:57 +0200 Subject: [PATCH 34/74] fix: Add loading spinner to add new network form when submitting (#4245) --- .../multichain/components/CreateSafeOnNewChain/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 2d34d57ccb..77717c879b 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -3,7 +3,7 @@ import NameInput from '@/components/common/NameInput' import NetworkInput from '@/components/common/NetworkInput' import ErrorMessage from '@/components/tx/ErrorMessage' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' -import { Box, Button, CircularProgress, DialogActions, DialogContent, Divider, Stack, Typography } from '@mui/material' +import { Box, Button, CircularProgress, DialogActions, DialogContent, Stack, Typography } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' import { useSafeCreationData } from '../../hooks/useSafeCreationData' import { replayCounterfactualSafeDeployment } from '@/features/counterfactual/utils' @@ -170,8 +170,7 @@ const ReplaySafeDialog = ({ - - + {isUnsupportedSafeCreationVersion ? ( @@ -187,7 +186,7 @@ const ReplaySafeDialog = ({ Cancel )} From 54f7c473c74d2730137d90320b2c853a45373601 Mon Sep 17 00:00:00 2001 From: James Mealy Date: Wed, 25 Sep 2024 11:17:08 +0200 Subject: [PATCH 35/74] Feat(Multichain): simplify multichain naming options [SW-187] [SW-221] [SW-211] (#4243) * feat: disable renaming multichain sub safes from the context menu * fix: when removing a CF safe, also remove it from the address book * feat: remove name input on replay safe modal * feat: do not show chain indicator when renaming a multichain safe * refactor: combine upsertAddressBookEntry and upsertMultichainAddressBookEntry * refactor: simplify upsertAddressBookEntries if-else block --- .../address-book/EntryDialog/index.tsx | 15 ++++++++------- .../address-book/ImportDialog/index.tsx | 4 ++-- .../new-safe/create/logic/address-book.ts | 8 ++++---- .../load/steps/SafeReviewStep/index.tsx | 10 +++++----- .../settings/owner/EditOwnerDialog/index.tsx | 6 +++--- .../sidebar/SafeListContextMenu/index.tsx | 16 ++++++++++------ .../sidebar/SafeListRemoveDialog/index.tsx | 2 ++ .../tx-flow/flows/AddOwner/ReviewOwner.tsx | 6 +++--- .../welcome/MyAccounts/AccountItem.tsx | 2 +- .../welcome/MyAccounts/SubAccountItem.tsx | 4 +++- src/features/counterfactual/utils.ts | 6 +++--- .../components/CreateSafeOnNewChain/index.tsx | 13 +++++++------ src/store/__tests__/addressBookSlice.test.ts | 19 +++++++++---------- src/store/addressBookSlice.ts | 17 ++--------------- 14 files changed, 62 insertions(+), 66 deletions(-) diff --git a/src/components/address-book/EntryDialog/index.tsx b/src/components/address-book/EntryDialog/index.tsx index 41616de15d..e2d3202776 100644 --- a/src/components/address-book/EntryDialog/index.tsx +++ b/src/components/address-book/EntryDialog/index.tsx @@ -7,8 +7,8 @@ import ModalDialog from '@/components/common/ModalDialog' import NameInput from '@/components/common/NameInput' import useChainId from '@/hooks/useChainId' import { useAppDispatch } from '@/store' -import { upsertAddressBookEntry, upsertMultichainAddressBookEntry } from '@/store/addressBookSlice' import madProps from '@/utils/mad-props' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' export type AddressEntry = { name: string @@ -41,11 +41,7 @@ function EntryDialog({ const { handleSubmit, formState } = methods const submitCallback = handleSubmit((data: AddressEntry) => { - if (chainIds) { - dispatch(upsertMultichainAddressBookEntry({ ...data, chainIds })) - } else { - dispatch(upsertAddressBookEntry({ ...data, chainId: currentChainId })) - } + dispatch(upsertAddressBookEntries({ ...data, chainIds: chainIds ?? [currentChainId] })) handleClose() }) @@ -55,7 +51,12 @@ function EntryDialog({ } return ( - + 1} + >
    diff --git a/src/components/address-book/ImportDialog/index.tsx b/src/components/address-book/ImportDialog/index.tsx index 7b83a40d8b..c470792962 100644 --- a/src/components/address-book/ImportDialog/index.tsx +++ b/src/components/address-book/ImportDialog/index.tsx @@ -7,7 +7,7 @@ import type { ParseResult } from 'papaparse' import { type ReactElement, useState, type MouseEvent, useMemo } from 'react' import ModalDialog from '@/components/common/ModalDialog' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { useAppDispatch } from '@/store' import css from './styles.module.css' @@ -60,7 +60,7 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen for (const entry of entries) { const [address, name, chainId] = entry - dispatch(upsertAddressBookEntry({ address, name, chainId: chainId.trim() })) + dispatch(upsertAddressBookEntries({ address, name, chainIds: [chainId.trim()] })) } trackEvent({ ...ADDRESS_BOOK_EVENTS.IMPORT, label: entries.length }) diff --git a/src/components/new-safe/create/logic/address-book.ts b/src/components/new-safe/create/logic/address-book.ts index e803e07aac..e57b77a6ad 100644 --- a/src/components/new-safe/create/logic/address-book.ts +++ b/src/components/new-safe/create/logic/address-book.ts @@ -1,6 +1,6 @@ import type { AppThunk } from '@/store' import { addOrUpdateSafe } from '@/store/addedSafesSlice' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' import type { NamedAddress } from '@/components/new-safe/create/types' @@ -13,8 +13,8 @@ export const updateAddressBook = ( ): AppThunk => { return (dispatch) => { dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address, name, }), @@ -23,7 +23,7 @@ export const updateAddressBook = ( owners.forEach((owner) => { const entryName = owner.name || owner.ens if (entryName) { - dispatch(upsertAddressBookEntry({ chainId, address: owner.address, name: entryName })) + dispatch(upsertAddressBookEntries({ chainIds: [chainId], address: owner.address, name: entryName })) } }) diff --git a/src/components/new-safe/load/steps/SafeReviewStep/index.tsx b/src/components/new-safe/load/steps/SafeReviewStep/index.tsx index d414e59419..3ebd7ae2d7 100644 --- a/src/components/new-safe/load/steps/SafeReviewStep/index.tsx +++ b/src/components/new-safe/load/steps/SafeReviewStep/index.tsx @@ -13,10 +13,10 @@ import { useAppDispatch } from '@/store' import { useRouter } from 'next/router' import { addOrUpdateSafe } from '@/store/addedSafesSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { LOAD_SAFE_EVENTS, OPEN_SAFE_LABELS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { AppRoutes } from '@/config/routes' import ReviewRow from '@/components/new-safe/ReviewRow' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' const SafeReviewStep = ({ data, onBack }: StepRenderProps) => { const chain = useCurrentChain() @@ -44,8 +44,8 @@ const SafeReviewStep = ({ data, onBack }: StepRenderProps) => ) dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address: safeAddress, name: safeName, }), @@ -59,8 +59,8 @@ const SafeReviewStep = ({ data, onBack }: StepRenderProps) => } dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address, name: entryName, }), diff --git a/src/components/settings/owner/EditOwnerDialog/index.tsx b/src/components/settings/owner/EditOwnerDialog/index.tsx index 75790f99c7..b12aab771d 100644 --- a/src/components/settings/owner/EditOwnerDialog/index.tsx +++ b/src/components/settings/owner/EditOwnerDialog/index.tsx @@ -4,11 +4,11 @@ import NameInput from '@/components/common/NameInput' import Track from '@/components/common/Track' import { SETTINGS_EVENTS } from '@/services/analytics/events/settings' import { useAppDispatch } from '@/store' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' import EditIcon from '@/public/images/common/edit.svg' import { Box, Button, DialogActions, DialogContent, IconButton, Tooltip, SvgIcon } from '@mui/material' import { useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' type EditOwnerValues = { name: string @@ -24,8 +24,8 @@ export const EditOwnerDialog = ({ chainId, address, name }: { chainId: string; a const onSubmit = (data: EditOwnerValues) => { if (data.name !== name) { dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address, name: data.name, }), diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index ec9cfa067d..5d1e77d867 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -34,11 +34,13 @@ const SafeListContextMenu = ({ address, chainId, addNetwork, + rename, }: { name: string address: string chainId: string addNetwork: boolean + rename: boolean }): ReactElement => { const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) const isAdded = !!addedSafes?.[address] @@ -78,12 +80,14 @@ const SafeListContextMenu = ({ ({ color: palette.border.main })} /> - - - - - {hasName ? 'Rename' : 'Give name'} - + {rename && ( + + + + + {hasName ? 'Rename' : 'Give name'} + + )} {isAdded && ( diff --git a/src/components/sidebar/SafeListRemoveDialog/index.tsx b/src/components/sidebar/SafeListRemoveDialog/index.tsx index 17718eb6a4..6332ba7a78 100644 --- a/src/components/sidebar/SafeListRemoveDialog/index.tsx +++ b/src/components/sidebar/SafeListRemoveDialog/index.tsx @@ -12,6 +12,7 @@ import Track from '@/components/common/Track' import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import { AppRoutes } from '@/config/routes' import router from 'next/router' +import { removeAddressBookEntry } from '@/store/addressBookSlice' const SafeListRemoveDialog = ({ handleClose, @@ -31,6 +32,7 @@ const SafeListRemoveDialog = ({ const handleConfirm = () => { dispatch(removeSafe({ chainId, address })) + dispatch(removeAddressBookEntry({ chainId, address })) handleClose() } diff --git a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx index 38fca21cc2..7806b28b97 100644 --- a/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx +++ b/src/components/tx-flow/flows/AddOwner/ReviewOwner.tsx @@ -7,7 +7,7 @@ import useSafeInfo from '@/hooks/useSafeInfo' import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics' import { createSwapOwnerTx, createAddOwnerTx } from '@/services/tx/tx-sender' import { useAppDispatch } from '@/store' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { SafeTxContext } from '../../SafeTxProvider' import type { AddOwnerFlowProps } from '.' import type { ReplaceOwnerFlowProps } from '../ReplaceOwner' @@ -44,8 +44,8 @@ export const ReviewOwner = ({ params }: { params: AddOwnerFlowProps | ReplaceOwn const addAddressBookEntryAndSubmit = () => { if (typeof newOwner.name !== 'undefined') { dispatch( - upsertAddressBookEntry({ - chainId, + upsertAddressBookEntries({ + chainIds: [chainId], address: newOwner.address, name: newOwner.name, }), diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index 7c524d6252..0c05222b42 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -120,7 +120,7 @@ const AccountItem = ({ onLinkClick, safeItem, safeOverview }: AccountItemProps) - + - + {undeployedSafe && ( + + )} ({ mode: 'all', defaultValues: { - name: currentName, chainId: chain?.chainId || '', }, }) @@ -96,7 +93,13 @@ const ReplaySafeDialog = ({ trackEvent({ ...OVERVIEW_EVENTS.SUBMIT_ADD_NEW_NETWORK, label: selectedChain.chainName }) // 2. Replay Safe creation and add it to the counterfactual Safes - replayCounterfactualSafeDeployment(selectedChain.chainId, safeAddress, safeCreationData, data.name, dispatch) + replayCounterfactualSafeDeployment( + selectedChain.chainId, + safeAddress, + safeCreationData, + currentName || '', + dispatch, + ) router.push({ query: { @@ -152,8 +155,6 @@ const ReplaySafeDialog = ({ This Safe cannot be replayed on any chains. ) : ( <> - - {chain ? ( ) : ( diff --git a/src/store/__tests__/addressBookSlice.test.ts b/src/store/__tests__/addressBookSlice.test.ts index ca4e11a2d9..5c1be3a222 100644 --- a/src/store/__tests__/addressBookSlice.test.ts +++ b/src/store/__tests__/addressBookSlice.test.ts @@ -2,10 +2,9 @@ import { faker } from '@faker-js/faker' import { addressBookSlice, setAddressBook, - upsertAddressBookEntry, removeAddressBookEntry, selectAddressBookByChain, - upsertMultichainAddressBookEntry, + upsertAddressBookEntries, } from '../addressBookSlice' const initialState = { @@ -22,8 +21,8 @@ describe('addressBookSlice', () => { it('should insert an entry in the address book', () => { const state = addressBookSlice.reducer( initialState, - upsertAddressBookEntry({ - chainId: '1', + upsertAddressBookEntries({ + chainIds: ['1'], address: '0x2', name: 'Fred', }), @@ -37,8 +36,8 @@ describe('addressBookSlice', () => { it('should ignore empty names in the address book', () => { const state = addressBookSlice.reducer( initialState, - upsertAddressBookEntry({ - chainId: '1', + upsertAddressBookEntries({ + chainIds: ['1'], address: '0x2', name: '', }), @@ -49,8 +48,8 @@ describe('addressBookSlice', () => { it('should edit an entry in the address book', () => { const state = addressBookSlice.reducer( initialState, - upsertAddressBookEntry({ - chainId: '1', + upsertAddressBookEntries({ + chainIds: ['1'], address: '0x0', name: 'Alice in Wonderland', }), @@ -65,7 +64,7 @@ describe('addressBookSlice', () => { const address = faker.finance.ethereumAddress() const state = addressBookSlice.reducer( initialState, - upsertMultichainAddressBookEntry({ + upsertAddressBookEntries({ chainIds: ['1', '10', '100', '137'], address, name: 'Max', @@ -84,7 +83,7 @@ describe('addressBookSlice', () => { const address = faker.finance.ethereumAddress() const state = addressBookSlice.reducer( initialState, - upsertMultichainAddressBookEntry({ + upsertAddressBookEntries({ chainIds: ['1', '10', '100', '137'], address, name: '', diff --git a/src/store/addressBookSlice.ts b/src/store/addressBookSlice.ts index 99dc98a981..72c3a975d5 100644 --- a/src/store/addressBookSlice.ts +++ b/src/store/addressBookSlice.ts @@ -24,19 +24,7 @@ export const addressBookSlice = createSlice({ return action.payload }, - upsertAddressBookEntry: (state, action: PayloadAction<{ chainId: string; address: string; name: string }>) => { - const { chainId, address, name } = action.payload - if (name.trim() === '') { - return - } - if (!state[chainId]) state[chainId] = {} - state[chainId][address] = name - }, - - upsertMultichainAddressBookEntry: ( - state, - action: PayloadAction<{ chainIds: string[]; address: string; name: string }>, - ) => { + upsertAddressBookEntries: (state, action: PayloadAction<{ chainIds: string[]; address: string; name: string }>) => { const { chainIds, address, name } = action.payload if (name.trim() === '') { return @@ -57,8 +45,7 @@ export const addressBookSlice = createSlice({ }, }) -export const { setAddressBook, upsertAddressBookEntry, upsertMultichainAddressBookEntry, removeAddressBookEntry } = - addressBookSlice.actions +export const { setAddressBook, upsertAddressBookEntries, removeAddressBookEntry } = addressBookSlice.actions export const selectAllAddressBooks = (state: RootState): AddressBookState => { return state[addressBookSlice.name] From 6880bf0c784b931c2cd3219be93cb84c223ebc55 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:05:15 +0200 Subject: [PATCH 36/74] fix: Display current chain network logo in success screen as a fallback (#4246) --- src/components/new-safe/create/steps/ReviewStep/index.tsx | 2 +- src/features/counterfactual/CounterfactualSuccessScreen.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 15357a6b7b..60b5e871b1 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -212,7 +212,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps { const [open, setOpen] = useState(false) const [safeAddress, setSafeAddress] = useState() - const [networks, setNetworks] = useState([]) const chain = useCurrentChain() + const [networks, setNetworks] = useState([]) const addressBooks = useAllAddressBooks() const safeName = safeAddress && chain ? addressBooks?.[chain.chainId]?.[safeAddress] : '' const isCFCreation = !!networks.length @@ -84,7 +84,7 @@ const CounterfactualSuccessScreen = () => { {safeAddress && ( - + 0 ? networks : chain ? [chain] : []} /> {safeName} From 2910cec45ee3690e97ca2404fb24c4d2e581d4fc Mon Sep 17 00:00:00 2001 From: Usame Algan Date: Wed, 25 Sep 2024 12:20:16 +0200 Subject: [PATCH 37/74] chore: Fix lint issues --- src/components/common/NetworkInput/index.tsx | 3 +- .../MyAccounts/utils/multiChainSafe.test.ts | 22 -- src/features/counterfactual/utils.ts | 43 --- .../__tests__/useSafeCreationData.test.ts | 4 +- src/hooks/useMnemonicName/dict.ts | 256 ------------------ src/hooks/useMnemonicName/index.ts | 3 +- src/utils/transactions.ts | 1 - 7 files changed, 5 insertions(+), 327 deletions(-) diff --git a/src/components/common/NetworkInput/index.tsx b/src/components/common/NetworkInput/index.tsx index 67b51bfc15..cd4385a4db 100644 --- a/src/components/common/NetworkInput/index.tsx +++ b/src/components/common/NetworkInput/index.tsx @@ -46,7 +46,8 @@ const NetworkInput = ({ name={name} rules={{ required }} control={control} - render={({ field: { ref, ...field }, fieldState: { error } }) => ( + // eslint-disable-next-line + render={({ field: { ref, ...field } }) => ( Network renderMenuItem(chain.chainId, false))} {offerSafeCreation && isSafeOpened && ( - + )} ) : ( diff --git a/src/components/common/NetworkSelector/styles.module.css b/src/components/common/NetworkSelector/styles.module.css index d4d38715da..c601e204c2 100644 --- a/src/components/common/NetworkSelector/styles.module.css +++ b/src/components/common/NetworkSelector/styles.module.css @@ -33,6 +33,7 @@ } .listSubHeader { + background-color: var(--color-background-main); text-transform: uppercase; font-size: 11px; font-weight: bold; @@ -40,20 +41,13 @@ text-align: center; letter-spacing: 1px; width: 100%; + margin-top: var(--space-1); } [data-theme='dark'] .undeployedNetworksHeader { background-color: var(--color-secondary-background); } -.undeployedNetworksHeader { - background-color: var(--color-background-main); - text-align: center; - line-height: 32px; - padding: 0; - margin-top: var(--space-1); -} - .plusIcon { background-color: var(--color-background-main); color: var(--color-border-main); diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index c726ae0aa6..5995afba18 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -2,6 +2,7 @@ import ModalDialog from '@/components/common/ModalDialog' import NetworkInput from '@/components/common/NetworkInput' import ErrorMessage from '@/components/tx/ErrorMessage' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import { showNotification } from '@/store/notificationsSlice' import { Box, Button, CircularProgress, DialogActions, DialogContent, Stack, Typography } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' import { useSafeCreationData } from '../../hooks/useSafeCreationData' @@ -108,6 +109,16 @@ const ReplaySafeDialog = ({ safe: `${selectedChain.shortName}:${safeAddress}`, }, }) + + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: selectedChain.chainId }) + + dispatch( + showNotification({ + variant: 'success', + groupKey: 'replay-safe-success', + message: `Successfully added your account on ${selectedChain.chainName}`, + }), + ) } catch (err) { console.error(err) } finally { From 2531ac13f266b7112a3cc2dab8f5f61d9b1cbbca Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Thu, 26 Sep 2024 12:40:45 +0200 Subject: [PATCH 42/74] fix: throw custom error for empty setupData (#4255) --- .../__tests__/useSafeCreationData.test.ts | 44 ++++++++++++++----- .../multichain/hooks/useSafeCreationData.ts | 2 +- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index 606966a4bb..2f16561f92 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -74,7 +74,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error for legacy counterfactual Safes', async () => { + it('should throw an error for legacy counterfactual Safes', async () => { const safeAddress = faker.finance.ethereumAddress() const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] const undeployedSafe = { @@ -110,7 +110,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if creation data cannot be found', async () => { + it('should throw an error if creation data cannot be found', async () => { jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ response: new Response(), data: undefined, @@ -127,7 +127,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if Safe creation data is incomplete', async () => { + it('should throw an error if Safe creation data is incomplete', async () => { jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ data: { created: new Date(Date.now()).toISOString(), @@ -151,7 +151,31 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if outdated masterCopy is being used', async () => { + it('should throw an error if Safe setupData is empty', async () => { + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: faker.finance.ethereumAddress(), + setupData: '0x', + }, + response: new Response(), + }) + + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA), false]) + }) + }) + + it('should throw an error if outdated masterCopy is being used', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -190,7 +214,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if unknown masterCopy is being used', async () => { + it('should throw an error if unknown masterCopy is being used', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -229,7 +253,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if the Safe creation uses reimbursement', async () => { + it('should throw an error if the Safe creation uses reimbursement', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -264,7 +288,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if RPC could not be created', async () => { + it('should throw an error if RPC could not be created', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -301,7 +325,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if RPC cannot find the tx hash', async () => { + it('should throw an error if RPC cannot find the tx hash', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -398,7 +422,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if the setup data does not match', async () => { + it('should throw an error if the setup data does not match', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -463,7 +487,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw error if the masterCopies do not match', async () => { + it('should throw an error if the masterCopies do not match', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index c2a4797052..4f75c801cd 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -76,7 +76,7 @@ const getCreationDataForChain = async ( }, }) - if (!creation || !creation.masterCopy || !creation.setupData) { + if (!creation || !creation.masterCopy || !creation.setupData || creation.setupData === '0x') { throw new Error(SAFE_CREATION_DATA_ERRORS.NO_CREATION_DATA) } From 5c7d23c9a5528f862786e9e1ce9a1ed30c6bb9eb Mon Sep 17 00:00:00 2001 From: James Mealy Date: Mon, 30 Sep 2024 10:51:37 +0100 Subject: [PATCH 43/74] Feat(Multichain): use contract addresses from safe-deployments [SW-169] (#4262) * feat: use actual contract addresses from safe-deployments * pr comments, use typechain * remove unecessary undefined check for safeToL2SetupInterface --- package.json | 2 +- .../new-safe/create/logic/index.test.ts | 21 +++++-- src/components/new-safe/create/logic/index.ts | 17 +++--- .../DecodedData/SingleTxDecoded/index.tsx | 9 ++- src/config/constants.ts | 7 --- .../counterfactual/ActivateAccountFlow.tsx | 8 ++- src/utils/__tests__/transactions.test.ts | 55 ++++++++++--------- src/utils/transaction-guards.ts | 21 +++++-- src/utils/transactions.ts | 18 ++++-- yarn.lock | 9 ++- 10 files changed, 106 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 8e4adfd2d6..fa0e34252b 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@safe-global/api-kit": "^2.4.4", "@safe-global/protocol-kit": "^4.1.0", "@safe-global/safe-apps-sdk": "^9.1.0", - "@safe-global/safe-deployments": "^1.37.8", + "@safe-global/safe-deployments": "^1.37.10", "@safe-global/safe-gateway-typescript-sdk": "3.22.3-beta.13", "@safe-global/safe-modules-deployments": "^2.2.1", "@sentry/react": "^7.91.0", diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 7bebdf8b22..148bfd2c65 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -9,7 +9,6 @@ import { relaySafeCreation, getRedirect, createNewUndeployedSafeWithoutSalt, - SAFE_TO_L2_SETUP_INTERFACE, } from '@/components/new-safe/create/logic/index' import { relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' import { toBeHex } from 'ethers' @@ -28,13 +27,15 @@ import { type FEATURES as GatewayFeatures } from '@safe-global/safe-gateway-type import { chainBuilder } from '@/tests/builders/chains' import { type ReplayedSafeProps } from '@/store/slices' import { faker } from '@faker-js/faker' -import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' import { getFallbackHandlerDeployment, getProxyFactoryDeployment, getSafeL2SingletonDeployment, getSafeSingletonDeployment, + getSafeToL2SetupDeployment, } from '@safe-global/safe-deployments' +import { Safe_to_l2_setup__factory } from '@/types/contracts' const provider = new JsonRpcProvider(undefined, { name: 'ethereum', chainId: 1 }) @@ -44,6 +45,10 @@ const latestSafeVersion = getLatestSafeVersion( .build(), ) +const safeToL2SetupDeployment = getSafeToL2SetupDeployment() +const safeToL2SetupAddress = safeToL2SetupDeployment?.defaultAddress +const safeToL2SetupInterface = Safe_to_l2_setup__factory.createInterface() + describe('create/logic', () => { describe('createNewSafeViaRelayer', () => { const owner1 = toBeHex('0x1', 20) @@ -285,6 +290,10 @@ describe('create/logic', () => { owners: [faker.finance.ethereumAddress()], threshold: 1, } + const safeL2SingletonDeployment = getSafeL2SingletonDeployment({ + version: '1.4.1', + network: '137', + })?.defaultAddress expect( createNewUndeployedSafeWithoutSalt( '1.4.1', @@ -300,10 +309,10 @@ describe('create/logic', () => { safeAccountConfig: { ...safeSetup, fallbackHandler: getFallbackHandlerDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, - to: SAFE_TO_L2_SETUP_ADDRESS, - data: SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [ - getSafeL2SingletonDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, - ]), + to: safeToL2SetupAddress, + data: + safeL2SingletonDeployment && + safeToL2SetupInterface.encodeFunctionData('setupToL2', [safeL2SingletonDeployment]), paymentReceiver: ECOSYSTEM_ID_ADDRESS, }, safeVersion: '1.4.1', diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 19d8249215..df20ff92ab 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -1,5 +1,5 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' -import { Interface, type Eip1193Provider, type Provider } from 'ethers' +import { type Eip1193Provider, type Provider } from 'ethers' import semverSatisfies from 'semver/functions/satisfies' import { getSafeInfo, type SafeInfo, type ChainInfo, relayTransaction } from '@safe-global/safe-gateway-typescript-sdk' @@ -19,12 +19,13 @@ import { getProxyFactoryDeployment, getSafeL2SingletonDeployment, getSafeSingletonDeployment, + getSafeToL2SetupDeployment, } from '@safe-global/safe-deployments' -import { ECOSYSTEM_ID_ADDRESS, SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' +import { ECOSYSTEM_ID_ADDRESS } from '@/config/constants' import type { ReplayedSafeProps, UndeployedSafeProps } from '@/store/slices' import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfactual/utils' import { getSafeContractDeployment } from '@/services/contracts/deployments' -import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' +import { Safe__factory, Safe_proxy_factory__factory, Safe_to_l2_setup__factory } from '@/types/contracts' import { createWeb3 } from '@/hooks/wallets/web3' import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe' @@ -90,8 +91,6 @@ export const computeNewSafeAddress = async ( }) } -export const SAFE_TO_L2_SETUP_INTERFACE = new Interface(['function setupToL2(address l2Singleton)']) - export const encodeSafeSetupCall = (safeAccountConfig: ReplayedSafeProps['safeAccountConfig']) => { return Safe__factory.createInterface().encodeFunctionData('setup', [ safeAccountConfig.owners, @@ -226,6 +225,10 @@ export const createNewUndeployedSafeWithoutSalt = ( throw new Error('No Safe deployment found') } + const safeToL2SetupDeployment = getSafeToL2SetupDeployment({ version: '1.4.1', network: chain.chainId }) + const safeToL2SetupAddress = safeToL2SetupDeployment?.networkAddresses[chain.chainId] + const safeToL2SetupInterface = Safe_to_l2_setup__factory.createInterface() + // Only do migration if the chain supports multiChain deployments. const includeMigration = hasMultiChainCreationFeatures(chain) && semverSatisfies(safeVersion, '>=1.4.1') @@ -238,8 +241,8 @@ export const createNewUndeployedSafeWithoutSalt = ( threshold: safeAccountConfig.threshold, owners: safeAccountConfig.owners, fallbackHandler: fallbackHandlerAddress, - to: includeMigration ? SAFE_TO_L2_SETUP_ADDRESS : ZERO_ADDRESS, - data: includeMigration ? SAFE_TO_L2_SETUP_INTERFACE.encodeFunctionData('setupToL2', [safeL2Address]) : EMPTY_DATA, + to: includeMigration && safeToL2SetupAddress ? safeToL2SetupAddress : ZERO_ADDRESS, + data: includeMigration ? safeToL2SetupInterface.encodeFunctionData('setupToL2', [safeL2Address]) : EMPTY_DATA, paymentReceiver: ECOSYSTEM_ID_ADDRESS, }, safeVersion, diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx index f8c0252eb4..dfe3114835 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx @@ -8,7 +8,8 @@ import accordionCss from '@/styles/accordion.module.css' import CodeIcon from '@mui/icons-material/Code' import DecodedData from '@/components/transactions/TxDetails/TxData/DecodedData' import { sameAddress } from '@/utils/addresses' -import { SAFE_TO_L2_MIGRATION_ADDRESS } from '@/config/constants' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' +import { useCurrentChain } from '@/hooks/useChains' type SingleTxDecodedProps = { tx: InternalTransaction @@ -20,12 +21,16 @@ type SingleTxDecodedProps = { } export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, onChange }: SingleTxDecodedProps) => { + const chain = useCurrentChain() const isNativeTransfer = tx.value !== '0' && (!tx.data || isEmptyHexData(tx.data)) const method = tx.dataDecoded?.method || (isNativeTransfer ? 'native transfer' : 'contract interaction') const addressInfo = txData.addressInfoIndex?.[tx.to] const name = addressInfo?.name + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = chain && safeToL2MigrationDeployment?.networkAddresses[chain.chainId] + const singleTxData = { to: { value: tx.to }, value: tx.value, @@ -33,7 +38,7 @@ export const SingleTxDecoded = ({ tx, txData, actionTitle, variant, expanded, on dataDecoded: tx.dataDecoded, hexData: tx.data ?? undefined, addressInfoIndex: txData.addressInfoIndex, - trustedDelegateCallTarget: sameAddress(tx.to, SAFE_TO_L2_MIGRATION_ADDRESS), // We only trusted a nested Migration + trustedDelegateCallTarget: sameAddress(tx.to, safeToL2MigrationAddress), // We only trusted a nested Migration } return ( diff --git a/src/config/constants.ts b/src/config/constants.ts index 24dcc554f5..f1039e0143 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,4 +1,3 @@ -import { Interface } from 'ethers' import chains from './chains' export const IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' @@ -110,11 +109,5 @@ export const REDEFINE_ARTICLE = 'https://safe.mirror.xyz/rInLWZwD_sf7enjoFerj6FI export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139addac8fb' -// TODO: Get from safe-deployments once available -export const SAFE_TO_L2_MIGRATION_ADDRESS = '0x7Baec386CAF8e02B0BB4AFc98b4F9381EEeE283C' -export const SAFE_TO_L2_INTERFACE = new Interface(['function migrateToL2(address l2Singleton)']) - export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' - -export const SAFE_TO_L2_SETUP_ADDRESS = '0x80E0d1577aD3d982BF2F49aAB00BfA161AA763c4' diff --git a/src/features/counterfactual/ActivateAccountFlow.tsx b/src/features/counterfactual/ActivateAccountFlow.tsx index c7f7944fea..0954722c42 100644 --- a/src/features/counterfactual/ActivateAccountFlow.tsx +++ b/src/features/counterfactual/ActivateAccountFlow.tsx @@ -31,8 +31,8 @@ import { sameAddress } from '@/utils/addresses' import { useEstimateSafeCreationGas } from '@/components/new-safe/create/useEstimateSafeCreationGas' import useIsWrongChain from '@/hooks/useIsWrongChain' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' -import { SAFE_TO_L2_SETUP_ADDRESS } from '@/config/constants' import CheckWallet from '@/components/common/CheckWallet' +import { getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' const useActivateAccount = (undeployedSafe: UndeployedSafe | undefined) => { const chain = useCurrentChain() @@ -84,7 +84,7 @@ const ActivateAccountFlow = () => { const safeAccountConfig = undeployedSafe && isPredictedSafeProps(undeployedSafe?.props) ? undeployedSafe?.props.safeAccountConfig : undefined - const isMultichainSafe = sameAddress(safeAccountConfig?.to, SAFE_TO_L2_SETUP_ADDRESS) + const ownerAddresses = undeployedSafeSetup?.owners || [] const [minRelays] = useLeastRemainingRelays(ownerAddresses) @@ -96,6 +96,10 @@ const ActivateAccountFlow = () => { const { owners, threshold, safeVersion } = undeployedSafeSetup + const safeToL2SetupDeployment = getSafeToL2SetupDeployment({ version: '1.4.1', network: chain?.chainId }) + const safeToL2SetupAddress = safeToL2SetupDeployment?.defaultAddress + const isMultichainSafe = sameAddress(safeAccountConfig?.to, safeToL2SetupAddress) + const onSubmit = (txHash?: string) => { trackEvent({ ...TX_EVENTS.CREATE, label: TX_TYPES.activate_without_tx }) trackEvent({ ...TX_EVENTS.EXECUTE, label: TX_TYPES.activate_without_tx }) diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index 4f7fccf782..d9d7c91713 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -16,18 +16,22 @@ import { 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 } from '@/types/contracts' +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 { SAFE_TO_L2_MIGRATION_ADDRESS, SAFE_TO_L2_INTERFACE } from '@/config/constants' jest.mock('@/services/tx/tx-sender/sdk') +const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() +const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress +const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + describe('transactions', () => { const mockGetAndValidateSdk = getAndValidateSafeSDK as jest.MockedFunction @@ -312,20 +316,21 @@ describe('transactions', () => { }) it('should not modify tx if the tx already migrates', () => { + const safeL2SingletonDeployment = getSafeL2SingletonDeployment()?.defaultAddress + const safeTx = safeTxBuilder() .with({ data: safeTxDataBuilder() .with({ nonce: 0, - to: SAFE_TO_L2_MIGRATION_ADDRESS, - data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [ - getSafeL2SingletonDeployment()?.defaultAddress, - ]), + to: safeToL2MigrationAddress, + data: + safeL2SingletonDeployment && + safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), }) .build(), }) .build() - const safeInfo = extendedSafeInfoBuilder() .with({ implementationVersionState: ImplementationVersionState.UNKNOWN, @@ -335,34 +340,32 @@ describe('transactions', () => { }, }) .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: Multi_send__factory.createInterface().encodeFunctionData('multiSend', [ - encodeMultiSendData([ - { - value: '0', - operation: 1, - to: SAFE_TO_L2_MIGRATION_ADDRESS, - data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [ - getSafeL2SingletonDeployment()?.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) @@ -402,14 +405,16 @@ describe('transactions', () => { 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: SAFE_TO_L2_MIGRATION_ADDRESS, + to: safeToL2MigrationAddress, value: '0', operation: 1, - data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [ - getSafeL2SingletonDeployment()?.defaultAddress, - ]), + data: + safeL2SingletonDeployment && + safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2SingletonDeployment]), }, { to: checksumAddress(safeTx.data.to), diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index fa219e780e..a26fd9a226 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -58,7 +58,8 @@ 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 { SAFE_TO_L2_MIGRATION_ADDRESS, SAFE_TO_L2_INTERFACE } from '@/config/constants' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' +import { Safe_to_l2_migration__factory } from '@/types/contracts' export const isTxQueued = (value: TransactionStatus): boolean => { return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value) @@ -88,8 +89,12 @@ export const isModuleDetailedExecutionInfo = (value?: DetailedExecutionInfo): va } export const isMigrateToL2TxData = (value: TransactionData | undefined): boolean => { - if (sameAddress(value?.to.value, SAFE_TO_L2_MIGRATION_ADDRESS)) { - const migrateToL2Selector = SAFE_TO_L2_INTERFACE.getFunction('migrateToL2')?.selector + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() + + if (sameAddress(value?.to.value, safeToL2MigrationAddress)) { + const migrateToL2Selector = safeToL2MigrationInterface?.getFunction('migrateToL2')?.selector return migrateToL2Selector && value?.hexData ? value.hexData?.startsWith(migrateToL2Selector) : false } return false @@ -100,12 +105,15 @@ export const isMigrateToL2MultiSend = (decodedData: DecodedDataResponse | undefi const innerTxs = decodedData.parameters[0].valueDecoded const firstInnerTx = innerTxs[0] if (firstInnerTx) { + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + return ( firstInnerTx.dataDecoded?.method === 'migrateToL2' && firstInnerTx.dataDecoded.parameters.length === 1 && firstInnerTx.dataDecoded?.parameters?.[0]?.type === 'address' && typeof firstInnerTx.dataDecoded?.parameters[0].value === 'string' && - sameAddress(firstInnerTx.to, SAFE_TO_L2_MIGRATION_ADDRESS) + sameAddress(firstInnerTx.to, safeToL2MigrationAddress) ) } } @@ -149,7 +157,10 @@ export const isOrderTxInfo = (value: TransactionInfo): value is Order => { } export const isMigrateToL2TxInfo = (value: TransactionInfo): value is Custom => { - return isCustomTxInfo(value) && sameAddress(value.to.value, SAFE_TO_L2_MIGRATION_ADDRESS) + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() + const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress + + return isCustomTxInfo(value) && sameAddress(value.to.value, safeToL2MigrationAddress) } export const isSwapOrderTxInfo = (value: TransactionInfo): value is SwapOrder => { diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 9ee2c70604..ee89fd1259 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -29,7 +29,7 @@ import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core import { FEATURES, hasFeature } from '@/utils/chains' import uniqBy from 'lodash/uniqBy' import { Errors, logError } from '@/services/exceptions' -import { Multi_send__factory } from '@/types/contracts' +import { Multi_send__factory, Safe_to_l2_migration__factory } from '@/types/contracts' import { toBeHex, AbiCoder } from 'ethers' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' import { id } from 'ethers' @@ -40,8 +40,8 @@ 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 { SAFE_TO_L2_MIGRATION_ADDRESS, SAFE_TO_L2_INTERFACE } from '@/config/constants' import { getOriginPath } from './url' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' export const makeTxFromDetails = (txDetails: TransactionDetails): Transaction => { const getMissingSigners = ({ @@ -342,11 +342,19 @@ export const prependSafeToL2Migration = ( const safeL2Deployment = getSafeContractDeployment(chain, safe.version) const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] + const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment({ network: chain?.chainId }) if (!safeL2DeploymentAddress) { throw new Error('No L2 MasterCopy found') } + if (!safeToL2MigrationDeployment) { + throw new Error('No safe to L2 migration contract found') + } + + const safeToL2MigrationAddress = safeToL2MigrationDeployment.defaultAddress + 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 @@ -364,7 +372,7 @@ export const prependSafeToL2Migration = ( internalTxs = [{ to: safeTx.data.to, operation: safeTx.data.operation, value: safeTx.data.value, data: txData }] } - if (sameAddress(internalTxs[0]?.to, SAFE_TO_L2_MIGRATION_ADDRESS)) { + if (sameAddress(internalTxs[0]?.to, safeToL2MigrationAddress)) { // We already migrate. Nothing to do. return Promise.resolve(safeTx) } @@ -373,8 +381,8 @@ export const prependSafeToL2Migration = ( const newTxs: MetaTransactionData[] = [ { operation: 1, // DELEGATE CALL REQUIRED - data: SAFE_TO_L2_INTERFACE.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), - to: SAFE_TO_L2_MIGRATION_ADDRESS, + data: safeToL2MigrationInterface.encodeFunctionData('migrateToL2', [safeL2DeploymentAddress]), + to: safeToL2MigrationAddress, value: '0', }, ...internalTxs, diff --git a/yarn.lock b/yarn.lock index e8d88c2f15..798791c14d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4279,7 +4279,14 @@ dependencies: abitype "^1.0.2" -"@safe-global/safe-deployments@^1.37.3", "@safe-global/safe-deployments@^1.37.8": +"@safe-global/safe-deployments@^1.37.10": + version "1.37.10" + resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.10.tgz#2f61a25bd479332821ba2e91a575237d77406ec3" + integrity sha512-lcxX9CV+xdcLs4dF6Cx18zDww5JyqaX6RdcvU0o/34IgJ4Wjo3J/RNzJAoMhurCAfTGr+0vyJ9V13Qo50AR6JA== + dependencies: + semver "^7.6.2" + +"@safe-global/safe-deployments@^1.37.3": version "1.37.8" resolved "https://registry.yarnpkg.com/@safe-global/safe-deployments/-/safe-deployments-1.37.8.tgz#5d51a57e4c3a9274ce09d8fe7fbe1265a1aaf4c4" integrity sha512-BT34eqSJ1K+4xJgJVY3/Yxg8TRTEvFppkt4wcirIPGCgR4/j06HptHPyDdmmqTuvih8wi8OpFHi0ncP+cGlXWA== From c63ea91b78ba53bbaeed0e2084ac3c18caa104bf Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 30 Sep 2024 15:40:26 +0200 Subject: [PATCH 44/74] [Multichain] fix: Adjust multichain design [SW-244] (#4284) * fix: Adjust multichain design * fix: Adjust border color --- src/components/common/ModalDialog/index.tsx | 6 +- .../common/ModalDialog/styles.module.css | 2 +- .../common/NetworkSelector/index.tsx | 37 +++++----- .../common/NetworkSelector/styles.module.css | 1 + src/components/dashboard/FirstSteps/index.tsx | 20 ++++-- .../welcome/MyAccounts/AccountItem.tsx | 8 +-- .../welcome/MyAccounts/MultiAccountItem.tsx | 12 ++-- .../welcome/MyAccounts/styles.module.css | 69 ++++++++++++------- .../components/CreateSafeOnNewChain/index.tsx | 21 +++--- .../components/NetworkLogosList/index.tsx | 16 ++++- .../NetworkLogosList/styles.module.css | 22 ++++-- 11 files changed, 136 insertions(+), 78 deletions(-) diff --git a/src/components/common/ModalDialog/index.tsx b/src/components/common/ModalDialog/index.tsx index c9d32eafe6..0e8050f2e1 100644 --- a/src/components/common/ModalDialog/index.tsx +++ b/src/components/common/ModalDialog/index.tsx @@ -20,7 +20,11 @@ interface DialogTitleProps { export const ModalDialogTitle = ({ children, onClose, hideChainIndicator = false, ...other }: DialogTitleProps) => { return ( - + {children} {!hideChainIndicator && } diff --git a/src/components/common/ModalDialog/styles.module.css b/src/components/common/ModalDialog/styles.module.css index dc1b741342..2b230f6bf3 100644 --- a/src/components/common/ModalDialog/styles.module.css +++ b/src/components/common/ModalDialog/styles.module.css @@ -1,6 +1,6 @@ .dialog :global .MuiDialogActions-root { border-top: 1px solid var(--color-border-light); - padding: var(--space-3); + padding: var(--space-2) var(--space-3); } .dialog :global .MuiDialogActions-root > :last-of-type:not(:first-of-type) { diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 5b64c37fdc..1e605bba3b 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -15,6 +15,7 @@ import { Select, Skeleton, Stack, + Tooltip, Typography, } from '@mui/material' import partition from 'lodash/partition' @@ -100,23 +101,25 @@ const UndeployedNetworkMenuItem = ({ return ( - onSelect(chain)} - disabled={isDisabled} - > - - - {isDisabled ? ( - - Not available - - ) : ( - - )} - - + + onSelect(chain)} + disabled={isDisabled} + > + + + {isDisabled ? ( + + Not available + + ) : ( + + )} + + + ) } diff --git a/src/components/common/NetworkSelector/styles.module.css b/src/components/common/NetworkSelector/styles.module.css index c601e204c2..f53b932c4c 100644 --- a/src/components/common/NetworkSelector/styles.module.css +++ b/src/components/common/NetworkSelector/styles.module.css @@ -77,6 +77,7 @@ .multiChainChip { padding: var(--space-2) 0; margin: 2px; + border-color: var(--color-border-main); } .comingSoon { diff --git a/src/components/dashboard/FirstSteps/index.tsx b/src/components/dashboard/FirstSteps/index.tsx index 6d70dbd95a..a508a899fc 100644 --- a/src/components/dashboard/FirstSteps/index.tsx +++ b/src/components/dashboard/FirstSteps/index.tsx @@ -15,6 +15,7 @@ import { useAppDispatch, useAppSelector } from '@/store' import { selectSettings, setQrShortName } from '@/store/settingsSlice' import { selectOutgoingTransactions } from '@/store/txHistorySlice' import { getExplorerLink } from '@/utils/gateway' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { type ReactNode, useState } from 'react' import { Card, WidgetBody, WidgetContainer } from '@/components/dashboard/styled' @@ -59,7 +60,9 @@ const StatusCard = ({ {title} - {content} + + {content} + {children} ) @@ -259,24 +262,27 @@ const FirstTransactionWidget = ({ completed }: { completed: boolean }) => { ) } -const ActivateSafeWidget = () => { +const ActivateSafeWidget = ({ chain }: { chain: ChainInfo | undefined }) => { const [open, setOpen] = useState(false) - const title = 'Activate your Safe account.' + const title = `Activate account ${chain ? 'on ' + chain.chainName : ''}` + const content = 'Activate your account to start using all benefits of Safe' return ( <> - Activate your Safe + First interaction } title={title} completed={false} - content="" + content={content} > - + + + setOpen(false)} /> @@ -386,7 +392,7 @@ const FirstSteps = () => { {isActivating ? ( ) : isMultiSig || isReplayedSafe ? ( - + ) : ( )} diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index 32fff77b8c..8ccfa609da 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -122,15 +122,15 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { )} - + + + {safeOverview ? ( ) : undeployedSafe ? null : ( - + )} - - diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index a265d7ad5a..cafca1fa2c 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -1,4 +1,5 @@ import { selectUndeployedSafes } from '@/features/counterfactual/store/undeployedSafesSlice' +import NetworkLogosList from '@/features/multichain/components/NetworkLogosList' import type { SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' import { useCallback, useMemo, useState } from 'react' import { @@ -24,7 +25,6 @@ import classnames from 'classnames' import { useRouter } from 'next/router' import FiatValue from '@/components/common/FiatValue' import { type MultiChainSafeItem } from './useAllSafesGrouped' -import MultiChainIcon from '@/public/images/sidebar/multichain-account.svg' import { shortenAddress } from '@/utils/formatters' import { type SafeItem } from './useAllSafes' import SubAccountItem from './SubAccountItem' @@ -58,8 +58,8 @@ const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { } arrow > - - + + ) @@ -143,7 +143,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte onClick={toggleExpand} sx={{ pl: 0, - '& .MuiAccordionSummary-content': { m: 0, alignItems: 'center' }, + '& .MuiAccordionSummary-content': { m: '0 !important', alignItems: 'center' }, '&.Mui-expanded': { backgroundColor: 'transparent !important' }, }} > @@ -161,14 +161,14 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte {shortenAddress(address)} - + + {totalFiatValue !== undefined ? ( ) : ( )} - diff --git a/src/components/welcome/MyAccounts/styles.module.css b/src/components/welcome/MyAccounts/styles.module.css index 76249917d8..bf8baa9739 100644 --- a/src/components/welcome/MyAccounts/styles.module.css +++ b/src/components/welcome/MyAccounts/styles.module.css @@ -81,33 +81,10 @@ .safeLink { display: grid; padding: var(--space-2) var(--space-1) var(--space-2) var(--space-2); - grid-template-columns: auto 3fr 2fr auto; + grid-template-columns: 60px 3fr 3fr 2fr; align-items: center; } -@media (max-width: 599.95px) { - .safeLink { - grid-template-columns: auto 1fr auto; - grid-template-areas: - 'a b d' - 'a c d'; - } - - .safeLink :nth-child(1) { - grid-area: a; - } - .safeLink :nth-child(2) { - grid-area: b; - } - .safeLink :nth-child(3) { - grid-area: c; - text-align: left; - } - .safeLink :nth-child(4) { - grid-area: d; - } -} - .safeName, .safeAddress { white-space: nowrap; @@ -164,6 +141,23 @@ color: var(--color-info-dark) !important; } +.multiChains { + display: flex; + justify-content: flex-end; +} + +.multiChains > span { + margin-left: -5px; + border-radius: 50%; + width: 24px; + height: 24px; + outline: 2px solid var(--color-background-paper); +} + +.chainIndicator { + justify-content: flex-end; +} + @media (max-width: 899.95px) { .container { width: auto; @@ -174,6 +168,33 @@ } } +@media (max-width: 599.95px) { + .safeLink { + grid-template-columns: auto 1fr auto; + grid-template-areas: + 'a b d' + 'a c d'; + } + + .safeLink :nth-child(1) { + grid-area: a; + } + .safeLink :nth-child(2) { + grid-area: b; + } + .safeLink :nth-child(3) { + grid-area: c; + text-align: left; + } + .safeLink :nth-child(4) { + grid-area: d; + } + + .multiChains { + justify-content: flex-start; + } +} + @container my-accounts-container (max-width: 500px) { .myAccounts { margin: 0; diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 5995afba18..62d2ce565e 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -145,7 +145,14 @@ const ReplaySafeDialog = ({ - This action re-deploys a Safe to another network with the same address. + Add this Safe to another network with the same address. + + {chain && ( + + + + )} + The Safe will use the initial setup of the copied Safe. Any changes to owners, threshold, modules or the Safe's version will not be reflected in the copy. @@ -167,13 +174,7 @@ const ReplaySafeDialog = ({ ) : noChainsAvailable ? ( This Safe cannot be replayed on any chains. ) : ( - <> - {chain ? ( - - ) : ( - - )} - + <>{!chain && } )} {creationError && ( @@ -196,9 +197,7 @@ const ReplaySafeDialog = ({ ) : ( <> - + diff --git a/src/features/multichain/components/NetworkLogosList/index.tsx b/src/features/multichain/components/NetworkLogosList/index.tsx index ca3bb74345..39c9bd6486 100644 --- a/src/features/multichain/components/NetworkLogosList/index.tsx +++ b/src/features/multichain/components/NetworkLogosList/index.tsx @@ -3,12 +3,24 @@ import { Box } from '@mui/material' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import css from './styles.module.css' -const NetworkLogosList = ({ networks }: { networks: ChainInfo[] }) => { +const NetworkLogosList = ({ + networks, + showHasMore = false, +}: { + networks: Pick[] + showHasMore?: boolean +}) => { + const MAX_NUM_VISIBLE_CHAINS = 4 + const visibleChains = showHasMore ? networks.slice(0, MAX_NUM_VISIBLE_CHAINS) : networks + return ( - {networks.map((chain) => ( + {visibleChains.map((chain) => ( ))} + {showHasMore && networks.length > MAX_NUM_VISIBLE_CHAINS && ( + +{networks.length - MAX_NUM_VISIBLE_CHAINS} + )} ) } diff --git a/src/features/multichain/components/NetworkLogosList/styles.module.css b/src/features/multichain/components/NetworkLogosList/styles.module.css index 1ff0475ccc..41d0b1c099 100644 --- a/src/features/multichain/components/NetworkLogosList/styles.module.css +++ b/src/features/multichain/components/NetworkLogosList/styles.module.css @@ -1,12 +1,24 @@ .networks { display: flex; flex-wrap: wrap; - margin-left: 12px; + margin-left: 6px; } .networks img { - margin-left: -12px; - background-color: var(--color-background-main); - padding: 1px; - border-radius: 12px; + margin-left: -6px; + outline: 2px solid var(--color-background-paper); + border-radius: 50%; +} + +.moreChainsIndicator { + margin-left: -5px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--color-border-light); + outline: 2px solid var(--color-background-paper); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; } From 2d832ebd3ca96e4e1711356485b86cc490c117b7 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 1 Oct 2024 10:24:13 +0200 Subject: [PATCH 45/74] Fix(Multichain): ignore empty safe address when loading safeOverviews (#4289) * fix: ignore safe overview requests for empty safe addresses * refactor: use asError instead of casting * test: add missing test case * fix: skip undeployed Safe overviews in network selector --- .../common/NetworkSelector/index.tsx | 7 +++- src/store/__tests__/safeOverviews.test.ts | 35 +++++++++++++++++++ src/store/api/gateway/safeOverviews.ts | 34 ++++++++++-------- 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 1e605bba3b..825526e4b6 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -39,6 +39,8 @@ import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' import { useGetSafeOverviewQuery } from '@/store/api/gateway' +import { selectUndeployedSafe } from '@/store/slices' +import { skipToken } from '@reduxjs/toolkit/query' const ChainIndicatorWithFiatBalance = ({ isSelected, @@ -49,7 +51,10 @@ const ChainIndicatorWithFiatBalance = ({ chain: ChainInfo safeAddress: string }) => { - const { data: safeOverview } = useGetSafeOverviewQuery({ safeAddress, chainId: chain.chainId }) + const undeployedSafe = useAppSelector((state) => selectUndeployedSafe(state, chain.chainId, safeAddress)) + const { data: safeOverview } = useGetSafeOverviewQuery( + undeployedSafe ? skipToken : { safeAddress, chainId: chain.chainId }, + ) return ( { }) describe('useGetSafeOverviewQuery', () => { + it('should return null for empty safe Address', async () => { + const request = { chainId: '1', safeAddress: '' } + const { result } = renderHook(() => useGetSafeOverviewQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toBeNull() + }) + + expect(mockedGetSafeOverviews).not.toHaveBeenCalled() + }) + it('should return an error if fetching fails', async () => { const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() } mockedGetSafeOverviews.mockRejectedValueOnce('Service unavailable') @@ -29,6 +45,25 @@ describe('safeOverviews', () => { }) }) + it('should return null if safeOverview is not found for a given Safe', async () => { + const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() } + mockedGetSafeOverviews.mockResolvedValueOnce([]) + + const { result } = renderHook(() => useGetSafeOverviewQuery(request)) + + // Request should get queued and remain loading for the queue seconds + expect(result.current.isLoading).toBeTruthy() + + await Promise.resolve() + + await waitFor(() => { + expect(mockedGetSafeOverviews).toHaveBeenCalled() + expect(result.current.isLoading).toBeFalsy() + expect(result.current.error).toBeUndefined() + expect(result.current.data).toEqual(null) + }) + }) + it('should return the Safe overview if fetching is successful', async () => { const request = { chainId: '1', safeAddress: faker.finance.ethereumAddress() } diff --git a/src/store/api/gateway/safeOverviews.ts b/src/store/api/gateway/safeOverviews.ts index 8caa1ef00a..95eef7c8bf 100644 --- a/src/store/api/gateway/safeOverviews.ts +++ b/src/store/api/gateway/safeOverviews.ts @@ -5,6 +5,7 @@ import { sameAddress } from '@/utils/addresses' import type { RootState } from '../..' import { selectCurrency } from '../../settingsSlice' import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes' +import { asError } from '@/services/exceptions/utils' type SafeOverviewQueueItem = { safeAddress: string @@ -125,22 +126,25 @@ export const safeOverviewEndpoints = ( 'gatewayApi' >, ) => ({ - getSafeOverview: builder.query< - SafeOverview | undefined, - { safeAddress: string; walletAddress?: string; chainId: string } - >({ - async queryFn({ safeAddress, walletAddress, chainId }, { getState }) { - const state = getState() - const currency = selectCurrency(state as RootState) - - try { - const safeOverview = await batchedFetcher.getOverview({ chainId, currency, walletAddress, safeAddress }) - return { data: safeOverview } - } catch (error) { - return { error: { status: 'CUSTOM_ERROR', error: (error as Error).message } } - } + getSafeOverview: builder.query( + { + async queryFn({ safeAddress, walletAddress, chainId }, { getState }) { + const state = getState() as RootState + const currency = selectCurrency(state) + + if (!safeAddress) { + return { data: null } + } + + try { + const safeOverview = await batchedFetcher.getOverview({ chainId, currency, walletAddress, safeAddress }) + return { data: safeOverview ?? null } + } catch (error) { + return { error: { status: 'CUSTOM_ERROR', error: asError(error).message } } + } + }, }, - }), + ), getMultipleSafeOverviews: builder.query({ async queryFn(params) { const { safes, walletAddress, currency } = params From f38021c0667a25f3179ffcd79be51365012f744b Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 1 Oct 2024 10:39:57 +0200 Subject: [PATCH 46/74] feat: block adding networks to safes with unknown setupModule calls (#4285) --- .../__tests__/useSafeCreationData.test.ts | 67 ++++++++++++++----- .../multichain/hooks/useSafeCreationData.ts | 8 +++ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index 2f16561f92..93aa1ac3b0 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -12,8 +12,10 @@ import { Safe__factory, Safe_proxy_factory__factory } from '@/types/contracts' import { type JsonRpcProvider } from 'ethers' import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' -import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import { EMPTY_DATA, ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { getSafeSingletonDeployment, getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' + +const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress! describe('useSafeCreationData', () => { beforeAll(() => { @@ -46,7 +48,7 @@ describe('useSafeCreationData', () => { owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], threshold: 1, data: faker.string.hexadecimal({ length: 64 }), - to: faker.finance.ethereumAddress(), + to: setupToL2Address, fallbackHandler: faker.finance.ethereumAddress(), payment: 0, paymentToken: ZERO_ADDRESS, @@ -179,7 +181,7 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64 }), faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), @@ -218,8 +220,8 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), - faker.string.hexadecimal({ length: 64 }), + ZERO_ADDRESS, + EMPTY_DATA, faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), 0, @@ -257,7 +259,7 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64 }), faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), @@ -288,7 +290,7 @@ describe('useSafeCreationData', () => { }) }) - it('should throw an error if RPC could not be created', async () => { + it('should throw an error if the Safe creation uses an unknown setupModules call', async () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, @@ -312,6 +314,41 @@ describe('useSafeCreationData', () => { response: new Response(), }) + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] + + // Run hook + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + + await waitFor(() => { + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES), false]) + }) + }) + + it('should throw an error if RPC could not be created', async () => { + const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ + [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + 1, + setupToL2Address, + faker.string.hexadecimal({ length: 64 }), + faker.finance.ethereumAddress(), + ZERO_ADDRESS, + 0, + faker.finance.ethereumAddress(), + ]) + + jest.spyOn(cgwSdk, 'getCreationTransaction').mockResolvedValue({ + data: { + created: new Date(Date.now()).toISOString(), + creator: faker.finance.ethereumAddress(), + factoryAddress: faker.finance.ethereumAddress(), + transactionHash: faker.string.hexadecimal({ length: 64 }), + masterCopy: getSafeSingletonDeployment({ version: '1.3.0' })?.defaultAddress, + setupData, + }, + response: new Response(), + }) + jest.spyOn(web3, 'createWeb3ReadOnly').mockReturnValue(undefined) const safeAddress = faker.finance.ethereumAddress() @@ -329,7 +366,7 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64 }), faker.finance.ethereumAddress(), ZERO_ADDRESS, @@ -372,7 +409,7 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64 }), faker.finance.ethereumAddress(), ZERO_ADDRESS, @@ -426,7 +463,7 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64 }), faker.finance.ethereumAddress(), ZERO_ADDRESS, @@ -437,7 +474,7 @@ describe('useSafeCreationData', () => { const nonMatchingSetupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64 }), faker.finance.ethereumAddress(), faker.finance.ethereumAddress(), @@ -491,7 +528,7 @@ describe('useSafeCreationData', () => { const setupData = Safe__factory.createInterface().encodeFunctionData('setup', [ [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], 1, - faker.finance.ethereumAddress(), + setupToL2Address, faker.string.hexadecimal({ length: 64, casing: 'lower' }), faker.finance.ethereumAddress(), ZERO_ADDRESS, @@ -545,7 +582,7 @@ describe('useSafeCreationData', () => { const safeProps = { owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], threshold: 1, - to: fakerChecksummedAddress(), + to: setupToL2Address, data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), fallbackHandler: fakerChecksummedAddress(), paymentToken: ZERO_ADDRESS, @@ -619,7 +656,7 @@ describe('useSafeCreationData', () => { const safeProps = { owners: [fakerChecksummedAddress(), fakerChecksummedAddress()], threshold: 1, - to: fakerChecksummedAddress(), + to: setupToL2Address, data: faker.string.hexadecimal({ length: 64, casing: 'lower' }), fallbackHandler: fakerChecksummedAddress(), paymentToken: ZERO_ADDRESS, diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index 4f75c801cd..864388a387 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -12,6 +12,7 @@ import ErrorCodes from '@/services/exceptions/ErrorCodes' import { asError } from '@/services/exceptions/utils' import semverSatisfies from 'semver/functions/satisfies' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' +import { getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' export const SAFE_CREATION_DATA_ERRORS = { TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', @@ -22,6 +23,7 @@ export const SAFE_CREATION_DATA_ERRORS = { PAYMENT_SAFE: 'The Safe creation used reimbursement. Adding networks to such Safes is not supported.', UNSUPPORTED_IMPLEMENTATION: 'The Safe was created using an unsupported or outdated implementation. Adding networks to this Safe is not possible.', + UNKNOWN_SETUP_MODULES: 'The Safe creation is using an unknown internal call', } export const decodeSetupData = (setupData: string): ReplayedSafeProps['safeAccountConfig'] => { @@ -93,6 +95,12 @@ const getCreationDataForChain = async ( throw new Error(SAFE_CREATION_DATA_ERRORS.PAYMENT_SAFE) } + const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress + if (safeAccountConfig.to !== ZERO_ADDRESS && !sameAddress(safeAccountConfig.to, setupToL2Address)) { + // Unknown setupModules calls cannot be replayed as the target contract is likely not deployed across chains + throw new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES) + } + // We need to create a readOnly provider of the deployed chain const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined const provider = createWeb3ReadOnly(chain, customRpcUrl) From 03c6810dd7d40825fef6777fabde8bb40136e012 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Tue, 1 Oct 2024 11:35:10 +0200 Subject: [PATCH 47/74] fix: Adjust balance alignment in safe list (#4286) --- src/components/welcome/MyAccounts/SubAccountItem.tsx | 2 +- src/components/welcome/MyAccounts/styles.module.css | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/welcome/MyAccounts/SubAccountItem.tsx b/src/components/welcome/MyAccounts/SubAccountItem.tsx index 51d67eb015..de8d1724e0 100644 --- a/src/components/welcome/MyAccounts/SubAccountItem.tsx +++ b/src/components/welcome/MyAccounts/SubAccountItem.tsx @@ -62,7 +62,7 @@ const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem) className={classnames(css.listItem, { [css.currentListItem]: isCurrentSafe }, css.subItem)} > - + Date: Tue, 1 Oct 2024 11:45:35 +0200 Subject: [PATCH 48/74] [Multichain] fix: Check all fallbackHandler deployments [SW-236] (#4281) * fix: Check all fallbackHandler deployments * fix: Add list of networks the twap fallback handler is deployed at and adjust check * fix: Add tests for twap fallback handler --- .../FallbackHandler/__tests__/index.test.tsx | 47 +++++++++++++++++++ .../settings/FallbackHandler/index.tsx | 19 ++++---- src/features/swap/helpers/utils.ts | 3 ++ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/components/settings/FallbackHandler/__tests__/index.test.tsx b/src/components/settings/FallbackHandler/__tests__/index.test.tsx index 3d7abd727f..4e5d144a6d 100644 --- a/src/components/settings/FallbackHandler/__tests__/index.test.tsx +++ b/src/components/settings/FallbackHandler/__tests__/index.test.tsx @@ -1,3 +1,4 @@ +import { TWAP_FALLBACK_HANDLER } from '@/features/swap/helpers/utils' import { chainBuilder } from '@/tests/builders/chains' import { render, waitFor } from '@/tests/test-utils' @@ -238,4 +239,50 @@ describe('FallbackHandler', () => { expect(fbHandler.container).toBeEmptyDOMElement() }) + + it('should display a message in case it is a TWAP fallback handler', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '1', + fallbackHandler: { + value: TWAP_FALLBACK_HANDLER, + }, + }, + } as unknown as ReturnType), + ) + + const { getByText } = render() + + expect( + getByText( + "This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.", + ), + ).toBeInTheDocument() + }) + + it('should not display a message in case it is a TWAP fallback handler on an unsupported network', () => { + jest.spyOn(useSafeInfoHook, 'default').mockImplementation( + () => + ({ + safe: { + version: '1.3.0', + chainId: '10', + fallbackHandler: { + value: TWAP_FALLBACK_HANDLER, + }, + }, + } as unknown as ReturnType), + ) + + const { queryByText } = render() + + expect( + queryByText( + "This is CoW's fallback handler. It is needed for this Safe to be able to use the TWAP feature for Swaps.", + ), + ).not.toBeInTheDocument() + }) }) diff --git a/src/components/settings/FallbackHandler/index.tsx b/src/components/settings/FallbackHandler/index.tsx index 7aa77ac6ec..3bdd6f8ff2 100644 --- a/src/components/settings/FallbackHandler/index.tsx +++ b/src/components/settings/FallbackHandler/index.tsx @@ -1,4 +1,5 @@ -import { TWAP_FALLBACK_HANDLER } from '@/features/swap/helpers/utils' +import { TWAP_FALLBACK_HANDLER, TWAP_FALLBACK_HANDLER_NETWORKS } from '@/features/swap/helpers/utils' +import { getCompatibilityFallbackHandlerDeployments } from '@safe-global/safe-deployments' import NextLink from 'next/link' import { Typography, Box, Grid, Paper, Link, Alert } from '@mui/material' import semverSatisfies from 'semver/functions/satisfies' @@ -7,7 +8,6 @@ import type { ReactElement } from 'react' import EthHashInfo from '@/components/common/EthHashInfo' import useSafeInfo from '@/hooks/useSafeInfo' -import { getFallbackHandlerContractDeployment } from '@/services/contracts/deployments' import { HelpCenterArticle } from '@/config/constants' import ExternalLink from '@/components/common/ExternalLink' import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp' @@ -22,11 +22,12 @@ export const FallbackHandler = (): ReactElement | null => { const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION) - const fallbackHandlerDeployment = useMemo(() => { - if (!chain) { + const fallbackHandlerDeployments = useMemo(() => { + if (!chain || !safe.version) { return undefined } - return getFallbackHandlerContractDeployment(chain, safe.version) + + return getCompatibilityFallbackHandlerDeployments({ network: chain?.chainId, version: safe.version }) }, [safe.version, chain]) if (!supportsFallbackHandler) { @@ -35,8 +36,10 @@ export const FallbackHandler = (): ReactElement | null => { const hasFallbackHandler = !!safe.fallbackHandler const isOfficial = - hasFallbackHandler && safe.fallbackHandler?.value === fallbackHandlerDeployment?.networkAddresses[safe.chainId] - const isTWAPFallbackHandler = safe.fallbackHandler?.value === TWAP_FALLBACK_HANDLER + safe.fallbackHandler && + fallbackHandlerDeployments?.networkAddresses[safe.chainId].includes(safe.fallbackHandler.value) + const isTWAPFallbackHandler = + safe.fallbackHandler?.value === TWAP_FALLBACK_HANDLER && TWAP_FALLBACK_HANDLER_NETWORKS.includes(safe.chainId) const warning = !hasFallbackHandler ? ( <> @@ -101,7 +104,7 @@ export const FallbackHandler = (): ReactElement | null => { {safe.fallbackHandler && ( , ): number => { From bfcbba16428eaa027dd02d3fb847f5f9c8a5c1be Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 1 Oct 2024 15:23:40 +0200 Subject: [PATCH 49/74] fix: decoding of migration txs (#4291) --- .../TxData/MigrationToL2TxData/index.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx index 6989286724..636769abc7 100644 --- a/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/MigrationToL2TxData/index.tsx @@ -49,19 +49,19 @@ export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetai } return createTx({ to: execTxArgs[0], - value: execTxArgs[1], + value: execTxArgs[1].toString(), data: execTxArgs[2], - operation: execTxArgs[3], - safeTxGas: execTxArgs[4], - baseGas: execTxArgs[5], - gasPrice: execTxArgs[6], - gasToken: execTxArgs[7], + operation: Number(execTxArgs[3]), + safeTxGas: execTxArgs[4].toString(), + baseGas: execTxArgs[5].toString(), + gasPrice: execTxArgs[6].toString(), + gasToken: execTxArgs[7].toString(), refundReceiver: execTxArgs[8], }) } }, [readOnlyProvider, txDetails.txHash, chain, safe.version, sdk]) - const [decodedRealTx] = useDecodeTx(realSafeTx) + const [decodedRealTx, decodedRealTxError] = useDecodeTx(realSafeTx) const decodedDataUnavailable = !realSafeTx && !realSafeTxLoading @@ -70,6 +70,8 @@ export const MigrationToL2TxData = ({ txDetails }: { txDetails: TransactionDetai {realSafeTxError ? ( {realSafeTxError.message} + ) : decodedRealTxError ? ( + {decodedRealTxError.message} ) : decodedDataUnavailable ? ( ) : ( From c8592a9bcffc0b4c056d610274bfaedab466abc3 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 2 Oct 2024 09:43:10 +0200 Subject: [PATCH 50/74] fix: detection of migration txs in SignOrExecuteForm (#4296) --- .../tx/ApprovalEditor/ApprovalEditor.test.tsx | 2 + .../ExecuteThroughRoleForm/hooks.ts | 4 +- src/components/tx/SignOrExecuteForm/index.tsx | 6 +- .../tx/security/useRecipientModule.ts | 39 - .../recovery/services/recovery-state.ts | 4 +- .../recovery/services/transaction-list.ts | 4 +- .../security/modules/ApprovalModule/index.ts | 4 +- .../RecipientAddressModule/index.test.ts | 991 ------------------ .../modules/RecipientAddressModule/index.ts | 143 --- src/utils/__tests__/transactions.test.ts | 82 +- src/utils/transaction-calldata.ts | 4 +- src/utils/transaction-guards.ts | 22 - src/utils/transactions.ts | 109 +- 13 files changed, 124 insertions(+), 1290 deletions(-) delete mode 100644 src/components/tx/security/useRecipientModule.ts delete mode 100644 src/services/security/modules/RecipientAddressModule/index.test.ts delete mode 100644 src/services/security/modules/RecipientAddressModule/index.ts diff --git a/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx b/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx index b2e6fd8295..7df0c7021e 100644 --- a/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx +++ b/src/components/tx/ApprovalEditor/ApprovalEditor.test.tsx @@ -229,6 +229,7 @@ describe('ApprovalEditor', () => { to: tokenAddress, data: ERC20_INTERFACE.encodeFunctionData('transfer', [spenderAddress, '25']), value: '0', + operation: OperationType.Call, }, { to: tokenAddress, @@ -336,6 +337,7 @@ describe('ApprovalEditor', () => { to: tokenAddress, data: ERC20_INTERFACE.encodeFunctionData('transfer', [spenderAddress, '25']), value: '0', + operation: OperationType.Call, }, { to: tokenAddress, diff --git a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts index c99b10daa1..1fa5166634 100644 --- a/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts +++ b/src/components/tx/SignOrExecuteForm/ExecuteThroughRoleForm/hooks.ts @@ -25,9 +25,9 @@ import { KnownContracts, getModuleInstance } from '@gnosis.pm/zodiac' import useWallet from '@/hooks/wallets/useWallet' import { useHasFeature } from '@/hooks/useChains' import { FEATURES } from '@/utils/chains' -import { decodeMultiSendTxs } from '@/utils/transactions' import { encodeMultiSendData } from '@safe-global/protocol-kit' import { Multi_send__factory } from '@/types/contracts' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' const ROLES_V2_SUPPORTED_CHAINS = Object.keys(chains) const multiSendInterface = Multi_send__factory.createInterface() @@ -50,7 +50,7 @@ export const useMetaTransactions = (safeTx?: SafeTransaction): MetaTransactionDa if (metaTx.operation === OperationType.DelegateCall) { // try decoding as multisend try { - const baseTransactions = decodeMultiSendTxs(metaTx.data) + const baseTransactions = decodeMultiSendData(metaTx.data) if (baseTransactions.length > 0) { return baseTransactions.map((tx) => ({ ...tx, operation: OperationType.Call })) } diff --git a/src/components/tx/SignOrExecuteForm/index.tsx b/src/components/tx/SignOrExecuteForm/index.tsx index 722f156e3f..4c2f4ca618 100644 --- a/src/components/tx/SignOrExecuteForm/index.tsx +++ b/src/components/tx/SignOrExecuteForm/index.tsx @@ -27,7 +27,7 @@ import { trackEvent } from '@/services/analytics' import useChainId from '@/hooks/useChainId' import ExecuteThroughRoleForm from './ExecuteThroughRoleForm' import { findAllowingRole, findMostLikelyRole, useRoles } from './ExecuteThroughRoleForm/hooks' -import { isCustomTxInfo, isGenericConfirmation, isMigrateToL2MultiSend } from '@/utils/transaction-guards' +import { isCustomTxInfo, isGenericConfirmation } from '@/utils/transaction-guards' import useIsSafeOwner from '@/hooks/useIsSafeOwner' import { BlockaidBalanceChanges } from '../security/blockaid/BlockaidBalanceChange' import { Blockaid } from '../security/blockaid' @@ -115,8 +115,8 @@ export const SignOrExecuteForm = ({ const { safe } = useSafeInfo() const isSafeOwner = useIsSafeOwner() const isCounterfactualSafe = !safe.deployed - const isMultiChainMigration = isMigrateToL2MultiSend(decodedData) - const multiChainMigrationTarget = extractMigrationL2MasterCopyAddress(decodedData) + 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( diff --git a/src/components/tx/security/useRecipientModule.ts b/src/components/tx/security/useRecipientModule.ts deleted file mode 100644 index dbfd50d26c..0000000000 --- a/src/components/tx/security/useRecipientModule.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useMemo } from 'react' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' - -import useAddressBook from '@/hooks/useAddressBook' -import useAsync from '@/hooks/useAsync' -import useSafeInfo from '@/hooks/useSafeInfo' -import { useWeb3ReadOnly } from '@/hooks/wallets/web3' -import { RecipientAddressModule } from '@/services/security/modules/RecipientAddressModule' -import type { RecipientAddressModuleResponse } from '@/services/security/modules/RecipientAddressModule' -import type { SecurityResponse } from '@/services/security/modules/types' - -const RecipientAddressModuleInstance = new RecipientAddressModule() - -// TODO: Not being used right now -export const useRecipientModule = (safeTransaction: SafeTransaction | undefined) => { - const { safe, safeLoaded } = useSafeInfo() - const web3ReadOnly = useWeb3ReadOnly() - const addressBook = useAddressBook() - - const knownAddresses = useMemo(() => { - const owners = safe.owners.map((owner) => owner.value) - const addressBookAddresses = Object.keys(addressBook) - - return Array.from(new Set(owners.concat(addressBookAddresses))) - }, [addressBook, safe.owners]) - - return useAsync>(() => { - if (!safeTransaction || !web3ReadOnly || !safeLoaded) { - return - } - - return RecipientAddressModuleInstance.scanTransaction({ - chainId: safe.chainId, - safeTransaction, - knownAddresses, - provider: web3ReadOnly, - }) - }, [safeTransaction, web3ReadOnly, safeLoaded, safe.chainId, knownAddresses]) -} diff --git a/src/features/recovery/services/recovery-state.ts b/src/features/recovery/services/recovery-state.ts index 6303750e05..70941f90dc 100644 --- a/src/features/recovery/services/recovery-state.ts +++ b/src/features/recovery/services/recovery-state.ts @@ -8,7 +8,7 @@ import { toBeHex, type JsonRpcProvider, type TransactionReceipt } from 'ethers' import { trimTrailingSlash } from '@/utils/url' import { sameAddress } from '@/utils/addresses' import { isMultiSendCalldata } from '@/utils/transaction-calldata' -import { decodeMultiSendTxs } from '@/utils/transactions' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export const MAX_RECOVERER_PAGE_SIZE = 100 @@ -47,7 +47,7 @@ export function _isMaliciousRecovery({ const BASE_MULTI_SEND_CALL_ONLY_VERSION = '1.3.0' const isMultiSend = isMultiSendCalldata(transaction.data) - const transactions = isMultiSend ? decodeMultiSendTxs(transaction.data) : [transaction] + const transactions = isMultiSend ? decodeMultiSendData(transaction.data) : [transaction] if (!isMultiSend) { // Calling the Safe itself diff --git a/src/features/recovery/services/transaction-list.ts b/src/features/recovery/services/transaction-list.ts index 084acf0273..8f37c43efd 100644 --- a/src/features/recovery/services/transaction-list.ts +++ b/src/features/recovery/services/transaction-list.ts @@ -6,11 +6,11 @@ import { isChangeThresholdCalldata, isMultiSendCalldata, } from '@/utils/transaction-calldata' -import { decodeMultiSendTxs } from '@/utils/transactions' import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' import { Interface } from 'ethers' import type { BaseTransaction } from '@safe-global/safe-apps-sdk' import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' function decodeOwnerManagementTransaction(safe: SafeInfo, transaction: BaseTransaction): SafeInfo { const safeDeployment = getSafeSingletonDeployment({ network: safe.chainId, version: safe.version ?? undefined }) @@ -54,7 +54,7 @@ function decodeOwnerManagementTransaction(safe: SafeInfo, transaction: BaseTrans } export function getRecoveredSafeInfo(safe: SafeInfo, transaction: BaseTransaction): SafeInfo { - const transactions = isMultiSendCalldata(transaction.data) ? decodeMultiSendTxs(transaction.data) : [transaction] + const transactions = isMultiSendCalldata(transaction.data) ? decodeMultiSendData(transaction.data) : [transaction] return transactions.reduce((acc, cur) => { return decodeOwnerManagementTransaction(acc, cur) diff --git a/src/services/security/modules/ApprovalModule/index.ts b/src/services/security/modules/ApprovalModule/index.ts index 747f8ab777..d513507b0c 100644 --- a/src/services/security/modules/ApprovalModule/index.ts +++ b/src/services/security/modules/ApprovalModule/index.ts @@ -3,12 +3,12 @@ import { INCREASE_ALLOWANCE_SIGNATURE_HASH, } from '@/components/tx/ApprovalEditor/utils/approvals' import { ERC20__factory } from '@/types/contracts' -import { decodeMultiSendTxs } from '@/utils/transactions' import { normalizeTypedData } from '@/utils/web3' import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' import { type EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk' import { id } from 'ethers' import { type SecurityResponse, type SecurityModule, SecuritySeverity } from '../types' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export type ApprovalModuleResponse = Approval[] @@ -120,7 +120,7 @@ export class ApprovalModule implements SecurityModule ApprovalModule.scanInnerTransaction(tx, index))) } else { approvalInfos.push(...ApprovalModule.scanInnerTransaction({ to: safeTransaction.data.to, data: safeTxData }, 0)) diff --git a/src/services/security/modules/RecipientAddressModule/index.test.ts b/src/services/security/modules/RecipientAddressModule/index.test.ts deleted file mode 100644 index b8aef115bb..0000000000 --- a/src/services/security/modules/RecipientAddressModule/index.test.ts +++ /dev/null @@ -1,991 +0,0 @@ -import * as sdk from '@safe-global/safe-gateway-typescript-sdk' -import { OperationType } from '@safe-global/safe-core-sdk-types' -import { toBeHex } from 'ethers' -import type { JsonRpcProvider } from 'ethers' -import type { SafeInfo } from '@safe-global/safe-gateway-typescript-sdk' - -import * as walletUtils from '@/utils/wallets' -import { RecipientAddressModule } from '.' -import { - createMockSafeTransaction, - getMockErc20TransferCalldata, - getMockErc721TransferFromCalldata, - getMockErc721SafeTransferFromCalldata, - getMockErc721SafeTransferFromWithBytesCalldata, - getMockMultiSendCalldata, -} from '@/tests/transactions' - -describe('RecipientAddressModule', () => { - const isSmartContractSpy = jest.spyOn(walletUtils, 'isSmartContract') - - const mockGetBalance = jest.fn() - const mockProvider = { - getBalance: mockGetBalance, - } as unknown as JsonRpcProvider - - const mockGetSafeInfo = jest.spyOn(sdk, 'getSafeInfo') - - beforeEach(() => { - jest.clearAllMocks() - }) - - const RecipientAddressModuleInstance = new RecipientAddressModule() - - it('should not warn if the address(es) is/are known', async () => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - - const recipient = toBeHex('0x1', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [recipient], - }) - - expect(result).toEqual({ - severity: 0, - }) - - // Don't check further if the recipient is known - expect(isSmartContractSpy).not.toHaveBeenCalled() - }) - - describe('it should warn if the address(es) is/are not known', () => { - beforeEach(() => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - }) - - // ERC-20 - it('should warn about recipient of ERC-20 transfer recipients', async () => { - const erc20 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc20TransferCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc20, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - // ERC-721 - it('should warn about recipient of ERC-721 transferFrom recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721TransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256,bytes) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromWithBytesCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - // multiSend - it('should warn about recipient(s) of multiSend recipients', async () => { - const multiSend = toBeHex('0x01', 20) - - const recipient1 = toBeHex('0x02', 20) - const recipient2 = toBeHex('0x03', 20) - - const data = getMockMultiSendCalldata([recipient1, recipient2]) - - const safeTransaction = createMockSafeTransaction({ - to: multiSend, - data, - operation: OperationType.DelegateCall, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(2) - expect(mockGetBalance).toHaveBeenCalledTimes(2) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient1, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - // Other - // Covered in test below: "should warn about recipient of native transfer recipients / should not warn if the address(es) is/are used" - }) - - it('should warn about recipient of native transfer recipients / should not warn if the address(es) is/are used', async () => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - describe('it should warn if the address(es) is/are unused', () => { - beforeEach(() => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(0n)) - mockGetSafeInfo.mockImplementation(() => Promise.reject('Safe not found')) - }) - - // ERC-20 - it('should warn about recipient of ERC-20 transfer recipients', async () => { - const erc20 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc20TransferCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc20, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - // ERC-721 - it('should warn about recipient of ERC-721 transferFrom recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721TransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256,bytes) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromWithBytesCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - // multiSend - it('should warn about recipient(s) of multiSend recipients', async () => { - const multiSend = toBeHex('0x01', 20) - - const recipient1 = toBeHex('0x02', 20) - const recipient2 = toBeHex('0x03', 20) - - const data = getMockMultiSendCalldata([recipient1, recipient2]) - - const safeTransaction = createMockSafeTransaction({ - to: multiSend, - data, - operation: OperationType.DelegateCall, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(2) - expect(mockGetBalance).toHaveBeenCalledTimes(2) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient1, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient1, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - - // Other - it('should warn about recipient of native transfer recipients', async () => { - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 1, - address: recipient, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: 'UNUSED_ADDRESS', - }, - ], - }) - }) - }) - - it('should not warn if the address(s) is/are Safe(s) deployed on the current network', async () => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.resolve({} as SafeInfo)) - - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '1', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - // Don't check as on mainnet - expect(mockGetSafeInfo).not.toHaveBeenCalled() - - expect(result).toEqual({ - severity: 1, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - ], - }) - }) - - describe('it should warn if the address(es) is/are Safe(s) deployed on mainnet but not the current network', () => { - beforeEach(() => { - isSmartContractSpy.mockImplementation(() => Promise.resolve(false)) - mockGetBalance.mockImplementation(() => Promise.resolve(1n)) - mockGetSafeInfo.mockImplementation(() => Promise.resolve({} as SafeInfo)) - }) - - // ERC-20 - it('should warn about recipient of ERC-20 transfer recipients', async () => { - const erc20 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc20TransferCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc20, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - // ERC-721 - it('should warn about recipient of ERC-721 transferFrom recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721TransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - it('should warn about recipient of ERC-721 safeTransferFrom(address,address,uint256,bytes) recipients', async () => { - const erc721 = toBeHex('0x01', 20) - - const recipient = toBeHex('0x02', 20) - const data = getMockErc721SafeTransferFromWithBytesCalldata(recipient) - - const safeTransaction = createMockSafeTransaction({ - to: erc721, - data, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - // multiSend - it('should warn about recipient(s) of multiSend recipients', async () => { - const multiSend = toBeHex('0x01', 20) - - const recipient1 = toBeHex('0x02', 20) - const recipient2 = toBeHex('0x03', 20) - - const data = getMockMultiSendCalldata([recipient1, recipient2]) - - const safeTransaction = createMockSafeTransaction({ - to: multiSend, - data, - operation: OperationType.DelegateCall, - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(2) - expect(mockGetBalance).toHaveBeenCalledTimes(2) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(2) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient1, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient1, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - { - severity: 1, - address: recipient2, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient2, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - - // Other - it('should warn about recipient of native transfer recipients', async () => { - const recipient = toBeHex('0x01', 20) - - const safeTransaction = createMockSafeTransaction({ - to: recipient, - data: '0x', - }) - - const result = await RecipientAddressModuleInstance.scanTransaction({ - safeTransaction, - provider: mockProvider, - chainId: '5', - knownAddresses: [], - }) - - expect(isSmartContractSpy).toHaveBeenCalledTimes(1) - expect(mockGetBalance).toHaveBeenCalledTimes(1) - expect(mockGetSafeInfo).toHaveBeenCalledTimes(1) - - expect(result).toEqual({ - severity: 3, - payload: [ - { - severity: 1, - address: recipient, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: 'UNKNOWN_ADDRESS', - }, - { - severity: 3, - address: recipient, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: 'SAFE_ON_WRONG_CHAIN', - }, - ], - }) - }) - }) -}) diff --git a/src/services/security/modules/RecipientAddressModule/index.ts b/src/services/security/modules/RecipientAddressModule/index.ts deleted file mode 100644 index 77d4f898a6..0000000000 --- a/src/services/security/modules/RecipientAddressModule/index.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { JsonRpcProvider } from 'ethers' - -import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { isSmartContract } from '@/utils/wallets' -import { sameAddress } from '@/utils/addresses' -import { getTransactionRecipients } from '@/utils/transaction-calldata' -import { SecuritySeverity } from '../types' -import type { SecurityResponse, SecurityModule } from '../types' - -type RecipientAddressModuleWarning = { - severity: SecuritySeverity - type: RecipietAddressIssueType - address: string - description: { - short: string - long: string - } -} - -export type RecipientAddressModuleResponse = Array - -export type RecipientAddressModuleRequest = { - knownAddresses: string[] - safeTransaction: SafeTransaction - provider: JsonRpcProvider - chainId: string -} - -export const enum RecipietAddressIssueType { - UNKNOWN_ADDRESS = 'UNKNOWN_ADDRESS', - UNUSED_ADDRESS = 'UNUSED_ADDRESS', - SAFE_ON_WRONG_CHAIN = 'SAFE_ON_WRONG_CHAIN', -} - -const MAINNET_CHAIN_ID = '1' - -export class RecipientAddressModule - implements SecurityModule -{ - private isKnownAddress(knownAddresses: string[], address: string): boolean { - return knownAddresses.some((knownAddress) => sameAddress(knownAddress, address)) - } - - private async shouldWarnOfMainnetSafe(currentChainId: string, address: string): Promise { - // We only check if the address is a Safe on mainnet to reduce the number of requests - if (currentChainId === MAINNET_CHAIN_ID) { - return false - } - - try { - await getSafeInfo(MAINNET_CHAIN_ID, address) - return true - } catch { - return false - } - } - - private async checkAddress( - chainId: string, - knownAddresses: Array, - address: string, - provider: JsonRpcProvider, - ): Promise> { - const warnings: Array = [] - - if (this.isKnownAddress(knownAddresses, address)) { - return warnings - } - - if (await isSmartContract(address)) { - return warnings - } - - warnings.push({ - severity: SecuritySeverity.LOW, - address, - description: { - short: 'Address is not known', - long: 'The address is not a signer or present in your address book and is not a smart contract', - }, - type: RecipietAddressIssueType.UNKNOWN_ADDRESS, - }) - - const [balance, shouldWarnOfMainnetSafe] = await Promise.all([ - provider.getBalance(address), - this.shouldWarnOfMainnetSafe(chainId, address), - ]) - - if (balance === 0n) { - warnings.push({ - severity: SecuritySeverity.LOW, - address, - description: { - short: 'Address seems to be unused', - long: 'The address has no native token balance and is not a smart contract', - }, - type: RecipietAddressIssueType.UNUSED_ADDRESS, - }) - } - - if (shouldWarnOfMainnetSafe) { - warnings.push({ - severity: SecuritySeverity.HIGH, - address, - description: { - short: 'Target Safe not deployed on current network', - long: 'The address is a Safe on mainnet, but it is not deployed on the current network', - }, - type: RecipietAddressIssueType.SAFE_ON_WRONG_CHAIN, - }) - } - - return warnings - } - - async scanTransaction( - request: RecipientAddressModuleRequest, - ): Promise> { - const { safeTransaction, provider, chainId, knownAddresses } = request - - const uniqueRecipients = Array.from(new Set(getTransactionRecipients(safeTransaction.data))) - - const warnings = ( - await Promise.all( - uniqueRecipients.map((address) => this.checkAddress(chainId, knownAddresses, address, provider)), - ) - ).flat() - - if (warnings.length === 0) { - return { - severity: SecuritySeverity.NONE, - } - } - - const severity = Math.max(...warnings.map((warning) => warning.severity)) - - return { - severity, - payload: warnings, - } - } -} diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index d9d7c91713..0f3f06814e 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -7,7 +7,12 @@ import type { } from '@safe-global/safe-gateway-typescript-sdk' import { TransactionInfoType, ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' import { isMultiSendTxInfo } from '../transaction-guards' -import { getQueuedTransactionCount, getTxOrigin, prependSafeToL2Migration } from '../transactions' +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' @@ -32,6 +37,8 @@ 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 @@ -425,4 +432,77 @@ describe('transactions', () => { ]) }) }) + + 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/transaction-calldata.ts b/src/utils/transaction-calldata.ts index 303cccb48a..2d91e6250d 100644 --- a/src/utils/transaction-calldata.ts +++ b/src/utils/transaction-calldata.ts @@ -5,8 +5,8 @@ import type { BaseTransaction } from '@safe-global/safe-apps-sdk' import { Multi_send__factory } from '@/types/contracts/factories/@safe-global/safe-deployments/dist/assets/v1.3.0' import { ERC20__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC20__factory' import { ERC721__factory } from '@/types/contracts/factories/@openzeppelin/contracts/build/contracts/ERC721__factory' -import { decodeMultiSendTxs } from '@/utils/transactions' import { Safe__factory } from '@/types/contracts' +import { decodeMultiSendData } from '@safe-global/protocol-kit/dist/src/utils' export const isCalldata = (data: string, fragment: FunctionFragment): boolean => { const signature = fragment.format() @@ -93,7 +93,7 @@ export const getTransactionRecipients = ({ data, to }: BaseTransaction): Array { - if (decodedData?.method === 'multiSend' && Array.isArray(decodedData.parameters[0].valueDecoded)) { - const innerTxs = decodedData.parameters[0].valueDecoded - const firstInnerTx = innerTxs[0] - if (firstInnerTx) { - const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() - const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress - - return ( - firstInnerTx.dataDecoded?.method === 'migrateToL2' && - firstInnerTx.dataDecoded.parameters.length === 1 && - firstInnerTx.dataDecoded?.parameters?.[0]?.type === 'address' && - typeof firstInnerTx.dataDecoded?.parameters[0].value === 'string' && - sameAddress(firstInnerTx.to, safeToL2MigrationAddress) - ) - } - } - - return false -} - // TransactionInfo type guards export const isTransferTxInfo = (value: TransactionInfo): value is Transfer => { return value.type === TransactionInfoType.TRANSFER || isSwapTransferOrderTxInfo(value) diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index ee89fd1259..09546754c3 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -1,6 +1,5 @@ import type { ChainInfo, - DecodedDataResponse, ExecutionInfo, MultisigExecutionDetails, MultisigExecutionInfo, @@ -29,10 +28,8 @@ import type { SafeTransaction, TransactionOptions } from '@safe-global/safe-core import { FEATURES, hasFeature } from '@/utils/chains' import uniqBy from 'lodash/uniqBy' import { Errors, logError } from '@/services/exceptions' -import { Multi_send__factory, Safe_to_l2_migration__factory } from '@/types/contracts' -import { toBeHex, AbiCoder } from 'ethers' +import { Safe_to_l2_migration__factory } from '@/types/contracts' import { type BaseTransaction } from '@safe-global/safe-apps-sdk' -import { id } from 'ethers' import { isEmptyHexData } from '@/utils/hex' import { type ExtendedSafeInfo } from '@/store/safeInfoSlice' import { getSafeContractDeployment } from '@/services/contracts/deployments' @@ -207,17 +204,11 @@ export const getTxOrigin = (app?: Partial): string | undefined => { return origin } -const multiSendInterface = Multi_send__factory.createInterface() - -const multiSendFragment = multiSendInterface.getFunction('multiSend') - -const MULTISEND_SIGNATURE_HASH = id('multiSend(bytes)').slice(0, 10) - export const decodeSafeTxToBaseTransactions = (safeTx: SafeTransaction): BaseTransaction[] => { const txs: BaseTransaction[] = [] const safeTxData = safeTx.data.data - if (safeTxData.startsWith(MULTISEND_SIGNATURE_HASH)) { - txs.push(...decodeMultiSendTxs(safeTxData)) + if (isMultiSendCalldata(safeTxData)) { + txs.push(...decodeMultiSendData(safeTxData)) } else { txs.push({ data: safeTxData, @@ -228,62 +219,6 @@ export const decodeSafeTxToBaseTransactions = (safeTx: SafeTransaction): BaseTra return txs } -/** - * TODO: Use core-sdk - * Decodes the transactions contained in `multiSend` call data - * - * @param encodedMultiSendData `multiSend` call data - * @returns array of individual transaction data - */ -export const decodeMultiSendTxs = (encodedMultiSendData: string): BaseTransaction[] => { - // uint8 operation, address to, uint256 value, uint256 dataLength - const INDIVIDUAL_TX_DATA_LENGTH = 2 + 40 + 64 + 64 - - const [decodedMultiSendData] = multiSendInterface.decodeFunctionData(multiSendFragment, encodedMultiSendData) - - const txs: BaseTransaction[] = [] - - // Decode after 0x - let index = 2 - - while (index < decodedMultiSendData.length) { - const txDataEncoded = `0x${decodedMultiSendData.slice( - index, - // Traverse next transaction - (index += INDIVIDUAL_TX_DATA_LENGTH), - )}` - - // Decode operation, to, value, dataLength - let txTo, txValue, txDataBytesLength - try { - ;[, txTo, txValue, txDataBytesLength] = AbiCoder.defaultAbiCoder().decode( - ['uint8', 'address', 'uint256', 'uint256'], - toBeHex(txDataEncoded, 32 * 4), - ) - } catch (e) { - logError(Errors._809, e) - continue - } - - // Each byte is represented by two characters - const dataLength = Number(txDataBytesLength) * 2 - - const txData = `0x${decodedMultiSendData.slice( - index, - // Traverse data length - (index += dataLength), - )}` - - txs.push({ - to: txTo, - value: txValue.toString(), - data: txData, - }) - } - - return txs -} - export const isRejectionTx = (tx?: SafeTransaction) => { return !!tx && !!tx.data.data && isEmptyHexData(tx.data.data) && tx.data.value === '0' } @@ -391,19 +326,31 @@ export const prependSafeToL2Migration = ( return __unsafe_createMultiSendTx(newTxs) } -export const extractMigrationL2MasterCopyAddress = ( - decodedData: DecodedDataResponse | undefined, -): string | undefined => { - if (decodedData?.method === 'multiSend' && Array.isArray(decodedData.parameters[0].valueDecoded)) { - const innerTxs = decodedData.parameters[0].valueDecoded - const firstInnerTx = innerTxs[0] - if (firstInnerTx) { - return firstInnerTx.dataDecoded?.method === 'migrateToL2' && - firstInnerTx.dataDecoded.parameters.length === 1 && - firstInnerTx.dataDecoded?.parameters?.[0]?.type === 'address' - ? firstInnerTx.dataDecoded.parameters?.[0].value.toString() - : undefined - } +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 From a5b4870c1f8218eab3979f0f4e2184cbf7b914fd Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 2 Oct 2024 09:43:43 +0200 Subject: [PATCH 51/74] Feat(Multichain): explain impossible network addition (#4283) --- .../common/NetworkSelector/index.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 825526e4b6..f99b96e634 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -39,6 +39,7 @@ import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' import { useGetSafeOverviewQuery } from '@/store/api/gateway' +import { InfoOutlined } from '@mui/icons-material' import { selectUndeployedSafe } from '@/store/slices' import { skipToken } from '@reduxjs/toolkit/query' @@ -198,11 +199,20 @@ const UndeployedNetworks = ({ } const errorMessage = - safeCreationDataError || (safeCreationData && noAvailableNetworks) - ? 'Adding another network is not possible for this Safe.' - : isUnsupportedSafeCreationVersion - ? 'This account was created from an outdated mastercopy. Adding another network is not possible.' - : '' + safeCreationDataError || (safeCreationData && noAvailableNetworks) ? ( + + {safeCreationDataError?.message && ( + + + + )} + Adding another network is not possible for this Safe. + + ) : isUnsupportedSafeCreationVersion ? ( + 'This account was created from an outdated mastercopy. Adding another network is not possible.' + ) : ( + '' + ) if (errorMessage) { return ( From 6cd64f899b489bf61ccf2fbd1463e223e70b4e8a Mon Sep 17 00:00:00 2001 From: James Mealy Date: Wed, 2 Oct 2024 11:42:29 +0100 Subject: [PATCH 52/74] Feat(Multichain): add feature flag checks when adding a new network [SW-240] (#4288) * feat: add checks for add network feature flag. * fix: remove condition for 141 to be enabled * feat: do not show add network option in context menu when feature is disabled * hide add network button on multichain groups * feat: hide disabled networks instead of disabling them in the networks selector and network input * rename feature flag variable for consistency --- .../common/NetworkSelector/index.tsx | 13 +++++++++--- .../new-safe/create/logic/index.test.ts | 4 ++-- .../MultiAccountContextMenu.tsx | 17 +++++++++------- .../welcome/MyAccounts/AccountItem.tsx | 7 ++++++- .../welcome/MyAccounts/MultiAccountItem.tsx | 20 ++++++++++++++----- .../MyAccounts/utils/multiChainSafe.ts | 5 +++++ .../components/CreateSafeOnNewChain/index.tsx | 10 ++++++++-- src/utils/chains.ts | 1 + 8 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index f99b96e634..962fd4a7bc 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -20,7 +20,7 @@ import { } from '@mui/material' import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import useChains from '@/hooks/useChains' +import useChains, { useCurrentChain } from '@/hooks/useChains' import type { NextRouter } from 'next/router' import { useRouter } from 'next/router' import css from './styles.module.css' @@ -39,6 +39,7 @@ import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' import { useGetSafeOverviewQuery } from '@/store/api/gateway' +import { hasMultiChainAddNetworkFeature } from '@/components/welcome/MyAccounts/utils/multiChainSafe' import { InfoOutlined } from '@mui/icons-material' import { selectUndeployedSafe } from '@/store/slices' import { skipToken } from '@reduxjs/toolkit/query' @@ -175,7 +176,10 @@ const UndeployedNetworks = ({ const isUnsupportedSafeCreationVersion = Boolean(!allCompatibleChains?.length) const availableNetworks = useMemo( - () => allCompatibleChains?.filter((config) => !deployedChains.includes(config.chainId)) || [], + () => + allCompatibleChains?.filter( + (config) => !deployedChains.includes(config.chainId) && hasMultiChainAddNetworkFeature(config), + ) || [], [allCompatibleChains, deployedChains], ) @@ -288,10 +292,13 @@ const NetworkSelector = ({ const chainId = useChainId() const router = useRouter() const safeAddress = useSafeAddress() + const currentChain = useCurrentChain() const chains = useAppSelector(selectChains) const isSafeOpened = safeAddress !== '' + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(currentChain) + const safesGrouped = useAllSafesGrouped() const availableChainIds = useMemo(() => { if (!isSafeOpened) { @@ -387,7 +394,7 @@ const NetworkSelector = ({ {testNets.map((chain) => renderMenuItem(chain.chainId, false))} - {offerSafeCreation && isSafeOpened && ( + {offerSafeCreation && isSafeOpened && addNetworkFeatureEnabled && ( { safeSetup, chainBuilder() .with({ chainId: '137' }) - // Multichain creation is toggled off + // Multichain creation is toggled on .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) .with({ l2: true }) .build(), @@ -300,7 +300,7 @@ describe('create/logic', () => { safeSetup, chainBuilder() .with({ chainId: '137' }) - // Multichain creation is toggled off + // Multichain creation is toggled on .with({ features: [FEATURES.SAFE_141, FEATURES.COUNTERFACTUAL, FEATURES.MULTI_CHAIN_SAFE_CREATION] as any }) .with({ l2: true }) .build(), diff --git a/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx index a5a796f752..79fb34c4c7 100644 --- a/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx +++ b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx @@ -27,10 +27,12 @@ const MultiAccountContextMenu = ({ name, address, chainIds, + addNetwork, }: { name: string address: string chainIds: string[] + addNetwork: boolean }): ReactElement => { const [anchorEl, setAnchorEl] = useState() const [open, setOpen] = useState(defaultOpen) @@ -72,13 +74,14 @@ const MultiAccountContextMenu = ({ Rename - - - - - - Add another network - + {addNetwork && ( + + + + + Add another network + + )} {open[ModalType.RENAME] && ( diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index 8ccfa609da..b8389543d3 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -29,6 +29,7 @@ import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features import { useGetSafeOverviewQuery } from '@/store/api/gateway' import useWallet from '@/hooks/wallets/useWallet' import { skipToken } from '@reduxjs/toolkit/query' +import { hasMultiChainAddNetworkFeature } from './utils/multiChainSafe' type AccountItemProps = { safeItem: SafeItem @@ -63,7 +64,11 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { ? extractCounterfactualSafeSetup(undeployedSafe, chain?.chainId) : undefined - const isReplayable = !safeItem.isWatchlist && (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(chain) + const isReplayable = + addNetworkFeatureEnabled && + !safeItem.isWatchlist && + (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) const { data: safeOverview } = useGetSafeOverviewQuery( undeployedSafe diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index cafca1fa2c..f28bae33f1 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -28,7 +28,7 @@ import { type MultiChainSafeItem } from './useAllSafesGrouped' import { shortenAddress } from '@/utils/formatters' import { type SafeItem } from './useAllSafes' import SubAccountItem from './SubAccountItem' -import { getSafeSetups, getSharedSetup } from './utils/multiChainSafe' +import { getSafeSetups, getSharedSetup, hasMultiChainAddNetworkFeature } from './utils/multiChainSafe' import { AddNetworkButton } from './AddNetworkButton' import { isPredictedSafeProps } from '@/features/counterfactual/utils' import ChainIndicator from '@/components/common/ChainIndicator' @@ -36,6 +36,7 @@ import MultiAccountContextMenu from '@/components/sidebar/SafeListContextMenu/Mu import { useGetMultipleSafeOverviewsQuery } from '@/store/api/gateway' import useWallet from '@/hooks/wallets/useWallet' import { selectCurrency } from '@/store/settingsSlice' +import { selectChains } from '@/store/chainsSlice' type MultiAccountItemProps = { multiSafeAccountItem: MultiChainSafeItem @@ -73,8 +74,9 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte const isCurrentSafe = sameAddress(safeAddress, address) const isWelcomePage = router.pathname === AppRoutes.welcome.accounts const [expanded, setExpanded] = useState(isCurrentSafe) + const chains = useAppSelector(selectChains) - const deployedChains = useMemo(() => safes.map((safe) => safe.chainId), [safes]) + const deployedChainIds = useMemo(() => safes.map((safe) => safe.chainId), [safes]) const isWatchlist = useMemo( () => multiSafeAccountItem.safes.every((safe) => safe.isWatchlist), @@ -116,10 +118,13 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte () => safes.some((safeItem) => { const undeployedSafe = undeployedSafes[safeItem.chainId]?.[safeItem.address] + const chain = chains.data.find((chain) => chain.chainId === safeItem.chainId) + const addNetworkFeatureEnabled = hasMultiChainAddNetworkFeature(chain) + // We can only replay deployed Safes and new counterfactual Safes. - return !undeployedSafe || !isPredictedSafeProps(undeployedSafe.props) + return (!undeployedSafe || !isPredictedSafeProps(undeployedSafe.props)) && addNetworkFeatureEnabled }), - [safes, undeployedSafes], + [chains.data, safes, undeployedSafes], ) const findOverview = useCallback( @@ -170,7 +175,12 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem }: MultiAccountIte )} - + diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts index 8e54d3df2c..cbc70f14ae 100644 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts +++ b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts @@ -121,3 +121,8 @@ export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { hasFeature(chain, FEATURES.SAFE_141) ) } + +export const hasMultiChainAddNetworkFeature = (chain: ChainInfo | undefined): boolean => { + if (!chain) return false + return hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_ADD_NETWORK) && hasFeature(chain, FEATURES.COUNTERFACTUAL) +} diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 62d2ce565e..894c00c8d6 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -12,7 +12,10 @@ import useChains from '@/hooks/useChains' import { useAppDispatch, useAppSelector } from '@/store' import { selectRpc } from '@/store/settingsSlice' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' -import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { + hasMultiChainAddNetworkFeature, + predictAddressBasedOnReplayData, +} from '@/components/welcome/MyAccounts/utils/multiChainSafe' import { sameAddress } from '@/utils/addresses' import ExternalLink from '@/components/common/ExternalLink' import { useRouter } from 'next/router' @@ -229,7 +232,10 @@ export const CreateSafeOnNewChain = ({ const allCompatibleChains = useCompatibleNetworks(safeCreationResult[0]) const isUnsupportedSafeCreationVersion = Boolean(!allCompatibleChains?.length) const replayableChains = useMemo( - () => allCompatibleChains?.filter((config) => !deployedChainIds.includes(config.chainId)) || [], + () => + allCompatibleChains?.filter( + (config) => !deployedChainIds.includes(config.chainId) && hasMultiChainAddNetworkFeature(config), + ) || [], [allCompatibleChains, deployedChainIds], ) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 74355ac115..aff0eaa512 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -36,6 +36,7 @@ export enum FEATURES { SAFE_141 = 'SAFE_141', STAKING = 'STAKING', MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', + MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', } export const FeatureRoutes = { From d7ee967fd4e63ef62b3496bb9e53dba0013b84d6 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 2 Oct 2024 14:44:12 +0200 Subject: [PATCH 53/74] Fix(Multichain): do not migrate to L2 if no lib contract is available (#4300) --- .../__tests__/useSafeNotifications.test.ts | 6 ++--- .../contracts/__tests__/safeContracts.test.ts | 22 +++++++++++++++++- src/services/contracts/safeContracts.ts | 6 ++++- src/utils/__tests__/transactions.test.ts | 14 +++++++++++ src/utils/transactions.ts | 23 ++++++++----------- 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/hooks/__tests__/useSafeNotifications.test.ts b/src/hooks/__tests__/useSafeNotifications.test.ts index d10ba16ed5..1f28a42fc0 100644 --- a/src/hooks/__tests__/useSafeNotifications.test.ts +++ b/src/hooks/__tests__/useSafeNotifications.test.ts @@ -134,7 +134,7 @@ describe('useSafeNotifications', () => { address: { value: '0x1', }, - chainId: '5', + chainId: '10', }, }) @@ -165,7 +165,7 @@ describe('useSafeNotifications', () => { address: { value: '0x1', }, - chainId: '5', + chainId: '10', }, }) @@ -191,7 +191,7 @@ describe('useSafeNotifications', () => { address: { value: '0x1', }, - chainId: '5', + chainId: '10', }, }) diff --git a/src/services/contracts/__tests__/safeContracts.test.ts b/src/services/contracts/__tests__/safeContracts.test.ts index 6e269bdc85..d81dcc21c1 100644 --- a/src/services/contracts/__tests__/safeContracts.test.ts +++ b/src/services/contracts/__tests__/safeContracts.test.ts @@ -1,5 +1,11 @@ import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk' -import { _getValidatedGetContractProps, isValidMasterCopy, _getMinimumMultiSendCallOnlyVersion } from '../safeContracts' +import { + _getValidatedGetContractProps, + isValidMasterCopy, + _getMinimumMultiSendCallOnlyVersion, + isMigrationToL2Possible, +} from '../safeContracts' +import { safeInfoBuilder } from '@/tests/builders/safe' describe('safeContracts', () => { describe('isValidMasterCopy', () => { @@ -63,4 +69,18 @@ describe('safeContracts', () => { expect(_getMinimumMultiSendCallOnlyVersion('1.4.1')).toBe('1.4.1') }) }) + + describe('isMigrationToL2Possible', () => { + it('should not be possible to migrate Safes on chains without migration lib', () => { + expect(isMigrationToL2Possible(safeInfoBuilder().with({ nonce: 0, chainId: '69420' }).build())).toBeFalsy() + }) + + it('should not be possible to migrate Safes with nonce > 0', () => { + expect(isMigrationToL2Possible(safeInfoBuilder().with({ nonce: 2, chainId: '10' }).build())).toBeFalsy() + }) + + it('should be possible to migrate Safes with nonce 0 on chains with migration lib', () => { + expect(isMigrationToL2Possible(safeInfoBuilder().with({ nonce: 0, chainId: '10' }).build())).toBeTruthy() + }) + }) }) diff --git a/src/services/contracts/safeContracts.ts b/src/services/contracts/safeContracts.ts index 044186a0ec..8a545fae69 100644 --- a/src/services/contracts/safeContracts.ts +++ b/src/services/contracts/safeContracts.ts @@ -15,6 +15,7 @@ import type { SafeVersion } from '@safe-global/safe-core-sdk-types' import { assertValidSafeVersion, getSafeSDK } from '@/hooks/coreSDK/safeCoreSDK' import semver from 'semver' import { getLatestSafeVersion } from '@/utils/chains' +import { getSafeToL2MigrationDeployment } from '@safe-global/safe-deployments' // `UNKNOWN` is returned if the mastercopy does not match supported ones // @see https://github.com/safe-global/safe-client-gateway/blob/main/src/routes/safes/handlers/safes.rs#L28-L31 @@ -24,7 +25,10 @@ export const isValidMasterCopy = (implementationVersionState: SafeInfo['implemen } export const isMigrationToL2Possible = (safe: SafeInfo): boolean => { - return safe.nonce === 0 + return ( + safe.nonce === 0 && + Boolean(getSafeToL2MigrationDeployment({ network: safe.chainId })?.networkAddresses[safe.chainId]) + ) } export const _getValidatedGetContractProps = ( diff --git a/src/utils/__tests__/transactions.test.ts b/src/utils/__tests__/transactions.test.ts index 0f3f06814e..68ca1f22c2 100644 --- a/src/utils/__tests__/transactions.test.ts +++ b/src/utils/__tests__/transactions.test.ts @@ -322,6 +322,20 @@ describe('transactions', () => { ) }) + 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 diff --git a/src/utils/transactions.ts b/src/utils/transactions.ts index 09546754c3..688cacefda 100644 --- a/src/utils/transactions.ts +++ b/src/utils/transactions.ts @@ -261,33 +261,28 @@ export const prependSafeToL2Migration = ( 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) + 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 safeL2Deployment = getSafeContractDeployment(chain, safe.version) - const safeL2DeploymentAddress = safeL2Deployment?.networkAddresses[chain.chainId] - const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment({ network: chain?.chainId }) - - if (!safeL2DeploymentAddress) { - throw new Error('No L2 MasterCopy found') - } - - if (!safeToL2MigrationDeployment) { - throw new Error('No safe to L2 migration contract found') - } - - const safeToL2MigrationAddress = safeToL2MigrationDeployment.defaultAddress const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() if (sameAddress(safe.implementation.value, safeL2DeploymentAddress)) { From c8f5406122b3b9e162d9e10e896df8f6c5bc83c6 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:11:25 +0200 Subject: [PATCH 54/74] fix: Display Activate now button in sidebar for counterfactual safes (#4301) --- src/components/sidebar/NewTxButton/index.tsx | 7 +++++++ src/features/counterfactual/ActivateAccountButton.tsx | 1 + 2 files changed, 8 insertions(+) diff --git a/src/components/sidebar/NewTxButton/index.tsx b/src/components/sidebar/NewTxButton/index.tsx index 2c57814b06..9f953643e7 100644 --- a/src/components/sidebar/NewTxButton/index.tsx +++ b/src/components/sidebar/NewTxButton/index.tsx @@ -1,3 +1,5 @@ +import ActivateAccountButton from '@/features/counterfactual/ActivateAccountButton' +import useIsCounterfactualSafe from '@/features/counterfactual/hooks/useIsCounterfactualSafe' import { type ReactElement, useContext } from 'react' import Button from '@mui/material/Button' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' @@ -8,12 +10,17 @@ import WatchlistAddButton from '../WatchlistAddButton' const NewTxButton = (): ReactElement => { const { setTxFlow } = useContext(TxModalContext) + const isCounterfactualSafe = useIsCounterfactualSafe() const onClick = () => { setTxFlow(, undefined, false) trackEvent({ ...OVERVIEW_EVENTS.NEW_TRANSACTION, label: 'sidebar' }) } + if (isCounterfactualSafe) { + return + } + return ( {(isOk) => diff --git a/src/features/counterfactual/ActivateAccountButton.tsx b/src/features/counterfactual/ActivateAccountButton.tsx index fe7670e4b7..c6cd0e3b7e 100644 --- a/src/features/counterfactual/ActivateAccountButton.tsx +++ b/src/features/counterfactual/ActivateAccountButton.tsx @@ -31,6 +31,7 @@ const ActivateAccountButton = () => { data-testid="activate-account-btn-cf" variant="contained" size="small" + fullWidth onClick={activateAccount} disabled={isProcessing || !isOk} sx={{ minHeight: '40px' }} From 53f220b05294c8fe9b208a5171dbcd4459fa1922 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:18:28 +0200 Subject: [PATCH 55/74] fix: Use chain-specific addresses for safe creation (#4305) --- src/components/new-safe/create/logic/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index df20ff92ab..f6e4475311 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -213,13 +213,13 @@ export const createNewUndeployedSafeWithoutSalt = ( }) const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) - const safeL2Address = safeL2Deployment?.defaultAddress + const safeL2Address = safeL2Deployment?.networkAddresses[chain.chainId] const safeL1Deployment = getSafeSingletonDeployment({ version: safeVersion, network: chain.chainId }) - const safeL1Address = safeL1Deployment?.defaultAddress + const safeL1Address = safeL1Deployment?.networkAddresses[chain.chainId] const safeFactoryDeployment = getProxyFactoryDeployment({ version: safeVersion, network: chain.chainId }) - const safeFactoryAddress = safeFactoryDeployment?.defaultAddress + const safeFactoryAddress = safeFactoryDeployment?.networkAddresses[chain.chainId] if (!safeL2Address || !safeL1Address || !safeFactoryAddress || !fallbackHandlerAddress) { throw new Error('No Safe deployment found') From 150013a1e000ad60d19f88a2eb0f2781ff3cde6f Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 7 Oct 2024 08:40:47 +0200 Subject: [PATCH 56/74] fix: validate account config for cf safes (#4302) --- .../__tests__/useSafeCreationData.test.ts | 51 +++++++++++++++++++ .../multichain/hooks/useSafeCreationData.ts | 30 +++++++---- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index 93aa1ac3b0..580c95d85b 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -76,6 +76,57 @@ describe('useSafeCreationData', () => { }) }) + it('should return undefined without chain info', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos: ChainInfo[] = [] + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos)) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, undefined, false]) + }) + }) + + it('should throw an error for replayed Safe it uses a unknown to address', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1' }).build()] + const undeployedSafe: UndeployedSafe = { + props: { + factoryAddress: faker.finance.ethereumAddress(), + saltNonce: '420', + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: faker.finance.ethereumAddress(), + fallbackHandler: faker.finance.ethereumAddress(), + payment: 0, + paymentToken: ZERO_ADDRESS, + paymentReceiver: ZERO_ADDRESS, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe, + }, + }, + }, + }) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([undefined, new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES), false]) + }) + }) + it('should throw an error for legacy counterfactual Safes', async () => { const safeAddress = faker.finance.ethereumAddress() const chainInfos = [chainBuilder().with({ chainId: '1', l2: false }).build()] diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index 864388a387..a880da987f 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -13,6 +13,7 @@ import { asError } from '@/services/exceptions/utils' import semverSatisfies from 'semver/functions/satisfies' import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants' import { getSafeToL2SetupDeployment } from '@safe-global/safe-deployments' +import { type SafeAccountConfig } from '@safe-global/protocol-kit' export const SAFE_CREATION_DATA_ERRORS = { TX_NOT_FOUND: 'The Safe creation transaction could not be found. Please retry later.', @@ -51,6 +52,19 @@ const getUndeployedSafeCreationData = async (undeployedSafe: UndeployedSafe): Pr return undeployedSafe.props } +const validateAccountConfig = (safeAccountConfig: SafeAccountConfig) => { + // Safes that used the reimbursement logic are not supported + if ((safeAccountConfig.payment && safeAccountConfig.payment > 0) || safeAccountConfig.paymentToken !== ZERO_ADDRESS) { + throw new Error(SAFE_CREATION_DATA_ERRORS.PAYMENT_SAFE) + } + + const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress + if (safeAccountConfig.to !== ZERO_ADDRESS && !sameAddress(safeAccountConfig.to, setupToL2Address)) { + // Unknown setupModules calls cannot be replayed as the target contract is likely not deployed across chains + throw new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES) + } +} + const proxyFactoryInterface = Safe_proxy_factory__factory.createInterface() const createProxySelector = proxyFactoryInterface.getFunction('createProxyWithNonce').selector @@ -68,7 +82,10 @@ const getCreationDataForChain = async ( ): Promise => { // 1. The safe is counterfactual if (undeployedSafe) { - return getUndeployedSafeCreationData(undeployedSafe) + const undeployedCreationData = await getUndeployedSafeCreationData(undeployedSafe) + validateAccountConfig(undeployedCreationData.safeAccountConfig) + + return undeployedCreationData } const { data: creation } = await getCreationTransaction({ @@ -90,16 +107,7 @@ const getCreationDataForChain = async ( const safeAccountConfig = decodeSetupData(creation.setupData) - // Safes that used the reimbursement logic are not supported - if ((safeAccountConfig.payment && safeAccountConfig.payment > 0) || safeAccountConfig.paymentToken !== ZERO_ADDRESS) { - throw new Error(SAFE_CREATION_DATA_ERRORS.PAYMENT_SAFE) - } - - const setupToL2Address = getSafeToL2SetupDeployment({ version: '1.4.1' })?.defaultAddress - if (safeAccountConfig.to !== ZERO_ADDRESS && !sameAddress(safeAccountConfig.to, setupToL2Address)) { - // Unknown setupModules calls cannot be replayed as the target contract is likely not deployed across chains - throw new Error(SAFE_CREATION_DATA_ERRORS.UNKNOWN_SETUP_MODULES) - } + validateAccountConfig(safeAccountConfig) // We need to create a readOnly provider of the deployed chain const customRpcUrl = chain ? customRpc?.[chain.chainId] : undefined From fcaa83e4e6f0dd346128fcbe470bda5251ec9dd0 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 7 Oct 2024 10:16:54 +0200 Subject: [PATCH 57/74] fix: store owner and Safe name in addressBook on all chosen networks (#4319) --- .../new-safe/create/logic/address-book.ts | 38 ++++++++++--------- .../create/steps/ReviewStep/index.tsx | 12 ++++++ .../create/steps/StatusStep/index.tsx | 2 - src/features/counterfactual/utils.ts | 17 --------- 4 files changed, 32 insertions(+), 37 deletions(-) diff --git a/src/components/new-safe/create/logic/address-book.ts b/src/components/new-safe/create/logic/address-book.ts index e57b77a6ad..09f16d2ce2 100644 --- a/src/components/new-safe/create/logic/address-book.ts +++ b/src/components/new-safe/create/logic/address-book.ts @@ -5,7 +5,7 @@ import { defaultSafeInfo } from '@/store/safeInfoSlice' import type { NamedAddress } from '@/components/new-safe/create/types' export const updateAddressBook = ( - chainId: string, + chainIds: string[], address: string, name: string, owners: NamedAddress[], @@ -14,7 +14,7 @@ export const updateAddressBook = ( return (dispatch) => { dispatch( upsertAddressBookEntries({ - chainIds: [chainId], + chainIds, address, name, }), @@ -23,24 +23,26 @@ export const updateAddressBook = ( owners.forEach((owner) => { const entryName = owner.name || owner.ens if (entryName) { - dispatch(upsertAddressBookEntries({ chainIds: [chainId], address: owner.address, name: entryName })) + dispatch(upsertAddressBookEntries({ chainIds, address: owner.address, name: entryName })) } }) - dispatch( - addOrUpdateSafe({ - safe: { - ...defaultSafeInfo, - address: { value: address, name }, - threshold, - owners: owners.map((owner) => ({ - value: owner.address, - name: owner.name || owner.ens, - })), - chainId, - nonce: 0, - }, - }), - ) + chainIds.forEach((chainId) => { + dispatch( + addOrUpdateSafe({ + safe: { + ...defaultSafeInfo, + address: { value: address, name }, + threshold, + owners: owners.map((owner) => ({ + value: owner.address, + name: owner.name || owner.ens, + })), + chainId, + nonce: 0, + }, + }), + ) + }) } } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index af6153d497..8a122672ff 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -48,6 +48,7 @@ import { type ReplayedSafeProps } from '@/store/slices' import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' import { createWeb3 } from '@/hooks/wallets/web3' import { type DeploySafeProps } from '@safe-global/protocol-kit' +import { updateAddressBook } from '../../logic/address-book' export const NetworkFee = ({ totalFee, @@ -215,6 +216,17 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps network.chainId), + safeAddress, + data.name, + data.owners, + data.threshold, + ), + ) + gtmSetChainId(chain.chainId) if (isCounterfactualEnabled && payMethod === PayMethod.PayLater) { diff --git a/src/components/new-safe/create/steps/StatusStep/index.tsx b/src/components/new-safe/create/steps/StatusStep/index.tsx index 11f1a79c95..546ceaa94d 100644 --- a/src/components/new-safe/create/steps/StatusStep/index.tsx +++ b/src/components/new-safe/create/steps/StatusStep/index.tsx @@ -2,7 +2,6 @@ import { useCounter } from '@/components/common/Notifications/useCounter' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' import { getRedirect } from '@/components/new-safe/create/logic' -import { updateAddressBook } from '@/components/new-safe/create/logic/address-book' import StatusMessage from '@/components/new-safe/create/steps/StatusStep/StatusMessage' import useUndeployedSafe from '@/components/new-safe/create/steps/StatusStep/useUndeployedSafe' import lightPalette from '@/components/theme/lightPalette' @@ -54,7 +53,6 @@ export const CreateSafeStatus = ({ if (!chain || !safeAddress) return if (status === SafeCreationEvent.SUCCESS) { - dispatch(updateAddressBook(chain.chainId, safeAddress, data.name, data.owners, data.threshold)) const redirect = getRedirect(chain.shortName, safeAddress, router.query?.safeViewRedirectURL) if (typeof redirect !== 'string' || redirect.startsWith('/')) { router.push(redirect) diff --git a/src/features/counterfactual/utils.ts b/src/features/counterfactual/utils.ts index 2b3b350ded..dead300c04 100644 --- a/src/features/counterfactual/utils.ts +++ b/src/features/counterfactual/utils.ts @@ -16,8 +16,6 @@ import ExternalStore from '@/services/ExternalStore' import { getSafeSDKWithSigner, getUncheckedSigner, tryOffChainTxSigning } from '@/services/tx/tx-sender/sdk' import { getRelayTxStatus, TaskState } from '@/services/tx/txMonitor' import type { AppDispatch } from '@/store' -import { addOrUpdateSafe } from '@/store/addedSafesSlice' -import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { defaultSafeInfo } from '@/store/safeInfoSlice' import { didRevert, type EthersError } from '@/utils/ethers-utils' import { assertProvider, assertTx, assertWallet } from '@/utils/helpers' @@ -175,21 +173,6 @@ export const replayCounterfactualSafeDeployment = ( } dispatch(addUndeployedSafe(undeployedSafe)) - dispatch(upsertAddressBookEntries({ chainIds: [chainId], address: safeAddress, name })) - dispatch( - addOrUpdateSafe({ - safe: { - ...defaultSafeInfo, - address: { value: safeAddress, name }, - threshold: setup.threshold, - owners: setup.owners.map((owner) => ({ - value: owner, - name: undefined, - })), - chainId, - }, - }), - ) } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) From 67e8423702d6cfdd1821a08e20de0823aff906ea Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:14:00 +0200 Subject: [PATCH 58/74] [Multichain] fix: Add more analytics events for multichain [SW-165] (#4311) * fix: Add more analytics events for multichain * fix: Remove event from SubAccountItem --- src/components/common/NetworkSelector/index.tsx | 8 +++++++- .../multichain/components/CreateSafeOnNewChain/index.tsx | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 962fd4a7bc..e3775772a2 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -26,7 +26,7 @@ import { useRouter } from 'next/router' import css from './styles.module.css' import { useChainId } from '@/hooks/useChainId' import { type ReactElement, useCallback, useMemo, useState } from 'react' -import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' import useSafeAddress from '@/hooks/useSafeAddress' @@ -327,12 +327,17 @@ const NetworkSelector = ({ const chain = chains.data.find((chain) => chain.chainId === chainId) if (!chain) return null + const onSwitchNetwork = () => { + trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: chainId }) + } + return ( { setOpen(true) + offerSafeCreation && trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: OVERVIEW_LABELS.top_bar }) } return configs.length ? ( diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 894c00c8d6..a4b276ac5e 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -1,7 +1,7 @@ import ModalDialog from '@/components/common/ModalDialog' import NetworkInput from '@/components/common/NetworkInput' import ErrorMessage from '@/components/tx/ErrorMessage' -import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { showNotification } from '@/store/notificationsSlice' import { Box, Button, CircularProgress, DialogActions, DialogContent, Stack, Typography } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' @@ -95,7 +95,7 @@ const ReplaySafeDialog = ({ return } - trackEvent({ ...OVERVIEW_EVENTS.SUBMIT_ADD_NEW_NETWORK, label: selectedChain.chainName }) + trackEvent({ ...OVERVIEW_EVENTS.SUBMIT_ADD_NEW_NETWORK, label: selectedChain.chainId }) // 2. Replay Safe creation and add it to the counterfactual Safes replayCounterfactualSafeDeployment( @@ -107,6 +107,9 @@ const ReplaySafeDialog = ({ PayMethod.PayLater, ) + trackEvent({ ...OVERVIEW_EVENTS.PROCEED_WITH_TX, label: 'counterfactual', category: CREATE_SAFE_CATEGORY }) + trackEvent({ ...CREATE_SAFE_EVENTS.CREATED_SAFE, label: 'counterfactual' }) + router.push({ query: { safe: `${selectedChain.shortName}:${safeAddress}`, From b6412105a95dd77dcb8aa7a545bbd56bdf8b11c3 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:14:15 +0200 Subject: [PATCH 59/74] fix: Add condensed network list to safe creation review (#4321) --- .../create/OverviewWidget/styles.module.css | 1 + .../create/steps/ReviewStep/index.tsx | 24 ++++++++++++++----- .../NetworkLogosList/styles.module.css | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/new-safe/create/OverviewWidget/styles.module.css b/src/components/new-safe/create/OverviewWidget/styles.module.css index c7e87b7dbe..6119485d8f 100644 --- a/src/components/new-safe/create/OverviewWidget/styles.module.css +++ b/src/components/new-safe/create/OverviewWidget/styles.module.css @@ -19,4 +19,5 @@ justify-content: space-between; align-items: center; border-top: 1px solid var(--color-border-light); + gap: var(--space-1); } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 8a122672ff..704e2c42d0 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -1,6 +1,7 @@ import type { NamedAddress } from '@/components/new-safe/create/types' import EthHashInfo from '@/components/common/EthHashInfo' import { safeCreationDispatch, SafeCreationEvent } from '@/features/counterfactual/services/safeCreationEvents' +import NetworkLogosList from '@/features/multichain/components/NetworkLogosList' import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' @@ -33,7 +34,7 @@ import { FEATURES, hasFeature } from '@/utils/chains' import { hasRemainingRelays } from '@/utils/relaying' import { isWalletRejection } from '@/utils/wallets' import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import { Box, Button, CircularProgress, Divider, Grid, Typography } from '@mui/material' +import { Box, Button, CircularProgress, Divider, Grid, Tooltip, Typography } from '@mui/material' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import classnames from 'classnames' import { useRouter } from 'next/router' @@ -88,11 +89,22 @@ export const SafeSetupOverview = ({ 1 ? 'Networks' : 'Network'} value={ - - {networks.map((network) => ( - - ))} - + + {networks.map((safeItem) => ( + + + + ))} + + } + arrow + > + + + + } /> {name && {name}} />} diff --git a/src/features/multichain/components/NetworkLogosList/styles.module.css b/src/features/multichain/components/NetworkLogosList/styles.module.css index 41d0b1c099..136e26c2c0 100644 --- a/src/features/multichain/components/NetworkLogosList/styles.module.css +++ b/src/features/multichain/components/NetworkLogosList/styles.module.css @@ -2,6 +2,7 @@ display: flex; flex-wrap: wrap; margin-left: 6px; + row-gap: 4px; } .networks img { From 87b5c0c771df98d31455152579e74278eb309007 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 7 Oct 2024 13:00:41 +0200 Subject: [PATCH 60/74] Fix(Multichain): disable apps for counterfactual Safes (#4320) --- .../sidebar/SidebarNavigation/index.tsx | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index 587e47a379..2a0d91e351 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -20,6 +20,7 @@ import { trackEvent } from '@/services/analytics' import { SWAP_EVENTS, SWAP_LABELS } from '@/services/analytics/events/swaps' import { GeoblockingContext } from '@/components/common/GeoblockingProvider' import { STAKE_EVENTS, STAKE_LABELS } from '@/services/analytics/events/stake' +import { Tooltip } from '@mui/material' const getSubdirectory = (pathname: string): string => { return pathname.split('/')[1] @@ -27,6 +28,8 @@ const getSubdirectory = (pathname: string): string => { const geoBlockedRoutes = [AppRoutes.swap, AppRoutes.stake] +const undeployedSafeBlockedRoutes = [AppRoutes.swap, AppRoutes.stake, AppRoutes.apps.index] + const customSidebarEvents: { [key: string]: { event: any; label: string } } = { [AppRoutes.swap]: { event: SWAP_EVENTS.OPEN_SWAPS, label: SWAP_LABELS.sidebar }, [AppRoutes.stake]: { event: STAKE_EVENTS.OPEN_STAKE, label: STAKE_LABELS.sidebar }, @@ -40,7 +43,7 @@ const Navigation = (): ReactElement => { const queueSize = useQueuedTxsLength() const isBlockedCountry = useContext(GeoblockingContext) - const enabledNavItems = useMemo(() => { + const visibleNavItems = useMemo(() => { return navItems.filter((item) => { if (isBlockedCountry && geoBlockedRoutes.includes(item.href)) { return false @@ -50,6 +53,12 @@ const Navigation = (): ReactElement => { }) }, [chain, isBlockedCountry]) + const enabledNavItems = useMemo(() => { + return safe.deployed + ? visibleNavItems + : visibleNavItems.filter((item) => undeployedSafeBlockedRoutes.includes(item.href)) + }, [safe.deployed, visibleNavItems]) + const getBadge = (item: NavItem) => { // Indicate whether the current Safe needs an upgrade if (item.href === AppRoutes.settings.setup) { @@ -74,9 +83,9 @@ const Navigation = (): ReactElement => { return ( - {enabledNavItems.map((item) => { + {visibleNavItems.map((item) => { const isSelected = currentSubdirectory === getSubdirectory(item.href) - + const isDisabled = item.disabled || !enabledNavItems.includes(item) let ItemTag = item.tag ? item.tag : null if (item.href === AppRoutes.transactions.history) { @@ -84,26 +93,34 @@ const Navigation = (): ReactElement => { } return ( - handleNavigationClick(item.href)} + - handleNavigationClick(item.href)} + key={item.href} > - {item.icon && {item.icon}} - - - {item.label} - - {ItemTag} - - - + + {item.icon && {item.icon}} + + + {item.label} + + {ItemTag} + + + + ) })} From ca3c779b3d5ab54bbd9184507139b51f1db65ea9 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 7 Oct 2024 13:01:21 +0200 Subject: [PATCH 61/74] fix: Use readonly provider for predicting address during safe creation (#4322) --- .../new-safe/create/steps/ReviewStep/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 704e2c42d0..79b4686ed4 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -47,7 +47,7 @@ import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' import { type ReplayedSafeProps } from '@/store/slices' import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' -import { createWeb3 } from '@/hooks/wallets/web3' +import { createWeb3ReadOnly } from '@/hooks/wallets/web3' import { type DeploySafeProps } from '@safe-global/protocol-kit' import { updateAddressBook } from '../../logic/address-book' @@ -147,6 +147,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps(false) @@ -222,7 +223,11 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps Date: Mon, 7 Oct 2024 14:12:25 +0200 Subject: [PATCH 62/74] fix: Invert condition to disable correct nav items (#4323) --- src/components/sidebar/SidebarNavigation/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sidebar/SidebarNavigation/index.tsx b/src/components/sidebar/SidebarNavigation/index.tsx index 2a0d91e351..0c77c43cf6 100644 --- a/src/components/sidebar/SidebarNavigation/index.tsx +++ b/src/components/sidebar/SidebarNavigation/index.tsx @@ -56,7 +56,7 @@ const Navigation = (): ReactElement => { const enabledNavItems = useMemo(() => { return safe.deployed ? visibleNavItems - : visibleNavItems.filter((item) => undeployedSafeBlockedRoutes.includes(item.href)) + : visibleNavItems.filter((item) => !undeployedSafeBlockedRoutes.includes(item.href)) }, [safe.deployed, visibleNavItems]) const getBadge = (item: NavItem) => { From 2d811eaf0e6ef349272a2bbcb284cd253e7e8126 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 8 Oct 2024 16:17:28 +0200 Subject: [PATCH 63/74] fix(Multichain): Safe address prediction and creation on zkSync (#4334) --- .../new-safe/create/logic/index.test.ts | 30 +++++++++++++++++++ src/components/new-safe/create/logic/index.ts | 3 +- src/components/new-safe/create/logic/utils.ts | 26 ++++++++++++---- .../create/steps/ReviewStep/index.tsx | 20 +++++++++++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/components/new-safe/create/logic/index.test.ts b/src/components/new-safe/create/logic/index.test.ts index 1a0ac55e53..dc04191981 100644 --- a/src/components/new-safe/create/logic/index.test.ts +++ b/src/components/new-safe/create/logic/index.test.ts @@ -320,5 +320,35 @@ describe('create/logic', () => { factoryAddress: getProxyFactoryDeployment({ version: '1.4.1', network: '137' })?.defaultAddress, }) }) + + it('should use l2 masterCopy and no migration on zkSync', () => { + const safeSetup = { + owners: [faker.finance.ethereumAddress()], + threshold: 1, + } + expect( + createNewUndeployedSafeWithoutSalt( + '1.3.0', + safeSetup, + chainBuilder() + .with({ chainId: '324' }) + // Multichain and 1.4.1 creation is toggled off + .with({ features: [FEATURES.COUNTERFACTUAL] as any }) + .with({ l2: true }) + .build(), + ), + ).toEqual({ + safeAccountConfig: { + ...safeSetup, + fallbackHandler: getFallbackHandlerDeployment({ version: '1.3.0', network: '324' })?.networkAddresses['324'], + to: ZERO_ADDRESS, + data: EMPTY_DATA, + paymentReceiver: ECOSYSTEM_ID_ADDRESS, + }, + safeVersion: '1.3.0', + masterCopy: getSafeL2SingletonDeployment({ version: '1.3.0', network: '324' })?.networkAddresses['324'], + factoryAddress: getProxyFactoryDeployment({ version: '1.3.0', network: '324' })?.networkAddresses['324'], + }) + }) }) }) diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index f6e4475311..8c6ca327c3 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -87,7 +87,6 @@ export const computeNewSafeAddress = async ( saltNonce: props.saltNonce, safeVersion: safeVersion ?? getLatestSafeVersion(chain), }, - isL1SafeSingleton: true, }) } @@ -211,7 +210,7 @@ export const createNewUndeployedSafeWithoutSalt = ( version: safeVersion, network: chain.chainId, }) - const fallbackHandlerAddress = fallbackHandlerDeployment?.defaultAddress + const fallbackHandlerAddress = fallbackHandlerDeployment?.networkAddresses[chain.chainId] const safeL2Deployment = getSafeL2SingletonDeployment({ version: safeVersion, network: chain.chainId }) const safeL2Address = safeL2Deployment?.networkAddresses[chain.chainId] diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts index fbc5a69bc3..bb471f358b 100644 --- a/src/components/new-safe/create/logic/utils.ts +++ b/src/components/new-safe/create/logic/utils.ts @@ -4,18 +4,20 @@ import { sameAddress } from '@/utils/addresses' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' import { type ReplayedSafeProps } from '@/store/slices' import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import chains from '@/config/chains' +import { computeNewSafeAddress } from '.' export const getAvailableSaltNonce = async ( customRpcs: { [chainId: string]: string }, replayedSafe: ReplayedSafeProps, - chains: ChainInfo[], + chainInfos: ChainInfo[], // All addresses from the sidebar disregarding the chain. This is an optimization to reduce RPC calls knownSafeAddresses: string[], ): Promise => { let isAvailableOnAllChains = true - const allRPCs = chains.map((chain) => { + const allRPCs = chainInfos.map((chain) => { const rpcUrl = customRpcs?.[chain.chainId] || getRpcServiceUrl(chain.rpcUri) // Turn into Eip1993Provider return { @@ -24,7 +26,7 @@ export const getAvailableSaltNonce = async ( } }) - for (const chain of chains) { + for (const chain of chainInfos) { const rpcUrl = allRPCs.find((rpc) => chain.chainId === rpc.chainId)?.rpcUrl if (!rpcUrl) { throw new Error(`No RPC available for ${chain.chainName}`) @@ -33,7 +35,21 @@ export const getAvailableSaltNonce = async ( if (!web3ReadOnly) { throw new Error('Could not initiate RPC') } - const safeAddress = await predictAddressBasedOnReplayData(replayedSafe, web3ReadOnly) + let safeAddress: string + if (chain.chainId === chains['zksync']) { + // ZK-sync is using a different create2 method which is supported by the SDK + safeAddress = await computeNewSafeAddress( + rpcUrl, + { + safeAccountConfig: replayedSafe.safeAccountConfig, + saltNonce: replayedSafe.saltNonce, + }, + chain, + replayedSafe.safeVersion, + ) + } else { + safeAddress = await predictAddressBasedOnReplayData(replayedSafe, web3ReadOnly) + } const isKnown = knownSafeAddresses.some((knownAddress) => sameAddress(knownAddress, safeAddress)) if (isKnown || (await isSmartContract(safeAddress, web3ReadOnly))) { // We found a chain where the nonce is used up @@ -47,7 +63,7 @@ export const getAvailableSaltNonce = async ( return getAvailableSaltNonce( customRpcs, { ...replayedSafe, saltNonce: (Number(replayedSafe.saltNonce) + 1).toString() }, - chains, + chainInfos, knownSafeAddresses, ) } diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 79b4686ed4..f9a195c414 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -6,6 +6,7 @@ import { getTotalFeeFormatted } from '@/hooks/useGasPrice' import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' import type { NewSafeFormData } from '@/components/new-safe/create' import { + computeNewSafeAddress, createNewSafe, createNewUndeployedSafeWithoutSalt, relaySafeCreation, @@ -47,9 +48,10 @@ import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' import { type ReplayedSafeProps } from '@/store/slices' import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' -import { createWeb3ReadOnly } from '@/hooks/wallets/web3' +import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' import { type DeploySafeProps } from '@safe-global/protocol-kit' import { updateAddressBook } from '../../logic/address-book' +import chains from '@/config/chains' export const NetworkFee = ({ totalFee, @@ -227,7 +229,21 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps Date: Tue, 8 Oct 2024 16:48:58 +0200 Subject: [PATCH 64/74] fix: Adjust multichain analytics events (#4330) --- src/components/common/NetworkSelector/index.tsx | 7 ++++++- src/components/welcome/MyAccounts/MultiAccountItem.tsx | 2 +- .../multichain/components/CreateSafeOnNewChain/index.tsx | 3 +++ src/services/analytics/events/overview.ts | 4 ++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index e3775772a2..b34f50d9d4 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -233,9 +233,14 @@ const UndeployedNetworks = ({ closeNetworkSelect() } + const onShowAllNetworks = () => { + !open && trackEvent(OVERVIEW_EVENTS.SHOW_ALL_NETWORKS) + setOpen((prev) => !prev) + } + return ( <> - setOpen((prev) => !prev)} tabIndex={-1}> +
    Show all networks
    { - trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: trackingLabel }) + !expanded && trackEvent({ ...OVERVIEW_EVENTS.EXPAND_MULTI_SAFE, label: trackingLabel }) setExpanded((prev) => !prev) } diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index a4b276ac5e..de1c40eeed 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -2,6 +2,7 @@ import ModalDialog from '@/components/common/ModalDialog' import NetworkInput from '@/components/common/NetworkInput' import ErrorMessage from '@/components/tx/ErrorMessage' import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import { gtmSetChainId } from '@/services/analytics/gtm' import { showNotification } from '@/store/notificationsSlice' import { Box, Button, CircularProgress, DialogActions, DialogContent, Stack, Typography } from '@mui/material' import { FormProvider, useForm } from 'react-hook-form' @@ -95,6 +96,8 @@ const ReplaySafeDialog = ({ return } + gtmSetChainId(selectedChain.chainId) + trackEvent({ ...OVERVIEW_EVENTS.SUBMIT_ADD_NEW_NETWORK, label: selectedChain.chainId }) // 2. Replay Safe creation and add it to the counterfactual Safes diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index 1f8748d630..e8b0babe2a 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -135,6 +135,10 @@ export const OVERVIEW_EVENTS = { category: OVERVIEW_CATEGORY, //label: OPEN_SAFE_LABELS }, + SHOW_ALL_NETWORKS: { + action: 'Show all networks', + category: OVERVIEW_CATEGORY, + }, // Track actual Safe views SAFE_VIEWED: { event: EventType.SAFE_OPENED, From 1220035c815fadb4c6cd2ab3ba9a3400705884ae Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 9 Oct 2024 10:44:42 +0200 Subject: [PATCH 65/74] fix(Multichain): detect migration txs and mark them as trusted (#4339) --- .../TxDetails/TxData/DecodedData/index.tsx | 5 ++- .../transactions/TxDetails/TxData/index.tsx | 2 +- src/utils/transaction-guards.ts | 44 +++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx index 37f8735a4d..50bdbfc53f 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/index.tsx @@ -12,6 +12,7 @@ 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,9 +59,11 @@ 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/index.tsx b/src/components/transactions/TxDetails/TxData/index.tsx index 9eb018ee9d..2c92a9493c 100644 --- a/src/components/transactions/TxDetails/TxData/index.tsx +++ b/src/components/transactions/TxDetails/TxData/index.tsx @@ -73,7 +73,7 @@ const TxData = ({ return } - if (isMigrateToL2TxData(txDetails.txData)) { + if (isMigrateToL2TxData(txDetails.txData, chainId)) { return } return diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index 0d5e1e55ea..bf9227901c 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -57,8 +57,12 @@ 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 } from '@safe-global/safe-deployments' +import { getSafeToL2MigrationDeployment, getMultiSendDeployments } from '@safe-global/safe-deployments' import { 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' export const isTxQueued = (value: TransactionStatus): boolean => { return [TransactionStatus.AWAITING_CONFIRMATIONS, TransactionStatus.AWAITING_EXECUTION].includes(value) @@ -87,18 +91,50 @@ export const isModuleDetailedExecutionInfo = (value?: DetailedExecutionInfo): va return value?.type === DetailedExecutionInfoType.MODULE } -export const isMigrateToL2TxData = (value: TransactionData | undefined): boolean => { +const isMigrateToL2CallData = (value: { + to: string + data: string | undefined + operation?: OperationType | undefined +}) => { const safeToL2MigrationDeployment = getSafeToL2MigrationDeployment() const safeToL2MigrationAddress = safeToL2MigrationDeployment?.defaultAddress const safeToL2MigrationInterface = Safe_to_l2_migration__factory.createInterface() - if (sameAddress(value?.to.value, safeToL2MigrationAddress)) { + if (value.operation === OperationType.DelegateCall && sameAddress(value.to, safeToL2MigrationAddress)) { const migrateToL2Selector = safeToL2MigrationInterface?.getFunction('migrateToL2')?.selector - return migrateToL2Selector && value?.hexData ? value.hexData?.startsWith(migrateToL2Selector) : false + return migrateToL2Selector && value.data ? value.data.startsWith(migrateToL2Selector) : false } return false } +export const isMigrateToL2TxData = (value: TransactionData | undefined, chainId: string | undefined): boolean => { + if (!value) { + return false + } + + if ( + chainId && + value?.hexData && + isMultiSendCalldata(value?.hexData) && + hasMatchingDeployment(getMultiSendDeployments, value.to.value, chainId, ['1.3.0', '1.4.1']) + ) { + // Its a multiSend to the MultiSend contract (not CallOnly) + const decodedMultiSend = decodeMultiSendData(value.hexData) + const firstTx = decodedMultiSend[0] + + // We only trust the tx if the first tx is the only delegateCall + const hasMoreDelegateCalls = decodedMultiSend + .slice(1) + .some((value) => value.operation === OperationType.DelegateCall) + + if (!hasMoreDelegateCalls && firstTx && isMigrateToL2CallData(firstTx)) { + return true + } + } + + return isMigrateToL2CallData({ to: value.to.value, data: value.hexData, operation: value.operation as 0 | 1 }) +} + // TransactionInfo type guards export const isTransferTxInfo = (value: TransactionInfo): value is Transfer => { return value.type === TransactionInfoType.TRANSFER || isSwapTransferOrderTxInfo(value) From dcdd502b3d020954eee6353e555c7d1b2330baf1 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Wed, 9 Oct 2024 10:45:16 +0200 Subject: [PATCH 66/74] fix(Multichain): allow missing paymentToken when adding networks (#4343) --- .../__tests__/useSafeCreationData.test.ts | 46 +++++++++++++++++++ .../multichain/hooks/useSafeCreationData.ts | 5 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts index 580c95d85b..1ea05115a2 100644 --- a/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts +++ b/src/features/multichain/hooks/__tests__/useSafeCreationData.test.ts @@ -76,6 +76,52 @@ describe('useSafeCreationData', () => { }) }) + it('should work for replayedSafe without payment info', async () => { + const safeAddress = faker.finance.ethereumAddress() + const chainInfos = [chainBuilder().with({ chainId: '1' }).build()] + const undeployedSafe: UndeployedSafe = { + props: { + factoryAddress: faker.finance.ethereumAddress(), + saltNonce: '420', + masterCopy: faker.finance.ethereumAddress(), + safeVersion: '1.3.0', + safeAccountConfig: { + owners: [faker.finance.ethereumAddress(), faker.finance.ethereumAddress()], + threshold: 1, + data: faker.string.hexadecimal({ length: 64 }), + to: setupToL2Address, + fallbackHandler: faker.finance.ethereumAddress(), + paymentReceiver: ZERO_ADDRESS, + }, + }, + status: { + status: PendingSafeStatus.AWAITING_EXECUTION, + type: PayMethod.PayLater, + }, + } + + const { result } = renderHook(() => useSafeCreationData(safeAddress, chainInfos), { + initialReduxState: { + undeployedSafes: { + '1': { + [safeAddress]: undeployedSafe, + }, + }, + }, + }) + await waitFor(async () => { + await Promise.resolve() + expect(result.current).toEqual([ + { + ...undeployedSafe.props, + safeAccountConfig: { ...undeployedSafe.props.safeAccountConfig }, + }, + undefined, + false, + ]) + }) + }) + it('should return undefined without chain info', async () => { const safeAddress = faker.finance.ethereumAddress() const chainInfos: ChainInfo[] = [] diff --git a/src/features/multichain/hooks/useSafeCreationData.ts b/src/features/multichain/hooks/useSafeCreationData.ts index a880da987f..b8e35abcb9 100644 --- a/src/features/multichain/hooks/useSafeCreationData.ts +++ b/src/features/multichain/hooks/useSafeCreationData.ts @@ -54,7 +54,10 @@ const getUndeployedSafeCreationData = async (undeployedSafe: UndeployedSafe): Pr const validateAccountConfig = (safeAccountConfig: SafeAccountConfig) => { // Safes that used the reimbursement logic are not supported - if ((safeAccountConfig.payment && safeAccountConfig.payment > 0) || safeAccountConfig.paymentToken !== ZERO_ADDRESS) { + if ( + (safeAccountConfig.payment && safeAccountConfig.payment > 0) || + (safeAccountConfig.paymentToken && safeAccountConfig.paymentToken !== ZERO_ADDRESS) + ) { throw new Error(SAFE_CREATION_DATA_ERRORS.PAYMENT_SAFE) } From 3789654b0e5518d8e4a5d87009c715c3d43c28e6 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:13:59 +0200 Subject: [PATCH 67/74] fix: Update address book for replayed safe (#4337) --- .../components/CreateSafeOnNewChain/index.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index de1c40eeed..376a9c0a16 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -1,5 +1,6 @@ import ModalDialog from '@/components/common/ModalDialog' import NetworkInput from '@/components/common/NetworkInput' +import { updateAddressBook } from '@/components/new-safe/create/logic/address-book' import ErrorMessage from '@/components/tx/ErrorMessage' import { CREATE_SAFE_CATEGORY, CREATE_SAFE_EVENTS, OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' import { gtmSetChainId } from '@/services/analytics/gtm' @@ -121,6 +122,16 @@ const ReplaySafeDialog = ({ trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: selectedChain.chainId }) + dispatch( + updateAddressBook( + [selectedChain.chainId], + safeAddress, + currentName || '', + safeCreationData.safeAccountConfig.owners.map((owner) => ({ address: owner, name: '' })), + safeCreationData.safeAccountConfig.threshold, + ), + ) + dispatch( showNotification({ variant: 'success', From a8274660dcec4456d982fe26e058a04591fbea65 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:11:24 +0200 Subject: [PATCH 68/74] [Multichain] fix: Copy owners to address book when replaying safe [SW-270] (#4355) * fix: Copy owners to address book when replaying safe * Update src/features/multichain/components/CreateSafeOnNewChain/index.tsx Co-authored-by: Manuel Gellfart --------- Co-authored-by: Manuel Gellfart --- src/components/new-safe/create/steps/ReviewStep/index.tsx | 2 +- .../multichain/components/CreateSafeOnNewChain/index.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index f9a195c414..96af64a8ec 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -263,7 +263,7 @@ const ReviewStep = ({ data, onSubmit, onBack, setStep }: StepRenderProps ({ address: owner, name: '' })), + safeCreationData.safeAccountConfig.owners.map((owner) => ({ + address: owner, + name: addressBook[owner] || '', + })), safeCreationData.safeAccountConfig.threshold, ), ) From 5dfcaab7a187c1e7733a582b439a558150884209 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Fri, 11 Oct 2024 11:27:01 +0200 Subject: [PATCH 69/74] feat(Multichain): link to help article (#4361) --- src/config/constants.ts | 1 + .../multichain/components/CreateSafeOnNewChain/index.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config/constants.ts b/src/config/constants.ts index 24b49de0b7..915403243c 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -109,3 +109,4 @@ export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139adda export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' +export const MULTICHAIN_HELP_ARTICLE = `${HELP_CENTER_URL}/en/articles/222612-multi-chain-safe` diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 4973b3b2cf..ac152c7f85 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -27,6 +27,7 @@ import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { useMemo, useState } from 'react' import { useCompatibleNetworks } from '../../hooks/useCompatibleNetworks' import { PayMethod } from '@/features/counterfactual/PayNowPayLater' +import { MULTICHAIN_HELP_ARTICLE } from '@/config/constants' type CreateSafeOnNewChainForm = { chainId: string @@ -213,7 +214,7 @@ const ReplaySafeDialog = ({ {isUnsupportedSafeCreationVersion ? ( - + Read more }, ) @@ -193,4 +188,17 @@ describe('CheckWallet', () => { expect(container.querySelector('button')).not.toBeDisabled() }) + + it('should not allow non-owners that have a spending limit without allowing spending limits', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;( + useIsOnlySpendingLimitBeneficiary as jest.MockedFunction + ).mockReturnValueOnce(true) + + const { container: allowContainer } = render( + {(isOk) => }, + ) + + expect(allowContainer.querySelector('button')).toBeDisabled() + }) }) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx index e5660fb3bf..65d6e2ab21 100644 --- a/src/components/common/CheckWallet/index.tsx +++ b/src/components/common/CheckWallet/index.tsx @@ -49,7 +49,8 @@ const CheckWallet = ({ if (isUndeployedSafe && !allowUndeployedSafe) { return Message.SafeNotActivated } - if ((!allowNonOwner && !isSafeOwner && !isDelegate) || (isOnlySpendingLimit && !allowSpendingLimit)) { + + if (!allowNonOwner && !isSafeOwner && !isDelegate && (!isOnlySpendingLimit || !allowSpendingLimit)) { return Message.NotSafeOwner } }, [ From 3473f7a809bf915717b8552ccb61d1d7e04ad424 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 14 Oct 2024 15:29:38 +0200 Subject: [PATCH 73/74] feat: add bade to unavailable networks (#4372) --- src/components/common/NetworkInput/index.tsx | 7 ++++++- src/components/common/NetworkInput/styles.module.css | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/common/NetworkInput/index.tsx b/src/components/common/NetworkInput/index.tsx index cd4385a4db..2e070cfacc 100644 --- a/src/components/common/NetworkInput/index.tsx +++ b/src/components/common/NetworkInput/index.tsx @@ -1,7 +1,7 @@ import ChainIndicator from '@/components/common/ChainIndicator' import { useDarkMode } from '@/hooks/useDarkMode' import { useTheme } from '@mui/material/styles' -import { FormControl, InputLabel, ListSubheader, MenuItem, Select } from '@mui/material' +import { FormControl, InputLabel, ListSubheader, MenuItem, Select, Typography } from '@mui/material' import partition from 'lodash/partition' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import css from './styles.module.css' @@ -35,6 +35,11 @@ const NetworkInput = ({ sx={{ '&:hover': { backgroundColor: 'inherit' } }} > + {isDisabled && ( + + Not available + + )} ) }, diff --git a/src/components/common/NetworkInput/styles.module.css b/src/components/common/NetworkInput/styles.module.css index 1703446ac1..d0c269ffb8 100644 --- a/src/components/common/NetworkInput/styles.module.css +++ b/src/components/common/NetworkInput/styles.module.css @@ -52,3 +52,11 @@ align-items: center; gap: var(--space-1); } + +.disabledChip { + background-color: var(--color-border-light); + border-radius: 4px; + color: var(--color-text-primary); + padding: 4px 8px; + margin-left: auto; +} From 295fef70910ccdabf3be6c559cd0241d332a00ef Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 14 Oct 2024 15:59:22 +0200 Subject: [PATCH 74/74] refactor: move multichain utils to correct file (#4373) --- .../NetworkSelector/NetworkMultiSelector.tsx | 2 +- .../common/NetworkSelector/index.tsx | 2 +- src/components/new-safe/create/logic/index.ts | 2 +- .../new-safe/create/logic/utils.test.ts | 2 +- src/components/new-safe/create/logic/utils.ts | 2 +- .../create/steps/ReviewStep/index.tsx | 2 +- .../welcome/MyAccounts/AccountItem.tsx | 3 +- .../welcome/MyAccounts/MultiAccountItem.tsx | 2 +- .../welcome/MyAccounts/PaginatedSafeList.tsx | 2 +- .../MyAccounts/useTrackedSafesCount.ts | 2 +- .../MyAccounts/utils/multiChainSafe.ts | 128 ----------------- .../components/CreateSafeOnNewChain/index.tsx | 5 +- .../InconsistentSignerSetupWarning.tsx | 2 +- .../multichain/utils/utils.test.ts} | 4 +- src/features/multichain/utils/utils.ts | 129 ++++++++++++++++++ 15 files changed, 143 insertions(+), 146 deletions(-) delete mode 100644 src/components/welcome/MyAccounts/utils/multiChainSafe.ts rename src/{components/welcome/MyAccounts/utils/multiChainSafe.test.ts => features/multichain/utils/utils.test.ts} (99%) diff --git a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx index d8aa16e498..5a4fbdcc68 100644 --- a/src/components/common/NetworkSelector/NetworkMultiSelector.tsx +++ b/src/components/common/NetworkSelector/NetworkMultiSelector.tsx @@ -12,7 +12,7 @@ import { SetNameStepFields } from '@/components/new-safe/create/steps/SetNameSte import { getSafeSingletonDeployments } from '@safe-global/safe-deployments' import { getLatestSafeVersion } from '@/utils/chains' import { hasCanonicalDeployment } from '@/services/contracts/deployments' -import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' const NetworkMultiSelector = ({ name, diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index b34f50d9d4..c2c0072b60 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -39,10 +39,10 @@ import PlusIcon from '@/public/images/common/plus.svg' import useAddressBook from '@/hooks/useAddressBook' import { CreateSafeOnSpecificChain } from '@/features/multichain/components/CreateSafeOnNewChain' import { useGetSafeOverviewQuery } from '@/store/api/gateway' -import { hasMultiChainAddNetworkFeature } from '@/components/welcome/MyAccounts/utils/multiChainSafe' import { InfoOutlined } from '@mui/icons-material' import { selectUndeployedSafe } from '@/store/slices' import { skipToken } from '@reduxjs/toolkit/query' +import { hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' const ChainIndicatorWithFiatBalance = ({ isSelected, diff --git a/src/components/new-safe/create/logic/index.ts b/src/components/new-safe/create/logic/index.ts index 8c6ca327c3..d7d629eb17 100644 --- a/src/components/new-safe/create/logic/index.ts +++ b/src/components/new-safe/create/logic/index.ts @@ -27,7 +27,7 @@ import { activateReplayedSafe, isPredictedSafeProps } from '@/features/counterfa import { getSafeContractDeployment } from '@/services/contracts/deployments' import { Safe__factory, Safe_proxy_factory__factory, Safe_to_l2_setup__factory } from '@/types/contracts' import { createWeb3 } from '@/hooks/wallets/web3' -import { hasMultiChainCreationFeatures } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { hasMultiChainCreationFeatures } from '@/features/multichain/utils/utils' export type SafeCreationProps = { owners: string[] diff --git a/src/components/new-safe/create/logic/utils.test.ts b/src/components/new-safe/create/logic/utils.test.ts index 700c081b24..94818a43bc 100644 --- a/src/components/new-safe/create/logic/utils.test.ts +++ b/src/components/new-safe/create/logic/utils.test.ts @@ -7,7 +7,7 @@ import { ZERO_ADDRESS } from '@safe-global/protocol-kit/dist/src/utils/constants import * as web3Hooks from '@/hooks/wallets/web3' import { type JsonRpcProvider, id } from 'ethers' import { Safe_proxy_factory__factory } from '@/types/contracts' -import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' // Proxy Factory 1.3.0 creation code const mockProxyCreationCode = diff --git a/src/components/new-safe/create/logic/utils.ts b/src/components/new-safe/create/logic/utils.ts index bb471f358b..68b5993aee 100644 --- a/src/components/new-safe/create/logic/utils.ts +++ b/src/components/new-safe/create/logic/utils.ts @@ -3,7 +3,7 @@ import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { sameAddress } from '@/utils/addresses' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' import { type ReplayedSafeProps } from '@/store/slices' -import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' import chains from '@/config/chains' import { computeNewSafeAddress } from '.' diff --git a/src/components/new-safe/create/steps/ReviewStep/index.tsx b/src/components/new-safe/create/steps/ReviewStep/index.tsx index 96af64a8ec..05860bbb61 100644 --- a/src/components/new-safe/create/steps/ReviewStep/index.tsx +++ b/src/components/new-safe/create/steps/ReviewStep/index.tsx @@ -47,7 +47,7 @@ import { uniq } from 'lodash' import { selectRpc } from '@/store/settingsSlice' import { AppRoutes } from '@/config/routes' import { type ReplayedSafeProps } from '@/store/slices' -import { predictAddressBasedOnReplayData } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' import { createWeb3ReadOnly, getRpcServiceUrl } from '@/hooks/wallets/web3' import { type DeploySafeProps } from '@safe-global/protocol-kit' import { updateAddressBook } from '../../logic/address-book' diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index b8389543d3..c2dda30b86 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -29,8 +29,7 @@ import { extractCounterfactualSafeSetup, isPredictedSafeProps } from '@/features import { useGetSafeOverviewQuery } from '@/store/api/gateway' import useWallet from '@/hooks/wallets/useWallet' import { skipToken } from '@reduxjs/toolkit/query' -import { hasMultiChainAddNetworkFeature } from './utils/multiChainSafe' - +import { hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' type AccountItemProps = { safeItem: SafeItem safeOverview?: SafeOverview diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index 2a006bf364..edadf108bd 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -28,7 +28,7 @@ import { type MultiChainSafeItem } from './useAllSafesGrouped' import { shortenAddress } from '@/utils/formatters' import { type SafeItem } from './useAllSafes' import SubAccountItem from './SubAccountItem' -import { getSafeSetups, getSharedSetup, hasMultiChainAddNetworkFeature } from './utils/multiChainSafe' +import { getSafeSetups, getSharedSetup, hasMultiChainAddNetworkFeature } from '@/features/multichain/utils/utils' import { AddNetworkButton } from './AddNetworkButton' import { isPredictedSafeProps } from '@/features/counterfactual/utils' import ChainIndicator from '@/components/common/ChainIndicator' diff --git a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx index 111291cf23..a45afcf35a 100644 --- a/src/components/welcome/MyAccounts/PaginatedSafeList.tsx +++ b/src/components/welcome/MyAccounts/PaginatedSafeList.tsx @@ -6,7 +6,7 @@ import css from './styles.module.css' import InfiniteScroll from '@/components/common/InfiniteScroll' import { type MultiChainSafeItem } from './useAllSafesGrouped' import MultiAccountItem from './MultiAccountItem' -import { isMultiChainSafeItem } from './utils/multiChainSafe' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' type PaginatedSafeListProps = { safes?: (SafeItem | MultiChainSafeItem)[] diff --git a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts b/src/components/welcome/MyAccounts/useTrackedSafesCount.ts index 45c8c0f3c5..1b0b0ec3ff 100644 --- a/src/components/welcome/MyAccounts/useTrackedSafesCount.ts +++ b/src/components/welcome/MyAccounts/useTrackedSafesCount.ts @@ -5,7 +5,7 @@ import { useEffect } from 'react' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { type SafeItem } from './useAllSafes' import { type MultiChainSafeItem } from './useAllSafesGrouped' -import { isMultiChainSafeItem } from './utils/multiChainSafe' +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' let isOwnedSafesTracked = false let isWatchlistTracked = false diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts b/src/components/welcome/MyAccounts/utils/multiChainSafe.ts deleted file mode 100644 index cbc70f14ae..0000000000 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { type ChainInfo, type SafeOverview } from '@safe-global/safe-gateway-typescript-sdk' -import { type SafeItem } from '../useAllSafes' -import { type UndeployedSafesState, type ReplayedSafeProps } from '@/store/slices' -import { sameAddress } from '@/utils/addresses' -import { type MultiChainSafeItem } from '../useAllSafesGrouped' -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' -import { FEATURES, hasFeature } from '@/utils/chains' - -type SafeSetup = { - owners: string[] - threshold: number - chainId: string -} - -export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { - if ('safes' in safe && 'address' in safe) { - return true - } - return false -} - -const areOwnersMatching = (owners1: string[], owners2: string[]) => - owners1.length === owners2.length && owners1.every((owner) => owners2.some((owner2) => sameAddress(owner, owner2))) - -export const getSafeSetups = ( - safes: SafeItem[], - safeOverviews: SafeOverview[], - undeployedSafes: UndeployedSafesState, -): (SafeSetup | undefined)[] => { - const safeSetups = safes.map((safeItem) => { - const undeployedSafe = undeployedSafes?.[safeItem.chainId]?.[safeItem.address] - if (undeployedSafe) { - const counterfactualSetup = extractCounterfactualSafeSetup(undeployedSafe, safeItem.chainId) - if (!counterfactualSetup) return undefined - return { - owners: counterfactualSetup.owners, - threshold: counterfactualSetup.threshold, - chainId: safeItem.chainId, - } - } - const foundOverview = safeOverviews?.find( - (overview) => overview.chainId === safeItem.chainId && sameAddress(overview.address.value, safeItem.address), - ) - if (!foundOverview) return undefined - return { - owners: foundOverview.owners.map((owner) => owner.value), - threshold: foundOverview.threshold, - chainId: safeItem.chainId, - } - }) - return safeSetups -} - -export const getSharedSetup = (safeSetups: (SafeSetup | undefined)[]): Omit | undefined => { - const comparisonSetup = safeSetups[0] - - if (!comparisonSetup) return undefined - - const allMatching = safeSetups.every( - (setup) => - setup && areOwnersMatching(setup.owners, comparisonSetup.owners) && setup.threshold === comparisonSetup.threshold, - ) - - const { owners, threshold } = comparisonSetup - return allMatching ? { owners, threshold } : undefined -} - -export const getDeviatingSetups = ( - safeSetups: (SafeSetup | undefined)[], - currentChainId: string | undefined, -): SafeSetup[] => { - const currentSafeSetup = safeSetups.find((setup) => setup?.chainId === currentChainId) - if (!currentChainId || !currentSafeSetup) return [] - - const deviatingSetups = safeSetups - .filter((setup): setup is SafeSetup => Boolean(setup)) - .filter((setup) => { - return ( - setup && - (!areOwnersMatching(setup.owners, currentSafeSetup.owners) || setup.threshold !== currentSafeSetup.threshold) - ) - }) - return deviatingSetups -} - -const memoizedGetProxyCreationCode = memoize( - async (factoryAddress: string, provider: Provider) => { - return Safe_proxy_factory__factory.connect(factoryAddress, provider).proxyCreationCode() - }, - async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`, -) - -export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { - const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig) - - // Step 1: Hash the initializer - const initializerHash = keccak256(setupData) - - // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent - const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) - - // Step 3: Hash the encoded value to get the final salt - const salt = keccak256(encoded) - - // Get Proxy creation code - const proxyCreationCode = await memoizedGetProxyCreationCode(safeCreationData.factoryAddress, provider) - - const constructorData = safeCreationData.masterCopy - const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) - return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) -} - -export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { - return ( - hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && - hasFeature(chain, FEATURES.COUNTERFACTUAL) && - hasFeature(chain, FEATURES.SAFE_141) - ) -} - -export const hasMultiChainAddNetworkFeature = (chain: ChainInfo | undefined): boolean => { - if (!chain) return false - return hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_ADD_NETWORK) && hasFeature(chain, FEATURES.COUNTERFACTUAL) -} diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index ac152c7f85..73a072b5e0 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -15,10 +15,7 @@ import useChains from '@/hooks/useChains' import { useAppDispatch, useAppSelector } from '@/store' import { selectRpc } from '@/store/settingsSlice' import { createWeb3ReadOnly } from '@/hooks/wallets/web3' -import { - hasMultiChainAddNetworkFeature, - predictAddressBasedOnReplayData, -} from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { hasMultiChainAddNetworkFeature, predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils' import { sameAddress } from '@/utils/addresses' import ExternalLink from '@/components/common/ExternalLink' import { useRouter } from 'next/router' diff --git a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx index b4ecbd2c7b..33a5417525 100644 --- a/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx +++ b/src/features/multichain/components/SignerSetupWarning/InconsistentSignerSetupWarning.tsx @@ -7,7 +7,7 @@ import { selectCurrency, selectUndeployedSafes, useGetMultipleSafeOverviewsQuery import { useAllSafesGrouped } from '@/components/welcome/MyAccounts/useAllSafesGrouped' import { sameAddress } from '@/utils/addresses' import { useMemo } from 'react' -import { getDeviatingSetups, getSafeSetups } from '@/components/welcome/MyAccounts/utils/multiChainSafe' +import { getDeviatingSetups, getSafeSetups } from '@/features/multichain/utils/utils' import { Box, Typography } from '@mui/material' import ChainIndicator from '@/components/common/ChainIndicator' diff --git a/src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts b/src/features/multichain/utils/utils.test.ts similarity index 99% rename from src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts rename to src/features/multichain/utils/utils.test.ts index 4553e5801d..0735b11d5a 100644 --- a/src/components/welcome/MyAccounts/utils/multiChainSafe.test.ts +++ b/src/features/multichain/utils/utils.test.ts @@ -1,9 +1,9 @@ import { faker } from '@faker-js/faker/locale/af_ZA' -import { getDeviatingSetups, getSafeSetups, getSharedSetup, isMultiChainSafeItem } from './multiChainSafe' +import { getDeviatingSetups, getSafeSetups, getSharedSetup, isMultiChainSafeItem } from './utils' import { PendingSafeStatus } from '@/store/slices' import { PayMethod } from '@/features/counterfactual/PayNowPayLater' -describe('multiChainSafe', () => { +describe('multiChain/utils', () => { describe('isMultiChainSafeItem', () => { it('should return true for MultiChainSafeIem', () => { expect( diff --git a/src/features/multichain/utils/utils.ts b/src/features/multichain/utils/utils.ts index 6c55de8282..1f60254d53 100644 --- a/src/features/multichain/utils/utils.ts +++ b/src/features/multichain/utils/utils.ts @@ -1,5 +1,134 @@ import type { DecodedDataResponse } from '@safe-global/safe-gateway-typescript-sdk' +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' +import { FEATURES, hasFeature } from '@/utils/chains' +import { type SafeItem } from '@/components/welcome/MyAccounts/useAllSafes' +import { type MultiChainSafeItem } from '@/components/welcome/MyAccounts/useAllSafesGrouped' + +type SafeSetup = { + owners: string[] + threshold: number + chainId: string +} + export const isChangingSignerSetup = (decodedData: DecodedDataResponse | undefined) => { return decodedData?.method === 'addOwnerWithThreshold' || decodedData?.method === 'removeOwner' } + +export const isMultiChainSafeItem = (safe: SafeItem | MultiChainSafeItem): safe is MultiChainSafeItem => { + if ('safes' in safe && 'address' in safe) { + return true + } + return false +} + +const areOwnersMatching = (owners1: string[], owners2: string[]) => + owners1.length === owners2.length && owners1.every((owner) => owners2.some((owner2) => sameAddress(owner, owner2))) + +export const getSafeSetups = ( + safes: SafeItem[], + safeOverviews: SafeOverview[], + undeployedSafes: UndeployedSafesState, +): (SafeSetup | undefined)[] => { + const safeSetups = safes.map((safeItem) => { + const undeployedSafe = undeployedSafes?.[safeItem.chainId]?.[safeItem.address] + if (undeployedSafe) { + const counterfactualSetup = extractCounterfactualSafeSetup(undeployedSafe, safeItem.chainId) + if (!counterfactualSetup) return undefined + return { + owners: counterfactualSetup.owners, + threshold: counterfactualSetup.threshold, + chainId: safeItem.chainId, + } + } + const foundOverview = safeOverviews?.find( + (overview) => overview.chainId === safeItem.chainId && sameAddress(overview.address.value, safeItem.address), + ) + if (!foundOverview) return undefined + return { + owners: foundOverview.owners.map((owner) => owner.value), + threshold: foundOverview.threshold, + chainId: safeItem.chainId, + } + }) + return safeSetups +} + +export const getSharedSetup = (safeSetups: (SafeSetup | undefined)[]): Omit | undefined => { + const comparisonSetup = safeSetups[0] + + if (!comparisonSetup) return undefined + + const allMatching = safeSetups.every( + (setup) => + setup && areOwnersMatching(setup.owners, comparisonSetup.owners) && setup.threshold === comparisonSetup.threshold, + ) + + const { owners, threshold } = comparisonSetup + return allMatching ? { owners, threshold } : undefined +} + +export const getDeviatingSetups = ( + safeSetups: (SafeSetup | undefined)[], + currentChainId: string | undefined, +): SafeSetup[] => { + const currentSafeSetup = safeSetups.find((setup) => setup?.chainId === currentChainId) + if (!currentChainId || !currentSafeSetup) return [] + + const deviatingSetups = safeSetups + .filter((setup): setup is SafeSetup => Boolean(setup)) + .filter((setup) => { + return ( + setup && + (!areOwnersMatching(setup.owners, currentSafeSetup.owners) || setup.threshold !== currentSafeSetup.threshold) + ) + }) + return deviatingSetups +} + +const memoizedGetProxyCreationCode = memoize( + async (factoryAddress: string, provider: Provider) => { + return Safe_proxy_factory__factory.connect(factoryAddress, provider).proxyCreationCode() + }, + async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`, +) + +export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => { + const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig) + + // Step 1: Hash the initializer + const initializerHash = keccak256(setupData) + + // Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent + const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])]) + + // Step 3: Hash the encoded value to get the final salt + const salt = keccak256(encoded) + + // Get Proxy creation code + const proxyCreationCode = await memoizedGetProxyCreationCode(safeCreationData.factoryAddress, provider) + + const constructorData = safeCreationData.masterCopy + const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2) + return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode)) +} + +export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => { + return ( + hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_CREATION) && + hasFeature(chain, FEATURES.COUNTERFACTUAL) && + hasFeature(chain, FEATURES.SAFE_141) + ) +} + +export const hasMultiChainAddNetworkFeature = (chain: ChainInfo | undefined): boolean => { + if (!chain) return false + return hasFeature(chain, FEATURES.MULTI_CHAIN_SAFE_ADD_NETWORK) && hasFeature(chain, FEATURES.COUNTERFACTUAL) +}