feat(service-party-app): 支持压缩公钥派生EVM地址并显示KAVA余额

- 实现secp256k1公钥解压缩算法 (y² = x³ + 7 mod p)
- 支持从TSS keygen的33字节压缩公钥派生EVM地址
- 在钱包主页显示KAVA代币余额 (通过Kava Testnet RPC获取)
- 添加余额加载状态和样式

🤖 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 04:47:49 -08:00
parent bc78ab3fac
commit a269e4d14b
3 changed files with 198 additions and 4 deletions

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

@ -16,6 +16,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() {
@ -30,7 +68,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 });
@ -39,6 +77,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();
}, []);
@ -64,6 +116,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 {
@ -215,6 +270,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}`);
}