- {wallet && !isWrongChain && !isConfirmed && (
+ {wallet && !isConfirmed && (
diff --git a/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx b/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx
new file mode 100644
index 00000000..3c73abc0
--- /dev/null
+++ b/src/components/safe-messages/MsgModal/ConfirmationDialog.tsx
@@ -0,0 +1,31 @@
+import { Dialog, DialogTitle, DialogContent, DialogContentText, Typography, DialogActions, Button } from '@mui/material'
+
+export const ConfirmationDialog = ({
+ open,
+ onCancel,
+ onClose,
+}: {
+ open: boolean
+ onCancel: () => void
+ onClose: () => void
+}) => (
+
+)
diff --git a/src/components/safe-messages/MsgModal/index.test.tsx b/src/components/safe-messages/MsgModal/index.test.tsx
index 71e3751b..d44c40b4 100644
--- a/src/components/safe-messages/MsgModal/index.test.tsx
+++ b/src/components/safe-messages/MsgModal/index.test.tsx
@@ -1,6 +1,6 @@
import { hexlify, hexZeroPad, toUtf8Bytes } from 'ethers/lib/utils'
-import { Web3Provider } from '@ethersproject/providers'
-import type { SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import type { ChainInfo, SafeInfo, SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import { SafeMessageListItemType } from '@safe-global/safe-gateway-typescript-sdk'
import MsgModal from '@/components/safe-messages/MsgModal'
import * as useIsWrongChainHook from '@/hooks/useIsWrongChain'
@@ -8,17 +8,64 @@ import * as useIsSafeOwnerHook from '@/hooks/useIsSafeOwner'
import * as useWalletHook from '@/hooks/wallets/useWallet'
import * as useSafeInfoHook from '@/hooks/useSafeInfo'
import * as useAsyncHook from '@/hooks/useAsync'
+import * as useChainsHook from '@/hooks/useChains'
+import * as useSafeMessages from '@/hooks/messages/useSafeMessages'
import * as sender from '@/services/safe-messages/safeMsgSender'
-import * as web3 from '@/hooks/wallets/web3'
+import * as onboard from '@/hooks/wallets/useOnboard'
import { render, act, fireEvent, waitFor } from '@/tests/test-utils'
import type { ConnectedWallet } from '@/hooks/wallets/useOnboard'
+import type { EIP1193Provider, WalletState, AppState, OnboardAPI } from '@web3-onboard/core'
+import { generateSafeMessageHash } from '@/utils/safe-messages'
jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({
...jest.requireActual('@safe-global/safe-gateway-typescript-sdk'),
getSafeMessage: jest.fn(),
}))
-const mockProvider: Web3Provider = new Web3Provider(jest.fn())
+let mockProvider = {
+ request: jest.fn,
+} as unknown as EIP1193Provider
+
+const mockOnboardState = {
+ chains: [],
+ walletModules: [],
+ wallets: [
+ {
+ label: 'Wallet 1',
+ icon: '',
+ provider: mockProvider,
+ chains: [{ id: '0x5' }],
+ accounts: [
+ {
+ address: '0x1234567890123456789012345678901234567890',
+ ens: null,
+ balance: null,
+ },
+ ],
+ },
+ ] as WalletState[],
+ accountCenter: {
+ enabled: true,
+ },
+} as unknown as AppState
+
+const mockOnboard = {
+ connectWallet: jest.fn(),
+ disconnectWallet: jest.fn(),
+ setChain: jest.fn(),
+ state: {
+ select: (key: keyof AppState) => ({
+ subscribe: (next: any) => {
+ next(mockOnboardState[key])
+
+ return {
+ unsubscribe: jest.fn(),
+ }
+ },
+ }),
+ get: () => mockOnboardState,
+ },
+} as unknown as OnboardAPI
describe('MsgModal', () => {
beforeEach(() => {
@@ -31,26 +78,15 @@ describe('MsgModal', () => {
value: hexZeroPad('0x1', 20),
},
chainId: '5',
+ threshold: 2,
} as SafeInfo,
safeAddress: hexZeroPad('0x1', 20),
safeError: undefined,
safeLoading: false,
safeLoaded: true,
}))
- })
-
- it('renders the message hash', () => {
- const { getByText } = render(
-
,
- )
- expect(getByText('0x123')).toBeInTheDocument()
+ jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
})
describe('EIP-191 messages', () => {
@@ -186,9 +222,8 @@ describe('MsgModal', () => {
})
it('proposes a message if not already proposed', async () => {
- jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true)
- jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider)
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
jest.spyOn(useAsyncHook, 'default').mockReturnValue([undefined, new Error('SafeMessage not found'), false])
@@ -219,39 +254,56 @@ describe('MsgModal', () => {
value: hexZeroPad('0x1', 20),
},
chainId: '5',
+ threshold: 2,
} as SafeInfo,
message: 'Hello world!',
- requestId: '123',
safeAppId: 25,
}),
)
})
it('confirms the message if already proposed', async () => {
- jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider)
- jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true)
jest.spyOn(useWalletHook, 'default').mockImplementation(
() =>
({
- address: hexZeroPad('0x2', 20),
+ address: hexZeroPad('0x3', 20),
} as ConnectedWallet),
)
- jest
- .spyOn(useAsyncHook, 'default')
- .mockReturnValue([
- { confirmations: [] as SafeMessage['confirmations'] } as SafeMessage,
- new Error('SafeMessage not found'),
- false,
- ])
+ const messageText = 'Hello world!'
+ const messageHash = generateSafeMessageHash(
+ {
+ version: '1.3.0',
+ address: {
+ value: hexZeroPad('0x1', 20),
+ },
+ chainId: '5',
+ } as SafeInfo,
+ messageText,
+ )
+ const msg = {
+ type: SafeMessageListItemType.MESSAGE,
+ messageHash,
+ confirmations: [
+ {
+ owner: {
+ value: hexZeroPad('0x2', 20),
+ },
+ },
+ ],
+ confirmationsRequired: 2,
+ confirmationsSubmitted: 1,
+ } as unknown as SafeMessage
+
+ jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg)
const { getByText } = render(
,
@@ -265,6 +317,8 @@ describe('MsgModal', () => {
const button = getByText('Sign')
+ expect(button).toBeEnabled()
+
await act(() => {
fireEvent.click(button)
})
@@ -277,15 +331,16 @@ describe('MsgModal', () => {
value: hexZeroPad('0x1', 20),
},
chainId: '5',
+ threshold: 2,
} as SafeInfo,
message: 'Hello world!',
- requestId: '123',
}),
)
})
- it('displays an error if connected to the wrong chain', () => {
- jest.spyOn(web3, 'useWeb3').mockReturnValue(undefined)
+ it('displays an error if no wallet is connected', () => {
+ jest.spyOn(useWalletHook, 'default').mockReturnValue(null)
+ jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false)
const { getByText } = render(
{
})
it('displays an error if connected to the wrong chain', () => {
- jest.spyOn(web3, 'useWeb3').mockReturnValue(mockProvider)
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
+ jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true)
jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => true)
+ jest.spyOn(useChainsHook, 'useCurrentChain').mockReturnValue({ chainName: 'Goerli' } as ChainInfo)
const { getByText } = render(
{
/>,
)
- expect(getByText('Your wallet is connected to the wrong chain.')).toBeInTheDocument()
+ expect(getByText('Wallet network switch')).toBeInTheDocument()
- expect(getByText('Sign')).toBeDisabled()
+ expect(getByText('Sign')).not.toBeDisabled()
})
it('displays an error if not an owner', () => {
- jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
+ jest.spyOn(useWalletHook, 'default').mockImplementation(
+ () =>
+ ({
+ address: hexZeroPad('0x7', 20),
+ } as ConnectedWallet),
+ )
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => false)
const { getByText } = render(
@@ -339,14 +402,14 @@ describe('MsgModal', () => {
)
expect(
- getByText("You are currently not an owner of this Safe and won't be able to confirm this message."),
+ getByText("You are currently not an owner of this Safe Account and won't be able to confirm this message."),
).toBeInTheDocument()
expect(getByText('Sign')).toBeDisabled()
})
- it('displays an error if the message has already been signed', async () => {
- jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
+ it('displays a success message if the message has already been signed', async () => {
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true)
jest.spyOn(useWalletHook, 'default').mockImplementation(
() =>
@@ -354,29 +417,40 @@ describe('MsgModal', () => {
address: hexZeroPad('0x2', 20),
} as ConnectedWallet),
)
-
- jest.spyOn(useAsyncHook, 'default').mockReturnValue([
+ const messageText = 'Hello world!'
+ const messageHash = generateSafeMessageHash(
{
- confirmations: [
- {
- owner: {
- value: hexZeroPad('0x2', 20),
- },
+ version: '1.3.0',
+ address: {
+ value: hexZeroPad('0x1', 20),
+ },
+ chainId: '5',
+ } as SafeInfo,
+ messageText,
+ )
+ const msg = {
+ type: SafeMessageListItemType.MESSAGE,
+ messageHash,
+ confirmations: [
+ {
+ owner: {
+ value: hexZeroPad('0x2', 20),
},
- ],
- } as SafeMessage,
- new Error('SafeMessage not found'),
- false,
- ])
+ },
+ ],
+ confirmationsRequired: 2,
+ confirmationsSubmitted: 1,
+ } as unknown as SafeMessage
+
+ jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(msg)
const { getByText } = render(
,
)
@@ -388,7 +462,14 @@ describe('MsgModal', () => {
})
it('displays an error if the message could not be proposed', async () => {
- jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
+ jest.spyOn(useWalletHook, 'default').mockImplementation(
+ () =>
+ ({
+ address: hexZeroPad('0x3', 20),
+ } as ConnectedWallet),
+ )
+ jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(undefined)
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true)
jest.spyOn(useAsyncHook, 'default').mockReturnValue([undefined, new Error('SafeMessage not found'), false])
@@ -409,22 +490,24 @@ describe('MsgModal', () => {
)
const button = getByText('Sign')
+ expect(button).not.toBeDisabled()
await act(() => {
fireEvent.click(button)
})
- expect(proposalSpy).toHaveBeenCalled()
-
await waitFor(() => {
+ expect(proposalSpy).toHaveBeenCalled()
expect(getByText('Error confirming the message. Please try again.')).toBeInTheDocument()
})
})
it('displays an error if the message could not be confirmed', async () => {
- jest.spyOn(useIsWrongChainHook, 'default').mockImplementation(() => false)
+ jest.spyOn(onboard, 'default').mockReturnValue(mockOnboard)
jest.spyOn(useIsSafeOwnerHook, 'default').mockImplementation(() => true)
+ jest.spyOn(useSafeMessages, 'useSafeMessage').mockReturnValue(undefined)
+
jest
.spyOn(useAsyncHook, 'default')
.mockReturnValue([
@@ -434,7 +517,7 @@ describe('MsgModal', () => {
])
const confirmationSpy = jest
- .spyOn(sender, 'dispatchSafeMsgConfirmation')
+ .spyOn(sender, 'dispatchSafeMsgProposal')
.mockImplementation(() => Promise.reject(new Error('Test error')))
const { getByText } = render(
diff --git a/src/components/safe-messages/MsgModal/index.tsx b/src/components/safe-messages/MsgModal/index.tsx
index c515952a..ab9ab83a 100644
--- a/src/components/safe-messages/MsgModal/index.tsx
+++ b/src/components/safe-messages/MsgModal/index.tsx
@@ -1,6 +1,7 @@
import { Grid, DialogActions, Button, Box, Typography, DialogContent, SvgIcon } from '@mui/material'
-import { useCallback, useMemo, useState } from 'react'
-import { getSafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import { useTheme } from '@mui/material/styles'
+import { useCallback, useState } from 'react'
+import { SafeMessageListItemType, SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk'
import type { ReactElement } from 'react'
import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
import type { RequestId } from '@safe-global/safe-apps-sdk'
@@ -9,26 +10,146 @@ import ModalDialog, { ModalDialogTitle } from '@/components/common/ModalDialog'
import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard'
import EthHashInfo from '@/components/common/EthHashInfo'
import RequiredIcon from '@/public/images/messages/required.svg'
-import { dispatchSafeMsgConfirmation, dispatchSafeMsgProposal } from '@/services/safe-messages/safeMsgSender'
import useSafeInfo from '@/hooks/useSafeInfo'
-import { generateSafeMessageHash, generateSafeMessageMessage } from '@/utils/safe-messages'
-import { getDecodedMessage } from '@/components/safe-apps/utils'
+
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
-import useIsWrongChain from '@/hooks/useIsWrongChain'
import ErrorMessage from '@/components/tx/ErrorMessage'
-import useAsync from '@/hooks/useAsync'
import useWallet from '@/hooks/wallets/useWallet'
-import useSafeMessages from '@/hooks/useSafeMessages'
-import { isSafeMessageListItem } from '@/utils/safe-message-guards'
-import { useWeb3 } from '@/hooks/wallets/web3'
+import { useSafeMessage } from '@/hooks/messages/useSafeMessages'
+import useOnboard, { switchWallet } from '@/hooks/wallets/useOnboard'
import txStepperCss from '@/components/tx/TxStepper/styles.module.css'
import { DecodedMsg } from '../DecodedMsg'
import CopyButton from '@/components/common/CopyButton'
+import { WrongChainWarning } from '@/components/tx/WrongChainWarning'
+import MsgSigners from '@/components/safe-messages/MsgSigners'
+import { ConfirmationDialog } from './ConfirmationDialog'
+import useDecodedSafeMessage from '@/hooks/messages/useDecodedSafeMessage'
+import useSyncSafeMessageSigner from '@/hooks/messages/useSyncSafeMessageSigner'
+import SuccessMessage from '@/components/tx/SuccessMessage'
+import InfoBox from '../InfoBox'
+import useHighlightHiddenTab from '@/hooks/useHighlightHiddenTab'
const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg'
const APP_NAME_FALLBACK = 'Sign message off-chain'
+const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => {
+ return {
+ confirmations: [],
+ confirmationsRequired,
+ confirmationsSubmitted: 0,
+ creationTimestamp: 0,
+ message: '',
+ logoUri: null,
+ messageHash: '',
+ modifiedTimestamp: 0,
+ name: null,
+ proposedBy: {
+ value: '',
+ },
+ status: SafeMessageStatus.NEEDS_CONFIRMATION,
+ type: SafeMessageListItemType.MESSAGE,
+ }
+}
+
+const MessageHashField = ({ label, hashValue }: { label: string; hashValue: string }) => (
+ <>
+
+ {label}:
+
+
+
+
+ >
+)
+
+const DialogHeader = ({ threshold }: { threshold: number }) => (
+ <>
+
+
+
+
+ Confirm message
+
+
+ To sign this message, you need to collect {threshold} owner signatures of your Safe Account.
+
+ >
+)
+
+const DialogTitle = ({
+ onClose,
+ name,
+ logoUri,
+}: {
+ onClose: () => void
+ name: string | null
+ logoUri: string | null
+}) => {
+ const appName = name || APP_NAME_FALLBACK
+ const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE
+ return (
+
+
+
+
+
+
+ {appName}
+
+
+
+
+
+ )
+}
+
+const MessageDialogError = ({ isOwner, submitError }: { isOwner: boolean; submitError: Error | undefined }) => {
+ const wallet = useWallet()
+ const onboard = useOnboard()
+
+ const errorMessage =
+ !wallet || !onboard
+ ? 'No wallet is connected.'
+ : !isOwner
+ ? "You are currently not an owner of this Safe Account and won't be able to confirm this message."
+ : submitError
+ ? 'Error confirming the message. Please try again.'
+ : null
+
+ if (errorMessage) {
+ return {errorMessage}
+ }
+ return null
+}
+
+const AlreadySignedByOwnerMessage = ({ hasSigned }: { hasSigned: boolean }) => {
+ const onboard = useOnboard()
+
+ const handleSwitchWallet = () => {
+ if (onboard) {
+ switchWallet(onboard)
+ }
+ }
+ if (!hasSigned) {
+ return null
+ }
+ return (
+
+
+
+ Your connected wallet has already signed this message.
+
+
+
+
+
+
+ )
+}
+
type BaseProps = {
onClose: () => void
} & Pick
@@ -36,14 +157,12 @@ type BaseProps = {
// Custom Safe Apps do not have a `safeAppId`
type ProposeProps = BaseProps & {
safeAppId?: number
- messageHash?: never
requestId: RequestId
}
// A proposed message does not return the `safeAppId` but the `logoUri` and `name` of the Safe App that proposed it
type ConfirmProps = BaseProps & {
safeAppId?: never
- messageHash: string
requestId?: RequestId
}
@@ -52,146 +171,94 @@ const MsgModal = ({
logoUri,
name,
message,
- messageHash,
safeAppId,
requestId,
}: ProposeProps | ConfirmProps): ReactElement => {
// Hooks & variables
- const [submitError, setSubmitError] = useState()
-
- const web3 = useWeb3()
+ const [showCloseTooltip, setShowCloseTooltip] = useState(false)
+ const { palette } = useTheme()
const { safe } = useSafeInfo()
- const isWrongChain = useIsWrongChain()
const isOwner = useIsSafeOwner()
const wallet = useWallet()
- const messages = useSafeMessages()
- // Decode message if UTF-8 encoded
- const decodedMessage = useMemo(() => {
- return typeof message === 'string' ? getDecodedMessage(message) : message
- }, [message])
+ const { decodedMessage, safeMessageMessage, safeMessageHash } = useDecodedSafeMessage(message, safe)
+ const ongoingMessage = useSafeMessage(safeMessageHash)
+ useHighlightHiddenTab()
- // Get `SafeMessage` message
- const safeMessageMessage = useMemo(() => {
- return generateSafeMessageMessage(decodedMessage)
- }, [decodedMessage])
+ const decodedMessageAsString =
+ typeof decodedMessage === 'string' ? decodedMessage : JSON.stringify(decodedMessage, null, 2)
- // Get `SafeMessage` hash
- const safeMessageHash = useMemo(() => {
- return messageHash ?? generateSafeMessageHash(safe, decodedMessage)
- }, [messageHash, safe, decodedMessage])
+ const hasSigned = !!ongoingMessage?.confirmations.some(({ owner }) => owner.value === wallet?.address)
- // Get already proposed message
- const [alreadyProposedMessage] = useAsync>(() => {
- const localMessage = messages.page?.results
- .filter(isSafeMessageListItem)
- .find((message) => message.messageHash === messageHash)
+ const isDisabled = !isOwner || hasSigned
- return localMessage ? Promise.resolve(localMessage) : getSafeMessage(safe.chainId, safeMessageHash)
- }, [safe.chainId, messageHash, safeMessageHash])
+ const { onSign, submitError } = useSyncSafeMessageSigner(
+ ongoingMessage,
+ decodedMessage,
+ safeMessageHash,
+ requestId,
+ safeAppId,
+ onClose,
+ )
- const hasSigned = !!alreadyProposedMessage?.confirmations.some(({ owner }) => owner.value === wallet?.address)
+ const handleClose = useCallback(() => {
+ if (requestId && (!ongoingMessage || ongoingMessage.status === SafeMessageStatus.NEEDS_CONFIRMATION)) {
+ // If we are in a Safe app modal we want to keep the modal open
+ setShowCloseTooltip(true)
+ } else {
+ onClose()
+ }
+ }, [onClose, ongoingMessage, requestId])
- const isDisabled = isWrongChain || !isOwner || hasSigned || !web3
+ return (
+ <>
+
+
+
- const onSign = useCallback(async () => {
- // Error is shown when no wallet is connected, this appeases TypeScript
- if (!web3) {
- return
- }
+
+
- setSubmitError(undefined)
+
+ Message:
+
+
- const signer = web3.getSigner()
+
+
- try {
- if (requestId && !alreadyProposedMessage) {
- await dispatchSafeMsgProposal({ signer, safe, message: decodedMessage, requestId, safeAppId })
- } else {
- await dispatchSafeMsgConfirmation({ signer, safe, message: decodedMessage, requestId })
- }
+
- onClose()
- } catch (e) {
- setSubmitError(e as Error)
- }
- }, [alreadyProposedMessage, decodedMessage, onClose, requestId, safe, safeAppId, web3])
+
+
+
- return (
-
-
-
-
-
-
-
-
- {name || APP_NAME_FALLBACK}
-
-
-
-
-
-
-
-
-
-
-
- Confirm message
-
-
- This action will confirm the message and add your confirmation to the prepared signature.
-
-
- Message:{' '}
-
-
-
-
- SafeMessage:
-
-
-
-
-
-
- SafeMessage hash:
-
-
-
-
-
- {!web3 ? (
- No wallet is connected.
- ) : isWrongChain ? (
- Your wallet is connected to the wrong chain.
- ) : !isOwner ? (
-
- You are currently not an owner of this Safe and won't be able to confirm this message.
-
- ) : hasSigned ? (
- Your connected wallet has already signed this message.
- ) : submitError ? (
- Error confirming the message. Please try again.
- ) : null}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ setShowCloseTooltip(false)} onClose={onClose} />
+ >
)
}
diff --git a/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx b/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx
new file mode 100644
index 00000000..258618a5
--- /dev/null
+++ b/src/components/safe-messages/MsgSigners/MsgSigners.test.tsx
@@ -0,0 +1,79 @@
+import { render } from '@/tests/test-utils'
+import { SafeMessageStatus, SafeMessageListItemType, type SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import { hexZeroPad } from 'ethers/lib/utils'
+import MsgSigners from '.'
+
+describe('MsgSigners', () => {
+ it('Message with more confirmations submitted than required', () => {
+ const mockMessage: SafeMessage = {
+ confirmations: [
+ {
+ owner: {
+ value: hexZeroPad('0x1', 20),
+ },
+ signature: '0x123',
+ },
+ {
+ owner: {
+ value: hexZeroPad('0x2', 20),
+ },
+ signature: '0x456',
+ },
+ ],
+ confirmationsRequired: 1,
+ confirmationsSubmitted: 2,
+ creationTimestamp: 0,
+ message: '',
+ logoUri: null,
+ messageHash: '',
+ modifiedTimestamp: 0,
+ name: null,
+ proposedBy: {
+ value: '',
+ },
+ status: SafeMessageStatus.NEEDS_CONFIRMATION,
+ type: SafeMessageListItemType.MESSAGE,
+ }
+
+ const result = render()
+
+ expect(result.baseElement).toHaveTextContent('0x0000...0001')
+ expect(result.baseElement).toHaveTextContent('0x0000...0002')
+ expect(result.baseElement).toHaveTextContent('2 of 1')
+ })
+
+ it('should show missing signatures if prop is enabled', () => {
+ const mockMessage: SafeMessage = {
+ confirmations: [
+ {
+ owner: {
+ value: hexZeroPad('0x1', 20),
+ },
+ signature: '0x123',
+ },
+ ],
+ confirmationsRequired: 5,
+ confirmationsSubmitted: 1,
+ creationTimestamp: 0,
+ message: '',
+ logoUri: null,
+ messageHash: '',
+ modifiedTimestamp: 0,
+ name: null,
+ proposedBy: {
+ value: '',
+ },
+ status: SafeMessageStatus.NEEDS_CONFIRMATION,
+ type: SafeMessageListItemType.MESSAGE,
+ }
+
+ const result = render()
+
+ expect(result.baseElement).toHaveTextContent('0x0000...0001')
+ expect(result.baseElement).toHaveTextContent('1 of 5')
+ expect(result.baseElement).toHaveTextContent('Confirmation #2')
+ expect(result.baseElement).toHaveTextContent('Confirmation #3')
+ expect(result.baseElement).toHaveTextContent('Confirmation #4')
+ expect(result.baseElement).toHaveTextContent('Confirmation #5')
+ })
+})
diff --git a/src/components/safe-messages/MsgSigners/index.tsx b/src/components/safe-messages/MsgSigners/index.tsx
index 831726bc..045a1602 100644
--- a/src/components/safe-messages/MsgSigners/index.tsx
+++ b/src/components/safe-messages/MsgSigners/index.tsx
@@ -1,7 +1,8 @@
import { useState, type ReactElement } from 'react'
-import { Box, Link, List, ListItem, ListItemIcon, ListItemText, SvgIcon } from '@mui/material'
+import { Box, Link, List, ListItem, ListItemIcon, ListItemText, Skeleton, SvgIcon, Typography } from '@mui/material'
import { SafeMessageStatus } from '@safe-global/safe-gateway-typescript-sdk'
import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
+import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined'
import CreatedIcon from '@/public/images/messages/created.svg'
import SignedIcon from '@/public/images/messages/signed.svg'
@@ -44,7 +45,17 @@ const shouldHideConfirmations = (msg: SafeMessage): boolean => {
return isConfirmed || msg.confirmations.length > 3
}
-export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => {
+export const MsgSigners = ({
+ msg,
+ showOnlyConfirmations = false,
+ showMissingSignatures = false,
+ backgroundColor,
+}: {
+ msg: SafeMessage
+ showOnlyConfirmations?: boolean
+ showMissingSignatures?: boolean
+ backgroundColor?: string
+}): ReactElement => {
const [hideConfirmations, setHideConfirmations] = useState(shouldHideConfirmations(msg))
const toggleHide = () => {
@@ -53,16 +64,20 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => {
const { confirmations, confirmationsRequired, confirmationsSubmitted } = msg
+ const missingConfirmations = [...new Array(Math.max(0, confirmationsRequired - confirmationsSubmitted))]
+
const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED
return (
-
-
-
-
- Created
-
+ {!showOnlyConfirmations && (
+
+
+
+
+ Created
+
+ )}
@@ -77,7 +92,7 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => {
{!hideConfirmations &&
confirmations.map(({ owner }) => (
-
+
@@ -85,9 +100,9 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => {
))}
- {confirmations.length > 0 && (
+ {!showOnlyConfirmations && confirmations.length > 0 && (
-
+
@@ -97,9 +112,25 @@ export const MsgSigners = ({ msg }: { msg: SafeMessage }): ReactElement => {
)}
+ {showMissingSignatures &&
+ missingConfirmations.map((_, idx) => (
+
+
+
+
+
+
+
+
+ Confirmation #{idx + 1 + confirmationsSubmitted}
+
+
+
+
+ ))}
{isConfirmed && (
-
+
Confirmed
diff --git a/src/components/safe-messages/MsgSigners/styles.module.css b/src/components/safe-messages/MsgSigners/styles.module.css
index 1c953ca2..7a6581ed 100644
--- a/src/components/safe-messages/MsgSigners/styles.module.css
+++ b/src/components/safe-messages/MsgSigners/styles.module.css
@@ -35,5 +35,4 @@
justify-content: center;
min-width: 32px;
padding: var(--space-1) 0;
- background-color: var(--color-background-paper);
}
diff --git a/src/components/safe-messages/MsgSummary/index.tsx b/src/components/safe-messages/MsgSummary/index.tsx
index d93e540d..21651cd0 100644
--- a/src/components/safe-messages/MsgSummary/index.tsx
+++ b/src/components/safe-messages/MsgSummary/index.tsx
@@ -6,11 +6,10 @@ import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
import DateTime from '@/components/common/DateTime'
import useWallet from '@/hooks/wallets/useWallet'
-import useIsWrongChain from '@/hooks/useIsWrongChain'
import MsgType from '@/components/safe-messages/MsgType'
import SignMsgButton from '@/components/safe-messages/SignMsgButton'
-import useSafeMessageStatus from '@/hooks/useSafeMessageStatus'
-import useIsSafeMessagePending from '@/hooks/useIsSafeMessagePending'
+import useSafeMessageStatus from '@/hooks/messages/useSafeMessageStatus'
+import useIsSafeMessagePending from '@/hooks/messages/useIsSafeMessagePending'
import TxConfirmations from '@/components/transactions/TxConfirmations'
import classNames from 'classnames'
@@ -30,7 +29,6 @@ const getStatusColor = (value: SafeMessageStatus, palette: Palette) => {
const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => {
const { confirmationsSubmitted, confirmationsRequired } = msg
const wallet = useWallet()
- const isWrongChain = useIsWrongChain()
const txStatusLabel = useSafeMessageStatus(msg)
const isPending = useIsSafeMessagePending(msg.messageHash)
const isConfirmed = msg.status === SafeMessageStatus.CONFIRMED
@@ -58,7 +56,7 @@ const MsgSummary = ({ msg }: { msg: SafeMessage }): ReactElement => {
)}
- {wallet && !isWrongChain && !isConfirmed && (
+ {wallet && !isConfirmed && (
diff --git a/src/components/safe-messages/PaginatedMsgs/index.tsx b/src/components/safe-messages/PaginatedMsgs/index.tsx
index 177915e8..b5722230 100644
--- a/src/components/safe-messages/PaginatedMsgs/index.tsx
+++ b/src/components/safe-messages/PaginatedMsgs/index.tsx
@@ -1,16 +1,18 @@
import { Box } from '@mui/material'
import { Typography, Link, SvgIcon } from '@mui/material'
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import type { ReactElement } from 'react'
import ErrorMessage from '@/components/tx/ErrorMessage'
-import useSafeMessages from '@/hooks/useSafeMessages'
+import useSafeMessages from '@/hooks/messages/useSafeMessages'
import LinkIcon from '@/public/images/common/link.svg'
import NoMessagesIcon from '@/public/images/messages/no-messages.svg'
import InfiniteScroll from '@/components/common/InfiniteScroll'
import PagePlaceholder from '@/components/common/PagePlaceholder'
import MsgList from '@/components/safe-messages/MsgList'
import SkeletonTxList from '@/components/common/PaginatedTxns/SkeletonTxList'
+import { HelpCenterArticle } from '@/config/constants'
+import useSafeInfo from '@/hooks/useSafeInfo'
const NoMessages = (): ReactElement => {
return (
@@ -19,12 +21,11 @@ const NoMessages = (): ReactElement => {
text={
Some applications allow you to interact with them via off-chain contract signatures (“messages“)
- that you can generate with your Safe.
+ that you can generate with your Safe Account.
}
>
- {/* TODO: Add link to help article */}
-
+
Learn more about off-chain messages{' '}
@@ -62,12 +63,18 @@ const MsgPage = ({
const PaginatedMsgs = (): ReactElement => {
const [pages, setPages] = useState([''])
+ const { safeAddress, safe } = useSafeInfo()
// Trigger the next page load
const onNextPage = (pageUrl: string) => {
setPages((prev) => prev.concat(pageUrl))
}
+ // Reset the pages when the Safe Account changes
+ useEffect(() => {
+ setPages([''])
+ }, [safe.chainId, safeAddress])
+
return (
{pages.map((pageUrl, index) => (
diff --git a/src/components/safe-messages/SignMsgButton/index.tsx b/src/components/safe-messages/SignMsgButton/index.tsx
index 612aa54e..29d46336 100644
--- a/src/components/safe-messages/SignMsgButton/index.tsx
+++ b/src/components/safe-messages/SignMsgButton/index.tsx
@@ -7,8 +7,8 @@ import type { SafeMessage } from '@safe-global/safe-gateway-typescript-sdk'
import useWallet from '@/hooks/wallets/useWallet'
import Track from '@/components/common/Track'
import { MESSAGE_EVENTS } from '@/services/analytics/events/txList'
-import useIsSafeMessageSignableBy from '@/hooks/useIsSafeMessageSignableBy'
-import useIsSafeMessagePending from '@/hooks/useIsSafeMessagePending'
+import useIsSafeMessageSignableBy from '@/hooks/messages/useIsSafeMessageSignableBy'
+import useIsSafeMessagePending from '@/hooks/messages/useIsSafeMessagePending'
import MsgModal from '@/components/safe-messages/MsgModal'
const SignMsgButton = ({ msg, compact = false }: { msg: SafeMessage; compact?: boolean }): ReactElement => {
diff --git a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx b/src/components/settings/ContractVersion/UpdateSafeDialog.tsx
index 239f09b5..b424aaf1 100644
--- a/src/components/settings/ContractVersion/UpdateSafeDialog.tsx
+++ b/src/components/settings/ContractVersion/UpdateSafeDialog.tsx
@@ -1,11 +1,10 @@
-import { Box, Button, Typography } from '@mui/material'
+import { Button, Typography } from '@mui/material'
import { useState } from 'react'
import { LATEST_SAFE_VERSION } from '@/config/constants'
import TxModal from '@/components/tx/TxModal'
-import useTxSender from '@/hooks/useTxSender'
import useAsync from '@/hooks/useAsync'
import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
@@ -16,10 +15,12 @@ import { createUpdateSafeTxs } from '@/services/tx/safeUpdateParams'
import useSafeInfo from '@/hooks/useSafeInfo'
import { useCurrentChain } from '@/hooks/useChains'
import ExternalLink from '@/components/common/ExternalLink'
+import { createMultiSendCallOnlyTx } from '@/services/tx/tx-sender'
+import CheckWallet from '@/components/common/CheckWallet'
const UpdateSafeSteps: TxStepperProps['steps'] = [
{
- label: 'Update Safe version',
+ label: 'Update Safe Account version',
render: (_, onSubmit) => ,
},
]
@@ -30,28 +31,29 @@ const UpdateSafeDialog = () => {
const handleClose = () => setOpen(false)
return (
-
-
-
-
+ <>
+
+ {(isOk) => (
+
+ )}
+
{open && }
-
+ >
)
}
const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => {
const { safe, safeLoaded } = useSafeInfo()
const chain = useCurrentChain()
- const { createMultiSendCallOnlyTx } = useTxSender()
const [safeTx, safeTxError] = useAsync(() => {
if (!chain || !safeLoaded) return
const txs = createUpdateSafeTxs(safe, chain)
return createMultiSendCallOnlyTx(txs)
- }, [chain, safe, safeLoaded, createMultiSendCallOnlyTx])
+ }, [chain, safe, safeLoaded])
return (
@@ -62,13 +64,13 @@ const ReviewUpdateSafeStep = ({ onSubmit }: { onSubmit: () => void }) => {
To check details about updates added by this smart contract version please visit{' '}
- latest Safe contracts changelog
+ latest Safe Account contracts changelog
You will need to confirm this update just like any other transaction. This means other owners will have to
- confirm the update in case more than one confirmation is required for this Safe.
+ confirm the update in case more than one confirmation is required for this Safe Account.
diff --git a/src/components/settings/ContractVersion/index.tsx b/src/components/settings/ContractVersion/index.tsx
index 5082dd3d..491d1814 100644
--- a/src/components/settings/ContractVersion/index.tsx
+++ b/src/components/settings/ContractVersion/index.tsx
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
-import { SvgIcon, Typography } from '@mui/material'
+import { Box, SvgIcon, Typography, Alert, AlertTitle, Skeleton } from '@mui/material'
import { ImplementationVersionState } from '@safe-global/safe-gateway-typescript-sdk'
import { LATEST_SAFE_VERSION } from '@/config/constants'
import { sameAddress } from '@/utils/addresses'
@@ -11,11 +11,9 @@ import InfoIcon from '@/public/images/notifications/info.svg'
import UpdateSafeDialog from './UpdateSafeDialog'
import ExternalLink from '@/components/common/ExternalLink'
-import Tooltip from '@mui/material/Tooltip'
-
-export const ContractVersion = ({ isGranted }: { isGranted: boolean }) => {
+export const ContractVersion = () => {
const [masterCopies] = useMasterCopies()
- const { safe } = useSafeInfo()
+ const { safe, safeLoaded } = useSafeInfo()
const masterCopyAddress = safe.implementation.value
const safeMasterCopy: MasterCopy | undefined = useMemo(() => {
@@ -23,58 +21,39 @@ export const ContractVersion = ({ isGranted }: { isGranted: boolean }) => {
}, [masterCopies, masterCopyAddress])
const needsUpdate = safe.implementationVersionState === ImplementationVersionState.OUTDATED
- const latestMasterContractVersion = LATEST_SAFE_VERSION
const showUpdateDialog = safeMasterCopy?.deployer === MasterCopyDeployer.GNOSIS && needsUpdate
- const getSafeVersionUpdate = () => {
- return showUpdateDialog ? ` (there's a newer version: ${latestMasterContractVersion})` : ''
- }
return (
-
+ <>
Contract version
- {safe.version ? (
-
- {safe.version}
- {getSafeVersionUpdate()}
-
- ) : (
-
- Unsupported contract
-
- )}
-
- {needsUpdate ? (
-
- Why should I upgrade?
-
-
-
-
-
-
- ) : (
-
- Latest version
-
- )}
-
- {showUpdateDialog && isGranted &&
}
-
+
+ {safeLoaded ? safe.version ? safe.version : 'Unsupported contract' : }
+
+
+ {safeLoaded ? (
+ showUpdateDialog ? (
+ }
+ >
+ New version is available: {LATEST_SAFE_VERSION}
+
+ Update now to take advantage of new features and the highest security standards available. You will need
+ to confirm this update just like any other transaction.{' '}
+ GitHub
+
+
+
+ ) : (
+
+ Latest version
+
+ )
+ ) : null}
+
+ >
)
}
diff --git a/src/components/settings/DataManagement/FileListCard.tsx b/src/components/settings/DataManagement/FileListCard.tsx
new file mode 100644
index 00000000..984d373b
--- /dev/null
+++ b/src/components/settings/DataManagement/FileListCard.tsx
@@ -0,0 +1,174 @@
+import { Box, Card, CardContent, CardHeader, List, ListItem, ListItemIcon, ListItemText, SvgIcon } from '@mui/material'
+import type { ListItemTextProps } from '@mui/material'
+import type { CardHeaderProps } from '@mui/material'
+import type { ReactElement } from 'react'
+
+import FileIcon from '@/public/images/settings/data/file.svg'
+import useChains from '@/hooks/useChains'
+import { ImportErrors } from '@/components/settings/DataManagement/useGlobalImportFileParser'
+import type { AddedSafesState } from '@/store/addedSafesSlice'
+import type { AddressBookState } from '@/store/addressBookSlice'
+import type { SafeAppsState } from '@/store/safeAppsSlice'
+import type { SettingsState } from '@/store/settingsSlice'
+import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
+
+import css from './styles.module.css'
+
+const getItemSecondaryText = (
+ chains: ChainInfo[],
+ data: AddedSafesState | AddressBookState = {},
+ singular: string,
+ plural: string,
+): ReactElement => {
+ return (
+
+ {Object.keys(data).map((chainId) => {
+ const count = Object.keys(data[chainId] ?? {}).length
+
+ if (count === 0) {
+ return null
+ }
+
+ const chain = chains.find((chain) => chain.chainId === chainId)
+
+ return (
+
+
+ {chain?.chainName}: {count} {count === 1 ? singular : plural}
+
+ )
+ })}
+
+ )
+}
+
+type Data = {
+ addedSafes?: AddedSafesState
+ addressBook?: AddressBookState
+ settings?: SettingsState
+ safeApps?: SafeAppsState
+ error?: string
+}
+
+type ListProps = Data & {
+ showPreview?: boolean
+}
+
+type ItemProps = ListProps & { chains: ChainInfo[] }
+
+const getItems = ({
+ addedSafes,
+ addressBook,
+ settings,
+ safeApps,
+ error,
+ chains,
+ showPreview = false,
+}: ItemProps): Array => {
+ if (error) {
+ return [{ primary: <>{error}> }]
+ }
+
+ const addedSafeChainAmount = Object.keys(addedSafes || {}).length
+ const addressBookChainAmount = Object.keys(addressBook || {}).length
+
+ const items: Array = []
+
+ if (addedSafeChainAmount > 0) {
+ const addedSafesPreview: ListItemTextProps = {
+ primary: (
+ <>
+ Added Safe Accounts on {addedSafeChainAmount} {addedSafeChainAmount === 1 ? 'chain' : 'chains'}
+ >
+ ),
+ secondary: showPreview ? getItemSecondaryText(chains, addedSafes, 'Safe', 'Safes') : undefined,
+ }
+
+ items.push(addedSafesPreview)
+ }
+
+ if (addressBookChainAmount > 0) {
+ const addressBookPreview: ListItemTextProps = {
+ primary: (
+ <>
+ Address book for {addressBookChainAmount} {addressBookChainAmount === 1 ? 'chain' : 'chains'}
+ >
+ ),
+ secondary: showPreview ? getItemSecondaryText(chains, addressBook, 'contact', 'contacts') : undefined,
+ }
+
+ items.push(addressBookPreview)
+ }
+
+ if (settings) {
+ const settingsPreview: ListItemTextProps = {
+ primary: (
+ <>
+ Settings (appearance, currency, hidden tokens and custom environment variables)
+ >
+ ),
+ }
+
+ items.push(settingsPreview)
+ }
+
+ const hasBookmarkedSafeApps = Object.values(safeApps || {}).some((chainId) => chainId.pinned?.length > 0)
+ if (hasBookmarkedSafeApps) {
+ const safeAppsPreview: ListItemTextProps = {
+ primary: (
+ <>
+ Bookmarked Safe Apps
+ >
+ ),
+ }
+
+ items.push(safeAppsPreview)
+ }
+
+ if (items.length === 0) {
+ return [{ primary: <>{ImportErrors.NO_IMPORT_DATA_FOUND}> }]
+ }
+
+ return items
+}
+
+type Props = ListProps & CardHeaderProps
+
+export const FileListCard = ({
+ addedSafes,
+ addressBook,
+ settings,
+ safeApps,
+ error,
+ showPreview = false,
+ ...cardHeaderProps
+}: Props): ReactElement => {
+ const chains = useChains()
+ const items = getItems({ addedSafes, addressBook, settings, safeApps, error, chains: chains.configs, showPreview })
+
+ return (
+
+
+
+
+ {items.map((item, i) => (
+
+
+
+
+ cannot appear as a descendant of
+ secondaryTypographyProps={{ component: 'div' }}
+ />
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/settings/DataManagement/ImportDialog.tsx b/src/components/settings/DataManagement/ImportDialog.tsx
new file mode 100644
index 00000000..d9dea29b
--- /dev/null
+++ b/src/components/settings/DataManagement/ImportDialog.tsx
@@ -0,0 +1,128 @@
+import { DialogContent, Alert, AlertTitle, DialogActions, Button, Box, SvgIcon } from '@mui/material'
+import type { ReactElement, Dispatch, SetStateAction } from 'react'
+
+import ModalDialog from '@/components/common/ModalDialog'
+import { useAppDispatch } from '@/store'
+import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics'
+import { addedSafesSlice } from '@/store/addedSafesSlice'
+import { addressBookSlice } from '@/store/addressBookSlice'
+import { safeAppsSlice } from '@/store/safeAppsSlice'
+import { settingsSlice } from '@/store/settingsSlice'
+import { FileListCard } from '@/components/settings/DataManagement/FileListCard'
+import { useGlobalImportJsonParser } from '@/components/settings/DataManagement/useGlobalImportFileParser'
+import FileIcon from '@/public/images/settings/data/file.svg'
+import { ImportFileUpload } from '@/components/settings/DataManagement/ImportFileUpload'
+import { showNotification } from '@/store/notificationsSlice'
+
+import css from './styles.module.css'
+
+export const ImportDialog = ({
+ onClose,
+ fileName = '',
+ setFileName,
+ jsonData = '',
+ setJsonData,
+}: {
+ onClose?: () => void
+ fileName: string | undefined
+ setFileName: Dispatch>
+ jsonData: string | undefined
+ setJsonData: Dispatch>
+}): ReactElement => {
+ const dispatch = useAppDispatch()
+ const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, settings, safeApps, error } =
+ useGlobalImportJsonParser(jsonData)
+
+ const isDisabled = (!addedSafes && !addressBook && !settings && !safeApps) || !!error
+
+ const handleClose = () => {
+ setFileName(undefined)
+ setJsonData(undefined)
+ onClose?.()
+ }
+
+ const handleImport = () => {
+ if (addressBook) {
+ dispatch(addressBookSlice.actions.setAddressBook(addressBook))
+ trackEvent({
+ ...SETTINGS_EVENTS.DATA.IMPORT_ADDRESS_BOOK,
+ label: addressBookEntriesCount,
+ })
+ }
+ if (addedSafes) {
+ dispatch(addedSafesSlice.actions.setAddedSafes(addedSafes))
+ trackEvent({
+ ...SETTINGS_EVENTS.DATA.IMPORT_ADDED_SAFES,
+ label: addedSafesCount,
+ })
+ }
+
+ if (settings) {
+ dispatch(settingsSlice.actions.setSettings(settings))
+ trackEvent(SETTINGS_EVENTS.DATA.IMPORT_SETTINGS)
+ }
+
+ if (safeApps) {
+ dispatch(safeAppsSlice.actions.setSafeApps(safeApps))
+ trackEvent(SETTINGS_EVENTS.DATA.IMPORT_SAFE_APPS)
+ }
+
+ dispatch(
+ showNotification({
+ variant: 'success',
+ groupKey: 'global-import-success',
+ message: 'Successfully imported data',
+ }),
+ )
+
+ handleClose()
+ }
+
+ return (
+
+
+ {!jsonData || !fileName ? (
+
+
+
+ ) : (
+ <>
+ `${shape.borderRadius}px` }}>
+
+
+ }
+ title={{fileName}}
+ className={css.header}
+ addedSafes={addedSafes}
+ addressBook={addressBook}
+ settings={settings}
+ safeApps={safeApps}
+ error={error}
+ showPreview
+ />
+ {!isDisabled && (
+
+ Overwrite your current data?
+ This action will overwrite your currently added Safe Accounts, address book and settings with those from
+ the imported file.
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/settings/DataManagement/ImportFileUpload.tsx b/src/components/settings/DataManagement/ImportFileUpload.tsx
new file mode 100644
index 00000000..3f4bc5a7
--- /dev/null
+++ b/src/components/settings/DataManagement/ImportFileUpload.tsx
@@ -0,0 +1,81 @@
+import { useDropzone } from 'react-dropzone'
+import { Typography, SvgIcon } from '@mui/material'
+import { useCallback } from 'react'
+import type { Dispatch, SetStateAction } from 'react'
+
+import FileUpload, { FileTypes } from '@/components/common/FileUpload'
+import InfoIcon from '@/public/images/notifications/info.svg'
+
+const AcceptedMimeTypes = {
+ 'application/json': ['.json'],
+}
+
+export const ImportFileUpload = ({
+ setFileName,
+ setJsonData,
+}: {
+ setFileName: Dispatch>
+ setJsonData: Dispatch>
+}) => {
+ const onDrop = useCallback(
+ (acceptedFiles: File[]) => {
+ if (acceptedFiles.length === 0) {
+ return
+ }
+ const file = acceptedFiles[0]
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ if (!event.target) {
+ return
+ }
+ if (typeof event.target.result !== 'string') {
+ return
+ }
+ setFileName(file.name)
+ setJsonData(event.target.result)
+ }
+ reader.readAsText(file)
+ },
+ [setFileName, setJsonData],
+ )
+
+ const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
+ maxFiles: 1,
+ onDrop,
+ accept: AcceptedMimeTypes,
+ })
+
+ const onRemove = () => {
+ setFileName(undefined)
+ setJsonData(undefined)
+ }
+
+ return (
+ <>
+ Import {'Evmos Safe'} data by clicking or dragging a file below.
+
+ ({ ...getRootProps(), height: '228px' })}
+ getInputProps={getInputProps}
+ isDragActive={isDragActive}
+ isDragReject={isDragReject}
+ onRemove={onRemove}
+ />
+
+
+
+ Only JSON files exported from a {'Evmos Safe'} can be imported.
+
+ >
+ )
+}
diff --git a/src/components/settings/ImportAllDialog/__tests__/useGlobalImportFileParser.test.ts b/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts
similarity index 54%
rename from src/components/settings/ImportAllDialog/__tests__/useGlobalImportFileParser.test.ts
rename to src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts
index 986fa460..f0915736 100644
--- a/src/components/settings/ImportAllDialog/__tests__/useGlobalImportFileParser.test.ts
+++ b/src/components/settings/DataManagement/__tests__/useGlobalImportFileParser.test.ts
@@ -1,5 +1,52 @@
import { renderHook } from '@/tests/test-utils'
-import { ImportErrors, useGlobalImportJsonParser } from '../useGlobalImportFileParser'
+import { ImportErrors, useGlobalImportJsonParser, _filterValidAbEntries } from '../useGlobalImportFileParser'
+
+describe('filterValidAbEntries', () => {
+ it('it should return undefined if no address book is provided', () => {
+ const ab = _filterValidAbEntries()
+
+ expect(ab).toBeUndefined()
+ })
+
+ it('it should return valid address books as is', () => {
+ const ab = _filterValidAbEntries({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } })
+
+ expect(ab).toStrictEqual({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } })
+ })
+
+ it('it should filter entries with invalid addresses', () => {
+ const ab = _filterValidAbEntries({
+ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name', invalidAddress: 'name2' },
+ 2: { '0XAECDFD3A19F777F0C03E6BF99AAFB59937D6467B': 'name3' },
+ })
+
+ expect(ab).toStrictEqual({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } })
+ })
+
+ it('it should filter entries with invalid names', () => {
+ const ab = _filterValidAbEntries({
+ 1: {
+ '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': '',
+ '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2': ' ',
+ '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5': 'name',
+ },
+ })
+
+ expect(ab).toStrictEqual({ 1: { '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5': 'name' } })
+ })
+
+ it('it should remove empty chain address books pre-/post-validation', () => {
+ // Pre-validation
+ const ab1 = _filterValidAbEntries({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' }, 2: {} })
+
+ expect(ab1).toStrictEqual({ 1: { '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b': 'name' } })
+
+ // Post-validation
+ const ab2 = _filterValidAbEntries({ 1: { invalidAddress: 'name' }, 2: {} })
+
+ expect(ab2).toStrictEqual({})
+ })
+})
describe('useGlobalImportFileParser', () => {
it('should return undefined values for undefined json', () => {
@@ -9,28 +56,38 @@ describe('useGlobalImportFileParser', () => {
addressBook: undefined,
addressBookEntriesCount: 0,
addedSafesCount: 0,
+ settings: undefined,
+ safeApps: undefined,
+ session: undefined,
+ error: undefined,
})
})
it('should return undefined values and error for empty json', () => {
- const { result } = renderHook(() => useGlobalImportJsonParser('{ "version": "1.0", "data": "{}" }'))
+ const { result } = renderHook(() => useGlobalImportJsonParser(JSON.stringify({ version: '1.0', data: {} })))
expect(result.current).toEqual({
addedSafes: undefined,
addressBook: undefined,
addressBookEntriesCount: 0,
addedSafesCount: 0,
error: ImportErrors.NO_IMPORT_DATA_FOUND,
+ settings: undefined,
+ safeApps: undefined,
+ session: undefined,
})
})
it('should return empty objects for invalid json', () => {
- const { result } = renderHook(() => useGlobalImportJsonParser('{ invalid: json, '))
+ const { result } = renderHook(() => useGlobalImportJsonParser('{ invalid: json'))
expect(result.current).toEqual({
addedSafes: undefined,
addressBook: undefined,
addressBookEntriesCount: 0,
addedSafesCount: 0,
error: ImportErrors.INVALID_JSON_FORMAT,
+ settings: undefined,
+ safeApps: undefined,
+ session: undefined,
})
})
@@ -42,7 +99,7 @@ describe('useGlobalImportFileParser', () => {
const owner2 = '0x954cD69f0E902439f99156e3eeDA080752c08401'
const jsonData = JSON.stringify({
- version: '2.0',
+ version: '17.0',
data: {
'_immortal|v2_5__SAFES': `{"${goerliSafeAddress}":{"address":"${goerliSafeAddress}","chainId":"5","threshold":2,"ethBalance":"0.3","totalFiatBalance":"435.08","owners":["${owner1}","${owner2}"],"modules":[],"spendingLimits":[],"balances":[{"tokenAddress":"0x0000000000000000000000000000000000000000","fiatBalance":"435.08100","tokenBalance":"0.3"},{"tokenAddress":"0x61fD3b6d656F39395e32f46E2050953376c3f5Ff","fiatBalance":"0.00000","tokenBalance":"22405.086233211233211233"}],"implementation":{"value":"0x3E5c63644E683549055b9Be8653de26E0B4CD36E"},"loaded":true,"nonce":1,"currentVersion":"1.3.0+L2","needsUpdate":false,"featuresEnabled":["CONTRACT_INTERACTION","DOMAIN_LOOKUP","EIP1559","ERC721","SAFE_APPS","SAFE_TX_GAS_OPTIONAL","SPENDING_LIMIT","TX_SIMULATION","WARNING_BANNER"],"loadedViaUrl":false,"guard":"","collectiblesTag":"1667921524","txQueuedTag":"1667921524","txHistoryTag":"1667400927"}}`,
'_immortal|v2_MAINNET__SAFES': `{"${mainnetSafeAddress}":{"address":"${mainnetSafeAddress}","chainId":"1","threshold":1,"ethBalance":"0","totalFiatBalance":"0.00","owners":["${owner1}","${owner2}"],"modules":[],"spendingLimits":[],"balances":[{"tokenAddress":"0x0000000000000000000000000000000000000000","fiatBalance":"0.00000","tokenBalance":"0"}],"implementation":{"value":"0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552","name":"Gnosis Safe: Singleton 1.3.0","logoUri":"https://safe-transaction-assets.safe.global/contracts/logos/0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552.png"},"loaded":true,"nonce":2,"currentVersion":"1.3.0","needsUpdate":false,"featuresEnabled":["CONTRACT_INTERACTION","DOMAIN_LOOKUP","EIP1559","ERC721","SAFE_APPS","SAFE_TX_GAS_OPTIONAL","SPENDING_LIMIT","TX_SIMULATION"],"loadedViaUrl":false,"guard":"","collectiblesTag":"1667397095","txQueuedTag":"1667397095","txHistoryTag":"1664287235"}}`,
@@ -57,10 +114,15 @@ describe('useGlobalImportFileParser', () => {
addressBookEntriesCount: 0,
addedSafesCount: 0,
error: ImportErrors.INVALID_VERSION,
+ settings: undefined,
+ safeApps: undefined,
+ session: undefined,
})
})
- it('should parse added safes correctly', () => {
+ // 1.0
+
+ it('should parse v1 added safes correctly', () => {
const goerliSafeAddress = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b'
const mainnetSafeAddress = '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5'
@@ -76,7 +138,7 @@ describe('useGlobalImportFileParser', () => {
})
const { result } = renderHook(() => useGlobalImportJsonParser(jsonData))
- const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount } = result.current
+ const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, safeApps, settings } = result.current
// No addressbook data
expect(addressBookEntriesCount).toEqual(0)
@@ -94,9 +156,13 @@ describe('useGlobalImportFileParser', () => {
expect(addedSafes['1'][mainnetSafeAddress]).toBeDefined()
const mainnetAddedSafe = addedSafes['1'][mainnetSafeAddress]
expect(mainnetAddedSafe.threshold).toEqual(1)
+
+ // Only v2
+ expect(safeApps).toEqual(undefined)
+ expect(settings).toEqual(undefined)
})
- it('should parse address book entries correctly', () => {
+ it('should parse v1 address book entries correctly', () => {
const goerliAddress1 = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b'
const goerliName1 = 'test.eth'
const goerliAddress2 = '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2'
@@ -118,7 +184,7 @@ describe('useGlobalImportFileParser', () => {
const { result } = renderHook(() => useGlobalImportJsonParser(jsonData))
- const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount } = result.current
+ const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, safeApps, settings } = result.current
// no added safes
// No addressbook data
@@ -134,5 +200,160 @@ describe('useGlobalImportFileParser', () => {
expect(addressBook['1'][mainnetAddress1]).toEqual(mainnetName1)
expect(addressBook['1'][mainnetAddress2]).toEqual(mainnetName2)
+
+ // Only v2
+ expect(safeApps).toEqual(undefined)
+ expect(settings).toEqual(undefined)
+ })
+
+ // 2.0
+
+ it('should parse v2 added Safes correctly', () => {
+ const goerliSafeAddress = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b'
+ const mainnetSafeAddress = '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5'
+
+ const owner1 = '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2'
+ const owner2 = '0x954cD69f0E902439f99156e3eeDA080752c08401'
+
+ const jsonData = JSON.stringify({
+ version: '2.0',
+ data: {
+ addedSafes: {
+ '5': {
+ [goerliSafeAddress]: {
+ owners: [{ value: owner1 }, { value: owner2 }],
+ threshold: 2,
+ ethBalance: '0',
+ },
+ },
+ '1': {
+ [mainnetSafeAddress]: {
+ owners: [{ value: owner1 }, { value: owner2 }],
+ threshold: 1,
+ ethBalance: '0',
+ },
+ },
+ },
+ },
+ })
+
+ const { result } = renderHook(() => useGlobalImportJsonParser(jsonData))
+
+ const { addedSafes, addedSafesCount } = result.current
+
+ expect(addedSafesCount).toEqual(2)
+
+ expect(addedSafes).toBeDefined()
+ if (!addedSafes) {
+ fail('No added Safes found')
+ }
+
+ expect(addedSafes['5'][goerliSafeAddress]).toBeDefined()
+
+ const goerliAddedSafe = addedSafes['5'][goerliSafeAddress]
+ expect(goerliAddedSafe.threshold).toEqual(2)
+
+ expect(addedSafes['1'][mainnetSafeAddress]).toBeDefined()
+ const mainnetAddedSafe = addedSafes['1'][mainnetSafeAddress]
+ expect(mainnetAddedSafe.threshold).toEqual(1)
+ })
+
+ it('should parse v2 address book entries correctly', () => {
+ const goerliAddress1 = '0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b'
+ const goerliName1 = 'test.eth'
+ const goerliAddress2 = '0x3819b800c67Be64029C1393c8b2e0d0d627dADE2'
+ const goerliName2 = 'some.eth'
+ const mainnetAddress1 = '0x954cD69f0E902439f99156e3eeDA080752c08401'
+ const mainnetName1 = 'mobile owner'
+ const mainnetAddress2 = '0x7cB6E6Cbc845e79d9CA05F6577141DA36ad398f5'
+ const mainnetName2 = 'S0mE&W3!rd#N4m€'
+
+ const jsonData = JSON.stringify({
+ version: '2.0',
+ data: {
+ addressBook: {
+ '5': {
+ [goerliAddress1]: goerliName1,
+ [goerliAddress2]: goerliName2,
+ },
+ '1': {
+ [mainnetAddress1]: mainnetName1,
+ [mainnetAddress2]: mainnetName2,
+ },
+ },
+ },
+ })
+
+ const { result } = renderHook(() => useGlobalImportJsonParser(jsonData))
+
+ const { addressBook, addressBookEntriesCount } = result.current
+
+ expect(addressBookEntriesCount).toEqual(4)
+
+ if (!addressBook) {
+ fail('No address book found')
+ }
+
+ expect(addressBook['5'][goerliAddress1]).toEqual(goerliName1)
+ expect(addressBook['5'][goerliAddress2]).toEqual(goerliName2)
+
+ expect(addressBook['1'][mainnetAddress1]).toEqual(mainnetName1)
+ expect(addressBook['1'][mainnetAddress2]).toEqual(mainnetName2)
+ })
+
+ it('should parse v2 settings correctly', () => {
+ const jsonData = JSON.stringify({
+ version: '2.0',
+ data: {
+ settings: {
+ currency: 'usd',
+ shortName: { show: true, copy: true, qr: true },
+ theme: { darkMode: false },
+ },
+ },
+ })
+
+ const { result } = renderHook(() => useGlobalImportJsonParser(jsonData))
+
+ const { settings } = result.current
+
+ if (!settings) {
+ fail('No settings found')
+ }
+
+ expect(settings.currency).toEqual('usd')
+
+ expect(settings.shortName.show).toEqual(true)
+ expect(settings.shortName.copy).toEqual(true)
+ expect(settings.shortName.qr).toEqual(true)
+
+ expect(settings.theme.darkMode).toEqual(false)
+ })
+
+ it('should parse v2 Safe app settings correctly', () => {
+ const jsonData = JSON.stringify({
+ version: '2.0',
+ data: {
+ safeApps: {
+ '5': {
+ pinned: [1, 2, 3],
+ },
+ '1': {
+ pinned: [4, 5, 6],
+ },
+ },
+ },
+ })
+
+ const { result } = renderHook(() => useGlobalImportJsonParser(jsonData))
+
+ const { safeApps } = result.current
+
+ if (!safeApps) {
+ fail('No Safe app settings found')
+ }
+
+ expect(safeApps['5'].pinned).toEqual([1, 2, 3])
+ expect(safeApps['1'].pinned).toEqual([4, 5, 6])
})
})
diff --git a/src/components/settings/DataManagement/index.tsx b/src/components/settings/DataManagement/index.tsx
index 595489c4..800334a6 100644
--- a/src/components/settings/DataManagement/index.tsx
+++ b/src/components/settings/DataManagement/index.tsx
@@ -1,42 +1,144 @@
-import { useState } from 'react'
-import { Paper, Grid, Typography, Button, Link } from '@mui/material'
-import Track from '@/components/common/Track'
-import { SETTINGS_EVENTS } from '@/services/analytics'
-import ImportAllDialog from '../ImportAllDialog'
+import { useEffect, useState } from 'react'
+import { Paper, Grid, Typography, Button, SvgIcon, Box } from '@mui/material'
+
+import FileIcon from '@/public/images/settings/data/file.svg'
+import ExportIcon from '@/public/images/common/export.svg'
+import { getPersistedState, useAppSelector } from '@/store'
+import { addressBookSlice, selectAllAddressBooks } from '@/store/addressBookSlice'
+import { addedSafesSlice, selectAllAddedSafes } from '@/store/addedSafesSlice'
+import { safeAppsSlice, selectSafeApps } from '@/store/safeAppsSlice'
+import { selectSettings, settingsSlice } from '@/store/settingsSlice'
+// import InfoIcon from '@/public/images/notifications/info.svg'
+// import ExternalLink from '@/components/common/ExternalLink'
+import { ImportFileUpload } from '@/components/settings/DataManagement/ImportFileUpload'
+import { ImportDialog } from '@/components/settings/DataManagement/ImportDialog'
+import { SAFE_EXPORT_VERSION } from '@/components/settings/DataManagement/useGlobalImportFileParser'
+import { FileListCard } from '@/components/settings/DataManagement/FileListCard'
+import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics'
+
+import css from './styles.module.css'
+
+const getExportFileName = () => {
+ const today = new Date().toISOString().slice(0, 10)
+ return `safe-${today}.json`
+}
+
+export const exportAppData = () => {
+ // Extract the slices we want to export
+ const {
+ [addressBookSlice.name]: addressBook,
+ [addedSafesSlice.name]: addedSafes,
+ [settingsSlice.name]: setting,
+ [safeAppsSlice.name]: safeApps,
+ } = getPersistedState()
+
+ // Ensure they are under the same name as the slice
+ const exportData = {
+ [addressBookSlice.name]: addressBook,
+ [addedSafesSlice.name]: addedSafes,
+ [settingsSlice.name]: setting,
+ [safeAppsSlice.name]: safeApps,
+ }
+
+ const data = JSON.stringify({ version: SAFE_EXPORT_VERSION.V2, data: exportData })
+
+ const blob = new Blob([data], { type: 'text/json' })
+ const link = document.createElement('a')
+
+ link.download = getExportFileName()
+ link.href = window.URL.createObjectURL(blob)
+ link.dataset.downloadurl = ['text/json', link.download, link.href].join(':')
+ link.dispatchEvent(new MouseEvent('click'))
+
+ trackEvent(SETTINGS_EVENTS.DATA.EXPORT_ALL_BUTTON)
+}
const DataManagement = () => {
- const [modalOpen, setModalOpen] = useState(false)
+ const [exportFileName, setExportFileName] = useState('')
+ const [importFileName, setImportFileName] = useState()
+ const [jsonData, setJsonData] = useState()
+
+ const addedSafes = useAppSelector(selectAllAddedSafes)
+ const addressBook = useAppSelector(selectAllAddressBooks)
+ const settings = useAppSelector(selectSettings)
+ const safeApps = useAppSelector(selectSafeApps)
+
+ useEffect(() => {
+ // Prevent hydration errors
+ setExportFileName(getExportFileName())
+ }, [])
return (
-
-
-
-
- Data import
-
-
+ <>
+
+
+
+
+ Data export
+
+
-
-
- You can export your data from the{' '}
-
- old app
-
- .
-
+
+ Download your local data with your added Safe Accounts, address book and settings.
+
+ `${shape.borderRadius}px` }}>
+
+
+ }
+ title={{exportFileName}}
+ action={
+
+ }
+ addedSafes={addedSafes}
+ addressBook={addressBook}
+ settings={settings}
+ safeApps={safeApps}
+ />
+ {/*
+
+ You can also export your data from the{' '}
+ old app
+ */}
+
+
+
- The imported data will overwrite all added Safes and all address book entries.
+
+
+
+
+ Data import
+
+
-
+
+
+
- {modalOpen && setModalOpen(false)} />}
+ {jsonData && (
+
+ )}
-
-
+
+ >
)
}
diff --git a/src/components/settings/DataManagement/styles.module.css b/src/components/settings/DataManagement/styles.module.css
new file mode 100644
index 00000000..f5450388
--- /dev/null
+++ b/src/components/settings/DataManagement/styles.module.css
@@ -0,0 +1,48 @@
+.card {
+ width: 100%;
+ border: 1px solid var(--color-border-light);
+ margin: var(--space-2) 0;
+}
+
+.fileIcon {
+ display: flex;
+ align-items: center;
+ padding: var(--space-1);
+ border: 1px solid var(--color-text-primary);
+}
+
+.exportIcon {
+ min-width: unset;
+ padding: var(--space-1);
+}
+
+.header {
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.header :global .MuiCardHeader-avatar {
+ margin-right: var(--space-2);
+}
+
+.header :global .MuiCardHeader-action {
+ align-self: center;
+ margin: 0;
+}
+
+.content {
+ padding: var(--space-3);
+}
+
+.listIcon {
+ min-width: unset;
+ margin-right: var(--space-3);
+ padding-top: var(--space-1);
+ align-self: flex-start;
+}
+
+.networkIcon {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ margin-right: calc(var(--space-1) / 2);
+}
diff --git a/src/components/settings/DataManagement/useGlobalImportFileParser.ts b/src/components/settings/DataManagement/useGlobalImportFileParser.ts
new file mode 100644
index 00000000..105cfc86
--- /dev/null
+++ b/src/components/settings/DataManagement/useGlobalImportFileParser.ts
@@ -0,0 +1,133 @@
+import { logError } from '@/services/exceptions'
+import ErrorCodes from '@/services/exceptions/ErrorCodes'
+import { migrateAddedSafes } from '@/services/ls-migration/addedSafes'
+import { migrateAddressBook } from '@/services/ls-migration/addressBook'
+import { isChecksummedAddress } from '@/utils/addresses'
+import type { AddressBook, AddressBookState } from '@/store/addressBookSlice'
+import type { AddedSafesState } from '@/store/addedSafesSlice'
+import type { SafeAppsState } from '@/store/safeAppsSlice'
+import type { SettingsState } from '@/store/settingsSlice'
+
+import { useMemo } from 'react'
+
+export const enum SAFE_EXPORT_VERSION {
+ V1 = '1.0',
+ V2 = '2.0',
+}
+
+export enum ImportErrors {
+ INVALID_VERSION = 'The file is not a Evmos Safe export.',
+ INVALID_JSON_FORMAT = 'The JSON format is invalid.',
+ NO_IMPORT_DATA_FOUND = 'This file contains no importable data.',
+}
+
+const countEntries = (data: { [chainId: string]: { [address: string]: unknown } }) =>
+ Object.values(data).reduce((count, entry) => count + Object.keys(entry).length, 0)
+
+export const _filterValidAbEntries = (ab?: AddressBookState): AddressBookState | undefined => {
+ if (!ab) {
+ return undefined
+ }
+
+ return Object.entries(ab).reduce((acc, [chainId, chainAb]) => {
+ const sanitizedChainAb = Object.entries(chainAb).reduce((acc, [address, name]) => {
+ // Legacy imported address books could have undefined name or address entries
+ if (name?.trim() && address && isChecksummedAddress(address)) {
+ acc[address] = name
+ }
+ return acc
+ }, {})
+
+ if (Object.keys(sanitizedChainAb).length > 0) {
+ acc[chainId] = sanitizedChainAb
+ }
+
+ return acc
+ }, {})
+}
+
+/**
+ * The global import currently imports:
+ * 1.0:
+ * - address book
+ * - added Safes
+ *
+ * 2.0:
+ * - address book
+ * - added Safes
+ * - safeApps
+ * - settings
+ *
+ * @param jsonData
+ * @returns data to import and some insights about it
+ */
+
+type Data = {
+ addedSafes?: AddedSafesState
+ addressBook?: AddressBookState
+ settings?: SettingsState
+ safeApps?: SafeAppsState
+ error?: ImportErrors
+ addressBookEntriesCount: number
+ addedSafesCount: number
+}
+
+export const useGlobalImportJsonParser = (jsonData: string | undefined): Data => {
+ return useMemo(() => {
+ const data: Data = {
+ addressBookEntriesCount: 0,
+ addedSafesCount: 0,
+ addressBook: undefined,
+ addedSafes: undefined,
+ settings: undefined,
+ safeApps: undefined,
+ }
+
+ if (!jsonData) {
+ return data
+ }
+
+ let parsedFile
+
+ try {
+ parsedFile = JSON.parse(jsonData)
+ } catch (err) {
+ logError(ErrorCodes._704, (err as Error).message)
+
+ data.error = ImportErrors.INVALID_JSON_FORMAT
+ return data
+ }
+
+ if (!parsedFile.data || Object.keys(parsedFile.data).length === 0) {
+ data.error = ImportErrors.NO_IMPORT_DATA_FOUND
+ return data
+ }
+
+ switch (parsedFile.version) {
+ case SAFE_EXPORT_VERSION.V1: {
+ data.addressBook = migrateAddressBook(parsedFile.data) ?? undefined
+ data.addedSafes = migrateAddedSafes(parsedFile.data) ?? undefined
+
+ break
+ }
+
+ case SAFE_EXPORT_VERSION.V2: {
+ data.addressBook = _filterValidAbEntries(parsedFile.data.addressBook)
+ data.addedSafes = parsedFile.data.addedSafes
+ data.settings = parsedFile.data.settings
+ data.safeApps = parsedFile.data.safeApps
+
+ break
+ }
+
+ default: {
+ data.error = ImportErrors.INVALID_VERSION
+ }
+ }
+
+ data.addressBookEntriesCount = data.addressBook ? countEntries(data.addressBook) : 0
+ data.addedSafesCount = data.addedSafes ? countEntries(data.addedSafes) : 0
+
+ return data
+ }, [jsonData])
+}
diff --git a/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx b/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx
index aa501399..ae58bfed 100644
--- a/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx
+++ b/src/components/settings/EnvironmentVariables/EnvHintButton/index.tsx
@@ -6,10 +6,12 @@ import { useAppSelector } from '@/store'
import { isEnvInitialState } from '@/store/settingsSlice'
import css from './styles.module.css'
import AlertIcon from '@/public/images/common/alert.svg'
+import useChainId from '@/hooks/useChainId'
const EnvHintButton = () => {
const router = useRouter()
- const isInitialState = useAppSelector(isEnvInitialState)
+ const chainId = useChainId()
+ const isInitialState = useAppSelector((state) => isEnvInitialState(state, chainId))
if (isInitialState) {
return null
diff --git a/src/components/settings/EnvironmentVariables/index.tsx b/src/components/settings/EnvironmentVariables/index.tsx
index 2435ee0b..d2b45729 100644
--- a/src/components/settings/EnvironmentVariables/index.tsx
+++ b/src/components/settings/EnvironmentVariables/index.tsx
@@ -3,7 +3,7 @@ import { Paper, Grid, Typography, TextField, Button, Tooltip, IconButton, SvgIco
import InputAdornment from '@mui/material/InputAdornment'
import RotateLeftIcon from '@mui/icons-material/RotateLeft'
import { useAppDispatch, useAppSelector } from '@/store'
-import { selectSettings, setEnv } from '@/store/settingsSlice'
+import { selectSettings, setRpc, setTenderly } from '@/store/settingsSlice'
import { TENDERLY_SIMULATE_ENDPOINT_URL } from '@/config/constants'
import useChainId from '@/hooks/useChainId'
import { useCurrentChain } from '@/hooks/useChains'
@@ -46,15 +46,21 @@ const EnvironmentVariables = () => {
const onSubmit = handleSubmit((data) => {
trackEvent({ ...SETTINGS_EVENTS.ENV_VARIABLES.SAVE })
+
+ dispatch(
+ setRpc({
+ chainId,
+ rpc: data[EnvVariablesField.rpc],
+ }),
+ )
+
dispatch(
- setEnv({
- rpc: data[EnvVariablesField.rpc] ? { [chainId]: data[EnvVariablesField.rpc] } : {},
- tenderly: {
- url: data[EnvVariablesField.tenderlyURL],
- accessToken: data[EnvVariablesField.tenderlyToken],
- },
+ setTenderly({
+ url: data[EnvVariablesField.tenderlyURL],
+ accessToken: data[EnvVariablesField.tenderlyToken],
}),
)
+
location.reload()
})
diff --git a/src/components/settings/FallbackHandler/__tests__/index.test.tsx b/src/components/settings/FallbackHandler/__tests__/index.test.tsx
new file mode 100644
index 00000000..1cb7be45
--- /dev/null
+++ b/src/components/settings/FallbackHandler/__tests__/index.test.tsx
@@ -0,0 +1,240 @@
+import { act, fireEvent, render, waitFor } from '@/tests/test-utils'
+
+import * as useSafeInfoHook from '@/hooks/useSafeInfo'
+import * as useTxBuilderHook from '@/hooks/safe-apps/useTxBuilderApp'
+import { FallbackHandler } from '..'
+
+const GOERLI_FALLBACK_HANDLER = '0xf48f2B2d2a534e402487b3ee7C18c33Aec0Fe5e4'
+
+describe('FallbackHandler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => ({
+ link: { href: 'https://mock.link/tx-builder' },
+ }))
+ })
+
+ it('should render the Fallback Handler when one is set', async () => {
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.3.0',
+ chainId: '5',
+ fallbackHandler: {
+ value: GOERLI_FALLBACK_HANDLER,
+ name: 'FallbackHandlerName',
+ },
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ await waitFor(() => {
+ expect(
+ fbHandler.queryByText(
+ 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe contract. Learn more about the fallback handler',
+ ),
+ ).toBeDefined()
+
+ expect(fbHandler.getByText(GOERLI_FALLBACK_HANDLER)).toBeDefined()
+
+ expect(fbHandler.getByText('FallbackHandlerName')).toBeDefined()
+ })
+ })
+
+ it('should use the official deployment name if the address is official but no known name is present', async () => {
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.3.0',
+ chainId: '5',
+ fallbackHandler: {
+ value: GOERLI_FALLBACK_HANDLER,
+ },
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ await waitFor(() => {
+ expect(fbHandler.getByText('CompatibilityFallbackHandler')).toBeDefined()
+ })
+ })
+
+ describe('No Fallback Handler', () => {
+ it('should render the Fallback Handler and warning tooltip when no Fallback Handler is set', async () => {
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.3.0',
+ chainId: '5',
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ await waitFor(() => {
+ expect(fbHandler.getByText('No fallback handler set')).toBeDefined()
+ })
+
+ const icon = fbHandler.getByTestId('fallback-handler-warning')
+
+ await act(() => {
+ fireEvent(
+ icon,
+ new MouseEvent('mouseover', {
+ bubbles: true,
+ }),
+ )
+ })
+
+ await waitFor(() => {
+ expect(
+ fbHandler.queryByText(
+ new RegExp('The Evmos Safe may not work correctly as no fallback handler is currently set.'),
+ ),
+ ).toBeInTheDocument()
+ expect(fbHandler.queryByText('Transaction Builder')).toBeInTheDocument()
+ })
+ })
+
+ it('should conditionally append the Transaction Builder link', async () => {
+ jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => undefined)
+
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.3.0',
+ chainId: '5',
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ const icon = fbHandler.getByTestId('fallback-handler-warning')
+
+ await act(() => {
+ fireEvent(
+ icon,
+ new MouseEvent('mouseover', {
+ bubbles: true,
+ }),
+ )
+ })
+
+ await waitFor(() => {
+ expect(
+ fbHandler.queryByText(
+ new RegExp('The Evmos Safe may not work correctly as no fallback handler is currently set.'),
+ ),
+ ).toBeInTheDocument()
+ expect(fbHandler.queryByText('Transaction Builder')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Unofficial Fallback Handler', () => {
+ it('should render placeholder and warning tooltip when an unofficial Fallback Handler is set', async () => {
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.3.0',
+ chainId: '5',
+ fallbackHandler: {
+ value: '0x123',
+ },
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ await waitFor(() => {
+ expect(
+ fbHandler.queryByText(
+ 'The fallback handler adds fallback logic for funtionality that may not be present in the Safe Account contract. Learn more about the fallback handler',
+ ),
+ ).toBeDefined()
+
+ expect(fbHandler.getByText('0x123')).toBeDefined()
+ })
+
+ const icon = fbHandler.getByTestId('fallback-handler-warning')
+
+ await act(() => {
+ fireEvent(
+ icon,
+ new MouseEvent('mouseover', {
+ bubbles: true,
+ }),
+ )
+ })
+
+ await waitFor(() => {
+ expect(fbHandler.queryByText(new RegExp('An unofficial fallback handler is currently set.')))
+ expect(fbHandler.queryByText('Transaction Builder')).toBeInTheDocument()
+ })
+ })
+
+ it('should conditionally append the Transaction Builder link', async () => {
+ jest.spyOn(useTxBuilderHook, 'useTxBuilderApp').mockImplementation(() => undefined)
+
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.3.0',
+ chainId: '5',
+ fallbackHandler: {
+ value: '0x123',
+ },
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ const icon = fbHandler.getByTestId('fallback-handler-warning')
+
+ await act(() => {
+ fireEvent(
+ icon,
+ new MouseEvent('mouseover', {
+ bubbles: true,
+ }),
+ )
+ })
+
+ await waitFor(() => {
+ expect(fbHandler.queryByText(new RegExp('An unofficial fallback handler is currently set.')))
+ expect(fbHandler.queryByText('Transaction Builder')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ it('should render nothing if the Safe Account version does not support Fallback Handlers', () => {
+ jest.spyOn(useSafeInfoHook, 'default').mockImplementation(
+ () =>
+ ({
+ safe: {
+ version: '1.0.0',
+ chainId: '5',
+ },
+ } as unknown as ReturnType),
+ )
+
+ const fbHandler = render()
+
+ expect(fbHandler.container).toBeEmptyDOMElement()
+ })
+})
diff --git a/src/components/settings/FallbackHandler/index.tsx b/src/components/settings/FallbackHandler/index.tsx
new file mode 100644
index 00000000..aba9f2df
--- /dev/null
+++ b/src/components/settings/FallbackHandler/index.tsx
@@ -0,0 +1,119 @@
+import NextLink from 'next/link'
+import { Typography, Box, SvgIcon, Tooltip, Grid, Paper, Link } from '@mui/material'
+import semverSatisfies from 'semver/functions/satisfies'
+import { useMemo } from 'react'
+import type { ReactElement } from 'react'
+
+import EthHashInfo from '@/components/common/EthHashInfo'
+import AlertIcon from '@/public/images/common/alert.svg'
+import useSafeInfo from '@/hooks/useSafeInfo'
+import { getFallbackHandlerDeployment } from '@safe-global/safe-deployments'
+import { HelpCenterArticle, LATEST_SAFE_VERSION } from '@/config/constants'
+import ExternalLink from '@/components/common/ExternalLink'
+import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp'
+
+import css from '../SafeModules/styles.module.css'
+
+const FALLBACK_HANDLER_VERSION = '>=1.1.1'
+
+export const FallbackHandler = (): ReactElement | null => {
+ const { safe } = useSafeInfo()
+ const txBuilder = useTxBuilderApp()
+
+ const supportsFallbackHandler = !!safe.version && semverSatisfies(safe.version, FALLBACK_HANDLER_VERSION)
+
+ const fallbackHandlerDeployment = useMemo(() => {
+ return getFallbackHandlerDeployment({
+ version: safe.version || LATEST_SAFE_VERSION,
+ network: safe.chainId,
+ })
+ }, [safe.version, safe.chainId])
+
+ if (!supportsFallbackHandler) {
+ return null
+ }
+
+ const isOfficial = !!safe.fallbackHandler && safe.fallbackHandler.value === fallbackHandlerDeployment?.defaultAddress
+
+ const tooltip = !safe.fallbackHandler ? (
+ <>
+ The {'Evmos Safe'} may not work correctly as no fallback handler is currently set.
+ {txBuilder && (
+ <>
+ {' '}
+ It can be set via the{' '}
+
+ Transaction Builder
+
+ .
+ >
+ )}
+ >
+ ) : !isOfficial ? (
+ <>
+ An unofficial fallback handler is currently set.
+ {txBuilder && (
+ <>
+ {' '}
+ It can be altered via the{' '}
+
+ Transaction Builder
+
+ .
+ >
+ )}
+ >
+ ) : undefined
+
+ return (
+
+
+
+
+ Fallback handler
+ {tooltip && (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ The fallback handler adds fallback logic for funtionality that may not be present in the Safe Account
+ contract. Learn more about the fallback handler{' '}
+ here
+
+ {safe.fallbackHandler ? (
+
+
+
+ ) : (
+ palette.primary.light}>
+ No fallback handler set
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/settings/ImportAllDialog/documentation.md b/src/components/settings/ImportAllDialog/documentation.md
deleted file mode 100644
index 6d8b8446..00000000
--- a/src/components/settings/ImportAllDialog/documentation.md
+++ /dev/null
@@ -1,91 +0,0 @@
-## Data Import / Export
-
-Currently we only support the importing of data from our old web interface (safe-react) to the new one (web-core).
-
-### How does the export work?
-
-In the old interface navigate to `Settings -> Details -> Download your data`. This button will download a `.json` file which contains the **entire localStorage**.
-The export files have this format:
-
-```json
-{
- "version": "1.0",
- "data": {
-
- }
-}
-```
-
-### How does the import work?
-
-In the new interface navigate to `/import` or `Settings -> Data` and open the _Import all data_ modal.
-
-This will only import specific data:
-
-- The added Safes
-- The (valid\*) address book entries
-
-* Only named, checksummed address book entries will be added.
-
-#### Address book
-
-All address book entries are stored under the key `SAFE__addressBook`.
-This entry contains a stringified address book with the following format:
-
-```ts
-{
- address: string
- name: string
- chainId: string
-}
-;[]
-```
-
-Example:
-
-```json
-{
- "version": "1.0",
- "data": {
- "SAFE__addressBook": "[{\"address\":\"0xB5E64e857bb7b5350196C5BAc8d639ceC1072745\",\"name\":\"Testname\",\"chainId\":\"5\"},{\"address\":\"0x08f6466dD7891ac9A60C769c7521b0CF2F60c153\",\"name\":\"authentic-goerli-safe\",\"chainId\":\"5\"}]"
- }
-}
-```
-
-#### Added safes
-
-Added safes are stored under one entry per chain.
-Each entry has a key in following format: `_immortal|v2___SAFES`
-The chain prefix is either the chain ID or prefix, as follows:
-
-```
- '1': 'MAINNET',
- '56': 'BSC',
- '100': 'XDAI',
- '137': 'POLYGON',
- '246': 'ENERGY_WEB_CHAIN',
- '42161': 'ARBITRUM',
- '73799': 'VOLTA',
-```
-
-Examples:
-
-- `_immortal|v2_MAINNET__SAFES` for mainnet
-- `_immortal|v2_5__SAFES` for goerli (chainId 5)
-
-Inside each of these keys the full Safe information (including balances) is stored in stringified format.
-Example:
-
-```json
-{
- "version": "1.0",
- "data": {
- "_immortal|v2_5__SAFES": "{\"0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b\":{\"address\":\"0xAecDFD3A19f777F0c03e6bf99AAfB59937d6467b\",\"chainId\":\"5\",\"threshold\":2,\"ethBalance\":\"0.3\",\"totalFiatBalance\":\"435.08\",\"owners\":[\"0x3819b800c67Be64029C1393c8b2e0d0d627dADE2\",\"0x954cD69f0E902439f99156e3eeDA080752c08401\",\"0xB5E64e857bb7b5350196C5BAc8d639ceC1072745\"],\"modules\":[],\"spendingLimits\":[],\"balances\":[{\"tokenAddress\":\"0x0000000000000000000000000000000000000000\",\"fiatBalance\":\"435.08100\",\"tokenBalance\":\"0.3\"},{\"tokenAddress\":\"0x61fD3b6d656F39395e32f46E2050953376c3f5Ff\",\"fiatBalance\":\"0.00000\",\"tokenBalance\":\"22405.086233211233211233\"}],\"implementation\":{\"value\":\"0x3E5c63644E683549055b9Be8653de26E0B4CD36E\"},\"loaded\":true,\"nonce\":1,\"currentVersion\":\"1.3.0+L2\",\"needsUpdate\":false,\"featuresEnabled\":[\"CONTRACT_INTERACTION\",\"DOMAIN_LOOKUP\",\"EIP1559\",\"ERC721\",\"SAFE_APPS\",\"SAFE_TX_GAS_OPTIONAL\",\"SPENDING_LIMIT\",\"TX_SIMULATION\",\"WARNING_BANNER\"],\"loadedViaUrl\":false,\"guard\":\"\",\"collectiblesTag\":\"1667921524\",\"txQueuedTag\":\"1667921524\",\"txHistoryTag\":\"1667400927\"}}"
- }
-}
-```
-
-### Noteworthy
-
-- Only address book entries with names and checksummed addresses will be imported.
-- Rinkeby data will be ignored as it's not supported anymore.
diff --git a/src/components/settings/ImportAllDialog/index.tsx b/src/components/settings/ImportAllDialog/index.tsx
deleted file mode 100644
index a8f20315..00000000
--- a/src/components/settings/ImportAllDialog/index.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import DialogContent from '@mui/material/DialogContent'
-import DialogActions from '@mui/material/DialogActions'
-import Button from '@mui/material/Button'
-import Typography from '@mui/material/Typography'
-import { type ReactElement, useState } from 'react'
-
-import ModalDialog from '@/components/common/ModalDialog'
-import { useAppDispatch } from '@/store'
-
-import { useDropzone } from 'react-dropzone'
-import { addedSafesSlice } from '@/store/addedSafesSlice'
-import { addressBookSlice } from '@/store/addressBookSlice'
-
-import css from './styles.module.css'
-import type { MouseEventHandler } from 'react'
-import { useGlobalImportJsonParser } from './useGlobalImportFileParser'
-import { showNotification } from '@/store/notificationsSlice'
-import { Alert, AlertTitle } from '@mui/material'
-import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics'
-import FileUpload, { FileTypes, type FileInfo } from '@/components/common/FileUpload'
-
-const AcceptedMimeTypes = {
- 'application/json': ['.json'],
-}
-
-const ImportAllDialog = ({ handleClose }: { handleClose: () => void }): ReactElement => {
- const [jsonData, setJsonData] = useState()
- const [fileName, setFileName] = useState()
-
- // Parse the jsonData whenever it changes
- const { addedSafes, addedSafesCount, addressBook, addressBookEntriesCount, error } =
- useGlobalImportJsonParser(jsonData)
-
- const dispatch = useAppDispatch()
-
- const onDrop = (acceptedFiles: File[]) => {
- if (acceptedFiles.length === 0) {
- return
- }
- const file = acceptedFiles[0]
- const reader = new FileReader()
- reader.onload = (event) => {
- if (!event.target) {
- return
- }
- if (typeof event.target.result !== 'string') {
- return
- }
- setJsonData(event.target.result)
- setFileName(file.name)
- }
- reader.readAsText(file)
- }
-
- const onRemove: MouseEventHandler = (event) => {
- setJsonData(undefined)
- setFileName(undefined)
- event.preventDefault()
- event.stopPropagation()
- }
-
- const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
- maxFiles: 1,
- onDrop,
- accept: AcceptedMimeTypes,
- })
-
- const handleImport = () => {
- if (!addressBook && !addedSafes) {
- return
- }
-
- if (addressBook) {
- dispatch(addressBookSlice.actions.setAddressBook(addressBook))
-
- trackEvent({
- ...SETTINGS_EVENTS.DATA.IMPORT_ADDRESS_BOOK,
- label: addressBookEntriesCount,
- })
- }
-
- if (addedSafes) {
- dispatch(addedSafesSlice.actions.setAddedSafes(addedSafes))
-
- trackEvent({
- ...SETTINGS_EVENTS.DATA.IMPORT_ADDED_SAFES,
- label: addedSafesCount,
- })
- }
-
- dispatch(
- showNotification({
- variant: 'success',
- groupKey: 'global-import-success',
- message: 'Successfully imported data',
- detailedMessage: [
- ...(addedSafesCount > 0 ? [`${addedSafesCount} Safes were added.`] : []),
- ...(addressBookEntriesCount > 0
- ? [`${addressBookEntriesCount} addresses were added to your address book.`]
- : []),
- ].join('\n'),
- }),
- )
-
- handleClose()
- }
-
- const fileInfo: FileInfo | undefined = fileName
- ? {
- name: fileName,
- error,
- summary: [
- ...(addedSafesCount > 0 && addedSafes
- ? [
-
- Found {addedSafesCount} Added Safes entries on{' '}
- {Object.keys(addedSafes).length} chain(s)
- ,
- ]
- : []),
- ...(addressBookEntriesCount > 0 && addressBook
- ? [
-
- Found {addressBookEntriesCount} Address book entries on{' '}
- {Object.keys(addressBook).length} chain(s)
- ,
- ]
- : []),
- ],
- }
- : undefined
-
- return (
-
-
-
-
-
-
- Only JSON files exported from a Safe can be imported.
-
- Overwrite your current data?
- This action will overwrite your currently added Safes and address book entries with those from the imported
- file.
-
-
-
-
-
-
-
- )
-}
-
-export default ImportAllDialog
diff --git a/src/components/settings/ImportAllDialog/styles.module.css b/src/components/settings/ImportAllDialog/styles.module.css
deleted file mode 100644
index 4891aa8b..00000000
--- a/src/components/settings/ImportAllDialog/styles.module.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.horizontalDivider {
- display: flex;
- margin: 24px -24px;
- border-top: 2px solid rgba(0, 0, 0, 0.12);
-}
diff --git a/src/components/settings/ImportAllDialog/useGlobalImportFileParser.ts b/src/components/settings/ImportAllDialog/useGlobalImportFileParser.ts
deleted file mode 100644
index 40e07cf3..00000000
--- a/src/components/settings/ImportAllDialog/useGlobalImportFileParser.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { logError } from '@/services/exceptions'
-import ErrorCodes from '@/services/exceptions/ErrorCodes'
-import { migrateAddedSafes } from '@/services/ls-migration/addedSafes'
-import { migrateAddressBook } from '@/services/ls-migration/addressBook'
-import { useMemo } from 'react'
-
-const V1 = '1.0'
-
-export enum ImportErrors {
- INVALID_VERSION = 'The file is not a Safe export.',
- INVALID_JSON_FORMAT = 'The JSON format is invalid.',
- NO_IMPORT_DATA_FOUND = 'This file contains no importable data.',
-}
-
-const countEntries = (data: { [chainId: string]: { [address: string]: unknown } }) =>
- Object.values(data).reduce((count, entry) => count + Object.keys(entry).length, 0)
-
-/**
- * The global import currently imports:
- * - all addressbook entries
- * - all addedSafes
- *
- * @param jsonData
- * @returns data to import and some insights about it
- */
-export const useGlobalImportJsonParser = (jsonData: string | undefined) => {
- const [migrationAddedSafes, migrationAddressbook, addressBookEntriesCount, addedSafesCount, error] = useMemo(() => {
- if (!jsonData) {
- return [undefined, undefined, 0, 0, undefined]
- }
- try {
- const parsedFile = JSON.parse(jsonData)
-
- // We only understand v1 data so far
- if (!parsedFile.data || parsedFile.version !== V1) {
- return [undefined, undefined, 0, 0, ImportErrors.INVALID_VERSION]
- }
-
- const abData = migrateAddressBook(parsedFile.data)
- const addedSafesData = migrateAddedSafes(parsedFile.data)
-
- const abCount = abData ? countEntries(abData) : 0
- const addedSafesCount = addedSafesData ? countEntries(addedSafesData) : 0
-
- return [
- addedSafesData,
- abData,
- abCount,
- addedSafesCount,
- !abData && !addedSafesData ? ImportErrors.NO_IMPORT_DATA_FOUND : undefined,
- ]
- } catch (err) {
- logError(ErrorCodes._704, (err as Error).message)
- return [undefined, undefined, 0, 0, ImportErrors.INVALID_JSON_FORMAT]
- }
- }, [jsonData])
-
- return {
- addedSafes: migrationAddedSafes,
- addressBook: migrationAddressbook,
- addressBookEntriesCount,
- addedSafesCount,
- error,
- }
-}
diff --git a/src/components/settings/RequiredConfirmations/index.tsx b/src/components/settings/RequiredConfirmations/index.tsx
index a7da15db..74837884 100644
--- a/src/components/settings/RequiredConfirmations/index.tsx
+++ b/src/components/settings/RequiredConfirmations/index.tsx
@@ -1,15 +1,7 @@
import { ChangeThresholdDialog } from '@/components/settings/owner/ChangeThresholdDialog'
import { Box, Grid, Typography } from '@mui/material'
-export const RequiredConfirmation = ({
- threshold,
- owners,
- isGranted,
-}: {
- threshold: number
- owners: number
- isGranted: boolean
-}) => {
+export const RequiredConfirmation = ({ threshold, owners }: { threshold: number; owners: number }) => {
return (
@@ -24,7 +16,8 @@ export const RequiredConfirmation = ({
{threshold} out of {owners} owners.
- {isGranted && owners > 1 && }
+
+ {owners > 1 && }
diff --git a/src/components/settings/SafeAppsSigningMethod/index.test.tsx b/src/components/settings/SafeAppsSigningMethod/index.test.tsx
new file mode 100644
index 00000000..b4bc1547
--- /dev/null
+++ b/src/components/settings/SafeAppsSigningMethod/index.test.tsx
@@ -0,0 +1,27 @@
+import { act, fireEvent, render } from '@/tests/test-utils'
+import { SafeAppsSigningMethod } from '.'
+
+describe('SafeAppsSigningMethod', () => {
+ it('Toggle on-chain signing', async () => {
+ const result = render(, {
+ initialReduxState: {
+ settings: {
+ signing: {
+ useOnChainSigning: false,
+ },
+ } as any,
+ },
+ })
+
+ const checkbox = result.getByRole('checkbox')
+ expect(checkbox).not.toBeChecked()
+
+ act(() => fireEvent.click(checkbox))
+
+ expect(checkbox).toBeChecked()
+
+ act(() => fireEvent.click(checkbox))
+
+ expect(checkbox).not.toBeChecked()
+ })
+})
diff --git a/src/components/settings/SafeAppsSigningMethod/index.tsx b/src/components/settings/SafeAppsSigningMethod/index.tsx
new file mode 100644
index 00000000..88f9e104
--- /dev/null
+++ b/src/components/settings/SafeAppsSigningMethod/index.tsx
@@ -0,0 +1,51 @@
+import ExternalLink from '@/components/common/ExternalLink'
+import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics'
+import { useAppDispatch, useAppSelector } from '@/store'
+import { selectOnChainSigning, setOnChainSigning } from '@/store/settingsSlice'
+import { FormControlLabel, Checkbox, Paper, Typography, FormGroup, Grid } from '@mui/material'
+
+export const SafeAppsSigningMethod = () => {
+ const onChainSigning = useAppSelector(selectOnChainSigning)
+
+ const dispatch = useAppDispatch()
+
+ const onChange = () => {
+ trackEvent(SETTINGS_EVENTS.SAFE_APPS.CHANGE_SIGNING_METHOD)
+ dispatch(setOnChainSigning(!onChainSigning))
+ }
+
+ return (
+
+
+
+
+ Signing method
+
+
+
+
+
+ This setting determines how the {'Evmos Safe'} will sign message requests from Safe Apps. Gasless, off-chain
+ signing is used by default. Learn more about message signing{' '}
+
+ here
+
+ .
+
+
+ ({
+ flex: 1,
+ '.MuiIconButton-root:not(.Mui-checked)': {
+ color: palette.text.disabled,
+ },
+ })}
+ control={}
+ label="Always use on-chain signatures"
+ />
+
+
+
+
+ )
+}
diff --git a/src/components/settings/SafeModules/RemoveModule/index.tsx b/src/components/settings/SafeModules/RemoveModule/index.tsx
index 0fb54388..6423c420 100644
--- a/src/components/settings/SafeModules/RemoveModule/index.tsx
+++ b/src/components/settings/SafeModules/RemoveModule/index.tsx
@@ -4,6 +4,7 @@ import { IconButton, SvgIcon } from '@mui/material'
import TxModal from '@/components/tx/TxModal'
import DeleteIcon from '@/public/images/common/delete.svg'
import { ReviewRemoveModule } from '@/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule'
+import CheckWallet from '@/components/common/CheckWallet'
export type RemoveModuleData = {
address: string
@@ -25,9 +26,14 @@ export const RemoveModule = ({ address }: { address: string }) => {
return (
<>
- setOpen(true)} color="error" size="small">
-
-
+
+ {(isOk) => (
+ setOpen(true)} color="error" size="small" disabled={!isOk}>
+
+
+ )}
+
+
{open && setOpen(false)} steps={RemoveModuleSteps} initialData={[initialData]} />}
>
)
diff --git a/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx b/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx
index ab7463a1..cef844ad 100644
--- a/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx
+++ b/src/components/settings/SafeModules/RemoveModule/steps/ReviewRemoveModule.tsx
@@ -1,6 +1,5 @@
import useAsync from '@/hooks/useAsync'
import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'
-import useTxSender from '@/hooks/useTxSender'
import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
import { Typography } from '@mui/material'
import SendToBlock from '@/components/tx/SendToBlock'
@@ -8,12 +7,12 @@ import type { RemoveModuleData } from '@/components/settings/SafeModules/RemoveM
import { useEffect } from 'react'
import { Errors, logError } from '@/services/exceptions'
import { trackEvent, SETTINGS_EVENTS } from '@/services/analytics'
+import { createRemoveModuleTx } from '@/services/tx/tx-sender'
export const ReviewRemoveModule = ({ data, onSubmit }: { data: RemoveModuleData; onSubmit: () => void }) => {
- const { createRemoveModuleTx } = useTxSender()
const [safeTx, safeTxError] = useAsync(() => {
return createRemoveModuleTx(data.address)
- }, [data.address, createRemoveModuleTx])
+ }, [data.address])
useEffect(() => {
if (safeTxError) {
@@ -31,8 +30,8 @@ export const ReviewRemoveModule = ({ data, onSubmit }: { data: RemoveModuleData;
- After removing this module, any feature or app that uses this module might no longer work. If this Safe requires
- more then one signature, the module removal will have to be confirmed by other owners as well.
+ After removing this module, any feature or app that uses this module might no longer work. If this Safe Account
+ requires more then one signature, the module removal will have to be confirmed by other owners as well.
)
diff --git a/src/components/settings/SafeModules/index.tsx b/src/components/settings/SafeModules/index.tsx
index 5361e1f2..26695c9e 100644
--- a/src/components/settings/SafeModules/index.tsx
+++ b/src/components/settings/SafeModules/index.tsx
@@ -4,7 +4,6 @@ import { Paper, Grid, Typography, Box } from '@mui/material'
import css from './styles.module.css'
import { RemoveModule } from '@/components/settings/SafeModules/RemoveModule'
-import useIsGranted from '@/hooks/useIsGranted'
import ExternalLink from '@/components/common/ExternalLink'
const NoModules = () => {
@@ -16,8 +15,6 @@ const NoModules = () => {
}
const ModuleDisplay = ({ moduleAddress, chainId, name }: { moduleAddress: string; chainId: string; name?: string }) => {
- const isGranted = useIsGranted()
-
return (
- {isGranted && }
+
)
}
@@ -42,15 +39,15 @@ const SafeModules = () => {
- Safe modules
+ Safe Account modules
- Modules allow you to customize the access-control logic of your Safe. Modules are potentially risky, so
- make sure to only use modules from trusted sources. Learn more about modules{' '}
+ Modules allow you to customize the access-control logic of your Safe Account. Modules are potentially
+ risky, so make sure to only use modules from trusted sources. Learn more about modules{' '}
here
{safeModules.length === 0 ? (
diff --git a/src/components/settings/SettingsHeader/index.tsx b/src/components/settings/SettingsHeader/index.tsx
index 7fefb885..9efcd616 100644
--- a/src/components/settings/SettingsHeader/index.tsx
+++ b/src/components/settings/SettingsHeader/index.tsx
@@ -2,10 +2,23 @@ import type { ReactElement } from 'react'
import NavTabs from '@/components/common/NavTabs'
import PageHeader from '@/components/common/PageHeader'
-import { settingsNavItems } from '@/components/sidebar/SidebarNavigation/config'
+import { generalSettingsNavItems, settingsNavItems } from '@/components/sidebar/SidebarNavigation/config'
+import css from '@/components/common/PageHeader/styles.module.css'
+import useSafeAddress from '@/hooks/useSafeAddress'
const SettingsHeader = (): ReactElement => {
- return } />
+ const safeAddress = useSafeAddress()
+
+ return (
+
+
+
+ }
+ />
+ )
}
export default SettingsHeader
diff --git a/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx b/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx
index 993ba495..fbaf98d1 100644
--- a/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx
+++ b/src/components/settings/SpendingLimits/NewSpendingLimit/index.tsx
@@ -6,6 +6,7 @@ import { SpendingLimitForm } from '@/components/settings/SpendingLimits/NewSpend
import { ReviewSpendingLimit } from '@/components/settings/SpendingLimits/NewSpendingLimit/steps/ReviewSpendingLimit'
import Track from '@/components/common/Track'
import { SETTINGS_EVENTS } from '@/services/analytics/events/settings'
+import CheckWallet from '@/components/common/CheckWallet'
const NewSpendingLimitSteps: TxStepperProps['steps'] = [
{
@@ -30,11 +31,16 @@ export const NewSpendingLimit = () => {
return (
<>
-