diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 733115ec43..e81535156c 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -69,6 +69,7 @@ runs: NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_FIREBASE_VAPID_KEY_STAGING }} NEXT_PUBLIC_SPINDL_SDK_KEY: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SPINDL_SDK_KEY }} NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS }} + NEXT_PUBLIC_HAS_SAFENET_FEATURE: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_HAS_SAFENET_FEATURE }} NEXT_PUBLIC_SAFENET_API_URL: ${{ fromJSON(inputs.secrets).NEXT_PUBLIC_SAFENET_API_URL }} - name: Save Next.js Build Cache & Cypress cache if: steps.restore-nc.outputs.cache-hit-nc != 'true' diff --git a/apps/web/src/components/common/ProgressBar/index.tsx b/apps/web/src/components/common/ProgressBar/index.tsx index 2a314d4be9..7ead373f2a 100644 --- a/apps/web/src/components/common/ProgressBar/index.tsx +++ b/apps/web/src/components/common/ProgressBar/index.tsx @@ -1,4 +1,4 @@ -import useIsSafenetEnabled from '@/hooks/useIsSafenetEnabled' +import useIsSafenetEnabled from '@/features/safenet/hooks/useIsSafenetEnabled' import type { LinearProgressProps } from '@mui/material' import { LinearProgress } from '@mui/material' diff --git a/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts b/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts index 845f3a3308..19424417c1 100644 --- a/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts +++ b/apps/web/src/components/safe-apps/AppFrame/useAppCommunicator.ts @@ -1,42 +1,44 @@ -import type { MutableRefObject } from 'react' -import { useEffect, useMemo, useState } from 'react' -import { getAddress } from 'ethers' -import type { - SafeAppData, - ChainInfo as WebCoreChainInfo, - TransactionDetails, -} from '@safe-global/safe-gateway-typescript-sdk' +import { SAFENET_API_URL } from '@/config/constants' +import { useHasSafenetFeature } from '@/features/safenet/hooks/useHasSafenetFeature' +import type { SafePermissionsRequest } from '@/hooks/safe-apps/permissions' +import { useDarkMode } from '@/hooks/useDarkMode' +import { createSafeAppsWeb3Provider } from '@/hooks/wallets/web3' +import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' +import { Errors, logError } from '@/services/exceptions' +import AppCommunicator from '@/services/safe-apps/AppCommunicator' +import { useAppSelector } from '@/store' +import { useGetSafenetConfigQuery } from '@/store/safenet' +import { selectRpc } from '@/store/settingsSlice' +import { QueryStatus } from '@reduxjs/toolkit/query' +import { skipToken } from '@reduxjs/toolkit/query/react' import type { AddressBookItem, BaseTransaction, + ChainInfo, EIP712TypedData, EnvironmentInfo, GetBalanceParams, GetTxBySafeTxHashParams, - RequestId, RPCPayload, + RequestId, + SafeBalances, + SafeInfoExtended, + SafeSettings, SendTransactionRequestParams, SendTransactionsParams, SignMessageParams, SignTypedMessageParams, - ChainInfo, - SafeBalances, - SafeInfoExtended, } from '@safe-global/safe-apps-sdk' import { Methods, RPC_CALLS } from '@safe-global/safe-apps-sdk' import type { Permission, PermissionRequest } from '@safe-global/safe-apps-sdk/dist/types/types/permissions' -import type { SafeSettings } from '@safe-global/safe-apps-sdk' -import AppCommunicator from '@/services/safe-apps/AppCommunicator' -import { Errors, logError } from '@/services/exceptions' -import type { SafePermissionsRequest } from '@/hooks/safe-apps/permissions' -import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' -import { useAppSelector } from '@/store' -import { selectRpc } from '@/store/settingsSlice' -import { createSafeAppsWeb3Provider } from '@/hooks/wallets/web3' -import { useDarkMode } from '@/hooks/useDarkMode' -import { QueryStatus } from '@reduxjs/toolkit/query' -import { SAFENET_API_URL } from '@/config/constants' -import { useGetSafenetConfigQuery } from '@/store/safenet' +import type { + SafeAppData, + TransactionDetails, + ChainInfo as WebCoreChainInfo, +} from '@safe-global/safe-gateway-typescript-sdk' +import { getAddress } from 'ethers' +import type { MutableRefObject } from 'react' +import { useEffect, useMemo, useState } from 'react' export enum CommunicatorMessages { REJECT_TRANSACTION_MESSAGE = 'Transaction was rejected', @@ -77,7 +79,10 @@ const useAppCommunicator = ( ): AppCommunicator | undefined => { const [communicator, setCommunicator] = useState(undefined) const customRpc = useAppSelector(selectRpc) - const { data: safenetConfig, status: safenetConfigStatus } = useGetSafenetConfigQuery() + const hasSafenetFeature = useHasSafenetFeature() + const { data: safenetConfig, status: safenetConfigStatus } = useGetSafenetConfigQuery( + !hasSafenetFeature ? skipToken : undefined, + ) const shouldUseSafenetRpc = safenetConfigStatus === QueryStatus.fulfilled && chain && diff --git a/apps/web/src/components/settings/SettingsHeader/index.tsx b/apps/web/src/components/settings/SettingsHeader/index.tsx index f20def13f1..0592e05e13 100644 --- a/apps/web/src/components/settings/SettingsHeader/index.tsx +++ b/apps/web/src/components/settings/SettingsHeader/index.tsx @@ -2,10 +2,12 @@ import type { ReactElement } from 'react' import NavTabs from '@/components/common/NavTabs' import PageHeader from '@/components/common/PageHeader' -import { generalSettingsNavItems, settingsNavItems } from '@/components/sidebar/SidebarNavigation/config' import css from '@/components/common/PageHeader/styles.module.css' -import useSafeAddress from '@/hooks/useSafeAddress' +import { generalSettingsNavItems, settingsNavItems } from '@/components/sidebar/SidebarNavigation/config' +import { AppRoutes } from '@/config/routes' +import { useHasSafenetFeature } from '@/features/safenet/hooks/useHasSafenetFeature' import { useCurrentChain } from '@/hooks/useChains' +import useSafeAddress from '@/hooks/useSafeAddress' import { isRouteEnabled } from '@/utils/chains' import madProps from '@/utils/mad-props' @@ -16,8 +18,13 @@ export const SettingsHeader = ({ safeAddress: ReturnType chain: ReturnType }): ReactElement => { + const hasSafenetFeature = useHasSafenetFeature() + const navItems = safeAddress - ? settingsNavItems.filter((route) => isRouteEnabled(route.href, chain)) + ? settingsNavItems.filter( + (route) => + isRouteEnabled(route.href, chain) || (hasSafenetFeature && route.href === AppRoutes.settings.safenet), + ) : generalSettingsNavItems return ( diff --git a/apps/web/src/components/sidebar/Sidebar/index.tsx b/apps/web/src/components/sidebar/Sidebar/index.tsx index 44af9276e1..4997c27a77 100644 --- a/apps/web/src/components/sidebar/Sidebar/index.tsx +++ b/apps/web/src/components/sidebar/Sidebar/index.tsx @@ -1,18 +1,21 @@ -import { useCallback, useState, type ReactElement } from 'react' -import { Box, Divider, Drawer } from '@mui/material' import ChevronRight from '@mui/icons-material/ChevronRight' +import { Box, Divider, Drawer } from '@mui/material' +import { useCallback, useState, type ReactElement } from 'react' import ChainIndicatorSafenet from '@/components/common/ChainIndicator/ChainIndicatorSafenet' +import IndexingStatus from '@/components/sidebar/IndexingStatus' +import SidebarFooter from '@/components/sidebar/SidebarFooter' import SidebarHeader from '@/components/sidebar/SidebarHeader' import SidebarNavigation from '@/components/sidebar/SidebarNavigation' -import SidebarFooter from '@/components/sidebar/SidebarFooter' -import IndexingStatus from '@/components/sidebar/IndexingStatus' -import css from './styles.module.css' -import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' +import ChainIndicator from '@/components/common/ChainIndicator' import MyAccounts from '@/features/myAccounts' +import useIsSafenetEnabled from '@/features/safenet/hooks/useIsSafenetEnabled' +import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import css from './styles.module.css' const Sidebar = (): ReactElement => { + const isSafenetEnabled = useIsSafenetEnabled() const [isDrawerOpen, setIsDrawerOpen] = useState(false) const onDrawerToggle = useCallback(() => { @@ -28,7 +31,7 @@ const Sidebar = (): ReactElement => { return (
- + {isSafenetEnabled ? : } {/* Open the safes list */}
diff --git a/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css b/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css index 112d80e1dd..96f1f2223e 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css +++ b/apps/web/src/components/tx/SignOrExecuteForm/styles.module.css @@ -75,11 +75,3 @@ border-radius: 4px; padding: 2px 8px; } - -.safenetGradientCard { - margin-bottom: var(--space-2); -} - -.safenetGradientCard > div:last-child > div:first-child { - margin-bottom: 0; -} diff --git a/apps/web/src/components/tx/security/safenet/index.tsx b/apps/web/src/components/tx/security/safenet/index.tsx deleted file mode 100644 index 6d45847912..0000000000 --- a/apps/web/src/components/tx/security/safenet/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { Button, CircularProgress, List, ListItem, ListItemText, Paper, SvgIcon, Typography } from '@mui/material' -import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' -import CopyTooltip from '@/components/common/CopyTooltip' -import useDecodeTx from '@/hooks/useDecodeTx' -import CheckIcon from '@/public/images/common/check.svg' -import CloseIcon from '@/public/images/common/close.svg' -import CopyIcon from '@/public/images/common/copy.svg' -import type { SafenetSimulationResponse } from '@/store/safenet' -import { useLazySimulateSafenetTxQuery } from '@/store/safenet' -import { hashTypedData } from '@/utils/web3' -import { useEffect, type ReactElement } from 'react' -import css from './styles.module.css' - -export type SafenetTxSimulationProps = { - safe: string - chainId: string - safeTx?: SafeTransaction -} - -function _getGuaranteeDisplayName(guarantee: string): string { - switch (guarantee) { - case 'no_delegatecall': - case 'no_contract_recipient': // We don't want to override the recipient verification - return 'Fraud verification' - case 'recipient_signature': - return 'Recipient verification' - default: - return 'Other' - } -} - -function _groupResultGuarantees({ - results, -}: Pick): { display: string; status: string; link?: string }[] { - const groups = results.reduce( - (groups, { guarantee, status, metadata }) => { - const display = _getGuaranteeDisplayName(guarantee) - if (status === 'skipped') { - return groups - } - return { - ...groups, - [display]: { status, link: metadata?.link }, - } - }, - {} as Record, - ) - return Object.entries(groups) - .map(([display, { status, link }]) => ({ display, status, link })) - .sort((a, b) => a.display.localeCompare(b.display)) -} - -function _getSafeTxHash({ safe, chainId, safeTx }: Required): string { - return hashTypedData({ - domain: { - chainId, - verifyingContract: safe, - }, - types: { - SafeTx: [ - { type: 'address', name: 'to' }, - { type: 'uint256', name: 'value' }, - { type: 'bytes', name: 'data' }, - { type: 'uint8', name: 'operation' }, - { type: 'uint256', name: 'safeTxGas' }, - { type: 'uint256', name: 'baseGas' }, - { type: 'uint256', name: 'gasPrice' }, - { type: 'address', name: 'gasToken' }, - { type: 'address', name: 'refundReceiver' }, - { type: 'uint256', name: 'nonce' }, - ], - }, - message: { ...safeTx.data }, - }) -} - -const StatusAction = ({ status, link }: { status: string; link?: string }): ReactElement => { - if (status === 'success') { - return ( -
- - No issues found -
- ) - } else if (status === 'pending' && link) { - return ( - - - - ) - } else { - return ( -
- - Failure -
- ) - } -} - -const SafenetTxTxSimulationSummary = ({ simulation }: { simulation: SafenetSimulationResponse }): ReactElement => { - if (simulation.results.length === 0) { - return No Safenet checks enabled... - } - - const guarantees = _groupResultGuarantees(simulation) - - return ( - - {simulation.hasError && ( - - One or more Safenet checks failed! - - )} - - - {guarantees.map(({ display, status, link }) => ( - }> - {display} - - ))} - - - ) -} - -export const SafenetTxSimulation = ({ safe, chainId, safeTx }: SafenetTxSimulationProps): ReactElement | null => { - const [dataDecoded] = useDecodeTx(safeTx) - const [simulate, { data: simulation, status }] = useLazySimulateSafenetTxQuery() - - useEffect(() => { - if (!safeTx || !dataDecoded) { - return - } - - const safeTxHash = _getSafeTxHash({ safe, chainId, safeTx }) - simulate({ - chainId, - tx: { - safe, - safeTxHash, - to: safeTx.data.to, - value: safeTx.data.value, - data: safeTx.data.data, - operation: safeTx.data.operation, - safeTxGas: safeTx.data.safeTxGas, - baseGas: safeTx.data.baseGas, - gasPrice: safeTx.data.gasPrice, - gasToken: safeTx.data.gasToken, - refundReceiver: safeTx.data.refundReceiver, - // We don't send confirmations, as we want to simulate the transaction before signing. - // In the future, we can consider sending the already collected signatures, but this is not - // necessary at the moment. - confirmations: [], - dataDecoded, - }, - }) - }, [safe, chainId, safeTx, dataDecoded, simulate]) - - switch (status) { - case 'fulfilled': - return - case 'rejected': - return ( - - - Unexpected error simulating with Safenet! - - ) - default: - return palette.text.secondary }} /> - } -} diff --git a/apps/web/src/config/constants.ts b/apps/web/src/config/constants.ts index dc8f191d8c..d2e736b22d 100644 --- a/apps/web/src/config/constants.ts +++ b/apps/web/src/config/constants.ts @@ -121,4 +121,5 @@ export const ECOSYSTEM_ID_ADDRESS = export const MULTICHAIN_HELP_ARTICLE = `${HELP_CENTER_URL}/en/articles/222612-multi-chain-safe` // Safenet +export const HAS_SAFENET_FEATURE = process.env.NEXT_PUBLIC_HAS_SAFENET_FEATURE === 'true' export const SAFENET_API_URL = process.env.NEXT_PUBLIC_SAFENET_API_URL diff --git a/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx b/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx index 3984d45177..217cb1443b 100644 --- a/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx +++ b/apps/web/src/features/myAccounts/components/AccountItems/MultiAccountItem.tsx @@ -45,7 +45,9 @@ import { addOrUpdateSafe, pinSafe, selectAllAddedSafes, unpinSafe } from '@/stor import { defaultSafeInfo } from '@/store/safeInfoSlice' import { selectOrderByPreference } from '@/store/orderByPreferenceSlice' import { getComparator } from '@/features/myAccounts/utils/utils' -import GradientBoxSafenet from '@/components/common/GradientBoxSafenet' +import dynamic from 'next/dynamic' + +const GradientBoxSafenet = dynamic(() => import('@/features/safenet/components/GradientBoxSafenet')) type MultiAccountItemProps = { multiSafeAccountItem: MultiChainSafeItem diff --git a/apps/web/src/features/myAccounts/components/SafesList/index.tsx b/apps/web/src/features/myAccounts/components/SafesList/index.tsx index 9b47c7f67d..2c7b021d46 100644 --- a/apps/web/src/features/myAccounts/components/SafesList/index.tsx +++ b/apps/web/src/features/myAccounts/components/SafesList/index.tsx @@ -1,10 +1,11 @@ +import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' +import MultiAccountItem from '@/features/myAccounts/components/AccountItems/MultiAccountItem' import SingleAccountItem from '@/features/myAccounts/components/AccountItems/SingleAccountItem' import type { SafeItem } from '@/features/myAccounts/hooks/useAllSafes' import type { MultiChainSafeItem } from '@/features/myAccounts/hooks/useAllSafesGrouped' -import MultiAccountItem from '@/features/myAccounts/components/AccountItems/MultiAccountItem' -import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' -import { TransitionGroup } from 'react-transition-group' +import { useHasSafenetFeature } from '@/features/safenet/hooks/useHasSafenetFeature' import { Collapse } from '@mui/material' +import { TransitionGroup } from 'react-transition-group' type SafeListProps = { safes?: (SafeItem | MultiChainSafeItem)[] @@ -12,15 +13,17 @@ type SafeListProps = { useTransitions?: boolean } -const renderSafeItem = (item: SafeItem | MultiChainSafeItem, onLinkClick?: () => void) => { +const renderSafeItem = (item: SafeItem | MultiChainSafeItem, onLinkClick?: () => void, hasSafenetFeature?: boolean) => { return isMultiChainSafeItem(item) ? ( - + ) : ( ) } const SafesList = ({ safes, onLinkClick, useTransitions = true }: SafeListProps) => { + const hasSafenetFeature = useHasSafenetFeature() + if (!safes || safes.length === 0) { return null } @@ -29,14 +32,14 @@ const SafesList = ({ safes, onLinkClick, useTransitions = true }: SafeListProps) {safes.map((item) => ( - {renderSafeItem(item, onLinkClick)} + {renderSafeItem(item, onLinkClick, hasSafenetFeature)} ))} ) : ( <> {safes.map((item) => ( -
{renderSafeItem(item, onLinkClick)}
+
{renderSafeItem(item, onLinkClick, hasSafenetFeature)}
))} ) diff --git a/apps/web/src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx b/apps/web/src/features/safenet/components/EnableSafenet/ReviewEnableSafenet.tsx similarity index 95% rename from apps/web/src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx rename to apps/web/src/features/safenet/components/EnableSafenet/ReviewEnableSafenet.tsx index f04ce0878f..73972346b8 100644 --- a/apps/web/src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx +++ b/apps/web/src/features/safenet/components/EnableSafenet/ReviewEnableSafenet.tsx @@ -13,7 +13,7 @@ import { UNLIMITED_APPROVAL_AMOUNT } from '@/utils/tokens' const ERC20_INTERFACE = ERC20__factory.createInterface() -export const ReviewEnableSafenet = ({ params }: { params: EnableSafenetFlowProps }) => { +const ReviewEnableSafenet = ({ params }: { params: EnableSafenetFlowProps }) => { const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) useEffect(() => { @@ -56,3 +56,5 @@ export const ReviewEnableSafenet = ({ params }: { params: EnableSafenetFlowProps ) } + +export default ReviewEnableSafenet diff --git a/apps/web/src/components/tx-flow/flows/EnableSafenet/index.tsx b/apps/web/src/features/safenet/components/EnableSafenet/index.tsx similarity index 85% rename from apps/web/src/components/tx-flow/flows/EnableSafenet/index.tsx rename to apps/web/src/features/safenet/components/EnableSafenet/index.tsx index f1019721c7..1772c9093f 100644 --- a/apps/web/src/components/tx-flow/flows/EnableSafenet/index.tsx +++ b/apps/web/src/features/safenet/components/EnableSafenet/index.tsx @@ -1,5 +1,5 @@ import TxLayout from '@/components/tx-flow/common/TxLayout' -import { ReviewEnableSafenet } from './ReviewEnableSafenet' +import ReviewEnableSafenet from './ReviewEnableSafenet' export type EnableSafenetFlowProps = { guardAddress: string @@ -15,4 +15,4 @@ const EnableSafenetFlow = ({ guardAddress, tokensForPresetAllowances, allowanceS ) } -export { EnableSafenetFlow } +export default EnableSafenetFlow diff --git a/apps/web/src/components/common/GradientBoxSafenet/index.tsx b/apps/web/src/features/safenet/components/GradientBoxSafenet/index.tsx similarity index 100% rename from apps/web/src/components/common/GradientBoxSafenet/index.tsx rename to apps/web/src/features/safenet/components/GradientBoxSafenet/index.tsx diff --git a/apps/web/src/features/safenet/components/SafenetPage/index.tsx b/apps/web/src/features/safenet/components/SafenetPage/index.tsx new file mode 100644 index 0000000000..0fc0f36ed8 --- /dev/null +++ b/apps/web/src/features/safenet/components/SafenetPage/index.tsx @@ -0,0 +1,131 @@ +import { TxModalContext } from '@/components/tx-flow' +import { EnableSafenetFlow } from '@/components/tx-flow/flows' +import useSafeInfo from '@/hooks/useSafeInfo' +import InfoIcon from '@/public/images/notifications/info.svg' +import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' +import type { SafenetConfigEntity } from '@/store/safenet' +import { useGetSafenetConfigQuery } from '@/store/safenet' +import { sameAddress } from '@/utils/addresses' +import { hasSafeFeature } from '@/utils/safe-versions' +import { Button, CircularProgress, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' +import { SAFE_FEATURES } from '@safe-global/protocol-kit/dist/src/utils' +import type { NextPage } from 'next' +import { useContext, useMemo } from 'react' + +const getSafenetTokensByChain = (chainId: number, safenetConfig: SafenetConfigEntity): string[] => { + const tokenSymbols = Object.keys(safenetConfig.tokens) + + const tokens: string[] = [] + for (const symbol of tokenSymbols) { + const tokenAddress = safenetConfig.tokens[symbol][chainId] + if (tokenAddress) { + tokens.push(tokenAddress) + } + } + + return tokens +} + +const SafenetContent = ({ safenetConfig, safe }: { safenetConfig: SafenetConfigEntity; safe: ExtendedSafeInfo }) => { + const isVersionWithGuards = hasSafeFeature(SAFE_FEATURES.SAFE_TX_GUARDS, safe.version) + const safenetGuardAddress = safenetConfig.guards[safe.chainId] + const safenetProcessorAddress = safenetConfig.processors[safe.chainId] + const isSafenetGuardEnabled = isVersionWithGuards && sameAddress(safe.guard?.value, safenetGuardAddress) + const chainSupported = safenetConfig.chains.includes(Number(safe.chainId)) + const { setTxFlow } = useContext(TxModalContext) + + const safenetAssets = useMemo( + () => getSafenetTokensByChain(Number(safe.chainId), safenetConfig), + [safe.chainId, safenetConfig], + ) + + switch (true) { + case !chainSupported: + return ( + + Safenet is not supported on this chain. List of supported chains ids: {safenetConfig.chains.join(', ')} + + ) + case !isVersionWithGuards: + return Please upgrade your Safe to the latest version to use Safenet + case isSafenetGuardEnabled: + return Safenet is enabled. Enjoy your unified experience. + case !isSafenetGuardEnabled: + return ( +
+ Safenet is not enabled. Enable it to enhance your Safe experience. + +
+ ) + default: + return null + } +} + +const SafenetPage: NextPage = () => { + const { safe, safeLoaded } = useSafeInfo() + const { data: safenetConfig, isLoading: safenetConfigLoading, error: safenetConfigError } = useGetSafenetConfigQuery() + + if (!safeLoaded || safenetConfigLoading) { + return + } + + if (safenetConfigError) { + return Error loading Safenet config + } + + if (!safenetConfig) { + // Should never happen, making TS happy + return No Safenet config found + } + + const safenetContent = + + return ( +
+ + + + + + + Safenet Status + + + + + + + + {safenetContent} + + + +
+ ) +} + +export default SafenetPage diff --git a/apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/CSVAirdropAppModal/index.tsx b/apps/web/src/features/safenet/components/SafenetTokenTransfers/CSVAirdropAppModal/index.tsx similarity index 100% rename from apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/CSVAirdropAppModal/index.tsx rename to apps/web/src/features/safenet/components/SafenetTokenTransfers/CSVAirdropAppModal/index.tsx diff --git a/apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/CreateTokenTransfers.tsx b/apps/web/src/features/safenet/components/SafenetTokenTransfers/CreateTokenTransfers.tsx similarity index 100% rename from apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/CreateTokenTransfers.tsx rename to apps/web/src/features/safenet/components/SafenetTokenTransfers/CreateTokenTransfers.tsx diff --git a/apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/RecipientRow/index.tsx b/apps/web/src/features/safenet/components/SafenetTokenTransfers/RecipientRow/index.tsx similarity index 100% rename from apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/RecipientRow/index.tsx rename to apps/web/src/features/safenet/components/SafenetTokenTransfers/RecipientRow/index.tsx diff --git a/apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/ReviewTokenTransfers.tsx b/apps/web/src/features/safenet/components/SafenetTokenTransfers/ReviewTokenTransfers.tsx similarity index 100% rename from apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/ReviewTokenTransfers.tsx rename to apps/web/src/features/safenet/components/SafenetTokenTransfers/ReviewTokenTransfers.tsx diff --git a/apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/index.tsx b/apps/web/src/features/safenet/components/SafenetTokenTransfers/index.tsx similarity index 100% rename from apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/index.tsx rename to apps/web/src/features/safenet/components/SafenetTokenTransfers/index.tsx diff --git a/apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/utils.ts b/apps/web/src/features/safenet/components/SafenetTokenTransfers/utils.ts similarity index 100% rename from apps/web/src/components/tx-flow/flows/SafenetTokenTransfers/utils.ts rename to apps/web/src/features/safenet/components/SafenetTokenTransfers/utils.ts diff --git a/apps/web/src/components/tx/SignOrExecuteForm/SafenetTxChecks.tsx b/apps/web/src/features/safenet/components/SafenetTxChecks/index.tsx similarity index 72% rename from apps/web/src/components/tx/SignOrExecuteForm/SafenetTxChecks.tsx rename to apps/web/src/features/safenet/components/SafenetTxChecks/index.tsx index 15714b516c..666e622e6a 100644 --- a/apps/web/src/components/tx/SignOrExecuteForm/SafenetTxChecks.tsx +++ b/apps/web/src/features/safenet/components/SafenetTxChecks/index.tsx @@ -1,22 +1,16 @@ -import { Typography } from '@mui/material' -import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' -import { type ReactElement } from 'react' -import GradientBoxSafenet from '@/components/common/GradientBoxSafenet' -import { SafenetTxSimulation } from '@/components/tx/security/safenet' import TxCard from '@/components/tx-flow/common/TxCard' -import useIsSafenetEnabled from '@/hooks/useIsSafenetEnabled' +import GradientBoxSafenet from '../GradientBoxSafenet' +import SafenetTxSimulation from '../SafenetTxSimulation' import useChainId from '@/hooks/useChainId' import useSafeAddress from '@/hooks/useSafeAddress' +import { Typography } from '@mui/material' +import { type SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { type ReactElement } from 'react' import css from './styles.module.css' const SafenetTxChecks = ({ safeTx }: { safeTx: SafeTransaction }): ReactElement | null => { const safe = useSafeAddress() const chainId = useChainId() - const isSafenetEnabled = useIsSafenetEnabled() - - if (!isSafenetEnabled) { - return null - } return ( diff --git a/apps/web/src/features/safenet/components/SafenetTxChecks/styles.module.css b/apps/web/src/features/safenet/components/SafenetTxChecks/styles.module.css new file mode 100644 index 0000000000..62ed850507 --- /dev/null +++ b/apps/web/src/features/safenet/components/SafenetTxChecks/styles.module.css @@ -0,0 +1,7 @@ +.safenetGradientCard { + margin-bottom: var(--space-2); +} + +.safenetGradientCard > div:last-child > div:first-child { + margin-bottom: 0; +} diff --git a/apps/web/src/features/safenet/components/SafenetTxSimulation/SafenetTxSimulationSummary.tsx b/apps/web/src/features/safenet/components/SafenetTxSimulation/SafenetTxSimulationSummary.tsx new file mode 100644 index 0000000000..abcfbbc83a --- /dev/null +++ b/apps/web/src/features/safenet/components/SafenetTxSimulation/SafenetTxSimulationSummary.tsx @@ -0,0 +1,66 @@ +import type { SafenetSimulationResponse } from '@/store/safenet' +import { List, ListItem, ListItemText, Paper, Typography } from '@mui/material' +import type { ReactElement } from 'react' +import css from './styles.module.css' +import StatusAction from './StatusAction' + +function _getGuaranteeDisplayName(guarantee: string): string { + switch (guarantee) { + case 'no_delegatecall': + case 'no_contract_recipient': // We don't want to override the recipient verification + return 'Fraud verification' + case 'recipient_signature': + return 'Recipient verification' + default: + return 'Other' + } +} + +function _groupResultGuarantees({ + results, +}: Pick): { display: string; status: string; link?: string }[] { + const groups = results.reduce( + (groups, { guarantee, status, metadata }) => { + const display = _getGuaranteeDisplayName(guarantee) + if (status === 'skipped') { + return groups + } + return { + ...groups, + [display]: { status, link: metadata?.link }, + } + }, + {} as Record, + ) + return Object.entries(groups) + .map(([display, { status, link }]) => ({ display, status, link })) + .sort((a, b) => a.display.localeCompare(b.display)) +} + +const SafenetTxSimulationSummary = ({ simulation }: { simulation: SafenetSimulationResponse }): ReactElement => { + if (simulation.results.length === 0) { + return No Safenet checks enabled... + } + + const guarantees = _groupResultGuarantees(simulation) + + return ( + + {simulation.hasError && ( + + One or more Safenet checks failed! + + )} + + + {guarantees.map(({ display, status, link }) => ( + }> + {display} + + ))} + + + ) +} + +export default SafenetTxSimulationSummary diff --git a/apps/web/src/features/safenet/components/SafenetTxSimulation/StatusAction.tsx b/apps/web/src/features/safenet/components/SafenetTxSimulation/StatusAction.tsx new file mode 100644 index 0000000000..12e5a0de34 --- /dev/null +++ b/apps/web/src/features/safenet/components/SafenetTxSimulation/StatusAction.tsx @@ -0,0 +1,46 @@ +import { Button, SvgIcon } from '@mui/material' +import CopyTooltip from '@/components/common/CopyTooltip' +import CheckIcon from '@/public/images/common/check.svg' +import CloseIcon from '@/public/images/common/close.svg' +import CopyIcon from '@/public/images/common/copy.svg' +import type { ReactElement } from 'react' +import css from './styles.module.css' + +const StatusAction = ({ status, link }: { status: string; link?: string }): ReactElement => { + if (status === 'success') { + return ( +
+ + No issues found +
+ ) + } else if (status === 'pending' && link) { + return ( + + + + ) + } else { + return ( +
+ + Failure +
+ ) + } +} + +export default StatusAction diff --git a/apps/web/src/features/safenet/components/SafenetTxSimulation/index.tsx b/apps/web/src/features/safenet/components/SafenetTxSimulation/index.tsx new file mode 100644 index 0000000000..0962f0aee6 --- /dev/null +++ b/apps/web/src/features/safenet/components/SafenetTxSimulation/index.tsx @@ -0,0 +1,88 @@ +import useDecodeTx from '@/hooks/useDecodeTx' +import CloseIcon from '@/public/images/common/close.svg' +import { useLazySimulateSafenetTxQuery } from '@/store/safenet' +import { hashTypedData } from '@/utils/web3' +import { CircularProgress, SvgIcon, Typography } from '@mui/material' +import type { SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { useEffect, type ReactElement } from 'react' +import SafenetTxSimulationSummary from './SafenetTxSimulationSummary' + +export type SafenetTxSimulationProps = { + safe: string + chainId: string + safeTx?: SafeTransaction +} + +function _getSafeTxHash({ safe, chainId, safeTx }: Required): string { + return hashTypedData({ + domain: { + chainId, + verifyingContract: safe, + }, + types: { + SafeTx: [ + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'value' }, + { type: 'bytes', name: 'data' }, + { type: 'uint8', name: 'operation' }, + { type: 'uint256', name: 'safeTxGas' }, + { type: 'uint256', name: 'baseGas' }, + { type: 'uint256', name: 'gasPrice' }, + { type: 'address', name: 'gasToken' }, + { type: 'address', name: 'refundReceiver' }, + { type: 'uint256', name: 'nonce' }, + ], + }, + message: { ...safeTx.data }, + }) +} + +const SafenetTxSimulation = ({ safe, chainId, safeTx }: SafenetTxSimulationProps): ReactElement | null => { + const [dataDecoded] = useDecodeTx(safeTx) + const [simulate, { data: simulation, status }] = useLazySimulateSafenetTxQuery() + + useEffect(() => { + if (!safeTx || !dataDecoded) { + return + } + + const safeTxHash = _getSafeTxHash({ safe, chainId, safeTx }) + simulate({ + chainId, + tx: { + safe, + safeTxHash, + to: safeTx.data.to, + value: safeTx.data.value, + data: safeTx.data.data, + operation: safeTx.data.operation, + safeTxGas: safeTx.data.safeTxGas, + baseGas: safeTx.data.baseGas, + gasPrice: safeTx.data.gasPrice, + gasToken: safeTx.data.gasToken, + refundReceiver: safeTx.data.refundReceiver, + // We don't send confirmations, as we want to simulate the transaction before signing. + // In the future, we can consider sending the already collected signatures, but this is not + // necessary at the moment. + confirmations: [], + dataDecoded, + }, + }) + }, [safe, chainId, safeTx, dataDecoded, simulate]) + + switch (status) { + case 'fulfilled': + return + case 'rejected': + return ( + + + Unexpected error simulating with Safenet! + + ) + default: + return palette.text.secondary }} /> + } +} + +export default SafenetTxSimulation diff --git a/apps/web/src/components/tx/security/safenet/styles.module.css b/apps/web/src/features/safenet/components/SafenetTxSimulation/styles.module.css similarity index 100% rename from apps/web/src/components/tx/security/safenet/styles.module.css rename to apps/web/src/features/safenet/components/SafenetTxSimulation/styles.module.css diff --git a/apps/web/src/features/safenet/hooks/useHasSafenetFeature.ts b/apps/web/src/features/safenet/hooks/useHasSafenetFeature.ts new file mode 100644 index 0000000000..37151c734b --- /dev/null +++ b/apps/web/src/features/safenet/hooks/useHasSafenetFeature.ts @@ -0,0 +1,14 @@ +import { HAS_SAFENET_FEATURE } from '@/config/constants' +import { useCurrentChain } from '@/hooks/useChains' +import { FEATURES, hasFeature } from '@/utils/chains' + +export const useHasSafenetFeature = (): boolean | undefined => { + const currentChain = useCurrentChain() + + if (HAS_SAFENET_FEATURE) { + return true + } + + const hasSafenetFeature = currentChain ? hasFeature(currentChain, FEATURES.SAFENET) : undefined + return hasSafenetFeature +} diff --git a/apps/web/src/hooks/useIsSafenetEnabled.ts b/apps/web/src/features/safenet/hooks/useIsSafenetEnabled.ts similarity index 51% rename from apps/web/src/hooks/useIsSafenetEnabled.ts rename to apps/web/src/features/safenet/hooks/useIsSafenetEnabled.ts index 4bda964382..7979446fbd 100644 --- a/apps/web/src/hooks/useIsSafenetEnabled.ts +++ b/apps/web/src/features/safenet/hooks/useIsSafenetEnabled.ts @@ -1,10 +1,17 @@ +import { useHasSafenetFeature } from './useHasSafenetFeature' import useSafeInfo from '@/hooks/useSafeInfo' import { useGetSafenetConfigQuery } from '@/store/safenet' import { sameAddress } from '@/utils/addresses' +import { skipToken } from '@reduxjs/toolkit/query/react' const useIsSafenetEnabled = () => { const { safe } = useSafeInfo() - const { data: safenetConfig } = useGetSafenetConfigQuery() + const hasSafenetFeature = useHasSafenetFeature() + const { data: safenetConfig } = useGetSafenetConfigQuery(!hasSafenetFeature ? skipToken : undefined) + + if (!hasSafenetFeature) { + return false + } return sameAddress(safe.guard?.value, safenetConfig?.guards[safe.chainId]) } diff --git a/apps/web/src/hooks/loadables/useLoadBalances.ts b/apps/web/src/hooks/loadables/useLoadBalances.ts index 13f3a4406e..c3c4dca5fc 100644 --- a/apps/web/src/hooks/loadables/useLoadBalances.ts +++ b/apps/web/src/hooks/loadables/useLoadBalances.ts @@ -1,19 +1,20 @@ +import { POLLING_INTERVAL } from '@/config/constants' import { getCounterfactualBalance } from '@/features/counterfactual/utils' import { useWeb3 } from '@/hooks/wallets/web3' -import { useEffect, useMemo } from 'react' -import { getBalances, type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { Errors, logError } from '@/services/exceptions' import { useAppSelector } from '@/store' +import { getSafenetBalances, useGetSafenetConfigQuery } from '@/store/safenet' +import { TOKEN_LISTS, selectCurrency, selectSettings } from '@/store/settingsSlice' +import { FEATURES, hasFeature } from '@/utils/chains' +import { convertSafenetBalanceToSafeClientGatewayBalance } from '@/utils/safenet' +import { skipToken } from '@reduxjs/toolkit/query/react' +import { getBalances, type SafeBalanceResponse } from '@safe-global/safe-gateway-typescript-sdk' +import { useEffect, useMemo } from 'react' import useAsync, { type AsyncResult } from '../useAsync' -import { Errors, logError } from '@/services/exceptions' -import { selectCurrency, selectSettings, TOKEN_LISTS } from '@/store/settingsSlice' import { useCurrentChain } from '../useChains' -import { FEATURES, hasFeature } from '@/utils/chains' -import { POLLING_INTERVAL } from '@/config/constants' import useIntervalCounter from '../useIntervalCounter' +import useIsSafenetEnabled from '@/features/safenet/hooks/useIsSafenetEnabled' import useSafeInfo from '../useSafeInfo' -import { useGetSafenetConfigQuery } from '@/store/safenet' -import { convertSafenetBalanceToSafeClientGatewayBalance } from '@/utils/safenet' -import { getSafenetBalances } from '@/store/safenet' export const useTokenListSetting = (): boolean | undefined => { const chain = useCurrentChain() @@ -46,11 +47,12 @@ const mergeBalances = (cgw: SafeBalanceResponse, sn: SafeBalanceResponse): SafeB export const useLoadBalances = (): AsyncResult => { const [pollCount, resetPolling] = useIntervalCounter(POLLING_INTERVAL) + const isSafenetEnabled = useIsSafenetEnabled() const { data: safenetConfig, isSuccess: isSafenetConfigSuccess, isLoading: isSafenetConfigLoading, - } = useGetSafenetConfigQuery() + } = useGetSafenetConfigQuery(!isSafenetEnabled ? skipToken : undefined) const currency = useAppSelector(selectCurrency) const isTrustedTokenList = useTokenListSetting() const { safe, safeAddress } = useSafeInfo() @@ -74,7 +76,7 @@ export const useLoadBalances = (): AsyncResult => { }), ] - if (isSafenetConfigSuccess && chainSupportedBySafenet) { + if (isSafenetEnabled && isSafenetConfigSuccess && chainSupportedBySafenet) { balanceQueries.push( getSafenetBalances(safeAddress) .then((safenetBalances) => diff --git a/apps/web/src/pages/settings/safenet.tsx b/apps/web/src/pages/settings/safenet.tsx index b91c248120..0b741c7c23 100644 --- a/apps/web/src/pages/settings/safenet.tsx +++ b/apps/web/src/pages/settings/safenet.tsx @@ -1,140 +1,33 @@ +import SettingsHeader from '@/components/settings/SettingsHeader' +import { BRAND_NAME } from '@/config/constants' +import { useHasSafenetFeature } from '@/features/safenet/hooks/useHasSafenetFeature' +import { Typography } from '@mui/material' import type { NextPage } from 'next' +import dynamic from 'next/dynamic' import Head from 'next/head' -import { Button, CircularProgress, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' -import InfoIcon from '@/public/images/notifications/info.svg' - -import SettingsHeader from '@/components/settings/SettingsHeader' -import useSafeInfo from '@/hooks/useSafeInfo' -import { sameAddress } from '@/utils/addresses' -import { useContext, useMemo } from 'react' -import { TxModalContext } from '@/components/tx-flow' -import { EnableSafenetFlow } from '@/components/tx-flow/flows/EnableSafenet' -import type { SafenetConfigEntity } from '@/store/safenet' -import { useGetSafenetConfigQuery } from '@/store/safenet' -import type { ExtendedSafeInfo } from '@/store/safeInfoSlice' -import { SAFE_FEATURES } from '@safe-global/protocol-kit/dist/src/utils' -import { hasSafeFeature } from '@/utils/safe-versions' - -const getSafenetTokensByChain = (chainId: number, safenetConfig: SafenetConfigEntity): string[] => { - const tokenSymbols = Object.keys(safenetConfig.tokens) - - const tokens: string[] = [] - for (const symbol of tokenSymbols) { - const tokenAddress = safenetConfig.tokens[symbol][chainId] - if (tokenAddress) { - tokens.push(tokenAddress) - } - } - - return tokens -} - -const SafenetContent = ({ safenetConfig, safe }: { safenetConfig: SafenetConfigEntity; safe: ExtendedSafeInfo }) => { - const isVersionWithGuards = hasSafeFeature(SAFE_FEATURES.SAFE_TX_GUARDS, safe.version) - const safenetGuardAddress = safenetConfig.guards[safe.chainId] - const safenetProcessorAddress = safenetConfig.processors[safe.chainId] - const isSafenetGuardEnabled = isVersionWithGuards && sameAddress(safe.guard?.value, safenetGuardAddress) - const chainSupported = safenetConfig.chains.includes(Number(safe.chainId)) - const { setTxFlow } = useContext(TxModalContext) - const safenetAssets = useMemo( - () => getSafenetTokensByChain(Number(safe.chainId), safenetConfig), - [safe.chainId, safenetConfig], - ) - - switch (true) { - case !chainSupported: - return ( - - Safenet is not supported on this chain. List of supported chains ids: {safenetConfig.chains.join(', ')} - - ) - case !isVersionWithGuards: - return Please upgrade your Safe to the latest version to use Safenet - case isSafenetGuardEnabled: - return Safenet is enabled. Enjoy your unified experience. - case !isSafenetGuardEnabled: - return ( -
- Safenet is not enabled. Enable it to enhance your Safe experience. - -
- ) - default: - return null - } -} +const LazySafenetPage = dynamic(() => import('@/features/safenet/components/SafenetPage'), { ssr: false }) const SafenetPage: NextPage = () => { - const { safe, safeLoaded } = useSafeInfo() - const { data: safenetConfig, isLoading: safenetConfigLoading, error: safenetConfigError } = useGetSafenetConfigQuery() - - if (!safeLoaded || safenetConfigLoading) { - return - } - - if (safenetConfigError) { - return Error loading Safenet config - } - - if (!safenetConfig) { - // Should never happen, making TS happy - return No Safenet config found - } - - const safenetContent = + const hasSafenetFeature = useHasSafenetFeature() return ( <> - {'Safe{Wallet} – Settings – Safenet'} + {`${BRAND_NAME} – Settings – Safenet`} -
- - - - - - - Safenet Status - - - - - - - - {safenetContent} - - - -
+ {hasSafenetFeature === true ? ( + + ) : hasSafenetFeature === false ? ( +
+ + Safenet is not available on this network. + +
+ ) : null} ) } diff --git a/apps/web/src/utils/chains.ts b/apps/web/src/utils/chains.ts index 8d7534b7c1..1fcd8b93d5 100644 --- a/apps/web/src/utils/chains.ts +++ b/apps/web/src/utils/chains.ts @@ -1,10 +1,10 @@ +import { LATEST_SAFE_VERSION } from '@/config/constants' import { AppRoutes } from '@/config/routes' -import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { getExplorerLink } from './gateway' import { type SafeVersion } from '@safe-global/safe-core-sdk-types' import { getSafeSingletonDeployment } from '@safe-global/safe-deployments' +import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import semverSatisfies from 'semver/functions/satisfies' -import { LATEST_SAFE_VERSION } from '@/config/constants' +import { getExplorerLink } from './gateway' /** This version is used if a network does not have the LATEST_SAFE_VERSION deployed yet */ const FALLBACK_SAFE_VERSION = '1.3.0' as const @@ -41,6 +41,7 @@ export enum FEATURES { BRIDGE = 'BRIDGE', RENEW_NOTIFICATIONS_TOKEN = 'RENEW_NOTIFICATIONS_TOKEN', TX_NOTES = 'TX_NOTES', + SAFENET = 'SAFENET', } export const FeatureRoutes = { @@ -50,6 +51,7 @@ export const FeatureRoutes = { [AppRoutes.balances.nfts]: FEATURES.ERC721, [AppRoutes.settings.notifications]: FEATURES.PUSH_NOTIFICATIONS, [AppRoutes.bridge]: FEATURES.BRIDGE, + [AppRoutes.settings.safenet]: FEATURES.SAFENET, } export const hasFeature = (chain: ChainInfo, feature: FEATURES): boolean => {