diff --git a/web/src/components/wallet/WalletConnectModal.tsx b/web/src/components/wallet/WalletConnectModal.tsx index b45d7a47..5f9f5799 100644 --- a/web/src/components/wallet/WalletConnectModal.tsx +++ b/web/src/components/wallet/WalletConnectModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Smartphone, Loader2, CheckCircle, XCircle, ExternalLink } from 'lucide-react'; import QRCode from 'qrcode'; @@ -13,6 +13,8 @@ import { Button } from '@/components/ui/button'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { useIsMobile } from '@/hooks/use-mobile'; +const CONNECTION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + interface WalletConnectModalProps { isOpen: boolean; onClose: () => void; @@ -28,15 +30,34 @@ export const WalletConnectModal: React.FC = ({ isOpen, const [wcUri, setWcUri] = useState(''); const [connectionState, setConnectionState] = useState('generating'); const [errorMsg, setErrorMsg] = useState(''); + const timeoutRef = useRef | null>(null); + const connectedRef = useRef(false); + + const clearConnectionTimeout = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); const startConnection = useCallback(async () => { setConnectionState('generating'); setErrorMsg(''); + connectedRef.current = false; + clearConnectionTimeout(); try { const uri = await connectWalletConnect(); setWcUri(uri); + // Start connection timeout + timeoutRef.current = setTimeout(() => { + if (!connectedRef.current) { + setConnectionState('error'); + setErrorMsg(t('walletModal.wcTimeout', 'Connection timed out. Please try again.')); + } + }, CONNECTION_TIMEOUT_MS); + if (isMobile) { // Mobile: open pezWallet via deep link automatically const deepLink = `pezkuwiwallet://wc?uri=${encodeURIComponent(uri)}`; @@ -56,35 +77,38 @@ export const WalletConnectModal: React.FC = ({ isOpen, setConnectionState('waiting'); } } catch (err) { + clearConnectionTimeout(); setConnectionState('error'); setErrorMsg(err instanceof Error ? err.message : 'Connection failed'); } - }, [connectWalletConnect, isMobile]); + }, [connectWalletConnect, isMobile, clearConnectionTimeout, t]); - // Start connection when modal opens + // Listen for successful connection - registered BEFORE startConnection to avoid race useEffect(() => { - if (isOpen) { - startConnection(); - } + if (!isOpen) return; - return () => { - setQrDataUrl(''); - setWcUri(''); - setConnectionState('generating'); - }; - }, [isOpen, startConnection]); - - // Listen for successful connection - useEffect(() => { const handleConnected = () => { + connectedRef.current = true; + clearConnectionTimeout(); setConnectionState('connected'); - // Auto-close after brief success display setTimeout(() => onClose(), 1500); }; window.addEventListener('walletconnect_connected', handleConnected); - return () => window.removeEventListener('walletconnect_connected', handleConnected); - }, [onClose]); + + // Start connection after listener is registered + startConnection(); + + return () => { + window.removeEventListener('walletconnect_connected', handleConnected); + clearConnectionTimeout(); + setQrDataUrl(''); + setWcUri(''); + setConnectionState('generating'); + connectedRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); const handleOpenPezWallet = () => { if (wcUri) { diff --git a/web/src/contexts/PezkuwiContext.tsx b/web/src/contexts/PezkuwiContext.tsx index 88833167..76bca74c 100644 --- a/web/src/contexts/PezkuwiContext.tsx +++ b/web/src/contexts/PezkuwiContext.tsx @@ -551,6 +551,25 @@ export const PezkuwiProvider: React.FC = ({ } }; + // Listen for remote WalletConnect disconnects (wallet side) + useEffect(() => { + const handleWcDisconnect = () => { + if (walletSource === 'walletconnect') { + setAccounts([]); + handleSetSelectedAccount(null); + setWalletSource(null); + setWcPeerName(null); + if (import.meta.env.DEV) { + console.log('🔌 WalletConnect session ended remotely'); + } + } + }; + + window.addEventListener('walletconnect_disconnected', handleWcDisconnect); + return () => window.removeEventListener('walletconnect_disconnected', handleWcDisconnect); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletSource]); + const value: PezkuwiContextType = { api, assetHubApi, diff --git a/web/src/contexts/WalletContext.tsx b/web/src/contexts/WalletContext.tsx index 2b9c99fd..d9859c12 100644 --- a/web/src/contexts/WalletContext.tsx +++ b/web/src/contexts/WalletContext.tsx @@ -11,7 +11,7 @@ import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types'; import type { Signer } from '@pezkuwi/api/types'; import { web3FromAddress } from '@pezkuwi/extension-dapp'; import { isMobileApp, signTransactionNative, type TransactionPayload } from '@/lib/mobile-bridge'; -import { createWCSigner, isWCConnected } from '@/lib/walletconnect-service'; +import { createWCSigner, isWCConnected, validateSession } from '@/lib/walletconnect-service'; interface TokenBalances { HEZ: string; @@ -257,7 +257,10 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr } // WalletConnect: Use WC signer - if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && pezkuwi.api) { + if (pezkuwi.walletSource === 'walletconnect' && pezkuwi.api) { + if (!isWCConnected() || !validateSession()) { + throw new Error('WalletConnect session expired. Please reconnect your wallet.'); + } if (import.meta.env.DEV) console.log('[WC] Using WalletConnect for transaction signing'); const genesisHash = pezkuwi.api.genesisHash.toHex(); @@ -295,7 +298,10 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr try { // WalletConnect signing - if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && pezkuwi.api) { + if (pezkuwi.walletSource === 'walletconnect' && pezkuwi.api) { + if (!isWCConnected() || !validateSession()) { + throw new Error('WalletConnect session expired. Please reconnect your wallet.'); + } if (import.meta.env.DEV) console.log('[WC] Using WalletConnect for message signing'); const genesisHash = pezkuwi.api.genesisHash.toHex(); @@ -340,12 +346,12 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr } try { - if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && pezkuwi.api) { + if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && validateSession() && pezkuwi.api) { const genesisHash = pezkuwi.api.genesisHash.toHex(); const wcSigner = createWCSigner(genesisHash, pezkuwi.selectedAccount.address); setSigner(wcSigner as unknown as Signer); if (import.meta.env.DEV) console.log('✅ WC Signer obtained for', pezkuwi.selectedAccount.address); - } else { + } else if (pezkuwi.walletSource !== 'walletconnect') { const injector = await web3FromAddress(pezkuwi.selectedAccount.address); setSigner(injector.signer); if (import.meta.env.DEV) console.log('✅ Extension Signer obtained for', pezkuwi.selectedAccount.address); diff --git a/web/src/i18n/locales/ar.ts b/web/src/i18n/locales/ar.ts index b46c7973..5bc754e5 100644 --- a/web/src/i18n/locales/ar.ts +++ b/web/src/i18n/locales/ar.ts @@ -1836,6 +1836,7 @@ export default { 'walletModal.wcConnected': 'تم الاتصال!', 'walletModal.wcInstructions': 'افتح تطبيق pezWallet → الإعدادات → WalletConnect → امسح رمز QR', 'walletModal.wcRetry': 'حاول مرة أخرى', + 'walletModal.wcTimeout': 'انتهت مهلة الاتصال. يرجى المحاولة مرة أخرى.', // WalletButton 'walletBtn.connectWallet': 'توصيل المحفظة', diff --git a/web/src/i18n/locales/ckb.ts b/web/src/i18n/locales/ckb.ts index 23024ba6..c5100b92 100644 --- a/web/src/i18n/locales/ckb.ts +++ b/web/src/i18n/locales/ckb.ts @@ -1826,6 +1826,7 @@ export default { 'walletModal.wcConnected': 'پەیوەندی کرا!', 'walletModal.wcInstructions': 'ئەپی pezWallet بکەرەوە → ڕێکخستنەکان → WalletConnect → QR کۆد سکان بکە', 'walletModal.wcRetry': 'دووبارە هەوڵبدەرەوە', + 'walletModal.wcTimeout': 'کاتی پەیوەندی تەواو بوو. تکایە دووبارە هەوڵبدەرەوە.', // WalletButton 'walletBtn.connectWallet': 'جزدان پەیوەست بکە', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 4e9cdc07..ec15945c 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -2195,6 +2195,7 @@ export default { 'walletModal.wcConnected': 'Connected!', 'walletModal.wcInstructions': 'Open pezWallet app → Settings → WalletConnect → Scan QR code', 'walletModal.wcRetry': 'Try Again', + 'walletModal.wcTimeout': 'Connection timed out. Please try again.', // WalletButton 'walletBtn.connectWallet': 'Connect Wallet', diff --git a/web/src/i18n/locales/fa.ts b/web/src/i18n/locales/fa.ts index ae9ae376..eb745656 100644 --- a/web/src/i18n/locales/fa.ts +++ b/web/src/i18n/locales/fa.ts @@ -1796,6 +1796,7 @@ export default { 'walletModal.wcConnected': 'متصل شد!', 'walletModal.wcInstructions': 'برنامه pezWallet را باز کنید → تنظیمات → WalletConnect → کد QR را اسکن کنید', 'walletModal.wcRetry': 'تلاش مجدد', + 'walletModal.wcTimeout': 'مهلت اتصال تمام شد. لطفا دوباره تلاش کنید.', // WalletButton 'walletBtn.connectWallet': 'اتصال کیف پول', diff --git a/web/src/i18n/locales/kmr.ts b/web/src/i18n/locales/kmr.ts index 89a8886e..9d818f92 100644 --- a/web/src/i18n/locales/kmr.ts +++ b/web/src/i18n/locales/kmr.ts @@ -1853,6 +1853,7 @@ export default { 'walletModal.wcConnected': 'Girêdayî!', 'walletModal.wcInstructions': 'Serlêdana pezWallet veke → Mîheng → WalletConnect → QR kodê bişopîne', 'walletModal.wcRetry': 'Dîsa biceribîne', + 'walletModal.wcTimeout': 'Girêdan qut bû. Ji kerema xwe dîsa biceribîne.', // WalletButton 'walletBtn.connectWallet': 'Berîkê Girêbide', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index 48de8801..5ad5e90c 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -1847,6 +1847,7 @@ export default { 'walletModal.wcConnected': 'Bağlandı!', 'walletModal.wcInstructions': 'pezWallet uygulamasını açın → Ayarlar → WalletConnect → QR kodu tarayın', 'walletModal.wcRetry': 'Tekrar Dene', + 'walletModal.wcTimeout': 'Bağlantı zaman aşımına uğradı. Lütfen tekrar deneyin.', // WalletButton 'walletBtn.connectWallet': 'Cüzdan Bağla', diff --git a/web/src/lib/walletconnect-service.ts b/web/src/lib/walletconnect-service.ts index 86963875..885031fd 100644 --- a/web/src/lib/walletconnect-service.ts +++ b/web/src/lib/walletconnect-service.ts @@ -19,16 +19,18 @@ const POLKADOT_METHODS = ['polkadot_signTransaction', 'polkadot_signMessage']; const POLKADOT_EVENTS = ['chainChanged', 'accountsChanged']; let signClient: SignClient | null = null; +let initPromise: Promise | null = null; let currentSession: SessionTypes.Struct | null = null; let requestId = 0; /** - * Initialize the WalletConnect SignClient + * Initialize the WalletConnect SignClient (singleton with race protection) */ export async function initWalletConnect(): Promise { if (signClient) return signClient; + if (initPromise) return initPromise; - signClient = await SignClient.init({ + initPromise = SignClient.init({ projectId: PROJECT_ID, metadata: { name: 'PezkuwiChain', @@ -36,38 +38,44 @@ export async function initWalletConnect(): Promise { url: 'https://app.pezkuwichain.io', icons: ['https://app.pezkuwichain.io/logo.png'], }, + }).then((client) => { + signClient = client; + + // Listen for session events + client.on('session_delete', () => { + currentSession = null; + localStorage.removeItem(WC_SESSION_KEY); + window.dispatchEvent(new Event('walletconnect_disconnected')); + }); + + client.on('session_expire', () => { + currentSession = null; + localStorage.removeItem(WC_SESSION_KEY); + window.dispatchEvent(new Event('walletconnect_disconnected')); + }); + + client.on('session_update', ({ params }) => { + if (currentSession) { + currentSession = { ...currentSession, namespaces: params.namespaces }; + } + }); + + return client; + }).catch((err) => { + initPromise = null; + throw err; }); - // Listen for session events - signClient.on('session_delete', () => { - currentSession = null; - localStorage.removeItem(WC_SESSION_KEY); - window.dispatchEvent(new Event('walletconnect_disconnected')); - }); - - signClient.on('session_expire', () => { - currentSession = null; - localStorage.removeItem(WC_SESSION_KEY); - window.dispatchEvent(new Event('walletconnect_disconnected')); - }); - - signClient.on('session_update', ({ params }) => { - if (currentSession) { - currentSession = { ...currentSession, namespaces: params.namespaces }; - } - }); - - return signClient; + return initPromise; } /** * Build the polkadot: chain ID from genesis hash - * Format: polkadot: + * CAIP-2 format: polkadot: */ export function buildChainId(genesisHash: string): string { const hash = genesisHash.startsWith('0x') ? genesisHash.slice(2) : genesisHash; - // WalletConnect uses first 32 bytes (64 hex chars) of genesis hash - return `polkadot:${hash.slice(0, 64)}`; + return `polkadot:${hash.slice(0, 32)}`; } /** @@ -82,7 +90,7 @@ export async function connectWithQR(genesisHash: string): Promise<{ const chainId = buildChainId(genesisHash); const { uri, approval } = await client.connect({ - requiredNamespaces: { + optionalNamespaces: { polkadot: { methods: POLKADOT_METHODS, chains: [chainId], @@ -139,6 +147,15 @@ export function getSessionPeerIcon(): string | null { return currentSession.peer.metadata.icons?.[0] || null; } +/** + * Check if the current WC session is still valid (not expired) + */ +export function validateSession(): boolean { + if (!currentSession) return false; + const now = Math.floor(Date.now() / 1000); + return currentSession.expiry > now; +} + /** * Create a Signer adapter compatible with @pezkuwi/api's Signer interface * Routes signPayload and signRaw through WalletConnect @@ -163,7 +180,13 @@ export function createWCSigner(genesisHash: string, address: string) { version: number; }) => { if (!signClient || !currentSession) { - throw new Error('WalletConnect session not active'); + throw new Error('WalletConnect session not active. Please reconnect your wallet.'); + } + if (!validateSession()) { + currentSession = null; + localStorage.removeItem(WC_SESSION_KEY); + window.dispatchEvent(new Event('walletconnect_disconnected')); + throw new Error('WalletConnect session expired. Please reconnect your wallet.'); } const id = ++requestId; @@ -192,7 +215,13 @@ export function createWCSigner(genesisHash: string, address: string) { type: 'bytes' | 'payload'; }) => { if (!signClient || !currentSession) { - throw new Error('WalletConnect session not active'); + throw new Error('WalletConnect session not active. Please reconnect your wallet.'); + } + if (!validateSession()) { + currentSession = null; + localStorage.removeItem(WC_SESSION_KEY); + window.dispatchEvent(new Event('walletconnect_disconnected')); + throw new Error('WalletConnect session expired. Please reconnect your wallet.'); } const id = ++requestId; @@ -264,6 +293,7 @@ export async function disconnectWC(): Promise { currentSession = null; localStorage.removeItem(WC_SESSION_KEY); + window.dispatchEvent(new Event('walletconnect_disconnected')); } /**