rwadurian/backend/mpc-system/services/service-party-app/electron/modules/kava-tx-service.ts

538 lines
14 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 交易服务
*
* 使用 Kava LCD REST API 构建和广播交易
* 支持 TSS 多方签名
*
* API 文档参考:
* - https://docs.kava.io/docs/using-kava-endpoints/endpoints/
* - https://docs.cosmos.network/main/learn/advanced/grpc_rest
*/
import * as crypto from 'crypto';
// =============================================================================
// 配置
// =============================================================================
export interface KavaTxConfig {
lcdEndpoint: string;
rpcEndpoint: string;
chainId: string;
prefix: string;
denom: string;
gasPrice: number; // ukava per gas unit
}
export const KAVA_MAINNET_TX_CONFIG: KavaTxConfig = {
lcdEndpoint: 'https://api.kava.io',
rpcEndpoint: 'https://rpc.kava.io',
chainId: 'kava_2222-10',
prefix: 'kava',
denom: 'ukava',
gasPrice: 0.025,
};
export const KAVA_TESTNET_TX_CONFIG: KavaTxConfig = {
lcdEndpoint: 'https://api.testnet.kava.io',
rpcEndpoint: 'https://rpc.testnet.kava.io',
chainId: 'kava_2221-16000',
prefix: 'kava',
denom: 'ukava',
gasPrice: 0.025,
};
// 备用端点
const BACKUP_ENDPOINTS = [
'https://api.kava.io',
'https://api.kava-rpc.com',
'https://api.kava.chainstacklabs.com',
];
// =============================================================================
// 类型定义
// =============================================================================
export interface Coin {
denom: string;
amount: string;
}
export interface AccountBalance {
denom: string;
amount: string;
formatted: string; // 人类可读格式 (KAVA)
}
export interface AccountInfo {
address: string;
accountNumber: number;
sequence: number;
balances: AccountBalance[];
}
export interface UnsignedTxData {
// 用于 TSS 签名的数据
signBytes: Uint8Array; // 待签名的哈希
signBytesHex: string; // 十六进制格式
// 交易元数据
txBodyBytes: Uint8Array;
authInfoBytes: Uint8Array;
accountNumber: number;
sequence: number;
chainId: string;
// 可读信息
from: string;
to: string;
amount: string;
denom: string;
memo: string;
fee: string;
gasLimit: number;
}
export interface SignedTxData {
txBytes: Uint8Array; // 完整的已签名交易
txBytesBase64: string; // Base64 格式
txHash: string; // 交易哈希
}
export interface TxBroadcastResult {
success: boolean;
txHash?: string;
code?: number;
rawLog?: string;
gasUsed?: string;
gasWanted?: string;
height?: string;
}
export interface TxStatus {
found: boolean;
status: 'pending' | 'success' | 'failed';
code?: number;
rawLog?: string;
height?: string;
gasUsed?: string;
timestamp?: string;
}
// =============================================================================
// Kava 交易服务类
// =============================================================================
export class KavaTxService {
private config: KavaTxConfig;
private backupEndpointIndex = 0;
constructor(config: KavaTxConfig = KAVA_MAINNET_TX_CONFIG) {
this.config = { ...config };
}
// ===========================================================================
// 配置管理
// ===========================================================================
getConfig(): KavaTxConfig {
return { ...this.config };
}
updateConfig(config: Partial<KavaTxConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* 切换到测试网
*/
switchToTestnet(): void {
this.config = { ...KAVA_TESTNET_TX_CONFIG };
}
/**
* 切换到主网
*/
switchToMainnet(): void {
this.config = { ...KAVA_MAINNET_TX_CONFIG };
}
/**
* 检查是否是测试网
*/
isTestnet(): boolean {
return this.config.chainId === KAVA_TESTNET_TX_CONFIG.chainId;
}
// ===========================================================================
// HTTP 请求
// ===========================================================================
private async request<T>(
path: string,
method: 'GET' | 'POST' = 'GET',
body?: unknown,
timeout: number = 10000
): Promise<T> {
const url = `${this.config.lcdEndpoint}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const options: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return await response.json() as T;
} catch (error) {
// 如果是主网且请求失败,尝试备用端点
if (!this.isTestnet()) {
this.backupEndpointIndex = (this.backupEndpointIndex + 1) % BACKUP_ENDPOINTS.length;
console.log(`Switched to backup endpoint: ${BACKUP_ENDPOINTS[this.backupEndpointIndex]}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// ===========================================================================
// 查询功能
// ===========================================================================
/**
* 健康检查 - 查询最新区块
*/
async healthCheck(): Promise<{ ok: boolean; latency?: number; blockHeight?: number; error?: string }> {
const start = Date.now();
try {
const response = await this.request<{ block: { header: { height: string } } }>(
'/cosmos/base/tendermint/v1beta1/blocks/latest',
'GET',
undefined,
5000
);
const latency = Date.now() - start;
return {
ok: true,
latency,
blockHeight: parseInt(response.block.header.height, 10),
};
} catch (error) {
return {
ok: false,
error: (error as Error).message,
};
}
}
/**
* 查询 KAVA 余额
*/
async getKavaBalance(address: string): Promise<string> {
try {
const response = await this.request<{ balance: Coin }>(
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=ukava`
);
return response.balance?.amount || '0';
} catch {
return '0';
}
}
/**
* 查询所有余额
*/
async getAllBalances(address: string): Promise<AccountBalance[]> {
const response = await this.request<{ balances: Coin[] }>(
`/cosmos/bank/v1beta1/balances/${address}`
);
return (response.balances || []).map(coin => ({
denom: coin.denom,
amount: coin.amount,
formatted: this.formatAmount(coin.amount, coin.denom),
}));
}
/**
* 查询账户信息
*/
async getAccountInfo(address: string): Promise<AccountInfo | null> {
try {
const [accountResp, balances] = await Promise.all([
this.request<{
account: {
'@type': string;
address: string;
account_number: string;
sequence: string;
};
}>(`/cosmos/auth/v1beta1/accounts/${address}`),
this.getAllBalances(address),
]);
return {
address: accountResp.account.address,
accountNumber: parseInt(accountResp.account.account_number, 10),
sequence: parseInt(accountResp.account.sequence, 10),
balances,
};
} catch {
return null;
}
}
/**
* 查询交易状态
*/
async getTxStatus(txHash: string): Promise<TxStatus> {
try {
const response = await this.request<{
tx_response: {
code: number;
raw_log: string;
height: string;
gas_used: string;
timestamp: string;
};
}>(`/cosmos/tx/v1beta1/txs/${txHash}`);
return {
found: true,
status: response.tx_response.code === 0 ? 'success' : 'failed',
code: response.tx_response.code,
rawLog: response.tx_response.raw_log,
height: response.tx_response.height,
gasUsed: response.tx_response.gas_used,
timestamp: response.tx_response.timestamp,
};
} catch {
return { found: false, status: 'pending' };
}
}
// ===========================================================================
// 交易构建 (使用 Amino JSON)
// ===========================================================================
/**
* 构建转账交易 (返回待签名数据)
*/
async buildSendTx(
fromAddress: string,
toAddress: string,
amount: string,
publicKeyHex: string,
memo: string = ''
): Promise<UnsignedTxData> {
// 获取账户信息
const accountInfo = await this.getAccountInfo(fromAddress);
if (!accountInfo) {
throw new Error('Account not found or has no transactions');
}
const gasLimit = 100000;
const feeAmount = Math.ceil(gasLimit * this.config.gasPrice);
// 构建 Amino JSON 签名文档
const signDoc = {
chain_id: this.config.chainId,
account_number: accountInfo.accountNumber.toString(),
sequence: accountInfo.sequence.toString(),
fee: {
amount: [{ denom: 'ukava', amount: feeAmount.toString() }],
gas: gasLimit.toString(),
},
msgs: [{
type: 'cosmos-sdk/MsgSend',
value: {
from_address: fromAddress,
to_address: toAddress,
amount: [{ denom: 'ukava', amount }],
},
}],
memo,
};
// 计算签名哈希 (SHA256)
const signDocJson = JSON.stringify(sortObject(signDoc));
const signBytes = crypto.createHash('sha256').update(signDocJson).digest();
// 构建交易体和认证信息 (用于广播)
const txBody = {
messages: [{
'@type': '/cosmos.bank.v1beta1.MsgSend',
from_address: fromAddress,
to_address: toAddress,
amount: [{ denom: 'ukava', amount }],
}],
memo,
timeout_height: '0',
extension_options: [],
non_critical_extension_options: [],
};
const authInfo = {
signer_infos: [{
public_key: {
'@type': '/cosmos.crypto.secp256k1.PubKey',
key: Buffer.from(publicKeyHex, 'hex').toString('base64'),
},
mode_info: { single: { mode: 'SIGN_MODE_LEGACY_AMINO_JSON' } },
sequence: accountInfo.sequence.toString(),
}],
fee: {
amount: [{ denom: 'ukava', amount: feeAmount.toString() }],
gas_limit: gasLimit.toString(),
},
};
return {
signBytes: new Uint8Array(signBytes),
signBytesHex: signBytes.toString('hex'),
txBodyBytes: new Uint8Array(Buffer.from(JSON.stringify(txBody))),
authInfoBytes: new Uint8Array(Buffer.from(JSON.stringify(authInfo))),
accountNumber: accountInfo.accountNumber,
sequence: accountInfo.sequence,
chainId: this.config.chainId,
from: fromAddress,
to: toAddress,
amount,
denom: 'ukava',
memo,
fee: feeAmount.toString(),
gasLimit,
};
}
/**
* 完成交易 (添加签名)
*/
async completeTx(unsignedTx: UnsignedTxData, signatureHex: string): Promise<SignedTxData> {
// 解析保存的交易数据
const txBody = JSON.parse(Buffer.from(unsignedTx.txBodyBytes).toString());
const authInfo = JSON.parse(Buffer.from(unsignedTx.authInfoBytes).toString());
// 构建完整的已签名交易
const signedTx = {
body: txBody,
auth_info: authInfo,
signatures: [Buffer.from(signatureHex, 'hex').toString('base64')],
};
const txBytes = Buffer.from(JSON.stringify(signedTx));
const txHash = crypto.createHash('sha256').update(txBytes).digest('hex').toUpperCase();
return {
txBytes: new Uint8Array(txBytes),
txBytesBase64: txBytes.toString('base64'),
txHash,
};
}
/**
* 广播交易
*/
async broadcastTx(signedTx: SignedTxData): Promise<TxBroadcastResult> {
try {
const response = await this.request<{
tx_response: {
code: number;
txhash: string;
raw_log: string;
gas_used: string;
gas_wanted: string;
height: string;
};
}>('/cosmos/tx/v1beta1/txs', 'POST', {
tx_bytes: signedTx.txBytesBase64,
mode: 'BROADCAST_MODE_SYNC',
});
return {
success: response.tx_response.code === 0,
txHash: response.tx_response.txhash,
code: response.tx_response.code,
rawLog: response.tx_response.raw_log,
gasUsed: response.tx_response.gas_used,
gasWanted: response.tx_response.gas_wanted,
height: response.tx_response.height,
};
} catch (error) {
return {
success: false,
rawLog: (error as Error).message,
};
}
}
// ===========================================================================
// 工具方法
// ===========================================================================
formatAmount(amount: string, denom: string): string {
if (denom === 'ukava') {
const kava = Number(amount) / 1_000_000;
return `${kava.toFixed(6)} KAVA`;
}
return `${amount} ${denom}`;
}
toUkava(kava: number | string): string {
return Math.floor(Number(kava) * 1_000_000).toString();
}
fromUkava(ukava: string): number {
return Number(ukava) / 1_000_000;
}
/**
* 断开连接 (清理资源)
*/
disconnect(): void {
// 目前使用 REST API无需特殊清理
}
}
// ===========================================================================
// 辅助函数
// ===========================================================================
/**
* 递归排序对象键 (Amino JSON 签名需要)
*/
function sortObject(obj: unknown): unknown {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObject);
}
const sortedObj: Record<string, unknown> = {};
const keys = Object.keys(obj as Record<string, unknown>).sort();
for (const key of keys) {
sortedObj[key] = sortObject((obj as Record<string, unknown>)[key]);
}
return sortedObj;
}
// 导出默认实例
export const kavaTxService = new KavaTxService();