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

686 lines
19 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';
// 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;
}