diff --git a/src/config/evm.ts b/src/config/evm.ts index c866e439..4c9bd67d 100644 --- a/src/config/evm.ts +++ b/src/config/evm.ts @@ -6,6 +6,8 @@ export const EVM_RPC_URLS: Record = { 1: 'https://cloudflare-eth.com', 137: 'https://polygon-rpc.com', 42161: 'https://arb1.arbitrum.io/rpc', + 10: 'https://mainnet.optimism.io', + 8453: 'https://mainnet.base.org', }; export function getEvmRpcUrl(chainId: number): string { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 43db1519..7b8374b0 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -6,7 +6,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import HomeScreen from '../screens/HomeScreen'; import AddSubscriptionScreen from '../screens/AddSubscriptionScreen'; -import WalletConnectScreen from '../screens/WalletConnectScreen'; +import WalletConnectScreen from '../screens/WalletConnectV2Screen'; import CryptoPaymentScreen from '../screens/CryptoPaymentScreen'; import CommunityScreen from '../screens/CommunityScreen'; import ProfileScreen from '../screens/ProfileScreen'; diff --git a/src/screens/WalletConnectV2Screen.tsx b/src/screens/WalletConnectV2Screen.tsx new file mode 100644 index 00000000..3bdf9152 --- /dev/null +++ b/src/screens/WalletConnectV2Screen.tsx @@ -0,0 +1,742 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, + Platform, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useAppKit, useAppKitAccount, useAppKitProvider } from '@reown/appkit-ethers-react-native'; +import QRCode from 'react-native-qrcode-svg'; +import * as Clipboard from 'expo-clipboard'; + +import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants'; +import { Button } from '../components/common/Button'; +import { Card } from '../components/common/Card'; +import walletServiceManager, { WalletConnection, TokenBalance } from '../services/walletService'; +import { useWalletStore } from '../store'; +import { RootStackParamList } from '../navigation/types'; +import { getWalletConnectChain, WALLETCONNECT_CHAINS } from '../services/walletconnect/chains'; +import { + buildPairingUri, + walletConnectSessionManager, +} from '../services/walletconnect/sessionManager'; +import { WalletConnectSessionState } from '../services/walletconnect/types'; + +const WalletConnectV2Screen: React.FC = () => { + const navigation = useNavigation>(); + const { open } = useAppKit(); + const { address, isConnected, chainId } = useAppKitAccount(); + const { walletProvider } = useAppKitProvider(); + const { syncWalletConnection, disconnect } = useWalletStore(); + + const previousConnectionRef = useRef(false); + + const [isConnecting, setIsConnecting] = useState(false); + const [isHydratingSession, setIsHydratingSession] = useState(true); + const [connection, setConnection] = useState(null); + const [tokenBalances, setTokenBalances] = useState([]); + const [isLoadingBalances, setIsLoadingBalances] = useState(false); + const [sessionState, setSessionState] = useState(null); + + useEffect(() => { + void initializeWalletService(); + void hydrateSession(); + }, []); + + useEffect(() => { + let active = true; + + const syncAppKitConnection = async () => { + if (isConnected && address && walletProvider) { + const nextConnection: WalletConnection = { + address, + chainId: chainId ?? 1, + isConnected: true, + eip1193Provider: walletProvider as unknown as WalletConnection['eip1193Provider'], + }; + + previousConnectionRef.current = true; + walletServiceManager.setConnection(nextConnection); + await syncWalletConnection({ + address, + chainId: nextConnection.chainId, + network: getChainName(nextConnection.chainId), + }); + + const nextSession = await walletConnectSessionManager.markConnected( + address, + nextConnection.chainId + ); + const balances = await loadTokenBalances(nextConnection); + + if (!active) return; + setConnection(nextConnection); + setSessionState(nextSession); + setTokenBalances(balances); + setIsConnecting(false); + return; + } + + if (!isConnected && previousConnectionRef.current) { + previousConnectionRef.current = false; + await walletServiceManager.disconnectWallet(); + await disconnect(); + const nextSession = await walletConnectSessionManager.markDisconnected( + 'wallet_session_closed' + ); + + if (!active) return; + setConnection(null); + setTokenBalances([]); + setSessionState(nextSession); + setIsConnecting(false); + } + }; + + void syncAppKitConnection().catch(async (error) => { + console.error('Failed to sync WalletConnect session:', error); + const nextSession = await walletConnectSessionManager.markError('walletconnect_sync_failed'); + if (!active) return; + setSessionState(nextSession); + setIsConnecting(false); + }); + + return () => { + active = false; + }; + }, [isConnected, address, chainId, walletProvider, syncWalletConnection, disconnect]); + + const initializeWalletService = async () => { + try { + await walletServiceManager.initialize(); + } catch (error) { + console.error('Failed to initialize wallet service:', error); + Alert.alert('Error', 'Failed to initialize wallet service'); + } + }; + + const hydrateSession = async () => { + try { + const restored = await walletConnectSessionManager.restore(); + setSessionState(restored); + } finally { + setIsHydratingSession(false); + } + }; + + const handleConnectWallet = async () => { + try { + setIsConnecting(true); + const connectingState = await walletConnectSessionManager.markConnecting(); + setSessionState(connectingState); + open(); + } catch (error) { + console.error('Failed to open wallet modal:', error); + setIsConnecting(false); + const nextSession = await walletConnectSessionManager.markError('walletconnect_open_failed'); + setSessionState(nextSession); + Alert.alert('Error', 'Failed to open wallet modal. Please try again.'); + } + }; + + const handleDisconnectWallet = async () => { + try { + await walletServiceManager.disconnectWallet(); + await disconnect(); + setConnection(null); + setTokenBalances([]); + setSessionState(await walletConnectSessionManager.markDisconnected('user_disconnected')); + previousConnectionRef.current = false; + Alert.alert('Success', 'Wallet disconnected successfully.'); + } catch (error) { + console.error('Failed to disconnect wallet:', error); + setSessionState(await walletConnectSessionManager.markError('walletconnect_disconnect_failed')); + Alert.alert('Error', 'Failed to disconnect wallet'); + } + }; + + const loadTokenBalances = async ( + nextConnection: WalletConnection | null = connection + ): Promise => { + if (!nextConnection) return []; + + try { + setIsLoadingBalances(true); + const balances = await walletServiceManager.getTokenBalances( + nextConnection.address, + nextConnection.chainId + ); + return balances; + } catch (error) { + console.error('Failed to load token balances:', error); + Alert.alert('Error', 'Failed to load token balances'); + return []; + } finally { + setIsLoadingBalances(false); + } + }; + + const handleRefreshBalances = async () => { + const balances = await loadTokenBalances(); + setTokenBalances(balances); + }; + + const handleCopyAddress = async () => { + if (!connection?.address) return; + + try { + await Clipboard.setStringAsync(connection.address); + Alert.alert(Platform.OS === 'android' ? 'Copied' : 'Success', 'Address copied to clipboard'); + } catch (error) { + console.error('Failed to copy address:', error); + Alert.alert('Error', 'Failed to copy address to clipboard'); + } + }; + + const handleCopyPairingUri = async () => { + try { + await Clipboard.setStringAsync(pairingUri); + Alert.alert('Copied', 'Pairing handoff copied to clipboard'); + } catch (error) { + console.error('Failed to copy pairing URI:', error); + Alert.alert('Error', 'Failed to copy pairing handoff'); + } + }; + + const handleSetupCryptoPayments = () => { + if (connection) { + navigation.navigate('CryptoPayment'); + return; + } + + Alert.alert('Error', 'Please connect a wallet first'); + }; + + const formatAddress = (value: string): string => `${value.slice(0, 6)}...${value.slice(-4)}`; + + const getChainName = (targetChainId: number): string => + getWalletConnectChain(targetChainId)?.name ?? `Chain ${targetChainId}`; + + const getChainColor = (targetChainId: number): string => + getWalletConnectChain(targetChainId)?.accentColor ?? colors.primary; + + const getChainDescription = (targetChainId: number): string => + getWalletConnectChain(targetChainId)?.description ?? 'Blockchain network'; + + const getTokenIcon = (symbol: string): string => { + const icons: Record = { + ETH: 'ETH', + MATIC: 'POLY', + USDC: 'USDC', + ARB: 'ARB', + }; + return icons[symbol] || symbol.slice(0, 4).toUpperCase(); + }; + + const getTokenPrice = (symbol: string): number => { + const prices: Record = { + ETH: 3500, + MATIC: 0.8, + USDC: 1.0, + ARB: 1.2, + }; + return prices[symbol] || 1.0; + }; + + const pairingUri = useMemo( + () => sessionState?.pairingUri || buildPairingUri(connection?.address, connection?.chainId), + [sessionState?.pairingUri, connection?.address, connection?.chainId] + ); + + const statusColor = useMemo(() => { + switch (sessionState?.status) { + case 'connected': + return colors.success; + case 'connecting': + return colors.accent; + case 'error': + return colors.error; + case 'disconnected': + return colors.warning; + default: + return colors.textSecondary; + } + }, [sessionState?.status]); + + return ( + + + + Connect Wallet + + WalletConnect v2 session management with multi-chain handoff and recovery controls + + + + + + + + + + {isHydratingSession ? 'Restoring session' : sessionState?.status ?? 'idle'} + + + {sessionState?.connectedAt ? ( + + Connected {new Date(sessionState.connectedAt).toLocaleString()} + + ) : null} + + + {sessionState?.lastError + ? `Last error: ${sessionState.lastError}` + : sessionState?.disconnectReason + ? `Disconnect reason: ${sessionState.disconnectReason}` + : 'Session state is persisted locally so reconnect flows are easier to recover.'} + + {sessionState?.sessionTopic ? ( + Session topic: {sessionState.sessionTopic} + ) : null} + + + + + + Supported chains + + WalletConnect v2 is configured for Ethereum, Polygon, Arbitrum, Optimism, and Base. + + + {WALLETCONNECT_CHAINS.map((chain) => ( + + + {chain.name} + + {chain.caipNetworkId} + + ))} + + + + + + + QR handoff + + Scan or copy this WalletConnect v2 handoff payload to continue the same session setup + on another device. + + + + + + Copy pairing handoff + + + + + {!connection ? ( + + + + Connect your wallet + + WalletConnect v2 will launch the modal with persisted connection state and the + configured chain set for better network switching. + + + + + {['MetaMask', 'Trust Wallet', 'Rainbow', 'Coinbase Wallet'].map((wallet) => ( + + + {wallet.slice(0, 2).toUpperCase()} + + {wallet} + + ))} + + +