From abdc544e22e9839ab01d251ab5cb6f6969323e20 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 30 Oct 2023 12:58:20 +0100 Subject: [PATCH] [Seedless-Onboarding] Refactor context + provider into service class (#2695) Co-authored-by: Usame Algan --- .../ConnectWallet/MPCWalletProvider.tsx | 20 - .../common/ConnectWallet/WalletDetails.tsx | 4 +- .../ConnectWallet/__tests__/MPCLogin.test.tsx | 136 ------- .../common/ConnectWallet/styles.module.css | 5 + .../common/NetworkSelector/index.tsx | 2 +- .../common/SocialLoginInfo/index.tsx | 6 +- .../PasswordRecovery.tsx | 16 +- .../__tests__/PasswordRecovery.test.tsx | 48 +++ .../__tests__/SocialSignerLogin.test.tsx | 179 +++++++++ .../MPCLogin.tsx => SocialSigner/index.tsx} | 85 +++-- .../common/SocialSigner/styles.module.css | 24 ++ .../common/WalletInfo/index.test.tsx | 38 +- src/components/common/WalletInfo/index.tsx | 23 +- .../common/WalletOverview/index.tsx | 2 +- src/components/new-safe/create/index.tsx | 2 +- .../create/steps/ReviewStep/index.test.tsx | 2 +- .../create/steps/ReviewStep/index.tsx | 2 +- .../ExportMPCAccountModal.tsx | 11 +- .../SocialSignerExport/index.tsx | 21 +- .../SecurityLogin/SocialSignerMFA/helper.ts | 51 --- .../SecurityLogin/SocialSignerMFA/index.tsx | 23 +- .../settings/SecurityLogin/index.tsx | 2 +- .../welcome/WelcomeLogin/WalletLogin.tsx | 2 +- src/components/welcome/WelcomeLogin/index.tsx | 4 +- .../wallets/mpc/__tests__/useMPC.test.ts | 4 +- .../mpc/__tests__/useMPCWallet.test.ts | 345 ------------------ src/hooks/wallets/mpc/useMPC.ts | 4 +- src/hooks/wallets/mpc/useMPCWallet.ts | 185 ---------- src/hooks/wallets/mpc/useSocialWallet.ts | 63 ++++ src/hooks/wallets/wallets.ts | 2 +- src/pages/_app.tsx | 5 +- .../mpc/{module.ts => SocialLoginModule.ts} | 0 src/services/mpc/SocialWalletService.ts | 153 ++++++++ .../mpc/__mocks__/SocialWalletService.ts | 74 ++++ .../mpc/__tests__/SocialWalletService.test.ts | 269 ++++++++++++++ src/services/mpc/__tests__/module.test.ts | 2 +- src/services/mpc/interfaces.ts | 52 +++ .../mpc/recovery/DeviceShareRecovery.ts | 13 +- .../mpc/recovery/SecurityQuestionRecovery.ts | 0 src/tests/builders/eip1193Provider.ts | 10 + src/tests/builders/wallet.ts | 16 + 41 files changed, 1055 insertions(+), 850 deletions(-) delete mode 100644 src/components/common/ConnectWallet/MPCWalletProvider.tsx delete mode 100644 src/components/common/ConnectWallet/__tests__/MPCLogin.test.tsx rename src/components/common/{ConnectWallet => SocialSigner}/PasswordRecovery.tsx (87%) create mode 100644 src/components/common/SocialSigner/__tests__/PasswordRecovery.test.tsx create mode 100644 src/components/common/SocialSigner/__tests__/SocialSignerLogin.test.tsx rename src/components/common/{ConnectWallet/MPCLogin.tsx => SocialSigner/index.tsx} (71%) create mode 100644 src/components/common/SocialSigner/styles.module.css delete mode 100644 src/components/settings/SecurityLogin/SocialSignerMFA/helper.ts delete mode 100644 src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts delete mode 100644 src/hooks/wallets/mpc/useMPCWallet.ts create mode 100644 src/hooks/wallets/mpc/useSocialWallet.ts rename src/services/mpc/{module.ts => SocialLoginModule.ts} (100%) create mode 100644 src/services/mpc/SocialWalletService.ts create mode 100644 src/services/mpc/__mocks__/SocialWalletService.ts create mode 100644 src/services/mpc/__tests__/SocialWalletService.test.ts create mode 100644 src/services/mpc/interfaces.ts rename src/{hooks/wallets => services}/mpc/recovery/DeviceShareRecovery.ts (79%) rename src/{hooks/wallets => services}/mpc/recovery/SecurityQuestionRecovery.ts (100%) create mode 100644 src/tests/builders/eip1193Provider.ts create mode 100644 src/tests/builders/wallet.ts diff --git a/src/components/common/ConnectWallet/MPCWalletProvider.tsx b/src/components/common/ConnectWallet/MPCWalletProvider.tsx deleted file mode 100644 index 27872ceddc..0000000000 --- a/src/components/common/ConnectWallet/MPCWalletProvider.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { type MPCWalletHook, MPCWalletState, useMPCWallet } from '@/hooks/wallets/mpc/useMPCWallet' -import { createContext, type ReactElement } from 'react' -import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' - -export const MpcWalletContext = createContext({ - walletState: MPCWalletState.NOT_INITIALIZED, - setWalletState: () => {}, - triggerLogin: () => Promise.resolve(COREKIT_STATUS.NOT_INITIALIZED), - resetAccount: () => Promise.resolve(), - upsertPasswordBackup: () => Promise.resolve(), - recoverFactorWithPassword: () => Promise.resolve(false), - userInfo: undefined, - exportPk: () => Promise.resolve(undefined), -}) - -export const MpcWalletProvider = ({ children }: { children: ReactElement }) => { - const mpcValue = useMPCWallet() - - return {children} -} diff --git a/src/components/common/ConnectWallet/WalletDetails.tsx b/src/components/common/ConnectWallet/WalletDetails.tsx index 29c09582a4..3421e0f8a7 100644 --- a/src/components/common/ConnectWallet/WalletDetails.tsx +++ b/src/components/common/ConnectWallet/WalletDetails.tsx @@ -2,7 +2,7 @@ import { Divider, Typography } from '@mui/material' import type { ReactElement } from 'react' import LockIcon from '@/public/images/common/lock.svg' -import MPCLogin from './MPCLogin' +import SocialSigner from '@/components/common/SocialSigner' import WalletLogin from '@/components/welcome/WelcomeLogin/WalletLogin' const WalletDetails = ({ onConnect }: { onConnect: () => void }): ReactElement => { @@ -18,7 +18,7 @@ const WalletDetails = ({ onConnect }: { onConnect: () => void }): ReactElement = - + ) } diff --git a/src/components/common/ConnectWallet/__tests__/MPCLogin.test.tsx b/src/components/common/ConnectWallet/__tests__/MPCLogin.test.tsx deleted file mode 100644 index 249e8da820..0000000000 --- a/src/components/common/ConnectWallet/__tests__/MPCLogin.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { render, waitFor } from '@/tests/test-utils' -import * as useWallet from '@/hooks/wallets/useWallet' -import * as useMPCWallet from '@/hooks/wallets/mpc/useMPCWallet' -import * as chains from '@/hooks/useChains' - -import MPCLogin, { _getSupportedChains } from '../MPCLogin' -import { hexZeroPad } from '@ethersproject/bytes' -import { type EIP1193Provider } from '@web3-onboard/common' -import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module' -import { MpcWalletProvider } from '../MPCWalletProvider' -import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' - -describe('MPCLogin', () => { - beforeEach(() => { - jest.resetAllMocks() - }) - - it('should render continue with connected account when on gnosis chain', async () => { - const mockOnLogin = jest.fn() - const walletAddress = hexZeroPad('0x1', 20) - jest - .spyOn(chains, 'useCurrentChain') - .mockReturnValue({ chainId: '100', disabledWallets: [] } as unknown as ChainInfo) - jest.spyOn(useWallet, 'default').mockReturnValue({ - address: walletAddress, - chainId: '5', - label: ONBOARD_MPC_MODULE_LABEL, - provider: {} as unknown as EIP1193Provider, - }) - jest.spyOn(useMPCWallet, 'useMPCWallet').mockReturnValue({ - userInfo: { - email: 'test@safe.test', - name: 'Test Testermann', - profileImage: 'test.png', - }, - triggerLogin: jest.fn(), - walletState: useMPCWallet.MPCWalletState.READY, - } as unknown as useMPCWallet.MPCWalletHook) - - const result = render( - - - , - ) - - await waitFor(() => { - expect(result.findByText('Continue as Test Testermann')).resolves.toBeDefined() - }) - - // We do not automatically invoke the callback as the user did not actively connect - expect(mockOnLogin).not.toHaveBeenCalled() - - const button = await result.findByRole('button') - button.click() - - expect(mockOnLogin).toHaveBeenCalled() - }) - - it('should render google login button and invoke the callback on connection if no wallet is connected on gnosis chain', async () => { - const mockOnLogin = jest.fn() - jest - .spyOn(chains, 'useCurrentChain') - .mockReturnValue({ chainId: '100', disabledWallets: [] } as unknown as ChainInfo) - jest.spyOn(useWallet, 'default').mockReturnValue(null) - const mockTriggerLogin = jest.fn(() => COREKIT_STATUS.LOGGED_IN) - jest.spyOn(useMPCWallet, 'useMPCWallet').mockReturnValue({ - triggerLogin: mockTriggerLogin, - } as unknown as useMPCWallet.MPCWalletHook) - - const result = render( - - - , - ) - - await waitFor(() => { - expect(result.findByText('Continue with Google')).resolves.toBeDefined() - }) - - // We do not automatically invoke the callback as the user did not actively connect - expect(mockOnLogin).not.toHaveBeenCalled() - - const button = await result.findByRole('button') - button.click() - - await waitFor(() => { - expect(mockOnLogin).toHaveBeenCalled() - }) - }) - - it('should disable the Google Login button with a message when not on gnosis chain', async () => { - const mockEthereumChain = { chainId: '1', chainName: 'Ethereum', disabledWallets: ['socialLogin'] } as ChainInfo - const mockGoerliChain = { chainId: '5', chainName: 'Goerli', disabledWallets: ['TallyHo'] } as ChainInfo - - jest - .spyOn(chains, 'useCurrentChain') - .mockReturnValue({ chainId: '1', disabledWallets: ['socialLogin'] } as unknown as ChainInfo) - jest.spyOn(chains, 'default').mockReturnValue({ configs: [mockEthereumChain, mockGoerliChain] }) - jest.spyOn(useMPCWallet, 'useMPCWallet').mockReturnValue({ - triggerLogin: jest.fn(), - } as unknown as useMPCWallet.MPCWalletHook) - - const result = render( - - - , - ) - - expect(result.getByText('Currently only supported on Goerli')).toBeInTheDocument() - expect(await result.findByRole('button')).toBeDisabled() - }) - - describe('getSupportedChains', () => { - it('returns chain names where social login is enabled', () => { - const mockEthereumChain = { chainId: '1', chainName: 'Ethereum', disabledWallets: ['socialLogin'] } as ChainInfo - const mockGnosisChain = { chainId: '100', chainName: 'Gnosis Chain', disabledWallets: ['Coinbase'] } as ChainInfo - const mockGoerliChain = { chainId: '5', chainName: 'Goerli', disabledWallets: [] } as unknown as ChainInfo - - const mockChains = [mockEthereumChain, mockGnosisChain, mockGoerliChain] - const result = _getSupportedChains(mockChains) - - expect(result).toEqual(['Gnosis Chain', 'Goerli']) - }) - - it('returns an empty array if social login is not enabled on any chain', () => { - const mockEthereumChain = { chainId: '1', chainName: 'Ethereum', disabledWallets: ['socialLogin'] } as ChainInfo - const mockGoerliChain = { chainId: '5', chainName: 'Goerli', disabledWallets: ['socialLogin'] } as ChainInfo - - const mockChains = [mockEthereumChain, mockGoerliChain] - const result = _getSupportedChains(mockChains) - - expect(result).toEqual([]) - }) - }) -}) diff --git a/src/components/common/ConnectWallet/styles.module.css b/src/components/common/ConnectWallet/styles.module.css index afa1289886..dd7bf01c3a 100644 --- a/src/components/common/ConnectWallet/styles.module.css +++ b/src/components/common/ConnectWallet/styles.module.css @@ -70,6 +70,11 @@ min-height: 42px; } +.loginError { + width: 100%; + margin: 0; +} + @media (max-width: 599.95px) { .socialLoginInfo > div > div { display: none; diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 9b2c7adaaa..4588d4d403 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -13,7 +13,7 @@ import { AppRoutes } from '@/config/routes' import { trackEvent, OVERVIEW_EVENTS } from '@/services/analytics' import useWallet from '@/hooks/wallets/useWallet' import { isSocialWalletEnabled } from '@/hooks/wallets/wallets' -import { isSocialLoginWallet } from '@/services/mpc/module' +import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule' const keepPathRoutes = [AppRoutes.welcome, AppRoutes.newSafe.create, AppRoutes.newSafe.load] diff --git a/src/components/common/SocialLoginInfo/index.tsx b/src/components/common/SocialLoginInfo/index.tsx index 8615a8a3be..4329cc2c83 100644 --- a/src/components/common/SocialLoginInfo/index.tsx +++ b/src/components/common/SocialLoginInfo/index.tsx @@ -1,14 +1,13 @@ import { Box, Typography } from '@mui/material' import css from './styles.module.css' -import { useContext } from 'react' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { type ConnectedWallet } from '@/services/onboard' -import { MpcWalletContext } from '@/components/common/ConnectWallet/MPCWalletProvider' import CopyAddressButton from '@/components/common/CopyAddressButton' import ExplorerButton from '@/components/common/ExplorerButton' import { getBlockExplorerLink } from '@/utils/chains' import { useAppSelector } from '@/store' import { selectSettings } from '@/store/settingsSlice' +import useSocialWallet from '@/hooks/wallets/mpc/useSocialWallet' const SocialLoginInfo = ({ wallet, @@ -19,7 +18,8 @@ const SocialLoginInfo = ({ chainInfo?: ChainInfo hideActions?: boolean }) => { - const { userInfo } = useContext(MpcWalletContext) + const socialWalletService = useSocialWallet() + const userInfo = socialWalletService?.getUserInfo() const prefix = chainInfo?.shortName const link = chainInfo ? getBlockExplorerLink(chainInfo, wallet.address) : undefined const settings = useAppSelector(selectSettings) diff --git a/src/components/common/ConnectWallet/PasswordRecovery.tsx b/src/components/common/SocialSigner/PasswordRecovery.tsx similarity index 87% rename from src/components/common/ConnectWallet/PasswordRecovery.tsx rename to src/components/common/SocialSigner/PasswordRecovery.tsx index d5c47fff6c..5a40e95756 100644 --- a/src/components/common/ConnectWallet/PasswordRecovery.tsx +++ b/src/components/common/SocialSigner/PasswordRecovery.tsx @@ -11,9 +11,12 @@ import { FormControl, } from '@mui/material' import { useState } from 'react' -import Track from '../Track' +import Track from '@/components/common/Track' import { FormProvider, useForm } from 'react-hook-form' import PasswordInput from '@/components/settings/SecurityLogin/SocialSignerMFA/PasswordInput' +import ErrorMessage from '@/components/tx/ErrorMessage' + +import css from './styles.module.css' type PasswordFormData = { password: string @@ -35,14 +38,17 @@ export const PasswordRecovery = ({ }, }) - const { handleSubmit, formState, setError } = formMethods + const { handleSubmit, formState } = formMethods + + const [error, setError] = useState() const onSubmit = async (data: PasswordFormData) => { + setError(undefined) try { await recoverFactorWithPassword(data.password, storeDeviceFactor) onSuccess?.() } catch (e) { - setError('password', { type: 'custom', message: 'Incorrect password' }) + setError('Incorrect password') } } @@ -71,7 +77,7 @@ export const PasswordRecovery = ({ - + + {error && {error}} + diff --git a/src/components/common/SocialSigner/__tests__/PasswordRecovery.test.tsx b/src/components/common/SocialSigner/__tests__/PasswordRecovery.test.tsx new file mode 100644 index 0000000000..488a33cccf --- /dev/null +++ b/src/components/common/SocialSigner/__tests__/PasswordRecovery.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render } from '@/tests/test-utils' +import { PasswordRecovery } from '@/components/common/SocialSigner/PasswordRecovery' +import { act, waitFor } from '@testing-library/react' + +describe('PasswordRecovery', () => { + it('displays an error if password is wrong', async () => { + const mockRecoverWithPassword = jest.fn(() => Promise.reject()) + const mockOnSuccess = jest.fn() + + const { getByText, getByLabelText } = render( + , + ) + + const passwordField = getByLabelText('Recovery password') + const submitButton = getByText('Submit') + + act(() => { + fireEvent.change(passwordField, { target: { value: 'somethingwrong' } }) + submitButton.click() + }) + + await waitFor(() => { + expect(mockOnSuccess).not.toHaveBeenCalled() + expect(getByText('Incorrect password')).toBeInTheDocument() + }) + }) + + it('calls onSuccess if password is correct', async () => { + const mockRecoverWithPassword = jest.fn(() => Promise.resolve()) + const mockOnSuccess = jest.fn() + + const { getByText, getByLabelText } = render( + , + ) + + const passwordField = getByLabelText('Recovery password') + const submitButton = getByText('Submit') + + act(() => { + fireEvent.change(passwordField, { target: { value: 'somethingCorrect' } }) + submitButton.click() + }) + + await waitFor(() => { + expect(mockOnSuccess).toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/common/SocialSigner/__tests__/SocialSignerLogin.test.tsx b/src/components/common/SocialSigner/__tests__/SocialSignerLogin.test.tsx new file mode 100644 index 0000000000..29a8ab560d --- /dev/null +++ b/src/components/common/SocialSigner/__tests__/SocialSignerLogin.test.tsx @@ -0,0 +1,179 @@ +import { act, render, waitFor } from '@/tests/test-utils' + +import { SocialSigner, _getSupportedChains } from '@/components/common/SocialSigner' +import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/SocialLoginModule' +import { COREKIT_STATUS, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' +import SocialWalletService from '@/services/mpc/SocialWalletService' +import { TxModalProvider } from '@/components/tx-flow' +import { fireEvent } from '@testing-library/react' +import { type ISocialWalletService } from '@/services/mpc/interfaces' +import { connectedWalletBuilder } from '@/tests/builders/wallet' +import { chainBuilder } from '@/tests/builders/chains' + +jest.mock('@/services/mpc/SocialWalletService') + +const mockWallet = connectedWalletBuilder().with({ chainId: '5', label: ONBOARD_MPC_MODULE_LABEL }).build() + +describe('SocialSignerLogin', () => { + let mockSocialWalletService: ISocialWalletService + + beforeEach(() => { + jest.resetAllMocks() + + mockSocialWalletService = new SocialWalletService({} as unknown as Web3AuthMPCCoreKit) + }) + + it('should render continue with connected account when on gnosis chain', async () => { + const mockOnLogin = jest.fn() + + const result = render( + + + , + ) + + await waitFor(() => { + expect(result.findByText('Continue as Test Testermann')).resolves.toBeDefined() + }) + + // We do not automatically invoke the callback as the user did not actively connect + expect(mockOnLogin).not.toHaveBeenCalled() + + const button = await result.findByRole('button') + button.click() + + expect(mockOnLogin).toHaveBeenCalled() + }) + + it('should render google login button and invoke the callback on connection if no wallet is connected on gnosis chain', async () => { + const mockOnLogin = jest.fn() + + const result = render( + + + , + ) + + await waitFor(async () => { + expect(result.findByText('Continue with Google')).resolves.toBeDefined() + expect(await result.findByRole('button')).toBeEnabled() + }) + + // We do not automatically invoke the callback as the user did not actively connect + expect(mockOnLogin).not.toHaveBeenCalled() + + const button = await result.findByRole('button') + act(() => { + button.click() + }) + + await waitFor(async () => { + expect(mockOnLogin).toHaveBeenCalled() + }) + }) + + it('should disable the Google Login button with a message when not on gnosis chain', async () => { + const result = render( + , + ) + + expect(result.getByText('Currently only supported on Goerli')).toBeInTheDocument() + expect(await result.findByRole('button')).toBeDisabled() + }) + + it('should display Password Recovery form and call onLogin if password recovery succeeds', async () => { + const mockOnLogin = jest.fn() + mockSocialWalletService.loginAndCreate = jest.fn(() => Promise.resolve(COREKIT_STATUS.REQUIRED_SHARE)) + mockSocialWalletService.getUserInfo = jest.fn(undefined) + + const result = render( + + + , + ) + + await waitFor(() => { + expect(result.findByText('Continue with Google')).resolves.toBeDefined() + }) + + // We do not automatically invoke the callback as the user did not actively connect + expect(mockOnLogin).not.toHaveBeenCalled() + + const button = await result.findByRole('button') + + act(() => { + button.click() + }) + + await waitFor(() => { + expect(result.findByText('Enter security password')).resolves.toBeDefined() + }) + + const passwordField = await result.findByLabelText('Recovery password') + const submitButton = await result.findByText('Submit') + + act(() => { + fireEvent.change(passwordField, { target: { value: 'Test1234!' } }) + submitButton.click() + }) + + await waitFor(() => { + expect(mockOnLogin).toHaveBeenCalled() + }) + }) + + describe('getSupportedChains', () => { + const mockEthereumChain = chainBuilder() + .with({ + chainId: '1', + chainName: 'Ethereum', + disabledWallets: ['socialLogin'], + }) + .build() + const mockGnosisChain = chainBuilder() + .with({ chainId: '100', chainName: 'Gnosis Chain', disabledWallets: ['Coinbase'] }) + .build() + it('returns chain names where social login is enabled', () => { + const mockGoerliChain = chainBuilder().with({ chainId: '5', chainName: 'Goerli', disabledWallets: [] }).build() + + const mockChains = [mockEthereumChain, mockGnosisChain, mockGoerliChain] + const result = _getSupportedChains(mockChains) + + expect(result).toEqual(['Gnosis Chain', 'Goerli']) + }) + + it('returns an empty array if social login is not enabled on any chain', () => { + const mockGoerliChain = chainBuilder() + .with({ chainId: '5', chainName: 'Goerli', disabledWallets: ['socialLogin'] }) + .build() + + const mockChains = [mockEthereumChain, mockGoerliChain] + const result = _getSupportedChains(mockChains) + + expect(result).toEqual([]) + }) + }) +}) diff --git a/src/components/common/ConnectWallet/MPCLogin.tsx b/src/components/common/SocialSigner/index.tsx similarity index 71% rename from src/components/common/ConnectWallet/MPCLogin.tsx rename to src/components/common/SocialSigner/index.tsx index 32d50e47bd..a772dba5e0 100644 --- a/src/components/common/ConnectWallet/MPCLogin.tsx +++ b/src/components/common/SocialSigner/index.tsx @@ -1,23 +1,23 @@ -import { MPCWalletState } from '@/hooks/wallets/mpc/useMPCWallet' import { Box, Button, SvgIcon, Typography } from '@mui/material' -import { useCallback, useContext, useMemo } from 'react' -import { MpcWalletContext } from './MPCWalletProvider' -import { PasswordRecovery } from './PasswordRecovery' +import { useCallback, useContext, useMemo, useState } from 'react' +import { PasswordRecovery } from '@/components/common/SocialSigner/PasswordRecovery' import GoogleLogo from '@/public/images/welcome/logo-google.svg' import InfoIcon from '@/public/images/notifications/info.svg' import css from './styles.module.css' import useWallet from '@/hooks/wallets/useWallet' -import Track from '../Track' +import Track from '@/components/common/Track' import { CREATE_SAFE_EVENTS } from '@/services/analytics' import { MPC_WALLET_EVENTS } from '@/services/analytics/events/mpcWallet' import useChains, { useCurrentChain } from '@/hooks/useChains' import { isSocialWalletEnabled } from '@/hooks/wallets/wallets' -import { isSocialLoginWallet } from '@/services/mpc/module' +import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule' import { CGW_NAMES } from '@/hooks/wallets/consts' import { type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import { TxModalContext } from '@/components/tx-flow' import { COREKIT_STATUS } from '@web3auth/mpc-core-kit' +import useSocialWallet from '@/hooks/wallets/mpc/useSocialWallet' +import madProps from '@/utils/mad-props' export const _getSupportedChains = (chains: ChainInfo[]) => { return chains @@ -38,47 +38,67 @@ const useIsSocialWalletEnabled = () => { return isSocialWalletEnabled(currentChain) } -const MPCLogin = ({ onLogin }: { onLogin?: () => void }) => { - const { triggerLogin, userInfo, walletState, setWalletState, recoverFactorWithPassword } = - useContext(MpcWalletContext) +type SocialSignerLoginProps = { + socialWalletService: ReturnType + wallet: ReturnType + supportedChains: ReturnType + isMPCLoginEnabled: ReturnType + onLogin?: () => void +} + +export const SocialSigner = ({ + socialWalletService, + wallet, + supportedChains, + isMPCLoginEnabled, + onLogin, +}: SocialSignerLoginProps) => { + const [loginPending, setLoginPending] = useState(false) const { setTxFlow } = useContext(TxModalContext) + const userInfo = socialWalletService?.getUserInfo() + const isDisabled = loginPending || !isMPCLoginEnabled - const wallet = useWallet() - const loginPending = walletState === MPCWalletState.AUTHENTICATING + const recoverPassword = useCallback( + async (password: string, storeDeviceFactor: boolean) => { + if (!socialWalletService) return - const supportedChains = useGetSupportedChains() - const isMPCLoginEnabled = useIsSocialWalletEnabled() + const success = await socialWalletService.recoverAccountWithPassword(password, storeDeviceFactor) - const isDisabled = loginPending || !isMPCLoginEnabled + if (success) { + onLogin?.() + setTxFlow(undefined) + } + }, + [onLogin, setTxFlow, socialWalletService], + ) const login = async () => { - const status = await triggerLogin() + if (!socialWalletService) return + + setLoginPending(true) + + const status = await socialWalletService.loginAndCreate() if (status === COREKIT_STATUS.LOGGED_IN) { onLogin?.() + setLoginPending(false) } if (status === COREKIT_STATUS.REQUIRED_SHARE) { setTxFlow( - , - () => setWalletState(MPCWalletState.NOT_INITIALIZED), + { + onLogin?.() + setLoginPending(false) + }} + />, + () => {}, false, ) } } - const recoverPassword = useCallback( - async (password: string, storeDeviceFactor: boolean) => { - const success = await recoverFactorWithPassword(password, storeDeviceFactor) - - if (success) { - onLogin?.() - setTxFlow(undefined) - } - }, - [onLogin, recoverFactorWithPassword, setTxFlow], - ) - const isSocialLogin = isSocialLoginWallet(wallet?.label) return ( @@ -148,4 +168,9 @@ const MPCLogin = ({ onLogin }: { onLogin?: () => void }) => { ) } -export default MPCLogin +export default madProps(SocialSigner, { + socialWalletService: useSocialWallet, + wallet: useWallet, + supportedChains: useGetSupportedChains, + isMPCLoginEnabled: useIsSocialWalletEnabled, +}) diff --git a/src/components/common/SocialSigner/styles.module.css b/src/components/common/SocialSigner/styles.module.css new file mode 100644 index 0000000000..92c29890de --- /dev/null +++ b/src/components/common/SocialSigner/styles.module.css @@ -0,0 +1,24 @@ +.profileImg { + border-radius: var(--space-2); + width: 32px; + height: 32px; +} + +.profileData { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.passwordWrapper { + padding: var(--space-4) var(--space-4) var(--space-2) var(--space-4); + display: flex; + flex-direction: column; + align-items: baseline; + gap: var(--space-1); +} + +.loginError { + width: 100%; + margin: 0; +} diff --git a/src/components/common/WalletInfo/index.test.tsx b/src/components/common/WalletInfo/index.test.tsx index f94de5e3a3..e402e7dd4a 100644 --- a/src/components/common/WalletInfo/index.test.tsx +++ b/src/components/common/WalletInfo/index.test.tsx @@ -2,11 +2,12 @@ import { render } from '@/tests/test-utils' import { WalletInfo } from '@/components/common/WalletInfo/index' import { type EIP1193Provider, type OnboardAPI } from '@web3-onboard/core' import { type NextRouter } from 'next/router' -import * as mpcModule from '@/services/mpc/module' +import * as mpcModule from '@/services/mpc/SocialLoginModule' import * as constants from '@/config/constants' -import * as mfaHelper from '@/components/settings/SecurityLogin/SocialSignerMFA/helper' import { type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' import { act } from '@testing-library/react' +import SocialWalletService from '@/services/mpc/SocialWalletService' +import type { ISocialWalletService } from '@/services/mpc/interfaces' const mockWallet = { address: '0x1234567890123456789012345678901234567890', @@ -26,17 +27,20 @@ const mockOnboard = { setChain: jest.fn(), } as unknown as OnboardAPI +jest.mock('@/services/mpc/SocialWalletService') + describe('WalletInfo', () => { + let socialWalletService: ISocialWalletService beforeEach(() => { jest.resetAllMocks() + socialWalletService = new SocialWalletService({} as unknown as Web3AuthMPCCoreKit) }) it('should display the wallet address', () => { const { getByText } = render( { const { getByText } = render( { const { getByText } = render( { const { getByText } = render( { const { queryByText } = render( { const { queryByText } = render( { it('should display an enable mfa button if mfa is not enabled', () => { jest.spyOn(mpcModule, 'isSocialLoginWallet').mockReturnValue(true) - jest.spyOn(mfaHelper, 'isMFAEnabled').mockReturnValue(false) const { getByText } = render( { it('should not display an enable mfa button if mfa is already enabled', () => { jest.spyOn(mpcModule, 'isSocialLoginWallet').mockReturnValue(true) - jest.spyOn(mfaHelper, 'isMFAEnabled').mockReturnValue(true) + + // Mock that MFA is enabled + socialWalletService.enableMFA('', '') const { queryByText } = render( + socialWalletService: ReturnType router: ReturnType onboard: ReturnType addressBook: ReturnType @@ -33,8 +28,7 @@ type WalletInfoProps = { export const WalletInfo = ({ wallet, - resetAccount, - mpcCoreKit, + socialWalletService, router, onboard, addressBook, @@ -50,6 +44,8 @@ export const WalletInfo = ({ } } + const resetAccount = () => socialWalletService?.__deleteAccount() + const handleDisconnect = () => { if (!wallet) return @@ -70,7 +66,7 @@ export const WalletInfo = ({ {isSocialLogin ? ( <> - {mpcCoreKit && !isMFAEnabled(mpcCoreKit) && ( + {socialWalletService && !socialWalletService.isMFAEnabled() && ( + + + setIsModalOpen(false)} open={isModalOpen} /> diff --git a/src/components/settings/SecurityLogin/SocialSignerMFA/helper.ts b/src/components/settings/SecurityLogin/SocialSignerMFA/helper.ts deleted file mode 100644 index 94b88cb8f0..0000000000 --- a/src/components/settings/SecurityLogin/SocialSignerMFA/helper.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery' -import { trackEvent } from '@/services/analytics' -import { MPC_WALLET_EVENTS } from '@/services/analytics/events/mpcWallet' -import { logError } from '@/services/exceptions' -import ErrorCodes from '@/services/exceptions/ErrorCodes' -import { asError } from '@/services/exceptions/utils' -import { type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' - -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, - currentPassword, - }: { - newPassword: string - currentPassword: string | undefined - }, -) => { - if (!mpcCoreKit) { - return - } - const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) - try { - // 1. setup device factor with password recovery - await securityQuestions.upsertPassword(newPassword, currentPassword) - const securityQuestionFactor = await securityQuestions.recoverWithPassword(newPassword) - if (!securityQuestionFactor) { - throw Error('Could not recover using the new password recovery') - } - - if (!isMFAEnabled(mpcCoreKit)) { - trackEvent(MPC_WALLET_EVENTS.ENABLE_MFA) - // 2. enable MFA in mpcCoreKit - await mpcCoreKit.enableMFA({}, false) - } - - await mpcCoreKit.commitChanges() - } catch (e) { - const error = asError(e) - logError(ErrorCodes._304, error.message) - throw error - } -} diff --git a/src/components/settings/SecurityLogin/SocialSignerMFA/index.tsx b/src/components/settings/SecurityLogin/SocialSignerMFA/index.tsx index 5121193345..bca00660bf 100644 --- a/src/components/settings/SecurityLogin/SocialSignerMFA/index.tsx +++ b/src/components/settings/SecurityLogin/SocialSignerMFA/index.tsx @@ -1,5 +1,4 @@ import Track from '@/components/common/Track' -import { SecurityQuestionRecovery } from '@/hooks/wallets/mpc/recovery/SecurityQuestionRecovery' import { Typography, Button, @@ -16,7 +15,6 @@ import { import { MPC_WALLET_EVENTS } from '@/services/analytics/events/mpcWallet' import { useState, useMemo, type ChangeEvent } from 'react' import { FormProvider, useForm } from 'react-hook-form' -import { enableMFA } from '@/components/settings/SecurityLogin/SocialSignerMFA/helper' import CheckIcon from '@/public/images/common/check-filled.svg' import LockWarningIcon from '@/public/images/common/lock-warning.svg' import PasswordInput from '@/components/settings/SecurityLogin/SocialSignerMFA/PasswordInput' @@ -24,8 +22,8 @@ import css from '@/components/settings/SecurityLogin/SocialSignerMFA/styles.modu import BarChartIcon from '@/public/images/common/bar-chart.svg' import ShieldIcon from '@/public/images/common/shield.svg' import ShieldOffIcon from '@/public/images/common/shield-off.svg' -import useMPC from '@/hooks/wallets/mpc/useMPC' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import useSocialWallet from '@/hooks/wallets/mpc/useSocialWallet' enum PasswordFieldNames { currentPassword = 'currentPassword', @@ -80,7 +78,7 @@ const passwordStrengthMap = { } as const const SocialSignerMFA = () => { - const mpcCoreKit = useMPC() + const socialWalletService = useSocialWallet() const [passwordStrength, setPasswordStrength] = useState() const [submitError, setSubmitError] = useState() const [open, setOpen] = useState(false) @@ -97,17 +95,20 @@ const SocialSignerMFA = () => { const { formState, handleSubmit, reset, watch } = formMethods const isPasswordSet = useMemo(() => { - if (!mpcCoreKit) return false - - const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) - return securityQuestions.isEnabled() - }, [mpcCoreKit]) + if (!socialWalletService) { + return false + } + return socialWalletService.isRecoveryPasswordSet() + }, [socialWalletService]) const onSubmit = async (data: PasswordFormData) => { - if (!mpcCoreKit) return + if (!socialWalletService) return try { - await enableMFA(mpcCoreKit, data) + await socialWalletService.enableMFA( + data[PasswordFieldNames.currentPassword], + data[PasswordFieldNames.newPassword], + ) onReset() setOpen(false) } catch (e) { diff --git a/src/components/settings/SecurityLogin/index.tsx b/src/components/settings/SecurityLogin/index.tsx index bb493cb90d..0a6e5f8223 100644 --- a/src/components/settings/SecurityLogin/index.tsx +++ b/src/components/settings/SecurityLogin/index.tsx @@ -2,7 +2,7 @@ import { Grid, Paper, Typography } from '@mui/material' import SocialSignerMFA from '@/components/settings/SecurityLogin/SocialSignerMFA' import SocialSignerExport from '@/components/settings/SecurityLogin/SocialSignerExport' import useWallet from '@/hooks/wallets/useWallet' -import { isSocialLoginWallet } from '@/services/mpc/module' +import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule' const SecurityLogin = () => { const wallet = useWallet() diff --git a/src/components/welcome/WelcomeLogin/WalletLogin.tsx b/src/components/welcome/WelcomeLogin/WalletLogin.tsx index 102b0de6ef..a02d570df8 100644 --- a/src/components/welcome/WelcomeLogin/WalletLogin.tsx +++ b/src/components/welcome/WelcomeLogin/WalletLogin.tsx @@ -1,7 +1,7 @@ import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' import Track from '@/components/common/Track' import useWallet from '@/hooks/wallets/useWallet' -import { isSocialLoginWallet } from '@/services/mpc/module' +import { isSocialLoginWallet } from '@/services/mpc/SocialLoginModule' import { CREATE_SAFE_EVENTS } from '@/services/analytics' import { Box, Button, Typography } from '@mui/material' import { EthHashInfo } from '@safe-global/safe-react-components' diff --git a/src/components/welcome/WelcomeLogin/index.tsx b/src/components/welcome/WelcomeLogin/index.tsx index 998d660307..a80237343a 100644 --- a/src/components/welcome/WelcomeLogin/index.tsx +++ b/src/components/welcome/WelcomeLogin/index.tsx @@ -1,4 +1,4 @@ -import MPCLogin from '@/components/common/ConnectWallet/MPCLogin' +import SocialSigner from '@/components/common/SocialSigner' import { AppRoutes } from '@/config/routes' import { Paper, SvgIcon, Typography, Divider, Link, Box } from '@mui/material' import SafeLogo from '@/public/images/logo-text.svg' @@ -35,7 +35,7 @@ const WelcomeLogin = () => { - + Already have a Safe Account? diff --git a/src/hooks/wallets/mpc/__tests__/useMPC.test.ts b/src/hooks/wallets/mpc/__tests__/useMPC.test.ts index edf9dfe9c3..a35c4875c0 100644 --- a/src/hooks/wallets/mpc/__tests__/useMPC.test.ts +++ b/src/hooks/wallets/mpc/__tests__/useMPC.test.ts @@ -4,7 +4,7 @@ import { _getMPCCoreKitInstance, setMPCCoreKitInstance, useInitMPC } from '../us import * as useChains from '@/hooks/useChains' import { type ChainInfo, RPC_AUTHENTICATION } from '@safe-global/safe-gateway-typescript-sdk' import { hexZeroPad } from 'ethers/lib/utils' -import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module' +import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/SocialLoginModule' import { type Web3AuthMPCCoreKit, COREKIT_STATUS } from '@web3auth/mpc-core-kit' import { type EIP1193Provider, type OnboardAPI } from '@web3-onboard/core' @@ -13,6 +13,8 @@ jest.mock('@web3auth/mpc-core-kit', () => ({ Web3AuthMPCCoreKit: jest.fn(), })) +jest.mock('@/hooks/wallets/mpc/useSocialWallet') + type MPCProvider = Web3AuthMPCCoreKit['provider'] /** diff --git a/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts b/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts deleted file mode 100644 index 176e7b777f..0000000000 --- a/src/hooks/wallets/mpc/__tests__/useMPCWallet.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -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' -import * as addressBookSlice from '@/store/addressBookSlice' -import * as useChainId from '@/hooks/useChainId' -import { hexZeroPad } from 'ethers/lib/utils' -import * as useAddressBook from '@/hooks/useAddressBook' - -/** time until mock login resolves */ -const MOCK_LOGIN_TIME = 1000 -/** Mock address for successful login */ -const mockSignerAddress = hexZeroPad('0x1', 20) - -/** - * 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() - } - } - - commitChanges() { - return Promise.resolve() - } - - getUserInfo() { - return this.state.userInfo - } -} - -describe('useMPCWallet', () => { - beforeAll(() => { - jest.useFakeTimers() - }) - beforeEach(() => { - jest.resetAllMocks() - setMPCCoreKitInstance(undefined) - jest.spyOn(useChainId, 'default').mockReturnValue('100') - }) - 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(useAddressBook, 'default').mockReturnValue({}) - 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(useAddressBook, 'default').mockReturnValue({}) - const upsertABSpy = jest.spyOn(addressBookSlice, 'upsertAddressBookEntry') - jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI) - const connectWalletSpy = jest.fn().mockResolvedValue([{ accounts: [{ address: mockSignerAddress }] }]) - 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()) - - let status: Promise - act(() => { - status = 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(status).resolves.toEqual(COREKIT_STATUS.LOGGED_IN) - expect(result.current.walletState === MPCWalletState.READY) - expect(connectWalletSpy).toBeCalledWith(expect.anything(), { - autoSelect: { - label: ONBOARD_MPC_MODULE_LABEL, - disableModals: true, - }, - }) - expect(upsertABSpy).toBeCalledWith({ address: mockSignerAddress, name: 'test@test.com', chainId: '100' }) - }) - }) - - it('should handle successful log in for MFA account with device share', async () => { - jest.spyOn(useAddressBook, 'default').mockReturnValue({ [mockSignerAddress]: 'Some name' }) - const upsertABSpy = jest.spyOn(addressBookSlice, 'upsertAddressBookEntry') - const mockDeviceFactor = ethers.Wallet.createRandom().privateKey.slice(2) - jest.spyOn(useOnboard, 'default').mockReturnValue({} as unknown as OnboardAPI) - const connectWalletSpy = jest.fn().mockResolvedValue([{ accounts: [{ address: mockSignerAddress }] }]) - - 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()) - - let status: Promise - act(() => { - status = 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(status).resolves.toEqual(COREKIT_STATUS.LOGGED_IN) - expect(result.current.walletState === MPCWalletState.READY) - expect(connectWalletSpy).toBeCalledWith(expect.anything(), { - autoSelect: { - label: ONBOARD_MPC_MODULE_LABEL, - disableModals: true, - }, - }) - expect(upsertABSpy).not.toHaveBeenCalled() - }) - }) - - 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, - ) - - jest.spyOn(mpcCoreKit, 'getWebBrowserFactor').mockReturnValue(Promise.resolve(undefined)) - jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ - getQuestion: () => 'SOME RANDOM QUESTION', - } as unknown as TssSecurityQuestion) - - const { result } = renderHook(() => useMPCWallet()) - - let status: Promise - act(() => { - status = 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(status).resolves.toEqual(COREKIT_STATUS.REQUIRED_SHARE) - 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/useMPC.ts b/src/hooks/wallets/mpc/useMPC.ts index 0e364d2454..611dede8d5 100644 --- a/src/hooks/wallets/mpc/useMPC.ts +++ b/src/hooks/wallets/mpc/useMPC.ts @@ -7,13 +7,15 @@ import { WEB3_AUTH_CLIENT_ID } from '@/config/constants' import { useCurrentChain } from '@/hooks/useChains' import { getRpcServiceUrl } from '../web3' import useOnboard, { connectWallet, getConnectedWallet } from '@/hooks/wallets/useOnboard' -import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/module' +import { useInitSocialWallet } from './useSocialWallet' +import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/SocialLoginModule' const { getStore, setStore, useStore } = new ExternalStore() export const useInitMPC = () => { const chain = useCurrentChain() const onboard = useOnboard() + useInitSocialWallet() useEffect(() => { if (!chain || !onboard) { diff --git a/src/hooks/wallets/mpc/useMPCWallet.ts b/src/hooks/wallets/mpc/useMPCWallet.ts deleted file mode 100644 index 1c7c1416af..0000000000 --- a/src/hooks/wallets/mpc/useMPCWallet.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { type Dispatch, type SetStateAction, 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, getWebBrowserFactor, type UserInfo } 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' -import { trackEvent } from '@/services/analytics' -import { MPC_WALLET_EVENTS } from '@/services/analytics/events/mpcWallet' -import useAddressBook from '@/hooks/useAddressBook' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' -import { useAppDispatch } from '@/store' -import useChainId from '@/hooks/useChainId' -import { checksumAddress } from '@/utils/addresses' - -export enum MPCWalletState { - NOT_INITIALIZED, - AUTHENTICATING, - MANUAL_RECOVERY, - READY, -} - -export type MPCWalletHook = { - upsertPasswordBackup: (password: string) => Promise - recoverFactorWithPassword: (password: string, storeDeviceShare: boolean) => Promise - walletState: MPCWalletState - setWalletState: Dispatch> - triggerLogin: () => Promise - resetAccount: () => Promise - userInfo: UserInfo | undefined - exportPk: (password: string) => Promise -} - -export const useMPCWallet = (): MPCWalletHook => { - const [walletState, setWalletState] = useState(MPCWalletState.NOT_INITIALIZED) - const mpcCoreKit = useMPC() - const onboard = useOnboard() - const addressBook = useAddressBook() - const currentChainId = useChainId() - const dispatch = useAppDispatch() - - const criticalResetAccount = async (): Promise => { - // This is a critical function that should only be used for testing purposes - // 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?.metadataKey) { - 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 - await mpcCoreKit.tKey.storageLayer.setMetadata({ - privKey: new BN(mpcCoreKit.metadataKey, 'hex'), - input: { message: 'KEY_NOT_FOUND' }, - }) - } - - const triggerLogin = async () => { - if (!onboard) { - throw Error('Onboard is not initialized') - } - - if (!mpcCoreKit) { - throw Error('MPC Core Kit is not initialized') - } - try { - setWalletState(MPCWalletState.AUTHENTICATING) - await mpcCoreKit.loginWithOauth({ - subVerifierDetails: { - typeOfLogin: 'google', - verifier: WEB3AUTH_VERIFIER_ID, - clientId: GOOGLE_CLIENT_ID, - }, - }) - - 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()) { - trackEvent(MPC_WALLET_EVENTS.MANUAL_RECOVERY) - setWalletState(MPCWalletState.MANUAL_RECOVERY) - return mpcCoreKit.status - } - } - } - - await finalizeLogin() - return mpcCoreKit.status - } catch (error) { - setWalletState(MPCWalletState.NOT_INITIALIZED) - console.error(error) - return mpcCoreKit.status - } - } - - const finalizeLogin = async () => { - if (!mpcCoreKit || !onboard) { - return - } - - if (mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) { - await mpcCoreKit.commitChanges() - - const wallets = await connectWallet(onboard, { - autoSelect: { - label: ONBOARD_MPC_MODULE_LABEL, - disableModals: true, - }, - }).catch((reason) => console.error('Error connecting to MPC module:', reason)) - - // If the signer is not in the address book => add the user's email as name - if (wallets && currentChainId && wallets.length > 0) { - const address = wallets[0].accounts[0]?.address - if (address) { - const signerAddress = checksumAddress(address) - if (addressBook[signerAddress] === undefined) { - const email = mpcCoreKit.getUserInfo().email - dispatch(upsertAddressBookEntry({ address: signerAddress, chainId: currentChainId, name: email })) - } - } - } - 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() - } - - await finalizeLogin() - } - - return mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN - } - - const exportPk = async (password: string): Promise => { - if (!mpcCoreKit) { - throw new Error('MPC Core Kit is not initialized') - } - const securityQuestions = new SecurityQuestionRecovery(mpcCoreKit) - - try { - if (securityQuestions.isEnabled()) { - // Only export PK if recovery works - await securityQuestions.recoverWithPassword(password) - } - const exportedPK = await mpcCoreKit?._UNSAFE_exportTssKey() - return exportedPK - } catch (err) { - throw new Error('Error exporting account. Make sure the password is correct.') - } - } - - return { - triggerLogin, - walletState, - setWalletState, - recoverFactorWithPassword, - resetAccount: criticalResetAccount, - upsertPasswordBackup: () => Promise.resolve(), - userInfo: mpcCoreKit?.state.userInfo, - exportPk, - } -} diff --git a/src/hooks/wallets/mpc/useSocialWallet.ts b/src/hooks/wallets/mpc/useSocialWallet.ts new file mode 100644 index 0000000000..d98998ffdd --- /dev/null +++ b/src/hooks/wallets/mpc/useSocialWallet.ts @@ -0,0 +1,63 @@ +import useAddressBook from '@/hooks/useAddressBook' +import useChainId from '@/hooks/useChainId' +import ExternalStore from '@/services/ExternalStore' +import type { ISocialWalletService } from '@/services/mpc/interfaces' +import { ONBOARD_MPC_MODULE_LABEL } from '@/services/mpc/SocialLoginModule' +import SocialWalletService from '@/services/mpc/SocialWalletService' +import { useAppDispatch } from '@/store' +import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { checksumAddress } from '@/utils/addresses' +import { useCallback, useEffect } from 'react' +import useOnboard, { connectWallet } from '../useOnboard' +import useMpc from './useMPC' + +const { getStore, setStore, useStore } = new ExternalStore() + +export const useInitSocialWallet = () => { + const mpcCoreKit = useMpc() + const onboard = useOnboard() + const addressBook = useAddressBook() + const currentChainId = useChainId() + const dispatch = useAppDispatch() + const socialWalletService = useStore() + + const onConnect = useCallback(async () => { + if (!onboard || !socialWalletService) return + + const wallets = await connectWallet(onboard, { + autoSelect: { + label: ONBOARD_MPC_MODULE_LABEL, + disableModals: true, + }, + }).catch((reason) => console.error('Error connecting to MPC module:', reason)) + + // If the signer is not in the address book => add the user's email as name + const userInfo = socialWalletService.getUserInfo() + if (userInfo && wallets && currentChainId && wallets.length > 0) { + const address = wallets[0].accounts[0]?.address + if (address) { + const signerAddress = checksumAddress(address) + if (addressBook[signerAddress] === undefined) { + const email = userInfo.email + dispatch(upsertAddressBookEntry({ address: signerAddress, chainId: currentChainId, name: email })) + } + } + } + }, [addressBook, currentChainId, dispatch, onboard, socialWalletService]) + + useEffect(() => { + socialWalletService?.setOnConnect(onConnect) + }, [onConnect, socialWalletService]) + + useEffect(() => { + if (mpcCoreKit) { + setStore(new SocialWalletService(mpcCoreKit)) + } + }, [mpcCoreKit]) +} + +export const getSocialWalletService = getStore + +export const __setSocialWalletService = setStore + +export default useStore diff --git a/src/hooks/wallets/wallets.ts b/src/hooks/wallets/wallets.ts index 50b3aeeb44..c55c23f9c2 100644 --- a/src/hooks/wallets/wallets.ts +++ b/src/hooks/wallets/wallets.ts @@ -12,7 +12,7 @@ import walletConnect from '@web3-onboard/walletconnect' import pairingModule from '@/services/pairing/module' import e2eWalletModule from '@/tests/e2e-wallet' import { CGW_NAMES, WALLET_KEYS } from './consts' -import MpcModule from '@/services/mpc/module' +import MpcModule from '@/services/mpc/SocialLoginModule' const prefersDarkMode = (): boolean => { return window?.matchMedia('(prefers-color-scheme: dark)')?.matches diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 36bdd4d979..5133a92961 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -38,7 +38,6 @@ import useSafeMessagePendingStatuses from '@/hooks/messages/useSafeMessagePendin import useChangedValue from '@/hooks/useChangedValue' import { TxModalProvider } from '@/components/tx-flow' import { useInitMPC } from '@/hooks/wallets/mpc/useMPC' -import { MpcWalletProvider } from '@/components/common/ConnectWallet/MPCWalletProvider' import useABTesting from '@/services/tracking/useAbTesting' import { AbTest } from '@/services/tracking/abTesting' import { useNotificationTracking } from '@/components/settings/PushNotifications/hooks/useNotificationTracking' @@ -81,9 +80,7 @@ export const AppProviders = ({ children }: { children: ReactNode | ReactNode[] } {(safeTheme: Theme) => ( - - {children} - + {children} )} diff --git a/src/services/mpc/module.ts b/src/services/mpc/SocialLoginModule.ts similarity index 100% rename from src/services/mpc/module.ts rename to src/services/mpc/SocialLoginModule.ts diff --git a/src/services/mpc/SocialWalletService.ts b/src/services/mpc/SocialWalletService.ts new file mode 100644 index 0000000000..3fa00d7a7e --- /dev/null +++ b/src/services/mpc/SocialWalletService.ts @@ -0,0 +1,153 @@ +import { COREKIT_STATUS, type Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit' +import BN from 'bn.js' +import { GOOGLE_CLIENT_ID, WEB3AUTH_VERIFIER_ID } from '@/config/constants' +import { SecurityQuestionRecovery } from '@/services/mpc/recovery/SecurityQuestionRecovery' +import { trackEvent } from '@/services/analytics' +import { MPC_WALLET_EVENTS } from '@/services/analytics/events/mpcWallet' +import { DeviceShareRecovery } from '@/services/mpc/recovery/DeviceShareRecovery' +import { logError } from '../exceptions' +import ErrorCodes from '../exceptions/ErrorCodes' +import { asError } from '../exceptions/utils' +import { type ISocialWalletService } from './interfaces' + +/** + * Singleton Service for accessing the social login wallet + */ +class SocialWalletService implements ISocialWalletService { + private mpcCoreKit: Web3AuthMPCCoreKit + private onConnect: () => Promise = () => Promise.resolve() + + private deviceShareRecovery: DeviceShareRecovery + private securityQuestionRecovery: SecurityQuestionRecovery + + constructor(mpcCoreKit: Web3AuthMPCCoreKit) { + this.mpcCoreKit = mpcCoreKit + this.deviceShareRecovery = new DeviceShareRecovery(mpcCoreKit) + this.securityQuestionRecovery = new SecurityQuestionRecovery(mpcCoreKit) + } + + isMFAEnabled() { + const { shareDescriptions } = this.mpcCoreKit.getKeyDetails() + return !Object.values(shareDescriptions).some((value) => value[0]?.includes('hashedShare')) + } + + isRecoveryPasswordSet() { + return this.securityQuestionRecovery.isEnabled() + } + + async enableMFA(oldPassword: string | undefined, newPassword: string): Promise { + try { + // 1. setup device factor with password recovery + await this.securityQuestionRecovery.upsertPassword(newPassword, oldPassword) + const securityQuestionFactor = await this.securityQuestionRecovery.recoverWithPassword(newPassword) + if (!securityQuestionFactor) { + throw Error('Problem setting up the new password') + } + + if (!this.isMFAEnabled()) { + trackEvent(MPC_WALLET_EVENTS.ENABLE_MFA) + // 2. enable MFA in mpcCoreKit + await this.mpcCoreKit.enableMFA({}, false) + } + + await this.mpcCoreKit.commitChanges() + } catch (e) { + const error = asError(e) + logError(ErrorCodes._304, error.message) + throw error + } + } + + setOnConnect(onConnect: () => Promise) { + this.onConnect = onConnect + } + + getUserInfo() { + return this.mpcCoreKit.state.userInfo + } + + async loginAndCreate(): Promise { + try { + await this.mpcCoreKit.loginWithOauth({ + subVerifierDetails: { + typeOfLogin: 'google', + verifier: WEB3AUTH_VERIFIER_ID, + clientId: GOOGLE_CLIENT_ID, + }, + }) + + if (this.mpcCoreKit.status === COREKIT_STATUS.REQUIRED_SHARE) { + // Check if we have a device share stored + if (await this.deviceShareRecovery.isEnabled()) { + await this.deviceShareRecovery.recoverWithDeviceFactor() + } else { + // Check password recovery + if (this.securityQuestionRecovery.isEnabled()) { + trackEvent(MPC_WALLET_EVENTS.MANUAL_RECOVERY) + return this.mpcCoreKit.status + } + } + } + + await this.finalizeLogin() + return this.mpcCoreKit.status + } catch (error) { + console.error(error) + return this.mpcCoreKit.status + } + } + + private async finalizeLogin() { + if (this.mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN) { + await this.mpcCoreKit.commitChanges() + await this.mpcCoreKit.provider?.request({ method: 'eth_accounts', params: [] }) + await this.onConnect() + } + } + + async recoverAccountWithPassword(password: string, storeDeviceShare: boolean = false) { + if (this.securityQuestionRecovery.isEnabled()) { + const factorKeyString = await this.securityQuestionRecovery.recoverWithPassword(password) + const factorKey = new BN(factorKeyString, 'hex') + await this.mpcCoreKit.inputFactorKey(factorKey) + + if (storeDeviceShare) { + await this.deviceShareRecovery.createAndStoreDeviceFactor() + } + + await this.finalizeLogin() + } + + return this.mpcCoreKit.status === COREKIT_STATUS.LOGGED_IN + } + + async exportSignerKey(password: string): Promise { + try { + if (this.securityQuestionRecovery.isEnabled()) { + // Only export PK if recovery works + await this.securityQuestionRecovery.recoverWithPassword(password) + } + const exportedPK = await this.mpcCoreKit?._UNSAFE_exportTssKey() + return exportedPK + } catch (err) { + throw new Error('Error exporting account. Make sure the password is correct.') + } + } + + async __deleteAccount() { + // This is a critical function that should only be used for testing purposes + // 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 (!this.mpcCoreKit?.metadataKey) { + 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 + await this.mpcCoreKit.tKey.storageLayer.setMetadata({ + privKey: new BN(this.mpcCoreKit.metadataKey, 'hex'), + input: { message: 'KEY_NOT_FOUND' }, + }) + } +} + +export default SocialWalletService diff --git a/src/services/mpc/__mocks__/SocialWalletService.ts b/src/services/mpc/__mocks__/SocialWalletService.ts new file mode 100644 index 0000000000..7e78191dc3 --- /dev/null +++ b/src/services/mpc/__mocks__/SocialWalletService.ts @@ -0,0 +1,74 @@ +import { COREKIT_STATUS, type UserInfo } from '@web3auth/mpc-core-kit' +import { hexZeroPad } from 'ethers/lib/utils' +import { type ISocialWalletService } from '../interfaces' + +/** + * Manual mock for SocialWalletService + * + * By default it will log in the user after a 1 second timer. + * For password recovery it expects the password to be "Test1234!" + */ +class TestSocialWalletService implements ISocialWalletService { + private fakePassword = 'Test1234!' + private postLoginState = COREKIT_STATUS.LOGGED_IN + private _isMfaEnabled = false + private onConnect: () => Promise = () => Promise.resolve() + private userInfo: UserInfo = { + email: 'test@testermann.com', + name: 'Test Testermann', + profileImage: 'test.testermann.local/profile.png', + } as unknown as UserInfo + + setOnConnect(onConnect: () => Promise): void { + this.onConnect = onConnect + } + + getUserInfo(): UserInfo | undefined { + return this.userInfo + } + isMFAEnabled(): boolean { + return this._isMfaEnabled + } + enableMFA(oldPassword: string, newPassword: string): Promise { + this._isMfaEnabled = true + return Promise.resolve() + } + isRecoveryPasswordSet(): boolean { + throw new Error('Method not implemented.') + } + + /** + * Method for tests to set the expected login state after calling loginAndCreate() + */ + __setPostLoginState(state: COREKIT_STATUS) { + this.postLoginState = state + } + + __setUserInfo(userInfo: UserInfo) { + this.userInfo = userInfo + } + + async loginAndCreate(): Promise { + return new Promise((resolve) => { + this.onConnect().then(() => resolve(this.postLoginState)) + }) + } + + __deleteAccount(): void { + throw new Error('Method not implemented.') + } + async recoverAccountWithPassword(password: string, storeDeviceFactor: boolean): Promise { + if (this.fakePassword === password) { + await this.onConnect() + return true + } + + throw Error('Invalid Password') + } + + exportSignerKey(password: string): Promise { + return Promise.resolve(hexZeroPad('0x1', 20)) + } +} + +export default TestSocialWalletService diff --git a/src/services/mpc/__tests__/SocialWalletService.test.ts b/src/services/mpc/__tests__/SocialWalletService.test.ts new file mode 100644 index 0000000000..e3163b1069 --- /dev/null +++ b/src/services/mpc/__tests__/SocialWalletService.test.ts @@ -0,0 +1,269 @@ +import { act, waitFor } from '@/tests/test-utils' +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 { ethers } from 'ethers' +import BN from 'bn.js' +import { hexZeroPad } from 'ethers/lib/utils' +import SocialWalletService from '../SocialWalletService' + +/** time until mock login resolves */ +const MOCK_LOGIN_TIME = 1000 +/** Mock address for successful login */ +const mockSignerAddress = hexZeroPad('0x1', 20) + +/** + * 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() + } + } + + commitChanges = jest.fn().mockImplementation(() => Promise.resolve()) + + getUserInfo() { + return this.state.userInfo + } +} + +describe('useMPCWallet', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + beforeEach(() => { + jest.resetAllMocks() + }) + afterAll(() => { + jest.useRealTimers() + }) + + describe('triggerLogin', () => { + it('should handle successful log in for SFA account', async () => { + const mockOnConnect = jest.fn() + + const mockCoreKit = new MockMPCCoreKit(COREKIT_STATUS.LOGGED_IN, { + email: 'test@test.com', + name: 'Test', + } as unknown as UserInfo) as unknown as Web3AuthMPCCoreKit + + const testService = new SocialWalletService(mockCoreKit) + testService.setOnConnect(mockOnConnect) + + let status: Promise + act(() => { + status = testService.loginAndCreate() + }) + + expect(mockOnConnect).not.toHaveBeenCalled() + + // Resolve mock login + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + // We should be logged in and onboard should get connected + await waitFor(() => { + expect(status).resolves.toEqual(COREKIT_STATUS.LOGGED_IN) + expect(mockOnConnect).toHaveBeenCalled() + expect(mockCoreKit.commitChanges).toHaveBeenCalled() + }) + }) + + it('should handle successful log in for MFA account with device share', async () => { + const mockOnConnect = jest.fn() + + const mockDeviceFactor = ethers.Wallet.createRandom().privateKey.slice(2) + + const mockCoreKit = 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 testService = new SocialWalletService(mockCoreKit) + testService.setOnConnect(mockOnConnect) + + let status: Promise + act(() => { + status = testService.loginAndCreate() + }) + + // While the login resolves we are in Authenticating state + expect(mockOnConnect).not.toHaveBeenCalled() + + // Resolve mock login + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + // We should be logged in and onboard should get connected + await waitFor(() => { + expect(status).resolves.toEqual(COREKIT_STATUS.LOGGED_IN) + expect(mockOnConnect).toHaveBeenCalled() + expect(mockCoreKit.commitChanges).toHaveBeenCalled() + }) + }) + + it('should require manual share for MFA account without device share', async () => { + const mockOnConnect = jest.fn() + const mockCoreKit = new MockMPCCoreKit(COREKIT_STATUS.REQUIRED_SHARE, { + email: 'test@test.com', + name: 'Test', + } as unknown as UserInfo) as unknown as Web3AuthMPCCoreKit + + jest.spyOn(mpcCoreKit, 'getWebBrowserFactor').mockReturnValue(Promise.resolve(undefined)) + jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ + getQuestion: () => 'SOME RANDOM QUESTION', + } as unknown as TssSecurityQuestion) + + const testService = new SocialWalletService(mockCoreKit) + testService.setOnConnect(mockOnConnect) + + let status: Promise + act(() => { + status = testService.loginAndCreate() + }) + + // Resolve mock login + act(() => { + jest.advanceTimersByTime(MOCK_LOGIN_TIME) + }) + + // A missing second factor should result in manual recovery state + await waitFor(() => { + expect(status).resolves.toEqual(COREKIT_STATUS.REQUIRED_SHARE) + expect(mockOnConnect).not.toHaveBeenCalled() + expect(mockCoreKit.commitChanges).not.toHaveBeenCalled() + }) + }) + }) + + describe('resetAccount', () => { + 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, + }, + }, + } + + const testService = new SocialWalletService(mockMPCCore as unknown as Web3AuthMPCCoreKit) + + await testService.__deleteAccount() + + expect(mockSetMetadata).toHaveBeenCalledWith({ + privKey: new BN(mockMPCCore.metadataKey, 'hex'), + input: { message: 'KEY_NOT_FOUND' }, + }) + }) + }) + + describe('recoverFactorWithPassword', () => { + it('should not recover if wrong password is entered', () => { + const mockOnConnect = jest.fn() + const mockMPCCore = { + state: { + userInfo: undefined, + }, + commitChanges: jest.fn(), + } as unknown as Web3AuthMPCCoreKit + + jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ + getQuestion: () => 'SOME RANDOM QUESTION', + recoverFactor: () => { + throw new Error('Invalid answer') + }, + } as unknown as TssSecurityQuestion) + + const testService = new SocialWalletService(mockMPCCore as unknown as Web3AuthMPCCoreKit) + testService.setOnConnect(mockOnConnect) + + expect(testService.recoverAccountWithPassword('test', false)).rejects.toEqual(new Error('Invalid answer')) + expect(mockOnConnect).not.toHaveBeenCalled() + expect(mockMPCCore.commitChanges).not.toHaveBeenCalled() + }) + + it('should input recovered factor if correct password is entered', async () => { + const mockOnConnect = jest.fn() + const mockSecurityQuestionFactor = ethers.Wallet.createRandom().privateKey.slice(2) + + const mockMPCCore = new MockMPCCoreKit( + COREKIT_STATUS.REQUIRED_SHARE, + { + email: 'test@test.com', + name: 'Test', + } as unknown as UserInfo, + new BN(mockSecurityQuestionFactor, 'hex'), + ) as unknown as Web3AuthMPCCoreKit + + jest.spyOn(mpcCoreKit, 'TssSecurityQuestion').mockReturnValue({ + getQuestion: () => 'SOME RANDOM QUESTION', + recoverFactor: () => Promise.resolve(mockSecurityQuestionFactor), + } as unknown as TssSecurityQuestion) + + const testService = new SocialWalletService(mockMPCCore as unknown as Web3AuthMPCCoreKit) + testService.setOnConnect(mockOnConnect) + + act(() => testService.recoverAccountWithPassword('test', false)) + + await waitFor(() => { + expect(mockOnConnect).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/src/services/mpc/__tests__/module.test.ts b/src/services/mpc/__tests__/module.test.ts index 3a03f09c81..350bdf65bb 100644 --- a/src/services/mpc/__tests__/module.test.ts +++ b/src/services/mpc/__tests__/module.test.ts @@ -1,4 +1,4 @@ -import MpcModule, { ONBOARD_MPC_MODULE_LABEL } from '../module' +import MpcModule, { ONBOARD_MPC_MODULE_LABEL } from '../SocialLoginModule' import { type WalletModule } from '@web3-onboard/common' import * as web3 from '@/hooks/wallets/web3' diff --git a/src/services/mpc/interfaces.ts b/src/services/mpc/interfaces.ts new file mode 100644 index 0000000000..b76ace0eec --- /dev/null +++ b/src/services/mpc/interfaces.ts @@ -0,0 +1,52 @@ +import type { COREKIT_STATUS, UserInfo } from '@web3auth/mpc-core-kit' + +export interface ISocialWalletService { + /** + * Opens a popup with the Google login and creates / restores the mpc wallet. + * + * @returns the follow up status of the mpcCoreKit. + */ + loginAndCreate(): Promise + + /** + * Deletes the currently logged in account. + * This should only be used in dev environments and never in prod! + */ + __deleteAccount(): void + + /** + * Tries to recover a social signer through the Security Questions module + * + * @param password entered recovery password + * @param storeDeviceFactor if true a device factor will be added after successful recovery + */ + recoverAccountWithPassword(password: string, storeDeviceFactor: boolean): Promise + + /** + * Exports the key of the signer + * + * @param password recovery password + */ + exportSignerKey(password: string): Promise + + /** + * Returns true if MFA is enabled + */ + isMFAEnabled(): boolean + + /** + * Enables MFA and stores a device share with 2 factors: + * - one factor encrypted with the password + * - one factor encrypted with a key in the local storage of the browser + * + * @param oldPassword required if MFA is already enabled + * @param newPassword new password to set + */ + enableMFA(oldPassword: string | undefined, newPassword: string): Promise + + isRecoveryPasswordSet(): boolean + + getUserInfo(): UserInfo | undefined + + setOnConnect(onConnect: () => Promise): void +} diff --git a/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts b/src/services/mpc/recovery/DeviceShareRecovery.ts similarity index 79% rename from src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts rename to src/services/mpc/recovery/DeviceShareRecovery.ts index e5a5fc4054..7806f91b92 100644 --- a/src/hooks/wallets/mpc/recovery/DeviceShareRecovery.ts +++ b/src/services/mpc/recovery/DeviceShareRecovery.ts @@ -16,9 +16,6 @@ export class DeviceShareRecovery { } async isEnabled() { - if (!this.mpcCoreKit.tKey.metadata) { - return false - } return !!(await getWebBrowserFactor(this.mpcCoreKit)) } @@ -32,6 +29,16 @@ export class DeviceShareRecovery { await storeWebBrowserFactor(deviceFactorKey, this.mpcCoreKit) } + async recoverWithDeviceFactor() { + // Recover from device factor + const deviceFactor = await getWebBrowserFactor(this.mpcCoreKit) + if (!deviceFactor) { + throw Error('Cannot recover from device factor. No device factor found') + } + const deviceFactorKey = new BN(deviceFactor, 'hex') + await this.mpcCoreKit.inputFactorKey(deviceFactorKey) + } + async removeDeviceFactor() { const deviceFactor = await getWebBrowserFactor(this.mpcCoreKit) if (!deviceFactor) { diff --git a/src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts b/src/services/mpc/recovery/SecurityQuestionRecovery.ts similarity index 100% rename from src/hooks/wallets/mpc/recovery/SecurityQuestionRecovery.ts rename to src/services/mpc/recovery/SecurityQuestionRecovery.ts diff --git a/src/tests/builders/eip1193Provider.ts b/src/tests/builders/eip1193Provider.ts new file mode 100644 index 0000000000..5896fe9b5f --- /dev/null +++ b/src/tests/builders/eip1193Provider.ts @@ -0,0 +1,10 @@ +import { type EIP1193Provider } from '@web3-onboard/core' +import { Builder } from '../Builder' + +export const eip1193ProviderBuilder = () => + Builder.new().with({ + disconnect: jest.fn(), + on: jest.fn(), + removeListener: jest.fn(), + request: jest.fn(), + }) diff --git a/src/tests/builders/wallet.ts b/src/tests/builders/wallet.ts new file mode 100644 index 0000000000..ce2d601b8d --- /dev/null +++ b/src/tests/builders/wallet.ts @@ -0,0 +1,16 @@ +import { type ConnectedWallet } from '@/services/onboard' +import { faker } from '@faker-js/faker' +import { Builder, type IBuilder } from '../Builder' +import { eip1193ProviderBuilder } from './eip1193Provider' + +const walletNames = ['MetaMask', 'Wallet Connect', 'Social Login', 'Rainbow'] + +export const connectedWalletBuilder = (): IBuilder => { + return Builder.new().with({ + address: faker.finance.ethereumAddress(), + chainId: faker.string.numeric(), + ens: faker.string.alpha() + '.ens', + label: faker.helpers.arrayElement(walletNames), + provider: eip1193ProviderBuilder().build(), + }) +}