538 lines
14 KiB
TypeScript
538 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,
|
||
};
|
||
|
||
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();
|