544 lines
14 KiB
TypeScript
544 lines
14 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
|
||
// 备用端点
|
||
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;
|
||
error?: string;
|
||
}
|
||
|
||
export interface TxStatus {
|
||
found: boolean;
|
||
status: 'pending' | 'success' | 'failed';
|
||
height?: number;
|
||
code?: number;
|
||
rawLog?: string;
|
||
}
|
||
|
||
// =============================================================================
|
||
// Kava 交易服务
|
||
// =============================================================================
|
||
|
||
export class KavaTxService {
|
||
private config: KavaTxConfig;
|
||
private currentEndpointIndex = 0;
|
||
|
||
constructor(config: KavaTxConfig = KAVA_MAINNET_TX_CONFIG) {
|
||
this.config = config;
|
||
}
|
||
|
||
/**
|
||
* 获取当前 LCD 端点
|
||
*/
|
||
private getLcdEndpoint(): string {
|
||
return this.config.lcdEndpoint;
|
||
}
|
||
|
||
/**
|
||
* 切换到备用端点
|
||
*/
|
||
private switchToBackupEndpoint(): void {
|
||
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % BACKUP_ENDPOINTS.length;
|
||
this.config.lcdEndpoint = BACKUP_ENDPOINTS[this.currentEndpointIndex];
|
||
console.log(`Switched to backup endpoint: ${this.config.lcdEndpoint}`);
|
||
}
|
||
|
||
/**
|
||
* HTTP 请求
|
||
*/
|
||
private async request<T>(
|
||
path: string,
|
||
method: 'GET' | 'POST' = 'GET',
|
||
body?: unknown
|
||
): Promise<T> {
|
||
const url = `${this.getLcdEndpoint()}${path}`;
|
||
|
||
const options: RequestInit = {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
};
|
||
|
||
if (body) {
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
|
||
try {
|
||
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) {
|
||
console.error(`Request failed for ${url}:`, error);
|
||
this.switchToBackupEndpoint();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 查询功能
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* 查询 KAVA 余额
|
||
*/
|
||
async getKavaBalance(address: string): Promise<AccountBalance> {
|
||
const response = await this.request<{ balance: Coin }>(
|
||
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=${this.config.denom}`
|
||
);
|
||
|
||
return {
|
||
denom: response.balance.denom,
|
||
amount: response.balance.amount,
|
||
formatted: this.formatAmount(response.balance.amount, response.balance.denom),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 查询所有余额
|
||
*/
|
||
async getAllBalances(address: string): Promise<AccountBalance[]> {
|
||
const response = await this.request<{ balances: Coin[] }>(
|
||
`/cosmos/bank/v1beta1/balances/${address}`
|
||
);
|
||
|
||
return response.balances.map((coin: Coin) => ({
|
||
denom: coin.denom,
|
||
amount: coin.amount,
|
||
formatted: this.formatAmount(coin.amount, coin.denom),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 查询账户信息
|
||
*/
|
||
async getAccountInfo(address: string): Promise<AccountInfo> {
|
||
// 获取账户信息
|
||
const accountResponse = await this.request<{
|
||
account: {
|
||
'@type': string;
|
||
address: string;
|
||
account_number: string;
|
||
sequence: string;
|
||
};
|
||
}>(`/cosmos/auth/v1beta1/accounts/${address}`);
|
||
|
||
// 获取余额
|
||
const balances = await this.getAllBalances(address);
|
||
|
||
return {
|
||
address: accountResponse.account.address,
|
||
accountNumber: parseInt(accountResponse.account.account_number, 10),
|
||
sequence: parseInt(accountResponse.account.sequence, 10),
|
||
balances,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 查询交易状态
|
||
*/
|
||
async getTxStatus(txHash: string): Promise<TxStatus> {
|
||
try {
|
||
const response = await this.request<{
|
||
tx_response: {
|
||
height: string;
|
||
txhash: string;
|
||
code: number;
|
||
raw_log: string;
|
||
};
|
||
}>(`/cosmos/tx/v1beta1/txs/${txHash}`);
|
||
|
||
return {
|
||
found: true,
|
||
status: response.tx_response.code === 0 ? 'success' : 'failed',
|
||
height: parseInt(response.tx_response.height, 10),
|
||
code: response.tx_response.code,
|
||
rawLog: response.tx_response.raw_log,
|
||
};
|
||
} catch {
|
||
return { found: false, status: 'pending' };
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 交易构建 (用于 TSS 签名)
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* 构建转账交易 (待签名)
|
||
*
|
||
* 注意: 这是简化版本,实际生产环境应使用 Protobuf 编码
|
||
*
|
||
* @param fromAddress - 发送方地址
|
||
* @param toAddress - 接收方地址
|
||
* @param amount - 金额 (KAVA,如 "1.5")
|
||
* @param publicKeyHex - 发送方公钥 (压缩格式,33字节,十六进制)
|
||
* @param memo - 备注
|
||
* @returns 待签名的交易数据
|
||
*/
|
||
async buildSendTx(
|
||
fromAddress: string,
|
||
toAddress: string,
|
||
amount: string,
|
||
publicKeyHex: string,
|
||
memo: string = ''
|
||
): Promise<UnsignedTxData> {
|
||
// 获取账户信息
|
||
const accountInfo = await this.getAccountInfo(fromAddress);
|
||
|
||
// 转换金额 (KAVA -> ukava)
|
||
const amountUkava = this.toMinimalDenom(amount);
|
||
|
||
// Gas 估算
|
||
const gasLimit = 100000;
|
||
const feeAmount = Math.ceil(gasLimit * this.config.gasPrice).toString();
|
||
|
||
// 构建交易消息 (Amino JSON 格式)
|
||
const msgSend = {
|
||
'@type': '/cosmos.bank.v1beta1.MsgSend',
|
||
from_address: fromAddress,
|
||
to_address: toAddress,
|
||
amount: [{ denom: this.config.denom, amount: amountUkava }],
|
||
};
|
||
|
||
// 构建交易体
|
||
const txBody = {
|
||
messages: [msgSend],
|
||
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_DIRECT' },
|
||
},
|
||
sequence: accountInfo.sequence.toString(),
|
||
}],
|
||
fee: {
|
||
amount: [{ denom: this.config.denom, amount: feeAmount }],
|
||
gas_limit: gasLimit.toString(),
|
||
},
|
||
};
|
||
|
||
// 序列化 (简化版使用 JSON)
|
||
const txBodyBytes = Buffer.from(JSON.stringify(txBody));
|
||
const authInfoBytes = Buffer.from(JSON.stringify(authInfo));
|
||
|
||
// 构建 SignDoc
|
||
const signDoc = {
|
||
body_bytes: txBodyBytes.toString('base64'),
|
||
auth_info_bytes: authInfoBytes.toString('base64'),
|
||
chain_id: this.config.chainId,
|
||
account_number: accountInfo.accountNumber.toString(),
|
||
};
|
||
|
||
// 计算待签名字节 (SHA256)
|
||
const signDocBytes = Buffer.from(JSON.stringify(signDoc));
|
||
const signBytes = crypto.createHash('sha256').update(signDocBytes).digest();
|
||
|
||
return {
|
||
signBytes: new Uint8Array(signBytes),
|
||
signBytesHex: signBytes.toString('hex'),
|
||
|
||
txBodyBytes: new Uint8Array(txBodyBytes),
|
||
authInfoBytes: new Uint8Array(authInfoBytes),
|
||
accountNumber: accountInfo.accountNumber,
|
||
sequence: accountInfo.sequence,
|
||
chainId: this.config.chainId,
|
||
|
||
from: fromAddress,
|
||
to: toAddress,
|
||
amount: amountUkava,
|
||
denom: this.config.denom,
|
||
memo,
|
||
fee: feeAmount,
|
||
gasLimit,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 使用签名完成交易
|
||
*
|
||
* @param unsignedTx - 未签名的交易数据
|
||
* @param signatureHex - TSS 签名 (64字节,R+S 格式,十六进制)
|
||
* @returns 已签名的交易数据
|
||
*/
|
||
async completeTx(
|
||
unsignedTx: UnsignedTxData,
|
||
signatureHex: string
|
||
): Promise<SignedTxData> {
|
||
const signature = Buffer.from(signatureHex, 'hex');
|
||
|
||
// 构建 TxRaw (简化版使用 JSON)
|
||
const txRaw = {
|
||
body_bytes: Buffer.from(unsignedTx.txBodyBytes).toString('base64'),
|
||
auth_info_bytes: Buffer.from(unsignedTx.authInfoBytes).toString('base64'),
|
||
signatures: [signature.toString('base64')],
|
||
};
|
||
|
||
const txBytes = Buffer.from(JSON.stringify(txRaw));
|
||
const txBytesBase64 = txBytes.toString('base64');
|
||
|
||
// 计算交易哈希
|
||
const txHash = crypto.createHash('sha256')
|
||
.update(txBytes)
|
||
.digest('hex')
|
||
.toUpperCase();
|
||
|
||
return {
|
||
txBytes: new Uint8Array(txBytes),
|
||
txBytesBase64,
|
||
txHash,
|
||
};
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 交易广播
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* 广播已签名的交易
|
||
*/
|
||
async broadcastTx(signedTx: SignedTxData): Promise<TxBroadcastResult> {
|
||
try {
|
||
const response = await this.request<{
|
||
tx_response: {
|
||
txhash: string;
|
||
code: number;
|
||
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,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 工具方法
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* 格式化金额 (ukava -> KAVA)
|
||
*/
|
||
formatAmount(amount: string, denom: string): string {
|
||
if (denom === 'ukava') {
|
||
const ukava = BigInt(amount);
|
||
const kava = Number(ukava) / 1_000_000;
|
||
return `${kava.toFixed(6)} KAVA`;
|
||
}
|
||
return `${amount} ${denom}`;
|
||
}
|
||
|
||
/**
|
||
* 转换为最小单位 (KAVA -> ukava)
|
||
*/
|
||
toMinimalDenom(amount: string): string {
|
||
const kava = parseFloat(amount);
|
||
const ukava = Math.floor(kava * 1_000_000);
|
||
return ukava.toString();
|
||
}
|
||
|
||
/**
|
||
* 从最小单位转换 (ukava -> KAVA)
|
||
*/
|
||
fromMinimalDenom(amount: string): string {
|
||
const ukava = BigInt(amount);
|
||
const kava = Number(ukava) / 1_000_000;
|
||
return kava.toFixed(6);
|
||
}
|
||
|
||
/**
|
||
* 验证地址格式
|
||
*/
|
||
isValidAddress(address: string): boolean {
|
||
return address.startsWith(this.config.prefix) && address.length === 43;
|
||
}
|
||
|
||
/**
|
||
* 获取当前配置
|
||
*/
|
||
getConfig(): KavaTxConfig {
|
||
return { ...this.config };
|
||
}
|
||
|
||
/**
|
||
* 更新配置
|
||
*/
|
||
updateConfig(config: Partial<KavaTxConfig>): void {
|
||
this.config = { ...this.config, ...config };
|
||
}
|
||
|
||
/**
|
||
* 断开连接 (兼容接口)
|
||
*/
|
||
disconnect(): void {
|
||
// REST API 无需断开连接
|
||
}
|
||
|
||
/**
|
||
* 健康检查 - 验证 API 是否可用
|
||
*/
|
||
async healthCheck(): Promise<{ ok: boolean; latency?: number; error?: string }> {
|
||
const startTime = Date.now();
|
||
try {
|
||
// 查询链的最新区块,这是最轻量级的检查
|
||
const response = await this.request<{
|
||
block: {
|
||
header: {
|
||
height: string;
|
||
chain_id: string;
|
||
};
|
||
};
|
||
}>('/cosmos/base/tendermint/v1beta1/blocks/latest');
|
||
|
||
const latency = Date.now() - startTime;
|
||
|
||
if (response?.block?.header?.height) {
|
||
return {
|
||
ok: true,
|
||
latency,
|
||
};
|
||
}
|
||
|
||
return {
|
||
ok: false,
|
||
error: 'Invalid response format',
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出默认服务实例
|
||
export const kavaTxService = new KavaTxService();
|