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/settings/SignerAccountMFA/index.tsx b/src/components/settings/SignerAccountMFA/index.tsx
new file mode 100644
index 0000000000..139b0bee99
--- /dev/null
+++ b/src/components/settings/SignerAccountMFA/index.tsx
@@ -0,0 +1,56 @@
+import useMPC from '@/hooks/wallets/mpc/useMPC'
+import { Box, Button, Typography } from '@mui/material'
+import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'
+import { getPubKeyPoint } from '@tkey-mpc/common-types'
+import useMFASettings from './useMFASettings'
+import { BN } from 'bn.js'
+import { useState } from 'react'
+
+const SignerAccountMFA = () => {
+ const mpcCoreKit = useMPC()
+ const mfaSettings = useMFASettings()
+
+ const [enablingMFA, setEnablingMFA] = useState(false)
+
+ const enableMFA = async () => {
+ setEnablingMFA(true)
+ if (!mpcCoreKit) {
+ return
+ }
+ try {
+ // First enable MFA in mpcCoreKit
+ const recoveryFactor = await mpcCoreKit.enableMFA({})
+
+ // Then remove the recovery factor the mpcCoreKit creates
+ const recoverKey = new BN(recoveryFactor, 'hex')
+ const recoverPubKey = getPubKeyPoint(recoverKey)
+ await mpcCoreKit.deleteFactor(recoverPubKey, recoverKey)
+ } catch (error) {
+ console.error(error)
+ } finally {
+ setEnablingMFA(false)
+ }
+ }
+
+ if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) {
+ return (
+
+ You are currently not logged in through a social account
+
+ )
+ }
+
+ return (
+
+ {mfaSettings?.mfaEnabled ? (
+ MFA is enabled!
+ ) : (
+
+ )}
+
+ )
+}
+
+export default SignerAccountMFA
diff --git a/src/components/settings/SignerAccountMFA/useMFASettings.ts b/src/components/settings/SignerAccountMFA/useMFASettings.ts
new file mode 100644
index 0000000000..29726087c0
--- /dev/null
+++ b/src/components/settings/SignerAccountMFA/useMFASettings.ts
@@ -0,0 +1,32 @@
+import useMPC from '@/hooks/wallets/mpc/useMPC'
+import { COREKIT_STATUS } from '@web3auth/mpc-core-kit'
+
+export type MFASettings = {
+ mfaEnabled: boolean
+} | null
+
+const useMFASettings = () => {
+ const mpcCoreKit = useMPC()
+
+ if (mpcCoreKit?.status !== COREKIT_STATUS.LOGGED_IN) {
+ return null
+ }
+
+ const { shareDescriptions } = mpcCoreKit?.getKeyDetails()
+
+ const hashedShareModuleFactor = Object.entries(shareDescriptions).find(([key, value]) =>
+ value[0]?.includes('hashedShare'),
+ )
+
+ if (hashedShareModuleFactor) {
+ return {
+ mfaEnabled: false,
+ }
+ }
+
+ return {
+ mfaEnabled: true,
+ }
+}
+
+export default useMFASettings
diff --git a/src/components/sidebar/SidebarNavigation/config.tsx b/src/components/sidebar/SidebarNavigation/config.tsx
index dc7df0699c..05eb71effb 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__/useMPCWallet.test.ts b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts
new file mode 100644
index 0000000000..ebb0e7fc7a
--- /dev/null
+++ b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts
@@ -0,0 +1,188 @@
+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 } 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'
+
+const MOCK_LOGIN_TIME = 1000
+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
+ 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()
+ }, 1000)
+ })
+ }
+
+ 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()
+ })
+ 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()
+ })
+
+ expect(result.current.walletState === MPCWalletState.AUTHENTICATING)
+ expect(connectWalletSpy).not.toBeCalled()
+
+ jest.advanceTimersByTime(MOCK_LOGIN_TIME)
+
+ 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()
+ })
+
+ expect(result.current.walletState === MPCWalletState.AUTHENTICATING)
+ expect(connectWalletSpy).not.toBeCalled()
+
+ jest.advanceTimersByTime(MOCK_LOGIN_TIME)
+
+ await waitFor(() => {
+ expect(result.current.walletState === MPCWalletState.READY)
+ expect(connectWalletSpy).toBeCalledWith(expect.anything(), {
+ autoSelect: {
+ label: ONBOARD_MPC_MODULE_LABEL,
+ disableModals: true,
+ },
+ })
+ })
+ })
+ })
+
+ 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' },
+ })
+ })
+ })
+})
diff --git a/src/hooks/wallets/mpc/useMPCWallet.ts b/src/hooks/wallets/mpc/useMPCWallet.ts
index f0d54044c5..42fd5e097f 100644
--- a/src/hooks/wallets/mpc/useMPCWallet.ts
+++ b/src/hooks/wallets/mpc/useMPCWallet.ts
@@ -2,18 +2,13 @@ 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'
export enum MPCWalletState {
NOT_INITIALIZED,
AUTHENTICATING,
- AUTHENTICATED,
- CREATING_SECOND_FACTOR,
- RECOVERING_ACCOUNT_PASSWORD,
- CREATED_SECOND_FACTOR,
- FINALIZING_ACCOUNT,
READY,
}
@@ -38,7 +33,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 +45,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,6 +61,18 @@ export const useMPCWallet = (): MPCWalletHook => {
},
})
+ 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)
+ }
+ }
+
+ // TODO: IF still required share, trigger another recovery option (i.e. Security Questions) or throw error as unrecoverable
+
if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) {
connectWallet(onboard, {
autoSelect: {
@@ -77,7 +82,7 @@ export const useMPCWallet = (): MPCWalletHook => {
}).catch((reason) => console.error('Error connecting to MPC module:', reason))
}
- setWalletState(MPCWalletState.AUTHENTICATED)
+ setWalletState(MPCWalletState.READY)
} catch (error) {
setWalletState(MPCWalletState.NOT_INITIALIZED)
console.error(error)
diff --git a/src/pages/settings/signer-account.tsx b/src/pages/settings/signer-account.tsx
new file mode 100644
index 0000000000..dad68286bd
--- /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