562 lines
13 KiB
TypeScript
562 lines
13 KiB
TypeScript
/**
|
|
* Kava 区块链客户端
|
|
*
|
|
* 功能:
|
|
* 1. 查询账户余额
|
|
* 2. 查询账户信息 (用于获取 sequence 和 account_number)
|
|
* 3. 构建交易
|
|
* 4. 广播交易
|
|
*
|
|
* 使用 Kava 官方 LCD REST API:
|
|
* - 主网: https://api.kava.io
|
|
* - 备用: https://api.kava-rpc.com
|
|
*/
|
|
|
|
import * as crypto from 'crypto';
|
|
|
|
// =============================================================================
|
|
// 配置
|
|
// =============================================================================
|
|
|
|
export interface KavaClientConfig {
|
|
lcdEndpoint: string; // LCD REST API 端点
|
|
chainId: string; // 链 ID (kava_2222-10 for mainnet)
|
|
gasPrice: string; // Gas 价格 (如 "0.025ukava")
|
|
defaultGasLimit: number; // 默认 Gas 限制
|
|
}
|
|
|
|
export const KAVA_MAINNET_CONFIG: KavaClientConfig = {
|
|
lcdEndpoint: 'https://api.kava.io',
|
|
chainId: 'kava_2222-10',
|
|
gasPrice: '0.025ukava',
|
|
defaultGasLimit: 200000,
|
|
};
|
|
|
|
export const KAVA_TESTNET_CONFIG: KavaClientConfig = {
|
|
lcdEndpoint: 'https://api.testnet.kava.io',
|
|
chainId: 'kava_2221-16000',
|
|
gasPrice: '0.025ukava',
|
|
defaultGasLimit: 200000,
|
|
};
|
|
|
|
// 备用端点列表
|
|
export const KAVA_LCD_ENDPOINTS = [
|
|
'https://api.kava.io',
|
|
'https://api.kava-rpc.com',
|
|
'https://api.kava.chainstacklabs.com',
|
|
];
|
|
|
|
// =============================================================================
|
|
// 类型定义
|
|
// =============================================================================
|
|
|
|
export interface Coin {
|
|
denom: string;
|
|
amount: string;
|
|
}
|
|
|
|
export interface AccountInfo {
|
|
address: string;
|
|
accountNumber: string;
|
|
sequence: string;
|
|
pubKey?: {
|
|
type: string;
|
|
value: string;
|
|
};
|
|
}
|
|
|
|
export interface BalanceResponse {
|
|
balances: Coin[];
|
|
pagination: {
|
|
next_key: string | null;
|
|
total: string;
|
|
};
|
|
}
|
|
|
|
export interface AccountResponse {
|
|
account: {
|
|
'@type': string;
|
|
address: string;
|
|
pub_key?: {
|
|
'@type': string;
|
|
key: string;
|
|
};
|
|
account_number: string;
|
|
sequence: string;
|
|
};
|
|
}
|
|
|
|
export interface TxResponse {
|
|
height: string;
|
|
txhash: string;
|
|
codespace: string;
|
|
code: number;
|
|
data: string;
|
|
raw_log: string;
|
|
logs: unknown[];
|
|
info: string;
|
|
gas_wanted: string;
|
|
gas_used: string;
|
|
tx: unknown;
|
|
timestamp: string;
|
|
events: unknown[];
|
|
}
|
|
|
|
export interface BroadcastTxResponse {
|
|
tx_response: TxResponse;
|
|
}
|
|
|
|
export interface SimulateTxResponse {
|
|
gas_info: {
|
|
gas_wanted: string;
|
|
gas_used: string;
|
|
};
|
|
result: {
|
|
data: string;
|
|
log: string;
|
|
events: unknown[];
|
|
};
|
|
}
|
|
|
|
// 交易消息类型
|
|
export interface MsgSend {
|
|
'@type': '/cosmos.bank.v1beta1.MsgSend';
|
|
from_address: string;
|
|
to_address: string;
|
|
amount: Coin[];
|
|
}
|
|
|
|
export interface Fee {
|
|
amount: Coin[];
|
|
gas_limit: string;
|
|
payer?: string;
|
|
granter?: string;
|
|
}
|
|
|
|
export interface SignerInfo {
|
|
public_key: {
|
|
'@type': string;
|
|
key: string;
|
|
};
|
|
mode_info: {
|
|
single: {
|
|
mode: string;
|
|
};
|
|
};
|
|
sequence: string;
|
|
}
|
|
|
|
export interface AuthInfo {
|
|
signer_infos: SignerInfo[];
|
|
fee: Fee;
|
|
}
|
|
|
|
export interface TxBody {
|
|
messages: MsgSend[];
|
|
memo: string;
|
|
timeout_height: string;
|
|
extension_options: unknown[];
|
|
non_critical_extension_options: unknown[];
|
|
}
|
|
|
|
export interface TxRaw {
|
|
body_bytes: string;
|
|
auth_info_bytes: string;
|
|
signatures: string[];
|
|
}
|
|
|
|
// =============================================================================
|
|
// Kava 客户端类
|
|
// =============================================================================
|
|
|
|
export class KavaClient {
|
|
private config: KavaClientConfig;
|
|
private currentEndpointIndex = 0;
|
|
|
|
constructor(config: KavaClientConfig = KAVA_MAINNET_CONFIG) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* 获取当前 LCD 端点
|
|
*/
|
|
private getLcdEndpoint(): string {
|
|
return this.config.lcdEndpoint;
|
|
}
|
|
|
|
/**
|
|
* 切换到备用端点
|
|
*/
|
|
private switchToBackupEndpoint(): void {
|
|
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % KAVA_LCD_ENDPOINTS.length;
|
|
this.config.lcdEndpoint = KAVA_LCD_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;
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 查询功能
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 查询账户余额
|
|
*
|
|
* @param address - Kava 地址 (bech32 格式,以 "kava" 开头)
|
|
* @returns 余额列表
|
|
*/
|
|
async getBalances(address: string): Promise<Coin[]> {
|
|
const response = await this.request<BalanceResponse>(
|
|
`/cosmos/bank/v1beta1/balances/${address}`
|
|
);
|
|
return response.balances;
|
|
}
|
|
|
|
/**
|
|
* 查询指定代币余额
|
|
*
|
|
* @param address - Kava 地址
|
|
* @param denom - 代币单位 (如 "ukava")
|
|
* @returns 余额
|
|
*/
|
|
async getBalance(address: string, denom: string = 'ukava'): Promise<Coin> {
|
|
const response = await this.request<{ balance: Coin }>(
|
|
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=${denom}`
|
|
);
|
|
return response.balance;
|
|
}
|
|
|
|
/**
|
|
* 查询账户信息 (用于构建交易)
|
|
*
|
|
* @param address - Kava 地址
|
|
* @returns 账户信息 (包含 account_number 和 sequence)
|
|
*/
|
|
async getAccountInfo(address: string): Promise<AccountInfo> {
|
|
const response = await this.request<AccountResponse>(
|
|
`/cosmos/auth/v1beta1/accounts/${address}`
|
|
);
|
|
|
|
const account = response.account;
|
|
return {
|
|
address: account.address,
|
|
accountNumber: account.account_number,
|
|
sequence: account.sequence,
|
|
pubKey: account.pub_key ? {
|
|
type: account.pub_key['@type'],
|
|
value: account.pub_key.key,
|
|
} : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 查询交易详情
|
|
*
|
|
* @param txHash - 交易哈希
|
|
* @returns 交易详情
|
|
*/
|
|
async getTx(txHash: string): Promise<TxResponse> {
|
|
const response = await this.request<{ tx_response: TxResponse }>(
|
|
`/cosmos/tx/v1beta1/txs/${txHash}`
|
|
);
|
|
return response.tx_response;
|
|
}
|
|
|
|
/**
|
|
* 查询最新区块高度
|
|
*/
|
|
async getLatestBlockHeight(): Promise<number> {
|
|
const response = await this.request<{ block: { header: { height: string } } }>(
|
|
`/cosmos/base/tendermint/v1beta1/blocks/latest`
|
|
);
|
|
return parseInt(response.block.header.height, 10);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 交易构建
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 构建转账交易消息
|
|
*
|
|
* @param fromAddress - 发送方地址
|
|
* @param toAddress - 接收方地址
|
|
* @param amount - 金额
|
|
* @param denom - 代币单位
|
|
* @returns MsgSend 消息
|
|
*/
|
|
buildMsgSend(
|
|
fromAddress: string,
|
|
toAddress: string,
|
|
amount: string,
|
|
denom: string = 'ukava'
|
|
): MsgSend {
|
|
return {
|
|
'@type': '/cosmos.bank.v1beta1.MsgSend',
|
|
from_address: fromAddress,
|
|
to_address: toAddress,
|
|
amount: [{ denom, amount }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 构建交易体
|
|
*
|
|
* @param messages - 交易消息列表
|
|
* @param memo - 备注
|
|
* @returns TxBody
|
|
*/
|
|
buildTxBody(messages: MsgSend[], memo: string = ''): TxBody {
|
|
return {
|
|
messages,
|
|
memo,
|
|
timeout_height: '0',
|
|
extension_options: [],
|
|
non_critical_extension_options: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 构建 AuthInfo
|
|
*
|
|
* @param publicKeyBase64 - 压缩公钥 (Base64)
|
|
* @param sequence - 账户序列号
|
|
* @param gasLimit - Gas 限制
|
|
* @param feeAmount - 手续费金额
|
|
* @returns AuthInfo
|
|
*/
|
|
buildAuthInfo(
|
|
publicKeyBase64: string,
|
|
sequence: string,
|
|
gasLimit: number = this.config.defaultGasLimit,
|
|
feeAmount?: Coin[]
|
|
): AuthInfo {
|
|
// 计算手续费 (如果未提供)
|
|
if (!feeAmount) {
|
|
const gasPrice = parseFloat(this.config.gasPrice.replace('ukava', ''));
|
|
const fee = Math.ceil(gasLimit * gasPrice);
|
|
feeAmount = [{ denom: 'ukava', amount: fee.toString() }];
|
|
}
|
|
|
|
return {
|
|
signer_infos: [{
|
|
public_key: {
|
|
'@type': '/cosmos.crypto.secp256k1.PubKey',
|
|
key: publicKeyBase64,
|
|
},
|
|
mode_info: {
|
|
single: {
|
|
mode: 'SIGN_MODE_DIRECT',
|
|
},
|
|
},
|
|
sequence,
|
|
}],
|
|
fee: {
|
|
amount: feeAmount,
|
|
gas_limit: gasLimit.toString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 构建待签名的交易数据
|
|
*
|
|
* @param txBody - 交易体
|
|
* @param authInfo - 认证信息
|
|
* @param accountNumber - 账户编号
|
|
* @returns 签名数据 (用于 TSS 签名)
|
|
*/
|
|
buildSignDoc(
|
|
txBody: TxBody,
|
|
authInfo: AuthInfo,
|
|
accountNumber: string
|
|
): {
|
|
bodyBytes: Buffer;
|
|
authInfoBytes: Buffer;
|
|
chainId: string;
|
|
accountNumber: string;
|
|
signBytes: Buffer;
|
|
} {
|
|
// 注意:这里需要使用 protobuf 编码
|
|
// 简化版本:使用 JSON 编码后进行 SHA256 哈希
|
|
// 生产环境应使用 @cosmjs/proto-signing
|
|
|
|
const bodyBytes = Buffer.from(JSON.stringify(txBody));
|
|
const authInfoBytes = Buffer.from(JSON.stringify(authInfo));
|
|
|
|
// 构建 SignDoc
|
|
const signDoc = {
|
|
body_bytes: bodyBytes.toString('base64'),
|
|
auth_info_bytes: authInfoBytes.toString('base64'),
|
|
chain_id: this.config.chainId,
|
|
account_number: accountNumber,
|
|
};
|
|
|
|
// 计算签名哈希 (SHA256)
|
|
const signBytes = crypto.createHash('sha256')
|
|
.update(JSON.stringify(signDoc))
|
|
.digest();
|
|
|
|
return {
|
|
bodyBytes,
|
|
authInfoBytes,
|
|
chainId: this.config.chainId,
|
|
accountNumber,
|
|
signBytes,
|
|
};
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 交易广播
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 模拟交易 (估算 Gas)
|
|
*
|
|
* @param txBytes - 交易字节 (Base64)
|
|
* @returns 模拟结果
|
|
*/
|
|
async simulateTx(txBytes: string): Promise<SimulateTxResponse> {
|
|
return this.request<SimulateTxResponse>(
|
|
'/cosmos/tx/v1beta1/simulate',
|
|
'POST',
|
|
{ tx_bytes: txBytes }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 广播交易
|
|
*
|
|
* @param txBytes - 已签名的交易字节 (Base64)
|
|
* @param mode - 广播模式 (BROADCAST_MODE_SYNC | BROADCAST_MODE_ASYNC | BROADCAST_MODE_BLOCK)
|
|
* @returns 广播结果
|
|
*/
|
|
async broadcastTx(
|
|
txBytes: string,
|
|
mode: 'BROADCAST_MODE_SYNC' | 'BROADCAST_MODE_ASYNC' | 'BROADCAST_MODE_BLOCK' = 'BROADCAST_MODE_SYNC'
|
|
): Promise<BroadcastTxResponse> {
|
|
return this.request<BroadcastTxResponse>(
|
|
'/cosmos/tx/v1beta1/txs',
|
|
'POST',
|
|
{
|
|
tx_bytes: txBytes,
|
|
mode,
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 构建并编码完整的已签名交易
|
|
*
|
|
* @param bodyBytes - 交易体字节
|
|
* @param authInfoBytes - 认证信息字节
|
|
* @param signature - 签名 (Buffer)
|
|
* @returns Base64 编码的交易字节
|
|
*/
|
|
encodeSignedTx(
|
|
bodyBytes: Buffer,
|
|
authInfoBytes: Buffer,
|
|
signature: Buffer
|
|
): string {
|
|
// 简化版本:使用 JSON 编码
|
|
// 生产环境应使用 protobuf 编码
|
|
const txRaw = {
|
|
body_bytes: bodyBytes.toString('base64'),
|
|
auth_info_bytes: authInfoBytes.toString('base64'),
|
|
signatures: [signature.toString('base64')],
|
|
};
|
|
|
|
return Buffer.from(JSON.stringify(txRaw)).toString('base64');
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 便捷方法
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* 格式化 KAVA 金额 (ukava -> KAVA)
|
|
*
|
|
* @param amount - 金额 (ukava)
|
|
* @returns 格式化的金额字符串
|
|
*/
|
|
formatKava(amount: string): string {
|
|
const ukava = BigInt(amount);
|
|
const kava = Number(ukava) / 1_000_000;
|
|
return kava.toFixed(6);
|
|
}
|
|
|
|
/**
|
|
* 转换 KAVA 到 ukava
|
|
*
|
|
* @param kava - KAVA 金额
|
|
* @returns ukava 金额字符串
|
|
*/
|
|
toUkava(kava: number | string): string {
|
|
const ukava = Math.floor(Number(kava) * 1_000_000);
|
|
return ukava.toString();
|
|
}
|
|
|
|
/**
|
|
* 检查地址格式
|
|
*
|
|
* @param address - 地址
|
|
* @returns 是否有效
|
|
*/
|
|
isValidAddress(address: string): boolean {
|
|
return address.startsWith('kava') && address.length === 43;
|
|
}
|
|
|
|
/**
|
|
* 获取配置
|
|
*/
|
|
getConfig(): KavaClientConfig {
|
|
return { ...this.config };
|
|
}
|
|
|
|
/**
|
|
* 更新配置
|
|
*/
|
|
updateConfig(config: Partial<KavaClientConfig>): void {
|
|
this.config = { ...this.config, ...config };
|
|
}
|
|
}
|
|
|
|
// 导出默认客户端实例
|
|
export const kavaClient = new KavaClient();
|