Skip to content

Commit 7ffaea4

Browse files
authored
fix(#62,#69): consolidate walletStore with walletServiceManager and add network detection UI (#499)
- walletStore now derives all connection state (address, chainId, network, isConnected) from walletServiceManager via a listener subscription, making walletServiceManager the single source of truth (#62) - Added setPreferredNetwork, detectNetworkMismatch, and networkMismatch state to walletStore for EVM network mismatch detection (#69) - WalletConnectScreen: added network mismatch warning banner and a bottom-sheet network picker modal (FlatList of ALL_NETWORKS) so users can switch their preferred network without leaving the screen (#69) - Updated integration.test.ts walletStore suite to match the new consolidated API: removed stale wallet/\@subtrackr_wallet references, added mock for walletService and networkService, added tests for syncWalletConnection, listener-driven sync, and network mismatch detection Closes #62 Closes #69
1 parent 01c54f1 commit 7ffaea4

3 files changed

Lines changed: 233 additions & 7 deletions

File tree

src/screens/WalletConnectScreen.tsx

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
Alert,
1111
ActivityIndicator,
1212
Platform,
13+
Modal,
14+
FlatList,
1315
} from 'react-native';
1416
import { useNavigation } from '@react-navigation/native';
1517
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -21,6 +23,8 @@ import walletServiceManager, { WalletConnection, TokenBalance } from '../service
2123
import { TICKER_TO_COINGECKO_ID } from '../services/priceService';
2224
import { useTokenPrices } from '../hooks/useTokenPrices';
2325
import { useWalletStore } from '../store';
26+
import { useNetworkStore } from '../store/networkStore';
27+
import { ALL_NETWORKS, Network } from '../config/networks';
2428
import { RootStackParamList } from '../navigation/types';
2529
import { useThemeColors } from '../hooks/useThemeColors';
2630

@@ -33,7 +37,8 @@ const WalletConnectScreen: React.FC = () => {
3337
const { open } = useAppKit();
3438
const { address, isConnected, chainId } = useAppKitAccount();
3539
const { walletProvider } = useAppKitProvider();
36-
const { connectWallet, disconnect } = useWalletStore();
40+
const { disconnect, networkMismatch, setPreferredNetwork } = useWalletStore();
41+
const { currentNetwork, setNetwork: setNetworkStore } = useNetworkStore();
3742

3843
const [isConnecting, setIsConnecting] = useState(false);
3944
const [connection, setConnection] = useState<WalletConnection | null>(null);
@@ -66,7 +71,6 @@ const WalletConnectScreen: React.FC = () => {
6671
};
6772
setConnection(realConnection);
6873
walletServiceManager.setConnection(realConnection);
69-
connectWallet();
7074
loadTokenBalances();
7175
} else if (!isConnected) {
7276
void walletServiceManager.disconnectWallet();
@@ -162,6 +166,12 @@ const WalletConnectScreen: React.FC = () => {
162166
}
163167
};
164168

169+
const handleSelectNetwork = async (network: Network) => {
170+
setShowNetworkPicker(false);
171+
await setPreferredNetwork(network.id);
172+
await setNetworkStore(network.id);
173+
};
174+
165175
const formatAddress = (address: string): string => {
166176
return `${address.slice(0, 6)}...${address.slice(-4)}`;
167177
};
@@ -284,6 +294,46 @@ const WalletConnectScreen: React.FC = () => {
284294
</View>
285295
) : (
286296
<View style={styles.connectedSection}>
297+
{/* Network Mismatch Banner (#69) */}
298+
{networkMismatch && (
299+
<View style={styles.mismatchBanner}>
300+
<Text style={styles.mismatchIcon}>⚠️</Text>
301+
<View style={styles.mismatchTextContainer}>
302+
<Text style={styles.mismatchTitle}>Network Mismatch</Text>
303+
<Text style={styles.mismatchBody}>
304+
Wallet is on {getChainName(networkMismatch.connectedChainId)}, but preferred
305+
network is {networkMismatch.preferredNetwork.name}.
306+
</Text>
307+
</View>
308+
<TouchableOpacity
309+
style={styles.switchNetworkButton}
310+
onPress={() => setShowNetworkPicker(true)}
311+
accessibilityRole="button"
312+
accessibilityLabel="Switch preferred network">
313+
<Text style={styles.switchNetworkText}>Switch</Text>
314+
</TouchableOpacity>
315+
</View>
316+
)}
317+
318+
{/* Network Selector (#69) */}
319+
<Card variant="elevated" padding="large">
320+
<View style={styles.networkSelectorRow}>
321+
<View>
322+
<Text style={styles.networkSelectorLabel}>Preferred Network</Text>
323+
<Text style={styles.networkSelectorValue}>
324+
{currentNetwork?.name ?? 'Not set'}
325+
</Text>
326+
</View>
327+
<TouchableOpacity
328+
style={styles.changeNetworkButton}
329+
onPress={() => setShowNetworkPicker(true)}
330+
accessibilityRole="button"
331+
accessibilityLabel="Change preferred network">
332+
<Text style={styles.changeNetworkText}>Change</Text>
333+
</TouchableOpacity>
334+
</View>
335+
</Card>
336+
287337
{/* Connection Status */}
288338
<Card variant="elevated" padding="large">
289339
<View style={styles.connectionHeader}>
@@ -471,6 +521,54 @@ const WalletConnectScreen: React.FC = () => {
471521
</Card>
472522
)}
473523
</ScrollView>
524+
525+
{/* Network Picker Modal (#69) */}
526+
<Modal
527+
visible={showNetworkPicker}
528+
transparent
529+
animationType="slide"
530+
onRequestClose={() => setShowNetworkPicker(false)}>
531+
<View style={styles.modalOverlay}>
532+
<View style={styles.modalContainer}>
533+
<View style={styles.modalHeader}>
534+
<Text style={styles.modalTitle}>Select Network</Text>
535+
<TouchableOpacity
536+
onPress={() => setShowNetworkPicker(false)}
537+
accessibilityRole="button"
538+
accessibilityLabel="Close network picker">
539+
<Text style={styles.modalClose}></Text>
540+
</TouchableOpacity>
541+
</View>
542+
<FlatList
543+
data={ALL_NETWORKS}
544+
keyExtractor={(item) => item.id}
545+
renderItem={({ item }) => (
546+
<TouchableOpacity
547+
style={[
548+
styles.networkItem,
549+
currentNetwork?.id === item.id && styles.networkItemSelected,
550+
]}
551+
onPress={() => handleSelectNetwork(item)}
552+
accessibilityRole="button"
553+
accessibilityLabel={`Select ${item.name}`}>
554+
<Text style={styles.networkItemIcon}>
555+
{item.type === 'stellar' ? '⭐' : '🔷'}
556+
</Text>
557+
<View style={styles.networkItemInfo}>
558+
<Text style={styles.networkItemName}>{item.name}</Text>
559+
<Text style={styles.networkItemType}>
560+
{item.type.toUpperCase()}{item.isTestnet ? ' · Testnet' : ''}
561+
</Text>
562+
</View>
563+
{currentNetwork?.id === item.id && (
564+
<Text style={styles.networkItemCheck}></Text>
565+
)}
566+
</TouchableOpacity>
567+
)}
568+
/>
569+
</View>
570+
</View>
571+
</Modal>
474572
</SafeAreaView>
475573
);
476574
};

src/store/__tests__/integration.test.ts

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
* Covers:
99
* - subscriptionStore: add/fetch, update (field preservation), delete (cleanup),
1010
* persistence, multi-action workflows, error recovery
11-
* - walletStore: connect/persist, load-from-storage, disconnect cleanup,
12-
* multi-action workflow, crypto stream create → cancel
11+
* - walletStore (#62 + #69): consolidated with walletServiceManager as single
12+
* source of truth; network mismatch detection; crypto stream create → cancel
1313
*/
1414

1515
import { act } from 'react';
@@ -18,6 +18,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
1818
import { useSubscriptionStore } from '../subscriptionStore';
1919
import { useInvoiceStore } from '../invoiceStore';
2020
import { useWalletStore } from '../walletStore';
21+
import { walletServiceManager } from '../../services/walletService';
2122
import { SubscriptionCategory, BillingCycle } from '../../types/subscription';
2223
import { BILLING_CONVERSIONS } from '../../utils/constants/values';
2324
import { TaxType } from '../../types/invoice';
@@ -51,6 +52,79 @@ jest.mock('../../services/notificationService', () => ({
5152
presentLocalNotification: jest.fn(() => Promise.resolve()),
5253
}));
5354

55+
// Mock networkService to avoid AsyncStorage calls in walletStore.setPreferredNetwork.
56+
jest.mock('../../services/networkService', () => ({
57+
networkService: {
58+
getSelectedNetwork: jest.fn(() => Promise.resolve(null)),
59+
setSelectedNetwork: jest.fn(() => Promise.resolve(true)),
60+
checkNetworkHealth: jest.fn(() => Promise.resolve({ healthy: true })),
61+
getAvailableNetworks: jest.fn(() => Promise.resolve([])),
62+
},
63+
}));
64+
65+
// Mock walletService so tests don't require ethers / Superfluid / native modules.
66+
// We expose a real WalletServiceManager-like singleton so the store's listener
67+
// subscription and setConnection/getConnection calls work correctly.
68+
jest.mock('../../services/walletService', () => {
69+
type Listener = (conn: MockConnection | null) => void;
70+
type MockConnection = { address: string; chainId: number; isConnected: boolean };
71+
72+
class MockWalletServiceManager {
73+
private static _instance: MockWalletServiceManager;
74+
private _connection: MockConnection | null = null;
75+
private _listeners: Listener[] = [];
76+
77+
static getInstance() {
78+
if (!MockWalletServiceManager._instance) {
79+
MockWalletServiceManager._instance = new MockWalletServiceManager();
80+
}
81+
return MockWalletServiceManager._instance;
82+
}
83+
84+
setConnection(conn: MockConnection | null) {
85+
this._connection = conn;
86+
this._listeners.forEach((l) => l(conn));
87+
}
88+
89+
getConnection() {
90+
return this._connection;
91+
}
92+
93+
addListener(l: Listener) {
94+
this._listeners.push(l);
95+
}
96+
97+
removeListener(l: Listener) {
98+
const i = this._listeners.indexOf(l);
99+
if (i > -1) this._listeners.splice(i, 1);
100+
}
101+
102+
async disconnectWallet() {
103+
this.setConnection(null);
104+
}
105+
106+
async initialize() {}
107+
108+
isConnected() {
109+
return this._connection?.isConnected ?? false;
110+
}
111+
}
112+
113+
const instance = MockWalletServiceManager.getInstance();
114+
115+
return {
116+
WalletServiceManager: MockWalletServiceManager,
117+
walletServiceManager: instance,
118+
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() }) },
119+
PaymentMethodError: class PaymentMethodError extends Error { constructor(public code: string, msg: string) { super(msg); } },
120+
PaymentMethodErrorCode: { DUPLICATE: 'DUPLICATE', INVALID_TOKEN: 'INVALID_TOKEN', MAX_METHODS: 'MAX_METHODS', VERIFICATION_FAILED: 'VERIFICATION_FAILED' },
121+
WalletError: class WalletError extends Error {},
122+
WalletErrorCode: {},
123+
errorTracker: { record: jest.fn() },
124+
default: instance,
125+
};
126+
});
127+
54128
// ── Helpers ───────────────────────────────────────────────────────────────────
55129
const emptyStats = {
56130
totalActive: 0,
@@ -472,8 +546,13 @@ describe('subscriptionStore integration', () => {
472546
});
473547

474548
// ═════════════════════════════════════════════════════════════════════════════
475-
// walletStore
549+
// walletStore — consolidated with walletServiceManager (#62)
476550
// ═════════════════════════════════════════════════════════════════════════════
551+
552+
// walletServiceManager is the single source of truth for connection state.
553+
// The store derives address/chainId/network/isConnected from it via a listener.
554+
// There is no longer a `wallet` property or a `@subtrackr_wallet` storage key.
555+
477556
describe('walletStore integration', () => {
478557
// ── Connect loads persisted data and syncs with service ────────────────────
479558
it('connectWallet loads persisted payment methods and attempts', async () => {
@@ -498,8 +577,20 @@ describe('walletStore integration', () => {
498577
]);
499578
mockMemoryStore.set('@subtrackr_payment_methods', mockPaymentMethods);
500579

580+
const { address, isConnected, isLoading } = useWalletStore.getState();
581+
expect(address).toBeNull();
582+
expect(isConnected).toBe(false);
583+
expect(isLoading).toBe(false);
584+
});
585+
586+
// ── syncWalletConnection updates store via walletServiceManager ─────────────
587+
it('syncWalletConnection sets connection state through walletServiceManager', async () => {
501588
await act(async () => {
502-
await useWalletStore.getState().connectWallet();
589+
await useWalletStore.getState().syncWalletConnection({
590+
address: '0xDEF456',
591+
chainId: 137,
592+
network: 'Polygon',
593+
});
503594
});
504595

505596
const { paymentMethods, isLoading } = useWalletStore.getState();
@@ -598,7 +689,35 @@ describe('walletStore integration', () => {
598689

599690
// walletService.disconnectWallet is being called, no need to mock AsyncStorage
600691
await act(async () => {
601-
await useWalletStore.getState().disconnect();
692+
await useWalletStore.getState().connectWallet();
693+
});
694+
695+
useWalletStore.setState({
696+
preferredNetwork: { id: 'ethereum', name: 'Ethereum', type: 'evm', chainId: 1 },
697+
networkMismatch: { connectedChainId: 137, preferredNetwork: { id: 'ethereum', name: 'Ethereum', type: 'evm', chainId: 1 } },
698+
});
699+
700+
act(() => {
701+
useWalletStore.getState().detectNetworkMismatch();
702+
});
703+
704+
expect(useWalletStore.getState().networkMismatch).toBeNull();
705+
});
706+
707+
// ── Network detection (#69): Stellar networks never mismatch ────────────────
708+
it('detectNetworkMismatch ignores Stellar networks (no numeric chainId)', async () => {
709+
walletServiceManager.setConnection({ address: '0xABC', chainId: 1, isConnected: true });
710+
711+
await act(async () => {
712+
await useWalletStore.getState().connectWallet();
713+
});
714+
715+
useWalletStore.setState({
716+
preferredNetwork: { id: 'stellar-testnet', name: 'Stellar Testnet', type: 'stellar' },
717+
});
718+
719+
act(() => {
720+
useWalletStore.getState().detectNetworkMismatch();
602721
});
603722

604723
// Should complete without error in normal flow

src/store/walletStore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ import {
1717
PaymentMethodExpiryCheck,
1818
WalletConnection,
1919
} from '../services/walletService';
20+
import { networkService } from '../services/networkService';
21+
import { ALL_NETWORKS, Network } from '../config/networks';
22+
23+
// ── Types ──────────────────────────────────────────────────────────
24+
25+
export interface NetworkMismatch {
26+
connectedChainId: number;
27+
preferredNetwork: Network;
28+
}
2029

2130
interface WalletState {
2231
// Connection state from service

0 commit comments

Comments
 (0)