diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 169e5ccc3dc..9576fde1dd0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,7 +47,7 @@ app/util/walletconnect.js @MetaMask/sdk-devs # Accounts Team app/core/Encryptor/ @MetaMask/accounts-engineers -app/core/Engine/controllers/accounts @MetaMask/accounts-engineers +app/core/Engine/controllers/AccountsController @MetaMask/accounts-engineers # Swaps Team app/components/UI/Swaps @MetaMask/swaps-engineers diff --git a/app/actions/navigation/index.ts b/app/actions/navigation/index.ts index 7a6fac7e9a0..b4c82b9a98e 100644 --- a/app/actions/navigation/index.ts +++ b/app/actions/navigation/index.ts @@ -1,18 +1,28 @@ /* eslint-disable import/prefer-default-export */ import { - SET_CURRENT_ROUTE, - SET_CURRENT_BOTTOM_NAV_ROUTE, -} from '../../reducers/navigation'; + type OnNavigationReadyAction, + type SetCurrentRouteAction, + type SetCurrentBottomNavRouteAction, + NavigationActionType, +} from './types'; -/** - * Action Creators - */ -export const setCurrentRoute = (route: string) => ({ - type: SET_CURRENT_ROUTE, +export * from './types'; + +export const setCurrentRoute = (route: string): SetCurrentRouteAction => ({ + type: NavigationActionType.SET_CURRENT_ROUTE, payload: { route }, }); -export const setCurrentBottomNavRoute = (route: string) => ({ - type: SET_CURRENT_BOTTOM_NAV_ROUTE, +export const setCurrentBottomNavRoute = ( + route: string, +): SetCurrentBottomNavRouteAction => ({ + type: NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE, payload: { route }, }); + +/** + * Action that is called when navigation is ready + */ +export const onNavigationReady = (): OnNavigationReadyAction => ({ + type: NavigationActionType.ON_NAVIGATION_READY, +}); diff --git a/app/actions/navigation/types.ts b/app/actions/navigation/types.ts new file mode 100644 index 00000000000..c57beba69da --- /dev/null +++ b/app/actions/navigation/types.ts @@ -0,0 +1,31 @@ +import { type Action } from 'redux'; + +/** + * Navigation action type enum + */ +export enum NavigationActionType { + ON_NAVIGATION_READY = 'ON_NAVIGATION_READY', + SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE', + SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_BOTTOM_NAV_ROUTE', +} + +export type OnNavigationReadyAction = + Action; + +export type SetCurrentRouteAction = + Action & { + payload: { route: string }; + }; + +export type SetCurrentBottomNavRouteAction = + Action & { + payload: { route: string }; + }; + +/** + * Navigation action + */ +export type NavigationAction = + | OnNavigationReadyAction + | SetCurrentRouteAction + | SetCurrentBottomNavRouteAction; diff --git a/app/actions/user/index.js b/app/actions/user/index.js deleted file mode 100644 index fd996b8707f..00000000000 --- a/app/actions/user/index.js +++ /dev/null @@ -1,134 +0,0 @@ -// Constants -export const LOCKED_APP = 'LOCKED_APP'; -export const AUTH_SUCCESS = 'AUTH_SUCCESS'; -export const AUTH_ERROR = 'AUTH_ERROR'; -export const INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS'; -export const LOGIN = 'LOGIN'; -export const LOGOUT = 'LOGOUT'; - -export function interruptBiometrics() { - return { - type: INTERRUPT_BIOMETRICS, - }; -} - -export function lockApp() { - return { - type: LOCKED_APP, - }; -} - -export function authSuccess(bioStateMachineId) { - return { - type: AUTH_SUCCESS, - payload: { bioStateMachineId }, - }; -} - -export function authError(bioStateMachineId) { - return { - type: AUTH_ERROR, - payload: { bioStateMachineId }, - }; -} - -export function passwordSet() { - return { - type: 'PASSWORD_SET', - }; -} - -export function passwordUnset() { - return { - type: 'PASSWORD_UNSET', - }; -} - -export function seedphraseBackedUp() { - return { - type: 'SEEDPHRASE_BACKED_UP', - }; -} - -export function seedphraseNotBackedUp() { - return { - type: 'SEEDPHRASE_NOT_BACKED_UP', - }; -} - -export function backUpSeedphraseAlertVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_VISIBLE', - }; -} - -export function backUpSeedphraseAlertNotVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', - }; -} - -export function protectWalletModalVisible() { - return { - type: 'PROTECT_MODAL_VISIBLE', - }; -} - -export function protectWalletModalNotVisible() { - return { - type: 'PROTECT_MODAL_NOT_VISIBLE', - }; -} - -export function loadingSet(loadingMsg) { - return { - type: 'LOADING_SET', - loadingMsg, - }; -} - -export function loadingUnset() { - return { - type: 'LOADING_UNSET', - }; -} - -export function setGasEducationCarouselSeen() { - return { - type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN', - }; -} - -export function logIn() { - return { - type: LOGIN, - }; -} - -export function logOut() { - return { - type: LOGOUT, - }; -} - -export function setAppTheme(theme) { - return { - type: 'SET_APP_THEME', - payload: { theme }, - }; -} - -/** - * Temporary action to control auth flow - * - * @param {string} initialScreen - "login" or "onboarding" - * @returns - void - */ -export function checkedAuth(initialScreen) { - return { - type: 'CHECKED_AUTH', - payload: { - initialScreen, - }, - }; -} diff --git a/app/actions/user/index.ts b/app/actions/user/index.ts new file mode 100644 index 00000000000..9071fcffd50 --- /dev/null +++ b/app/actions/user/index.ts @@ -0,0 +1,161 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { + type InterruptBiometricsAction, + type LockAppAction, + type AuthSuccessAction, + type AuthErrorAction, + type PasswordSetAction, + type PasswordUnsetAction, + type SeedphraseBackedUpAction, + type SeedphraseNotBackedUpAction, + type BackUpSeedphraseVisibleAction, + type BackUpSeedphraseNotVisibleAction, + type ProtectModalVisibleAction, + type ProtectModalNotVisibleAction, + type LoadingSetAction, + type LoadingUnsetAction, + type SetGasEducationCarouselSeenAction, + type LoginAction, + type LogoutAction, + type SetAppThemeAction, + type CheckedAuthAction, + type PersistedDataLoadedAction, + UserActionType, +} from './types'; + +export * from './types'; + +export function interruptBiometrics(): InterruptBiometricsAction { + return { + type: UserActionType.INTERRUPT_BIOMETRICS, + }; +} + +export function lockApp(): LockAppAction { + return { + type: UserActionType.LOCKED_APP, + }; +} + +export function authSuccess(bioStateMachineId?: string): AuthSuccessAction { + return { + type: UserActionType.AUTH_SUCCESS, + payload: { bioStateMachineId }, + }; +} + +export function authError(bioStateMachineId?: string): AuthErrorAction { + return { + type: UserActionType.AUTH_ERROR, + payload: { bioStateMachineId }, + }; +} + +export function passwordSet(): PasswordSetAction { + return { + type: UserActionType.PASSWORD_SET, + }; +} + +export function passwordUnset(): PasswordUnsetAction { + return { + type: UserActionType.PASSWORD_UNSET, + }; +} + +export function seedphraseBackedUp(): SeedphraseBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_BACKED_UP, + }; +} + +export function seedphraseNotBackedUp(): SeedphraseNotBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_NOT_BACKED_UP, + }; +} + +export function backUpSeedphraseAlertVisible(): BackUpSeedphraseVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_VISIBLE, + }; +} + +export function backUpSeedphraseAlertNotVisible(): BackUpSeedphraseNotVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE, + }; +} + +export function protectWalletModalVisible(): ProtectModalVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_VISIBLE, + }; +} + +export function protectWalletModalNotVisible(): ProtectModalNotVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_NOT_VISIBLE, + }; +} + +export function loadingSet(loadingMsg: string): LoadingSetAction { + return { + type: UserActionType.LOADING_SET, + loadingMsg, + }; +} + +export function loadingUnset(): LoadingUnsetAction { + return { + type: UserActionType.LOADING_UNSET, + }; +} + +export function setGasEducationCarouselSeen(): SetGasEducationCarouselSeenAction { + return { + type: UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN, + }; +} + +export function logIn(): LoginAction { + return { + type: UserActionType.LOGIN, + }; +} + +export function logOut(): LogoutAction { + return { + type: UserActionType.LOGOUT, + }; +} + +export function setAppTheme(theme: AppThemeKey): SetAppThemeAction { + return { + type: UserActionType.SET_APP_THEME, + payload: { theme }, + }; +} + +/** + * Temporary action to control auth flow + * + * @param initialScreen - "login" or "onboarding" + */ +export function checkedAuth(initialScreen: string): CheckedAuthAction { + return { + type: UserActionType.CHECKED_AUTH, + payload: { + initialScreen, + }, + }; +} + +/** + * Action to signal that persisted data has been loaded + */ +export function onPersistedDataLoaded(): PersistedDataLoadedAction { + return { + type: UserActionType.ON_PERSISTED_DATA_LOADED, + }; +} diff --git a/app/actions/user/types.ts b/app/actions/user/types.ts new file mode 100644 index 00000000000..704aee6092d --- /dev/null +++ b/app/actions/user/types.ts @@ -0,0 +1,111 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { type Action } from 'redux'; + +// Action type enum +export enum UserActionType { + LOCKED_APP = 'LOCKED_APP', + AUTH_SUCCESS = 'AUTH_SUCCESS', + AUTH_ERROR = 'AUTH_ERROR', + INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS', + LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', + ON_PERSISTED_DATA_LOADED = 'ON_PERSISTED_DATA_LOADED', + PASSWORD_SET = 'PASSWORD_SET', + PASSWORD_UNSET = 'PASSWORD_UNSET', + SEEDPHRASE_BACKED_UP = 'SEEDPHRASE_BACKED_UP', + SEEDPHRASE_NOT_BACKED_UP = 'SEEDPHRASE_NOT_BACKED_UP', + BACK_UP_SEEDPHRASE_VISIBLE = 'BACK_UP_SEEDPHRASE_VISIBLE', + BACK_UP_SEEDPHRASE_NOT_VISIBLE = 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', + PROTECT_MODAL_VISIBLE = 'PROTECT_MODAL_VISIBLE', + PROTECT_MODAL_NOT_VISIBLE = 'PROTECT_MODAL_NOT_VISIBLE', + LOADING_SET = 'LOADING_SET', + LOADING_UNSET = 'LOADING_UNSET', + SET_GAS_EDUCATION_CAROUSEL_SEEN = 'SET_GAS_EDUCATION_CAROUSEL_SEEN', + SET_APP_THEME = 'SET_APP_THEME', + CHECKED_AUTH = 'CHECKED_AUTH', +} + +// User actions +export type LockAppAction = Action; + +export type AuthSuccessAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type AuthErrorAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type InterruptBiometricsAction = + Action; + +export type LoginAction = Action; + +export type LogoutAction = Action; + +export type PersistedDataLoadedAction = + Action; + +export type PasswordSetAction = Action; + +export type PasswordUnsetAction = Action; + +export type SeedphraseBackedUpAction = + Action; + +export type SeedphraseNotBackedUpAction = + Action; + +export type BackUpSeedphraseVisibleAction = + Action; + +export type BackUpSeedphraseNotVisibleAction = + Action; + +export type ProtectModalVisibleAction = + Action; + +export type ProtectModalNotVisibleAction = + Action; + +export type LoadingSetAction = Action & { + loadingMsg: string; +}; + +export type LoadingUnsetAction = Action; + +export type SetGasEducationCarouselSeenAction = + Action; + +export type SetAppThemeAction = Action & { + payload: { theme: AppThemeKey }; +}; + +export type CheckedAuthAction = Action & { + payload: { initialScreen: string }; +}; + +/** + * User actions union type + */ +export type UserAction = + | LockAppAction + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction + | LoginAction + | LogoutAction + | PersistedDataLoadedAction + | PasswordSetAction + | PasswordUnsetAction + | SeedphraseBackedUpAction + | SeedphraseNotBackedUpAction + | BackUpSeedphraseVisibleAction + | BackUpSeedphraseNotVisibleAction + | ProtectModalVisibleAction + | ProtectModalNotVisibleAction + | LoadingSetAction + | LoadingUnsetAction + | SetGasEducationCarouselSeenAction + | SetAppThemeAction + | CheckedAuthAction; diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 7218385de28..32ec533e3ae 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -44,6 +44,7 @@ import { getVersion } from 'react-native-device-info'; import { setCurrentBottomNavRoute, setCurrentRoute, + onNavigationReady, } from '../../../actions/navigation'; import { findRouteNameFromNavigatorState } from '../../../util/general'; import { Authentication } from '../../../core/'; @@ -880,6 +881,11 @@ const App = (props) => { } }; + /** + * Triggers when the navigation is ready + */ + const onNavigationReadyHandler = () => dispatch(onNavigationReady()); + return supressRender ? null : ( <> { @@ -905,6 +911,7 @@ const App = (props) => { const currentRoute = findRouteNameFromNavigatorState(state.routes); triggerSetCurrentRoute(currentRoute); }} + onReady={onNavigationReadyHandler} > - Number(b.overallValueOfQuote) - Number(a.overallValueOfQuote), - ) + (a, b) => + Number(b.overallValueOfQuote) - Number(a.overallValueOfQuote), + ) : Object.values(quotes).sort((a, b) => { - const comparison = new BigNumber(b.destinationAmount).comparedTo( - a.destinationAmount, + const comparison = new BigNumber(b.destinationAmount).comparedTo( + a.destinationAmount, + ); + if (comparison === 0) { + // If the destination amount is the same, we sort by fees ascending + return ( + Number(quoteValues[a.aggregator]?.ethFee) - + Number(quoteValues[b.aggregator]?.ethFee) || 0 ); - if (comparison === 0) { - // If the destination amount is the same, we sort by fees ascending - return ( - Number(quoteValues[a.aggregator]?.ethFee) - - Number(quoteValues[b.aggregator]?.ethFee) || 0 - ); - } - return comparison; - // eslint-disable-next-line no-mixed-spaces-and-tabs - }); + } + return comparison; + // eslint-disable-next-line no-mixed-spaces-and-tabs + }); return orderedAggregators.map( (quoteValue) => quotes[quoteValue.aggregator], @@ -720,19 +720,19 @@ function SwapsQuotesView({ GAS_ESTIMATE_TYPES.ETH_GASPRICE, ].includes(gasEstimateType) ? weiToFiat( - toWei( - swapsUtils.calcTokenAmount( - new BigNumber(changedGasLimit, 10).times( - decGWEIToHexWEI(changedGasEstimate.gasPrice), - 16, - ), - 18, + toWei( + swapsUtils.calcTokenAmount( + new BigNumber(changedGasLimit, 10).times( + decGWEIToHexWEI(changedGasEstimate.gasPrice), + 16, ), + 18, ), - conversionRate, - currentCurrency, - // eslint-disable-next-line no-mixed-spaces-and-tabs - ) + ), + conversionRate, + currentCurrency, + // eslint-disable-next-line no-mixed-spaces-and-tabs + ) : '', chain_id: getDecimalChainId(chainId), }; @@ -1767,9 +1767,9 @@ function SwapsQuotesView({ {!hasEnoughTokenBalance && !isSwapsNativeAsset(sourceToken) ? `${renderFromTokenMinimalUnit( - missingTokenBalance, - sourceToken.decimals, - )} ${sourceToken.symbol} ` + missingTokenBalance, + sourceToken.decimals, + )} ${sourceToken.symbol} ` : `${renderFromWei(missingEthBalance)} ${getTicker(ticker)} `} {!hasEnoughTokenBalance @@ -1777,10 +1777,10 @@ function SwapsQuotesView({ : `${strings('swaps.more_gas_to_complete')} `} {(isSwapsNativeAsset(sourceToken) || (hasEnoughTokenBalance && !hasEnoughEthBalance)) && ( - - {strings('swaps.token_marketplace')} - - )} + + {strings('swaps.token_marketplace')} + + )} )} @@ -1835,7 +1835,7 @@ function SwapsQuotesView({ {weiToFiat( toWei( selectedQuote.priceSlippage?.sourceAmountInETH || - 0, + 0, ), conversionRate, currentCurrency, @@ -2018,13 +2018,12 @@ function SwapsQuotesView({ {getTicker(ticker)} - {` ${ - weiToFiat( - toWei(selectedQuoteValue?.ethFee), - conversionRate, - currentCurrency, - ) || '' - }`} + {` ${weiToFiat( + toWei(selectedQuoteValue?.ethFee), + conversionRate, + currentCurrency, + ) || '' + }`} ) : ( @@ -2038,13 +2037,12 @@ function SwapsQuotesView({ {primaryCurrency === 'ETH' ? ( <> - {`${ - weiToFiat( - toWei(selectedQuoteValue?.ethFee), - conversionRate, - currentCurrency, - ) || '' - } `} + {`${weiToFiat( + toWei(selectedQuoteValue?.ethFee), + conversionRate, + currentCurrency, + ) || '' + } `} - {` ${ - weiToFiat( - toWei(selectedQuoteValue?.ethFee), - conversionRate, - currentCurrency, - ) || '' - }`} + {` ${weiToFiat( + toWei(selectedQuoteValue?.ethFee), + conversionRate, + currentCurrency, + ) || '' + }`} )} @@ -2115,13 +2112,12 @@ function SwapsQuotesView({ {getTicker(ticker)} - {` ${ - weiToFiat( - toWei(selectedQuoteValue?.maxEthFee), - conversionRate, - currentCurrency, - ) || '' - }`} + {` ${weiToFiat( + toWei(selectedQuoteValue?.maxEthFee), + conversionRate, + currentCurrency, + ) || '' + }`} @@ -2139,15 +2135,14 @@ function SwapsQuotesView({ {primaryCurrency === 'ETH' ? ` ${renderFromWei( - toWei(selectedQuoteValue?.maxEthFee || '0x0'), - )} ${getTicker(ticker)}` // eslint-disable-line - : ` ${ - weiToFiat( - toWei(selectedQuoteValue?.maxEthFee), - conversionRate, - currentCurrency, - ) || '' // eslint-disable-next-line - }`} + toWei(selectedQuoteValue?.maxEthFee || '0x0'), + )} ${getTicker(ticker)}` // eslint-disable-line + : ` ${weiToFiat( + toWei(selectedQuoteValue?.maxEthFee), + conversionRate, + currentCurrency, + ) || '' // eslint-disable-next-line + }`} @@ -2243,8 +2238,8 @@ function SwapsQuotesView({ {selectedQuote && selectedQuote?.fee > 0 ? strings('swaps.fee_text.fee_is_applied', { - fee: `${selectedQuote.fee}%`, - }) + fee: `${selectedQuote.fee}%`, + }) : strings('swaps.fee_text.fee_is_not_applied')} } diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx index 49dd1d80b67..0cf16912f0e 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx @@ -229,8 +229,11 @@ export const TokenListItem = ({ } if (isTestNet(currentChainId)) return getTestNetImageByChainId(currentChainId); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as any; + const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as + | { + imageSource: string; + } + | undefined; if (defaultNetwork) { return defaultNetwork.imageSource; diff --git a/app/components/UI/Tokens/util/filterAssets.ts b/app/components/UI/Tokens/util/filterAssets.ts index d7b7eaab20e..7d201831b57 100644 --- a/app/components/UI/Tokens/util/filterAssets.ts +++ b/app/components/UI/Tokens/util/filterAssets.ts @@ -14,13 +14,31 @@ export interface FilterCallbacksT { range: (value: number, opts: Record) => boolean; } +/** + * A collection of filter callback functions used for various filtering operations. + */ const filterCallbacks: FilterCallbacksT = { + /** + * Checks if a given value exists as a key in the provided options object + * and returns its corresponding boolean value. + * + * @param value - The key to check in the options object. + * @param opts - A record object containing boolean values for keys. + * @returns `false` if the options object is empty, otherwise returns the boolean value associated with the key. + */ inclusive: (value: string, opts: Record) => { if (Object.entries(opts).length === 0) { return false; } return opts[value]; }, + /** + * Checks if a given numeric value falls within a specified range. + * + * @param value - The number to check. + * @param opts - A record object with `min` and `max` properties defining the range. + * @returns `true` if the value is within the range [opts.min, opts.max], otherwise `false`. + */ range: (value: number, opts: Record) => value >= opts.min && value <= opts.max, }; @@ -29,6 +47,17 @@ function getNestedValue(obj: T, keyPath: string): FilterType { return get(obj, keyPath); } +/** + * Filters an array of assets based on a set of criteria. + * + * @template T - The type of the assets in the array. + * @param assets - The array of assets to be filtered. + * @param criteria - An array of filter criteria objects. Each criterion contains: + * - `key`: A string representing the key to be accessed within the asset (supports nested keys). + * - `opts`: An object specifying the options for the filter. The structure depends on the `filterCallback` type. + * - `filterCallback`: The filtering method to apply, such as `'inclusive'` or `'range'`. + * @returns A new array of assets that match all the specified criteria. + */ export function filterAssets(assets: T[], criteria: FilterCriteria[]): T[] { if (criteria.length === 0) { return assets; diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index ff3eb03035e..3cc18c5725a 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -213,10 +213,6 @@ class Transactions extends PureComponent { */ onScrollThroughContent: PropTypes.func, gasFeeEstimates: PropTypes.object, - /** - * Chain ID of the token - */ - tokenChainId: PropTypes.string, /** * ID of the global network client */ @@ -379,15 +375,6 @@ class Transactions extends PureComponent { renderEmpty = () => { const { colors, typography } = this.context || mockTheme; const styles = createStyles(colors, typography); - if (this.props.tokenChainId !== this.props.chainId) { - return ( - - - {strings('wallet.switch_network_to_view_transactions')} - - - ); - } return ( {strings('wallet.no_transactions')} diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 05beea7c416..df2a6c4fef2 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -93,7 +93,6 @@ import { PortfolioBalance } from '../../UI/Tokens/TokenList/PortfolioBalance'; import useCheckNftAutoDetectionModal from '../../hooks/useCheckNftAutoDetectionModal'; import useCheckMultiRpcModal from '../../hooks/useCheckMultiRpcModal'; import { selectContractBalances } from '../../../selectors/tokenBalancesController'; -import Logger from '../../../util/Logger'; const createStyles = ({ colors, typography }: Theme) => StyleSheet.create({ @@ -391,31 +390,23 @@ const Wallet = ({ accountBalanceByChainId?.balance, ]); - useEffect(() => { - const refreshNetworks = async () => { - const { AccountTrackerController } = Engine.context; - - const refreshPromises = Object.values(networkConfigurations) - .map(({ defaultRpcEndpointIndex, rpcEndpoints }) => { - const networkClientId = - rpcEndpoints[defaultRpcEndpointIndex]?.networkClientId; - if (networkClientId) { - return AccountTrackerController.refresh(networkClientId); - } - return null; - }) - .filter(Boolean); - - await Promise.all(refreshPromises); - }; - - // Use requestAnimationFrame to batch updates if necessary - requestAnimationFrame(() => { - refreshNetworks().catch((error) => { - Logger.error(error, 'Error refreshing networks'); + useEffect( + () => { + requestAnimationFrame(async () => { + const { AccountTrackerController } = Engine.context; + + Object.values(networkConfigurations).forEach( + ({ defaultRpcEndpointIndex, rpcEndpoints }) => { + AccountTrackerController.refresh( + rpcEndpoints[defaultRpcEndpointIndex].networkClientId, + ); + }, + ); }); - }); - }, [networkConfigurations, providerConfig.chainId]); + }, + /* eslint-disable-next-line */ + [navigation, providerConfig.chainId], + ); useEffect(() => { if (!selectedInternalAccount) return; diff --git a/app/components/hooks/DeleteWallet/useDeleteWallet.test.tsx b/app/components/hooks/DeleteWallet/useDeleteWallet.test.tsx index 2f261b8c1f8..fba7a25cd52 100644 --- a/app/components/hooks/DeleteWallet/useDeleteWallet.test.tsx +++ b/app/components/hooks/DeleteWallet/useDeleteWallet.test.tsx @@ -21,6 +21,12 @@ jest.mock('../../../store/storage-wrapper', () => ({ removeItem: jest.fn(), })); +jest.mock('../../../core/redux/ReduxService', () => ({ + store: { + dispatch: jest.fn(), + }, +})); + describe('useDeleteWallet', () => { test('it should provide two outputs of type function', () => { const { result } = renderHook(() => useDeleteWallet()); diff --git a/app/core/AppStateEventListener.test.ts b/app/core/AppStateEventListener.test.ts index 9940e4c2100..ede6491cc96 100644 --- a/app/core/AppStateEventListener.test.ts +++ b/app/core/AppStateEventListener.test.ts @@ -1,23 +1,12 @@ import { AppState, AppStateStatus } from 'react-native'; -import { store } from '../store'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { AppStateEventListener } from './AppStateEventListener'; import { processAttribution } from './processAttribution'; import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; +import ReduxService, { ReduxStore } from './redux'; -jest.mock('react-native', () => ({ - AppState: { - addEventListener: jest.fn(), - currentState: 'active', - }, -})); - -jest.mock('../store', () => ({ - store: { - getState: jest.fn(), - }, -})); +jest.mock('./DeeplinkManager/ParseManager/extractURLParams', () => jest.fn()); jest.mock('../util/Logger', () => ({ error: jest.fn(), @@ -44,6 +33,7 @@ describe('AppStateEventListener', () => { beforeEach(() => { jest.clearAllMocks(); + jest.resetModules(); jest.useFakeTimers(); (AppState.addEventListener as jest.Mock).mockImplementation( (_, listener) => { @@ -52,7 +42,7 @@ describe('AppStateEventListener', () => { }, ); appStateManager = new AppStateEventListener(); - appStateManager.init(store); + appStateManager.start(); }); afterEach(() => { @@ -66,16 +56,14 @@ describe('AppStateEventListener', () => { ); }); - it('throws error if store is initialized more than once', () => { - expect(() => appStateManager.init(store)).toThrow( - 'store is already initialized', - ); - expect(Logger.error).toHaveBeenCalledWith( - new Error('store is already initialized'), - ); + it('does not initialize event listener more than once', () => { + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); }); it('tracks event when app becomes active and attribution data is available', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); const mockAttribution = { attributionId: 'test123', utm: 'test_utm', @@ -113,6 +101,9 @@ describe('AppStateEventListener', () => { }); it('handles errors gracefully', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); const testError = new Error('Test error'); (processAttribution as jest.Mock).mockImplementation(() => { throw testError; @@ -135,7 +126,7 @@ describe('AppStateEventListener', () => { }); appStateManager = new AppStateEventListener(); - appStateManager.init(store); + appStateManager.start(); appStateManager.cleanup(); expect(mockRemove).toHaveBeenCalled(); @@ -160,13 +151,22 @@ describe('AppStateEventListener', () => { }); it('should handle undefined store gracefully', () => { - appStateManager = new AppStateEventListener(); + const { processAttribution: realProcessAttribution } = jest.requireActual( + './processAttribution', + ); + (processAttribution as jest.Mock).mockImplementation( + realProcessAttribution, + ); + mockAppStateListener('active'); jest.advanceTimersByTime(2000); - expect(mockMetrics.trackEvent).not.toHaveBeenCalled(); + const missingReduxStoreError = new Error('Redux store does not exist!'); + const appStateManagerErrorMessage = + 'AppStateManager: Error processing app state change'; expect(Logger.error).toHaveBeenCalledWith( - new Error('store is not initialized'), + missingReduxStoreError, + appStateManagerErrorMessage, ); }); }); diff --git a/app/core/AppStateEventListener.ts b/app/core/AppStateEventListener.ts index 9380aa585ea..2376c337494 100644 --- a/app/core/AppStateEventListener.ts +++ b/app/core/AppStateEventListener.ts @@ -1,36 +1,33 @@ import { AppState, AppStateStatus } from 'react-native'; -import { Store } from 'redux'; -import { RootState } from '../reducers'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; import { processAttribution } from './processAttribution'; import DevLogger from './SDKConnect/utils/DevLogger'; +import ReduxService from './redux'; export class AppStateEventListener { - private appStateSubscription: ReturnType; + private appStateSubscription: + | ReturnType + | undefined = undefined; private currentDeeplink: string | null = null; private lastAppState: AppStateStatus = AppState.currentState; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private store: Store | undefined; - constructor() { this.lastAppState = AppState.currentState; + } + + start() { + if (this.appStateSubscription) { + // Already started + return; + } this.appStateSubscription = AppState.addEventListener( 'change', this.handleAppStateChange, ); } - init(store: Store) { - if (this.store) { - Logger.error(new Error('store is already initialized')); - throw new Error('store is already initialized'); - } - this.store = store; - } - public setCurrentDeeplink(deeplink: string | null) { this.currentDeeplink = deeplink; } @@ -46,15 +43,10 @@ export class AppStateEventListener { }; private processAppStateChange = () => { - if (!this.store) { - Logger.error(new Error('store is not initialized')); - return; - } - try { const attribution = processAttribution({ currentDeeplink: this.currentDeeplink, - store: this.store, + store: ReduxService.store, }); if (attribution) { const { attributionId, utm, ...utmParams } = attribution; @@ -77,7 +69,8 @@ export class AppStateEventListener { }; public cleanup() { - this.appStateSubscription.remove(); + this.appStateSubscription?.remove(); + this.appStateSubscription = undefined; } } diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index e8be46072c4..b268825ad18 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -10,8 +10,7 @@ import AUTHENTICATION_TYPE from '../../constants/userProperties'; // eslint-disable-next-line import/no-namespace import * as Keychain from 'react-native-keychain'; import SecureKeychain from '../SecureKeychain'; -import configureMockStore from 'redux-mock-store'; -import Logger from '../../util/Logger'; +import ReduxService, { ReduxStore } from '../redux'; const storage: Record = {}; @@ -32,32 +31,11 @@ jest.mock('../../store/storage-wrapper', () => ({ })); describe('Authentication', () => { - const initialState = { - security: { - allowLoginWithRememberMe: true, - }, - }; - const mockStore = configureMockStore(); - const store = mockStore(initialState); - - beforeEach(() => { - Authentication.init(store); - }); - afterEach(() => { StorageWrapper.clearAll(); jest.restoreAllMocks(); }); - it('Does not initialize class more than once', async () => { - const spy = jest.spyOn(Logger, 'log'); - Authentication.init(store); - Authentication.init(store); - expect(spy).toHaveBeenCalledWith( - 'Attempted to call init on AuthenticationService but an instance has already been initialized', - ); - }); - it('should return a type password', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() @@ -129,6 +107,10 @@ describe('Authentication', () => { }); it('should return a auth type for components AUTHENTICATION_TYPE.REMEMBER_ME', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ security: { allowLoginWithRememberMe: true } }), + } as unknown as ReduxStore); + SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index beba46cf7c6..ea4bf865def 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -7,7 +7,6 @@ import { PASSCODE_DISABLED, SEED_PHRASE_HINTS, } from '../../constants/storage'; -import Logger from '../../util/Logger'; import { authSuccess, authError, @@ -16,7 +15,6 @@ import { passwordSet, } from '../../actions/user'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; -import { Store } from 'redux'; import AuthenticationError from './AuthenticationError'; import { UserCredentials, BIOMETRY_TYPE } from 'react-native-keychain'; import { @@ -32,6 +30,7 @@ import StorageWrapper from '../../store/storage-wrapper'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; import { TraceName, TraceOperation, endTrace, trace } from '../../util/trace'; +import ReduxService from '../redux'; /** * Holds auth data used to determine auth configuration @@ -43,51 +42,17 @@ export interface AuthData { class AuthenticationService { private authData: AuthData = { currentAuthType: AUTHENTICATION_TYPE.UNKNOWN }; - private store: Store | undefined = undefined; - private static isInitialized = false; - - /** - * This method creates the instance of the authentication class - * @param {Store} store - A redux function that will dispatch global state actions - */ - init(store: Store) { - if (!AuthenticationService.isInitialized) { - AuthenticationService.isInitialized = true; - this.store = store; - } else { - Logger.log( - 'Attempted to call init on AuthenticationService but an instance has already been initialized', - ); - } - } private dispatchLogin(): void { - if (this.store) { - this.store.dispatch(logIn()); - } else { - Logger.log( - 'Attempted to dispatch logIn action but dispatch was not initialized', - ); - } + ReduxService.store.dispatch(logIn()); } private dispatchPasswordSet(): void { - if (this.store) { - this.store.dispatch(passwordSet()); - } else { - Logger.log( - 'Attempted to dispatch passwordSet action but dispatch was not initialized', - ); - } + ReduxService.store.dispatch(passwordSet()); } private dispatchLogout(): void { - if (this.store) { - this.store.dispatch(logOut()); - } else - Logger.log( - 'Attempted to dispatch logOut action but dispatch was not initialized', - ); + ReduxService.store.dispatch(logOut()); } /** @@ -307,7 +272,7 @@ class AuthenticationService { }; } else if ( rememberMe && - this.store?.getState().security.allowLoginWithRememberMe + ReduxService.store.getState().security.allowLoginWithRememberMe ) { return { currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, @@ -460,12 +425,12 @@ class AuthenticationService { endTrace({ name: TraceName.VaultCreation }); this.dispatchLogin(); - this.store?.dispatch(authSuccess(bioStateMachineId)); + ReduxService.store.dispatch(authSuccess(bioStateMachineId)); this.dispatchPasswordSet(); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { - this.store?.dispatch(authError(bioStateMachineId)); + ReduxService.store.dispatch(authError(bioStateMachineId)); !disableAutoLogout && this.lockApp({ reset: false }); throw new AuthenticationError( (e as Error).message, diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 294baf2b96f..4e21df41a30 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -154,9 +154,9 @@ import { AccountsControllerSelectedAccountChangeEvent, AccountsControllerAccountAddedEvent, AccountsControllerAccountRenamedEvent, -} from './controllers/accounts/constants'; +} from './controllers/AccountsController/constants'; import { AccountsControllerMessenger } from '@metamask/accounts-controller'; -import { createAccountsController } from './controllers/accounts/utils'; +import { createAccountsController } from './controllers/AccountsController/utils'; import { createRemoteFeatureFlagController } from './controllers/RemoteFeatureFlagController'; import { captureException } from '@sentry/react-native'; import { lowerCase } from 'lodash'; diff --git a/app/core/Engine/controllers/accounts/constants.ts b/app/core/Engine/controllers/AccountsController/constants.ts similarity index 100% rename from app/core/Engine/controllers/accounts/constants.ts rename to app/core/Engine/controllers/AccountsController/constants.ts diff --git a/app/core/Engine/controllers/accounts/utils.test.ts b/app/core/Engine/controllers/AccountsController/utils.test.ts similarity index 100% rename from app/core/Engine/controllers/accounts/utils.test.ts rename to app/core/Engine/controllers/AccountsController/utils.test.ts diff --git a/app/core/Engine/controllers/accounts/utils.ts b/app/core/Engine/controllers/AccountsController/utils.ts similarity index 100% rename from app/core/Engine/controllers/accounts/utils.ts rename to app/core/Engine/controllers/AccountsController/utils.ts diff --git a/app/core/Engine/controllers/accounts/README.md b/app/core/Engine/controllers/accounts/README.md deleted file mode 100644 index 24ea735bd59..00000000000 --- a/app/core/Engine/controllers/accounts/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Controllers owned by the Accounts team - -This folder contains controllers that are owned by the Accounts team. diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 20ee32ab5bf..99bc238b2f5 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -1,39 +1,40 @@ -import EngineService from './EngineService'; +import { EngineService } from './EngineService'; +import ReduxService, { type ReduxStore } from '../redux'; import Engine from '../Engine'; -import { store } from '../../store'; +import { type KeyringControllerState } from '@metamask/keyring-controller'; +import NavigationService from '../NavigationService'; +import Logger from '../../util/Logger'; +import Routes from '../../constants/navigation/Routes'; -jest.mock('../../util/test/network-store.js', () => jest.fn()); -jest.mock('../../store', () => ({ - store: { - getState: jest.fn(() => ({ - engine: { - backgroundState: {}, - }, - })), - dispatch: jest.fn(), +// Mock NavigationService +jest.mock('../NavigationService', () => ({ + navigation: { + reset: jest.fn(), }, })); +// Mock Logger +jest.mock('../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../BackupVault', () => ({ + getVaultFromBackup: () => ({ success: true, vault: 'fake_vault' }), +})); + +jest.mock('../../util/test/network-store.js', () => jest.fn()); + +// Unmock global Engine +jest.unmock('../Engine'); + jest.mock('../Engine', () => { // Do not need to mock entire Engine. Only need subset of data for testing purposes. // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any let instance: any; - return { - get context() { - if (!instance) { - throw new Error('Engine does not exist'); - } - return instance.context; - }, - get controllerMessenger() { - if (!instance) { - throw new Error('Engine does not exist'); - } - return instance.controllerMessenger; - }, - destroyEngine: jest.fn(), - init: jest.fn((_, keyringState) => { + + const mockEngine = { + init: (_: unknown, keyringState: KeyringControllerState) => { instance = { controllerMessenger: { subscribe: jest.fn(), @@ -77,18 +78,68 @@ jest.mock('../Engine', () => { }, }; return instance; + }, + get context() { + if (!instance) { + throw new Error('Engine does not exist'); + } + return instance.context; + }, + get controllerMessenger() { + if (!instance) { + throw new Error('Engine does not exist'); + } + return instance.controllerMessenger; + }, + destroyEngine: jest.fn(async () => { + instance = null; }), }; + + return { + __esModule: true, + default: mockEngine, + }; }); describe('EngineService', () => { - EngineService.initalizeEngine(store); + let engineService: EngineService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ engine: { backgroundState: {} } }), + } as unknown as ReduxStore); + + engineService = new EngineService(); + }); + it('should have Engine initialized', () => { + engineService.start(); expect(Engine.context).toBeDefined(); }); + it('should have recovered vault on redux store ', async () => { - const { success } = await EngineService.initializeVaultFromBackup(); + engineService.start(); + const { success } = await engineService.initializeVaultFromBackup(); expect(success).toBeTruthy(); expect(Engine.context.KeyringController.state.vault).toBeDefined(); }); + + it('should navigate to vault recovery if Engine fails to initialize', () => { + jest.spyOn(Engine, 'init').mockImplementation(() => { + throw new Error('Failed to initialize Engine'); + }); + engineService.start(); + // Logs error to Sentry + expect(Logger.error).toHaveBeenCalledWith( + new Error('Failed to initialize Engine'), + 'Failed to initialize Engine! Falling back to vault recovery.', + ); + // Navigates to vault recovery + expect(NavigationService.navigation?.reset).toHaveBeenCalledWith({ + routes: [{ name: Routes.VAULT_RECOVERY.RESTORE_WALLET }], + }); + }); }); diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index bedb3e97534..6ba3e827cb9 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -1,7 +1,6 @@ import UntypedEngine from '../Engine'; import AppConstants from '../AppConstants'; import { getVaultFromBackup } from '../BackupVault'; -import { store as importedStore } from '../../store'; import Logger from '../../util/Logger'; import { NO_VAULT_IN_BACKUP_ERROR, @@ -10,6 +9,9 @@ import { import { getTraceTags } from '../../util/sentry/tags'; import { trace, endTrace, TraceName, TraceOperation } from '../../util/trace'; import getUIStartupSpan from '../Performance/UIStartup'; +import ReduxService from '../redux'; +import NavigationService from '../NavigationService'; +import Routes from '../../constants/navigation/Routes'; interface InitializeEngineResult { success: boolean; @@ -18,37 +20,57 @@ interface InitializeEngineResult { const UPDATE_BG_STATE_KEY = 'UPDATE_BG_STATE'; const INIT_BG_STATE_KEY = 'INIT_BG_STATE'; -class EngineService { +export class EngineService { private engineInitialized = false; /** - * Initializer for the EngineService + * Starts the Engine and subscribes to the controller state changes * - * @param store - Redux store + * EngineService.start() with SES/lockdown: + * Requires ethjs nested patches (lib->src) + * - ethjs/ethjs-query + * - ethjs/ethjs-contract + * Otherwise causing the following errors: + * - TypeError: Cannot assign to read only property 'constructor' of object '[object Object]' + * - Error: Requiring module "node_modules/ethjs/node_modules/ethjs-query/lib/index.js", which threw an exception: TypeError: + * - V8: Cannot assign to read only property 'constructor' of object '[object Object]' + * - JSC: Attempted to assign to readonly property + * - node_modules/babel-runtime/node_modules/regenerator-runtime/runtime.js + * - V8: TypeError: _$$_REQUIRE(...) is not a constructor + * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') + * - V8: SES_UNHANDLED_REJECTION */ - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - initalizeEngine = (store: any) => { + start = () => { + const reduxState = ReduxService.store.getState(); trace({ name: TraceName.EngineInitialization, op: TraceOperation.EngineInitialization, parentContext: getUIStartupSpan(), - tags: getTraceTags(store.getState()), + tags: getTraceTags(reduxState), }); - const reduxState = store.getState?.(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const Engine = UntypedEngine as any; - Engine.init(state); - this.updateControllers(store, Engine); + try { + Engine.init(state); + this.updateControllers(Engine); + } catch (error) { + Logger.error( + error as Error, + 'Failed to initialize Engine! Falling back to vault recovery.', + ); + // Navigate to vault recovery + NavigationService.navigation?.reset({ + routes: [{ name: Routes.VAULT_RECOVERY.RESTORE_WALLET }], + }); + } endTrace({ name: TraceName.EngineInitialization }); }; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - private updateControllers = (store: any, engine: any) => { + private updateControllers = (engine: any) => { if (!engine.context) { Logger.error( new Error( @@ -179,7 +201,7 @@ class EngineService { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for INIT_BG_STATE_KEY'); } - store.dispatch({ type: INIT_BG_STATE_KEY }); + ReduxService.store.dispatch({ type: INIT_BG_STATE_KEY }); this.engineInitialized = true; }, () => !this.engineInitialized, @@ -191,7 +213,10 @@ class EngineService { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for UPDATE_BG_STATE_KEY'); } - store.dispatch({ type: UPDATE_BG_STATE_KEY, payload: { key: name } }); + ReduxService.store.dispatch({ + type: UPDATE_BG_STATE_KEY, + payload: { key: name }, + }); }; engine.controllerMessenger.subscribe(key, update_bg_state_cb); }); @@ -208,7 +233,7 @@ class EngineService { */ async initializeVaultFromBackup(): Promise { const keyringState = await getVaultFromBackup(); - const reduxState = importedStore.getState?.(); + const reduxState = ReduxService.store.getState(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -223,7 +248,7 @@ class EngineService { }; const instance = Engine.init(state, newKeyringState); if (instance) { - this.updateControllers(importedStore, instance); + this.updateControllers(instance); // this is a hack to give the engine time to reinitialize await new Promise((resolve) => setTimeout(resolve, 2000)); return { diff --git a/app/core/EngineService/index.ts b/app/core/EngineService/index.ts index 9bed417ddf2..dd9072d50ea 100644 --- a/app/core/EngineService/index.ts +++ b/app/core/EngineService/index.ts @@ -1,2 +1 @@ -import EngineService from './EngineService'; -export default EngineService; +export { default } from './EngineService'; diff --git a/app/core/LockManagerService/index.test.ts b/app/core/LockManagerService/index.test.ts index a562ab57efe..4924e5d479d 100644 --- a/app/core/LockManagerService/index.test.ts +++ b/app/core/LockManagerService/index.test.ts @@ -1,7 +1,8 @@ -import LockManagerService from '.'; -import { AppState } from 'react-native'; -import configureMockStore from 'redux-mock-store'; +import { LockManagerService } from '.'; +import { AppState, AppStateStatus } from 'react-native'; import { interruptBiometrics, lockApp } from '../../actions/user'; +import Logger from '../../util/Logger'; +import ReduxService, { type ReduxStore } from '../redux'; jest.mock('../Engine', () => ({ context: { @@ -10,133 +11,148 @@ jest.mock('../Engine', () => ({ }, }, })); + const mockSetTimeout = jest.fn(); + jest.mock('react-native-background-timer', () => ({ setTimeout: () => mockSetTimeout(), })); + jest.mock('../SecureKeychain', () => ({ getInstance: () => ({ isAuthenticating: false, }), })); -const initialState = { - settings: { - lockTime: 0, - }, -}; -const mockStore = configureMockStore(); -const defaultStore = mockStore(initialState); - -describe('startListening', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); - - afterEach(() => { - LockManagerService.stopListening(); - jest.clearAllMocks(); - }); - - it('should do nothing when store is undefined.', async () => { - LockManagerService.startListening(); - expect(addEventListener).not.toBeCalled(); - }); - - it('should do nothing when app state listener is already subscribed.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - }); - - it('should add event listener when store is defined and listener is not yet subscribed.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalled(); - }); -}); +jest.mock('../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); -describe('stopListening', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); +describe('LockManagerService', () => { + let lockManagerService: LockManagerService; + let mockAppStateListener: (state: AppStateStatus) => void; - afterEach(() => { - LockManagerService.stopListening(); + beforeEach(() => { jest.clearAllMocks(); + jest.resetModules(); + jest.useFakeTimers(); + (AppState.addEventListener as jest.Mock).mockImplementation( + (_, listener) => { + mockAppStateListener = listener; + return { remove: jest.fn() }; + }, + ); + lockManagerService = new LockManagerService(); }); - it('should remove app state listener.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - LockManagerService.stopListening(); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(2); - }); -}); - -describe('handleAppStateChange', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); - const defaultDispatch = jest.spyOn(defaultStore, 'dispatch'); - afterEach(() => { - LockManagerService.stopListening(); - jest.clearAllMocks(); - }); - - it('should do nothing if lockTime is -1 while going into the background', async () => { - const store = mockStore({ settings: { lockTime: -1 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(defaultDispatch).not.toBeCalled(); - }); - - it('should do nothing if lockTime is 0 while going inactive.', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('inactive'); - expect(defaultDispatch).not.toBeCalled(); - }); - - it('should do nothing while lockTime is 0 while going from inactive to active', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('inactive'); - appStateTrigger('active'); - expect(defaultDispatch).not.toBeCalled(); + lockManagerService.stopListening(); + jest.useRealTimers(); }); - it('should dispatch interruptBiometrics when lockTimer is undefined, lockTime is non-zero, and app state is not active', async () => { - const store = mockStore({ settings: { lockTime: 5 } }); - const dispatch = jest.spyOn(store, 'dispatch'); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(dispatch).toBeCalledWith(interruptBiometrics()); + describe('startListening', () => { + it('should do nothing when app state listener is already subscribed.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + expect(Logger.log).toHaveBeenCalledWith( + 'Already subscribed to app state listener.', + ); + }); + + it('should add event listener when it is not yet subscribed.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalled(); + }); }); - it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - const dispatch = jest.spyOn(store, 'dispatch'); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(await dispatch).toBeCalledWith(lockApp()); + describe('stopListening', () => { + it('should remove app state listener.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + lockManagerService.stopListening(); + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(2); + }); }); - it('should set background timer when lockTimer is non-zero while going into the background', async () => { - const store = mockStore({ settings: { lockTime: 5 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(mockSetTimeout).toBeCalled(); + describe('handleAppStateChange', () => { + it('should throw an error if store is undefined.', async () => { + lockManagerService.startListening(); + mockAppStateListener('active'); + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store does not exist!'), + 'LockManagerService: Error handling app state change', + ); + }); + + it('should do nothing if lockTime is -1 while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: -1 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('active'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should do nothing if lockTime is 0 while going inactive.', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('inactive'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should do nothing while lockTime is 0 while going from inactive to active', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('inactive'); + mockAppStateListener('active'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should dispatch interruptBiometrics when lockTimer is undefined, lockTime is non-zero, and app state is not active', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 5 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(mockDispatch).toHaveBeenCalledWith(interruptBiometrics()); + }); + + it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(await mockDispatch).toHaveBeenCalledWith(lockApp()); + }); + + it('should set background timer when lockTimer is non-zero while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 5 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(mockSetTimeout).toHaveBeenCalled(); + }); }); }); diff --git a/app/core/LockManagerService/index.ts b/app/core/LockManagerService/index.ts index a5f0b346827..0fba5c0978a 100644 --- a/app/core/LockManagerService/index.ts +++ b/app/core/LockManagerService/index.ts @@ -8,26 +8,19 @@ import BackgroundTimer from 'react-native-background-timer'; import Engine from '../Engine'; import Logger from '../../util/Logger'; import { lockApp, interruptBiometrics } from '../../actions/user'; -import { Store } from 'redux'; +import ReduxService from '../redux'; -class LockManagerService { +export class LockManagerService { #appState?: AppStateStatus; #appStateListener?: NativeEventSubscription; #lockTimer?: number; - #store?: Store; - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - init = (store: any) => { - this.#store = store; - }; #lockApp = async () => { if (!SecureKeychain.getInstance().isAuthenticating) { const { KeyringController } = Engine.context; try { await KeyringController.setLocked(); - this.#store?.dispatch(lockApp()); + ReduxService.store.dispatch(lockApp()); } catch (error) { Logger.log('Failed to lock KeyringController', error); } @@ -47,55 +40,58 @@ class LockManagerService { #handleAppStateChange = async (nextAppState: AppStateStatus) => { // Don't auto-lock. - const lockTime = this.#store?.getState().settings.lockTime; - if ( - lockTime === -1 || // Lock timer isn't set. - nextAppState === 'inactive' || // Ignore inactive state. - (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. - ) { - this.#appState = nextAppState; - return; - } + try { + const lockTime = ReduxService.store.getState().settings.lockTime; + if ( + lockTime === -1 || // Lock timer isn't set. + nextAppState === 'inactive' || // Ignore inactive state. + (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. + ) { + this.#appState = nextAppState; + return; + } - // EDGE CASE - // Handles interruptions in the middle of authentication while lock timer is a non-zero value - // This is most likely called when the background timer fails to be called while backgrounding the app - if (!this.#lockTimer && lockTime !== 0 && nextAppState !== 'active') { - this.#store?.dispatch(interruptBiometrics()); - } + // EDGE CASE + // Handles interruptions in the middle of authentication while lock timer is a non-zero value + // This is most likely called when the background timer fails to be called while backgrounding the app + if (!this.#lockTimer && lockTime !== 0 && nextAppState !== 'active') { + ReduxService.store.dispatch(interruptBiometrics()); + } - // Handle lock logic on background. - if (nextAppState === 'background') { - if (lockTime === 0) { - this.#lockApp(); - } else { - // Autolock after some time. + // Handle lock logic on background. + if (nextAppState === 'background') { + if (lockTime === 0) { + this.#lockApp(); + } else { + // Autolock after some time. + this.#clearBackgroundTimer(); + this.#lockTimer = BackgroundTimer.setTimeout(() => { + if (this.#lockTimer) { + this.#lockApp(); + } + }, lockTime); + } + } + + // App has foregrounded from background. + // Clear background timer for safe measure. + if (nextAppState === 'active') { this.#clearBackgroundTimer(); - this.#lockTimer = BackgroundTimer.setTimeout(() => { - if (this.#lockTimer) { - this.#lockApp(); - } - }, lockTime); } - } - // App has foregrounded from background. - // Clear background timer for safe measure. - if (nextAppState === 'active') { - this.#clearBackgroundTimer(); + this.#appState = nextAppState; + } catch (error) { + Logger.error( + error as Error, + 'LockManagerService: Error handling app state change', + ); } - - this.#appState = nextAppState; }; /** * Listen to AppState events to control lock state. */ startListening = () => { - if (!this.#store) { - Logger.log('Failed to start listener since store is undefined.'); - return; - } if (this.#appStateListener) { Logger.log('Already subscribed to app state listener.'); return; diff --git a/app/core/redux/ReduxService.test.ts b/app/core/redux/ReduxService.test.ts new file mode 100644 index 00000000000..7b14a872c07 --- /dev/null +++ b/app/core/redux/ReduxService.test.ts @@ -0,0 +1,67 @@ +import ReduxService from './ReduxService'; +import Logger from '../../util/Logger'; +import type { ReduxStore } from './types'; + +describe('ReduxService', () => { + let mockStore: ReduxStore; + + beforeEach(() => { + // Reset any internal state + jest.clearAllMocks(); + + // Create a mock store + mockStore = { + dispatch: jest.fn(), + getState: jest.fn(), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + } as unknown as ReduxStore; + + // Spy on Logger + jest.spyOn(Logger, 'error'); + }); + + describe('store getter', () => { + it('should throw error if store does not exist', () => { + expect(() => ReduxService.store).toThrow('Redux store does not exist!'); + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store does not exist!'), + ); + }); + + it('should return store if it exists', () => { + ReduxService.store = mockStore; + expect(ReduxService.store).toBe(mockStore); + }); + }); + + describe('store setter', () => { + it('should throw error if store is invalid', () => { + const invalidStore = {} as ReduxStore; + + expect(() => { + ReduxService.store = invalidStore; + }).toThrow('Redux store is not a valid store!'); + + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store is not a valid store!'), + ); + }); + + it('should set store if valid', () => { + ReduxService.store = mockStore; + expect(ReduxService.store).toBe(mockStore); + }); + + it('should validate store has required methods', () => { + const incompleteStore = { + dispatch: jest.fn(), + // missing getState + } as unknown as ReduxStore; + + expect(() => { + ReduxService.store = incompleteStore; + }).toThrow('Redux store is not a valid store!'); + }); + }); +}); diff --git a/app/core/redux/ReduxService.ts b/app/core/redux/ReduxService.ts new file mode 100644 index 00000000000..7f2d9f75fa6 --- /dev/null +++ b/app/core/redux/ReduxService.ts @@ -0,0 +1,49 @@ +import Logger from '../../util/Logger'; +import { ReduxStore } from './types'; + +/** + * ReduxService class that manages the Redux store + */ +class ReduxService { + static #reduxStore: ReduxStore; + + static #assertReduxStoreType(store: ReduxStore) { + if ( + typeof store.dispatch !== 'function' || + typeof store.getState !== 'function' + ) { + const error = new Error('Redux store is not a valid store!'); + Logger.error(error); + throw error; + } + return this.#reduxStore; + } + + static #assertReduxStoreExists() { + if (!this.#reduxStore) { + const error = new Error('Redux store does not exist!'); + Logger.error(error); + throw error; + } + return this.#reduxStore; + } + + /** + * Set the store in the Redux class + * @param store + */ + static set store(store: ReduxStore) { + this.#assertReduxStoreType(store); + this.#reduxStore = store; + } + + /** + * Get the store from the Redux class + */ + static get store() { + this.#assertReduxStoreExists(); + return this.#reduxStore; + } +} + +export default ReduxService; diff --git a/app/core/redux/index.ts b/app/core/redux/index.ts new file mode 100644 index 00000000000..d3b1d8b7418 --- /dev/null +++ b/app/core/redux/index.ts @@ -0,0 +1,3 @@ +export { default } from './ReduxService'; + +export * from './types'; diff --git a/app/core/redux/types.ts b/app/core/redux/types.ts new file mode 100644 index 00000000000..09fff70ce7e --- /dev/null +++ b/app/core/redux/types.ts @@ -0,0 +1,8 @@ +import { AnyAction, Store } from 'redux'; +import { RootState } from '../../reducers'; + +/** + * Redux store type + * TODO: Replace AnyAction with union type of all actions + */ +export type ReduxStore = Store; diff --git a/app/reducers/index.ts b/app/reducers/index.ts index ee998f4724f..990bc742032 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -7,7 +7,7 @@ import settingsReducer from './settings'; import alertReducer from './alert'; import transactionReducer from './transaction'; import legalNoticesReducer from './legalNotices'; -import userReducer, { IUserReducer } from './user'; +import userReducer, { UserState } from './user'; import wizardReducer from './wizard'; import onboardingReducer from './onboarding'; import fiatOrders from './fiatOrders'; @@ -16,7 +16,7 @@ import signatureRequestReducer from './signatureRequest'; import notificationReducer from './notification'; import infuraAvailabilityReducer from './infuraAvailability'; import collectiblesReducer from './collectibles'; -import navigationReducer from './navigation'; +import navigationReducer, { NavigationState } from './navigation'; import networkOnboardReducer from './networkSelector'; import securityReducer, { SecurityState } from './security'; import { combineReducers, Reducer } from 'redux'; @@ -81,7 +81,7 @@ export interface RootState { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any transaction: any; - user: IUserReducer; + user: UserState; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any wizard: any; @@ -98,10 +98,7 @@ export interface RootState { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any infuraAvailability: any; - // The navigation reducer is TypeScript but not yet a valid reducer - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navigation: any; + navigation: NavigationState; // The networkOnboarded reducer is TypeScript but not yet a valid reducer // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/reducers/navigation/index.ts b/app/reducers/navigation/index.ts index 9078531b0cb..4cdf8a0558f 100644 --- a/app/reducers/navigation/index.ts +++ b/app/reducers/navigation/index.ts @@ -1,32 +1,36 @@ -/** - * Constants - */ -export const SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE'; -export const SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_TAB_BAR_ROUTE'; +import { + type NavigationAction, + NavigationActionType, +} from '../../actions/navigation/types'; +import { NavigationState } from './types'; + +export * from './types'; + +export * from './selectors'; /** - * Reducers + * Initial navigation state */ -interface InitialState { - currentRoute: string; - currentBottomNavRoute: string; -} - -const initialState: InitialState = { +export const initialNavigationState: NavigationState = { currentRoute: 'WalletView', currentBottomNavRoute: 'Wallet', }; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const navigationReducer = (state = initialState, action: any = {}) => { +/** + * Navigation reducer + */ +/* eslint-disable @typescript-eslint/default-param-last */ +const navigationReducer = ( + state: NavigationState = initialNavigationState, + action: NavigationAction, +): NavigationState => { switch (action.type) { - case SET_CURRENT_ROUTE: + case NavigationActionType.SET_CURRENT_ROUTE: return { ...state, currentRoute: action.payload.route, }; - case SET_CURRENT_BOTTOM_NAV_ROUTE: + case NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE: return { ...state, currentBottomNavRoute: action.payload.route, diff --git a/app/reducers/navigation/selectors.ts b/app/reducers/navigation/selectors.ts new file mode 100644 index 00000000000..a85aaa1cac0 --- /dev/null +++ b/app/reducers/navigation/selectors.ts @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; +import { RootState } from '..'; + +/** + * Selects the navigation state + */ +export const selectNavigationState = (state: RootState) => state.navigation; + +/** + * Selects the current route + */ +export const selectCurrentRoute = createSelector( + selectNavigationState, + (navigationState) => navigationState.currentRoute, +); + +/** + * Selects the current bottom nav route + */ +export const selectCurrentBottomNavRoute = createSelector( + selectNavigationState, + (navigationState) => navigationState.currentBottomNavRoute, +); diff --git a/app/reducers/navigation/types.ts b/app/reducers/navigation/types.ts new file mode 100644 index 00000000000..b5f22b005e6 --- /dev/null +++ b/app/reducers/navigation/types.ts @@ -0,0 +1,8 @@ +/** + * Navigation state + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type NavigationState = { + currentRoute: string; + currentBottomNavRoute: string; +}; diff --git a/app/reducers/user/index.ts b/app/reducers/user/index.ts index a942f0eb88d..5941561d6ea 100644 --- a/app/reducers/user/index.ts +++ b/app/reducers/user/index.ts @@ -1,21 +1,15 @@ +import { UserAction, UserActionType } from '../../actions/user/types'; import { AppThemeKey } from '../../util/theme/models'; +import { UserState } from './types'; -export interface IUserReducer { - loadingMsg: string; - loadingSet: boolean; - passwordSet: boolean; - seedphraseBackedUp: boolean; - backUpSeedphraseVisible: boolean; - protectWalletModalVisible: boolean; - gasEducationCarouselSeen: boolean; - userLoggedIn: boolean; - isAuthChecked: boolean; - initialScreen: string; - appTheme: AppThemeKey; - ambiguousAddressEntries: Record; -} +export * from './types'; -export const userInitialState = { +export * from './selectors'; + +/** + * Initial user state + */ +export const userInitialState: UserState = { loadingMsg: '', loadingSet: false, passwordSet: false, @@ -30,83 +24,69 @@ export const userInitialState = { ambiguousAddressEntries: {}, }; -// Define action types -type UserAction = - | { type: 'LOGIN' } - | { type: 'LOGOUT' } - | { type: 'LOADING_SET'; loadingMsg: string } - | { type: 'LOADING_UNSET' } - | { type: 'PASSWORD_SET' } - | { type: 'PASSWORD_UNSET' } - | { type: 'SEEDPHRASE_NOT_BACKED_UP' } - | { type: 'SEEDPHRASE_BACKED_UP' } - | { type: 'BACK_UP_SEEDPHRASE_VISIBLE' } - | { type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE' } - | { type: 'PROTECT_MODAL_VISIBLE' } - | { type: 'PROTECT_MODAL_NOT_VISIBLE' } - | { type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN' } - | { type: 'SET_APP_THEME'; payload: { theme: AppThemeKey } }; - +/** + * User reducer + */ +/* eslint-disable @typescript-eslint/default-param-last */ const userReducer = ( - // eslint-disable-next-line @typescript-eslint/default-param-last - state: IUserReducer = userInitialState, + state: UserState = userInitialState, action: UserAction, -) => { +): UserState => { switch (action.type) { - case 'LOGIN': + case UserActionType.LOGIN: return { ...state, userLoggedIn: true, }; - case 'LOGOUT': + case UserActionType.LOGOUT: return { ...state, userLoggedIn: false, }; - case 'LOADING_SET': + case UserActionType.LOADING_SET: return { ...state, loadingSet: true, loadingMsg: action.loadingMsg, }; - case 'LOADING_UNSET': + case UserActionType.LOADING_UNSET: return { ...state, loadingSet: false, }; - case 'PASSWORD_SET': + case UserActionType.PASSWORD_SET: return { ...state, passwordSet: true, }; - case 'PASSWORD_UNSET': + case UserActionType.PASSWORD_UNSET: return { ...state, passwordSet: false, }; - case 'SEEDPHRASE_NOT_BACKED_UP': + case UserActionType.SEEDPHRASE_NOT_BACKED_UP: return { ...state, seedphraseBackedUp: false, backUpSeedphraseVisible: true, }; - case 'SEEDPHRASE_BACKED_UP': + case UserActionType.SEEDPHRASE_BACKED_UP: return { ...state, seedphraseBackedUp: true, backUpSeedphraseVisible: false, }; - case 'BACK_UP_SEEDPHRASE_VISIBLE': + case UserActionType.BACK_UP_SEEDPHRASE_VISIBLE: return { ...state, backUpSeedphraseVisible: true, }; - case 'BACK_UP_SEEDPHRASE_NOT_VISIBLE': + case UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE: return { ...state, backUpSeedphraseVisible: false, }; - case 'PROTECT_MODAL_VISIBLE': + case UserActionType.PROTECT_MODAL_VISIBLE: if (!state.seedphraseBackedUp) { return { ...state, @@ -114,17 +94,17 @@ const userReducer = ( }; } return state; - case 'PROTECT_MODAL_NOT_VISIBLE': + case UserActionType.PROTECT_MODAL_NOT_VISIBLE: return { ...state, protectWalletModalVisible: false, }; - case 'SET_GAS_EDUCATION_CAROUSEL_SEEN': + case UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN: return { ...state, gasEducationCarouselSeen: true, }; - case 'SET_APP_THEME': + case UserActionType.SET_APP_THEME: return { ...state, appTheme: action.payload.theme, diff --git a/app/reducers/user/selectors.ts b/app/reducers/user/selectors.ts new file mode 100644 index 00000000000..2341b19cad8 --- /dev/null +++ b/app/reducers/user/selectors.ts @@ -0,0 +1,6 @@ +import { RootState } from '..'; + +/** + * Selects the user state + */ +export const selectUserState = (state: RootState) => state.user; diff --git a/app/reducers/user/types.ts b/app/reducers/user/types.ts new file mode 100644 index 00000000000..4080aebc5ee --- /dev/null +++ b/app/reducers/user/types.ts @@ -0,0 +1,19 @@ +import { AppThemeKey } from '../../util/theme/models'; + +/** + * User state + */ +export interface UserState { + loadingMsg: string; + loadingSet: boolean; + passwordSet: boolean; + seedphraseBackedUp: boolean; + backUpSeedphraseVisible: boolean; + protectWalletModalVisible: boolean; + gasEducationCarouselSeen: boolean; + userLoggedIn: boolean; + isAuthChecked: boolean; + initialScreen: string; + appTheme: AppThemeKey; + ambiguousAddressEntries: Record; +} diff --git a/app/store/index.ts b/app/store/index.ts index 5f74eb552d9..e158b1e5f81 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -4,9 +4,6 @@ import { persistStore, persistReducer } from 'redux-persist'; import createSagaMiddleware from 'redux-saga'; import { rootSaga } from './sagas'; import rootReducer, { RootState } from '../reducers'; -import EngineService from '../core/EngineService'; -import { Authentication } from '../core'; -import LockManagerService from '../core/LockManagerService'; import ReadOnlyNetworkStore from '../util/test/network-store'; import { isE2E } from '../util/test/utils'; import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; @@ -14,8 +11,9 @@ import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; import thunk from 'redux-thunk'; import persistConfig from './persistConfig'; -import { AppStateEventProcessor } from '../core/AppStateEventListener'; import getUIStartupSpan from '../core/Performance/UIStartup'; +import ReduxService from '../core/redux'; +import { onPersistedDataLoaded } from '../actions/user'; // TODO: Improve type safety by using real Action types instead of `any` // TODO: Replace "any" with type @@ -59,6 +57,8 @@ const createStoreAndPersistor = async () => { middleware: middlewares, preloadedState: initialState, }); + // Set the store in the Redux class + ReduxService.store = store; sagaMiddleware.run(rootSaga); @@ -66,34 +66,9 @@ const createStoreAndPersistor = async () => { * Initialize services after persist is completed */ const onPersistComplete = () => { - /** - * EngineService.initalizeEngine(store) with SES/lockdown: - * Requires ethjs nested patches (lib->src) - * - ethjs/ethjs-query - * - ethjs/ethjs-contract - * Otherwise causing the following errors: - * - TypeError: Cannot assign to read only property 'constructor' of object '[object Object]' - * - Error: Requiring module "node_modules/ethjs/node_modules/ethjs-query/lib/index.js", which threw an exception: TypeError: - * - V8: Cannot assign to read only property 'constructor' of object '[object Object]' - * - JSC: Attempted to assign to readonly property - * - node_modules/babel-runtime/node_modules/regenerator-runtime/runtime.js - * - V8: TypeError: _$$_REQUIRE(...) is not a constructor - * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') - * - V8: SES_UNHANDLED_REJECTION - */ - - store.dispatch({ - type: 'TOGGLE_BASIC_FUNCTIONALITY', - basicFunctionalityEnabled: - store.getState().settings.basicFunctionalityEnabled, - }); - - EngineService.initalizeEngine(store); - - Authentication.init(store); - AppStateEventProcessor.init(store); - LockManagerService.init(store); endTrace({ name: TraceName.StoreInit }); + // Signal that persisted data has been loaded + store.dispatch(onPersistedDataLoaded()); }; persistor = persistStore(store, null, onPersistComplete); diff --git a/app/store/persistConfig.ts b/app/store/persistConfig.ts index 0aa71c0ccad..5b8b4358134 100644 --- a/app/store/persistConfig.ts +++ b/app/store/persistConfig.ts @@ -6,7 +6,7 @@ import { RootState } from '../reducers'; import { migrations, version } from './migrations'; import Logger from '../util/Logger'; import Device from '../util/device'; -import { IUserReducer } from '../reducers/user'; +import { UserState } from '../reducers/user'; const TIMEOUT = 40000; @@ -96,7 +96,7 @@ const persistTransform = createTransform( ); const persistUserTransform = createTransform( - (inboundState: IUserReducer) => { + (inboundState: UserState) => { const { initialScreen, isAuthChecked, ...state } = inboundState; // Reconstruct data to persist return state; diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 32afb2b55c7..b7b0724a993 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -1,15 +1,14 @@ -import { fork, take, cancel, put, call } from 'redux-saga/effects'; +import { fork, take, cancel, put, call, all } from 'redux-saga/effects'; import NavigationService from '../../core/NavigationService'; import Routes from '../../constants/navigation/Routes'; import { - LOCKED_APP, - AUTH_SUCCESS, - AUTH_ERROR, + AuthSuccessAction, + AuthErrorAction, + InterruptBiometricsAction, lockApp, - INTERRUPT_BIOMETRICS, - LOGOUT, - LOGIN, + UserActionType, } from '../../actions/user'; +import { NavigationActionType } from '../../actions/navigation'; import { Task } from 'redux-saga'; import Engine from '../../core/Engine'; import Logger from '../../util/Logger'; @@ -18,11 +17,13 @@ import { overrideXMLHttpRequest, restoreXMLHttpRequest, } from './xmlHttpRequestOverride'; +import EngineService from '../../core/EngineService'; +import { AppStateEventProcessor } from '../../core/AppStateEventListener'; export function* appLockStateMachine() { let biometricsListenerTask: Task | undefined; while (true) { - yield take(LOCKED_APP); + yield take(UserActionType.LOCKED_APP); if (biometricsListenerTask) { yield cancel(biometricsListenerTask); } @@ -45,11 +46,11 @@ export function* appLockStateMachine() { export function* authStateMachine() { // Start when the user is logged in. while (true) { - yield take(LOGIN); + yield take(UserActionType.LOGIN); const appLockStateMachineTask: Task = yield fork(appLockStateMachine); LockManagerService.startListening(); // Listen to app lock behavior. - yield take(LOGOUT); + yield take(UserActionType.LOGOUT); LockManagerService.stopListening(); // Cancels appLockStateMachineTask, which also cancels nested sagas once logged out. yield cancel(appLockStateMachineTask); @@ -78,32 +79,32 @@ export function* biometricsStateMachine(originalBioStateMachineId: string) { // Handle next three possible states. let shouldHandleAction = false; let action: - | { - type: - | typeof AUTH_SUCCESS - | typeof AUTH_ERROR - | typeof INTERRUPT_BIOMETRICS; - payload?: { bioStateMachineId: string }; - } + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction | undefined; // Only continue on INTERRUPT_BIOMETRICS action or when actions originated from corresponding state machine. while (!shouldHandleAction) { - action = yield take([AUTH_SUCCESS, AUTH_ERROR, INTERRUPT_BIOMETRICS]); + action = yield take([ + UserActionType.AUTH_SUCCESS, + UserActionType.AUTH_ERROR, + UserActionType.INTERRUPT_BIOMETRICS, + ]); if ( - action?.type === INTERRUPT_BIOMETRICS || + action?.type === UserActionType.INTERRUPT_BIOMETRICS || action?.payload?.bioStateMachineId === originalBioStateMachineId ) { shouldHandleAction = true; } } - if (action?.type === INTERRUPT_BIOMETRICS) { + if (action?.type === UserActionType.INTERRUPT_BIOMETRICS) { // Biometrics was most likely interrupted during authentication with a non-zero lock timer. yield fork(lockKeyringAndApp); - } else if (action?.type === AUTH_ERROR) { + } else if (action?.type === UserActionType.AUTH_ERROR) { // Authentication service will automatically log out. - } else if (action?.type === AUTH_SUCCESS) { + } else if (action?.type === UserActionType.AUTH_SUCCESS) { // Authentication successful. Navigate to wallet. NavigationService.navigation?.navigate(Routes.ONBOARDING.HOME_NAV); } @@ -124,8 +125,24 @@ export function* basicFunctionalityToggle() { } } +/** + * Handles initializing app services on start up + */ +export function* startAppServices() { + // Wait for persisted data to be loaded and navigation to be ready + yield all([ + take(UserActionType.ON_PERSISTED_DATA_LOADED), + take(NavigationActionType.ON_NAVIGATION_READY), + ]); + // Start services + EngineService.start(); + AppStateEventProcessor.start(); + // TODO: Track a property in redux to gate keep the app until services are initialized +} + // Main generator function that initializes other sagas in parallel. export function* rootSaga() { + yield fork(startAppServices); yield fork(authStateMachine); yield fork(basicFunctionalityToggle); } diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index c9830d7f8d4..70ffa35e276 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -1,12 +1,8 @@ import { Action } from 'redux'; import { take, fork, cancel } from 'redux-saga/effects'; +import { expectSaga } from 'redux-saga-test-plan'; import { - AUTH_ERROR, - AUTH_SUCCESS, - INTERRUPT_BIOMETRICS, - LOGIN, - LOCKED_APP, - LOGOUT, + UserActionType, authError, authSuccess, interruptBiometrics, @@ -17,10 +13,16 @@ import { authStateMachine, appLockStateMachine, lockKeyringAndApp, + startAppServices, } from './'; +import { NavigationActionType } from '../../actions/navigation'; +import EngineService from '../../core/EngineService'; +import { AppStateEventProcessor } from '../../core/AppStateEventListener'; const mockBioStateMachineId = '123'; + const mockNavigate = jest.fn(); + jest.mock('../../core/NavigationService', () => ({ navigation: { // TODO: Replace "any" with type @@ -31,6 +33,17 @@ jest.mock('../../core/NavigationService', () => ({ }, })); +// Mock the services +jest.mock('../../core/EngineService', () => ({ + start: jest.fn(), +})); + +jest.mock('../../core/AppStateEventListener', () => ({ + AppStateEventProcessor: { + start: jest.fn(), + }, +})); + describe('authStateMachine', () => { beforeEach(() => { mockNavigate.mockClear(); @@ -38,7 +51,7 @@ describe('authStateMachine', () => { it('should fork appLockStateMachine when logged in', async () => { const generator = authStateMachine(); - expect(generator.next().value).toEqual(take(LOGIN)); + expect(generator.next().value).toEqual(take(UserActionType.LOGIN)); expect(generator.next().value).toEqual(fork(appLockStateMachine)); }); @@ -48,7 +61,7 @@ describe('authStateMachine', () => { generator.next(); // Fork appLockStateMachine generator.next(); - expect(generator.next().value).toEqual(take(LOGOUT)); + expect(generator.next().value).toEqual(take(UserActionType.LOGOUT)); expect(generator.next().value).toEqual(cancel()); }); }); @@ -60,7 +73,7 @@ describe('appLockStateMachine', () => { it('should fork biometricsStateMachine when app is locked', async () => { const generator = appLockStateMachine(); - expect(generator.next().value).toEqual(take(LOCKED_APP)); + expect(generator.next().value).toEqual(take(UserActionType.LOCKED_APP)); // Fork biometrics listener. expect(generator.next().value).toEqual( fork(biometricsStateMachine, mockBioStateMachineId), @@ -90,7 +103,11 @@ describe('biometricsStateMachine', () => { const generator = biometricsStateMachine(mockBioStateMachineId); // Take next step expect(generator.next().value).toEqual( - take([AUTH_SUCCESS, AUTH_ERROR, INTERRUPT_BIOMETRICS]), + take([ + UserActionType.AUTH_SUCCESS, + UserActionType.AUTH_ERROR, + UserActionType.INTERRUPT_BIOMETRICS, + ]), ); // Dispatch interrupt biometrics const nextFork = generator.next(interruptBiometrics() as Action).value; @@ -127,3 +144,44 @@ describe('biometricsStateMachine', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); }); + +// TODO: Update all saga tests to use expectSaga (more intuitive and easier to read) +describe('startAppServices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start app services', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: UserActionType.ON_PERSISTED_DATA_LOADED }) + .dispatch({ type: NavigationActionType.ON_NAVIGATION_READY }) + .run(); + + // Verify services are started + expect(EngineService.start).toHaveBeenCalled(); + expect(AppStateEventProcessor.start).toHaveBeenCalled(); + }); + + it('should not start app services if navigation is not ready', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: UserActionType.ON_PERSISTED_DATA_LOADED }) + .run(); + + // Verify services are not started + expect(EngineService.start).not.toHaveBeenCalled(); + expect(AppStateEventProcessor.start).not.toHaveBeenCalled(); + }); + + it('should not start app services if persisted data is not loaded', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: NavigationActionType.ON_NAVIGATION_READY }) + .run(); + + // Verify services are not started + expect(EngineService.start).not.toHaveBeenCalled(); + expect(AppStateEventProcessor.start).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index 20ed3cd2a88..d7e1c2b64da 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -7,6 +7,7 @@ import { initialState as transactionMetrics } from '../../core/redux/slices/tran import { initialState as originThrottling } from '../../core/redux/slices/originThrottling'; import initialBackgroundState from './initial-background-state.json'; import { userInitialState } from '../../reducers/user'; +import { initialNavigationState } from '../../reducers/navigation'; import { initialState as initialStakingState } from '../../core/redux/slices/staking'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { initialState as initialMultichainSettingsState } from '../../reducers/multichain'; @@ -35,7 +36,7 @@ const initialRootState: RootState = { swaps: undefined, fiatOrders: initialFiatOrdersState, infuraAvailability: undefined, - navigation: undefined, + navigation: initialNavigationState, networkOnboarded: undefined, security: initialSecurityState, signatureRequest: undefined, diff --git a/babel.config.js b/babel.config.js index af4c36a0a77..8b3aa390673 100644 --- a/babel.config.js +++ b/babel.config.js @@ -23,6 +23,12 @@ module.exports = { test: './app/lib/snaps', plugins: [['babel-plugin-inline-import', { extensions: ['.html'] }]], }, + // TODO: Remove this once we have a fix for the private methods + // Do not apply this plugin globally since it breaks FlatList props.getItem + { + test: './app/core/redux/ReduxService.ts', + plugins: [['@babel/plugin-transform-private-methods', { loose: true }]], + }, ], env: { production: { diff --git a/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js b/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js index a586d49349a..98931feee82 100644 --- a/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js +++ b/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js @@ -18,6 +18,7 @@ import AddAccountBottomSheet from '../../../pages/wallet/AddAccountBottomSheet'; import AccountActionsBottomSheet from '../../../pages/wallet/AccountActionsBottomSheet'; import { mockIdentityServices } from '../utils/mocks'; import { SmokeIdentity } from '../../../tags'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe(SmokeIdentity('Account syncing'), () => { const NEW_ACCOUNT_NAME = 'My third account'; @@ -33,9 +34,13 @@ describe(SmokeIdentity('Account syncing'), () => { mockServer, ); - userStorageMockttpControllerInstance.setupPath('accounts', mockServer, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: accountsSyncMockResponse, + }, + ); decryptedAccountNames = await Promise.all( accountsSyncMockResponse.map(async (response) => { diff --git a/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js b/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js index 83fdd49c635..4930a9cf1be 100644 --- a/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js +++ b/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js @@ -16,6 +16,7 @@ import AccountListBottomSheet from '../../../pages/wallet/AccountListBottomSheet import Assertions from '../../../utils/Assertions'; import { mockIdentityServices } from '../utils/mocks'; import { SmokeIdentity } from '../../../tags'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe(SmokeIdentity('Account syncing'), () => { beforeAll(async () => { @@ -27,9 +28,13 @@ describe(SmokeIdentity('Account syncing'), () => { mockServer, ); - userStorageMockttpControllerInstance.setupPath('accounts', mockServer, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: accountsSyncMockResponse, + }, + ); jest.setTimeout(200000); await TestHelpers.reverseServerPort(); diff --git a/e2e/specs/identity/utils/mocks.js b/e2e/specs/identity/utils/mocks.js index a8509c988e0..f8203ffb69c 100644 --- a/e2e/specs/identity/utils/mocks.js +++ b/e2e/specs/identity/utils/mocks.js @@ -1,6 +1,7 @@ import { AuthenticationController } from '@metamask/profile-sync-controller'; import { UserStorageMockttpController } from './user-storage/userStorageMockttpController'; import { getDecodedProxiedURL } from './helpers'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; const AuthMocks = AuthenticationController.Mocks; @@ -20,8 +21,14 @@ export async function mockIdentityServices(server) { const userStorageMockttpControllerInstance = new UserStorageMockttpController(); - userStorageMockttpControllerInstance.setupPath('accounts', server); - userStorageMockttpControllerInstance.setupPath('networks', server); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.networks, + server, + ); return { userStorageMockttpControllerInstance, diff --git a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js index 9438e2a7fc7..8ffafe83d34 100644 --- a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js +++ b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js @@ -1,16 +1,25 @@ +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { determineIfFeatureEntryFromURL, getDecodedProxiedURL, } from '../helpers'; -// TODO: Export user storage schema from @metamask/profile-sync-controller +const baseUrl = + 'https://user-storage\\.api\\.cx\\.metamask\\.io\\/api\\/v1\\/userstorage'; + export const pathRegexps = { - accounts: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/accounts/u, - networks: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/networks/u, - notifications: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/notifications/u, + [USER_STORAGE_FEATURE_NAMES.accounts]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.networks]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.networks}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.notifications]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.notifications}`, + 'u', + ), }; export class UserStorageMockttpController { @@ -57,44 +66,68 @@ export class UserStorageMockttpController { const data = await request.body.getJson(); - const newOrUpdatedSingleOrBatchEntries = - isFeatureEntry && typeof data?.data === 'string' - ? [ - { - HashedKey: getDecodedProxiedURL(request.url).split('/').pop(), - Data: data?.data, - }, - ] - : Object.entries(data?.data).map(([key, value]) => ({ - HashedKey: key, - Data: value, - })); - - newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + // We're handling batch delete inside the PUT method due to API limitations + if (data?.batch_delete) { + const keysToDelete = data.batch_delete; + const internalPathData = this.paths.get(path); if (!internalPathData) { - return; + return { + statusCode, + }; } - const doesThisEntryExist = internalPathData.response?.find( - (existingEntry) => existingEntry.HashedKey === entry.HashedKey, - ); + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.filter( + (entry) => !keysToDelete.includes(entry.HashedKey), + ), + }); + } - if (doesThisEntryExist) { - this.paths.set(path, { - ...internalPathData, - response: internalPathData.response.map((existingEntry) => - existingEntry.HashedKey === entry.HashedKey ? entry : existingEntry, - ), - }); - } else { - this.paths.set(path, { - ...internalPathData, - response: [...(internalPathData?.response || []), entry], - }); - } - }); + if (data?.data) { + const newOrUpdatedSingleOrBatchEntries = + isFeatureEntry && typeof data?.data === 'string' + ? [ + { + HashedKey: getDecodedProxiedURL(request.url).split('/').pop(), + Data: data?.data, + }, + ] + : Object.entries(data?.data).map(([key, value]) => ({ + HashedKey: key, + Data: value, + })); + + newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + const internalPathData = this.paths.get(path); + + if (!internalPathData) { + return; + } + + const doesThisEntryExist = internalPathData.response?.find( + (existingEntry) => existingEntry.HashedKey === entry.HashedKey, + ); + + if (doesThisEntryExist) { + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.map((existingEntry) => + existingEntry.HashedKey === entry.HashedKey + ? entry + : existingEntry, + ), + }); + } else { + this.paths.set(path, { + ...internalPathData, + response: [...(internalPathData?.response || []), entry], + }); + } + }); + } return { statusCode, diff --git a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js index 5ea0f1603ca..dc7ee91cfa3 100644 --- a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js +++ b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js @@ -1,5 +1,6 @@ import { getLocal } from 'mockttp'; import { UserStorageMockttpController } from './userStorageMockttpController'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe('UserStorageMockttpController', () => { let mockServer; @@ -13,11 +14,17 @@ describe('UserStorageMockttpController', () => { it('handles GET requests that have empty response', async () => { const controller = new UserStorageMockttpController(); - await controller.setupPath('accounts', mockServer); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(null); }); @@ -37,13 +44,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -63,13 +77,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -89,13 +110,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, + }, + ); expect(request.json).toEqual(mockedData[0]); }); @@ -120,24 +148,34 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, - body: { - getJson: async () => ({ - data: mockedAddedData.Data, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, + body: { + getJson: async () => ({ + data: mockedAddedData.Data, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([...mockedData, mockedAddedData]); }); @@ -162,24 +200,34 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - body: { - getJson: async () => ({ - data: mockedUpdatedData.Data, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + body: { + getJson: async () => ({ + data: mockedUpdatedData.Data, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0], mockedUpdatedData]); }); @@ -211,29 +259,39 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); const putData = {}; mockedUpdatedData.forEach((entry) => { putData[entry.HashedKey] = entry.Data; }); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts`, - body: { - getJson: async () => ({ - data: putData, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + data: putData, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual(mockedUpdatedData); }); @@ -253,19 +311,29 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const deleteRequest = await controller.onDelete('accounts', { - url: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0]]); }); @@ -283,22 +351,76 @@ describe('UserStorageMockttpController', () => { 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', Data: 'data2', }, + { + HashedKey: + 'x236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data3', + }, ]; - await controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - url: `${baseUrl}/accounts`, - }); + const deleteRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + batch_delete: [mockedData[1].HashedKey, mockedData[2].HashedKey], + }), + }, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(getRequest.json).toEqual([mockedData[0]]); + }); + + it('handles entire feature DELETE requests', async () => { + const controller = new UserStorageMockttpController(); + const mockedData = [ + { + HashedKey: + '7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b', + Data: 'data1', + }, + { + HashedKey: + 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data2', + }, + ]; + + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { + getResponse: mockedData, }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(deleteRequest.statusCode).toEqual(204); + + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + expect(getRequest.json).toEqual(null); }); }); diff --git a/e2e/specs/notifications/utils/mocks.js b/e2e/specs/notifications/utils/mocks.js index dbf8e8b5e73..72db127ca3a 100644 --- a/e2e/specs/notifications/utils/mocks.js +++ b/e2e/specs/notifications/utils/mocks.js @@ -4,6 +4,7 @@ import { } from '@metamask/notification-services-controller'; import { UserStorageMockttpController } from '../../identity/utils/user-storage/userStorageMockttpController'; import { getDecodedProxiedURL } from './helpers'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; const NotificationMocks = NotificationServicesController.Mocks; const PushMocks = NotificationServicesPushController.Mocks; @@ -19,7 +20,10 @@ export async function mockNotificationServices(server) { const userStorageMockttpControllerInstance = new UserStorageMockttpController(); - userStorageMockttpControllerInstance.setupPath('notifications', server); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.notifications, + server, + ); // Notifications mockAPICall(server, NotificationMocks.getMockFeatureAnnouncementResponse()); diff --git a/locales/languages/en.json b/locales/languages/en.json index aa11c8578ab..b92a0d71dcf 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -470,7 +470,6 @@ "learn_more": "Learn more", "add_collectibles": "Import NFTs", "no_transactions": "You have no transactions!", - "switch_network_to_view_transactions": "Please switch network to view transactions", "send_button": "Send", "deposit_button": "Deposit", "copy_address": "Copy", diff --git a/package.json b/package.json index f27eefc20d4..36e526e6b4d 100644 --- a/package.json +++ b/package.json @@ -496,6 +496,7 @@ "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.2.0", "redux-flipper": "^2.0.3", + "redux-saga-test-plan": "^4.0.6", "regenerator-runtime": "0.13.9", "rn-nodeify": "10.3.0", "serve-handler": "^6.1.5", diff --git a/yarn.lock b/yarn.lock index aa0795670d9..f8e9220e36c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17874,6 +17874,11 @@ fsevents@^2.3.2, fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +fsm-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fsm-iterator/-/fsm-iterator-1.1.0.tgz#337de45de19eb205788cf02e3a955ec206760dec" + integrity sha512-hg47CNYdIGJ5m9WSKh617LHRdvJo4PiF0VkncFLwPVxKvBEQfSPd1qx/xLV/eSusewEu0C8eUFrsLsWlBgIcOg== + ftp-response-parser@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz#3b9d33f8edd5fb8e4700b8f778c462e5b1581f89" @@ -21185,6 +21190,11 @@ lodash.isequal@4.5.0, lodash.isequal@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== +lodash.ismatch@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" + integrity sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g== + lodash.isobject@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" @@ -25607,6 +25617,15 @@ redux-persist@6.0.0: resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== +redux-saga-test-plan@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/redux-saga-test-plan/-/redux-saga-test-plan-4.0.6.tgz#0e50a68f63083fbda4bb20cc087833d5b84ace77" + integrity sha512-ESdbFoDWCeJ/EiFdUNSCGtA2CC9tnuvHDm6k06gVFa98EIeR2hpzFkGk9kJ1/hpMUnYFp+OOEEITIrZeDYBfFg== + dependencies: + fsm-iterator "^1.1.0" + lodash.isequal "^4.5.0" + lodash.ismatch "^4.4.0" + redux-saga@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.3.0.tgz#a59ada7c28010189355356b99738c9fcb7ade30e"