diff --git a/src/screens/WalletConnectScreen.tsx b/src/screens/WalletConnectScreen.tsx index 2a4dd3dc..27369d45 100644 --- a/src/screens/WalletConnectScreen.tsx +++ b/src/screens/WalletConnectScreen.tsx @@ -10,6 +10,8 @@ import { Alert, ActivityIndicator, Platform, + Modal, + FlatList, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -21,6 +23,8 @@ import walletServiceManager, { WalletConnection, TokenBalance } from '../service import { TICKER_TO_COINGECKO_ID } from '../services/priceService'; import { useTokenPrices } from '../hooks/useTokenPrices'; import { useWalletStore } from '../store'; +import { useNetworkStore } from '../store/networkStore'; +import { ALL_NETWORKS, Network } from '../config/networks'; import { RootStackParamList } from '../navigation/types'; import { useThemeColors } from '../hooks/useThemeColors'; @@ -33,7 +37,8 @@ const WalletConnectScreen: React.FC = () => { const { open } = useAppKit(); const { address, isConnected, chainId } = useAppKitAccount(); const { walletProvider } = useAppKitProvider(); - const { connectWallet, disconnect } = useWalletStore(); + const { disconnect, networkMismatch, setPreferredNetwork } = useWalletStore(); + const { currentNetwork, setNetwork: setNetworkStore } = useNetworkStore(); const [isConnecting, setIsConnecting] = useState(false); const [connection, setConnection] = useState(null); @@ -66,7 +71,6 @@ const WalletConnectScreen: React.FC = () => { }; setConnection(realConnection); walletServiceManager.setConnection(realConnection); - connectWallet(); loadTokenBalances(); } else if (!isConnected) { void walletServiceManager.disconnectWallet(); @@ -162,6 +166,12 @@ const WalletConnectScreen: React.FC = () => { } }; + const handleSelectNetwork = async (network: Network) => { + setShowNetworkPicker(false); + await setPreferredNetwork(network.id); + await setNetworkStore(network.id); + }; + const formatAddress = (address: string): string => { return `${address.slice(0, 6)}...${address.slice(-4)}`; }; @@ -284,6 +294,46 @@ const WalletConnectScreen: React.FC = () => { ) : ( + {/* Network Mismatch Banner (#69) */} + {networkMismatch && ( + + โš ๏ธ + + Network Mismatch + + Wallet is on {getChainName(networkMismatch.connectedChainId)}, but preferred + network is {networkMismatch.preferredNetwork.name}. + + + setShowNetworkPicker(true)} + accessibilityRole="button" + accessibilityLabel="Switch preferred network"> + Switch + + + )} + + {/* Network Selector (#69) */} + + + + Preferred Network + + {currentNetwork?.name ?? 'Not set'} + + + setShowNetworkPicker(true)} + accessibilityRole="button" + accessibilityLabel="Change preferred network"> + Change + + + + {/* Connection Status */} @@ -471,6 +521,54 @@ const WalletConnectScreen: React.FC = () => { )} + + {/* Network Picker Modal (#69) */} + setShowNetworkPicker(false)}> + + + + Select Network + setShowNetworkPicker(false)} + accessibilityRole="button" + accessibilityLabel="Close network picker"> + โœ• + + + item.id} + renderItem={({ item }) => ( + handleSelectNetwork(item)} + accessibilityRole="button" + accessibilityLabel={`Select ${item.name}`}> + + {item.type === 'stellar' ? 'โญ' : '๐Ÿ”ท'} + + + {item.name} + + {item.type.toUpperCase()}{item.isTestnet ? ' ยท Testnet' : ''} + + + {currentNetwork?.id === item.id && ( + โœ“ + )} + + )} + /> + + + ); }; diff --git a/src/store/__tests__/integration.test.ts b/src/store/__tests__/integration.test.ts index 8b14916d..3320161a 100644 --- a/src/store/__tests__/integration.test.ts +++ b/src/store/__tests__/integration.test.ts @@ -8,8 +8,8 @@ * Covers: * - subscriptionStore: add/fetch, update (field preservation), delete (cleanup), * persistence, multi-action workflows, error recovery - * - walletStore: connect/persist, load-from-storage, disconnect cleanup, - * multi-action workflow, crypto stream create โ†’ cancel + * - walletStore (#62 + #69): consolidated with walletServiceManager as single + * source of truth; network mismatch detection; crypto stream create โ†’ cancel */ import { act } from 'react'; @@ -18,6 +18,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSubscriptionStore } from '../subscriptionStore'; import { useInvoiceStore } from '../invoiceStore'; import { useWalletStore } from '../walletStore'; +import { walletServiceManager } from '../../services/walletService'; import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; import { BILLING_CONVERSIONS } from '../../utils/constants/values'; import { TaxType } from '../../types/invoice'; @@ -51,6 +52,79 @@ jest.mock('../../services/notificationService', () => ({ presentLocalNotification: jest.fn(() => Promise.resolve()), })); +// Mock networkService to avoid AsyncStorage calls in walletStore.setPreferredNetwork. +jest.mock('../../services/networkService', () => ({ + networkService: { + getSelectedNetwork: jest.fn(() => Promise.resolve(null)), + setSelectedNetwork: jest.fn(() => Promise.resolve(true)), + checkNetworkHealth: jest.fn(() => Promise.resolve({ healthy: true })), + getAvailableNetworks: jest.fn(() => Promise.resolve([])), + }, +})); + +// Mock walletService so tests don't require ethers / Superfluid / native modules. +// We expose a real WalletServiceManager-like singleton so the store's listener +// subscription and setConnection/getConnection calls work correctly. +jest.mock('../../services/walletService', () => { + type Listener = (conn: MockConnection | null) => void; + type MockConnection = { address: string; chainId: number; isConnected: boolean }; + + class MockWalletServiceManager { + private static _instance: MockWalletServiceManager; + private _connection: MockConnection | null = null; + private _listeners: Listener[] = []; + + static getInstance() { + if (!MockWalletServiceManager._instance) { + MockWalletServiceManager._instance = new MockWalletServiceManager(); + } + return MockWalletServiceManager._instance; + } + + setConnection(conn: MockConnection | null) { + this._connection = conn; + this._listeners.forEach((l) => l(conn)); + } + + getConnection() { + return this._connection; + } + + addListener(l: Listener) { + this._listeners.push(l); + } + + removeListener(l: Listener) { + const i = this._listeners.indexOf(l); + if (i > -1) this._listeners.splice(i, 1); + } + + async disconnectWallet() { + this.setConnection(null); + } + + async initialize() {} + + isConnected() { + return this._connection?.isConnected ?? false; + } + } + + const instance = MockWalletServiceManager.getInstance(); + + return { + WalletServiceManager: MockWalletServiceManager, + walletServiceManager: instance, + PaymentMethodService: { getInstance: () => ({ canAddMethod: jest.fn(), validatePaymentMethodForm: jest.fn(), isDuplicateMethod: jest.fn(), generateId: jest.fn(), verifyPaymentMethod: jest.fn(), processPaymentWithFallback: jest.fn(), getExpiredMethods: jest.fn(() => []), getExpiringSoonMethods: jest.fn(() => []), checkExpiry: jest.fn(), getPrimaryMethods: jest.fn(() => []), getBackupMethods: jest.fn(() => []), getFallbackMethods: jest.fn(() => []), detectTokenContractUpgrade: jest.fn() }) }, + PaymentMethodError: class PaymentMethodError extends Error { constructor(public code: string, msg: string) { super(msg); } }, + PaymentMethodErrorCode: { DUPLICATE: 'DUPLICATE', INVALID_TOKEN: 'INVALID_TOKEN', MAX_METHODS: 'MAX_METHODS', VERIFICATION_FAILED: 'VERIFICATION_FAILED' }, + WalletError: class WalletError extends Error {}, + WalletErrorCode: {}, + errorTracker: { record: jest.fn() }, + default: instance, + }; +}); + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const emptyStats = { totalActive: 0, @@ -472,8 +546,13 @@ describe('subscriptionStore integration', () => { }); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -// walletStore +// walletStore โ€” consolidated with walletServiceManager (#62) // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +// walletServiceManager is the single source of truth for connection state. +// The store derives address/chainId/network/isConnected from it via a listener. +// There is no longer a `wallet` property or a `@subtrackr_wallet` storage key. + describe('walletStore integration', () => { // โ”€โ”€ Connect loads persisted data and syncs with service โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ it('connectWallet loads persisted payment methods and attempts', async () => { @@ -498,8 +577,20 @@ describe('walletStore integration', () => { ]); mockMemoryStore.set('@subtrackr_payment_methods', mockPaymentMethods); + const { address, isConnected, isLoading } = useWalletStore.getState(); + expect(address).toBeNull(); + expect(isConnected).toBe(false); + expect(isLoading).toBe(false); + }); + + // โ”€โ”€ syncWalletConnection updates store via walletServiceManager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('syncWalletConnection sets connection state through walletServiceManager', async () => { await act(async () => { - await useWalletStore.getState().connectWallet(); + await useWalletStore.getState().syncWalletConnection({ + address: '0xDEF456', + chainId: 137, + network: 'Polygon', + }); }); const { paymentMethods, isLoading } = useWalletStore.getState(); @@ -598,7 +689,35 @@ describe('walletStore integration', () => { // walletService.disconnectWallet is being called, no need to mock AsyncStorage await act(async () => { - await useWalletStore.getState().disconnect(); + await useWalletStore.getState().connectWallet(); + }); + + useWalletStore.setState({ + preferredNetwork: { id: 'ethereum', name: 'Ethereum', type: 'evm', chainId: 1 }, + networkMismatch: { connectedChainId: 137, preferredNetwork: { id: 'ethereum', name: 'Ethereum', type: 'evm', chainId: 1 } }, + }); + + act(() => { + useWalletStore.getState().detectNetworkMismatch(); + }); + + expect(useWalletStore.getState().networkMismatch).toBeNull(); + }); + + // โ”€โ”€ Network detection (#69): Stellar networks never mismatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('detectNetworkMismatch ignores Stellar networks (no numeric chainId)', async () => { + walletServiceManager.setConnection({ address: '0xABC', chainId: 1, isConnected: true }); + + await act(async () => { + await useWalletStore.getState().connectWallet(); + }); + + useWalletStore.setState({ + preferredNetwork: { id: 'stellar-testnet', name: 'Stellar Testnet', type: 'stellar' }, + }); + + act(() => { + useWalletStore.getState().detectNetworkMismatch(); }); // Should complete without error in normal flow diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index a87787c3..19c8fe2d 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -17,6 +17,15 @@ import { PaymentMethodExpiryCheck, WalletConnection, } from '../services/walletService'; +import { networkService } from '../services/networkService'; +import { ALL_NETWORKS, Network } from '../config/networks'; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface NetworkMismatch { + connectedChainId: number; + preferredNetwork: Network; +} interface WalletState { // Connection state from service