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:
parent
bc78ab3fac
commit
a269e4d14b
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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