686 lines
19 KiB
TypeScript
686 lines
19 KiB
TypeScript
/**
|
||
* 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';
|
||
|
||
// Green Points (绿积分) Token Configuration
|
||
export const GREEN_POINTS_TOKEN = {
|
||
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
|
||
name: '绿积分',
|
||
symbol: 'dUSDT',
|
||
decimals: 6,
|
||
// ERC-20 function selectors
|
||
balanceOfSelector: '0x70a08231',
|
||
transferSelector: '0xa9059cbb',
|
||
};
|
||
|
||
// 当前网络配置 (从 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<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;
|
||
}
|
||
|
||
/**
|
||
* 将绿积分金额转换为最小单位 (6 decimals)
|
||
*/
|
||
export function greenPointsToRaw(amount: string): bigint {
|
||
const parts = amount.split('.');
|
||
const whole = BigInt(parts[0] || '0');
|
||
let fraction = parts[1] || '';
|
||
|
||
// 补齐或截断到 6 位
|
||
if (fraction.length > 6) {
|
||
fraction = fraction.substring(0, 6);
|
||
} else {
|
||
fraction = fraction.padEnd(6, '0');
|
||
}
|
||
|
||
return whole * BigInt(10 ** 6) + BigInt(fraction);
|
||
}
|
||
|
||
/**
|
||
* 将最小单位转换为绿积分金额
|
||
*/
|
||
export function rawToGreenPoints(raw: bigint): string {
|
||
const rawStr = raw.toString().padStart(7, '0');
|
||
const whole = rawStr.slice(0, -6) || '0';
|
||
const fraction = rawStr.slice(-6).replace(/0+$/, '');
|
||
return fraction ? `${whole}.${fraction}` : whole;
|
||
}
|
||
|
||
/**
|
||
* 查询绿积分 (ERC-20) 余额
|
||
*/
|
||
export async function fetchGreenPointsBalance(address: string): Promise<string> {
|
||
try {
|
||
const rpcUrl = getCurrentRpcUrl();
|
||
// Encode balanceOf(address) call data
|
||
// Function selector: 0x70a08231
|
||
// Address parameter: padded to 32 bytes
|
||
const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0');
|
||
const callData = GREEN_POINTS_TOKEN.balanceOfSelector + paddedAddress;
|
||
|
||
const response = await fetch(rpcUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
method: 'eth_call',
|
||
params: [
|
||
{
|
||
to: GREEN_POINTS_TOKEN.contractAddress,
|
||
data: callData,
|
||
},
|
||
'latest',
|
||
],
|
||
id: 1,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.result && data.result !== '0x') {
|
||
const balanceRaw = BigInt(data.result);
|
||
return rawToGreenPoints(balanceRaw);
|
||
}
|
||
return '0';
|
||
} catch (error) {
|
||
console.error('Failed to fetch Green Points balance:', error);
|
||
return '0';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Encode ERC-20 transfer function call
|
||
*/
|
||
function encodeErc20Transfer(to: string, amount: bigint): string {
|
||
// Function selector: transfer(address,uint256) = 0xa9059cbb
|
||
const selector = GREEN_POINTS_TOKEN.transferSelector;
|
||
// 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<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 价格
|
||
* 使用 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<bigint> {
|
||
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 (tokenType === 'GREEN_POINTS') {
|
||
// ERC-20 transfer: to is contract, value is 0, data is transfer call
|
||
const tokenAmount = greenPointsToRaw(params.value);
|
||
const transferData = encodeErc20Transfer(params.to, tokenAmount);
|
||
txParams = {
|
||
from: params.from,
|
||
to: GREEN_POINTS_TOKEN.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 tokenType === 'GREEN_POINTS' ? 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<PreparedTransaction> {
|
||
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 (tokenType === 'GREEN_POINTS') {
|
||
// ERC-20 token transfer
|
||
// To address is the contract, value is 0
|
||
// Data is transfer(recipient, amount) encoded
|
||
const tokenAmount = greenPointsToRaw(params.value);
|
||
toAddress = GREEN_POINTS_TOKEN.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<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;
|
||
}
|