Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions src/screens/WalletConnectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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<WalletConnection | null>(null);
Expand Down Expand Up @@ -66,7 +71,6 @@ const WalletConnectScreen: React.FC = () => {
};
setConnection(realConnection);
walletServiceManager.setConnection(realConnection);
connectWallet();
loadTokenBalances();
} else if (!isConnected) {
void walletServiceManager.disconnectWallet();
Expand Down Expand Up @@ -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)}`;
};
Expand Down Expand Up @@ -284,6 +294,46 @@ const WalletConnectScreen: React.FC = () => {
</View>
) : (
<View style={styles.connectedSection}>
{/* Network Mismatch Banner (#69) */}
{networkMismatch && (
<View style={styles.mismatchBanner}>
<Text style={styles.mismatchIcon}>⚠️</Text>
<View style={styles.mismatchTextContainer}>
<Text style={styles.mismatchTitle}>Network Mismatch</Text>
<Text style={styles.mismatchBody}>
Wallet is on {getChainName(networkMismatch.connectedChainId)}, but preferred
network is {networkMismatch.preferredNetwork.name}.
</Text>
</View>
<TouchableOpacity
style={styles.switchNetworkButton}
onPress={() => setShowNetworkPicker(true)}
accessibilityRole="button"
accessibilityLabel="Switch preferred network">
<Text style={styles.switchNetworkText}>Switch</Text>
</TouchableOpacity>
</View>
)}

{/* Network Selector (#69) */}
<Card variant="elevated" padding="large">
<View style={styles.networkSelectorRow}>
<View>
<Text style={styles.networkSelectorLabel}>Preferred Network</Text>
<Text style={styles.networkSelectorValue}>
{currentNetwork?.name ?? 'Not set'}
</Text>
</View>
<TouchableOpacity
style={styles.changeNetworkButton}
onPress={() => setShowNetworkPicker(true)}
accessibilityRole="button"
accessibilityLabel="Change preferred network">
<Text style={styles.changeNetworkText}>Change</Text>
</TouchableOpacity>
</View>
</Card>

{/* Connection Status */}
<Card variant="elevated" padding="large">
<View style={styles.connectionHeader}>
Expand Down Expand Up @@ -471,6 +521,54 @@ const WalletConnectScreen: React.FC = () => {
</Card>
)}
</ScrollView>

{/* Network Picker Modal (#69) */}
<Modal
visible={showNetworkPicker}
transparent
animationType="slide"
onRequestClose={() => setShowNetworkPicker(false)}>
<View style={styles.modalOverlay}>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Select Network</Text>
<TouchableOpacity
onPress={() => setShowNetworkPicker(false)}
accessibilityRole="button"
accessibilityLabel="Close network picker">
<Text style={styles.modalClose}>✕</Text>
</TouchableOpacity>
</View>
<FlatList
data={ALL_NETWORKS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.networkItem,
currentNetwork?.id === item.id && styles.networkItemSelected,
]}
onPress={() => handleSelectNetwork(item)}
accessibilityRole="button"
accessibilityLabel={`Select ${item.name}`}>
<Text style={styles.networkItemIcon}>
{item.type === 'stellar' ? '⭐' : '🔷'}
</Text>
<View style={styles.networkItemInfo}>
<Text style={styles.networkItemName}>{item.name}</Text>
<Text style={styles.networkItemType}>
{item.type.toUpperCase()}{item.isTestnet ? ' · Testnet' : ''}
</Text>
</View>
{currentNetwork?.id === item.id && (
<Text style={styles.networkItemCheck}>✓</Text>
)}
</TouchableOpacity>
)}
/>
</View>
</View>
</Modal>
</SafeAreaView>
);
};
Expand Down
129 changes: 124 additions & 5 deletions src/store/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/store/walletStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading