From 69bfea605865a105b6b68f1fd1488bf5dd53aecd Mon Sep 17 00:00:00 2001 From: softmind <198604584+sheyman546@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:14:25 +0000 Subject: [PATCH] fix(#62,#69): consolidate walletStore with walletServiceManager and add network detection UI - 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 --- src/screens/WalletConnectScreen.tsx | 234 ++++++- src/store/__tests__/integration.test.ts | 278 ++++++-- src/store/walletStore.ts | 858 ++++++++++++------------ 3 files changed, 882 insertions(+), 488 deletions(-) diff --git a/src/screens/WalletConnectScreen.tsx b/src/screens/WalletConnectScreen.tsx index a3426314..0b8e625c 100644 --- a/src/screens/WalletConnectScreen.tsx +++ b/src/screens/WalletConnectScreen.tsx @@ -9,6 +9,8 @@ import { Alert, ActivityIndicator, Platform, + Modal, + FlatList, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -18,6 +20,8 @@ import { Card } from '../components/common/Card'; import { useAppKit, useAppKitAccount, useAppKitProvider } from '@reown/appkit-ethers-react-native'; import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; import { useWalletStore } from '../store'; +import { useNetworkStore } from '../store/networkStore'; +import { ALL_NETWORKS, Network } from '../config/networks'; import { RootStackParamList } from '../navigation/types'; import * as Clipboard from 'expo-clipboard'; @@ -27,12 +31,14 @@ 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); const [tokenBalances, setTokenBalances] = useState([]); const [isLoadingBalances, setIsLoadingBalances] = useState(false); + const [showNetworkPicker, setShowNetworkPicker] = useState(false); useEffect(() => { initializeWalletService(); @@ -48,7 +54,6 @@ const WalletConnectScreen: React.FC = () => { }; setConnection(realConnection); walletServiceManager.setConnection(realConnection); - connectWallet(); loadTokenBalances(); } else if (!isConnected) { void walletServiceManager.disconnectWallet(); @@ -144,6 +149,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)}`; }; @@ -261,6 +272,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 */} @@ -413,6 +464,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 && ( + โœ“ + )} + + )} + /> + + + ); }; @@ -748,6 +847,137 @@ const styles = StyleSheet.create({ fontSize: 48, marginBottom: spacing.sm, }, + // โ”€โ”€ Network mismatch banner (#69) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + mismatchBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFF3CD', + borderColor: '#FFC107', + borderWidth: 1, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.md, + }, + mismatchIcon: { + fontSize: 20, + marginRight: spacing.sm, + }, + mismatchTextContainer: { + flex: 1, + }, + mismatchTitle: { + ...typography.caption, + color: '#856404', + fontWeight: '700', + marginBottom: 2, + }, + mismatchBody: { + ...typography.caption, + color: '#856404', + fontSize: 11, + }, + switchNetworkButton: { + backgroundColor: '#FFC107', + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + marginLeft: spacing.sm, + }, + switchNetworkText: { + ...typography.caption, + color: '#212529', + fontWeight: '700', + }, + // โ”€โ”€ Network selector row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + networkSelectorRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + networkSelectorLabel: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: 2, + }, + networkSelectorValue: { + ...typography.body, + color: colors.text, + fontWeight: '600', + }, + changeNetworkButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + }, + changeNetworkText: { + ...typography.caption, + color: colors.text, + fontWeight: '600', + }, + // โ”€โ”€ Network picker modal (#69) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'flex-end', + }, + modalContainer: { + backgroundColor: colors.background, + borderTopLeftRadius: borderRadius.lg, + borderTopRightRadius: borderRadius.lg, + maxHeight: '60%', + paddingBottom: spacing.xl, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.surface, + }, + modalTitle: { + ...typography.h3, + color: colors.text, + }, + modalClose: { + fontSize: 18, + color: colors.textSecondary, + padding: spacing.xs, + }, + networkItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.surface, + }, + networkItemSelected: { + backgroundColor: colors.surface, + }, + networkItemIcon: { + fontSize: 20, + marginRight: spacing.md, + }, + networkItemInfo: { + flex: 1, + }, + networkItemName: { + ...typography.body, + color: colors.text, + fontWeight: '600', + }, + networkItemType: { + ...typography.caption, + color: colors.textSecondary, + fontSize: 11, + }, + networkItemCheck: { + fontSize: 18, + color: colors.primary, + fontWeight: '700', + }, }); export default WalletConnectScreen; diff --git a/src/store/__tests__/integration.test.ts b/src/store/__tests__/integration.test.ts index 96ee4f82..b3df73d1 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'; @@ -50,6 +51,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, @@ -69,10 +143,15 @@ function resetSubscriptionStore() { function resetWalletStore() { useWalletStore.setState({ - wallet: null, address: null, + chainId: null, network: null, + isConnected: false, + preferredNetwork: null, + networkMismatch: null, cryptoStreams: [], + paymentMethods: [], + paymentAttempts: [], isLoading: false, error: null, }); @@ -470,53 +549,76 @@ 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 creates wallet and persists to AsyncStorage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - it('connectWallet creates a wallet and writes it to AsyncStorage', async () => { + // Reset walletServiceManager connection before each test so tests are isolated. + beforeEach(() => { + walletServiceManager.setConnection(null); + }); + + // โ”€โ”€ connectWallet reflects walletServiceManager state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('connectWallet reflects connection state from walletServiceManager', async () => { + // Simulate an external wallet connection (e.g. AppKit callback) + walletServiceManager.setConnection({ + address: '0xABC123', + chainId: 1, + isConnected: true, + }); + await act(async () => { await useWalletStore.getState().connectWallet(); }); - const { wallet, address, network, isLoading } = useWalletStore.getState(); - expect(wallet).not.toBeNull(); - expect(address).toBe('0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1'); - expect(network).toBe('Ethereum Mainnet'); + const { address, chainId, network, isConnected, isLoading } = useWalletStore.getState(); + expect(address).toBe('0xABC123'); + expect(chainId).toBe(1); + expect(network).toBe('Ethereum'); + expect(isConnected).toBe(true); expect(isLoading).toBe(false); - expect(AsyncStorage.setItem).toHaveBeenCalledWith( - '@subtrackr_wallet', - expect.stringContaining('0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1') - ); }); - // โ”€โ”€ Connect loads persisted wallet instead of creating a new one โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - it('connectWallet loads saved wallet from AsyncStorage when one exists', async () => { - const savedData = JSON.stringify({ - address: '0xSavedAddress', - network: 'Polygon', - wallet: { - address: '0xSavedAddress', - chainId: 137, - isConnected: true, - balance: '2.0', - tokens: [], - }, + // โ”€โ”€ connectWallet with no active connection leaves state disconnected โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('connectWallet with no active connection leaves store in disconnected state', async () => { + await act(async () => { + await useWalletStore.getState().connectWallet(); }); - mockMemoryStore.set('@subtrackr_wallet', savedData); + 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', + }); }); - expect(useWalletStore.getState().address).toBe('0xSavedAddress'); - expect(useWalletStore.getState().network).toBe('Polygon'); - // setItem should NOT have been called (loaded from storage, not written) - expect(AsyncStorage.setItem).not.toHaveBeenCalled(); + const { address, chainId, isConnected } = useWalletStore.getState(); + expect(address).toBe('0xDEF456'); + expect(chainId).toBe(137); + expect(isConnected).toBe(true); }); - // โ”€โ”€ Disconnect clears wallet from state and AsyncStorage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - it('disconnect removes wallet from store and calls AsyncStorage.removeItem', async () => { + // โ”€โ”€ disconnect clears connection state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('disconnect clears address, chainId, network, and cryptoStreams', async () => { + walletServiceManager.setConnection({ + address: '0xABC123', + chainId: 1, + isConnected: true, + }); + await act(async () => { await useWalletStore.getState().connectWallet(); }); @@ -525,31 +627,63 @@ describe('walletStore integration', () => { await useWalletStore.getState().disconnect(); }); - const { wallet, address, network, cryptoStreams } = useWalletStore.getState(); - expect(wallet).toBeNull(); + const { address, chainId, network, isConnected, cryptoStreams, networkMismatch } = + useWalletStore.getState(); expect(address).toBeNull(); + expect(chainId).toBeNull(); expect(network).toBeNull(); + expect(isConnected).toBe(false); expect(cryptoStreams).toHaveLength(0); - expect(AsyncStorage.removeItem).toHaveBeenCalledWith('@subtrackr_wallet'); + expect(networkMismatch).toBeNull(); + }); + + // โ”€โ”€ walletServiceManager listener keeps store in sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('store stays in sync when walletServiceManager connection changes externally', async () => { + // Simulate AppKit connecting + act(() => { + walletServiceManager.setConnection({ + address: '0xLIVE', + chainId: 42161, + isConnected: true, + }); + }); + + const { address, chainId, network, isConnected } = useWalletStore.getState(); + expect(address).toBe('0xLIVE'); + expect(chainId).toBe(42161); + expect(network).toBe('Arbitrum'); + expect(isConnected).toBe(true); + + // Simulate AppKit disconnecting + act(() => { + walletServiceManager.setConnection(null); + }); + + expect(useWalletStore.getState().address).toBeNull(); + expect(useWalletStore.getState().isConnected).toBe(false); }); // โ”€โ”€ Multi-action: connect โ†’ disconnect โ†’ reconnect โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - it('multi-action: connect โ†’ disconnect โ†’ reconnect restores wallet', async () => { + it('multi-action: connect โ†’ disconnect โ†’ reconnect restores wallet state', async () => { + walletServiceManager.setConnection({ address: '0xABC', chainId: 1, isConnected: true }); + await act(async () => { await useWalletStore.getState().connectWallet(); }); - expect(useWalletStore.getState().wallet).not.toBeNull(); + expect(useWalletStore.getState().isConnected).toBe(true); await act(async () => { await useWalletStore.getState().disconnect(); }); - expect(useWalletStore.getState().wallet).toBeNull(); + expect(useWalletStore.getState().isConnected).toBe(false); + + walletServiceManager.setConnection({ address: '0xABC', chainId: 1, isConnected: true }); await act(async () => { await useWalletStore.getState().connectWallet(); }); - expect(useWalletStore.getState().wallet).not.toBeNull(); - expect(useWalletStore.getState().address).toBe('0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1'); + expect(useWalletStore.getState().isConnected).toBe(true); + expect(useWalletStore.getState().address).toBe('0xABC'); }); // โ”€โ”€ Multi-action: create then cancel crypto stream โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -598,20 +732,66 @@ describe('walletStore integration', () => { expect(useWalletStore.getState().isLoading).toBe(false); }); - // โ”€โ”€ Error recovery: disconnect handles AsyncStorage failure gracefully โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - it('disconnect sets error state when AsyncStorage.removeItem throws', async () => { + // โ”€โ”€ Network detection (#69): detectNetworkMismatch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('detectNetworkMismatch sets networkMismatch when chainId differs from preferredNetwork', async () => { + // Set up: connected to Polygon (137) but preferred is Ethereum (chainId 1) + walletServiceManager.setConnection({ address: '0xABC', chainId: 137, isConnected: true }); + await act(async () => { await useWalletStore.getState().connectWallet(); }); - (AsyncStorage.removeItem as jest.Mock).mockImplementationOnce(() => - Promise.reject(new Error('Storage unavailable')) - ); + // Manually set preferredNetwork to Ethereum + useWalletStore.setState({ + preferredNetwork: { id: 'ethereum', name: 'Ethereum', type: 'evm', chainId: 1 }, + }); + + act(() => { + useWalletStore.getState().detectNetworkMismatch(); + }); + + const { networkMismatch } = useWalletStore.getState(); + expect(networkMismatch).not.toBeNull(); + expect(networkMismatch!.connectedChainId).toBe(137); + expect(networkMismatch!.preferredNetwork.id).toBe('ethereum'); + }); + + // โ”€โ”€ Network detection (#69): no mismatch when chains match โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + it('detectNetworkMismatch clears networkMismatch when chainId matches preferredNetwork', async () => { + walletServiceManager.setConnection({ address: '0xABC', chainId: 1, isConnected: true }); 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(); }); - expect(useWalletStore.getState().error).toBe('Failed to disconnect wallet'); + expect(useWalletStore.getState().networkMismatch).toBeNull(); }); }); diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index 97234665..2e1eb8b0 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -15,18 +15,38 @@ import { PaymentMethodError, PaymentMethodErrorCode, PaymentMethodExpiryCheck, + walletServiceManager, + 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 { - wallet: Wallet | null; + // Connection state โ€” derived from walletServiceManager (single source of truth) address: string | null; + chainId: number | null; network: string | null; + isConnected: boolean; + + // Network detection (#69) + preferredNetwork: Network | null; + networkMismatch: NetworkMismatch | null; + + // Other wallet state cryptoStreams: CryptoStream[]; paymentMethods: PaymentMethod[]; paymentAttempts: PaymentAttempt[]; isLoading: boolean; error: string | null; + // Connection actions connectWallet: () => Promise; syncWalletConnection: (payload: { address: string; @@ -35,10 +55,17 @@ interface WalletState { }) => Promise; disconnect: () => Promise; updateBalance: () => Promise; + + // Network actions (#69) + setPreferredNetwork: (networkId: string) => Promise; + detectNetworkMismatch: () => void; + + // Stream actions createCryptoStream: (setup: StreamSetup) => Promise; cancelCryptoStream: (streamId: string) => Promise; fetchCryptoStreams: () => Promise; + // Payment method actions addPaymentMethod: (data: PaymentMethodFormData) => Promise; removePaymentMethod: (id: string) => Promise; updatePaymentMethod: (id: string, updates: Partial) => Promise; @@ -62,482 +89,439 @@ interface WalletState { checkTokenContractUpgrade: (id: string) => Promise; } -const WALLET_STORAGE_KEY = '@subtrackr_wallet'; +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const PAYMENT_METHODS_STORAGE_KEY = '@subtrackr_payment_methods'; const PAYMENT_ATTEMPTS_STORAGE_KEY = '@subtrackr_payment_attempts'; const paymentService = PaymentMethodService.getInstance(); -export const useWalletStore = create((set, get) => ({ - wallet: null, - address: null, - network: null, - cryptoStreams: [], - paymentMethods: [], - paymentAttempts: [], - isLoading: false, - error: null, - - connectWallet: async () => { - set({ isLoading: true, error: null }); - try { - const savedWallet = await AsyncStorage.getItem(WALLET_STORAGE_KEY); - - if (savedWallet) { - const parsed = JSON.parse(savedWallet); - set({ - address: parsed.address, - network: parsed.network, - wallet: parsed.wallet, - isLoading: false, - }); +// โ”€โ”€ Helper: derive connection state from WalletServiceManager โ”€โ”€โ”€โ”€โ”€โ”€ + +function connectionToState(conn: WalletConnection | null) { + if (!conn || !conn.isConnected) { + return { address: null, chainId: null, network: null, isConnected: false }; + } + const networkName = + ALL_NETWORKS.find((n) => n.chainId === conn.chainId)?.name ?? `Chain ${conn.chainId}`; + return { + address: conn.address, + chainId: conn.chainId, + network: networkName, + isConnected: true, + }; +} +// โ”€โ”€ Store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const useWalletStore = create((set, get) => { + // Subscribe to walletServiceManager so the store stays in sync (#62) + walletServiceManager.addListener((conn) => { + const connState = connectionToState(conn); + set(connState); + // Re-run mismatch detection whenever connection changes (#69) + get().detectNetworkMismatch(); + }); + + return { + // Initial connection state from walletServiceManager + ...connectionToState(walletServiceManager.getConnection()), + + // Network detection state + preferredNetwork: null, + networkMismatch: null, + + cryptoStreams: [], + paymentMethods: [], + paymentAttempts: [], + isLoading: false, + error: null, + + // โ”€โ”€ Connection actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + connectWallet: async () => { + set({ isLoading: true, error: null }); + try { + // Load persisted payment methods const savedMethods = await AsyncStorage.getItem(PAYMENT_METHODS_STORAGE_KEY); - if (savedMethods) { - set({ paymentMethods: JSON.parse(savedMethods) }); - } + if (savedMethods) set({ paymentMethods: JSON.parse(savedMethods) }); const savedAttempts = await AsyncStorage.getItem(PAYMENT_ATTEMPTS_STORAGE_KEY); - if (savedAttempts) { - set({ paymentAttempts: JSON.parse(savedAttempts) }); - } - - return; + if (savedAttempts) set({ paymentAttempts: JSON.parse(savedAttempts) }); + + // walletServiceManager is the source of truth for connection; + // if already connected, reflect that state now. + const conn = walletServiceManager.getConnection(); + set({ ...connectionToState(conn), isLoading: false }); + get().detectNetworkMismatch(); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to connect wallet', + isLoading: false, + }); } + }, + + syncWalletConnection: async ({ address, chainId, network }) => { + // Update walletServiceManager โ€” the listener will sync the store state + walletServiceManager.setConnection({ address, chainId, isConnected: true }); + set({ isLoading: false, error: null }); + }, + + disconnect: async () => { + try { + await walletServiceManager.disconnectWallet(); + // Listener will clear address/chainId/network/isConnected + set({ + cryptoStreams: [], + paymentMethods: [], + paymentAttempts: [], + networkMismatch: null, + }); + } catch (error) { + set({ error: 'Failed to disconnect wallet' }); + } + }, + + updateBalance: async () => { + if (!get().isConnected) return; + set({ isLoading: true, error: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + set({ isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to update balance', + isLoading: false, + }); + } + }, - const mockWallet: Wallet = { - address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0fAb1', - chainId: 1, - isConnected: true, - balance: '0.5', - tokens: [ - { - symbol: 'ETH', - name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - balance: '0.5', - decimals: 18, - }, - { - symbol: 'USDC', - name: 'USD Coin', - address: '0xA0b86a33E6441b8b4b8b8b8b8b8b8b8b8b8b8b8', - balance: '1000', - decimals: 6, - }, - ], - }; - - const walletData = { - address: mockWallet.address, - network: 'Ethereum Mainnet', - wallet: mockWallet, - }; - - await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(walletData)); - - set({ - wallet: mockWallet, - address: mockWallet.address, - network: 'Ethereum Mainnet', - isLoading: false, - }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to connect wallet', - isLoading: false, - }); - } - }, - - syncWalletConnection: async ({ address, chainId, network }) => { - const walletData = { - address, - network, - wallet: { - address, - chainId, - isConnected: true, - balance: '0', - tokens: [], - } as Wallet, - }; - - await AsyncStorage.setItem(WALLET_STORAGE_KEY, JSON.stringify(walletData)); - set({ - wallet: walletData.wallet, - address, - network, - isLoading: false, - error: null, - }); - }, - - disconnect: async () => { - try { - await AsyncStorage.removeItem(WALLET_STORAGE_KEY); - set({ - wallet: null, - address: null, - network: null, - cryptoStreams: [], - paymentMethods: [], - paymentAttempts: [], - }); - } catch (error) { - set({ error: 'Failed to disconnect wallet' }); - } - }, - - updateBalance: async () => { - const { wallet } = get(); - if (!wallet) return; - - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 500)); - set({ isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update balance', - isLoading: false, - }); - } - }, - - createCryptoStream: async (setup: StreamSetup) => { - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const newStream: CryptoStream = { - id: Date.now().toString(), - subscriptionId: 'temp', - ...setup, - isActive: true, - streamId: `stream_${Date.now()}`, - }; + // โ”€โ”€ Network actions (#69) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - set((state) => ({ - cryptoStreams: [...state.cryptoStreams, newStream], - isLoading: false, - })); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to create crypto stream', - isLoading: false, - }); - } - }, - - cancelCryptoStream: async (streamId: string) => { - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - set((state) => ({ - cryptoStreams: state.cryptoStreams.map((stream) => - stream.id === streamId ? { ...stream, isActive: false } : stream - ), - isLoading: false, - })); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to cancel crypto stream', - isLoading: false, - }); - } - }, - - fetchCryptoStreams: async () => { - set({ isLoading: true, error: null }); - try { - await new Promise((resolve) => setTimeout(resolve, 1000)); - set({ isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to fetch crypto streams', - isLoading: false, - }); - } - }, - - addPaymentMethod: async (data: PaymentMethodFormData) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods, address } = get(); - if (!address) { - throw new PaymentMethodError( - PaymentMethodErrorCode.VERIFICATION_FAILED, - 'Wallet not connected.', - 'Connect your wallet first.' - ); + setPreferredNetwork: async (networkId: string) => { + const success = await networkService.setSelectedNetwork(networkId); + if (success) { + const network = await networkService.getSelectedNetwork(); + set({ preferredNetwork: network }); + get().detectNetworkMismatch(); } + }, - const canAdd = paymentService.canAddMethod(paymentMethods.length); - if (!canAdd.canAdd) { - throw new PaymentMethodError( - PaymentMethodErrorCode.MAX_METHODS, - canAdd.reason!, - 'Remove an existing payment method first.' - ); + detectNetworkMismatch: () => { + const { chainId, preferredNetwork } = get(); + if (!chainId || !preferredNetwork) { + set({ networkMismatch: null }); + return; } - - const validation = paymentService.validatePaymentMethodForm(data); - if (!validation.isValid) { - throw new PaymentMethodError( - PaymentMethodErrorCode.INVALID_TOKEN, - validation.errors.join('; '), - 'Fix the validation errors and try again.' - ); + // Only EVM networks have chainId; Stellar wallets don't have a numeric chainId + if (preferredNetwork.type !== 'evm' || preferredNetwork.chainId == null) { + set({ networkMismatch: null }); + return; } + if (chainId !== preferredNetwork.chainId) { + set({ networkMismatch: { connectedChainId: chainId, preferredNetwork } }); + } else { + set({ networkMismatch: null }); + } + }, + + // โ”€โ”€ Stream actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + createCryptoStream: async (setup: StreamSetup) => { + set({ isLoading: true, error: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const newStream: CryptoStream = { + id: Date.now().toString(), + subscriptionId: 'temp', + ...setup, + isActive: true, + streamId: `stream_${Date.now()}`, + }; + set((state) => ({ cryptoStreams: [...state.cryptoStreams, newStream], isLoading: false })); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to create crypto stream', + isLoading: false, + }); + } + }, + + cancelCryptoStream: async (streamId: string) => { + set({ isLoading: true, error: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + set((state) => ({ + cryptoStreams: state.cryptoStreams.map((s) => + s.id === streamId ? { ...s, isActive: false } : s + ), + isLoading: false, + })); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to cancel crypto stream', + isLoading: false, + }); + } + }, + + fetchCryptoStreams: async () => { + set({ isLoading: true, error: null }); + try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + set({ isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to fetch crypto streams', + isLoading: false, + }); + } + }, + + // โ”€โ”€ Payment method actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + addPaymentMethod: async (data: PaymentMethodFormData) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods, address } = get(); + if (!address) { + throw new PaymentMethodError( + PaymentMethodErrorCode.VERIFICATION_FAILED, + 'Wallet not connected.', + 'Connect your wallet first.' + ); + } + + const canAdd = paymentService.canAddMethod(paymentMethods.length); + if (!canAdd.canAdd) { + throw new PaymentMethodError( + PaymentMethodErrorCode.MAX_METHODS, + canAdd.reason!, + 'Remove an existing payment method first.' + ); + } - const isDup = paymentService.isDuplicateMethod( - paymentMethods, - data.tokenAddress, - data.chainId, - data.tokenType - ); - if (isDup) { - throw new PaymentMethodError( - PaymentMethodErrorCode.DUPLICATE, - 'A payment method with this token and chain already exists.', - 'Use a different token or chain.' + const validation = paymentService.validatePaymentMethodForm(data); + if (!validation.isValid) { + throw new PaymentMethodError( + PaymentMethodErrorCode.INVALID_TOKEN, + validation.errors.join('; '), + 'Fix the validation errors and try again.' + ); + } + + const isDup = paymentService.isDuplicateMethod( + paymentMethods, + data.tokenAddress, + data.chainId, + data.tokenType ); - } + if (isDup) { + throw new PaymentMethodError( + PaymentMethodErrorCode.DUPLICATE, + 'A payment method with this token and chain already exists.', + 'Use a different token or chain.' + ); + } - const newMethod: PaymentMethod = { - id: paymentService.generateId(), - userId: address, - tokenType: data.tokenType, - tokenAddress: data.tokenAddress, - chainId: data.chainId, - label: data.label, - priority: data.priority, - maxSpendPerInterval: data.maxSpendPerInterval, - isVerified: data.tokenType === 'NATIVE', - isActive: true, - expiresAt: null, - lastUsedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - metadata: {}, - }; + const newMethod: PaymentMethod = { + id: paymentService.generateId(), + userId: address, + tokenType: data.tokenType, + tokenAddress: data.tokenAddress, + chainId: data.chainId, + label: data.label, + priority: data.priority, + maxSpendPerInterval: data.maxSpendPerInterval, + isVerified: data.tokenType === 'NATIVE', + isActive: true, + expiresAt: null, + lastUsedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + + if (!newMethod.isVerified) { + await paymentService.verifyPaymentMethod(newMethod); + newMethod.isVerified = true; + } - if (!newMethod.isVerified) { - await paymentService.verifyPaymentMethod(newMethod); - newMethod.isVerified = true; + const updatedMethods = [...paymentMethods, newMethod]; + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + return newMethod; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to add payment method', + isLoading: false, + }); + throw error; } + }, - const updatedMethods = [...paymentMethods, newMethod]; - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - - set({ - paymentMethods: updatedMethods, - isLoading: false, - }); - - return newMethod; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to add payment method', - isLoading: false, - }); - throw error; - } - }, - - removePaymentMethod: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const updatedMethods = paymentMethods.filter((m) => m.id !== id); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to remove payment method', - isLoading: false, - }); - } - }, - - updatePaymentMethod: async (id: string, updates: Partial) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const updatedMethods = paymentMethods.map((m) => - m.id === id ? { ...m, ...updates, updatedAt: new Date() } : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update payment method', - isLoading: false, - }); - } - }, - - verifyPaymentMethod: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const method = paymentMethods.find((m) => m.id === id); - if (!method) { - throw new Error('Payment method not found'); + removePaymentMethod: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const updatedMethods = paymentMethods.filter((m) => m.id !== id); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to remove payment method', + isLoading: false, + }); } + }, - const verified = await paymentService.verifyPaymentMethod(method); - if (verified) { + updatePaymentMethod: async (id: string, updates: Partial) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); const updatedMethods = paymentMethods.map((m) => - m.id === id ? { ...m, isVerified: true, updatedAt: new Date() } : m + m.id === id ? { ...m, ...updates, updatedAt: new Date() } : m ); await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); set({ paymentMethods: updatedMethods, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to update payment method', + isLoading: false, + }); } - return verified; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to verify payment method', - isLoading: false, - }); - throw error; - } - }, - - setPaymentMethodPriority: async (id: string, priority: PaymentPriority) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const method = paymentMethods.find((m) => m.id === id); - if (!method) { - throw new Error('Payment method not found'); - } - - const updatedMethods = paymentMethods.map((m) => - m.id === id ? { ...m, priority, updatedAt: new Date() } : m - ); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to update payment method priority', - isLoading: false, - }); - } - }, - - processPayment: async ( - subscriptionId: string, - amount: string, - chainId: number, - maxGasPriceGwei: number = 500 - ) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const result = await paymentService.processPaymentWithFallback( - paymentMethods, - subscriptionId, - amount, - chainId, - maxGasPriceGwei - ); - - const updatedMethods = paymentMethods.map((m) => { - if (m.id === result.attempt.paymentMethodId) { - return { ...m, lastUsedAt: new Date(), updatedAt: new Date() }; + }, + + verifyPaymentMethod: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const method = paymentMethods.find((m) => m.id === id); + if (!method) throw new Error('Payment method not found'); + + const verified = await paymentService.verifyPaymentMethod(method); + if (verified) { + const updatedMethods = paymentMethods.map((m) => + m.id === id ? { ...m, isVerified: true, updatedAt: new Date() } : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); } - return m; - }); - await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - - const newAttempts = [...get().paymentAttempts, result.attempt, ...result.fallbackAttempts]; - await AsyncStorage.setItem(PAYMENT_ATTEMPTS_STORAGE_KEY, JSON.stringify(newAttempts)); - - set({ - paymentMethods: updatedMethods, - paymentAttempts: newAttempts, - isLoading: false, - }); - - return result; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Payment processing failed', - isLoading: false, - }); - throw error; - } - }, - - getExpiryInfo: () => { - const { paymentMethods } = get(); - const expired = paymentService.getExpiredMethods(paymentMethods); - const expiringSoon = paymentService.getExpiringSoonMethods(paymentMethods); - - return { - expired: expired.map((m) => paymentService.checkExpiry(m)), - expiringSoon: expiringSoon.map((m) => paymentService.checkExpiry(m)), - }; - }, - - getPaymentMethodsByPriority: () => { - const { paymentMethods } = get(); - return { - primary: paymentService.getPrimaryMethods(paymentMethods), - backup: paymentService.getBackupMethods(paymentMethods), - fallback: paymentService.getFallbackMethods(paymentMethods), - }; - }, - - checkTokenContractUpgrade: async (id: string) => { - set({ isLoading: true, error: null }); - try { - const { paymentMethods } = get(); - const method = paymentMethods.find((m) => m.id === id); - if (!method) { - throw new Error('Payment method not found'); + return verified; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to verify payment method', + isLoading: false, + }); + throw error; } + }, - const previousHash = method.metadata.token_code_hash ?? null; - const result = await paymentService.detectTokenContractUpgrade(method, previousHash); + setPaymentMethodPriority: async (id: string, priority: PaymentPriority) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const method = paymentMethods.find((m) => m.id === id); + if (!method) throw new Error('Payment method not found'); - if (result.upgraded && result.newHash) { const updatedMethods = paymentMethods.map((m) => - m.id === id - ? { - ...m, - metadata: { ...m.metadata, token_code_hash: result.newHash }, - updatedAt: new Date(), - } - : m + m.id === id ? { ...m, priority, updatedAt: new Date() } : m ); await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); set({ paymentMethods: updatedMethods, isLoading: false }); - } else if (result.newHash && !previousHash) { + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to update payment method priority', + isLoading: false, + }); + } + }, + + processPayment: async ( + subscriptionId: string, + amount: string, + chainId: number, + maxGasPriceGwei: number = 500 + ) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const result = await paymentService.processPaymentWithFallback( + paymentMethods, + subscriptionId, + amount, + chainId, + maxGasPriceGwei + ); + const updatedMethods = paymentMethods.map((m) => - m.id === id - ? { - ...m, - metadata: { ...m.metadata, token_code_hash: result.newHash }, - updatedAt: new Date(), - } + m.id === result.attempt.paymentMethodId + ? { ...m, lastUsedAt: new Date(), updatedAt: new Date() } : m ); await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); - set({ paymentMethods: updatedMethods, isLoading: false }); + + const newAttempts = [...get().paymentAttempts, result.attempt, ...result.fallbackAttempts]; + await AsyncStorage.setItem(PAYMENT_ATTEMPTS_STORAGE_KEY, JSON.stringify(newAttempts)); + + set({ paymentMethods: updatedMethods, paymentAttempts: newAttempts, isLoading: false }); + return result; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Payment processing failed', + isLoading: false, + }); + throw error; } + }, + + getExpiryInfo: () => { + const { paymentMethods } = get(); + return { + expired: paymentService.getExpiredMethods(paymentMethods).map((m) => paymentService.checkExpiry(m)), + expiringSoon: paymentService.getExpiringSoonMethods(paymentMethods).map((m) => paymentService.checkExpiry(m)), + }; + }, + + getPaymentMethodsByPriority: () => { + const { paymentMethods } = get(); + return { + primary: paymentService.getPrimaryMethods(paymentMethods), + backup: paymentService.getBackupMethods(paymentMethods), + fallback: paymentService.getFallbackMethods(paymentMethods), + }; + }, + + checkTokenContractUpgrade: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const method = paymentMethods.find((m) => m.id === id); + if (!method) throw new Error('Payment method not found'); + + const previousHash = method.metadata.token_code_hash ?? null; + const result = await paymentService.detectTokenContractUpgrade(method, previousHash); + + if (result.newHash) { + const updatedMethods = paymentMethods.map((m) => + m.id === id + ? { ...m, metadata: { ...m.metadata, token_code_hash: result.newHash }, updatedAt: new Date() } + : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods }); + } - set({ isLoading: false }); - return result.upgraded; - } catch (error) { - set({ - error: error instanceof Error ? error.message : 'Failed to check token contract upgrade', - isLoading: false, - }); - return false; - } - }, -})); + set({ isLoading: false }); + return result.upgraded; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to check token contract upgrade', + isLoading: false, + }); + return false; + } + }, + }; +});