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-align: center;
|
||||||
text-decoration: none;
|
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 {
|
interface ShareWithAddress extends ShareItem {
|
||||||
evmAddress?: string;
|
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() {
|
export default function Home() {
|
||||||
|
|
@ -30,7 +68,7 @@ export default function Home() {
|
||||||
for (const share of shareList) {
|
for (const share of shareList) {
|
||||||
try {
|
try {
|
||||||
const evmAddress = await deriveEvmAddress(share.publicKey);
|
const evmAddress = await deriveEvmAddress(share.publicKey);
|
||||||
sharesWithAddresses.push({ ...share, evmAddress });
|
sharesWithAddresses.push({ ...share, evmAddress, balanceLoading: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to derive address for share ${share.id}:`, error);
|
console.warn(`Failed to derive address for share ${share.id}:`, error);
|
||||||
sharesWithAddresses.push({ ...share });
|
sharesWithAddresses.push({ ...share });
|
||||||
|
|
@ -39,6 +77,20 @@ export default function Home() {
|
||||||
return sharesWithAddresses;
|
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(() => {
|
useEffect(() => {
|
||||||
loadShares();
|
loadShares();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -64,6 +116,9 @@ export default function Home() {
|
||||||
// 为每个 share 派生 EVM 地址
|
// 为每个 share 派生 EVM 地址
|
||||||
const sharesWithAddresses = await deriveAddresses(shareList);
|
const sharesWithAddresses = await deriveAddresses(shareList);
|
||||||
setShares(sharesWithAddresses);
|
setShares(sharesWithAddresses);
|
||||||
|
|
||||||
|
// 异步获取所有余额 (不阻塞 UI)
|
||||||
|
fetchAllBalances(sharesWithAddresses);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load shares:', error);
|
console.error('Failed to load shares:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -215,6 +270,20 @@ export default function Home() {
|
||||||
</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}>
|
<div className={styles.infoRow}>
|
||||||
<span className={styles.infoLabel}>参与方</span>
|
<span className={styles.infoLabel}>参与方</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,101 @@
|
||||||
* 从 ECDSA 公钥派生 EVM 兼容地址
|
* 从 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
|
* 将十六进制字符串转换为 Uint8Array
|
||||||
*/
|
*/
|
||||||
|
|
@ -155,9 +250,8 @@ export async function deriveEvmAddress(publicKey: string): Promise<string> {
|
||||||
// 无前缀的未压缩格式
|
// 无前缀的未压缩格式
|
||||||
uncompressedPubKey = pubKeyBytes;
|
uncompressedPubKey = pubKeyBytes;
|
||||||
} else if (pubKeyBytes.length === 33 && (pubKeyBytes[0] === 0x02 || pubKeyBytes[0] === 0x03)) {
|
} else if (pubKeyBytes.length === 33 && (pubKeyBytes[0] === 0x02 || pubKeyBytes[0] === 0x03)) {
|
||||||
// 压缩格式,需要解压 - 这里简化处理,实际需要椭圆曲线计算
|
// 压缩格式,解压为 64 字节的 x,y 坐标
|
||||||
// 在真实场景中应该使用 elliptic 或 secp256k1 库
|
uncompressedPubKey = decompressPublicKey(pubKeyBytes);
|
||||||
throw new Error('压缩公钥暂不支持,请提供未压缩公钥');
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`无效的公钥格式: length=${pubKeyBytes.length}`);
|
throw new Error(`无效的公钥格式: length=${pubKeyBytes.length}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue