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}
)}
-
);
};
@@ -85,7 +81,6 @@ describe('WdkAppProvider', () => {
it('provides the INITIALIZING state', () => {
mockUseWalletOrchestrator.mockReturnValue({
state: { status: 'INITIALIZING' },
- retry: jest.fn(),
});
render(
@@ -100,7 +95,6 @@ describe('WdkAppProvider', () => {
it('provides the READY state', () => {
mockUseWalletOrchestrator.mockReturnValue({
state: { status: 'READY', walletId: 'test-wallet' },
- retry: jest.fn(),
});
render(
@@ -116,36 +110,17 @@ describe('WdkAppProvider', () => {
const errorMessage = 'Something went wrong';
mockUseWalletOrchestrator.mockReturnValue({
state: { status: 'ERROR', error: new Error(errorMessage) },
- retry: jest.fn(),
});
-
+
render(
);
-
+
expect(screen.getByTestId('status-text').props.children).toBe('ERROR');
expect(screen.getByTestId('error-message').props.children).toBe(errorMessage);
});
-
- it('allows a child to call the retry function', () => {
- const retryMock = jest.fn();
- mockUseWalletOrchestrator.mockReturnValue({
- state: { status: 'ERROR', error: new Error('Needs retry') },
- retry: retryMock,
- });
-
- render(
-
-
-
- );
-
- fireEvent.press(screen.getByTestId('retry-button'));
-
- expect(retryMock).toHaveBeenCalledTimes(1);
- });
});
describe('Props validation', () => {
diff --git a/tests/services/walletSwitchingService.test.ts b/tests/services/walletSwitchingService.test.ts
deleted file mode 100644
index 34e5523..0000000
--- a/tests/services/walletSwitchingService.test.ts
+++ /dev/null
@@ -1,270 +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.
-
-/**
- * Tests for WalletSwitchingService
- *
- * Tests wallet switching operations
- */
-
-import { WalletSwitchingService } from '../../src/services/walletSwitchingService'
-import { WalletSetupService } from '../../src/services/walletSetupService'
-import { WorkletLifecycleService } from '../../src/services/workletLifecycleService'
-import { getWalletStore } from '../../src/store/walletStore'
-
-// Mock dependencies
-jest.mock('../../src/services/walletSetupService', () => ({
- WalletSetupService: {
- hasWallet: jest.fn(),
- loadExistingWallet: jest.fn(),
- clearCredentialsCache: jest.fn(),
- },
-}))
-
-jest.mock('../../src/services/workletLifecycleService', () => ({
- WorkletLifecycleService: {
- ensureWorkletStarted: jest.fn(),
- initializeWDK: jest.fn(),
- },
-}))
-
-jest.mock('../../src/store/walletStore', () => ({
- getWalletStore: jest.fn(),
- updateWalletLoadingState: jest.fn((prev: any, state: any) => ({ ...prev, ...state })),
- getWalletIdFromLoadingState: jest.fn((state: any) => {
- if (state && typeof state === 'object' && 'identifier' in state) {
- return state.identifier
- }
- return null
- }),
-}))
-
-jest.mock('../../src/utils/logger', () => ({
- log: jest.fn(),
- logError: jest.fn(),
- logWarn: jest.fn(),
-}))
-
-describe('WalletSwitchingService', () => {
- let mockWalletStore: any
-
- beforeEach(() => {
- jest.clearAllMocks()
-
- mockWalletStore = {
- getState: jest.fn(() => ({
- activeWalletId: null,
- walletLoadingState: { type: 'not_loaded' },
- })),
- setState: jest.fn(),
- }
-
- ;(getWalletStore as jest.Mock).mockReturnValue(mockWalletStore)
- })
-
- describe('switchToWallet', () => {
- it('should switch to wallet successfully', async () => {
- const walletId = 'test-wallet-1'
- const credentials = {
- encryptionKey: 'test-key',
- encryptedSeed: 'test-seed',
- }
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: null,
- })
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
- ;(WorkletLifecycleService.ensureWorkletStarted as jest.Mock).mockResolvedValue(undefined)
- ;(WalletSetupService.loadExistingWallet as jest.Mock).mockResolvedValue(credentials)
- ;(WorkletLifecycleService.initializeWDK as jest.Mock).mockResolvedValue(undefined)
-
- await WalletSwitchingService.switchToWallet(walletId)
-
- expect(WalletSetupService.hasWallet).toHaveBeenCalledWith(walletId)
- expect(WorkletLifecycleService.ensureWorkletStarted).toHaveBeenCalled()
- expect(WalletSetupService.loadExistingWallet).toHaveBeenCalledWith(walletId)
- expect(WorkletLifecycleService.initializeWDK).toHaveBeenCalledWith({
- encryptionKey: credentials.encryptionKey,
- encryptedSeed: credentials.encryptedSeed,
- })
- // setState is called with a function that updates the state
- expect(mockWalletStore.setState).toHaveBeenCalled()
- // Find the call that sets activeWalletId (might be the last one)
- const setStateCalls = mockWalletStore.setState.mock.calls
- let foundActiveWalletId = false
- for (const call of setStateCalls) {
- const setStateCall = call[0]
- if (typeof setStateCall === 'function') {
- const prevState = mockWalletStore.getState()
- const newState = setStateCall(prevState)
- if (newState && newState.activeWalletId === walletId) {
- foundActiveWalletId = true
- break
- }
- } else if (setStateCall && setStateCall.activeWalletId === walletId) {
- foundActiveWalletId = true
- break
- }
- }
- expect(foundActiveWalletId).toBe(true)
- })
-
- it('should return early if already on the wallet', async () => {
- const walletId = 'test-wallet-1'
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: walletId,
- })
-
- await WalletSwitchingService.switchToWallet(walletId)
-
- expect(WalletSetupService.hasWallet).not.toHaveBeenCalled()
- expect(WalletSetupService.loadExistingWallet).not.toHaveBeenCalled()
- })
-
- it('should throw error if wallet does not exist', async () => {
- const walletId = 'non-existent-wallet'
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: null,
- })
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(false)
-
- await expect(WalletSwitchingService.switchToWallet(walletId)).rejects.toThrow(
- `Wallet with identifier "${walletId}" does not exist`
- )
-
- expect(WalletSetupService.hasWallet).toHaveBeenCalledWith(walletId)
- expect(WalletSetupService.loadExistingWallet).not.toHaveBeenCalled()
- })
-
- it('should clear credentials cache when switching from another wallet', async () => {
- const fromWalletId = 'wallet-1'
- const toWalletId = 'wallet-2'
- const credentials = {
- encryptionKey: 'test-key',
- encryptedSeed: 'test-seed',
- }
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: fromWalletId,
- })
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
- ;(WorkletLifecycleService.ensureWorkletStarted as jest.Mock).mockResolvedValue(undefined)
- ;(WalletSetupService.loadExistingWallet as jest.Mock).mockResolvedValue(credentials)
- ;(WorkletLifecycleService.initializeWDK as jest.Mock).mockResolvedValue(undefined)
-
- await WalletSwitchingService.switchToWallet(toWalletId)
-
- // Note: clearCredentialsCache might not be called if the implementation changed
- // Check if it was called, but don't fail if it wasn't (implementation might have changed)
- // setState is called with a function that updates the state
- expect(mockWalletStore.setState).toHaveBeenCalled()
- // Find the call that sets activeWalletId (might be the last one)
- const setStateCalls = mockWalletStore.setState.mock.calls
- let foundActiveWalletId = false
- for (const call of setStateCalls) {
- const setStateCall = call[0]
- if (typeof setStateCall === 'function') {
- const prevState = mockWalletStore.getState()
- const newState = setStateCall(prevState)
- if (newState && newState.activeWalletId === toWalletId) {
- foundActiveWalletId = true
- break
- }
- } else if (setStateCall && setStateCall.activeWalletId === toWalletId) {
- foundActiveWalletId = true
- break
- }
- }
- expect(foundActiveWalletId).toBe(true)
- })
-
- it('should use autoStartWorklet option', async () => {
- const walletId = 'test-wallet-1'
- const credentials = {
- encryptionKey: 'test-key',
- encryptedSeed: 'test-seed',
- }
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: null,
- })
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
- ;(WorkletLifecycleService.ensureWorkletStarted as jest.Mock).mockReturnValue(undefined)
- ;(WalletSetupService.loadExistingWallet as jest.Mock).mockResolvedValue(credentials)
- ;(WorkletLifecycleService.initializeWDK as jest.Mock).mockResolvedValue(undefined)
-
- await WalletSwitchingService.switchToWallet(walletId)
-
- expect(WorkletLifecycleService.ensureWorkletStarted).toHaveBeenCalled()
- })
-
- it('should handle errors during wallet switching', async () => {
- const walletId = 'test-wallet-1'
- const error = new Error('Failed to load wallet')
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: null,
- })
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
- ;(WorkletLifecycleService.ensureWorkletStarted as jest.Mock).mockResolvedValue(undefined)
- ;(WalletSetupService.loadExistingWallet as jest.Mock).mockRejectedValue(error)
-
- await expect(WalletSwitchingService.switchToWallet(walletId)).rejects.toThrow('Failed to load wallet')
- })
- })
-
- describe('canSwitchToWallet', () => {
- it('should return true if wallet exists', async () => {
- const walletId = 'test-wallet-1'
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
-
- const result = await WalletSwitchingService.canSwitchToWallet(walletId)
-
- expect(result).toBe(true)
- expect(WalletSetupService.hasWallet).toHaveBeenCalledWith(walletId)
- })
-
- it('should return false if wallet does not exist', async () => {
- const walletId = 'non-existent-wallet'
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(false)
-
- const result = await WalletSwitchingService.canSwitchToWallet(walletId)
-
- expect(result).toBe(false)
- expect(WalletSetupService.hasWallet).toHaveBeenCalledWith(walletId)
- })
-
- it('should return false on error', async () => {
- const walletId = 'test-wallet-1'
- const error = new Error('Check failed')
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockRejectedValue(error)
-
- const result = await WalletSwitchingService.canSwitchToWallet(walletId)
-
- expect(result).toBe(false)
- expect(WalletSetupService.hasWallet).toHaveBeenCalledWith(walletId)
- })
- })
-})
-
diff --git a/tests/store/walletStore.test.ts b/tests/store/walletStore.test.ts
index cb4833c..41ddd4b 100644
--- a/tests/store/walletStore.test.ts
+++ b/tests/store/walletStore.test.ts
@@ -288,7 +288,8 @@ describe('walletStore', () => {
expect(stateBeforeRehydration.addresses).toEqual(mockState.addresses)
expect(stateBeforeRehydration.balances).toEqual(mockState.balances)
expect(stateBeforeRehydration.lastBalanceUpdate).toEqual(mockState.lastBalanceUpdate)
- expect(stateBeforeRehydration.activeWalletId).toEqual(mockState.activeWalletId)
+
+ expect(stateBeforeRehydration.activeWalletId).toBeNull()
})
it('should do nothing if state is undefined during rehydration', () => {
diff --git a/tests/utils/raceConditions.test.ts b/tests/utils/raceConditions.test.ts
index 4d5b5ac..a4fcc80 100644
--- a/tests/utils/raceConditions.test.ts
+++ b/tests/utils/raceConditions.test.ts
@@ -18,25 +18,9 @@
* Tests concurrent operations, state synchronization, and mutex behavior
*/
-import { WalletSwitchingService } from '../../src/services/walletSwitchingService'
import { getWalletStore } from '../../src/store/walletStore'
import { acquireOperationMutex, withOperationMutex, isOperationInProgress } from '../../src/utils/operationMutex'
-jest.mock('../../src/services/walletSetupService', () => ({
- WalletSetupService: {
- hasWallet: jest.fn(),
- loadExistingWallet: jest.fn(),
- clearCredentialsCache: jest.fn(),
- },
-}))
-
-jest.mock('../../src/services/workletLifecycleService', () => ({
- WorkletLifecycleService: {
- ensureWorkletStarted: jest.fn(),
- initializeWDK: jest.fn(),
- },
-}))
-
jest.mock('../../src/store/walletStore', () => {
const actual = jest.requireActual('../../src/store/walletStore')
return {
@@ -186,87 +170,6 @@ describe('Race Conditions', () => {
})
})
- describe('Wallet Switching Race Conditions', () => {
- it('should prevent concurrent wallet switches', async () => {
- const { WalletSetupService } = require('../../src/services/walletSetupService')
- const { WorkletLifecycleService } = require('../../src/services/workletLifecycleService')
-
- const walletId1 = 'wallet-1'
- const walletId2 = 'wallet-2'
- const credentials = {
- encryptionKey: 'test-key',
- encryptedSeed: 'test-seed',
- }
-
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: null,
- walletLoadingState: { type: 'not_loaded' },
- isOperationInProgress: false,
- currentOperation: null,
- })
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
- ;(WorkletLifecycleService.ensureWorkletStarted as jest.Mock).mockResolvedValue(undefined)
- ;(WalletSetupService.loadExistingWallet as jest.Mock).mockResolvedValue(credentials)
- ;(WorkletLifecycleService.initializeWDK as jest.Mock).mockImplementation(
- async () => {
- // Simulate slow operation
- await new Promise((resolve) => setTimeout(resolve, 100))
- }
- )
-
- // Start two concurrent switches
- const promise1 = WalletSwitchingService.switchToWallet(walletId1)
- const promise2 = WalletSwitchingService.switchToWallet(walletId2)
-
- // Second switch should be blocked by mutex
- await expect(promise2).rejects.toThrow('Another operation is in progress')
-
- // First switch should complete
- await promise1
- expect(WalletSetupService.loadExistingWallet).toHaveBeenCalledWith(walletId1)
- expect(WalletSetupService.loadExistingWallet).not.toHaveBeenCalledWith(walletId2)
- })
-
- it('should handle rapid sequential wallet switches', async () => {
- const { WalletSetupService } = require('../../src/services/walletSetupService')
- const { WorkletLifecycleService } = require('../../src/services/workletLifecycleService')
-
- const walletId1 = 'wallet-1'
- const walletId2 = 'wallet-2'
- const credentials = {
- encryptionKey: 'test-key',
- encryptedSeed: 'test-seed',
- }
-
- ;(WalletSetupService.hasWallet as jest.Mock).mockResolvedValue(true)
- ;(WorkletLifecycleService.ensureWorkletStarted as jest.Mock).mockResolvedValue(undefined)
- ;(WalletSetupService.loadExistingWallet as jest.Mock).mockResolvedValue(credentials)
- ;(WorkletLifecycleService.initializeWDK as jest.Mock).mockResolvedValue(undefined)
-
- // First switch
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: null,
- walletLoadingState: { type: 'not_loaded' },
- isOperationInProgress: false,
- currentOperation: null,
- })
- await WalletSwitchingService.switchToWallet(walletId1)
-
- // Second switch (after first completes)
- mockWalletStore.getState.mockReturnValue({
- activeWalletId: walletId1,
- walletLoadingState: { type: 'ready', identifier: walletId1 },
- isOperationInProgress: false,
- currentOperation: null,
- })
- await WalletSwitchingService.switchToWallet(walletId2)
-
- expect(WalletSetupService.loadExistingWallet).toHaveBeenCalledWith(walletId1)
- expect(WalletSetupService.loadExistingWallet).toHaveBeenCalledWith(walletId2)
- })
- })
-
describe('State Synchronization', () => {
it('should maintain consistent state during concurrent updates', () => {
const stateUpdates: any[] = []