diff --git a/README.md b/README.md index bfd4770..89cd7a7 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This library uses a unique **worklet bundle** to run intensive cryptographic ope - [Bundle Configuration](#bundle-configuration) - [Quick Start](#quick-start) - [Guide to Hooks](#guide-to-hooks) +- [Wallet Lifecycle](#wallet-lifecycle) - [Best Practices](#best-practices) - [Architecture](#architecture) - [Security](#security) @@ -103,37 +104,54 @@ Getting started involves three main steps: The library's functionality is exposed through a set of React hooks. They are designed to separate concerns, giving you specific tools for managing the app state, wallet lifecycle, and account interactions. ### `useWdkApp` -* **Design Rationale:** Provides a global, top-level view of the library's state. It's the single source of truth for whether the underlying engine is ready, allowing your app to react safely. -* **Standard Use Case:** Displaying a loading screen while the WDK initializes. +* **Design Rationale:** Provides a global, top-level view of the library's state via a single `state.status` value. It's the source of truth for which screen your app should render - loading, onboarding, unlock, or the main app. +* **Standard Use Case:** Routing your app's top-level UI off one status value instead of juggling several booleans. * **Snippet:** ```typescript import { useWdkApp } from '@tetherto/wdk-react-native-core'; - const { isReady, error } = useWdkApp(); - if (!isReady) { - // Show a loading or splash screen while the worklet starts + const { state } = useWdkApp(); + + switch (state.status) { + case 'INITIALIZING': + return ; + case 'NO_WALLET': + return ; + case 'LOCKED': + // state.walletId is a hint, present when a specific wallet is + // targeted; it's undefined when a wallet exists but none is + // active yet. Either way, show your own unlock flow. + return ; + case 'READY': + return ; + case 'ERROR': + return ; } ``` + See [Wallet Lifecycle](#wallet-lifecycle) for exactly what each status means and when it's reported. ### `useWalletManager` -* **Design Rationale:** Exclusively handles "heavy" lifecycle actions that affect the entire wallet (create, load, import). Separating these ensures they are used deliberately, typically during app startup or in a settings screen. -* **Standard Use Case:** Checking if a wallet exists on startup, creating or loading a wallet. +* **Design Rationale:** Exclusively handles the wallet's identity lifecycle - create, restore, unlock, lock, and switch. Identity is always caller-owned: the library never guesses which wallet to load or auto-unlocks on your behalf, so these actions are explicit and deliberate, typically triggered from app startup, a settings screen, or your own auth flow. +* **Standard Use Case:** Onboarding a new user, unlocking an existing wallet after your own auth check, or switching between accounts. * **Snippet:** ```typescript - import { useWdkApp, useWalletManager } from '@tetherto/wdk-react-native-core'; + import { useWalletManager } from '@tetherto/wdk-react-native-core'; - const { isReady } = useWdkApp(); - const { createWallet, loadWallet } = useWalletManager(); - - useEffect(() => { - if (isReady) { - const setup = async () => { - await createWallet(); - }; - setup(); - } - }, [isReady, hasWallet, createWallet, loadWallet]); + const { createWallet, unlock, lock, switchWallet } = useWalletManager(); + + // First-time user: create a wallet for a specific id (e.g. your app's user id) + await createWallet(userId); + + // Returning user: unlock after your own auth check (biometrics, passcode, etc.) + await unlock(userId); + + // Signing out + await lock(); + + // Switching accounts - atomically locks the previous wallet, then unlocks the next + await switchWallet(otherUserId); ``` + See [Wallet Lifecycle](#wallet-lifecycle) for the full set of rules around switching identities. ### `useAddresses` * **Design Rationale:** Decouples the loading and management of addresses from other account operations. This provides a focused way to get a list of addresses for the active wallet. @@ -183,6 +201,19 @@ The library's functionality is exposed through a set of React hooks. They are de const balanceString = data?.balance; // e.g., '1000000000000000000' ``` +## Wallet Lifecycle + +Identity is always **caller-owned** - the library never persists, guesses, or auto-unlocks a wallet for you. Only one wallet can be active at a time: `unlock`, `createWallet`, and `restoreWallet` all reject if a wallet is already active, so call `await lock()` first before switching to a different one (or use `switchWallet(walletId)`, which does both atomically). + +**Status meanings (`useWdkApp().state.status`):** + +- `INITIALIZING` - not enough information yet to say anything more specific. +- `NO_WALLET` - confirmed: no wallet exists. Safe to show onboarding. +- `LOCKED` - no wallet unlocked. `walletId` is a hint (present when a specific wallet is targeted, absent when one just isn't active yet) - show your own unlock flow either way. +- `READY` - a wallet is unlocked and ready. +- `ERROR` - something failed; inspect `state.error`. +- `REINITIALIZING` - the worklet is being manually reinitialized. + ## Best Practices See [Best Practices](docs/best-practices.md) for important patterns and recommendations for building robust apps. diff --git a/docs/architecture.md b/docs/architecture.md index 51ca64f..4fdcc83 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,13 +3,13 @@ ``` ┌─────────────────────────────────────┐ │ App Layer (Hooks) │ -│ useWallet, useBalance, useWdkApp │ +│ useWalletManager, useBalance, useWdkApp │ └──────────────┬──────────────────────┘ │ ┌──────────────▼──────────────────────┐ │ Provider Layer │ │ WdkAppProvider │ -│ (Consolidated state sync effect) │ +│ (worklet bootstrap + status derivation) │ └──────────────┬──────────────────────┘ │ ┌──────────────▼──────────────────────┐ @@ -19,7 +19,6 @@ │ AccountService │ │ BalanceService │ │ WalletSetupService │ -│ WalletSwitchingService │ └──────────────┬──────────────────────┘ │ ┌──────────────▼──────────────────────┐ @@ -39,7 +38,7 @@ ### State Synchronization -The `WdkAppProvider` uses a **consolidated effect** for wallet state synchronization to prevent race conditions. Multiple interdependent state changes (activeWalletId, addresses, loadingState, errors) must be evaluated atomically in a single effect. See [WALLET_STATE_MACHINE.md](src/store/WALLET_STATE_MACHINE.md) for detailed state machine documentation. +Identity is caller-owned: `WdkAppProvider` does not auto-create, auto-unlock, or persist which wallet is active. All wallet identity mutations (create, restore, unlock, lock, switch) go through `useWalletManager`, and each of them is serialized behind a single shared operation mutex to prevent race conditions between concurrent calls. `useWdkApp`'s top-level `state.status` is a pure derivation from the underlying store - it has no side effects of its own. See the [Wallet Lifecycle](../README.md#wallet-lifecycle) section of the README for the full rules. ### Key Services diff --git a/docs/quick-start.md b/docs/quick-start.md index e2c04e3..32d3644 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -62,32 +62,37 @@ function App() { // Component that uses the wallet hooks function MyWalletComponent() { - const { isReady } = useWdkApp(); - const { createWallet, loadWallet, hasWallet } = useWalletManager(); + const { state } = useWdkApp(); + const { createWallet, unlock } = useWalletManager(); const { addresses, loadAddresses } = useAddresses(); + // Identity is caller-owned: the library never picks a wallet id for you. + // Replace this with your own app's user/session id. + const userId = 'demo-user'; + useEffect(() => { - if (isReady) { - const setupWallet = async () => { - const walletExists = await hasWallet(); - if (walletExists) { - await loadWallet(); - } else { - await createWallet(); - } - // After wallet is ready, load addresses for account 0 - loadAddresses([0]); - }; - setupWallet(); - } - }, [isReady, hasWallet, createWallet, loadWallet, loadAddresses]); + const setupWallet = async () => { + if (state.status === 'NO_WALLET') { + await createWallet(userId); + } else if (state.status === 'LOCKED') { + // In a real app, gate this behind your own auth check + // (biometrics, passcode, etc.) before calling unlock. + await unlock(userId); + } else { + return; + } + // After the wallet is ready, load addresses for account 0 + loadAddresses([0]); + }; + setupWallet(); + }, [state.status, createWallet, unlock, loadAddresses]); // Find the first Ethereum address from the loaded addresses const ethAddress = addresses.find(a => a.network === 'ethereum')?.address; return ( - App Status: {isReady ? 'Ready' : 'Initializing...'} + App Status: {state.status} {ethAddress ? ( Your ETH Address: {ethAddress} ) : ( diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e5b12c2..0346cdd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,7 +7,7 @@ **Solutions**: 1. Check that `wdkConfigs` are valid (use `validateWdkConfigs()`) 2. Check console logs for detailed error messages -3. Try calling `retry()` method from context +3. Re-run whichever `useWalletManager` call failed (`unlock`/`createWallet`/`restoreWallet`) - there is no `retry()` method; the library never retries lifecycle actions on your behalf **Common Errors**: - "WDK not initialized" → Worklet failed to start, check network configs diff --git a/src/hooks/internal/useAppLifecycle.ts b/src/hooks/internal/useAppLifecycle.ts deleted file mode 100644 index 416dd1d..0000000 --- a/src/hooks/internal/useAppLifecycle.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2026 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { useEffect } from 'react' -import { AppState, type AppStateStatus } from 'react-native' - -import { getWalletStore } from '../../store/walletStore' -import { updateWalletLoadingState } from '../../store/walletStore' -import { log } from '../../utils/logger' - -export interface UseAppLifecycleProps { - clearSensitiveDataOnBackground: boolean -} - -export function useAppLifecycle({ - clearSensitiveDataOnBackground, -}: UseAppLifecycleProps): void { - useEffect(() => { - // Skip if not explicitly enabled - if (!clearSensitiveDataOnBackground) { - return - } - - // CRITICAL: Clear cache on mount to handle true app restarts (not hot reloads) - log('[useAppLifecycle] Clearing credentials cache on mount (app restart)') - - const appStateRef = { current: AppState.currentState } - - const subscription = AppState.addEventListener( - 'change', - (nextAppState: AppStateStatus) => { - const previousState = appStateRef.current - appStateRef.current = nextAppState - - // When going to background: clear cache and mark wallet for re-authentication - if ( - (nextAppState === 'background' || nextAppState === 'inactive') && - previousState === 'active' - ) { - log( - '[useAppLifecycle] App going to background - clearing sensitive data and marking for re-auth', - ) - - // Reset wallet state to trigger re-authentication on foreground - const walletStore = getWalletStore() - const currentState = walletStore.getState() - const currentStateType = currentState.walletLoadingState.type - - if (currentStateType === 'ready' && currentState.activeWalletId) { - log( - '[useAppLifecycle] Resetting wallet state to trigger biometrics on foreground', - ) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'not_loaded', - }), - ) - } else if ( - currentStateType === 'loading' || - currentStateType === 'checking' - ) { - log( - '[useAppLifecycle] Preserving wallet loading state during background transition', - { - currentState: currentStateType, - }, - ) - // Do not reset - allow biometric authentication to complete - } - } - - // When coming to foreground: wallet will auto-initialize with biometrics - if ( - nextAppState === 'active' && - (previousState === 'background' || previousState === 'inactive') - ) { - log( - '[useAppLifecycle] App coming to foreground - auto-initialization will trigger biometrics', - ) - } - }, - ) - - return () => subscription.remove() - }, [clearSensitiveDataOnBackground]) -} diff --git a/src/hooks/internal/useWalletOrchestrator.ts b/src/hooks/internal/useWalletOrchestrator.ts index 0c1008c..8072a4b 100644 --- a/src/hooks/internal/useWalletOrchestrator.ts +++ b/src/hooks/internal/useWalletOrchestrator.ts @@ -12,53 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' import { - getWalletIdFromLoadingState, getWalletStore, - isWalletErrorState, - isWalletLoadingState, - updateWalletLoadingState, - WalletLoadingState, type WalletStore, } from '../../store/walletStore' -import { WalletSetupService } from '../../services/walletSetupService' -import { useWalletManager } from '../useWalletManager' -import { - getWalletSwitchDecision, - shouldMarkWalletAsReady, - shouldResetToNotLoaded, -} from '../../utils/walletStateHelpers' -import { log, logError } from '../../utils/logger' import type { WdkAppState } from '../../provider/WdkAppProvider' -// Custom deep equality for walletLoadingState comparison -const deepEqualityFn = (a: WalletLoadingState, b: WalletLoadingState): boolean => { - if (a === b) return true - - if (a.type !== b.type) return false - - switch (a.type) { - case 'not_loaded': - return true - case 'checking': - return a.identifier === (b as typeof a).identifier - case 'loading': - return a.identifier === (b as typeof a).identifier && - a.walletExists === (b as typeof a).walletExists - case 'ready': - return a.identifier === (b as typeof a).identifier - case 'error': - return a.identifier === (b as typeof a).identifier && - a.error?.message === (b as typeof a).error?.message - default: - return false - } -} - export interface UseWalletOrchestratorProps { - enableAutoInitialization: boolean - currentUserId?: string | null isWorkletStarted: boolean isWorkletInitialized: boolean isWdkReinitialized: boolean @@ -66,8 +28,6 @@ export interface UseWalletOrchestratorProps { } export function useWalletOrchestrator({ - enableAutoInitialization, - currentUserId, isWorkletStarted, isWorkletInitialized, isWdkReinitialized: isWorkletReinitialized, @@ -75,279 +35,14 @@ export function useWalletOrchestrator({ }: UseWalletOrchestratorProps) { const walletStore = getWalletStore() - const activeWalletId = walletStore( - (state: WalletStore) => state.activeWalletId, - ) - - // For walletLoadingState, use a ref to manually check equality and prevent unnecessary re-renders - const walletLoadingStateRef = useRef( - walletStore.getState().walletLoadingState, - ) - const [walletLoadingState, setWalletLoadingState] = useState( - walletStore.getState().walletLoadingState, + const { activeWalletId, walletLoadingState, wallets } = walletStore( + useShallow((state: WalletStore) => ({ + activeWalletId: state.activeWalletId, + walletLoadingState: state.walletLoadingState, + wallets: state.walletList, + })), ) - useEffect(() => { - const unsubscribe = walletStore.subscribe((state: WalletStore) => { - const newState = state.walletLoadingState - // Only update if content actually changed (deep equality check) - if (!deepEqualityFn(walletLoadingStateRef.current, newState)) { - walletLoadingStateRef.current = newState - setWalletLoadingState(newState) - } - }) - return unsubscribe - }, [walletStore]) - - const walletAddresses = walletStore((state: WalletStore) => - state.activeWalletId ? state.addresses[state.activeWalletId] : undefined, - ) - - const { createWallet, unlock } = useWalletManager() - - // Track authentication errors to prevent infinite retry loops - // When biometric authentication fails, we shouldn't automatically retry - const authErrorRef = useRef(null) - - // Derive isWalletInitializing from walletLoadingState (single source of truth) - const isWalletInitializing = useMemo(() => { - return isWalletLoadingState(walletLoadingState) - }, [walletLoadingState]) - - // Consolidated effect: Sync wallet loading state with activeWalletId, addresses, and errors - useEffect(() => { - // EARLY EXIT: Skip automatic wallet initialization if disabled (e.g., when logged out) - if (!enableAutoInitialization) { - // Clear authentication error flag when auto-init is disabled (e.g., logout) - if (authErrorRef.current) { - log( - '[useWalletOrchestrator] Clearing authentication error flag - auto-init disabled', - ) - authErrorRef.current = null - } - return - } - - if (currentUserId === undefined || currentUserId === null) { - log( - '[useWalletOrchestrator] Waiting for user identity confirmation before auto-init', - { - hasActiveWalletId: !!activeWalletId, - }, - ) - return - } - - if (activeWalletId !== currentUserId) { - log('[useWalletOrchestrator] Setting activeWalletId to current user', { - activeWalletId, - currentUserId, - }) - - walletStore.setState({ - activeWalletId: currentUserId, - }) - - if (authErrorRef.current) { - authErrorRef.current = null - } - - return - } - - // EARLY EXIT: Skip if we have an authentication error to prevent infinite retry loop - const loadingStateError = - walletLoadingState.type === 'error' && walletLoadingState.error?.message - if (loadingStateError && authErrorRef.current === loadingStateError) { - log( - '[useWalletOrchestrator] Skipping auto-initialization due to persistent authentication error', - { - error: authErrorRef.current, - }, - ) - return - } - - const currentWalletId = getWalletIdFromLoadingState(walletLoadingState) - const hasAddresses = !!( - walletAddresses && Object.keys(walletAddresses).length > 0 - ) - - // Handle activeWalletId cleared - if (shouldResetToNotLoaded(activeWalletId, walletLoadingState)) { - log( - '[useWalletOrchestrator] Active wallet cleared, resetting wallet state', - ) - if (authErrorRef.current) { - log( - '[useWalletOrchestrator] Clearing authentication error flag on wallet reset', - ) - authErrorRef.current = null - } - walletStore.setState((prev) => - updateWalletLoadingState(prev, { type: 'not_loaded' }), - ) - return - } - - if (!activeWalletId) { - return - } - - const initialize = async (walletId: string, isSwitching: boolean) => { - try { - const walletExists = await WalletSetupService.hasWallet(walletId) - const shouldCreateNew = !walletExists - - log( - `[useWalletOrchestrator] Wallet initialization check for ${ - isSwitching ? 'switch' : 'load' - }`, - { - activeWalletId: walletId, - walletExists, - shouldCreateNew, - }, - ) - - if (shouldCreateNew) { - await createWallet(walletId) - } else { - await unlock(walletId) - } - - log( - `[useWalletOrchestrator] Wallet initialization call completed for ${walletId}`, - ) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const isAuthError = - errorMessage.includes('authentication') || - errorMessage.includes('biometric') || - errorMessage.includes('user cancel') - - if (isAuthError && authErrorRef.current !== errorMessage) { - log( - '[useWalletOrchestrator] Authentication error detected - preventing auto-retry', - { error: errorMessage }, - ) - authErrorRef.current = errorMessage - } - - logError( - `[useWalletOrchestrator] Failed to initialize wallet for ${ - isSwitching ? 'switch' : 'load' - }:`, - error, - ) - } - } - - const switchDecision = getWalletSwitchDecision( - currentWalletId, - activeWalletId, - hasAddresses, - ) - if (switchDecision.shouldSwitch) { - log('[useWalletOrchestrator] Active wallet changed', { - from: currentWalletId, - to: activeWalletId, - hasAddresses, - isWorkletStarted, - }) - - if (isWorkletStarted) { - if (isWalletInitializing) { - log( - '[useWalletOrchestrator] Skipping wallet switch initialization - already in progress', - { activeWalletId, walletLoadingState: walletLoadingState.type }, - ) - return - } - initialize(activeWalletId, true) - } else { - walletStore.setState((prev) => - updateWalletLoadingState(prev, { type: 'not_loaded' }), - ) - } - return - } - - const needsInitialization = - walletLoadingState.type === 'not_loaded' && - activeWalletId && - isWorkletStarted - - if (needsInitialization) { - if (isWalletInitializing) { - log( - '[useWalletOrchestrator] Skipping wallet initialization - already in progress', - { activeWalletId, walletLoadingState: walletLoadingState.type }, - ) - return - } - - log( - '[useWalletOrchestrator] Wallet needs initialization - starting process', - { - activeWalletId, - hasAddresses, - isWorkletStarted, - isWorkletInitialized, - walletLoadingState: walletLoadingState.type, - }, - ) - initialize(activeWalletId, false) - return - } - - if ( - shouldMarkWalletAsReady( - walletLoadingState, - hasAddresses, - currentWalletId, - activeWalletId, - isWorkletInitialized, - ) - ) { - log('[useWalletOrchestrator] Wallet ready', { activeWalletId }) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'ready', - identifier: activeWalletId, - }), - ) - return - } - }, [ - enableAutoInitialization, - currentUserId, - activeWalletId, - walletLoadingState, - walletAddresses, - isWalletInitializing, - isWorkletStarted, - isWorkletInitialized, - createWallet, - unlock, - walletStore - ]) - - const retry = useCallback(() => { - log('[useWalletOrchestrator] Retrying initialization...') - if (authErrorRef.current) { - log( - '[useWalletOrchestrator] Clearing authentication error flag for retry', - ) - authErrorRef.current = null - } - if (isWalletErrorState(walletLoadingState)) { - walletStore.setState((prev) => - updateWalletLoadingState(prev, { type: 'not_loaded' }), - ) - } - }, [walletLoadingState, walletStore]) - const state = useMemo((): WdkAppState => { const walletError = walletLoadingState.type === 'error' ? walletLoadingState.error : null @@ -357,7 +52,12 @@ export function useWalletOrchestrator({ return { status: 'ERROR', error: topLevelError } } - if (isWorkletInitialized && activeWalletId) { + if ( + isWorkletInitialized && + activeWalletId && + walletLoadingState.type === 'ready' && + walletLoadingState.identifier === activeWalletId + ) { return { status: 'READY', walletId: activeWalletId } } @@ -365,14 +65,16 @@ export function useWalletOrchestrator({ return { status: 'REINITIALIZING'} } - if (isWorkletStarted && !activeWalletId) { - return { status: 'NO_WALLET' } - } - if (isWorkletStarted && activeWalletId) { return { status: 'LOCKED', walletId: activeWalletId } } + if (isWorkletStarted && !activeWalletId) { + return wallets.length > 0 + ? { status: 'LOCKED' } + : { status: 'NO_WALLET' } + } + return { status: 'INITIALIZING' } }, [ workletError, @@ -380,11 +82,11 @@ export function useWalletOrchestrator({ isWorkletInitialized, isWorkletStarted, activeWalletId, - isWorkletReinitialized + isWorkletReinitialized, + wallets, ]) return { state, - retry, } } diff --git a/src/hooks/useWalletManager.ts b/src/hooks/useWalletManager.ts index 8bdd462..d219237 100644 --- a/src/hooks/useWalletManager.ts +++ b/src/hooks/useWalletManager.ts @@ -37,16 +37,21 @@ export interface UseWalletManagerResult { /** The current state of the active wallet. */ status: 'LOCKED' | 'UNLOCKED' | 'NO_WALLET' | 'LOADING' | 'ERROR' - /** Set the global active wallet (loads the seed). */ - setActiveWalletId: (walletId: string) => void - /** List of backing Wallets (Seeds) managed by the device. */ wallets: WalletInfo[] - /** Create a new Wallet (Seed). */ + /** + * Create a new Wallet (Seed). + * The app is responsible for any biometric or other security check before + * calling this - the library does not enforce one. + */ createWallet: (walletId: string) => Promise - /** Restore a Wallet from Seed Phrase. Returns the new walletId. */ + /** + * Restore a Wallet from Seed Phrase. Returns the new walletId. + * The app is responsible for any biometric or other security check before + * calling this - the library does not enforce one. + */ restoreWallet: (mnemonic: string, walletId: string) => Promise /** Generate a mnemonic phrase. */ @@ -58,15 +63,29 @@ export interface UseWalletManagerResult { /** * Locks the wallet. * This clears all sensitive data from memory and stops the worklet. + * Shares the same operation mutex as unlock/switchWallet/createWallet/restoreWallet, + * so it rejects rather than silently racing if one of those is already in flight. + */ + lock: () => Promise + + /** + * Unlocks a wallet by decrypting and loading it. + * The app is responsible for any biometric or other security check before + * calling this - the library does not enforce one. + * Resolves without doing anything if this exact wallet is already ready. + * Throws if a *different* wallet is already active - call lock() first. + * @param walletId - The wallet to unlock. Callers own identity - there is no implicit fallback. */ - lock: () => void + unlock: (walletId: string) => Promise /** - * Unlocks the currently active wallet. - * This typically triggers a biometric prompt to decrypt and load the wallet. - * @param walletId - Optional walletId to switch to before unlocking + * Switches to a different wallet. + * Equivalent to calling lock() followed by unlock(walletId), performed as a single + * atomic operation so the previous wallet's data is always cleared before the new + * one is loaded. The app is responsible for any biometric or other security check + * before calling this - the library does not enforce one. */ - unlock: (walletId?: string) => Promise + switchWallet: (walletId: string) => Promise /** Clear the wallet cache. */ clearCache: () => void @@ -85,7 +104,11 @@ export interface UseWalletManagerResult { */ clearTemporaryWallet: () => void - /** Get mnemonic phrase from wallet (requires biometric auth). */ + /** + * Get mnemonic phrase from wallet. + * The app is responsible for any biometric or other security check before + * calling this - the library does not enforce one. + */ getMnemonic: (walletId: string) => Promise /** Get encryption key from cache or secure storage. */ @@ -148,7 +171,7 @@ export function useWalletManager(): UseWalletManagerResult { } if (!activeWalletId) { - return 'NO_WALLET' + return wallets.length > 0 ? 'LOCKED' : 'NO_WALLET' } if (isWdkInitialized) { @@ -156,18 +179,13 @@ export function useWalletManager(): UseWalletManagerResult { } return 'LOCKED' - }, [activeWalletId, walletLoadingState.type, isWdkInitialized]) - - const setActiveWalletId = useCallback((walletId: string) => { - const walletStore = getWalletStore() - walletStore.setState({ activeWalletId: walletId }) - }, []) + }, [activeWalletId, walletLoadingState.type, isWdkInitialized, wallets]) /** - * Clear active wallet ID - * Useful when switching users or logging out to prevent auto-initialization with wrong wallet + * Signs out of the active wallet: clears activeWalletId, resets the worklet, and + * drops back to not_loaded. Callers are responsible for tracking which wallet to target on the next unlock. */ - const lock = useCallback(() => { + const performLock = useCallback(() => { if (walletStore.getState().activeWalletId) { WorkletLifecycleService.reset() walletStore.setState({ @@ -178,6 +196,11 @@ export function useWalletManager(): UseWalletManagerResult { } }, [walletStore]) + const lock = useCallback( + () => withOperationMutex('lock', async () => performLock()), + [performLock], + ) + const clearTemporaryWallet = useCallback(() => { const { tempWalletId, activeWalletId } = walletStore.getState() @@ -186,7 +209,7 @@ export function useWalletManager(): UseWalletManagerResult { } if (activeWalletId === tempWalletId) { - lock() + performLock() } walletStore.setState( @@ -199,64 +222,77 @@ export function useWalletManager(): UseWalletManagerResult { ) log('[useWalletManager] Cleared temporary wallet session') - }, [lock, walletStore]) + }, [performLock, walletStore]) - const unlock = useCallback( - (walletId?: string) => - withOperationMutex('unlock', async () => { - if (walletStore.getState().walletLoadingState.type === 'ready') { - log('[useWalletManager] Skipping unlock, wallet is already ready.') + const performUnlock = useCallback( + async (walletId: string) => { + const { walletLoadingState: currentLoadingState, activeWalletId } = + walletStore.getState() + if (currentLoadingState.type === 'ready') { + if (currentLoadingState.identifier === walletId && activeWalletId === walletId) { + log('[useWalletManager] Skipping unlock: this wallet is already ready.', { + walletId, + }) return } - await WorkletLifecycleService.ensureWorkletStarted() + throw new Error( + 'A wallet is already active. Call lock() before unlocking a different wallet.', + ) + } - if (walletId) { - clearTemporaryWallet() - walletStore.setState({ activeWalletId: walletId }) - } + await WorkletLifecycleService.ensureWorkletStarted() - const targetWalletId = walletStore.getState().activeWalletId + clearTemporaryWallet() + walletStore.setState({ activeWalletId: walletId }) - if (!targetWalletId) { - log('[useWalletManager] No wallet is selected', { targetWalletId }) + try { + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'loading', + identifier: walletId, + walletExists: true, + }), + ) - return - } + await WalletSetupService.initializeWallet({ + walletId, + }) - try { - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'loading', - identifier: targetWalletId, - walletExists: true, - }), - ) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'ready', + identifier: walletId, + }), + ) + } catch (err) { + logError('Failed to unlock wallet:', err) + const errorMessage = err instanceof Error ? err.message : String(err) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'error', + identifier: walletId, + error: new Error(errorMessage), + }), + ) + throw err + } + }, + [walletStore, clearTemporaryWallet], + ) - await WalletSetupService.initializeWallet({ - walletId: targetWalletId, - }) + const unlock = useCallback( + (walletId: string) => withOperationMutex('unlock', () => performUnlock(walletId)), + [performUnlock], + ) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'ready', - identifier: targetWalletId, - }), - ) - } catch (err) { - logError('Failed to unlock wallet:', err) - const errorMessage = err instanceof Error ? err.message : String(err) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'error', - identifier: targetWalletId, - error: new Error(errorMessage), - }), - ) - throw err - } + const switchWallet = useCallback( + (walletId: string) => + withOperationMutex('switchWallet', async () => { + performLock() + await performUnlock(walletId) }), - [walletStore, clearTemporaryWallet], + [performLock, performUnlock], ) const checkWallet = useCallback( @@ -305,54 +341,59 @@ export function useWalletManager(): UseWalletManagerResult { ) const restoreWallet = useCallback( - async (mnemonic: string, walletId: string): Promise => { - clearTemporaryWallet() + (mnemonic: string, walletId: string): Promise => + withOperationMutex('restoreWallet', async () => { + if (walletStore.getState().walletLoadingState.type === 'ready') { + throw new Error( + 'A wallet is already active. Call lock() before restoring a new wallet.', + ) + } - const exists = await WalletSetupService.hasWallet(walletId) + clearTemporaryWallet() - if (exists) { - throw new Error(`A wallet with the ID "${walletId}" already exists.`) - } + const exists = await WalletSetupService.hasWallet(walletId) - try { - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'loading', - identifier: walletId, - walletExists: false, - }), - ) + if (exists) { + throw new Error(`A wallet with the ID "${walletId}" already exists.`) + } - // Call the service to perform the actual crypto and storage - await WalletSetupService.initializeFromMnemonic(mnemonic, walletId) + try { + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'loading', + identifier: walletId, + walletExists: false, + }), + ) - // Refresh the main wallet list so the UI updates - await refreshWalletList([walletId]) + await WalletSetupService.initializeFromMnemonic(mnemonic, walletId) - walletStore.setState({ activeWalletId: walletId }) + // Refresh the main wallet list so the UI updates + await refreshWalletList([walletId]) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'ready', - identifier: walletId, - }), - ) + walletStore.setState({ activeWalletId: walletId }) - // Return the new wallet's ID as promised by the spec - return walletId - } catch (err) { - logError('Failed to restore wallet:', err) - const errorMessage = err instanceof Error ? err.message : String(err) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'error', - identifier: walletId, - error: new Error(errorMessage), - }), - ) - throw err - } - }, + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'ready', + identifier: walletId, + }), + ) + + return walletId + } catch (err) { + logError('Failed to restore wallet:', err) + const errorMessage = err instanceof Error ? err.message : String(err) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'error', + identifier: walletId, + error: new Error(errorMessage), + }), + ) + throw err + } + }), [refreshWalletList, walletStore, clearTemporaryWallet], ) @@ -397,8 +438,9 @@ export function useWalletManager(): UseWalletManagerResult { ) /** - * Get mnemonic phrase from wallet - * Requires biometric authentication if credentials are not cached + * Get mnemonic phrase from wallet. + * The app is responsible for any biometric or other security check before + * calling this - the library does not enforce one. */ const getMnemonic = useCallback( async (walletId: string): Promise => { @@ -413,8 +455,9 @@ export function useWalletManager(): UseWalletManagerResult { ) /** - * Get encryption key from cache or secure storage - * Requires biometric authentication if not cached + * Get encryption key from cache or secure storage. + * The app is responsible for any biometric or other security check before + * calling this - the library does not enforce one. * * @param walletId - Optional walletId override (defaults to hook's walletId) * @returns Promise resolving to encryption key or null if not found @@ -542,10 +585,17 @@ export function useWalletManager(): UseWalletManagerResult { throw new Error('A valid walletId is required for createTemporaryWallet.') } - try { - const tempWalletId = walletId - clearTemporaryWallet() + const tempWalletId = walletId + clearTemporaryWallet() + const { walletLoadingState, activeWalletId } = walletStore.getState() + if (walletLoadingState.type === 'ready' && activeWalletId !== tempWalletId) { + throw new Error( + 'A wallet is already active. Call lock() before creating a temporary wallet.', + ) + } + + try { await WorkletLifecycleService.ensureWorkletStarted() let encryptionKey: string @@ -581,11 +631,26 @@ export function useWalletManager(): UseWalletManagerResult { }), ) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'loading', + identifier: tempWalletId, + walletExists: true, + }), + ) + await WorkletLifecycleService.initializeWDK({ encryptionKey, encryptedSeed, }) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'ready', + identifier: tempWalletId, + }), + ) + log( '[useWalletManager] Temporary wallet created and set as active', ) @@ -595,6 +660,14 @@ export function useWalletManager(): UseWalletManagerResult { '[useWalletManager] Failed to create temporary wallet:', err, ) + const errorMessage = err instanceof Error ? err.message : String(err) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'error', + identifier: walletId, + error: new Error(errorMessage), + }), + ) throw err } }) @@ -606,58 +679,64 @@ export function useWalletManager(): UseWalletManagerResult { * Create a new wallet and add it to the wallet list */ const createWallet = useCallback( - async (walletId: string) => { - clearTemporaryWallet() - await WorkletLifecycleService.ensureWorkletStarted() - - try { - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'loading', - identifier: walletId, - walletExists: false, - }), - ) - - const exists = await checkWallet(walletId) - if (exists) { - throw new Error(`Wallet with walletId "${walletId}" already exists`) + (walletId: string) => + withOperationMutex('createWallet', async () => { + if (walletStore.getState().walletLoadingState.type === 'ready') { + throw new Error( + 'A wallet is already active. Call lock() before creating a new wallet.', + ) } - await WalletSetupService.createNewWallet(walletId) + clearTemporaryWallet() + await WorkletLifecycleService.ensureWorkletStarted() - walletStore.setState((prev) => - produce(prev, (state) => { - state.walletList.push({ + try { + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'loading', identifier: walletId, - exists: true, - }) - // Set as active wallet so WdkAppProvider can auto-initialize on restart - state.activeWalletId = walletId - }), - ) + walletExists: false, + }), + ) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'ready', - identifier: walletId, - }), - ) + const exists = await checkWallet(walletId) + if (exists) { + throw new Error(`Wallet with walletId "${walletId}" already exists`) + } - log(`Created new wallet: ${walletId} and set as active`) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - logError('Failed to create wallet:', err) - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'error', - identifier: walletId, - error: new Error(errorMessage), - }), - ) - throw err - } - }, + await WalletSetupService.createNewWallet(walletId) + + walletStore.setState((prev) => + produce(prev, (state) => { + state.walletList.push({ + identifier: walletId, + exists: true, + }) + state.activeWalletId = walletId + }), + ) + + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'ready', + identifier: walletId, + }), + ) + + log(`Created new wallet: ${walletId} and set as active`) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + logError('Failed to create wallet:', err) + walletStore.setState((prev) => + updateWalletLoadingState(prev, { + type: 'error', + identifier: walletId, + error: new Error(errorMessage), + }), + ) + throw err + } + }), [checkWallet, walletStore, clearTemporaryWallet], ) @@ -679,7 +758,7 @@ export function useWalletManager(): UseWalletManagerResult { // Session Management unlock, lock, - setActiveWalletId, + switchWallet, clearCache, // Wallet Management @@ -700,7 +779,7 @@ export function useWalletManager(): UseWalletManagerResult { [ unlock, lock, - setActiveWalletId, + switchWallet, clearCache, createWallet, createTemporaryWallet, diff --git a/src/hooks/useWdkApp.ts b/src/hooks/useWdkApp.ts index f9c9b1d..41afa8a 100644 --- a/src/hooks/useWdkApp.ts +++ b/src/hooks/useWdkApp.ts @@ -19,18 +19,44 @@ * It provides a simple state object to determine if the WDK is ready, loading, locked, etc. * Must be used within WdkAppProvider. * + * ## Status meanings + * + * - `INITIALIZING` - The worklet hasn't started yet, or there isn't enough + * information yet to report anything more specific (e.g. the worklet has + * started but no identity or wallets are known - nothing has been + * created/restored/unlocked this session, and nothing has told the SDK + * who the user is). + * - `REINITIALIZING` - The worklet is being manually reinitialized via + * `reinitializeWdk()`. + * - `NO_WALLET` - No wallet exists at all - confirmed empty, so this is a + * genuinely fresh device/user. Safe to route to onboarding + * (create/restore). + * - `LOCKED` - No wallet is currently unlocked. `walletId` is present when a + * specific wallet is targeted (e.g. `unlock()`/`switchWallet()` was called + * and is still mid-decrypt) and absent when a wallet is only known to + * exist but none is currently targeted (e.g. right after `lock()`). + * Either way, the correct action is the same: show your own unlock flow - + * treat `walletId` as an optional hint for labeling that flow, not as a + * signal to show a different screen. + * - `READY` - A wallet is fully unlocked and its identity has been confirmed + * to match what's actually loaded. Safe to render the main app. + * - `ERROR` - Something failed - inspect `error` for details. Can originate + * from either the worklet layer or a wallet operation (create/unlock/etc). + * * @example * ```tsx * import { useWdkApp } from '@tetherto/wdk-react-native-core' - * + * * function App() { - * const { state, retry } = useWdkApp() + * const { state } = useWdkApp() * * switch (state.status) { * case 'INITIALIZING': * return * * case 'LOCKED': + * // walletId is an optional hint, not a different flow - fall back to + * // whatever wallet your own session logic already knows about. * return * * case 'NO_WALLET': @@ -40,7 +66,7 @@ * return * * case 'ERROR': - * return + * return * * default: * return diff --git a/src/provider/WdkAppProvider.tsx b/src/provider/WdkAppProvider.tsx index 6a26ecf..923da6d 100644 --- a/src/provider/WdkAppProvider.tsx +++ b/src/provider/WdkAppProvider.tsx @@ -16,7 +16,6 @@ import React, { createContext, useMemo, useRef, useEffect } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createSecureStorage } from '@tetherto/wdk-react-native-secure-storage' -import { useAppLifecycle } from '../hooks/internal/useAppLifecycle' import { useWalletOrchestrator } from '../hooks/internal/useWalletOrchestrator' import { useWorkletInitializer } from '../hooks/internal/useWorkletInitializer' @@ -31,16 +30,33 @@ import { import type { WdkConfigs, BundleConfig } from '../types' export type WdkAppState = + /** The worklet hasn't started yet, or there isn't enough information yet + * to report anything more specific (e.g. the worklet has started but no + * identity or wallets are known - nothing has been created/restored/ + * unlocked this session, and nothing has told the SDK who the user is). */ | { status: 'INITIALIZING' } + /** The worklet is being manually reinitialized via reinitializeWdk(). */ | { status: 'REINITIALIZING' } + /** No wallet exists at all - walletList is confirmed empty, so this is a + * genuinely fresh device/user. Safe to route to onboarding + * (create/restore). */ | { status: 'NO_WALLET' } - | { status: 'LOCKED'; walletId: string } + /** No wallet is currently unlocked. walletId is present when a specific + * wallet is targeted (e.g. unlock()/switchWallet() was called and is still + * mid-decrypt) and absent when a wallet is only known to exist (from + * walletList) but none is currently targeted (e.g. right after lock()). + * Either way, the correct action is the same: show your own unlock flow, + * using walletId as an optional hint rather than a required one. */ + | { status: 'LOCKED'; walletId?: string } + /** A wallet is fully unlocked and its identity has been confirmed to + * match what's actually loaded. Safe to render the main app. */ | { status: 'READY'; walletId: string } + /** Something failed - inspect error for details. Can originate from + * either the worklet layer or a wallet operation (create/unlock/etc). */ | { status: 'ERROR'; error: Error }; export interface WdkAppContextValue { state: WdkAppState; - retry: () => void; } const WdkAppContext = createContext(null) @@ -51,9 +67,6 @@ export interface WdkAppProviderProps< > { bundle: BundleConfig wdkConfigs: WdkConfigs - enableAutoInitialization?: boolean - currentUserId?: string | null - clearSensitiveDataOnBackground?: boolean children: React.ReactNode } @@ -73,9 +86,6 @@ export function WdkAppProvider< >({ bundle: bundleConfig, wdkConfigs, - enableAutoInitialization = true, - currentUserId, - clearSensitiveDataOnBackground = false, children, }: WdkAppProviderProps) { // Synchronous service setup (must run before child effects) @@ -110,11 +120,7 @@ export function WdkAppProvider< wdkConfigs, }) - useAppLifecycle({ clearSensitiveDataOnBackground }) - - const { state, retry } = useWalletOrchestrator({ - enableAutoInitialization, - currentUserId, + const { state } = useWalletOrchestrator({ isWorkletStarted, isWorkletInitialized, isWdkReinitialized, @@ -124,9 +130,8 @@ export function WdkAppProvider< const contextValue: WdkAppContextValue = useMemo( () => ({ state, - retry, }), - [state, retry], + [state], ) return ( diff --git a/src/services/walletSwitchingService.ts b/src/services/walletSwitchingService.ts deleted file mode 100644 index 724e6a2..0000000 --- a/src/services/walletSwitchingService.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright 2026 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * Wallet Switching Service - * - * Handles wallet switching operations: checking wallet existence, loading credentials, - * and initializing WDK with the new wallet. - * - * Architecture: - * - This service is the single source of truth for wallet switching logic - * - Used by useWallet hook for automatic wallet switching - * - Can be used by other components that need to switch wallets programmatically - */ - -import { WalletSetupService } from './walletSetupService' -import { WorkletLifecycleService } from './workletLifecycleService' -import { getWalletStore } from '../store/walletStore' -import { - updateWalletLoadingState, - getWalletIdFromLoadingState, -} from '../store/walletStore' -import { withOperationMutex } from '../utils/operationMutex' -import { normalizeError } from '../utils/errorUtils' -import { log, logError } from '../utils/logger' -import { produce } from 'immer' - -/** - * Wallet Switching Service - * - * Provides methods for switching between wallets. - */ -export class WalletSwitchingService { - /** - * Switch to a wallet by identifier - * - * This method: - * 1. Checks if the wallet exists (fail fast) - * 2. Ensures worklet is started (WdkAppProvider must be mounted) - * 3. Loads credentials for the wallet - * 4. Initializes WDK with the credentials - * 5. Updates activeWalletId in the store - * - * @param walletId - Wallet identifier to switch to - * @throws Error if wallet doesn't exist or switching fails - * - * @example - * ```typescript - * await WalletSwitchingService.switchToWallet('user@example.com') - * ``` - */ - static async switchToWallet( - walletId: string - ): Promise { - return withOperationMutex(`switchToWallet:${walletId}`, async () => { - const walletStore = getWalletStore() - const activeWalletId = walletStore.getState().activeWalletId - - // Check if already on this wallet - if (activeWalletId === walletId) { - log('[WalletSwitchingService] Already on wallet', { walletId }) - return - } - - try { - // Update loading state - walletStore.setState((prev) => - updateWalletLoadingState(prev, { - type: 'loading', - identifier: walletId, - walletExists: true, - }), - ) - - // Check if wallet exists first (fail fast) - const walletExists = await WalletSetupService.hasWallet(walletId) - if (!walletExists) { - throw new Error(`Wallet with identifier "${walletId}" does not exist`) - } - - // Ensure worklet is started (WdkAppProvider must be mounted) - await WorkletLifecycleService.ensureWorkletStarted() - - // Note: We don't clear previous wallet's credentials cache when switching - // The LRU eviction policy in workletStore will handle cache management - // This allows users to switch back to recently used wallets without re-authentication - if (activeWalletId !== null && activeWalletId !== walletId) { - log('[WalletSwitchingService] Switching wallets', { - from: activeWalletId, - to: walletId, - }) - // Cache is managed by LRU eviction - no need to clear here - } - - // Load credentials for the wallet - const credentials = await WalletSetupService.loadExistingWallet( - walletId, - ) - - // Switch worklet to this wallet - await WorkletLifecycleService.initializeWDK({ - encryptionKey: credentials.encryptionKey, - encryptedSeed: credentials.encryptedSeed, - }) - - // Update activeWalletId in store and mark as ready - walletStore.setState((prev) => - produce( - updateWalletLoadingState(prev, { - type: 'ready', - identifier: walletId, - }), - (state) => { - state.activeWalletId = walletId - }, - ), - ) - - log('[WalletSwitchingService] Successfully switched to wallet', { - walletId, - }) - } catch (error) { - // Cleanup state on error - const normalizedError = normalizeError(error, false, { - component: 'WalletSwitchingService', - operation: 'switchToWallet', - walletId, - }) - logError( - '[WalletSwitchingService] Failed to switch wallet, cleaning up state', - normalizedError, - ) - - walletStore.setState((prev) => { - const currentWalletId = getWalletIdFromLoadingState( - prev.walletLoadingState, - ) - // Only update error state if we were loading this wallet - if ( - currentWalletId === walletId || - prev.walletLoadingState.type === 'loading' - ) { - return updateWalletLoadingState(prev, { - type: 'error', - identifier: walletId, - error: normalizedError, - }) - } - return prev - }) - - throw normalizedError - } - }) - } - - /** - * Check if a wallet can be switched to - * - * @param walletId - Wallet identifier to check - * @returns Promise resolving to true if wallet exists and can be switched to - */ - static async canSwitchToWallet(walletId: string): Promise { - try { - return await WalletSetupService.hasWallet(walletId) - } catch (error) { - // Log error but don't throw - this is a non-critical check - const normalizedError = normalizeError(error, false, { - component: 'WalletSwitchingService', - operation: 'canSwitchToWallet', - walletId, - }) - logError( - '[WalletSwitchingService] Failed to check wallet:', - normalizedError, - ) - return false - } - } -} diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 99065fc..6b50f71 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -170,7 +170,6 @@ export function createWalletStore(): WalletStoreInstance { lastBalanceUpdate: state.lastBalanceUpdate, accountList: state.accountList, walletList: state.walletList, - activeWalletId: state.activeWalletId, // Don't persist loading state or operation mutex - these are runtime-only }), onRehydrateStorage: () => { @@ -183,6 +182,7 @@ export function createWalletStore(): WalletStoreInstance { state.walletLoadingState = { type: 'not_loaded' } state.isOperationInProgress = false state.currentOperation = null + state.activeWalletId = null } } }, diff --git a/src/utils/operationMutex.ts b/src/utils/operationMutex.ts index e6e5ae8..7395115 100644 --- a/src/utils/operationMutex.ts +++ b/src/utils/operationMutex.ts @@ -120,8 +120,8 @@ const DEFAULT_OPERATION_TIMEOUT_MS = 30 * 1000 * * @example * ```typescript - * await withOperationMutex('switchWallet', async () => { - * await WalletSwitchingService.switchToWallet(walletId) + * await withOperationMutex('unlock', async () => { + * await performUnlock(walletId) * }) * * // With custom timeout diff --git a/tests/hooks/useAppLifecycle.test.tsx b/tests/hooks/useAppLifecycle.test.tsx deleted file mode 100644 index 8f051b5..0000000 --- a/tests/hooks/useAppLifecycle.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2026 Tether Operations Limited -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { renderHook, act } from '@testing-library/react-native'; -import { AppState, type AppStateStatus } from 'react-native'; -import { create, StoreApi } from 'zustand'; -import { useAppLifecycle } from '../../src/hooks/internal/useAppLifecycle'; -import { getWalletStore, WalletStore, WalletLoadingState } from '../../src/store/walletStore'; -import { log } from '../../src/utils/logger'; - -let mockCurrentAppStateValue: AppStateStatus = 'active'; -let appStateChangeHandler: ((status: AppStateStatus) => void) | undefined; - -jest.mock('react-native', () => ({ - AppState: { - get currentState() { - return mockCurrentAppStateValue; - }, - addEventListener: jest.fn((event, handler) => { - if (event === 'change') { - appStateChangeHandler = handler; - return { remove: jest.fn() }; - } - return { remove: jest.fn() }; - }), - }, -})); - -const triggerAppStateChange = (nextState: AppStateStatus) => { - mockCurrentAppStateValue = nextState; - if (appStateChangeHandler) { - appStateChangeHandler(nextState); - } -}; - -jest.mock('../../src/store/walletStore', () => ({ - ...jest.requireActual('../../src/store/walletStore'), - getWalletStore: jest.fn(), - updateWalletLoadingState: jest.fn((prev, update) => ({ ...prev, walletLoadingState: update })), -})); - -jest.mock('../../src/utils/logger', () => ({ - log: jest.fn(), - logWarn: jest.fn() -})); - -describe('useAppLifecycle', () => { - let mockWalletStore: StoreApi; - const mockLog = log as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - mockCurrentAppStateValue = 'active'; - appStateChangeHandler = undefined; - - mockWalletStore = create(() => ({ - activeWalletId: null, - walletLoadingState: { type: 'not_loaded' } as WalletLoadingState, - addresses: {}, - } as WalletStore)); - (getWalletStore as jest.Mock).mockReturnValue(mockWalletStore); - }); - - it('should not register AppState listener if clearSensitiveDataOnBackground is false', () => { - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: false })); - expect(AppState.addEventListener).not.toHaveBeenCalled(); - expect(mockLog).not.toHaveBeenCalled(); - }); - - it('should register and unregister AppState listener if clearSensitiveDataOnBackground is true', () => { - const { unmount } = renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); - - act(() => { - unmount(); - }); - expect(AppState.addEventListener).toHaveReturnedWith({ remove: expect.any(Function) }); - expect(AppState.addEventListener).toHaveBeenCalledTimes(1); - const removeFn = (AppState.addEventListener as jest.Mock).mock.results[0].value.remove; - expect(removeFn).toHaveBeenCalledTimes(1); - }); - - it('should log cache clear on mount when clearSensitiveDataOnBackground is true', () => { - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] Clearing credentials cache on mount (app restart)'); - }); - - describe('when transitioning to background/inactive (clearSensitiveDataOnBackground: true)', () => { - it('should set walletLoadingState to not_loaded if wallet is ready and activeWalletId exists', () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'ready', identifier: 'user1' } }); - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - - act(() => { - triggerAppStateChange('background'); - }); - - expect(mockWalletStore.getState().walletLoadingState).toEqual({ type: 'not_loaded' }); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] App going to background - clearing sensitive data and marking for re-auth'); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] Resetting wallet state to trigger biometrics on foreground'); - }); - - it('should set walletLoadingState to not_loaded if wallet is ready and activeWalletId exists (inactive)', () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'ready', identifier: 'user1' } }); - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - - act(() => { - triggerAppStateChange('inactive'); - }); - - expect(mockWalletStore.getState().walletLoadingState).toEqual({ type: 'not_loaded' }); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] App going to background - clearing sensitive data and marking for re-auth'); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] Resetting wallet state to trigger biometrics on foreground'); - }); - - it('should not change walletLoadingState if wallet is loading', () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'loading', identifier: 'user1' } as WalletLoadingState }); - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - - act(() => { - triggerAppStateChange('background'); - }); - - expect(mockWalletStore.getState().walletLoadingState).toEqual({ type: 'loading', identifier: 'user1' }); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] App going to background - clearing sensitive data and marking for re-auth'); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] Preserving wallet loading state during background transition', { currentState: 'loading' }); - }); - - it('should not change walletLoadingState if wallet is checking', () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'checking', identifier: 'user1' } }); - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - - act(() => { - triggerAppStateChange('inactive'); - }); - - expect(mockWalletStore.getState().walletLoadingState).toEqual({ type: 'checking', identifier: 'user1' }); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] App going to background - clearing sensitive data and marking for re-auth'); - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] Preserving wallet loading state during background transition', { currentState: 'checking' }); - }); - }); - - describe('when transitioning to active (clearSensitiveDataOnBackground: true)', () => { - it('should log message when coming from background to active', () => { - mockCurrentAppStateValue = 'background'; - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - - act(() => { - triggerAppStateChange('active'); - }); - - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] App coming to foreground - auto-initialization will trigger biometrics'); - }); - - it('should log message when coming from inactive to active', () => { - mockCurrentAppStateValue = 'inactive'; - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - - act(() => { - triggerAppStateChange('active'); - }); - - expect(mockLog).toHaveBeenCalledWith('[useAppLifecycle] App coming to foreground - auto-initialization will trigger biometrics'); - }); - }); - - it('should not trigger wallet state changes on non-relevant AppState transitions', () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'ready', identifier: 'user1' } }); - mockCurrentAppStateValue = 'active'; - renderHook(() => useAppLifecycle({ clearSensitiveDataOnBackground: true })); - mockLog.mockClear(); - - act(() => { - triggerAppStateChange('background'); - }); - expect(mockWalletStore.getState().walletLoadingState.type).toBe('not_loaded'); - expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('clearing sensitive data')); - - mockLog.mockClear(); - - act(() => { - triggerAppStateChange('inactive'); - }); - expect(mockWalletStore.getState().walletLoadingState.type).toBe('not_loaded'); - expect(mockLog).not.toHaveBeenCalledWith(expect.stringContaining('clearing sensitive data')); - expect(mockLog).not.toHaveBeenCalledWith(expect.stringContaining('Resetting wallet state')); - - mockLog.mockClear(); - - act(() => { - triggerAppStateChange('background'); - }); - expect(mockWalletStore.getState().walletLoadingState.type).toBe('not_loaded'); - expect(mockLog).not.toHaveBeenCalledWith(expect.stringContaining('clearing sensitive data')); - expect(mockLog).not.toHaveBeenCalledWith(expect.stringContaining('Resetting wallet state')); - }); -}); \ No newline at end of file diff --git a/tests/hooks/useWalletManager.test.tsx b/tests/hooks/useWalletManager.test.tsx index edd631a..14123d1 100644 --- a/tests/hooks/useWalletManager.test.tsx +++ b/tests/hooks/useWalletManager.test.tsx @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, { PropsWithChildren } from 'react'; import { renderHook, act } from '@testing-library/react-native'; import { create, StoreApi } from 'zustand'; import { useWalletManager } from '../../src/hooks/useWalletManager'; @@ -20,8 +19,6 @@ import { WalletSetupService } from '../../src/services/walletSetupService'; import { WorkletLifecycleService } from '../../src/services/workletLifecycleService'; import { getWalletStore, WalletState, WalletInfo } from '../../src/store/walletStore'; import { getWorkletStore, WorkletStore } from '../../src/store/workletStore'; -import { useWdkApp } from '../../src/hooks/useWdkApp'; -import { WdkAppContext, WdkAppContextValue } from '../../src/provider/WdkAppProvider'; jest.mock('../../src/services/walletSetupService'); jest.mock('../../src/services/workletLifecycleService'); @@ -35,7 +32,6 @@ jest.mock('../../src/store/walletStore', () => ({ jest.mock('../../src/store/workletStore', () => ({ getWorkletStore: jest.fn(), })); -jest.mock('../../src/hooks/useWdkApp'); type MockWalletStore = StoreApi; type MockWorkletStore = StoreApi; @@ -44,7 +40,6 @@ const mockWalletSetupService = WalletSetupService as jest.Mocked; const mockGetWalletStore = getWalletStore as jest.Mock; const mockGetWorkletStore = getWorkletStore as jest.Mock; -const mockUseWdkApp = useWdkApp as jest.Mock; const mockInitialWalletState: WalletState = { addresses: {}, @@ -112,13 +107,6 @@ beforeEach(() => { mockWorkletStoreInstance = create(() => mockInitialWorkletState); mockGetWorkletStore.mockReturnValue(mockWorkletStoreInstance); - mockUseWdkApp.mockReturnValue({ - state: { status: 'READY', walletId: 'mock-wdk-ready' }, - retry: jest.fn(), - reinitializeWdk: jest.fn(), - resetWallets: jest.fn(), - }); - mockWalletSetupService.initializeWallet.mockResolvedValue(undefined); mockWalletSetupService.hasWallet.mockResolvedValue(false); mockWalletSetupService.initializeFromMnemonic.mockResolvedValue({ encryptedEntropy: '', encryptedSeed: '', encryptionKey: ''}); @@ -128,53 +116,13 @@ beforeEach(() => { mockWorkletLifecycleService.ensureWorkletStarted.mockResolvedValue(undefined); }); -const ContextWrapper = ({ children }: PropsWithChildren) => { - const mockWdkAppValue: WdkAppContextValue = { - state: { status: 'READY', walletId: 'mock-wdk-ready' }, - retry: jest.fn(), - }; - mockUseWdkApp.mockReturnValue(mockWdkAppValue); - - return ( - - {children} - - ); -}; - describe('useWalletManager', () => { - it('should expose state and actions from stores and services', () => { - const { result } = renderHook(() => useWalletManager(), { - wrapper: ContextWrapper - }); - - expect(result.current.activeWalletId).toBeNull(); - expect(result.current.wallets).toEqual([]); - expect(result.current.status).toBe('NO_WALLET'); - - expect(typeof result.current.unlock).toBe('function'); - expect(typeof result.current.createWallet).toBe('function'); - expect(typeof result.current.deleteWallet).toBe('function'); - expect(typeof result.current.getMnemonic).toBe('function'); - expect(typeof result.current.getEncryptionKey).toBe('function'); - expect(typeof result.current.getEncryptedSeed).toBe('function'); - expect(typeof result.current.getEncryptedEntropy).toBe('function'); - expect(typeof result.current.generateEntropyAndEncrypt).toBe('function'); - expect(typeof result.current.getMnemonicFromEntropy).toBe('function'); - expect(typeof result.current.getSeedAndEntropyFromMnemonic).toBe('function'); - expect(typeof result.current.lock).toBe('function'); - expect(typeof result.current.generateMnemonic).toBe('function'); - expect(typeof result.current.clearTemporaryWallet).toBe('function'); - expect(typeof result.current.createTemporaryWallet).toBe('function'); - expect(typeof result.current.clearCache).toBe('function'); - }); - describe('State Management and Transitions', () => { it('should call WalletSetupService.createWallet', async () => { const walletId = 'new-wallet'; mockWalletSetupService.hasWallet.mockResolvedValue(false); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { await result.current.createWallet(walletId); @@ -189,12 +137,59 @@ describe('useWalletManager', () => { const walletId = 'existing-wallet'; mockWalletSetupService.hasWallet.mockResolvedValue(true); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await expect(act(async () => { await result.current.createWallet(walletId); })).rejects.toThrow(`Wallet with walletId "${walletId}" already exists`); }); + + it('should reject creating a new wallet while a different wallet is already active', async () => { + const activeWalletId = 'wallet-a'; + const newWalletId = 'wallet-b'; + mockWalletSetupService.hasWallet.mockResolvedValue(false); + + mockWalletStoreInstance.setState({ + activeWalletId, + walletLoadingState: { type: 'ready', identifier: activeWalletId }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await expect(act(async () => { + await result.current.createWallet(newWalletId); + })).rejects.toThrow('A wallet is already active. Call lock() before creating a new wallet.'); + + expect(mockWalletSetupService.createNewWallet).not.toHaveBeenCalled(); + expect(result.current.activeWalletId).toBe(activeWalletId); + }); + + it('should allow creating a new wallet after a manual lock', async () => { + const activeWalletId = 'wallet-a'; + const newWalletId = 'wallet-b'; + mockWalletSetupService.hasWallet.mockResolvedValue(false); + + mockWalletStoreInstance.setState({ + activeWalletId, + walletLoadingState: { type: 'ready', identifier: activeWalletId }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + await result.current.lock(); + }); + + await act(async () => { + await result.current.createWallet(newWalletId); + }); + + expect(mockWalletSetupService.createNewWallet).toHaveBeenCalledWith(newWalletId); + expect(result.current.activeWalletId).toBe(newWalletId); + + const state = mockWalletStoreInstance.getState(); + expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: newWalletId }); + }); }); describe('Wallet Operations', () => { @@ -202,7 +197,7 @@ describe('useWalletManager', () => { const walletId = 'test-wallet-456'; mockWalletSetupService.initializeWallet.mockResolvedValue(); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { await result.current.unlock(walletId); @@ -222,7 +217,7 @@ describe('useWalletManager', () => { }); mockWalletSetupService.initializeWallet.mockReturnValue(unlockPromise); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); let firstUnlockPromise: Promise; await act(async () => { @@ -251,7 +246,7 @@ describe('useWalletManager', () => { }); }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { result.current.lock(); @@ -265,11 +260,174 @@ describe('useWalletManager', () => { expect(state.walletLoadingState).toEqual({ type: 'not_loaded' }); }); + it('should allow a manual unlock for a different wallet after a manual lock', async () => { + const previousWalletId = 'wallet-a'; + const nextWalletId = 'wallet-b'; + + mockWalletStoreInstance.setState({ + activeWalletId: previousWalletId, + walletLoadingState: { type: 'ready', identifier: previousWalletId }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + result.current.lock(); + }); + + expect(result.current.activeWalletId).toBeNull(); + expect(mockWalletStoreInstance.getState().walletLoadingState).toEqual({ type: 'not_loaded' }); + + await act(async () => { + await result.current.unlock(nextWalletId); + }); + + expect(mockWalletSetupService.initializeWallet).toHaveBeenCalledWith({ walletId: nextWalletId }); + expect(result.current.activeWalletId).toBe(nextWalletId); + expect(mockWalletStoreInstance.getState().walletLoadingState).toEqual({ type: 'ready', identifier: nextWalletId }); + }); + + it('should switch wallets by locking the previous one before unlocking the new one', async () => { + const previousWalletId = 'wallet-a'; + const nextWalletId = 'wallet-b'; + + mockWalletStoreInstance.setState({ + activeWalletId: previousWalletId, + walletLoadingState: { type: 'ready', identifier: previousWalletId }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + await result.current.switchWallet(nextWalletId); + }); + + expect(mockWorkletLifecycleService.reset).toHaveBeenCalledTimes(1); + expect(mockWalletSetupService.initializeWallet).toHaveBeenCalledWith({ walletId: nextWalletId }); + expect(result.current.activeWalletId).toBe(nextWalletId); + + const state = mockWalletStoreInstance.getState(); + expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: nextWalletId }); + }); + + it('should reject a concurrent unlock call while switchWallet is in flight', async () => { + let resolveInitialize: (value: void | PromiseLike) => void; + const initializePromise = new Promise((resolve) => { + resolveInitialize = resolve; + }); + mockWalletSetupService.initializeWallet.mockReturnValue(initializePromise); + + mockWalletStoreInstance.setState({ + activeWalletId: 'wallet-a', + walletLoadingState: { type: 'ready', identifier: 'wallet-a' }, + }); + + const { result } = renderHook(() => useWalletManager()); + + let switchWalletPromise: Promise; + await act(async () => { + switchWalletPromise = result.current.switchWallet('wallet-b'); + }); + + await expect(act(async () => { + await result.current.unlock('wallet-c'); + })).rejects.toThrow(/Another operation is in progress/); + + await act(async () => { + resolveInitialize!(); + await switchWalletPromise!; + }); + + expect(result.current.activeWalletId).toBe('wallet-b'); + }); + + it('should reject a concurrent lock() call while unlock is in flight', async () => { + let resolveInitialize: (value: void | PromiseLike) => void; + const initializePromise = new Promise((resolve) => { + resolveInitialize = resolve; + }); + mockWalletSetupService.initializeWallet.mockReturnValue(initializePromise); + + const { result } = renderHook(() => useWalletManager()); + + let unlockPromise: Promise; + await act(async () => { + unlockPromise = result.current.unlock('wallet-a'); + }); + + // Without the shared mutex, lock() would run immediately here, clearing + // state - only for unlock's later completion to silently overwrite it + // back to ready, leaving decrypted wallet-a material in the worklet + // while the app believes it locked. It must reject instead. + await expect(act(async () => { + await result.current.lock(); + })).rejects.toThrow(/Another operation is in progress/); + + expect(mockWorkletLifecycleService.reset).not.toHaveBeenCalled(); + + await act(async () => { + resolveInitialize!(); + await unlockPromise!; + }); + + expect(result.current.activeWalletId).toBe('wallet-a'); + expect(mockWalletStoreInstance.getState().walletLoadingState).toEqual({ type: 'ready', identifier: 'wallet-a' }); + }); + + it('should reject unlocking a different wallet while one is already ready', async () => { + mockWalletStoreInstance.setState({ + activeWalletId: 'wallet-a', + walletLoadingState: { type: 'ready', identifier: 'wallet-a' }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await expect(act(async () => { + await result.current.unlock('wallet-b'); + })).rejects.toThrow('A wallet is already active. Call lock() before unlocking a different wallet.'); + + expect(mockWalletSetupService.initializeWallet).not.toHaveBeenCalled(); + expect(result.current.activeWalletId).toBe('wallet-a'); + }); + + it('should reject rather than silently skip when the loading state and activeWalletId have diverged', async () => { + // If activeWalletId and walletLoadingState ever fall out of lockstep, unlock('wallet-a') + // must not treat the stale 'wallet-a' loading state as "already unlocked". + mockWalletStoreInstance.setState({ + activeWalletId: 'wallet-b', + walletLoadingState: { type: 'ready', identifier: 'wallet-a' }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await expect(act(async () => { + await result.current.unlock('wallet-a'); + })).rejects.toThrow('A wallet is already active. Call lock() before unlocking a different wallet.'); + + expect(mockWalletSetupService.initializeWallet).not.toHaveBeenCalled(); + }); + + it('should no-op when unlocking the same wallet that is already ready', async () => { + mockWalletStoreInstance.setState({ + activeWalletId: 'wallet-a', + walletLoadingState: { type: 'ready', identifier: 'wallet-a' }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + await result.current.unlock('wallet-a'); + }); + + expect(mockWalletSetupService.initializeWallet).not.toHaveBeenCalled(); + expect(result.current.activeWalletId).toBe('wallet-a'); + }); + it('should delegate unlock to WalletSetupService', async () => { const walletId = 'test-wallet-to-unlock'; mockWalletSetupService.initializeWallet.mockResolvedValue(); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { await result.current.unlock(walletId); @@ -279,17 +437,18 @@ describe('useWalletManager', () => { expect(mockWalletSetupService.initializeWallet).toHaveBeenCalledTimes(1); }); - it('should delegate deleteWallet to WalletSetupService and update store', async () => { + it('should delegate deleteWallet to WalletSetupService and clear active wallet state', async () => { const walletIdToDelete = 'wallet-to-delete'; - const updatedWalletList: WalletInfo[] = [{ identifier: 'other-wallet', exists: true }]; - + const otherWallet: WalletInfo = { identifier: 'other-wallet', exists: true }; + mockWalletSetupService.deleteWallet.mockResolvedValue(undefined); mockWalletStoreInstance.setState({ - walletList: [{ identifier: walletIdToDelete, exists: true }, ...updatedWalletList], + walletList: [{ identifier: walletIdToDelete, exists: true }, otherWallet], activeWalletId: walletIdToDelete, + walletLoadingState: { type: 'ready', identifier: walletIdToDelete }, }); - - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + + const { result } = renderHook(() => useWalletManager()); await act(async () => { await result.current.deleteWallet(walletIdToDelete); @@ -297,24 +456,41 @@ describe('useWalletManager', () => { expect(mockWalletSetupService.deleteWallet).toHaveBeenCalledWith(walletIdToDelete); expect(mockWalletSetupService.deleteWallet).toHaveBeenCalledTimes(1); + + const state = mockWalletStoreInstance.getState(); + expect(state.activeWalletId).toBeNull(); + expect(state.walletLoadingState).toEqual({ type: 'not_loaded' }); + expect(state.walletList).toEqual([otherWallet]); }); - - it('should delegate getMnemonic to WalletSetupService', async () => { - const mnemonicPhrase = 'test mnemonic phrase'; - mockWalletSetupService.getMnemonic.mockResolvedValue(mnemonicPhrase); - const walletId = 'wallet-with-mnemonic'; - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + it('should not clear activeWalletId when deleting a different, non-active wallet', async () => { + const activeWalletId = 'wallet-a'; + const walletIdToDelete = 'wallet-b'; + + mockWalletSetupService.deleteWallet.mockResolvedValue(undefined); + mockWalletStoreInstance.setState({ + walletList: [ + { identifier: activeWalletId, exists: true }, + { identifier: walletIdToDelete, exists: true }, + ], + activeWalletId, + walletLoadingState: { type: 'ready', identifier: activeWalletId }, + }); - const mnemonic = await result.current.getMnemonic(walletId); + const { result } = renderHook(() => useWalletManager()); - expect(mockWalletSetupService.getMnemonic).toHaveBeenCalledWith(walletId); - expect(mockWalletSetupService.getMnemonic).toHaveBeenCalledTimes(1); - expect(mnemonic).toBe(mnemonicPhrase); + await act(async () => { + await result.current.deleteWallet(walletIdToDelete); + }); + + const state = mockWalletStoreInstance.getState(); + expect(state.activeWalletId).toBe(activeWalletId); + expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: activeWalletId }); + expect(state.walletList).toEqual([{ identifier: activeWalletId, exists: true }]); }); it('should delegate createTemporaryWallet', async () => { - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); mockWorkletLifecycleService.generateEntropyAndEncrypt.mockResolvedValue({ encryptionKey: '', encryptedEntropyBuffer: '', @@ -328,12 +504,73 @@ describe('useWalletManager', () => { expect(mockWorkletLifecycleService.initializeWDK).toHaveBeenCalledTimes(1); }); + it('should clear the previous temporary wallet when creating a new one', async () => { + mockWorkletLifecycleService.generateEntropyAndEncrypt.mockResolvedValue({ + encryptionKey: '', + encryptedEntropyBuffer: '', + encryptedSeedBuffer: '' + }); + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + await result.current.createTemporaryWallet('temp-1'); + }); + + expect(mockWalletStoreInstance.getState().walletList).toEqual([{ identifier: 'temp-1', exists: true }]); + + await act(async () => { + await result.current.createTemporaryWallet('temp-2'); + }); + + const state = mockWalletStoreInstance.getState(); + expect(state.walletList).toEqual([{ identifier: 'temp-2', exists: true }]); + expect(state.tempWalletId).toBe('temp-2'); + expect(state.activeWalletId).toBe('temp-2'); + }); + + it('should mark the temporary wallet as ready so a stale unlock cannot skip past it', async () => { + mockWorkletLifecycleService.generateEntropyAndEncrypt.mockResolvedValue({ + encryptionKey: '', + encryptedEntropyBuffer: '', + encryptedSeedBuffer: '' + }) + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + await result.current.createTemporaryWallet('temp-preview'); + }); + + const state = mockWalletStoreInstance.getState(); + expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: 'temp-preview' }); + expect(state.activeWalletId).toBe('temp-preview'); + }); + + it('should reject creating a temporary wallet while a different real wallet is already active', async () => { + mockWalletStoreInstance.setState({ + activeWalletId: 'wallet-a', + walletLoadingState: { type: 'ready', identifier: 'wallet-a' }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await expect(act(async () => { + await result.current.createTemporaryWallet('temp-preview'); + })).rejects.toThrow('A wallet is already active. Call lock() before creating a temporary wallet.'); + + expect(mockWorkletLifecycleService.initializeWDK).not.toHaveBeenCalled(); + const state = mockWalletStoreInstance.getState(); + expect(state.activeWalletId).toBe('wallet-a'); + expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: 'wallet-a' }); + }); + it('should set walletLoadingState to loading when createWallet is called', async () => { const walletId = 'new-wallet-state-test'; mockWalletSetupService.hasWallet.mockResolvedValue(false); const setStateSpy = jest.spyOn(mockWalletStoreInstance, 'setState'); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { await result.current.createWallet(walletId); @@ -356,7 +593,7 @@ describe('useWalletManager', () => { const mnemonic = 'test mnemonic'; mockWalletSetupService.hasWallet.mockResolvedValue(false); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { await result.current.restoreWallet(mnemonic, walletId); @@ -369,15 +606,94 @@ describe('useWalletManager', () => { expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: walletId }); }); - it('should update activeWalletId via setActiveWalletId', () => { - const walletId = 'switched-wallet'; - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + it('should throw if a wallet with that id already exists when restoring', async () => { + const walletId = 'existing-wallet'; + const mnemonic = 'test mnemonic'; + mockWalletSetupService.hasWallet.mockResolvedValue(true); + + const { result } = renderHook(() => useWalletManager()); - act(() => { - result.current.setActiveWalletId(walletId); + await expect(act(async () => { + await result.current.restoreWallet(mnemonic, walletId); + })).rejects.toThrow(`A wallet with the ID "${walletId}" already exists.`); + + expect(mockWalletSetupService.initializeFromMnemonic).not.toHaveBeenCalled(); + }); + + it('should reject restoring a new wallet while a different wallet is already active', async () => { + const activeWalletId = 'wallet-a'; + const restoredWalletId = 'wallet-b'; + const mnemonic = 'test mnemonic'; + mockWalletSetupService.hasWallet.mockResolvedValue(false); + + mockWalletStoreInstance.setState({ + activeWalletId, + walletLoadingState: { type: 'ready', identifier: activeWalletId }, + }); + + const { result } = renderHook(() => useWalletManager()); + + await expect(act(async () => { + await result.current.restoreWallet(mnemonic, restoredWalletId); + })).rejects.toThrow('A wallet is already active. Call lock() before restoring a new wallet.'); + + expect(mockWalletSetupService.initializeFromMnemonic).not.toHaveBeenCalled(); + expect(result.current.activeWalletId).toBe(activeWalletId); + }); + + it('should allow restoring a new wallet after a manual lock', async () => { + const activeWalletId = 'wallet-a'; + const restoredWalletId = 'wallet-b'; + const mnemonic = 'test mnemonic'; + mockWalletSetupService.hasWallet.mockResolvedValue(false); + + mockWalletStoreInstance.setState({ + activeWalletId, + walletLoadingState: { type: 'ready', identifier: activeWalletId }, }); - expect(mockWalletStoreInstance.getState().activeWalletId).toBe(walletId); + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + result.current.lock(); + }); + + await act(async () => { + await result.current.restoreWallet(mnemonic, restoredWalletId); + }); + + expect(mockWalletSetupService.initializeFromMnemonic).toHaveBeenCalledWith(mnemonic, restoredWalletId); + expect(result.current.activeWalletId).toBe(restoredWalletId); + + const state = mockWalletStoreInstance.getState(); + expect(state.walletLoadingState).toEqual({ type: 'ready', identifier: restoredWalletId }); + }); + + it('should reject a concurrent createWallet call while restoreWallet is in flight', async () => { + let resolveRestore: (value: { encryptedEntropy: string; encryptedSeed: string; encryptionKey: string } | PromiseLike<{ encryptedEntropy: string; encryptedSeed: string; encryptionKey: string }>) => void; + const restorePromise = new Promise<{ encryptedEntropy: string; encryptedSeed: string; encryptionKey: string }>((resolve) => { + resolveRestore = resolve; + }); + mockWalletSetupService.initializeFromMnemonic.mockReturnValue(restorePromise); + mockWalletSetupService.hasWallet.mockResolvedValue(false); + + const { result } = renderHook(() => useWalletManager()); + + let restoreWalletPromise: Promise; + await act(async () => { + restoreWalletPromise = result.current.restoreWallet('test mnemonic', 'wallet-b'); + }); + + await expect(act(async () => { + await result.current.createWallet('wallet-c'); + })).rejects.toThrow(/Another operation is in progress/); + + await act(async () => { + resolveRestore!({ encryptedEntropy: '', encryptedSeed: '', encryptionKey: '' }); + await restoreWalletPromise!; + }); + + expect(result.current.activeWalletId).toBe('wallet-b'); }); it('should clear balances and loading states via clearCache', () => { @@ -387,7 +703,7 @@ describe('useWalletManager', () => { lastBalanceUpdate: { 'w1': {} } }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); act(() => { result.current.clearCache(); @@ -406,7 +722,7 @@ describe('useWalletManager', () => { walletList: [{ identifier: tempId, exists: true }] }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); act(() => { result.current.clearTemporaryWallet(); @@ -417,32 +733,30 @@ describe('useWalletManager', () => { expect(state.walletList).toEqual([]); }); - it('should delegate credential getters to WalletSetupService', async () => { - const walletId = 'test-wallet'; - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - - await result.current.getEncryptionKey(walletId); - expect(mockWalletSetupService.getEncryptionKey).toHaveBeenCalledWith(walletId); - - await result.current.getEncryptedSeed(walletId); - expect(mockWalletSetupService.getEncryptedSeed).toHaveBeenCalledWith(walletId); - - await result.current.getEncryptedEntropy(walletId); - expect(mockWalletSetupService.getEncryptedEntropy).toHaveBeenCalledWith(walletId); - }); + it('should lock when clearing a temporary wallet that is currently active', () => { + const tempId = 'temp-active'; + mockWalletStoreInstance.setState({ + tempWalletId: tempId, + activeWalletId: tempId, + walletLoadingState: { type: 'ready', identifier: tempId }, + walletList: [{ identifier: tempId, exists: true }], + }); - it('should delegate worklet operations to WorkletLifecycleService', async () => { - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); - await result.current.generateEntropyAndEncrypt(12); - expect(mockWorkletLifecycleService.generateEntropyAndEncrypt).toHaveBeenCalledWith(12); + act(() => { + result.current.clearTemporaryWallet(); + }); - await result.current.getMnemonicFromEntropy('ent', 'key'); - expect(mockWorkletLifecycleService.getMnemonicFromEntropy).toHaveBeenCalledWith('ent', 'key'); + expect(mockWorkletLifecycleService.reset).toHaveBeenCalledTimes(1); - await result.current.getSeedAndEntropyFromMnemonic('mnemonic'); - expect(mockWorkletLifecycleService.getSeedAndEntropyFromMnemonic).toHaveBeenCalledWith('mnemonic'); + const state = mockWalletStoreInstance.getState(); + expect(state.tempWalletId).toBeNull(); + expect(state.activeWalletId).toBeNull(); + expect(state.walletLoadingState).toEqual({ type: 'not_loaded' }); + expect(state.walletList).toEqual([]); }); + }); describe('Status Memo', () => { @@ -450,7 +764,7 @@ describe('useWalletManager', () => { mockWalletStoreInstance.setState({ walletLoadingState: { type: 'loading', identifier: 'test', walletExists: true } }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); expect(result.current.status).toBe('LOADING'); }); @@ -458,14 +772,23 @@ describe('useWalletManager', () => { mockWalletStoreInstance.setState({ walletLoadingState: { type: 'error', identifier: 'test', error: new Error('fail') } }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); expect(result.current.status).toBe('ERROR'); }); it('should return LOCKED when activeWalletId is set but WDK is not initialized', () => { mockWorkletStoreInstance.setState({ isInitialized: false }); mockWalletStoreInstance.setState({ activeWalletId: 'some-wallet' }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); + expect(result.current.status).toBe('LOCKED'); + }); + + it('should return LOCKED, not NO_WALLET, when wallets are known but none is active', () => { + mockWalletStoreInstance.setState({ + activeWalletId: null, + walletList: [{ identifier: 'some-wallet', exists: true }], + }); + const { result } = renderHook(() => useWalletManager()); expect(result.current.status).toBe('LOCKED'); }); }); @@ -474,7 +797,7 @@ describe('useWalletManager', () => { it('should update state to error when unlock fails', async () => { const walletId = 'fail-wallet'; mockWalletSetupService.initializeWallet.mockRejectedValue(new Error('init fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { try { @@ -491,12 +814,11 @@ describe('useWalletManager', () => { it('should handle non-Error catch in unlock', async () => { mockWalletSetupService.initializeWallet.mockRejectedValue('string error'); - mockWalletStoreInstance.setState({ activeWalletId: 'test' }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await act(async () => { try { - await result.current.unlock(); + await result.current.unlock('test'); } catch (e) { // Expected error } @@ -506,36 +828,12 @@ describe('useWalletManager', () => { expect(state.walletLoadingState.type).toBe('error'); expect((state.walletLoadingState as any).error.message).toBe('string error'); }); - - it('should handle errors in getMnemonic', async () => { - mockWalletSetupService.getMnemonic.mockRejectedValue(new Error('mnem fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.getMnemonic('id')).rejects.toThrow('mnem fail'); - }); - - it('should handle errors in getEncryptionKey', async () => { - mockWalletSetupService.getEncryptionKey.mockRejectedValue(new Error('key fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.getEncryptionKey('id')).rejects.toThrow('key fail'); - }); - - it('should handle errors in getEncryptedSeed', async () => { - mockWalletSetupService.getEncryptedSeed.mockRejectedValue(new Error('seed fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.getEncryptedSeed('id')).rejects.toThrow('seed fail'); - }); - - it('should handle errors in getEncryptedEntropy', async () => { - mockWalletSetupService.getEncryptedEntropy.mockRejectedValue(new Error('ent fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.getEncryptedEntropy('id')).rejects.toThrow('ent fail'); - }); }); describe('createTemporaryWallet options', () => { it('should handle mnemonic parameter in createTemporaryWallet', async () => { const mnemonic = 'test mnemonic'; - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); mockWorkletLifecycleService.getSeedAndEntropyFromMnemonic.mockResolvedValue({ encryptionKey: 'key', @@ -555,7 +853,7 @@ describe('useWalletManager', () => { }); it('should throw error if walletId is missing in createTemporaryWallet', async () => { - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await expect(act(async () => { await (result.current as any).createTemporaryWallet(null); })).rejects.toThrow('A valid walletId is required for createTemporaryWallet.'); @@ -563,34 +861,8 @@ describe('useWalletManager', () => { }); describe('Helper methods and edge cases', () => { - it('should handle errors in generateEntropyAndEncrypt', async () => { - mockWorkletLifecycleService.generateEntropyAndEncrypt.mockRejectedValue(new Error('gen fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.generateEntropyAndEncrypt()).rejects.toThrow('gen fail'); - }); - - it('should handle errors in getMnemonicFromEntropy', async () => { - mockWorkletLifecycleService.getMnemonicFromEntropy.mockRejectedValue(new Error('mnem fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.getMnemonicFromEntropy('e', 'k')).rejects.toThrow('mnem fail'); - }); - - it('should handle errors in getSeedAndEntropyFromMnemonic', async () => { - mockWorkletLifecycleService.getSeedAndEntropyFromMnemonic.mockRejectedValue(new Error('seed fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(result.current.getSeedAndEntropyFromMnemonic('m')).rejects.toThrow('seed fail'); - }); - - it('should handle errors in deleteWallet', async () => { - mockWalletSetupService.deleteWallet.mockRejectedValue(new Error('del fail')); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); - await expect(act(async () => { - await result.current.deleteWallet('id'); - })).rejects.toThrow('del fail'); - }); - it('should throw error if walletId is empty in deleteWallet', async () => { - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); await expect(act(async () => { await (result.current as any).deleteWallet(""); })).rejects.toThrow('Wallet ID is required for deletion'); @@ -604,7 +876,7 @@ describe('useWalletManager', () => { }); mockWorkletLifecycleService.getMnemonicFromEntropy.mockResolvedValue({ mnemonic: 'gen mnemonic' }); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); const mnem = await result.current.generateMnemonic(12); expect(mnem).toBe('gen mnemonic'); @@ -620,7 +892,7 @@ describe('useWalletManager', () => { mockWorkletLifecycleService.ensureWorkletStarted.mockReturnValue(workletStartPromise); - const { result } = renderHook(() => useWalletManager(), { wrapper: ContextWrapper }); + const { result } = renderHook(() => useWalletManager()); const createWalletPromise = act(async () => { await result.current.createWallet('test-wallet'); diff --git a/tests/hooks/useWalletOrchestrator.test.tsx b/tests/hooks/useWalletOrchestrator.test.tsx index abeca65..97cc349 100644 --- a/tests/hooks/useWalletOrchestrator.test.tsx +++ b/tests/hooks/useWalletOrchestrator.test.tsx @@ -16,29 +16,20 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; import { create, StoreApi } from 'zustand'; import { useWalletOrchestrator, UseWalletOrchestratorProps } from '../../src/hooks/internal/useWalletOrchestrator'; import { getWalletStore, WalletStore, WalletLoadingState } from '../../src/store/walletStore'; -import * as useWalletManager from '../../src/hooks/useWalletManager'; -import { WalletSetupService } from '../../src/services/walletSetupService'; jest.mock('../../src/store/walletStore', () => ({ ...jest.requireActual('../../src/store/walletStore'), getWalletStore: jest.fn(), })); -jest.mock('../../src/hooks/useWalletManager'); -jest.mock('../../src/services/walletSetupService'); jest.mock('../../src/utils/logger', () => ({ log: jest.fn(), logError: jest.fn(), logWarn: jest.fn(), })); -const mockCreateWallet = jest.fn(); -const mockUnlock = jest.fn(); - describe('useWalletOrchestrator', () => { let mockWalletStore: StoreApi; const initialProps: UseWalletOrchestratorProps = { - enableAutoInitialization: true, - currentUserId: 'user1', isWorkletStarted: true, isWorkletInitialized: false, isWdkReinitialized: false, @@ -49,196 +40,104 @@ describe('useWalletOrchestrator', () => { jest.clearAllMocks(); mockWalletStore = create(() => ({ + addresses: {}, + walletLoading: {}, + balances: {}, + balanceLoading: {}, + lastBalanceUpdate: {}, + accountList: {}, + walletList: [], activeWalletId: null, walletLoadingState: { type: 'not_loaded' } as WalletLoadingState, - addresses: {}, - } as WalletStore)); + isOperationInProgress: false, + currentOperation: null, + tempWalletId: null, + })); (getWalletStore as jest.Mock).mockReturnValue(mockWalletStore); - - (useWalletManager.useWalletManager as jest.Mock).mockReturnValue({ - createWallet: mockCreateWallet, - unlock: mockUnlock, - }); - - mockCreateWallet.mockImplementation(async (walletId) => { - act(() => { - mockWalletStore.setState({ walletLoadingState: { type: 'loading', identifier: walletId } as WalletLoadingState }); - }); - await new Promise(resolve => setTimeout(resolve, 0)); - }); - mockUnlock.mockImplementation(async (walletId) => { - act(() => { - mockWalletStore.setState({ walletLoadingState: { type: 'loading', identifier: walletId } as WalletLoadingState }); - }); - await new Promise(resolve => setTimeout(resolve, 0)); - }); - }); - - it('should set activeWalletId from currentUserId if not set', async () => { - renderHook(() => useWalletOrchestrator(initialProps)); - - await waitFor(() => { - expect(mockWalletStore.getState().activeWalletId).toBe('user1'); - }); - }); - - it('should create a new wallet if one does not exist', async () => { - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(false); - - renderHook(() => useWalletOrchestrator(initialProps)); - - await waitFor(() => { - expect(mockWalletStore.getState().activeWalletId).toBe('user1'); - }); - - await waitFor(() => { - expect(WalletSetupService.hasWallet).toHaveBeenCalledWith('user1'); - expect(mockCreateWallet).toHaveBeenCalledWith('user1'); - }); - expect(mockUnlock).not.toHaveBeenCalled(); }); - it('should unlock an existing wallet', async () => { - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - mockWalletStore.setState({ activeWalletId: 'user1' }); - - renderHook(() => useWalletOrchestrator(initialProps)); - - await waitFor(() => { - expect(WalletSetupService.hasWallet).toHaveBeenCalledWith('user1'); - expect(mockUnlock).toHaveBeenCalledWith('user1'); + it('should return correct WdkAppState', async () => { + const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { + initialProps: { ...initialProps, isWorkletStarted: false } }); - expect(mockCreateWallet).not.toHaveBeenCalled(); - }); - - it('should switch wallet when currentUserId changes', async () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'ready', identifier: 'user1' }, addresses: { 'user1': { 'addr1': {} } } }); - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - - const { rerender } = renderHook((props) => useWalletOrchestrator(props), { initialProps }); - - await waitFor(() => expect(mockWalletStore.getState().activeWalletId).toBe('user1')); - - rerender({ ...initialProps, currentUserId: 'user2' }); + expect(result.current.state).toEqual({ status: 'INITIALIZING' }); - await waitFor(() => { - expect(mockWalletStore.getState().activeWalletId).toBe('user2'); - expect(WalletSetupService.hasWallet).toHaveBeenCalledWith('user2'); - expect(mockUnlock).toHaveBeenCalledWith('user2'); + rerender({ ...initialProps, isWorkletStarted: true }); + act(() => { + mockWalletStore.setState({ activeWalletId: null }); }); - }); + await waitFor(() => expect(result.current.state).toEqual({ status: 'NO_WALLET' })); - it('should not re-initialize on authentication error and return ERROR state', async () => { - const authError = new Error('user cancel'); - mockUnlock.mockRejectedValue(authError); - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - - const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { initialProps: { ...initialProps, currentUserId: 'user1' } }); - + rerender({ ...initialProps, isWorkletStarted: true }); act(() => { mockWalletStore.setState({ activeWalletId: 'user1' }); }); + await waitFor(() => expect(result.current.state).toEqual({ status: 'LOCKED', walletId: 'user1' })); - await waitFor(() => { - expect(mockUnlock).toHaveBeenCalledTimes(1); - }); + rerender({ ...initialProps, isWorkletStarted: true, isWdkReinitialized: true }); + await waitFor(() => expect(result.current.state).toEqual({ status: 'REINITIALIZING' })); + rerender({ ...initialProps, isWorkletStarted: true, isWorkletInitialized: true, isWdkReinitialized: false }); act(() => { - mockWalletStore.setState({ walletLoadingState: { type: 'error', error: authError, identifier: 'user1' } }); - }); - - rerender({ ...initialProps, currentUserId: 'user1' }); - - await waitFor(() => { - expect(result.current.state).toEqual({ status: 'ERROR', error: authError }); + mockWalletStore.setState({ + activeWalletId: 'user1', + walletLoadingState: { type: 'ready', identifier: 'user1' } as WalletLoadingState, + }); }); + await waitFor(() => expect(result.current.state).toEqual({ status: 'READY', walletId: 'user1' })); - rerender({ ...initialProps, currentUserId: 'user1' }); - await act(async () => { await new Promise(res => setTimeout(res, 10)) }); - - expect(mockUnlock).toHaveBeenCalledTimes(1); + const error = new Error('test error'); + rerender({ ...initialProps, workletError: 'test error' }); + await waitFor(() => expect(result.current.state).toEqual({ status: 'ERROR', error })); }); - it('should retry initialization when retry() is called', async () => { - const error = new Error('some error'); - mockWalletStore.setState({ - activeWalletId: 'user1', - walletLoadingState: { type: 'error', error } as WalletLoadingState + it('should report LOCKED, not READY, when the ready wallet does not match activeWalletId', async () => { + const { result } = renderHook((props) => useWalletOrchestrator(props), { + initialProps: { ...initialProps, isWorkletStarted: true, isWorkletInitialized: true }, }); - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - - const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { initialProps }); - - expect(mockUnlock).not.toHaveBeenCalled(); - - rerender(initialProps); - - expect(mockUnlock).not.toHaveBeenCalled(); act(() => { - result.current.retry(); + mockWalletStore.setState({ + activeWalletId: 'user2', + walletLoadingState: { type: 'ready', identifier: 'user1' } as WalletLoadingState, + }); }); - expect(mockWalletStore.getState().walletLoadingState.type).toBe('not_loaded'); - - rerender(initialProps); - await waitFor(() => { - expect(mockUnlock).toHaveBeenCalledWith('user1'); + expect(result.current.state).toEqual({ status: 'LOCKED', walletId: 'user2' }); }); }); - it('should return correct WdkAppState', async () => { - const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { - initialProps: { ...initialProps, isWorkletStarted: false } + it('should report LOCKED with no walletId when wallets are known but none is active', async () => { + mockWalletStore.setState({ + walletList: [{ identifier: 'user1', exists: true }], }); - expect(result.current.state).toEqual({ status: 'INITIALIZING' }); - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: null }); + const { result } = renderHook((props) => useWalletOrchestrator(props), { initialProps }); + act(() => { mockWalletStore.setState({ activeWalletId: null }); }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'NO_WALLET' })); - - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: 'user1' }); - act(() => { - mockWalletStore.setState({ activeWalletId: 'user1' }); - }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'LOCKED', walletId: 'user1' })); - - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: 'user1', isWdkReinitialized: true }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'REINITIALIZING' })); - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: 'user1', isWorkletInitialized: true, isWdkReinitialized: false }); - act(() => { - mockWalletStore.setState({ activeWalletId: 'user1' }); + await waitFor(() => { + expect(result.current.state).toEqual({ status: 'LOCKED' }); }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'READY', walletId: 'user1' })); - - const error = new Error('test error'); - rerender({ ...initialProps, workletError: 'test error' }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'ERROR', error })); }); - - it('should mark wallet as ready when conditions are met', async () => { - mockWalletStore.setState({ - activeWalletId: 'user1', - walletLoadingState: { type: 'loading', identifier: 'user1' } as WalletLoadingState, - }); - const { rerender } = renderHook((props) => useWalletOrchestrator(props), { - initialProps: { ...initialProps, isWorkletInitialized: false } - }); + it('should return ERROR state when walletLoadingState reports an error', async () => { + const walletError = new Error('some error'); + const { result } = renderHook((props) => useWalletOrchestrator(props), { initialProps }); act(() => { - mockWalletStore.setState({ addresses: { 'user1': { 'address1': {} } } }); + mockWalletStore.setState({ + activeWalletId: 'user1', + walletLoadingState: { type: 'error', error: walletError, identifier: 'user1' } as WalletLoadingState, + }); }); - rerender({ ...initialProps, isWorkletInitialized: true }); - await waitFor(() => { - expect(mockWalletStore.getState().walletLoadingState).toEqual({ type: 'ready', identifier: 'user1' }); + expect(result.current.state).toEqual({ status: 'ERROR', error: walletError }); }); }); }); diff --git a/tests/hooks/useWdkApp.test.tsx b/tests/hooks/useWdkApp.test.tsx index 095858e..88b25bc 100644 --- a/tests/hooks/useWdkApp.test.tsx +++ b/tests/hooks/useWdkApp.test.tsx @@ -32,10 +32,8 @@ jest.spyOn(operationMutex, 'withOperationMutex').mockImplementation((_, fn) => f describe('useWdkApp', () => { let mockStore: StoreApi; - const mockRetry = jest.fn(); const mockContextValue: WdkAppContextValue = { state: { status: 'INITIALIZING' }, - retry: mockRetry, }; // Create a wrapper component that provides the mock context @@ -64,7 +62,6 @@ describe('useWdkApp', () => { const { result } = renderHook(() => useWdkApp(), { wrapper }); expect(result.current.state.status).toBe('INITIALIZING'); - expect(result.current.retry).toBe(mockRetry); }); describe('reinitializeWdk', () => { diff --git a/tests/hooks/useWorkletInitializer.test.tsx b/tests/hooks/useWorkletInitializer.test.tsx index ea6099a..072c1b8 100644 --- a/tests/hooks/useWorkletInitializer.test.tsx +++ b/tests/hooks/useWorkletInitializer.test.tsx @@ -14,23 +14,12 @@ // limitations under the License. import { renderHook, act, waitFor } from '@testing-library/react-native'; -import { create, StoreApi } from 'zustand'; -import { useWalletOrchestrator, UseWalletOrchestratorProps } from '../../src/hooks/internal/useWalletOrchestrator'; -import { getWalletStore, WalletStore, WalletLoadingState } from '../../src/store/walletStore'; -import * as useWalletManager from '../../src/hooks/useWalletManager'; -import { WalletSetupService } from '../../src/services/walletSetupService'; import { useWorkletInitializer, UseWorkletInitializerProps } from '../../src/hooks/internal/useWorkletInitializer'; import * as useWorklet from '../../src/hooks/internal/useWorklet'; import { WorkletLifecycleService } from '../../src/services/workletLifecycleService'; import { logError } from '../../src/utils/logger'; import { BundleConfig, WdkConfigs } from '../../src/types'; -jest.mock('../../src/store/walletStore', () => ({ - ...jest.requireActual('../../src/store/walletStore'), - getWalletStore: jest.fn(), -})); -jest.mock('../../src/hooks/useWalletManager'); -jest.mock('../../src/services/walletSetupService'); jest.mock('../../src/utils/logger', () => ({ log: jest.fn(), logError: jest.fn(), @@ -42,218 +31,6 @@ jest.mock('../../src/utils/errorUtils', () => ({ normalizeError: jest.fn(error => error), })); -const mockCreateWallet = jest.fn(); -const mockUnlock = jest.fn(); - -describe('useWalletOrchestrator', () => { - let mockWalletStore: StoreApi; - const initialProps: UseWalletOrchestratorProps = { - enableAutoInitialization: true, - currentUserId: 'user1', - isWorkletStarted: true, - isWorkletInitialized: false, - isWdkReinitialized: false, - workletError: null, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - mockWalletStore = create(() => ({ - activeWalletId: null, - walletLoadingState: { type: 'not_loaded' } as WalletLoadingState, - addresses: {}, - } as WalletStore)); - - (getWalletStore as jest.Mock).mockReturnValue(mockWalletStore); - - (useWalletManager.useWalletManager as jest.Mock).mockReturnValue({ - createWallet: mockCreateWallet, - unlock: mockUnlock, - }); - - mockCreateWallet.mockImplementation(async (walletId) => { - act(() => { - mockWalletStore.setState({ walletLoadingState: { type: 'loading', identifier: walletId } as WalletLoadingState }); - }); - await new Promise(resolve => setTimeout(resolve, 0)); - }); - mockUnlock.mockImplementation(async (walletId) => { - act(() => { - mockWalletStore.setState({ walletLoadingState: { type: 'loading', identifier: walletId } as WalletLoadingState }); - }); - await new Promise(resolve => setTimeout(resolve, 0)); - }); - }); - - it('should set activeWalletId from currentUserId if not set', async () => { - renderHook(() => useWalletOrchestrator(initialProps)); - - await waitFor(() => { - expect(mockWalletStore.getState().activeWalletId).toBe('user1'); - }); - }); - - it('should create a new wallet if one does not exist', async () => { - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(false); - - renderHook(() => useWalletOrchestrator(initialProps)); - - await waitFor(() => { - expect(mockWalletStore.getState().activeWalletId).toBe('user1'); - }); - - await waitFor(() => { - expect(WalletSetupService.hasWallet).toHaveBeenCalledWith('user1'); - expect(mockCreateWallet).toHaveBeenCalledWith('user1'); - }); - expect(mockUnlock).not.toHaveBeenCalled(); - }); - - it('should unlock an existing wallet', async () => { - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - mockWalletStore.setState({ activeWalletId: 'user1' }); - - renderHook(() => useWalletOrchestrator(initialProps)); - - await waitFor(() => { - expect(WalletSetupService.hasWallet).toHaveBeenCalledWith('user1'); - expect(mockUnlock).toHaveBeenCalledWith('user1'); - }); - expect(mockCreateWallet).not.toHaveBeenCalled(); - }); - - it('should switch wallet when currentUserId changes', async () => { - mockWalletStore.setState({ activeWalletId: 'user1', walletLoadingState: { type: 'ready', identifier: 'user1' }, addresses: { 'user1': { 'addr1': {} } } }); - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - - const { rerender } = renderHook((props) => useWalletOrchestrator(props), { initialProps }); - - await waitFor(() => expect(mockWalletStore.getState().activeWalletId).toBe('user1')); - - rerender({ ...initialProps, currentUserId: 'user2' }); - - await waitFor(() => { - expect(mockWalletStore.getState().activeWalletId).toBe('user2'); - expect(WalletSetupService.hasWallet).toHaveBeenCalledWith('user2'); - expect(mockUnlock).toHaveBeenCalledWith('user2'); - }); - }); - - it('should not re-initialize on authentication error and return ERROR state', async () => { - const authError = new Error('user cancel'); - mockUnlock.mockRejectedValue(authError); - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - - const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { initialProps: { ...initialProps, currentUserId: 'user1' } }); - - act(() => { - mockWalletStore.setState({ activeWalletId: 'user1' }); - }); - - await waitFor(() => { - expect(mockUnlock).toHaveBeenCalledTimes(1); - }); - - act(() => { - mockWalletStore.setState({ walletLoadingState: { type: 'error', error: authError, identifier: 'user1' } }); - }); - - rerender({ ...initialProps, currentUserId: 'user1' }); - - await waitFor(() => { - expect(result.current.state).toEqual({ status: 'ERROR', error: authError }); - }); - - rerender({ ...initialProps, currentUserId: 'user1' }); - await act(async () => { await new Promise(res => setTimeout(res, 10)) }); - - expect(mockUnlock).toHaveBeenCalledTimes(1); - }); - - it('should retry initialization when retry() is called', async () => { - const error = new Error('some error'); - mockWalletStore.setState({ - activeWalletId: 'user1', - walletLoadingState: { type: 'error', error } as WalletLoadingState - }); - (WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true); - - const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { initialProps }); - - expect(mockUnlock).not.toHaveBeenCalled(); - - rerender(initialProps); - - expect(mockUnlock).not.toHaveBeenCalled(); - - act(() => { - result.current.retry(); - }); - - expect(mockWalletStore.getState().walletLoadingState.type).toBe('not_loaded'); - - rerender(initialProps); - - await waitFor(() => { - expect(mockUnlock).toHaveBeenCalledWith('user1'); - }); - }); - - it('should return correct WdkAppState', async () => { - const { result, rerender } = renderHook((props) => useWalletOrchestrator(props), { - initialProps: { ...initialProps, isWorkletStarted: false } - }); - expect(result.current.state).toEqual({ status: 'INITIALIZING' }); - - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: null }); - act(() => { - mockWalletStore.setState({ activeWalletId: null }); - }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'NO_WALLET' })); - - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: 'user1' }); - act(() => { - mockWalletStore.setState({ activeWalletId: 'user1' }); - }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'LOCKED', walletId: 'user1' })); - - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: 'user1', isWdkReinitialized: true }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'REINITIALIZING' })); - - rerender({ ...initialProps, isWorkletStarted: true, currentUserId: 'user1', isWorkletInitialized: true, isWdkReinitialized: false }); - act(() => { - mockWalletStore.setState({ activeWalletId: 'user1' }); - }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'READY', walletId: 'user1' })); - - const error = new Error('test error'); - rerender({ ...initialProps, workletError: 'test error' }); - await waitFor(() => expect(result.current.state).toEqual({ status: 'ERROR', error })); - }); - - it('should mark wallet as ready when conditions are met', async () => { - mockWalletStore.setState({ - activeWalletId: 'user1', - walletLoadingState: { type: 'loading', identifier: 'user1' } as WalletLoadingState, - }); - - const { rerender } = renderHook((props) => useWalletOrchestrator(props), { - initialProps: { ...initialProps, isWorkletInitialized: false } - }); - - act(() => { - mockWalletStore.setState({ addresses: { 'user1': { 'address1': {} } } }); - }); - - rerender({ ...initialProps, isWorkletInitialized: true }); - - await waitFor(() => { - expect(mockWalletStore.getState().walletLoadingState).toEqual({ type: 'ready', identifier: 'user1' }); - }); - }); -}); - describe('useWorkletInitializer', () => { const mockUseWorklet = useWorklet.useWorklet as jest.Mock; const mockStartWorklet = WorkletLifecycleService.startWorklet as jest.Mock; diff --git a/tests/provider/WdkAppProvider.test.tsx b/tests/provider/WdkAppProvider.test.tsx index 935c31d..0ba3e38 100644 --- a/tests/provider/WdkAppProvider.test.tsx +++ b/tests/provider/WdkAppProvider.test.tsx @@ -13,8 +13,8 @@ // limitations under the License. import React from 'react'; -import { View, Text, Button } from 'react-native'; -import { render, screen, fireEvent } from '@testing-library/react-native'; +import { View, Text } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; import { WdkAppProvider } from '../../src/provider/WdkAppProvider'; import { useWdkApp } from '../../src/hooks/useWdkApp'; import { useWalletOrchestrator } from '../../src/hooks/internal/useWalletOrchestrator'; @@ -33,14 +33,11 @@ jest.mock('../../src/hooks/internal/useWorkletInitializer', () => ({ error: null, }), })); -jest.mock('../../src/hooks/internal/useAppLifecycle', () => ({ - useAppLifecycle: jest.fn(), -})); const mockUseWalletOrchestrator = useWalletOrchestrator as jest.Mock; const DummyConsumer = () => { - const { state, retry } = useWdkApp(); + const { state } = useWdkApp(); return ( @@ -48,7 +45,6 @@ const DummyConsumer = () => { {state.status === 'ERROR' && ( {state.error.message} )} -