diff --git a/package.json b/package.json index d1a993ca7d..987eb7b4c3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@safe-global/protocol-kit": "^4.1.1", "@safe-global/safe-apps-sdk": "^9.1.0", "@safe-global/safe-deployments": "1.37.12", - "@safe-global/safe-client-gateway-sdk": "v1.60.1", + "@safe-global/safe-client-gateway-sdk": "1.60.1-next-069fa2b", "@safe-global/safe-gateway-typescript-sdk": "3.22.3-beta.15", "@safe-global/safe-modules-deployments": "^2.2.1", "@sentry/react": "^7.91.0", diff --git a/src/components/common/AddressInput/styles.module.css b/src/components/common/AddressInput/styles.module.css index 7e95c6959d..bd88bff8cd 100644 --- a/src/components/common/AddressInput/styles.module.css +++ b/src/components/common/AddressInput/styles.module.css @@ -16,5 +16,4 @@ .readOnly :global .MuiInputBase-input { visibility: hidden; - position: absolute; } diff --git a/src/components/common/CheckWallet/index.test.tsx b/src/components/common/CheckWallet/index.test.tsx index 8455fa4c93..7058f79761 100644 --- a/src/components/common/CheckWallet/index.test.tsx +++ b/src/components/common/CheckWallet/index.test.tsx @@ -5,7 +5,7 @@ import useIsSafeOwner from '@/hooks/useIsSafeOwner' import useIsWrongChain from '@/hooks/useIsWrongChain' import useWallet from '@/hooks/wallets/useWallet' import { chainBuilder } from '@/tests/builders/chains' -import { useIsWalletDelegate } from '@/hooks/useDelegates' +import { useIsWalletProposer } from '@/hooks/useProposers' import { faker } from '@faker-js/faker' import { extendedSafeInfoBuilder } from '@/tests/builders/safe' import useSafeInfo from '@/hooks/useSafeInfo' @@ -42,9 +42,9 @@ jest.mock('@/hooks/useIsWrongChain', () => ({ default: jest.fn(() => false), })) -jest.mock('@/hooks/useDelegates', () => ({ +jest.mock('@/hooks/useProposers', () => ({ __esModule: true, - useIsWalletDelegate: jest.fn(() => false), + useIsWalletProposer: jest.fn(() => false), })) jest.mock('@/hooks/useSafeInfo', () => ({ @@ -125,15 +125,37 @@ describe('CheckWallet', () => { expect(allowContainer.querySelector('button')).not.toBeDisabled() }) - it('should not disable the button for delegates', () => { + it('should not disable the button for proposers', () => { ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) - ;(useIsWalletDelegate as jest.MockedFunction).mockReturnValueOnce(true) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) const { container } = renderButton() expect(container.querySelector('button')).not.toBeDisabled() }) + it('should disable the button for proposers if specified via flag', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(false) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).toBeDisabled() + }) + + it('should not disable the button for proposers that are also owners', () => { + ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) + ;(useIsWalletProposer as jest.MockedFunction).mockReturnValueOnce(true) + + const { getByText } = render( + {(isOk) => }, + ) + + expect(getByText('Continue')).not.toBeDisabled() + }) + it('should disable the button for counterfactual Safes', () => { ;(useIsSafeOwner as jest.MockedFunction).mockReturnValueOnce(true) diff --git a/src/components/common/CheckWallet/index.tsx b/src/components/common/CheckWallet/index.tsx index 65d6e2ab21..93296deb67 100644 --- a/src/components/common/CheckWallet/index.tsx +++ b/src/components/common/CheckWallet/index.tsx @@ -1,4 +1,4 @@ -import { useIsWalletDelegate } from '@/hooks/useDelegates' +import { useIsWalletProposer } from '@/hooks/useProposers' import { useMemo, type ReactElement } from 'react' import useIsOnlySpendingLimitBeneficiary from '@/hooks/useIsOnlySpendingLimitBeneficiary' import useIsSafeOwner from '@/hooks/useIsSafeOwner' @@ -15,6 +15,7 @@ type CheckWalletProps = { noTooltip?: boolean checkNetwork?: boolean allowUndeployedSafe?: boolean + allowProposer?: boolean } enum Message { @@ -30,13 +31,14 @@ const CheckWallet = ({ noTooltip, checkNetwork = false, allowUndeployedSafe = false, + allowProposer = true, }: CheckWalletProps): ReactElement => { const wallet = useWallet() const isSafeOwner = useIsSafeOwner() const isOnlySpendingLimit = useIsOnlySpendingLimitBeneficiary() const connectWallet = useConnectWallet() const isWrongChain = useIsWrongChain() - const isDelegate = useIsWalletDelegate() + const isProposer = useIsWalletProposer() const { safe } = useSafeInfo() @@ -46,18 +48,24 @@ const CheckWallet = ({ if (!wallet) { return Message.WalletNotConnected } + if (isUndeployedSafe && !allowUndeployedSafe) { return Message.SafeNotActivated } - if (!allowNonOwner && !isSafeOwner && !isDelegate && (!isOnlySpendingLimit || !allowSpendingLimit)) { + if (!allowNonOwner && !isSafeOwner && !isProposer && (!isOnlySpendingLimit || !allowSpendingLimit)) { + return Message.NotSafeOwner + } + + if (!allowProposer && isProposer && !isSafeOwner) { return Message.NotSafeOwner } }, [ allowNonOwner, + allowProposer, allowSpendingLimit, allowUndeployedSafe, - isDelegate, + isProposer, isOnlySpendingLimit, isSafeOwner, isUndeployedSafe, diff --git a/src/components/common/Header/index.test.tsx b/src/components/common/Header/index.test.tsx new file mode 100644 index 0000000000..d5ee8fca72 --- /dev/null +++ b/src/components/common/Header/index.test.tsx @@ -0,0 +1,129 @@ +import Header from '@/components/common/Header/index' +import * as useChains from '@/hooks/useChains' +import * as useIsSafeOwner from '@/hooks/useIsSafeOwner' +import * as useProposers from '@/hooks/useProposers' +import * as useSafeAddress from '@/hooks/useSafeAddress' +import * as useSafeTokenEnabled from '@/hooks/useSafeTokenEnabled' +import { render } from '@/tests/test-utils' +import { faker } from '@faker-js/faker' +import { screen, fireEvent } from '@testing-library/react' + +jest.mock( + '@/components/common/SafeTokenWidget', + () => + function SafeTokenWidget() { + return
SafeTokenWidget
+ }, +) + +jest.mock( + '@/features/walletconnect/components', + () => + function WalletConnect() { + return
WalletConnect
+ }, +) + +jest.mock( + '@/components/common/NetworkSelector', + () => + function NetworkSelector() { + return
NetworkSelector
+ }, +) + +describe('Header', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('renders the menu button when onMenuToggle is provided', () => { + render(
) + expect(screen.getByLabelText('menu')).toBeInTheDocument() + }) + + it('does not render the menu button when onMenuToggle is not provided', () => { + render(
) + expect(screen.queryByLabelText('menu')).not.toBeInTheDocument() + }) + + it('calls onMenuToggle when menu button is clicked', () => { + const onMenuToggle = jest.fn() + render(
) + + const menuButton = screen.getByLabelText('menu') + fireEvent.click(menuButton) + + expect(onMenuToggle).toHaveBeenCalled() + }) + + it('renders the SafeTokenWidget when showSafeToken is true', () => { + jest.spyOn(useSafeTokenEnabled, 'useSafeTokenEnabled').mockReturnValue(true) + + render(
) + expect(screen.getByText('SafeTokenWidget')).toBeInTheDocument() + }) + + it('does not render the SafeTokenWidget when showSafeToken is false', () => { + jest.spyOn(useSafeTokenEnabled, 'useSafeTokenEnabled').mockReturnValue(false) + + render(
) + expect(screen.queryByText('SafeTokenWidget')).not.toBeInTheDocument() + }) + + it('displays the safe logo', () => { + render(
) + expect(screen.getAllByAltText('Safe logo')[0]).toBeInTheDocument() + }) + + it('renders the BatchIndicator when showBatchButton is true', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue(faker.finance.ethereumAddress()) + jest.spyOn(useProposers, 'useIsWalletProposer').mockReturnValue(false) + jest.spyOn(useIsSafeOwner, 'default').mockReturnValue(false) + + render(
) + expect(screen.getByTitle('Batch')).toBeInTheDocument() + }) + + it('does not render the BatchIndicator when there is no safe address', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render(
) + expect(screen.queryByTitle('Batch')).not.toBeInTheDocument() + }) + + it('does not render the BatchIndicator when connected wallet is a proposer', () => { + jest.spyOn(useProposers, 'useIsWalletProposer').mockReturnValue(true) + + render(
) + expect(screen.queryByTitle('Batch')).not.toBeInTheDocument() + }) + + it('renders the WalletConnect component when enableWc is true', () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(true) + + render(
) + expect(screen.getByText('WalletConnect')).toBeInTheDocument() + }) + + it('does not render the WalletConnect component when enableWc is false', () => { + jest.spyOn(useChains, 'useHasFeature').mockReturnValue(false) + + render(
) + expect(screen.queryByText('WalletConnect')).not.toBeInTheDocument() + }) + + it('renders the NetworkSelector when safeAddress exists', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue(faker.finance.ethereumAddress()) + + render(
) + expect(screen.getByText('NetworkSelector')).toBeInTheDocument() + }) + + it('does not render the NetworkSelector when safeAddress is falsy', () => { + jest.spyOn(useSafeAddress, 'default').mockReturnValue('') + + render(
) + expect(screen.queryByText('NetworkSelector')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/common/Header/index.tsx b/src/components/common/Header/index.tsx index 5d7cdbf2ff..215ef45aa6 100644 --- a/src/components/common/Header/index.tsx +++ b/src/components/common/Header/index.tsx @@ -1,3 +1,5 @@ +import useIsSafeOwner from '@/hooks/useIsSafeOwner' +import { useIsWalletProposer } from '@/hooks/useProposers' import type { Dispatch, SetStateAction } from 'react' import { type ReactElement } from 'react' import { useRouter } from 'next/router' @@ -39,6 +41,8 @@ function getLogoLink(router: ReturnType): Url { const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { const safeAddress = useSafeAddress() const showSafeToken = useSafeTokenEnabled() + const isProposer = useIsWalletProposer() + const isSafeOwner = useIsSafeOwner() const router = useRouter() const enableWc = useHasFeature(FEATURES.NATIVE_WALLETCONNECT) @@ -59,6 +63,8 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => { } } + const showBatchButton = safeAddress && (!isProposer || isSafeOwner) + return (
@@ -91,7 +97,7 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
- {safeAddress && ( + {showBatchButton && (
diff --git a/src/components/settings/DelegatesList/index.tsx b/src/components/settings/DelegatesList/index.tsx deleted file mode 100644 index 9aa65bb02c..0000000000 --- a/src/components/settings/DelegatesList/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import useDelegates from '@/hooks/useDelegates' -import { Box, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' -import EthHashInfo from '@/components/common/EthHashInfo' -import InfoIcon from '@/public/images/notifications/info.svg' -import ExternalLink from '@/components/common/ExternalLink' -import { HelpCenterArticle } from '@/config/constants' - -const DelegatesList = () => { - const delegates = useDelegates() - - if (!delegates.data?.results) return null - - return ( - - - - - - - What are delegated accounts?{' '} - Learn more - - } - > - - Delegated accounts - - - - - - - -
    - {delegates.data.results.map((item) => ( -
  • - -
  • - ))} -
-
-
-
-
- ) -} - -export default DelegatesList diff --git a/src/components/settings/ProposersList/index.tsx b/src/components/settings/ProposersList/index.tsx new file mode 100644 index 0000000000..ad43226dda --- /dev/null +++ b/src/components/settings/ProposersList/index.tsx @@ -0,0 +1,134 @@ +import CheckWallet from '@/components/common/CheckWallet' +import { Chip } from '@/components/common/Chip' +import EnhancedTable from '@/components/common/EnhancedTable' +import tableCss from '@/components/common/EnhancedTable/styles.module.css' +import Track from '@/components/common/Track' +import UpsertProposer from '@/features/proposers/components/UpsertProposer' +import DeleteProposerDialog from '@/features/proposers/components/DeleteProposerDialog' +import EditProposerDialog from '@/features/proposers/components/EditProposerDialog' +import { useHasFeature } from '@/hooks/useChains' +import useProposers from '@/hooks/useProposers' +import AddIcon from '@/public/images/common/add.svg' +import { SETTINGS_EVENTS } from '@/services/analytics' +import { FEATURES } from '@/utils/chains' +import { Box, Button, Grid, Paper, SvgIcon, Typography } from '@mui/material' +import EthHashInfo from '@/components/common/EthHashInfo' +import ExternalLink from '@/components/common/ExternalLink' +import { HelpCenterArticle } from '@/config/constants' +import React, { useMemo, useState } from 'react' + +const headCells = [ + { + id: 'proposer', + label: 'Proposer', + }, + { + id: 'creator', + label: 'Creator', + }, + { + id: 'Actions', + label: '', + }, +] + +const ProposersList = () => { + const [isAddDialogOpen, setIsAddDialogOpen] = useState() + const proposers = useProposers() + const isEnabled = useHasFeature(FEATURES.PROPOSERS) + + const rows = useMemo(() => { + if (!proposers.data) return [] + + return proposers.data.results.map((proposer) => { + return { + cells: { + proposer: { + rawValue: proposer.delegate, + content: ( + + ), + }, + + creator: { + rawValue: proposer.delegator, + content: , + }, + actions: { + rawValue: '', + sticky: true, + content: isEnabled && ( +
+ + +
+ ), + }, + }, + } + }) + }, [isEnabled, proposers.data]) + + if (!proposers.data?.results) return null + + const onAdd = () => { + setIsAddDialogOpen(true) + } + + return ( + + + + + + + + + + Proposers + + + Proposers can suggest transactions but cannot approve or execute them. Signers should review and approve + transactions first. Learn more + + + {isEnabled && ( + + + {(isOk) => ( + + + + )} + + + )} + + {rows.length > 0 && } + + + {isAddDialogOpen && ( + setIsAddDialogOpen(false)} onSuccess={() => setIsAddDialogOpen(false)} /> + )} + + + + ) +} + +export default ProposersList diff --git a/src/components/settings/RequiredConfirmations/index.tsx b/src/components/settings/RequiredConfirmations/index.tsx index 2ccc9fc094..9b6df6aa5a 100644 --- a/src/components/settings/RequiredConfirmations/index.tsx +++ b/src/components/settings/RequiredConfirmations/index.tsx @@ -29,7 +29,12 @@ export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; {(isOk) => ( - diff --git a/src/components/settings/SpendingLimits/index.tsx b/src/components/settings/SpendingLimits/index.tsx index 7a5f38c05e..77c779a2d7 100644 --- a/src/components/settings/SpendingLimits/index.tsx +++ b/src/components/settings/SpendingLimits/index.tsx @@ -44,6 +44,7 @@ const SpendingLimits = () => { sx={{ mt: 2 }} variant="contained" disabled={!isOk} + size="small" > New spending limit diff --git a/src/components/settings/owner/OwnerList/index.tsx b/src/components/settings/owner/OwnerList/index.tsx index 0b8df0aae5..d7c4871506 100644 --- a/src/components/settings/owner/OwnerList/index.tsx +++ b/src/components/settings/owner/OwnerList/index.tsx @@ -19,11 +19,6 @@ import type { AddressBook } from '@/store/addressBookSlice' import tableCss from '@/components/common/EnhancedTable/styles.module.css' -const headCells = [ - { id: 'owner', label: 'Name' }, - { id: 'actions', label: '', sticky: true }, -] - export const OwnerList = () => { const addressBook = useAddressBook() const { safe } = useSafeInfo() @@ -99,19 +94,20 @@ export const OwnerList = () => { - Manage Safe Account signers + Members + + Signers + - Add, remove and replace or rename existing signers. Signer names are only stored locally and will never be - shared with us or any third parties. + Signers have full control over the account, they can propose, sign and execute transactions, as well as + reject them. - - - + {(isOk) => ( @@ -121,17 +117,20 @@ export const OwnerList = () => { variant="text" startIcon={} disabled={!isOk} + size="compact" > - Add new signer + Add signer )} - + + diff --git a/src/components/theme/safeTheme.ts b/src/components/theme/safeTheme.ts index e9108c3258..9c43de5545 100644 --- a/src/components/theme/safeTheme.ts +++ b/src/components/theme/safeTheme.ts @@ -49,6 +49,7 @@ declare module '@mui/material/SvgIcon' { declare module '@mui/material/Button' { export interface ButtonPropsSizeOverrides { stretched: true + compact: true } export interface ButtonPropsColorOverrides { @@ -100,6 +101,12 @@ const createSafeTheme = (mode: PaletteMode): Theme => { }, MuiButton: { variants: [ + { + props: { size: 'compact' }, + style: { + padding: '8px 12px', + }, + }, { props: { size: 'stretched' }, style: { @@ -299,7 +306,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.error.background, }, - border: `1px solid ${theme.palette.error.main}`, }), standardInfo: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -308,7 +314,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.info.background, }, - border: `1px solid ${theme.palette.info.main}`, }), standardSuccess: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -317,7 +322,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.success.background, }, - border: `1px solid ${theme.palette.success.main}`, }), standardWarning: ({ theme }) => ({ '& .MuiAlert-icon': { @@ -326,7 +330,6 @@ const createSafeTheme = (mode: PaletteMode): Theme => { '&.MuiPaper-root': { backgroundColor: theme.palette.warning.background, }, - border: `1px solid ${theme.palette.warning.main}`, }), root: ({ theme }) => ({ color: theme.palette.text.primary, diff --git a/src/components/transactions/SignTxButton/index.tsx b/src/components/transactions/SignTxButton/index.tsx index 76298771c5..f1fbdeb8a7 100644 --- a/src/components/transactions/SignTxButton/index.tsx +++ b/src/components/transactions/SignTxButton/index.tsx @@ -1,4 +1,5 @@ import useIsExpiredSwap from '@/features/swap/hooks/useIsExpiredSwap' +import useIsSafeOwner from '@/hooks/useIsSafeOwner' import type { SyntheticEvent } from 'react' import { useContext, type ReactElement } from 'react' import { type TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' @@ -22,6 +23,7 @@ const SignTxButton = ({ }): ReactElement => { const { setTxFlow } = useContext(TxModalContext) const wallet = useWallet() + const isSafeOwner = useIsSafeOwner() const isSignable = isSignableBy(txSummary, wallet?.address || '') const safeSDK = useSafeSDK() const expiredSwap = useIsExpiredSwap(txSummary.txInfo) @@ -36,7 +38,7 @@ const SignTxButton = ({ return ( {(isOk) => ( - + + + + {(isOk) => ( + + )} + + + + + ) +} + +const DeleteProposerDialog = madProps(_DeleteProposer, { + wallet: useWallet, + chainId: useChainId, + safeAddress: useSafeAddress, +}) + +export default DeleteProposerDialog diff --git a/src/features/proposers/components/EditProposerDialog.tsx b/src/features/proposers/components/EditProposerDialog.tsx new file mode 100644 index 0000000000..9450125237 --- /dev/null +++ b/src/features/proposers/components/EditProposerDialog.tsx @@ -0,0 +1,46 @@ +import CheckWallet from '@/components/common/CheckWallet' +import Track from '@/components/common/Track' +import UpsertProposer from '@/features/proposers/components/UpsertProposer' +import useWallet from '@/hooks/wallets/useWallet' +import EditIcon from '@/public/images/common/edit.svg' +import { SETTINGS_EVENTS } from '@/services/analytics' +import { IconButton, SvgIcon, Tooltip } from '@mui/material' +import type { Delegate } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' +import React, { useState } from 'react' + +const EditProposerDialog = ({ proposer }: { proposer: Delegate }) => { + const [open, setOpen] = useState(false) + const wallet = useWallet() + + const canEdit = wallet?.address === proposer.delegator + + return ( + <> + + {(isOk) => ( + + + + setOpen(true)} size="small" disabled={!isOk || !canEdit}> + + + + + + )} + + + {open && setOpen(false)} onSuccess={() => setOpen(false)} proposer={proposer} />} + + ) +} + +export default EditProposerDialog diff --git a/src/features/proposers/components/TxProposalChip.tsx b/src/features/proposers/components/TxProposalChip.tsx new file mode 100644 index 0000000000..ee77076164 --- /dev/null +++ b/src/features/proposers/components/TxProposalChip.tsx @@ -0,0 +1,32 @@ +import { Chip, SvgIcon, Tooltip, Typography } from '@mui/material' +import InfoIcon from '@/public/images/notifications/info.svg' + +const TxProposalChip = () => { + return ( + + + + + + Proposal + + + } + /> + + + ) +} + +export default TxProposalChip diff --git a/src/features/proposers/components/UpsertProposer.tsx b/src/features/proposers/components/UpsertProposer.tsx new file mode 100644 index 0000000000..975135bd5d --- /dev/null +++ b/src/features/proposers/components/UpsertProposer.tsx @@ -0,0 +1,223 @@ +import AddressBookInput from '@/components/common/AddressBookInput' +import CheckWallet from '@/components/common/CheckWallet' +import EthHashInfo from '@/components/common/EthHashInfo' +import NameInput from '@/components/common/NameInput' +import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import ErrorMessage from '@/components/tx/ErrorMessage' +import { signProposerData, signProposerTypedData } from '@/features/proposers/utils/utils' +import useChainId from '@/hooks/useChainId' +import useSafeAddress from '@/hooks/useSafeAddress' +import useWallet from '@/hooks/wallets/useWallet' +import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics' +import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk' +import { useAppDispatch } from '@/store' +import { useAddProposerMutation } from '@/store/api/gateway' +import { showNotification } from '@/store/notificationsSlice' +import { shortenAddress } from '@/utils/formatters' +import { addressIsNotCurrentSafe } from '@/utils/validation' +import { isHardwareWallet } from '@/utils/wallets' +import { Close } from '@mui/icons-material' +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Typography, +} from '@mui/material' +import type { Delegate } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates' +import { type BaseSyntheticEvent, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' + +type UpsertProposerProps = { + onClose: () => void + onSuccess: () => void + proposer?: Delegate +} + +enum ProposerEntryFields { + address = 'address', + name = 'name', +} + +type ProposerEntry = { + [ProposerEntryFields.name]: string + [ProposerEntryFields.address]: string +} + +const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) => { + const [error, setError] = useState() + const [isLoading, setIsLoading] = useState(false) + const [addProposer] = useAddProposerMutation() + const dispatch = useAppDispatch() + + const chainId = useChainId() + const wallet = useWallet() + const safeAddress = useSafeAddress() + + const methods = useForm({ + defaultValues: { + [ProposerEntryFields.address]: proposer?.delegate, + [ProposerEntryFields.name]: proposer?.label, + }, + mode: 'onChange', + }) + + const notCurrentSafe = addressIsNotCurrentSafe(safeAddress, 'Cannot add Safe Account itself as proposer') + + const { handleSubmit, formState } = methods + + const onConfirm = handleSubmit(async (data: ProposerEntry) => { + if (!wallet) return + + setError(undefined) + setIsLoading(true) + + try { + const hardwareWallet = isHardwareWallet(wallet) + const signer = await getAssertedChainSigner(wallet.provider) + const signature = hardwareWallet + ? await signProposerData(data.address, signer) + : await signProposerTypedData(chainId, data.address, signer) + + await addProposer({ + chainId, + delegator: wallet.address, + signature, + label: data.name, + delegate: data.address, + safeAddress, + isHardwareWallet: hardwareWallet, + }) + + trackEvent( + isEditing ? SETTINGS_EVENTS.PROPOSERS.SUBMIT_EDIT_PROPOSER : SETTINGS_EVENTS.PROPOSERS.SUBMIT_ADD_PROPOSER, + ) + + dispatch( + showNotification({ + variant: 'success', + groupKey: 'add-proposer-success', + title: 'Proposer added successfully!', + message: `${shortenAddress(data.address)} can now suggest transactions for this account.`, + }), + ) + } catch (error) { + setIsLoading(false) + setError(error as Error) + return + } + + setIsLoading(false) + onSuccess() + }) + + const onSubmit = (e: BaseSyntheticEvent) => { + e.stopPropagation() + onConfirm(e) + } + + const onCancel = () => { + trackEvent( + isEditing ? SETTINGS_EVENTS.PROPOSERS.CANCEL_EDIT_PROPOSER : SETTINGS_EVENTS.PROPOSERS.CANCEL_ADD_PROPOSER, + ) + onClose() + } + + const isEditing = !!proposer + const canEdit = wallet?.address === proposer?.delegator + + return ( + + + + + + + {isEditing ? 'Edit' : 'Add'} proposer + + + + + + + + + + + + + + + + You're about to grant this address the ability to propose transactions. To complete the setup, + confirm with a signature from your connected wallet. + + + + Proposer’s name and address are publicly visible. + + + {isEditing ? ( + + + + ) : ( + + )} + + + + + + + {error && ( + + Error adding proposer + + )} + + + + + + + + + + + {(isOk) => ( + + )} + + + + + + ) +} + +export default UpsertProposer diff --git a/src/features/proposers/utils/utils.ts b/src/features/proposers/utils/utils.ts new file mode 100644 index 0000000000..13afbb762e --- /dev/null +++ b/src/features/proposers/utils/utils.ts @@ -0,0 +1,51 @@ +import { signTypedData } from '@/utils/web3' +import { SigningMethod } from '@safe-global/protocol-kit' +import { adjustVInSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures' +import type { JsonRpcSigner } from 'ethers' + +const getProposerDataV2 = (chainId: string, proposerAddress: string) => { + const totp = Math.floor(Date.now() / 1000 / 3600) + + const domain = { + name: 'Safe Transaction Service', + version: '1.0', + chainId, + } + + const types = { + Delegate: [ + { name: 'delegateAddress', type: 'address' }, + { name: 'totp', type: 'uint256' }, + ], + } + + const message = { + delegateAddress: proposerAddress, + totp, + } + + return { + domain, + types, + message, + } +} + +export const signProposerTypedData = async (chainId: string, proposerAddress: string, signer: JsonRpcSigner) => { + const typedData = getProposerDataV2(chainId, proposerAddress) + return signTypedData(signer, typedData) +} + +const getProposerDataV1 = (proposerAddress: string) => { + const totp = Math.floor(Date.now() / 1000 / 3600) + + return `${proposerAddress}${totp}` +} + +export const signProposerData = async (proposerAddress: string, signer: JsonRpcSigner) => { + const data = getProposerDataV1(proposerAddress) + + const signature = await signer.signMessage(data) + + return adjustVInSignature(SigningMethod.ETH_SIGN_TYPED_DATA, signature) +} diff --git a/src/hooks/useDelegates.ts b/src/hooks/useDelegates.ts deleted file mode 100644 index c669ed93c0..0000000000 --- a/src/hooks/useDelegates.ts +++ /dev/null @@ -1,22 +0,0 @@ -import useSafeInfo from '@/hooks/useSafeInfo' -import useWallet from '@/hooks/wallets/useWallet' -import { useGetDelegatesQuery } from '@/store/api/gateway' -import { skipToken } from '@reduxjs/toolkit/query/react' - -const useDelegates = () => { - const { - safe: { chainId }, - safeAddress, - } = useSafeInfo() - - return useGetDelegatesQuery(chainId && safeAddress ? { chainId, safeAddress } : skipToken) -} - -export const useIsWalletDelegate = () => { - const wallet = useWallet() - const delegates = useDelegates() - - return delegates.data?.results.some((delegate) => delegate.delegate === wallet?.address) -} - -export default useDelegates diff --git a/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx b/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx index 544d865c34..a2b9a6517a 100644 --- a/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx +++ b/src/hooks/useIsOnlySpendingLimitBeneficiary.tsx @@ -1,3 +1,4 @@ +import { useIsWalletProposer } from '@/hooks/useProposers' import { FEATURES } from '@/utils/chains' import { useAppSelector } from '@/store' import { selectSpendingLimits } from '@/store/spendingLimitsSlice' @@ -10,8 +11,9 @@ const useIsOnlySpendingLimitBeneficiary = (): boolean => { const spendingLimits = useAppSelector(selectSpendingLimits) const wallet = useWallet() const isSafeOwner = useIsSafeOwner() + const isProposer = useIsWalletProposer() - if (isSafeOwner || !isEnabled || spendingLimits.length === 0) { + if (isSafeOwner || !isEnabled || spendingLimits.length === 0 || isProposer) { return false } diff --git a/src/hooks/useProposers.ts b/src/hooks/useProposers.ts new file mode 100644 index 0000000000..bd85674bb7 --- /dev/null +++ b/src/hooks/useProposers.ts @@ -0,0 +1,22 @@ +import useSafeInfo from '@/hooks/useSafeInfo' +import useWallet from '@/hooks/wallets/useWallet' +import { useGetProposersQuery } from '@/store/api/gateway' +import { skipToken } from '@reduxjs/toolkit/query/react' + +const useProposers = () => { + const { + safe: { chainId }, + safeAddress, + } = useSafeInfo() + + return useGetProposersQuery(chainId && safeAddress ? { chainId, safeAddress } : skipToken) +} + +export const useIsWalletProposer = () => { + const wallet = useWallet() + const proposers = useProposers() + + return proposers.data?.results.some((proposer) => proposer.delegate === wallet?.address) +} + +export default useProposers diff --git a/src/hooks/wallets/__tests__/useOnboard.test.ts b/src/hooks/wallets/__tests__/useOnboard.test.ts index a885e21afa..237ceeaf1b 100644 --- a/src/hooks/wallets/__tests__/useOnboard.test.ts +++ b/src/hooks/wallets/__tests__/useOnboard.test.ts @@ -51,7 +51,7 @@ describe('useOnboard', () => { chainId: '4', ens: 'test.eth', balance: '0.00235 ETH', - isDelegate: false, + isProposer: false, }) }) diff --git a/src/hooks/wallets/useOnboard.ts b/src/hooks/wallets/useOnboard.ts index 583976d5ee..b73596ffac 100644 --- a/src/hooks/wallets/useOnboard.ts +++ b/src/hooks/wallets/useOnboard.ts @@ -21,7 +21,7 @@ export type ConnectedWallet = { provider: Eip1193Provider icon?: string balance?: string - isDelegate?: boolean + isProposer?: boolean } const { getStore, setStore, useStore } = new ExternalStore() @@ -71,7 +71,7 @@ export const getConnectedWallet = (wallets: WalletState[]): ConnectedWallet | nu provider: primaryWallet.provider, icon: primaryWallet.icon, balance, - isDelegate: false, + isProposer: false, } } catch (e) { logError(Errors._106, e) diff --git a/src/pages/settings/setup.tsx b/src/pages/settings/setup.tsx index 92da73d9e2..1dad75fee9 100644 --- a/src/pages/settings/setup.tsx +++ b/src/pages/settings/setup.tsx @@ -7,7 +7,7 @@ import { OwnerList } from '@/components/settings/owner/OwnerList' import { RequiredConfirmation } from '@/components/settings/RequiredConfirmations' import useSafeInfo from '@/hooks/useSafeInfo' import SettingsHeader from '@/components/settings/SettingsHeader' -import DelegatesList from '@/components/settings/DelegatesList' +import ProposersList from 'src/components/settings/ProposersList' import SpendingLimits from '@/components/settings/SpendingLimits' const Setup: NextPage = () => { @@ -61,12 +61,12 @@ const Setup: NextPage = () => { + + - - ) diff --git a/src/services/analytics/events/settings.ts b/src/services/analytics/events/settings.ts index 0b1518ef16..5150ba40da 100644 --- a/src/services/analytics/events/settings.ts +++ b/src/services/analytics/events/settings.ts @@ -74,6 +74,44 @@ export const SETTINGS_EVENTS = { category: SETTINGS_CATEGORY, }, }, + PROPOSERS: { + ADD_PROPOSER: { + action: 'Add safe proposer', + category: SETTINGS_CATEGORY, + }, + REMOVE_PROPOSER: { + action: 'Remove safe proposer', + category: SETTINGS_CATEGORY, + }, + EDIT_PROPOSER: { + action: 'Edit safe proposer', + category: SETTINGS_CATEGORY, + }, + SUBMIT_ADD_PROPOSER: { + action: 'Submit add safe proposer', + category: SETTINGS_CATEGORY, + }, + SUBMIT_REMOVE_PROPOSER: { + action: 'Submit remove safe proposer', + category: SETTINGS_CATEGORY, + }, + SUBMIT_EDIT_PROPOSER: { + action: 'Submit edit safe proposer', + category: SETTINGS_CATEGORY, + }, + CANCEL_ADD_PROPOSER: { + action: 'Cancel add safe proposer', + category: SETTINGS_CATEGORY, + }, + CANCEL_REMOVE_PROPOSER: { + action: 'Cancel remove safe proposer', + category: SETTINGS_CATEGORY, + }, + CANCEL_EDIT_PROPOSER: { + action: 'Cancel edit safe proposer', + category: SETTINGS_CATEGORY, + }, + }, DATA: { IMPORT_ADDRESS_BOOK: { action: 'Imported address book via Import all', diff --git a/src/services/analytics/events/transactions.ts b/src/services/analytics/events/transactions.ts index 1a4a49ce34..839a8057a0 100644 --- a/src/services/analytics/events/transactions.ts +++ b/src/services/analytics/events/transactions.ts @@ -48,9 +48,9 @@ export const TX_EVENTS = { action: 'Create via spending limit', category: TX_CATEGORY, }, - CREATE_VIA_DELEGATE: { + CREATE_VIA_PROPOSER: { event: EventType.TX_CREATED, - action: 'Create via delegate', + action: 'Create via proposer', category: TX_CATEGORY, }, CONFIRM: { diff --git a/src/services/tx/tx-sender/dispatch.ts b/src/services/tx/tx-sender/dispatch.ts index 6da7ee20dc..a60ce9d11c 100644 --- a/src/services/tx/tx-sender/dispatch.ts +++ b/src/services/tx/tx-sender/dispatch.ts @@ -112,8 +112,8 @@ export const dispatchTxSigning = async ( return signedTx } -// We have to manually sign because sdk.signTransaction doesn't support delegates -export const dispatchDelegateTxSigning = async (safeTx: SafeTransaction, wallet: ConnectedWallet) => { +// We have to manually sign because sdk.signTransaction doesn't support proposers +export const dispatchProposerTxSigning = async (safeTx: SafeTransaction, wallet: ConnectedWallet) => { const sdk = await getSafeSDKWithSigner(wallet.provider) let signature: SafeSignature diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts index 82f8241c70..8c8893b150 100644 --- a/src/store/api/gateway/index.ts +++ b/src/store/api/gateway/index.ts @@ -1,13 +1,12 @@ +import { proposerEndpoints } from '@/store/api/gateway/proposers' import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react' import { getTransactionDetails, type TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk' 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 { createSubmission, getSubmission } from '@safe-global/safe-client-gateway-sdk' -async function buildQueryFn(fn: () => Promise) { +export async function buildQueryFn(fn: () => Promise) { try { return { data: await fn() } } catch (error) { @@ -30,11 +29,6 @@ export const gatewayApi = createApi({ return buildQueryFn(() => Promise.all(txIds.map((txId) => getTransactionDetails(chainId, txId)))) }, }), - getDelegates: builder.query({ - queryFn({ chainId, safeAddress }) { - return buildQueryFn(() => getDelegates(chainId, { safe: safeAddress })) - }, - }), getSubmission: builder.query< getSubmission, { outreachId: number; chainId: string; safeAddress: string; signerAddress: string } @@ -62,6 +56,7 @@ export const gatewayApi = createApi({ }, invalidatesTags: ['Submissions'], }), + ...proposerEndpoints(builder), ...safeOverviewEndpoints(builder), }), }) @@ -70,7 +65,9 @@ export const { useGetTransactionDetailsQuery, useGetMultipleTransactionDetailsQuery, useLazyGetTransactionDetailsQuery, - useGetDelegatesQuery, + useGetProposersQuery, + useDeleteProposerMutation, + useAddProposerMutation, useGetSubmissionQuery, useCreateSubmissionMutation, useGetSafeOverviewQuery, diff --git a/src/store/api/gateway/proposers.ts b/src/store/api/gateway/proposers.ts new file mode 100644 index 0000000000..fab8833fc7 --- /dev/null +++ b/src/store/api/gateway/proposers.ts @@ -0,0 +1,100 @@ +import { buildQueryFn, gatewayApi } from '@/store/api/gateway/index' +import { type fakeBaseQuery } from '@reduxjs/toolkit/dist/query/react' +import type { EndpointBuilder } from '@reduxjs/toolkit/dist/query/react' +import { deleteDelegate, deleteDelegateV2, postDelegate, postDelegateV2 } from '@safe-global/safe-client-gateway-sdk' +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'>, +) => ({ + getProposers: builder.query({ + queryFn({ chainId, safeAddress }) { + return buildQueryFn(() => getDelegates(chainId, { safe: safeAddress })) + }, + }), + deleteProposer: builder.mutation< + void, + { + chainId: string + safeAddress: string + delegateAddress: string + delegator: string + signature: string + isHardwareWallet: boolean + } + >({ + queryFn({ chainId, safeAddress, delegateAddress, delegator, signature, isHardwareWallet }) { + const options = { + params: { path: { chainId, delegateAddress } }, + body: { safe: safeAddress, signature, delegator }, + } + return buildQueryFn(() => + isHardwareWallet + ? deleteDelegate({ params: options.params, body: { ...options.body, delegate: delegateAddress } }) + : deleteDelegateV2(options), + ) + }, + // Optimistically update the cache and roll back in case the mutation fails + async onQueryStarted({ chainId, safeAddress, delegateAddress, delegator }, { dispatch, queryFulfilled }) { + const patchResult = dispatch( + gatewayApi.util.updateQueryData('getProposers', { chainId, safeAddress }, (draft) => { + draft.results = draft.results.filter( + (delegate: Delegate) => delegate.delegate !== delegateAddress || delegate.delegator !== delegator, + ) + }), + ) + try { + await queryFulfilled + } catch { + patchResult.undo() + } + }, + }), + addProposer: builder.mutation< + Delegate, + { + chainId: string + safeAddress: string + delegate: string + delegator: string + label: string + signature: string + isHardwareWallet: boolean + } + >({ + queryFn({ chainId, safeAddress, delegate, delegator, label, signature, isHardwareWallet }) { + const options = { + params: { path: { chainId } }, + body: { delegate, delegator, label, signature, safe: safeAddress }, + } + + return buildQueryFn(() => (isHardwareWallet ? postDelegate(options) : postDelegateV2(options))) + }, + // Optimistically update the cache and roll back in case the mutation fails + async onQueryStarted({ chainId, safeAddress, delegate, delegator, label }, { dispatch, queryFulfilled }) { + const patchResult = dispatch( + gatewayApi.util.updateQueryData('getProposers', { chainId, safeAddress }, (draft) => { + const existingProposer = draft.results.findIndex( + (proposer: Delegate) => proposer.delegate === delegate && delegator === proposer.delegator, + ) + + if (existingProposer !== -1) { + // Update the existing delegate's label + draft.results[existingProposer] = { + ...draft.results[existingProposer], + label, + } + } else { + draft.results.push({ delegate, delegator, label, safe: safeAddress }) + } + }), + ) + try { + await queryFulfilled + } catch { + patchResult.undo() + } + }, + }), +}) diff --git a/src/utils/chains.ts b/src/utils/chains.ts index 3aab4b0a7a..ec3786dd41 100644 --- a/src/utils/chains.ts +++ b/src/utils/chains.ts @@ -37,6 +37,7 @@ export enum FEATURES { STAKING_BANNER = 'STAKING_BANNER', MULTI_CHAIN_SAFE_CREATION = 'MULTI_CHAIN_SAFE_CREATION', MULTI_CHAIN_SAFE_ADD_NETWORK = 'MULTI_CHAIN_SAFE_ADD_NETWORK', + PROPOSERS = 'PROPOSERS', } export const FeatureRoutes = { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 174667ef75..f437838b2a 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -38,12 +38,19 @@ export const uniqueAddress = } export const addressIsNotCurrentSafe = - (safeAddress: string) => + (safeAddress: string, message?: string) => (address: string): string | undefined => { - const SIGNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = 'Cannot use Safe Account itself as signer.' + const SIGNER_ADDRESS_IS_SAFE_ADDRESS_ERROR = message || 'Cannot use Safe Account itself as signer.' return sameAddress(safeAddress, address) ? SIGNER_ADDRESS_IS_SAFE_ADDRESS_ERROR : undefined } +export const addressIsNotOwner = + (owners: string[], message?: string) => + (address: string): string | undefined => { + const ADDRESS_IS_OWNER_ERROR = message || 'Cannot use Owners.' + return owners.some((owner) => owner === address) ? ADDRESS_IS_OWNER_ERROR : undefined + } + export const FLOAT_REGEX = /^[0-9]+([,.][0-9]+)?$/ export const validateAmount = (amount?: string, includingZero: boolean = false) => { diff --git a/yarn.lock b/yarn.lock index ed4c495811..b1a1c6c5f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4195,10 +4195,10 @@ "@safe-global/safe-gateway-typescript-sdk" "^3.5.3" viem "^2.1.1" -"@safe-global/safe-client-gateway-sdk@v1.60.1": - version "1.60.1" - resolved "https://registry.yarnpkg.com/@safe-global/safe-client-gateway-sdk/-/safe-client-gateway-sdk-1.60.1.tgz#4f24a4c7f0ba04a82a2208bd14163cde46189661" - integrity sha512-3vdDOSXLlvx9B+bo15MPTRGPrUn5jJEvtvWF0esY1oFWfI9riQOttWIIyQGYgDZhxMd+qW0aYKNMpn8DTP+7rw== +"@safe-global/safe-client-gateway-sdk@1.60.1-next-069fa2b": + version "1.60.1-next-069fa2b" + resolved "https://registry.yarnpkg.com/@safe-global/safe-client-gateway-sdk/-/safe-client-gateway-sdk-1.60.1-next-069fa2b.tgz#5a4ac69661713dd08b33bbcc15fa7d870339ed0a" + integrity sha512-gS+AcYuX1L6l79q87TA/A6vS34wovUgypRKS8lXzo4nDbHE333i/yp7JoOAcxKXFlJDHJXHIbPRkOKoMjdylEA== dependencies: openapi-fetch "0.10.5"