From 9060d1f9f374d98ec4c7838469e6937c8e219b1f Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 31 Oct 2024 16:29:16 +0100 Subject: [PATCH 01/24] Add transaction flow for creating (and funding) Subaccounts --- src/components/sidebar/NewTxButton/index.tsx | 43 ++- .../CreateSubaccount/ReviewSubaccount.tsx | 80 +++++ .../CreateSubaccount/SetupSubaccount.tsx | 288 ++++++++++++++++++ .../CreateSubaccount/create-subaccount-tx.ts | 106 +++++++ .../tx-flow/flows/CreateSubaccount/index.tsx | 29 ++ .../flows/CreateSubaccount/styles.module.css | 8 + 6 files changed, 542 insertions(+), 12 deletions(-) create mode 100644 src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx create mode 100644 src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx create mode 100644 src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts create mode 100644 src/components/tx-flow/flows/CreateSubaccount/index.tsx create mode 100644 src/components/tx-flow/flows/CreateSubaccount/styles.module.css diff --git a/src/components/sidebar/NewTxButton/index.tsx b/src/components/sidebar/NewTxButton/index.tsx index 9f953643e7..388e0d550a 100644 --- a/src/components/sidebar/NewTxButton/index.tsx +++ b/src/components/sidebar/NewTxButton/index.tsx @@ -7,6 +7,7 @@ import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' import { NewTxFlow } from '@/components/tx-flow/flows' import WatchlistAddButton from '../WatchlistAddButton' +import { CreateSubaccount } from '@/components/tx-flow/flows/CreateSubaccount' const NewTxButton = (): ReactElement => { const { setTxFlow } = useContext(TxModalContext) @@ -17,6 +18,10 @@ const NewTxButton = (): ReactElement => { trackEvent({ ...OVERVIEW_EVENTS.NEW_TRANSACTION, label: 'sidebar' }) } + const onClickSubaccount = () => { + setTxFlow() + } + if (isCounterfactualSafe) { return } @@ -25,18 +30,32 @@ const NewTxButton = (): ReactElement => { {(isOk) => isOk ? ( - + <> + + {/* TODO: Locate according to designs */} + + ) : ( ) 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..084f2a86c7 --- /dev/null +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -0,0 +1,80 @@ +import { useContext, useEffect } from 'react' + +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import { createSubaccount } from '@/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx' +import useSafeInfo from '@/hooks/useSafeInfo' +import useBalances from '@/hooks/useBalances' +import { useCurrentChain } from '@/hooks/useChains' +import useWallet from '@/hooks/wallets/useWallet' +import { SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' +import { useAppDispatch } from '@/store' +import { upsertAddressBookEntries } from '@/store/addressBookSlice' +import { getLatestSafeVersion } from '@/utils/chains' +import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' +import useAsync from '@/hooks/useAsync' +import { computeNewSafeAddress } from '@/components/new-safe/create/logic' + +export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { + const dispatch = useAppDispatch() + const wallet = useWallet() + const { safeAddress, safe } = useSafeInfo() + const chain = useCurrentChain() + const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) + const { balances } = useBalances() + const safeVersion = getLatestSafeVersion(chain) + + const [safeAccountConfig] = useAsync(async () => { + const fallbackHandler = await getReadOnlyFallbackHandlerContract(safeVersion) + const owners = [safeAddress] + return { + owners, + threshold: owners.length, + fallbackHandler: fallbackHandler?.contractAddress, + } + }, [safeVersion]) + + const [predictedSafeAddress] = useAsync(async () => { + if (!wallet?.provider || !safeAccountConfig || !chain || !safeVersion) { + return + } + return computeNewSafeAddress( + wallet.provider, + { + safeAccountConfig, + }, + chain, + safeVersion, + ) + }, [wallet?.provider, safeAccountConfig, chain, safeVersion]) + + useEffect(() => { + if (!wallet?.provider || !safeAccountConfig || !predictedSafeAddress) { + return + } + createSubaccount({ + provider: wallet.provider, + assets: params.assets, + safeAccountConfig, + predictedSafeAddress, + balances, + }) + .then(setSafeTx) + .catch(setSafeTxError) + }, [wallet?.provider, params.assets, safeAccountConfig, predictedSafeAddress, setSafeTx, setSafeTxError]) + + const onSubmit = () => { + if (!predictedSafeAddress) { + return + } + dispatch( + upsertAddressBookEntries({ + chainIds: [safe.chainId], + address: predictedSafeAddress, + name: params.name, + }), + ) + } + + return +} 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..d20233016b --- /dev/null +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -0,0 +1,288 @@ +import { Controller, FieldArrayWithId, FormProvider, useFieldArray, useForm, UseFormReturn } from 'react-hook-form' +import { + Box, + Button, + CardActions, + Divider, + FormControl, + IconButton, + InputAdornment, + InputLabel, + MenuItem, + SvgIcon, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import classNames from 'classnames' + +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 { useMnemonicSafeName } from '@/hooks/useMnemonicName' +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 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 { balances } = useVisibleBalances() + const fallbackName = useMnemonicSafeName() + const formMethods = useForm({ + defaultValues: params, + mode: 'onChange', + }) + const fieldArray = useFieldArray({ + control: formMethods.control, + name: SetupSubaccountFormFields.assets, + }) + + const assetsToFund = formMethods.watch(SetupSubaccountFormFields.assets) + const remainingFundableAssets = balances.items.filter((item) => { + return !assetsToFund.map((asset) => asset.tokenAddress).includes(item.tokenInfo.address) + }) + const defaultAsset: SetupSubaccountForm['assets'][number] = { + // tokenAddress is "next" token that isn't selected to fund the subaccount + tokenAddress: remainingFundableAssets[0]?.tokenInfo.address, + amount: '', + } + const canFund = !!defaultAsset.tokenAddress + + const onFormSubmit = (data: SetupSubaccountForm) => { + onSubmit({ + ...data, + [SetupSubaccountFormFields.name]: data[SetupSubaccountFormFields.name] || fallbackName, + }) + } + + return ( + + +
+ + It's possible to fund a Subaccount with multiple assets during deployment. You can select which assets you'd + like to fund the Subaccount with below, and they will be sent in the deployment transaction. + + + + + + + + + ), + }} + /> + + + {fieldArray.fields.map((field, index) => { + return ( + fieldArray.remove(index)} + /> + ) + })} + + + + + + + + + +
+
+ ) +} + +/** + * 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 AssetInputRow({ + field, + index, + balances, + assets, + formMethods, + remove, +}: { + field: FieldArrayWithId + index: number + balances: ReturnType['balances'] + assets: Record[] + formMethods: UseFormReturn + remove: () => void +}) { + const element = `${SetupSubaccountFormFields.assets}.${index}` as const + + const tokenAddress = assets[index][SetupSubaccountFormAssetFields.tokenAddress] + const token = balances.items.find((item) => item.tokenInfo.address === tokenAddress) + + const errors = formMethods.formState.errors?.[SetupSubaccountFormFields.assets]?.[index] + const isError = !!errors && Object.keys(errors).length > 0 + + const label = + errors?.[SetupSubaccountFormAssetFields.tokenAddress]?.message || + errors?.[SetupSubaccountFormAssetFields.amount]?.message || + 'Amount' + + return ( + + + + {label} + + +
+ { + return ( + validateLimitedAmount(value, token?.tokenInfo.decimals, token?.balance) || + validateDecimalLength(value, token?.tokenInfo.decimals) + ) + }, + }} + render={({ field }) => { + const onClickMax = () => { + if (!token) { + return + } + const name = + `${SetupSubaccountFormFields.assets}.${index}.${SetupSubaccountFormAssetFields.amount}` as const + const maxAmount = safeFormatUnits(token.balance, token.tokenInfo.decimals) + formMethods.setValue( + name, + // @ts-expect-error - computed name does not return field typ + maxAmount, + { + shouldValidate: true, + }, + ) + } + return ( + + Max + + ), + }} + className={tokenInputCss.amount} + required + placeholder="0" + {...field} + /> + ) + }} + /> + + + + { + return ( + + {balances.items.map((item) => { + // Tokens that are already selected to fund with + if (assets.filter((asset) => asset.tokenAddress === item.tokenInfo.address).length > 1) { + return null + } + return ( + + + + ) + })} + + ) + }} + /> +
+
+ + + + +
+ ) +} diff --git a/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts b/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts new file mode 100644 index 0000000000..0d3773d2fd --- /dev/null +++ b/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts @@ -0,0 +1,106 @@ +import { OperationType } from '@safe-global/safe-core-sdk-types' +import Safe from '@safe-global/protocol-kit' +import type { Eip1193Provider } from 'ethers' +import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import type { SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import type { SafeAccountConfig } from '@safe-global/protocol-kit' + +import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' +import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' +import { SetupSubaccountFormAssetFields } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' +import type { + SetupSubaccountForm, + SetupSubaccountFormFields, +} from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' + +/** + * Creates a (batch) transaction to deploy (and fund) a Subaccount. + * + * Note: Subaccounts are owned by provided {@link currentSafeAddress}, with a threshold of 1. + * + * @param {Eip1193Provider} args.provider - EIP-1193 provider + * @param {SetupSubaccountForm['assets']} args.assets - assets to fund the Subaccount with + * @param {SafeAccountConfig} args.safeAccountConfig - Subaccount configuration + * @param {string} args.predictedSafeAddress - predicted Subaccount address + * @param {SafeBalanceResponse} args.balances - current Safe balance + * + * @returns {Promise} (batch) transaction to deploy (and fund) Subaccount + */ +export async function createSubaccount(args: { + provider: Eip1193Provider + assets: SetupSubaccountForm[SetupSubaccountFormFields.assets] + safeAccountConfig: SafeAccountConfig + predictedSafeAddress: string + balances: SafeBalanceResponse +}): Promise { + const deploymentTransaction = await getDeploymentTransaction(args) + const fundingTransactions = getFundingTransactions(args) + if (fundingTransactions.length === 0) { + return createTx(deploymentTransaction) + } + return createMultiSendCallOnlyTx([deploymentTransaction, ...fundingTransactions]) +} + +/** + * Creates a transaction to deploy a Subaccount. + * + * @param {Eip1193Provider} args.provider - EIP-1193 provider + * @param {SafeAccountConfig} args.safeAccountConfig - Subaccount configuration + * + * @returns {Promise} Safe deployment transaction + */ +async function getDeploymentTransaction(args: { + provider: Eip1193Provider + safeAccountConfig: SafeAccountConfig +}): Promise { + const sdk = await Safe.init({ + provider: args.provider, + predictedSafe: { + safeAccountConfig: args.safeAccountConfig, + }, + }) + return sdk.createSafeDeploymentTransaction().then(({ to, value, data }) => { + return { + to, + value, + data, + operation: OperationType.Call, + } + }) +} + +/** + * Creates a list of transfer transactions (to fund a Subaccount). + * + * @param {SetupSubaccountForm['assets']} args.assets - assets to fund the Subaccount + * @param {SafeBalanceResponse} args.balances - current Safe balances + * @param {string} args.predictedSafeAddress - predicted Subaccount address + * + * @returns {Array} list of transfer transactions + */ +function getFundingTransactions(args: { + assets: SetupSubaccountForm[SetupSubaccountFormFields.assets] + balances: SafeBalanceResponse + predictedSafeAddress: string +}): Array { + if (args.assets.length === 0) { + return [] + } + return args.assets + .map((asset) => { + const token = args.balances.items.find((item) => { + return item.tokenInfo.address === asset[SetupSubaccountFormAssetFields.tokenAddress] + }) + if (token) { + return createTokenTransferParams( + args.predictedSafeAddress, + asset[SetupSubaccountFormAssetFields.amount], + token.tokenInfo.decimals, + token.tokenInfo.address, + ) + } + }) + .filter((x: T): x is NonNullable => { + return x != null + }) +} 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..6f8a2fa1e8 --- /dev/null +++ b/src/components/tx-flow/flows/CreateSubaccount/index.tsx @@ -0,0 +1,29 @@ +import CustomIcon from '@/public/images/transactions/custom.svg' +import TxLayout from '@/components/tx-flow/common/TxLayout' +import useTxStepper from '@/components/tx-flow/useTxStepper' +import { SetUpSubaccount, SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' +import { ReviewSubaccount } from '@/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount' + +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; +} From b60f19f79ff6cc14fc65efefe23a9422f8e0e551 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 31 Oct 2024 16:45:42 +0100 Subject: [PATCH 02/24] Fix lint --- .../tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx | 6 +++--- .../tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx | 7 ++++--- src/components/tx-flow/flows/CreateSubaccount/index.tsx | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx index 084f2a86c7..f798b50c7a 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -7,13 +7,13 @@ import useSafeInfo from '@/hooks/useSafeInfo' import useBalances from '@/hooks/useBalances' import { useCurrentChain } from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' -import { SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' import { useAppDispatch } from '@/store' import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { getLatestSafeVersion } from '@/utils/chains' import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' import useAsync from '@/hooks/useAsync' import { computeNewSafeAddress } from '@/components/new-safe/create/logic' +import type { SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { const dispatch = useAppDispatch() @@ -32,7 +32,7 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { threshold: owners.length, fallbackHandler: fallbackHandler?.contractAddress, } - }, [safeVersion]) + }, [safeVersion, safeAddress]) const [predictedSafeAddress] = useAsync(async () => { if (!wallet?.provider || !safeAccountConfig || !chain || !safeVersion) { @@ -61,7 +61,7 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { }) .then(setSafeTx) .catch(setSafeTxError) - }, [wallet?.provider, params.assets, safeAccountConfig, predictedSafeAddress, setSafeTx, setSafeTxError]) + }, [wallet?.provider, params.assets, safeAccountConfig, predictedSafeAddress, balances, setSafeTx, setSafeTxError]) const onSubmit = () => { if (!predictedSafeAddress) { diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index d20233016b..d3e7325f2c 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -1,4 +1,3 @@ -import { Controller, FieldArrayWithId, FormProvider, useFieldArray, useForm, UseFormReturn } from 'react-hook-form' import { Box, Button, @@ -15,6 +14,8 @@ import { Typography, } from '@mui/material' import classNames from 'classnames' +import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' +import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form' import InfoIcon from '@/public/images/notifications/info.svg' import AddIcon from '@/public/images/common/add.svg' @@ -88,8 +89,8 @@ export function SetUpSubaccount({
- It's possible to fund a Subaccount with multiple assets during deployment. You can select which assets you'd - like to fund the Subaccount with below, and they will be sent in the deployment transaction. + It's possible to fund a Subaccount with multiple assets during deployment. You can select which assets + you'd like to fund the Subaccount with below, and they will be sent in the deployment transaction. diff --git a/src/components/tx-flow/flows/CreateSubaccount/index.tsx b/src/components/tx-flow/flows/CreateSubaccount/index.tsx index 6f8a2fa1e8..2b76750b20 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/index.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/index.tsx @@ -1,8 +1,9 @@ import CustomIcon from '@/public/images/transactions/custom.svg' import TxLayout from '@/components/tx-flow/common/TxLayout' import useTxStepper from '@/components/tx-flow/useTxStepper' -import { SetUpSubaccount, SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' 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({ From c73547513ff962ec3b79c2436923dd4fce08990d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 31 Oct 2024 17:30:30 +0100 Subject: [PATCH 03/24] Don't allow multiple selection of assets --- .../CreateSubaccount/SetupSubaccount.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index d3e7325f2c..a98766a1d2 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -14,6 +14,7 @@ import { Typography, } from '@mui/material' import classNames from 'classnames' +import { useMemo } from 'react' import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form' @@ -66,13 +67,13 @@ export function SetUpSubaccount({ name: SetupSubaccountFormFields.assets, }) - const assetsToFund = formMethods.watch(SetupSubaccountFormFields.assets) - const remainingFundableAssets = balances.items.filter((item) => { - return !assetsToFund.map((asset) => asset.tokenAddress).includes(item.tokenInfo.address) + const selectedAssets = formMethods.watch(SetupSubaccountFormFields.assets) + const nonSelectedAssets = balances.items.filter((item) => { + return !selectedAssets.map((asset) => asset.tokenAddress).includes(item.tokenInfo.address) }) const defaultAsset: SetupSubaccountForm['assets'][number] = { // tokenAddress is "next" token that isn't selected to fund the subaccount - tokenAddress: remainingFundableAssets[0]?.tokenInfo.address, + tokenAddress: nonSelectedAssets[0]?.tokenInfo.address, amount: '', } const canFund = !!defaultAsset.tokenAddress @@ -122,7 +123,7 @@ export function SetUpSubaccount({ field={field} index={index} balances={balances} - assets={assetsToFund} + selectedAssets={selectedAssets} formMethods={formMethods} remove={() => fieldArray.remove(index)} /> @@ -164,20 +165,20 @@ function AssetInputRow({ field, index, balances, - assets, + selectedAssets, formMethods, remove, }: { field: FieldArrayWithId index: number balances: ReturnType['balances'] - assets: Record[] + selectedAssets: Record[] formMethods: UseFormReturn remove: () => void }) { const element = `${SetupSubaccountFormFields.assets}.${index}` as const - const tokenAddress = assets[index][SetupSubaccountFormAssetFields.tokenAddress] + const tokenAddress = selectedAssets[index][SetupSubaccountFormAssetFields.tokenAddress] const token = balances.items.find((item) => item.tokenInfo.address === tokenAddress) const errors = formMethods.formState.errors?.[SetupSubaccountFormFields.assets]?.[index] @@ -188,6 +189,14 @@ function AssetInputRow({ errors?.[SetupSubaccountFormAssetFields.amount]?.message || 'Amount' + const otherAssets = useMemo(() => { + return balances.items.filter((item) => { + return !selectedAssets.some((asset) => { + return asset.tokenAddress !== tokenAddress && asset.tokenAddress === item.tokenInfo.address + }) + }) + }, [balances.items, selectedAssets, index]) + return ( @@ -263,11 +272,7 @@ function AssetInputRow({ required {...field} > - {balances.items.map((item) => { - // Tokens that are already selected to fund with - if (assets.filter((asset) => asset.tokenAddress === item.tokenInfo.address).length > 1) { - return null - } + {otherAssets.map((item) => { return ( From 2f069159ee3731bb779f9af52f0eb5568713c0ad Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 31 Oct 2024 17:33:30 +0100 Subject: [PATCH 04/24] Fix lint --- .../tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index a98766a1d2..6ddbd74e06 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -195,7 +195,7 @@ function AssetInputRow({ return asset.tokenAddress !== tokenAddress && asset.tokenAddress === item.tokenInfo.address }) }) - }, [balances.items, selectedAssets, index]) + }, [balances.items, selectedAssets, tokenAddress]) return ( From 1f757875a2bfcaca9467020e8ea83fbda4ec4668 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 31 Oct 2024 22:52:05 +0100 Subject: [PATCH 05/24] Simplify code --- .../CreateSubaccount/SetupSubaccount.tsx | 288 ++++++++---------- 1 file changed, 124 insertions(+), 164 deletions(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index 6ddbd74e06..d9e9b4fab8 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -14,9 +14,7 @@ import { Typography, } from '@mui/material' import classNames from 'classnames' -import { useMemo } from 'react' -import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' -import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form' +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' @@ -56,27 +54,11 @@ export function SetUpSubaccount({ params: SetupSubaccountForm onSubmit: (params: SetupSubaccountForm) => void }) { - const { balances } = useVisibleBalances() const fallbackName = useMnemonicSafeName() const formMethods = useForm({ defaultValues: params, mode: 'onChange', }) - const fieldArray = useFieldArray({ - control: formMethods.control, - name: SetupSubaccountFormFields.assets, - }) - - const selectedAssets = formMethods.watch(SetupSubaccountFormFields.assets) - const nonSelectedAssets = balances.items.filter((item) => { - return !selectedAssets.map((asset) => asset.tokenAddress).includes(item.tokenInfo.address) - }) - const defaultAsset: SetupSubaccountForm['assets'][number] = { - // tokenAddress is "next" token that isn't selected to fund the subaccount - tokenAddress: nonSelectedAssets[0]?.tokenInfo.address, - amount: '', - } - const canFund = !!defaultAsset.tokenAddress const onFormSubmit = (data: SetupSubaccountForm) => { onSubmit({ @@ -116,32 +98,7 @@ export function SetUpSubaccount({ /> - {fieldArray.fields.map((field, index) => { - return ( - fieldArray.remove(index)} - /> - ) - })} - - + @@ -161,134 +118,137 @@ export function SetUpSubaccount({ * 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 AssetInputRow({ - field, - index, - balances, - selectedAssets, - formMethods, - remove, -}: { - field: FieldArrayWithId - index: number - balances: ReturnType['balances'] - selectedAssets: Record[] - formMethods: UseFormReturn - remove: () => void -}) { - const element = `${SetupSubaccountFormFields.assets}.${index}` as const - - const tokenAddress = selectedAssets[index][SetupSubaccountFormAssetFields.tokenAddress] - const token = balances.items.find((item) => item.tokenInfo.address === tokenAddress) - - const errors = formMethods.formState.errors?.[SetupSubaccountFormFields.assets]?.[index] - const isError = !!errors && Object.keys(errors).length > 0 +function AssetInputs({ name }: { name: SetupSubaccountFormFields.assets }) { + const { balances } = useVisibleBalances() - const label = - errors?.[SetupSubaccountFormAssetFields.tokenAddress]?.message || - errors?.[SetupSubaccountFormAssetFields.amount]?.message || - 'Amount' + const formMethods = useFormContext() + const fieldArray = useFieldArray({ name }) - const otherAssets = useMemo(() => { - return balances.items.filter((item) => { - return !selectedAssets.some((asset) => { - return asset.tokenAddress !== tokenAddress && asset.tokenAddress === item.tokenInfo.address - }) - }) - }, [balances.items, selectedAssets, tokenAddress]) + 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 ( - - - - {label} - - -
- { - return ( - validateLimitedAmount(value, token?.tokenInfo.decimals, token?.balance) || - validateDecimalLength(value, token?.tokenInfo.decimals) - ) - }, - }} - render={({ field }) => { - const onClickMax = () => { - if (!token) { - return - } - const name = - `${SetupSubaccountFormFields.assets}.${index}.${SetupSubaccountFormAssetFields.amount}` as const - const maxAmount = safeFormatUnits(token.balance, token.tokenInfo.decimals) - formMethods.setValue( - name, - // @ts-expect-error - computed name does not return field typ - maxAmount, - { - shouldValidate: true, - }, - ) - } - return ( - - Max - - ), + <> + {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} + /> + ) }} - className={tokenInputCss.amount} - required - placeholder="0" - {...field} /> - ) - }} - /> - + - { - return ( - - {otherAssets.map((item) => { + { return ( - - - + + {thisAndNonSelectedAssets.map((item) => { + return ( + + + + ) + })} + ) - })} - - ) - }} - /> -
-
- - - - -
+ }} + /> +
+
+ + fieldArray.remove(index)}> + + +
+ ) + })} + + + ) } From c8e8e6858ff43fe78c14401db06c47acab6778e1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 08:40:01 +0100 Subject: [PATCH 06/24] Add popover, settings section and success screen --- public/images/sidebar/subaccounts-icon.svg | 8 ++ public/images/sidebar/subaccounts.svg | 10 ++ src/components/common/ModalDialog/index.tsx | 11 +- .../settings/SubaccountsList/index.tsx | 121 ++++++++++++++++++ src/components/sidebar/NewTxButton/index.tsx | 43 ++----- .../sidebar/SafeListContextMenu/index.tsx | 35 ++++- .../sidebar/SidebarHeader/index.tsx | 5 + .../sidebar/SubaccountInfo/index.tsx | 62 +++++++++ .../sidebar/SubaccountsButton/index.tsx | 34 +++++ .../sidebar/SubaccountsList/index.tsx | 62 +++++++++ .../sidebar/SubaccountsPopover/index.tsx | 83 ++++++++++++ .../CreateSubaccount/SetupSubaccount.tsx | 6 +- .../tx-flow/flows/SuccessScreen/index.tsx | 7 +- .../SuccessScreen/statuses/IndexingStatus.tsx | 4 +- .../statuses/ProcessingStatus.tsx | 7 +- .../welcome/MyAccounts/AccountItem.tsx | 9 +- .../welcome/MyAccounts/SubAccountItem.tsx | 11 +- src/pages/settings/setup.tsx | 3 + src/services/analytics/events/overview.ts | 16 +++ src/services/analytics/events/settings.ts | 4 + src/store/api/gateway/index.ts | 8 +- src/utils/transaction-guards.ts | 21 +++ 22 files changed, 517 insertions(+), 53 deletions(-) create mode 100644 public/images/sidebar/subaccounts-icon.svg create mode 100755 public/images/sidebar/subaccounts.svg create mode 100644 src/components/settings/SubaccountsList/index.tsx create mode 100644 src/components/sidebar/SubaccountInfo/index.tsx create mode 100644 src/components/sidebar/SubaccountsButton/index.tsx create mode 100644 src/components/sidebar/SubaccountsList/index.tsx create mode 100644 src/components/sidebar/SubaccountsPopover/index.tsx diff --git a/public/images/sidebar/subaccounts-icon.svg b/public/images/sidebar/subaccounts-icon.svg new file mode 100644 index 0000000000..b2758c6f24 --- /dev/null +++ b/public/images/sidebar/subaccounts-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + 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..ce9661a6b9 --- /dev/null +++ b/src/components/settings/SubaccountsList/index.tsx @@ -0,0 +1,121 @@ +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 { SETTINGS_EVENTS } from '@/services/analytics' + +export function SubaccountsList(): ReactElement | null { + const { setTxFlow } = useContext(TxModalContext) + const [addressToRename, setAddressToRename] = useState(null) + + const { safe } = useSafeInfo() + const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId: safe.chainId, ownerAddress: safe.address.value }) + + 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/NewTxButton/index.tsx b/src/components/sidebar/NewTxButton/index.tsx index 388e0d550a..9f953643e7 100644 --- a/src/components/sidebar/NewTxButton/index.tsx +++ b/src/components/sidebar/NewTxButton/index.tsx @@ -7,7 +7,6 @@ import CheckWallet from '@/components/common/CheckWallet' import { TxModalContext } from '@/components/tx-flow' import { NewTxFlow } from '@/components/tx-flow/flows' import WatchlistAddButton from '../WatchlistAddButton' -import { CreateSubaccount } from '@/components/tx-flow/flows/CreateSubaccount' const NewTxButton = (): ReactElement => { const { setTxFlow } = useContext(TxModalContext) @@ -18,10 +17,6 @@ const NewTxButton = (): ReactElement => { trackEvent({ ...OVERVIEW_EVENTS.NEW_TRANSACTION, label: 'sidebar' }) } - const onClickSubaccount = () => { - setTxFlow() - } - if (isCounterfactualSafe) { return } @@ -30,32 +25,18 @@ const NewTxButton = (): ReactElement => { {(isOk) => isOk ? ( - <> - - {/* TODO: Locate according to designs */} - - + ) : ( ) diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index 5d1e77d867..f87cad86a3 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -10,6 +10,7 @@ import EntryDialog from '@/components/address-book/EntryDialog' import SafeListRemoveDialog from '@/components/sidebar/SafeListRemoveDialog' import { useAppSelector } from '@/store' import { selectAddedSafes } from '@/store/addedSafesSlice' +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' @@ -20,14 +21,22 @@ 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' 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, @@ -35,19 +44,22 @@ const SafeListContextMenu = ({ chainId, addNetwork, rename, + showSubaccounts, }: { name: string address: string chainId: string addNetwork: boolean rename: boolean + showSubaccounts: boolean }): ReactElement => { + const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId, ownerAddress: address }) const addedSafes = useAppSelector((state) => selectAddedSafes(state, chainId)) const isAdded = !!addedSafes?.[address] const addressBook = useAddressBook() const hasName = address in addressBook - const [anchorEl, setAnchorEl] = useState() + const [anchorEl, setAnchorEl] = useState(null) const [open, setOpen] = useState(defaultOpen) const trackingLabel = @@ -58,13 +70,15 @@ 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) => () => { - handleCloseContextMenu() + if (type !== ModalType.SUBACCOUNTS) { + handleCloseContextMenu() + } setOpen((prev) => ({ ...prev, [type]: true })) trackEvent({ ...event, label: trackingLabel }) @@ -80,6 +94,15 @@ const SafeListContextMenu = ({ ({ color: palette.border.main })} /> + {showSubaccounts && subaccounts?.safes && subaccounts.safes.length > 0 && ( + + + + + Subaccounts + + )} + {rename && ( @@ -108,6 +131,10 @@ const SafeListContextMenu = ({ )} + {open[ModalType.SUBACCOUNTS] && ( + + )} + {open[ModalType.RENAME] && ( { const { balances } = useVisibleBalances() @@ -108,6 +109,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..fd04f65c01 --- /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 + + + + + + + + + + + + With Subaccounts you can: + + + + {[ + 'Use them for specific cases such as DeFi operations', + 'Install modules to execute transactions, bypassing thresholds', + 'Make sure that this Safe is not exposed to additional risks', + ].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..d7184cb2ca --- /dev/null +++ b/src/components/sidebar/SubaccountsButton/index.tsx @@ -0,0 +1,34 @@ +import { Tooltip, IconButton, SvgIcon } 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 headerCss from '@/components/sidebar/SidebarHeader/styles.module.css' + +export function SubaccountsButton({ chainId, safeAddress }: { chainId: string; safeAddress: string }): ReactElement { + const [anchorEl, setAnchorEl] = useState(null) + + const onClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const onClose = () => { + setAnchorEl(null) + } + + return ( + <> + + + + + + + + ) +} diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx new file mode 100644 index 0000000000..68a4627e70 --- /dev/null +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -0,0 +1,62 @@ +import EthHashInfo from '@/components/common/EthHashInfo' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS } from '@/services/analytics' +import { ChevronRight } from '@mui/icons-material' +import { Box, Typography } from '@mui/material' +import { useState, type ReactElement } from 'react' + +const MAX_SUBACCOUNTS = 5 + +export function SubaccountsList({ subaccounts }: { 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 ( + `${shape.borderRadius}px`, + cursor: 'pointer', + py: '11px', + px: 2, + '&:hover': { + backgroundColor: 'var(--color-background-light)', + borderColor: 'var(--color-secondary-light)', + }, + }} + key={subaccount} + > + + + + ) + })} + {subaccounts.length > MAX_SUBACCOUNTS && !showAll && ( + + + Show all Subaccounts + + + + )} + + ) +} diff --git a/src/components/sidebar/SubaccountsPopover/index.tsx b/src/components/sidebar/SubaccountsPopover/index.tsx new file mode 100644 index 0000000000..c524676789 --- /dev/null +++ b/src/components/sidebar/SubaccountsPopover/index.tsx @@ -0,0 +1,83 @@ +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 { base } from '@/styles/spacings' +import { useGetSafesByOwnerQuery } from '@/store/slices' +import Track from '@/components/common/Track' +import { OVERVIEW_EVENTS } from '@/services/analytics' + +export function SubaccountsPopover({ + anchorEl, + onClose, + chainId, + safeAddress, +}: { + anchorEl: HTMLElement | null + onClose: () => void + chainId: string + safeAddress: string +}): ReactElement { + const { setTxFlow } = useContext(TxModalContext) + const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId, ownerAddress: safeAddress }) + + const onAdd = () => { + setTxFlow() + onClose() + } + + return ( + + `1px solid ${palette.border.light}` }} + > + Subaccounts + + + {subaccounts?.safes ? ( + subaccounts.safes.length === 0 ? ( + + ) : ( + + + + ) + ) : null} + + + + + + ) +} diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index d9e9b4fab8..ada305d557 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -72,8 +72,8 @@ export function SetUpSubaccount({ - It's possible to fund a Subaccount with multiple assets during deployment. You can select which assets - you'd like to fund the Subaccount with below, and they will be sent in the deployment transaction. + Name your Subaccount and select which assets to fund it with. All selected assets will be transferred when + deployed. @@ -247,7 +247,7 @@ function AssetInputs({ name }: { name: SetupSubaccountFormFields.assets }) { sx={{ my: 3 }} disabled={nonSelectedAssets.length === 0} > - Fund new asset + Add asset ) diff --git a/src/components/tx-flow/flows/SuccessScreen/index.tsx b/src/components/tx-flow/flows/SuccessScreen/index.tsx index 88e31e4461..f566883f21 100644 --- a/src/components/tx-flow/flows/SuccessScreen/index.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/index.tsx @@ -15,7 +15,7 @@ import { ProcessingStatus } from '@/components/tx-flow/flows/SuccessScreen/statu import { IndexingStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus' import { DefaultStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus' import useDecodeTx from '@/hooks/useDecodeTx' -import { isSwapConfirmationViewOrder } from '@/utils/transaction-guards' +import { isSubaccountDecodedData, isSwapConfirmationViewOrder } from '@/utils/transaction-guards' import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' import { getTxLink } from '@/utils/tx-link' @@ -40,6 +40,7 @@ const SuccessScreen = ({ txId, txHash, safeTx }: Props) => { const txLink = chain && txId && getTxLink(txId, chain, safeAddress) const [decodedData] = useDecodeTx(safeTx) const isSwapOrder = isSwapConfirmationViewOrder(decodedData) + const isSubaccount = isSubaccountDecodedData(decodedData) useEffect(() => { if (!pendingTxHash) return @@ -69,10 +70,10 @@ const SuccessScreen = ({ txId, txHash, safeTx }: 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 = diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx index 088f7cf31c..10ff523d9c 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx @@ -2,10 +2,10 @@ 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 = ({ isSubaccount }: { isSubaccount: boolean }) => ( - Transaction was processed + {!isSubaccount ? 'Transaction was processed' : 'Subaccount was created'} 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 5c94c74f81..b744a4627f 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx @@ -6,14 +6,15 @@ import { PendingStatus, type PendingTx } from '@/store/pendingTxsSlice' type Props = { txId: string pendingTx: PendingTx + isSubaccount: boolean } -export const ProcessingStatus = ({ txId, pendingTx }: Props) => ( +export const ProcessingStatus = ({ txId, pendingTx, isSubaccount }: Props) => ( - Transaction is now processing + {!isSubaccount ? 'Transaction is now processing' : 'Subaccount is now being created'} - The transaction was confirmed and is now being processed. + {`${!isSubaccount ? 'The transaction' : 'Your Subaccount'} was confirmed and is now being processed.`} {pendingTx.status === PendingStatus.PROCESSING && ( diff --git a/src/components/welcome/MyAccounts/AccountItem.tsx b/src/components/welcome/MyAccounts/AccountItem.tsx index c2dda30b86..d415ba39ee 100644 --- a/src/components/welcome/MyAccounts/AccountItem.tsx +++ b/src/components/welcome/MyAccounts/AccountItem.tsx @@ -138,7 +138,14 @@ const AccountItem = ({ onLinkClick, safeItem }: AccountItemProps) => { - + - {undeployedSafe && ( - - )} + { const { safe, safeLoaded } = useSafeInfo() @@ -67,6 +68,8 @@ const Setup: NextPage = () => { + + ) diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index e2d2fbb4bd..9b54fc0864 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -91,6 +91,22 @@ export const OVERVIEW_EVENTS = { action: 'Interact with notification', category: OVERVIEW_CATEGORY, }, + HEADER_SUBACCOUNTS: { + action: 'Open Subaccounts from header', + category: OVERVIEW_CATEGORY, + }, + SIDEBAR_SUBACCOUNTS: { + action: 'Open Subaccounts from sidebar', + category: OVERVIEW_CATEGORY, + }, + ADD_SUBACCOUNT: { + action: 'Add subaccount', + category: OVERVIEW_CATEGORY, + }, + SHOW_ALL_SUBACCOUNTS: { + action: 'Show all Subaccounts', + category: OVERVIEW_CATEGORY, + }, SIDEBAR_RENAME: { action: 'Rename Safe from sidebar', category: OVERVIEW_CATEGORY, diff --git a/src/services/analytics/events/settings.ts b/src/services/analytics/events/settings.ts index 0b1518ef16..be8ee73e85 100644 --- a/src/services/analytics/events/settings.ts +++ b/src/services/analytics/events/settings.ts @@ -34,6 +34,10 @@ export const SETTINGS_EVENTS = { action: 'Threshold', category: SETTINGS_CATEGORY, }, + RENAME_SUBACCOUNT: { + action: 'Rename subaccount', + category: SETTINGS_CATEGORY, + }, }, APPEARANCE: { COPY_PREFIXES: { diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts index d626c91d9f..1cd7cad629 100644 --- a/src/store/api/gateway/index.ts +++ b/src/store/api/gateway/index.ts @@ -5,7 +5,7 @@ import { asError } from '@/services/exceptions/utils' import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk' import type { DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' import { safeOverviewEndpoints } from './safeOverviews' -import { getSubmission } from '@safe-global/safe-client-gateway-sdk' +import { getSafesByOwner, getSubmission } from '@safe-global/safe-client-gateway-sdk' async function buildQueryFn(fn: () => Promise) { try { @@ -44,6 +44,11 @@ export const gatewayApi = createApi({ ) }, }), + getSafesByOwner: builder.query({ + queryFn({ chainId, ownerAddress }) { + return buildQueryFn(() => getSafesByOwner({ params: { path: { chainId, ownerAddress } } })) + }, + }), ...safeOverviewEndpoints(builder), }), }) @@ -56,4 +61,5 @@ export const { useGetSubmissionQuery, useGetSafeOverviewQuery, useGetMultipleSafeOverviewsQuery, + useGetSafesByOwnerQuery, } = gatewayApi diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index bf9227901c..a6729b9f9a 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -41,6 +41,7 @@ import type { NativeStakingValidatorsExitConfirmationView, StakingTxInfo, TransactionData, + DecodedDataResponse, } from '@safe-global/safe-gateway-typescript-sdk' import { ConfirmationViewTypes, @@ -273,6 +274,26 @@ export const isGenericConfirmation = ( return false } +export const isCreateProxyWithNonceDecodedData = (decodedData: DecodedDataResponse | undefined): boolean => { + return decodedData?.method === 'createProxyWithNonce' +} + +export const isSubaccountDecodedData = (decodedData: DecodedDataResponse | undefined): boolean => { + if (!decodedData) { + return false + } + + const isMultiSend = decodedData?.method === 'multiSend' + if (!isMultiSend) { + return isCreateProxyWithNonceDecodedData(decodedData) + } + + const transactions = decodedData?.parameters.find((param) => param.name === 'transactions') + return !!transactions?.valueDecoded?.some(({ dataDecoded }) => { + return isCreateProxyWithNonceDecodedData(dataDecoded) + }) +} + export const isCancelledSwapOrder = (value: TransactionInfo) => { return isSwapOrderTxInfo(value) && value.status === 'cancelled' } From be43e72468754be8f3e586d9ff9595e74c5a0493 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 09:54:41 +0100 Subject: [PATCH 07/24] Fix saltNonce collision and remove status screen changes --- src/components/common/Header/index.tsx | 4 +-- .../CreateSubaccount/ReviewSubaccount.tsx | 20 +++++++++++-- .../CreateSubaccount/create-subaccount-tx.ts | 7 ++++- .../tx-flow/flows/SuccessScreen/index.tsx | 7 ++--- .../SuccessScreen/statuses/IndexingStatus.tsx | 20 +++---------- .../statuses/ProcessingStatus.tsx | 30 ++++--------------- src/utils/transaction-guards.ts | 23 -------------- 7 files changed, 39 insertions(+), 72 deletions(-) diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index 215ef45aa6..f9aa713cc7 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -103,11 +103,11 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { )} - {enableWc && ( + {/* {enableWc && (
- )} + )} */}
diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx index f798b50c7a..7fce624f9e 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -14,6 +14,7 @@ import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeCon import useAsync from '@/hooks/useAsync' import { computeNewSafeAddress } from '@/components/new-safe/create/logic' import type { SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' +import { useGetSafesByOwnerQuery } from '@/store/slices' export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { const dispatch = useAppDispatch() @@ -23,6 +24,8 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const { balances } = useBalances() const safeVersion = getLatestSafeVersion(chain) + const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId: safe.chainId, ownerAddress: safe.address.value }) + const saltNonce = (subaccounts?.safes.length ?? 0).toString() const [safeAccountConfig] = useAsync(async () => { const fallbackHandler = await getReadOnlyFallbackHandlerContract(safeVersion) @@ -42,11 +45,12 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { wallet.provider, { safeAccountConfig, + saltNonce, }, chain, safeVersion, ) - }, [wallet?.provider, safeAccountConfig, chain, safeVersion]) + }, [wallet?.provider, safeAccountConfig, chain, safeVersion, saltNonce]) useEffect(() => { if (!wallet?.provider || !safeAccountConfig || !predictedSafeAddress) { @@ -56,12 +60,24 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { provider: wallet.provider, assets: params.assets, safeAccountConfig, + safeDeploymentConfig: { + saltNonce, + }, predictedSafeAddress, balances, }) .then(setSafeTx) .catch(setSafeTxError) - }, [wallet?.provider, params.assets, safeAccountConfig, predictedSafeAddress, balances, setSafeTx, setSafeTxError]) + }, [ + wallet?.provider, + params.assets, + safeAccountConfig, + predictedSafeAddress, + balances, + setSafeTx, + setSafeTxError, + saltNonce, + ]) const onSubmit = () => { if (!predictedSafeAddress) { diff --git a/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts b/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts index 0d3773d2fd..4138de460c 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts +++ b/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts @@ -3,7 +3,7 @@ import Safe from '@safe-global/protocol-kit' import type { Eip1193Provider } from 'ethers' import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' import type { SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeAccountConfig } from '@safe-global/protocol-kit' +import type { SafeAccountConfig, SafeDeploymentConfig } from '@safe-global/protocol-kit' import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' @@ -21,6 +21,7 @@ import type { * @param {Eip1193Provider} args.provider - EIP-1193 provider * @param {SetupSubaccountForm['assets']} args.assets - assets to fund the Subaccount with * @param {SafeAccountConfig} args.safeAccountConfig - Subaccount configuration + * @param {SafeDeploymentConfig} args.safeDeploymentConfig - Subaccount deployment configuration * @param {string} args.predictedSafeAddress - predicted Subaccount address * @param {SafeBalanceResponse} args.balances - current Safe balance * @@ -30,6 +31,7 @@ export async function createSubaccount(args: { provider: Eip1193Provider assets: SetupSubaccountForm[SetupSubaccountFormFields.assets] safeAccountConfig: SafeAccountConfig + safeDeploymentConfig: SafeDeploymentConfig predictedSafeAddress: string balances: SafeBalanceResponse }): Promise { @@ -46,17 +48,20 @@ export async function createSubaccount(args: { * * @param {Eip1193Provider} args.provider - EIP-1193 provider * @param {SafeAccountConfig} args.safeAccountConfig - Subaccount configuration + * @param {SafeDeploymentConfig} args.safeDeploymentConfig - Subaccount deployment configuration * * @returns {Promise} Safe deployment transaction */ async function getDeploymentTransaction(args: { provider: Eip1193Provider safeAccountConfig: SafeAccountConfig + safeDeploymentConfig: SafeDeploymentConfig }): Promise { const sdk = await Safe.init({ provider: args.provider, predictedSafe: { safeAccountConfig: args.safeAccountConfig, + safeDeploymentConfig: args.safeDeploymentConfig, }, }) return sdk.createSafeDeploymentTransaction().then(({ to, value, data }) => { diff --git a/src/components/tx-flow/flows/SuccessScreen/index.tsx b/src/components/tx-flow/flows/SuccessScreen/index.tsx index 1029ad016a..b70605ac20 100644 --- a/src/components/tx-flow/flows/SuccessScreen/index.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/index.tsx @@ -14,7 +14,7 @@ import LoadingSpinner, { SpinnerStatus } from '@/components/new-safe/create/step import { ProcessingStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus' import { IndexingStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus' import { DefaultStatus } from '@/components/tx-flow/flows/SuccessScreen/statuses/DefaultStatus' -import { isSubaccountTxData, isSwapTransferOrderTxInfo } from '@/utils/transaction-guards' +import { isSwapTransferOrderTxInfo } from '@/utils/transaction-guards' import { getTxLink } from '@/utils/tx-link' import useTxDetails from '@/hooks/useTxDetails' @@ -37,7 +37,6 @@ const SuccessScreen = ({ txId, txHash }: Props) => { const txLink = chain && txId && getTxLink(txId, chain, safeAddress) const [txDetails] = useTxDetails(txId) const isSwapOrder = txDetails && isSwapTransferOrderTxInfo(txDetails.txInfo) - const isSubaccount = !!txDetails && isSubaccountTxData(txDetails.txData?.dataDecoded) useEffect(() => { if (!pendingTxHash) return @@ -67,10 +66,10 @@ 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 = diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx index fb42438ac4..088f7cf31c 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx @@ -2,22 +2,10 @@ import { Box, Typography } from '@mui/material' import classNames from 'classnames' import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css' -export const IndexingStatus = ({ isSubaccount }: { isSubaccount: boolean }) => ( - - - {!isSubaccount ? 'Transaction was processed' : 'Subaccount was created'} +export const IndexingStatus = () => ( + + + Transaction 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 1cbd58ea5c..5c94c74f81 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx @@ -6,32 +6,14 @@ import { PendingStatus, type PendingTx } from '@/store/pendingTxsSlice' type Props = { txId: string pendingTx: PendingTx - isSubaccount: boolean } -export const ProcessingStatus = ({ txId, pendingTx, isSubaccount }: Props) => ( - - - {!isSubaccount ? 'Transaction is now processing' : 'Subaccount is being created'} +export const ProcessingStatus = ({ txId, pendingTx }: Props) => ( + + + Transaction is now processing - - {!isSubaccount ? 'The transaction' : 'Your Subaccount'} was confirmed and is now being processed. + + The transaction was confirmed and is now being processed. {pendingTx.status === PendingStatus.PROCESSING && ( diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index 20045c611f..3ee3e88b72 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -41,7 +41,6 @@ import type { NativeStakingValidatorsExitConfirmationView, StakingTxInfo, TransactionData, - DataDecoded, } from '@safe-global/safe-gateway-typescript-sdk' import { ConfirmationViewTypes, @@ -274,28 +273,6 @@ export const isGenericConfirmation = ( return false } -const isCreateProxyWithNonceTxData = (dataDecoded?: DataDecoded) => { - const isMethod = dataDecoded?.method === 'createProxyWithNonce' - if (!isMethod) { - return false - } - // We could try to decoded the data, but this is simpler - const params = dataDecoded?.parameters - return params?.[0]?.name === '_singleton' && params?.[1]?.name === 'initializer' && params?.[2]?.name === 'saltNonce' -} - -export const isSubaccountTxData = (dataDecoded?: DataDecoded): boolean => { - const isMultiSend = dataDecoded?.method === 'multiSend' && dataDecoded?.parameters?.[0]?.name === 'transaction' - if (!isMultiSend) { - return isCreateProxyWithNonceTxData(dataDecoded) - } - - const transactions = dataDecoded?.parameters?.[0]?.valueDecoded - return !!transactions?.some((transaction) => { - return isCreateProxyWithNonceTxData(transaction.dataDecoded) - }) -} - export const isCancelledSwapOrder = (value: TransactionInfo) => { return isSwapOrderTxInfo(value) && value.status === 'cancelled' } From d1451c060d163ec6da823b7ba68dc5910515b2d3 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 09:56:10 +0100 Subject: [PATCH 08/24] Fix merge issues --- src/components/common/Header/index.tsx | 4 ++-- .../SuccessScreen/statuses/IndexingStatus.tsx | 16 +++++++++++-- .../statuses/ProcessingStatus.tsx | 23 ++++++++++++++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index f9aa713cc7..215ef45aa6 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -103,11 +103,11 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
)} - {/* {enableWc && ( + {enableWc && (
- )} */} + )}
diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx index 088f7cf31c..36e195a0de 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/statuses/IndexingStatus.tsx @@ -3,8 +3,20 @@ import classNames from 'classnames' import css from '@/components/tx-flow/flows/SuccessScreen/styles.module.css' export const IndexingStatus = () => ( - - + + Transaction was processed diff --git a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx b/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx index 5c94c74f81..86c97c8918 100644 --- a/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx +++ b/src/components/tx-flow/flows/SuccessScreen/statuses/ProcessingStatus.tsx @@ -8,11 +8,28 @@ type Props = { pendingTx: PendingTx } export const ProcessingStatus = ({ txId, pendingTx }: Props) => ( - - + + Transaction is now processing - + The transaction was confirmed and is now being processed. From 4385779085a7b45d1cded2997817bafaaf574982 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 10:55:49 +0100 Subject: [PATCH 09/24] Add badge --- .../sidebar/SafeListContextMenu/index.tsx | 2 +- .../sidebar/SubaccountsButton/index.tsx | 33 ++++++++++++++----- .../SubaccountsButton/styles.module.css | 19 +++++++++++ .../sidebar/SubaccountsPopover/index.tsx | 24 +++++--------- 4 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 src/components/sidebar/SubaccountsButton/styles.module.css diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index 962e5152cb..ca2aaf0bec 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -128,7 +128,7 @@ const SafeListContextMenu = ({ {open[ModalType.SUBACCOUNTS] && ( - + )} {open[ModalType.RENAME] && ( diff --git a/src/components/sidebar/SubaccountsButton/index.tsx b/src/components/sidebar/SubaccountsButton/index.tsx index d7184cb2ca..6a3fb31cdb 100644 --- a/src/components/sidebar/SubaccountsButton/index.tsx +++ b/src/components/sidebar/SubaccountsButton/index.tsx @@ -1,14 +1,18 @@ -import { Tooltip, IconButton, SvgIcon } from '@mui/material' +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 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({ chainId, ownerAddress: safeAddress }) + const subaccounts = data?.safes ?? [] const onClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget) @@ -20,15 +24,26 @@ export function SubaccountsButton({ chainId, safeAddress }: { chainId: string; s 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..0a17a7536e --- /dev/null +++ b/src/components/sidebar/SubaccountsButton/styles.module.css @@ -0,0 +1,19 @@ +.badge :global .MuiBadge-badge { + border: 2px solid var(--color-background-paper); + border-radius: 50%; + box-sizing: content-box; + right: 10px; + top: 8px; + background-color: var(--color-success-light); +} + +.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; +} diff --git a/src/components/sidebar/SubaccountsPopover/index.tsx b/src/components/sidebar/SubaccountsPopover/index.tsx index c524676789..89c944c458 100644 --- a/src/components/sidebar/SubaccountsPopover/index.tsx +++ b/src/components/sidebar/SubaccountsPopover/index.tsx @@ -9,23 +9,19 @@ import { TxModalContext } from '@/components/tx-flow' import { SubaccountsList } from '@/components/sidebar/SubaccountsList' import { SubaccountInfo } from '@/components/sidebar/SubaccountInfo' import { base } from '@/styles/spacings' -import { useGetSafesByOwnerQuery } from '@/store/slices' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS } from '@/services/analytics' export function SubaccountsPopover({ anchorEl, onClose, - chainId, - safeAddress, + subaccounts, }: { anchorEl: HTMLElement | null onClose: () => void - chainId: string - safeAddress: string + subaccounts: Array }): ReactElement { const { setTxFlow } = useContext(TxModalContext) - const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId, ownerAddress: safeAddress }) const onAdd = () => { setTxFlow() @@ -62,15 +58,13 @@ export function SubaccountsPopover({ maxHeight: '590px', }} > - {subaccounts?.safes ? ( - subaccounts.safes.length === 0 ? ( - - ) : ( - - - - ) - ) : null} + {subaccounts.length === 0 ? ( + + ) : ( + + + + )}
)} - {enableWc && ( + {/* {enableWc && (
- )} + )} */}
diff --git a/src/components/settings/SubaccountsList/index.tsx b/src/components/settings/SubaccountsList/index.tsx index ce9661a6b9..f8480101b2 100644 --- a/src/components/settings/SubaccountsList/index.tsx +++ b/src/components/settings/SubaccountsList/index.tsx @@ -16,7 +16,7 @@ import { useGetSafesByOwnerQuery } from '@/store/slices' import tableCss from '@/components/common/EnhancedTable/styles.module.css' import Track from '@/components/common/Track' -import { SETTINGS_EVENTS } from '@/services/analytics' +import { SUBACCOUNT_EVENTS } from '@/services/analytics/events/subaccounts' export function SubaccountsList(): ReactElement | null { const { setTxFlow } = useContext(TxModalContext) @@ -42,7 +42,7 @@ export function SubaccountsList(): ReactElement | null {
{(isOk) => ( - + setAddressToRename(subaccount)} size="small" disabled={!isOk}> diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index ca2aaf0bec..daffce9e53 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -13,7 +13,7 @@ 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' @@ -21,6 +21,7 @@ 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' enum ModalType { SUBACCOUNTS = 'subaccounts', @@ -69,16 +70,14 @@ const SafeListContextMenu = ({ setAnchorEl(null) } - const handleOpenModal = - (type: keyof typeof open, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.SIDEBAR_RENAME) => - () => { - if (type !== ModalType.SUBACCOUNTS) { - handleCloseContextMenu() - } - setOpen((prev) => ({ ...prev, [type]: true })) - - trackEvent({ ...event, label: trackingLabel }) + const handleOpenModal = (type: keyof typeof open, event: AnalyticsEvent) => () => { + if (type !== ModalType.SUBACCOUNTS) { + handleCloseContextMenu() } + setOpen((prev) => ({ ...prev, [type]: true })) + + trackEvent({ ...event, label: trackingLabel }) + } const handleCloseModal = () => { setOpen(defaultOpen) @@ -91,7 +90,12 @@ const SafeListContextMenu = ({ {!undeployedSafe && subaccounts?.safes && subaccounts.safes.length > 0 && ( - + diff --git a/src/components/sidebar/SidebarHeader/index.tsx b/src/components/sidebar/SidebarHeader/index.tsx index 8c0c93ab38..95f69c9226 100644 --- a/src/components/sidebar/SidebarHeader/index.tsx +++ b/src/components/sidebar/SidebarHeader/index.tsx @@ -32,6 +32,7 @@ import CopyTooltip from '@/components/common/CopyTooltip' import FiatValue from '@/components/common/FiatValue' import { useAddressResolver } from '@/hooks/useAddressResolver' import { SubaccountsButton } from '@/components/sidebar/SubaccountsButton' +import { SUBACCOUNT_EVENTS, SUBACCOUNT_LABELS } from '@/services/analytics/events/subaccounts' const SafeHeader = (): ReactElement => { const { balances } = useVisibleBalances() @@ -115,7 +116,7 @@ const SafeHeader = (): ReactElement => { - + diff --git a/src/components/sidebar/SubaccountInfo/index.tsx b/src/components/sidebar/SubaccountInfo/index.tsx index fd04f65c01..dbb333d3af 100644 --- a/src/components/sidebar/SubaccountInfo/index.tsx +++ b/src/components/sidebar/SubaccountInfo/index.tsx @@ -8,9 +8,9 @@ import InfoIcon from '@/public/images/notifications/info.svg' export function SubaccountInfo(): ReactElement { return ( - + - + No Subaccounts yet - + - + - With Subaccounts you can: + Subaccounts allow you to: diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx index 68a4627e70..c796fd08f9 100644 --- a/src/components/sidebar/SubaccountsList/index.tsx +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -1,6 +1,6 @@ import EthHashInfo from '@/components/common/EthHashInfo' import Track from '@/components/common/Track' -import { OVERVIEW_EVENTS } from '@/services/analytics' +import { SUBACCOUNT_EVENTS } from '@/services/analytics/events/subaccounts' import { ChevronRight } from '@mui/icons-material' import { Box, Typography } from '@mui/material' import { useState, type ReactElement } from 'react' @@ -43,7 +43,7 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) ) })} {subaccounts.length > MAX_SUBACCOUNTS && !showAll && ( - + )} - +
)} - {/* {enableWc && ( + {enableWc && (
- )} */} + )}
From e5c50ac1db1261fb9c07d225f01543f2916e54f5 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 17:25:01 +0100 Subject: [PATCH 12/24] Add status screen --- public/images/sidebar/subaccounts-icon.svg | 7 +- .../sidebar/SafeListContextMenu/index.tsx | 2 +- .../sidebar/SidebarHeader/index.tsx | 2 +- .../sidebar/SubaccountsList/index.tsx | 4 +- .../CreateSubaccount/ReviewSubaccount.tsx | 153 ++++++++++++------ .../CreateSubaccount/SetupSubaccount.tsx | 8 +- .../CreateSubaccount/create-subaccount-tx.ts | 111 ------------- .../tx-flow/flows/SuccessScreen/index.tsx | 40 ++++- .../SuccessScreen/statuses/DefaultStatus.tsx | 6 +- .../SuccessScreen/statuses/IndexingStatus.tsx | 4 +- .../statuses/ProcessingStatus.tsx | 7 +- src/features/multichain/utils/utils.ts | 28 ++-- ...usePredictSafeAddressFromTxDetails.test.ts | 58 +++++++ .../usePredictSafeAddressFromTxDetails.ts | 60 +++++++ src/services/analytics/events/subaccounts.ts | 9 +- 15 files changed, 300 insertions(+), 199 deletions(-) delete mode 100644 src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts create mode 100644 src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts create mode 100644 src/hooks/usePredictSafeAddressFromTxDetails.ts diff --git a/public/images/sidebar/subaccounts-icon.svg b/public/images/sidebar/subaccounts-icon.svg index b2758c6f24..ee7ca94938 100644 --- a/public/images/sidebar/subaccounts-icon.svg +++ b/public/images/sidebar/subaccounts-icon.svg @@ -1,8 +1,3 @@ - - - - - - + diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index daffce9e53..932bae11b6 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -92,7 +92,7 @@ const SafeListContextMenu = ({ {!undeployedSafe && subaccounts?.safes && subaccounts.safes.length > 0 && ( diff --git a/src/components/sidebar/SidebarHeader/index.tsx b/src/components/sidebar/SidebarHeader/index.tsx index 95f69c9226..67fc56e1f0 100644 --- a/src/components/sidebar/SidebarHeader/index.tsx +++ b/src/components/sidebar/SidebarHeader/index.tsx @@ -116,7 +116,7 @@ const SafeHeader = (): ReactElement => { - + diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx index c796fd08f9..abc8b939ea 100644 --- a/src/components/sidebar/SubaccountsList/index.tsx +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -18,6 +18,7 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) return ( {subaccountsToShow.map((subaccount) => { + // TODO: Turn into link to Subaccount return ( }) }} key={subaccount} > - + ) @@ -49,6 +50,7 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) color="text.secondary" sx={{ textTransform: 'uppercase', + fontWeight: 700, }} onClick={onShowAll} > diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx index 7fce624f9e..5ced62f773 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -1,83 +1,101 @@ -import { useContext, useEffect } from 'react' +import { useContext, useEffect, useMemo } from 'react' import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' -import { createSubaccount } from '@/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx' import useSafeInfo from '@/hooks/useSafeInfo' import useBalances from '@/hooks/useBalances' import { useCurrentChain } from '@/hooks/useChains' -import useWallet from '@/hooks/wallets/useWallet' import { useAppDispatch } from '@/store' import { upsertAddressBookEntries } from '@/store/addressBookSlice' import { getLatestSafeVersion } from '@/utils/chains' -import { getReadOnlyFallbackHandlerContract } from '@/services/contracts/safeContracts' import useAsync from '@/hooks/useAsync' -import { computeNewSafeAddress } from '@/components/new-safe/create/logic' -import type { SetupSubaccountForm } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' +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 { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import EthHashInfo from '@/components/common/EthHashInfo' +import { Grid, Typography } from '@mui/material' export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { const dispatch = useAppDispatch() - const wallet = useWallet() const { safeAddress, safe } = useSafeInfo() const chain = useCurrentChain() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const { balances } = useBalances() - const safeVersion = getLatestSafeVersion(chain) + const provider = useWeb3ReadOnly() const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId: safe.chainId, ownerAddress: safe.address.value }) - const saltNonce = (subaccounts?.safes.length ?? 0).toString() + const version = getLatestSafeVersion(chain) - const [safeAccountConfig] = useAsync(async () => { - const fallbackHandler = await getReadOnlyFallbackHandlerContract(safeVersion) - const owners = [safeAddress] - return { - owners, - threshold: owners.length, - fallbackHandler: fallbackHandler?.contractAddress, - } - }, [safeVersion, safeAddress]) - - const [predictedSafeAddress] = useAsync(async () => { - if (!wallet?.provider || !safeAccountConfig || !chain || !safeVersion) { + const safeAccountConfig = useMemo(() => { + if (!chain || !subaccounts) { return } - return computeNewSafeAddress( - wallet.provider, + + const undeployedSafe = createNewUndeployedSafeWithoutSalt( + version, { - safeAccountConfig, - saltNonce, + owners: [safeAddress], + threshold: 1, }, chain, - safeVersion, ) - }, [wallet?.provider, safeAccountConfig, chain, safeVersion, saltNonce]) + const saltNonce = subaccounts.safes.length.toString() + + return { + ...undeployedSafe, + saltNonce, + } + }, [chain, safeAddress, subaccounts, version]) + + const [predictedSafeAddress] = useAsync(async () => { + if (provider && safeAccountConfig) { + return predictAddressBasedOnReplayData(safeAccountConfig, provider) + } + }, [provider, safeAccountConfig]) useEffect(() => { - if (!wallet?.provider || !safeAccountConfig || !predictedSafeAddress) { + if (!chain || !safeAccountConfig || !predictedSafeAddress) { return } - createSubaccount({ - provider: wallet.provider, - assets: params.assets, - safeAccountConfig, - safeDeploymentConfig: { - saltNonce, - }, - predictedSafeAddress, - balances, - }) - .then(setSafeTx) - .catch(setSafeTxError) - }, [ - wallet?.provider, - params.assets, - safeAccountConfig, - predictedSafeAddress, - balances, - setSafeTx, - setSafeTxError, - saltNonce, - ]) + + const deploymentTx = { + to: safeAccountConfig.factoryAddress, + data: encodeSafeCreationTx(safeAccountConfig, chain), + value: '0', + } + + const fundingTxs = params.assets + .map((asset) => { + const token = balances.items.find((item) => { + return item.tokenInfo.address === asset[SetupSubaccountFormAssetFields.tokenAddress] + }) + if (token) { + return createTokenTransferParams( + predictedSafeAddress, + asset[SetupSubaccountFormAssetFields.amount], + token.tokenInfo.decimals, + token.tokenInfo.address, + ) + } + }) + .filter((tx) => { + return tx != null + }) + + 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) { @@ -92,5 +110,38 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { ) } - return + return ( + + {predictedSafeAddress && ( + + + + Subaccount + + + + + + + + )} + + ) } diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index ada305d557..0b52d39a2a 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -20,7 +20,8 @@ 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 { useMnemonicSafeName } from '@/hooks/useMnemonicName' +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' @@ -54,7 +55,10 @@ export function SetUpSubaccount({ params: SetupSubaccountForm onSubmit: (params: SetupSubaccountForm) => void }) { - const fallbackName = useMnemonicSafeName() + const addressBook = useAddressBook() + const safeAddress = useSafeAddress() + const fallbackName = `${addressBook[safeAddress]} Subaccount` + const formMethods = useForm({ defaultValues: params, mode: 'onChange', diff --git a/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts b/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts deleted file mode 100644 index 4138de460c..0000000000 --- a/src/components/tx-flow/flows/CreateSubaccount/create-subaccount-tx.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { OperationType } from '@safe-global/safe-core-sdk-types' -import Safe from '@safe-global/protocol-kit' -import type { Eip1193Provider } from 'ethers' -import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' -import type { SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' -import type { SafeAccountConfig, SafeDeploymentConfig } from '@safe-global/protocol-kit' - -import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' -import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' -import { SetupSubaccountFormAssetFields } from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' -import type { - SetupSubaccountForm, - SetupSubaccountFormFields, -} from '@/components/tx-flow/flows/CreateSubaccount/SetupSubaccount' - -/** - * Creates a (batch) transaction to deploy (and fund) a Subaccount. - * - * Note: Subaccounts are owned by provided {@link currentSafeAddress}, with a threshold of 1. - * - * @param {Eip1193Provider} args.provider - EIP-1193 provider - * @param {SetupSubaccountForm['assets']} args.assets - assets to fund the Subaccount with - * @param {SafeAccountConfig} args.safeAccountConfig - Subaccount configuration - * @param {SafeDeploymentConfig} args.safeDeploymentConfig - Subaccount deployment configuration - * @param {string} args.predictedSafeAddress - predicted Subaccount address - * @param {SafeBalanceResponse} args.balances - current Safe balance - * - * @returns {Promise} (batch) transaction to deploy (and fund) Subaccount - */ -export async function createSubaccount(args: { - provider: Eip1193Provider - assets: SetupSubaccountForm[SetupSubaccountFormFields.assets] - safeAccountConfig: SafeAccountConfig - safeDeploymentConfig: SafeDeploymentConfig - predictedSafeAddress: string - balances: SafeBalanceResponse -}): Promise { - const deploymentTransaction = await getDeploymentTransaction(args) - const fundingTransactions = getFundingTransactions(args) - if (fundingTransactions.length === 0) { - return createTx(deploymentTransaction) - } - return createMultiSendCallOnlyTx([deploymentTransaction, ...fundingTransactions]) -} - -/** - * Creates a transaction to deploy a Subaccount. - * - * @param {Eip1193Provider} args.provider - EIP-1193 provider - * @param {SafeAccountConfig} args.safeAccountConfig - Subaccount configuration - * @param {SafeDeploymentConfig} args.safeDeploymentConfig - Subaccount deployment configuration - * - * @returns {Promise} Safe deployment transaction - */ -async function getDeploymentTransaction(args: { - provider: Eip1193Provider - safeAccountConfig: SafeAccountConfig - safeDeploymentConfig: SafeDeploymentConfig -}): Promise { - const sdk = await Safe.init({ - provider: args.provider, - predictedSafe: { - safeAccountConfig: args.safeAccountConfig, - safeDeploymentConfig: args.safeDeploymentConfig, - }, - }) - return sdk.createSafeDeploymentTransaction().then(({ to, value, data }) => { - return { - to, - value, - data, - operation: OperationType.Call, - } - }) -} - -/** - * Creates a list of transfer transactions (to fund a Subaccount). - * - * @param {SetupSubaccountForm['assets']} args.assets - assets to fund the Subaccount - * @param {SafeBalanceResponse} args.balances - current Safe balances - * @param {string} args.predictedSafeAddress - predicted Subaccount address - * - * @returns {Array} list of transfer transactions - */ -function getFundingTransactions(args: { - assets: SetupSubaccountForm[SetupSubaccountFormFields.assets] - balances: SafeBalanceResponse - predictedSafeAddress: string -}): Array { - if (args.assets.length === 0) { - return [] - } - return args.assets - .map((asset) => { - const token = args.balances.items.find((item) => { - return item.tokenInfo.address === asset[SetupSubaccountFormAssetFields.tokenAddress] - }) - if (token) { - return createTokenTransferParams( - args.predictedSafeAddress, - asset[SetupSubaccountFormAssetFields.amount], - token.tokenInfo.decimals, - token.tokenInfo.address, - ) - } - }) - .filter((x: T): x is NonNullable => { - return x != null - }) -} 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/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts b/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts new file mode 100644 index 0000000000..e1c13d7729 --- /dev/null +++ b/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts @@ -0,0 +1,58 @@ +import type { DataDecoded } from '@safe-global/safe-gateway-typescript-sdk' +import { _getSetupFromDataDecoded } from '../usePredictSafeAddressFromTxDetails' + +const createProxyWithNonce = { + method: 'createProxyWithNonce', + parameters: [ + { + name: '_singleton', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + valueDecoded: null, + }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + valueDecoded: null, + }, + { + name: 'saltNonce', + type: 'uint256', + value: '3', + valueDecoded: null, + }, + ], +} as unknown as DataDecoded + +describe('getSetupFromDataDecoded', () => { + 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)).toEqual({ + singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + initializer: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + saltNonce: '3', + }) + }) + + it.each([ + ['_singleton', 0], + ['initializer', 1], + ['saltNonce', 2], + ])('should return undefined if %s is not a string', (_, index) => { + const dataDecoded = JSON.parse(JSON.stringify(createProxyWithNonce)) as DataDecoded + // @ts-expect-error value is a string + dataDecoded.parameters[index].value = 1 + expect(_getSetupFromDataDecoded(dataDecoded)).toBeUndefined() + }) +}) + +it.todo('usePredictSafeAddressFromTxDetails') 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/services/analytics/events/subaccounts.ts b/src/services/analytics/events/subaccounts.ts index 5261af9a86..4a73f2e8d0 100644 --- a/src/services/analytics/events/subaccounts.ts +++ b/src/services/analytics/events/subaccounts.ts @@ -1,8 +1,12 @@ const SUBACCOUNTS_CATEGORY = 'subaccounts' export const SUBACCOUNT_EVENTS = { - OPEN: { - action: 'Open', + OPEN_LIST: { + action: 'Open Subaccount list', + category: SUBACCOUNTS_CATEGORY, + }, + OPEN_SUBACCOUNT: { + action: 'Open Subaccount', category: SUBACCOUNTS_CATEGORY, }, SHOW_ALL: { @@ -22,4 +26,5 @@ export const SUBACCOUNT_EVENTS = { export enum SUBACCOUNT_LABELS { header = 'header', sidebar = 'sidebar', + success_screen = 'success_screen', } From dc5bb92a554d14275ac752f5d512f02c87b72cac Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 18:16:29 +0100 Subject: [PATCH 13/24] Convert Subaccount list to correct elements --- .../sidebar/SubaccountsList/index.tsx | 79 ++++++++++++------- src/services/analytics/events/subaccounts.ts | 1 + 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx index abc8b939ea..c6425d2a0b 100644 --- a/src/components/sidebar/SubaccountsList/index.tsx +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -1,9 +1,12 @@ -import EthHashInfo from '@/components/common/EthHashInfo' import Track from '@/components/common/Track' -import { SUBACCOUNT_EVENTS } from '@/services/analytics/events/subaccounts' +import { SUBACCOUNT_EVENTS, SUBACCOUNT_LABELS } from '@/services/analytics/events/subaccounts' import { ChevronRight } from '@mui/icons-material' -import { Box, Typography } from '@mui/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' const MAX_SUBACCOUNTS = 5 @@ -16,32 +19,9 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) } return ( - + {subaccountsToShow.map((subaccount) => { - // TODO: Turn into link to Subaccount - return ( - `${shape.borderRadius}px`, - cursor: 'pointer', - py: '11px', - px: 2, - '&:hover': { - backgroundColor: 'var(--color-background-light)', - borderColor: 'var(--color-secondary-light)', - }, - }} - key={subaccount} - > - - - - ) + return })} {subaccounts.length > MAX_SUBACCOUNTS && !showAll && ( @@ -59,6 +39,47 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) )} - + + ) +} + +function SubaccountListItem({ subaccount }: { subaccount: string }): ReactElement { + const addressBook = useAddressBook() + const name = addressBook[subaccount] + + // Note: using the Track element breaks accessibility/styles + const onClick = () => { + trackEvent({ ...SUBACCOUNT_EVENTS.OPEN_SUBACCOUNT, label: SUBACCOUNT_LABELS.list }) + } + + return ( + `1px solid ${palette.border.light}`, + borderRadius: ({ shape }) => `${shape.borderRadius}px`, + p: 0, + }} + > + + + + + + + + ) } diff --git a/src/services/analytics/events/subaccounts.ts b/src/services/analytics/events/subaccounts.ts index 4a73f2e8d0..f0f6021bc1 100644 --- a/src/services/analytics/events/subaccounts.ts +++ b/src/services/analytics/events/subaccounts.ts @@ -26,5 +26,6 @@ export const SUBACCOUNT_EVENTS = { export enum SUBACCOUNT_LABELS { header = 'header', sidebar = 'sidebar', + list = 'list', success_screen = 'success_screen', } From 12d7051ca74e31c279370fb8a454a92a673b9df6 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 19:01:14 +0100 Subject: [PATCH 14/24] Invalidate subaccount cache after deployment --- .../settings/SubaccountsList/index.tsx | 7 ++++-- .../sidebar/SafeListContextMenu/index.tsx | 3 ++- .../sidebar/SubaccountsButton/index.tsx | 3 ++- .../CreateSubaccount/ReviewSubaccount.tsx | 7 ++++-- src/hooks/useOwnedSafes.ts | 1 + src/store/api/gateway/index.ts | 5 +++- src/store/txHistorySlice.ts | 24 +++++++++++++++++++ 7 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/components/settings/SubaccountsList/index.tsx b/src/components/settings/SubaccountsList/index.tsx index f8480101b2..92289775d0 100644 --- a/src/components/settings/SubaccountsList/index.tsx +++ b/src/components/settings/SubaccountsList/index.tsx @@ -17,13 +17,16 @@ 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 } = useSafeInfo() - const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId: safe.chainId, ownerAddress: safe.address.value }) + const { safe, safeLoaded, safeAddress } = useSafeInfo() + const { data: subaccounts } = useGetSafesByOwnerQuery( + safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken, + ) const rows = useMemo(() => { return subaccounts?.safes.map((subaccount) => { diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index 932bae11b6..7c5002a33a 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -22,6 +22,7 @@ import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSaf 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', @@ -52,7 +53,7 @@ const SafeListContextMenu = ({ rename: boolean undeployedSafe: boolean }): ReactElement => { - const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId, ownerAddress: address }) + const { data: subaccounts } = useGetSafesByOwnerQuery(address ? { chainId, ownerAddress: address } : skipToken) const addressBook = useAddressBook() const hasName = address in addressBook diff --git a/src/components/sidebar/SubaccountsButton/index.tsx b/src/components/sidebar/SubaccountsButton/index.tsx index 6a3fb31cdb..27df8050a6 100644 --- a/src/components/sidebar/SubaccountsButton/index.tsx +++ b/src/components/sidebar/SubaccountsButton/index.tsx @@ -5,13 +5,14 @@ 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({ chainId, ownerAddress: safeAddress }) + const { data } = useGetSafesByOwnerQuery(safeAddress ? { chainId, ownerAddress: safeAddress } : skipToken) const subaccounts = data?.safes ?? [] const onClick = (event: React.MouseEvent) => { diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx index 5ced62f773..3057e84873 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -22,15 +22,18 @@ import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' import type { 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 } = useSafeInfo() + const { safeAddress, safe, safeLoaded } = useSafeInfo() const chain = useCurrentChain() const { setSafeTx, setSafeTxError } = useContext(SafeTxContext) const { balances } = useBalances() const provider = useWeb3ReadOnly() - const { data: subaccounts } = useGetSafesByOwnerQuery({ chainId: safe.chainId, ownerAddress: safe.address.value }) + const { data: subaccounts } = useGetSafesByOwnerQuery( + safeLoaded ? { chainId: safe.chainId, ownerAddress: safeAddress } : skipToken, + ) const version = getLatestSafeVersion(chain) const safeAccountConfig = useMemo(() => { diff --git a/src/hooks/useOwnedSafes.ts b/src/hooks/useOwnedSafes.ts index 66db1772b8..0463efd4a1 100644 --- a/src/hooks/useOwnedSafes.ts +++ b/src/hooks/useOwnedSafes.ts @@ -14,6 +14,7 @@ type OwnedSafesCache = { } } +// TODO: Replace with useGetSafesByOwnerQuery const useOwnedSafes = (): OwnedSafesCache['walletAddress'] => { const chainId = useChainId() const { address: walletAddress } = useWallet() || {} diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts index 5e02b1794d..769f2ee924 100644 --- a/src/store/api/gateway/index.ts +++ b/src/store/api/gateway/index.ts @@ -17,7 +17,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 }) { @@ -44,6 +44,9 @@ export const gatewayApi = createApi({ queryFn({ chainId, ownerAddress }) { return buildQueryFn(() => getSafesByOwner({ params: { path: { chainId, ownerAddress } } })) }, + providesTags: (_res, _err, { chainId, ownerAddress }) => { + return [{ type: 'OwnedSafes', id: `${chainId}:${ownerAddress}` }] + }, }), createSubmission: builder.mutation< createSubmission, 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 From 4f58bea7e24c311d5f694e9b578b47595eb6f09e Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 19:15:51 +0100 Subject: [PATCH 15/24] Fix CI and add tests --- .../CreateSubaccount/ReviewSubaccount.tsx | 29 +- ...usePredictSafeAddressFromTxDetails.test.ts | 294 ++++++++++++++++-- 2 files changed, 280 insertions(+), 43 deletions(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx index 3057e84873..f63c8ad803 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -19,7 +19,7 @@ import { predictAddressBasedOnReplayData } from '@/features/multichain/utils/uti import { useWeb3ReadOnly } from '@/hooks/wallets/web3' import { createTokenTransferParams } from '@/services/tx/tokenTransferParams' import { createMultiSendCallOnlyTx, createTx } from '@/services/tx/tx-sender' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +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' @@ -74,23 +74,24 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { value: '0', } - const fundingTxs = params.assets - .map((asset) => { - const token = balances.items.find((item) => { - return item.tokenInfo.address === asset[SetupSubaccountFormAssetFields.tokenAddress] - }) - if (token) { - return createTokenTransferParams( + 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, - ) - } - }) - .filter((tx) => { - return tx != null - }) + ), + ) + } + } const createSafeTx = async (): Promise => { const isMultiSend = fundingTxs.length > 0 diff --git a/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts b/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts index e1c13d7729..daded09011 100644 --- a/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts +++ b/src/hooks/__tests__/usePredictSafeAddressFromTxDetails.test.ts @@ -1,30 +1,221 @@ -import type { DataDecoded } from '@safe-global/safe-gateway-typescript-sdk' -import { _getSetupFromDataDecoded } from '../usePredictSafeAddressFromTxDetails' +import type { DataDecoded, TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' +import { _getSetupFromDataDecoded, usePredictSafeAddressFromTxDetails } from '../usePredictSafeAddressFromTxDetails' +import { renderHook } from '@testing-library/react' +// @see https://safe-client.safe.global/v1/chains/11155111/transactions/multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0xd0d519d3ebd6efac7a9d7c591c0a311193b6acbbf5db970fab6a51e1d3509e72 const createProxyWithNonce = { - method: 'createProxyWithNonce', - parameters: [ - { - name: '_singleton', - type: 'address', - value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', - valueDecoded: null, - }, - { - name: 'initializer', - type: 'bytes', - value: - '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', - valueDecoded: null, + safeAddress: '0x57c26D4d117c926A872814fa46C179691f580e84', + txId: 'multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0xd0d519d3ebd6efac7a9d7c591c0a311193b6acbbf5db970fab6a51e1d3509e72', + executedAt: 1732816512000, + txStatus: 'SUCCESS', + txInfo: { + type: 'Custom', + humanDescription: null, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', + }, + dataSize: '580', + value: '0', + methodName: 'createProxyWithNonce', + actionCount: null, + isCancellation: false, + }, + txData: { + hexData: + '0x1688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000b00000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c7620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { + name: '_singleton', + type: 'address', + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + valueDecoded: null, + }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + valueDecoded: null, + }, + { name: 'saltNonce', type: 'uint256', value: '11', valueDecoded: null }, + ], + }, + to: { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', }, - { - name: 'saltNonce', - type: 'uint256', - value: '3', - valueDecoded: null, + value: '0', + operation: 0, + trustedDelegateCallTarget: null, + addressInfoIndex: { + '0x41675C099F32341bf84BFc5382aF534df5C7461a': { + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + name: 'Safe 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x41675C099F32341bf84BFc5382aF534df5C7461a.png', + }, }, - ], -} as unknown as DataDecoded + }, + txHash: '0xef0ae869f2aa8ef5c60aa7e47cfb1dc463d25f41b1c9322d822bc96b529c7e60', + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1732816512000, + nonce: 30, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { value: '0x0000000000000000000000000000000000000000', name: 'MetaMultiSigWallet', logoUri: null }, + safeTxHash: '0xd0d519d3ebd6efac7a9d7c591c0a311193b6acbbf5db970fab6a51e1d3509e72', + executor: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signers: [{ value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }], + confirmationsRequired: 1, + confirmations: [ + { + signer: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signature: + '0x000000000000000000000000bbeedb6d8e56e23f5812e59d1b6602f15957271f000000000000000000000000000000000000000000000000000000000000000001', + submittedAt: 1732816512000, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: null, + proposedByDelegate: null, + }, + safeAppInfo: null, +} + +// @see https://safe-client.safe.global/v1/chains/11155111/transactions/multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0x1bfa1753ff85b19b9b455a7bf6b5f491e75fef451b01d31dac5236966aa82dbb +const createProxyWithNonceThenFund = { + safeAddress: '0x57c26D4d117c926A872814fa46C179691f580e84', + txId: 'multisig_0x57c26D4d117c926A872814fa46C179691f580e84_0x1bfa1753ff85b19b9b455a7bf6b5f491e75fef451b01d31dac5236966aa82dbb', + executedAt: 1732815948000, + txStatus: 'SUCCESS', + txInfo: { + type: 'Custom', + humanDescription: null, + to: { + value: '0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B', + name: 'Safe: MultiSendCallOnly 1.3.0', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png', + }, + dataSize: '836', + value: '0', + methodName: 'multiSend', + actionCount: 2, + isCancellation: false, + }, + txData: { + hexData: + '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002ee004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002441688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1eeb7cc56e9ff272fd2da70cbe18c9c500fc478000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'multiSend', + parameters: [ + { + name: 'transactions', + type: 'bytes', + value: + '0x004e1dcf7ad4e460cfd30791ccc4f9c8a4f820ec67000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002441688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a1eeb7cc56e9ff272fd2da70cbe18c9c500fc478000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000000', + valueDecoded: [ + { + operation: 0, + to: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + value: '0', + data: '0x1688f0b900000000000000000000000041675c099f32341bf84bfc5382af534df5c7461a0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000001a4b63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c7620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + dataDecoded: { + method: 'createProxyWithNonce', + parameters: [ + { name: '_singleton', type: 'address', value: '0x41675C099F32341bf84BFc5382aF534df5C7461a' }, + { + name: 'initializer', + type: 'bytes', + value: + '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', + }, + { name: 'saltNonce', type: 'uint256', value: '9' }, + ], + }, + }, + { + operation: 0, + to: '0xA1eEB7CC56e9FF272Fd2Da70CBE18c9C500FC478', + value: '100000000000000000', + data: null, + dataDecoded: null, + }, + ], + }, + ], + }, + to: { + value: '0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B', + name: 'Safe: MultiSendCallOnly 1.3.0', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B.png', + }, + value: '0', + operation: 1, + trustedDelegateCallTarget: true, + addressInfoIndex: { + '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67': { + value: '0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67', + name: 'SafeProxyFactory 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67.png', + }, + '0x41675C099F32341bf84BFc5382aF534df5C7461a': { + value: '0x41675C099F32341bf84BFc5382aF534df5C7461a', + name: 'Safe 1.4.1', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x41675C099F32341bf84BFc5382aF534df5C7461a.png', + }, + '0xA1eEB7CC56e9FF272Fd2Da70CBE18c9C500FC478': { + value: '0xA1eEB7CC56e9FF272Fd2Da70CBE18c9C500FC478', + name: 'SafeProxy', + logoUri: null, + }, + }, + }, + txHash: '0x08d3c281a136d43346433453125afee79f9455bf5810deec3dd3806a42de41b1', + detailedExecutionInfo: { + type: 'MULTISIG', + submittedAt: 1732815948000, + nonce: 28, + safeTxGas: '0', + baseGas: '0', + gasPrice: '0', + gasToken: '0x0000000000000000000000000000000000000000', + refundReceiver: { value: '0x0000000000000000000000000000000000000000', name: 'MetaMultiSigWallet', logoUri: null }, + safeTxHash: '0x1bfa1753ff85b19b9b455a7bf6b5f491e75fef451b01d31dac5236966aa82dbb', + executor: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signers: [{ value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }], + confirmationsRequired: 1, + confirmations: [ + { + signer: { value: '0xbbeedB6d8e56e23f5812e59d1b6602F15957271F', name: null, logoUri: null }, + signature: + '0x000000000000000000000000bbeedb6d8e56e23f5812e59d1b6602f15957271f000000000000000000000000000000000000000000000000000000000000000001', + submittedAt: 1732815948000, + }, + ], + rejectors: [], + gasTokenInfo: null, + trusted: true, + proposer: null, + proposedByDelegate: null, + }, + safeAppInfo: null, +} describe('getSetupFromDataDecoded', () => { it('should return undefined if no createProxyWithNonce method is found', () => { @@ -35,11 +226,11 @@ describe('getSetupFromDataDecoded', () => { }) it('should return direct createProxyWithNonce calls', () => { - expect(_getSetupFromDataDecoded(createProxyWithNonce)).toEqual({ + expect(_getSetupFromDataDecoded(createProxyWithNonce.txData.dataDecoded as unknown as DataDecoded)).toEqual({ singleton: '0x41675C099F32341bf84BFc5382aF534df5C7461a', initializer: '0xb63e800d00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000bd89a1ce4dde368ffab0ec35506eece0b1ffdc540000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fd0732dc9e303f09fcef3a7388ad10a83459ec99000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000057c26d4d117c926a872814fa46c179691f580e840000000000000000000000000000000000000000000000000000000000000024fe51f64300000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c76200000000000000000000000000000000000000000000000000000000', - saltNonce: '3', + saltNonce: '11', }) }) @@ -47,12 +238,57 @@ describe('getSetupFromDataDecoded', () => { ['_singleton', 0], ['initializer', 1], ['saltNonce', 2], - ])('should return undefined if %s is not a string', (_, index) => { - const dataDecoded = JSON.parse(JSON.stringify(createProxyWithNonce)) as DataDecoded + ])('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[index].value = 1 + dataDecoded.parameters[argIndex].value = 1 expect(_getSetupFromDataDecoded(dataDecoded)).toBeUndefined() }) }) -it.todo('usePredictSafeAddressFromTxDetails') +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', + ) + }) +}) From 97aeed828d2dd8bb312153167a9fdd0f61bcf18d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 19:19:58 +0100 Subject: [PATCH 16/24] Fix types --- src/store/api/gateway/proposers.ts | 2 +- src/store/api/gateway/safeOverviews.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 }) { From f2fc1d4184db60a0da48502d8b53a7f3f40dc671 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 28 Nov 2024 20:02:58 +0100 Subject: [PATCH 17/24] Fix test --- src/store/__tests__/txHistorySlice.test.ts | 53 +++++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) 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', }) From 93a9283b93fb37dacb5ce4223655bb4e74c9ea9e Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 29 Nov 2024 08:28:40 +0100 Subject: [PATCH 18/24] Update icon in flow --- src/components/tx-flow/flows/CreateSubaccount/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/index.tsx b/src/components/tx-flow/flows/CreateSubaccount/index.tsx index 2b76750b20..f8f5e3bd29 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/index.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/index.tsx @@ -1,4 +1,4 @@ -import CustomIcon from '@/public/images/transactions/custom.svg' +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' @@ -20,7 +20,7 @@ export function CreateSubaccount() { From 55cceba1d4171ec2d2cf7a669a6ecbc22945412b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 29 Nov 2024 10:48:43 +0100 Subject: [PATCH 19/24] Address design review --- public/images/sidebar/subaccounts-icon.svg | 2 +- src/components/sidebar/SubaccountInfo/index.tsx | 6 +++--- .../sidebar/SubaccountsButton/styles.module.css | 8 +++++--- .../tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/public/images/sidebar/subaccounts-icon.svg b/public/images/sidebar/subaccounts-icon.svg index ee7ca94938..51289915a7 100644 --- a/public/images/sidebar/subaccounts-icon.svg +++ b/public/images/sidebar/subaccounts-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/sidebar/SubaccountInfo/index.tsx b/src/components/sidebar/SubaccountInfo/index.tsx index dbb333d3af..6bf792ab89 100644 --- a/src/components/sidebar/SubaccountInfo/index.tsx +++ b/src/components/sidebar/SubaccountInfo/index.tsx @@ -39,9 +39,9 @@ export function SubaccountInfo(): ReactElement { {[ - 'Use them for specific cases such as DeFi operations', - 'Install modules to execute transactions, bypassing thresholds', - 'Make sure that this Safe is not exposed to additional risks', + '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 ( diff --git a/src/components/sidebar/SubaccountsButton/styles.module.css b/src/components/sidebar/SubaccountsButton/styles.module.css index 0a17a7536e..d0254216ea 100644 --- a/src/components/sidebar/SubaccountsButton/styles.module.css +++ b/src/components/sidebar/SubaccountsButton/styles.module.css @@ -1,10 +1,12 @@ .badge :global .MuiBadge-badge { - border: 2px solid var(--color-background-paper); + border: 1px solid var(--color-background-main); border-radius: 50%; box-sizing: content-box; - right: 10px; + right: 12px; top: 8px; - background-color: var(--color-success-light); + background-color: var(--color-secondary-main); + height: 6px; + min-width: 6px; } .count { diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index 0b52d39a2a..64c95809fd 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -251,7 +251,7 @@ function AssetInputs({ name }: { name: SetupSubaccountFormFields.assets }) { sx={{ my: 3 }} disabled={nonSelectedAssets.length === 0} > - Add asset + Fund new asset ) From 23157851432133faf4beb42ed779e61d9044c1b1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 29 Nov 2024 15:28:32 +0100 Subject: [PATCH 20/24] Set pointer on show all --- src/components/sidebar/SubaccountsList/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx index c6425d2a0b..29b9525a0e 100644 --- a/src/components/sidebar/SubaccountsList/index.tsx +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -31,6 +31,7 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) sx={{ textTransform: 'uppercase', fontWeight: 700, + cursor: 'pointer', }} onClick={onShowAll} > From 00fe4abe2b803a99ae6cd334747660282d9c0e18 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 29 Nov 2024 15:34:20 +0100 Subject: [PATCH 21/24] Make Subaccount a link --- .../sidebar/SubaccountsList/index.tsx | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/components/sidebar/SubaccountsList/index.tsx b/src/components/sidebar/SubaccountsList/index.tsx index 29b9525a0e..16c2d3ec86 100644 --- a/src/components/sidebar/SubaccountsList/index.tsx +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -7,6 +7,9 @@ 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 @@ -45,6 +48,7 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) } function SubaccountListItem({ subaccount }: { subaccount: string }): ReactElement { + const chain = useCurrentChain() const addressBook = useAddressBook() const name = addressBook[subaccount] @@ -61,26 +65,37 @@ function SubaccountListItem({ subaccount }: { subaccount: string }): ReactElemen p: 0, }} > - - - - - - - + + + + + + + + + ) } From 6ef1bca7837525da9395f4e2f3273c1074464ae7 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 2 Dec 2024 10:57:19 +0100 Subject: [PATCH 22/24] Close modal when navigating to Subaccount and fix colour in dark mode --- .../sidebar/SubaccountsButton/styles.module.css | 1 + src/components/sidebar/SubaccountsList/index.tsx | 16 ++++++++++++---- .../sidebar/SubaccountsPopover/index.tsx | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/sidebar/SubaccountsButton/styles.module.css b/src/components/sidebar/SubaccountsButton/styles.module.css index d0254216ea..e7f1cb4f90 100644 --- a/src/components/sidebar/SubaccountsButton/styles.module.css +++ b/src/components/sidebar/SubaccountsButton/styles.module.css @@ -18,4 +18,5 @@ 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 index 16c2d3ec86..867745af04 100644 --- a/src/components/sidebar/SubaccountsList/index.tsx +++ b/src/components/sidebar/SubaccountsList/index.tsx @@ -13,7 +13,13 @@ import { useCurrentChain } from '@/hooks/useChains' const MAX_SUBACCOUNTS = 5 -export function SubaccountsList({ subaccounts }: { subaccounts: Array }): ReactElement { +export function SubaccountsList({ + onClose, + subaccounts, +}: { + onClose: () => void + subaccounts: Array +}): ReactElement { const [showAll, setShowAll] = useState(false) const subaccountsToShow = showAll ? subaccounts : subaccounts.slice(0, MAX_SUBACCOUNTS) @@ -24,7 +30,7 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) return ( {subaccountsToShow.map((subaccount) => { - return + return })} {subaccounts.length > MAX_SUBACCOUNTS && !showAll && ( @@ -47,14 +53,16 @@ export function SubaccountsList({ subaccounts }: { subaccounts: Array }) ) } -function SubaccountListItem({ subaccount }: { subaccount: string }): ReactElement { +function SubaccountListItem({ onClose, subaccount }: { onClose: () => void; subaccount: string }): ReactElement { const chain = useCurrentChain() const addressBook = useAddressBook() const name = addressBook[subaccount] - // Note: using the Track element breaks accessibility/styles const onClick = () => { + // Note: using the Track element breaks accessibility/styles trackEvent({ ...SUBACCOUNT_EVENTS.OPEN_SUBACCOUNT, label: SUBACCOUNT_LABELS.list }) + + onClose() } return ( diff --git a/src/components/sidebar/SubaccountsPopover/index.tsx b/src/components/sidebar/SubaccountsPopover/index.tsx index 317f25937a..0d6b229534 100644 --- a/src/components/sidebar/SubaccountsPopover/index.tsx +++ b/src/components/sidebar/SubaccountsPopover/index.tsx @@ -62,7 +62,7 @@ export function SubaccountsPopover({ ) : ( - + )} From 322f47db1f5f9520d04fc28960d7e2322acb1eac Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 2 Dec 2024 12:16:36 +0100 Subject: [PATCH 23/24] Use `Date.now` for `saltNonce` --- .../tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx index f63c8ad803..6f8b0e5553 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/ReviewSubaccount.tsx @@ -49,7 +49,7 @@ export function ReviewSubaccount({ params }: { params: SetupSubaccountForm }) { }, chain, ) - const saltNonce = subaccounts.safes.length.toString() + const saltNonce = Date.now().toString() return { ...undeployedSafe, From cc3c8940b7bfbb34e7f80dba563ecfbc9f0a341f Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 2 Dec 2024 12:23:30 +0100 Subject: [PATCH 24/24] Add fallback name --- .../tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx index 64c95809fd..f2851e068d 100644 --- a/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx +++ b/src/components/tx-flow/flows/CreateSubaccount/SetupSubaccount.tsx @@ -29,6 +29,7 @@ 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' @@ -57,7 +58,8 @@ export function SetUpSubaccount({ }) { const addressBook = useAddressBook() const safeAddress = useSafeAddress() - const fallbackName = `${addressBook[safeAddress]} Subaccount` + const randomName = useMnemonicSafeName() + const fallbackName = `${addressBook[safeAddress] ?? randomName} Subaccount` const formMethods = useForm({ defaultValues: params,