rwadurian/backend/mpc-system/services/service-party-app/src/utils/transaction.ts

839 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<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;
}
/**
* 将代币金额转换为最小单位
* @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<string> {
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<string> {
return fetchERC20Balance(address, GREEN_POINTS_TOKEN.contractAddress, GREEN_POINTS_TOKEN.decimals);
}
/**
* 查询积分股 (eUSDT) 余额
*/
export async function fetchEnergyPointsBalance(address: string): Promise<string> {
return fetchERC20Balance(address, ENERGY_POINTS_TOKEN.contractAddress, ENERGY_POINTS_TOKEN.decimals);
}
/**
* 查询积分值 (fUSDT) 余额
*/
export async function fetchFuturePointsBalance(address: string): Promise<string> {
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<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 (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<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 (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<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;
}