fix: WalletConnect race conditions, session validation and timeout handling

This commit is contained in:
2026-02-23 00:16:34 +03:00
parent 73b5ebece6
commit 87dfc249aa
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 { 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<WalletConnectModalProps> = ({ isOpen,
const [wcUri, setWcUri] = useState<string>('');
const [connectionState, setConnectionState] = useState<ConnectionState>('generating');
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 () => {
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<WalletConnectModalProps> = ({ 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) {
+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 = {
api,
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 { 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);
+1
View File
@@ -1836,6 +1836,7 @@ export default {
'walletModal.wcConnected': 'تم الاتصال!',
'walletModal.wcInstructions': 'افتح تطبيق pezWallet → الإعدادات → WalletConnect → امسح رمز QR',
'walletModal.wcRetry': 'حاول مرة أخرى',
'walletModal.wcTimeout': 'انتهت مهلة الاتصال. يرجى المحاولة مرة أخرى.',
// WalletButton
'walletBtn.connectWallet': 'توصيل المحفظة',
+1
View File
@@ -1826,6 +1826,7 @@ export default {
'walletModal.wcConnected': 'پەیوەندی کرا!',
'walletModal.wcInstructions': 'ئەپی pezWallet بکەرەوە → ڕێکخستنەکان → WalletConnect → QR کۆد سکان بکە',
'walletModal.wcRetry': 'دووبارە هەوڵبدەرەوە',
'walletModal.wcTimeout': 'کاتی پەیوەندی تەواو بوو. تکایە دووبارە هەوڵبدەرەوە.',
// WalletButton
'walletBtn.connectWallet': 'جزدان پەیوەست بکە',
+1
View File
@@ -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',
+1
View File
@@ -1796,6 +1796,7 @@ export default {
'walletModal.wcConnected': 'متصل شد!',
'walletModal.wcInstructions': 'برنامه pezWallet را باز کنید → تنظیمات → WalletConnect → کد QR را اسکن کنید',
'walletModal.wcRetry': 'تلاش مجدد',
'walletModal.wcTimeout': 'مهلت اتصال تمام شد. لطفا دوباره تلاش کنید.',
// WalletButton
'walletBtn.connectWallet': 'اتصال کیف پول',
+1
View File
@@ -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',
+1
View File
@@ -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',
+58 -28
View File
@@ -19,16 +19,18 @@ const POLKADOT_METHODS = ['polkadot_signTransaction', 'polkadot_signMessage'];
const POLKADOT_EVENTS = ['chainChanged', 'accountsChanged'];
let signClient: SignClient | null = null;
let initPromise: Promise<SignClient> | 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<SignClient> {
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<SignClient> {
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:<first_32_bytes_hex_without_0x>
* CAIP-2 format: polkadot:<first_16_bytes_hex_without_0x>
*/
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<void> {
currentSession = null;
localStorage.removeItem(WC_SESSION_KEY);
window.dispatchEvent(new Event('walletconnect_disconnected'));
}
/**