feat(service-party-app): add transfer functionality with co-sign integration

Add complete KAVA transfer feature to the wallet home page:

Frontend (React):
- Home.tsx: Add transfer modal with address/amount input, transaction
  confirmation, and co-sign session initiation
- Home.module.css: Transfer modal styles (form, confirm, error states)
- CoSignSession.tsx: Add transaction broadcast after signing completion,
  with block explorer link

Utils:
- transaction.ts: EIP-1559 transaction building, RLP encoding, Keccak-256
  hashing, nonce/gas fetching, transaction broadcast via JSON-RPC

Flow: Wallet -> Transfer Modal -> Prepare TX -> Confirm -> Co-Sign ->
      Sign Session -> Broadcast -> Block Explorer

🤖 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 19:08:03 -08:00
parent ebea74e57b
commit 879fc3a816
4 changed files with 1310 additions and 1 deletions

View File

@ -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<string | null>(null);
// 交易广播相关状态
const [txInfo, setTxInfo] = useState<TransactionInfo | null>(null);
const [broadcastStep, setBroadcastStep] = useState<'idle' | 'broadcasting' | 'success' | 'error'>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [broadcastError, setBroadcastError] = useState<string | null>(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 (
<div className={styles.container}>
@ -304,6 +432,136 @@ export default function CoSignSession() {
<p className={styles.successMessage}>
OK
</p>
{/* 交易广播部分 */}
{txInfo && (
<div style={{ marginTop: 'var(--spacing-lg)' }}>
<h4 style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 'var(--spacing-sm)',
}}>
</h4>
<div style={{
backgroundColor: 'var(--background-color)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
marginBottom: 'var(--spacing-md)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}></span>
<span style={{ fontFamily: 'monospace', fontSize: '13px' }}>
{txInfo.to.slice(0, 10)}...{txInfo.to.slice(-8)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}></span>
<span style={{ fontFamily: 'monospace', fontSize: '13px', fontWeight: 600 }}>
{txInfo.amount} KAVA
</span>
</div>
</div>
{broadcastStep === 'idle' && (
<button
className={styles.primaryButton}
onClick={handleBroadcastTransaction}
style={{ width: '100%' }}
>
广
</button>
)}
{broadcastStep === 'broadcasting' && (
<div style={{
textAlign: 'center',
padding: 'var(--spacing-md)',
color: 'var(--text-secondary)',
}}>
<div className={styles.spinner} style={{ margin: '0 auto var(--spacing-sm)' }}></div>
<p>广...</p>
</div>
)}
{broadcastStep === 'success' && txHash && (
<div style={{
backgroundColor: 'rgba(40, 167, 69, 0.1)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
textAlign: 'center',
}}>
<div style={{
fontSize: '24px',
color: '#28a745',
marginBottom: 'var(--spacing-sm)',
}}>OK</div>
<p style={{
color: '#28a745',
fontWeight: 600,
marginBottom: 'var(--spacing-sm)',
}}>
广!
</p>
<div style={{
backgroundColor: 'white',
padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-sm)',
marginBottom: 'var(--spacing-md)',
}}>
<code style={{
fontSize: '12px',
wordBreak: 'break-all',
fontFamily: 'monospace',
}}>
{txHash}
</code>
</div>
<a
href={getTxExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: 'var(--spacing-sm) var(--spacing-md)',
backgroundColor: '#28a745',
color: 'white',
borderRadius: 'var(--radius-md)',
textDecoration: 'none',
fontSize: '14px',
}}
>
</a>
</div>
)}
{broadcastStep === 'error' && (
<div style={{
backgroundColor: 'rgba(220, 53, 69, 0.1)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
textAlign: 'center',
}}>
<div style={{
fontSize: '24px',
color: '#dc3545',
marginBottom: 'var(--spacing-sm)',
}}>!</div>
<p style={{ color: '#dc3545', marginBottom: 'var(--spacing-sm)' }}>
广: {broadcastError}
</p>
<button
className={styles.secondaryButton}
onClick={() => setBroadcastStep('idle')}
>
</button>
</div>
)}
</div>
)}
</div>
)}

View File

@ -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);
}

View File

@ -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<ShareWithAddress | null>(null);
const [showQrModal, setShowQrModal] = useState(false);
// 转账相关状态
const [showTransferModal, setShowTransferModal] = useState(false);
const [transferShare, setTransferShare] = useState<ShareWithAddress | null>(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<string | null>(null);
const [preparedTx, setPreparedTx] = useState<PreparedTransaction | null>(null);
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
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() {
)}
</div>
<div className={styles.cardFooter}>
{share.evmAddress && (
<button
className={`${styles.actionButton} ${styles.transferButton}`}
onClick={() => handleOpenTransfer(share)}
>
</button>
)}
<button
className={styles.actionButton}
onClick={() => handleExport(share.id)}
@ -327,6 +461,172 @@ export default function Home() {
</div>
)}
{/* 转账模态框 */}
{showTransferModal && transferShare && (
<div className={styles.modalOverlay} onClick={handleCloseTransfer}>
<div className={styles.transferModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}></h2>
<button
className={styles.modalClose}
onClick={handleCloseTransfer}
>
×
</button>
</div>
<div className={styles.modalBody}>
{/* 钱包信息 */}
<div className={styles.transferWalletInfo}>
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
<div className={styles.transferWalletBalance}>
: {transferShare.kavaBalance || '0'} KAVA
</div>
<div className={styles.transferNetwork}>
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
</div>
</div>
{transferStep === 'input' && (
<div className={styles.transferForm}>
{/* 收款地址 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}></label>
<input
type="text"
value={transferTo}
onChange={(e) => setTransferTo(e.target.value)}
placeholder="0x..."
className={styles.transferInput}
/>
</div>
{/* 转账金额 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> (KAVA)</label>
<div className={styles.transferAmountWrapper}>
<input
type="text"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
placeholder="0.0"
className={styles.transferInput}
/>
<button
className={styles.maxButton}
onClick={() => setTransferAmount(transferShare.kavaBalance || '0')}
>
MAX
</button>
</div>
</div>
{/* 钱包密码 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> ()</label>
<input
type="password"
value={transferPassword}
onChange={(e) => setTransferPassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.transferInput}
/>
</div>
{transferError && (
<div className={styles.transferError}>{transferError}</div>
)}
<div className={styles.transferActions}>
<button
className={styles.secondaryButton}
onClick={handleCloseTransfer}
>
</button>
<button
className={styles.primaryButton}
onClick={handlePrepareTransaction}
>
</button>
</div>
</div>
)}
{transferStep === 'preparing' && (
<div className={styles.transferPreparing}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
{transferStep === 'confirm' && preparedTx && (
<div className={styles.transferConfirm}>
<h3 className={styles.confirmTitle}></h3>
<div className={styles.confirmDetails}>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{formatAddress(transferTo, 8, 6)}</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{transferAmount} KAVA</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}> Gas </span>
<span className={styles.confirmValue}>
~{formatGasFee(preparedTx.gasLimit, preparedTx.maxFeePerGas)} KAVA
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}>Nonce</span>
<span className={styles.confirmValue}>{preparedTx.nonce}</span>
</div>
</div>
<div className={styles.confirmNote}>
<strong>:</strong> {transferShare.threshold.t}-of-{transferShare.threshold.n}
{transferShare.threshold.t}
</div>
{transferError && (
<div className={styles.transferError}>{transferError}</div>
)}
<div className={styles.transferActions}>
<button
className={styles.secondaryButton}
onClick={() => setTransferStep('input')}
>
</button>
<button
className={styles.primaryButton}
onClick={handleInitiateCoSign}
>
</button>
</div>
</div>
)}
{transferStep === 'error' && (
<div className={styles.transferError}>
<p>{transferError}</p>
<button
className={styles.secondaryButton}
onClick={() => setTransferStep('input')}
>
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* 二维码弹窗 */}
{showQrModal && selectedShare && (
<div className={styles.modalOverlay} onClick={() => setShowQrModal(false)}>

View File

@ -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<Uint8Array> {
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<number> {
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<bigint> {
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<PreparedTransaction> {
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<string> {
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<unknown | null> {
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;
}