637 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|