mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-21 23:47:56 +00:00
feat: add WalletConnect v2 integration for mobile wallet connection
- Add @walletconnect/sign-client for dApp-side WC v2 support - Create walletconnect-service.ts with session management and Signer adapter - Create WalletConnectModal.tsx with QR code display - Update PezkuwiContext with connectWalletConnect(), session restore, walletSource tracking - Update WalletContext signing router: mobile → WalletConnect → extension - Update WalletModal with "Connect with pezWallet (Mobile)" button - Move WalletConnect projectId to env variable - Fix vite build: disable assetsInclude for JSON (crypto-browserify compat)
This commit is contained in:
@@ -67,6 +67,7 @@ jobs:
|
||||
VITE_WS_ENDPOINT_FALLBACK_1: wss://mainnet.pezkuwichain.io
|
||||
VITE_ASSET_HUB_ENDPOINT: wss://asset-hub-rpc.pezkuwichain.io
|
||||
VITE_PEOPLE_CHAIN_ENDPOINT: wss://people-rpc.pezkuwichain.io
|
||||
VITE_WALLETCONNECT_PROJECT_ID: 8292a793b7640e8364c378e331e76d04
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -94,6 +94,12 @@ VITE_ENABLE_P2P_MARKET=true
|
||||
VITE_ENABLE_GOVERNANCE=true
|
||||
VITE_ENABLE_STAKING=true
|
||||
|
||||
# ========================================
|
||||
# WALLETCONNECT
|
||||
# ========================================
|
||||
# Get from: https://cloud.walletconnect.com (Reown dashboard)
|
||||
VITE_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id_here
|
||||
|
||||
# ========================================
|
||||
# DEVELOPMENT & DEBUGGING
|
||||
# ========================================
|
||||
|
||||
Generated
+1150
-5
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,8 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@walletconnect/sign-client": "^2.23.6",
|
||||
"@walletconnect/types": "^2.23.6",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Smartphone, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
|
||||
interface WalletConnectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ConnectionState = 'generating' | 'waiting' | 'connected' | 'error';
|
||||
|
||||
export const WalletConnectModal: React.FC<WalletConnectModalProps> = ({ isOpen, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const { connectWalletConnect, selectedAccount, wcPeerName } = usePezkuwi();
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('generating');
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
const startConnection = useCallback(async () => {
|
||||
setConnectionState('generating');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const uri = await connectWalletConnect();
|
||||
|
||||
// Generate QR code as data URL
|
||||
const dataUrl = await QRCode.toDataURL(uri, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
setQrDataUrl(dataUrl);
|
||||
setConnectionState('waiting');
|
||||
} catch (err) {
|
||||
setConnectionState('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Connection failed');
|
||||
}
|
||||
}, [connectWalletConnect]);
|
||||
|
||||
// Start connection when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
startConnection();
|
||||
}
|
||||
|
||||
return () => {
|
||||
setQrDataUrl('');
|
||||
setConnectionState('generating');
|
||||
};
|
||||
}, [isOpen, startConnection]);
|
||||
|
||||
// Listen for successful connection
|
||||
useEffect(() => {
|
||||
const handleConnected = () => {
|
||||
setConnectionState('connected');
|
||||
// Auto-close after brief success display
|
||||
setTimeout(() => onClose(), 1500);
|
||||
};
|
||||
|
||||
window.addEventListener('walletconnect_connected', handleConnected);
|
||||
return () => window.removeEventListener('walletconnect_connected', handleConnected);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Smartphone className="h-5 w-5 text-purple-400" />
|
||||
WalletConnect
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('walletModal.wcScanQR', 'Scan with pezWallet to connect')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
{/* Generating state */}
|
||||
{connectionState === 'generating' && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-400" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{t('walletModal.wcGenerating', 'Generating QR code...')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code display - waiting for scan */}
|
||||
{connectionState === 'waiting' && qrDataUrl && (
|
||||
<>
|
||||
<div className="bg-white rounded-xl p-3">
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="WalletConnect QR Code"
|
||||
className="w-[280px] h-[280px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('walletModal.wcWaiting', 'Waiting for wallet to connect...')}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 text-center max-w-[280px]">
|
||||
{t('walletModal.wcInstructions', 'Open pezWallet app → Settings → WalletConnect → Scan QR code')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connected state */}
|
||||
{connectionState === 'connected' && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
<p className="text-lg font-medium text-green-400">
|
||||
{t('walletModal.wcConnected', 'Connected!')}
|
||||
</p>
|
||||
{wcPeerName && (
|
||||
<p className="text-sm text-gray-400">{wcPeerName}</p>
|
||||
)}
|
||||
{selectedAccount && (
|
||||
<code className="text-xs text-gray-500 font-mono">
|
||||
{selectedAccount.address.slice(0, 8)}...{selectedAccount.address.slice(-6)}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{connectionState === 'error' && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<XCircle className="h-12 w-12 text-red-500" />
|
||||
<p className="text-sm text-red-400">{errorMsg}</p>
|
||||
<Button
|
||||
onClick={startConnection}
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
>
|
||||
{t('walletModal.wcRetry', 'Try Again')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Wallet, Chrome, ExternalLink, Copy, Check, LogOut, Award, Users, TrendingUp, Shield } from 'lucide-react';
|
||||
import { Wallet, Chrome, ExternalLink, Copy, Check, LogOut, Award, Users, TrendingUp, Shield, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { usePezkuwi } from '@/contexts/PezkuwiContext';
|
||||
import { formatAddress } from '@pezkuwi/lib/wallet';
|
||||
import { getAllScores, type UserScores } from '@pezkuwi/lib/scores';
|
||||
import { WalletConnectModal } from './WalletConnectModal';
|
||||
|
||||
interface WalletModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -28,11 +29,14 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
|
||||
api,
|
||||
isApiReady,
|
||||
peopleApi,
|
||||
walletSource,
|
||||
wcPeerName,
|
||||
error
|
||||
} = usePezkuwi();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showWCModal, setShowWCModal] = useState(false);
|
||||
const [scores, setScores] = useState<UserScores>({
|
||||
trustScore: 0,
|
||||
referralScore: 0,
|
||||
@@ -241,8 +245,15 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-1">{t('walletModal.source')}</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
{selectedAccount.meta.source || 'pezkuwi'}
|
||||
<div className="text-sm text-gray-300 flex items-center gap-2">
|
||||
{walletSource === 'walletconnect' ? (
|
||||
<>
|
||||
<Smartphone className="h-3 w-3 text-purple-400" />
|
||||
WalletConnect{wcPeerName ? ` (${wcPeerName})` : ''}
|
||||
</>
|
||||
) : (
|
||||
selectedAccount.meta.source || 'pezkuwi'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,7 +332,7 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// No accounts, show connect button
|
||||
// No accounts, show connect buttons
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
@@ -331,6 +342,26 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
|
||||
{t('walletModal.connectPezkuwi')}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-gray-700" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-gray-500">
|
||||
{t('walletModal.or', 'or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowWCModal(true)}
|
||||
variant="outline"
|
||||
className="w-full border-purple-500/30 hover:border-purple-500/60 hover:bg-purple-500/10"
|
||||
>
|
||||
<Smartphone className="mr-2 h-4 w-4" />
|
||||
{t('walletModal.connectWC', 'Connect with pezWallet (Mobile)')}
|
||||
</Button>
|
||||
|
||||
<div className="text-sm text-gray-400 text-center">
|
||||
{t('walletModal.noWallet')}{' '}
|
||||
<a
|
||||
@@ -347,6 +378,16 @@ export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose }) =>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
{/* WalletConnect QR Modal */}
|
||||
<WalletConnectModal
|
||||
isOpen={showWCModal}
|
||||
onClose={() => {
|
||||
setShowWCModal(false);
|
||||
// If connected via WC, close the main modal too
|
||||
if (selectedAccount) onClose();
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,16 @@ import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types';
|
||||
import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/pezkuwi';
|
||||
import { getCurrentNetworkConfig } from '../../../shared/blockchain/endpoints';
|
||||
import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge';
|
||||
import {
|
||||
initWalletConnect,
|
||||
connectWithQR,
|
||||
getSessionAccounts,
|
||||
getSessionPeerName,
|
||||
restoreSession,
|
||||
disconnectWC,
|
||||
isWCConnected,
|
||||
createWCSigner,
|
||||
} from '@/lib/walletconnect-service';
|
||||
|
||||
// Get network config from shared endpoints
|
||||
const networkConfig = getCurrentNetworkConfig();
|
||||
@@ -34,6 +44,8 @@ const networkConfig = getCurrentNetworkConfig();
|
||||
const ASSET_HUB_ENDPOINT = import.meta.env.VITE_ASSET_HUB_ENDPOINT || networkConfig.assetHubEndpoint || 'wss://asset-hub-rpc.pezkuwichain.io';
|
||||
const PEOPLE_CHAIN_ENDPOINT = import.meta.env.VITE_PEOPLE_CHAIN_ENDPOINT || networkConfig.peopleChainEndpoint || 'wss://people-rpc.pezkuwichain.io';
|
||||
|
||||
export type WalletSource = 'extension' | 'native' | 'walletconnect' | null;
|
||||
|
||||
interface PezkuwiContextType {
|
||||
api: ApiPromise | null;
|
||||
assetHubApi: ApiPromise | null;
|
||||
@@ -46,7 +58,10 @@ interface PezkuwiContextType {
|
||||
selectedAccount: InjectedAccountWithMeta | null;
|
||||
setSelectedAccount: (account: InjectedAccountWithMeta | null) => void;
|
||||
connectWallet: () => Promise<void>;
|
||||
connectWalletConnect: () => Promise<string>;
|
||||
disconnectWallet: () => void;
|
||||
walletSource: WalletSource;
|
||||
wcPeerName: string | null;
|
||||
error: string | null;
|
||||
sudoKey: string | null;
|
||||
}
|
||||
@@ -72,6 +87,8 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
const [selectedAccount, setSelectedAccount] = useState<InjectedAccountWithMeta | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sudoKey, setSudoKey] = useState<string | null>(null);
|
||||
const [walletSource, setWalletSource] = useState<WalletSource>(null);
|
||||
const [wcPeerName, setWcPeerName] = useState<string | null>(null);
|
||||
|
||||
// Wrapper to trigger events when wallet changes
|
||||
const handleSetSelectedAccount = (account: InjectedAccountWithMeta | null) => {
|
||||
@@ -310,12 +327,44 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop: Try to restore from localStorage
|
||||
// Try to restore WalletConnect session first
|
||||
try {
|
||||
const wcSession = await restoreSession();
|
||||
if (wcSession) {
|
||||
const wcAddresses = getSessionAccounts();
|
||||
if (wcAddresses.length > 0) {
|
||||
const peerName = getSessionPeerName();
|
||||
const wcAccounts: InjectedAccountWithMeta[] = wcAddresses.map((addr) => ({
|
||||
address: addr,
|
||||
meta: {
|
||||
name: peerName || 'WalletConnect',
|
||||
source: 'walletconnect',
|
||||
},
|
||||
type: 'sr25519' as const,
|
||||
}));
|
||||
|
||||
setAccounts(wcAccounts);
|
||||
handleSetSelectedAccount(wcAccounts[0]);
|
||||
setWalletSource('walletconnect');
|
||||
setWcPeerName(peerName);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ WalletConnect session restored:', wcAddresses[0].slice(0, 8) + '...');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('Failed to restore WC session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop: Try to restore from localStorage (extension)
|
||||
const savedAddress = localStorage.getItem('selectedWallet');
|
||||
if (!savedAddress) return;
|
||||
|
||||
try {
|
||||
// Enable extension
|
||||
// Enable extension (works for both desktop extension and pezWallet DApps browser)
|
||||
const extensions = await web3Enable('PezkuwiChain');
|
||||
if (extensions.length === 0) return;
|
||||
|
||||
@@ -328,6 +377,7 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
if (savedAccount) {
|
||||
setAccounts(allAccounts);
|
||||
handleSetSelectedAccount(savedAccount);
|
||||
setWalletSource('extension');
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Wallet restored:', savedAddress.slice(0, 8) + '...');
|
||||
}
|
||||
@@ -361,13 +411,12 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Check if running in mobile app
|
||||
// Check if running in mobile app (native WebView bridge)
|
||||
if (isMobileApp()) {
|
||||
const nativeAddress = getNativeWalletAddress();
|
||||
const nativeAccountName = getNativeAccountName();
|
||||
|
||||
if (nativeAddress) {
|
||||
// Create a virtual account for the mobile wallet
|
||||
const mobileAccount: InjectedAccountWithMeta = {
|
||||
address: nativeAddress,
|
||||
meta: {
|
||||
@@ -379,30 +428,27 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
|
||||
setAccounts([mobileAccount]);
|
||||
handleSetSelectedAccount(mobileAccount);
|
||||
setWalletSource('native');
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Mobile] Native wallet connected:', nativeAddress.slice(0, 8) + '...');
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Request wallet connection from native app
|
||||
setError('Please connect your wallet in the app');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop: Check if extension is installed first
|
||||
// Desktop / pezWallet DApps browser: Try extension (injected provider)
|
||||
const hasExtension = !!(window as unknown as { injectedWeb3?: Record<string, unknown> }).injectedWeb3;
|
||||
|
||||
// Enable extension
|
||||
const extensions = await web3Enable('PezkuwiChain');
|
||||
|
||||
if (extensions.length === 0) {
|
||||
if (hasExtension) {
|
||||
// Extension is installed but user didn't authorize - don't redirect
|
||||
setError('Please authorize the connection in your Pezkuwi Wallet extension');
|
||||
} else {
|
||||
// Extension not installed - show install link
|
||||
setError('Pezkuwi Wallet extension not found. Please install from Chrome Web Store.');
|
||||
window.open('https://chrome.google.com/webstore/detail/pezkuwi-wallet/fbnboicjjeebjhgnapneaeccpgjcdibn', '_blank');
|
||||
}
|
||||
@@ -413,7 +459,6 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
console.log('✅ Pezkuwi.js extension enabled');
|
||||
}
|
||||
|
||||
// Get accounts
|
||||
const allAccounts = await web3Accounts();
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
@@ -423,14 +468,13 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
|
||||
setAccounts(allAccounts);
|
||||
|
||||
// Try to restore previously selected account, otherwise use first
|
||||
const savedAddress = localStorage.getItem('selectedWallet');
|
||||
const accountToSelect = savedAddress
|
||||
? allAccounts.find(acc => acc.address === savedAddress) || allAccounts[0]
|
||||
: allAccounts[0];
|
||||
|
||||
// Use wrapper to trigger events
|
||||
handleSetSelectedAccount(accountToSelect);
|
||||
setWalletSource('extension');
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ Found ${allAccounts.length} account(s)`);
|
||||
@@ -444,12 +488,67 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect wallet
|
||||
const disconnectWallet = () => {
|
||||
// Connect via WalletConnect v2 - returns pairing URI for QR code
|
||||
const connectWalletConnect = async (): Promise<string> => {
|
||||
if (!api || !isApiReady) {
|
||||
throw new Error('API not ready. Please wait for blockchain connection.');
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const genesisHash = api.genesisHash.toHex();
|
||||
|
||||
try {
|
||||
await initWalletConnect();
|
||||
const { uri, approval } = await connectWithQR(genesisHash);
|
||||
|
||||
// Start approval listener in background
|
||||
approval().then((session) => {
|
||||
const wcAddresses = getSessionAccounts();
|
||||
if (wcAddresses.length > 0) {
|
||||
const peerName = getSessionPeerName();
|
||||
const wcAccounts: InjectedAccountWithMeta[] = wcAddresses.map((addr) => ({
|
||||
address: addr,
|
||||
meta: {
|
||||
name: peerName || 'WalletConnect',
|
||||
source: 'walletconnect',
|
||||
},
|
||||
type: 'sr25519' as const,
|
||||
}));
|
||||
|
||||
setAccounts(wcAccounts);
|
||||
handleSetSelectedAccount(wcAccounts[0]);
|
||||
setWalletSource('walletconnect');
|
||||
setWcPeerName(peerName);
|
||||
window.dispatchEvent(new Event('walletconnect_connected'));
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ WalletConnect session established:', session.topic);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (import.meta.env.DEV) console.error('WalletConnect approval failed:', err);
|
||||
setError('WalletConnect connection was rejected');
|
||||
});
|
||||
|
||||
return uri;
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('WalletConnect connection failed:', err);
|
||||
setError('Failed to start WalletConnect');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect wallet (extension, native, or WalletConnect)
|
||||
const disconnectWallet = async () => {
|
||||
if (walletSource === 'walletconnect') {
|
||||
await disconnectWC();
|
||||
setWcPeerName(null);
|
||||
}
|
||||
setAccounts([]);
|
||||
handleSetSelectedAccount(null);
|
||||
setWalletSource(null);
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV) console.log('🔌 Wallet disconnected');
|
||||
console.log('🔌 Wallet disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -460,12 +559,15 @@ export const PezkuwiProvider: React.FC<PezkuwiProviderProps> = ({
|
||||
isApiReady,
|
||||
isAssetHubReady,
|
||||
isPeopleReady,
|
||||
isConnected: isApiReady, // Alias for backward compatibility
|
||||
isConnected: isApiReady,
|
||||
accounts,
|
||||
selectedAccount,
|
||||
setSelectedAccount: handleSetSelectedAccount,
|
||||
connectWallet,
|
||||
connectWalletConnect,
|
||||
disconnectWallet,
|
||||
walletSource,
|
||||
wcPeerName,
|
||||
error,
|
||||
sudoKey,
|
||||
};
|
||||
|
||||
@@ -11,6 +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';
|
||||
|
||||
interface TokenBalances {
|
||||
HEZ: string;
|
||||
@@ -255,11 +256,25 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
return blockHash;
|
||||
}
|
||||
|
||||
// Desktop: Use browser extension for signing
|
||||
// WalletConnect: Use WC signer
|
||||
if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && pezkuwi.api) {
|
||||
if (import.meta.env.DEV) console.log('[WC] Using WalletConnect for transaction signing');
|
||||
|
||||
const genesisHash = pezkuwi.api.genesisHash.toHex();
|
||||
const wcSigner = createWCSigner(genesisHash, pezkuwi.selectedAccount.address);
|
||||
|
||||
const hash = await (tx as { signAndSend: (address: string, options: { signer: unknown }) => Promise<{ toHex: () => string }> }).signAndSend(
|
||||
pezkuwi.selectedAccount.address,
|
||||
{ signer: wcSigner }
|
||||
);
|
||||
|
||||
return hash.toHex();
|
||||
}
|
||||
|
||||
// Desktop / pezWallet DApps browser: Use extension signer
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
|
||||
|
||||
// Sign and send transaction
|
||||
const hash = await (tx as { signAndSend: (address: string, options: { signer: unknown }) => Promise<{ toHex: () => string }> }).signAndSend(
|
||||
pezkuwi.selectedAccount.address,
|
||||
{ signer: injector.signer }
|
||||
@@ -279,6 +294,23 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
}
|
||||
|
||||
try {
|
||||
// WalletConnect signing
|
||||
if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && pezkuwi.api) {
|
||||
if (import.meta.env.DEV) console.log('[WC] Using WalletConnect for message signing');
|
||||
|
||||
const genesisHash = pezkuwi.api.genesisHash.toHex();
|
||||
const wcSigner = createWCSigner(genesisHash, pezkuwi.selectedAccount.address);
|
||||
|
||||
const { signature } = await wcSigner.signRaw({
|
||||
address: pezkuwi.selectedAccount.address,
|
||||
data: message,
|
||||
type: 'bytes',
|
||||
});
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
// Extension signing
|
||||
const { web3FromAddress } = await import('@pezkuwi/extension-dapp');
|
||||
const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
|
||||
|
||||
@@ -297,27 +329,35 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
if (import.meta.env.DEV) console.error('Message signing failed:', error);
|
||||
throw new Error(error instanceof Error ? error.message : 'Failed to sign message');
|
||||
}
|
||||
}, [pezkuwi.selectedAccount]);
|
||||
}, [pezkuwi.selectedAccount, pezkuwi.walletSource, pezkuwi.api]);
|
||||
|
||||
// Get signer from extension when account changes
|
||||
// Get signer from extension or WalletConnect when account changes
|
||||
useEffect(() => {
|
||||
const getSigner = async () => {
|
||||
if (pezkuwi.selectedAccount) {
|
||||
try {
|
||||
if (!pezkuwi.selectedAccount) {
|
||||
setSigner(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pezkuwi.walletSource === 'walletconnect' && isWCConnected() && 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 {
|
||||
const injector = await web3FromAddress(pezkuwi.selectedAccount.address);
|
||||
setSigner(injector.signer);
|
||||
if (import.meta.env.DEV) console.log('✅ Signer obtained for', pezkuwi.selectedAccount.address);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Failed to get signer:', error);
|
||||
setSigner(null);
|
||||
if (import.meta.env.DEV) console.log('✅ Extension Signer obtained for', pezkuwi.selectedAccount.address);
|
||||
}
|
||||
} else {
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Failed to get signer:', error);
|
||||
setSigner(null);
|
||||
}
|
||||
};
|
||||
|
||||
getSigner();
|
||||
}, [pezkuwi.selectedAccount]);
|
||||
}, [pezkuwi.selectedAccount, pezkuwi.walletSource, pezkuwi.api]);
|
||||
|
||||
// Update balance when selected account changes or Asset Hub becomes ready
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* WalletConnect v2 Service for Pezkuwi dApp
|
||||
*
|
||||
* Handles WalletConnect v2 session management and provides a Signer adapter
|
||||
* compatible with @pezkuwi/api's Signer interface.
|
||||
*
|
||||
* Flow A: Mobile browser -> QR code -> pezWallet scans -> session established
|
||||
* Flow B: pezWallet DApps browser -> injected provider (handled by extension-dapp, not here)
|
||||
*/
|
||||
|
||||
import SignClient from '@walletconnect/sign-client';
|
||||
import type { SessionTypes, SignClientTypes } from '@walletconnect/types';
|
||||
|
||||
const PROJECT_ID = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || '';
|
||||
const WC_SESSION_KEY = 'wc_session_topic';
|
||||
|
||||
// WalletConnect Polkadot namespace methods
|
||||
const POLKADOT_METHODS = ['polkadot_signTransaction', 'polkadot_signMessage'];
|
||||
const POLKADOT_EVENTS = ['chainChanged', 'accountsChanged'];
|
||||
|
||||
let signClient: SignClient | null = null;
|
||||
let currentSession: SessionTypes.Struct | null = null;
|
||||
let requestId = 0;
|
||||
|
||||
/**
|
||||
* Initialize the WalletConnect SignClient
|
||||
*/
|
||||
export async function initWalletConnect(): Promise<SignClient> {
|
||||
if (signClient) return signClient;
|
||||
|
||||
signClient = await SignClient.init({
|
||||
projectId: PROJECT_ID,
|
||||
metadata: {
|
||||
name: 'PezkuwiChain',
|
||||
description: 'Pezkuwi Web Application',
|
||||
url: 'https://app.pezkuwichain.io',
|
||||
icons: ['https://app.pezkuwichain.io/logo.png'],
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the polkadot: chain ID from genesis hash
|
||||
* Format: polkadot:<first_32_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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a WalletConnect pairing session
|
||||
* Returns the URI for QR code display
|
||||
*/
|
||||
export async function connectWithQR(genesisHash: string): Promise<{
|
||||
uri: string;
|
||||
approval: () => Promise<SessionTypes.Struct>;
|
||||
}> {
|
||||
const client = await initWalletConnect();
|
||||
const chainId = buildChainId(genesisHash);
|
||||
|
||||
const { uri, approval } = await client.connect({
|
||||
requiredNamespaces: {
|
||||
polkadot: {
|
||||
methods: POLKADOT_METHODS,
|
||||
chains: [chainId],
|
||||
events: POLKADOT_EVENTS,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('Failed to generate WalletConnect pairing URI');
|
||||
}
|
||||
|
||||
return {
|
||||
uri,
|
||||
approval: async () => {
|
||||
const session = await approval();
|
||||
currentSession = session;
|
||||
localStorage.setItem(WC_SESSION_KEY, session.topic);
|
||||
return session;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts from the current WalletConnect session
|
||||
* Returns array of SS58 addresses
|
||||
*/
|
||||
export function getSessionAccounts(): string[] {
|
||||
if (!currentSession) return [];
|
||||
|
||||
const polkadotNamespace = currentSession.namespaces['polkadot'];
|
||||
if (!polkadotNamespace?.accounts) return [];
|
||||
|
||||
// Account format: polkadot:<chain_id>:<ss58_address>
|
||||
return polkadotNamespace.accounts.map((account) => {
|
||||
const parts = account.split(':');
|
||||
return parts[parts.length - 1]; // Last part is the SS58 address
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the peer wallet name from current session
|
||||
*/
|
||||
export function getSessionPeerName(): string | null {
|
||||
if (!currentSession) return null;
|
||||
return currentSession.peer.metadata.name || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the peer wallet icon from current session
|
||||
*/
|
||||
export function getSessionPeerIcon(): string | null {
|
||||
if (!currentSession) return null;
|
||||
return currentSession.peer.metadata.icons?.[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Signer adapter compatible with @pezkuwi/api's Signer interface
|
||||
* Routes signPayload and signRaw through WalletConnect
|
||||
*/
|
||||
export function createWCSigner(genesisHash: string, address: string) {
|
||||
const chainId = buildChainId(genesisHash);
|
||||
const wcAccount = `polkadot:${chainId.split(':')[1]}:${address}`;
|
||||
|
||||
return {
|
||||
signPayload: async (payload: {
|
||||
address: string;
|
||||
blockHash: string;
|
||||
blockNumber: string;
|
||||
era: string;
|
||||
genesisHash: string;
|
||||
method: string;
|
||||
nonce: string;
|
||||
specVersion: string;
|
||||
tip: string;
|
||||
transactionVersion: string;
|
||||
signedExtensions: string[];
|
||||
version: number;
|
||||
}) => {
|
||||
if (!signClient || !currentSession) {
|
||||
throw new Error('WalletConnect session not active');
|
||||
}
|
||||
|
||||
const id = ++requestId;
|
||||
|
||||
const result = await signClient.request<{ signature: string }>({
|
||||
topic: currentSession.topic,
|
||||
chainId,
|
||||
request: {
|
||||
method: 'polkadot_signTransaction',
|
||||
params: {
|
||||
address: wcAccount,
|
||||
transactionPayload: payload,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
signature: result.signature as `0x${string}`,
|
||||
};
|
||||
},
|
||||
|
||||
signRaw: async (raw: {
|
||||
address: string;
|
||||
data: string;
|
||||
type: 'bytes' | 'payload';
|
||||
}) => {
|
||||
if (!signClient || !currentSession) {
|
||||
throw new Error('WalletConnect session not active');
|
||||
}
|
||||
|
||||
const id = ++requestId;
|
||||
|
||||
const result = await signClient.request<{ signature: string }>({
|
||||
topic: currentSession.topic,
|
||||
chainId,
|
||||
request: {
|
||||
method: 'polkadot_signMessage',
|
||||
params: {
|
||||
address: wcAccount,
|
||||
message: raw.data,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
signature: result.signature as `0x${string}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previous WalletConnect session from localStorage
|
||||
*/
|
||||
export async function restoreSession(): Promise<SessionTypes.Struct | null> {
|
||||
const client = await initWalletConnect();
|
||||
const savedTopic = localStorage.getItem(WC_SESSION_KEY);
|
||||
|
||||
if (!savedTopic) return null;
|
||||
|
||||
// Check if the session still exists
|
||||
const sessions = client.session.getAll();
|
||||
const session = sessions.find((s) => s.topic === savedTopic);
|
||||
|
||||
if (session) {
|
||||
// Check if session is not expired
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (session.expiry > now) {
|
||||
currentSession = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
// Session expired or not found
|
||||
localStorage.removeItem(WC_SESSION_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the current WalletConnect session
|
||||
*/
|
||||
export async function disconnectWC(): Promise<void> {
|
||||
if (!signClient || !currentSession) return;
|
||||
|
||||
try {
|
||||
await signClient.disconnect({
|
||||
topic: currentSession.topic,
|
||||
reason: {
|
||||
code: 6000,
|
||||
message: 'User disconnected',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
|
||||
currentSession = null;
|
||||
localStorage.removeItem(WC_SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an active WalletConnect session
|
||||
*/
|
||||
export function isWCConnected(): boolean {
|
||||
return currentSession !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session
|
||||
*/
|
||||
export function getCurrentSession(): SessionTypes.Struct | null {
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for WalletConnect session proposals (for debugging)
|
||||
*/
|
||||
export function onSessionEvent(
|
||||
event: SignClientTypes.Event,
|
||||
callback: (data: unknown) => void
|
||||
): void {
|
||||
if (!signClient) return;
|
||||
signClient.on(event, callback);
|
||||
}
|
||||
+1
-1
@@ -103,5 +103,5 @@ export default defineConfig(() => ({
|
||||
},
|
||||
chunkSizeWarningLimit: 600
|
||||
},
|
||||
assetsInclude: ['**/*.json'],
|
||||
// assetsInclude: ['**/*.json'], // Disabled: interferes with node_modules JSON imports (crypto-browserify)
|
||||
}));
|
||||
Reference in New Issue
Block a user