From bc42057b12eef4f29ef0dfb0ccab6a60bd37140f Mon Sep 17 00:00:00 2001 From: Mikhail <16622558+mmv08@users.noreply.github.com> Date: Mon, 7 Oct 2024 15:33:49 +0200 Subject: [PATCH] opt in safenet in settings (#4312) --- .env.example | 5 +- .../sidebar/SidebarNavigation/config.tsx | 4 + .../EnableSafenet/ReviewEnableSafenet.tsx | 58 ++++++ .../tx-flow/flows/EnableSafenet/index.tsx | 17 ++ src/config/constants.ts | 3 + src/config/routes.ts | 1 + src/pages/settings/safenet.tsx | 186 ++++++++++++++++++ src/services/tx/tx-sender/create.ts | 5 + src/store/index.ts | 3 + src/store/safenet.ts | 46 +++++ 10 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx create mode 100644 src/components/tx-flow/flows/EnableSafenet/index.tsx create mode 100644 src/pages/settings/safenet.tsx create mode 100644 src/store/safenet.ts diff --git a/.env.example b/.env.example index 2503d6269e..67cc39cbf4 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,7 @@ NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_STAGING= NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_PRODUCTION= # Cypress wallet private keys -CYPRESS_WALLET_CREDENTIALS= \ No newline at end of file +CYPRESS_WALLET_CREDENTIALS= + +# SafeNet +NEXT_PUBLIC_SAFENET_API_URL= diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx index 1dfc50a6ac..eb7ae0bfa4 100644 --- a/src/components/sidebar/SidebarNavigation/config.tsx +++ b/src/components/sidebar/SidebarNavigation/config.tsx @@ -91,6 +91,10 @@ export const balancesNavItems = [ ] export const settingsNavItems = [ + { + label: 'SafeNet', + href: AppRoutes.settings.safenet, + }, { label: 'Setup', href: AppRoutes.settings.setup, diff --git a/src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx b/src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx new file mode 100644 index 0000000000..5bc7a63b1a --- /dev/null +++ b/src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx @@ -0,0 +1,58 @@ +import { useContext, useEffect } from 'react' +import { Typography } from '@mui/material' +import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm' +import EthHashInfo from '@/components/common/EthHashInfo' +import { Errors, logError } from '@/services/exceptions' +import { createEnableGuardTx, createMultiSendCallOnlyTx } from '@/services/tx/tx-sender' +import { type EnableSafenetFlowProps } from '.' +import { SafeTxContext } from '@/components/tx-flow/SafeTxProvider' +import type { MetaTransactionData, SafeTransaction } from '@safe-global/safe-core-sdk-types' +import { OperationType } from '@safe-global/safe-core-sdk-types' +import { ERC20__factory } from '@/types/contracts' +import { UNLIMITED_APPROVAL_AMOUNT } from '@/utils/tokens' + +const ERC20_INTERFACE = ERC20__factory.createInterface() + +export const ReviewEnableSafenet = ({ params }: { params: EnableSafenetFlowProps }) => { + const { setSafeTx, safeTxError, setSafeTxError } = useContext(SafeTxContext) + + useEffect(() => { + async function getTxs(): Promise { + if (params.tokensForPresetAllowances.length === 0) { + return await createEnableGuardTx(params.guardAddress) + } + + const txs: MetaTransactionData[] = [(await createEnableGuardTx(params.guardAddress)).data] + params.tokensForPresetAllowances.forEach((tokenAddress) => { + txs.push({ + to: tokenAddress, + data: ERC20_INTERFACE.encodeFunctionData('approve', [params.guardAddress, UNLIMITED_APPROVAL_AMOUNT]), + value: '0', + operation: OperationType.Call, + }) + }) + + return createMultiSendCallOnlyTx(txs) + } + + getTxs().then(setSafeTx).catch(setSafeTxError) + }, [setSafeTx, setSafeTxError, params.guardAddress, params.tokensForPresetAllowances]) + + useEffect(() => { + if (safeTxError) { + logError(Errors._807, safeTxError.message) + } + }, [safeTxError]) + + return ( + + ({ color: palette.primary.light })}>Transaction guard + + + + + Once the transaction guard has been enabled, SafeNet will be enabled for your Safe. + + + ) +} diff --git a/src/components/tx-flow/flows/EnableSafenet/index.tsx b/src/components/tx-flow/flows/EnableSafenet/index.tsx new file mode 100644 index 0000000000..9a2996dae7 --- /dev/null +++ b/src/components/tx-flow/flows/EnableSafenet/index.tsx @@ -0,0 +1,17 @@ +import TxLayout from '@/components/tx-flow/common/TxLayout' +import { ReviewEnableSafenet } from './ReviewEnableSafenet' + +export type EnableSafenetFlowProps = { + guardAddress: string + tokensForPresetAllowances: string[] +} + +const EnableSafenetFlow = ({ guardAddress, tokensForPresetAllowances }: EnableSafenetFlowProps) => { + return ( + + + + ) +} + +export { EnableSafenetFlow } diff --git a/src/config/constants.ts b/src/config/constants.ts index 24b49de0b7..d0b99149fc 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -109,3 +109,6 @@ export const CHAINALYSIS_OFAC_CONTRACT = '0x40c57923924b5c5c5455c48d93317139adda export const ECOSYSTEM_ID_ADDRESS = process.env.NEXT_PUBLIC_ECOSYSTEM_ID_ADDRESS || '0x0000000000000000000000000000000000000000' + +// SafeNet +export const SAFENET_API_URL = process.env.NEXT_PUBLIC_SAFENET_API_URL diff --git a/src/config/routes.ts b/src/config/routes.ts index 8f65fa5bb7..bf855d0db1 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -42,6 +42,7 @@ export const AppRoutes = { safeApps: { index: '/settings/safe-apps', }, + safenet: '/settings/safenet', }, share: { safeApp: '/share/safe-app', diff --git a/src/pages/settings/safenet.tsx b/src/pages/settings/safenet.tsx new file mode 100644 index 0000000000..f5daed3b27 --- /dev/null +++ b/src/pages/settings/safenet.tsx @@ -0,0 +1,186 @@ +import type { NextPage } from 'next' +import Head from 'next/head' +import { Button, CircularProgress, Grid, Paper, SvgIcon, Tooltip, Typography } from '@mui/material' +import { QueryStatus } from '@reduxjs/toolkit/query' +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, useEffect, 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, + useLazyGetSafeNetOffchainStatusQuery, + useRegisterSafeNetMutation, +} 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 isSupportedChain = (chainId: number, safenetConfig: SafenetConfigEntity) => { + return safenetConfig.chains.sources.includes(chainId) +} + +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 isSafeNetGuardEnabled = isVersionWithGuards && sameAddress(safe.guard?.value, safenetGuardAddress) + const chainSupported = isSupportedChain(Number(safe.chainId), safenetConfig) + const { setTxFlow } = useContext(TxModalContext) + + // Lazy query because running it on unsupported chain throws an error + const [ + triggerGetSafeNetOffchainStatus, + { + data: safeNetOffchainStatus, + error: safeNetOffchainStatusError, + isLoading: safeNetOffchainStatusLoading, + status: safeNetOffchainStatusStatus, + }, + ] = useLazyGetSafeNetOffchainStatusQuery() + + // @ts-expect-error bad types. We don't want 404 to be an error - it just means that the safe is not registered + const offchainLookupError = safeNetOffchainStatusError?.status === 404 ? null : safeNetOffchainStatusError + const registeredOffchainStatus = + !offchainLookupError && sameAddress(safeNetOffchainStatus?.guard, safenetGuardAddress) + + const safenetStatusQueryWorked = + safeNetOffchainStatusStatus === QueryStatus.fulfilled || safeNetOffchainStatusStatus === QueryStatus.rejected + const needsRegistration = safenetStatusQueryWorked && isSafeNetGuardEnabled && !registeredOffchainStatus + const [registerSafeNet, { error: registerSafeNetError }] = useRegisterSafeNetMutation() + const error = offchainLookupError || registerSafeNetError + const safenetAssets = useMemo( + () => getSafenetTokensByChain(Number(safe.chainId), safenetConfig), + [safe.chainId, safenetConfig], + ) + + if (error) { + throw error + } + + useEffect(() => { + if (needsRegistration) { + registerSafeNet({ chainId: safe.chainId, safeAddress: safe.address.value }) + } + }, [needsRegistration, registerSafeNet, safe.chainId, safe.address.value]) + + useEffect(() => { + if (chainSupported) { + triggerGetSafeNetOffchainStatus({ chainId: safe.chainId, safeAddress: safe.address.value }) + } + }, [chainSupported, triggerGetSafeNetOffchainStatus, safe.chainId, safe.address.value]) + + switch (true) { + case !chainSupported: + return ( + + SafeNet is not supported on this chain. List of supported chains ids:{' '} + {safenetConfig.chains.sources.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. + +
+ ) + case safeNetOffchainStatusLoading: + return + 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 ( + <> + + {'Safe{Wallet} – Settings – SafeNet'} + + + + +
+ + + + + + + SafeNet Status + + + + + + + + {safenetContent} + + + +
+ + ) +} + +export default SafeNetPage diff --git a/src/services/tx/tx-sender/create.ts b/src/services/tx/tx-sender/create.ts index 4d348a0356..f3a0d5df9a 100644 --- a/src/services/tx/tx-sender/create.ts +++ b/src/services/tx/tx-sender/create.ts @@ -95,6 +95,11 @@ export const createRemoveGuardTx = async (): Promise => { return safeSDK.createDisableGuardTx() } +export const createEnableGuardTx = async (guardAddress: string): Promise => { + const safeSDK = getAndValidateSafeSDK() + return safeSDK.createEnableGuardTx(guardAddress) +} + /** * Create a rejection tx */ diff --git a/src/store/index.ts b/src/store/index.ts index 70097eec6e..5606fbe8e6 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -27,6 +27,7 @@ import * as hydrate from './useHydrateStore' import { ofacApi } from '@/store/ofac' import { safePassApi } from './safePass' import { metadata } from '@/markdown/terms/terms.md' +import { safenetApi } from './safenet' const rootReducer = combineReducers({ [slices.chainsSlice.name]: slices.chainsSlice.reducer, @@ -53,6 +54,7 @@ const rootReducer = combineReducers({ [ofacApi.reducerPath]: ofacApi.reducer, [safePassApi.reducerPath]: safePassApi.reducer, [slices.gatewayApi.reducerPath]: slices.gatewayApi.reducer, + [safenetApi.reducerPath]: safenetApi.reducer, }) const persistedSlices: (keyof Partial)[] = [ @@ -83,6 +85,7 @@ const middleware: Middleware<{}, RootState>[] = [ ofacApi.middleware, safePassApi.middleware, slices.gatewayApi.middleware, + safenetApi.middleware, ] const listeners = [safeMessagesListener, txHistoryListener, txQueueListener, swapOrderListener, swapOrderStatusListener] diff --git a/src/store/safenet.ts b/src/store/safenet.ts new file mode 100644 index 0000000000..a17e99422b --- /dev/null +++ b/src/store/safenet.ts @@ -0,0 +1,46 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + +import { SAFENET_API_URL } from '@/config/constants' + +export type SafenetSafeEntity = { + safe: string + chainId: number + guard: string +} + +export type SafenetConfigEntity = { + chains: { + sources: number[] + destinations: number[] + } + guards: Record + tokens: Record> +} + +export const safenetApi = createApi({ + reducerPath: 'safenetApi', + baseQuery: fetchBaseQuery({ baseUrl: `${SAFENET_API_URL}/safenet` }), + tagTypes: ['SafeNetOffchainStatus'], + endpoints: (builder) => ({ + getSafenetConfig: builder.query({ + query: () => `/config/`, + }), + getSafeNetOffchainStatus: builder.query({ + query: ({ chainId, safeAddress }) => `/safes/${chainId}/${safeAddress}`, + providesTags: (_, __, arg) => [{ type: 'SafeNetOffchainStatus', id: arg.safeAddress }], + }), + registerSafeNet: builder.mutation({ + query: ({ chainId, safeAddress }) => ({ + url: `/register`, + method: 'POST', + body: { + chainId: Number(chainId), + safe: safeAddress, + }, + }), + invalidatesTags: (_, __, arg) => [{ type: 'SafeNetOffchainStatus', id: arg.safeAddress }], + }), + }), +}) + +export const { useLazyGetSafeNetOffchainStatusQuery, useRegisterSafeNetMutation, useGetSafenetConfigQuery } = safenetApi