diff --git a/public/images/sidebar/subaccounts-icon.svg b/public/images/sidebar/subaccounts-icon.svg new file mode 100644 index 0000000000..51289915a7 --- /dev/null +++ b/public/images/sidebar/subaccounts-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/sidebar/subaccounts.svg b/public/images/sidebar/subaccounts.svg new file mode 100755 index 0000000000..737eee13a1 --- /dev/null +++ b/public/images/sidebar/subaccounts.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/common/ModalDialog/index.tsx b/src/components/common/ModalDialog/index.tsx index 0e8050f2e1..6159e1991c 100644 --- a/src/components/common/ModalDialog/index.tsx +++ b/src/components/common/ModalDialog/index.tsx @@ -16,13 +16,20 @@ interface DialogTitleProps { children: ReactNode onClose?: ModalProps['onClose'] hideChainIndicator?: boolean + sx?: DialogProps['sx'] } -export const ModalDialogTitle = ({ children, onClose, hideChainIndicator = false, ...other }: DialogTitleProps) => { +export const ModalDialogTitle = ({ + children, + onClose, + hideChainIndicator = false, + sx = {}, + ...other +}: DialogTitleProps) => { return ( {children} diff --git a/src/components/settings/SubaccountsList/index.tsx b/src/components/settings/SubaccountsList/index.tsx new file mode 100644 index 0000000000..92289775d0 --- /dev/null +++ b/src/components/settings/SubaccountsList/index.tsx @@ -0,0 +1,124 @@ +import { Paper, Grid, Typography, Button, SvgIcon, Tooltip, IconButton } from '@mui/material' +import { useContext, useMemo, useState } from 'react' +import type { ReactElement } from 'react' + +import AddIcon from '@/public/images/common/add.svg' +import EditIcon from '@/public/images/common/edit.svg' +import CheckWallet from '@/components/common/CheckWallet' +import EthHashInfo from '@/components/common/EthHashInfo' +import ExternalLink from '@/components/common/ExternalLink' +import { CreateSubaccount } from '@/components/tx-flow/flows/CreateSubaccount' +import EntryDialog from '@/components/address-book/EntryDialog' +import { TxModalContext } from '@/components/tx-flow' +import EnhancedTable from '@/components/common/EnhancedTable' +import useSafeInfo from '@/hooks/useSafeInfo' +import { useGetSafesByOwnerQuery } from '@/store/slices' + +import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import Track from '@/components/common/Track' +import { SUBACCOUNT_EVENTS } from '@/services/analytics/events/subaccounts' +import { skipToken } from '@reduxjs/toolkit/query' + +export function SubaccountsList(): ReactElement | null { + const { setTxFlow } = useContext(TxModalContext) + const [addressToRename, setAddressToRename] = useState(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) => ( + + + + setAddressToRename(subaccount)} size="small" disabled={!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 && ( + + + + + Subaccounts + + )} + {rename && ( @@ -106,6 +132,10 @@ const SafeListContextMenu = ({ )} + {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 && ( + + + Show all Subaccounts + + + + )} + + ) +} + +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 ( + + +
+ + Name your Subaccount and select which assets to fund it with. All selected assets will be transferred when + deployed. + + + + + + + + + ), + }} + /> + + + + + + + + + + +
+
+ ) +} + +/** + * 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