mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 02:07:55 +00:00
fix: WalletConnect race conditions, session validation and timeout handling
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -1836,6 +1836,7 @@ export default {
|
||||
'walletModal.wcConnected': 'تم الاتصال!',
|
||||
'walletModal.wcInstructions': 'افتح تطبيق pezWallet → الإعدادات → WalletConnect → امسح رمز QR',
|
||||
'walletModal.wcRetry': 'حاول مرة أخرى',
|
||||
'walletModal.wcTimeout': 'انتهت مهلة الاتصال. يرجى المحاولة مرة أخرى.',
|
||||
|
||||
// WalletButton
|
||||
'walletBtn.connectWallet': 'توصيل المحفظة',
|
||||
|
||||
@@ -1826,6 +1826,7 @@ export default {
|
||||
'walletModal.wcConnected': 'پەیوەندی کرا!',
|
||||
'walletModal.wcInstructions': 'ئەپی pezWallet بکەرەوە → ڕێکخستنەکان → WalletConnect → QR کۆد سکان بکە',
|
||||
'walletModal.wcRetry': 'دووبارە هەوڵبدەرەوە',
|
||||
'walletModal.wcTimeout': 'کاتی پەیوەندی تەواو بوو. تکایە دووبارە هەوڵبدەرەوە.',
|
||||
|
||||
// WalletButton
|
||||
'walletBtn.connectWallet': 'جزدان پەیوەست بکە',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1796,6 +1796,7 @@ export default {
|
||||
'walletModal.wcConnected': 'متصل شد!',
|
||||
'walletModal.wcInstructions': 'برنامه pezWallet را باز کنید → تنظیمات → WalletConnect → کد QR را اسکن کنید',
|
||||
'walletModal.wcRetry': 'تلاش مجدد',
|
||||
'walletModal.wcTimeout': 'مهلت اتصال تمام شد. لطفا دوباره تلاش کنید.',
|
||||
|
||||
// WalletButton
|
||||
'walletBtn.connectWallet': 'اتصال کیف پول',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user