fix: WalletConnect race conditions, session validation and timeout handling

This commit is contained in:
2026-02-23 00:16:34 +03:00
parent 87f6d0471e
commit d7278956fa
10 changed files with 136 additions and 51 deletions
@@ -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 { useTranslation } from 'react-i18next';
import { Smartphone, Loader2, CheckCircle, XCircle, ExternalLink } from 'lucide-react'; import { Smartphone, Loader2, CheckCircle, XCircle, ExternalLink } from 'lucide-react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
@@ -13,6 +13,8 @@ import { Button } from '@/components/ui/button';
import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
const CONNECTION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
interface WalletConnectModalProps { interface WalletConnectModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -28,15 +30,34 @@ export const WalletConnectModal: React.FC<WalletConnectModalProps> = ({ isOpen,
const [wcUri, setWcUri] = useState<string>(''); const [wcUri, setWcUri] = useState<string>('');
const [connectionState, setConnectionState] = useState<ConnectionState>('generating'); const [connectionState, setConnectionState] = useState<ConnectionState>('generating');
const [errorMsg, setErrorMsg] = useState<string>(''); const [errorMsg, setErrorMsg] = useState<string>('');
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connectedRef = useRef(false);
const clearConnectionTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const startConnection = useCallback(async () => { const startConnection = useCallback(async () => {
setConnectionState('generating'); setConnectionState('generating');
setErrorMsg(''); setErrorMsg('');
connectedRef.current = false;
clearConnectionTimeout();
try { try {
const uri = await connectWalletConnect(); const uri = await connectWalletConnect();
setWcUri(uri); 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) { if (isMobile) {
// Mobile: open pezWallet via deep link automatically // Mobile: open pezWallet via deep link automatically
const deepLink = `pezkuwiwallet://wc?uri=${encodeURIComponent(uri)}`; const deepLink = `pezkuwiwallet://wc?uri=${encodeURIComponent(uri)}`;
@@ -56,35 +77,38 @@ export const WalletConnectModal: React.FC<WalletConnectModalProps> = ({ isOpen,
setConnectionState('waiting'); setConnectionState('waiting');
} }
} catch (err) { } catch (err) {
clearConnectionTimeout();
setConnectionState('error'); setConnectionState('error');
setErrorMsg(err instanceof Error ? err.message : 'Connection failed'); 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(() => { useEffect(() => {
if (isOpen) { if (!isOpen) return;
startConnection();
}
return () => {
setQrDataUrl('');
setWcUri('');
setConnectionState('generating');
};
}, [isOpen, startConnection]);
// Listen for successful connection
useEffect(() => {
const handleConnected = () => { const handleConnected = () => {
connectedRef.current = true;
clearConnectionTimeout();
setConnectionState('connected'); setConnectionState('connected');
// Auto-close after brief success display
setTimeout(() => onClose(), 1500); setTimeout(() => onClose(), 1500);
}; };
window.addEventListener('walletconnect_connected', handleConnected); 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 = () => { const handleOpenPezWallet = () => {
if (wcUri) { if (wcUri) {
+19
View File
@@ -551,6 +551,25 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
} }
}; };
// 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 = { const value: PezkuwiContextType = {
api, api,
assetHubApi, assetHubApi,
+11 -5
View File
@@ -11,7 +11,7 @@ import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types';
import type { Signer } from '@pezkuwi/api/types'; import type { Signer } from '@pezkuwi/api/types';
import { web3FromAddress } from '@pezkuwi/extension-dapp'; import { web3FromAddress } from '@pezkuwi/extension-dapp';
import { isMobileApp, signTransactionNative, type TransactionPayload } from '@/lib/mobile-bridge'; 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 { interface TokenBalances {
HEZ: string; HEZ: string;
@@ -257,7 +257,10 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
} }
// WalletConnect: Use WC signer // 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'); if (import.meta.env.DEV) console.log('[WC] Using WalletConnect for transaction signing');
const genesisHash = pezkuwi.api.genesisHash.toHex(); const genesisHash = pezkuwi.api.genesisHash.toHex();
@@ -295,7 +298,10 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
try { try {
// WalletConnect signing // 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'); if (import.meta.env.DEV) console.log('[WC] Using WalletConnect for message signing');
const genesisHash = pezkuwi.api.genesisHash.toHex(); const genesisHash = pezkuwi.api.genesisHash.toHex();
@@ -340,12 +346,12 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
} }
try { try {
if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && pezkuwi.api) { if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && validateSession() && pezkuwi.api) {
const genesisHash = pezkuwi.api.genesisHash.toHex(); const genesisHash = pezkuwi.api.genesisHash.toHex();
const wcSigner = createWCSigner(genesisHash, pezkuwi.selectedAccount.address); const wcSigner = createWCSigner(genesisHash, pezkuwi.selectedAccount.address);
setSigner(wcSigner as unknown as Signer); setSigner(wcSigner as unknown as Signer);
if (import.meta.env.DEV) console.log('✅ WC Signer obtained for', pezkuwi.selectedAccount.address); 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); const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
setSigner(injector.signer); setSigner(injector.signer);
if (import.meta.env.DEV) console.log('✅ Extension Signer obtained for', pezkuwi.selectedAccount.address); if (import.meta.env.DEV) console.log('✅ Extension Signer obtained for', pezkuwi.selectedAccount.address);
+1
View File
@@ -1836,6 +1836,7 @@ export default {
'walletModal.wcConnected': 'تم الاتصال!', 'walletModal.wcConnected': 'تم الاتصال!',
'walletModal.wcInstructions': 'افتح تطبيق pezWallet → الإعدادات → WalletConnect → امسح رمز QR', 'walletModal.wcInstructions': 'افتح تطبيق pezWallet → الإعدادات → WalletConnect → امسح رمز QR',
'walletModal.wcRetry': 'حاول مرة أخرى', 'walletModal.wcRetry': 'حاول مرة أخرى',
'walletModal.wcTimeout': 'انتهت مهلة الاتصال. يرجى المحاولة مرة أخرى.',
// WalletButton // WalletButton
'walletBtn.connectWallet': 'توصيل المحفظة', 'walletBtn.connectWallet': 'توصيل المحفظة',
+1
View File
@@ -1826,6 +1826,7 @@ export default {
'walletModal.wcConnected': 'پەیوەندی کرا!', 'walletModal.wcConnected': 'پەیوەندی کرا!',
'walletModal.wcInstructions': 'ئەپی pezWallet بکەرەوە → ڕێکخستنەکان → WalletConnect → QR کۆد سکان بکە', 'walletModal.wcInstructions': 'ئەپی pezWallet بکەرەوە → ڕێکخستنەکان → WalletConnect → QR کۆد سکان بکە',
'walletModal.wcRetry': 'دووبارە هەوڵبدەرەوە', 'walletModal.wcRetry': 'دووبارە هەوڵبدەرەوە',
'walletModal.wcTimeout': 'کاتی پەیوەندی تەواو بوو. تکایە دووبارە هەوڵبدەرەوە.',
// WalletButton // WalletButton
'walletBtn.connectWallet': 'جزدان پەیوەست بکە', 'walletBtn.connectWallet': 'جزدان پەیوەست بکە',
+1
View File
@@ -2195,6 +2195,7 @@ export default {
'walletModal.wcConnected': 'Connected!', 'walletModal.wcConnected': 'Connected!',
'walletModal.wcInstructions': 'Open pezWallet app → Settings → WalletConnect → Scan QR code', 'walletModal.wcInstructions': 'Open pezWallet app → Settings → WalletConnect → Scan QR code',
'walletModal.wcRetry': 'Try Again', 'walletModal.wcRetry': 'Try Again',
'walletModal.wcTimeout': 'Connection timed out. Please try again.',
// WalletButton // WalletButton
'walletBtn.connectWallet': 'Connect Wallet', 'walletBtn.connectWallet': 'Connect Wallet',
+1
View File
@@ -1796,6 +1796,7 @@ export default {
'walletModal.wcConnected': 'متصل شد!', 'walletModal.wcConnected': 'متصل شد!',
'walletModal.wcInstructions': 'برنامه pezWallet را باز کنید → تنظیمات → WalletConnect → کد QR را اسکن کنید', 'walletModal.wcInstructions': 'برنامه pezWallet را باز کنید → تنظیمات → WalletConnect → کد QR را اسکن کنید',
'walletModal.wcRetry': 'تلاش مجدد', 'walletModal.wcRetry': 'تلاش مجدد',
'walletModal.wcTimeout': 'مهلت اتصال تمام شد. لطفا دوباره تلاش کنید.',
// WalletButton // WalletButton
'walletBtn.connectWallet': 'اتصال کیف پول', 'walletBtn.connectWallet': 'اتصال کیف پول',
+1
View File
@@ -1853,6 +1853,7 @@ export default {
'walletModal.wcConnected': 'Girêdayî!', 'walletModal.wcConnected': 'Girêdayî!',
'walletModal.wcInstructions': 'Serlêdana pezWallet veke → Mîheng → WalletConnect → QR kodê bişopîne', 'walletModal.wcInstructions': 'Serlêdana pezWallet veke → Mîheng → WalletConnect → QR kodê bişopîne',
'walletModal.wcRetry': 'Dîsa biceribîne', 'walletModal.wcRetry': 'Dîsa biceribîne',
'walletModal.wcTimeout': 'Girêdan qut bû. Ji kerema xwe dîsa biceribîne.',
// WalletButton // WalletButton
'walletBtn.connectWallet': 'Berîkê Girêbide', 'walletBtn.connectWallet': 'Berîkê Girêbide',
+1
View File
@@ -1847,6 +1847,7 @@ export default {
'walletModal.wcConnected': 'Bağlandı!', 'walletModal.wcConnected': 'Bağlandı!',
'walletModal.wcInstructions': 'pezWallet uygulamasını açın → Ayarlar → WalletConnect → QR kodu tarayın', 'walletModal.wcInstructions': 'pezWallet uygulamasını açın → Ayarlar → WalletConnect → QR kodu tarayın',
'walletModal.wcRetry': 'Tekrar Dene', 'walletModal.wcRetry': 'Tekrar Dene',
'walletModal.wcTimeout': 'Bağlantı zaman aşımına uğradı. Lütfen tekrar deneyin.',
// WalletButton // WalletButton
'walletBtn.connectWallet': 'Cüzdan Bağla', 'walletBtn.connectWallet': 'Cüzdan Bağla',
+58 -28
View File
@@ -19,16 +19,18 @@ const POLKADOT_METHODS = ['polkadot_signTransaction', 'polkadot_signMessage'];
const POLKADOT_EVENTS = ['chainChanged', 'accountsChanged']; const POLKADOT_EVENTS = ['chainChanged', 'accountsChanged'];
let signClient: SignClient | null = null; let signClient: SignClient | null = null;
let initPromise: Promise<SignClient> | null = null;
let currentSession: SessionTypes.Struct | null = null; let currentSession: SessionTypes.Struct | null = null;
let requestId = 0; let requestId = 0;
/** /**
* Initialize the WalletConnect SignClient * Initialize the WalletConnect SignClient (singleton with race protection)
*/ */
export async function initWalletConnect(): Promise<SignClient> { export async function initWalletConnect(): Promise<SignClient> {
if (signClient) return signClient; if (signClient) return signClient;
if (initPromise) return initPromise;
signClient = await SignClient.init({ initPromise = SignClient.init({
projectId: PROJECT_ID, projectId: PROJECT_ID,
metadata: { metadata: {
name: 'PezkuwiChain', name: 'PezkuwiChain',
@@ -36,38 +38,44 @@ export async function initWalletConnect(): Promise<SignClient> {
url: 'https://app.pezkuwichain.io', url: 'https://app.pezkuwichain.io',
icons: ['https://app.pezkuwichain.io/logo.png'], 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 return initPromise;
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;
} }
/** /**
* Build the polkadot: chain ID from genesis hash * Build the polkadot: chain ID from genesis hash
* Format: polkadot:<first_32_bytes_hex_without_0x> * CAIP-2 format: polkadot:<first_16_bytes_hex_without_0x>
*/ */
export function buildChainId(genesisHash: string): string { export function buildChainId(genesisHash: string): string {
const hash = genesisHash.startsWith('0x') ? genesisHash.slice(2) : genesisHash; 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, 32)}`;
return `polkadot:${hash.slice(0, 64)}`;
} }
/** /**
@@ -82,7 +90,7 @@ export async function connectWithQR(genesisHash: string): Promise<{
const chainId = buildChainId(genesisHash); const chainId = buildChainId(genesisHash);
const { uri, approval } = await client.connect({ const { uri, approval } = await client.connect({
requiredNamespaces: { optionalNamespaces: {
polkadot: { polkadot: {
methods: POLKADOT_METHODS, methods: POLKADOT_METHODS,
chains: [chainId], chains: [chainId],
@@ -139,6 +147,15 @@ export function getSessionPeerIcon(): string | null {
return currentSession.peer.metadata.icons?.[0] || 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 * Create a Signer adapter compatible with @pezkuwi/api's Signer interface
* Routes signPayload and signRaw through WalletConnect * Routes signPayload and signRaw through WalletConnect
@@ -163,7 +180,13 @@ export function createWCSigner(genesisHash: string, address: string) {
version: number; version: number;
}) => { }) => {
if (!signClient || !currentSession) { 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; const id = ++requestId;
@@ -192,7 +215,13 @@ export function createWCSigner(genesisHash: string, address: string) {
type: 'bytes' | 'payload'; type: 'bytes' | 'payload';
}) => { }) => {
if (!signClient || !currentSession) { 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; const id = ++requestId;
@@ -264,6 +293,7 @@ export async function disconnectWC(): Promise<void> {
currentSession = null; currentSession = null;
localStorage.removeItem(WC_SESSION_KEY); localStorage.removeItem(WC_SESSION_KEY);
window.dispatchEvent(new Event('walletconnect_disconnected'));
} }
/** /**