diff --git a/backend/mpc-system/services/service-party-app/src/pages/CoSignSession.tsx b/backend/mpc-system/services/service-party-app/src/pages/CoSignSession.tsx index 11c01a78..150aa313 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/CoSignSession.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/CoSignSession.tsx @@ -1,6 +1,20 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import styles from './Session.module.css'; +import { + finalizeTransaction, + broadcastTransaction, + type PreparedTransaction, +} from '../utils/transaction'; + +// 从 sessionStorage 获取的交易信息 +interface TransactionInfo { + preparedTx: PreparedTransaction; + to: string; + amount: string; + from: string; + walletName: string; +} interface Participant { partyId: string; @@ -29,6 +43,12 @@ export default function CoSignSession() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // 交易广播相关状态 + const [txInfo, setTxInfo] = useState(null); + const [broadcastStep, setBroadcastStep] = useState<'idle' | 'broadcasting' | 'success' | 'error'>('idle'); + const [txHash, setTxHash] = useState(null); + const [broadcastError, setBroadcastError] = useState(null); + const fetchSessionStatus = useCallback(async () => { if (!sessionId) return; @@ -41,7 +61,10 @@ export default function CoSignSession() { messageHash: result.session.messageHash || '', threshold: result.session.threshold || { t: 0, n: 0 }, status: mapStatus(result.session.status), - participants: result.session.participants || [], + participants: (result.session.participants || []).map(p => ({ + ...p, + status: mapParticipantStatus(p.status), + })), currentRound: 0, totalRounds: 9, // GG20 签名有 9 轮 }); @@ -55,6 +78,27 @@ export default function CoSignSession() { } }, [sessionId]); + // 映射参与者状态 + const mapParticipantStatus = (status: string): Participant['status'] => { + switch (status) { + case 'waiting': + case 'pending': + return 'waiting'; + case 'ready': + case 'joined': + return 'ready'; + case 'processing': + case 'signing': + return 'processing'; + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + default: + return 'waiting'; + } + }; + // 映射后端状态到前端状态 const mapStatus = (status: string): 'waiting' | 'ready' | 'processing' | 'completed' | 'failed' => { switch (status) { @@ -77,6 +121,28 @@ export default function CoSignSession() { } }; + // 加载交易信息 + useEffect(() => { + if (sessionId) { + const storedTxInfo = sessionStorage.getItem(`tx_${sessionId}`); + if (storedTxInfo) { + try { + const parsed = JSON.parse(storedTxInfo); + // 恢复 bigint 类型 + if (parsed.preparedTx) { + parsed.preparedTx.gasLimit = BigInt(parsed.preparedTx.gasLimit); + parsed.preparedTx.maxFeePerGas = BigInt(parsed.preparedTx.maxFeePerGas); + parsed.preparedTx.maxPriorityFeePerGas = BigInt(parsed.preparedTx.maxPriorityFeePerGas); + parsed.preparedTx.value = BigInt(parsed.preparedTx.value); + } + setTxInfo(parsed); + } catch (err) { + console.error('Failed to parse transaction info:', err); + } + } + } + }, [sessionId]); + useEffect(() => { fetchSessionStatus(); @@ -173,6 +239,68 @@ export default function CoSignSession() { } }; + // 解析签名 + const parseSignature = (signatureHex: string): { r: string; s: string; v: number } | null => { + try { + // 签名格式: r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes + const sig = signatureHex.startsWith('0x') ? signatureHex.slice(2) : signatureHex; + if (sig.length !== 130) { + console.error('Invalid signature length:', sig.length); + return null; + } + const r = sig.slice(0, 64); + const s = sig.slice(64, 128); + const v = parseInt(sig.slice(128, 130), 16); + // EIP-1559 recovery id is 0 or 1 + const recoveryId = v >= 27 ? v - 27 : v; + return { r, s, v: recoveryId }; + } catch (err) { + console.error('Failed to parse signature:', err); + return null; + } + }; + + // 广播交易 + const handleBroadcastTransaction = async () => { + if (!session?.signature || !txInfo) return; + + setBroadcastStep('broadcasting'); + setBroadcastError(null); + + try { + // 解析签名 + const parsedSig = parseSignature(session.signature); + if (!parsedSig) { + throw new Error('无法解析签名'); + } + + // 构建最终交易 + const signedTx = finalizeTransaction(txInfo.preparedTx, parsedSig); + + // 广播交易 + const hash = await broadcastTransaction(signedTx); + setTxHash(hash); + setBroadcastStep('success'); + + // 清除 sessionStorage 中的交易信息 + sessionStorage.removeItem(`tx_${sessionId}`); + } catch (err) { + setBroadcastError((err as Error).message); + setBroadcastStep('error'); + } + }; + + // 获取区块浏览器交易 URL + const getTxExplorerUrl = (hash: string): string => { + // 从 transaction.ts 获取当前网络 + const isTestnet = typeof window !== 'undefined' && + window.localStorage?.getItem('kava_network') !== 'mainnet'; + const baseUrl = isTestnet + ? 'https://testnet.kavascan.com' + : 'https://kavascan.com'; + return `${baseUrl}/tx/${hash}`; + }; + if (isLoading) { return (
@@ -304,6 +432,136 @@ export default function CoSignSession() {

OK 签名已成功生成

+ + {/* 交易广播部分 */} + {txInfo && ( +
+

+ 交易详情 +

+
+
+ 收款地址 + + {txInfo.to.slice(0, 10)}...{txInfo.to.slice(-8)} + +
+
+ 转账金额 + + {txInfo.amount} KAVA + +
+
+ + {broadcastStep === 'idle' && ( + + )} + + {broadcastStep === 'broadcasting' && ( +
+
+

正在广播交易...

+
+ )} + + {broadcastStep === 'success' && txHash && ( +
+
OK
+

+ 交易已成功广播! +

+
+ + {txHash} + +
+ + 在区块浏览器查看 + +
+ )} + + {broadcastStep === 'error' && ( +
+
!
+

+ 广播失败: {broadcastError} +

+ +
+ )} +
+ )}
)} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.module.css b/backend/mpc-system/services/service-party-app/src/pages/Home.module.css index 8f1f352e..82a7d2e5 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Home.module.css +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.module.css @@ -404,3 +404,205 @@ color: rgba(255, 255, 255, 0.7); font-style: italic; } + +/* Transfer Button */ +.transferButton { + background-color: var(--primary-color); + color: white; + border-color: var(--primary-color); + margin-right: var(--spacing-sm); +} + +.transferButton:hover { + background-color: var(--primary-light); + border-color: var(--primary-light); +} + +/* Transfer Modal */ +.transferModal { + background-color: var(--surface-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 480px; + width: 90%; + overflow: hidden; +} + +.transferWalletInfo { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: var(--spacing-md); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-lg); + text-align: center; +} + +.transferWalletName { + font-size: 16px; + font-weight: 600; + color: white; + margin-bottom: var(--spacing-xs); +} + +.transferWalletBalance { + font-size: 14px; + color: rgba(255, 255, 255, 0.9); + font-family: monospace; +} + +.transferNetwork { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + margin-top: var(--spacing-xs); +} + +.transferForm { + width: 100%; +} + +.transferInputGroup { + margin-bottom: var(--spacing-md); +} + +.transferLabel { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--spacing-xs); +} + +.transferInput { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 14px; + font-family: monospace; + background-color: var(--background-color); + color: var(--text-primary); + box-sizing: border-box; +} + +.transferInput:focus { + outline: none; + border-color: var(--primary-color); +} + +.transferInput::placeholder { + color: var(--text-secondary); + opacity: 0.6; +} + +.transferAmountWrapper { + display: flex; + gap: var(--spacing-sm); +} + +.transferAmountWrapper .transferInput { + flex: 1; +} + +.maxButton { + padding: var(--spacing-sm) var(--spacing-md); + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.maxButton:hover { + background-color: var(--primary-color); + color: white; +} + +.transferError { + background-color: rgba(220, 53, 69, 0.1); + color: #dc3545; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + font-size: 13px; + margin-bottom: var(--spacing-md); + text-align: center; +} + +.transferActions { + display: flex; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); +} + +.transferActions .primaryButton, +.transferActions .secondaryButton { + flex: 1; +} + +.transferPreparing { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-xl); + color: var(--text-secondary); +} + +/* Transfer Confirm */ +.transferConfirm { + width: 100%; +} + +.confirmTitle { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-md); + text-align: center; +} + +.confirmDetails { + background-color: var(--background-color); + border-radius: var(--radius-md); + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); +} + +.confirmRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-sm) 0; + border-bottom: 1px solid var(--border-color); +} + +.confirmRow:last-child { + border-bottom: none; +} + +.confirmLabel { + font-size: 13px; + color: var(--text-secondary); +} + +.confirmValue { + font-size: 13px; + color: var(--text-primary); + font-family: monospace; + font-weight: 500; +} + +.confirmNote { + font-size: 12px; + color: var(--text-secondary); + background-color: rgba(102, 126, 234, 0.1); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + margin-bottom: var(--spacing-md); + line-height: 1.5; +} + +.confirmNote strong { + color: var(--primary-color); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx index 0f9c513f..67eb62f8 100644 --- a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx @@ -3,6 +3,13 @@ import { useNavigate } from 'react-router-dom'; import { QRCodeSVG } from 'qrcode.react'; import styles from './Home.module.css'; import { deriveEvmAddress, formatAddress, getKavaExplorerUrl } from '../utils/address'; +import { + prepareTransaction, + isValidAddress, + isValidAmount, + getCurrentNetwork, + type PreparedTransaction, +} from '../utils/transaction'; interface ShareItem { id: string; @@ -65,6 +72,16 @@ export default function Home() { const [selectedShare, setSelectedShare] = useState(null); const [showQrModal, setShowQrModal] = useState(false); + // 转账相关状态 + const [showTransferModal, setShowTransferModal] = useState(false); + const [transferShare, setTransferShare] = useState(null); + const [transferTo, setTransferTo] = useState(''); + const [transferAmount, setTransferAmount] = useState(''); + const [transferPassword, setTransferPassword] = useState(''); + const [transferStep, setTransferStep] = useState<'input' | 'confirm' | 'preparing' | 'error'>('input'); + const [transferError, setTransferError] = useState(null); + const [preparedTx, setPreparedTx] = useState(null); + const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise => { const sharesWithAddresses: ShareWithAddress[] = []; for (const share of shareList) { @@ -190,6 +207,115 @@ export default function Home() { alert('地址已复制到剪贴板'); }; + // 打开转账模态框 + const handleOpenTransfer = (share: ShareWithAddress) => { + setTransferShare(share); + setTransferTo(''); + setTransferAmount(''); + setTransferPassword(''); + setTransferStep('input'); + setTransferError(null); + setPreparedTx(null); + setShowTransferModal(true); + }; + + // 关闭转账模态框 + const handleCloseTransfer = () => { + setShowTransferModal(false); + setTransferShare(null); + setPreparedTx(null); + }; + + // 验证转账输入 + const validateTransferInput = (): string | null => { + if (!transferTo.trim()) { + return '请输入收款地址'; + } + if (!isValidAddress(transferTo.trim())) { + return '收款地址格式无效'; + } + if (!transferAmount.trim()) { + return '请输入转账金额'; + } + if (!isValidAmount(transferAmount.trim())) { + return '转账金额无效'; + } + const amount = parseFloat(transferAmount); + const balance = parseFloat(transferShare?.kavaBalance || '0'); + if (amount > balance) { + return '余额不足'; + } + return null; + }; + + // 准备交易 + const handlePrepareTransaction = async () => { + const error = validateTransferInput(); + if (error) { + setTransferError(error); + return; + } + + setTransferStep('preparing'); + setTransferError(null); + + try { + const prepared = await prepareTransaction({ + from: transferShare!.evmAddress!, + to: transferTo.trim().toLowerCase(), + value: transferAmount.trim(), + }); + setPreparedTx(prepared); + setTransferStep('confirm'); + } catch (err) { + setTransferError('准备交易失败: ' + (err as Error).message); + setTransferStep('error'); + } + }; + + // 发起签名会话 + const handleInitiateCoSign = async () => { + if (!preparedTx || !transferShare) return; + + try { + // 调用 co-sign API 创建签名会话 + const result = await window.electronAPI.cosign.createSession({ + shareId: transferShare.id, + sharePassword: transferPassword, + messageHash: preparedTx.signHash, + initiatorName: '发起者', + }); + + if (result.success && result.sessionId) { + // 保存交易信息到 sessionStorage,以便签名完成后使用 + sessionStorage.setItem(`tx_${result.sessionId}`, JSON.stringify({ + preparedTx, + to: transferTo, + amount: transferAmount, + from: transferShare.evmAddress, + walletName: transferShare.walletName, + })); + + // 关闭模态框并跳转到签名会话页面 + handleCloseTransfer(); + navigate(`/cosign/session/${result.sessionId}`); + } else { + setTransferError(result.error || '创建签名会话失败'); + setTransferStep('error'); + } + } catch (err) { + setTransferError('创建签名会话失败: ' + (err as Error).message); + setTransferStep('error'); + } + }; + + // 格式化 gas 费用显示 + const formatGasFee = (gasLimit: bigint, maxFeePerGas: bigint): string => { + const maxFee = gasLimit * maxFeePerGas; + const feeKava = Number(maxFee) / 1e18; + return feeKava.toFixed(8).replace(/\.?0+$/, ''); + }; + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', @@ -309,6 +435,14 @@ export default function Home() { )}
+ {share.evmAddress && ( + + )}
)} + {/* 转账模态框 */} + {showTransferModal && transferShare && ( +
+
e.stopPropagation()}> +
+

转账

+ +
+
+ {/* 钱包信息 */} +
+
{transferShare.walletName}
+
+ 余额: {transferShare.kavaBalance || '0'} KAVA +
+
+ 网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'} +
+
+ + {transferStep === 'input' && ( +
+ {/* 收款地址 */} +
+ + setTransferTo(e.target.value)} + placeholder="0x..." + className={styles.transferInput} + /> +
+ + {/* 转账金额 */} +
+ +
+ setTransferAmount(e.target.value)} + placeholder="0.0" + className={styles.transferInput} + /> + +
+
+ + {/* 钱包密码 */} +
+ + setTransferPassword(e.target.value)} + placeholder="如果设置了密码,请输入" + className={styles.transferInput} + /> +
+ + {transferError && ( +
{transferError}
+ )} + +
+ + +
+
+ )} + + {transferStep === 'preparing' && ( +
+
+

正在准备交易...

+
+ )} + + {transferStep === 'confirm' && preparedTx && ( +
+

确认交易

+ +
+
+ 收款地址 + {formatAddress(transferTo, 8, 6)} +
+
+ 转账金额 + {transferAmount} KAVA +
+
+ 预估 Gas 费 + + ~{formatGasFee(preparedTx.gasLimit, preparedTx.maxFeePerGas)} KAVA + +
+
+ Nonce + {preparedTx.nonce} +
+
+ +
+ 注意: 这是一个 {transferShare.threshold.t}-of-{transferShare.threshold.n} 共管钱包, + 需要至少 {transferShare.threshold.t} 方参与签名才能完成交易。 +
+ + {transferError && ( +
{transferError}
+ )} + +
+ + +
+
+ )} + + {transferStep === 'error' && ( +
+

{transferError}

+ +
+ )} +
+
+
+ )} + {/* 二维码弹窗 */} {showQrModal && selectedShare && (
setShowQrModal(false)}> diff --git a/backend/mpc-system/services/service-party-app/src/utils/transaction.ts b/backend/mpc-system/services/service-party-app/src/utils/transaction.ts new file mode 100644 index 00000000..714b99ca --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/utils/transaction.ts @@ -0,0 +1,549 @@ +/** + * Kava EVM 交易构建工具 + * 用于构建 EIP-1559 交易并计算签名哈希 + */ + +// Kava EVM Chain IDs +export const KAVA_CHAIN_ID = { + mainnet: 2222, + testnet: 2221, +}; + +// RPC URLs +export const KAVA_RPC_URL = { + mainnet: 'https://evm.kava.io', + testnet: 'https://evm.testnet.kava.io', +}; + +// 当前网络配置 (从 localStorage 读取或使用默认值) +export function getCurrentNetwork(): 'mainnet' | 'testnet' { + if (typeof window !== 'undefined' && window.localStorage) { + const stored = localStorage.getItem('kava_network'); + if (stored === 'mainnet' || stored === 'testnet') { + return stored; + } + } + return 'testnet'; // 默认测试网 +} + +export function getCurrentChainId(): number { + return KAVA_CHAIN_ID[getCurrentNetwork()]; +} + +export function getCurrentRpcUrl(): string { + return KAVA_RPC_URL[getCurrentNetwork()]; +} + +/** + * 交易参数接口 + */ +export interface TransactionParams { + to: string; // 收款地址 + value: string; // 转账金额 (KAVA, 字符串以支持大数) + from: string; // 发送地址 + nonce?: number; // 可选,自动获取 + gasLimit?: bigint; // 可选,默认 21000 + maxFeePerGas?: bigint; // 可选,自动获取 + maxPriorityFeePerGas?: bigint; // 可选,自动获取 + data?: string; // 可选,合约调用数据 +} + +/** + * 准备好的交易 (包含所有必要字段) + */ +export interface PreparedTransaction { + chainId: number; + nonce: number; + maxPriorityFeePerGas: bigint; + maxFeePerGas: bigint; + gasLimit: bigint; + to: string; + value: bigint; + data: string; + accessList: unknown[]; + // 用于签名的哈希 + signHash: string; + // 原始交易数据 (用于签名后广播) + rawTxForSigning: string; +} + +/** + * 将数字转换为十六进制字符串 (带 0x 前缀) + */ +function toHex(value: number | bigint): string { + const hex = value.toString(16); + return '0x' + hex; +} + +/** + * 将十六进制字符串转换为 Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex; + if (cleanHex.length === 0) return new Uint8Array(0); + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16); + } + return bytes; +} + +/** + * 将 Uint8Array 转换为十六进制字符串 + */ +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * RLP 编码 + * 参考: https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/ + */ +function rlpEncode(input: unknown): Uint8Array { + if (typeof input === 'string') { + // 处理十六进制字符串 + if (input.startsWith('0x')) { + const bytes = hexToBytes(input); + return rlpEncodeBytes(bytes); + } + // 普通字符串 + const bytes = new TextEncoder().encode(input); + return rlpEncodeBytes(bytes); + } + + if (typeof input === 'number' || typeof input === 'bigint') { + if (input === 0 || input === 0n) { + return rlpEncodeBytes(new Uint8Array(0)); + } + // 转换为最小字节表示 + let hex = input.toString(16); + if (hex.length % 2 !== 0) hex = '0' + hex; + return rlpEncodeBytes(hexToBytes(hex)); + } + + if (input instanceof Uint8Array) { + return rlpEncodeBytes(input); + } + + if (Array.isArray(input)) { + // 编码列表 + const encoded = input.map(item => rlpEncode(item)); + const totalLength = encoded.reduce((acc, item) => acc + item.length, 0); + + if (totalLength <= 55) { + const result = new Uint8Array(1 + totalLength); + result[0] = 0xc0 + totalLength; + let offset = 1; + for (const item of encoded) { + result.set(item, offset); + offset += item.length; + } + return result; + } else { + const lengthBytes = encodeLength(totalLength); + const result = new Uint8Array(1 + lengthBytes.length + totalLength); + result[0] = 0xf7 + lengthBytes.length; + result.set(lengthBytes, 1); + let offset = 1 + lengthBytes.length; + for (const item of encoded) { + result.set(item, offset); + offset += item.length; + } + return result; + } + } + + throw new Error('Unsupported RLP input type'); +} + +function rlpEncodeBytes(bytes: Uint8Array): Uint8Array { + if (bytes.length === 1 && bytes[0] < 0x80) { + // 单字节值直接返回 + return bytes; + } + + if (bytes.length <= 55) { + const result = new Uint8Array(1 + bytes.length); + result[0] = 0x80 + bytes.length; + result.set(bytes, 1); + return result; + } + + const lengthBytes = encodeLength(bytes.length); + const result = new Uint8Array(1 + lengthBytes.length + bytes.length); + result[0] = 0xb7 + lengthBytes.length; + result.set(lengthBytes, 1); + result.set(bytes, 1 + lengthBytes.length); + return result; +} + +function encodeLength(length: number): Uint8Array { + let hex = length.toString(16); + if (hex.length % 2 !== 0) hex = '0' + hex; + return hexToBytes(hex); +} + +/** + * Keccak-256 哈希 (复用 address.ts 中的实现) + */ +async function keccak256(data: Uint8Array): Promise { + const RC = [ + 0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, + 0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n, + 0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an, + 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an, + 0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, + 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n, + 0x000000000000800an, 0x800000008000000an, 0x8000000080008081n, + 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n, + ]; + + const ROTC = [ + [0, 36, 3, 41, 18], + [1, 44, 10, 45, 2], + [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], + [27, 20, 39, 8, 14], + ]; + + function rotl64(x: bigint, n: number): bigint { + return ((x << BigInt(n)) | (x >> BigInt(64 - n))) & 0xffffffffffffffffn; + } + + function keccakF(state: bigint[][]): void { + for (let round = 0; round < 24; round++) { + const C: bigint[] = []; + for (let x = 0; x < 5; x++) { + C[x] = state[x][0] ^ state[x][1] ^ state[x][2] ^ state[x][3] ^ state[x][4]; + } + const D: bigint[] = []; + for (let x = 0; x < 5; x++) { + D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1); + } + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 5; y++) { + state[x][y] ^= D[x]; + } + } + + const B: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n)); + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 5; y++) { + B[y][(2 * x + 3 * y) % 5] = rotl64(state[x][y], ROTC[x][y]); + } + } + + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 5; y++) { + state[x][y] = B[x][y] ^ (~B[(x + 1) % 5][y] & B[(x + 2) % 5][y]); + } + } + + state[0][0] ^= RC[round]; + } + } + + const rate = 136; + const outputLen = 32; + const state: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n)); + const padded = new Uint8Array(Math.ceil((data.length + 1) / rate) * rate); + padded.set(data); + padded[data.length] = 0x01; + padded[padded.length - 1] |= 0x80; + + for (let i = 0; i < padded.length; i += rate) { + for (let j = 0; j < rate && i + j < padded.length; j += 8) { + const x = Math.floor(j / 8) % 5; + const y = Math.floor(Math.floor(j / 8) / 5); + let lane = 0n; + for (let k = 0; k < 8 && i + j + k < padded.length; k++) { + lane |= BigInt(padded[i + j + k]) << BigInt(k * 8); + } + state[x][y] ^= lane; + } + keccakF(state); + } + + const output = new Uint8Array(outputLen); + for (let i = 0; i < outputLen; i += 8) { + const x = Math.floor(i / 8) % 5; + const y = Math.floor(Math.floor(i / 8) / 5); + const lane = state[x][y]; + for (let k = 0; k < 8 && i + k < outputLen; k++) { + output[i + k] = Number((lane >> BigInt(k * 8)) & 0xffn); + } + } + + return output; +} + +/** + * 将 KAVA 金额转换为 wei (18 位小数) + */ +export function kavaToWei(kava: string): bigint { + const parts = kava.split('.'); + const whole = BigInt(parts[0] || '0'); + let fraction = parts[1] || ''; + + // 补齐或截断到 18 位 + if (fraction.length > 18) { + fraction = fraction.substring(0, 18); + } else { + fraction = fraction.padEnd(18, '0'); + } + + return whole * BigInt(10 ** 18) + BigInt(fraction); +} + +/** + * 将 wei 转换为 KAVA 金额 + */ +export function weiToKava(wei: bigint): string { + const weiStr = wei.toString().padStart(19, '0'); + const whole = weiStr.slice(0, -18) || '0'; + const fraction = weiStr.slice(-18).replace(/0+$/, ''); + return fraction ? `${whole}.${fraction}` : whole; +} + +/** + * 通过 RPC 获取账户 nonce + */ +export async function getNonce(address: string): Promise { + const rpcUrl = getCurrentRpcUrl(); + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: [address, 'pending'], + id: 1, + }), + }); + + const data = await response.json(); + if (data.error) { + throw new Error(`获取 nonce 失败: ${data.error.message}`); + } + return parseInt(data.result, 16); +} + +/** + * 通过 RPC 获取当前 gas 价格 + */ +export async function getGasPrice(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> { + const rpcUrl = getCurrentRpcUrl(); + + // 获取基础费用 + const blockResponse = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['latest', false], + id: 1, + }), + }); + + const blockData = await blockResponse.json(); + const baseFeePerGas = blockData.result?.baseFeePerGas + ? BigInt(blockData.result.baseFeePerGas) + : BigInt(1000000000); // 默认 1 gwei + + // 优先费用设为 1 gwei + const maxPriorityFeePerGas = BigInt(1000000000); + // 最大费用 = 基础费用 * 2 + 优先费用 + const maxFeePerGas = baseFeePerGas * 2n + maxPriorityFeePerGas; + + return { maxFeePerGas, maxPriorityFeePerGas }; +} + +/** + * 预估 gas 用量 + */ +export async function estimateGas(params: { from: string; to: string; value: string; data?: string }): Promise { + const rpcUrl = getCurrentRpcUrl(); + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_estimateGas', + params: [{ + from: params.from, + to: params.to, + value: toHex(kavaToWei(params.value)), + data: params.data || '0x', + }], + id: 1, + }), + }); + + const data = await response.json(); + if (data.error) { + // 如果估算失败,使用默认值 21000 (普通转账) + console.warn('Gas 估算失败,使用默认值:', data.error); + return BigInt(21000); + } + return BigInt(data.result); +} + +/** + * 准备 EIP-1559 交易 + * 返回交易数据和待签名的哈希 + */ +export async function prepareTransaction(params: TransactionParams): Promise { + const chainId = getCurrentChainId(); + + // 获取或使用提供的参数 + const nonce = params.nonce ?? await getNonce(params.from); + const gasPrice = await getGasPrice(); + const maxFeePerGas = params.maxFeePerGas ?? gasPrice.maxFeePerGas; + const maxPriorityFeePerGas = params.maxPriorityFeePerGas ?? gasPrice.maxPriorityFeePerGas; + const gasLimit = params.gasLimit ?? await estimateGas({ + from: params.from, + to: params.to, + value: params.value, + data: params.data, + }); + + const value = kavaToWei(params.value); + const data = params.data || '0x'; + const accessList: unknown[] = []; + + // EIP-1559 交易字段顺序 + // [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList] + const txFields = [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + params.to.toLowerCase(), + value, + data, + accessList, + ]; + + // RLP 编码交易字段 + const encodedTx = rlpEncode(txFields); + + // EIP-1559 交易类型前缀: 0x02 + const txWithType = new Uint8Array(1 + encodedTx.length); + txWithType[0] = 0x02; + txWithType.set(encodedTx, 1); + + // 计算签名哈希 + const signHashBytes = await keccak256(txWithType); + const signHash = bytesToHex(signHashBytes); + + return { + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + to: params.to.toLowerCase(), + value, + data, + accessList, + signHash, + rawTxForSigning: bytesToHex(txWithType), + }; +} + +/** + * 使用签名完成交易并返回可广播的原始交易 + */ +export function finalizeTransaction( + preparedTx: PreparedTransaction, + signature: { r: string; s: string; v: number } +): string { + // EIP-1559 签名后的交易字段 + // [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s] + const signedTxFields = [ + preparedTx.chainId, + preparedTx.nonce, + preparedTx.maxPriorityFeePerGas, + preparedTx.maxFeePerGas, + preparedTx.gasLimit, + preparedTx.to, + preparedTx.value, + preparedTx.data, + preparedTx.accessList, + signature.v, // recovery id (0 or 1 for EIP-1559) + '0x' + signature.r, + '0x' + signature.s, + ]; + + const encodedSignedTx = rlpEncode(signedTxFields); + + // 添加类型前缀 + const signedTxWithType = new Uint8Array(1 + encodedSignedTx.length); + signedTxWithType[0] = 0x02; + signedTxWithType.set(encodedSignedTx, 1); + + return '0x' + bytesToHex(signedTxWithType); +} + +/** + * 广播已签名的交易 + */ +export async function broadcastTransaction(signedTx: string): Promise { + const rpcUrl = getCurrentRpcUrl(); + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_sendRawTransaction', + params: [signedTx], + id: 1, + }), + }); + + const data = await response.json(); + if (data.error) { + throw new Error(`广播交易失败: ${data.error.message}`); + } + return data.result; // 返回交易哈希 +} + +/** + * 获取交易收据 (确认状态) + */ +export async function getTransactionReceipt(txHash: string): Promise { + const rpcUrl = getCurrentRpcUrl(); + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_getTransactionReceipt', + params: [txHash], + id: 1, + }), + }); + + const data = await response.json(); + return data.result; +} + +/** + * 验证地址格式 + */ +export function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address); +} + +/** + * 验证金额格式 + */ +export function isValidAmount(amount: string): boolean { + if (!amount || amount.trim() === '') return false; + const num = parseFloat(amount); + return !isNaN(num) && num > 0; +}