/** * Kava EVM 交易构建工具 * 用于构建 EIP-1559 交易并计算签名哈希 * 支持原生 KAVA 和 ERC-20 代币 (绿积分) 转账 */ // 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', }; // Token types export type TokenType = 'KAVA' | 'GREEN_POINTS' | 'ENERGY_POINTS' | 'FUTURE_POINTS'; // ERC-20 通用函数选择器 export const ERC20_SELECTORS = { balanceOf: '0x70a08231', // balanceOf(address) transfer: '0xa9059cbb', // transfer(address,uint256) approve: '0x095ea7b3', // approve(address,uint256) allowance: '0xdd62ed3e', // allowance(address,address) totalSupply: '0x18160ddd', // totalSupply() }; // Green Points (绿积分) Token Configuration - dUSDT export const GREEN_POINTS_TOKEN = { contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3', name: '绿积分', symbol: 'dUSDT', decimals: 6, // ERC-20 function selectors (kept for backward compatibility) balanceOfSelector: ERC20_SELECTORS.balanceOf, transferSelector: ERC20_SELECTORS.transfer, }; // Energy Points (积分股) Token Configuration - eUSDT export const ENERGY_POINTS_TOKEN = { contractAddress: '0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931', name: '积分股', symbol: 'eUSDT', decimals: 6, }; // Future Points (积分值) Token Configuration - fUSDT export const FUTURE_POINTS_TOKEN = { contractAddress: '0x14dc4f7d3E4197438d058C3D156dd9826A161134', name: '积分值', symbol: 'fUSDT', decimals: 6, }; // Token configuration utility export const TOKEN_CONFIG = { getContractAddress: (tokenType: TokenType): string | null => { switch (tokenType) { case 'KAVA': return null; // Native token has no contract case 'GREEN_POINTS': return GREEN_POINTS_TOKEN.contractAddress; case 'ENERGY_POINTS': return ENERGY_POINTS_TOKEN.contractAddress; case 'FUTURE_POINTS': return FUTURE_POINTS_TOKEN.contractAddress; } }, getDecimals: (tokenType: TokenType): number => { switch (tokenType) { case 'KAVA': return 18; case 'GREEN_POINTS': return GREEN_POINTS_TOKEN.decimals; case 'ENERGY_POINTS': return ENERGY_POINTS_TOKEN.decimals; case 'FUTURE_POINTS': return FUTURE_POINTS_TOKEN.decimals; } }, getName: (tokenType: TokenType): string => { switch (tokenType) { case 'KAVA': return 'KAVA'; case 'GREEN_POINTS': return GREEN_POINTS_TOKEN.name; case 'ENERGY_POINTS': return ENERGY_POINTS_TOKEN.name; case 'FUTURE_POINTS': return FUTURE_POINTS_TOKEN.name; } }, getSymbol: (tokenType: TokenType): string => { switch (tokenType) { case 'KAVA': return 'KAVA'; case 'GREEN_POINTS': return GREEN_POINTS_TOKEN.symbol; case 'ENERGY_POINTS': return ENERGY_POINTS_TOKEN.symbol; case 'FUTURE_POINTS': return FUTURE_POINTS_TOKEN.symbol; } }, isERC20: (tokenType: TokenType): boolean => { return tokenType !== 'KAVA'; }, }; // 当前网络配置 (从 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 'mainnet'; // 默认主网 } 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; // 可选,合约调用数据 tokenType?: TokenType; // 可选,默认 KAVA } /** * 准备好的交易 (包含所有必要字段) * 使用 Legacy 交易格式 (Type 0),因为 KAVA 不支持 EIP-1559 */ export interface PreparedTransaction { chainId: number; nonce: number; gasPrice: bigint; // Legacy 使用 gasPrice 而不是 maxFeePerGas gasLimit: bigint; to: string; value: bigint; data: string; // 用于签名的哈希 signHash: string; // 原始交易数据 (用于签名后广播) rawTxForSigning: string; // 为了兼容性保留这些字段 maxPriorityFeePerGas?: bigint; maxFeePerGas?: bigint; accessList?: unknown[]; } /** * 将数字转换为十六进制字符串 (带 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; } /** * 将代币金额转换为最小单位 * @param amount Human-readable amount * @param decimals Token decimals (default 6 for USDT-like tokens) */ export function tokenToRaw(amount: string, decimals: number = 6): bigint { const parts = amount.split('.'); const whole = BigInt(parts[0] || '0'); let fraction = parts[1] || ''; // 补齐或截断到指定位数 if (fraction.length > decimals) { fraction = fraction.substring(0, decimals); } else { fraction = fraction.padEnd(decimals, '0'); } return whole * BigInt(10 ** decimals) + BigInt(fraction); } /** * 将最小单位转换为代币金额 * @param raw Raw amount in smallest units * @param decimals Token decimals (default 6 for USDT-like tokens) */ export function rawToToken(raw: bigint, decimals: number = 6): string { const rawStr = raw.toString().padStart(decimals + 1, '0'); const whole = rawStr.slice(0, -decimals) || '0'; const fraction = rawStr.slice(-decimals).replace(/0+$/, ''); return fraction ? `${whole}.${fraction}` : whole; } /** * 将绿积分金额转换为最小单位 (6 decimals) * @deprecated Use tokenToRaw(amount, 6) instead */ export function greenPointsToRaw(amount: string): bigint { return tokenToRaw(amount, GREEN_POINTS_TOKEN.decimals); } /** * 将最小单位转换为绿积分金额 * @deprecated Use rawToToken(raw, 6) instead */ export function rawToGreenPoints(raw: bigint): string { return rawToToken(raw, GREEN_POINTS_TOKEN.decimals); } /** * 查询 ERC-20 代币余额 * @param address Wallet address * @param contractAddress Token contract address * @param decimals Token decimals */ export async function fetchERC20Balance( address: string, contractAddress: string, decimals: number = 6 ): Promise { try { const rpcUrl = getCurrentRpcUrl(); // Encode balanceOf(address) call data const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0'); const callData = ERC20_SELECTORS.balanceOf + paddedAddress; const response = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_call', params: [ { to: contractAddress, data: callData, }, 'latest', ], id: 1, }), }); const data = await response.json(); if (data.result && data.result !== '0x') { const balanceRaw = BigInt(data.result); return rawToToken(balanceRaw, decimals); } return '0'; } catch (error) { console.error('Failed to fetch ERC20 balance:', error); return '0'; } } /** * 查询绿积分 (ERC-20) 余额 */ export async function fetchGreenPointsBalance(address: string): Promise { return fetchERC20Balance(address, GREEN_POINTS_TOKEN.contractAddress, GREEN_POINTS_TOKEN.decimals); } /** * 查询积分股 (eUSDT) 余额 */ export async function fetchEnergyPointsBalance(address: string): Promise { return fetchERC20Balance(address, ENERGY_POINTS_TOKEN.contractAddress, ENERGY_POINTS_TOKEN.decimals); } /** * 查询积分值 (fUSDT) 余额 */ export async function fetchFuturePointsBalance(address: string): Promise { return fetchERC20Balance(address, FUTURE_POINTS_TOKEN.contractAddress, FUTURE_POINTS_TOKEN.decimals); } /** * 查询所有代币余额 */ export async function fetchAllTokenBalances(address: string): Promise<{ kava: string; greenPoints: string; energyPoints: string; futurePoints: string; }> { const [greenPoints, energyPoints, futurePoints] = await Promise.all([ fetchGreenPointsBalance(address), fetchEnergyPointsBalance(address), fetchFuturePointsBalance(address), ]); // Note: KAVA balance is fetched separately via eth_getBalance return { kava: '0', // Caller should fetch KAVA balance separately greenPoints, energyPoints, futurePoints, }; } /** * Encode ERC-20 transfer function call */ function encodeErc20Transfer(to: string, amount: bigint): string { // Function selector: transfer(address,uint256) = 0xa9059cbb const selector = ERC20_SELECTORS.transfer; // Encode recipient address (padded to 32 bytes) const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0'); // Encode amount (padded to 32 bytes) const amountHex = amount.toString(16).padStart(64, '0'); return selector + paddedAddress + amountHex; } /** * 通过 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 价格 * 使用 eth_gasPrice 方法获取 Legacy 交易的 gasPrice */ export async function getGasPrice(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> { const rpcUrl = getCurrentRpcUrl(); // 使用 eth_gasPrice 获取建议的 gas 价格 const response = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_gasPrice', params: [], id: 1, }), }); const data = await response.json(); let gasPrice: bigint; if (data.result) { gasPrice = BigInt(data.result); // 增加 10% 以确保交易能被打包 gasPrice = gasPrice * 110n / 100n; } else { // 默认 1 gwei gasPrice = BigInt(1000000000); } // 为了兼容性,同时返回 maxFeePerGas (实际上用作 gasPrice) return { maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice }; } /** * 预估 gas 用量 */ export async function estimateGas(params: { from: string; to: string; value: string; data?: string; tokenType?: TokenType }): Promise { const rpcUrl = getCurrentRpcUrl(); const tokenType = params.tokenType || 'KAVA'; // For token transfers, we need different params let txParams: { from: string; to: string; value: string; data?: string }; if (TOKEN_CONFIG.isERC20(tokenType)) { // ERC-20 transfer: to is contract, value is 0, data is transfer call const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType); const decimals = TOKEN_CONFIG.getDecimals(tokenType); const tokenAmount = tokenToRaw(params.value, decimals); const transferData = encodeErc20Transfer(params.to, tokenAmount); txParams = { from: params.from, to: contractAddress!, value: '0x0', data: transferData, }; } else { // Native KAVA transfer txParams = { from: params.from, to: params.to, value: toHex(kavaToWei(params.value)), data: params.data || '0x', }; } const response = await fetch(rpcUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_estimateGas', params: [txParams], id: 1, }), }); const data = await response.json(); if (data.error) { // 如果估算失败,使用默认值 console.warn('Gas 估算失败,使用默认值:', data.error); return TOKEN_CONFIG.isERC20(tokenType) ? BigInt(65000) : BigInt(21000); } return BigInt(data.result); } /** * 准备 Legacy 交易 (Type 0) * KAVA 不支持 EIP-1559,所以使用 Legacy 格式 * 返回交易数据和待签名的哈希 * 支持原生 KAVA 和 ERC-20 代币 (绿积分) 转账 */ export async function prepareTransaction(params: TransactionParams): Promise { const chainId = getCurrentChainId(); const tokenType = params.tokenType || 'KAVA'; // 获取或使用提供的参数 const nonce = params.nonce ?? await getNonce(params.from); const gasPriceData = await getGasPrice(); const gasPrice = params.maxFeePerGas ?? gasPriceData.maxFeePerGas; const gasLimit = params.gasLimit ?? await estimateGas({ from: params.from, to: params.to, value: params.value, data: params.data, tokenType, }); // Prepare transaction based on token type let toAddress: string; let value: bigint; let data: string; if (TOKEN_CONFIG.isERC20(tokenType)) { // ERC-20 token transfer // To address is the contract, value is 0 // Data is transfer(recipient, amount) encoded const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType); const decimals = TOKEN_CONFIG.getDecimals(tokenType); const tokenAmount = tokenToRaw(params.value, decimals); toAddress = contractAddress!.toLowerCase(); value = BigInt(0); data = encodeErc20Transfer(params.to, tokenAmount); } else { // Native KAVA transfer toAddress = params.to.toLowerCase(); value = kavaToWei(params.value); data = params.data || '0x'; } // Legacy 交易字段顺序 (EIP-155) // [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0] // 最后三个字段用于 EIP-155 replay protection const txFields = [ nonce, gasPrice, gasLimit, toAddress, value, data, chainId, 0, // EIP-155: v placeholder 0, // EIP-155: r placeholder ]; // RLP 编码交易字段 const encodedTx = rlpEncode(txFields); // 计算签名哈希 (不需要类型前缀,Legacy 交易直接哈希) const signHashBytes = await keccak256(encodedTx); const signHash = bytesToHex(signHashBytes); return { chainId, nonce, gasPrice, gasLimit, to: toAddress, value, data, signHash, rawTxForSigning: bytesToHex(encodedTx), }; } /** * 使用签名完成交易并返回可广播的原始交易 * 使用 Legacy 交易格式 (Type 0) */ export function finalizeTransaction( preparedTx: PreparedTransaction, signature: { r: string; s: string; v: number } ): string { // EIP-155: v = chainId * 2 + 35 + recovery_id // recovery_id 是 0 或 1 const v = preparedTx.chainId * 2 + 35 + signature.v; // Legacy 签名后的交易字段 // [nonce, gasPrice, gasLimit, to, value, data, v, r, s] const signedTxFields = [ preparedTx.nonce, preparedTx.gasPrice, preparedTx.gasLimit, preparedTx.to, preparedTx.value, preparedTx.data, v, '0x' + signature.r, '0x' + signature.s, ]; const encodedSignedTx = rlpEncode(signedTxFields); // Legacy 交易不需要类型前缀 return '0x' + bytesToHex(encodedSignedTx); } /** * 广播已签名的交易 */ 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; }