rwadurian/backend/mpc-system/services/service-party-app/src/pages/CoSignSession.tsx

637 lines
22 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import styles from './Session.module.css';
import {
finalizeTransaction,
broadcastTransaction,
type PreparedTransaction,
} from '../utils/transaction';
// 从 sessionStorage 获取的交易信息
interface TransactionInfo {
preparedTx: PreparedTransaction;
to: string;
amount: string;
from: string;
walletName: string;
}
interface Participant {
partyId: string;
name: string;
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
}
interface SessionState {
sessionId: string;
walletName: string;
messageHash: string;
inviteCode?: string;
threshold: { t: number; n: number };
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
participants: Participant[];
currentRound: number;
totalRounds: number;
signature?: string;
error?: string;
}
export default function CoSignSession() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<SessionState | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 交易广播相关状态
const [txInfo, setTxInfo] = useState<TransactionInfo | null>(null);
const [broadcastStep, setBroadcastStep] = useState<'idle' | 'broadcasting' | 'success' | 'error'>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [broadcastError, setBroadcastError] = useState<string | null>(null);
const fetchSessionStatus = useCallback(async () => {
if (!sessionId) return;
try {
const result = await window.electronAPI.cosign.getSessionStatus(sessionId);
if (result.success && result.session) {
setSession({
sessionId: result.session.sessionId || sessionId,
walletName: result.session.walletName || '',
messageHash: result.session.messageHash || '',
inviteCode: result.session.inviteCode || '',
threshold: result.session.threshold || { t: 0, n: 0 },
status: mapStatus(result.session.status),
participants: (result.session.participants || []).map(p => ({
...p,
status: mapParticipantStatus(p.status),
})),
currentRound: 0,
totalRounds: 9, // GG20 签名有 9 轮
});
} else {
setError(result.error || '获取会话状态失败');
}
} catch (err) {
setError('获取会话状态失败');
} finally {
setIsLoading(false);
}
}, [sessionId]);
// 映射参与者状态
const mapParticipantStatus = (status: string): Participant['status'] => {
switch (status) {
case 'waiting':
case 'pending':
return 'waiting';
case 'ready':
case 'joined':
return 'ready';
case 'processing':
case 'signing':
return 'processing';
case 'completed':
return 'completed';
case 'failed':
return 'failed';
default:
return 'waiting';
}
};
// 映射后端状态到前端状态
const mapStatus = (status: string): 'waiting' | 'ready' | 'processing' | 'completed' | 'failed' => {
switch (status) {
case 'pending':
case 'waiting':
return 'waiting';
case 'all_joined':
case 'ready':
return 'ready';
case 'in_progress':
case 'signing':
return 'processing';
case 'completed':
return 'completed';
case 'failed':
case 'expired':
return 'failed';
default:
return 'waiting';
}
};
// 加载交易信息
useEffect(() => {
if (sessionId) {
const storedTxInfo = sessionStorage.getItem(`tx_${sessionId}`);
if (storedTxInfo) {
try {
const parsed = JSON.parse(storedTxInfo);
// 恢复 bigint 类型 (Legacy 交易使用 gasPrice)
if (parsed.preparedTx) {
parsed.preparedTx.gasLimit = BigInt(parsed.preparedTx.gasLimit);
parsed.preparedTx.gasPrice = BigInt(parsed.preparedTx.gasPrice);
parsed.preparedTx.value = BigInt(parsed.preparedTx.value);
}
setTxInfo(parsed);
} catch (err) {
console.error('Failed to parse transaction info:', err);
}
}
}
}, [sessionId]);
useEffect(() => {
fetchSessionStatus();
// 订阅会话事件
const unsubscribe = window.electronAPI.cosign.subscribeSessionEvents(
sessionId!,
(event: any) => {
console.log('[CoSignSession] Received event:', event);
if (event.type === 'participant_joined') {
setSession(prev => prev ? {
...prev,
participants: event.participant
? [...prev.participants, event.participant]
: prev.participants,
} : null);
// 刷新状态
fetchSessionStatus();
} else if (event.type === 'status_changed' || event.type === 'all_joined') {
setSession(prev => prev ? {
...prev,
status: event.status ? mapStatus(event.status) : prev.status,
} : null);
// 刷新状态
fetchSessionStatus();
} else if (event.type === 'progress') {
setSession(prev => prev ? {
...prev,
status: 'processing',
currentRound: event.round || prev.currentRound,
totalRounds: event.totalRounds || prev.totalRounds,
} : null);
} else if (event.type === 'completed') {
setSession(prev => prev ? {
...prev,
status: 'completed',
signature: event.signature,
} : null);
} else if (event.type === 'failed' || event.type === 'sign_start_timeout') {
setSession(prev => prev ? {
...prev,
status: 'failed',
error: event.error,
} : null);
}
}
);
return () => {
unsubscribe();
};
}, [sessionId, fetchSessionStatus]);
const getStatusText = (status: string) => {
switch (status) {
case 'waiting': return '等待参与方';
case 'ready': return '准备就绪';
case 'processing': return '签名进行中';
case 'completed': return '签名完成';
case 'failed': return '签名失败';
default: return status;
}
};
const getStatusClass = (status: string) => {
switch (status) {
case 'waiting': return styles.statusWaiting;
case 'ready': return styles.statusReady;
case 'processing': return styles.statusProcessing;
case 'completed': return styles.statusCompleted;
case 'failed': return styles.statusFailed;
default: return '';
}
};
const getParticipantStatusIcon = (status: string) => {
switch (status) {
case 'waiting': return '...';
case 'ready': return 'OK';
case 'processing': return '*';
case 'completed': return 'OK';
case 'failed': return 'X';
default: return '-';
}
};
const handleCopySignature = async () => {
if (session?.signature) {
try {
await navigator.clipboard.writeText(session.signature);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
// 解析签名
const parseSignature = (signatureHex: string): { r: string; s: string; v: number } | null => {
try {
// 签名格式: r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes
const sig = signatureHex.startsWith('0x') ? signatureHex.slice(2) : signatureHex;
if (sig.length !== 130) {
console.error('Invalid signature length:', sig.length);
return null;
}
const r = sig.slice(0, 64);
const s = sig.slice(64, 128);
const v = parseInt(sig.slice(128, 130), 16);
// EIP-1559 recovery id is 0 or 1
const recoveryId = v >= 27 ? v - 27 : v;
return { r, s, v: recoveryId };
} catch (err) {
console.error('Failed to parse signature:', err);
return null;
}
};
// 广播交易
const handleBroadcastTransaction = async () => {
if (!session?.signature || !txInfo) return;
setBroadcastStep('broadcasting');
setBroadcastError(null);
try {
// 解析签名
const parsedSig = parseSignature(session.signature);
if (!parsedSig) {
throw new Error('无法解析签名');
}
// 构建最终交易
const signedTx = finalizeTransaction(txInfo.preparedTx, parsedSig);
// 广播交易
const hash = await broadcastTransaction(signedTx);
setTxHash(hash);
setBroadcastStep('success');
// 清除 sessionStorage 中的交易信息
sessionStorage.removeItem(`tx_${sessionId}`);
} catch (err) {
setBroadcastError((err as Error).message);
setBroadcastStep('error');
}
};
// 获取区块浏览器交易 URL
const getTxExplorerUrl = (hash: string): string => {
// 从 transaction.ts 获取当前网络
const isTestnet = typeof window !== 'undefined' &&
window.localStorage?.getItem('kava_network') !== 'mainnet';
const baseUrl = isTestnet
? 'https://testnet.kavascan.com'
: 'https://kavascan.com';
return `${baseUrl}/tx/${hash}`;
};
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
</div>
);
}
if (error || !session) {
return (
<div className={styles.container}>
<div className={styles.error}>
<div className={styles.errorIcon}>!</div>
<h3></h3>
<p>{error || '无法获取会话信息'}</p>
<button className={styles.primaryButton} onClick={() => navigate('/')}>
</button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{session.walletName || '多方签名'}</h1>
<p className={styles.sessionId}> ID: {session.sessionId?.substring(0, 16)}...</p>
</div>
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
{getStatusText(session.status)}
</span>
</div>
<div className={styles.content}>
{/* 邀请码 - 等待状态时显示 */}
{session.inviteCode && session.status === 'waiting' && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
{/* QR Code */}
<div style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 'var(--spacing-md)',
}}>
<div style={{
padding: 'var(--spacing-md)',
backgroundColor: 'white',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-color)',
}}>
<QRCodeSVG
value={session.inviteCode}
size={180}
level="M"
includeMargin={false}
/>
</div>
</div>
<div className={styles.publicKeyWrapper}>
<code className={styles.publicKey}>{session.inviteCode}</code>
<button
className={styles.copyButton}
onClick={async () => {
try {
await navigator.clipboard.writeText(session.inviteCode!);
} catch (err) {
console.error('Failed to copy:', err);
}
}}
>
</button>
</div>
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: 'var(--spacing-xs)', textAlign: 'center' }}>
</p>
</div>
)}
{/* 消息哈希 */}
{session.messageHash && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<code style={{
display: 'block',
padding: '8px 12px',
backgroundColor: 'var(--background-color)',
borderRadius: 'var(--radius-md)',
fontSize: '12px',
wordBreak: 'break-all',
fontFamily: 'monospace',
}}>
{session.messageHash}
</code>
</div>
)}
{/* 进度部分 */}
{session.status === 'processing' && (
<div className={styles.progress}>
<div className={styles.progressHeader}>
<span></span>
<span>{session.currentRound} / {session.totalRounds}</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
></div>
</div>
</div>
)}
{/* 参与方列表 */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
({(session.participants || []).length} / {session.threshold?.t || 0})
</h3>
<div className={styles.participantList}>
{(session.participants || []).map((participant, index) => (
<div key={participant.partyId || index} className={styles.participant}>
<div className={styles.participantInfo}>
<span className={styles.participantIndex}>#{index + 1}</span>
<span className={styles.participantName}>{participant.name || `参与方 ${index + 1}`}</span>
</div>
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
{getParticipantStatusIcon(participant.status)}
</span>
</div>
))}
{Array.from({ length: Math.max(0, (session.threshold?.t || 0) - (session.participants || []).length) }).map((_, index) => (
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
<div className={styles.participantInfo}>
<span className={styles.participantIndex}>#{(session.participants || []).length + index + 1}</span>
<span className={styles.participantName}>...</span>
</div>
<span className={styles.participantStatus}>...</span>
</div>
))}
</div>
</div>
{/* 阈值信息 */}
{session.threshold && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.thresholdInfo}>
<span className={styles.thresholdBadge}>
{session.threshold.t}-of-{session.threshold.n}
</span>
<span className={styles.thresholdText}>
{session.threshold.t}
</span>
</div>
</div>
)}
{/* 完成状态 */}
{session.status === 'completed' && session.signature && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.publicKeyWrapper}>
<code className={styles.publicKey}>{session.signature}</code>
<button className={styles.copyButton} onClick={handleCopySignature}>
</button>
</div>
<p className={styles.successMessage}>
OK
</p>
{/* 交易广播部分 */}
{txInfo && (
<div style={{ marginTop: 'var(--spacing-lg)' }}>
<h4 style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 'var(--spacing-sm)',
}}>
</h4>
<div style={{
backgroundColor: 'var(--background-color)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
marginBottom: 'var(--spacing-md)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}></span>
<span style={{ fontFamily: 'monospace', fontSize: '13px' }}>
{txInfo.to.slice(0, 10)}...{txInfo.to.slice(-8)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}></span>
<span style={{ fontFamily: 'monospace', fontSize: '13px', fontWeight: 600 }}>
{txInfo.amount} KAVA
</span>
</div>
</div>
{broadcastStep === 'idle' && (
<button
className={styles.primaryButton}
onClick={handleBroadcastTransaction}
style={{ width: '100%' }}
>
广
</button>
)}
{broadcastStep === 'broadcasting' && (
<div style={{
textAlign: 'center',
padding: 'var(--spacing-md)',
color: 'var(--text-secondary)',
}}>
<div className={styles.spinner} style={{ margin: '0 auto var(--spacing-sm)' }}></div>
<p>广...</p>
</div>
)}
{broadcastStep === 'success' && txHash && (
<div style={{
backgroundColor: 'rgba(40, 167, 69, 0.1)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
textAlign: 'center',
}}>
<div style={{
fontSize: '24px',
color: '#28a745',
marginBottom: 'var(--spacing-sm)',
}}>OK</div>
<p style={{
color: '#28a745',
fontWeight: 600,
marginBottom: 'var(--spacing-sm)',
}}>
广!
</p>
<div style={{
backgroundColor: 'white',
padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-sm)',
marginBottom: 'var(--spacing-md)',
}}>
<code style={{
fontSize: '12px',
wordBreak: 'break-all',
fontFamily: 'monospace',
}}>
{txHash}
</code>
</div>
<a
href={getTxExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: 'var(--spacing-sm) var(--spacing-md)',
backgroundColor: '#28a745',
color: 'white',
borderRadius: 'var(--radius-md)',
textDecoration: 'none',
fontSize: '14px',
}}
>
</a>
</div>
)}
{broadcastStep === 'error' && (
<div style={{
backgroundColor: 'rgba(220, 53, 69, 0.1)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
textAlign: 'center',
}}>
<div style={{
fontSize: '24px',
color: '#dc3545',
marginBottom: 'var(--spacing-sm)',
}}>!</div>
<p style={{ color: '#dc3545', marginBottom: 'var(--spacing-sm)' }}>
广: {broadcastError}
</p>
<button
className={styles.secondaryButton}
onClick={() => setBroadcastStep('idle')}
>
</button>
</div>
)}
</div>
)}
</div>
)}
{/* 失败状态 */}
{session.status === 'failed' && session.error && (
<div className={styles.section}>
<div className={styles.failureMessage}>
<span className={styles.failureIcon}>!</span>
<span>{session.error}</span>
</div>
</div>
)}
</div>
<div className={styles.footer}>
<button className={styles.primaryButton} onClick={() => navigate('/')}>
</button>
</div>
</div>
</div>
);
}