/** * 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): 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( path: string, method: 'GET' | 'POST' = 'GET', body?: unknown, timeout: number = 10000 ): Promise { 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 { 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 { 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 { 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 { 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 { // 获取账户信息 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 { // 解析保存的交易数据 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 { 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 = {}; const keys = Object.keys(obj as Record).sort(); for (const key of keys) { sortedObj[key] = sortObject((obj as Record)[key]); } return sortedObj; } // 导出默认实例 export const kavaTxService = new KavaTxService();