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:
parent
e038f1784f
commit
57b84bb9fa
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue