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:
parent
ebea74e57b
commit
879fc3a816
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue