diff --git a/package.json b/package.json
index ab8f5ec26a..f5f7b6a666 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
"@safe-global/safe-react-components": "^2.0.6",
"@sentry/react": "^7.28.1",
"@sentry/tracing": "^7.28.1",
+ "@tkey-mpc/common-types": "^8.2.2",
"@truffle/hdwallet-provider": "^2.1.4",
"@web3-onboard/coinbase": "^2.2.4",
"@web3-onboard/core": "^2.21.0",
diff --git a/src/components/common/ConnectWallet/MPCWallet.tsx b/src/components/common/ConnectWallet/MPCWallet.tsx
index 3799144921..86b5e05ca6 100644
--- a/src/components/common/ConnectWallet/MPCWallet.tsx
+++ b/src/components/common/ConnectWallet/MPCWallet.tsx
@@ -1,9 +1,12 @@
+import { MPCWalletState } from '@/hooks/wallets/mpc/useMPCWallet'
import { Box, Button, CircularProgress } from '@mui/material'
import { useContext } from 'react'
import { MpcWalletContext } from './MPCWalletProvider'
+import { PasswordRecovery } from './PasswordRecovery'
export const MPCWallet = () => {
- const { loginPending, triggerLogin, resetAccount, userInfo } = useContext(MpcWalletContext)
+ const { loginPending, triggerLogin, resetAccount, userInfo, walletState, recoverFactorWithPassword } =
+ useContext(MpcWalletContext)
return (
<>
@@ -28,6 +31,10 @@ export const MPCWallet = () => {
)}
)}
+
+ {walletState === MPCWalletState.MANUAL_RECOVERY && (
+
+ )}
>
)
}
diff --git a/src/components/common/ConnectWallet/MPCWalletProvider.tsx b/src/components/common/ConnectWallet/MPCWalletProvider.tsx
index 30e2be7117..30360bdcb2 100644
--- a/src/components/common/ConnectWallet/MPCWalletProvider.tsx
+++ b/src/components/common/ConnectWallet/MPCWalletProvider.tsx
@@ -6,7 +6,7 @@ type MPCWalletContext = {
triggerLogin: () => Promise
resetAccount: () => Promise
upsertPasswordBackup: (password: string) => Promise
- recoverFactorWithPassword: (password: string) => Promise
+ recoverFactorWithPassword: (password: string, storeDeviceFactor: boolean) => Promise
walletState: MPCWalletState
userInfo: {
email: string | undefined
diff --git a/src/components/common/ConnectWallet/PasswordRecovery.tsx b/src/components/common/ConnectWallet/PasswordRecovery.tsx
new file mode 100644
index 0000000000..17c8fb6060
--- /dev/null
+++ b/src/components/common/ConnectWallet/PasswordRecovery.tsx
@@ -0,0 +1,64 @@
+import { VisibilityOff, Visibility } from '@mui/icons-material'
+import {
+ DialogContent,
+ Typography,
+ TextField,
+ IconButton,
+ FormControlLabel,
+ Checkbox,
+ Button,
+ Box,
+} from '@mui/material'
+import { useState } from 'react'
+import ModalDialog from '../ModalDialog'
+
+export const PasswordRecovery = ({
+ recoverFactorWithPassword,
+}: {
+ recoverFactorWithPassword: (password: string, storeDeviceFactor: boolean) => Promise
+}) => {
+ const [showPassword, setShowPassword] = useState(false)
+ const [recoveryPassword, setRecoveryPassword] = useState('')
+ const [storeDeviceFactor, setStoreDeviceFactor] = useState(false)
+ return (
+
+
+
+
+ This browser is not registered with your Account yet. Please enter your recovery password to restore access
+ to this Account.
+
+
+ {
+ setRecoveryPassword(event.target.value)
+ }}
+ InputProps={{
+ endAdornment: (
+ setShowPassword((prev) => !prev)}
+ edge="end"
+ >
+ {showPassword ? : }
+
+ ),
+ }}
+ />
+ setStoreDeviceFactor((prev) => !prev)} />}
+ label="Do not ask again on this device"
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx b/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx
index 8081ca8e8a..c242060b5d 100644
--- a/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx
+++ b/src/components/new-safe/create/steps/ConnectWalletStep/index.tsx
@@ -1,8 +1,6 @@
import { useEffect, useState } from 'react'
-import { Box, Button, Divider, Grid, Typography } from '@mui/material'
+import { Box, Button, Divider, Typography } from '@mui/material'
import useWallet from '@/hooks/wallets/useWallet'
-import { useCurrentChain } from '@/hooks/useChains'
-import { isPairingSupported } from '@/services/pairing/utils'
import type { NewSafeFormData } from '@/components/new-safe/create'
import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper'
@@ -10,16 +8,12 @@ import useSyncSafeCreationStep from '@/components/new-safe/create/useSyncSafeCre
import layoutCss from '@/components/new-safe/create/styles.module.css'
import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet'
import KeyholeIcon from '@/components/common/icons/KeyholeIcon'
-import PairingDescription from '@/components/common/PairingDetails/PairingDescription'
-import PairingQRCode from '@/components/common/PairingDetails/PairingQRCode'
import { usePendingSafe } from '../StatusStep/usePendingSafe'
import { MPCWallet } from '@/components/common/ConnectWallet/MPCWallet'
const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps) => {
const [pendingSafe] = usePendingSafe()
const wallet = useWallet()
- const chain = useCurrentChain()
- const isSupported = isPairingSupported(chain?.disabledWallets)
const handleConnect = useConnectWallet()
const [, setSubmitted] = useState(false)
useSyncSafeCreationStep(setStep)
@@ -37,33 +31,21 @@ const ConnectWalletStep = ({ onSubmit, setStep }: StepRenderProps
-
-
-
-
-
+
+
+
+
-
+
-
- or
-
+
+ or
+
-
-
-
- {isSupported && (
-
-
-
- Connect to {'Safe{Wallet}'} mobile
-
-
-
- )}
-
+
+
>
)
diff --git a/src/components/settings/SignerAccountMFA/PasswordForm.tsx b/src/components/settings/SignerAccountMFA/PasswordForm.tsx
new file mode 100644
index 0000000000..0f4d905974
--- /dev/null
+++ b/src/components/settings/SignerAccountMFA/PasswordForm.tsx
@@ -0,0 +1,124 @@
+import { DeviceShareRecovery } from '@/hooks/wallets/mpc/recovery/DeviceShareRecovery'
+import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery'
+import { Typography, TextField, FormControlLabel, Checkbox, Button, Box } from '@mui/material'
+import { type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'
+import { useState, useMemo } from 'react'
+import { Controller, useForm } from 'react-hook-form'
+import { enableMFA } from './helper'
+
+enum PasswordFieldNames {
+ oldPassword = 'oldPassword',
+ newPassword = 'newPassword',
+ confirmPassword = 'confirmPassword',
+ storeDeviceShare = 'storeDeviceShare',
+}
+
+type PasswordFormData = {
+ [PasswordFieldNames.oldPassword]: string | undefined
+ [PasswordFieldNames.newPassword]: string
+ [PasswordFieldNames.confirmPassword]: string
+ [PasswordFieldNames.storeDeviceShare]: boolean
+}
+
+export const PasswordForm = ({ mpcCoreKit }: { mpcCoreKit: Web3AuthMPCCoreKit }) => {
+ const formMethods = useForm({
+ mode: 'all',
+ defaultValues: async () => {
+ const isDeviceShareStored = await new DeviceShareRecovery(mpcCoreKit).isEnabled()
+ return {
+ [PasswordFieldNames.confirmPassword]: '',
+ [PasswordFieldNames.oldPassword]: undefined,
+ [PasswordFieldNames.newPassword]: '',
+ [PasswordFieldNames.storeDeviceShare]: isDeviceShareStored,
+ }
+ },
+ })
+
+ const { register, formState, getValues, control, handleSubmit } = formMethods
+
+ const [enablingMFA, setEnablingMFA] = useState(false)
+
+ const isPasswordSet = useMemo(() => {
+ const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit)
+ return securityQuestions.isEnabled()
+ }, [mpcCoreKit])
+
+ const onSubmit = async (data: PasswordFormData) => {
+ setEnablingMFA(true)
+ try {
+ await enableMFA(mpcCoreKit, data)
+ } finally {
+ setEnablingMFA(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/settings/SignerAccountMFA/helper.ts b/src/components/settings/SignerAccountMFA/helper.ts
new file mode 100644
index 0000000000..9984df01cf
--- /dev/null
+++ b/src/components/settings/SignerAccountMFA/helper.ts
@@ -0,0 +1,68 @@
+import { DeviceShareRecovery } from '@/hooks/wallets/mpc/recovery/DeviceShareRecovery'
+import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery'
+import { logError } from '@/services/exceptions'
+import ErrorCodes from '@/services/exceptions/ErrorCodes'
+import { asError } from '@/services/exceptions/utils'
+import { getPubKeyPoint } from '@tkey-mpc/common-types'
+import { type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'
+import BN from 'bn.js'
+
+export const isMFAEnabled = (mpcCoreKit: Web3AuthMPCCoreKit) => {
+ if (!mpcCoreKit) {
+ return false
+ }
+ const { shareDescriptions } = mpcCoreKit.getKeyDetails()
+ return !Object.values(shareDescriptions).some((value) => value[0]?.includes('hashedShare'))
+}
+
+export const enableMFA = async (
+ mpcCoreKit: Web3AuthMPCCoreKit,
+ {
+ newPassword,
+ oldPassword,
+ storeDeviceShare,
+ }: {
+ newPassword: string
+ oldPassword: string | undefined
+ storeDeviceShare: boolean
+ },
+) => {
+ if (!mpcCoreKit) {
+ return
+ }
+ const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit)
+ const deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit)
+ try {
+ // 1. setup device factor with password recovery
+ await securityQuestions.upsertPassword(newPassword, oldPassword)
+ const securityQuestionFactor = await securityQuestions.recoverWithPassword(newPassword)
+ if (!securityQuestionFactor) {
+ throw Error('Could not recover using the new password recovery')
+ }
+
+ if (!isMFAEnabled(mpcCoreKit)) {
+ // 2. enable MFA in mpcCoreKit
+ const recoveryFactor = await mpcCoreKit.enableMFA({})
+
+ // 3. remove the recovery factor the mpcCoreKit creates
+ const recoverKey = new BN(recoveryFactor, 'hex')
+ const recoverPubKey = getPubKeyPoint(recoverKey)
+ await mpcCoreKit.deleteFactor(recoverPubKey, recoverKey)
+ }
+
+ const hasDeviceShare = await deviceShareRecovery.isEnabled()
+
+ if (!hasDeviceShare && storeDeviceShare) {
+ await deviceShareRecovery.createAndStoreDeviceFactor()
+ }
+
+ if (hasDeviceShare && !storeDeviceShare) {
+ // Switch to password recovery factor such that we can delete the device factor
+ await mpcCoreKit.inputFactorKey(new BN(securityQuestionFactor, 'hex'))
+ await deviceShareRecovery.removeDeviceFactor()
+ }
+ } catch (e) {
+ const error = asError(e)
+ logError(ErrorCodes._304, error.message)
+ }
+}
diff --git a/src/components/settings/SignerAccountMFA/index.tsx b/src/components/settings/SignerAccountMFA/index.tsx
new file mode 100644
index 0000000000..b3918f7c65
--- /dev/null
+++ b/src/components/settings/SignerAccountMFA/index.tsx
@@ -0,0 +1,21 @@
+import useMPC from '@/hooks/wallets/mpc/useMPC'
+import { Box, Typography } from '@mui/material'
+import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'
+
+import { PasswordForm } from './PasswordForm'
+
+const SignerAccountMFA = () => {
+ const mpcCoreKit = useMPC()
+
+ if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) {
+ return (
+
+ You are currently not logged in with a social account
+
+ )
+ }
+
+ return
+}
+
+export default SignerAccountMFA
diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx
index dc7df0699c..b3c940981c 100644
--- a/src/components/sidebar/SidebarNavigation/config.tsx
+++ b/src/components/sidebar/SidebarNavigation/config.tsx
@@ -104,6 +104,10 @@ export const settingsNavItems = [
label: 'Environment variables',
href: AppRoutes.settings.environmentVariables,
},
+ {
+ label: 'Signer account',
+ href: AppRoutes.settings.signerAccount,
+ },
]
export const generalSettingsNavItems = [
diff --git a/src/config/routes.ts b/src/config/routes.ts
index 6bea1cd90d..ad24a9cf41 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -27,6 +27,7 @@ export const AppRoutes = {
},
settings: {
spendingLimits: '/settings/spending-limits',
+ signerAccount: '/settings/signer-account',
setup: '/settings/setup',
modules: '/settings/modules',
index: '/settings',
diff --git a/src/hooks/wallets/mpc/__tests__/useMPC.test.ts b/src/hooks/wallets/mpc/__tests__/useMPC.test.ts
index 3f67c0a1e7..edf9dfe9c3 100644
--- a/src/hooks/wallets/mpc/__tests__/useMPC.test.ts
+++ b/src/hooks/wallets/mpc/__tests__/useMPC.test.ts
@@ -1,6 +1,6 @@
import * as useOnboard from '@/hooks/wallets/useOnboard'
import { renderHook, waitFor } from '@/tests/test-utils'
-import { getMPCCoreKitInstance, setMPCCoreKitInstance, useInitMPC } from '../useMPC'
+import { _getMPCCoreKitInstance, setMPCCoreKitInstance, useInitMPC } from '../useMPC'
import * as useChains from '@/hooks/useChains'
import { type ChainInfo, RPC_AUTHENTICATION } from '@safe-global/safe-gateway-typescript-sdk'
import { hexZeroPad } from 'ethers/lib/utils'
@@ -104,7 +104,7 @@ describe('useInitMPC', () => {
renderHook(() => useInitMPC())
await waitFor(() => {
- expect(getMPCCoreKitInstance()).toBeDefined()
+ expect(_getMPCCoreKitInstance()).toBeDefined()
expect(connectWalletSpy).not.toBeCalled()
})
})
@@ -151,7 +151,7 @@ describe('useInitMPC', () => {
await waitFor(() => {
expect(connectWalletSpy).toBeCalled()
- expect(getMPCCoreKitInstance()).toBeDefined()
+ expect(_getMPCCoreKitInstance()).toBeDefined()
})
})
@@ -215,7 +215,7 @@ describe('useInitMPC', () => {
await waitFor(() => {
expect(mockChainChangedListener).toHaveBeenCalledWith('0x5')
- expect(getMPCCoreKitInstance()).toBeDefined()
+ expect(_getMPCCoreKitInstance()).toBeDefined()
expect(connectWalletSpy).not.toBeCalled()
})
})
diff --git a/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts
new file mode 100644
index 0000000000..c6af1f84dd
--- /dev/null
+++ b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts
@@ -0,0 +1,317 @@
+import { act, renderHook, waitFor } from '@/tests/test-utils'
+import { MPCWalletState, useMPCWallet } from '../useMPCWallet'
+import * as useOnboard from '@/hooks/wallets/useOnboard'
+import { type OnboardAPI } from '@web3-onboard/core'
+import {
+ COREKIT_STATUS,
+ type UserInfo,
+ type OauthLoginParams,
+ type Web3AuthMPCCoreKit,
+ type TssSecurityQuestion,
+} from '@web3auth/mpc-core-kit'
+import * as mpcCoreKit from '@web3auth/mpc-core-kit'
+import { setMPCCoreKitInstance } from '../useMPC'
+import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module'
+import { ethers } from 'ethers'
+import BN from 'bn.js'
+
+/** time until mock login resolves */
+const MOCK_LOGIN_TIME = 1000
+
+/**
+ * Helper class for mocking MPC Core Kit login flow
+ */
+class MockMPCCoreKit {
+ status: COREKIT_STATUS = COREKIT_STATUS.INITIALIZED
+ state: {
+ userInfo: UserInfo | undefined
+ } = {
+ userInfo: undefined,
+ }
+
+ private stateAfterLogin: COREKIT_STATUS
+ private userInfoAfterLogin: UserInfo | undefined
+ private expectedFactorKey: BN
+ /**
+ *
+ * @param stateAfterLogin State after loginWithOauth resolves
+ * @param userInfoAfterLogin User info to set in the state after loginWithOauth resolves
+ * @param expectedFactorKey For MFA login flow the expected factor key. If inputFactorKey gets called with the expected factor key the state switches to logged in
+ */
+ constructor(stateAfterLogin: COREKIT_STATUS, userInfoAfterLogin: UserInfo, expectedFactorKey: BN = new BN(-1)) {
+ this.stateAfterLogin = stateAfterLogin
+ this.userInfoAfterLogin = userInfoAfterLogin
+ this.expectedFactorKey = expectedFactorKey
+ }
+
+ loginWithOauth(params: OauthLoginParams): Promise {
+ return new Promise((resolve) => {
+ // Resolve after 1 sec
+ setTimeout(() => {
+ this.status = this.stateAfterLogin
+ this.state.userInfo = this.userInfoAfterLogin
+ resolve()
+ }, MOCK_LOGIN_TIME)
+ })
+ }
+
+ inputFactorKey(factorKey: BN) {
+ if (factorKey.eq(this.expectedFactorKey)) {
+ this.status = COREKIT_STATUS.LOGGED_IN
+ return Promise.resolve()
+ } else {
+ Promise.reject()
+ }
+ }
+}
+
+describe('useMPCWallet', () => {
+ beforeAll(() => {
+ jest.useFakeTimers()
+ })
+ beforeEach(() => {
+ jest.resetAllMocks()
+ setMPCCoreKitInstance(undefined)
+ })
+ afterAll(() => {
+ jest.useRealTimers()
+ })
+ it('should have state NOT_INITIALIZED initially', () => {
+ const { result } = renderHook(() => useMPCWallet())
+ expect(result.current.walletState).toBe(MPCWalletState.NOT_INITIALIZED)
+ expect(result.current.userInfo.email).toBeUndefined()
+ })
+
+ describe('triggerLogin', () => {
+ it('should throw if Onboard is not initialized', () => {
+ const { result } = renderHook(() => useMPCWallet())
+ expect(result.current.triggerLogin()).rejects.toEqual(new Error('Onboard is not initialized'))
+ expect(result.current.walletState).toBe(MPCWalletState.NOT_INITIALIZED)
+ })
+
+ it('should throw if MPC Core Kit is not initialized', () => {
+ jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI)
+ const { result } = renderHook(() => useMPCWallet())
+
+ expect(result.current.triggerLogin()).rejects.toEqual(new Error('MPC Core Kit is not initialized'))
+ expect(result.current.walletState).toBe(MPCWalletState.NOT_INITIALIZED)
+ })
+
+ it('should handle successful log in for SFA account', async () => {
+ jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI)
+ const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve())
+ jest.spyOn(useOnboard, 'connectWallet').mockImplementation(connectWalletSpy)
+ setMPCCoreKitInstance(
+ new MockMPCCoreKit(COREKIT_STATUS.LOGGED_IN, {
+ email: 'test@test.com',
+ name: 'Test',
+ } as unknown as UserInfo) as unknown as Web3AuthMPCCoreKit,
+ )
+ const { result } = renderHook(() => useMPCWallet())
+
+ act(() => {
+ result.current.triggerLogin()
+ })
+
+ // While the login resolves we are in Authenticating state
+ expect(result.current.walletState === MPCWalletState.AUTHENTICATING)
+ expect(connectWalletSpy).not.toBeCalled()
+
+ // Resolve mock login
+ act(() => {
+ jest.advanceTimersByTime(MOCK_LOGIN_TIME)
+ })
+
+ // We should be logged in and onboard should get connected
+ await waitFor(() => {
+ expect(result.current.walletState === MPCWalletState.READY)
+ expect(connectWalletSpy).toBeCalledWith(expect.anything(), {
+ autoSelect: {
+ label: ONBOARD_MPC_MODULE_LABEL,
+ disableModals: true,
+ },
+ })
+ })
+ })
+
+ it('should handle successful log in for MFA account with device share', async () => {
+ const mockDeviceFactor = ethers.Wallet.createRandom().privateKey.slice(2)
+ jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI)
+ const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve())
+ jest.spyOn(useOnboard, 'connectWallet').mockImplementation(connectWalletSpy)
+ setMPCCoreKitInstance(
+ new MockMPCCoreKit(
+ COREKIT_STATUS.REQUIRED_SHARE,
+ {
+ email: 'test@test.com',
+ name: 'Test',
+ } as unknown as UserInfo,
+ new BN(mockDeviceFactor, 'hex'),
+ ) as unknown as Web3AuthMPCCoreKit,
+ )
+
+ jest.spyOn(mpcCoreKit, 'getWebBrowserFactor').mockReturnValue(Promise.resolve(mockDeviceFactor))
+
+ const { result } = renderHook(() => useMPCWallet())
+
+ act(() => {
+ result.current.triggerLogin()
+ })
+
+ // While the login resolves we are in Authenticating state
+ expect(result.current.walletState === MPCWalletState.AUTHENTICATING)
+ expect(connectWalletSpy).not.toBeCalled()
+
+ // Resolve mock login
+ act(() => {
+ jest.advanceTimersByTime(MOCK_LOGIN_TIME)
+ })
+
+ // We should be logged in and onboard should get connected
+ await waitFor(() => {
+ expect(result.current.walletState === MPCWalletState.READY)
+ expect(connectWalletSpy).toBeCalledWith(expect.anything(), {
+ autoSelect: {
+ label: ONBOARD_MPC_MODULE_LABEL,
+ disableModals: true,
+ },
+ })
+ })
+ })
+
+ it('should require manual share for MFA account without device share', async () => {
+ jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI)
+ const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve())
+ jest.spyOn(useOnboard, 'connectWallet').mockImplementation(connectWalletSpy)
+ setMPCCoreKitInstance(
+ new MockMPCCoreKit(COREKIT_STATUS.REQUIRED_SHARE, {
+ email: 'test@test.com',
+ name: 'Test',
+ } as unknown as UserInfo) as unknown as Web3AuthMPCCoreKit,
+ )
+
+ // TODO: remove unnecessary cast if mpc core sdk gets updated
+ jest.spyOn(mpcCoreKit, 'getWebBrowserFactor').mockReturnValue(Promise.resolve(undefined as unknown as string))
+ jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({
+ getQuestion: () => 'SOME RANDOM QUESTION',
+ } as unknown as TssSecurityQuestion)
+
+ const { result } = renderHook(() => useMPCWallet())
+
+ act(() => {
+ result.current.triggerLogin()
+ })
+
+ // While the login resolves we are in Authenticating state
+ expect(result.current.walletState === MPCWalletState.AUTHENTICATING)
+ expect(connectWalletSpy).not.toBeCalled()
+
+ // Resolve mock login
+ act(() => {
+ jest.advanceTimersByTime(MOCK_LOGIN_TIME)
+ })
+
+ // A missing second factor should result in manual recovery state
+ await waitFor(() => {
+ expect(result.current.walletState === MPCWalletState.MANUAL_RECOVERY)
+ expect(connectWalletSpy).not.toBeCalled()
+ })
+ })
+ })
+
+ describe('resetAccount', () => {
+ it('should throw if mpcCoreKit is not initialized', () => {
+ const { result } = renderHook(() => useMPCWallet())
+ expect(result.current.resetAccount()).rejects.toEqual(
+ new Error('MPC Core Kit is not initialized or the user is not logged in'),
+ )
+ })
+ it('should reset an account by overwriting the metadata', async () => {
+ const mockSetMetadata = jest.fn()
+ const mockMPCCore = {
+ metadataKey: ethers.Wallet.createRandom().privateKey.slice(2),
+ state: {
+ userInfo: undefined,
+ },
+ tKey: {
+ storageLayer: {
+ setMetadata: mockSetMetadata,
+ },
+ },
+ }
+
+ setMPCCoreKitInstance(mockMPCCore as unknown as Web3AuthMPCCoreKit)
+
+ const { result } = renderHook(() => useMPCWallet())
+
+ await result.current.resetAccount()
+
+ expect(mockSetMetadata).toHaveBeenCalledWith({
+ privKey: new BN(mockMPCCore.metadataKey, 'hex'),
+ input: { message: 'KEY_NOT_FOUND' },
+ })
+ })
+ })
+
+ describe('recoverFactorWithPassword', () => {
+ it('should throw if mpcCoreKit is not initialized', () => {
+ const { result } = renderHook(() => useMPCWallet())
+ expect(result.current.recoverFactorWithPassword('test', false)).rejects.toEqual(
+ new Error('MPC Core Kit is not initialized'),
+ )
+ })
+
+ it('should not recover if wrong password is entered', () => {
+ setMPCCoreKitInstance({
+ state: {
+ userInfo: undefined,
+ },
+ } as unknown as Web3AuthMPCCoreKit)
+ const { result } = renderHook(() => useMPCWallet())
+ jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({
+ getQuestion: () => 'SOME RANDOM QUESTION',
+ recoverFactor: () => {
+ throw new Error('Invalid answer')
+ },
+ } as unknown as TssSecurityQuestion)
+
+ expect(result.current.recoverFactorWithPassword('test', false)).rejects.toEqual(new Error('Invalid answer'))
+ })
+
+ it('should input recovered factor if correct password is entered', async () => {
+ const mockSecurityQuestionFactor = ethers.Wallet.createRandom().privateKey.slice(2)
+ const connectWalletSpy = jest.fn().mockImplementation(() => Promise.resolve())
+ jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI)
+ jest.spyOn(useOnboard, 'connectWallet').mockImplementation(connectWalletSpy)
+
+ setMPCCoreKitInstance(
+ new MockMPCCoreKit(
+ COREKIT_STATUS.REQUIRED_SHARE,
+ {
+ email: 'test@test.com',
+ name: 'Test',
+ } as unknown as UserInfo,
+ new BN(mockSecurityQuestionFactor, 'hex'),
+ ) as unknown as Web3AuthMPCCoreKit,
+ )
+
+ const { result } = renderHook(() => useMPCWallet())
+ jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({
+ getQuestion: () => 'SOME RANDOM QUESTION',
+ recoverFactor: () => Promise.resolve(mockSecurityQuestionFactor),
+ } as unknown as TssSecurityQuestion)
+
+ act(() => result.current.recoverFactorWithPassword('test', false))
+
+ await waitFor(() => {
+ expect(result.current.walletState === MPCWalletState.READY)
+ expect(connectWalletSpy).toBeCalledWith(expect.anything(), {
+ autoSelect: {
+ label: ONBOARD_MPC_MODULE_LABEL,
+ disableModals: true,
+ },
+ })
+ })
+ })
+ })
+})
diff --git a/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts b/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts
new file mode 100644
index 0000000000..8a080ec626
--- /dev/null
+++ b/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts
@@ -0,0 +1,44 @@
+import {
+ BrowserStorage,
+ getWebBrowserFactor,
+ storeWebBrowserFactor,
+ TssShareType,
+ type Web3AuthMPCCoreKit,
+} from '@web3auth/mpc-core-kit'
+import BN from 'bn.js'
+import { getPubKeyPoint } from '@tkey-mpc/common-types'
+
+export class DeviceShareRecovery {
+ private mpcCoreKit: Web3AuthMPCCoreKit
+
+ constructor(mpcCoreKit: Web3AuthMPCCoreKit) {
+ this.mpcCoreKit = mpcCoreKit
+ }
+
+ async isEnabled() {
+ if (!this.mpcCoreKit.tKey.metadata) {
+ return false
+ }
+ return !!(await getWebBrowserFactor(this.mpcCoreKit))
+ }
+
+ async createAndStoreDeviceFactor() {
+ const userAgent = navigator.userAgent
+
+ const deviceFactorKey = new BN(
+ await this.mpcCoreKit.createFactor({ shareType: TssShareType.DEVICE, additionalMetadata: { userAgent } }),
+ 'hex',
+ )
+ await storeWebBrowserFactor(deviceFactorKey, this.mpcCoreKit)
+ }
+
+ async removeDeviceFactor() {
+ const deviceFactor = await getWebBrowserFactor(this.mpcCoreKit)
+ const key = new BN(deviceFactor, 'hex')
+ const pubKey = getPubKeyPoint(key)
+ const pubKeyX = pubKey.x.toString('hex', 64)
+ await this.mpcCoreKit.deleteFactor(pubKey)
+ const currentStorage = BrowserStorage.getInstance('mpc_corekit_store')
+ currentStorage.set(pubKeyX, undefined)
+ }
+}
diff --git a/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts b/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts
new file mode 100644
index 0000000000..0d707cb29f
--- /dev/null
+++ b/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts
@@ -0,0 +1,48 @@
+import { TssSecurityQuestion, TssShareType, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'
+
+export class SecurityQuestionRecovery {
+ /** This is only used internally in the metadata store of tKey. Not in the UI */
+ private static readonly DEFAULT_SECURITY_QUESTION = 'ENTER PASSWORD'
+
+ private mpcCoreKit: Web3AuthMPCCoreKit
+ private securityQuestions = new TssSecurityQuestion()
+
+ constructor(mpcCoreKit: Web3AuthMPCCoreKit) {
+ this.mpcCoreKit = mpcCoreKit
+ }
+
+ isEnabled(): boolean {
+ try {
+ const question = this.securityQuestions.getQuestion(this.mpcCoreKit)
+ return !!question
+ } catch (error) {
+ // It errors out if recovery is not setup currently
+ return false
+ }
+ }
+
+ async upsertPassword(newPassword: string, oldPassword?: string) {
+ if (this.isEnabled()) {
+ if (!oldPassword) {
+ throw Error('To change the password you need to provide the old password')
+ }
+ await this.securityQuestions.changeSecurityQuestion({
+ answer: oldPassword,
+ mpcCoreKit: this.mpcCoreKit,
+ newAnswer: newPassword,
+ newQuestion: SecurityQuestionRecovery.DEFAULT_SECURITY_QUESTION,
+ })
+ } else {
+ await this.securityQuestions.setSecurityQuestion({
+ question: SecurityQuestionRecovery.DEFAULT_SECURITY_QUESTION,
+ answer: newPassword,
+ mpcCoreKit: this.mpcCoreKit,
+ shareType: TssShareType.DEVICE,
+ })
+ }
+ }
+
+ async recoverWithPassword(password: string) {
+ return this.securityQuestions.recoverFactor(this.mpcCoreKit, password)
+ }
+}
diff --git a/src/hooks/wallets/mpc/useMPC.ts b/src/hooks/wallets/mpc/useMPC.ts
index ba110255ea..c7f4607c05 100644
--- a/src/hooks/wallets/mpc/useMPC.ts
+++ b/src/hooks/wallets/mpc/useMPC.ts
@@ -24,7 +24,7 @@ export const useInitMPC = () => {
chainNamespace: CHAIN_NAMESPACES.EIP155,
rpcTarget: getRpcServiceUrl(chain.rpcUri),
displayName: chain.chainName,
- blockExplorer: chain.blockExplorerUriTemplate.address,
+ blockExplorer: new URL(chain.blockExplorerUriTemplate.address).origin,
ticker: chain.nativeCurrency.symbol,
tickerName: chain.nativeCurrency.name,
}
@@ -40,7 +40,7 @@ export const useInitMPC = () => {
const web3AuthCoreKit = new Web3AuthMPCCoreKit({
web3AuthClientId: WEB3_AUTH_CLIENT_ID,
// Available networks are "sapphire_devnet", "sapphire_mainnet"
- web3AuthNetwork: WEB3AUTH_NETWORK.DEVNET,
+ web3AuthNetwork: WEB3AUTH_NETWORK.MAINNET,
baseUrl: `${window.location.origin}/serviceworker`,
uxMode: 'popup',
enableLogging: true,
@@ -79,7 +79,7 @@ export const useInitMPC = () => {
}, [chain, onboard])
}
-export const getMPCCoreKitInstance = getStore
+export const _getMPCCoreKitInstance = getStore
export const setMPCCoreKitInstance = setStore
diff --git a/src/hooks/wallets/mpc/useMPCWallet.ts b/src/hooks/wallets/mpc/useMPCWallet.ts
index f0d54044c5..9fc69b4d06 100644
--- a/src/hooks/wallets/mpc/useMPCWallet.ts
+++ b/src/hooks/wallets/mpc/useMPCWallet.ts
@@ -2,24 +2,22 @@ import { useState } from 'react'
import useMPC from './useMPC'
import BN from 'bn.js'
import { GOOGLE_CLIENT_ID, WEB3AUTH_VERIFIER_ID } from '@/config/constants'
-import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'
+import { COREKIT_STATUS, getWebBrowserFactor } from '@web3auth/mpc-core-kit'
import useOnboard, { connectWallet } from '../useOnboard'
import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module'
+import { SecurityQuestionRecovery } from './recovery/SecurityQuestionRecovery'
+import { DeviceShareRecovery } from './recovery/DeviceShareRecovery'
export enum MPCWalletState {
NOT_INITIALIZED,
AUTHENTICATING,
- AUTHENTICATED,
- CREATING_SECOND_FACTOR,
- RECOVERING_ACCOUNT_PASSWORD,
- CREATED_SECOND_FACTOR,
- FINALIZING_ACCOUNT,
+ MANUAL_RECOVERY,
READY,
}
export type MPCWalletHook = {
upsertPasswordBackup: (password: string) => Promise
- recoverFactorWithPassword: (password: string) => Promise
+ recoverFactorWithPassword: (password: string, storeDeviceShare: boolean) => Promise
walletState: MPCWalletState
triggerLogin: () => Promise
resetAccount: () => Promise
@@ -38,7 +36,7 @@ export const useMPCWallet = (): MPCWalletHook => {
// Resetting your account means clearing all the metadata associated with it from the metadata server
// The key details will be deleted from our server and you will not be able to recover your account
if (!mpcCoreKit || !mpcCoreKit.metadataKey) {
- throw new Error('coreKitInstance is not set or the user is not logged in')
+ throw new Error('MPC Core Kit is not initialized or the user is not logged in')
}
// In web3auth an account is reset by overwriting the metadata with KEY_NOT_FOUND
@@ -50,13 +48,11 @@ export const useMPCWallet = (): MPCWalletHook => {
const triggerLogin = async () => {
if (!onboard) {
- console.error('Onboard not initialized')
- return
+ throw Error('Onboard is not initialized')
}
if (!mpcCoreKit) {
- console.error('tKey not initialized yet')
- return
+ throw Error('MPC Core Kit is not initialized')
}
try {
setWalletState(MPCWalletState.AUTHENTICATING)
@@ -68,26 +64,70 @@ export const useMPCWallet = (): MPCWalletHook => {
},
})
- if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) {
- connectWallet(onboard, {
- autoSelect: {
- label: ONBOARD_MPC_MODULE_LABEL,
- disableModals: true,
- },
- }).catch((reason) => console.error('Error connecting to MPC module:', reason))
+ if (mpcCoreKit.status === COREKIT_STATUS.REQUIRED_SHARE) {
+ // Check if we have a device share stored
+ const deviceFactor = await getWebBrowserFactor(mpcCoreKit)
+ if (deviceFactor) {
+ // Recover from device factor
+ const deviceFactorKey = new BN(deviceFactor, 'hex')
+ await mpcCoreKit.inputFactorKey(deviceFactorKey)
+ } else {
+ // Check password recovery
+ const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit)
+ if (securityQuestions.isEnabled()) {
+ setWalletState(MPCWalletState.MANUAL_RECOVERY)
+ return
+ }
+ }
}
- setWalletState(MPCWalletState.AUTHENTICATED)
+ finalizeLogin()
} catch (error) {
setWalletState(MPCWalletState.NOT_INITIALIZED)
console.error(error)
}
}
+ const finalizeLogin = () => {
+ if (!mpcCoreKit || !onboard) {
+ return
+ }
+ if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) {
+ connectWallet(onboard, {
+ autoSelect: {
+ label: ONBOARD_MPC_MODULE_LABEL,
+ disableModals: true,
+ },
+ }).catch((reason) => console.error('Error connecting to MPC module:', reason))
+ setWalletState(MPCWalletState.READY)
+ }
+ }
+
+ const recoverFactorWithPassword = async (password: string, storeDeviceShare: boolean = false) => {
+ if (!mpcCoreKit) {
+ throw new Error('MPC Core Kit is not initialized')
+ }
+
+ const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit)
+
+ if (securityQuestions.isEnabled()) {
+ const factorKeyString = await securityQuestions.recoverWithPassword(password)
+ const factorKey = new BN(factorKeyString, 'hex')
+ await mpcCoreKit.inputFactorKey(factorKey)
+
+ if (storeDeviceShare) {
+ const deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit)
+ await deviceShareRecovery.createAndStoreDeviceFactor()
+ }
+
+ finalizeLogin()
+ }
+ }
+
return {
triggerLogin,
walletState,
- recoverFactorWithPassword: () => Promise.resolve(),
+ recoverFactorWithPassword,
resetAccount: criticalResetAccount,
upsertPasswordBackup: () => Promise.resolve(),
userInfo: {
diff --git a/src/pages/settings/signer-account.tsx b/src/pages/settings/signer-account.tsx
new file mode 100644
index 0000000000..3c0f36b91b
--- /dev/null
+++ b/src/pages/settings/signer-account.tsx
@@ -0,0 +1,37 @@
+import { Grid, Paper, Typography } from '@mui/material'
+
+import type { NextPage } from 'next'
+import Head from 'next/head'
+
+import SettingsHeader from '@/components/settings/SettingsHeader'
+import SignerAccountMFA from '@/components/settings/SignerAccountMFA'
+
+const SignerAccountPage: NextPage = () => {
+ return (
+ <>
+
+ {'Safe{Wallet} – Settings – Signer account'}
+
+
+
+
+
+
+
+
+
+ Multi-factor Authentication
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default SignerAccountPage
diff --git a/src/services/exceptions/ErrorCodes.ts b/src/services/exceptions/ErrorCodes.ts
index 22fe117513..f9a6986dc7 100644
--- a/src/services/exceptions/ErrorCodes.ts
+++ b/src/services/exceptions/ErrorCodes.ts
@@ -16,6 +16,7 @@ enum ErrorCodes {
_302 = '302: Error connecting to the wallet',
_303 = '303: Error creating pairing session',
+ _304 = '304: Error enabling MFA',
_600 = '600: Error fetching Safe info',
_601 = '601: Error fetching balances',
diff --git a/src/services/mpc/__tests__/module.test.ts b/src/services/mpc/__tests__/module.test.ts
index 4a510b992f..3a03f09c81 100644
--- a/src/services/mpc/__tests__/module.test.ts
+++ b/src/services/mpc/__tests__/module.test.ts
@@ -36,7 +36,7 @@ describe('MPC Onboard module', () => {
send: mockReadOnlySend,
} as any)
- jest.spyOn(useMPC, 'getMPCCoreKitInstance').mockImplementation(() => {
+ jest.spyOn(useMPC, '_getMPCCoreKitInstance').mockImplementation(() => {
return {
provider: {},
} as any
@@ -85,7 +85,7 @@ describe('MPC Onboard module', () => {
send: mockReadOnlySend,
} as any)
- jest.spyOn(useMPC, 'getMPCCoreKitInstance').mockImplementation(() => {
+ jest.spyOn(useMPC, '_getMPCCoreKitInstance').mockImplementation(() => {
return {
provider: {
request: mockMPCProviderRequest,
diff --git a/src/services/mpc/module.ts b/src/services/mpc/module.ts
index 180cbddc43..1814f111d0 100644
--- a/src/services/mpc/module.ts
+++ b/src/services/mpc/module.ts
@@ -1,9 +1,9 @@
-import { getMPCCoreKitInstance } from '@/hooks/wallets/mpc/useMPC'
+import { _getMPCCoreKitInstance } from '@/hooks/wallets/mpc/useMPC'
import { getWeb3ReadOnly } from '@/hooks/wallets/web3'
import { type WalletInit, ProviderRpcError } from '@web3-onboard/common'
import { type EIP1193Provider } from '@web3-onboard/core'
-const getMPCProvider = () => getMPCCoreKitInstance()?.provider
+const getMPCProvider = () => _getMPCCoreKitInstance()?.provider
const assertDefined = (mpcProvider: T | undefined) => {
if (!mpcProvider) {
@@ -78,7 +78,7 @@ function MpcModule(): WalletInit {
return web3.removeListener(event, listener)
},
disconnect: () => {
- getMPCCoreKitInstance()?.logout()
+ _getMPCCoreKitInstance()?.logout()
},
}