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

688 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import styles from './Home.module.css';
import { deriveEvmAddress, formatAddress, getKavaExplorerUrl } from '../utils/address';
import {
prepareTransaction,
isValidAddress,
isValidAmount,
getCurrentNetwork,
type PreparedTransaction,
} from '../utils/transaction';
interface ShareItem {
id: string;
walletName: string;
publicKey: string;
threshold: { t: number; n: number };
createdAt: string;
lastUsedAt?: string;
metadata: {
participants: Array<{ partyId: string; name: string }>;
};
}
interface ShareWithAddress extends ShareItem {
evmAddress?: string;
kavaBalance?: string;
balanceLoading?: boolean;
}
// Kava Testnet EVM RPC endpoint
const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
/**
* 获取 KAVA 代币余额
* @param address EVM 地址
* @returns 余额字符串 (格式化后的 KAVA 数量)
*/
async function fetchKavaBalance(address: string): Promise<string> {
try {
const response = await fetch(KAVA_TESTNET_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getBalance',
params: [address, 'latest'],
id: 1,
}),
});
const data = await response.json();
if (data.result) {
// 将 wei 转换为 KAVA (18 位小数)
const balanceWei = BigInt(data.result);
const balanceKava = Number(balanceWei) / 1e18;
// 格式化显示,最多 6 位小数
return balanceKava.toFixed(balanceKava < 0.000001 && balanceKava > 0 ? 8 : 6).replace(/\.?0+$/, '') || '0';
}
return '0';
} catch (error) {
console.error('Failed to fetch KAVA balance:', error);
return '0';
}
}
export default function Home() {
const navigate = useNavigate();
const [shares, setShares] = useState<ShareWithAddress[]>([]);
const [loading, setLoading] = useState(true);
const [selectedShare, setSelectedShare] = useState<ShareWithAddress | null>(null);
const [showQrModal, setShowQrModal] = useState(false);
// 转账相关状态
const [showTransferModal, setShowTransferModal] = useState(false);
const [transferShare, setTransferShare] = useState<ShareWithAddress | null>(null);
const [transferTo, setTransferTo] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const [transferPassword, setTransferPassword] = useState('');
const [transferStep, setTransferStep] = useState<'input' | 'confirm' | 'preparing' | 'error'>('input');
const [transferError, setTransferError] = useState<string | null>(null);
const [preparedTx, setPreparedTx] = useState<PreparedTransaction | null>(null);
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
const sharesWithAddresses: ShareWithAddress[] = [];
for (const share of shareList) {
try {
const evmAddress = await deriveEvmAddress(share.publicKey);
sharesWithAddresses.push({ ...share, evmAddress, balanceLoading: true });
} catch (error) {
console.warn(`Failed to derive address for share ${share.id}:`, error);
sharesWithAddresses.push({ ...share });
}
}
return sharesWithAddresses;
}, []);
// 单独获取所有钱包的余额
const fetchAllBalances = useCallback(async (sharesWithAddrs: ShareWithAddress[]) => {
const updatedShares = await Promise.all(
sharesWithAddrs.map(async (share) => {
if (share.evmAddress) {
const kavaBalance = await fetchKavaBalance(share.evmAddress);
return { ...share, kavaBalance, balanceLoading: false };
}
return { ...share, balanceLoading: false };
})
);
setShares(updatedShares);
}, []);
useEffect(() => {
loadShares();
}, []);
const loadShares = async () => {
try {
let shareList: ShareItem[] = [];
// 检测是否在 Electron 环境中
if (window.electronAPI) {
const result = await window.electronAPI.storage.listShares();
if (result.success && result.data) {
shareList = result.data as ShareItem[];
}
} else {
// 浏览器环境,使用 localStorage 或 API
const stored = localStorage.getItem('shares');
if (stored) {
shareList = JSON.parse(stored);
}
}
// 为每个 share 派生 EVM 地址
const sharesWithAddresses = await deriveAddresses(shareList);
setShares(sharesWithAddresses);
// 异步获取所有余额 (不阻塞 UI)
fetchAllBalances(sharesWithAddresses);
} catch (error) {
console.error('Failed to load shares:', error);
} finally {
setLoading(false);
}
};
const handleExport = async (id: string) => {
const password = window.prompt('请输入密码以导出备份文件:');
if (!password) return;
try {
if (window.electronAPI) {
// 先让用户选择保存位置
const savePath = await window.electronAPI.dialog.saveFile(
`share-backup-${id.slice(0, 8)}.dat`,
[{ name: 'Share Backup', extensions: ['dat'] }]
);
if (!savePath) return;
const result = await window.electronAPI.storage.exportShare(id, password);
if (result.success && result.data) {
// 通过 IPC 写入文件
const blob = new Blob([result.data], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `share-backup-${id.slice(0, 8)}.dat`;
a.click();
URL.revokeObjectURL(url);
alert('备份文件导出成功!');
} else {
alert('导出失败: ' + (result.error || '未知错误'));
}
}
} catch (error) {
alert('导出失败: ' + (error as Error).message);
}
};
const handleDelete = async (id: string) => {
const confirmed = window.confirm('确定要删除这个钱包吗?删除后无法恢复,请确保已备份。');
if (!confirmed) return;
try {
if (window.electronAPI) {
const result = await window.electronAPI.storage.deleteShare(id);
if (result.success) {
setShares(shares.filter((s) => s.id !== id));
} else {
alert('删除失败: ' + (result.error || '未知错误'));
}
}
} catch (error) {
alert('删除失败: ' + (error as Error).message);
}
};
const handleShowQr = (share: ShareWithAddress) => {
setSelectedShare(share);
setShowQrModal(true);
};
const handleCopyAddress = (address: string) => {
navigator.clipboard.writeText(address);
alert('地址已复制到剪贴板');
};
// 打开转账模态框
const handleOpenTransfer = (share: ShareWithAddress) => {
setTransferShare(share);
setTransferTo('');
setTransferAmount('');
setTransferPassword('');
setTransferStep('input');
setTransferError(null);
setPreparedTx(null);
setShowTransferModal(true);
};
// 关闭转账模态框
const handleCloseTransfer = () => {
setShowTransferModal(false);
setTransferShare(null);
setPreparedTx(null);
};
// 验证转账输入
const validateTransferInput = (): string | null => {
if (!transferTo.trim()) {
return '请输入收款地址';
}
if (!isValidAddress(transferTo.trim())) {
return '收款地址格式无效';
}
if (!transferAmount.trim()) {
return '请输入转账金额';
}
if (!isValidAmount(transferAmount.trim())) {
return '转账金额无效';
}
const amount = parseFloat(transferAmount);
const balance = parseFloat(transferShare?.kavaBalance || '0');
if (amount > balance) {
return '余额不足';
}
return null;
};
// 准备交易
const handlePrepareTransaction = async () => {
const error = validateTransferInput();
if (error) {
setTransferError(error);
return;
}
setTransferStep('preparing');
setTransferError(null);
try {
const prepared = await prepareTransaction({
from: transferShare!.evmAddress!,
to: transferTo.trim().toLowerCase(),
value: transferAmount.trim(),
});
setPreparedTx(prepared);
setTransferStep('confirm');
} catch (err) {
setTransferError('准备交易失败: ' + (err as Error).message);
setTransferStep('error');
}
};
// 发起签名会话
const handleInitiateCoSign = async () => {
if (!preparedTx || !transferShare) return;
try {
// 调用 co-sign API 创建签名会话
const result = await window.electronAPI.cosign.createSession({
shareId: transferShare.id,
sharePassword: transferPassword,
messageHash: preparedTx.signHash,
initiatorName: '发起者',
});
if (result.success && result.sessionId) {
// 保存交易信息到 sessionStorage以便签名完成后使用
// 注意: BigInt 无法直接 JSON 序列化,需要转换为字符串
const txToStore = {
preparedTx: {
...preparedTx,
gasLimit: preparedTx.gasLimit.toString(),
gasPrice: preparedTx.gasPrice.toString(),
value: preparedTx.value.toString(),
},
to: transferTo,
amount: transferAmount,
from: transferShare.evmAddress,
walletName: transferShare.walletName,
};
sessionStorage.setItem(`tx_${result.sessionId}`, JSON.stringify(txToStore));
// 关闭模态框并跳转到签名会话页面
handleCloseTransfer();
navigate(`/cosign/session/${result.sessionId}`);
} else {
setTransferError(result.error || '创建签名会话失败');
setTransferStep('error');
}
} catch (err) {
setTransferError('创建签名会话失败: ' + (err as Error).message);
setTransferStep('error');
}
};
// 格式化 gas 费用显示
const formatGasFee = (gasLimit: bigint, gasPrice: bigint): string => {
const maxFee = gasLimit * gasPrice;
const feeKava = Number(maxFee) / 1e18;
return feeKava.toFixed(8).replace(/\.?0+$/, '');
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<div className={styles.loading}>
<div className={styles.spinner} />
<p>...</p>
</div>
);
}
return (
<div className={styles.container}>
<header className={styles.header}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}> (3-of-5 )</p>
</header>
{shares.length === 0 ? (
<div className={styles.empty}>
<div className={styles.emptyIcon}>🔐</div>
<h2 className={styles.emptyTitle}></h2>
<p className={styles.emptyText}>
</p>
<div className={styles.emptyActions}>
<button
className={styles.primaryButton}
onClick={() => navigate('/create')}
>
</button>
<button
className={styles.secondaryButton}
onClick={() => navigate('/join')}
>
</button>
</div>
</div>
) : (
<div className={styles.grid}>
{shares.map((share) => (
<div key={share.id} className={styles.card}>
<div className={styles.cardHeader}>
<h3 className={styles.cardTitle}>{share.walletName}</h3>
<span className={styles.threshold}>
{share.threshold.t}-of-{share.threshold.n}
</span>
</div>
<div className={styles.cardBody}>
{/* 地址区域 - 可点击显示二维码 */}
{share.evmAddress && (
<div
className={styles.addressSection}
onClick={() => handleShowQr(share)}
>
<div className={styles.qrPreview}>
<QRCodeSVG
value={share.evmAddress}
size={80}
level="M"
includeMargin={false}
/>
</div>
<div className={styles.addressInfo}>
<span className={styles.addressLabel}>Kava EVM </span>
<code className={styles.addressValue}>
{formatAddress(share.evmAddress)}
</code>
<span className={styles.addressHint}></span>
</div>
</div>
)}
{/* KAVA 余额显示 */}
{share.evmAddress && (
<div className={styles.balanceSection}>
<span className={styles.balanceLabel}>KAVA </span>
<span className={styles.balanceValue}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.kavaBalance || '0'} KAVA</>
)}
</span>
</div>
)}
<div className={styles.infoRow}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{(share.metadata?.participants || []).length}
</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{formatDate(share.createdAt)}
</span>
</div>
{share.lastUsedAt && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>使</span>
<span className={styles.infoValue}>
{formatDate(share.lastUsedAt)}
</span>
</div>
)}
</div>
<div className={styles.cardFooter}>
{share.evmAddress && (
<button
className={`${styles.actionButton} ${styles.transferButton}`}
onClick={() => handleOpenTransfer(share)}
>
</button>
)}
<button
className={styles.actionButton}
onClick={() => handleExport(share.id)}
>
</button>
<button
className={`${styles.actionButton} ${styles.dangerButton}`}
onClick={() => handleDelete(share.id)}
>
</button>
</div>
</div>
))}
</div>
)}
{/* 转账模态框 */}
{showTransferModal && transferShare && (
<div className={styles.modalOverlay} onClick={handleCloseTransfer}>
<div className={styles.transferModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}></h2>
<button
className={styles.modalClose}
onClick={handleCloseTransfer}
>
×
</button>
</div>
<div className={styles.modalBody}>
{/* 钱包信息 */}
<div className={styles.transferWalletInfo}>
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
<div className={styles.transferWalletBalance}>
: {transferShare.kavaBalance || '0'} KAVA
</div>
<div className={styles.transferNetwork}>
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
</div>
</div>
{transferStep === 'input' && (
<div className={styles.transferForm}>
{/* 收款地址 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}></label>
<input
type="text"
value={transferTo}
onChange={(e) => setTransferTo(e.target.value)}
placeholder="0x..."
className={styles.transferInput}
/>
</div>
{/* 转账金额 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> (KAVA)</label>
<div className={styles.transferAmountWrapper}>
<input
type="text"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
placeholder="0.0"
className={styles.transferInput}
/>
<button
className={styles.maxButton}
onClick={() => setTransferAmount(transferShare.kavaBalance || '0')}
>
MAX
</button>
</div>
</div>
{/* 钱包密码 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> ()</label>
<input
type="password"
value={transferPassword}
onChange={(e) => setTransferPassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.transferInput}
/>
</div>
{transferError && (
<div className={styles.transferError}>{transferError}</div>
)}
<div className={styles.transferActions}>
<button
className={styles.secondaryButton}
onClick={handleCloseTransfer}
>
</button>
<button
className={styles.primaryButton}
onClick={handlePrepareTransaction}
>
</button>
</div>
</div>
)}
{transferStep === 'preparing' && (
<div className={styles.transferPreparing}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
{transferStep === 'confirm' && preparedTx && (
<div className={styles.transferConfirm}>
<h3 className={styles.confirmTitle}></h3>
<div className={styles.confirmDetails}>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{formatAddress(transferTo, 8, 6)}</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{transferAmount} KAVA</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}> Gas </span>
<span className={styles.confirmValue}>
~{formatGasFee(preparedTx.gasLimit, preparedTx.gasPrice)} KAVA
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}>Nonce</span>
<span className={styles.confirmValue}>{preparedTx.nonce}</span>
</div>
</div>
<div className={styles.confirmNote}>
<strong>:</strong> {transferShare.threshold.t}-of-{transferShare.threshold.n}
{transferShare.threshold.t}
</div>
{transferError && (
<div className={styles.transferError}>{transferError}</div>
)}
<div className={styles.transferActions}>
<button
className={styles.secondaryButton}
onClick={() => setTransferStep('input')}
>
</button>
<button
className={styles.primaryButton}
onClick={handleInitiateCoSign}
>
</button>
</div>
</div>
)}
{transferStep === 'error' && (
<div className={styles.transferError}>
<p>{transferError}</p>
<button
className={styles.secondaryButton}
onClick={() => setTransferStep('input')}
>
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* 二维码弹窗 */}
{showQrModal && selectedShare && (
<div className={styles.modalOverlay} onClick={() => setShowQrModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{selectedShare.walletName}</h2>
<button
className={styles.modalClose}
onClick={() => setShowQrModal(false)}
>
×
</button>
</div>
<div className={styles.modalBody}>
<div className={styles.qrContainer}>
<QRCodeSVG
value={selectedShare.evmAddress || ''}
size={240}
level="H"
includeMargin={true}
/>
</div>
<div className={styles.fullAddress}>
<span className={styles.addressLabelLarge}>Kava EVM </span>
<code className={styles.addressValueLarge}>
{selectedShare.evmAddress}
</code>
</div>
<div className={styles.modalActions}>
<button
className={styles.primaryButton}
onClick={() => handleCopyAddress(selectedShare.evmAddress || '')}
>
</button>
<a
href={getKavaExplorerUrl(selectedShare.evmAddress || '', true)}
target="_blank"
rel="noopener noreferrer"
className={styles.secondaryButton}
>
</a>
</div>
</div>
</div>
</div>
)}
</div>
);
}