feat: 恢复EVM地址派生和余额显示功能 + 修复0人参与bug

恢复的功能:
1. ee59d1c0 - 方案B修复最后加入者错过session_started事件的竞态条件
   - 修复了显示"0人参与"的bug
   - 使用事件缓存机制解决时序问题

2. a269e4d1 - 支持压缩公钥派生EVM地址并显示KAVA余额
   - Home页面显示钱包的KAVA EVM地址
   - 显示KAVA测试网余额
   - 支持压缩公钥格式

这些功能已经过验证,与转账功能无关。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-30 08:53:26 -08:00
parent e038f1784f
commit 57b84bb9fa
4 changed files with 253 additions and 12 deletions

View File

@ -791,10 +791,38 @@ function setupIpcHandlers() {
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(sessionId, partyId);
}
// 注意:不再在这里调用 checkAndTriggerKeygen
// 而是等待收到 all_joined 事件后再启动 5 分钟倒计时
// 这样用户可以在 24 小时内慢慢邀请其他参与者加入
debugLog.info('main', `Joined session ${sessionId}, waiting for all_joined event to start keygen`);
// 方案 B: 检查 JoinSession 响应中的 session 状态
// 如果 session 已经是 in_progress说明我们是最后一个加入的
// 此时 session_started 事件可能已经在 JoinSession 返回前到达(并被忽略)
// 所以我们需要直接触发 keygen而不是等待 session_started 事件
//
// 注意:只检查 status === 'in_progress' 就足够了
// 因为 session 只有在所有参与者都加入后才会变成 in_progress
// 不需要额外检查参与者数量
const sessionStatus = result.session_info?.status;
debugLog.info('main', `JoinSession response: status=${sessionStatus}`);
if (sessionStatus === 'in_progress') {
// Session 已经开始,说明我们是最后一个加入的
// 直接触发 keygen不需要等待 session_started 事件
debugLog.info('main', `Session already in_progress, triggering keygen immediately (Solution B)`);
// 使用 setImmediate 确保 activeKeygenSession 已完全设置
setImmediate(async () => {
const selectedParties = activeKeygenSession?.participants.map(p => p.partyId) || [];
await handleSessionStart({
eventType: 'session_started',
sessionId: sessionId,
thresholdN: result.session_info?.threshold_n || activeKeygenSession?.threshold.n || 0,
thresholdT: result.session_info?.threshold_t || activeKeygenSession?.threshold.t || 0,
selectedParties: selectedParties,
});
});
} else {
// Session 还未开始,等待 session_started 事件
debugLog.info('main', `Joined session ${sessionId}, waiting for session_started event to start keygen`);
}
}
return { success: true, data: result };
} catch (error) {
@ -876,10 +904,29 @@ function setupIpcHandlers() {
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(result.session_id, partyId);
}
// 注意:不再在这里调用 checkAndTriggerKeygen
// 而是等待收到 all_joined 事件后再启动 5 分钟倒计时
// 这样用户可以在 24 小时内慢慢邀请其他参与者加入
debugLog.info('main', `Initiator joined session ${result.session_id}, waiting for all_joined event to start keygen`);
// 方案 B: 检查 JoinSession 响应中的 session 状态
// 发起方通常是第一个加入的,所以 session 不太可能已经是 in_progress
// 但为了代码一致性,这里也添加检查
const sessionStatus = joinResult.session_info?.status;
debugLog.info('main', `Initiator JoinSession response: status=${sessionStatus}`);
if (sessionStatus === 'in_progress') {
debugLog.info('main', `Session already in_progress, triggering keygen immediately (Solution B)`);
setImmediate(async () => {
const selectedParties = activeKeygenSession?.participants.map(p => p.partyId) || [];
await handleSessionStart({
eventType: 'session_started',
sessionId: result.session_id,
thresholdN: joinResult.session_info?.threshold_n || params.thresholdN,
thresholdT: joinResult.session_info?.threshold_t || params.thresholdT,
selectedParties: selectedParties,
});
});
} else {
debugLog.info('main', `Initiator joined session ${result.session_id}, waiting for session_started event to start keygen`);
}
} else {
console.warn('Initiator failed to join session');
}

View File

@ -373,3 +373,34 @@
text-align: center;
text-decoration: none;
}
/* Balance Section */
.balanceSection {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-md);
}
.balanceLabel {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.balanceValue {
font-size: 18px;
font-weight: 700;
color: white;
font-family: monospace;
}
.balanceLoading {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
}

View File

@ -18,6 +18,44 @@ interface ShareItem {
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() {
@ -32,7 +70,7 @@ export default function Home() {
for (const share of shareList) {
try {
const evmAddress = await deriveEvmAddress(share.publicKey);
sharesWithAddresses.push({ ...share, evmAddress });
sharesWithAddresses.push({ ...share, evmAddress, balanceLoading: true });
} catch (error) {
console.warn(`Failed to derive address for share ${share.id}:`, error);
sharesWithAddresses.push({ ...share });
@ -41,6 +79,20 @@ export default function Home() {
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();
}, []);
@ -66,6 +118,9 @@ export default function Home() {
// 为每个 share 派生 EVM 地址
const sharesWithAddresses = await deriveAddresses(shareList);
setShares(sharesWithAddresses);
// 异步获取所有余额 (不阻塞 UI)
fetchAllBalances(sharesWithAddresses);
} catch (error) {
console.error('Failed to load shares:', error);
} finally {
@ -218,6 +273,20 @@ export default function Home() {
</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}>

View File

@ -3,6 +3,101 @@
* ECDSA EVM
*/
// secp256k1 曲线参数
const SECP256K1_P = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F');
const SECP256K1_B = 7n;
/**
* Uint8Array BigInt
*/
function bytesToBigInt(bytes: Uint8Array): bigint {
let result = 0n;
for (let i = 0; i < bytes.length; i++) {
result = (result << 8n) | BigInt(bytes[i]);
}
return result;
}
/**
* BigInt 32 Uint8Array
*/
function bigIntToBytes32(n: bigint): Uint8Array {
const bytes = new Uint8Array(32);
let temp = n;
for (let i = 31; i >= 0; i--) {
bytes[i] = Number(temp & 0xffn);
temp = temp >> 8n;
}
return bytes;
}
/**
* ()
*/
function modPow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
base = ((base % mod) + mod) % mod;
while (exp > 0n) {
if (exp & 1n) {
result = (result * base) % mod;
}
exp = exp >> 1n;
base = (base * base) % mod;
}
return result;
}
/**
* secp256k1 线 x y
* y² = x³ + 7 (mod p)
*/
function decompressY(x: bigint, isOdd: boolean): bigint {
// 计算 y² = x³ + 7 (mod p)
const x3 = modPow(x, 3n, SECP256K1_P);
const ySquared = (x3 + SECP256K1_B) % SECP256K1_P;
// 计算平方根: y = ySquared^((p+1)/4) mod p
// 这是因为 p ≡ 3 (mod 4) 对于 secp256k1
const exp = (SECP256K1_P + 1n) / 4n;
let y = modPow(ySquared, exp, SECP256K1_P);
// 根据奇偶性选择正确的 y 值
const yIsOdd = (y & 1n) === 1n;
if (yIsOdd !== isOdd) {
y = SECP256K1_P - y;
}
return y;
}
/**
* secp256k1
* 输入: 33 (02/03 + 32 x )
* 输出: 64 (32 x + 32 y)
*/
function decompressPublicKey(compressedPubKey: Uint8Array): Uint8Array {
if (compressedPubKey.length !== 33) {
throw new Error('压缩公钥必须是 33 字节');
}
const prefix = compressedPubKey[0];
if (prefix !== 0x02 && prefix !== 0x03) {
throw new Error('无效的压缩公钥前缀');
}
const isOdd = prefix === 0x03;
const xBytes = compressedPubKey.slice(1);
const x = bytesToBigInt(xBytes);
const y = decompressY(x, isOdd);
// 组合 x 和 y 坐标
const result = new Uint8Array(64);
result.set(xBytes, 0);
result.set(bigIntToBytes32(y), 32);
return result;
}
/**
* Uint8Array
*/
@ -155,9 +250,8 @@ export async function deriveEvmAddress(publicKey: string): Promise<string> {
// 无前缀的未压缩格式
uncompressedPubKey = pubKeyBytes;
} else if (pubKeyBytes.length === 33 && (pubKeyBytes[0] === 0x02 || pubKeyBytes[0] === 0x03)) {
// 压缩格式,需要解压 - 这里简化处理,实际需要椭圆曲线计算
// 在真实场景中应该使用 elliptic 或 secp256k1 库
throw new Error('压缩公钥暂不支持,请提供未压缩公钥');
// 压缩格式,解压为 64 字节的 x,y 坐标
uncompressedPubKey = decompressPublicKey(pubKeyBytes);
} else {
throw new Error(`无效的公钥格式: length=${pubKeyBytes.length}`);
}