Skip to content

Commit

Permalink
opt in safenet in settings (#4312)
Browse files Browse the repository at this point in the history
  • Loading branch information
mmv08 authored Oct 7, 2024
1 parent da41027 commit bc42057
Show file tree
Hide file tree
Showing 10 changed files with 327 additions and 1 deletion.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,7 @@ NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_STAGING=
NEXT_PUBLIC_SOCIAL_WALLET_OPTIONS_PRODUCTION=

# Cypress wallet private keys
CYPRESS_WALLET_CREDENTIALS=
CYPRESS_WALLET_CREDENTIALS=

# SafeNet
NEXT_PUBLIC_SAFENET_API_URL=
4 changes: 4 additions & 0 deletions src/components/sidebar/SidebarNavigation/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export const balancesNavItems = [
]

export const settingsNavItems = [
{
label: 'SafeNet',
href: AppRoutes.settings.safenet,
},
{
label: 'Setup',
href: AppRoutes.settings.setup,
Expand Down
58 changes: 58 additions & 0 deletions src/components/tx-flow/flows/EnableSafenet/ReviewEnableSafenet.tsx
Original file line number Diff line number Diff line change
@@ -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<SafeTransaction> {
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 (
<SignOrExecuteForm>
<Typography sx={({ palette }) => ({ color: palette.primary.light })}>Transaction guard</Typography>

<EthHashInfo address={params.guardAddress} showCopyButton hasExplorer shortAddress={false} />

<Typography my={2}>
Once the transaction guard has been enabled, SafeNet will be enabled for your Safe.
</Typography>
</SignOrExecuteForm>
)
}
17 changes: 17 additions & 0 deletions src/components/tx-flow/flows/EnableSafenet/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TxLayout title="Confirm transaction" subtitle="Enable SafeNet">
<ReviewEnableSafenet params={{ guardAddress, tokensForPresetAllowances }} />
</TxLayout>
)
}

export { EnableSafenetFlow }
3 changes: 3 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const AppRoutes = {
safeApps: {
index: '/settings/safe-apps',
},
safenet: '/settings/safenet',
},
share: {
safeApp: '/share/safe-app',
Expand Down
186 changes: 186 additions & 0 deletions src/pages/settings/safenet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Typography>
SafeNet is not supported on this chain. List of supported chains ids:{' '}
{safenetConfig.chains.sources.join(', ')}
</Typography>
)
case !isVersionWithGuards:
return <Typography>Please upgrade your Safe to the latest version to use SafeNet</Typography>
case isSafeNetGuardEnabled:
return <Typography>SafeNet is enabled. Enjoy your unified experience.</Typography>
case !isSafeNetGuardEnabled:
return (
<div>
<Typography>SafeNet is not enabled. Enable it to enhance your Safe experience.</Typography>
<Button
variant="contained"
onClick={() =>
setTxFlow(
<EnableSafenetFlow guardAddress={safenetGuardAddress} tokensForPresetAllowances={safenetAssets} />,
)
}
sx={{ mt: 2 }}
>
Enable
</Button>
</div>
)
case safeNetOffchainStatusLoading:
return <CircularProgress />
default:
return null
}
}

const SafeNetPage: NextPage = () => {
const { safe, safeLoaded } = useSafeInfo()
const { data: safenetConfig, isLoading: safenetConfigLoading, error: safenetConfigError } = useGetSafenetConfigQuery()

if (!safeLoaded || safenetConfigLoading) {
return <CircularProgress />
}

if (safenetConfigError) {
return <Typography>Error loading SafeNet config</Typography>
}

if (!safenetConfig) {
// Should never happen, making TS happy
return <Typography>No SafeNet config found</Typography>
}

const safenetContent = <SafeNetContent safenetConfig={safenetConfig} safe={safe} />

return (
<>
<Head>
<title>{'Safe{Wallet} – Settings – SafeNet'}</title>
</Head>

<SettingsHeader />

<main>
<Paper data-testid="setup-section" sx={{ p: 4, mb: 2 }}>
<Grid container spacing={3}>
<Grid item lg={4} xs={12}>
<Typography variant="h4" fontWeight={700}>
<Tooltip
placement="top"
title="SafeNet enhances your Safe experience by providing additional security features."
>
<span>
SafeNet Status
<SvgIcon
component={InfoIcon}
inheritViewBox
fontSize="small"
color="border"
sx={{ verticalAlign: 'middle', ml: 0.5 }}
/>
</span>
</Tooltip>
</Typography>
</Grid>

<Grid item xs>
{safenetContent}
</Grid>
</Grid>
</Paper>
</main>
</>
)
}

export default SafeNetPage
5 changes: 5 additions & 0 deletions src/services/tx/tx-sender/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ export const createRemoveGuardTx = async (): Promise<SafeTransaction> => {
return safeSDK.createDisableGuardTx()
}

export const createEnableGuardTx = async (guardAddress: string): Promise<SafeTransaction> => {
const safeSDK = getAndValidateSafeSDK()
return safeSDK.createEnableGuardTx(guardAddress)
}

/**
* Create a rejection tx
*/
Expand Down
3 changes: 3 additions & 0 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<RootState>)[] = [
Expand Down Expand Up @@ -83,6 +85,7 @@ const middleware: Middleware<{}, RootState>[] = [
ofacApi.middleware,
safePassApi.middleware,
slices.gatewayApi.middleware,
safenetApi.middleware,
]
const listeners = [safeMessagesListener, txHistoryListener, txQueueListener, swapOrderListener, swapOrderStatusListener]

Expand Down
46 changes: 46 additions & 0 deletions src/store/safenet.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
tokens: Record<string, Record<string, string>>
}

export const safenetApi = createApi({
reducerPath: 'safenetApi',
baseQuery: fetchBaseQuery({ baseUrl: `${SAFENET_API_URL}/safenet` }),
tagTypes: ['SafeNetOffchainStatus'],
endpoints: (builder) => ({
getSafenetConfig: builder.query<SafenetConfigEntity, void>({
query: () => `/config/`,
}),
getSafeNetOffchainStatus: builder.query<SafenetSafeEntity, { chainId: string; safeAddress: string }>({
query: ({ chainId, safeAddress }) => `/safes/${chainId}/${safeAddress}`,
providesTags: (_, __, arg) => [{ type: 'SafeNetOffchainStatus', id: arg.safeAddress }],
}),
registerSafeNet: builder.mutation<boolean, { chainId: string; safeAddress: string }>({
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

0 comments on commit bc42057

Please sign in to comment.