(null)
+
+ const { safe, safeLoaded, safeAddress } = useSafeInfo()
+ const { data: subaccounts } = useGetSafesByOwnerQuery(
+ safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken,
+ )
+
+ const rows = useMemo(() => {
+ return subaccounts?.safes.map((subaccount) => {
+ return {
+ cells: {
+ owner: {
+ rawValue: subaccount,
+ content: (
+
+ ),
+ },
+ actions: {
+ rawValue: '',
+ sticky: true,
+ content: (
+
+
+ {(isOk) => (
+
+ )}
+
+
+ ),
+ },
+ },
+ }
+ })
+ }, [subaccounts?.safes])
+
+ return (
+ <>
+
+
+
+
+ Subaccounts
+
+
+
+
+
+ Subaccounts are separate wallets owned by your main Account, perfect for organizing different funds and
+ projects.{' '}
+
+ Learn more
+
+
+
+ {subaccounts?.safes.length === 0 && (
+
+ You don't have any Subaccounts yet. Set one up now to better organize your assets
+
+ )}
+
+
+ {(isOk) => (
+
+ )}
+
+
+ {rows && rows.length > 0 && }
+
+
+
+
+ {addressToRename && (
+ setAddressToRename(null)}
+ defaultValues={{ name: '', address: addressToRename }}
+ chainIds={[safe.chainId]}
+ disableAddressInput
+ />
+ )}
+ >
+ )
+}
diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx
index d715110463..7c5002a33a 100644
--- a/src/components/sidebar/SafeListContextMenu/index.tsx
+++ b/src/components/sidebar/SafeListContextMenu/index.tsx
@@ -8,24 +8,35 @@ import ListItemText from '@mui/material/ListItemText'
import EntryDialog from '@/components/address-book/EntryDialog'
import SafeListRemoveDialog from '@/components/sidebar/SafeListRemoveDialog'
+import SubaccountsIcon from '@/public/images/sidebar/subaccounts-icon.svg'
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 { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS, type AnalyticsEvent } 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'
+import { useGetSafesByOwnerQuery } from '@/store/slices'
+import { SubaccountsPopover } from '../SubaccountsPopover'
+import { SUBACCOUNT_EVENTS, SUBACCOUNT_LABELS } from '@/services/analytics/events/subaccounts'
+import { skipToken } from '@reduxjs/toolkit/query'
enum ModalType {
+ SUBACCOUNTS = 'subaccounts',
RENAME = 'rename',
REMOVE = 'remove',
ADD_CHAIN = 'add_chain',
}
-const defaultOpen = { [ModalType.RENAME]: false, [ModalType.REMOVE]: false, [ModalType.ADD_CHAIN]: false }
+const defaultOpen = {
+ [ModalType.SUBACCOUNTS]: false,
+ [ModalType.RENAME]: false,
+ [ModalType.REMOVE]: false,
+ [ModalType.ADD_CHAIN]: false,
+}
const SafeListContextMenu = ({
name,
@@ -42,10 +53,11 @@ const SafeListContextMenu = ({
rename: boolean
undeployedSafe: boolean
}): ReactElement => {
+ const { data: subaccounts } = useGetSafesByOwnerQuery(address ? { chainId, ownerAddress: address } : skipToken)
const addressBook = useAddressBook()
const hasName = address in addressBook
- const [anchorEl, setAnchorEl] = useState()
+ const [anchorEl, setAnchorEl] = useState(null)
const [open, setOpen] = useState(defaultOpen)
const trackingLabel =
@@ -56,17 +68,17 @@ const SafeListContextMenu = ({
}
const handleCloseContextMenu = () => {
- setAnchorEl(undefined)
+ setAnchorEl(null)
}
- const handleOpenModal =
- (type: keyof typeof open, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.SIDEBAR_RENAME) =>
- () => {
+ const handleOpenModal = (type: keyof typeof open, event: AnalyticsEvent) => () => {
+ if (type !== ModalType.SUBACCOUNTS) {
handleCloseContextMenu()
- setOpen((prev) => ({ ...prev, [type]: true }))
-
- trackEvent({ ...event, label: trackingLabel })
}
+ setOpen((prev) => ({ ...prev, [type]: true }))
+
+ trackEvent({ ...event, label: trackingLabel })
+ }
const handleCloseModal = () => {
setOpen(defaultOpen)
@@ -78,6 +90,20 @@ const SafeListContextMenu = ({
({ color: palette.border.main })} />
+ {!undeployedSafe && subaccounts?.safes && subaccounts.safes.length > 0 && (
+
+ )}
+
{rename && (
+ {open[ModalType.SUBACCOUNTS] && (
+
+ )}
+
{open[ModalType.RENAME] && (
{
const { balances } = useVisibleBalances()
@@ -114,6 +116,10 @@ const SafeHeader = (): ReactElement => {
+
+
diff --git a/src/components/sidebar/SubaccountInfo/index.tsx b/src/components/sidebar/SubaccountInfo/index.tsx
new file mode 100644
index 0000000000..6bf792ab89
--- /dev/null
+++ b/src/components/sidebar/SubaccountInfo/index.tsx
@@ -0,0 +1,62 @@
+import { Tooltip, SvgIcon, Typography, List, ListItem, Box, ListItemAvatar, Avatar, ListItemText } from '@mui/material'
+import CheckIcon from '@mui/icons-material/Check'
+import type { ReactElement } from 'react'
+
+import SubaccountsIcon from '@/public/images/sidebar/subaccounts-icon.svg'
+import Subaccounts from '@/public/images/sidebar/subaccounts.svg'
+import InfoIcon from '@/public/images/notifications/info.svg'
+
+export function SubaccountInfo(): ReactElement {
+ return (
+
+
+
+ No Subaccounts yet
+
+
+
+
+
+
+
+
+
+
+
+ Subaccounts allow you to:
+
+
+
+ {[
+ 'rebuild your organizational structure onchain',
+ 'explore new DeFi opportunities without exposing your main Account',
+ 'deploy specialized modules and enable easier access through passkeys and other signers',
+ ].map((item) => {
+ return (
+
+
+
+
+
+
+
+ {item}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/sidebar/SubaccountsButton/index.tsx b/src/components/sidebar/SubaccountsButton/index.tsx
new file mode 100644
index 0000000000..27df8050a6
--- /dev/null
+++ b/src/components/sidebar/SubaccountsButton/index.tsx
@@ -0,0 +1,50 @@
+import { Tooltip, IconButton, SvgIcon, Badge, Typography } from '@mui/material'
+import { useState } from 'react'
+import type { ReactElement } from 'react'
+
+import SubaccountsIcon from '@/public/images/sidebar/subaccounts-icon.svg'
+import { SubaccountsPopover } from '@/components/sidebar/SubaccountsPopover'
+import { useGetSafesByOwnerQuery } from '@/store/slices'
+import { skipToken } from '@reduxjs/toolkit/query'
+
+import headerCss from '@/components/sidebar/SidebarHeader/styles.module.css'
+import css from './styles.module.css'
+
+export function SubaccountsButton({ chainId, safeAddress }: { chainId: string; safeAddress: string }): ReactElement {
+ const [anchorEl, setAnchorEl] = useState(null)
+ const { data } = useGetSafesByOwnerQuery(safeAddress ? { chainId, ownerAddress: safeAddress } : skipToken)
+ const subaccounts = data?.safes ?? []
+
+ const onClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget)
+ }
+ const onClose = () => {
+ setAnchorEl(null)
+ }
+
+ return (
+ <>
+
+ 0} variant="dot" className={css.badge}>
+
+
+ {subaccounts.length > 0 && (
+
+ {subaccounts.length}
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/sidebar/SubaccountsButton/styles.module.css b/src/components/sidebar/SubaccountsButton/styles.module.css
new file mode 100644
index 0000000000..e7f1cb4f90
--- /dev/null
+++ b/src/components/sidebar/SubaccountsButton/styles.module.css
@@ -0,0 +1,22 @@
+.badge :global .MuiBadge-badge {
+ border: 1px solid var(--color-background-main);
+ border-radius: 50%;
+ box-sizing: content-box;
+ right: 12px;
+ top: 8px;
+ background-color: var(--color-secondary-main);
+ height: 6px;
+ min-width: 6px;
+}
+
+.count {
+ margin-left: calc(var(--space-1) / 2);
+ background-color: var(--color-success-light);
+ border-radius: 100%;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: var(--color-static-main);
+}
diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx
new file mode 100644
index 0000000000..867745af04
--- /dev/null
+++ b/src/components/sidebar/SubaccountsList/index.tsx
@@ -0,0 +1,109 @@
+import Track from '@/components/common/Track'
+import { SUBACCOUNT_EVENTS, SUBACCOUNT_LABELS } from '@/services/analytics/events/subaccounts'
+import { ChevronRight } from '@mui/icons-material'
+import { List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Typography } from '@mui/material'
+import { useState, type ReactElement } from 'react'
+import Identicon from '@/components/common/Identicon'
+import { shortenAddress } from '@/utils/formatters'
+import useAddressBook from '@/hooks/useAddressBook'
+import { trackEvent } from '@/services/analytics'
+import Link from 'next/link'
+import { AppRoutes } from '@/config/routes'
+import { useCurrentChain } from '@/hooks/useChains'
+
+const MAX_SUBACCOUNTS = 5
+
+export function SubaccountsList({
+ onClose,
+ subaccounts,
+}: {
+ onClose: () => void
+ subaccounts: Array
+}): ReactElement {
+ const [showAll, setShowAll] = useState(false)
+ const subaccountsToShow = showAll ? subaccounts : subaccounts.slice(0, MAX_SUBACCOUNTS)
+
+ const onShowAll = () => {
+ setShowAll(true)
+ }
+
+ return (
+
+ {subaccountsToShow.map((subaccount) => {
+ return
+ })}
+ {subaccounts.length > MAX_SUBACCOUNTS && !showAll && (
+
+ )}
+
+ )
+}
+
+function SubaccountListItem({ onClose, subaccount }: { onClose: () => void; subaccount: string }): ReactElement {
+ const chain = useCurrentChain()
+ const addressBook = useAddressBook()
+ const name = addressBook[subaccount]
+
+ const onClick = () => {
+ // Note: using the Track element breaks accessibility/styles
+ trackEvent({ ...SUBACCOUNT_EVENTS.OPEN_SUBACCOUNT, label: SUBACCOUNT_LABELS.list })
+
+ onClose()
+ }
+
+ return (
+ `1px solid ${palette.border.light}`,
+ borderRadius: ({ shape }) => `${shape.borderRadius}px`,
+ p: 0,
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/sidebar/SubaccountsPopover/index.tsx b/src/components/sidebar/SubaccountsPopover/index.tsx
new file mode 100644
index 0000000000..0d6b229534
--- /dev/null
+++ b/src/components/sidebar/SubaccountsPopover/index.tsx
@@ -0,0 +1,77 @@
+import { SvgIcon, Popover, Button, Box } from '@mui/material'
+import { useContext } from 'react'
+import type { ReactElement } from 'react'
+
+import AddIcon from '@/public/images/common/add.svg'
+import { ModalDialogTitle } from '@/components/common/ModalDialog'
+import { CreateSubaccount } from '@/components/tx-flow/flows/CreateSubaccount'
+import { TxModalContext } from '@/components/tx-flow'
+import { SubaccountsList } from '@/components/sidebar/SubaccountsList'
+import { SubaccountInfo } from '@/components/sidebar/SubaccountInfo'
+import Track from '@/components/common/Track'
+import { SUBACCOUNT_EVENTS } from '@/services/analytics/events/subaccounts'
+
+export function SubaccountsPopover({
+ anchorEl,
+ onClose,
+ subaccounts,
+}: {
+ anchorEl: HTMLElement | null
+ onClose: () => void
+ subaccounts: Array
+}): ReactElement {
+ const { setTxFlow } = useContext(TxModalContext)
+
+ const onAdd = () => {
+ setTxFlow()
+ onClose()
+ }
+
+ return (
+
+ `1px solid ${palette.border.light}` }}
+ >
+ Subaccounts
+
+
+ {subaccounts.length === 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx
new file mode 100644
index 0000000000..6f8b0e5553
--- /dev/null
+++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx
@@ -0,0 +1,151 @@
+import { useContext, useEffect, useMemo } from 'react'
+
+import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
+import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import useBalances from '@/hooks/useBalances'
+import { useCurrentChain } from '@/hooks/useChains'
+import { useAppDispatch } from '@/store'
+import { upsertAddressBookEntries } from '@/store/addressBookSlice'
+import { getLatestSafeVersion } from '@/utils/chains'
+import useAsync from '@/hooks/useAsync'
+import { createNewUndeployedSafeWithoutSalt, encodeSafeCreationTx } from '@/components/new-safe/create/logic'
+import {
+ SetupSubaccountFormAssetFields,
+ type SetupSubaccountForm,
+} from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount'
+import { useGetSafesByOwnerQuery } from '@/store/slices'
+import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/utils'
+import { useWeb3ReadOnly } from '@/hooks/wallets/web3'
+import { createTokenTransferParams } from '@/services/tx/tokenTransferParams'
+import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender'
+import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types'
+import EthHashInfo from '@/components/common/EthHashInfo'
+import { Grid, Typography } from '@mui/material'
+import { skipToken } from '@reduxjs/toolkit/query'
+
+export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) {
+ const dispatch = useAppDispatch()
+ const { safeAddress, safe, safeLoaded } = useSafeInfo()
+ const chain = useCurrentChain()
+ const { setSafeTx, setSafeTxError } = useContext(SafeTxContext)
+ const { balances } = useBalances()
+ const provider = useWeb3ReadOnly()
+ const { data: subaccounts } = useGetSafesByOwnerQuery(
+ safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken,
+ )
+ const version = getLatestSafeVersion(chain)
+
+ const safeAccountConfig = useMemo(() => {
+ if (!chain || !subaccounts) {
+ return
+ }
+
+ const undeployedSafe = createNewUndeployedSafeWithoutSalt(
+ version,
+ {
+ owners: [safeAddress],
+ threshold: 1,
+ },
+ chain,
+ )
+ const saltNonce = Date.now().toString()
+
+ return {
+ ...undeployedSafe,
+ saltNonce,
+ }
+ }, [chain, safeAddress, subaccounts, version])
+
+ const [predictedSafeAddress] = useAsync(async () => {
+ if (provider && safeAccountConfig) {
+ return predictAddressBasedOnReplayData(safeAccountConfig, provider)
+ }
+ }, [provider, safeAccountConfig])
+
+ useEffect(() => {
+ if (!chain || !safeAccountConfig || !predictedSafeAddress) {
+ return
+ }
+
+ const deploymentTx = {
+ to: safeAccountConfig.factoryAddress,
+ data: encodeSafeCreationTx(safeAccountConfig, chain),
+ value: '0',
+ }
+
+ const fundingTxs: Array = []
+
+ for (const asset of params.assets) {
+ const token = balances.items.find((item) => {
+ return item.tokenInfo.address === asset[SetupSubaccountFormAssetFields.tokenAddress]
+ })
+
+ if (token) {
+ fundingTxs.push(
+ createTokenTransferParams(
+ predictedSafeAddress,
+ asset[SetupSubaccountFormAssetFields.amount],
+ token.tokenInfo.decimals,
+ token.tokenInfo.address,
+ ),
+ )
+ }
+ }
+
+ const createSafeTx = async (): Promise => {
+ const isMultiSend = fundingTxs.length > 0
+ return isMultiSend ? createMultiSendCallOnlyTx([deploymentTx, ...fundingTxs]) : createTx(deploymentTx)
+ }
+
+ createSafeTx().then(setSafeTx).catch(setSafeTxError)
+ }, [chain, params.assets, safeAccountConfig, predictedSafeAddress, balances.items, setSafeTx, setSafeTxError])
+
+ const onSubmit = () => {
+ if (!predictedSafeAddress) {
+ return
+ }
+ dispatch(
+ upsertAddressBookEntries({
+ chainIds: [safe.chainId],
+ address: predictedSafeAddress,
+ name: params.name,
+ }),
+ )
+ }
+
+ return (
+
+ {predictedSafeAddress && (
+
+
+
+ Subaccount
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx
new file mode 100644
index 0000000000..f2851e068d
--- /dev/null
+++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx
@@ -0,0 +1,260 @@
+import {
+ Box,
+ Button,
+ CardActions,
+ Divider,
+ FormControl,
+ IconButton,
+ InputAdornment,
+ InputLabel,
+ MenuItem,
+ SvgIcon,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material'
+import classNames from 'classnames'
+import { Controller, FormProvider, useFieldArray, useForm, useFormContext } from 'react-hook-form'
+
+import InfoIcon from '@/public/images/notifications/info.svg'
+import AddIcon from '@/public/images/common/add.svg'
+import DeleteIcon from '@/public/images/common/delete.svg'
+import TxCard from '@/components/tx-flow/common/TxCard'
+import useSafeAddress from '@/hooks/useSafeAddress'
+import useAddressBook from '@/hooks/useAddressBook'
+import NameInput from '@/components/common/NameInput'
+import tokenInputCss from '@/components/common/TokenAmountInput/styles.module.css'
+import NumberField from '@/components/common/NumberField'
+import { useVisibleBalances } from '@/hooks/useVisibleBalances'
+import { AutocompleteItem } from '@/components/tx-flow/flows/TokenTransfer/CreateTokenTransfer'
+import { validateDecimalLength, validateLimitedAmount } from '@/utils/validation'
+import { safeFormatUnits } from '@/utils/formatters'
+import { useMnemonicSafeName } from '@/hooks/useMnemonicName'
+
+import css from '@/components/tx-flow/flows/CreateSubaccount/styles.module.css'
+import commonCss from '@/components/tx-flow/common/styles.module.css'
+
+export type SetupSubaccountForm = {
+ [SetupSubaccountFormFields.name]: string
+ [SetupSubaccountFormFields.assets]: Array>
+}
+
+export enum SetupSubaccountFormFields {
+ name = 'name',
+ assets = 'assets',
+}
+
+export enum SetupSubaccountFormAssetFields {
+ tokenAddress = 'tokenAddress',
+ amount = 'amount',
+}
+
+export function SetUpSubaccount({
+ params,
+ onSubmit,
+}: {
+ params: SetupSubaccountForm
+ onSubmit: (params: SetupSubaccountForm) => void
+}) {
+ const addressBook = useAddressBook()
+ const safeAddress = useSafeAddress()
+ const randomName = useMnemonicSafeName()
+ const fallbackName = `${addressBook[safeAddress] ?? randomName} Subaccount`
+
+ const formMethods = useForm({
+ defaultValues: params,
+ mode: 'onChange',
+ })
+
+ const onFormSubmit = (data: SetupSubaccountForm) => {
+ onSubmit({
+ ...data,
+ [SetupSubaccountFormFields.name]: data[SetupSubaccountFormFields.name] || fallbackName,
+ })
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+/**
+ * Note: the following is very similar to TokenAmountInput but with key differences to support
+ * a field array. Adjusting the former was initially attempted but proved to be too complex.
+ * We should consider refactoring both to be more "pure" to share easier across components.
+ */
+function AssetInputs({ name }: { name: SetupSubaccountFormFields.assets }) {
+ const { balances } = useVisibleBalances()
+
+ const formMethods = useFormContext()
+ const fieldArray = useFieldArray({ name })
+
+ const selectedAssets = formMethods.watch(name)
+ const nonSelectedAssets = balances.items.filter((item) => {
+ return !selectedAssets.map((asset) => asset.tokenAddress).includes(item.tokenInfo.address)
+ })
+ const defaultAsset: SetupSubaccountForm[typeof name][number] = {
+ tokenAddress: nonSelectedAssets[0]?.tokenInfo.address,
+ amount: '',
+ }
+
+ return (
+ <>
+ {fieldArray.fields.map((field, index) => {
+ const errors = formMethods.formState.errors?.[name]?.[index]
+ const label =
+ errors?.[SetupSubaccountFormAssetFields.tokenAddress]?.message ||
+ errors?.[SetupSubaccountFormAssetFields.amount]?.message ||
+ 'Amount'
+ const isError = !!errors && Object.keys(errors).length > 0
+
+ const thisAsset = balances.items.find((item) => {
+ return item.tokenInfo.address === selectedAssets[index][SetupSubaccountFormAssetFields.tokenAddress]
+ })
+ const thisAndNonSelectedAssets = balances.items.filter((item) => {
+ return (
+ item.tokenInfo.address === thisAsset?.tokenInfo.address ||
+ nonSelectedAssets.some((nonSelected) => item.tokenInfo.address === nonSelected.tokenInfo.address)
+ )
+ })
+ return (
+
+
+
+ {label}
+
+
+
+
{
+ return (
+ validateLimitedAmount(value, thisAsset?.tokenInfo.decimals, thisAsset?.balance) ||
+ validateDecimalLength(value, thisAsset?.tokenInfo.decimals)
+ )
+ },
+ }}
+ render={({ field }) => {
+ const onClickMax = () => {
+ if (thisAsset) {
+ const maxAmount = safeFormatUnits(thisAsset.balance, thisAsset.tokenInfo.decimals)
+ field.onChange(maxAmount)
+ }
+ }
+ return (
+
+ Max
+
+ ),
+ }}
+ className={tokenInputCss.amount}
+ required
+ placeholder="0"
+ {...field}
+ />
+ )
+ }}
+ />
+
+
+
+ {
+ return (
+
+ {thisAndNonSelectedAssets.map((item) => {
+ return (
+
+ )
+ })}
+
+ )
+ }}
+ />
+
+
+
+ fieldArray.remove(index)}>
+
+
+
+ )
+ })}
+
+
+ >
+ )
+}
diff --git a/src/components/tx-flow/flows/CreateSubaccount/index.tsx b/src/components/tx-flow/flows/CreateSubaccount/index.tsx
new file mode 100644
index 0000000000..f8f5e3bd29
--- /dev/null
+++ b/src/components/tx-flow/flows/CreateSubaccount/index.tsx
@@ -0,0 +1,30 @@
+import SubaccountsIcon from '@/public/images/sidebar/subaccounts-icon.svg'
+import TxLayout from '@/components/tx-flow/common/TxLayout'
+import useTxStepper from '@/components/tx-flow/useTxStepper'
+import { ReviewSubaccount } from '@/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount'
+import { SetUpSubaccount } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount'
+import type { SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount'
+
+export function CreateSubaccount() {
+ const { data, step, nextStep, prevStep } = useTxStepper({
+ name: '',
+ assets: [],
+ })
+
+ const steps = [
+ nextStep({ ...data, ...formData })} />,
+ ,
+ ]
+
+ return (
+
+ {steps}
+
+ )
+}
diff --git a/src/components/tx-flow/flows/CreateSubaccount/styles.module.css b/src/components/tx-flow/flows/CreateSubaccount/styles.module.css
new file mode 100644
index 0000000000..c0be8e64f1
--- /dev/null
+++ b/src/components/tx-flow/flows/CreateSubaccount/styles.module.css
@@ -0,0 +1,8 @@
+.assetInput {
+ display: flex;
+ flex-direction: row;
+ margin-top: var(--space-3);
+ gap: var(--space-1);
+ align-items: center;
+ justify-content: center;
+}
diff --git a/src/components/tx-flow/flows/SuccessScreen/index.tsx b/src/components/tx-flow/flows/SuccessScreen/index.tsx
index b70605ac20..a0af516d35 100644
--- a/src/components/tx-flow/flows/SuccessScreen/index.tsx
+++ b/src/components/tx-flow/flows/SuccessScreen/index.tsx
@@ -17,6 +17,10 @@ import { DefaultStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses
import { isSwapTransferOrderTxInfo } from '@/utils/transaction-guards'
import { getTxLink } from '@/utils/tx-link'
import useTxDetails from '@/hooks/useTxDetails'
+import { usePredictSafeAddressFromTxDetails } from '@/hooks/usePredictSafeAddressFromTxDetails'
+import { AppRoutes } from '@/config/routes'
+import { SUBACCOUNT_EVENTS, SUBACCOUNT_LABELS } from '@/services/analytics/events/subaccounts'
+import Track from '@/components/common/Track'
interface Props {
/** The ID assigned to the transaction in the client-gateway */
@@ -37,6 +41,7 @@ const SuccessScreen = ({ txId, txHash }: Props) => {
const txLink = chain && txId && getTxLink(txId, chain, safeAddress)
const [txDetails] = useTxDetails(txId)
const isSwapOrder = txDetails && isSwapTransferOrderTxInfo(txDetails.txInfo)
+ const [predictedSafeAddress] = usePredictSafeAddressFromTxDetails(txDetails)
useEffect(() => {
if (!pendingTxHash) return
@@ -66,13 +71,13 @@ const SuccessScreen = ({ txId, txHash }: Props) => {
case PendingStatus.PROCESSING:
case PendingStatus.RELAYING:
// status can only have these values if txId & pendingTx are defined
- StatusComponent =
+ StatusComponent =
break
case PendingStatus.INDEXING:
- StatusComponent =
+ StatusComponent =
break
default:
- StatusComponent =
+ StatusComponent =
}
return (
@@ -121,11 +126,30 @@ const SuccessScreen = ({ txId, txHash }: Props) => {
)}
- {!isSwapOrder && (
-
- )}
+ {!isSwapOrder &&
+ (predictedSafeAddress ? (
+
+ ) : (
+
+ ))}
)
diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx
index f0c47c9fd3..71b9fc915b 100644
--- a/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx
+++ b/src/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus.tsx
@@ -4,12 +4,14 @@ import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css'
import { isTimeoutError } from '@/utils/ethers-utils'
const TRANSACTION_FAILED = 'Transaction failed'
+const SUBACCOUNT_SUCCESSFUL = 'Subaccount was created'
const TRANSACTION_SUCCESSFUL = 'Transaction was successful'
type Props = {
error: undefined | Error
+ willDeploySafe: boolean
}
-export const DefaultStatus = ({ error }: Props) => (
+export const DefaultStatus = ({ error, willDeploySafe: isCreatingSafe }: Props) => (
(
fontWeight: 700,
}}
>
- {error ? TRANSACTION_FAILED : TRANSACTION_SUCCESSFUL}
+ {error ? TRANSACTION_FAILED : !isCreatingSafe ? TRANSACTION_SUCCESSFUL : SUBACCOUNT_SUCCESSFUL}
{error && (
diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx
index 36e195a0de..5391d11e0d 100644
--- a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx
+++ b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx
@@ -2,7 +2,7 @@ import { Box, Typography } from '@mui/material'
import classNames from 'classnames'
import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css'
-export const IndexingStatus = () => (
+export const IndexingStatus = ({ willDeploySafe: isCreatingSafe }: { willDeploySafe: boolean }) => (
(
fontWeight: 700,
}}
>
- Transaction was processed
+ {!isCreatingSafe ? 'Transaction' : 'Subaccount'} was processed
It is now being indexed.
diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx
index 86c97c8918..2480ac034e 100644
--- a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx
+++ b/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx
@@ -6,8 +6,9 @@ import { PendingStatus, type PendingTx } from '@/store/pendingTxsSlice'
type Props = {
txId: string
pendingTx: PendingTx
+ willDeploySafe: boolean
}
-export const ProcessingStatus = ({ txId, pendingTx }: Props) => (
+export const ProcessingStatus = ({ txId, pendingTx, willDeploySafe: isCreatingSafe }: Props) => (
(
fontWeight: 700,
}}
>
- Transaction is now processing
+ {!isCreatingSafe ? 'Transaction is now processing' : 'Subaccount is now being created'}
(
mb: 3,
}}
>
- The transaction was confirmed and is now being processed.
+ {!isCreatingSafe ? 'The transaction' : 'Your Subaccount'} was confirmed and is now being processed.
{pendingTx.status === PendingStatus.PROCESSING && (
diff --git a/src/features/multichain/utils/utils.ts b/src/features/multichain/utils/utils.ts
index 05384ee149..8d5da2353b 100644
--- a/src/features/multichain/utils/utils.ts
+++ b/src/features/multichain/utils/utils.ts
@@ -100,24 +100,34 @@ const memoizedGetProxyCreationCode = memoize(
async (factoryAddress, provider) => `${factoryAddress}${(await provider.getNetwork()).chainId}`,
)
-export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => {
- const setupData = encodeSafeSetupCall(safeCreationData.safeAccountConfig)
-
+export const predictSafeAddress = async (
+ setupData: { initializer: string; saltNonce: string; singleton: string },
+ factoryAddress: string,
+ provider: Provider,
+) => {
// Step 1: Hash the initializer
- const initializerHash = keccak256(setupData)
+ const initializerHash = keccak256(setupData.initializer)
// Step 2: Encode the initializerHash and saltNonce using abi.encodePacked equivalent
- const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [safeCreationData.saltNonce])])
+ const encoded = ethers.concat([initializerHash, solidityPacked(['uint256'], [setupData.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 proxyCreationCode = await memoizedGetProxyCreationCode(factoryAddress, provider)
+
+ const initCode = proxyCreationCode + solidityPacked(['uint256'], [setupData.singleton]).slice(2)
+ return getCreate2Address(factoryAddress, salt, keccak256(initCode))
+}
- const constructorData = safeCreationData.masterCopy
- const initCode = proxyCreationCode + solidityPacked(['uint256'], [constructorData]).slice(2)
- return getCreate2Address(safeCreationData.factoryAddress, salt, keccak256(initCode))
+export const predictAddressBasedOnReplayData = async (safeCreationData: ReplayedSafeProps, provider: Provider) => {
+ const initializer = encodeSafeSetupCall(safeCreationData.safeAccountConfig)
+ return predictSafeAddress(
+ { initializer, saltNonce: safeCreationData.saltNonce, singleton: safeCreationData.masterCopy },
+ safeCreationData.factoryAddress,
+ provider,
+ )
}
export const hasMultiChainCreationFeatures = (chain: ChainInfo): boolean => {
diff --git a/src/features/myAccounts/components/AccountItems/SubAccountItem.tsx b/src/features/myAccounts/components/AccountItems/SubAccountItem.tsx
index e5a8e80a98..4189cd33de 100644
--- a/src/features/myAccounts/components/AccountItems/SubAccountItem.tsx
+++ b/src/features/myAccounts/components/AccountItems/SubAccountItem.tsx
@@ -123,16 +123,15 @@ const SubAccountItem = ({ onLinkClick, safeItem, safeOverview }: SubAccountItem)
- {undeployedSafe && (
-
- )}
+
+
{isMobile && (
{
+ it('should return undefined if no createProxyWithNonce method is found', () => {
+ const dataDecoded = {
+ method: 'notCreateProxyWithNonce',
+ }
+ expect(_getSetupFromDataDecoded(dataDecoded)).toBeUndefined()
+ })
+
+ it('should return direct createProxyWithNonce calls', () => {
+ expect(_getSetupFromDataDecoded(createProxyWithNonce.txData.dataDecoded as unknown as DataDecoded)).toEqual({
+ singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a',
+ initializer:
+ '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000',
+ saltNonce: '11',
+ })
+ })
+
+ it.each([
+ ['_singleton', 0],
+ ['initializer', 1],
+ ['saltNonce', 2],
+ ])('should return undefined if %s is not a string', (_, argIndex) => {
+ const dataDecoded = JSON.parse(JSON.stringify(createProxyWithNonce.txData.dataDecoded)) as DataDecoded
+ // @ts-expect-error value is a string
+ dataDecoded.parameters[argIndex].value = 1
+ expect(_getSetupFromDataDecoded(dataDecoded)).toBeUndefined()
+ })
+})
+
+jest.mock('@/features/multichain/utils/utils', () => ({
+ __esModule: true,
+ predictSafeAddress: jest.fn(),
+}))
+jest.mock('@/hooks/wallets/web3', () => ({
+ __esModule: true,
+ useWeb3ReadOnly: () => {
+ return 'Mock provider'
+ },
+}))
+
+describe('usePredictSafeAddressFromTxDetails', () => {
+ it('should pass the correct arguments to predictSafeAddress from a createProxyWithNonce call', () => {
+ const mockPredictSafeAddress = jest.spyOn(require('@/features/multichain/utils/utils'), 'predictSafeAddress')
+
+ renderHook(() => usePredictSafeAddressFromTxDetails(createProxyWithNonce as unknown as TransactionDetails))
+
+ expect(mockPredictSafeAddress).toHaveBeenCalledWith(
+ {
+ singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a',
+ initializer:
+ '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000',
+ saltNonce: '11',
+ },
+ '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67',
+ 'Mock provider',
+ )
+ })
+
+ it('should pass the correct arguments to predictSafeAddress from a multiSend, containing a createProxyWithNonce call', () => {
+ const mockPredictSafeAddress = jest.spyOn(require('@/features/multichain/utils/utils'), 'predictSafeAddress')
+
+ renderHook(() => usePredictSafeAddressFromTxDetails(createProxyWithNonceThenFund as unknown as TransactionDetails))
+
+ expect(mockPredictSafeAddress).toHaveBeenCalledWith(
+ {
+ singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a',
+ initializer:
+ '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000',
+ saltNonce: '9',
+ },
+ '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67',
+ 'Mock provider',
+ )
+ })
+})
diff --git a/src/hooks/useOwnedSafes.ts b/src/hooks/useOwnedSafes.ts
index 9a0f778ab1..769bfdb507 100644
--- a/src/hooks/useOwnedSafes.ts
+++ b/src/hooks/useOwnedSafes.ts
@@ -3,7 +3,7 @@ import { type OwnedSafes } from '@safe-global/safe-gateway-typescript-sdk'
import useWallet from '@/hooks/wallets/useWallet'
import useChainId from './useChainId'
-import { useGetOwnedSafesQuery } from '@/store/slices'
+import { useGetSafesByOwnerQuery } from '@/store/slices'
import { skipToken } from '@reduxjs/toolkit/query'
type OwnedSafesCache = {
@@ -16,7 +16,9 @@ const useOwnedSafes = (): OwnedSafesCache['walletAddress'] => {
const chainId = useChainId()
const { address: walletAddress } = useWallet() || {}
- const { data: ownedSafes } = useGetOwnedSafesQuery(walletAddress ? { chainId, walletAddress } : skipToken)
+ const { data: ownedSafes } = useGetSafesByOwnerQuery(
+ walletAddress ? { chainId, ownerAddress: walletAddress } : skipToken,
+ )
const result = useMemo(() => ({ [chainId]: ownedSafes?.safes ?? [] }), [chainId, ownedSafes])
diff --git a/src/hooks/usePredictSafeAddressFromTxDetails.ts b/src/hooks/usePredictSafeAddressFromTxDetails.ts
new file mode 100644
index 0000000000..fadc911048
--- /dev/null
+++ b/src/hooks/usePredictSafeAddressFromTxDetails.ts
@@ -0,0 +1,60 @@
+import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk'
+import { predictSafeAddress } from '@/features/multichain/utils/utils'
+import useAsync from './useAsync'
+import { useWeb3ReadOnly } from './wallets/web3'
+
+export function _getSetupFromDataDecoded(dataDecoded: DataDecoded) {
+ if (dataDecoded?.method !== 'createProxyWithNonce') {
+ return
+ }
+
+ const singleton = dataDecoded?.parameters?.[0]?.value
+ const initializer = dataDecoded?.parameters?.[1]?.value
+ const saltNonce = dataDecoded?.parameters?.[2]?.value
+
+ if (typeof singleton !== 'string' || typeof initializer !== 'string' || typeof saltNonce !== 'string') {
+ return
+ }
+
+ return {
+ singleton,
+ initializer,
+ saltNonce,
+ }
+}
+
+function isCreateProxyWithNonce(dataDecoded?: DataDecoded) {
+ return dataDecoded?.method === 'createProxyWithNonce'
+}
+
+export function usePredictSafeAddressFromTxDetails(txDetails: TransactionDetails | undefined) {
+ const web3 = useWeb3ReadOnly()
+
+ return useAsync(() => {
+ const txData = txDetails?.txData
+ if (!web3 || !txData) {
+ return
+ }
+
+ const isMultiSend = txData?.dataDecoded?.method === 'multiSend'
+
+ const dataDecoded = isMultiSend
+ ? txData?.dataDecoded?.parameters?.[0]?.valueDecoded?.find((tx) => isCreateProxyWithNonce(tx?.dataDecoded))
+ ?.dataDecoded
+ : txData?.dataDecoded
+ const factoryAddress = isMultiSend
+ ? txData?.dataDecoded?.parameters?.[0]?.valueDecoded?.find((tx) => isCreateProxyWithNonce(tx?.dataDecoded))?.to
+ : txData?.to?.value
+
+ if (!dataDecoded || !isCreateProxyWithNonce(dataDecoded) || !factoryAddress) {
+ return
+ }
+
+ const setup = _getSetupFromDataDecoded(dataDecoded)
+ if (!setup) {
+ return
+ }
+
+ return predictSafeAddress(setup, factoryAddress, web3)
+ }, [txDetails?.txData, web3])
+}
diff --git a/src/pages/settings/setup.tsx b/src/pages/settings/setup.tsx
index 60338a6c79..b9e0d90db5 100644
--- a/src/pages/settings/setup.tsx
+++ b/src/pages/settings/setup.tsx
@@ -9,6 +9,7 @@ import useSafeInfo from '@/hooks/useSafeInfo'
import SettingsHeader from '@/components/settings/SettingsHeader'
import ProposersList from 'src/components/settings/ProposersList'
import SpendingLimits from '@/components/settings/SpendingLimits'
+import { SubaccountsList } from '@/components/settings/SubaccountsList'
const Setup: NextPage = () => {
const { safe, safeLoaded } = useSafeInfo()
@@ -74,6 +75,8 @@ const Setup: NextPage = () => {
+
+
>
)
diff --git a/src/services/analytics/events/subaccounts.ts b/src/services/analytics/events/subaccounts.ts
new file mode 100644
index 0000000000..f0f6021bc1
--- /dev/null
+++ b/src/services/analytics/events/subaccounts.ts
@@ -0,0 +1,31 @@
+const SUBACCOUNTS_CATEGORY = 'subaccounts'
+
+export const SUBACCOUNT_EVENTS = {
+ OPEN_LIST: {
+ action: 'Open Subaccount list',
+ category: SUBACCOUNTS_CATEGORY,
+ },
+ OPEN_SUBACCOUNT: {
+ action: 'Open Subaccount',
+ category: SUBACCOUNTS_CATEGORY,
+ },
+ SHOW_ALL: {
+ action: 'Show all',
+ category: SUBACCOUNTS_CATEGORY,
+ },
+ ADD: {
+ action: 'Add',
+ category: SUBACCOUNTS_CATEGORY,
+ },
+ RENAME: {
+ action: 'Rename',
+ category: SUBACCOUNTS_CATEGORY,
+ },
+}
+
+export enum SUBACCOUNT_LABELS {
+ header = 'header',
+ sidebar = 'sidebar',
+ list = 'list',
+ success_screen = 'success_screen',
+}
diff --git a/src/store/__tests__/txHistorySlice.test.ts b/src/store/__tests__/txHistorySlice.test.ts
index f307c2f663..68632df397 100644
--- a/src/store/__tests__/txHistorySlice.test.ts
+++ b/src/store/__tests__/txHistorySlice.test.ts
@@ -1,12 +1,19 @@
import * as txEvents from '@/services/tx/txEvents'
import { pendingTxBuilder } from '@/tests/builders/pendingTx'
import { createListenerMiddleware } from '@reduxjs/toolkit'
-import type { ConflictHeader, DateLabel, Label, TransactionListItem } from '@safe-global/safe-gateway-typescript-sdk'
+import type {
+ ConflictHeader,
+ DateLabel,
+ Label,
+ TransactionListItem,
+ TransactionSummary,
+} from '@safe-global/safe-gateway-typescript-sdk'
import { LabelValue, TransactionListItemType } from '@safe-global/safe-gateway-typescript-sdk'
import type { RootState } from '..'
import type { PendingTxsState } from '../pendingTxsSlice'
import { PendingStatus } from '../pendingTxsSlice'
import { txHistoryListener, txHistorySlice } from '../txHistorySlice'
+import { faker } from '@faker-js/faker'
describe('txHistorySlice', () => {
describe('txHistoryListener', () => {
@@ -41,7 +48,10 @@ describe('txHistorySlice', () => {
type: 'MULTISIG',
nonce: 1,
},
- },
+ txInfo: {
+ type: 'TRANSFER',
+ },
+ } as unknown as TransactionSummary,
} as TransactionListItem
const action = txHistorySlice.actions.set({
@@ -121,7 +131,7 @@ describe('txHistorySlice', () => {
expect(txDispatchSpy).not.toHaveBeenCalled()
})
- it('should not dispatch an event if tx is not pending', () => {
+ it('should not dispatch an event/invalidate owned Safes if tx is not pending', () => {
const state = {
pendingTxs: {
'0x123': pendingTxBuilder().build(),
@@ -137,7 +147,15 @@ describe('txHistorySlice', () => {
type: TransactionListItemType.TRANSACTION,
transaction: {
id: '0x456',
- },
+ executionInfo: {
+ nonce: 1,
+ type: 'MULTISIG',
+ },
+ txInfo: {
+ type: 'Custom',
+ methodName: 'createProxyWithNonce',
+ },
+ } as unknown as TransactionSummary,
} as TransactionListItem
const action = txHistorySlice.actions.set({
@@ -150,13 +168,20 @@ describe('txHistorySlice', () => {
listenerMiddlewareInstance.middleware(listenerApi)(jest.fn())(action)
expect(txDispatchSpy).not.toHaveBeenCalled()
+ expect(listenerApi.dispatch).not.toHaveBeenCalled()
})
- it('should clear a replaced pending transaction', () => {
+ it('should clear a replaced pending transaction and invalidate owned Safes for Safe creations', () => {
const state = {
pendingTxs: {
'0x123': pendingTxBuilder().with({ nonce: 1, status: PendingStatus.INDEXING }).build(),
} as PendingTxsState,
+ safeInfo: {
+ data: {
+ address: { value: faker.finance.ethereumAddress() },
+ chainId: 1,
+ },
+ } as unknown as RootState['safeInfo'],
} as RootState
const listenerApi = {
@@ -172,7 +197,11 @@ describe('txHistorySlice', () => {
nonce: 1,
type: 'MULTISIG',
},
- },
+ txInfo: {
+ type: 'Custom',
+ methodName: 'createProxyWithNonce',
+ },
+ } as unknown as TransactionSummary,
} as TransactionListItem
const action = txHistorySlice.actions.set({
@@ -184,7 +213,17 @@ describe('txHistorySlice', () => {
listenerMiddlewareInstance.middleware(listenerApi)(jest.fn())(action)
- expect(listenerApi.dispatch).toHaveBeenCalledWith({
+ expect(listenerApi.dispatch).toHaveBeenCalledTimes(2)
+ expect(listenerApi.dispatch).toHaveBeenNthCalledWith(1, {
+ payload: [
+ {
+ id: `${state.safeInfo.data!.chainId}:${state.safeInfo.data!.address.value}`,
+ type: 'OwnedSafes',
+ },
+ ],
+ type: 'gatewayApi/invalidateTags',
+ })
+ expect(listenerApi.dispatch).toHaveBeenNthCalledWith(2, {
payload: expect.anything(),
type: 'pendingTxs/clearPendingTx',
})
diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts
index 7e2fdccb18..cf87583b2b 100644
--- a/src/store/api/gateway/index.ts
+++ b/src/store/api/gateway/index.ts
@@ -6,12 +6,10 @@ import {
getAllOwnedSafes,
getTransactionDetails,
type TransactionDetails,
- type OwnedSafes,
- getOwnedSafes,
} from '@safe-global/safe-gateway-typescript-sdk'
import { asError } from '@/services/exceptions/utils'
import { safeOverviewEndpoints } from './safeOverviews'
-import { createSubmission, getSubmission } from '@safe-global/safe-client-gateway-sdk'
+import { createSubmission, getSafesByOwner, getSubmission } from '@safe-global/safe-client-gateway-sdk'
export async function buildQueryFn(fn: () => Promise) {
try {
@@ -24,7 +22,7 @@ export async function buildQueryFn(fn: () => Promise) {
export const gatewayApi = createApi({
reducerPath: 'gatewayApi',
baseQuery: fakeBaseQuery(),
- tagTypes: ['Submissions'],
+ tagTypes: ['OwnedSafes', 'Submissions'],
endpoints: (builder) => ({
getTransactionDetails: builder.query({
queryFn({ chainId, txId }) {
@@ -41,9 +39,12 @@ export const gatewayApi = createApi({
return buildQueryFn(() => getAllOwnedSafes(walletAddress))
},
}),
- getOwnedSafes: builder.query({
- queryFn({ chainId, walletAddress }) {
- return buildQueryFn(() => getOwnedSafes(chainId, walletAddress))
+ getSafesByOwner: builder.query({
+ queryFn({ chainId, ownerAddress }) {
+ return buildQueryFn(() => getSafesByOwner({ params: { path: { chainId, ownerAddress } } }))
+ },
+ providesTags: (_res, _err, { chainId, ownerAddress }) => {
+ return [{ type: 'OwnedSafes', id: `${chainId}:${ownerAddress}` }]
},
}),
getSubmission: builder.query<
@@ -89,6 +90,6 @@ export const {
useCreateSubmissionMutation,
useGetSafeOverviewQuery,
useGetMultipleSafeOverviewsQuery,
+ useGetSafesByOwnerQuery,
useGetAllOwnedSafesQuery,
- useGetOwnedSafesQuery,
} = gatewayApi
diff --git a/src/store/api/gateway/proposers.ts b/src/store/api/gateway/proposers.ts
index fab8833fc7..b9eb37391f 100644
--- a/src/store/api/gateway/proposers.ts
+++ b/src/store/api/gateway/proposers.ts
@@ -6,7 +6,7 @@ import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk'
import type { Delegate, DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates'
export const proposerEndpoints = (
- builder: EndpointBuilder>, 'Submissions', 'gatewayApi'>,
+ builder: EndpointBuilder>, 'OwnedSafes' | 'Submissions', 'gatewayApi'>,
) => ({
getProposers: builder.query({
queryFn({ chainId, safeAddress }) {
diff --git a/src/store/api/gateway/safeOverviews.ts b/src/store/api/gateway/safeOverviews.ts
index 8bb8aa728f..3b4a37d7fb 100644
--- a/src/store/api/gateway/safeOverviews.ts
+++ b/src/store/api/gateway/safeOverviews.ts
@@ -117,7 +117,7 @@ type MultiOverviewQueryParams = {
safes: SafeItem[]
}
-export const safeOverviewEndpoints = (builder: EndpointBuilder) => ({
+export const safeOverviewEndpoints = (builder: EndpointBuilder) => ({
getSafeOverview: builder.query(
{
async queryFn({ safeAddress, walletAddress, chainId }, { getState }) {
diff --git a/src/store/txHistorySlice.ts b/src/store/txHistorySlice.ts
index 80b1e35aeb..d2adc624b8 100644
--- a/src/store/txHistorySlice.ts
+++ b/src/store/txHistorySlice.ts
@@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'
import type { TransactionListPage } from '@safe-global/safe-gateway-typescript-sdk'
import {
isCreationTxInfo,
+ isCustomTxInfo,
isIncomingTransfer,
isMultisigExecutionInfo,
isTransactionListItem,
@@ -10,6 +11,7 @@ import {
import { txDispatch, TxEvent } from '@/services/tx/txEvents'
import { clearPendingTx, selectPendingTxs } from './pendingTxsSlice'
import { makeLoadableSlice } from './common'
+import { gatewayApi, selectSafeInfo } from './slices'
const { slice, selector } = makeLoadableSlice('txHistory', undefined as TransactionListPage | undefined)
@@ -45,6 +47,28 @@ export const txHistoryListener = (listenerMiddleware: typeof listenerMiddlewareI
if (!pendingTxByNonce) continue
+ // Invalidate getSafesByOwner cache as Subaccount was (likely) created
+ if (isCustomTxInfo(result.transaction.txInfo)) {
+ const method = result.transaction.txInfo.methodName
+ const deployedSafe = method === 'createProxyWithNonce'
+ const likelyDeployedSafe = method === 'multiSend'
+
+ if (deployedSafe || likelyDeployedSafe) {
+ const safe = selectSafeInfo(listenerApi.getState())
+ const safeAddress = safe.data?.address?.value
+ const chainId = safe.data?.chainId
+
+ listenerApi.dispatch(
+ gatewayApi.util.invalidateTags([
+ {
+ type: 'OwnedSafes',
+ id: `${chainId}:${safeAddress}`,
+ },
+ ]),
+ )
+ }
+ }
+
const txId = result.transaction.id
const [pendingTxId, pendingTx] = pendingTxByNonce